Skip to main content

max / makenotwork

10.7 KB · 247 lines History Blame Raw
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 }
247