//! Rhai recipe engine + host-function API. //! //! A `(app, target)` resolves to a `.rhai` recipe composed from a shared step //! vocabulary. The daemon embeds Rhai and registers the host functions recipes //! call; the recipe is the orchestration, the host functions are the //! privileged primitives (run a command, read a secret, collect artifacts, //! publish). Recipes are otherwise sandboxed — no arbitrary FS/network except //! through these functions — matching the Balanced Breakfast plugin model. //! //! Rhai is synchronous; the engine runs each recipe on a blocking thread //! (`spawn_blocking`, see [`crate::runner`]) and host functions bridge to async //! work via `Handle::block_on`. That is sound only off a runtime worker thread, //! which `spawn_blocking` guarantees. //! //! One file per thing the engine actually does. `ctx` holds the state a recipe //! is handed; `host_fns` is the API surface it may call; the rest are the jobs //! behind those names. use crate::domain::{Step, StepRunId}; use crate::topology::Kind; use ops_core::live_log::LiveLog; use ops_exec::{Action, ObserveKind}; use rhai::EvalAltResult; use std::sync::Arc; use tokio::sync::Mutex as AsyncMutex; mod crates_io; mod ctx; mod deploy; mod git; mod host_fns; mod macos; mod publish; mod version; pub mod collect; pub use ctx::RecipeCtx; pub use git::{ app_dir_in_worktree, expand_tilde, git_fetch_cmd, git_rev_parse_cmd, git_tag_exists_cmd, git_toplevel_and_prefix_cmd, git_worktree_add_cmd, git_worktree_pin_cmd, git_worktree_probe_cmd, git_worktree_prune_cmd, parse_toplevel_and_prefix, repo_dir_name, worktree_failure_reason, }; pub use host_fns::build_engine; pub use version::{ // `check_version_consistency` has no caller anywhere in the tree, only its // own four tests. It is re-exported to keep it exactly as reachable as it // was before this split rather than to hide that: narrowing it to // `pub(super)` turns it into a dead-code error, which is the finding. check_version_consistency, git_show_file_cmd, version_from_repo, version_sources, versions_agree, }; /// The capability label for a command, derived from the open recipe step. A /// recipe's `sh("mbp", …)` under `step("sign")` becomes an `Action::Sign`, gated /// by the mac host's `sign` grant — so recipes stay unchanged while every command /// is capability-checked at its transport. `Verify` is read-only (an observe). /// The capability a step's commands are gated on. /// /// `Verify` depends on what is being released, which is the one place this is /// not a property of the step alone. An app's verify is a Gatekeeper check on a /// signed bundle, and the `gatekeeper` observe is granted implicitly to hosts /// that can `sign` (`CapabilitySet::from_tokens`) precisely so that pairing /// holds. A library's verify is a crate preflight: it runs `cargo` on the build /// host and asks the registry a question. Gating that on Gatekeeper asks a Linux /// host for a macOS code-signing capability it can never honestly hold, and the /// only way to satisfy it would be to declare the capability falsely. fn action_for(step: Step, kind: Kind) -> Action { match step { Step::Checkout | Step::Prebuild | Step::Build => Action::Build, Step::Sign => Action::Sign, Step::Notarize => Action::Notarize, Step::Staple => Action::Staple, Step::Package => Action::Package, Step::Verify => match kind { Kind::App => Action::Observe(ObserveKind::Custom("gatekeeper".into())), // Running the build toolchain to inspect a crate or a service // binary, which is what `build` means on a host. Neither has a // bundle for Gatekeeper to have an opinion about. Kind::Library | Kind::Service => Action::Build, }, // Publish/Collect/Handoff run on the daemon, not through a host // executor; this label only applies if a recipe runs a bare `sh` while // one is open. `handoff` is the daemon's own post-recipe motion and no // recipe should open it at all — naming it here costs nothing and beats // a wildcard that would silently absorb the next step somebody adds. Step::Publish | Step::Collect | Step::Handoff => Action::Package, // The one step that dispatches to a host OUTSIDE the build topology. // Every command a recipe runs while `deploy` is open — the install, the // restart, the health assertion — carries this action, so it reaches the // service host only through the deploy grant and reaches a build host // not at all (no build host is granted `deploy`). Step::Deploy => Action::Deploy, } } /// The currently-open step within a recipe run: its DB row id, which step it /// is, and the live-log sink that `sh`/`log` stream into. struct StepState { run_id: StepRunId, step: Step, log: Arc>, /// Set when something in the step recorded a hard failure the recipe did /// not abort on (e.g. `verify_gatekeeper` rejected the artifact but the /// recipe ignored the bool). Forces the step's recorded status to `Failed` /// and bars `publish` (the step-success ledger). failed: bool, /// Wall-clock deadline for this step. A command that runs past it fails the /// step (and unwinds the recipe) rather than wedging under the old /// whole-build guillotine, which a legitimate 5-target fan-out plus notary /// queueing could trip — mismarking every target failed while the blocking /// recipe bodies kept signing. deadline: std::time::Instant, } /// Per-step wall-clock budget: a generous ceiling that catches a wedged command /// (a hung ssh, a stuck notary poll) without killing legitimately slow work. /// Bounding each step, rather than the whole build, keeps one slow step from /// being blamed on another and keeps a fan-out of slow-but-fine targets from /// being guillotined. `Config::step_timeout_secs` overrides these per-kind /// defaults for every step; see [`RecipeCtx::step_budget`]. fn default_step_budget(step: Step) -> std::time::Duration { use std::time::Duration; let mins = match step { Step::Checkout => 10, // clippy + full test suite, cold, on a workspace. Step::Prebuild => 45, // cargo tauri build, cold, universal bundles. Step::Build => 90, Step::Sign => 15, // Apple's notary queue + this step's bounded retries. Step::Notarize => 60, Step::Staple => 10, Step::Package => 30, Step::Verify => 10, // rsync of multi-GiB artifacts off the build host. Step::Collect => 30, Step::Publish => 20, // A binary push, an install, a unit restart, and a health poll. Minutes // of work; the ceiling is for a wedged transport, not slow work. Step::Deploy => 15, // Unused by any recipe — the daemon runs the handoff itself, outside a // step's clock — and matched to `collect`, since it moves the same // bytes the same way and would wedge for the same reasons. Step::Handoff => 30, }; Duration::from_secs(mins * 60) } /// Where a service's binary is staged on the host that will run it, before the /// privileged installer moves it into place. /// /// A fixed, unguessable-by-accident path rather than a recipe-chosen one, /// because the installer refuses any source outside it. That refusal is the /// only thing standing between the NOPASSWD sudo grant and `install`-as-root to /// an arbitrary path, so both ends have to name the same constant. `/var/tmp` /// rather than `/tmp` so a staged binary survives a `PrivateTmp` unit and a /// systemd tmpfiles sweep between staging and install. pub const DEPLOY_STAGING_ROOT: &str = "/var/tmp/bento-deploy"; // Rhai host functions return `Result<_, Box>` by convention, so // this bridge must yield the boxed form to be usable with `.map_err(rhai_err)`. #[allow( clippy::unnecessary_box_returns, reason = "rhai's error type is used boxed throughout its host-function API" )] fn rhai_err(e: impl std::fmt::Display) -> Box { Box::new(EvalAltResult::ErrorRuntime( e.to_string().into(), rhai::Position::NONE, )) } #[cfg(test)] mod tests { use super::*; // A library's verify is a crate preflight, not a Gatekeeper check on a // signed bundle. Gating it on `gatekeeper` asked a Linux host for a macOS // code-signing capability it can never hold, so the step was denied before // it ran a command; the denial then surfaced as "no crates.io credentials", // which is not what went wrong. The only way to satisfy the old gate was to // declare the capability falsely in the topology. #[test] fn a_library_verify_is_not_gated_on_gatekeeper() { assert_eq!( action_for(Step::Verify, Kind::Library), Action::Build, "a crate preflight runs the build toolchain; that is what it needs", ); assert_eq!( action_for(Step::Verify, Kind::App), Action::Observe(ObserveKind::Custom("gatekeeper".into())), "an app's verify still proves the bundle is signed and notarized", ); } // The capability the default host grant actually carries. Without this the // fix above is only true by inspection. #[test] fn a_default_host_can_run_a_library_verify_and_not_an_app_one() { let caps = ops_exec::CapabilitySet::from_tokens(["build", "package"], ["build-log", "artifact"]); assert!(caps.permits(&action_for(Step::Verify, Kind::Library))); assert!(!caps.permits(&action_for(Step::Verify, Kind::App))); } // Every other step is a property of the step alone; verify is the one that // depends on what is being released. #[test] fn no_other_step_changes_with_the_kind() { for step in [ Step::Checkout, Step::Prebuild, Step::Build, Step::Sign, Step::Notarize, Step::Staple, Step::Package, Step::Publish, Step::Collect, ] { assert_eq!( action_for(step, Kind::App), action_for(step, Kind::Library), "{step:?} should not depend on the kind", ); } } #[test] fn every_step_has_a_nonzero_default_budget() { // A zero/missing budget would deadline-fail a step instantly. Cover the // whole matrix so a new Step variant can't silently get a 0 budget. for step in Step::ALL { assert!( default_step_budget(step) >= std::time::Duration::from_mins(1), "{step} budget must be a sane ceiling", ); } } }