Skip to main content

max / makenotwork

18.6 KB · 413 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 ///
118 /// Unknown fields are refused. The table is five keys an operator writes by
119 /// hand, and the one migration it has had — `token_file` to `token_env` — is
120 /// exactly the case a silently ignored key turns into a 401 nobody can read.
121 #[derive(Debug, Clone, Deserialize)]
122 #[serde(deny_unknown_fields)]
123 pub struct Handoff {
124 /// SSH destination of the host sandod runs on (a tailnet alias like `fw13`,
125 /// or `sando@fw13`); `local` stages on this daemon's own filesystem.
126 ///
127 /// Usually the same machine bentod is on, and still worth going through ssh:
128 /// sandod runs as `sando` and bentod as a user unit, so `sando@fw13` lands
129 /// the bytes owned by the process that has to rename them, without a group
130 /// or an ACL on the staging directory.
131 pub host: String,
132 /// Absolute path of Sando's staging directory ON THAT HOST — its
133 /// `release_root` plus `staging`. Sando refuses a bundle staged anywhere
134 /// else, because publishing is an atomic rename and a staging dir on another
135 /// filesystem would silently become a copy.
136 pub staging_root: PathBuf,
137 /// Base URL of the sandod that will take the intake, e.g.
138 /// `http://100.103.89.95:7766`. Tailnet address, never a public one.
139 pub url: String,
140 /// The app id SANDO knows this product by, when it differs from Bento's.
141 /// Absent means the daemon's default product, which is the unprefixed mount;
142 /// present routes to `/apps/<id>/intake`.
143 #[serde(default)]
144 pub sando_app: Option<String>,
145 /// Name of the environment variable holding sandod's bearer token.
146 ///
147 /// The token is named, never inlined and never a path into `secrets_root`:
148 /// this file describes topology and has every reason to be readable, while
149 /// both daemons already take their own tokens from the environment
150 /// (`SANDO_API_TOKEN`, `BENTO_API_TOKEN`). Pointing at a file put the
151 /// credential on disk in plaintext inside a tree that is now under git,
152 /// where the only thing standing between it and a mirror was a pre-commit
153 /// hook. Same convention as magicmirror's `[[source]] token_env`.
154 ///
155 /// Absent sends no `Authorization` header, which only works against a
156 /// loopback sandod that configured none.
157 #[serde(default)]
158 pub token_env: Option<String>,
159 }
160
161 fn default_true() -> bool {
162 true
163 }
164
165 fn default_deploy_installer() -> String {
166 "sudo /usr/local/lib/bento/install-service.sh".into()
167 }
168
169 fn default_logs_root() -> PathBuf {
170 PathBuf::from("/srv/bento/logs")
171 }
172
173 impl Config {
174 pub fn load() -> Result<Self> {
175 let path = std::env::var("BENTO_CONFIG").unwrap_or_else(|_| "bento-daemon.toml".into());
176 let raw = std::fs::read_to_string(&path)
177 .with_context(|| format!("reading daemon config at {path}"))?;
178 let cfg: Self = toml::from_str(&raw)?;
179 cfg.validate()
180 .with_context(|| format!("daemon config at {path}"))?;
181 Ok(cfg)
182 }
183
184 /// Check what would otherwise only fail mid-release. The archive root is
185 /// interpolated into an rsync destination on another machine, so a relative
186 /// path or a `..` would deposit a signed release somewhere other than where
187 /// the config appears to say — the same rule, and the same reason, as
188 /// `topology::validate_deploy`'s check on `install_path`.
189 fn validate(&self) -> Result<()> {
190 if let Some(a) = &self.archive {
191 anyhow::ensure!(
192 !a.host.trim().is_empty(),
193 "[archive] host is empty; set it to an ssh destination or `local`"
194 );
195 anyhow::ensure!(
196 a.root.is_absolute()
197 && !a
198 .root
199 .components()
200 .any(|c| c == std::path::Component::ParentDir),
201 "[archive] root `{}` must be an absolute path with no `..`",
202 a.root.display()
203 );
204 }
205 for (app, h) in &self.handoff {
206 anyhow::ensure!(
207 !h.host.trim().is_empty(),
208 "[handoff.{app}] host is empty; set it to an ssh destination or `local`"
209 );
210 // Same rule and the same reason as the archive root, with more at
211 // stake: this destination is rsynced with `--delete`, so a relative
212 // path or a `..` prunes a directory other than the one the config
213 // reads as naming.
214 anyhow::ensure!(
215 h.staging_root.is_absolute()
216 && !h
217 .staging_root
218 .components()
219 .any(|c| c == std::path::Component::ParentDir),
220 "[handoff.{app}] staging_root `{}` must be an absolute path with no `..`",
221 h.staging_root.display()
222 );
223 anyhow::ensure!(
224 h.url.starts_with("http://") || h.url.starts_with("https://"),
225 "[handoff.{app}] url `{}` must be an http(s) URL for sandod",
226 h.url
227 );
228 // A variable name, not a value and not a path. Catching the two
229 // wrong shapes at startup matters more than it looks: a name with a
230 // `/` in it is an operator who wrote the old `token_file` spelling
231 // under the new key, and a name that parses as one but holds the
232 // secret itself is the paste this field exists to prevent. Both
233 // would otherwise surface as an unset variable at the first release.
234 if let Some(t) = &h.token_env {
235 anyhow::ensure!(
236 !t.trim().is_empty()
237 && t.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
238 && !t.starts_with(|c: char| c.is_ascii_digit()),
239 "[handoff.{app}] token_env `{t}` must name an environment variable \
240 (letters, digits and underscores), not hold the token or a path"
241 );
242 }
243 }
244 Ok(())
245 }
246
247 #[cfg(test)]
248 pub fn for_tests(root: &std::path::Path) -> Self {
249 Self {
250 listen: "127.0.0.1:0".into(),
251 db_path: root.join("bento.db"),
252 topology_path: root.join("bento.toml"),
253 secrets_root: root.join("secrets"),
254 dist_root: root.join("dist"),
255 // Off by default in tests: the archive is a second machine, and the
256 // tests that exercise it point it at a local directory themselves.
257 archive: None,
258 // Off for the same reason as the archive: a handoff is another
259 // daemon, and the tests that exercise it stand one up themselves.
260 handoff: HashMap::new(),
261 logs_root: root.join("logs"),
262 step_timeout_secs: None,
263 notarize_backoff_secs: None,
264 // Test repos are plain dirs, not git checkouts; the barrier is
265 // exercised by its own tests that build a real tagged repo.
266 pin_release_sha: false,
267 deploy_installer: default_deploy_installer(),
268 }
269 }
270 }
271
272 #[cfg(test)]
273 mod tests {
274 use super::*;
275
276 /// Parse a whole daemon config with `body` appended, through the same
277 /// validation `load()` runs.
278 fn parse(body: &str) -> Result<Config> {
279 let base = r#"
280 listen = "127.0.0.1:8765"
281 db_path = "/var/lib/bento/bento.db"
282 topology_path = "/etc/bento/bento.toml"
283 secrets_root = "/home/max/Code/_private"
284 dist_root = "/home/max/Dist"
285 "#;
286 let cfg: Config = toml::from_str(&format!("{base}{body}"))?;
287 cfg.validate()?;
288 Ok(cfg)
289 }
290
291 /// The table is optional: every config written before the archive existed
292 /// must keep loading, and an operator who has not named an archive host
293 /// still gets releases.
294 #[test]
295 fn the_archive_table_is_optional() {
296 assert!(parse("").unwrap().archive.is_none());
297 }
298
299 #[test]
300 fn an_archive_host_and_root_parse() {
301 let cfg =
302 parse("[archive]\nhost = \"astra\"\nroot = \"/var/lib/bento/artifacts\"\n").unwrap();
303 let a = cfg.archive.expect("archive configured");
304 assert_eq!(a.host, "astra");
305 assert_eq!(a.root, PathBuf::from("/var/lib/bento/artifacts"));
306 }
307
308 /// The root reaches an rsync destination on another machine. A relative path
309 /// lands in the ssh user's home and a `..` walks out of the declared tree —
310 /// both put a signed release somewhere other than where the config reads as
311 /// saying, which is worth failing at startup rather than mid-release.
312 #[test]
313 fn a_relative_or_dot_dot_archive_root_is_rejected() {
314 assert!(parse("[archive]\nhost = \"astra\"\nroot = \"artifacts\"\n").is_err());
315 assert!(parse("[archive]\nhost = \"astra\"\nroot = \"/var/../etc/bento\"\n").is_err());
316 }
317
318 #[test]
319 fn an_empty_archive_host_is_rejected() {
320 assert!(parse("[archive]\nhost = \"\"\nroot = \"/var/lib/bento/artifacts\"\n").is_err());
321 }
322
323 /// No handoff table at all is the ordinary case — every app but the ones
324 /// Sando deploys builds and archives and stops there.
325 #[test]
326 fn handoff_is_per_app_and_absent_by_default() {
327 assert!(parse("").unwrap().handoff.is_empty());
328 }
329
330 #[test]
331 fn a_handoff_parses_with_its_optional_fields_absent() {
332 let cfg = parse(
333 r#"
334 [handoff.pom]
335 host = "sando@fw13"
336 staging_root = "/srv/sando/staging"
337 url = "http://100.103.89.95:7766"
338 "#,
339 )
340 .unwrap();
341 let h = cfg.handoff.get("pom").expect("pom hands off");
342 assert_eq!(h.host, "sando@fw13");
343 assert_eq!(h.staging_root, PathBuf::from("/srv/sando/staging"));
344 // Absent means Sando's default product and no bearer header, which is
345 // the shape a single-product sandod on loopback wants.
346 assert!(h.sando_app.is_none());
347 assert!(h.token_env.is_none());
348 }
349
350 /// The destination is rsynced with `--delete`. A relative path lands in the
351 /// ssh user's home and a `..` walks out of the declared tree, and either one
352 /// would prune a directory the config does not appear to name.
353 #[test]
354 fn a_relative_or_dot_dot_staging_root_is_rejected() {
355 let with = |root: &str| {
356 format!(
357 "[handoff.pom]\nhost = \"fw13\"\nstaging_root = \"{root}\"\nurl = \"http://x:1\"\n"
358 )
359 };
360 assert!(parse(&with("staging")).is_err());
361 assert!(parse(&with("/srv/../etc/sando")).is_err());
362 assert!(parse(&with("/srv/sando/staging")).is_ok());
363 }
364
365 /// `token_env` names a variable. The two ways to get it wrong both read as
366 /// plausible TOML and both fail at the first release rather than at
367 /// startup: a path is the retired `token_file` spelling moved to the new
368 /// key, and a value is the paste the field exists to prevent.
369 #[test]
370 fn a_token_env_that_is_a_path_or_a_pasted_secret_is_rejected() {
371 let with = |token: &str| {
372 format!(
373 "[handoff.pom]\nhost = \"fw13\"\nstaging_root = \"/srv/sando/staging\"\n\
374 url = \"http://x:1\"\ntoken_env = \"{token}\"\n"
375 )
376 };
377 assert!(parse(&with("sando/api-token")).is_err());
378 assert!(parse(&with("../../etc/shadow")).is_err());
379 assert!(parse(&with("")).is_err());
380 // A hex token pasted where its name belongs: the dashes and slashes are
381 // gone, but so is any reason a variable would be named this.
382 assert!(parse(&with("2f9c4e1a-77bd-4f0e-9a3e-1c8d5b6e0f21")).is_err());
383 assert!(parse(&with("BENTO_SANDO_TOKEN")).is_ok());
384 }
385
386 /// The old key is gone rather than silently ignored. `serde` accepts an
387 /// unknown field by default, so a config still carrying `token_file` would
388 /// load, send no `Authorization` header, and fail at sandod as a 401 that
389 /// reads as a wrong token rather than as a config that was never migrated.
390 #[test]
391 fn the_retired_token_file_key_is_refused() {
392 let err = parse(
393 "[handoff.pom]\nhost = \"fw13\"\nstaging_root = \"/srv/sando/staging\"\n\
394 url = \"http://x:1\"\ntoken_file = \"sando/api-token\"\n",
395 )
396 .expect_err("token_file was replaced by token_env");
397 assert!(format!("{err:#}").contains("token_file"), "{err:#}");
398 }
399
400 /// The url is interpolated into a request. A bare host:port would be sent
401 /// as a relative URL and fail at the first release rather than at startup.
402 #[test]
403 fn a_handoff_url_must_name_a_scheme() {
404 let with = |url: &str| {
405 format!(
406 "[handoff.pom]\nhost = \"fw13\"\nstaging_root = \"/srv/sando/staging\"\nurl = \"{url}\"\n"
407 )
408 };
409 assert!(parse(&with("100.103.89.95:7766")).is_err());
410 assert!(parse(&with("http://100.103.89.95:7766")).is_ok());
411 }
412 }
413