majordomus_cli/served.rs
1//! What a deployment actually serves, observed from outside and judged against the commit
2//! that was meant to be there.
3//!
4//! A green publication job is not proof that a reader receives the build. GitHub's own
5//! Pages build can fail on a branch that was pushed perfectly, a CDN can hold the previous
6//! bytes, and a workflow can succeed while publishing nothing. The only evidence that a
7//! deployment carries a commit is the deployment saying so: the site serves its build
8//! identity (`/build.json`, written by `scripts/site-build` from the tree it built) and this
9//! module reads it the way a visitor would.
10//!
11//! Three things are kept apart, because collapsing any two of them is how a deployment gets
12//! reported as current when nobody measured it:
13//!
14//! - **fetching** is behind [`Fetch`]; the executable's implementation runs `curl` with a
15//! fixed argument vector and a bounded time, and tests substitute a fake;
16//! - **judging** is [`judge`], a pure function of what was served, the commit expected and
17//! what git knows about the two;
18//! - **recording** appends one [`Observation`] to a checkout-local file. It is not the
19//! committed evidence ledger: an observation of a later deployment committed into the tree
20//! it observes would describe a commit that no longer is the tree's.
21//!
22//! Freshness is containment, never age. A served commit proves every commit it contains, so
23//! an observation made an hour ago still proves a commit that was already on the site then,
24//! and an observation made a second ago proves nothing about a commit the site does not
25//! contain. [`judge`] is the same function whether the served identity was fetched now or
26//! read back from a record: re-judging a record against a newer expected commit is how a
27//! proof goes stale without anyone deciding that it has.
28//!
29//! Only [`ServedVerdict::Served`] passes. Unreachable, malformed, dirty and undecidable are each a
30//! reason the question was not answered, and none of them is an answer of yes.
31//!
32//! ```
33//! use majordomus_cli::served::ServedVerdict;
34//! assert!(ServedVerdict::Served.passes());
35//! assert!(!ServedVerdict::Unreachable.passes());
36//! assert_eq!(ServedVerdict::Stale.as_str(), "stale");
37//! ```
38
39use std::io::Write as _;
40use std::path::{Path, PathBuf};
41use std::process::Command;
42use std::time::SystemTime;
43
44use schemars::JsonSchema;
45use serde::{Deserialize, Serialize};
46use serde_json::Value;
47
48/// The schema every recorded observation carries.
49pub const OBSERVATION_SCHEMA: &str = "majordomus.served-observation/v1";
50
51/// Where observations are appended, repository-relative. Checkout-local and ignored.
52pub const OBSERVATIONS_PATH: &str = ".ai/local/state/served/observations.jsonl";
53
54/// The identity file a site serves when nothing names another.
55pub const DEFAULT_IDENTITY: &str = "build.json";
56
57/// The longest a single probe may take, in seconds, when the caller names no bound.
58pub const DEFAULT_PROBE_SECONDS: u64 = 10;
59
60// ---------------------------------------------------------------- what was served
61
62/// The build identity a deployment serves, as far as this module relies on it. Other
63/// fields of the served document are ignored rather than refused: the document is written
64/// by another program, and a field it adds is not a malformation.
65///
66/// ```
67/// use majordomus_cli::served::BuildIdentity;
68/// let id = BuildIdentity {
69/// commit: "a".repeat(40),
70/// dirty: false,
71/// source_version: Some("0.7.0".into()),
72/// source_hash: None,
73/// };
74/// // what is judged is the commit and whether the tree it was built from was committed
75/// assert_eq!(id.commit.len(), 40);
76/// assert!(!id.dirty);
77/// ```
78#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
79pub struct BuildIdentity {
80 /// The full commit the served build was made from.
81 pub commit: String,
82 /// Whether the tree it was built from had uncommitted changes. A dirty build names a
83 /// commit it does not equal, so it proves no commit.
84 pub dirty: bool,
85 /// The version the build states, when it states one.
86 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub source_version: Option<String>,
88 /// The fingerprint of the site's canonical inputs, when the build states one.
89 #[serde(default, skip_serializing_if = "Option::is_none")]
90 pub source_hash: Option<String>,
91}
92
93/// Why a served body is not a build identity. The reason is the value: a refusal that says
94/// only "invalid" sends the reader to look at the site by hand.
95///
96/// ```
97/// use majordomus_cli::served::{BuildIdentity, Malformed};
98/// let Malformed(why) = BuildIdentity::parse(b"<html>").unwrap_err();
99/// assert!(why.contains("not JSON"), "{why}");
100/// ```
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct Malformed(pub String);
103
104fn is_full_sha(s: &str) -> bool {
105 s.len() == 40 && s.bytes().all(|b| b.is_ascii_hexdigit())
106}
107
108impl BuildIdentity {
109 /// Reads a served body. The commit must be a full forty-character sha: a prefix names
110 /// whatever happens to start with it, and a served identity that could mean several
111 /// commits proves none of them.
112 ///
113 /// ```
114 /// use majordomus_cli::served::BuildIdentity;
115 /// let body = format!(r#"{{"schema":1,"commit":"{}","dirty":false}}"#, "a".repeat(40));
116 /// assert_eq!(BuildIdentity::parse(body.as_bytes()).unwrap().commit, "a".repeat(40));
117 /// assert!(BuildIdentity::parse(b"<html>").is_err());
118 /// ```
119 pub fn parse(body: &[u8]) -> Result<BuildIdentity, Malformed> {
120 let v: Value = serde_json::from_slice(body)
121 .map_err(|e| Malformed(format!("the served identity is not JSON: {e}")))?;
122 let commit = v
123 .get("commit")
124 .and_then(Value::as_str)
125 .ok_or_else(|| Malformed("the served identity names no `commit`".into()))?;
126 if !is_full_sha(commit) {
127 return Err(Malformed(format!(
128 "the served `commit` is `{commit}`, not a forty-character sha"
129 )));
130 }
131 let dirty = match v.get("dirty") {
132 None => false,
133 Some(Value::Bool(b)) => *b,
134 Some(other) => {
135 return Err(Malformed(format!(
136 "the served `dirty` is `{other}`, not a boolean"
137 )))
138 }
139 };
140 let text = |k: &str| v.get(k).and_then(Value::as_str).map(str::to_string);
141 Ok(BuildIdentity {
142 commit: commit.to_ascii_lowercase(),
143 dirty,
144 source_version: text("source_version"),
145 source_hash: text("source_hash"),
146 })
147 }
148}
149
150// ---------------------------------------------------------------- the verdict
151
152/// What an observation says about the expected commit. Only [`ServedVerdict::Served`] is a
153/// proof; the other five are each a different reason the question was not answered yes.
154///
155/// ```
156/// use majordomus_cli::served::ServedVerdict;
157/// // a measured no and an unanswered question are never spelled the same
158/// assert!(ServedVerdict::Served.passes());
159/// assert_eq!(ServedVerdict::Stale.exit_code(), 10);
160/// assert_eq!(ServedVerdict::Unreachable.exit_code(), 12);
161/// ```
162#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
163#[serde(rename_all = "snake_case")]
164pub enum ServedVerdict {
165 /// The served build is the expected commit or contains it. The only passing verdict.
166 Served,
167 /// The served build does not contain the expected commit: the deployment is behind.
168 Stale,
169 /// Nothing was received. The deployment was not measured.
170 Unreachable,
171 /// Something was received and it is not a build identity.
172 Malformed,
173 /// The served build was made from an uncommitted tree, so it proves no commit.
174 Dirty,
175 /// The served commit is not in this clone, or git could not be asked; containment is
176 /// undecided. Fetching the remote usually turns this into an answer.
177 Undecided,
178}
179
180impl ServedVerdict {
181 /// The word every surface prints — the command line, the HTTP body and the MCP
182 /// result all render the verdict through this one spelling.
183 ///
184 /// ```
185 /// use majordomus_cli::served::ServedVerdict;
186 /// assert_eq!(ServedVerdict::Undecided.as_str(), "undecided");
187 /// assert_eq!(ServedVerdict::Dirty.as_str(), "dirty");
188 /// ```
189 pub fn as_str(self) -> &'static str {
190 match self {
191 ServedVerdict::Served => "served",
192 ServedVerdict::Stale => "stale",
193 ServedVerdict::Unreachable => "unreachable",
194 ServedVerdict::Malformed => "malformed",
195 ServedVerdict::Dirty => "dirty",
196 ServedVerdict::Undecided => "undecided",
197 }
198 }
199
200 /// Whether this verdict proves the expected commit is deployed.
201 ///
202 /// ```
203 /// use majordomus_cli::served::ServedVerdict;
204 /// assert!(ServedVerdict::Served.passes());
205 /// // a site nobody could reach is not a site that serves the commit
206 /// assert!(!ServedVerdict::Unreachable.passes());
207 /// assert!(!ServedVerdict::Dirty.passes());
208 /// ```
209 pub fn passes(self) -> bool {
210 matches!(self, ServedVerdict::Served)
211 }
212
213 /// The exit status the command line reports it with: 0 a proof, 10 a measured "no",
214 /// 12 a question that could not be answered. The same three codes `scripts/pages
215 /// verify` uses, so a caller of either reads one convention.
216 ///
217 /// ```
218 /// use majordomus_cli::served::ServedVerdict;
219 /// assert_eq!(ServedVerdict::Served.exit_code(), 0);
220 /// assert_eq!(ServedVerdict::Dirty.exit_code(), 10); // measured, and a no
221 /// assert_eq!(ServedVerdict::Malformed.exit_code(), 12); // not measured at all
222 /// ```
223 pub fn exit_code(self) -> i32 {
224 match self {
225 ServedVerdict::Served => 0,
226 ServedVerdict::Stale | ServedVerdict::Dirty => 10,
227 ServedVerdict::Unreachable | ServedVerdict::Malformed | ServedVerdict::Undecided => 12,
228 }
229 }
230}
231
232/// What git knows about commits, asked through a trait so that the judgement is testable
233/// without a repository. Both answers are three-valued: `None` is "git could not be asked",
234/// which is not a `false`, and collapsing the two is how a record from a history this clone
235/// has never seen gets read as current.
236///
237/// ```
238/// use majordomus_cli::served::Ancestry;
239///
240/// struct OneCommit;
241/// impl Ancestry for OneCommit {
242/// fn has(&self, c: &str) -> Option<bool> { Some(c == "a".repeat(40)) }
243/// fn contains(&self, d: &str, a: &str) -> Option<bool> { Some(d == a) }
244/// }
245/// assert_eq!(OneCommit.has(&"a".repeat(40)), Some(true));
246/// assert_eq!(OneCommit.has(&"f".repeat(40)), Some(false));
247/// ```
248pub trait Ancestry {
249 /// Does this clone hold `commit`? `None` when git cannot be asked.
250 ///
251 /// ```
252 /// use majordomus_cli::served::{Ancestry, GitAncestry};
253 /// use std::path::Path;
254 /// // a sha no repository holds resolves to a `false`, not to an error
255 /// let git = GitAncestry { root: Path::new(env!("CARGO_MANIFEST_DIR")) };
256 /// assert_eq!(git.has(&"f".repeat(40)), Some(false));
257 /// ```
258 fn has(&self, commit: &str) -> Option<bool>;
259 /// Does `descendant` contain `ancestor` (equal, or reachable from it)? `None` when git
260 /// cannot be asked.
261 ///
262 /// ```
263 /// use majordomus_cli::served::{Ancestry, GitAncestry};
264 /// use std::path::Path;
265 /// let git = GitAncestry { root: Path::new(env!("CARGO_MANIFEST_DIR")) };
266 /// // every commit contains itself, which is what makes an exact match a proof
267 /// let head = String::from_utf8(
268 /// std::process::Command::new("git").args(["-C", env!("CARGO_MANIFEST_DIR"), "rev-parse", "HEAD"])
269 /// .output().unwrap().stdout).unwrap();
270 /// let head = head.trim();
271 /// assert_eq!(git.contains(head, head), Some(true));
272 /// ```
273 fn contains(&self, descendant: &str, ancestor: &str) -> Option<bool>;
274}
275
276/// The [`Ancestry`] the executable uses: this repository's own git, asked read-only.
277///
278/// ```
279/// use majordomus_cli::served::{Ancestry, GitAncestry};
280/// use std::path::Path;
281/// let git = GitAncestry { root: Path::new(env!("CARGO_MANIFEST_DIR")) };
282/// assert_eq!(git.has(&"0".repeat(40)), Some(false));
283/// ```
284pub struct GitAncestry<'a> {
285 /// The checkout asked.
286 pub root: &'a Path,
287}
288
289impl Ancestry for GitAncestry<'_> {
290 fn has(&self, commit: &str) -> Option<bool> {
291 // `merge-base --is-ancestor X X` exits 0 exactly when X is a commit this clone
292 // holds, and [`crate::git::is_ancestor`] already maps "git did not run" to `None`.
293 crate::git::is_ancestor(self.root, commit, commit)
294 }
295 fn contains(&self, descendant: &str, ancestor: &str) -> Option<bool> {
296 crate::git::is_ancestor(self.root, ancestor, descendant)
297 }
298}
299
300/// The judgement of one served identity against one expected commit, with its reason. The
301/// reason travels with the verdict so that every surface can say *why* without re-deriving
302/// it.
303///
304/// ```
305/// use majordomus_cli::served::{judge, Fetched, Judgement, ServedVerdict};
306/// let j: Judgement = judge(&Err(Fetched::Unreachable("timeout".into())), &"a".repeat(40), &fake());
307/// assert_eq!(j.verdict, ServedVerdict::Unreachable);
308/// assert!(j.reason.contains("timeout"));
309/// # use majordomus_cli::served::Ancestry;
310/// # struct N; impl Ancestry for N {
311/// # fn has(&self, _: &str) -> Option<bool> { Some(true) }
312/// # fn contains(&self, _: &str, _: &str) -> Option<bool> { Some(true) }
313/// # }
314/// # fn fake() -> impl Ancestry { N }
315/// ```
316#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
317pub struct Judgement {
318 /// The verdict.
319 pub verdict: ServedVerdict,
320 /// Why, in one line a reader can act on.
321 pub reason: String,
322}
323
324/// What the probe received: a body, or nothing and the reason. Nothing received is carried
325/// as a value rather than an error, because "the site could not be reached" is an answer
326/// the judgement acts on.
327///
328/// ```
329/// use majordomus_cli::served::Fetched;
330/// let got = Fetched::Unreachable("curl exited 7".into());
331/// assert!(matches!(got, Fetched::Unreachable(ref why) if why.contains("7")));
332/// assert!(matches!(Fetched::Body(b"{}".to_vec()), Fetched::Body(_)));
333/// ```
334#[derive(Debug, Clone, PartialEq, Eq)]
335pub enum Fetched {
336 /// A body arrived.
337 Body(Vec<u8>),
338 /// Nothing arrived, and why.
339 Unreachable(String),
340}
341
342/// Judges what was served against `expected`, a full commit sha.
343///
344/// The order of the questions is the order in which each makes the next meaningless: no
345/// body, no identity, no committed tree, no local knowledge of the served commit, and only
346/// then containment.
347///
348/// ```
349/// use majordomus_cli::served::{judge, Ancestry, BuildIdentity, ServedVerdict};
350///
351/// struct Linear; // a <- b
352/// impl Ancestry for Linear {
353/// fn has(&self, c: &str) -> Option<bool> { Some(c.starts_with('a') || c.starts_with('b')) }
354/// fn contains(&self, d: &str, a: &str) -> Option<bool> { Some(d >= a) }
355/// }
356/// let id = |c: &str, dirty| Ok(BuildIdentity {
357/// commit: c.into(), dirty, source_version: None, source_hash: None });
358/// let (a, b) = ("a".repeat(40), "b".repeat(40));
359///
360/// // the newer build proves the older commit, because it contains it
361/// assert_eq!(judge(&id(&b, false), &a, &Linear).verdict, ServedVerdict::Served);
362/// // the older build does not prove the newer commit
363/// assert_eq!(judge(&id(&a, false), &b, &Linear).verdict, ServedVerdict::Stale);
364/// // and a build from an uncommitted tree proves no commit at all, not even its own
365/// assert_eq!(judge(&id(&b, true), &b, &Linear).verdict, ServedVerdict::Dirty);
366/// ```
367pub fn judge(
368 served: &Result<BuildIdentity, Fetched>,
369 expected: &str,
370 git: &dyn Ancestry,
371) -> Judgement {
372 let j = |verdict, reason: String| Judgement { verdict, reason };
373 let id = match served {
374 Ok(id) => id,
375 Err(Fetched::Unreachable(why)) => {
376 return j(
377 ServedVerdict::Unreachable,
378 format!("nothing was received: {why}"),
379 )
380 }
381 Err(Fetched::Body(_)) => {
382 return j(
383 ServedVerdict::Malformed,
384 "the served body is not a build identity".into(),
385 )
386 }
387 };
388 if id.dirty {
389 return j(
390 ServedVerdict::Dirty,
391 format!(
392 "the served build names {} but was built from an uncommitted tree",
393 short(&id.commit)
394 ),
395 );
396 }
397 if id.commit == expected {
398 return j(
399 ServedVerdict::Served,
400 format!("the deployment serves {}", short(expected)),
401 );
402 }
403 match git.has(&id.commit) {
404 Some(true) => {}
405 Some(false) => {
406 return j(
407 ServedVerdict::Undecided,
408 format!(
409 "the deployment serves {}, a commit this clone does not hold; fetch and \
410 observe again",
411 short(&id.commit)
412 ),
413 )
414 }
415 None => {
416 return j(
417 ServedVerdict::Undecided,
418 "git could not be asked about ancestry".into(),
419 )
420 }
421 }
422 match git.contains(&id.commit, expected) {
423 Some(true) => j(
424 ServedVerdict::Served,
425 format!(
426 "the deployment serves {}, which contains {}",
427 short(&id.commit),
428 short(expected)
429 ),
430 ),
431 Some(false) => j(
432 ServedVerdict::Stale,
433 format!(
434 "the deployment serves {}, which does not contain {}",
435 short(&id.commit),
436 short(expected)
437 ),
438 ),
439 None => j(
440 ServedVerdict::Undecided,
441 "git could not be asked about ancestry".into(),
442 ),
443 }
444}
445
446fn short(sha: &str) -> &str {
447 &sha[..sha.len().min(12)]
448}
449
450// ---------------------------------------------------------------- fetching
451
452/// How a served identity is fetched. Behind a trait so that every verdict can be tested
453/// without a network, and so that the one implementation that does reach the network is a
454/// single, reviewable place.
455///
456/// ```
457/// use majordomus_cli::served::{Fetch, Fetched};
458/// struct Canned;
459/// impl Fetch for Canned {
460/// fn fetch(&self, _: &str) -> Fetched { Fetched::Body(b"{}".to_vec()) }
461/// }
462/// assert_eq!(Canned.fetch("https://example.test/build.json"), Fetched::Body(b"{}".to_vec()));
463/// ```
464pub trait Fetch {
465 /// Fetches `url` once, bounded. An implementation never retries: a caller that wants a
466 /// second look decides that itself, with its own clock.
467 ///
468 /// ```
469 /// use majordomus_cli::served::{Curl, Fetch, Fetched};
470 /// // nothing listens on the discard port, so the probe answers with a reason
471 /// let got = Curl { max_seconds: 2 }.fetch("http://127.0.0.1:9/build.json");
472 /// assert!(matches!(got, Fetched::Unreachable(_)));
473 /// ```
474 fn fetch(&self, url: &str) -> Fetched;
475}
476
477/// `curl`, run with a fixed argument vector: the URL is an argument, never shell text, and
478/// the probe is bounded so that an unreachable host costs a known number of seconds.
479///
480/// ```
481/// use majordomus_cli::served::{Curl, Fetch, Fetched};
482/// let got = Curl { max_seconds: 2 }.fetch("http://127.0.0.1:9/build.json");
483/// assert!(matches!(got, Fetched::Unreachable(ref why) if why.contains("curl")), "{got:?}");
484/// ```
485pub struct Curl {
486 /// The bound on one probe, in seconds.
487 pub max_seconds: u64,
488}
489
490impl Fetch for Curl {
491 fn fetch(&self, url: &str) -> Fetched {
492 let out = Command::new("curl")
493 .args(["-fsSL", "--proto", "=https,http", "--max-time"])
494 .arg(self.max_seconds.to_string())
495 .args(["-H", "Cache-Control: no-cache", "--"])
496 .arg(url)
497 .output();
498 match out {
499 Err(e) => Fetched::Unreachable(format!("curl could not be run: {e}")),
500 Ok(o) if o.status.success() && !o.stdout.is_empty() => Fetched::Body(o.stdout),
501 Ok(o) => Fetched::Unreachable(format!(
502 "curl exited {} for {url}",
503 o.status
504 .code()
505 .map_or("by signal".into(), |c| c.to_string())
506 )),
507 }
508 }
509}
510
511/// The identity URL of a site: its base with the identity file appended.
512///
513/// ```
514/// use majordomus_cli::served::identity_url;
515/// assert_eq!(identity_url("https://majordomus.dev/", "build.json"), "https://majordomus.dev/build.json");
516/// ```
517pub fn identity_url(base: &str, identity: &str) -> String {
518 format!(
519 "{}/{}",
520 base.trim_end_matches('/'),
521 identity.trim_start_matches('/')
522 )
523}
524
525/// Refuses a base URL that is not plain `http(s)` with a host. The value reaches `curl` as an
526/// argument, so this is not what keeps a shell safe; it is what keeps `file://` and friends
527/// from being read as a deployment.
528///
529/// ```
530/// use majordomus_cli::served::check_base;
531/// assert!(check_base("https://majordomus.dev").is_ok());
532/// assert!(check_base("http://127.0.0.1:8080/x").is_ok());
533/// assert!(check_base("file:///etc/passwd").is_err());
534/// ```
535pub fn check_base(base: &str) -> Result<(), String> {
536 let rest = base
537 .strip_prefix("https://")
538 .or_else(|| base.strip_prefix("http://"))
539 .ok_or_else(|| format!("`{base}` is not an http(s) URL"))?;
540 let host = rest.split('/').next().unwrap_or("");
541 if host.is_empty() || host.chars().any(|c| c.is_whitespace() || c == '@') {
542 return Err(format!("`{base}` names no plain host"));
543 }
544 Ok(())
545}
546
547// ---------------------------------------------------------------- recording
548
549/// One observation of one deployment, as recorded: what was asked, what was served, what
550/// was judged and when. Everything needed to judge it again later is in the record, which
551/// is what lets [`Observation::rejudge`] answer without probing.
552///
553/// ```
554/// use majordomus_cli::served::{observe, Fetch, Fetched, GitAncestry, Observation, OBSERVATION_SCHEMA};
555/// use std::{path::Path, time::SystemTime};
556/// struct None_;
557/// impl Fetch for None_ {
558/// fn fetch(&self, _: &str) -> Fetched { Fetched::Unreachable("no route".into()) }
559/// }
560/// let git = GitAncestry { root: Path::new(env!("CARGO_MANIFEST_DIR")) };
561/// let o: Observation = observe("pages", "https://s", "build.json", &"a".repeat(40), &None_,
562/// &git, SystemTime::UNIX_EPOCH);
563/// assert_eq!(o.schema, OBSERVATION_SCHEMA);
564/// assert_eq!(o.url, "https://s/build.json");
565/// assert_eq!(o.at, "1970-01-01T00:00:00Z");
566/// ```
567#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
568pub struct Observation {
569 /// Always [`OBSERVATION_SCHEMA`].
570 pub schema: String,
571 /// Which deployment: the name the caller observed it under (`pages`, or a deployment
572 /// object's id).
573 pub deployment: String,
574 /// The identity URL probed.
575 pub url: String,
576 /// The commit that was expected, full.
577 pub expected: String,
578 /// What was served, when a build identity was received.
579 #[serde(default, skip_serializing_if = "Option::is_none")]
580 pub served: Option<BuildIdentity>,
581 /// The judgement at the time of the observation.
582 #[serde(flatten)]
583 pub judgement: Judgement,
584 /// When, RFC 3339 UTC.
585 pub at: String,
586}
587
588impl Observation {
589 /// Re-judges this record against `expected` now. A served commit keeps proving what it
590 /// contains; a record that received nothing keeps proving nothing. This is where a
591 /// proof goes stale: nothing about the record changes, the commit asked about does.
592 ///
593 /// ```
594 /// use majordomus_cli::served::{observe, Ancestry, Fetch, Fetched, ServedVerdict};
595 /// use std::time::SystemTime;
596 /// struct Linear; // a <- b
597 /// impl Ancestry for Linear {
598 /// fn has(&self, c: &str) -> Option<bool> { Some(c.starts_with('a') || c.starts_with('b')) }
599 /// fn contains(&self, d: &str, a: &str) -> Option<bool> { Some(d >= a) }
600 /// }
601 /// let (a, b) = ("a".repeat(40), "b".repeat(40));
602 /// struct ServesA(String);
603 /// impl Fetch for ServesA {
604 /// fn fetch(&self, _: &str) -> Fetched {
605 /// Fetched::Body(format!(r#"{{"commit":"{}","dirty":false}}"#, self.0).into_bytes())
606 /// }
607 /// }
608 /// let o = observe("pages", "https://s", "build.json", &a, &ServesA(a.clone()), &Linear,
609 /// SystemTime::UNIX_EPOCH);
610 /// assert_eq!(o.judgement.verdict, ServedVerdict::Served);
611 /// // the same record, asked about a commit the served build does not contain
612 /// assert_eq!(o.rejudge(&b, &Linear).verdict, ServedVerdict::Stale);
613 /// ```
614 pub fn rejudge(&self, expected: &str, git: &dyn Ancestry) -> Judgement {
615 let served = match &self.served {
616 Some(id) => Ok(id.clone()),
617 None if self.judgement.verdict == ServedVerdict::Malformed => {
618 Err(Fetched::Body(Vec::new()))
619 }
620 None => Err(Fetched::Unreachable(self.judgement.reason.clone())),
621 };
622 judge(&served, expected, git)
623 }
624}
625
626/// Probes `base` once and judges the result against `expected`, returning the record.
627/// Writes nothing: [`append`] is what keeps it.
628///
629/// ```
630/// use majordomus_cli::served::{observe, Ancestry, Fetch, Fetched, ServedVerdict};
631/// use std::time::SystemTime;
632/// struct Nothing;
633/// impl Fetch for Nothing {
634/// fn fetch(&self, _: &str) -> Fetched { Fetched::Unreachable("no route".into()) }
635/// }
636/// struct Any;
637/// impl Ancestry for Any {
638/// fn has(&self, _: &str) -> Option<bool> { Some(true) }
639/// fn contains(&self, _: &str, _: &str) -> Option<bool> { Some(true) }
640/// }
641/// let o = observe("pages", "https://s", "build.json", &"a".repeat(40), &Nothing, &Any,
642/// SystemTime::UNIX_EPOCH);
643/// assert_eq!(o.judgement.verdict, ServedVerdict::Unreachable);
644/// assert!(o.served.is_none());
645/// ```
646pub fn observe(
647 deployment: &str,
648 base: &str,
649 identity: &str,
650 expected: &str,
651 fetch: &dyn Fetch,
652 git: &dyn Ancestry,
653 at: SystemTime,
654) -> Observation {
655 let url = identity_url(base, identity);
656 let fetched = fetch.fetch(&url);
657 let served = match &fetched {
658 Fetched::Body(b) => BuildIdentity::parse(b).map_err(|_| fetched.clone()),
659 Fetched::Unreachable(_) => Err(fetched.clone()),
660 };
661 let mut judgement = judge(&served, expected, git);
662 if let (Fetched::Body(b), ServedVerdict::Malformed) = (&fetched, judgement.verdict) {
663 if let Err(Malformed(why)) = BuildIdentity::parse(b) {
664 judgement.reason = why;
665 }
666 }
667 Observation {
668 schema: OBSERVATION_SCHEMA.into(),
669 deployment: deployment.into(),
670 url,
671 expected: expected.into(),
672 served: served.ok(),
673 judgement,
674 at: crate::peers::rfc3339(at),
675 }
676}
677
678/// Appends `obs` to the checkout's observation file, creating it. The file is
679/// checkout-local and never tracked, so a record of a deployment cannot end up describing
680/// the tree it is committed into.
681///
682/// ```
683/// use majordomus_cli::served::{append, observe, read_all, Ancestry, Fetch, Fetched};
684/// use std::time::SystemTime;
685/// # struct N; impl Fetch for N {
686/// # fn fetch(&self, _: &str) -> Fetched { Fetched::Unreachable("x".into()) } }
687/// # struct A; impl Ancestry for A {
688/// # fn has(&self, _: &str) -> Option<bool> { Some(true) }
689/// # fn contains(&self, _: &str, _: &str) -> Option<bool> { Some(true) } }
690/// let dir = tempfile::tempdir().unwrap();
691/// let o = observe("pages", "https://s", "build.json", &"a".repeat(40), &N, &A,
692/// SystemTime::UNIX_EPOCH);
693/// append(dir.path(), &o).unwrap();
694/// assert_eq!(read_all(dir.path()).0, vec![o]);
695/// ```
696pub fn append(root: &Path, obs: &Observation) -> std::io::Result<PathBuf> {
697 let path = root.join(OBSERVATIONS_PATH);
698 if let Some(dir) = path.parent() {
699 std::fs::create_dir_all(dir)?;
700 }
701 let mut line = serde_json::to_string(obs).map_err(std::io::Error::other)?;
702 line.push('\n');
703 let mut f = std::fs::OpenOptions::new()
704 .create(true)
705 .append(true)
706 .open(&path)?;
707 f.write_all(line.as_bytes())?;
708 Ok(path)
709}
710
711/// Every readable observation of the checkout, oldest first, and the number of lines that
712/// could not be read. A torn or foreign line is counted, never silently dropped: a reader
713/// that cannot tell "nothing recorded" from "the record is damaged" acts on the wrong one.
714///
715/// ```
716/// use majordomus_cli::served::read_all;
717/// let dir = tempfile::tempdir().unwrap();
718/// // a checkout that has observed nothing answers with no observations, not an error
719/// assert_eq!(read_all(dir.path()), (Vec::new(), 0));
720/// ```
721pub fn read_all(root: &Path) -> (Vec<Observation>, usize) {
722 let Ok(text) = std::fs::read_to_string(root.join(OBSERVATIONS_PATH)) else {
723 return (Vec::new(), 0);
724 };
725 let mut out = Vec::new();
726 let mut unreadable = 0;
727 for line in text.lines().filter(|l| !l.trim().is_empty()) {
728 match serde_json::from_str::<Observation>(line) {
729 Ok(o) if o.schema == OBSERVATION_SCHEMA => out.push(o),
730 _ => unreadable += 1,
731 }
732 }
733 (out, unreadable)
734}
735
736/// The newest observation of `deployment`, if any. The newest is what a reader judges by;
737/// the older ones are the history of what the deployment has served.
738///
739/// ```
740/// use majordomus_cli::served::latest;
741/// // nothing observed, nothing to judge
742/// assert!(latest(&[], "pages").is_none());
743/// ```
744pub fn latest<'a>(all: &'a [Observation], deployment: &str) -> Option<&'a Observation> {
745 all.iter().rev().find(|o| o.deployment == deployment)
746}
747
748#[cfg(test)]
749mod tests {
750 use super::*;
751 use std::collections::BTreeMap;
752
753 const A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
754 const B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
755 const C: &str = "cccccccccccccccccccccccccccccccccccccccc";
756 const FOREIGN: &str = "ffffffffffffffffffffffffffffffffffffffff";
757
758 /// A linear history A <- B <- C, and nothing else. `broken` makes git unaskable.
759 struct History {
760 order: Vec<&'static str>,
761 broken: bool,
762 }
763 fn history() -> History {
764 History {
765 order: vec![A, B, C],
766 broken: false,
767 }
768 }
769 impl Ancestry for History {
770 fn has(&self, c: &str) -> Option<bool> {
771 (!self.broken).then(|| self.order.contains(&c))
772 }
773 fn contains(&self, d: &str, a: &str) -> Option<bool> {
774 if self.broken {
775 return None;
776 }
777 let pos = |x| self.order.iter().position(|y| *y == x);
778 Some(matches!((pos(d), pos(a)), (Some(i), Some(j)) if j <= i))
779 }
780 }
781 /// Answers `has` and refuses `contains`, the one path a whole-broken git cannot reach.
782 struct HasOnly;
783 impl Ancestry for HasOnly {
784 fn has(&self, _: &str) -> Option<bool> {
785 Some(true)
786 }
787 fn contains(&self, _: &str, _: &str) -> Option<bool> {
788 None
789 }
790 }
791
792 struct Fake(BTreeMap<String, Fetched>);
793 impl Fetch for Fake {
794 fn fetch(&self, url: &str) -> Fetched {
795 self.0
796 .get(url)
797 .cloned()
798 .unwrap_or(Fetched::Unreachable("no route".into()))
799 }
800 }
801
802 fn id(commit: &str, dirty: bool) -> Result<BuildIdentity, Fetched> {
803 Ok(BuildIdentity {
804 commit: commit.into(),
805 dirty,
806 source_version: None,
807 source_hash: None,
808 })
809 }
810 fn body(commit: &str) -> Fetched {
811 Fetched::Body(format!(r#"{{"schema":1,"commit":"{commit}","dirty":false}}"#).into_bytes())
812 }
813
814 #[test]
815 fn the_expected_commit_served_exactly_passes() {
816 let j = judge(&id(B, false), B, &history());
817 assert_eq!(j.verdict, ServedVerdict::Served);
818 assert!(j.reason.contains("bbbbbbbbbbbb"));
819 }
820
821 #[test]
822 fn a_newer_build_proves_every_commit_it_contains() {
823 assert_eq!(
824 judge(&id(C, false), A, &history()).verdict,
825 ServedVerdict::Served
826 );
827 }
828
829 #[test]
830 fn a_build_behind_the_expected_commit_is_stale() {
831 let j = judge(&id(A, false), C, &history());
832 assert_eq!(j.verdict, ServedVerdict::Stale);
833 assert!(!j.verdict.passes());
834 }
835
836 #[test]
837 fn a_dirty_build_proves_no_commit_even_the_one_it_names() {
838 assert_eq!(
839 judge(&id(B, true), B, &history()).verdict,
840 ServedVerdict::Dirty
841 );
842 }
843
844 #[test]
845 fn a_served_commit_this_clone_lacks_is_undecided_not_stale() {
846 let j = judge(&id(FOREIGN, false), B, &history());
847 assert_eq!(j.verdict, ServedVerdict::Undecided);
848 assert!(j.reason.contains("fetch"));
849 }
850
851 #[test]
852 fn an_unaskable_git_is_undecided_at_either_question() {
853 let broken = History {
854 order: vec![],
855 broken: true,
856 };
857 assert_eq!(
858 judge(&id(A, false), B, &broken).verdict,
859 ServedVerdict::Undecided
860 );
861 assert_eq!(
862 judge(&id(A, false), B, &HasOnly).verdict,
863 ServedVerdict::Undecided
864 );
865 }
866
867 #[test]
868 fn nothing_received_and_garbage_received_are_distinct_non_answers() {
869 let u = judge(&Err(Fetched::Unreachable("timeout".into())), B, &history());
870 assert_eq!(u.verdict, ServedVerdict::Unreachable);
871 assert!(u.reason.contains("timeout"));
872 let m = judge(&Err(Fetched::Body(b"<html>".to_vec())), B, &history());
873 assert_eq!(m.verdict, ServedVerdict::Malformed);
874 }
875
876 #[test]
877 fn exit_codes_separate_a_measured_no_from_an_unanswered_question() {
878 let codes: Vec<_> = [
879 ServedVerdict::Served,
880 ServedVerdict::Stale,
881 ServedVerdict::Dirty,
882 ServedVerdict::Unreachable,
883 ServedVerdict::Malformed,
884 ServedVerdict::Undecided,
885 ]
886 .iter()
887 .map(|v| (v.as_str(), v.exit_code(), v.passes()))
888 .collect();
889 assert_eq!(
890 codes,
891 vec![
892 ("served", 0, true),
893 ("stale", 10, false),
894 ("dirty", 10, false),
895 ("unreachable", 12, false),
896 ("malformed", 12, false),
897 ("undecided", 12, false),
898 ]
899 );
900 }
901
902 #[test]
903 fn parse_refuses_what_could_mean_several_commits_or_none() {
904 for (bad, why) in [
905 (&b"not json"[..], "not JSON"),
906 (br#"{"dirty":false}"#, "no `commit`"),
907 (br#"{"commit":"abc1234"}"#, "forty-character"),
908 (
909 br#"{"commit":"zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"}"#,
910 "forty-character",
911 ),
912 ] {
913 let e = BuildIdentity::parse(bad).unwrap_err();
914 assert!(e.0.contains(why), "{} did not say {why}", e.0);
915 }
916 let dirty = format!(r#"{{"commit":"{A}","dirty":"no"}}"#);
917 assert!(BuildIdentity::parse(dirty.as_bytes())
918 .unwrap_err()
919 .0
920 .contains("boolean"));
921 }
922
923 #[test]
924 fn parse_keeps_what_it_relies_on_and_tolerates_what_it_does_not() {
925 let upper = A.to_ascii_uppercase();
926 let text = format!(
927 r#"{{"commit":"{upper}","source_version":"0.7.0","source_hash":"h","extra":[1]}}"#
928 );
929 let got = BuildIdentity::parse(text.as_bytes()).unwrap();
930 assert_eq!(got.commit, A, "a sha is compared lowercase");
931 assert!(!got.dirty, "an absent `dirty` is a committed build");
932 assert_eq!(got.source_version.as_deref(), Some("0.7.0"));
933 assert_eq!(got.source_hash.as_deref(), Some("h"));
934 }
935
936 #[test]
937 fn base_urls_are_plain_http_hosts() {
938 assert!(check_base("https://majordomus.dev").is_ok());
939 assert!(check_base("http://127.0.0.1:8080/x").is_ok());
940 for bad in [
941 "file:///etc/passwd",
942 "https://",
943 "https://user@host",
944 "https://a b",
945 "ftp://x",
946 ] {
947 assert!(check_base(bad).is_err(), "{bad} accepted");
948 }
949 assert_eq!(
950 identity_url("https://x/", "/build.json"),
951 "https://x/build.json"
952 );
953 }
954
955 #[test]
956 fn observe_records_the_served_identity_and_its_judgement() {
957 let url = identity_url("https://site", DEFAULT_IDENTITY);
958 let fake = Fake(BTreeMap::from([(url.clone(), body(C))]));
959 let o = observe(
960 "pages",
961 "https://site",
962 DEFAULT_IDENTITY,
963 B,
964 &fake,
965 &history(),
966 SystemTime::UNIX_EPOCH,
967 );
968 assert_eq!(o.schema, OBSERVATION_SCHEMA);
969 assert_eq!(o.url, url);
970 assert_eq!(o.judgement.verdict, ServedVerdict::Served);
971 assert_eq!(o.served.as_ref().unwrap().commit, C);
972 assert_eq!(o.at, "1970-01-01T00:00:00Z");
973 }
974
975 #[test]
976 fn observe_names_the_malformation_rather_than_a_generic_reason() {
977 let fake = Fake(BTreeMap::from([(
978 identity_url("https://site", "build.json"),
979 Fetched::Body(br#"{"commit":"short"}"#.to_vec()),
980 )]));
981 let o = observe(
982 "pages",
983 "https://site",
984 "build.json",
985 B,
986 &fake,
987 &history(),
988 SystemTime::UNIX_EPOCH,
989 );
990 assert_eq!(o.judgement.verdict, ServedVerdict::Malformed);
991 assert!(
992 o.judgement.reason.contains("forty-character"),
993 "{}",
994 o.judgement.reason
995 );
996 assert!(o.served.is_none());
997 let none = observe(
998 "pages",
999 "https://elsewhere",
1000 "build.json",
1001 B,
1002 &fake,
1003 &history(),
1004 SystemTime::UNIX_EPOCH,
1005 );
1006 assert_eq!(none.judgement.verdict, ServedVerdict::Unreachable);
1007 }
1008
1009 /// Staleness is recomputation: the record of a served B still proves A and B, and
1010 /// stops proving the moment the expected commit moves past it.
1011 #[test]
1012 fn a_record_goes_stale_when_the_expected_commit_moves_past_it() {
1013 let fake = Fake(BTreeMap::from([(
1014 identity_url("https://s", "build.json"),
1015 body(B),
1016 )]));
1017 let o = observe(
1018 "pages",
1019 "https://s",
1020 "build.json",
1021 B,
1022 &fake,
1023 &history(),
1024 SystemTime::UNIX_EPOCH,
1025 );
1026 assert_eq!(o.rejudge(A, &history()).verdict, ServedVerdict::Served);
1027 assert_eq!(o.rejudge(B, &history()).verdict, ServedVerdict::Served);
1028 assert_eq!(o.rejudge(C, &history()).verdict, ServedVerdict::Stale);
1029 }
1030
1031 #[test]
1032 fn a_record_that_received_nothing_keeps_proving_nothing() {
1033 let none = Fake(BTreeMap::new());
1034 let u = observe(
1035 "pages",
1036 "https://s",
1037 "build.json",
1038 A,
1039 &none,
1040 &history(),
1041 SystemTime::UNIX_EPOCH,
1042 );
1043 assert_eq!(u.rejudge(A, &history()).verdict, ServedVerdict::Unreachable);
1044 let bad = Fake(BTreeMap::from([(
1045 identity_url("https://s", "build.json"),
1046 Fetched::Body(b"<html>".to_vec()),
1047 )]));
1048 let m = observe(
1049 "pages",
1050 "https://s",
1051 "build.json",
1052 A,
1053 &bad,
1054 &history(),
1055 SystemTime::UNIX_EPOCH,
1056 );
1057 assert_eq!(m.rejudge(A, &history()).verdict, ServedVerdict::Malformed);
1058 }
1059
1060 #[test]
1061 fn observations_round_trip_and_unreadable_lines_are_counted() {
1062 let dir = tempfile::tempdir().unwrap();
1063 assert_eq!(
1064 read_all(dir.path()),
1065 (Vec::new(), 0),
1066 "no file is no observations"
1067 );
1068 let fake = Fake(BTreeMap::from([(
1069 identity_url("https://s", "build.json"),
1070 body(A),
1071 )]));
1072 let first = observe(
1073 "pages",
1074 "https://s",
1075 "build.json",
1076 A,
1077 &fake,
1078 &history(),
1079 SystemTime::UNIX_EPOCH,
1080 );
1081 let other = Observation {
1082 deployment: "majordomus".into(),
1083 ..first.clone()
1084 };
1085 let path = append(dir.path(), &first).unwrap();
1086 append(dir.path(), &other).unwrap();
1087 let mut f = std::fs::OpenOptions::new()
1088 .append(true)
1089 .open(&path)
1090 .unwrap();
1091 writeln!(f, "{{torn").unwrap();
1092 writeln!(f).unwrap();
1093 writeln!(
1094 f,
1095 "{}",
1096 serde_json::to_string(&Observation {
1097 schema: "other/v9".into(),
1098 ..first.clone()
1099 })
1100 .unwrap()
1101 )
1102 .unwrap();
1103 let (all, unreadable) = read_all(dir.path());
1104 assert_eq!(all, vec![first.clone(), other.clone()]);
1105 assert_eq!(
1106 unreadable, 2,
1107 "a torn line and a foreign schema, not the blank line"
1108 );
1109 assert_eq!(latest(&all, "pages"), Some(&first));
1110 assert_eq!(latest(&all, "majordomus"), Some(&other));
1111 assert_eq!(latest(&all, "nothing"), None);
1112 }
1113
1114 #[test]
1115 fn append_reports_a_path_it_cannot_create() {
1116 let dir = tempfile::tempdir().unwrap();
1117 std::fs::write(dir.path().join(".ai"), "a file where a directory must be").unwrap();
1118 let fake = Fake(BTreeMap::new());
1119 let o = observe(
1120 "pages",
1121 "https://s",
1122 "build.json",
1123 A,
1124 &fake,
1125 &history(),
1126 SystemTime::UNIX_EPOCH,
1127 );
1128 assert!(append(dir.path(), &o).is_err());
1129 }
1130
1131 #[test]
1132 fn curl_that_cannot_connect_is_unreachable_never_a_body() {
1133 // Port 9 on loopback: discard, closed on any machine that runs tests.
1134 let got = Curl { max_seconds: 2 }.fetch("http://127.0.0.1:9/build.json");
1135 assert!(
1136 matches!(got, Fetched::Unreachable(ref why) if why.contains("curl exited")),
1137 "{got:?}"
1138 );
1139 }
1140
1141 #[test]
1142 fn real_git_answers_containment_and_absence() {
1143 let root = Path::new(env!("CARGO_MANIFEST_DIR"));
1144 let git = GitAncestry { root };
1145 let head = String::from_utf8(
1146 crate::git::read_only(root)
1147 .args(["rev-parse", "HEAD"])
1148 .output()
1149 .unwrap()
1150 .stdout,
1151 )
1152 .unwrap();
1153 let head = head.trim();
1154 assert_eq!(git.has(head), Some(true));
1155 assert_eq!(git.has(FOREIGN), Some(false));
1156 assert_eq!(git.contains(head, head), Some(true));
1157 }
1158
1159 mod properties {
1160 use super::*;
1161 use proptest::prelude::*;
1162
1163 fn verdict_of(served: u8, expected: u8, dirty: bool, reach: u8) -> ServedVerdict {
1164 let order = [A, B, C, FOREIGN];
1165 let s = order[(served % 4) as usize];
1166 let e = order[(expected % 3) as usize];
1167 let input = match reach % 3 {
1168 0 => id(s, dirty),
1169 1 => Err(Fetched::Unreachable("x".into())),
1170 _ => Err(Fetched::Body(vec![])),
1171 };
1172 judge(&input, e, &history()).verdict
1173 }
1174
1175 proptest! {
1176 /// Served is only ever the answer for a committed build this clone holds that
1177 /// contains the expected commit; everything else is not a pass.
1178 #[test]
1179 fn only_a_contained_committed_known_build_passes(
1180 s in 0u8..4, e in 0u8..3, dirty: bool, reach in 0u8..3,
1181 ) {
1182 let v = verdict_of(s, e, dirty, reach);
1183 let contained = reach % 3 == 0 && !dirty && s < 3 && e <= s;
1184 prop_assert_eq!(v.passes(), contained);
1185 }
1186 }
1187 }
1188}