Skip to main content

max / makenotwork

18.1 KB · 410 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 /// Cached answers to "is this app's version downloadable from MNW".
88 ///
89 /// `/status.json` is polled every few seconds and the probe talks to
90 /// production, so the answer is cached per `(app, version)`: a viewer
91 /// refreshing its board must not turn into sustained traffic against the
92 /// endpoint real users' updaters poll.
93 pub distribution: DistributionCache,
94 /// HTTP client for the distribution probe. One client, so the connection
95 /// pool is reused across polls rather than rebuilt per request.
96 pub http: reqwest::Client,
97 /// MNW base URL the probe reads. Same value the `tauri-mnw` backend
98 /// publishes to, so the check and the publish can never disagree about
99 /// which host they mean.
100 pub mnw_base_url: Arc<str>,
101 }
102
103 /// `(app, version)` to its last probe result and when that was taken.
104 pub type DistributionCache =
105 Arc<Mutex<HashMap<(String, String), (std::time::Instant, crate::ota::Distribution)>>>;
106
107 /// How long a distribution answer stays good.
108 ///
109 /// Generous on purpose. The thing being watched is a manual upload, which
110 /// happens on human timescales, so a minute of staleness costs nothing and
111 /// keeps the poll off MNW's back.
112 pub const DISTRIBUTION_TTL: std::time::Duration = std::time::Duration::from_mins(1);
113
114 /// Build one host's EXEC executor: `LocalExec` for `ssh = "local"`, `AgentRpc`
115 /// for an agent-transport host (macOS in-session signing), `SshExec` otherwise —
116 /// each granted exactly the host's declared capabilities.
117 ///
118 /// This is the transport for *running steps*. Moving artifacts uses
119 /// [`build_sync`] instead; see it for why the two are not the same thing.
120 pub fn build_executor(host: &Host) -> Arc<dyn Executor> {
121 let caps = CapabilitySet::from_tokens(&host.actuate, &host.observe);
122 match host.transport {
123 HostTransport::Agent => {
124 // validate() guarantees agent_url is set for agent hosts.
125 let url = host.agent_url.clone().unwrap_or_default();
126 Arc::new(AgentRpc::new(url, host.name.clone(), caps))
127 }
128 HostTransport::Ssh if host.ssh == "local" || host.ssh.is_empty() => {
129 Arc::new(LocalExec::new(caps))
130 }
131 HostTransport::Ssh => Arc::new(SshExec::new(host.ssh.clone(), caps)),
132 }
133 }
134
135 /// Build one host's SYNC transport — how the daemon moves *artifacts* off it.
136 /// Always `LocalExec`/`SshExec`, **never `AgentRpc`, even for an agent host.**
137 ///
138 /// The agent is an *execution* transport: it exists so codesign runs in the Aqua
139 /// session where the Developer ID key is usable (design §7 "THE WALL"). It is
140 /// deliberately a poor artifact mover — `/pull` is confined to the agent's
141 /// narrow `pull_root` (mbp: `/Users/max/Dist`), which is what keeps an
142 /// allow-listed caller from reading `~/.tauri/passwords.env`. Build artifacts
143 /// live in the *repo checkout*, outside that root, so routing collect through
144 /// the agent would either 404 or force `pull_root` wide enough to undo the
145 /// confinement. `AgentRpc::push_dir`/`pull_glob` say the same thing in their
146 /// refusals: bulk data moves over ssh/rsync, not the agent.
147 ///
148 /// So a mac host has two transports at once: `AgentRpc` to sign, `SshExec` to
149 /// fetch what it signed. Both reach the same box; only the privilege differs.
150 ///
151 /// The sync transport is confined to the host's declared `pull_root` and gated
152 /// on its `observe:artifact` grant (`ops_exec::gate_pull`). Both are
153 /// fail-closed: a host that declares no `pull_root` collects nothing. That
154 /// confinement is what keeps a `collect(host, '…/.tauri/passwords.env')` from
155 /// depositing the notary credential into `dist_root`.
156 pub fn build_sync(host: &Host) -> Arc<dyn Executor> {
157 let caps = CapabilitySet::from_tokens(&host.actuate, &host.observe);
158 let roots = host.artifact_roots();
159 if host.ssh == "local" || host.ssh.is_empty() {
160 Arc::new(LocalExec::new(caps).with_pull_roots(roots))
161 } else {
162 Arc::new(SshExec::new(host.ssh.clone(), caps).with_pull_roots(roots))
163 }
164 }
165
166 /// Build the executor for a service's deploy destination.
167 ///
168 /// Deliberately NOT a build host, and never in the topology's host list: it is
169 /// granted `deploy` + `restart` and nothing else, so the same executor that
170 /// installs pom's binary cannot be handed a `build` step, and a compromised
171 /// recipe cannot turn the production box into a build host. `Action::Deploy` and
172 /// `Action::Restart` already exist in `ops_exec` for Sando's promotions; this is
173 /// the same grant reaching the same kind of destination.
174 ///
175 /// It gets no `pull_root`, so the artifact-collection plane is closed on it in
176 /// both directions: `collect()` from a deploy host is refused fail-closed, which
177 /// is right — a service host produces nothing Bento should be fetching.
178 pub fn build_deploy_executor(d: &crate::topology::DeployTarget) -> Arc<dyn Executor> {
179 let caps = CapabilitySet::from_tokens(["deploy", "restart"], ["build-log"]);
180 if d.host == "local" || d.host.is_empty() {
181 return Arc::new(LocalExec::new(caps));
182 }
183 Arc::new(SshExec::new(d.host.clone(), caps).with_port(d.port))
184 }
185
186 /// Build the full host name -> exec-executor map from the topology.
187 pub fn build_executors(topo: &Topology) -> ExecutorMap {
188 topo.hosts
189 .iter()
190 .map(|h| (h.name.clone(), build_executor(h)))
191 .collect()
192 }
193
194 /// Build the full host name -> sync-transport map from the topology.
195 pub fn build_syncs(topo: &Topology) -> ExecutorMap {
196 topo.hosts
197 .iter()
198 .map(|h| (h.name.clone(), build_sync(h)))
199 .collect()
200 }
201
202 #[cfg(test)]
203 mod tests {
204 use super::*;
205 use ops_exec::{Action, SyncOpts};
206
207 fn host(toml_host: &str) -> Host {
208 // Only a host is under test here, so parse just the `[[host]]` table.
209 // Going through `Topology` would drag in an app pointer and a manifest
210 // on disk for no reason.
211 #[derive(serde::Deserialize)]
212 struct Hosts {
213 #[serde(rename = "host")]
214 hosts: Vec<Host>,
215 }
216 let parsed: Hosts = toml::from_str(toml_host).unwrap();
217 parsed.hosts.into_iter().next().unwrap()
218 }
219
220 #[test]
221 fn build_host_executor_permits_build_not_sign() {
222 let h = host("[[host]]\nname = \"fw13\"\nssh = \"local\"\ntargets = [\"linux/x86_64\"]");
223 let exec = build_executor(&h);
224 assert!(exec.capabilities().permits(&Action::Build));
225 assert!(exec.capabilities().permits(&Action::Package));
226 assert!(!exec.capabilities().permits(&Action::Sign));
227 }
228
229 /// The load-bearing property of the two-plane split: a mac host signs over
230 /// the agent but is COLLECTED FROM over ssh. If this ever regresses to one
231 /// transport, collect hits `AgentRpc::pull_glob` (refused by design) or
232 /// forces `pull_root` wide enough to expose `~/.tauri/passwords.env`.
233 #[tokio::test]
234 async fn agent_host_syncs_over_ssh_never_the_agent() {
235 let h = host(
236 "[[host]]\nname = \"mbp\"\nssh = \"mbp\"\ntargets = [\"macos/aarch64\"]\n\
237 transport = \"agent\"\nagent_url = \"http://mbp:8765\"\n\
238 actuate = [\"build\", \"sign\", \"notarize\", \"staple\"]\n\
239 observe = [\"build-log\", \"gatekeeper\", \"artifact\"]\n\
240 pull_root = \"/nonexistent\"",
241 );
242 // The sync transport must not be the agent. AgentRpc refuses pull_glob
243 // by design, so a non-refusing error proves we got an ssh transport.
244 // The host declares `artifact` + a `pull_root` so the pull clears the
245 // fail-closed sync gate and reaches the actual ssh rsync (which then
246 // fails on the unreachable host / no match) rather than the gate.
247 let sync = build_sync(&h);
248 let err = sync
249 .pull_glob(
250 "/nonexistent/*.dmg",
251 std::path::Path::new("/tmp"),
252 &SyncOpts::default(),
253 )
254 .await
255 .expect_err("nothing matches, so this must error either way");
256 assert!(
257 !err.to_string().contains("unsupported by design"),
258 "sync transport for an agent host must NOT be AgentRpc: {err}"
259 );
260 assert!(
261 !err.to_string().contains("no artifact root declared"),
262 "the declared pull_root must let the pull reach the ssh transport: {err}"
263 );
264 // ...while the exec transport for the same host still is the agent.
265 let exec = build_executor(&h);
266 let err = exec
267 .pull_glob(
268 "/x/*.dmg",
269 std::path::Path::new("/tmp"),
270 &SyncOpts::default(),
271 )
272 .await
273 .expect_err("AgentRpc has no glob form");
274 assert!(
275 err.to_string().contains("unsupported by design"),
276 "exec plane is the agent: {err}"
277 );
278 }
279
280 /// pom's case: one host, two trees. The singular and the plural are unioned,
281 /// so a topology that adds `pull_roots` keeps whatever `pull_root` said.
282 #[tokio::test]
283 async fn sync_pull_accepts_every_declared_root() {
284 let h = host(
285 "[[host]]\nname = \"fw13\"\nssh = \"local\"\ntargets = [\"linux/x86_64\"]\n\
286 pull_root = \"/home/max/Code/Apps\"\n\
287 pull_roots = [\"/home/max/Code/MNW\"]",
288 );
289 assert_eq!(
290 h.artifact_roots(),
291 vec![
292 std::path::PathBuf::from("/home/max/Code/Apps"),
293 std::path::PathBuf::from("/home/max/Code/MNW"),
294 ],
295 );
296 let sync = build_sync(&h);
297 // The refusal must still name the secret-bearing sibling as out of
298 // bounds: adding MNW widens the gate by MNW, not by ~/Code.
299 let err = sync
300 .pull_file(
301 std::path::Path::new("/home/max/Code/_private/apple/notary.p8"),
302 std::path::Path::new("/tmp/out"),
303 &SyncOpts::default(),
304 )
305 .await
306 .expect_err("_private is under neither root");
307 assert!(
308 err.to_string()
309 .contains("escapes every declared artifact root"),
310 "{err}"
311 );
312 }
313
314 #[test]
315 fn local_host_syncs_locally() {
316 let h = host("[[host]]\nname = \"fw13\"\nssh = \"local\"\ntargets = [\"linux/x86_64\"]");
317 // A local host's sync transport still carries the host's grant.
318 assert!(build_sync(&h).capabilities().permits(&Action::Build));
319 }
320
321 /// The finding this fix closes, end to end through `build_sync`: a host with
322 /// a declared `pull_root` collects artifacts under it but refuses a path
323 /// outside it (the `~/.tauri/passwords.env` exfil). Confinement is lexical,
324 /// so no real filesystem is needed.
325 #[tokio::test]
326 async fn sync_pull_is_confined_to_declared_root() {
327 let h = host(
328 "[[host]]\nname = \"fw13\"\nssh = \"local\"\ntargets = [\"linux/x86_64\"]\n\
329 pull_root = \"/home/max/Code/Apps\"",
330 );
331 let sync = build_sync(&h);
332 // A path outside the declared root is refused before any rsync.
333 let err = sync
334 .pull_file(
335 std::path::Path::new("/home/max/.tauri/passwords.env"),
336 std::path::Path::new("/tmp/out"),
337 &SyncOpts::default(),
338 )
339 .await
340 .expect_err("a path outside pull_root must be denied");
341 assert!(
342 err.to_string()
343 .contains("escapes every declared artifact root"),
344 "{err}"
345 );
346
347 // A host with NO declared root collects nothing at all (fail-closed).
348 let bare = host("[[host]]\nname = \"fw13\"\nssh = \"local\"\ntargets = [\"linux/x86_64\"]");
349 let err = build_sync(&bare)
350 .pull_file(
351 std::path::Path::new("/home/max/Code/Apps/goingson/x.dmg"),
352 std::path::Path::new("/tmp/out"),
353 &SyncOpts::default(),
354 )
355 .await
356 .expect_err("no pull_root ⇒ no pulls");
357 assert!(
358 err.to_string().contains("no artifact root declared"),
359 "{err}"
360 );
361 }
362
363 /// One serialization lock per host, and they are independent: holding mbp's
364 /// lock (a macOS + iOS build serialize behind it) leaves fw13 free to build
365 /// in parallel. This is the fan-out-across-hosts / serialize-within-a-host
366 /// property the runner relies on.
367 #[tokio::test]
368 async fn host_locks_are_one_per_host_and_independent() {
369 use crate::topology::Topology;
370 let dir = tempfile::tempdir().unwrap();
371 let repo = dir.path().join("goingson");
372 std::fs::create_dir_all(&repo).unwrap();
373 std::fs::write(repo.join("bento.toml"), "targets = [\"macos/aarch64\"]\n").unwrap();
374 let topo = Topology::from_str_for_tests(&format!(
375 "[[host]]\nname = \"fw13\"\nssh = \"local\"\ntargets = [\"linux/x86_64\"]\n\
376 [[host]]\nname = \"mbp\"\nssh = \"mbp\"\ntargets = [\"macos/aarch64\"]\n\
377 [app.goingson]\nrepo = \"{}\"\n",
378 repo.display()
379 ))
380 .unwrap();
381 let locks = build_host_locks(&topo);
382 assert_eq!(locks.len(), 2);
383 let mbp = locks.get("mbp").expect("mbp lock").clone();
384 let _held = mbp.clone().lock_owned().await;
385 // Same host: a second acquire cannot proceed while the first is held.
386 assert!(mbp.try_lock().is_err(), "same-host builds must serialize");
387 // Different host: unaffected — it builds in parallel.
388 assert!(
389 locks.get("fw13").expect("fw13 lock").try_lock().is_ok(),
390 "a different host must not be blocked"
391 );
392 }
393
394 #[test]
395 fn agent_host_executor_permits_sign() {
396 // The mac host: agent transport, widened grant. Proves AgentRpc is
397 // constructed (no panic) and carries the sign capability that the
398 // SSH/local transports' default grant does not.
399 let h = host(
400 "[[host]]\nname = \"mbp\"\nssh = \"mbp\"\ntargets = [\"macos/aarch64\"]\n\
401 transport = \"agent\"\nagent_url = \"http://mbp:8765\"\n\
402 actuate = [\"build\", \"sign\", \"notarize\", \"staple\"]",
403 );
404 let exec = build_executor(&h);
405 assert!(exec.capabilities().permits(&Action::Sign));
406 assert!(exec.capabilities().permits(&Action::Notarize));
407 assert!(exec.capabilities().permits(&Action::Build));
408 }
409 }
410