max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
- Claude-Session
- https://claude.ai/code/session_01EEmeiSJnmyL98QzA5Dwsvz
14 files changed,
+3617 insertions,
-504 deletions
| @@ -35,7 +35,7 @@ | |||
| 35 | 35 | #[serde(default = "default_logs_root")] | |
| 36 | 36 | pub logs_root: PathBuf, | |
| 37 | 37 | /// Override the per-step wall-clock budget (in seconds) for EVERY step, | |
| 38 | - | /// replacing the per-kind defaults in `engine::step_budget`. Unset (the | |
| 38 | + | /// replacing the per-kind defaults in `engine::ctx::RecipeCtx::step_budget`. Unset (the | |
| 39 | 39 | /// default) uses those. Mainly an escape hatch for a constrained host or a | |
| 40 | 40 | /// test that needs a short deadline. | |
| 41 | 41 | #[serde(default)] |
| @@ -105,7 +105,7 @@ | |||
| 105 | 105 | /// The value half of `token`, split out so the rules can be tested without | |
| 106 | 106 | /// touching the process environment. `set_var` is global and unsynchronized, and | |
| 107 | 107 | /// a test that sets one affects every other test in the binary (see | |
| 108 | - | /// `engine::tests::expand_tilde_handles_home`). | |
| 108 | + | /// `engine::git::tests::expand_tilde_handles_home`). | |
| 109 | 109 | fn token_from(name: &str, raw: Option<String>) -> anyhow::Result<Option<String>> { | |
| 110 | 110 | // Unset and empty are one case on purpose. `EnvironmentFile` turns a line | |
| 111 | 111 | // whose value was never filled in into an empty variable rather than no | |
| @@ -441,7 +441,7 @@ | |||
| 441 | 441 | let tmp = tempfile::tempdir().unwrap(); | |
| 442 | 442 | let mut cfg = Config::for_tests(tmp.path()); | |
| 443 | 443 | // The one place this binary sets a variable. Distinct from the HOME | |
| 444 | - | // lesson in `engine::tests`: the name is this test's own, so no other | |
| 444 | + | // lesson in `engine::git::tests`: the name is this test's own, so no other | |
| 445 | 445 | // test reads it, and it is never removed — the value it holds is the | |
| 446 | 446 | // whole point of the assertion at the bottom. | |
| 447 | 447 | unsafe { std::env::set_var("BENTO_HANDOFF_WIRE_TEST_TOKEN", "s3cr3t\n") }; |
| @@ -910,7 +910,7 @@ | |||
| 910 | 910 | /// outcome is supervised — a panicked task is logged (and its still-`running` | |
| 911 | 911 | /// row is reconciled below), an aborted (superseded) task is expected. | |
| 912 | 912 | /// | |
| 913 | - | /// Per-step deadlines (see `engine::step_budget`) are the real bound on a wedged | |
| 913 | + | /// Per-step deadlines (see `engine::ctx::RecipeCtx::step_budget`) are the real bound on a wedged | |
| 914 | 914 | /// step now; this overall deadline is only a generous last-resort backstop for a | |
| 915 | 915 | /// hang outside a bounded command. When it trips it sets each still-running | |
| 916 | 916 | /// target's cooperative cancel FIRST (the blocking recipe bodies observe that at |
| @@ -1,3785 +1,0 @@ | |||
| 1 | - | //! Rhai recipe engine + host-function API. | |
| 2 | - | //! | |
| 3 | - | //! A `(app, target)` resolves to a `.rhai` recipe composed from a shared step | |
| 4 | - | //! vocabulary. The daemon embeds Rhai and registers the host functions recipes | |
| 5 | - | //! call; the recipe is the orchestration, the host functions are the | |
| 6 | - | //! privileged primitives (run a command, read a secret, collect artifacts, | |
| 7 | - | //! publish). Recipes are otherwise sandboxed — no arbitrary FS/network except | |
| 8 | - | //! through these functions — matching the Balanced Breakfast plugin model. | |
| 9 | - | //! | |
| 10 | - | //! Rhai is synchronous; the engine runs each recipe on a blocking thread | |
| 11 | - | //! (`spawn_blocking`, see [`crate::runner`]) and host functions bridge to async | |
| 12 | - | //! work via `Handle::block_on`. That is sound only off a runtime worker thread, | |
| 13 | - | //! which `spawn_blocking` guarantees. | |
| 14 | - | ||
| 15 | - | use crate::config::Config; | |
| 16 | - | use crate::domain::{AppId, Status, Step, StepRunId, Target, Version}; | |
| 17 | - | use crate::events::{self, Event, EventTx}; | |
| 18 | - | use crate::ota::{OtaRegistry, PublishAuthority, Release}; | |
| 19 | - | use crate::state::ExecutorMap; | |
| 20 | - | use crate::topology::{DeployTarget, Kind}; | |
| 21 | - | use anyhow::{Context as _, Result}; | |
| 22 | - | use ops_core::live_log::LiveLog; | |
| 23 | - | use ops_exec::{Action, Executor, ObserveKind, Step as OpStep, SyncOpts}; | |
| 24 | - | use rhai::{Engine, EvalAltResult, Map}; | |
| 25 | - | use sha2::{Digest, Sha256}; | |
| 26 | - | use sqlx::SqlitePool; | |
| 27 | - | use std::collections::HashMap; | |
| 28 | - | use std::path::{Path, PathBuf}; | |
| 29 | - | use std::sync::atomic::{AtomicBool, Ordering}; | |
| 30 | - | use std::sync::{Arc, Mutex}; | |
| 31 | - | use tokio::runtime::Handle; | |
| 32 | - | use tokio::sync::Mutex as AsyncMutex; | |
| 33 | - | ||
| 34 | - | /// The capability label for a command, derived from the open recipe step. A | |
| 35 | - | /// recipe's `sh("mbp", …)` under `step("sign")` becomes an `Action::Sign`, gated | |
| 36 | - | /// by the mac host's `sign` grant — so recipes stay unchanged while every command | |
| 37 | - | /// is capability-checked at its transport. `Verify` is read-only (an observe). | |
| 38 | - | /// The capability a step's commands are gated on. | |
| 39 | - | /// | |
| 40 | - | /// `Verify` depends on what is being released, which is the one place this is | |
| 41 | - | /// not a property of the step alone. An app's verify is a Gatekeeper check on a | |
| 42 | - | /// signed bundle, and the `gatekeeper` observe is granted implicitly to hosts | |
| 43 | - | /// that can `sign` (`CapabilitySet::from_tokens`) precisely so that pairing | |
| 44 | - | /// holds. A library's verify is a crate preflight: it runs `cargo` on the build | |
| 45 | - | /// host and asks the registry a question. Gating that on Gatekeeper asks a Linux | |
| 46 | - | /// host for a macOS code-signing capability it can never honestly hold, and the | |
| 47 | - | /// only way to satisfy it would be to declare the capability falsely. | |
| 48 | - | fn action_for(step: Step, kind: Kind) -> Action { | |
| 49 | - | match step { | |
| 50 | - | Step::Checkout | Step::Prebuild | Step::Build => Action::Build, | |
| 51 | - | Step::Sign => Action::Sign, | |
| 52 | - | Step::Notarize => Action::Notarize, | |
| 53 | - | Step::Staple => Action::Staple, | |
| 54 | - | Step::Package => Action::Package, | |
| 55 | - | Step::Verify => match kind { | |
| 56 | - | Kind::App => Action::Observe(ObserveKind::Custom("gatekeeper".into())), | |
| 57 | - | // Running the build toolchain to inspect a crate or a service | |
| 58 | - | // binary, which is what `build` means on a host. Neither has a | |
| 59 | - | // bundle for Gatekeeper to have an opinion about. | |
| 60 | - | Kind::Library | Kind::Service => Action::Build, | |
| 61 | - | }, | |
| 62 | - | // Publish/Collect/Handoff run on the daemon, not through a host | |
| 63 | - | // executor; this label only applies if a recipe runs a bare `sh` while | |
| 64 | - | // one is open. `handoff` is the daemon's own post-recipe motion and no | |
| 65 | - | // recipe should open it at all — naming it here costs nothing and beats | |
| 66 | - | // a wildcard that would silently absorb the next step somebody adds. | |
| 67 | - | Step::Publish | Step::Collect | Step::Handoff => Action::Package, | |
| 68 | - | // The one step that dispatches to a host OUTSIDE the build topology. | |
| 69 | - | // Every command a recipe runs while `deploy` is open — the install, the | |
| 70 | - | // restart, the health assertion — carries this action, so it reaches the | |
| 71 | - | // service host only through the deploy grant and reaches a build host | |
| 72 | - | // not at all (no build host is granted `deploy`). | |
| 73 | - | Step::Deploy => Action::Deploy, | |
| 74 | - | } | |
| 75 | - | } | |
| 76 | - | ||
| 77 | - | /// The currently-open step within a recipe run: its DB row id, which step it | |
| 78 | - | /// is, and the live-log sink that `sh`/`log` stream into. | |
| 79 | - | struct StepState { | |
| 80 | - | run_id: StepRunId, | |
| 81 | - | step: Step, | |
| 82 | - | log: Arc<AsyncMutex<LiveLog>>, | |
| 83 | - | /// Set when something in the step recorded a hard failure the recipe did | |
| 84 | - | /// not abort on (e.g. `verify_gatekeeper` rejected the artifact but the | |
| 85 | - | /// recipe ignored the bool). Forces the step's recorded status to `Failed` | |
| 86 | - | /// and bars `publish` (the step-success ledger). | |
| 87 | - | failed: bool, | |
| 88 | - | /// Wall-clock deadline for this step. A command that runs past it fails the | |
| 89 | - | /// step (and unwinds the recipe) rather than wedging under the old | |
| 90 | - | /// whole-build guillotine, which a legitimate 5-target fan-out plus notary | |
| 91 | - | /// queueing could trip — mismarking every target failed while the blocking | |
| 92 | - | /// recipe bodies kept signing. | |
| 93 | - | deadline: std::time::Instant, | |
| 94 | - | } | |
| 95 | - | ||
| 96 | - | /// Per-step wall-clock budget: a generous ceiling that catches a wedged command | |
| 97 | - | /// (a hung ssh, a stuck notary poll) without killing legitimately slow work. | |
| 98 | - | /// Bounding each step, rather than the whole build, keeps one slow step from | |
| 99 | - | /// being blamed on another and keeps a fan-out of slow-but-fine targets from | |
| 100 | - | /// being guillotined. `Config::step_timeout_secs` overrides these per-kind | |
| 101 | - | /// defaults for every step; see [`RecipeCtx::step_budget`]. | |
| 102 | - | fn default_step_budget(step: Step) -> std::time::Duration { | |
| 103 | - | use std::time::Duration; | |
| 104 | - | let mins = match step { | |
| 105 | - | Step::Checkout => 10, | |
| 106 | - | // clippy + full test suite, cold, on a workspace. | |
| 107 | - | Step::Prebuild => 45, | |
| 108 | - | // cargo tauri build, cold, universal bundles. | |
| 109 | - | Step::Build => 90, | |
| 110 | - | Step::Sign => 15, | |
| 111 | - | // Apple's notary queue + this step's bounded retries. | |
| 112 | - | Step::Notarize => 60, | |
| 113 | - | Step::Staple => 10, | |
| 114 | - | Step::Package => 30, | |
| 115 | - | Step::Verify => 10, | |
| 116 | - | // rsync of multi-GiB artifacts off the build host. | |
| 117 | - | Step::Collect => 30, | |
| 118 | - | Step::Publish => 20, | |
| 119 | - | // A binary push, an install, a unit restart, and a health poll. Minutes | |
| 120 | - | // of work; the ceiling is for a wedged transport, not slow work. | |
| 121 | - | Step::Deploy => 15, | |
| 122 | - | // Unused by any recipe — the daemon runs the handoff itself, outside a | |
| 123 | - | // step's clock — and matched to `collect`, since it moves the same | |
| 124 | - | // bytes the same way and would wedge for the same reasons. | |
| 125 | - | Step::Handoff => 30, | |
| 126 | - | }; | |
| 127 | - | Duration::from_secs(mins * 60) | |
| 128 | - | } | |
| 129 | - | ||
| 130 | - | /// Where a service's binary is staged on the host that will run it, before the | |
| 131 | - | /// privileged installer moves it into place. | |
| 132 | - | /// | |
| 133 | - | /// A fixed, unguessable-by-accident path rather than a recipe-chosen one, | |
| 134 | - | /// because the installer refuses any source outside it. That refusal is the | |
| 135 | - | /// only thing standing between the NOPASSWD sudo grant and `install`-as-root to | |
| 136 | - | /// an arbitrary path, so both ends have to name the same constant. `/var/tmp` | |
| 137 | - | /// rather than `/tmp` so a staged binary survives a `PrivateTmp` unit and a | |
| 138 | - | /// systemd tmpfiles sweep between staging and install. | |
| 139 | - | pub const DEPLOY_STAGING_ROOT: &str = "/var/tmp/bento-deploy"; | |
| 140 | - | ||
| 141 | - | /// Everything a recipe's host functions need, shared (Arc) into each closure. | |
| 142 | - | pub struct RecipeCtx { | |
| 143 | - | pub app: AppId, | |
| 144 | - | pub version: Version, | |
| 145 | - | pub target: Target, | |
| 146 | - | /// Name of the host this target builds on (resolved from the topology by the | |
| 147 | - | /// runner). Recipes read it via `build_host()` so one per-platform recipe can | |
| 148 | - | /// dispatch to the right host across arches (linux x86_64 -> fw13, aarch64 -> | |
| 149 | - | /// astra) without hard-coding a host name. | |
| 150 | - | pub build_host: String, | |
| 151 | - | /// The build host's SSH destination (topology `ssh`), as opposed to its | |
| 152 | - | /// name. Deploy compares it against the service host's to tell "build and | |
| 153 | - | /// run on the same box" from "two hosts that need a transfer" — a question | |
| 154 | - | /// the host NAMES cannot answer, since a build host and a deploy | |
| 155 | - | /// destination are declared in different files and need not agree on one. | |
| 156 | - | pub build_host_ssh: String, | |
| 157 | - | /// This release's git tag, rendered from the app's `tag_format`. Held here | |
| 158 | - | /// rather than derived from the version because a repo holding several | |
| 159 | - | /// products spells it per product (`pom-v0.4.1`), and the recipe, the | |
| 160 | - | /// preflight barrier and the failure message all have to agree on it. | |
| 161 | - | pub tag: String, | |
| 162 | - | /// The app's default checkout path (topology `repo`, `~`-prefixed). Read it | |
| 163 | - | /// through [`RecipeCtx::repo_for`] for anything that runs on a build host; | |
| 164 | - | /// this field alone is the daemon-local answer. | |
| 165 | - | pub repo: String, | |
| 166 | - | /// Per-host checkout overrides (topology `repo_by_host`). Empty for every app | |
| 167 | - | /// that has not declared one, which is all of them but the Windows-shipping | |
| 168 | - | /// ones. | |
| 169 | - | pub repo_by_host: HashMap<String, String>, | |
| 170 | - | /// Cargo features this app's release builds enable (topology `features`). | |
| 171 | - | /// Recipes read it via `feature_flags()`. | |
| 172 | - | pub features: Vec<String>, | |
| 173 | - | /// App or library. Decides which capability a `verify` step is gated on; | |
| 174 | - | /// see `action_for`. | |
| 175 | - | pub kind: Kind, | |
| 176 | - | pub target_run_id: i64, | |
| 177 | - | /// Capability-scoped executor per build host. Recipe commands dispatch | |
| 178 | - | /// through these — the transport (local / ssh / in-session agent) and the | |
| 179 | - | /// capability gate are the executor's, not the engine's. | |
| 180 | - | pub execs: Arc<ExecutorMap>, | |
| 181 | - | /// Sync transport per build host, used only by `collect` to pull artifacts | |
| 182 | - | /// back. Never the agent, even for an agent host — see `state::build_sync`. | |
| 183 | - | /// Not an execution path. | |
| 184 | - | pub syncs: Arc<ExecutorMap>, | |
| 185 | - | /// Where this target installs, for a `kind = "service"` app. `None` for an | |
| 186 | - | /// app or a library, which makes every deploy host function fail with that | |
| 187 | - | /// as the reason rather than with a missing-host error. | |
| 188 | - | /// | |
| 189 | - | /// The runner resolves it from the app manifest's `[[deploy]]` table and | |
| 190 | - | /// registers its executor into `execs` under the destination's host string, | |
| 191 | - | /// so `sh_ok(deploy_host(), ...)` reaches the service host through the same | |
| 192 | - | /// capability gate as everything else. | |
| 193 | - | pub deploy: Option<DeployTarget>, | |
| 194 | - | pub pool: SqlitePool, | |
| 195 | - | pub events: EventTx, | |
| 196 | - | pub cfg: Arc<Config>, | |
| 197 | - | pub ota: Arc<OtaRegistry>, | |
| 198 | - | pub rt: Handle, | |
| 199 | - | current: Mutex<Option<StepState>>, | |
| 200 | - | /// Gatekeeper verdict recorded by `verify_gatekeeper`: `None` = never run, | |
| 201 | - | /// `Some(false)` = ran and rejected, `Some(true)` = accepted. `publish` | |
| 202 | - | /// requires `Some(true)` for a macOS/iOS artifact (the proof it is signed + | |
| 203 | - | /// notarized). | |
| 204 | - | gatekeeper_ok: Mutex<Option<bool>>, | |
| 205 | - | /// Steps finalized as `Failed` during this run. A non-empty ledger bars | |
| 206 | - | /// `publish` — an artifact is never shipped after a step failed, even if the | |
| 207 | - | /// recipe ignored the failure and ran on. | |
| 208 | - | failed_steps: Mutex<Vec<Step>>, | |
| 209 | - | /// Set when a newer build supersedes this run. Checked at step boundaries | |
| 210 | - | /// and before publish so a superseded recipe stops promptly rather than | |
| 211 | - | /// running to completion (the blocking Rhai body can't be `abort()`ed). | |
| 212 | - | cancel: Arc<AtomicBool>, | |
| 213 | - | /// The all-targets-green publish gate (topology `require_all_targets`). | |
| 214 | - | /// `Some(declared)` ⇒ `publish` refuses unless every one of `declared` has a | |
| 215 | - | /// successful latest run for this `(app, version)`. `None` ⇒ gate off, each | |
| 216 | - | /// target publishes independently. | |
| 217 | - | all_green_required: Option<Vec<Target>>, | |
| 218 | - | /// sha256 of each artifact hashed at `collect`, keyed by file name. `publish` | |
| 219 | - | /// reads it to record `releases.artifact_hash` for the bytes it ships, so the | |
| 220 | - | /// hash is the one computed when the artifact landed rather than a re-read | |
| 221 | - | /// that could see a different file. Absent ⇒ `publish` hashes on demand. | |
| 222 | - | artifact_hashes: Mutex<HashMap<String, String>>, | |
| 223 | - | } | |
| 224 | - | ||
| 225 | - | impl RecipeCtx { | |
| 226 | - | /// Versions of `name` already on crates.io. A network failure yields an | |
| 227 | - | /// empty list: preflight then cannot claim a version is a duplicate, and | |
| 228 | - | /// `cargo publish` still refuses one, so the check degrades to advisory | |
| 229 | - | /// rather than blocking a release on registry availability. | |
| 230 | - | fn published_versions(name: &str) -> Vec<String> { | |
| 231 | - | let url = format!("https://crates.io/api/v1/crates/{name}"); | |
| 232 | - | let Ok(out) = std::process::Command::new("curl") | |
| 233 | - | .args([ | |
| 234 | - | "-sS", | |
| 235 | - | "--max-time", | |
| 236 | - | "15", | |
| 237 | - | "-H", | |
| 238 | - | "User-Agent: bento-preflight", | |
| 239 | - | &url, | |
| 240 | - | ]) | |
| 241 | - | .output() | |
| 242 | - | else { | |
| 243 | - | return Vec::new(); | |
| 244 | - | }; | |
| 245 | - | let Ok(v) = serde_json::from_slice::<serde_json::Value>(&out.stdout) else { | |
| 246 | - | return Vec::new(); | |
| 247 | - | }; | |
| 248 | - | v.get("versions") | |
| 249 | - | .and_then(|x| x.as_array()) | |
| 250 | - | .map(|a| { | |
| 251 | - | a.iter() | |
| 252 | - | .filter_map(|x| x.get("num").and_then(|n| n.as_str()).map(str::to_string)) | |
| 253 | - | .collect() | |
| 254 | - | }) | |
| 255 | - | .unwrap_or_default() | |
| 256 | - | } | |
| 257 | - | ||
| 258 | - | #[allow(clippy::too_many_arguments)] | |
| 259 | - | pub fn new( | |
| 260 | - | app: AppId, | |
| 261 | - | version: Version, | |
| 262 | - | target: Target, | |
| 263 | - | build_host: String, | |
| 264 | - | build_host_ssh: String, | |
| 265 | - | tag: String, | |
| 266 | - | repo: String, | |
| 267 | - | features: Vec<String>, | |
| 268 | - | kind: Kind, | |
| 269 | - | target_run_id: i64, | |
| 270 | - | execs: Arc<ExecutorMap>, | |
| 271 | - | syncs: Arc<ExecutorMap>, | |
| 272 | - | deploy: Option<DeployTarget>, | |
| 273 | - | pool: SqlitePool, | |
| 274 | - | events: EventTx, | |
| 275 | - | cfg: Arc<Config>, | |
| 276 | - | ota: Arc<OtaRegistry>, | |
| 277 | - | rt: Handle, | |
| 278 | - | cancel: Arc<AtomicBool>, | |
| 279 | - | all_green_required: Option<Vec<Target>>, | |
| 280 | - | ) -> Self { | |
| 281 | - | Self { | |
| 282 | - | app, | |
| 283 | - | version, | |
| 284 | - | target, | |
| 285 | - | build_host, | |
| 286 | - | build_host_ssh, | |
| 287 | - | tag, | |
| 288 | - | repo, | |
| 289 | - | repo_by_host: HashMap::new(), | |
| 290 | - | features, | |
| 291 | - | kind, | |
| 292 | - | target_run_id, | |
| 293 | - | execs, | |
| 294 | - | syncs, | |
| 295 | - | deploy, | |
| 296 | - | pool, | |
| 297 | - | events, | |
| 298 | - | cfg, | |
| 299 | - | ota, | |
| 300 | - | rt, | |
| 301 | - | current: Mutex::new(None), | |
| 302 | - | gatekeeper_ok: Mutex::new(None), | |
| 303 | - | failed_steps: Mutex::new(Vec::new()), | |
| 304 | - | cancel, | |
| 305 | - | all_green_required, | |
| 306 | - | artifact_hashes: Mutex::new(HashMap::new()), | |
| 307 | - | } | |
| 308 | - | } | |
| 309 | - | ||
| 310 | - | /// Declare the app's per-host checkout overrides (topology `repo_by_host`). | |
| 311 | - | /// | |
| 312 | - | /// Separate from `new` because it is empty for every app that has not opted | |
| 313 | - | /// in, and `new` already carries twenty arguments no test wants a | |
| 314 | - | /// twenty-first of. | |
| 315 | - | #[must_use] | |
| 316 | - | pub fn with_repo_by_host(mut self, repo_by_host: HashMap<String, String>) -> Self { | |
| 317 | - | self.repo_by_host = repo_by_host; | |
| 318 | - | self | |
| 319 | - | } | |
| 320 | - | ||
| 321 | - | /// Where this app is checked out on `host` (topology `AppConfig::repo_for`). | |
| 322 | - | /// | |
| 323 | - | /// Every git command a recipe or the engine runs on a build host goes | |
| 324 | - | /// through this. The bare `repo` field is the daemon-local path. | |
| 325 | - | pub fn repo_for(&self, host: &str) -> &str { | |
| 326 | - | self.repo_by_host | |
| 327 | - | .get(host) | |
| 328 | - | .map_or(self.repo.as_str(), String::as_str) | |
| 329 | - | } | |
| 330 | - | ||
| 331 | - | /// Whether a newer build has superseded this run. | |
| 332 | - | fn is_cancelled(&self) -> bool { | |
| 333 | - | self.cancel.load(Ordering::SeqCst) | |
| 334 | - | } | |
| 335 | - | ||
| 336 | - | fn now() -> String { | |
| 337 | - | chrono::Utc::now().to_rfc3339() | |
| 338 | - | } | |
| 339 | - | ||
| 340 | - | /// `<logs_root>/<app>/<version>/<target-with-slash-as-dash>/<step>.<run_id>.log`. | |
| 341 | - | /// | |
| 342 | - | /// The run id is in the filename because the rest of the key is not unique: | |
| 343 | - | /// re-running an app at a version it already built (a retry, or a rebuild of | |
| 344 | - | /// an already-published release) reopens the same path, and appending would | |
| 345 | - | /// show two runs' output with nothing marking the boundary. The | |
| 346 | - | /// step ledger records a run id per step, so keying the file on it makes a | |
| 347 | - | /// ledger row resolve to exactly one file and keeps the earlier run readable. | |
| 348 | - | fn log_path(&self, step: Step, run_id: StepRunId) -> PathBuf { | |
| 349 | - | self.log_dir() | |
| 350 | - | .join(format!("{}.{}.log", step.as_str(), run_id.0)) | |
| 351 | - | } | |
| 352 | - | ||
| 353 | - | /// `<logs_root>/<app>/<version>/<target-with-slash-as-dash>/`. | |
| 354 | - | fn log_dir(&self) -> PathBuf { | |
| 355 | - | let target_dir = self.target.to_string().replace('/', "-"); | |
| 356 | - | self.cfg | |
| 357 | - | .logs_root | |
| 358 | - | .join(self.app.as_str()) | |
| 359 | - | .join(self.version.to_string()) | |
| 360 | - | .join(target_dir) | |
| 361 | - | } | |
| 362 | - | ||
| 363 | - | /// Close the previous step (as `Ok`), open a new one: insert its DB row, | |
| 364 | - | /// open a live log whose chunks broadcast `StepLogChunk`, emit `StepStart`. | |
| 365 | - | fn begin_step(self: &Arc<Self>, step: Step) -> Result<()> { | |
| 366 | - | anyhow::ensure!( | |
| 367 | - | !self.is_cancelled(), | |
| 368 | - | "build superseded by a newer request; aborting before `{}`", | |
| 369 | - | step.as_str() | |
| 370 | - | ); | |
| 371 | - | self.finish_step(Status::Ok)?; | |
| 372 | - | let me = self.clone(); | |
| 373 | - | let started = Self::now(); | |
| 374 | - | let started_for_header = started.clone(); | |
| 375 | - | let run_id = self.rt.block_on(async move { | |
| 376 | - | // The step row and the target's current_step pointer are one logical | |
| 377 | - | // state — write them atomically so a failure can't leave a `running` | |
| 378 | - | // step row while current_step still names the previous step. | |
| 379 | - | let mut tx = me.pool.begin().await.context("begin step tx")?; | |
| 380 | - | // log_ref names the run id, which only exists once the row does, so | |
| 381 | - | // the path is written back inside the same transaction rather than | |
| 382 | - | // guessed beforehand. | |
| 383 | - | let id: i64 = sqlx::query_scalar( | |
| 384 | - | "INSERT INTO step_runs (target_run_id, step, status, started_at) | |
| 385 | - | VALUES (?, ?, 'running', ?) RETURNING id", | |
| 386 | - | ) | |
| 387 | - | .bind(me.target_run_id) | |
| 388 | - | .bind(step.as_str()) | |
| 389 | - | .bind(&started) | |
| 390 | - | .fetch_one(&mut *tx) | |
| 391 | - | .await | |
| 392 | - | .context("insert step_run")?; | |
| 393 | - | let log_ref = me | |
| 394 | - | .log_path(step, StepRunId(id)) | |
| 395 | - | .to_string_lossy() | |
| 396 | - | .into_owned(); | |
| 397 | - | sqlx::query("UPDATE step_runs SET log_ref = ? WHERE id = ?") | |
| 398 | - | .bind(&log_ref) | |
| 399 | - | .bind(id) | |
| 400 | - | .execute(&mut *tx) | |
| 401 | - | .await | |
| 402 | - | .context("set step_run log_ref")?; | |
| 403 | - | sqlx::query("UPDATE target_runs SET current_step = ? WHERE id = ?") | |
| 404 | - | .bind(step.as_str()) | |
| 405 | - | .bind(me.target_run_id) | |
| 406 | - | .execute(&mut *tx) | |
| 407 | - | .await | |
| 408 | - | .context("update current_step")?; | |
| 409 | - | tx.commit().await.context("commit step tx")?; | |
| 410 | - | anyhow::Ok(StepRunId(id)) | |
| 411 | - | })?; | |
| 412 | - | ||
| 413 | - | // Live log: each chunk fans out as a StepLogChunk event keyed by run_id. | |
| 414 | - | let events = self.events.clone(); | |
| 415 | - | let cb_run_id = run_id; | |
| 416 | - | let mut log = self.rt.block_on(LiveLog::open( | |
| 417 | - | self.log_path(step, run_id), | |
| 418 | - | Box::new(move |seq, text| { | |
| 419 | - | events::emit( | |
| 420 | - | &events, | |
| 421 | - | Event::StepLogChunk { | |
| 422 | - | run_id: cb_run_id, | |
| 423 | - | seq, | |
| 424 | - | text: text.to_string(), | |
| 425 | - | }, | |
| 426 | - | ); | |
| 427 | - | }), | |
| 428 | - | )); | |
| 429 | - | ||
| 430 | - | events::emit( | |
| 431 | - | &self.events, | |
| 432 | - | Event::StepStart { | |
| 433 | - | run_id, | |
| 434 | - | app: self.app.clone(), | |
| 435 | - | version: self.version.clone(), | |
| 436 | - | target: self.target, | |
| 437 | - | step, | |
| 438 | - | }, | |
| 439 | - | ); | |
| 440 | - | ||
| 441 | - | // A log that names its own run: reading one tells you which ledger row | |
| 442 | - | // it belongs to without going back to the DB, and a file that somehow | |
| 443 | - | // does get appended to still shows where the second run began. Written | |
| 444 | - | // after `StepStart` so its chunk event cannot precede the step it | |
| 445 | - | // belongs to. | |
| 446 | - | let header = format!( | |
| 447 | - | "=== bento {app} {version} {target} step={step} run_id={run_id} started={started_for_header} ===\n", | |
| 448 | - | app = self.app.as_str(), | |
| 449 | - | version = self.version, | |
| 450 | - | target = self.target, | |
| 451 | - | step = step.as_str(), | |
| 452 | - | ); | |
| 453 | - | self.rt.block_on(async { | |
| 454 | - | use ops_core::remote::LogSink as _; | |
| 455 | - | log.write_chunk(header.as_bytes()).await; | |
| 456 | - | }); | |
| 457 | - | ||
| 458 | - | *self.current.lock().unwrap() = Some(StepState { | |
| 459 | - | run_id, | |
| 460 | - | step, | |
| 461 | - | log: Arc::new(AsyncMutex::new(log)), | |
| 462 | - | failed: false, | |
| 463 | - | deadline: std::time::Instant::now() + self.step_budget(step), | |
| 464 | - | }); | |
| 465 | - | Ok(()) | |
| 466 | - | } | |
| 467 | - | ||
| 468 | - | /// This build's budget for `step`: the `Config` override if set, else the | |
| 469 | - | /// per-kind default. | |
| 470 | - | fn step_budget(&self, step: Step) -> std::time::Duration { | |
| 471 | - | self.cfg | |
| 472 | - | .step_timeout_secs | |
| 473 | - | .map_or_else(|| default_step_budget(step), std::time::Duration::from_secs) | |
| 474 | - | } | |
| 475 | - | ||
| 476 | - | /// The current step's wall-clock deadline (or a default if no step is open, | |
| 477 | - | /// which only happens before the first `step()` — commands then run under an | |
| 478 | - | /// implicit `Build` step opened by `ensure_step`). | |
| 479 | - | fn step_deadline(&self) -> std::time::Instant { | |
| 480 | - | self.current.lock().unwrap().as_ref().map_or_else( | |
| 481 | - | || std::time::Instant::now() + self.step_budget(Step::Build), | |
| 482 | - | |s| s.deadline, | |
| 483 | - | ) | |
| 484 | - | } | |
| 485 | - | ||
| 486 | - | /// Drive `fut` on the runtime, but stop early on two conditions the recipe | |
| 487 | - | /// bodies otherwise cannot observe (they run synchronously on a blocking | |
| 488 | - | /// thread): the current step's deadline, and supersession by a newer build. | |
| 489 | - | /// Either turns into an error that fails the step and unwinds the recipe, so | |
| 490 | - | /// a wedged command cannot run unbounded and a superseded build stops | |
| 491 | - | /// mid-step instead of only at the next step boundary. | |
| 492 | - | fn run_bounded<F, T>(&self, what: &str, fut: F) -> Result<T> | |
| 493 | - | where | |
| 494 | - | F: std::future::Future<Output = Result<T>>, | |
| 495 | - | { | |
| 496 | - | let deadline = self.step_deadline(); | |
| 497 | - | let cancel = self.cancel.clone(); | |
| 498 | - | self.rt.block_on(async move { | |
| 499 | - | tokio::pin!(fut); | |
| 500 | - | let watch = async { |
Lines truncated
| @@ -1,0 +1,470 @@ | |||
| 1 | + | //! Collecting a build's artifacts: finding them, proving they are the version | |
| 2 | + | //! that was asked for, hashing them, and pulling them back. | |
| 3 | + | ||
| 4 | + | use super::RecipeCtx; | |
| 5 | + | use crate::domain::Version; | |
| 6 | + | use crate::events::{self, Event}; | |
| 7 | + | use anyhow::{Context as _, Result}; | |
| 8 | + | use ops_exec::SyncOpts; | |
| 9 | + | use sha2::{Digest, Sha256}; | |
| 10 | + | use std::path::{Path, PathBuf}; | |
| 11 | + | use std::sync::Arc; | |
| 12 | + | ||
| 13 | + | /// Every `X.Y.Z`-shaped version embedded in an artifact file name. Each maximal | |
| 14 | + | /// run of digits-and-dots contributes its first three numeric fields: | |
| 15 | + | /// `GoingsOn_0.5.0_aarch64.dmg` and `demo-9.9.9.bin` both yield one version (the | |
| 16 | + | /// trailing `.bin`/`.dmg` dot is tolerated), while `latest.json` yields `[]` and | |
| 17 | + | /// the `64` in `x86_64` is not three fields. Only the `major.minor.patch` core is | |
| 18 | + | /// taken; a prerelease/build suffix is separated by `-`/`+` and not needed here. | |
| 19 | + | pub(super) fn versions_in_filename(name: &str) -> Vec<Version> { | |
| 20 | + | name.split(|c: char| !(c.is_ascii_digit() || c == '.')) | |
| 21 | + | .filter_map(|run| { | |
| 22 | + | let f: Vec<&str> = run.split('.').filter(|s| !s.is_empty()).collect(); | |
| 23 | + | if f.len() >= 3 && f[..3].iter().all(|s| s.chars().all(|c| c.is_ascii_digit())) { | |
| 24 | + | Version::parse(&format!("{}.{}.{}", f[0], f[1], f[2])).ok() | |
| 25 | + | } else { | |
| 26 | + | None | |
| 27 | + | } | |
| 28 | + | }) | |
| 29 | + | .collect() | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | /// Fail when a collected file's name embeds a version whose `major.minor.patch` | |
| 33 | + | /// is not the one being built. This is the guard against a stale checked-in | |
| 34 | + | /// artifact winning a glob: `ls -t <glob>` once let | |
| 35 | + | /// `AudioFiles-0.4.0-x86_64.AppImage` ship against 0.5.0. A file whose name | |
| 36 | + | /// carries no version (an updater `latest.json`, a `.sig`) is not asserted — | |
| 37 | + | /// there is nothing to compare. Compared on the core so a prerelease build's | |
| 38 | + | /// plain `X.Y.Z` in the filename still matches. | |
| 39 | + | pub(super) fn assert_artifact_version(name: &str, expected: &Version) -> Result<()> { | |
| 40 | + | let versions = versions_in_filename(name); | |
| 41 | + | anyhow::ensure!( | |
| 42 | + | versions.is_empty() || versions.iter().any(|v| v.core() == expected.core()), | |
| 43 | + | "collected artifact `{name}` carries version {} but the build is {expected}; \ | |
| 44 | + | a stale artifact was left in the output dir — clean it so only {expected} remains", | |
| 45 | + | versions | |
| 46 | + | .iter() | |
| 47 | + | .map(ToString::to_string) | |
| 48 | + | .collect::<Vec<_>>() | |
| 49 | + | .join("/"), | |
| 50 | + | ); | |
| 51 | + | Ok(()) | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | /// sha256 of a file, lowercase hex. Streams in 64 KiB chunks so a multi-GiB | |
| 55 | + | /// bundle never lands in memory whole. | |
| 56 | + | pub(super) fn sha256_file(path: &Path) -> Result<String> { | |
| 57 | + | let mut file = | |
| 58 | + | std::fs::File::open(path).with_context(|| format!("hashing {}", path.display()))?; | |
| 59 | + | let mut hasher = Sha256::new(); | |
| 60 | + | std::io::copy(&mut file, &mut hasher) | |
| 61 | + | .with_context(|| format!("reading {} to hash", path.display()))?; | |
| 62 | + | Ok(hex_lower(&hasher.finalize())) | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | /// Every regular file under `root`, as `(path relative to root, absolute path)`, | |
| 66 | + | /// sorted by the relative path. | |
| 67 | + | /// | |
| 68 | + | /// **This walk has to match `bundle::digest_dir` in sando, file for file.** That | |
| 69 | + | /// function re-hashes an incoming bundle and refuses it when the bytes disagree | |
| 70 | + | /// with the manifest they arrived with, so a producer that walks differently | |
| 71 | + | /// produces a manifest the consumer will reject for an artifact nothing is wrong | |
| 72 | + | /// with. Three properties carry that agreement, and none is incidental: | |
| 73 | + | /// | |
| 74 | + | /// - **Recursive.** A bundle may carry a directory (migrations, resources), and | |
| 75 | + | /// a top-level-only listing would omit its contents from the manifest while | |
| 76 | + | /// the verifier hashed them. | |
| 77 | + | /// - **Symlinks are not followed, and not recorded.** Following one would let | |
| 78 | + | /// content from outside the bundle into its identity; recording the link | |
| 79 | + | /// itself would name a file the verifier does not hash. | |
| 80 | + | /// - **Relative paths, `/`-separated, sorted.** Readdir order is not guaranteed, | |
| 81 | + | /// so an unsorted manifest would differ run to run on one machine, never mind | |
| 82 | + | /// between two. | |
| 83 | + | pub(super) fn collected_files(root: &Path) -> std::io::Result<Vec<(String, PathBuf)>> { | |
| 84 | + | fn walk(dir: &Path, root: &Path, out: &mut Vec<(String, PathBuf)>) -> std::io::Result<()> { | |
| 85 | + | for entry in std::fs::read_dir(dir)? { | |
| 86 | + | let entry = entry?; | |
| 87 | + | let ft = entry.file_type()?; | |
| 88 | + | let path = entry.path(); | |
| 89 | + | if ft.is_dir() { | |
| 90 | + | walk(&path, root, out)?; | |
| 91 | + | } else if ft.is_file() { | |
| 92 | + | let rel = path | |
| 93 | + | .strip_prefix(root) | |
| 94 | + | .unwrap_or(&path) | |
| 95 | + | .components() | |
| 96 | + | .map(|c| c.as_os_str().to_string_lossy()) | |
| 97 | + | .collect::<Vec<_>>() | |
| 98 | + | .join("/"); | |
| 99 | + | out.push((rel, path)); | |
| 100 | + | } | |
| 101 | + | // Symlinks and other special files are intentionally ignored, | |
| 102 | + | // matching the verifier. | |
| 103 | + | } | |
| 104 | + | Ok(()) | |
| 105 | + | } | |
| 106 | + | let mut out = Vec::new(); | |
| 107 | + | walk(root, root, &mut out)?; | |
| 108 | + | out.sort_by(|a, b| a.0.cmp(&b.0)); | |
| 109 | + | Ok(out) | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | /// Lowercase-hex encode without pulling in a hex crate. | |
| 113 | + | pub(super) fn hex_lower(bytes: &[u8]) -> String { | |
| 114 | + | use std::fmt::Write as _; | |
| 115 | + | let mut s = String::with_capacity(bytes.len() * 2); | |
| 116 | + | for b in bytes { | |
| 117 | + | let _ = write!(s, "{b:02x}"); | |
| 118 | + | } | |
| 119 | + | s | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | /// Reject a glob that carries shell command metacharacters. Path and wildcard | |
| 123 | + | /// characters (`/ . * ? [ ] ~` etc.) are fine — the pattern reaches a login | |
| 124 | + | /// shell to be expanded — but a `;` or `$(...)` must not ride along and run. | |
| 125 | + | /// Not a privilege boundary (a recipe already runs arbitrary shell via `sh_ok`) | |
| 126 | + | /// but it keeps a malformed pattern from turning into a command. Shared by | |
| 127 | + | /// `collect` and `resolve_artifact`. | |
| 128 | + | pub(super) fn ensure_glob_safe(glob: &str) -> Result<()> { | |
| 129 | + | anyhow::ensure!( | |
| 130 | + | !glob.chars().any(|c| matches!( | |
| 131 | + | c, | |
| 132 | + | ';' | '&' | '|' | '$' | '`' | '\'' | '"' | '\\' | ' ' | '\n' | '(' | ')' | '<' | '>' | |
| 133 | + | )), | |
| 134 | + | "glob `{glob}` contains shell metacharacters" | |
| 135 | + | ); | |
| 136 | + | Ok(()) | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | /// Decide the single artifact a glob resolves to from a newline-separated | |
| 140 | + | /// listing of the paths that matched it. | |
| 141 | + | /// | |
| 142 | + | /// Demands exactly one match. Zero matches fail when `required` (return `""` | |
| 143 | + | /// when optional); more than one is always an error rather than an arbitrary | |
| 144 | + | /// newest-wins pick, because an ambiguous match means the build left stale | |
| 145 | + | /// artifacts behind and the wrong one could ship. | |
| 146 | + | pub(super) fn resolve_artifact_match(listing: &str, glob: &str, required: bool) -> Result<String> { | |
| 147 | + | let matches: Vec<&str> = listing | |
| 148 | + | .lines() | |
| 149 | + | .map(str::trim) | |
| 150 | + | .filter(|l| !l.is_empty()) | |
| 151 | + | .collect(); | |
| 152 | + | match matches.as_slice() { | |
| 153 | + | [] if required => anyhow::bail!("no artifact matched glob `{glob}`"), | |
| 154 | + | [] => Ok(String::new()), | |
| 155 | + | [one] => Ok((*one).to_string()), | |
| 156 | + | many => anyhow::bail!( | |
| 157 | + | "glob `{glob}` is ambiguous: {} artifacts matched ({}). \ | |
| 158 | + | The build left more than one behind; clean stale artifacts so exactly one remains.", | |
| 159 | + | many.len(), | |
| 160 | + | many.join(", ") | |
| 161 | + | ), | |
| 162 | + | } | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | pub(super) fn dir_size(p: &Path) -> Option<i64> { | |
| 166 | + | let mut total = 0i64; | |
| 167 | + | for entry in std::fs::read_dir(p).ok()? { | |
| 168 | + | let entry = entry.ok()?; | |
| 169 | + | let md = entry.metadata().ok()?; | |
| 170 | + | if md.is_file() { | |
| 171 | + | total += md.len() as i64; | |
| 172 | + | } else if md.is_dir() { | |
| 173 | + | // Recurse so a bundle dir (a `.app`) reports its real size, not ~0. | |
| 174 | + | total += dir_size(&entry.path()).unwrap_or(0); | |
| 175 | + | } | |
| 176 | + | } | |
| 177 | + | Some(total) | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | impl RecipeCtx { | |
| 181 | + | /// Resolve `glob` on `host` to the single artifact it names. The `for` loop | |
| 182 | + | /// lists each existing match on its own line (and prints nothing — rather | |
| 183 | + | /// than a literal unexpanded pattern — when the glob matches no file), so | |
| 184 | + | /// the count is unambiguous. `required` controls whether zero matches is an | |
| 185 | + | /// error; more than one always is. See `resolve_artifact_match`. | |
| 186 | + | pub(super) fn resolve_artifact( | |
| 187 | + | self: &Arc<Self>, | |
| 188 | + | host: &str, | |
| 189 | + | glob: &str, | |
| 190 | + | required: bool, | |
| 191 | + | ) -> Result<String> { | |
| 192 | + | ensure_glob_safe(glob)?; | |
| 193 | + | // `[ -e ]` guards against a non-matching glob surviving as its literal | |
| 194 | + | // self, and lists one path per line for the count. | |
| 195 | + | let cmd = format!("for __f in {glob}; do [ -e \"$__f\" ] && printf '%s\\n' \"$__f\"; done"); | |
| 196 | + | let (code, tail) = self.run(host, &cmd)?; | |
| 197 | + | anyhow::ensure!( | |
| 198 | + | code == 0, | |
| 199 | + | "resolving artifact glob `{glob}` on `{host}` exited {code}" | |
| 200 | + | ); | |
| 201 | + | resolve_artifact_match(&tail, glob, required) | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | /// Where this run's collected files land locally. | |
| 205 | + | /// | |
| 206 | + | /// Per target, not per version. Sharing one `dist_root/<app>/<version>/` | |
| 207 | + | /// across targets would have the hash loop below (which lists the directory) | |
| 208 | + | /// attribute a sibling's AppImage to the mac build's artifact record. It is | |
| 209 | + | /// also the layout the archive uses, and the two have to agree or the local | |
| 210 | + | /// copy and the deposited one are different shapes. | |
| 211 | + | pub(super) fn collect_dest(&self, app: &str, version: &str) -> PathBuf { | |
| 212 | + | self.cfg | |
| 213 | + | .dist_root | |
| 214 | + | .join(app) | |
| 215 | + | .join(version) | |
| 216 | + | .join(crate::archive::target_slug(self.target)) | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | pub(super) fn collect( | |
| 220 | + | self: &Arc<Self>, | |
| 221 | + | host: &str, | |
| 222 | + | glob: &str, | |
| 223 | + | app: &str, | |
| 224 | + | version: &str, | |
| 225 | + | ) -> Result<()> { | |
| 226 | + | let dest = self.collect_dest(app, version); | |
| 227 | + | let dest_s = dest.to_string_lossy().into_owned(); | |
| 228 | + | // The glob reaches a remote login shell intact (that's what expands it), | |
| 229 | + | // so command metacharacters stay barred. Path/wildcard chars are fine. | |
| 230 | + | ensure_glob_safe(glob)?; | |
| 231 | + | std::fs::create_dir_all(&dest) | |
| 232 | + | .with_context(|| format!("creating collect dest {dest_s}"))?; | |
| 233 | + | // The SYNC transport, not the host's exec executor: artifacts move over | |
| 234 | + | // ssh/rsync even from an agent host, whose `/pull` is confined to a | |
| 235 | + | // narrow `pull_root` that deliberately excludes the repo checkout these | |
| 236 | + | // artifacts are built in (see `state::build_sync`). The daemon still | |
| 237 | + | // runs the transfer itself, as it always has. | |
| 238 | + | let sync = self.host_sync(host)?; | |
| 239 | + | let opts = SyncOpts::precompressed(); | |
| 240 | + | // Bounded by the collect step's deadline (rsync of a multi-GiB artifact | |
| 241 | + | // can wedge on a stalled transport) and interruptible on supersession. | |
| 242 | + | let dest_pull = dest.clone(); | |
| 243 | + | self.run_bounded(&format!("collect {glob} from `{host}`"), async move { | |
| 244 | + | sync.pull_glob(glob, &dest_pull, &opts).await | |
| 245 | + | }) | |
| 246 | + | .with_context(|| format!("collect {glob} from `{host}`"))?; | |
| 247 | + | // Assert the version and hash every collected file. This is where a | |
| 248 | + | // stale artifact is caught: a file whose name embeds a different version | |
| 249 | + | // fails the collect (rather than silently winning a later glob), and the | |
| 250 | + | // sha256 recorded here is what `publish` writes into the release ledger | |
| 251 | + | // and what the artifact record's manifest is built from. | |
| 252 | + | // | |
| 253 | + | // Recursive, and keyed by path relative to the collect dir. That is not | |
| 254 | + | // a preference: Sando's intake re-hashes the bundle with its own walker, | |
| 255 | + | // which recurses and keys the same way, and refuses a bundle whose bytes | |
| 256 | + | // do not match the manifest it was handed. A top-level `read_dir` keyed | |
| 257 | + | // by file name agrees with that walker for a flat directory and diverges | |
| 258 | + | // the moment a bundle carries a subdirectory — the honest artifact would | |
| 259 | + | // be refused for a manifest that omitted everything nested. The two | |
| 260 | + | // walkers have to be the same walk. See `bundle::digest_dir` in sando. | |
| 261 | + | for (rel, path) in | |
| 262 | + | collected_files(&dest).with_context(|| format!("listing collect dest {dest_s}"))? | |
| 263 | + | { | |
| 264 | + | // The version check stays on the file NAME rather than the relative | |
| 265 | + | // path: it is looking for a stale `app_1.2.3.AppImage` beside the | |
| 266 | + | // one this release built, and a directory component is not that. | |
| 267 | + | let name = path | |
| 268 | + | .file_name() | |
| 269 | + | .map_or_else(|| rel.clone(), |n| n.to_string_lossy().into_owned()); | |
| 270 | + | assert_artifact_version(&name, &self.version)?; | |
| 271 | + | let digest = sha256_file(&path)?; | |
| 272 | + | self.record_artifact_hash(rel, digest); | |
| 273 | + | } | |
| 274 | + | // Deposit at the archive path, so this target's bytes have one address | |
| 275 | + | // whichever host produced them. A no-op when no archive is configured. | |
| 276 | + | // | |
| 277 | + | // Inside `collect`, not after the recipe: a failure here fails the | |
| 278 | + | // collect step, before sign and publish, rather than putting a red mark | |
| 279 | + | // on a release that has already shipped. And it is a failure, not a | |
| 280 | + | // warning — a deposit that is quietly skipped leaves the archive path | |
| 281 | + | // wrong for exactly the release nobody was watching, which is the thing | |
| 282 | + | // having one address is for. | |
| 283 | + | let (cfg, app_id, version, target) = ( | |
| 284 | + | self.cfg.clone(), | |
| 285 | + | self.app.clone(), | |
| 286 | + | self.version.clone(), | |
| 287 | + | self.target, | |
| 288 | + | ); | |
| 289 | + | let dest_archive = dest.clone(); | |
| 290 | + | self.run_bounded("deposit in the archive", async move { | |
| 291 | + | crate::archive::deposit(&cfg, &dest_archive, &app_id, &version, target).await | |
| 292 | + | })?; | |
| 293 | + | // Best-effort size accounting for the event. | |
| 294 | + | events::emit( | |
| 295 | + | &self.events, | |
| 296 | + | Event::ArtifactCollected { | |
| 297 | + | app: self.app.clone(), | |
| 298 | + | target: self.target, | |
| 299 | + | path: dest_s, | |
| 300 | + | bytes: dir_size(&dest).unwrap_or(0), | |
| 301 | + | }, | |
| 302 | + | ); | |
| 303 | + | Ok(()) | |
| 304 | + | } | |
| 305 | + | } | |
| 306 | + | ||
| 307 | + | #[cfg(test)] | |
| 308 | + | mod tests { | |
| 309 | + | use super::*; | |
| 310 | + | ||
| 311 | + | /// A parsed version, for the cases below. | |
| 312 | + | fn ver(s: &str) -> Version { | |
| 313 | + | Version::parse(s).unwrap() | |
| 314 | + | } | |
| 315 | + | ||
| 316 | + | /// Build the shared cross-crate bundle fixture under `root`. | |
| 317 | + | /// | |
| 318 | + | /// A binary at the top and two files in a subdirectory: the shape a service | |
| 319 | + | /// that ships its migrations has, and the case a flat walk gets wrong. | |
| 320 | + | pub(crate) fn write_bundle_fixture(root: &Path) { | |
| 321 | + | std::fs::create_dir_all(root.join("migrations")).unwrap(); | |
| 322 | + | std::fs::write(root.join("pom"), b"binary-bytes").unwrap(); | |
| 323 | + | std::fs::write(root.join("migrations/001_init.sql"), b"create table a;").unwrap(); | |
| 324 | + | std::fs::write(root.join("migrations/002_next.sql"), b"alter table a;").unwrap(); | |
| 325 | + | } | |
| 326 | + | ||
| 327 | + | /// The manifest text the fixture must produce, in BOTH crates. | |
| 328 | + | /// | |
| 329 | + | /// Sando's `bundle::digest_dir` has the identical constant and the identical | |
| 330 | + | /// fixture. That is the whole point: bento writes this text into the artifact | |
| 331 | + | /// record, sando recomputes it from the bytes that arrive, and an artifact is | |
| 332 | + | /// refused when they differ. Two walks, one answer, pinned from both ends — | |
| 333 | + | /// if either crate's walk drifts, its own test fails and names the drift | |
| 334 | + | /// rather than a release failing intake for a bundle nothing is wrong with. | |
| 335 | + | pub(crate) const BUNDLE_FIXTURE_MANIFEST: &str = concat!( | |
| 336 | + | "e4c908e219c533fa7ad7ea1634398f9bf51637ba20717769ada545bab26d7368 migrations/001_init.sql\n", | |
| 337 | + | "b026fd51bae096b34672cefdb781b6585b13efb53bc301d50c305f422552a380 migrations/002_next.sql\n", | |
| 338 | + | "71227a7f160afca3fb3c39f448735886dda7bd366252580c2222fb87d4bb4d85 pom\n", | |
| 339 | + | ); | |
| 340 | + | ||
| 341 | + | /// The producer half of the contract above: what `collect` hashes, turned | |
| 342 | + | /// into a manifest, is exactly the text the verifier will recompute. | |
| 343 | + | /// | |
| 344 | + | /// Nested files are included and addressed by relative path. Before this, | |
| 345 | + | /// `collect` listed only the top level, so `migrations/` contributed nothing | |
| 346 | + | /// to the manifest while sando's walker hashed both files in it — and the | |
| 347 | + | /// honest bundle was refused for a manifest that had omitted them. | |
| 348 | + | #[test] | |
| 349 | + | fn a_collected_bundle_manifests_exactly_as_the_verifier_will_read_it() { | |
| 350 | + | let dir = tempfile::tempdir().unwrap(); | |
| 351 | + | write_bundle_fixture(dir.path()); | |
| 352 | + | ||
| 353 | + | let files = collected_files(dir.path()).unwrap(); | |
| 354 | + | assert_eq!( | |
| 355 | + | files.iter().map(|(r, _)| r.as_str()).collect::<Vec<_>>(), | |
| 356 | + | vec!["migrations/001_init.sql", "migrations/002_next.sql", "pom"], | |
| 357 | + | "recursive, relative, sorted" | |
| 358 | + | ); | |
| 359 | + | ||
| 360 | + | let hashes: Vec<(String, String)> = files | |
| 361 | + | .into_iter() | |
| 362 | + | .map(|(rel, path)| (rel, sha256_file(&path).unwrap())) | |
| 363 | + | .collect(); | |
| 364 | + | let manifest = ops_artifact::Manifest::new(hashes).unwrap(); | |
| 365 | + | assert_eq!(manifest.to_text(), BUNDLE_FIXTURE_MANIFEST); | |
| 366 | + | } | |
| 367 | + | ||
| 368 | + | /// A symlink is neither followed nor named. Following one would let bytes | |
| 369 | + | /// from outside the bundle into its identity; naming it would put a path in | |
| 370 | + | /// the manifest the verifier does not hash, which reads as a corrupt bundle. | |
| 371 | + | #[test] | |
| 372 | + | #[cfg(unix)] | |
| 373 | + | fn a_symlink_in_the_collect_dir_is_not_part_of_the_bundle() { | |
| 374 | + | let dir = tempfile::tempdir().unwrap(); | |
| 375 | + | write_bundle_fixture(dir.path()); | |
| 376 | + | let outside = dir.path().join("..").join("secret.env"); | |
| 377 | + | std::fs::write(&outside, b"TOKEN=1").ok(); | |
| 378 | + | std::os::unix::fs::symlink(&outside, dir.path().join("link.env")).unwrap(); | |
| 379 | + | ||
| 380 | + | let files = collected_files(dir.path()).unwrap(); | |
| 381 | + | assert!( | |
| 382 | + | !files.iter().any(|(rel, _)| rel.contains("link.env")), | |
| 383 | + | "{files:?}" | |
| 384 | + | ); | |
| 385 | + | } | |
| 386 | + | ||
| 387 | + | #[test] | |
| 388 | + | fn resolve_artifact_match_wants_exactly_one() { | |
| 389 | + | // Exactly one match: the path, trimmed of the listing's line noise. | |
| 390 | + | assert_eq!( | |
| 391 | + | resolve_artifact_match(" /d/App.AppImage \n", "*.AppImage", true).unwrap(), | |
| 392 | + | "/d/App.AppImage" | |
| 393 | + | ); | |
| 394 | + | } | |
| 395 | + | ||
| 396 | + | #[test] | |
| 397 | + | fn resolve_artifact_match_zero_depends_on_required() { | |
| 398 | + | // Required + zero matches is the case the old empty-string guard caught; | |
| 399 | + | // keep failing it. | |
| 400 | + | let err = resolve_artifact_match("", "*.dmg", true).unwrap_err(); | |
| 401 | + | assert!(err.to_string().contains("no artifact matched"), "{err}"); | |
| 402 | + | // Optional + zero matches resolves to empty (recipe skips the collect). | |
| 403 | + | assert_eq!( | |
| 404 | + | resolve_artifact_match("\n \n", "*.deb", false).unwrap(), | |
| 405 | + | "" | |
| 406 | + | ); | |
| 407 | + | } | |
| 408 | + | ||
| 409 | + | #[test] | |
| 410 | + | fn resolve_artifact_match_rejects_ambiguous() { | |
| 411 | + | // Two matches must throw rather than silently pick one — this is the | |
| 412 | + | // stale-newest-mtime hole the audit flagged. Applies even when optional. | |
| 413 | + | for required in [true, false] { | |
| 414 | + | let err = | |
| 415 | + | resolve_artifact_match("/d/old.deb\n/d/new.deb\n", "*.deb", required).unwrap_err(); | |
| 416 | + | let msg = err.to_string(); | |
| 417 | + | assert!(msg.contains("ambiguous"), "{msg}"); | |
| 418 | + | assert!(msg.contains("old.deb") && msg.contains("new.deb"), "{msg}"); | |
| 419 | + | } | |
| 420 | + | } | |
| 421 | + | ||
| 422 | + | #[test] | |
| 423 | + | fn ensure_glob_safe_allows_paths_bars_commands() { | |
| 424 | + | // Path and wildcard characters pass. | |
| 425 | + | assert!(ensure_glob_safe("~/Code/app/dist/*.AppImage").is_ok()); | |
| 426 | + | assert!(ensure_glob_safe("/t/App_1.2.3-x86_64.dmg").is_ok()); | |
| 427 | + | // A command substitution or separator does not. | |
| 428 | + | for bad in ["*.dmg; rm -rf /", "$(evil)", "a|b", "a b"] { | |
| 429 | + | assert!(ensure_glob_safe(bad).is_err(), "should reject {bad:?}"); | |
| 430 | + | } | |
| 431 | + | } | |
| 432 | + | ||
| 433 | + | #[test] | |
| 434 | + | fn versions_in_filename_extracts_only_real_semvers() { | |
| 435 | + | assert_eq!( | |
| 436 | + | versions_in_filename("GoingsOn_0.5.0_aarch64.dmg"), | |
| 437 | + | vec![ver("0.5.0")] | |
| 438 | + | ); | |
| 439 | + | assert_eq!( | |
| 440 | + | versions_in_filename("AudioFiles-0.4.0-x86_64.AppImage"), | |
| 441 | + | vec![ver("0.4.0")] | |
| 442 | + | ); | |
| 443 | + | // No three-part token ⇒ nothing (an updater manifest, a bare signature). | |
| 444 | + | assert!(versions_in_filename("latest.json").is_empty()); | |
| 445 | + | assert!(versions_in_filename("app.sig").is_empty()); | |
| 446 | + | } | |
| 447 | + | ||
| 448 | + | #[test] | |
| 449 | + | fn assert_artifact_version_rejects_a_stale_artifact() { | |
| 450 | + | // The 0.4.0 file sitting in the output dir against a 0.5.0 build. | |
| 451 | + | let err = | |
| 452 | + | assert_artifact_version("AudioFiles-0.4.0-x86_64.AppImage", &ver("0.5.0")).unwrap_err(); | |
| 453 | + | assert!(format!("{err:#}").contains("stale artifact"), "{err:#}"); | |
| 454 | + | // The matching version passes, and a versionless file is not asserted. | |
| 455 | + | assert_artifact_version("GoingsOn_0.5.0_aarch64.dmg", &ver("0.5.0")).unwrap(); | |
| 456 | + | assert_artifact_version("latest.json", &ver("0.5.0")).unwrap(); | |
| 457 | + | } | |
| 458 | + | ||
| 459 | + | #[test] | |
| 460 | + | fn sha256_file_is_lowercase_hex_of_contents() { | |
| 461 | + | let tmp = tempfile::tempdir().unwrap(); | |
| 462 | + | let f = tmp.path().join("a.bin"); | |
| 463 | + | std::fs::write(&f, b"abc").unwrap(); | |
| 464 | + | // Known SHA-256 of "abc". | |
| 465 | + | assert_eq!( | |
| 466 | + | sha256_file(&f).unwrap(), | |
| 467 | + | "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" | |
| 468 | + | ); | |
| 469 | + | } | |
| 470 | + | } |
| @@ -1,0 +1,228 @@ | |||
| 1 | + | //! Talking to crates.io: what is already published, and what the registry will | |
| 2 | + | //! refuse before `cargo publish` gets there. | |
| 3 | + | ||
| 4 | + | use anyhow::{Context as _, Result}; | |
| 5 | + | ||
| 6 | + | /// A crate's publish-relevant metadata, read from `cargo metadata`. | |
| 7 | + | #[derive(Debug, Clone)] | |
| 8 | + | pub(super) struct CrateMeta { | |
| 9 | + | pub name: String, | |
| 10 | + | pub version: String, | |
| 11 | + | pub repository: Option<String>, | |
| 12 | + | pub description: Option<String>, | |
| 13 | + | pub licensed: bool, | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | /// Parse the fields that matter for publishing out of `cargo metadata` JSON. | |
| 17 | + | pub(super) fn crate_meta_from_json(raw: &str) -> Result<CrateMeta> { | |
| 18 | + | let v: serde_json::Value = serde_json::from_str(raw).context("parsing cargo metadata")?; | |
| 19 | + | let p = v | |
| 20 | + | .get("packages") | |
| 21 | + | .and_then(|p| p.as_array()) | |
| 22 | + | .and_then(|a| a.first()) | |
| 23 | + | .context("cargo metadata reported no package")?; | |
| 24 | + | let str_field = |k: &str| { | |
| 25 | + | p.get(k) | |
| 26 | + | .and_then(|x| x.as_str()) | |
| 27 | + | .filter(|s| !s.is_empty()) | |
| 28 | + | .map(str::to_string) | |
| 29 | + | }; | |
| 30 | + | Ok(CrateMeta { | |
| 31 | + | name: str_field("name").context("package has no name")?, | |
| 32 | + | version: str_field("version").context("package has no version")?, | |
| 33 | + | repository: str_field("repository"), | |
| 34 | + | description: str_field("description"), | |
| 35 | + | licensed: str_field("license").is_some() || str_field("license_file").is_some(), | |
| 36 | + | }) | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | /// Everything wrong with a crate's metadata, as messages. Empty means publishable. | |
| 40 | + | /// | |
| 41 | + | /// Checks only what crates.io records permanently. A published version cannot | |
| 42 | + | /// be edited, only yanked, and yanking does not correct a wrong URL — so these | |
| 43 | + | /// are the last moment any of it can be fixed. | |
| 44 | + | pub(super) fn crate_publish_problems( | |
| 45 | + | meta: &CrateMeta, | |
| 46 | + | repo_clonable: bool, | |
| 47 | + | published: &[String], | |
| 48 | + | credentials_present: bool, | |
| 49 | + | ) -> Vec<String> { | |
| 50 | + | let mut out = Vec::new(); | |
| 51 | + | if !credentials_present { | |
| 52 | + | out.push( | |
| 53 | + | "no crates.io credentials on the publishing host: `cargo login` there first. \ | |
| 54 | + | Checked now rather than at the upload, so this fails in seconds instead of \ | |
| 55 | + | after a full build and verify." | |
| 56 | + | .to_string(), | |
| 57 | + | ); | |
| 58 | + | } | |
| 59 | + | match &meta.repository { | |
| 60 | + | None => out.push( | |
| 61 | + | "no `repository` field: the crates.io page will show no source link, permanently" | |
| 62 | + | .to_string(), | |
| 63 | + | ), | |
| 64 | + | Some(url) if !repo_clonable => out.push(format!( | |
| 65 | + | "`repository` is not publicly clonable: {url} \ | |
| 66 | + | (wrong URL, or the repo is private)" | |
| 67 | + | )), | |
| 68 | + | Some(_) => {} | |
| 69 | + | } | |
| 70 | + | if meta.description.is_none() { | |
| 71 | + | out.push("no `description`: crates.io requires one".to_string()); | |
| 72 | + | } | |
| 73 | + | if !meta.licensed { | |
| 74 | + | out.push("no `license` or `license-file`".to_string()); | |
| 75 | + | } | |
| 76 | + | if published.iter().any(|v| v == &meta.version) { | |
| 77 | + | out.push(format!( | |
| 78 | + | "version {} is already published; bump it", | |
| 79 | + | meta.version | |
| 80 | + | )); | |
| 81 | + | } | |
| 82 | + | out | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | /// Versions of `name` already on crates.io. A network failure yields an | |
| 86 | + | /// empty list: preflight then cannot claim a version is a duplicate, and | |
| 87 | + | /// `cargo publish` still refuses one, so the check degrades to advisory | |
| 88 | + | /// rather than blocking a release on registry availability. | |
| 89 | + | pub(super) fn published_versions(name: &str) -> Vec<String> { | |
| 90 | + | let url = format!("https://crates.io/api/v1/crates/{name}"); | |
| 91 | + | let Ok(out) = std::process::Command::new("curl") | |
| 92 | + | .args([ | |
| 93 | + | "-sS", | |
| 94 | + | "--max-time", | |
| 95 | + | "15", | |
| 96 | + | "-H", | |
| 97 | + | "User-Agent: bento-preflight", | |
| 98 | + | &url, | |
| 99 | + | ]) | |
| 100 | + | .output() | |
| 101 | + | else { | |
| 102 | + | return Vec::new(); | |
| 103 | + | }; | |
| 104 | + | let Ok(v) = serde_json::from_slice::<serde_json::Value>(&out.stdout) else { | |
| 105 | + | return Vec::new(); | |
| 106 | + | }; | |
| 107 | + | v.get("versions") | |
| 108 | + | .and_then(|x| x.as_array()) | |
| 109 | + | .map(|a| { | |
| 110 | + | a.iter() | |
| 111 | + | .filter_map(|x| x.get("num").and_then(|n| n.as_str()).map(str::to_string)) | |
| 112 | + | .collect() | |
| 113 | + | }) | |
| 114 | + | .unwrap_or_default() | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | #[cfg(test)] | |
| 118 | + | mod tests { | |
| 119 | + | use super::*; | |
| 120 | + | ||
| 121 | + | /// The two failures that actually shipped, as regression cases. | |
| 122 | + | #[test] | |
| 123 | + | fn preflight_catches_a_dead_repository_url() { | |
| 124 | + | // pter 0.1.0: repository pointed at a URL that does not exist. It | |
| 125 | + | // published clean and the link is now permanent for that version. | |
| 126 | + | let meta = CrateMeta { | |
| 127 | + | name: "pter".into(), | |
| 128 | + | version: "0.1.0".into(), | |
| 129 | + | repository: Some("https://github.com/maxjacobson/pter".into()), | |
| 130 | + | description: Some("d".into()), | |
| 131 | + | licensed: true, | |
| 132 | + | }; | |
| 133 | + | let problems = crate_publish_problems(&meta, false, &[], true); | |
| 134 | + | assert_eq!(problems.len(), 1, "{problems:?}"); | |
| 135 | + | assert!( | |
| 136 | + | problems[0].contains("not publicly clonable"), | |
| 137 | + | "{problems:?}" | |
| 138 | + | ); | |
| 139 | + | ||
| 140 | + | // Same metadata, reachable URL: nothing to report. | |
| 141 | + | assert!(crate_publish_problems(&meta, true, &[], true).is_empty()); | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | #[test] | |
| 145 | + | fn preflight_requires_the_fields_crates_io_bakes_in() { | |
| 146 | + | let bare = CrateMeta { | |
| 147 | + | name: "x".into(), | |
| 148 | + | version: "0.1.0".into(), | |
| 149 | + | repository: None, | |
| 150 | + | description: None, | |
| 151 | + | licensed: false, | |
| 152 | + | }; | |
| 153 | + | let problems = crate_publish_problems(&bare, false, &[], true); | |
| 154 | + | assert_eq!(problems.len(), 3, "{problems:?}"); | |
| 155 | + | assert!(problems.iter().any(|p| p.contains("repository"))); | |
| 156 | + | assert!(problems.iter().any(|p| p.contains("description"))); | |
| 157 | + | assert!(problems.iter().any(|p| p.contains("license"))); | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | #[test] | |
| 161 | + | fn preflight_rejects_republishing_the_same_version() { | |
| 162 | + | let meta = CrateMeta { | |
| 163 | + | name: "makeover".into(), | |
| 164 | + | version: "0.10.0".into(), | |
| 165 | + | repository: Some("https://git.sr.ht/~maxmj/makeover".into()), | |
| 166 | + | description: Some("d".into()), | |
| 167 | + | licensed: true, | |
| 168 | + | }; | |
| 169 | + | let problems = | |
| 170 | + | crate_publish_problems(&meta, true, &["0.9.0".into(), "0.10.0".into()], true); | |
| 171 | + | assert_eq!(problems.len(), 1, "{problems:?}"); | |
| 172 | + | assert!(problems[0].contains("already published"), "{problems:?}"); | |
| 173 | + | ||
| 174 | + | // An unreleased version against the same history is fine. | |
| 175 | + | let mut next = meta.clone(); | |
| 176 | + | next.version = "0.11.0".into(); | |
| 177 | + | assert!(crate_publish_problems(&next, true, &["0.10.0".into()], true).is_empty()); | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | /// Missing credentials must surface at preflight, not at the upload. The | |
| 181 | + | /// publish step is the irreversible one and runs last, after a full build | |
| 182 | + | /// and verify; discovering there that cargo cannot authenticate wastes the | |
| 183 | + | /// whole run. | |
| 184 | + | #[test] | |
| 185 | + | fn preflight_reports_missing_credentials_up_front() { | |
| 186 | + | let meta = CrateMeta { | |
| 187 | + | name: "makeover".into(), | |
| 188 | + | version: "0.11.0".into(), | |
| 189 | + | repository: Some("https://git.sr.ht/~maxmj/makeover".into()), | |
| 190 | + | description: Some("d".into()), | |
| 191 | + | licensed: true, | |
| 192 | + | }; | |
| 193 | + | // Metadata is perfect; only the token is absent. | |
| 194 | + | let problems = crate_publish_problems(&meta, true, &[], false); | |
| 195 | + | assert_eq!(problems.len(), 1, "{problems:?}"); | |
| 196 | + | assert!(problems[0].contains("credentials"), "{problems:?}"); | |
| 197 | + | assert!( | |
| 198 | + | problems[0].contains("cargo login"), | |
| 199 | + | "should say how to fix it" | |
| 200 | + | ); | |
| 201 | + | ||
| 202 | + | // Present: nothing to report. | |
| 203 | + | assert!(crate_publish_problems(&meta, true, &[], true).is_empty()); | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | #[test] | |
| 207 | + | fn crate_meta_reads_cargo_metadata_json() { | |
| 208 | + | let raw = r#"{"packages":[{"name":"makeover","version":"0.10.0", | |
| 209 | + | "repository":"https://git.sr.ht/~maxmj/makeover","description":"themes", | |
| 210 | + | "license":"MIT"}]}"#; | |
| 211 | + | let m = crate_meta_from_json(raw).unwrap(); | |
| 212 | + | assert_eq!(m.name, "makeover"); | |
| 213 | + | assert_eq!(m.version, "0.10.0"); | |
| 214 | + | assert!(m.licensed); | |
| 215 | + | assert_eq!( | |
| 216 | + | m.repository.as_deref(), | |
| 217 | + | Some("https://git.sr.ht/~maxmj/makeover") | |
| 218 | + | ); | |
| 219 | + | ||
| 220 | + | // license_file alone also counts as licensed; empty strings do not | |
| 221 | + | // count as present. | |
| 222 | + | let lf = r#"{"packages":[{"name":"x","version":"0.1.0","license":"", | |
| 223 | + | "license_file":"LICENSE","description":""}]}"#; | |
| 224 | + | let m = crate_meta_from_json(lf).unwrap(); | |
| 225 | + | assert!(m.licensed); | |
| 226 | + | assert!(m.description.is_none()); | |
| 227 | + | } | |
| 228 | + | } |
| @@ -1,0 +1,733 @@ | |||
| 1 | + | //! [`RecipeCtx`]: everything a recipe's host functions are handed, and the | |
| 2 | + | //! step lifecycle they drive. | |
| 3 | + | //! | |
| 4 | + | //! The four private run-state fields are reached from the sibling modules | |
| 5 | + | //! through the accessors at the bottom of this file, which hand out values and | |
| 6 | + | //! never the guard. | |
| 7 | + | ||
| 8 | + | use super::git::{ | |
| 9 | + | git_fetch_cmd, git_rev_parse_cmd, git_tag_exists_cmd, git_worktree_pin_cmd, | |
| 10 | + | worktree_failure_reason, | |
| 11 | + | }; | |
| 12 | + | use super::{StepState, action_for, default_step_budget}; | |
| 13 | + | use crate::config::Config; | |
| 14 | + | use crate::domain::{AppId, Status, Step, StepRunId, Target, Version}; | |
| 15 | + | use crate::events::{self, Event, EventTx}; | |
| 16 | + | use crate::ota::OtaRegistry; | |
| 17 | + | use crate::state::ExecutorMap; | |
| 18 | + | use crate::topology::{DeployTarget, Kind}; | |
| 19 | + | use anyhow::{Context as _, Result}; | |
| 20 | + | use ops_core::live_log::LiveLog; | |
| 21 | + | use ops_exec::{Action, Executor, Step as OpStep}; | |
| 22 | + | use sqlx::SqlitePool; | |
| 23 | + | use std::collections::HashMap; | |
| 24 | + | use std::path::PathBuf; | |
| 25 | + | use std::sync::atomic::{AtomicBool, Ordering}; | |
| 26 | + | use std::sync::{Arc, Mutex}; | |
| 27 | + | use tokio::runtime::Handle; | |
| 28 | + | use tokio::sync::Mutex as AsyncMutex; | |
| 29 | + | ||
| 30 | + | /// Everything a recipe's host functions need, shared (Arc) into each closure. | |
| 31 | + | pub struct RecipeCtx { | |
| 32 | + | pub app: AppId, | |
| 33 | + | pub version: Version, | |
| 34 | + | pub target: Target, | |
| 35 | + | /// Name of the host this target builds on (resolved from the topology by the | |
| 36 | + | /// runner). Recipes read it via `build_host()` so one per-platform recipe can | |
| 37 | + | /// dispatch to the right host across arches (linux x86_64 -> fw13, aarch64 -> | |
| 38 | + | /// astra) without hard-coding a host name. | |
| 39 | + | pub build_host: String, | |
| 40 | + | /// The build host's SSH destination (topology `ssh`), as opposed to its | |
| 41 | + | /// name. Deploy compares it against the service host's to tell "build and | |
| 42 | + | /// run on the same box" from "two hosts that need a transfer" — a question | |
| 43 | + | /// the host NAMES cannot answer, since a build host and a deploy | |
| 44 | + | /// destination are declared in different files and need not agree on one. | |
| 45 | + | pub build_host_ssh: String, | |
| 46 | + | /// This release's git tag, rendered from the app's `tag_format`. Held here | |
| 47 | + | /// rather than derived from the version because a repo holding several | |
| 48 | + | /// products spells it per product (`pom-v0.4.1`), and the recipe, the | |
| 49 | + | /// preflight barrier and the failure message all have to agree on it. | |
| 50 | + | pub tag: String, | |
| 51 | + | /// The app's default checkout path (topology `repo`, `~`-prefixed). Read it | |
| 52 | + | /// through [`RecipeCtx::repo_for`] for anything that runs on a build host; | |
| 53 | + | /// this field alone is the daemon-local answer. | |
| 54 | + | pub repo: String, | |
| 55 | + | /// Per-host checkout overrides (topology `repo_by_host`). Empty for every app | |
| 56 | + | /// that has not declared one, which is all of them but the Windows-shipping | |
| 57 | + | /// ones. | |
| 58 | + | pub repo_by_host: HashMap<String, String>, | |
| 59 | + | /// Cargo features this app's release builds enable (topology `features`). | |
| 60 | + | /// Recipes read it via `feature_flags()`. | |
| 61 | + | pub features: Vec<String>, | |
| 62 | + | /// App or library. Decides which capability a `verify` step is gated on; | |
| 63 | + | /// see `action_for`. | |
| 64 | + | pub kind: Kind, | |
| 65 | + | pub target_run_id: i64, | |
| 66 | + | /// Capability-scoped executor per build host. Recipe commands dispatch | |
| 67 | + | /// through these — the transport (local / ssh / in-session agent) and the | |
| 68 | + | /// capability gate are the executor's, not the engine's. | |
| 69 | + | pub execs: Arc<ExecutorMap>, | |
| 70 | + | /// Sync transport per build host, used only by `collect` to pull artifacts | |
| 71 | + | /// back. Never the agent, even for an agent host — see `state::build_sync`. | |
| 72 | + | /// Not an execution path. | |
| 73 | + | pub syncs: Arc<ExecutorMap>, | |
| 74 | + | /// Where this target installs, for a `kind = "service"` app. `None` for an | |
| 75 | + | /// app or a library, which makes every deploy host function fail with that | |
| 76 | + | /// as the reason rather than with a missing-host error. | |
| 77 | + | /// | |
| 78 | + | /// The runner resolves it from the app manifest's `[[deploy]]` table and | |
| 79 | + | /// registers its executor into `execs` under the destination's host string, | |
| 80 | + | /// so `sh_ok(deploy_host(), ...)` reaches the service host through the same | |
| 81 | + | /// capability gate as everything else. | |
| 82 | + | pub deploy: Option<DeployTarget>, | |
| 83 | + | pub pool: SqlitePool, | |
| 84 | + | pub events: EventTx, | |
| 85 | + | pub cfg: Arc<Config>, | |
| 86 | + | pub ota: Arc<OtaRegistry>, | |
| 87 | + | pub rt: Handle, | |
| 88 | + | current: Mutex<Option<StepState>>, | |
| 89 | + | /// Gatekeeper verdict recorded by `verify_gatekeeper`: `None` = never run, | |
| 90 | + | /// `Some(false)` = ran and rejected, `Some(true)` = accepted. `publish` | |
| 91 | + | /// requires `Some(true)` for a macOS/iOS artifact (the proof it is signed + | |
| 92 | + | /// notarized). | |
| 93 | + | gatekeeper_ok: Mutex<Option<bool>>, | |
| 94 | + | /// Steps finalized as `Failed` during this run. A non-empty ledger bars | |
| 95 | + | /// `publish` — an artifact is never shipped after a step failed, even if the | |
| 96 | + | /// recipe ignored the failure and ran on. | |
| 97 | + | failed_steps: Mutex<Vec<Step>>, | |
| 98 | + | /// Set when a newer build supersedes this run. Checked at step boundaries | |
| 99 | + | /// and before publish so a superseded recipe stops promptly rather than | |
| 100 | + | /// running to completion (the blocking Rhai body can't be `abort()`ed). | |
| 101 | + | cancel: Arc<AtomicBool>, | |
| 102 | + | /// The all-targets-green publish gate (topology `require_all_targets`). | |
| 103 | + | /// `Some(declared)` ⇒ `publish` refuses unless every one of `declared` has a | |
| 104 | + | /// successful latest run for this `(app, version)`. `None` ⇒ gate off, each | |
| 105 | + | /// target publishes independently. | |
| 106 | + | all_green_required: Option<Vec<Target>>, | |
| 107 | + | /// sha256 of each artifact hashed at `collect`, keyed by file name. `publish` | |
| 108 | + | /// reads it to record `releases.artifact_hash` for the bytes it ships, so the | |
| 109 | + | /// hash is the one computed when the artifact landed rather than a re-read | |
| 110 | + | /// that could see a different file. Absent ⇒ `publish` hashes on demand. | |
| 111 | + | artifact_hashes: Mutex<HashMap<String, String>>, | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | impl RecipeCtx { | |
| 115 | + | #[allow(clippy::too_many_arguments)] | |
| 116 | + | pub fn new( | |
| 117 | + | app: AppId, | |
| 118 | + | version: Version, | |
| 119 | + | target: Target, | |
| 120 | + | build_host: String, | |
| 121 | + | build_host_ssh: String, | |
| 122 | + | tag: String, | |
| 123 | + | repo: String, | |
| 124 | + | features: Vec<String>, | |
| 125 | + | kind: Kind, | |
| 126 | + | target_run_id: i64, | |
| 127 | + | execs: Arc<ExecutorMap>, | |
| 128 | + | syncs: Arc<ExecutorMap>, | |
| 129 | + | deploy: Option<DeployTarget>, | |
| 130 | + | pool: SqlitePool, | |
| 131 | + | events: EventTx, | |
| 132 | + | cfg: Arc<Config>, | |
| 133 | + | ota: Arc<OtaRegistry>, | |
| 134 | + | rt: Handle, | |
| 135 | + | cancel: Arc<AtomicBool>, | |
| 136 | + | all_green_required: Option<Vec<Target>>, | |
| 137 | + | ) -> Self { | |
| 138 | + | Self { | |
| 139 | + | app, | |
| 140 | + | version, | |
| 141 | + | target, | |
| 142 | + | build_host, | |
| 143 | + | build_host_ssh, | |
| 144 | + | tag, | |
| 145 | + | repo, | |
| 146 | + | repo_by_host: HashMap::new(), | |
| 147 | + | features, | |
| 148 | + | kind, | |
| 149 | + | target_run_id, | |
| 150 | + | execs, | |
| 151 | + | syncs, | |
| 152 | + | deploy, | |
| 153 | + | pool, | |
| 154 | + | events, | |
| 155 | + | cfg, | |
| 156 | + | ota, | |
| 157 | + | rt, | |
| 158 | + | current: Mutex::new(None), | |
| 159 | + | gatekeeper_ok: Mutex::new(None), | |
| 160 | + | failed_steps: Mutex::new(Vec::new()), | |
| 161 | + | cancel, | |
| 162 | + | all_green_required, | |
| 163 | + | artifact_hashes: Mutex::new(HashMap::new()), | |
| 164 | + | } | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | /// Declare the app's per-host checkout overrides (topology `repo_by_host`). | |
| 168 | + | /// | |
| 169 | + | /// Separate from `new` because it is empty for every app that has not opted | |
| 170 | + | /// in, and `new` already carries twenty arguments no test wants a | |
| 171 | + | /// twenty-first of. | |
| 172 | + | #[must_use] | |
| 173 | + | pub fn with_repo_by_host(mut self, repo_by_host: HashMap<String, String>) -> Self { | |
| 174 | + | self.repo_by_host = repo_by_host; | |
| 175 | + | self | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | /// Where this app is checked out on `host` (topology `AppConfig::repo_for`). | |
| 179 | + | /// | |
| 180 | + | /// Every git command a recipe or the engine runs on a build host goes | |
| 181 | + | /// through this. The bare `repo` field is the daemon-local path. | |
| 182 | + | pub fn repo_for(&self, host: &str) -> &str { | |
| 183 | + | self.repo_by_host | |
| 184 | + | .get(host) | |
| 185 | + | .map_or(self.repo.as_str(), String::as_str) | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | /// Whether a newer build has superseded this run. | |
| 189 | + | pub(super) fn is_cancelled(&self) -> bool { | |
| 190 | + | self.cancel.load(Ordering::SeqCst) | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | pub(super) fn now() -> String { | |
| 194 | + | chrono::Utc::now().to_rfc3339() | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | /// `<logs_root>/<app>/<version>/<target-with-slash-as-dash>/<step>.<run_id>.log`. | |
| 198 | + | /// | |
| 199 | + | /// The run id is in the filename because the rest of the key is not unique: | |
| 200 | + | /// re-running an app at a version it already built (a retry, or a rebuild of | |
| 201 | + | /// an already-published release) reopens the same path, and appending would | |
| 202 | + | /// show two runs' output with nothing marking the boundary. The | |
| 203 | + | /// step ledger records a run id per step, so keying the file on it makes a | |
| 204 | + | /// ledger row resolve to exactly one file and keeps the earlier run readable. | |
| 205 | + | fn log_path(&self, step: Step, run_id: StepRunId) -> PathBuf { | |
| 206 | + | self.log_dir() | |
| 207 | + | .join(format!("{}.{}.log", step.as_str(), run_id.0)) | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | /// `<logs_root>/<app>/<version>/<target-with-slash-as-dash>/`. | |
| 211 | + | fn log_dir(&self) -> PathBuf { | |
| 212 | + | let target_dir = self.target.to_string().replace('/', "-"); | |
| 213 | + | self.cfg | |
| 214 | + | .logs_root | |
| 215 | + | .join(self.app.as_str()) | |
| 216 | + | .join(self.version.to_string()) | |
| 217 | + | .join(target_dir) | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | /// Close the previous step (as `Ok`), open a new one: insert its DB row, | |
| 221 | + | /// open a live log whose chunks broadcast `StepLogChunk`, emit `StepStart`. | |
| 222 | + | pub(super) fn begin_step(self: &Arc<Self>, step: Step) -> Result<()> { | |
| 223 | + | anyhow::ensure!( | |
| 224 | + | !self.is_cancelled(), | |
| 225 | + | "build superseded by a newer request; aborting before `{}`", | |
| 226 | + | step.as_str() | |
| 227 | + | ); | |
| 228 | + | self.finish_step(Status::Ok)?; | |
| 229 | + | let me = self.clone(); | |
| 230 | + | let started = Self::now(); | |
| 231 | + | let started_for_header = started.clone(); | |
| 232 | + | let run_id = self.rt.block_on(async move { | |
| 233 | + | // The step row and the target's current_step pointer are one logical | |
| 234 | + | // state — write them atomically so a failure can't leave a `running` | |
| 235 | + | // step row while current_step still names the previous step. | |
| 236 | + | let mut tx = me.pool.begin().await.context("begin step tx")?; | |
| 237 | + | // log_ref names the run id, which only exists once the row does, so | |
| 238 | + | // the path is written back inside the same transaction rather than | |
| 239 | + | // guessed beforehand. | |
| 240 | + | let id: i64 = sqlx::query_scalar( | |
| 241 | + | "INSERT INTO step_runs (target_run_id, step, status, started_at) | |
| 242 | + | VALUES (?, ?, 'running', ?) RETURNING id", | |
| 243 | + | ) | |
| 244 | + | .bind(me.target_run_id) | |
| 245 | + | .bind(step.as_str()) | |
| 246 | + | .bind(&started) | |
| 247 | + | .fetch_one(&mut *tx) | |
| 248 | + | .await | |
| 249 | + | .context("insert step_run")?; | |
| 250 | + | let log_ref = me | |
| 251 | + | .log_path(step, StepRunId(id)) | |
| 252 | + | .to_string_lossy() | |
| 253 | + | .into_owned(); | |
| 254 | + | sqlx::query("UPDATE step_runs SET log_ref = ? WHERE id = ?") | |
| 255 | + | .bind(&log_ref) | |
| 256 | + | .bind(id) | |
| 257 | + | .execute(&mut *tx) | |
| 258 | + | .await | |
| 259 | + | .context("set step_run log_ref")?; | |
| 260 | + | sqlx::query("UPDATE target_runs SET current_step = ? WHERE id = ?") | |
| 261 | + | .bind(step.as_str()) | |
| 262 | + | .bind(me.target_run_id) | |
| 263 | + | .execute(&mut *tx) | |
| 264 | + | .await | |
| 265 | + | .context("update current_step")?; | |
| 266 | + | tx.commit().await.context("commit step tx")?; | |
| 267 | + | anyhow::Ok(StepRunId(id)) | |
| 268 | + | })?; | |
| 269 | + | ||
| 270 | + | // Live log: each chunk fans out as a StepLogChunk event keyed by run_id. | |
| 271 | + | let events = self.events.clone(); | |
| 272 | + | let cb_run_id = run_id; | |
| 273 | + | let mut log = self.rt.block_on(LiveLog::open( | |
| 274 | + | self.log_path(step, run_id), | |
| 275 | + | Box::new(move |seq, text| { | |
| 276 | + | events::emit( | |
| 277 | + | &events, | |
| 278 | + | Event::StepLogChunk { | |
| 279 | + | run_id: cb_run_id, | |
| 280 | + | seq, | |
| 281 | + | text: text.to_string(), | |
| 282 | + | }, | |
| 283 | + | ); | |
| 284 | + | }), | |
| 285 | + | )); | |
| 286 | + | ||
| 287 | + | events::emit( | |
| 288 | + | &self.events, | |
| 289 | + | Event::StepStart { | |
| 290 | + | run_id, | |
| 291 | + | app: self.app.clone(), | |
| 292 | + | version: self.version.clone(), | |
| 293 | + | target: self.target, | |
| 294 | + | step, | |
| 295 | + | }, | |
| 296 | + | ); | |
| 297 | + | ||
| 298 | + | // A log that names its own run: reading one tells you which ledger row | |
| 299 | + | // it belongs to without going back to the DB, and a file that somehow | |
| 300 | + | // does get appended to still shows where the second run began. Written | |
| 301 | + | // after `StepStart` so its chunk event cannot precede the step it | |
| 302 | + | // belongs to. | |
| 303 | + | let header = format!( | |
| 304 | + | "=== bento {app} {version} {target} step={step} run_id={run_id} started={started_for_header} ===\n", | |
| 305 | + | app = self.app.as_str(), | |
| 306 | + | version = self.version, | |
| 307 | + | target = self.target, | |
| 308 | + | step = step.as_str(), | |
| 309 | + | ); | |
| 310 | + | self.rt.block_on(async { | |
| 311 | + | use ops_core::remote::LogSink as _; | |
| 312 | + | log.write_chunk(header.as_bytes()).await; | |
| 313 | + | }); | |
| 314 | + | ||
| 315 | + | *self.current.lock().unwrap() = Some(StepState { | |
| 316 | + | run_id, | |
| 317 | + | step, | |
| 318 | + | log: Arc::new(AsyncMutex::new(log)), | |
| 319 | + | failed: false, | |
| 320 | + | deadline: std::time::Instant::now() + self.step_budget(step), | |
| 321 | + | }); | |
| 322 | + | Ok(()) | |
| 323 | + | } | |
| 324 | + | ||
| 325 | + | /// This build's budget for `step`: the `Config` override if set, else the | |
| 326 | + | /// per-kind default. | |
| 327 | + | fn step_budget(&self, step: Step) -> std::time::Duration { | |
| 328 | + | self.cfg | |
| 329 | + | .step_timeout_secs | |
| 330 | + | .map_or_else(|| default_step_budget(step), std::time::Duration::from_secs) | |
| 331 | + | } | |
| 332 | + | ||
| 333 | + | /// The current step's wall-clock deadline (or a default if no step is open, | |
| 334 | + | /// which only happens before the first `step()` — commands then run under an | |
| 335 | + | /// implicit `Build` step opened by `ensure_step`). | |
| 336 | + | fn step_deadline(&self) -> std::time::Instant { | |
| 337 | + | self.current.lock().unwrap().as_ref().map_or_else( | |
| 338 | + | || std::time::Instant::now() + self.step_budget(Step::Build), | |
| 339 | + | |s| s.deadline, | |
| 340 | + | ) | |
| 341 | + | } | |
| 342 | + | ||
| 343 | + | /// Drive `fut` on the runtime, but stop early on two conditions the recipe | |
| 344 | + | /// bodies otherwise cannot observe (they run synchronously on a blocking | |
| 345 | + | /// thread): the current step's deadline, and supersession by a newer build. | |
| 346 | + | /// Either turns into an error that fails the step and unwinds the recipe, so | |
| 347 | + | /// a wedged command cannot run unbounded and a superseded build stops | |
| 348 | + | /// mid-step instead of only at the next step boundary. | |
| 349 | + | pub(super) fn run_bounded<F, T>(&self, what: &str, fut: F) -> Result<T> | |
| 350 | + | where | |
| 351 | + | F: std::future::Future<Output = Result<T>>, | |
| 352 | + | { | |
| 353 | + | let deadline = self.step_deadline(); | |
| 354 | + | let cancel = self.cancel.clone(); | |
| 355 | + | self.rt.block_on(async move { | |
| 356 | + | tokio::pin!(fut); | |
| 357 | + | let watch = async { | |
| 358 | + | // Poll the cooperative cancel flag; the finalizer and a | |
| 359 | + | // superseding build both set it. Cheap next to a build step. | |
| 360 | + | while !cancel.load(Ordering::SeqCst) { | |
| 361 | + | tokio::time::sleep(std::time::Duration::from_millis(250)).await; | |
| 362 | + | } | |
| 363 | + | }; | |
| 364 | + | tokio::select! { | |
| 365 | + | r = &mut fut => r, | |
| 366 | + | () = tokio::time::sleep_until(deadline.into()) => { | |
| 367 | + | Err(anyhow::anyhow!("`{what}` exceeded its per-step deadline")) | |
| 368 | + | } | |
| 369 | + | () = watch => { | |
| 370 | + | Err(anyhow::anyhow!("build superseded by a newer request; aborting `{what}`")) | |
| 371 | + | } | |
| 372 | + | } | |
| 373 | + | }) | |
| 374 | + | } | |
| 375 | + | ||
| 376 | + | /// Flag the currently-open step as failed (no-op if none is open). Forces | |
| 377 | + | /// its recorded status to `Failed` at `finish_step` and adds it to the | |
| 378 | + | /// publish-barring ledger, even though the recipe kept running. | |
| 379 | + | pub(super) fn fail_current_step(&self) { | |
| 380 | + | if let Some(st) = self.current.lock().unwrap().as_mut() { | |
| 381 | + | st.failed = true; | |
| 382 | + | } | |
| 383 | + | } | |
| 384 | + | ||
| 385 | + | /// Finalize the open step (if any): close its log, stamp the DB row, emit | |
| 386 | + | /// `StepDone`. Idempotent when no step is open. A step flagged via | |
| 387 | + | /// `fail_current_step` is recorded `Failed` regardless of the requested | |
| 388 | + | /// status, and added to the ledger `publish` consults. | |
| 389 | + | pub fn finish_step(self: &Arc<Self>, status: Status) -> Result<()> { | |
| 390 | + | let st = self.current.lock().unwrap().take(); | |
| 391 | + | let Some(st) = st else { return Ok(()) }; | |
| 392 | + | let status = if st.failed { Status::Failed } else { status }; | |
| 393 | + | if status == Status::Failed { | |
| 394 | + | self.failed_steps.lock().unwrap().push(st.step); | |
| 395 | + | } | |
| 396 | + | let me = self.clone(); | |
| 397 | + | self.rt.block_on(async move { | |
| 398 | + | // Drop all log refs so the sink can be owned + flushed. | |
| 399 | + | if let Ok(m) = Arc::try_unwrap(st.log) { | |
| 400 | + | m.into_inner().close().await; | |
| 401 | + | } | |
| 402 | + | if let Err(e) = sqlx::query( | |
| 403 | + | "UPDATE step_runs SET status = ?, finished_at = ? WHERE id = ?", | |
| 404 | + | ) | |
| 405 | + | .bind(status.as_str()) | |
| 406 | + | .bind(Self::now()) | |
| 407 | + | .bind(st.run_id.0) | |
| 408 | + | .execute(&me.pool) | |
| 409 | + | .await | |
| 410 | + | { | |
| 411 | + | tracing::error!(step = st.step.as_str(), error = %e, "could not stamp step_run status"); | |
| 412 | + | } | |
| 413 | + | }); | |
| 414 | + | events::emit( | |
| 415 | + | &self.events, | |
| 416 | + | Event::StepDone { | |
| 417 | + | run_id: st.run_id, | |
| 418 | + | app: self.app.clone(), | |
| 419 | + | target: self.target, | |
| 420 | + | step: st.step, | |
| 421 | + | status, | |
| 422 | + | }, | |
| 423 | + | ); | |
| 424 | + | Ok(()) | |
| 425 | + | } | |
| 426 | + | ||
| 427 | + | /// Ensure a step is open; default to `Build` if a recipe runs a command | |
| 428 | + | /// before declaring one. | |
| 429 | + | pub(super) fn ensure_step(self: &Arc<Self>) -> Result<Arc<AsyncMutex<LiveLog>>> { | |
| 430 | + | if self.current.lock().unwrap().is_none() { | |
| 431 | + | self.begin_step(Step::Build)?; | |
| 432 | + | } | |
| 433 | + | Ok(self.current.lock().unwrap().as_ref().unwrap().log.clone()) | |
| 434 | + | } | |
| 435 | + | ||
| 436 | + | /// The step currently open, or `Build` as a default for failure | |
| 437 | + | /// attribution before any step was declared. | |
| 438 | + | pub fn current_step(&self) -> Step { | |
| 439 | + | self.current | |
| 440 | + | .lock() | |
| 441 | + | .unwrap() | |
| 442 | + | .as_ref() | |
| 443 | + | .map_or(Step::Build, |s| s.step) | |
| 444 | + | } | |
| 445 | + | ||
| 446 | + | /// The capability-scoped executor for `name`, or an error if the host isn't | |
| 447 | + | /// in the topology. | |
| 448 | + | pub(super) fn exec(&self, name: &str) -> Result<Arc<dyn ops_exec::Executor>> { | |
| 449 | + | self.execs | |
| 450 | + | .get(name) | |
| 451 | + | .cloned() | |
| 452 | + | .ok_or_else(|| anyhow::anyhow!("unknown build host `{name}` (not in topology)")) | |
| 453 | + | } | |
| 454 | + | ||
| 455 | + | /// The transport that moves artifacts off `name`, for `collect`'s remote | |
| 456 | + | /// scp source. Distinct from `RecipeCtx::exec`'s executor: an agent host | |
| 457 | + | /// signs over `AgentRpc` | |
| 458 | + | /// but is collected from over ssh (`state::build_sync`). | |
| 459 | + | pub(super) fn host_sync(&self, name: &str) -> Result<Arc<dyn Executor>> { | |
| 460 | + | self.syncs | |
| 461 | + | .get(name) | |
| 462 | + | .cloned() | |
| 463 | + | .ok_or_else(|| anyhow::anyhow!("unknown build host `{name}` (not in topology)")) | |
| 464 | + | } | |
| 465 | + | ||
| 466 | + | /// Run `cmd` on `host` through its capability-scoped executor, streaming into | |
| 467 | + | /// the current step's log. The command's [`Action`] is derived from the open | |
| 468 | + | /// step (see [`action_for`]) and gated at the transport before dispatch — so a | |
| 469 | + | /// `build` step on a host without the `build` grant is denied, and the macOS | |
| 470 | + | /// sign steps ride the in-session `AgentRpc` transport automatically. Returns | |
| 471 | + | /// exit code + a tail of stdout for the recipe to branch on. | |
| 472 | + | pub(super) fn run(self: &Arc<Self>, host: &str, cmd: &str) -> Result<(i32, String)> { | |
| 473 | + | // The service host is addressed as a service host whatever step is open. | |
| 474 | + | // Deriving the action from the step is right for a build host, where the | |
| 475 | + | // step IS the work; on a service host it would ask for `build` during a | |
| 476 | + | // `verify` and be denied for a reason unrelated to what was attempted. | |
| 477 | + | let action = match &self.deploy { | |
| 478 | + | Some(d) if d.host == host => Action::Deploy, | |
| 479 | + | _ => action_for(self.current_step(), self.kind), | |
| 480 | + | }; | |
| 481 | + | self.run_as(host, cmd, action) | |
| 482 | + | } | |
| 483 | + | ||
| 484 | + | /// `run`, with the [`Action`] stated rather than resolved. Used where the | |
| 485 | + | /// caller already knows which plane it is on. | |
| 486 | + | fn run_as(self: &Arc<Self>, host: &str, cmd: &str, action: Action) -> Result<(i32, String)> { | |
| 487 | + | let sink = self.ensure_step()?; | |
| 488 | + | let exec = self.exec(host)?; | |
| 489 | + | let cur = self.current_step(); | |
| 490 | + | let step = OpStep::shell(action, cmd.to_string()); | |
| 491 | + | // Echo the command before running it. Without this a log says what | |
| 492 | + | // happened but not what was asked, and a gate that prints nothing when | |
| 493 | + | // it passes (`cargo fmt --all --check`) is indistinguishable from a gate | |
| 494 | + | // that never ran. | |
| 495 | + | let echo = format!("$ [{host}] {cmd}\n"); | |
| 496 | + | // Bounded by the step's deadline and interruptible on supersession, so a | |
| 497 | + | // hung command fails its step instead of running unbounded, and a | |
| 498 | + | // superseded build stops mid-step rather than only at the next boundary. | |
| 499 | + | let label = format!("{cur} command on `{host}`"); | |
| 500 | + | let out = self.run_bounded(&label, async move { |
Lines truncated
| @@ -1,0 +1,489 @@ | |||
| 1 | + | //! Deploying a service target, and the glibc floor that says whether the host | |
| 2 | + | //! can run what was built. | |
| 3 | + | ||
| 4 | + | use super::DEPLOY_STAGING_ROOT; | |
| 5 | + | use super::RecipeCtx; | |
| 6 | + | use super::collect::ensure_glob_safe; | |
| 7 | + | use super::git::expand_tilde; | |
| 8 | + | use crate::topology::{DeployTarget, Kind}; | |
| 9 | + | use anyhow::{Context as _, Result}; | |
| 10 | + | use ops_exec::{Action, SyncOpts}; | |
| 11 | + | use std::path::{Path, PathBuf}; | |
| 12 | + | use std::sync::Arc; | |
| 13 | + | ||
| 14 | + | /// Highest `GLIBC_x.y` version referenced by a built binary, and the glibc a | |
| 15 | + | /// host actually has, both parsed from the text the commands print. | |
| 16 | + | /// | |
| 17 | + | /// Native-per-architecture builds remove the cross-compile hazard `deploy.sh` | |
| 18 | + | /// was written against, but not this one: fw13 tracks a newer glibc than the | |
| 19 | + | /// Ubuntu 24.04 box in Hetzner, so a binary built here can reference a symbol | |
| 20 | + | /// version that box does not have and fail at exec — after the unit has already | |
| 21 | + | /// been restarted onto it. Comparing the two before the install is what makes | |
| 22 | + | /// that a failed step instead of a downed service. | |
| 23 | + | pub(super) fn max_glibc_symbol(objdump_out: &str) -> Option<(u64, u64)> { | |
| 24 | + | objdump_out | |
| 25 | + | .split(|c: char| !(c.is_ascii_digit() || c == '.' || c == '_' || c.is_ascii_alphabetic())) | |
| 26 | + | .filter_map(|tok| tok.strip_prefix("GLIBC_")) | |
| 27 | + | .filter_map(parse_glibc_version) | |
| 28 | + | .max() | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | /// Parse `2.39` (or `2.39.1`, keeping major/minor) into a comparable pair. | |
| 32 | + | pub(super) fn parse_glibc_version(s: &str) -> Option<(u64, u64)> { | |
| 33 | + | let mut parts = s.split('.'); | |
| 34 | + | let major = parts.next()?.parse().ok()?; | |
| 35 | + | let minor = parts.next()?.parse().ok()?; | |
| 36 | + | Some((major, minor)) | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | /// The glibc version out of `ldd --version`'s first line, whose tail is the | |
| 40 | + | /// version however the distro decorates the rest (`ldd (Ubuntu GLIBC | |
| 41 | + | /// 2.39-0ubuntu8.8) 2.39`). | |
| 42 | + | pub(super) fn glibc_from_ldd(ldd_out: &str) -> Option<(u64, u64)> { | |
| 43 | + | let first = ldd_out.lines().find(|l| !l.trim().is_empty())?; | |
| 44 | + | parse_glibc_version(first.split_whitespace().last()?) | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | impl RecipeCtx { | |
| 48 | + | /// This target's install destination, or an error naming why there is none. | |
| 49 | + | pub(super) fn deploy_target(&self) -> Result<&DeployTarget> { | |
| 50 | + | self.deploy.as_ref().ok_or_else(|| { | |
| 51 | + | anyhow::anyhow!( | |
| 52 | + | "no deploy destination for {} {}: the app is `kind = \"{}\"`, and only a \ | |
| 53 | + | service declares [[deploy]] entries", | |
| 54 | + | self.app, | |
| 55 | + | self.target, | |
| 56 | + | match self.kind { | |
| 57 | + | Kind::App => "app", | |
| 58 | + | Kind::Library => "library", | |
| 59 | + | Kind::Service => "service", | |
| 60 | + | } | |
| 61 | + | ) | |
| 62 | + | }) | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | /// Compare the built binary's glibc requirement against the service host's. | |
| 66 | + | /// Returns the two versions for the recipe to log. | |
| 67 | + | pub(super) fn glibc_check(self: &Arc<Self>, binary: &str) -> Result<(String, String)> { | |
| 68 | + | let d = self.deploy_target()?.clone(); | |
| 69 | + | // `objdump -T` on the build host; no symbols at all (a static binary) | |
| 70 | + | // means nothing to check, which is a pass rather than a failure. | |
| 71 | + | let (code, out) = self.run( | |
| 72 | + | &self.build_host.clone(), | |
| 73 | + | &format!( | |
| 74 | + | "objdump -T {binary} 2>/dev/null | grep -o 'GLIBC_[0-9.]*' | sort -uV || true" | |
| 75 | + | ), | |
| 76 | + | )?; | |
| 77 | + | anyhow::ensure!(code == 0, "reading glibc symbols from {binary} failed"); | |
| 78 | + | let Some(needs) = max_glibc_symbol(&out) else { | |
| 79 | + | return Ok(("none".into(), "n/a".into())); | |
| 80 | + | }; | |
| 81 | + | let (code, ldd) = self.run(&d.host, "ldd --version")?; | |
| 82 | + | anyhow::ensure!( | |
| 83 | + | code == 0, | |
| 84 | + | "could not read glibc version on service host `{}`", | |
| 85 | + | d.host | |
| 86 | + | ); | |
| 87 | + | let has = glibc_from_ldd(&ldd).ok_or_else(|| { | |
| 88 | + | anyhow::anyhow!( | |
| 89 | + | "could not parse glibc version from `ldd --version` on `{}`", | |
| 90 | + | d.host | |
| 91 | + | ) | |
| 92 | + | })?; | |
| 93 | + | anyhow::ensure!( | |
| 94 | + | needs <= has, | |
| 95 | + | "binary needs glibc {}.{} but `{}` has {}.{} — it would fail to exec after the \ | |
| 96 | + | unit restarted onto it. Build on a host no newer than the service host.", | |
| 97 | + | needs.0, | |
| 98 | + | needs.1, | |
| 99 | + | d.host, | |
| 100 | + | has.0, | |
| 101 | + | has.1, | |
| 102 | + | ); | |
| 103 | + | Ok(( | |
| 104 | + | format!("{}.{}", needs.0, needs.1), | |
| 105 | + | format!("{}.{}", has.0, has.1), | |
| 106 | + | )) | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | /// Install `binary` (a path on the BUILD host) onto the service host and | |
| 110 | + | /// restart its unit, via the privileged installer the host holds a scoped | |
| 111 | + | /// sudo grant for. | |
| 112 | + | /// | |
| 113 | + | /// Bento never runs the install itself. It stages the bytes and calls a | |
| 114 | + | /// root script whose arguments are re-checked on the far side — the same | |
| 115 | + | /// shape as Sando's `install-companion.sh`, and for the same reason: the | |
| 116 | + | /// sudoers grant is then ONE auditable script rather than a broad | |
| 117 | + | /// `install`+`systemctl` grant on a production box. | |
| 118 | + | /// | |
| 119 | + | /// Only the binary moves. Config is deliberately untouched: pom's | |
| 120 | + | /// `pom-astra.toml` / `pom-hetzner.toml` differ per instance, and prod's | |
| 121 | + | /// carried a `[targets.mnw.tests]` block the repo did not have. A deploy | |
| 122 | + | /// that copies config over is how that block gets silently deleted. | |
| 123 | + | pub(super) fn deploy(self: &Arc<Self>, binary: &str) -> Result<String> { | |
| 124 | + | anyhow::ensure!( | |
| 125 | + | !self.is_cancelled(), | |
| 126 | + | "build superseded by a newer request; refusing to deploy" | |
| 127 | + | ); | |
| 128 | + | // A failed earlier step bars a deploy exactly as it bars a publish. An | |
| 129 | + | // artifact that failed its gates must not reach a production host just | |
| 130 | + | // because the recipe kept running. | |
| 131 | + | let failed = self.failed_steps_snapshot(); | |
| 132 | + | anyhow::ensure!( | |
| 133 | + | failed.is_empty(), | |
| 134 | + | "refusing to deploy {} {}: {} failed earlier in this run", | |
| 135 | + | self.app, | |
| 136 | + | self.version, | |
| 137 | + | failed | |
| 138 | + | .iter() | |
| 139 | + | .map(ToString::to_string) | |
| 140 | + | .collect::<Vec<_>>() | |
| 141 | + | .join(", "), | |
| 142 | + | ); | |
| 143 | + | let d = self.deploy_target()?.clone(); | |
| 144 | + | ensure_glob_safe(binary)?; | |
| 145 | + | ||
| 146 | + | // Stage under a fixed root the installer also insists on, so "what was | |
| 147 | + | // checked" and "what is installed" cannot drift apart. | |
| 148 | + | let staged = format!("{DEPLOY_STAGING_ROOT}/{}", self.app); | |
| 149 | + | let staged_bin = format!("{staged}/{}", self.app); | |
| 150 | + | let deploy_exec = self.exec(&d.host)?; | |
| 151 | + | anyhow::ensure!( | |
| 152 | + | deploy_exec.capabilities().permits(&Action::Deploy), | |
| 153 | + | "service host `{}` is not granted the `deploy` capability", | |
| 154 | + | d.host | |
| 155 | + | ); | |
| 156 | + | ||
| 157 | + | self.run_ok(&d.host, &format!("mkdir -p {staged}"))?; | |
| 158 | + | if self.build_host_ssh == d.host { | |
| 159 | + | // Same box: the binary is already there. Routing it through the | |
| 160 | + | // daemon would be two transfers to end up where it started. This is | |
| 161 | + | // pom's aarch64 leg — astra builds it and astra runs it. | |
| 162 | + | self.run_ok(&d.host, &format!("cp -f {binary} {staged_bin}"))?; | |
| 163 | + | } else { | |
| 164 | + | // Build host -> daemon -> service host. Two hops because an executor | |
| 165 | + | // reaches one host; a direct host-to-host transport would mean the | |
| 166 | + | // build host holding a credential for the production box. | |
| 167 | + | let tmp = tempfile::tempdir().context("staging dir for deploy")?; | |
| 168 | + | let local = tmp.path().join(self.app.as_str()); | |
| 169 | + | self.pull_for_deploy(binary, &local)?; | |
| 170 | + | let (dest, opts) = (PathBuf::from(&staged), SyncOpts::default()); | |
| 171 | + | let dir = tmp.path().to_path_buf(); | |
| 172 | + | self.run_bounded(&format!("stage {} on `{}`", self.app, d.host), async move { | |
| 173 | + | deploy_exec.push_dir(&dir, &dest, &opts).await | |
| 174 | + | }) | |
| 175 | + | .with_context(|| format!("staging {} onto `{}`", self.app, d.host))?; | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | // The privileged half. Every argument is re-validated by the script, | |
| 179 | + | // which is the thing actually holding the sudo grant. | |
| 180 | + | self.run_ok( | |
| 181 | + | &d.host, | |
| 182 | + | &format!( | |
| 183 | + | "{} {staged_bin} {} {}", | |
| 184 | + | self.cfg.deploy_installer, d.install_path, d.service | |
| 185 | + | ), | |
| 186 | + | )?; | |
| 187 | + | Ok(format!( | |
| 188 | + | "{} {} installed at {} on `{}`; {} restarted", | |
| 189 | + | self.app, self.version, d.install_path, d.host, d.service | |
| 190 | + | )) | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | /// Fetch one file off a host into a daemon-local path for re-pushing. | |
| 194 | + | /// | |
| 195 | + | /// A local build host is read directly: `fw13` is the daemon's own box, so | |
| 196 | + | /// the file is already on this filesystem. Routing it through the | |
| 197 | + | /// artifact-pull gate instead would demand a `pull_root` covering every repo | |
| 198 | + | /// a service could be built in — today that is `~/Code/Apps`, and pom lives | |
| 199 | + | /// in `~/Code/MNW`. Widening it to `~/Code` would put `_private`, the | |
| 200 | + | /// secrets root, inside the collectable tree. This is pom's x86_64 leg. | |
| 201 | + | fn pull_for_deploy(self: &Arc<Self>, remote: &str, local: &Path) -> Result<()> { | |
| 202 | + | let host = self.build_host.clone(); | |
| 203 | + | let remote_path = expand_tilde(remote); | |
| 204 | + | if self.build_host_ssh == "local" || self.build_host_ssh.is_empty() { | |
| 205 | + | std::fs::copy(&remote_path, local).with_context(|| { | |
| 206 | + | format!("staging {} from the daemon host", remote_path.display()) | |
| 207 | + | })?; | |
| 208 | + | return Ok(()); | |
| 209 | + | } | |
| 210 | + | let sync = self.host_sync(&host)?; | |
| 211 | + | let (src, dst, opts) = (remote_path, local.to_path_buf(), SyncOpts::default()); | |
| 212 | + | self.run_bounded(&format!("fetch {remote} from `{host}`"), async move { | |
| 213 | + | sync.pull_file(&src, &dst, &opts).await | |
| 214 | + | }) | |
| 215 | + | .with_context(|| format!("fetching {remote} from `{host}` to deploy")) | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | /// `run`, failing the step on a non-zero exit. The Rust-side twin of the | |
| 219 | + | /// recipe's `sh_ok`, for commands the deploy machinery issues itself. | |
| 220 | + | fn run_ok(self: &Arc<Self>, host: &str, cmd: &str) -> Result<String> { | |
| 221 | + | let (code, tail) = self.run(host, cmd)?; | |
| 222 | + | if code != 0 { | |
| 223 | + | self.fail_current_step(); | |
| 224 | + | anyhow::bail!("command on `{host}` exited {code}: {cmd}\n{tail}"); | |
| 225 | + | } | |
| 226 | + | Ok(tail) | |
| 227 | + | } | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | #[cfg(test)] | |
| 231 | + | mod tests { | |
| 232 | + | use super::super::action_for; | |
| 233 | + | use super::super::build_engine; | |
| 234 | + | use super::*; | |
| 235 | + | use crate::config::Config; | |
| 236 | + | use crate::domain::{AppId, Status, Step, Version}; | |
| 237 | + | use crate::ota::OtaRegistry; | |
| 238 | + | use std::sync::atomic::AtomicBool; | |
| 239 | + | ||
| 240 | + | /// The comparison that decides whether a binary can exec on the box that is | |
| 241 | + | /// about to be restarted onto it. Both sides are parsed out of text a tool | |
| 242 | + | /// printed, so both parsers are worth pinning: fw13 tracks a newer glibc | |
| 243 | + | /// than the Ubuntu 24.04 host in Hetzner, and getting this backwards means a | |
| 244 | + | /// dead unit rather than a failed step. | |
| 245 | + | #[test] | |
| 246 | + | fn glibc_versions_parse_from_what_the_tools_actually_print() { | |
| 247 | + | // `objdump -T | grep -o 'GLIBC_[0-9.]*'` output: highest wins, and the | |
| 248 | + | // comparison is numeric (2.9 must not beat 2.34 lexically). | |
| 249 | + | let objdump = "GLIBC_2.2.5\nGLIBC_2.34\nGLIBC_2.9\nGLIBC_2.17\n"; | |
| 250 | + | assert_eq!(max_glibc_symbol(objdump), Some((2, 34))); | |
| 251 | + | // A static binary references none: nothing to check. | |
| 252 | + | assert_eq!(max_glibc_symbol(""), None); | |
| 253 | + | ||
| 254 | + | // `ldd --version` first line, however the distro decorates it. | |
| 255 | + | assert_eq!( | |
| 256 | + | glibc_from_ldd("ldd (Ubuntu GLIBC 2.39-0ubuntu8.8) 2.39\nCopyright...\n"), | |
| 257 | + | Some((2, 39)) | |
| 258 | + | ); | |
| 259 | + | assert_eq!( | |
| 260 | + | glibc_from_ldd("ldd (GNU libc) 2.41\nCopyright (C) 2025\n"), | |
| 261 | + | Some((2, 41)) | |
| 262 | + | ); | |
| 263 | + | assert_eq!(glibc_from_ldd(""), None); | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | /// A binary needing MORE than the host has is the failure this check exists | |
| 267 | + | /// for; equal and less are both fine (glibc symbol versioning is backward | |
| 268 | + | /// compatible, so an older requirement runs on a newer host). | |
| 269 | + | #[test] | |
| 270 | + | fn glibc_requirement_is_satisfied_by_equal_or_newer_only() { | |
| 271 | + | let needs = max_glibc_symbol("GLIBC_2.41").unwrap(); | |
| 272 | + | assert!(needs > glibc_from_ldd("ldd (Ubuntu GLIBC 2.39) 2.39").unwrap()); | |
| 273 | + | assert!(needs <= glibc_from_ldd("ldd (GNU libc) 2.41").unwrap()); | |
| 274 | + | assert!(needs <= glibc_from_ldd("ldd (GNU libc) 2.42").unwrap()); | |
| 275 | + | assert!(needs <= glibc_from_ldd("ldd (GNU libc) 3.0").unwrap()); | |
| 276 | + | } | |
| 277 | + | ||
| 278 | + | /// Every deploy host function fails with the app's KIND as the reason when | |
| 279 | + | /// there is no destination, rather than with a missing-host error from | |
| 280 | + | /// somewhere deeper. A recipe calling `deploy()` on a library is a recipe | |
| 281 | + | /// written against the wrong kind, and the message should say so. | |
| 282 | + | #[tokio::test] | |
| 283 | + | async fn deploy_host_fns_explain_a_missing_destination_by_kind() { | |
| 284 | + | let dir = tempfile::tempdir().unwrap(); | |
| 285 | + | let cfg = Arc::new(Config::for_tests(dir.path())); | |
| 286 | + | let pool = crate::db::open(&cfg.db_path).await.unwrap(); | |
| 287 | + | let ctx = Arc::new(RecipeCtx::new( | |
| 288 | + | AppId::new("demo"), | |
| 289 | + | Version::parse("0.1.0").unwrap(), | |
| 290 | + | "linux/x86_64".parse().unwrap(), | |
| 291 | + | "fw13".into(), | |
| 292 | + | "local".into(), | |
| 293 | + | "v0.1.0".into(), | |
| 294 | + | "/tmp".into(), | |
| 295 | + | vec![], | |
| 296 | + | Kind::Library, | |
| 297 | + | 1, | |
| 298 | + | Arc::new(std::collections::HashMap::new()), | |
| 299 | + | Arc::new(std::collections::HashMap::new()), | |
| 300 | + | None, | |
| 301 | + | pool, | |
| 302 | + | crate::events::channel(), | |
| 303 | + | cfg, | |
| 304 | + | Arc::new(OtaRegistry::standard("https://makenot.work")), | |
| 305 | + | tokio::runtime::Handle::current(), | |
| 306 | + | Arc::new(AtomicBool::new(false)), | |
| 307 | + | None, | |
| 308 | + | )); | |
| 309 | + | let engine = build_engine(&ctx); | |
| 310 | + | for call in [ | |
| 311 | + | "deploy_host()", | |
| 312 | + | "service_name()", | |
| 313 | + | "install_path()", | |
| 314 | + | "health_url()", | |
| 315 | + | r#"deploy("/tmp/x")"#, | |
| 316 | + | ] { | |
| 317 | + | let err = engine.eval::<String>(call).unwrap_err().to_string(); | |
| 318 | + | assert!( | |
| 319 | + | err.contains("library") && err.contains("no deploy destination"), | |
| 320 | + | "`{call}` must fail on the kind, got: {err}" | |
| 321 | + | ); | |
| 322 | + | } | |
| 323 | + | } | |
| 324 | + | ||
| 325 | + | /// A service host is addressed on the DEPLOY plane whatever step is open. | |
| 326 | + | /// | |
| 327 | + | /// The subtle one. Actions are normally derived from the step, which is | |
| 328 | + | /// right for a build host — the step is what that host is being asked to do. | |
| 329 | + | /// A service host is granted `deploy`/`restart` and must never be granted | |
| 330 | + | /// `build`, so the same rule would have `glibc_check` ask it for `build` | |
| 331 | + | /// during a `verify` step and get denied for a reason unrelated to what was | |
| 332 | + | /// attempted. `verify` is the step that check belongs in, so without this | |
| 333 | + | /// routing the glibc gate cannot run at all. | |
| 334 | + | #[tokio::test] | |
| 335 | + | async fn a_service_host_is_addressed_on_the_deploy_plane_in_any_step() { | |
| 336 | + | let dir = tempfile::tempdir().unwrap(); | |
| 337 | + | let cfg = Arc::new(Config::for_tests(dir.path())); | |
| 338 | + | let pool = crate::db::open(&cfg.db_path).await.unwrap(); | |
| 339 | + | sqlx::query( | |
| 340 | + | "INSERT INTO builds (id, app, version, status, created_at) \ | |
| 341 | + | VALUES (1, 'demo', '0.1.0', 'running', '2026-07-30T00:00:00Z')", | |
| 342 | + | ) | |
| 343 | + | .execute(&pool) | |
| 344 | + | .await | |
| 345 | + | .unwrap(); | |
| 346 | + | sqlx::query( | |
| 347 | + | "INSERT INTO target_runs (id, build_id, app, version, target, status, started_at) \ | |
| 348 | + | VALUES (1, 1, 'demo', '0.1.0', 'linux/x86_64', 'running', '2026-07-30T00:00:00Z')", | |
| 349 | + | ) | |
| 350 | + | .execute(&pool) | |
| 351 | + | .await | |
| 352 | + | .unwrap(); | |
| 353 | + | ||
| 354 | + | let deploy = crate::topology::DeployTarget { | |
| 355 | + | target: "linux/x86_64".parse().unwrap(), | |
| 356 | + | host: "local".into(), | |
| 357 | + | port: None, | |
| 358 | + | install_path: "/usr/local/bin/demo".into(), | |
| 359 | + | service: "demo.service".into(), | |
| 360 | + | health_url: None, | |
| 361 | + | }; | |
| 362 | + | let mut execs: crate::state::ExecutorMap = std::collections::HashMap::new(); | |
| 363 | + | execs.insert("local".into(), crate::state::build_deploy_executor(&deploy)); | |
| 364 | + | // The service host's grant is exactly deploy + restart. If this ever | |
| 365 | + | // widens to include `build`, the test below stops proving anything. | |
| 366 | + | assert!(!execs["local"].capabilities().permits(&Action::Build)); | |
| 367 | + | assert!(execs["local"].capabilities().permits(&Action::Deploy)); | |
| 368 | + | ||
| 369 | + | let ctx = Arc::new(RecipeCtx::new( | |
| 370 | + | AppId::new("demo"), | |
| 371 | + | Version::parse("0.1.0").unwrap(), | |
| 372 | + | "linux/x86_64".parse().unwrap(), | |
| 373 | + | "fw13".into(), | |
| 374 | + | "local".into(), | |
| 375 | + | "v0.1.0".into(), | |
| 376 | + | "/tmp".into(), | |
| 377 | + | vec![], | |
| 378 | + | Kind::Service, | |
| 379 | + | 1, | |
| 380 | + | Arc::new(execs), | |
| 381 | + | Arc::new(std::collections::HashMap::new()), | |
| 382 | + | Some(deploy), | |
| 383 | + | pool, | |
| 384 | + | crate::events::channel(), | |
| 385 | + | cfg, | |
| 386 | + | Arc::new(OtaRegistry::standard("https://makenot.work")), | |
| 387 | + | tokio::runtime::Handle::current(), | |
| 388 | + | Arc::new(AtomicBool::new(false)), | |
| 389 | + | None, | |
| 390 | + | )); | |
| 391 | + | ||
| 392 | + | let ctx_blocking = ctx.clone(); | |
| 393 | + | tokio::task::spawn_blocking(move || { | |
| 394 | + | // `verify` on a service derives Action::Build — which the service | |
| 395 | + | // host does not grant. The command must still run. | |
| 396 | + | ctx_blocking.begin_step(Step::Verify).unwrap(); | |
| 397 | + | assert_eq!( | |
| 398 | + | action_for(Step::Verify, Kind::Service), | |
| 399 | + | Action::Build, | |
| 400 | + | "the step's own action is the one that would be denied", | |
| 401 | + | ); | |
| 402 | + | let (code, out) = ctx_blocking | |
| 403 | + | .run("local", "echo reached-the-service-host") | |
| 404 | + | .expect("a service host must be reachable during a verify step"); | |
| 405 | + | assert_eq!(code, 0, "{out}"); | |
| 406 | + | assert!(out.contains("reached-the-service-host"), "{out}"); | |
| 407 | + | }) | |
| 408 | + | .await | |
| 409 | + | .unwrap(); | |
| 410 | + | } | |
| 411 | + | ||
| 412 | + | /// A step that finalized `Failed` bars the deploy, exactly as it bars a | |
| 413 | + | /// publish. Without this, a recipe that inspects `sh(...).code` and carries | |
| 414 | + | /// on regardless still lands a binary on a production host — the precise | |
| 415 | + | /// hazard a pipeline exists to remove. The check is the ledger, not the | |
| 416 | + | /// control flow, so it holds whether or not the recipe noticed. | |
| 417 | + | #[tokio::test] | |
| 418 | + | async fn a_failed_step_bars_the_deploy() { | |
| 419 | + | let dir = tempfile::tempdir().unwrap(); | |
| 420 | + | let cfg = Arc::new(Config::for_tests(dir.path())); | |
| 421 | + | let pool = crate::db::open(&cfg.db_path).await.unwrap(); | |
| 422 | + | let deploy = crate::topology::DeployTarget { | |
| 423 | + | target: "linux/x86_64".parse().unwrap(), | |
| 424 | + | host: "local".into(), | |
| 425 | + | port: None, | |
| 426 | + | install_path: "/usr/local/bin/demo".into(), | |
| 427 | + | service: "demo.service".into(), | |
| 428 | + | health_url: None, | |
| 429 | + | }; | |
| 430 | + | // A real build + target run, so the step rows this test finalizes have | |
| 431 | + | // the parents the schema requires. | |
| 432 | + | sqlx::query( | |
| 433 | + | "INSERT INTO builds (id, app, version, status, created_at) \ | |
| 434 | + | VALUES (1, 'demo', '0.1.0', 'running', '2026-07-30T00:00:00Z')", | |
| 435 | + | ) | |
| 436 | + | .execute(&pool) | |
| 437 | + | .await | |
| 438 | + | .unwrap(); | |
| 439 | + | sqlx::query( | |
| 440 | + | "INSERT INTO target_runs (id, build_id, app, version, target, status, started_at) \ | |
| 441 | + | VALUES (1, 1, 'demo', '0.1.0', 'linux/x86_64', 'running', '2026-07-30T00:00:00Z')", | |
| 442 | + | ) | |
| 443 | + | .execute(&pool) | |
| 444 | + | .await | |
| 445 | + | .unwrap(); | |
| 446 | + | ||
| 447 | + | let mut execs: crate::state::ExecutorMap = std::collections::HashMap::new(); | |
| 448 | + | execs.insert("local".into(), crate::state::build_deploy_executor(&deploy)); | |
| 449 | + | let ctx = Arc::new(RecipeCtx::new( | |
| 450 | + | AppId::new("demo"), | |
| 451 | + | Version::parse("0.1.0").unwrap(), | |
| 452 | + | "linux/x86_64".parse().unwrap(), | |
| 453 | + | "fw13".into(), | |
| 454 | + | "local".into(), | |
| 455 | + | "v0.1.0".into(), | |
| 456 | + | "/tmp".into(), | |
| 457 | + | vec![], | |
| 458 | + | Kind::Service, | |
| 459 | + | 1, | |
| 460 | + | Arc::new(execs), | |
| 461 | + | Arc::new(std::collections::HashMap::new()), | |
| 462 | + | Some(deploy), | |
| 463 | + | pool, | |
| 464 | + | crate::events::channel(), | |
| 465 | + | cfg, | |
| 466 | + | Arc::new(OtaRegistry::standard("https://makenot.work")), | |
| 467 | + | tokio::runtime::Handle::current(), | |
| 468 | + | Arc::new(AtomicBool::new(false)), | |
| 469 | + | None, | |
| 470 | + | )); | |
| 471 | + | ||
| 472 | + | // A gate ran, failed, and the recipe did not abort — the swallowed | |
| 473 | + | // failure. Finalizing it is what puts it in the ledger. | |
| 474 | + | let ctx_blocking = ctx.clone(); | |
| 475 | + | tokio::task::spawn_blocking(move || { | |
| 476 | + | ctx_blocking.begin_step(Step::Prebuild).unwrap(); | |
| 477 | + | ctx_blocking.fail_current_step(); | |
| 478 | + | ctx_blocking.finish_step(Status::Ok).unwrap(); | |
| 479 | + | ||
| 480 | + | let err = ctx_blocking.deploy("/tmp/demo").unwrap_err().to_string(); | |
| 481 | + | assert!( | |
| 482 | + | err.contains("refusing to deploy") && err.contains("prebuild"), | |
| 483 | + | "must refuse and name the failed step, got: {err}" | |
| 484 | + | ); | |
| 485 | + | }) | |
| 486 | + | .await | |
| 487 | + | .unwrap(); | |
| 488 | + | } | |
| 489 | + | } |
| @@ -1,0 +1,337 @@ | |||
| 1 | + | //! Building the git command lines the engine runs, and reading their output. | |
| 2 | + | //! | |
| 3 | + | //! Command builders rather than command runners, which is what makes them | |
| 4 | + | //! testable without a repository. | |
| 5 | + | ||
| 6 | + | use std::path::{Path, PathBuf}; | |
| 7 | + | ||
| 8 | + | /// Refresh every remote's refs and tags, so the tag a release names is present | |
| 9 | + | /// locally however it was pushed. No branch/upstream assumptions — a bare | |
| 10 | + | /// `git pull --ff-only` needs a tracking branch the release path shouldn't | |
| 11 | + | /// depend on. | |
| 12 | + | /// | |
| 13 | + | /// Best-effort on purpose. `fetch --all` exits non-zero if ANY remote fails, and | |
| 14 | + | /// the library repos carry three (`astra`, `mnw`, `srht`), so chaining this into | |
| 15 | + | /// the checkout with `&&` meant one unreachable mirror aborted the release and | |
| 16 | + | /// reported it as a missing tag. The checkout below is the step allowed to fail; | |
| 17 | + | /// this one only has to try. See [`git_worktree_pin_cmd`]. | |
| 18 | + | /// | |
| 19 | + | /// `repo` is interpolated UNQUOTED so a leading `~` is expanded by the remote | |
| 20 | + | /// host's shell (the checkout path is trusted topology config, not user input), | |
| 21 | + | /// matching how the recipes `cd` into it. | |
| 22 | + | pub fn git_fetch_cmd(repo: &str) -> String { | |
| 23 | + | format!("git -C {repo} fetch --all --tags --prune") | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | /// Probe for the one failure a tracked `Cargo.lock` hits inside Bento's | |
| 27 | + | /// worktree. Exits 0 when the crate tracks a lock AND the checkout sits under a | |
| 28 | + | /// `.cargo/config.toml` declaring `[patch]`; 1 otherwise. | |
| 29 | + | /// | |
| 30 | + | /// Both halves are needed and cargo reports neither. Under a `[patch]` block | |
| 31 | + | /// cargo re-resolves and rewrites the lock's `[[patch.unused]]` entries, so | |
| 32 | + | /// `cargo publish --dry-run` refuses the tree with "1 files in the working | |
| 33 | + | /// directory contain changes that were not yet committed into git: Cargo.lock" | |
| 34 | + | /// -- naming the lock and nothing about why it moved. pter 0.2.1 lost half an | |
| 35 | + | /// hour to that message on build 325. | |
| 36 | + | /// | |
| 37 | + | /// `~/Code/.bento` is under `~/Code` deliberately, so that the patch block | |
| 38 | + | /// reaches the build (see [`crate::topology::Host::worktree_root`]). The patch | |
| 39 | + | /// block is therefore not the half to remove, which is why this is worth | |
| 40 | + | /// saying rather than leaving cargo to be cryptic about it. | |
| 41 | + | /// | |
| 42 | + | /// `repo` is interpolated unquoted for the `~`, matching [`git_fetch_cmd`]; | |
| 43 | + | /// `pwd -P` then hands the loop an absolute path to walk up from. | |
| 44 | + | pub(super) fn tracked_lock_under_patch_cmd(repo: &str) -> String { | |
| 45 | + | format!( | |
| 46 | + | "git -C {repo} ls-files --error-unmatch Cargo.lock >/dev/null 2>&1 || exit 1; \ | |
| 47 | + | d=$(cd {repo} && pwd -P) || exit 1; \ | |
| 48 | + | while [ -n \"$d\" ] && [ \"$d\" != / ]; do \ | |
| 49 | + | for c in \"$d/.cargo/config.toml\" \"$d/.cargo/config\"; do \ | |
| 50 | + | [ -f \"$c\" ] && grep -q '^\\[patch' \"$c\" && exit 0; \ | |
| 51 | + | done; d=$(dirname \"$d\"); done; exit 1" | |
| 52 | + | ) | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | /// What to say when [`tracked_lock_under_patch_cmd`] answers yes. Names both | |
| 56 | + | /// facts, because the error cargo would otherwise print names neither, and | |
| 57 | + | /// closes off the two wrong fixes that are both one flag away. | |
| 58 | + | pub(super) fn tracked_lock_under_patch_problem(repo: &str) -> String { | |
| 59 | + | format!( | |
| 60 | + | "`Cargo.lock` is tracked and {repo} sits under a `.cargo/config.toml` \ | |
| 61 | + | declaring `[patch]`. Cargo re-resolves there and rewrites the lock, so \ | |
| 62 | + | `cargo publish --dry-run` will refuse the tree as dirty and name only \ | |
| 63 | + | the lock. Untrack it: `git rm --cached Cargo.lock`. Every other library \ | |
| 64 | + | in this tree already does. Not `--allow-dirty`, which publishes a lock \ | |
| 65 | + | nobody reviewed, and not committing the rewritten lock, which resolves \ | |
| 66 | + | differently on the next machine and fails there instead. The build \ | |
| 67 | + | worktree is under `~/Code` on purpose so the patch block applies to it; \ | |
| 68 | + | that is not the half to change." | |
| 69 | + | ) | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | /// Does `tag` resolve to a commit in this checkout? Run only when the checkout | |
| 73 | + | /// has already failed, to say WHY: an absent tag is an untagged or unpushed | |
| 74 | + | /// release, while a tag that resolves fine means the checkout was refused for a | |
| 75 | + | /// local reason (a dirty tree, most often) and the operator needs to hear that | |
| 76 | + | /// instead. | |
| 77 | + | pub fn git_tag_exists_cmd(repo: &str, tag: &str) -> String { | |
| 78 | + | format!("git -C {repo} rev-parse -q --verify \"refs/tags/{tag}^{{commit}}\"") | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | /// Which repository `repo` belongs to, and where `repo` sits inside it, in one | |
| 82 | + | /// call: `--show-toplevel` then `--show-prefix`, one per line. | |
| 83 | + | /// | |
| 84 | + | /// Both halves are needed to build in a worktree of a repo holding several | |
| 85 | + | /// products. The worktree is made of the repository (`~/Code/MNW`), and the | |
| 86 | + | /// recipe has to be pointed at the app inside it (`<worktree>/pom`). | |
| 87 | + | pub fn git_toplevel_and_prefix_cmd(repo: &str) -> String { | |
| 88 | + | format!("git -C {repo} rev-parse --show-toplevel --show-prefix") | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | /// Read [`git_toplevel_and_prefix_cmd`]'s two lines. | |
| 92 | + | /// | |
| 93 | + | /// The prefix is empty for a repo holding one product, where `repo` IS the | |
| 94 | + | /// repository root — and git prints an empty second line for it, so a missing | |
| 95 | + | /// line is a malformed answer rather than that case. | |
| 96 | + | pub fn parse_toplevel_and_prefix(out: &str) -> Option<(String, String)> { | |
| 97 | + | let mut lines = out.split('\n'); | |
| 98 | + | let toplevel = lines.next()?.trim().to_string(); | |
| 99 | + | let prefix = lines.next()?.trim().to_string(); | |
| 100 | + | (!toplevel.is_empty()).then_some((toplevel, prefix)) | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | /// The repository's own directory name, which is what names its worktrees: | |
| 104 | + | /// `MNW` for `/home/max/Code/MNW`. | |
| 105 | + | /// | |
| 106 | + | /// Splits on `/` only. Git reports `--show-toplevel` with forward slashes on | |
| 107 | + | /// every platform, Windows included, so this is the separator to read. | |
| 108 | + | pub fn repo_dir_name(toplevel: &str) -> &str { | |
| 109 | + | toplevel | |
| 110 | + | .trim_end_matches('/') | |
| 111 | + | .rsplit('/') | |
| 112 | + | .next() | |
| 113 | + | .unwrap_or(toplevel) | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | /// Where the app being released sits inside its worktree: the worktree root for | |
| 117 | + | /// a repo holding one product, `<worktree>/pom` for one holding several. | |
| 118 | + | pub fn app_dir_in_worktree(worktree: &str, prefix: &str) -> String { | |
| 119 | + | let prefix = prefix.trim_matches('/'); | |
| 120 | + | if prefix.is_empty() { | |
| 121 | + | worktree.to_string() | |
| 122 | + | } else { | |
| 123 | + | format!("{}/{prefix}", worktree.trim_end_matches('/')) | |
| 124 | + | } | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | /// Does this worktree already exist? Run before deciding whether to create one. | |
| 128 | + | /// | |
| 129 | + | /// `rev-parse --git-dir` rather than a shell test, because the one non-unix | |
| 130 | + | /// build host has no `test`: every command Bento renders for a host is a git | |
| 131 | + | /// command or something a recipe wrote. | |
| 132 | + | pub fn git_worktree_probe_cmd(worktree: &str) -> String { | |
| 133 | + | format!("git -C \"{worktree}\" rev-parse --git-dir") | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | /// Forget worktrees whose directories are gone. Run before creating one: a | |
| 137 | + | /// directory somebody deleted by hand is still registered in the repository, and | |
| 138 | + | /// `worktree add` refuses the path as in use rather than rebuilding it. | |
| 139 | + | pub fn git_worktree_prune_cmd(toplevel: &str) -> String { | |
| 140 | + | format!("git -C \"{toplevel}\" worktree prune") | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | /// Create this app's build worktree, detached at the release tag. Git creates | |
| 144 | + | /// the leading directories, so the worktree root needs no preparation. | |
| 145 | + | pub fn git_worktree_add_cmd(toplevel: &str, worktree: &str, tag: &str) -> String { | |
| 146 | + | format!("git -C \"{toplevel}\" worktree add --detach --force \"{worktree}\" \"{tag}\"") | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | /// Put an existing build worktree at the release tag. | |
| 150 | + | /// | |
| 151 | + | /// `--force` discards whatever the last release left in it — a rewritten | |
| 152 | + | /// `Cargo.lock`, most often — and that is safe here in a way it never was in the | |
| 153 | + | /// ordinary checkout: nothing but Bento writes in this tree, so there is no edit | |
| 154 | + | /// of anybody's to lose. Owning the tree is what buys the forcing. | |
| 155 | + | pub fn git_worktree_pin_cmd(worktree: &str, tag: &str) -> String { | |
| 156 | + | format!("git -C \"{worktree}\" checkout --detach --force \"{tag}\"") | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | /// The operator-facing explanation for a worktree that could not be put at the | |
| 160 | + | /// tag. | |
| 161 | + | /// | |
| 162 | + | /// An absent tag is an untagged or unpushed release and is the common case, so | |
| 163 | + | /// it is answered plainly. Anything else is git's own stderr, which says more | |
| 164 | + | /// about a path that is not a worktree, or a worktree another release holds, | |
| 165 | + | /// than a guess would. | |
| 166 | + | pub fn worktree_failure_reason(tag: &str, tag_exists: bool, stderr: &str) -> String { | |
| 167 | + | if !tag_exists { | |
| 168 | + | return format!("tag {tag} does not exist there (is it created and pushed?)"); | |
| 169 | + | } | |
| 170 | + | let stderr = stderr.trim(); | |
| 171 | + | if stderr.is_empty() { | |
| 172 | + | format!("tag {tag} exists, and git said nothing about why") | |
| 173 | + | } else { | |
| 174 | + | stderr.to_string() | |
| 175 | + | } | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | /// The command a host runs to report the commit it has checked out, for the | |
| 179 | + | /// release preflight barrier. | |
| 180 | + | pub fn git_rev_parse_cmd(repo: &str) -> String { | |
| 181 | + | format!("git -C {repo} rev-parse HEAD") | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | /// Expand a leading `~/` to `$HOME`. Paths in the topology are written with `~`. | |
| 185 | + | pub fn expand_tilde(p: &str) -> PathBuf { | |
| 186 | + | if let Some(rest) = p.strip_prefix("~/") | |
| 187 | + | && let Ok(home) = std::env::var("HOME") | |
| 188 | + | { | |
| 189 | + | return Path::new(&home).join(rest); | |
| 190 | + | } | |
| 191 | + | PathBuf::from(p) | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | #[cfg(test)] | |
| 195 | + | mod tests { | |
| 196 | + | use super::*; | |
| 197 | + | ||
| 198 | + | /// Reads the ambient `HOME` rather than setting one. `set_var` is | |
| 199 | + | /// process-global and unsynchronized, so a test that overwrote HOME changed | |
| 200 | + | /// it for every other test in the binary — which is what silently disabled | |
| 201 | + | /// `topology::live_config_smoke` (it skips when `$HOME/.config/bento` is | |
| 202 | + | /// absent, and `/home/test` always is). | |
| 203 | + | #[test] | |
| 204 | + | fn expand_tilde_handles_home() { | |
| 205 | + | let home = PathBuf::from(std::env::var("HOME").expect("HOME is set")); | |
| 206 | + | assert_eq!(expand_tilde("~/Code/x"), home.join("Code/x")); | |
| 207 | + | assert_eq!(expand_tilde("/abs/path"), PathBuf::from("/abs/path")); | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | /// Both shapes of repo: one holding several products, and one holding a | |
| 211 | + | /// single crate, where git prints an empty prefix line. | |
| 212 | + | #[test] | |
| 213 | + | fn toplevel_and_prefix_read_both_shapes_of_repo() { | |
| 214 | + | let (top, prefix) = | |
| 215 | + | parse_toplevel_and_prefix("/home/max/Code/MNW\npom/\n").expect("two lines"); | |
| 216 | + | assert_eq!(top, "/home/max/Code/MNW"); | |
| 217 | + | assert_eq!(prefix, "pom/"); | |
| 218 | + | // A repo holding one product: git prints an empty second line. | |
| 219 | + | let (top, prefix) = | |
| 220 | + | parse_toplevel_and_prefix("/home/max/Code/Libraries/pter\n\n").expect("two lines"); | |
| 221 | + | assert_eq!(top, "/home/max/Code/Libraries/pter"); | |
| 222 | + | assert_eq!(prefix, ""); | |
| 223 | + | assert!( | |
| 224 | + | parse_toplevel_and_prefix("").is_none(), | |
| 225 | + | "no answer is not an answer" | |
| 226 | + | ); | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | #[test] | |
| 230 | + | fn repo_dir_name_is_the_last_segment_on_every_platform() { | |
| 231 | + | assert_eq!(repo_dir_name("/home/max/Code/MNW"), "MNW"); | |
| 232 | + | assert_eq!(repo_dir_name("/home/max/Code/MNW/"), "MNW"); | |
| 233 | + | // Git reports forward slashes on Windows too. | |
| 234 | + | assert_eq!(repo_dir_name("C:/Users/me/Code/Apps/goingson"), "goingson"); | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | /// The app's directory inside its worktree, for both repo shapes. | |
| 238 | + | #[test] | |
| 239 | + | fn app_dir_in_worktree_follows_the_prefix() { | |
| 240 | + | assert_eq!( | |
| 241 | + | app_dir_in_worktree("/home/max/Code/.bento/MNW/pom", "pom/"), | |
| 242 | + | "/home/max/Code/.bento/MNW/pom/pom" | |
| 243 | + | ); | |
| 244 | + | assert_eq!( | |
| 245 | + | app_dir_in_worktree("/home/max/Code/.bento/pter/pter", ""), | |
| 246 | + | "/home/max/Code/.bento/pter/pter" | |
| 247 | + | ); | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | /// A missing tag is the common failure and gets a plain answer; anything | |
| 251 | + | /// else is git's own stderr, which says more than a guess. | |
| 252 | + | #[test] | |
| 253 | + | fn worktree_failure_reason_names_the_tag_or_repeats_git() { | |
| 254 | + | let missing = worktree_failure_reason("pom-v0.4.5", false, "irrelevant"); | |
| 255 | + | assert!(missing.contains("does not exist"), "{missing}"); | |
| 256 | + | let held = worktree_failure_reason( | |
| 257 | + | "pom-v0.4.5", | |
| 258 | + | true, | |
| 259 | + | "fatal: '/home/max/Code/.bento/MNW/pom' already exists", | |
| 260 | + | ); | |
| 261 | + | assert!(held.contains("already exists"), "{held}"); | |
| 262 | + | let silent = worktree_failure_reason("pom-v0.4.5", true, " "); | |
| 263 | + | assert!(silent.contains("said nothing"), "{silent}"); | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | /// Run the probe for real rather than asserting on its text: it is shell, | |
| 267 | + | /// and the thing worth knowing is whether `sh` agrees, not whether the | |
| 268 | + | /// string looks right. | |
| 269 | + | fn probe(repo: &std::path::Path) -> bool { | |
| 270 | + | std::process::Command::new("sh") | |
| 271 | + | .arg("-c") | |
| 272 | + | .arg(tracked_lock_under_patch_cmd(&repo.display().to_string())) | |
| 273 | + | .status() | |
| 274 | + | .unwrap() | |
| 275 | + | .success() | |
| 276 | + | } | |
| 277 | + | ||
| 278 | + | /// A crate under a `[patch]` root, with and without its lock tracked. | |
| 279 | + | /// | |
| 280 | + | /// pter 0.2.1 is the case: the tracked half was true, the patch half was | |
| 281 | + | /// true, and cargo reported only "Cargo.lock" (build 325). Untracking the | |
| 282 | + | /// lock in `a9969a9` is what fixed it, and this asserts the probe agrees | |
| 283 | + | /// with that fix in both directions. | |
| 284 | + | #[test] | |
| 285 | + | fn a_tracked_lock_is_only_a_problem_under_a_patch_block() { | |
| 286 | + | let root = tempfile::tempdir().unwrap(); | |
| 287 | + | let repo = root.path().join("crate"); | |
| 288 | + | std::fs::create_dir_all(&repo).unwrap(); | |
| 289 | + | let git = |args: &[&str]| { | |
| 290 | + | std::process::Command::new("git") | |
| 291 | + | .args(args) | |
| 292 | + | .current_dir(&repo) | |
| 293 | + | .env("GIT_AUTHOR_NAME", "t") | |
| 294 | + | .env("GIT_AUTHOR_EMAIL", "t@t") | |
| 295 | + | .env("GIT_COMMITTER_NAME", "t") | |
| 296 | + | .env("GIT_COMMITTER_EMAIL", "t@t") | |
| 297 | + | .output() | |
| 298 | + | .unwrap() | |
| 299 | + | }; | |
| 300 | + | git(&["init", "-q", "."]); | |
| 301 | + | std::fs::write(repo.join("Cargo.lock"), "# lock\n").unwrap(); | |
| 302 | + | ||
| 303 | + | // Lock present but untracked, no patch anywhere: nothing to say. | |
| 304 | + | assert!(!probe(&repo)); | |
| 305 | + | ||
| 306 | + | // Tracked, still no patch root. Every library that commits a lock and | |
| 307 | + | // builds outside `~/Code` lives here, and it publishes fine. | |
| 308 | + | git(&["add", "Cargo.lock"]); | |
| 309 | + | git(&["commit", "-qm", "lock"]); | |
| 310 | + | assert!(!probe(&repo)); | |
| 311 | + | ||
| 312 | + | // The ancestor declares `[patch]`. Both halves now hold. | |
| 313 | + | std::fs::create_dir_all(root.path().join(".cargo")).unwrap(); | |
| 314 | + | std::fs::write( | |
| 315 | + | root.path().join(".cargo/config.toml"), | |
| 316 | + | "[patch.\"https://makenot.work/git/max/docengine.git\"]\ndocengine = { path = \"x\" }\n", | |
| 317 | + | ) | |
| 318 | + | .unwrap(); | |
| 319 | + | assert!(probe(&repo)); | |
| 320 | + | ||
| 321 | + | // Untracking the lock is the fix, and the probe has to agree that it is. | |
| 322 | + | git(&["rm", "-q", "--cached", "Cargo.lock"]); | |
| 323 | + | assert!(!probe(&repo)); | |
| 324 | + | } | |
| 325 | + | ||
| 326 | + | /// The message exists because cargo's names neither fact and both wrong | |
| 327 | + | /// fixes are one flag away. Assert it still says all four things. | |
| 328 | + | #[test] | |
| 329 | + | fn the_tracked_lock_message_names_both_facts_and_refuses_both_wrong_fixes() { | |
| 330 | + | let msg = tracked_lock_under_patch_problem("~/Code/.bento/pter/pter"); | |
| 331 | + | assert!(msg.contains("Cargo.lock"), "{msg}"); | |
| 332 | + | assert!(msg.contains("[patch]"), "{msg}"); | |
| 333 | + | assert!(msg.contains("git rm --cached"), "{msg}"); | |
| 334 | + | assert!(msg.contains("--allow-dirty"), "{msg}"); | |
| 335 | + | assert!(msg.contains("~/Code/.bento/pter/pter"), "{msg}"); | |
| 336 | + | } | |
| 337 | + | } |
| @@ -1,0 +1,621 @@ | |||
| 1 | + | //! Registering the host functions a recipe may call. | |
| 2 | + | //! | |
| 3 | + | //! One function, because it is one act: the thirty names a `.rhai` file can | |
| 4 | + | //! reach are the engine's whole API surface, and reading them as a list is the | |
| 5 | + | //! point. | |
| 6 | + | ||
| 7 | + | use super::RecipeCtx; | |
| 8 | + | use super::crates_io; | |
| 9 | + | use super::crates_io::{crate_meta_from_json, crate_publish_problems}; | |
| 10 | + | use super::git::{expand_tilde, tracked_lock_under_patch_cmd, tracked_lock_under_patch_problem}; | |
| 11 | + | use super::macos::register_macos_fns; | |
| 12 | + | use super::rhai_err; | |
| 13 | + | use crate::domain::Step; | |
| 14 | + | use anyhow::Result; | |
| 15 | + | use rhai::{Engine, EvalAltResult, Map}; | |
| 16 | + | use std::sync::Arc; | |
| 17 | + | ||
| 18 | + | /// Build a Rhai engine with the host API bound to `ctx`. Sandboxed: recipes | |
| 19 | + | /// touch the outside world only through these functions. | |
| 20 | + | pub fn build_engine(ctx: &Arc<RecipeCtx>) -> Engine { | |
| 21 | + | let mut engine = Engine::new(); | |
| 22 | + | // Defensive caps — recipes are first-party but bound the blast radius. | |
| 23 | + | engine.set_max_operations(5_000_000); | |
| 24 | + | engine.set_max_call_levels(64); | |
| 25 | + | engine.set_max_string_size(0); | |
| 26 | + | ||
| 27 | + | // --- step(name) --- | |
| 28 | + | { | |
| 29 | + | let ctx = ctx.clone(); | |
| 30 | + | engine.register_fn( | |
| 31 | + | "step", | |
| 32 | + | move |name: &str| -> Result<(), Box<EvalAltResult>> { | |
| 33 | + | let step: Step = name.parse().map_err(rhai_err)?; | |
| 34 | + | ctx.begin_step(step).map_err(rhai_err) | |
| 35 | + | }, | |
| 36 | + | ); | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // --- sh(host, cmd) -> #{ code, stdout_tail } --- | |
| 40 | + | // | |
| 41 | + | // The branch-on-exit-code primitive: the recipe OWNS the outcome. A non-zero | |
| 42 | + | // exit is returned, not raised, and does NOT fail the step or bar publish — | |
| 43 | + | // use this only when the recipe inspects `code` and decides. For a command | |
| 44 | + | // that must succeed (build/sign/etc.), use `sh_ok`, which fails the step (and | |
| 45 | + | // therefore bars publish via the failed-step ledger) on a non-zero exit. | |
| 46 | + | { | |
| 47 | + | let ctx = ctx.clone(); | |
| 48 | + | engine.register_fn( | |
| 49 | + | "sh", | |
| 50 | + | move |host: &str, cmd: &str| -> Result<Map, Box<EvalAltResult>> { | |
| 51 | + | let (code, tail) = ctx.run(host, cmd).map_err(rhai_err)?; | |
| 52 | + | let mut m = Map::new(); | |
| 53 | + | m.insert("code".into(), (code as i64).into()); | |
| 54 | + | m.insert("stdout_tail".into(), tail.into()); | |
| 55 | + | Ok(m) | |
| 56 | + | }, | |
| 57 | + | ); | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // --- sh_ok(host, cmd): run + assert exit 0 (the must-succeed primitive) --- | |
| 61 | + | // | |
| 62 | + | // A non-zero exit fails the current step (added to the publish-barring | |
| 63 | + | // ledger) and aborts the recipe, so an artifact is never shipped after a | |
| 64 | + | // must-succeed command failed. | |
| 65 | + | { | |
| 66 | + | let ctx = ctx.clone(); | |
| 67 | + | engine.register_fn( | |
| 68 | + | "sh_ok", | |
| 69 | + | move |host: &str, cmd: &str| -> Result<(), Box<EvalAltResult>> { | |
| 70 | + | let (code, _) = ctx.run(host, cmd).map_err(rhai_err)?; | |
| 71 | + | if code != 0 { | |
| 72 | + | // Attribute the failure to the current step explicitly so the | |
| 73 | + | // ledger bars publish even if a future caller swallowed the error. | |
| 74 | + | ctx.fail_current_step(); | |
| 75 | + | return Err(rhai_err(format!( | |
| 76 | + | "command on `{host}` exited {code}: {cmd}" | |
| 77 | + | ))); | |
| 78 | + | } | |
| 79 | + | Ok(()) | |
| 80 | + | }, | |
| 81 | + | ); | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | // --- resolve_artifact(host, glob) -> path: the ONE artifact matching glob --- | |
| 85 | + | // | |
| 86 | + | // The artifact-selection primitive. Replaces `sh(host, "ls -t <glob> | head | |
| 87 | + | // -1").stdout_tail.trim()` guarded on an empty string, which let a non-zero | |
| 88 | + | // `ls` pass quietly and a stale newest-by-mtime file win. This resolves the | |
| 89 | + | // glob on the host and demands exactly one match: zero matches or more than | |
| 90 | + | // one both throw (an ambiguous match means the build left stale artifacts, | |
| 91 | + | // and silently picking the newest is how the wrong bytes ship). Use | |
| 92 | + | // `resolve_artifact_opt` for an artifact that may legitimately be absent. | |
| 93 | + | { | |
| 94 | + | let ctx = ctx.clone(); | |
| 95 | + | engine.register_fn( | |
| 96 | + | "resolve_artifact", | |
| 97 | + | move |host: &str, glob: &str| -> Result<String, Box<EvalAltResult>> { | |
| 98 | + | ctx.resolve_artifact(host, glob, true).map_err(rhai_err) | |
| 99 | + | }, | |
| 100 | + | ); | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | // --- resolve_artifact_opt(host, glob) -> path | "": zero-or-one match --- | |
| 104 | + | // | |
| 105 | + | // Same strict resolution as `resolve_artifact` but tolerates zero matches | |
| 106 | + | // (returns ""); more than one is still an error. For optional outputs like a | |
| 107 | + | // `.deb` or an updater bundle a recipe collects only when present. | |
| 108 | + | { | |
| 109 | + | let ctx = ctx.clone(); | |
| 110 | + | engine.register_fn( | |
| 111 | + | "resolve_artifact_opt", | |
| 112 | + | move |host: &str, glob: &str| -> Result<String, Box<EvalAltResult>> { | |
| 113 | + | ctx.resolve_artifact(host, glob, false).map_err(rhai_err) | |
| 114 | + | }, | |
| 115 | + | ); | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | // --- log(msg): operator-visible line into the current step's tail --- | |
| 119 | + | { | |
| 120 | + | let ctx = ctx.clone(); | |
| 121 | + | engine.register_fn("log", move |msg: &str| -> Result<(), Box<EvalAltResult>> { | |
| 122 | + | let sink = ctx.ensure_step().map_err(rhai_err)?; | |
| 123 | + | let line = format!("[recipe] {msg}\n"); | |
| 124 | + | ctx.rt.block_on(async { | |
| 125 | + | use ops_core::remote::LogSink; | |
| 126 | + | sink.lock().await.write_chunk(line.as_bytes()).await; | |
| 127 | + | }); | |
| 128 | + | Ok(()) | |
| 129 | + | }); | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | // --- version_of(app) -> string --- | |
| 133 | + | { | |
| 134 | + | let ctx = ctx.clone(); | |
| 135 | + | engine.register_fn( | |
| 136 | + | "version_of", | |
| 137 | + | move |app: &str| -> Result<String, Box<EvalAltResult>> { | |
| 138 | + | // Only the current app is in scope; cross-app reads aren't needed. | |
| 139 | + | if app != ctx.app.as_str() { | |
| 140 | + | return Err(rhai_err(format!( | |
| 141 | + | "version_of: `{app}` is not the app being built" | |
| 142 | + | ))); | |
| 143 | + | } | |
| 144 | + | Ok(ctx.version.to_string()) | |
| 145 | + | }, | |
| 146 | + | ); | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | // --- version() -> string: the version being built (no-arg form) --- | |
| 150 | + | { | |
| 151 | + | let ctx = ctx.clone(); | |
| 152 | + | engine.register_fn("version", move || -> String { ctx.version.to_string() }); | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | // --- build_host() -> string: the host this target builds on --- | |
| 156 | + | { | |
| 157 | + | let ctx = ctx.clone(); | |
| 158 | + | engine.register_fn("build_host", move || -> String { ctx.build_host.clone() }); | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | // --- repo() -> string: the app's checkout path on this target's build host | |
| 162 | + | // (`~`-prefixed on a unix host). Host-correct rather than one path per | |
| 163 | + | // app, so a recipe for a host whose checkout is elsewhere still calls | |
| 164 | + | // this instead of hard-coding the path — which is what kept the Windows | |
| 165 | + | // recipes off `checkout_sha`. --- | |
| 166 | + | { | |
| 167 | + | let ctx = ctx.clone(); | |
| 168 | + | engine.register_fn("repo", move || -> String { | |
| 169 | + | ctx.repo_for(&ctx.build_host).to_string() | |
| 170 | + | }); | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | // --- checkout_sha(host) -> sha: pin this host to the release tag and report | |
| 174 | + | // its commit. Replaces a recipe's `git pull --ff-only`, which builds | |
| 175 | + | // whatever `main` is at pull time; the daemon also runs the same pin as | |
| 176 | + | // a cross-host preflight barrier before any target builds. --- | |
| 177 | + | { | |
| 178 | + | let ctx = ctx.clone(); | |
| 179 | + | engine.register_fn( | |
| 180 | + | "checkout_sha", | |
| 181 | + | move |host: &str| -> Result<String, Box<EvalAltResult>> { | |
| 182 | + | ctx.checkout_sha(host).map_err(rhai_err) | |
| 183 | + | }, | |
| 184 | + | ); | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | // --- crate_preflight() -> string: verify this crate is safe to publish, | |
| 188 | + | // or abort the run. Everything it checks is immutable once published: | |
| 189 | + | // crates.io versions can be yanked but never edited, so a wrong | |
| 190 | + | // repository URL is permanent. pter 0.1.0 shipped with a dead one. --- | |
| 191 | + | { | |
| 192 | + | let ctx = ctx.clone(); | |
| 193 | + | engine.register_fn( | |
| 194 | + | "crate_preflight", | |
| 195 | + | move || -> Result<String, Box<EvalAltResult>> { | |
| 196 | + | // `repo`, not `repo_for(...)`: `cargo metadata` runs on the | |
| 197 | + | // daemon's own box, so this is the one checkout that is always | |
| 198 | + | // the local one. It is not a missed call site. | |
| 199 | + | let repo = expand_tilde(&ctx.repo); | |
| 200 | + | ||
| 201 | + | let out = std::process::Command::new("cargo") | |
| 202 | + | .args(["metadata", "--no-deps", "--format-version", "1"]) | |
| 203 | + | .current_dir(&repo) | |
| 204 | + | .output() | |
| 205 | + | .map_err(|e| format!("running cargo metadata in {}: {e}", repo.display()))?; | |
| 206 | + | if !out.status.success() { | |
| 207 | + | return Err(format!( | |
| 208 | + | "cargo metadata failed in {}: {}", | |
| 209 | + | repo.display(), | |
| 210 | + | String::from_utf8_lossy(&out.stderr).trim() | |
| 211 | + | ) | |
| 212 | + | .into()); | |
| 213 | + | } | |
| 214 | + | let meta = crate_meta_from_json(&String::from_utf8_lossy(&out.stdout)) | |
| 215 | + | .map_err(|e| e.to_string())?; | |
| 216 | + | ||
| 217 | + | // The real question is not whether a page renders but whether a | |
| 218 | + | // stranger with no credentials can fetch the source, so ask git. | |
| 219 | + | let clonable = meta.repository.as_ref().is_some_and(|url| { | |
| 220 | + | std::process::Command::new("git") | |
| 221 | + | .args(["ls-remote", url]) | |
| 222 | + | .env("GIT_TERMINAL_PROMPT", "0") | |
| 223 | + | .output() | |
| 224 | + | .is_ok_and(|o| o.status.success()) | |
| 225 | + | }); | |
| 226 | + | ||
| 227 | + | // Ask the publishing host whether cargo has credentials, rather | |
| 228 | + | // than moving the token anywhere. It stays in cargo's own 0600 | |
| 229 | + | // store; a shell line carrying it would be visible in `ps`. | |
| 230 | + | // An exit code answers "are there credentials"; an Err answers | |
| 231 | + | // "the question could not be asked". Collapsing the second into | |
| 232 | + | // the first reported a capability denial as "no crates.io | |
| 233 | + | // credentials", which sent a real diagnosis three rounds the | |
| 234 | + | // wrong way. A check that cannot run is not a failed check. | |
| 235 | + | let creds = | |
| 236 | + | ctx.run( | |
| 237 | + | &ctx.build_host.clone(), | |
| 238 | + | "cargo login --help >/dev/null 2>&1 && \ | |
| 239 | + | test -s \"${CARGO_HOME:-$HOME/.cargo}/credentials.toml\" \ | |
| 240 | + | || test -s \"${CARGO_HOME:-$HOME/.cargo}/credentials\"", | |
| 241 | + | ) | |
| 242 | + | .map_err(|e| { | |
| 243 | + | format!( | |
| 244 | + | "could not check crates.io credentials on `{}`: {e}", | |
| 245 | + | ctx.build_host | |
| 246 | + | ) | |
| 247 | + | })? | |
| 248 | + | .0 == 0; | |
| 249 | + | ||
| 250 | + | // Asked of the build host rather than the daemon: the tree | |
| 251 | + | // that gets published is the worktree over there, and it is the | |
| 252 | + | // one whose `[patch]` ancestry decides this. An Err is "the | |
| 253 | + | // question could not be asked" and is not a finding -- same | |
| 254 | + | // rule as the credentials check above, for the same reason it | |
| 255 | + | // was written that way. | |
| 256 | + | let build_repo = ctx.repo_for(&ctx.build_host).to_string(); | |
| 257 | + | let patched_lock = | |
| 258 | + | ctx.run( | |
| 259 | + | &ctx.build_host.clone(), | |
| 260 | + | &tracked_lock_under_patch_cmd(&build_repo), | |
| 261 | + | ) | |
| 262 | + | .map_err(|e| { | |
| 263 | + | format!( | |
| 264 | + | "could not check for a tracked Cargo.lock on `{}`: {e}", | |
| 265 | + | ctx.build_host | |
| 266 | + | ) | |
| 267 | + | })? | |
| 268 | + | .0 == 0; | |
| 269 | + | ||
| 270 | + | let published = crates_io::published_versions(&meta.name); | |
| 271 | + | let mut problems = crate_publish_problems(&meta, clonable, &published, creds); | |
| 272 | + | if patched_lock { | |
| 273 | + | problems.push(tracked_lock_under_patch_problem(&build_repo)); | |
| 274 | + | } | |
| 275 | + | if !problems.is_empty() { | |
| 276 | + | return Err(format!( | |
| 277 | + | "{} {} is not safe to publish:\n - {}", | |
| 278 | + | meta.name, | |
| 279 | + | meta.version, | |
| 280 | + | problems.join("\n - ") | |
| 281 | + | ) | |
| 282 | + | .into()); | |
| 283 | + | } | |
| 284 | + | Ok(format!("{} {} passed preflight", meta.name, meta.version)) | |
| 285 | + | }, | |
| 286 | + | ); | |
| 287 | + | } | |
| 288 | + | ||
| 289 | + | // --- feature_flags() -> string: `--features a,b`, or "" when the app | |
| 290 | + | // declares none. Returns the whole flag rather than a bare list so an | |
| 291 | + | // app with no features cannot produce a dangling `--features`. --- | |
| 292 | + | { | |
| 293 | + | let ctx = ctx.clone(); | |
| 294 | + | engine.register_fn("feature_flags", move || -> String { | |
| 295 | + | if ctx.features.is_empty() { | |
| 296 | + | String::new() | |
| 297 | + | } else { | |
| 298 | + | format!("--features {}", ctx.features.join(",")) | |
| 299 | + | } | |
| 300 | + | }); | |
| 301 | + | } | |
| 302 | + | ||
| 303 | + | // --- target() / platform() / arch(): the target axis, for one per-platform | |
| 304 | + | // recipe to branch on arch (bundle paths differ between x86_64/aarch64). --- | |
| 305 | + | { | |
| 306 | + | let ctx = ctx.clone(); | |
| 307 | + | engine.register_fn("target", move || -> String { ctx.target.to_string() }); | |
| 308 | + | } | |
| 309 | + | { | |
| 310 | + | let ctx = ctx.clone(); | |
| 311 | + | engine.register_fn("platform", move || -> String { | |
| 312 | + | ctx.target.platform.as_str().to_string() | |
| 313 | + | }); | |
| 314 | + | } | |
| 315 | + | { | |
| 316 | + | let ctx = ctx.clone(); | |
| 317 | + | engine.register_fn("arch", move || -> String { | |
| 318 | + | ctx.target.arch.as_str().to_string() | |
| 319 | + | }); | |
| 320 | + | } | |
| 321 | + | ||
| 322 | + | // --- secret(key) -> string (file under secrets_root; never logged) --- | |
| 323 | + | { | |
| 324 | + | let ctx = ctx.clone(); | |
| 325 | + | engine.register_fn("secret", move |key: &str| -> Result<String, Box<EvalAltResult>> { | |
| 326 | + | // Guard against traversal out of secrets_root. Require every path | |
| 327 | + | // component to be `Normal` (rejects `..`, `.`, absolute roots and | |
| 328 | + | // drive prefixes) and forbid backslashes (a literal filename char on | |
| 329 | + | // Linux, but a separator elsewhere) — the per-component strength of | |
| 330 | + | // Sando's `safe()`. A multi-segment key like `app/token` is still | |
| 331 | + | // allowed; `foo..bar` (a legit filename) is no longer falsely blocked. | |
| 332 | + | let safe = !key.is_empty() | |
| 333 | + | && !key.contains('\\') | |
| 334 | + | && std::path::Path::new(key) | |
| 335 | + | .components() | |
| 336 | + | .all(|c| matches!(c, std::path::Component::Normal(_))); | |
| 337 | + | if !safe { | |
| 338 | + | return Err(rhai_err( | |
| 339 | + | "secret key must be a relative path under secrets_root (no `..`, `.`, absolute paths, or backslashes)", | |
| 340 | + | )); | |
| 341 | + | } | |
| 342 | + | let path = ctx.cfg.secrets_root.join(key); | |
| 343 | + | std::fs::read_to_string(&path) | |
| 344 | + | .map(|s| s.trim_end().to_string()) | |
| 345 | + | .map_err(|e| rhai_err(format!("secret `{key}`: {e}"))) | |
| 346 | + | }); | |
| 347 | + | } | |
| 348 | + | ||
| 349 | + | // --- env(host, key) -> string --- | |
| 350 | + | { | |
| 351 | + | let ctx = ctx.clone(); | |
| 352 | + | engine.register_fn( | |
| 353 | + | "env", | |
| 354 | + | move |host: &str, key: &str| -> Result<String, Box<EvalAltResult>> { | |
| 355 | + | // The key is interpolated into a `${...}` shell expansion, so it must | |
| 356 | + | // be a bare shell identifier — anything else (quotes, `}`, `$`, `;`) | |
| 357 | + | // could break out and run arbitrary commands on the host. Validate | |
| 358 | + | // before building the command; this is the one env read that can't | |
| 359 | + | // sh-quote its argument (a quoted var name doesn't expand). | |
| 360 | + | if key.is_empty() | |
| 361 | + | || !key | |
| 362 | + | .chars() | |
| 363 | + | .next() | |
| 364 | + | .is_some_and(|c| c == '_' || c.is_ascii_alphabetic()) | |
| 365 | + | || !key.chars().all(|c| c == '_' || c.is_ascii_alphanumeric()) | |
| 366 | + | { | |
| 367 | + | return Err(rhai_err(format!( | |
| 368 | + | "env name `{key}` must be a shell identifier ([A-Za-z_][A-Za-z0-9_]*)" | |
| 369 | + | ))); | |
| 370 | + | } | |
| 371 | + | // Read via the shell so it works on remote hosts too. | |
| 372 | + | let (code, tail) = ctx | |
| 373 | + | .run(host, &format!("printf '%s' \"${{{key}}}\"")) | |
| 374 | + | .map_err(rhai_err)?; | |
| 375 | + | if code != 0 { | |
| 376 | + | return Err(rhai_err(format!("env `{key}` on `{host}` failed"))); | |
| 377 | + | } | |
| 378 | + | Ok(tail.trim().to_string()) | |
| 379 | + | }, | |
| 380 | + | ); | |
| 381 | + | } | |
| 382 | + | ||
| 383 | + | // --- collect(host, glob, app, version): pull artifacts to dist_root --- | |
| 384 | + | { | |
| 385 | + | let ctx = ctx.clone(); | |
| 386 | + | engine.register_fn( | |
| 387 | + | "collect", | |
| 388 | + | move |host: &str, | |
| 389 | + | glob: &str, | |
| 390 | + | app: &str, | |
| 391 | + | version: &str| | |
| 392 | + | -> Result<(), Box<EvalAltResult>> { | |
| 393 | + | ctx.collect(host, glob, app, version).map_err(rhai_err) | |
| 394 | + | }, | |
| 395 | + | ); | |
| 396 | + | } | |
| 397 | + | ||
| 398 | + | // --- publish(channel, app, target, version, artifact, meta) --- | |
| 399 | + | { | |
| 400 | + | let ctx = ctx.clone(); | |
| 401 | + | engine.register_fn( | |
| 402 | + | "publish", | |
| 403 | + | move |channel: &str, | |
| 404 | + | app: &str, | |
| 405 | + | target: &str, | |
| 406 | + | version: &str, | |
| 407 | + | artifact: &str, | |
| 408 | + | meta: Map| | |
| 409 | + | -> Result<String, Box<EvalAltResult>> { | |
| 410 | + | ctx.publish(channel, app, target, version, artifact, &meta) | |
| 411 | + | .map_err(rhai_err) | |
| 412 | + | }, | |
| 413 | + | ); | |
| 414 | + | } | |
| 415 | + | ||
| 416 | + | // --- deploy(binary) -> summary: install a service binary and restart its | |
| 417 | + | // unit. The terminal step for `kind = "service"`, the counterpart of | |
| 418 | + | // `publish` for something that is run rather than distributed. | |
| 419 | + | // | |
| 420 | + | // Takes only the binary's path on the build host: where it lands, on | |
| 421 | + | // which machine, and which unit restarts all come from the `[[deploy]]` | |
| 422 | + | // entry for the target already being built. A recipe cannot deploy the | |
| 423 | + | // aarch64 binary to the x86_64 box by naming the wrong host, because it | |
| 424 | + | // never names a host at all. | |
| 425 | + | { | |
| 426 | + | let ctx = ctx.clone(); | |
| 427 | + | engine.register_fn( | |
| 428 | + | "deploy", | |
| 429 | + | move |binary: &str| -> Result<String, Box<EvalAltResult>> { | |
| 430 | + | ctx.deploy(binary).map_err(rhai_err) | |
| 431 | + | }, | |
| 432 | + | ); | |
| 433 | + | } | |
| 434 | + | ||
| 435 | + | // --- deploy_host() -> string: the service host's ssh destination, so a | |
| 436 | + | // recipe can run its own assertions there (`sh_ok(deploy_host(), ...)`). | |
| 437 | + | // Commands run through it while the `deploy` step is open, so they are | |
| 438 | + | // gated on the deploy grant like the install itself. --- | |
| 439 | + | { | |
| 440 | + | let ctx = ctx.clone(); | |
| 441 | + | engine.register_fn( | |
| 442 | + | "deploy_host", | |
| 443 | + | move || -> Result<String, Box<EvalAltResult>> { | |
| 444 | + | ctx.deploy_target() | |
| 445 | + | .map(|d| d.host.clone()) | |
| 446 | + | .map_err(rhai_err) | |
| 447 | + | }, | |
| 448 | + | ); | |
| 449 | + | } | |
| 450 | + | ||
| 451 | + | // --- service_name() / install_path() / health_url(): the rest of the | |
| 452 | + | // `[[deploy]]` entry, so a recipe asserts against the configured values | |
| 453 | + | // rather than repeating them as literals that can drift. `health_url` | |
| 454 | + | // is "" when unset. --- | |
| 455 | + | { | |
| 456 | + | let ctx = ctx.clone(); | |
| 457 | + | engine.register_fn( | |
| 458 | + | "service_name", | |
| 459 | + | move || -> Result<String, Box<EvalAltResult>> { | |
| 460 | + | ctx.deploy_target() | |
| 461 | + | .map(|d| d.service.clone()) | |
| 462 | + | .map_err(rhai_err) | |
| 463 | + | }, | |
| 464 | + | ); | |
| 465 | + | } | |
| 466 | + | { | |
| 467 | + | let ctx = ctx.clone(); | |
| 468 | + | engine.register_fn( | |
| 469 | + | "install_path", | |
| 470 | + | move || -> Result<String, Box<EvalAltResult>> { | |
| 471 | + | ctx.deploy_target() | |
| 472 | + | .map(|d| d.install_path.clone()) | |
| 473 | + | .map_err(rhai_err) | |
| 474 | + | }, | |
| 475 | + | ); | |
| 476 | + | } | |
| 477 | + | { | |
| 478 | + | let ctx = ctx.clone(); | |
| 479 | + | engine.register_fn( | |
| 480 | + | "health_url", | |
| 481 | + | move || -> Result<String, Box<EvalAltResult>> { | |
| 482 | + | ctx.deploy_target() | |
| 483 | + | .map(|d| d.health_url.clone().unwrap_or_default()) | |
| 484 | + | .map_err(rhai_err) | |
| 485 | + | }, | |
| 486 | + | ); | |
| 487 | + | } | |
| 488 | + | ||
| 489 | + | // --- glibc_check(binary) -> string: assert the build host did not produce | |
| 490 | + | // a binary the service host's glibc is too old to exec. Aborts the run | |
| 491 | + | // if it did; returns "needs X, host has Y" for the log if it did not. | |
| 492 | + | // | |
| 493 | + | // WHICH RECIPES CALL THIS, AND WHY THE OTHERS MUST NOT. The rule is not | |
| 494 | + | // a style preference and it is not optional: this reads the recipe's | |
| 495 | + | // `[[deploy]]` entry to learn which machine runs the bytes, so a recipe | |
| 496 | + | // with no `[[deploy]]` cannot call it at all. | |
| 497 | + | // | |
| 498 | + | // - A service that installs ITSELF (`[[deploy]]` present: magicmirror, | |
| 499 | + | // wam, mnw-cli) SHOULD call it. Bento is both builder and installer | |
| 500 | + | // there, so it knows the service host, and nothing downstream will |
Lines truncated
| @@ -1,0 +1,227 @@ | |||
| 1 | + | //! The Apple-only host functions: notarization, and reading what the notary | |
| 2 | + | //! said. | |
| 3 | + | ||
| 4 | + | use super::RecipeCtx; | |
| 5 | + | use super::rhai_err; | |
| 6 | + | use crate::events::{self, Event}; | |
| 7 | + | use anyhow::Result; | |
| 8 | + | use rhai::{Engine, EvalAltResult}; | |
| 9 | + | use std::sync::Arc; | |
| 10 | + | ||
| 11 | + | /// macOS signing/notarization host functions. Thin wrappers over the right | |
| 12 | + | /// shell incantations, dispatched through the named host's executor. On the mac | |
| 13 | + | /// host (`transport = "agent"`) they run via the in-session `ops-agent`, the only | |
| 14 | + | /// context where codesign can use the Developer ID key (design §7 "THE WALL"); a | |
| 15 | + | /// plain SSH session cannot. Each is gated by the host's `sign` capability. | |
| 16 | + | pub(super) fn register_macos_fns(engine: &mut Engine, ctx: &Arc<RecipeCtx>) { | |
| 17 | + | { | |
| 18 | + | let ctx = ctx.clone(); | |
| 19 | + | engine.register_fn( | |
| 20 | + | "verify_gatekeeper", | |
| 21 | + | move |host: &str, path: &str| -> Result<bool, Box<EvalAltResult>> { | |
| 22 | + | // spctl has no JSON mode, so assess on-host and decide there, | |
| 23 | + | // emitting an unambiguous sentinel as the final line. We match the | |
| 24 | + | // sentinel rather than substring-hunting `source=Notarized...` in a | |
| 25 | + | // 2000-char tail: truncation only drops the front, so the sentinel | |
| 26 | + | // is always present, and it can't be spoofed by spctl's own prose. | |
| 27 | + | // The full assess output is still streamed to the step log. | |
| 28 | + | let q = ops_core::remote::sh_quote(path); | |
| 29 | + | let cmd = format!( | |
| 30 | + | "out=$(spctl --assess -vv --type install {q} 2>&1); printf '%s\\n' \"$out\"; \ | |
| 31 | + | printf '%s' \"$out\" | grep -q 'source=Notarized Developer ID' \ | |
| 32 | + | && echo BENTO_GATEKEEPER_OK || echo BENTO_GATEKEEPER_FAIL", | |
| 33 | + | ); | |
| 34 | + | let (_, tail) = ctx.run(host, &cmd).map_err(rhai_err)?; | |
| 35 | + | let accepted = tail.contains("BENTO_GATEKEEPER_OK"); | |
| 36 | + | // Record the verdict for the publish gate. A rejection also | |
| 37 | + | // fails the step, so the matrix shows red and `publish` is barred | |
| 38 | + | // even if the recipe ignores the returned bool. | |
| 39 | + | ctx.set_gatekeeper_ok(accepted); | |
| 40 | + | if !accepted { | |
| 41 | + | ctx.fail_current_step(); | |
| 42 | + | } | |
| 43 | + | Ok(accepted) | |
| 44 | + | }, | |
| 45 | + | ); | |
| 46 | + | } | |
| 47 | + | { | |
| 48 | + | let ctx = ctx.clone(); | |
| 49 | + | engine.register_fn( | |
| 50 | + | "codesign", | |
| 51 | + | move |host: &str, identity: &str, path: &str| -> Result<(), Box<EvalAltResult>> { | |
| 52 | + | let cmd = format!( | |
| 53 | + | "codesign --force --options runtime --timestamp --sign {} {}", | |
| 54 | + | ops_core::remote::sh_quote(identity), | |
| 55 | + | ops_core::remote::sh_quote(path), | |
| 56 | + | ); | |
| 57 | + | let (code, _) = ctx.run(host, &cmd).map_err(rhai_err)?; | |
| 58 | + | if code != 0 { | |
| 59 | + | return Err(rhai_err("codesign failed")); | |
| 60 | + | } | |
| 61 | + | Ok(()) | |
| 62 | + | }, | |
| 63 | + | ); | |
| 64 | + | } | |
| 65 | + | { | |
| 66 | + | let ctx = ctx.clone(); | |
| 67 | + | engine.register_fn( | |
| 68 | + | "staple", | |
| 69 | + | move |host: &str, path: &str| -> Result<(), Box<EvalAltResult>> { | |
| 70 | + | let (code, _) = ctx | |
| 71 | + | .run( | |
| 72 | + | host, | |
| 73 | + | &format!("xcrun stapler staple {}", ops_core::remote::sh_quote(path)), | |
| 74 | + | ) | |
| 75 | + | .map_err(rhai_err)?; | |
| 76 | + | if code != 0 { | |
| 77 | + | return Err(rhai_err("stapler failed")); | |
| 78 | + | } | |
| 79 | + | Ok(()) | |
| 80 | + | }, | |
| 81 | + | ); | |
| 82 | + | } | |
| 83 | + | { | |
| 84 | + | let ctx = ctx.clone(); | |
| 85 | + | engine.register_fn( | |
| 86 | + | "notarize", | |
| 87 | + | move |host: &str, path: &str| -> Result<String, Box<EvalAltResult>> { | |
| 88 | + | ctx.notarize(host, path).map_err(rhai_err) | |
| 89 | + | }, | |
| 90 | + | ); | |
| 91 | + | } | |
| 92 | + | { | |
| 93 | + | let ctx = ctx.clone(); | |
| 94 | + | engine.register_fn( | |
| 95 | + | "keychain_open", | |
| 96 | + | move |host: &str, name: &str| -> Result<(), Box<EvalAltResult>> { | |
| 97 | + | // The full build-keychain lifecycle lives in dist/build-keychain.sh | |
| 98 | + | // (design §7); this drives it by name so the recipe stays short. | |
| 99 | + | let (code, _) = ctx | |
| 100 | + | .run( | |
| 101 | + | host, | |
| 102 | + | &format!( | |
| 103 | + | ". ~/.tauri/passwords.env && ./dist/build-keychain.sh open {}", | |
| 104 | + | ops_core::remote::sh_quote(name) | |
| 105 | + | ), | |
| 106 | + | ) | |
| 107 | + | .map_err(rhai_err)?; | |
| 108 | + | if code != 0 { | |
| 109 | + | return Err(rhai_err("keychain_open failed")); | |
| 110 | + | } | |
| 111 | + | Ok(()) | |
| 112 | + | }, | |
| 113 | + | ); | |
| 114 | + | } | |
| 115 | + | { | |
| 116 | + | let ctx = ctx.clone(); | |
| 117 | + | engine.register_fn( | |
| 118 | + | "keychain_close", | |
| 119 | + | move |host: &str, name: &str| -> Result<(), Box<EvalAltResult>> { | |
| 120 | + | let _ = ctx.run( | |
| 121 | + | host, | |
| 122 | + | &format!( | |
| 123 | + | "./dist/build-keychain.sh close {}", | |
| 124 | + | ops_core::remote::sh_quote(name) | |
| 125 | + | ), | |
| 126 | + | ); | |
| 127 | + | Ok(()) | |
| 128 | + | }, | |
| 129 | + | ); | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | /// True iff `notarytool --output-format json` output reports `status: Accepted`. | |
| 134 | + | /// Isolates the JSON object (`{`..`}`) from any shell-sourcing noise and reads | |
| 135 | + | /// the typed `status` field, rather than substring-matching `"status":"Accepted"` | |
| 136 | + | /// in a possibly-truncated tail — which could match the literal inside an error | |
| 137 | + | /// message or miss it across a whitespace variant. Fails closed: any parse or | |
| 138 | + | /// field miss returns false. | |
| 139 | + | pub(super) fn notary_accepted(output: &str) -> bool { | |
| 140 | + | let (Some(start), Some(end)) = (output.find('{'), output.rfind('}')) else { | |
| 141 | + | return false; | |
| 142 | + | }; | |
| 143 | + | if start > end { | |
| 144 | + | return false; | |
| 145 | + | } | |
| 146 | + | serde_json::from_str::<serde_json::Value>(&output[start..=end]) | |
| 147 | + | .ok() | |
| 148 | + | .and_then(|v| { | |
| 149 | + | v.get("status") | |
| 150 | + | .and_then(|s| s.as_str()) | |
| 151 | + | .map(|s| s.eq_ignore_ascii_case("accepted")) | |
| 152 | + | }) | |
| 153 | + | .unwrap_or(false) | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | impl RecipeCtx { | |
| 157 | + | /// `xcrun notarytool submit --wait` with bounded retry (the one flaky, | |
| 158 | + | /// network-bound step). Emits `NotarizeRetry` per attempt. | |
| 159 | + | fn notarize(self: &Arc<Self>, host: &str, path: &str) -> Result<String> { | |
| 160 | + | const MAX_ATTEMPTS: u32 = 3; | |
| 161 | + | let backoff = self | |
| 162 | + | .cfg | |
| 163 | + | .notarize_backoff_secs | |
| 164 | + | .map_or(std::time::Duration::from_secs(15), |s| { | |
| 165 | + | std::time::Duration::from_secs(s) | |
| 166 | + | }); | |
| 167 | + | let cmd = format!( | |
| 168 | + | ". ~/.tauri/passwords.env && xcrun notarytool submit {} \ | |
| 169 | + | --key \"$NOTARY_KEY\" --key-id \"$NOTARY_KEY_ID\" --issuer \"$NOTARY_ISSUER\" \ | |
| 170 | + | --wait --output-format json", | |
| 171 | + | ops_core::remote::sh_quote(path), | |
| 172 | + | ); | |
| 173 | + | let mut last = String::new(); | |
| 174 | + | for attempt in 1..=MAX_ATTEMPTS { | |
| 175 | + | let (code, tail) = self.run(host, &cmd)?; | |
| 176 | + | if code == 0 && notary_accepted(&tail) { | |
| 177 | + | return Ok(tail); | |
| 178 | + | } | |
| 179 | + | last = tail; | |
| 180 | + | if attempt < MAX_ATTEMPTS { | |
| 181 | + | events::emit( | |
| 182 | + | &self.events, | |
| 183 | + | Event::NotarizeRetry { | |
| 184 | + | app: self.app.clone(), | |
| 185 | + | target: self.target, | |
| 186 | + | attempt, | |
| 187 | + | reason: format!("exit {code}"), | |
| 188 | + | }, | |
| 189 | + | ); | |
| 190 | + | self.rt.block_on(tokio::time::sleep(backoff)); | |
| 191 | + | } | |
| 192 | + | } | |
| 193 | + | anyhow::bail!("notarization failed after {MAX_ATTEMPTS} attempts: {last}") | |
| 194 | + | } | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | #[cfg(test)] | |
| 198 | + | mod tests { | |
| 199 | + | use super::*; | |
| 200 | + | ||
| 201 | + | #[test] | |
| 202 | + | fn notary_accepted_parses_status_field() { | |
| 203 | + | assert!(notary_accepted( | |
| 204 | + | r#"{"id":"abc","status":"Accepted","message":"ok"}"# | |
| 205 | + | )); | |
| 206 | + | // Embedded in shell-sourcing noise: the object is isolated and parsed. | |
| 207 | + | assert!(notary_accepted( | |
| 208 | + | "sourcing env...\n{\n \"status\": \"Accepted\"\n}\nbye" | |
| 209 | + | )); | |
| 210 | + | // Whitespace variant that a tight substring `"status":"Accepted"` misses. | |
| 211 | + | assert!(notary_accepted(r#"{ "status" : "Accepted" }"#)); | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | #[test] | |
| 215 | + | fn notary_accepted_rejects_non_accepted_and_garbage() { | |
| 216 | + | assert!(!notary_accepted(r#"{"status":"Invalid"}"#)); | |
| 217 | + | assert!(!notary_accepted(r#"{"status":"In Progress"}"#)); | |
| 218 | + | assert!(!notary_accepted("no json here")); | |
| 219 | + | assert!(!notary_accepted("")); // empty / truncated -> fail closed | |
| 220 | + | // A truncated tail whose opening brace was cut off cannot parse -> closed. | |
| 221 | + | assert!(!notary_accepted(r#""status":"Accepted"}"#)); | |
| 222 | + | // The literal appearing inside an error string must NOT pass as success. | |
| 223 | + | assert!(!notary_accepted( | |
| 224 | + | r#"{"status":"Invalid","message":"expected status:Accepted"}"# | |
| 225 | + | )); | |
| 226 | + | } | |
| 227 | + | } |
| @@ -1,0 +1,246 @@ | |||
| 1 | + | //! Rhai recipe engine + host-function API. | |
| 2 | + | //! | |
| 3 | + | //! A `(app, target)` resolves to a `.rhai` recipe composed from a shared step | |
| 4 | + | //! vocabulary. The daemon embeds Rhai and registers the host functions recipes | |
| 5 | + | //! call; the recipe is the orchestration, the host functions are the | |
| 6 | + | //! privileged primitives (run a command, read a secret, collect artifacts, | |
| 7 | + | //! publish). Recipes are otherwise sandboxed — no arbitrary FS/network except | |
| 8 | + | //! through these functions — matching the Balanced Breakfast plugin model. | |
| 9 | + | //! | |
| 10 | + | //! Rhai is synchronous; the engine runs each recipe on a blocking thread | |
| 11 | + | //! (`spawn_blocking`, see [`crate::runner`]) and host functions bridge to async | |
| 12 | + | //! work via `Handle::block_on`. That is sound only off a runtime worker thread, | |
| 13 | + | //! which `spawn_blocking` guarantees. | |
| 14 | + | //! | |
| 15 | + | //! One file per thing the engine actually does. `ctx` holds the state a recipe | |
| 16 | + | //! is handed; `host_fns` is the API surface it may call; the rest are the jobs | |
| 17 | + | //! behind those names. | |
| 18 | + | ||
| 19 | + | use crate::domain::{Step, StepRunId}; | |
| 20 | + | use crate::topology::Kind; | |
| 21 | + | use ops_core::live_log::LiveLog; | |
| 22 | + | use ops_exec::{Action, ObserveKind}; | |
| 23 | + | use rhai::EvalAltResult; | |
| 24 | + | use std::sync::Arc; | |
| 25 | + | use tokio::sync::Mutex as AsyncMutex; | |
| 26 | + | ||
| 27 | + | mod crates_io; | |
| 28 | + | mod ctx; | |
| 29 | + | mod deploy; | |
| 30 | + | mod git; | |
| 31 | + | mod host_fns; | |
| 32 | + | mod macos; | |
| 33 | + | mod publish; | |
| 34 | + | mod version; | |
| 35 | + | ||
| 36 | + | pub mod collect; | |
| 37 | + | ||
| 38 | + | pub use ctx::RecipeCtx; | |
| 39 | + | pub use git::{ | |
| 40 | + | app_dir_in_worktree, expand_tilde, git_fetch_cmd, git_rev_parse_cmd, git_tag_exists_cmd, | |
| 41 | + | git_toplevel_and_prefix_cmd, git_worktree_add_cmd, git_worktree_pin_cmd, | |
| 42 | + | git_worktree_probe_cmd, git_worktree_prune_cmd, parse_toplevel_and_prefix, repo_dir_name, | |
| 43 | + | worktree_failure_reason, | |
| 44 | + | }; | |
| 45 | + | pub use host_fns::build_engine; | |
| 46 | + | pub use version::{ | |
| 47 | + | // `check_version_consistency` has no caller anywhere in the tree, only its | |
| 48 | + | // own four tests. It is re-exported to keep it exactly as reachable as it | |
| 49 | + | // was before this split rather than to hide that: narrowing it to | |
| 50 | + | // `pub(super)` turns it into a dead-code error, which is the finding. | |
| 51 | + | check_version_consistency, | |
| 52 | + | git_show_file_cmd, | |
| 53 | + | version_from_repo, | |
| 54 | + | version_sources, | |
| 55 | + | versions_agree, | |
| 56 | + | }; | |
| 57 | + | ||
| 58 | + | /// The capability label for a command, derived from the open recipe step. A | |
| 59 | + | /// recipe's `sh("mbp", …)` under `step("sign")` becomes an `Action::Sign`, gated | |
| 60 | + | /// by the mac host's `sign` grant — so recipes stay unchanged while every command | |
| 61 | + | /// is capability-checked at its transport. `Verify` is read-only (an observe). | |
| 62 | + | /// The capability a step's commands are gated on. | |
| 63 | + | /// | |
| 64 | + | /// `Verify` depends on what is being released, which is the one place this is | |
| 65 | + | /// not a property of the step alone. An app's verify is a Gatekeeper check on a | |
| 66 | + | /// signed bundle, and the `gatekeeper` observe is granted implicitly to hosts | |
| 67 | + | /// that can `sign` (`CapabilitySet::from_tokens`) precisely so that pairing | |
| 68 | + | /// holds. A library's verify is a crate preflight: it runs `cargo` on the build | |
| 69 | + | /// host and asks the registry a question. Gating that on Gatekeeper asks a Linux | |
| 70 | + | /// host for a macOS code-signing capability it can never honestly hold, and the | |
| 71 | + | /// only way to satisfy it would be to declare the capability falsely. | |
| 72 | + | fn action_for(step: Step, kind: Kind) -> Action { | |
| 73 | + | match step { | |
| 74 | + | Step::Checkout | Step::Prebuild | Step::Build => Action::Build, | |
| 75 | + | Step::Sign => Action::Sign, | |
| 76 | + | Step::Notarize => Action::Notarize, | |
| 77 | + | Step::Staple => Action::Staple, | |
| 78 | + | Step::Package => Action::Package, | |
| 79 | + | Step::Verify => match kind { | |
| 80 | + | Kind::App => Action::Observe(ObserveKind::Custom("gatekeeper".into())), | |
| 81 | + | // Running the build toolchain to inspect a crate or a service | |
| 82 | + | // binary, which is what `build` means on a host. Neither has a | |
| 83 | + | // bundle for Gatekeeper to have an opinion about. | |
| 84 | + | Kind::Library | Kind::Service => Action::Build, | |
| 85 | + | }, | |
| 86 | + | // Publish/Collect/Handoff run on the daemon, not through a host | |
| 87 | + | // executor; this label only applies if a recipe runs a bare `sh` while | |
| 88 | + | // one is open. `handoff` is the daemon's own post-recipe motion and no | |
| 89 | + | // recipe should open it at all — naming it here costs nothing and beats | |
| 90 | + | // a wildcard that would silently absorb the next step somebody adds. | |
| 91 | + | Step::Publish | Step::Collect | Step::Handoff => Action::Package, | |
| 92 | + | // The one step that dispatches to a host OUTSIDE the build topology. | |
| 93 | + | // Every command a recipe runs while `deploy` is open — the install, the | |
| 94 | + | // restart, the health assertion — carries this action, so it reaches the | |
| 95 | + | // service host only through the deploy grant and reaches a build host | |
| 96 | + | // not at all (no build host is granted `deploy`). | |
| 97 | + | Step::Deploy => Action::Deploy, | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | /// The currently-open step within a recipe run: its DB row id, which step it | |
| 102 | + | /// is, and the live-log sink that `sh`/`log` stream into. | |
| 103 | + | struct StepState { | |
| 104 | + | run_id: StepRunId, | |
| 105 | + | step: Step, | |
| 106 | + | log: Arc<AsyncMutex<LiveLog>>, | |
| 107 | + | /// Set when something in the step recorded a hard failure the recipe did | |
| 108 | + | /// not abort on (e.g. `verify_gatekeeper` rejected the artifact but the | |
| 109 | + | /// recipe ignored the bool). Forces the step's recorded status to `Failed` | |
| 110 | + | /// and bars `publish` (the step-success ledger). | |
| 111 | + | failed: bool, | |
| 112 | + | /// Wall-clock deadline for this step. A command that runs past it fails the | |
| 113 | + | /// step (and unwinds the recipe) rather than wedging under the old | |
| 114 | + | /// whole-build guillotine, which a legitimate 5-target fan-out plus notary | |
| 115 | + | /// queueing could trip — mismarking every target failed while the blocking | |
| 116 | + | /// recipe bodies kept signing. | |
| 117 | + | deadline: std::time::Instant, | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | /// Per-step wall-clock budget: a generous ceiling that catches a wedged command | |
| 121 | + | /// (a hung ssh, a stuck notary poll) without killing legitimately slow work. | |
| 122 | + | /// Bounding each step, rather than the whole build, keeps one slow step from | |
| 123 | + | /// being blamed on another and keeps a fan-out of slow-but-fine targets from | |
| 124 | + | /// being guillotined. `Config::step_timeout_secs` overrides these per-kind | |
| 125 | + | /// defaults for every step; see [`RecipeCtx::step_budget`]. | |
| 126 | + | fn default_step_budget(step: Step) -> std::time::Duration { | |
| 127 | + | use std::time::Duration; | |
| 128 | + | let mins = match step { | |
| 129 | + | Step::Checkout => 10, | |
| 130 | + | // clippy + full test suite, cold, on a workspace. | |
| 131 | + | Step::Prebuild => 45, | |
| 132 | + | // cargo tauri build, cold, universal bundles. | |
| 133 | + | Step::Build => 90, | |
| 134 | + | Step::Sign => 15, | |
| 135 | + | // Apple's notary queue + this step's bounded retries. | |
| 136 | + | Step::Notarize => 60, | |
| 137 | + | Step::Staple => 10, | |
| 138 | + | Step::Package => 30, | |
| 139 | + | Step::Verify => 10, | |
| 140 | + | // rsync of multi-GiB artifacts off the build host. | |
| 141 | + | Step::Collect => 30, | |
| 142 | + | Step::Publish => 20, | |
| 143 | + | // A binary push, an install, a unit restart, and a health poll. Minutes | |
| 144 | + | // of work; the ceiling is for a wedged transport, not slow work. | |
| 145 | + | Step::Deploy => 15, | |
| 146 | + | // Unused by any recipe — the daemon runs the handoff itself, outside a | |
| 147 | + | // step's clock — and matched to `collect`, since it moves the same | |
| 148 | + | // bytes the same way and would wedge for the same reasons. | |
| 149 | + | Step::Handoff => 30, | |
| 150 | + | }; | |
| 151 | + | Duration::from_secs(mins * 60) | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | /// Where a service's binary is staged on the host that will run it, before the | |
| 155 | + | /// privileged installer moves it into place. | |
| 156 | + | /// | |
| 157 | + | /// A fixed, unguessable-by-accident path rather than a recipe-chosen one, | |
| 158 | + | /// because the installer refuses any source outside it. That refusal is the | |
| 159 | + | /// only thing standing between the NOPASSWD sudo grant and `install`-as-root to | |
| 160 | + | /// an arbitrary path, so both ends have to name the same constant. `/var/tmp` | |
| 161 | + | /// rather than `/tmp` so a staged binary survives a `PrivateTmp` unit and a | |
| 162 | + | /// systemd tmpfiles sweep between staging and install. | |
| 163 | + | pub const DEPLOY_STAGING_ROOT: &str = "/var/tmp/bento-deploy"; | |
| 164 | + | ||
| 165 | + | // Rhai host functions return `Result<_, Box<EvalAltResult>>` by convention, so | |
| 166 | + | // this bridge must yield the boxed form to be usable with `.map_err(rhai_err)`. | |
| 167 | + | #[allow( | |
| 168 | + | clippy::unnecessary_box_returns, | |
| 169 | + | reason = "rhai's error type is used boxed throughout its host-function API" | |
| 170 | + | )] | |
| 171 | + | fn rhai_err(e: impl std::fmt::Display) -> Box<EvalAltResult> { | |
| 172 | + | Box::new(EvalAltResult::ErrorRuntime( | |
| 173 | + | e.to_string().into(), | |
| 174 | + | rhai::Position::NONE, | |
| 175 | + | )) | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | #[cfg(test)] | |
| 179 | + | mod tests { | |
| 180 | + | use super::*; | |
| 181 | + | ||
| 182 | + | // A library's verify is a crate preflight, not a Gatekeeper check on a | |
| 183 | + | // signed bundle. Gating it on `gatekeeper` asked a Linux host for a macOS | |
| 184 | + | // code-signing capability it can never hold, so the step was denied before | |
| 185 | + | // it ran a command; the denial then surfaced as "no crates.io credentials", | |
| 186 | + | // which is not what went wrong. The only way to satisfy the old gate was to | |
| 187 | + | // declare the capability falsely in the topology. | |
| 188 | + | #[test] | |
| 189 | + | fn a_library_verify_is_not_gated_on_gatekeeper() { | |
| 190 | + | assert_eq!( | |
| 191 | + | action_for(Step::Verify, Kind::Library), | |
| 192 | + | Action::Build, | |
| 193 | + | "a crate preflight runs the build toolchain; that is what it needs", | |
| 194 | + | ); | |
| 195 | + | assert_eq!( | |
| 196 | + | action_for(Step::Verify, Kind::App), | |
| 197 | + | Action::Observe(ObserveKind::Custom("gatekeeper".into())), | |
| 198 | + | "an app's verify still proves the bundle is signed and notarized", | |
| 199 | + | ); | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | // The capability the default host grant actually carries. Without this the | |
| 203 | + | // fix above is only true by inspection. | |
| 204 | + | #[test] | |
| 205 | + | fn a_default_host_can_run_a_library_verify_and_not_an_app_one() { | |
| 206 | + | let caps = | |
| 207 | + | ops_exec::CapabilitySet::from_tokens(["build", "package"], ["build-log", "artifact"]); | |
| 208 | + | assert!(caps.permits(&action_for(Step::Verify, Kind::Library))); | |
| 209 | + | assert!(!caps.permits(&action_for(Step::Verify, Kind::App))); | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | // Every other step is a property of the step alone; verify is the one that | |
| 213 | + | // depends on what is being released. | |
| 214 | + | #[test] | |
| 215 | + | fn no_other_step_changes_with_the_kind() { | |
| 216 | + | for step in [ | |
| 217 | + | Step::Checkout, | |
| 218 | + | Step::Prebuild, | |
| 219 | + | Step::Build, | |
| 220 | + | Step::Sign, | |
| 221 | + | Step::Notarize, | |
| 222 | + | Step::Staple, | |
| 223 | + | Step::Package, | |
| 224 | + | Step::Publish, | |
| 225 | + | Step::Collect, | |
| 226 | + | ] { | |
| 227 | + | assert_eq!( | |
| 228 | + | action_for(step, Kind::App), | |
| 229 | + | action_for(step, Kind::Library), | |
| 230 | + | "{step:?} should not depend on the kind", | |
| 231 | + | ); | |
| 232 | + | } | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | #[test] | |
| 236 | + | fn every_step_has_a_nonzero_default_budget() { | |
| 237 | + | // A zero/missing budget would deadline-fail a step instantly. Cover the | |
| 238 | + | // whole matrix so a new Step variant can't silently get a 0 budget. | |
| 239 | + | for step in Step::ALL { | |
| 240 | + | assert!( | |
| 241 | + | default_step_budget(step) >= std::time::Duration::from_mins(1), | |
| 242 | + | "{step} budget must be a sane ceiling", | |
| 243 | + | ); | |
| 244 | + | } | |
| 245 | + | } | |
| 246 | + | } |
| @@ -1,0 +1,275 @@ | |||
| 1 | + | //! Shipping: the all-targets-green gate, and the publish that records a | |
| 2 | + | //! release. | |
| 3 | + | ||
| 4 | + | use super::RecipeCtx; | |
| 5 | + | use super::collect::sha256_file; | |
| 6 | + | use crate::domain::{AppId, Target, Version}; | |
| 7 | + | use crate::events::{self, Event}; | |
| 8 | + | use crate::ota::{PublishAuthority, Release}; | |
| 9 | + | use anyhow::{Context as _, Result}; | |
| 10 | + | use rhai::Map; | |
| 11 | + | use std::path::PathBuf; | |
| 12 | + | use std::sync::Arc; | |
| 13 | + | ||
| 14 | + | impl RecipeCtx { | |
| 15 | + | /// The all-targets-green gate: err unless every declared target OTHER than | |
| 16 | + | /// the one publishing has a latest `target_runs` row of `ok` for this | |
| 17 | + | /// `(app, version)`. A sibling with no run, a running run, or a failed | |
| 18 | + | /// latest run all block the publish, naming what is not green. | |
| 19 | + | fn assert_siblings_green(self: &Arc<Self>, declared: &[Target]) -> Result<()> { | |
| 20 | + | let me = self.clone(); | |
| 21 | + | let (app_s, ver_s) = (self.app.to_string(), self.version.to_string()); | |
| 22 | + | let rows: Vec<(String, String)> = self.rt.block_on(async move { | |
| 23 | + | sqlx::query_as( | |
| 24 | + | "SELECT target, status FROM target_runs tr | |
| 25 | + | WHERE app = ?1 AND version = ?2 | |
| 26 | + | AND id = (SELECT MAX(id) FROM target_runs | |
| 27 | + | WHERE app = ?1 AND version = ?2 AND target = tr.target)", | |
| 28 | + | ) | |
| 29 | + | .bind(app_s) | |
| 30 | + | .bind(ver_s) | |
| 31 | + | .fetch_all(&me.pool) | |
| 32 | + | .await | |
| 33 | + | .unwrap_or_default() | |
| 34 | + | }); | |
| 35 | + | let status_of = |t: &Target| -> Option<String> { | |
| 36 | + | let key = t.to_string(); | |
| 37 | + | rows.iter() | |
| 38 | + | .find(|(name, _)| name == &key) | |
| 39 | + | .map(|(_, s)| s.clone()) | |
| 40 | + | }; | |
| 41 | + | let not_green: Vec<String> = declared | |
| 42 | + | .iter() | |
| 43 | + | .filter(|t| **t != self.target) // the publishing target is the last mile | |
| 44 | + | .filter(|t| status_of(t).as_deref() != Some("ok")) | |
| 45 | + | .map(|t| format!("{t} ({})", status_of(t).unwrap_or_else(|| "no run".into()))) | |
| 46 | + | .collect(); | |
| 47 | + | anyhow::ensure!( | |
| 48 | + | not_green.is_empty(), | |
| 49 | + | "all-targets-green gate: refusing to publish {} {} — not green: {}", | |
| 50 | + | self.app, | |
| 51 | + | self.version, | |
| 52 | + | not_green.join(", "), | |
| 53 | + | ); | |
| 54 | + | Ok(()) | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | pub(super) fn publish( | |
| 58 | + | self: &Arc<Self>, | |
| 59 | + | channel: &str, | |
| 60 | + | app: &str, | |
| 61 | + | target: &str, | |
| 62 | + | version: &str, | |
| 63 | + | artifact: &str, | |
| 64 | + | meta: &Map, | |
| 65 | + | ) -> Result<String> { | |
| 66 | + | // Never let a superseded build ship. This is the last and most important | |
| 67 | + | // cooperative-cancel checkpoint: even if a long-running step finished | |
| 68 | + | // after supersession, the artifact must not reach the backend. | |
| 69 | + | anyhow::ensure!( | |
| 70 | + | !self.is_cancelled(), | |
| 71 | + | "build superseded by a newer request; refusing to publish" | |
| 72 | + | ); | |
| 73 | + | // Opt-in all-targets-green gate: refuse a partial release. Every OTHER | |
| 74 | + | // declared target of this (app, version) must have a successful latest | |
| 75 | + | // run before this one ships, so macOS can't publish while windows is red | |
| 76 | + | // or still building. The publishing target itself is the last mile (it | |
| 77 | + | // reached publish, so its steps passed) and is not required to be green | |
| 78 | + | // in the ledger yet. | |
| 79 | + | if let Some(declared) = self.all_green_required() { | |
| 80 | + | self.assert_siblings_green(&declared)?; | |
| 81 | + | } | |
| 82 | + | let backend = self | |
| 83 | + | .ota | |
| 84 | + | .get(channel) | |
| 85 | + | .ok_or_else(|| anyhow::anyhow!("unknown publish channel `{channel}`"))?; | |
| 86 | + | let target: Target = target.parse().map_err(|e: String| anyhow::anyhow!(e))?; | |
| 87 | + | let version = Version::parse(version).map_err(|e| anyhow::anyhow!(e))?; | |
| 88 | + | let app = AppId::new(app); | |
| 89 | + | ||
| 90 | + | // The backend must actually handle this target (e.g. the desktop updater | |
| 91 | + | // disclaims iOS) — otherwise publish would push an artifact through a | |
| 92 | + | // backend that does not support it. | |
| 93 | + | anyhow::ensure!( | |
| 94 | + | backend.supports(target), | |
| 95 | + | "publish channel `{channel}` does not support target {target}", | |
| 96 | + | ); | |
| 97 | + | ||
| 98 | + | // Monotonicity: never publish a version that is not strictly newer than | |
| 99 | + | // the latest already published for this (app, target, channel). Without | |
| 100 | + | // this an older build could republish over a live newer release. The | |
| 101 | + | // `releases` column is TEXT, so compare by parsed semver precedence | |
| 102 | + | // (Version: Ord), not lexically. | |
| 103 | + | { | |
| 104 | + | let (app_s, target_s, chan_s) = | |
| 105 | + | (app.to_string(), target.to_string(), channel.to_string()); | |
| 106 | + | let me = self.clone(); | |
| 107 | + | let latest: Option<Version> = self.rt.block_on(async move { | |
| 108 | + | let rows: Vec<(String,)> = sqlx::query_as( | |
| 109 | + | "SELECT version FROM releases WHERE app = ? AND target = ? AND channel = ?", | |
| 110 | + | ) | |
| 111 | + | .bind(app_s) | |
| 112 | + | .bind(target_s) | |
| 113 | + | .bind(chan_s) | |
| 114 | + | .fetch_all(&me.pool) | |
| 115 | + | .await | |
| 116 | + | .unwrap_or_default(); | |
| 117 | + | rows.into_iter() | |
| 118 | + | .filter_map(|(v,)| Version::parse(&v).ok()) | |
| 119 | + | .max() | |
| 120 | + | }); | |
| 121 | + | if let Some(latest) = latest { | |
| 122 | + | anyhow::ensure!( | |
| 123 | + | version > latest, | |
| 124 | + | "refusing to publish {app} {version} to `{channel}` ({target}): \ | |
| 125 | + | not newer than the last published {latest}", | |
| 126 | + | ); | |
| 127 | + | } | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | // Step-success ledger (the Bento analogue of Sando's gate fail-closed), | |
| 131 | + | // minted as an unforgeable PublishAuthority. `backend.publish` cannot be | |
| 132 | + | // called without one, so the unverified/post-failure ship path is sealed | |
| 133 | + | // at the type level rather than guarded by a separate runtime check. | |
| 134 | + | let authority = { | |
| 135 | + | let failed = self.failed_steps_snapshot(); | |
| 136 | + | let gatekeeper = self.gatekeeper_ok(); | |
| 137 | + | PublishAuthority::prove(target, failed.as_slice(), gatekeeper)? | |
| 138 | + | }; | |
| 139 | + | let notes = meta | |
| 140 | + | .get("notes") | |
| 141 | + | .and_then(|v| v.clone().into_string().ok()) | |
| 142 | + | .unwrap_or_default(); | |
| 143 | + | // Resolve the artifact relative to the collected dist dir if not absolute. | |
| 144 | + | let artifact_path = { | |
| 145 | + | let p = PathBuf::from(artifact); | |
| 146 | + | if p.is_absolute() { | |
| 147 | + | p | |
| 148 | + | } else { | |
| 149 | + | self.collect_dest(app.as_str(), &version.to_string()) | |
| 150 | + | .join(artifact) | |
| 151 | + | } | |
| 152 | + | }; | |
| 153 | + | let rel = Release { | |
| 154 | + | app: &app, | |
| 155 | + | target, | |
| 156 | + | version: &version, | |
| 157 | + | notes, | |
| 158 | + | }; | |
| 159 | + | let receipt = backend | |
| 160 | + | .publish(&rel, &artifact_path, &authority) | |
| 161 | + | .with_context(|| format!("publish to `{channel}`"))?; | |
| 162 | + | // Record for idempotency / monotonicity. This write is CHECKED, not | |
| 163 | + | // fire-and-forget: a swallowed failure here would silently re-arm the | |
| 164 | + | // monotonicity guard (which reads this same table), letting an older | |
| 165 | + | // version republish over a live release. Concurrent same-(app,target) | |
| 166 | + | // publishers can't race the read-then-insert because the latest-wins slot | |
| 167 | + | // (state::ActiveSlot) serializes them and a superseded run is cancelled | |
| 168 | + | // before it reaches publish. | |
| 169 | + | // The artifact's hash, recorded so the release ledger says exactly which | |
| 170 | + | // bytes shipped. Prefer the digest computed at `collect`; fall back to | |
| 171 | + | // hashing the file now (an absolute-path artifact never routed through | |
| 172 | + | // `collect`). A hash failure must not fail an already-published release, | |
| 173 | + | // so degrade to NULL rather than erroring. | |
| 174 | + | let artifact_hash: Option<String> = artifact_path | |
| 175 | + | .file_name() | |
| 176 | + | .and_then(|n| n.to_str()) | |
| 177 | + | .and_then(|n| self.artifact_hash(n)) | |
| 178 | + | .or_else(|| sha256_file(&artifact_path).ok()); | |
| 179 | + | let me = self.clone(); | |
| 180 | + | let (app_s, target_s, ver_s, chan_s) = ( | |
| 181 | + | app.to_string(), | |
| 182 | + | target.to_string(), | |
| 183 | + | version.to_string(), | |
| 184 | + | channel.to_string(), | |
| 185 | + | ); | |
| 186 | + | self.rt | |
| 187 | + | .block_on(async move { | |
| 188 | + | sqlx::query( | |
| 189 | + | "INSERT OR IGNORE INTO releases (app, target, version, channel, artifact_hash, published_at) | |
| 190 | + | VALUES (?, ?, ?, ?, ?, ?)", | |
| 191 | + | ) | |
| 192 | + | .bind(app_s) | |
| 193 | + | .bind(target_s) | |
| 194 | + | .bind(ver_s) | |
| 195 | + | .bind(chan_s) | |
| 196 | + | .bind(artifact_hash) | |
| 197 | + | .bind(Self::now()) | |
| 198 | + | .execute(&me.pool) | |
| 199 | + | .await | |
| 200 | + | }) | |
| 201 | + | .context("recording release in the idempotency ledger (artifact published but ledger write failed)")?; | |
| 202 | + | events::emit( | |
| 203 | + | &self.events, | |
| 204 | + | Event::PublishOk { | |
| 205 | + | app: self.app.clone(), | |
| 206 | + | target: self.target, | |
| 207 | + | channel: channel.to_string(), | |
| 208 | + | }, | |
| 209 | + | ); | |
| 210 | + | Ok(receipt) | |
| 211 | + | } | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | #[cfg(test)] | |
| 215 | + | mod tests { | |
| 216 | + | use super::*; | |
| 217 | + | use crate::domain::Step; | |
| 218 | + | ||
| 219 | + | fn target(s: &str) -> Target { | |
| 220 | + | s.parse().unwrap() | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | #[test] | |
| 224 | + | fn publish_gate_blocks_macos_without_verification() { | |
| 225 | + | // Never verified -> blocked, with a message pointing at verify_gatekeeper. | |
| 226 | + | let err = PublishAuthority::prove(target("macos/aarch64"), &[], None).unwrap_err(); | |
| 227 | + | assert!(format!("{err:#}").contains("never verified"), "{err:#}"); | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | #[test] | |
| 231 | + | fn publish_gate_blocks_macos_when_gatekeeper_rejected() { | |
| 232 | + | let err = PublishAuthority::prove(target("macos/aarch64"), &[], Some(false)).unwrap_err(); | |
| 233 | + | assert!( | |
| 234 | + | format!("{err:#}").contains("Gatekeeper rejected"), | |
| 235 | + | "{err:#}" | |
| 236 | + | ); | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | #[test] | |
| 240 | + | fn publish_gate_allows_macos_when_gatekeeper_accepted() { | |
| 241 | + | PublishAuthority::prove(target("macos/aarch64"), &[], Some(true)).unwrap(); | |
| 242 | + | // iOS is gated the same way. | |
| 243 | + | PublishAuthority::prove(target("ios/universal"), &[], Some(true)).unwrap(); | |
| 244 | + | assert!(PublishAuthority::prove(target("ios/universal"), &[], None).is_err()); | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | #[test] | |
| 248 | + | fn publish_gate_does_not_require_gatekeeper_for_non_apple_targets() { | |
| 249 | + | // Linux/Windows aren't notarized; no gatekeeper proof needed. | |
| 250 | + | PublishAuthority::prove(target("linux/x86_64"), &[], None).unwrap(); | |
| 251 | + | PublishAuthority::prove(target("windows/x86_64"), &[], None).unwrap(); | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | #[test] | |
| 255 | + | fn publish_gate_blocks_when_any_prior_step_failed() { | |
| 256 | + | // A failed step bars publish on every target, even a verified macOS one. | |
| 257 | + | let err = | |
| 258 | + | PublishAuthority::prove(target("linux/x86_64"), &[Step::Build], None).unwrap_err(); | |
| 259 | + | assert!( | |
| 260 | + | format!("{err:#}").contains("prior step(s) failed"), | |
| 261 | + | "{err:#}" | |
| 262 | + | ); | |
| 263 | + | assert!( | |
| 264 | + | format!("{err:#}").contains("build"), | |
| 265 | + | "names the failed step: {err:#}" | |
| 266 | + | ); | |
| 267 | + | ||
| 268 | + | let err = PublishAuthority::prove(target("macos/aarch64"), &[Step::Sign], Some(true)) | |
| 269 | + | .unwrap_err(); | |
| 270 | + | assert!( | |
| 271 | + | format!("{err:#}").contains("prior step(s) failed"), | |
| 272 | + | "{err:#}" | |
| 273 | + | ); | |
| 274 | + | } | |
| 275 | + | } |
| @@ -1,0 +1,341 @@ | |||
| 1 | + | //! What version this release is, read from the repository rather than taken on | |
| 2 | + | //! trust, and the drift check between the places it is written down. | |
| 3 | + | ||
| 4 | + | use super::git::expand_tilde; | |
| 5 | + | use crate::domain::Version; | |
| 6 | + | use anyhow::{Context as _, Result}; | |
| 7 | + | ||
| 8 | + | /// Read the app's version from its checkout on the daemon host. With | |
| 9 | + | /// `version_path` set (topology `version_path`), read exactly that file — a | |
| 10 | + | /// `.json` as a Tauri config, anything else as a `Cargo.toml`. Unset (the Tauri | |
| 11 | + | /// default), try `src-tauri/tauri.conf.json` then the root `Cargo.toml`. Used by | |
| 12 | + | /// the runner's default-version path. | |
| 13 | + | pub fn version_from_repo(repo: &str, version_path: Option<&str>) -> Result<Version> { | |
| 14 | + | let root = expand_tilde(repo); | |
| 15 | + | if let Some(vp) = version_path { | |
| 16 | + | let path = root.join(vp); | |
| 17 | + | let raw = std::fs::read_to_string(&path) | |
| 18 | + | .with_context(|| format!("reading version file {}", path.display()))?; | |
| 19 | + | let ver = if std::path::Path::new(vp) | |
| 20 | + | .extension() | |
| 21 | + | .is_some_and(|e| e.eq_ignore_ascii_case("json")) | |
| 22 | + | { | |
| 23 | + | version_from_tauri_json(&raw)? | |
| 24 | + | } else { | |
| 25 | + | version_from_cargo_toml(&raw)? | |
| 26 | + | }; | |
| 27 | + | return Version::parse(&ver).map_err(|e| anyhow::anyhow!(e)); | |
| 28 | + | } | |
| 29 | + | let tauri_conf = root.join("src-tauri").join("tauri.conf.json"); | |
| 30 | + | if tauri_conf.exists() { | |
| 31 | + | let raw = std::fs::read_to_string(&tauri_conf) | |
| 32 | + | .with_context(|| format!("reading {}", tauri_conf.display()))?; | |
| 33 | + | return Version::parse(&version_from_tauri_json(&raw)?).map_err(|e| anyhow::anyhow!(e)); | |
| 34 | + | } | |
| 35 | + | let cargo_toml = root.join("Cargo.toml"); | |
| 36 | + | let raw = std::fs::read_to_string(&cargo_toml).with_context(|| { | |
| 37 | + | format!( | |
| 38 | + | "reading {} (no tauri.conf.json either)", | |
| 39 | + | cargo_toml.display() | |
| 40 | + | ) | |
| 41 | + | })?; | |
| 42 | + | Version::parse(&version_from_cargo_toml(&raw)?).map_err(|e| anyhow::anyhow!(e)) | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | /// Extract `version` from raw `tauri.conf.json` text. | |
| 46 | + | fn version_from_tauri_json(raw: &str) -> Result<String> { | |
| 47 | + | let v: serde_json::Value = serde_json::from_str(raw).context("parsing tauri.conf.json")?; | |
| 48 | + | v.get("version") | |
| 49 | + | .and_then(|x| x.as_str()) | |
| 50 | + | .map(str::to_owned) | |
| 51 | + | .context("no `version` in tauri.conf.json") | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | /// Extract the version from raw `Cargo.toml` text — `[package].version` (a leaf | |
| 55 | + | /// crate) or `[workspace.package].version` (a workspace that sets it). | |
| 56 | + | fn version_from_cargo_toml(raw: &str) -> Result<String> { | |
| 57 | + | let doc: toml::Value = toml::from_str(raw).context("parsing Cargo.toml")?; | |
| 58 | + | doc.get("package") | |
| 59 | + | .and_then(|p| p.get("version")) | |
| 60 | + | .or_else(|| { | |
| 61 | + | doc.get("workspace") | |
| 62 | + | .and_then(|w| w.get("package")) | |
| 63 | + | .and_then(|p| p.get("version")) | |
| 64 | + | }) | |
| 65 | + | .and_then(|v| v.as_str()) | |
| 66 | + | .map(str::to_owned) | |
| 67 | + | .context("no `[package].version` or `[workspace.package].version` in Cargo.toml") | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | /// Cross-check every version source in a repo and confirm they all agree with | |
| 71 | + | /// the version being built, before a single host pulls or compiles. | |
| 72 | + | /// | |
| 73 | + | /// `version_from_repo` reads exactly one file, so a `tauri.conf.json` at 0.5.0 | |
| 74 | + | /// and a root `Cargo.toml` still at 0.4.0 build happily and file artifacts under | |
| 75 | + | /// whichever the runner happened to read. This reads every source present — | |
| 76 | + | /// `version_path` (when set), `src-tauri/tauri.conf.json`, and the root | |
| 77 | + | /// `Cargo.toml` — and fails loudly when any disagree, naming each file and its | |
| 78 | + | /// version. A source that is absent is skipped (a library crate with only a | |
| 79 | + | /// `Cargo.toml` has nothing to disagree with); the check never invents drift. | |
| 80 | + | /// | |
| 81 | + | /// Scope: the JSON/TOML sources bentod itself reads. The iOS `gen/apple/project.yml` | |
| 82 | + | /// path (rewritten by a build-time `sed`) is out of scope here — it is asserted at | |
| 83 | + | /// its own build step — but the same drift class motivated this guard. | |
| 84 | + | pub fn check_version_consistency( | |
| 85 | + | repo: &str, | |
| 86 | + | version_path: Option<&str>, | |
| 87 | + | expected: &Version, | |
| 88 | + | ) -> Result<()> { | |
| 89 | + | let root = expand_tilde(repo); | |
| 90 | + | let mut sources: Vec<(String, String)> = Vec::new(); | |
| 91 | + | for rel in version_sources(version_path) { | |
| 92 | + | let path = root.join(&rel); | |
| 93 | + | // A source that is absent is skipped — a library crate with only a | |
| 94 | + | // `Cargo.toml` has nothing to disagree with — but one the app NAMES | |
| 95 | + | // must be readable, or the check would pass by failing to look. | |
| 96 | + | match std::fs::read_to_string(&path) { | |
| 97 | + | Ok(raw) => sources.push((rel, raw)), | |
| 98 | + | Err(e) if version_path == Some(rel.as_str()) => { | |
| 99 | + | return Err(e).with_context(|| format!("reading version file {}", path.display())); | |
| 100 | + | } | |
| 101 | + | Err(_) => {} | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | versions_agree(repo, &sources, version_path, expected) | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | /// The files a repo can state its version in, in the order they are read: | |
| 108 | + | /// whatever the app names, then the two conventional ones. | |
| 109 | + | /// | |
| 110 | + | /// The app's own `version_path` is never read twice, which is why this is a | |
| 111 | + | /// function rather than a constant. | |
| 112 | + | pub fn version_sources(version_path: Option<&str>) -> Vec<String> { | |
| 113 | + | let mut rels: Vec<String> = version_path.into_iter().map(str::to_string).collect(); | |
| 114 | + | for conventional in ["src-tauri/tauri.conf.json", "Cargo.toml"] { | |
| 115 | + | if version_path != Some(conventional) { | |
| 116 | + | rels.push(conventional.to_string()); | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | rels | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | /// The judgement half of [`check_version_consistency`], over sources somebody | |
| 123 | + | /// else read. | |
| 124 | + | /// | |
| 125 | + | /// Split out so the same rule can be applied to files read out of the release | |
| 126 | + | /// TAG on a build host, which is where the question actually belongs: the tree | |
| 127 | + | /// a release compiles is the tag's, so a `Cargo.toml` that disagrees with the | |
| 128 | + | /// tag it is tagged in is the drift worth refusing. Reading the working copy | |
| 129 | + | /// instead answered a question about a tree the release does not build. | |
| 130 | + | /// | |
| 131 | + | /// `where_` is only for the error message — a path, or a tag and a host. | |
| 132 | + | pub fn versions_agree( | |
| 133 | + | where_: &str, | |
| 134 | + | sources: &[(String, String)], | |
| 135 | + | version_path: Option<&str>, | |
| 136 | + | expected: &Version, | |
| 137 | + | ) -> Result<()> { | |
| 138 | + | let mut found: Vec<(String, Version)> = Vec::new(); | |
| 139 | + | for (rel, raw) in sources { | |
| 140 | + | // The app's own `version_path` can be either shape, so it is decided by | |
| 141 | + | // extension; the two conventional sources are what they are. | |
| 142 | + | let as_json = std::path::Path::new(rel) | |
| 143 | + | .extension() | |
| 144 | + | .is_some_and(|e| e.eq_ignore_ascii_case("json")); | |
| 145 | + | let ver = if as_json { | |
| 146 | + | version_from_tauri_json(raw) | |
| 147 | + | } else { | |
| 148 | + | // A Cargo.toml with neither `[package].version` nor | |
| 149 | + | // `[workspace.package].version` (a pure virtual workspace) carries | |
| 150 | + | // no version to check — skip it rather than fail. An app that NAMED | |
| 151 | + | // this file is held to it. | |
| 152 | + | match version_from_cargo_toml(raw) { | |
| 153 | + | Ok(v) => Ok(v), | |
| 154 | + | Err(e) if version_path == Some(rel.as_str()) => Err(e), | |
| 155 | + | Err(_) => continue, | |
| 156 | + | } | |
| 157 | + | }?; | |
| 158 | + | found.push(( | |
| 159 | + | rel.clone(), | |
| 160 | + | Version::parse(&ver).map_err(|e| anyhow::anyhow!(e))?, | |
| 161 | + | )); | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | let disagree: Vec<&(String, Version)> = found.iter().filter(|(_, v)| v != expected).collect(); | |
| 165 | + | anyhow::ensure!( | |
| 166 | + | disagree.is_empty(), | |
| 167 | + | "version drift in {where_}: building {expected} but {}", | |
| 168 | + | disagree | |
| 169 | + | .iter() | |
| 170 | + | .map(|(src, v)| format!("{src} says {v}")) | |
| 171 | + | .collect::<Vec<_>>() | |
| 172 | + | .join(", ") | |
| 173 | + | ); | |
| 174 | + | Ok(()) | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | /// Read one file as it exists in `tag`, without checking anything out. | |
| 178 | + | /// | |
| 179 | + | /// `<rev>:./<path>` resolves the path relative to `-C`, so this is asked from | |
| 180 | + | /// the app's own directory and needs no knowledge of where that sits inside the | |
| 181 | + | /// repository. A non-zero exit means the file is not in the tag, which is the | |
| 182 | + | /// same "absent, so nothing to disagree with" the local read treats it as. | |
| 183 | + | pub fn git_show_file_cmd(dir: &str, tag: &str, rel: &str) -> String { | |
| 184 | + | format!("git -C \"{dir}\" show \"{tag}:./{rel}\"") | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | #[cfg(test)] | |
| 188 | + | mod tests { | |
| 189 | + | use super::*; | |
| 190 | + | ||
| 191 | + | #[test] | |
| 192 | + | fn version_from_tauri_json_reads_version() { | |
| 193 | + | assert_eq!( | |
| 194 | + | version_from_tauri_json(r#"{"version":"0.4.2"}"#).unwrap(), | |
| 195 | + | "0.4.2" | |
| 196 | + | ); | |
| 197 | + | assert!(version_from_tauri_json(r#"{"productName":"X"}"#).is_err()); | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | #[test] | |
| 201 | + | fn version_from_cargo_toml_prefers_package_then_workspace() { | |
| 202 | + | // A leaf crate's [package].version. | |
| 203 | + | assert_eq!( | |
| 204 | + | version_from_cargo_toml("[package]\nname = \"x\"\nversion = \"0.5.0\"\n").unwrap(), | |
| 205 | + | "0.5.0" | |
| 206 | + | ); | |
| 207 | + | // A workspace that sets [workspace.package].version. | |
| 208 | + | assert_eq!( | |
| 209 | + | version_from_cargo_toml("[workspace.package]\nversion = \"1.2.3\"\n").unwrap(), | |
| 210 | + | "1.2.3" | |
| 211 | + | ); | |
| 212 | + | // No version anywhere -> error, not a panic. | |
| 213 | + | assert!(version_from_cargo_toml("[workspace]\nmembers = []\n").is_err()); | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | #[test] | |
| 217 | + | fn version_from_repo_default_and_explicit_paths() { | |
| 218 | + | let tmp = tempfile::tempdir().unwrap(); | |
| 219 | + | let root = tmp.path(); | |
| 220 | + | ||
| 221 | + | // Tauri app: default path reads src-tauri/tauri.conf.json. | |
| 222 | + | let tauri = root.join("tauri"); | |
| 223 | + | std::fs::create_dir_all(tauri.join("src-tauri")).unwrap(); | |
| 224 | + | std::fs::write( | |
| 225 | + | tauri.join("src-tauri/tauri.conf.json"), | |
| 226 | + | r#"{"version":"0.4.2"}"#, | |
| 227 | + | ) | |
| 228 | + | .unwrap(); | |
| 229 | + | assert_eq!( | |
| 230 | + | version_from_repo(tauri.to_str().unwrap(), None) | |
| 231 | + | .unwrap() | |
| 232 | + | .to_string(), | |
| 233 | + | "0.4.2" | |
| 234 | + | ); | |
| 235 | + | ||
| 236 | + | // Workspace egui app: no tauri.conf.json, explicit version_path at a member crate. | |
| 237 | + | let ws = root.join("ws"); | |
| 238 | + | std::fs::create_dir_all(ws.join("crates/app")).unwrap(); | |
| 239 | + | std::fs::write( | |
| 240 | + | ws.join("Cargo.toml"), | |
| 241 | + | "[workspace]\nmembers = [\"crates/app\"]\n", | |
| 242 | + | ) | |
| 243 | + | .unwrap(); | |
| 244 | + | std::fs::write( | |
| 245 | + | ws.join("crates/app/Cargo.toml"), | |
| 246 | + | "[package]\nname = \"app\"\nversion = \"0.5.0\"\n", | |
| 247 | + | ) | |
| 248 | + | .unwrap(); | |
| 249 | + | assert_eq!( | |
| 250 | + | version_from_repo(ws.to_str().unwrap(), Some("crates/app/Cargo.toml")) | |
| 251 | + | .unwrap() | |
| 252 | + | .to_string(), | |
| 253 | + | "0.5.0" | |
| 254 | + | ); | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | fn ver(s: &str) -> Version { | |
| 258 | + | Version::parse(s).unwrap() | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | #[test] | |
| 262 | + | fn version_consistency_passes_when_all_sources_agree() { | |
| 263 | + | let tmp = tempfile::tempdir().unwrap(); | |
| 264 | + | let repo = tmp.path(); | |
| 265 | + | std::fs::create_dir_all(repo.join("src-tauri")).unwrap(); | |
| 266 | + | std::fs::write( | |
| 267 | + | repo.join("src-tauri/tauri.conf.json"), | |
| 268 | + | r#"{"version":"0.5.0"}"#, | |
| 269 | + | ) | |
| 270 | + | .unwrap(); | |
| 271 | + | std::fs::write( | |
| 272 | + | repo.join("Cargo.toml"), | |
| 273 | + | "[package]\nname = \"app\"\nversion = \"0.5.0\"\n", | |
| 274 | + | ) | |
| 275 | + | .unwrap(); | |
| 276 | + | check_version_consistency(repo.to_str().unwrap(), None, &ver("0.5.0")).unwrap(); | |
| 277 | + | } | |
| 278 | + | ||
| 279 | + | #[test] | |
| 280 | + | fn version_consistency_flags_tauri_vs_cargo_drift() { | |
| 281 | + | // The concrete finding: tauri.conf.json bumped to 0.5.0 but the root | |
| 282 | + | // Cargo.toml left at 0.4.0. version_from_repo (one file) would miss it. | |
| 283 | + | let tmp = tempfile::tempdir().unwrap(); | |
| 284 | + | let repo = tmp.path(); | |
| 285 | + | std::fs::create_dir_all(repo.join("src-tauri")).unwrap(); | |
| 286 | + | std::fs::write( | |
| 287 | + | repo.join("src-tauri/tauri.conf.json"), | |
| 288 | + | r#"{"version":"0.5.0"}"#, | |
| 289 | + | ) | |
| 290 | + | .unwrap(); | |
| 291 | + | std::fs::write( | |
| 292 | + | repo.join("Cargo.toml"), | |
| 293 | + | "[package]\nname = \"app\"\nversion = \"0.4.0\"\n", | |
| 294 | + | ) | |
| 295 | + | .unwrap(); | |
| 296 | + | let err = | |
| 297 | + | check_version_consistency(repo.to_str().unwrap(), None, &ver("0.5.0")).unwrap_err(); | |
| 298 | + | let msg = format!("{err:#}"); | |
| 299 | + | assert!(msg.contains("Cargo.toml says 0.4.0"), "{msg}"); | |
| 300 | + | } | |
| 301 | + | ||
| 302 | + | #[test] | |
| 303 | + | fn version_consistency_flags_explicit_version_the_repo_does_not_reflect() { | |
| 304 | + | let tmp = tempfile::tempdir().unwrap(); | |
| 305 | + | let repo = tmp.path(); | |
| 306 | + | std::fs::create_dir_all(repo.join("src-tauri")).unwrap(); | |
| 307 | + | std::fs::write( | |
| 308 | + | repo.join("src-tauri/tauri.conf.json"), | |
| 309 | + | r#"{"version":"0.5.0"}"#, | |
| 310 | + | ) | |
| 311 | + | .unwrap(); | |
| 312 | + | let err = | |
| 313 | + | check_version_consistency(repo.to_str().unwrap(), None, &ver("9.9.9")).unwrap_err(); | |
| 314 | + | assert!(format!("{err:#}").contains("building 9.9.9")); | |
| 315 | + | } | |
| 316 | + | ||
| 317 | + | #[test] | |
| 318 | + | fn version_consistency_single_source_never_invents_drift() { | |
| 319 | + | // A virtual-workspace root Cargo.toml (no version) alongside the member | |
| 320 | + | // crate the version_path points at: only one real source, so no drift. | |
| 321 | + | let tmp = tempfile::tempdir().unwrap(); | |
| 322 | + | let repo = tmp.path(); | |
| 323 | + | std::fs::create_dir_all(repo.join("crates/app")).unwrap(); | |
| 324 | + | std::fs::write( | |
| 325 | + | repo.join("Cargo.toml"), | |
| 326 | + | "[workspace]\nmembers = [\"crates/app\"]\n", | |
| 327 | + | ) | |
| 328 | + | .unwrap(); | |
| 329 | + | std::fs::write( | |
| 330 | + | repo.join("crates/app/Cargo.toml"), | |
| 331 | + | "[package]\nname = \"app\"\nversion = \"0.5.0\"\n", | |
| 332 | + | ) | |
| 333 | + | .unwrap(); | |
| 334 | + | check_version_consistency( | |
| 335 | + | repo.to_str().unwrap(), | |
| 336 | + | Some("crates/app/Cargo.toml"), | |
| 337 | + | &ver("0.5.0"), | |
| 338 | + | ) | |
| 339 | + | .unwrap(); | |
| 340 | + | } | |
| 341 | + | } |