Skip to main content

max / makenotwork

16.5 KB · 378 lines History Blame Raw
1 //! Daemon-local config (`bento-daemon.toml`) — paths and listen address that
2 //! belong to the machine bentod runs on, not to the build matrix (which lives
3 //! in the separate topology file, see [`crate::topology`]).
4
5 use anyhow::{Context, Result};
6 use serde::Deserialize;
7 use std::collections::HashMap;
8 use std::path::PathBuf;
9
10 #[derive(Debug, Clone, Deserialize)]
11 pub struct Config {
12 pub listen: String,
13 pub db_path: PathBuf,
14 pub topology_path: PathBuf,
15 /// Root of the Syncthing private layer (`~/Code/_private`); the `secret()`
16 /// host function reads credential files relative to here. Never logged.
17 pub secrets_root: PathBuf,
18 /// Where collected artifacts land on the daemon's own box
19 /// (`<dist_root>/<app>/<version>/<target>/`).
20 pub dist_root: PathBuf,
21 /// Where every build's finished artifacts are deposited, whichever host
22 /// produced them (see [`Archive`]). Unset = artifacts stay on whichever box
23 /// bentod happens to run on, which is what "where is the AppImage for
24 /// goingson 1.4.0" had no single answer to.
25 #[serde(default)]
26 pub archive: Option<Archive>,
27 /// Which apps hand their finished artifacts to a Sando, keyed by the app id
28 /// this daemon knows it by (see [`Handoff`]). An app with no entry builds
29 /// and archives exactly as before; there is no fleet-wide default, because
30 /// handing an artifact to a deploy controller is a per-product decision.
31 #[serde(default)]
32 pub handoff: HashMap<String, Handoff>,
33 /// Root for per-step run logs
34 /// (`<logs_root>/<app>/<version>/<target>/<step>.<run_id>.log`).
35 #[serde(default = "default_logs_root")]
36 pub logs_root: PathBuf,
37 /// Override the per-step wall-clock budget (in seconds) for EVERY step,
38 /// replacing the per-kind defaults in `engine::step_budget`. Unset (the
39 /// default) uses those. Mainly an escape hatch for a constrained host or a
40 /// test that needs a short deadline.
41 #[serde(default)]
42 pub step_timeout_secs: Option<u64>,
43 /// Seconds to wait between `notarize` retry attempts. Unset (the default)
44 /// uses the 15-second production backoff; a test drives the retry loop with
45 /// `Some(0)` so it doesn't actually sleep. Same test-seam shape as
46 /// `step_timeout_secs`.
47 #[serde(default)]
48 pub notarize_backoff_secs: Option<u64>,
49 /// Pin every build host to the release tag `v<version>` and verify they all
50 /// report the same commit BEFORE any target builds — so `mbp`/`astra`/`fw13`
51 /// can't each build whatever `main` happened to be at pull time. On by
52 /// default; turn off only to build from an untagged commit (or in tests
53 /// whose repos aren't git checkouts).
54 #[serde(default = "default_true")]
55 pub pin_release_sha: bool,
56 /// The command that installs a service binary and restarts its unit, run on
57 /// the service host with `<staged-binary> <install-path> <unit>` appended.
58 ///
59 /// Bento never installs or restarts anything itself. It stages bytes and
60 /// calls this, and what it calls is a root script whose arguments are
61 /// re-checked on the far side — so the sudoers grant on a production box is
62 /// ONE auditable script rather than a broad `install` + `systemctl` grant.
63 /// Same shape as Sando's `install-companion.sh`.
64 ///
65 /// Configurable because the sudo path differs per host fleet, and because a
66 /// test needs to point it somewhere that is not root. It is read from the
67 /// same trusted daemon config that already names the secrets root, so this
68 /// adds no trust boundary that was not already there.
69 #[serde(default = "default_deploy_installer")]
70 pub deploy_installer: String,
71 }
72
73 /// The one place a finished artifact ends up, named once for the whole fleet.
74 ///
75 /// `pull_root` is the *collection* root the sync gate fences on each build host;
76 /// it says what may be pulled off that box, not where the result belongs. So
77 /// before this, a release's bytes ended up wherever bentod was running, and the
78 /// answer to "where is the AppImage for goingson 1.4.0" depended on which host
79 /// built it.
80 ///
81 /// Deposited by the daemon after each target's `collect`, so it holds every
82 /// target of every app — including the macOS and Windows ones bentod does not
83 /// run on — under one versioned path.
84 #[derive(Debug, Clone, Deserialize)]
85 pub struct Archive {
86 /// SSH destination of the archive host (a tailnet alias like `astra`, or
87 /// `user@host`); `local` deposits on the daemon's own filesystem.
88 ///
89 /// astra in production: always on, on the tailnet, and already the aarch64
90 /// build host and git mirror, so it is the one box every other build host
91 /// can reliably reach.
92 pub host: String,
93 /// Absolute root ON THE ARCHIVE HOST, e.g. `/var/lib/bento/artifacts`.
94 /// Never tilde-expanded — it names a location on the far side, not here.
95 pub root: PathBuf,
96 }
97
98 /// Where one app's finished artifacts are handed to Sando, and how to tell it
99 /// they arrived.
100 ///
101 /// The Sando/Bento boundary is "Bento builds and packages, Sando decides whether
102 /// a thing advances a stage", and deciding requires being handed bytes. Sando's
103 /// `POST /intake` takes a bundle that is *already* under its staging directory —
104 /// it owns proving the bytes, and the producer owns getting them there. This is
105 /// the producer half.
106 ///
107 /// Push and not pull, for a reason worth recording: Sando would have to be told
108 /// a version exists either way (the same POST), and it would then be reaching
109 /// into the build daemon's tree as a different user to fetch what it was just
110 /// told about. Pull is push with an extra hop and a second credential.
111 ///
112 /// The transfer excludes the artifact record. Evidence names the digest of the
113 /// bytes it vouches for, and the digest covers every file in the bundle, so a
114 /// record copied in among the artifacts would change the digest it names and
115 /// Sando would refuse the bundle — correctly. The record travels in the request
116 /// body instead.
117 #[derive(Debug, Clone, Deserialize)]
118 pub struct Handoff {
119 /// SSH destination of the host sandod runs on (a tailnet alias like `fw13`,
120 /// or `sando@fw13`); `local` stages on this daemon's own filesystem.
121 ///
122 /// Usually the same machine bentod is on, and still worth going through ssh:
123 /// sandod runs as `sando` and bentod as a user unit, so `sando@fw13` lands
124 /// the bytes owned by the process that has to rename them, without a group
125 /// or an ACL on the staging directory.
126 pub host: String,
127 /// Absolute path of Sando's staging directory ON THAT HOST — its
128 /// `release_root` plus `staging`. Sando refuses a bundle staged anywhere
129 /// else, because publishing is an atomic rename and a staging dir on another
130 /// filesystem would silently become a copy.
131 pub staging_root: PathBuf,
132 /// Base URL of the sandod that will take the intake, e.g.
133 /// `http://100.103.89.95:7766`. Tailnet address, never a public one.
134 pub url: String,
135 /// The app id SANDO knows this product by, when it differs from Bento's.
136 /// Absent means the daemon's default product, which is the unprefixed mount;
137 /// present routes to `/apps/<id>/intake`.
138 #[serde(default)]
139 pub sando_app: Option<String>,
140 /// File holding sandod's bearer token, as a relative path under
141 /// `secrets_root` — the same private layer the `secret()` host function
142 /// reads, so the token is not a second secrets mechanism. Absent sends no
143 /// `Authorization` header, which only works against a loopback sandod that
144 /// configured none.
145 #[serde(default)]
146 pub token_file: Option<PathBuf>,
147 }
148
149 fn default_true() -> bool {
150 true
151 }
152
153 fn default_deploy_installer() -> String {
154 "sudo /usr/local/lib/bento/install-service.sh".into()
155 }
156
157 fn default_logs_root() -> PathBuf {
158 PathBuf::from("/srv/bento/logs")
159 }
160
161 impl Config {
162 pub fn load() -> Result<Self> {
163 let path = std::env::var("BENTO_CONFIG").unwrap_or_else(|_| "bento-daemon.toml".into());
164 let raw = std::fs::read_to_string(&path)
165 .with_context(|| format!("reading daemon config at {path}"))?;
166 let cfg: Self = toml::from_str(&raw)?;
167 cfg.validate()
168 .with_context(|| format!("daemon config at {path}"))?;
169 Ok(cfg)
170 }
171
172 /// Check what would otherwise only fail mid-release. The archive root is
173 /// interpolated into an rsync destination on another machine, so a relative
174 /// path or a `..` would deposit a signed release somewhere other than where
175 /// the config appears to say — the same rule, and the same reason, as
176 /// `topology::validate_deploy`'s check on `install_path`.
177 fn validate(&self) -> Result<()> {
178 if let Some(a) = &self.archive {
179 anyhow::ensure!(
180 !a.host.trim().is_empty(),
181 "[archive] host is empty; set it to an ssh destination or `local`"
182 );
183 anyhow::ensure!(
184 a.root.is_absolute()
185 && !a
186 .root
187 .components()
188 .any(|c| c == std::path::Component::ParentDir),
189 "[archive] root `{}` must be an absolute path with no `..`",
190 a.root.display()
191 );
192 }
193 for (app, h) in &self.handoff {
194 anyhow::ensure!(
195 !h.host.trim().is_empty(),
196 "[handoff.{app}] host is empty; set it to an ssh destination or `local`"
197 );
198 // Same rule and the same reason as the archive root, with more at
199 // stake: this destination is rsynced with `--delete`, so a relative
200 // path or a `..` prunes a directory other than the one the config
201 // reads as naming.
202 anyhow::ensure!(
203 h.staging_root.is_absolute()
204 && !h
205 .staging_root
206 .components()
207 .any(|c| c == std::path::Component::ParentDir),
208 "[handoff.{app}] staging_root `{}` must be an absolute path with no `..`",
209 h.staging_root.display()
210 );
211 anyhow::ensure!(
212 h.url.starts_with("http://") || h.url.starts_with("https://"),
213 "[handoff.{app}] url `{}` must be an http(s) URL for sandod",
214 h.url
215 );
216 // The token is read relative to `secrets_root`, so the same
217 // traversal guard the `secret()` host function applies belongs here:
218 // a config that could name `../../etc/shadow` would turn a path into
219 // a read primitive.
220 if let Some(t) = &h.token_file {
221 anyhow::ensure!(
222 t.is_relative()
223 && !t.components().any(|c| c == std::path::Component::ParentDir),
224 "[handoff.{app}] token_file `{}` must be a relative path under secrets_root",
225 t.display()
226 );
227 }
228 }
229 Ok(())
230 }
231
232 #[cfg(test)]
233 pub fn for_tests(root: &std::path::Path) -> Self {
234 Self {
235 listen: "127.0.0.1:0".into(),
236 db_path: root.join("bento.db"),
237 topology_path: root.join("bento.toml"),
238 secrets_root: root.join("secrets"),
239 dist_root: root.join("dist"),
240 // Off by default in tests: the archive is a second machine, and the
241 // tests that exercise it point it at a local directory themselves.
242 archive: None,
243 // Off for the same reason as the archive: a handoff is another
244 // daemon, and the tests that exercise it stand one up themselves.
245 handoff: HashMap::new(),
246 logs_root: root.join("logs"),
247 step_timeout_secs: None,
248 notarize_backoff_secs: None,
249 // Test repos are plain dirs, not git checkouts; the barrier is
250 // exercised by its own tests that build a real tagged repo.
251 pin_release_sha: false,
252 deploy_installer: default_deploy_installer(),
253 }
254 }
255 }
256
257 #[cfg(test)]
258 mod tests {
259 use super::*;
260
261 /// Parse a whole daemon config with `body` appended, through the same
262 /// validation `load()` runs.
263 fn parse(body: &str) -> Result<Config> {
264 let base = r#"
265 listen = "127.0.0.1:8765"
266 db_path = "/var/lib/bento/bento.db"
267 topology_path = "/etc/bento/bento.toml"
268 secrets_root = "/home/max/Code/_private"
269 dist_root = "/home/max/Dist"
270 "#;
271 let cfg: Config = toml::from_str(&format!("{base}{body}"))?;
272 cfg.validate()?;
273 Ok(cfg)
274 }
275
276 /// The table is optional: every config written before the archive existed
277 /// must keep loading, and an operator who has not named an archive host
278 /// still gets releases.
279 #[test]
280 fn the_archive_table_is_optional() {
281 assert!(parse("").unwrap().archive.is_none());
282 }
283
284 #[test]
285 fn an_archive_host_and_root_parse() {
286 let cfg =
287 parse("[archive]\nhost = \"astra\"\nroot = \"/var/lib/bento/artifacts\"\n").unwrap();
288 let a = cfg.archive.expect("archive configured");
289 assert_eq!(a.host, "astra");
290 assert_eq!(a.root, PathBuf::from("/var/lib/bento/artifacts"));
291 }
292
293 /// The root reaches an rsync destination on another machine. A relative path
294 /// lands in the ssh user's home and a `..` walks out of the declared tree —
295 /// both put a signed release somewhere other than where the config reads as
296 /// saying, which is worth failing at startup rather than mid-release.
297 #[test]
298 fn a_relative_or_dot_dot_archive_root_is_rejected() {
299 assert!(parse("[archive]\nhost = \"astra\"\nroot = \"artifacts\"\n").is_err());
300 assert!(parse("[archive]\nhost = \"astra\"\nroot = \"/var/../etc/bento\"\n").is_err());
301 }
302
303 #[test]
304 fn an_empty_archive_host_is_rejected() {
305 assert!(parse("[archive]\nhost = \"\"\nroot = \"/var/lib/bento/artifacts\"\n").is_err());
306 }
307
308 /// No handoff table at all is the ordinary case — every app but the ones
309 /// Sando deploys builds and archives and stops there.
310 #[test]
311 fn handoff_is_per_app_and_absent_by_default() {
312 assert!(parse("").unwrap().handoff.is_empty());
313 }
314
315 #[test]
316 fn a_handoff_parses_with_its_optional_fields_absent() {
317 let cfg = parse(
318 r#"
319 [handoff.pom]
320 host = "sando@fw13"
321 staging_root = "/srv/sando/staging"
322 url = "http://100.103.89.95:7766"
323 "#,
324 )
325 .unwrap();
326 let h = cfg.handoff.get("pom").expect("pom hands off");
327 assert_eq!(h.host, "sando@fw13");
328 assert_eq!(h.staging_root, PathBuf::from("/srv/sando/staging"));
329 // Absent means Sando's default product and no bearer header, which is
330 // the shape a single-product sandod on loopback wants.
331 assert!(h.sando_app.is_none());
332 assert!(h.token_file.is_none());
333 }
334
335 /// The destination is rsynced with `--delete`. A relative path lands in the
336 /// ssh user's home and a `..` walks out of the declared tree, and either one
337 /// would prune a directory the config does not appear to name.
338 #[test]
339 fn a_relative_or_dot_dot_staging_root_is_rejected() {
340 let with = |root: &str| {
341 format!(
342 "[handoff.pom]\nhost = \"fw13\"\nstaging_root = \"{root}\"\nurl = \"http://x:1\"\n"
343 )
344 };
345 assert!(parse(&with("staging")).is_err());
346 assert!(parse(&with("/srv/../etc/sando")).is_err());
347 assert!(parse(&with("/srv/sando/staging")).is_ok());
348 }
349
350 /// `token_file` is resolved under `secrets_root`. Left unguarded it would be
351 /// a read primitive for any file the daemon user can open.
352 #[test]
353 fn a_token_file_that_escapes_secrets_root_is_rejected() {
354 let with = |token: &str| {
355 format!(
356 "[handoff.pom]\nhost = \"fw13\"\nstaging_root = \"/srv/sando/staging\"\n\
357 url = \"http://x:1\"\ntoken_file = \"{token}\"\n"
358 )
359 };
360 assert!(parse(&with("../../etc/shadow")).is_err());
361 assert!(parse(&with("/etc/shadow")).is_err());
362 assert!(parse(&with("sando/api-token")).is_ok());
363 }
364
365 /// The url is interpolated into a request. A bare host:port would be sent
366 /// as a relative URL and fail at the first release rather than at startup.
367 #[test]
368 fn a_handoff_url_must_name_a_scheme() {
369 let with = |url: &str| {
370 format!(
371 "[handoff.pom]\nhost = \"fw13\"\nstaging_root = \"/srv/sando/staging\"\nurl = \"{url}\"\n"
372 )
373 };
374 assert!(parse(&with("100.103.89.95:7766")).is_err());
375 assert!(parse(&with("http://100.103.89.95:7766")).is_ok());
376 }
377 }
378