Skip to main content

max / makenotwork

17.0 KB · 385 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 if host.ssh == "local" || host.ssh.is_empty() {
159 let exec = LocalExec::new(caps);
160 let exec = match &host.pull_root {
161 Some(root) => exec.with_pull_root(root),
162 None => exec,
163 };
164 Arc::new(exec)
165 } else {
166 let exec = SshExec::new(host.ssh.clone(), caps);
167 let exec = match &host.pull_root {
168 Some(root) => exec.with_pull_root(root),
169 None => exec,
170 };
171 Arc::new(exec)
172 }
173 }
174
175 /// Build the executor for a service's deploy destination.
176 ///
177 /// Deliberately NOT a build host, and never in the topology's host list: it is
178 /// granted `deploy` + `restart` and nothing else, so the same executor that
179 /// installs pom's binary cannot be handed a `build` step, and a compromised
180 /// recipe cannot turn the production box into a build host. `Action::Deploy` and
181 /// `Action::Restart` already exist in `ops_exec` for Sando's promotions; this is
182 /// the same grant reaching the same kind of destination.
183 ///
184 /// It gets no `pull_root`, so the artifact-collection plane is closed on it in
185 /// both directions: `collect()` from a deploy host is refused fail-closed, which
186 /// is right — a service host produces nothing Bento should be fetching.
187 pub fn build_deploy_executor(d: &crate::topology::DeployTarget) -> Arc<dyn Executor> {
188 let caps = CapabilitySet::from_tokens(["deploy", "restart"], ["build-log"]);
189 if d.host == "local" || d.host.is_empty() {
190 return Arc::new(LocalExec::new(caps));
191 }
192 Arc::new(SshExec::new(d.host.clone(), caps).with_port(d.port))
193 }
194
195 /// Build the full host name -> exec-executor map from the topology.
196 pub fn build_executors(topo: &Topology) -> ExecutorMap {
197 topo.hosts
198 .iter()
199 .map(|h| (h.name.clone(), build_executor(h)))
200 .collect()
201 }
202
203 /// Build the full host name -> sync-transport map from the topology.
204 pub fn build_syncs(topo: &Topology) -> ExecutorMap {
205 topo.hosts
206 .iter()
207 .map(|h| (h.name.clone(), build_sync(h)))
208 .collect()
209 }
210
211 #[cfg(test)]
212 mod tests {
213 use super::*;
214 use ops_exec::{Action, SyncOpts};
215
216 fn host(toml_host: &str) -> Host {
217 // Only a host is under test here, so parse just the `[[host]]` table.
218 // Going through `Topology` would drag in an app pointer and a manifest
219 // on disk for no reason.
220 #[derive(serde::Deserialize)]
221 struct Hosts {
222 #[serde(rename = "host")]
223 hosts: Vec<Host>,
224 }
225 let parsed: Hosts = toml::from_str(toml_host).unwrap();
226 parsed.hosts.into_iter().next().unwrap()
227 }
228
229 #[test]
230 fn build_host_executor_permits_build_not_sign() {
231 let h = host("[[host]]\nname = \"fw13\"\nssh = \"local\"\ntargets = [\"linux/x86_64\"]");
232 let exec = build_executor(&h);
233 assert!(exec.capabilities().permits(&Action::Build));
234 assert!(exec.capabilities().permits(&Action::Package));
235 assert!(!exec.capabilities().permits(&Action::Sign));
236 }
237
238 /// The load-bearing property of the two-plane split: a mac host signs over
239 /// the agent but is COLLECTED FROM over ssh. If this ever regresses to one
240 /// transport, collect hits `AgentRpc::pull_glob` (refused by design) or
241 /// forces `pull_root` wide enough to expose `~/.tauri/passwords.env`.
242 #[tokio::test]
243 async fn agent_host_syncs_over_ssh_never_the_agent() {
244 let h = host(
245 "[[host]]\nname = \"mbp\"\nssh = \"mbp\"\ntargets = [\"macos/aarch64\"]\n\
246 transport = \"agent\"\nagent_url = \"http://mbp:8765\"\n\
247 actuate = [\"build\", \"sign\", \"notarize\", \"staple\"]\n\
248 observe = [\"build-log\", \"gatekeeper\", \"artifact\"]\n\
249 pull_root = \"/nonexistent\"",
250 );
251 // The sync transport must not be the agent. AgentRpc refuses pull_glob
252 // by design, so a non-refusing error proves we got an ssh transport.
253 // The host declares `artifact` + a `pull_root` so the pull clears the
254 // fail-closed sync gate and reaches the actual ssh rsync (which then
255 // fails on the unreachable host / no match) rather than the gate.
256 let sync = build_sync(&h);
257 let err = sync
258 .pull_glob(
259 "/nonexistent/*.dmg",
260 std::path::Path::new("/tmp"),
261 &SyncOpts::default(),
262 )
263 .await
264 .expect_err("nothing matches, so this must error either way");
265 assert!(
266 !err.to_string().contains("unsupported by design"),
267 "sync transport for an agent host must NOT be AgentRpc: {err}"
268 );
269 assert!(
270 !err.to_string().contains("no artifact root declared"),
271 "the declared pull_root must let the pull reach the ssh transport: {err}"
272 );
273 // ...while the exec transport for the same host still is the agent.
274 let exec = build_executor(&h);
275 let err = exec
276 .pull_glob(
277 "/x/*.dmg",
278 std::path::Path::new("/tmp"),
279 &SyncOpts::default(),
280 )
281 .await
282 .expect_err("AgentRpc has no glob form");
283 assert!(
284 err.to_string().contains("unsupported by design"),
285 "exec plane is the agent: {err}"
286 );
287 }
288
289 #[test]
290 fn local_host_syncs_locally() {
291 let h = host("[[host]]\nname = \"fw13\"\nssh = \"local\"\ntargets = [\"linux/x86_64\"]");
292 // A local host's sync transport still carries the host's grant.
293 assert!(build_sync(&h).capabilities().permits(&Action::Build));
294 }
295
296 /// The finding this fix closes, end to end through `build_sync`: a host with
297 /// a declared `pull_root` collects artifacts under it but refuses a path
298 /// outside it (the `~/.tauri/passwords.env` exfil). Confinement is lexical,
299 /// so no real filesystem is needed.
300 #[tokio::test]
301 async fn sync_pull_is_confined_to_declared_root() {
302 let h = host(
303 "[[host]]\nname = \"fw13\"\nssh = \"local\"\ntargets = [\"linux/x86_64\"]\n\
304 pull_root = \"/home/max/Code/Apps\"",
305 );
306 let sync = build_sync(&h);
307 // A path outside the declared root is refused before any rsync.
308 let err = sync
309 .pull_file(
310 std::path::Path::new("/home/max/.tauri/passwords.env"),
311 std::path::Path::new("/tmp/out"),
312 &SyncOpts::default(),
313 )
314 .await
315 .expect_err("a path outside pull_root must be denied");
316 assert!(
317 err.to_string()
318 .contains("escapes the declared artifact root"),
319 "{err}"
320 );
321
322 // A host with NO declared root collects nothing at all (fail-closed).
323 let bare = host("[[host]]\nname = \"fw13\"\nssh = \"local\"\ntargets = [\"linux/x86_64\"]");
324 let err = build_sync(&bare)
325 .pull_file(
326 std::path::Path::new("/home/max/Code/Apps/goingson/x.dmg"),
327 std::path::Path::new("/tmp/out"),
328 &SyncOpts::default(),
329 )
330 .await
331 .expect_err("no pull_root ⇒ no pulls");
332 assert!(
333 err.to_string().contains("no artifact root declared"),
334 "{err}"
335 );
336 }
337
338 /// One serialization lock per host, and they are independent: holding mbp's
339 /// lock (a macOS + iOS build serialize behind it) leaves fw13 free to build
340 /// in parallel. This is the fan-out-across-hosts / serialize-within-a-host
341 /// property the runner relies on.
342 #[tokio::test]
343 async fn host_locks_are_one_per_host_and_independent() {
344 use crate::topology::Topology;
345 let dir = tempfile::tempdir().unwrap();
346 let repo = dir.path().join("goingson");
347 std::fs::create_dir_all(&repo).unwrap();
348 std::fs::write(repo.join("bento.toml"), "targets = [\"macos/aarch64\"]\n").unwrap();
349 let topo = Topology::from_str_for_tests(&format!(
350 "[[host]]\nname = \"fw13\"\nssh = \"local\"\ntargets = [\"linux/x86_64\"]\n\
351 [[host]]\nname = \"mbp\"\nssh = \"mbp\"\ntargets = [\"macos/aarch64\"]\n\
352 [app.goingson]\nrepo = \"{}\"\n",
353 repo.display()
354 ))
355 .unwrap();
356 let locks = build_host_locks(&topo);
357 assert_eq!(locks.len(), 2);
358 let mbp = locks.get("mbp").expect("mbp lock").clone();
359 let _held = mbp.clone().lock_owned().await;
360 // Same host: a second acquire cannot proceed while the first is held.
361 assert!(mbp.try_lock().is_err(), "same-host builds must serialize");
362 // Different host: unaffected — it builds in parallel.
363 assert!(
364 locks.get("fw13").expect("fw13 lock").try_lock().is_ok(),
365 "a different host must not be blocked"
366 );
367 }
368
369 #[test]
370 fn agent_host_executor_permits_sign() {
371 // The mac host: agent transport, widened grant. Proves AgentRpc is
372 // constructed (no panic) and carries the sign capability that the
373 // SSH/local transports' default grant does not.
374 let h = host(
375 "[[host]]\nname = \"mbp\"\nssh = \"mbp\"\ntargets = [\"macos/aarch64\"]\n\
376 transport = \"agent\"\nagent_url = \"http://mbp:8765\"\n\
377 actuate = [\"build\", \"sign\", \"notarize\", \"staple\"]",
378 );
379 let exec = build_executor(&h);
380 assert!(exec.capabilities().permits(&Action::Sign));
381 assert!(exec.capabilities().permits(&Action::Notarize));
382 assert!(exec.capabilities().permits(&Action::Build));
383 }
384 }
385