Skip to main content

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}