Skip to main content

max / makenotwork

15.7 KB · 360 lines History Blame Raw
1 use crate::config::Config;
2 use crate::domain::{AppId, Target};
3 use crate::events::EventTx;
4 use crate::ota::OtaRegistry;
5 use crate::topology::{Host, HostTransport, Topology};
6 use metrics_exporter_prometheus::PrometheusHandle;
7 use ops_exec::{AgentRpc, CapabilitySet, Executor, LocalExec, SshExec};
8 use sqlx::SqlitePool;
9 use std::collections::HashMap;
10 use std::sync::Arc;
11 use std::sync::atomic::AtomicBool;
12 use tokio::sync::Mutex;
13 use tokio::task::AbortHandle;
14
15 /// Per-host executors keyed by host name, built once from the topology at
16 /// startup. The recipe engine looks a host's executor up here instead of
17 /// constructing ssh/scp invocations inline — capability-scoped, transport
18 /// chosen per host (local / ssh / in-session agent). Mirrors Sando's
19 /// `ExecutorMap`.
20 pub type ExecutorMap = HashMap<String, Arc<dyn Executor>>;
21
22 /// One occupant of the latest-wins guard: the owning `build_id`, an
23 /// [`AbortHandle`] for the spawned target task (stops the async wrapper), and a
24 /// cooperative `cancel` flag the recipe checks at step boundaries and before
25 /// publish (stops the *blocking* Rhai body, which `abort()` alone cannot reach).
26 #[derive(Clone)]
27 pub struct ActiveSlot {
28 pub build_id: i64,
29 pub abort: AbortHandle,
30 pub cancel: Arc<AtomicBool>,
31 }
32
33 /// Latest-wins guard map: per `(app, target)`, the in-flight occupant. A newer
34 /// build supersedes the slot (setting the prior `cancel` and aborting its
35 /// handle); a finishing build reaps only the slots it still owns.
36 pub type ActiveBuilds = Arc<Mutex<HashMap<(AppId, Target), ActiveSlot>>>;
37
38 /// One serialization lock per build host. A target holds its host's lock across
39 /// the whole recipe run, so two targets that land on the same host never build
40 /// concurrently in one checkout. Without it, goingson's `macos/aarch64` and
41 /// `ios/universal` — both on mbp — run at once in `~/Code/Apps/goingson`:
42 /// concurrent `git pull`, a shared `target/`, `release-ios.sh` rewriting
43 /// `project.yml` mid-build, and (worst) a fixed-path build keychain that the
44 /// second build deletes out from under the first's codesign.
45 pub type HostLocks = Arc<HashMap<String, Arc<Mutex<()>>>>;
46
47 /// Build one serialization lock per host in the topology. Hosts are fixed at
48 /// load, so the map is built once and never mutated.
49 pub fn build_host_locks(topo: &Topology) -> HostLocks {
50 Arc::new(
51 topo.hosts
52 .iter()
53 .map(|h| (h.name.clone(), Arc::new(Mutex::new(()))))
54 .collect(),
55 )
56 }
57
58 #[derive(Clone)]
59 pub struct AppState {
60 pub pool: SqlitePool,
61 pub topo: Arc<Topology>,
62 pub cfg: Arc<Config>,
63 pub prom: PrometheusHandle,
64 pub events: EventTx,
65 pub ota: Arc<OtaRegistry>,
66 /// One capability-scoped [`Executor`] per build host, from the topology —
67 /// the transport that *runs steps* (may be the in-session agent).
68 pub executors: Arc<ExecutorMap>,
69 /// One transport per build host for *moving artifacts* off it. Never the
70 /// agent — see [`build_sync`].
71 pub syncs: Arc<ExecutorMap>,
72 /// Bearer token required on the build-triggering routes (`/build`,
73 /// `/retry`). Sourced from `BENTO_API_TOKEN` (systemd EnvironmentFile).
74 /// `None` = unauthenticated, which main() permits only on a loopback bind
75 /// (CF2).
76 pub api_token: Option<Arc<str>>,
77 /// Single-slot guard per `(app, target)`: a newer build for the same
78 /// target aborts the in-flight one (latest request wins), mirroring
79 /// Sando's `active_build`. Other targets keep running — that's the fan-out.
80 /// The value carries the owning `build_id` so a finished build reaps only
81 /// its own slots and never a superseding build's handle.
82 pub active: ActiveBuilds,
83 /// One lock per host, held for a target's whole recipe run so two targets on
84 /// the same host serialize instead of corrupting one shared checkout +
85 /// keychain. See [`HostLocks`].
86 pub host_locks: HostLocks,
87 }
88
89 /// Build one host's EXEC executor: `LocalExec` for `ssh = "local"`, `AgentRpc`
90 /// for an agent-transport host (macOS in-session signing), `SshExec` otherwise —
91 /// each granted exactly the host's declared capabilities.
92 ///
93 /// This is the transport for *running steps*. Moving artifacts uses
94 /// [`build_sync`] instead; see it for why the two are not the same thing.
95 pub fn build_executor(host: &Host) -> Arc<dyn Executor> {
96 let caps = CapabilitySet::from_tokens(&host.actuate, &host.observe);
97 match host.transport {
98 HostTransport::Agent => {
99 // validate() guarantees agent_url is set for agent hosts.
100 let url = host.agent_url.clone().unwrap_or_default();
101 Arc::new(AgentRpc::new(url, host.name.clone(), caps))
102 }
103 HostTransport::Ssh if host.ssh == "local" || host.ssh.is_empty() => {
104 Arc::new(LocalExec::new(caps))
105 }
106 HostTransport::Ssh => Arc::new(SshExec::new(host.ssh.clone(), caps)),
107 }
108 }
109
110 /// Build one host's SYNC transport — how the daemon moves *artifacts* off it.
111 /// Always `LocalExec`/`SshExec`, **never `AgentRpc`, even for an agent host.**
112 ///
113 /// The agent is an *execution* transport: it exists so codesign runs in the Aqua
114 /// session where the Developer ID key is usable (design §7 "THE WALL"). It is
115 /// deliberately a poor artifact mover — `/pull` is confined to the agent's
116 /// narrow `pull_root` (mbp: `/Users/max/Dist`), which is what keeps an
117 /// allow-listed caller from reading `~/.tauri/passwords.env`. Build artifacts
118 /// live in the *repo checkout*, outside that root, so routing collect through
119 /// the agent would either 404 or force `pull_root` wide enough to undo the
120 /// confinement. `AgentRpc::push_dir`/`pull_glob` say the same thing in their
121 /// refusals: bulk data moves over ssh/rsync, not the agent.
122 ///
123 /// So a mac host has two transports at once: `AgentRpc` to sign, `SshExec` to
124 /// fetch what it signed. Both reach the same box; only the privilege differs.
125 ///
126 /// The sync transport is confined to the host's declared `pull_root` and gated
127 /// on its `observe:artifact` grant (`ops_exec::gate_pull`). Both are
128 /// fail-closed: a host that declares no `pull_root` collects nothing. That
129 /// confinement is what keeps a `collect(host, '…/.tauri/passwords.env')` from
130 /// depositing the notary credential into `dist_root`.
131 pub fn build_sync(host: &Host) -> Arc<dyn Executor> {
132 let caps = CapabilitySet::from_tokens(&host.actuate, &host.observe);
133 if host.ssh == "local" || host.ssh.is_empty() {
134 let exec = LocalExec::new(caps);
135 let exec = match &host.pull_root {
136 Some(root) => exec.with_pull_root(root),
137 None => exec,
138 };
139 Arc::new(exec)
140 } else {
141 let exec = SshExec::new(host.ssh.clone(), caps);
142 let exec = match &host.pull_root {
143 Some(root) => exec.with_pull_root(root),
144 None => exec,
145 };
146 Arc::new(exec)
147 }
148 }
149
150 /// Build the executor for a service's deploy destination.
151 ///
152 /// Deliberately NOT a build host, and never in the topology's host list: it is
153 /// granted `deploy` + `restart` and nothing else, so the same executor that
154 /// installs pom's binary cannot be handed a `build` step, and a compromised
155 /// recipe cannot turn the production box into a build host. `Action::Deploy` and
156 /// `Action::Restart` already exist in `ops_exec` for Sando's promotions; this is
157 /// the same grant reaching the same kind of destination.
158 ///
159 /// It gets no `pull_root`, so the artifact-collection plane is closed on it in
160 /// both directions: `collect()` from a deploy host is refused fail-closed, which
161 /// is right — a service host produces nothing Bento should be fetching.
162 pub fn build_deploy_executor(d: &crate::topology::DeployTarget) -> Arc<dyn Executor> {
163 let caps = CapabilitySet::from_tokens(["deploy", "restart"], ["build-log"]);
164 if d.host == "local" || d.host.is_empty() {
165 return Arc::new(LocalExec::new(caps));
166 }
167 Arc::new(SshExec::new(d.host.clone(), caps).with_port(d.port))
168 }
169
170 /// Build the full host name -> exec-executor map from the topology.
171 pub fn build_executors(topo: &Topology) -> ExecutorMap {
172 topo.hosts
173 .iter()
174 .map(|h| (h.name.clone(), build_executor(h)))
175 .collect()
176 }
177
178 /// Build the full host name -> sync-transport map from the topology.
179 pub fn build_syncs(topo: &Topology) -> ExecutorMap {
180 topo.hosts
181 .iter()
182 .map(|h| (h.name.clone(), build_sync(h)))
183 .collect()
184 }
185
186 #[cfg(test)]
187 mod tests {
188 use super::*;
189 use ops_exec::{Action, SyncOpts};
190
191 fn host(toml_host: &str) -> Host {
192 // Only a host is under test here, so parse just the `[[host]]` table.
193 // Going through `Topology` would drag in an app pointer and a manifest
194 // on disk for no reason.
195 #[derive(serde::Deserialize)]
196 struct Hosts {
197 #[serde(rename = "host")]
198 hosts: Vec<Host>,
199 }
200 let parsed: Hosts = toml::from_str(toml_host).unwrap();
201 parsed.hosts.into_iter().next().unwrap()
202 }
203
204 #[test]
205 fn build_host_executor_permits_build_not_sign() {
206 let h = host("[[host]]\nname = \"fw13\"\nssh = \"local\"\ntargets = [\"linux/x86_64\"]");
207 let exec = build_executor(&h);
208 assert!(exec.capabilities().permits(&Action::Build));
209 assert!(exec.capabilities().permits(&Action::Package));
210 assert!(!exec.capabilities().permits(&Action::Sign));
211 }
212
213 /// The load-bearing property of the two-plane split: a mac host signs over
214 /// the agent but is COLLECTED FROM over ssh. If this ever regresses to one
215 /// transport, collect hits `AgentRpc::pull_glob` (refused by design) or
216 /// forces `pull_root` wide enough to expose `~/.tauri/passwords.env`.
217 #[tokio::test]
218 async fn agent_host_syncs_over_ssh_never_the_agent() {
219 let h = host(
220 "[[host]]\nname = \"mbp\"\nssh = \"mbp\"\ntargets = [\"macos/aarch64\"]\n\
221 transport = \"agent\"\nagent_url = \"http://mbp:8765\"\n\
222 actuate = [\"build\", \"sign\", \"notarize\", \"staple\"]\n\
223 observe = [\"build-log\", \"gatekeeper\", \"artifact\"]\n\
224 pull_root = \"/nonexistent\"",
225 );
226 // The sync transport must not be the agent. AgentRpc refuses pull_glob
227 // by design, so a non-refusing error proves we got an ssh transport.
228 // The host declares `artifact` + a `pull_root` so the pull clears the
229 // fail-closed sync gate and reaches the actual ssh rsync (which then
230 // fails on the unreachable host / no match) rather than the gate.
231 let sync = build_sync(&h);
232 let err = sync
233 .pull_glob(
234 "/nonexistent/*.dmg",
235 std::path::Path::new("/tmp"),
236 &SyncOpts::default(),
237 )
238 .await
239 .expect_err("nothing matches, so this must error either way");
240 assert!(
241 !err.to_string().contains("unsupported by design"),
242 "sync transport for an agent host must NOT be AgentRpc: {err}"
243 );
244 assert!(
245 !err.to_string().contains("no artifact root declared"),
246 "the declared pull_root must let the pull reach the ssh transport: {err}"
247 );
248 // ...while the exec transport for the same host still is the agent.
249 let exec = build_executor(&h);
250 let err = exec
251 .pull_glob(
252 "/x/*.dmg",
253 std::path::Path::new("/tmp"),
254 &SyncOpts::default(),
255 )
256 .await
257 .expect_err("AgentRpc has no glob form");
258 assert!(
259 err.to_string().contains("unsupported by design"),
260 "exec plane is the agent: {err}"
261 );
262 }
263
264 #[test]
265 fn local_host_syncs_locally() {
266 let h = host("[[host]]\nname = \"fw13\"\nssh = \"local\"\ntargets = [\"linux/x86_64\"]");
267 // A local host's sync transport still carries the host's grant.
268 assert!(build_sync(&h).capabilities().permits(&Action::Build));
269 }
270
271 /// The finding this fix closes, end to end through `build_sync`: a host with
272 /// a declared `pull_root` collects artifacts under it but refuses a path
273 /// outside it (the `~/.tauri/passwords.env` exfil). Confinement is lexical,
274 /// so no real filesystem is needed.
275 #[tokio::test]
276 async fn sync_pull_is_confined_to_declared_root() {
277 let h = host(
278 "[[host]]\nname = \"fw13\"\nssh = \"local\"\ntargets = [\"linux/x86_64\"]\n\
279 pull_root = \"/home/max/Code/Apps\"",
280 );
281 let sync = build_sync(&h);
282 // A path outside the declared root is refused before any rsync.
283 let err = sync
284 .pull_file(
285 std::path::Path::new("/home/max/.tauri/passwords.env"),
286 std::path::Path::new("/tmp/out"),
287 &SyncOpts::default(),
288 )
289 .await
290 .expect_err("a path outside pull_root must be denied");
291 assert!(
292 err.to_string()
293 .contains("escapes the declared artifact root"),
294 "{err}"
295 );
296
297 // A host with NO declared root collects nothing at all (fail-closed).
298 let bare = host("[[host]]\nname = \"fw13\"\nssh = \"local\"\ntargets = [\"linux/x86_64\"]");
299 let err = build_sync(&bare)
300 .pull_file(
301 std::path::Path::new("/home/max/Code/Apps/goingson/x.dmg"),
302 std::path::Path::new("/tmp/out"),
303 &SyncOpts::default(),
304 )
305 .await
306 .expect_err("no pull_root ⇒ no pulls");
307 assert!(
308 err.to_string().contains("no artifact root declared"),
309 "{err}"
310 );
311 }
312
313 /// One serialization lock per host, and they are independent: holding mbp's
314 /// lock (a macOS + iOS build serialize behind it) leaves fw13 free to build
315 /// in parallel. This is the fan-out-across-hosts / serialize-within-a-host
316 /// property the runner relies on.
317 #[tokio::test]
318 async fn host_locks_are_one_per_host_and_independent() {
319 use crate::topology::Topology;
320 let dir = tempfile::tempdir().unwrap();
321 let repo = dir.path().join("goingson");
322 std::fs::create_dir_all(&repo).unwrap();
323 std::fs::write(repo.join("bento.toml"), "targets = [\"macos/aarch64\"]\n").unwrap();
324 let topo = Topology::from_str_for_tests(&format!(
325 "[[host]]\nname = \"fw13\"\nssh = \"local\"\ntargets = [\"linux/x86_64\"]\n\
326 [[host]]\nname = \"mbp\"\nssh = \"mbp\"\ntargets = [\"macos/aarch64\"]\n\
327 [app.goingson]\nrepo = \"{}\"\n",
328 repo.display()
329 ))
330 .unwrap();
331 let locks = build_host_locks(&topo);
332 assert_eq!(locks.len(), 2);
333 let mbp = locks.get("mbp").expect("mbp lock").clone();
334 let _held = mbp.clone().lock_owned().await;
335 // Same host: a second acquire cannot proceed while the first is held.
336 assert!(mbp.try_lock().is_err(), "same-host builds must serialize");
337 // Different host: unaffected — it builds in parallel.
338 assert!(
339 locks.get("fw13").expect("fw13 lock").try_lock().is_ok(),
340 "a different host must not be blocked"
341 );
342 }
343
344 #[test]
345 fn agent_host_executor_permits_sign() {
346 // The mac host: agent transport, widened grant. Proves AgentRpc is
347 // constructed (no panic) and carries the sign capability that the
348 // SSH/local transports' default grant does not.
349 let h = host(
350 "[[host]]\nname = \"mbp\"\nssh = \"mbp\"\ntargets = [\"macos/aarch64\"]\n\
351 transport = \"agent\"\nagent_url = \"http://mbp:8765\"\n\
352 actuate = [\"build\", \"sign\", \"notarize\", \"staple\"]",
353 );
354 let exec = build_executor(&h);
355 assert!(exec.capabilities().permits(&Action::Sign));
356 assert!(exec.capabilities().permits(&Action::Notarize));
357 assert!(exec.capabilities().permits(&Action::Build));
358 }
359 }
360