diff --git a/README.md b/README.md index 7e254ff..9ea2635 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,7 @@ is a usable health check. `--json` prints the same thing as one object. ``` atgc status pr # your PRs across every repo (--author for someone else's) +atgc status tangled # are Tangled's services answering, and what are they running? ``` Everything else in atgc acts on the repo you are standing in; these do not. @@ -101,6 +102,30 @@ a directory that is not a checkout at all. `--author` asks the same of somebody else. The old spelling, `atgc pr status`, still works and prints where it went. +`atgc status tangled` is the other half of `atgc doctor`. That one asks +whether *you* are set up and hands you a command for every bad row; this one +asks whether *they* are up, so no row is about you and none has a remedy — +the answer to a bad one is to wait. It is the question left over when +`doctor` says everything is fine and the thing you were doing still fails. + +``` + appview ok tangled.org answered in 1181ms + bobbin ok api.tangled.org answered in 372ms + knot ok knot1.tangled.sh answered in 240ms, running v1.15.0 + capabilities: knot-acl, repo-did-input + pds ok amanita.us-east.host.bsky.network answered in 81ms, running 6dac4dd +``` + +Each service is asked the question atgc's own use of it depends on rather +than a health endpoint invented for the purpose, so a green row means the +thing atgc needs works. A host that never answered is an error and exits `6`, +*try again unchanged*; a host that answered a refusal is only a warning, +because a service that refuses is a service that is running. The knot comes +from this checkout's repo — `--knot ` names another, or one from +outside a checkout — and the PDS row is your own, which belongs in a report +about Tangled precisely because a pull request is a record in it. Needs no +session; every request is a public read. + #### Accounts ``` diff --git a/TODO.md b/TODO.md index acfd381..abc6ac9 100644 --- a/TODO.md +++ b/TODO.md @@ -1628,6 +1628,31 @@ and borrows `pr`'s conventions rather than inventing new ones beside them. before this command existed, which was the other half of the problem: the planned `atgc status` could not be added while a verb one level down was called that +- [x] `status tangled` — whether Tangled's services are answering, and what + they are running. The other half of `doctor`, which asks whether *you* + are set up and hands you a command per bad row; this asks whether + *they* are up, so no row is about you and none has a remedy. It is also + the question `doctor` structurally cannot answer, every network row it + has being scoped to the repo you are standing in +- [x] Each service asked the question atgc's own use of it depends on — the + appview's site, Bobbin's search, `sh.tangled.knot.version`, the PDS's + `_health` — rather than one health endpoint for all four. Tangled's + appview implements `_health` in its source and the deployed build + 404s it, so a uniform probe would have reported the same 404 for every + service in every state; and a green row now means the thing atgc + actually needs works. The knot answers with its version and the + protocol capabilities it declares, which nothing else in atgc prints +- [x] The knot for that row comes from the repo's own DID document rather + than from its `sh.tangled.repo` record, which is where `doctor` reads + it. That record sits in the owner's PDS at an address only Bobbin can + supply, so `doctor`'s route goes dark exactly when the index does — + and an index outage is one of the things somebody runs this to confirm +- [ ] `status tangled` says nothing about how far behind the index is, which + is the other half of "is Bobbin working" and the failure that is silent + rather than loud. `doctor`'s `index` row measures it against your PDS + for one repo; a service-wide measure wants a newest-indexed-record + timestamp, and `sh.tangled.search.query` ranks by relevance with no way + to ask for the newest - [x] `pr status` kept as a hidden alias that prints the new spelling. The opposite call to `auth log`, on that entry's own reasoning: a log reader's whole audience is people already reading their own output and diff --git a/docs/output.md b/docs/output.md index aab4bf3..a8f83e7 100644 --- a/docs/output.md +++ b/docs/output.md @@ -51,11 +51,19 @@ rules. The numbers are a public interface: `crate::exit::Exit` spells them out as a match so that inserting a variant cannot renumber the rest. -`atgc doctor` is the one command with an answer *and* a non-zero status. Its -report is the answer, so it goes on stdout, and the status summarises the -report rather than standing in for it: each failing row carries the status the -command it stands for would have exited with — a missing session is `3`, a -scope gap or an unregistered push key `4`, a host that did not answer `6` — -and with more than one, the first row wins, because the rows are ordered by -what depends on what. A warning never decides it, or `doctor` would fail in -every checkout that has never been near Tangled. +`atgc doctor` and `atgc status tangled` are the two commands with an answer +*and* a non-zero status. The report is the answer, so it goes on stdout, and +the status summarises the report rather than standing in for it: each failing +row carries the status the operation it stands for would have exited with — a +missing session is `3`, a scope gap or an unregistered push key `4`, a host +that did not answer `6` — and with more than one, the first row wins. A +warning never decides it, or `doctor` would fail in every checkout that has +never been near Tangled. + +The two ask opposite questions and only one of them can say what to do about +the answer. `doctor` orders its rows by what depends on what, so the first +broken row is the one to fix first and it says so; `status tangled` reports on +four services that need nothing from each other, so it names the row the +status came from and stops there. That difference is why every `remedy` in a +`status tangled` report is `null`: there is no command here that mends +somebody else's host. diff --git a/src/clients/mod.rs b/src/clients/mod.rs index 82fa745..3c9a3bb 100644 --- a/src/clients/mod.rs +++ b/src/clients/mod.rs @@ -16,6 +16,9 @@ //! Tangled's alone. //! - [`mod@git`] — the other side of the bridge. Every git invocation, every //! read or write of git config, and the SSH key a push authenticates with. +//! - [`mod@probe`] — one request whose only question is whether the host +//! answered at all. The inverse of every other client here, which is why it +//! is not a helper inside one of them. //! - [`mod@xrpc`] — one request whose method is a string rather than a type, //! with the credential the host it is going to takes. The shape //! [`crate::cmd::api`] needs and nothing else does. @@ -27,5 +30,6 @@ pub(crate) mod atproto; pub(crate) mod endpoints; pub(crate) mod git; pub(crate) mod http; +pub(crate) mod probe; pub(crate) mod tangled; pub(crate) mod xrpc; diff --git a/src/clients/probe.rs b/src/clients/probe.rs new file mode 100644 index 0000000..b6fe398 --- /dev/null +++ b/src/clients/probe.rs @@ -0,0 +1,148 @@ +//! One request whose only question is whether the host answered. +//! +//! Every other client here asks a service for something and fails if it does +//! not get it. This one asks nothing: it sends a GET, times how long the +//! headers took to come back, and reports what arrived — a status code that +//! may be a refusal, a body that may not be JSON, and neither of those being +//! a failure. Only a host that never answered at all is an `Err`. +//! +//! That inversion is the whole module. `atgc status tangled` exists to tell +//! "the service is down" apart from "the service is up and said no", and a +//! client that turns the second into an error has thrown the answer away +//! before the command can read it. +//! +//! What is asked *of* each service is the command's decision and is written +//! down there, beside the reason. This knows nothing about Tangled. + +use anyhow::{Context, Result}; +use std::time::{Duration, Instant}; + +/// What a service said when it was asked whether it was there. +#[derive(Debug)] +pub struct Answer { + /// Whatever came back, including a refusal. A 404 from a router is an + /// answer, and a service that routes is a service that is running. + pub status: reqwest::StatusCode, + /// Time to the response headers, not to the last byte. The question is + /// how quickly the service began to answer; a front page that is 200 KB + /// of HTML would otherwise read as a slower service than one that + /// answers `{}`. + pub elapsed: Duration, + /// The response body, when it parsed as JSON. `None` for a rendered page + /// or an empty body, which is not a fault — see the module docs. + pub body: Option, +} + +impl Answer { + /// The version the service reports, when it reports one. + /// + /// `version` is the field both halves of the convention agree on: a + /// PDS's `_health`, Tangled's appview `_health`, and a knot's + /// `sh.tangled.knot.version` all answer with one. Read here rather than + /// at three call sites because the field name is the shared part. + pub fn version(&self) -> Option<&str> { + self.body.as_ref()?["version"].as_str() + } + + /// A string list under `key`, for the answers that carry one — a knot's + /// `capabilities`, so far. Empty when the field is absent or is not a + /// list of strings, which is how a knot too old to declare them reads. + pub fn strings(&self, key: &str) -> Vec { + let Some(body) = self.body.as_ref() else { + return Vec::new(); + }; + body[key] + .as_array() + .map(|items| { + items + .iter() + .filter_map(|item| item.as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default() + } +} + +/// GET `url` and report how it went, treating any answer as an answer. +/// +/// `Err` means the host never answered — DNS, connect, TLS, or a stall past +/// [`crate::clients::http`]'s deadline — which is the one outcome that is +/// genuinely about reachability. [`crate::exit::classify`] reads the right +/// exit status straight off it. +pub async fn get(url: &str) -> Result { + crate::logging::debug::log(format!(">> GET {url}")); + let started = Instant::now(); + let resp = crate::clients::http::get(url) + .await + .with_context(|| format!("could not reach {url}"))?; + let elapsed = started.elapsed(); + let status = resp.status(); + // Bounded like every other body atgc reads from a host it does not own. + // A probe that answered with a gigabyte would otherwise be a probe that + // hung, which is the failure this module exists to report rather than + // suffer. + let text = crate::clients::http::text_bounded(resp, url).await?; + crate::logging::debug::log(format!( + "<< {status} ({} bytes) in {}ms", + text.len(), + elapsed.as_millis() + )); + Ok(Answer { + status, + elapsed, + body: serde_json::from_str(&text).ok(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn answer(body: serde_json::Value) -> Answer { + Answer { + status: reqwest::StatusCode::OK, + elapsed: Duration::from_millis(1), + body: Some(body), + } + } + + /// The two fields read off a body, and the shapes that must not panic. + /// + /// Both readers index into a `serde_json::Value` that came off the wire, + /// so every one of these is a real possibility rather than a hypothetical + /// — a service can answer `{}`, answer with an array, or answer with + /// `version` as a number, and none of those may take the report down. + #[test] + fn a_missing_or_wrongly_typed_field_reads_as_absent() { + let full = answer(serde_json::json!({ + "version": "v1.15.0", + "capabilities": ["knot-acl", "repo-did-input"], + })); + assert_eq!(full.version(), Some("v1.15.0")); + assert_eq!(full.strings("capabilities"), ["knot-acl", "repo-did-input"]); + + // A knot too old to declare capabilities, which the lexicon says to + // treat as legacy rather than as broken. + let bare = answer(serde_json::json!({"version": "v1.0.0"})); + assert!(bare.strings("capabilities").is_empty()); + + // Nothing recognisable, in the four ways it comes. + let empty = answer(serde_json::json!({})); + assert_eq!(empty.version(), None); + assert!(empty.strings("capabilities").is_empty()); + let wrong_type = answer(serde_json::json!({"version": 15, "capabilities": "yes"})); + assert_eq!(wrong_type.version(), None); + assert!(wrong_type.strings("capabilities").is_empty()); + let not_an_object = answer(serde_json::json!([1, 2, 3])); + assert_eq!(not_an_object.version(), None); + + // A rendered page, which is what the appview answers with. + let html = Answer { + status: reqwest::StatusCode::OK, + elapsed: Duration::from_millis(1), + body: None, + }; + assert_eq!(html.version(), None); + assert!(html.strings("capabilities").is_empty()); + } +} diff --git a/src/cmd/doctor.rs b/src/cmd/doctor.rs index aa21dac..9214bc5 100644 --- a/src/cmd/doctor.rs +++ b/src/cmd/doctor.rs @@ -862,7 +862,10 @@ pub async fn doctor(args: DoctorArgs) -> Result<()> { // The report is already on stdout; this is the summary that carries the // status. `error:` on stderr, like every other failure — see the module // docs for why this one command has both an answer and a non-zero exit. - Err(crate::exit::fail(exit, summary(&checks, "doctor"))) + Err(crate::exit::fail( + exit, + summary(&checks, "doctor", Some("which is the one to fix first")), + )) } #[cfg(test)] @@ -876,7 +879,7 @@ mod tests { /// this command promises — the rows are its eight and the wording names /// it — so the subject is bound here rather than repeated in each body. fn summary_of(checks: &[Check]) -> String { - summary(checks, "doctor") + summary(checks, "doctor", Some("which is the one to fix first")) } fn checks(statuses: &[Status]) -> Vec { diff --git a/src/cmd/status.rs b/src/cmd/status.rs index fc103eb..e9362ae 100644 --- a/src/cmd/status.rs +++ b/src/cmd/status.rs @@ -3,9 +3,10 @@ //! Every other family here takes one: `pr list` lists this repo's pull //! requests, `issue list` this repo's issues, `repo view` this repo. The //! verbs under `status` deliberately do not. `status pr` is your pull -//! requests wherever you filed them, and neither of them has a repo to be -//! scoped to — both are things you reach for from a directory that may not be -//! a checkout at all. +//! requests wherever you filed them, `status tangled` is whether the services +//! underneath all of it are answering, and neither has a repo to be scoped to +//! — both are things you reach for from a directory that may not be a +//! checkout at all. //! //! # Why a group, and why it took a name that was in use //! @@ -36,6 +37,25 @@ //! two siblings to sit under the word that invokes it would be filing by //! spelling. What was wrong was the command surface, and the command surface //! is what moved. +//! +//! `status tangled` is here in full, by the same rule read the other way: it +//! shares its machinery with nothing. What it needs from outside is one +//! request that treats a refusal as an answer, which is +//! [`crate::clients::probe`], and the report vocabulary `doctor` also uses, +//! which is [`crate::term::checks`]. +//! +//! # `status tangled` against `doctor` +//! +//! They look alike and ask opposite questions, which is the only thing to +//! know about either. `doctor` asks whether *you* are set up: every row is +//! about this machine, this account or this checkout, and a bad one carries a +//! command you can run. `status tangled` asks whether *they* are up: no row +//! is about you, no row has a remedy, and the answer to a bad one is to wait. +//! +//! That is the question left over when `doctor` says everything is fine and +//! the thing you were doing still does not work — and it is the one `doctor` +//! structurally cannot answer, because every network row it has is scoped to +//! the repo you are standing in and goes `n/a` the moment you are not. /// The `atgc status` subjects. #[derive(clap::Subcommand, Debug)] @@ -60,11 +80,475 @@ pub(crate) enum Command { /// atgc status pr --state all --json | jq 'group_by(.repo) | length' #[command(verbatim_doc_comment)] Pr(crate::cmd::pr::read::StatusArgs), + /// Whether Tangled's services are answering, and what they are running + /// + /// The other half of `atgc doctor`. That command asks whether *you* are + /// set up — this machine, this account, this checkout — and every bad + /// row it prints carries a command you can run. This one asks whether + /// *they* are up, so no row is about you and none has a remedy: the + /// answer to a bad one is to wait. It is the question left when `doctor` + /// says everything is fine and the thing you were doing still fails. + /// + /// Four rows, each asked the question atgc's own use of that service + /// depends on rather than a health endpoint invented for the purpose: + /// + /// appview tangled.org, which serves every `view:` link and the + /// pull numbers atgc cannot get anywhere else + /// bobbin api.tangled.org, the index behind `--source bobbin` and + /// `atgc search` + /// knot the git host this checkout's repo names, asked for its + /// version and the protocol capabilities it declares + /// pds your own PDS — not Tangled's, and the reason it belongs + /// here: a pull request is a record in it, so it is as much + /// a part of "is Tangled working" as the appview is + /// + /// A row goes `n/a` when there is nothing to ask: no knot outside a + /// checkout unless `--knot` names one, no PDS with no account selected. + /// A host that never answered is an error and exits 6, the status that + /// means retry unchanged; a host that answered a refusal is a warning, + /// because a service that refuses is a service that is running. + /// + /// Needs no session and no checkout. Every request is a public read. + /// + /// Examples: + /// atgc status tangled + /// atgc status tangled --knot knot1.tangled.sh + /// atgc status tangled --json | jq -r '.checks[] | "\(.name) \(.status)"' + #[command(verbatim_doc_comment)] + Tangled(TangledArgs), } /// Run whichever `status` subject was parsed. pub(crate) async fn run(command: Command) -> anyhow::Result<()> { match command { Command::Pr(args) => crate::cmd::pr::read::status(args).await, + Command::Tangled(args) => tangled(args).await, + } +} + +// --------------------------------------------------------------------------- +// status tangled +// --------------------------------------------------------------------------- + +use crate::clients::probe; +use crate::exit::Exit; +use crate::term::checks::{Check, CheckJson, exit_status, render, summary}; + +#[derive(clap::Args, Debug)] +pub(crate) struct TangledArgs { + /// Ask this knot, rather than the one this checkout's repo names + #[arg(long, value_name = "HOST")] + pub knot: Option, + /// Print one JSON object instead of the report: every service with its + /// status, plus which host each row was asked + #[arg(long)] + pub json: bool, +} + +/// The Bobbin query the `bobbin` row is a probe of. +/// +/// `sh.tangled.search.query` and not a health endpoint, for the reason the +/// command's help gives: what is worth knowing is whether the method atgc +/// depends on answers, and Tangled's `_health` route is absent from the +/// build running in production, so asking it would report a 404 for every +/// service in every state. +/// +/// The term is chosen to match nothing. A probe wants the query path +/// exercised, not a page of results carried across the network, and a +/// recognisable string is a courtesy to anyone reading Bobbin's own logs +/// wondering what keeps asking. +const BOBBIN_PROBE: &str = "sh.tangled.search.query?q=atgc-status-probe&limit=1"; + +/// The knot method the `knot` row is a probe of: public, cheap, and the only +/// one that answers with something worth printing even when all is well — +/// the knot's version, and the protocol capabilities it declares. A knot too +/// old to declare them omits the field, which its own lexicon says to read as +/// legacy rather than as broken. +const KNOT_PROBE: &str = "sh.tangled.knot.version"; + +/// Which host each row was asked, as `--json` reports it. +/// +/// The counterpart of `doctor`'s subject block and there for the same reason: +/// a report that says `bobbin: ok` without saying which Bobbin is a report +/// that cannot be told apart from one taken against a local instance. Every +/// one of these moves — see [`crate::clients::endpoints`] — and two of them +/// are read out of a checkout and a session rather than compiled in. +#[derive(serde::Serialize, Debug, PartialEq)] +pub(crate) struct TangledSubjectJson { + pub appview: String, + pub bobbin: String, + /// The knot named by `--knot`, or the one this checkout's repo's DID + /// document names. `null` when neither said. + pub knot: Option, + /// The acting account's PDS, `null` with no account selected. An + /// authority like the two above it and not the endpoint URL the DID + /// document carries: one object naming its four hosts two different ways + /// is one a caller has to special-case. + pub pds: Option, + pub account_did: Option, +} + +/// `status tangled --json`'s whole object. Deliberately `doctor --json`'s +/// shape — `ok`, a subject block, then the rows — because a caller that +/// learned to read one report should not have to learn the other. +#[derive(serde::Serialize, Debug, PartialEq)] +pub(crate) struct TangledJson { + /// Whether the process is about to exit `0`. + pub ok: bool, + pub subject: TangledSubjectJson, + /// Every service, in report order. + pub checks: Vec, +} + +/// Ask each service whether it is there, and print what came back. +pub(crate) async fn tangled(args: TangledArgs) -> anyhow::Result<()> { + crate::term::jsonout::init(args.json); + + let appview = crate::clients::endpoints::appview(); + let bobbin = crate::clients::endpoints::bobbin(); + + // Four things at once: the two compiled-in services, and the two lookups + // that decide whether there is a knot and a PDS to ask at all. None of + // them can inform another, and doing them in turn would cost four + // timeouts on a machine with no network instead of one — the same reason + // `doctor` overlaps its two chains. + let (appview_row, bobbin_row, knot_host, account) = tokio::join!( + service_row("appview", host_of(&appview), format!("{appview}/")), + service_row( + "bobbin", + host_of(&bobbin), + format!("{bobbin}/xrpc/{BOBBIN_PROBE}") + ), + knot_here(args.knot.clone()), + acting_account(), + ); + + // And the two that could not be addressed until those resolved. + let pds_host = match &account { + Some(did) => crate::clients::atproto::did::pds_from_did_doc(did).await, + None => None, + }; + let (knot_row, pds_row) = tokio::join!( + async { + match &knot_host { + Some(host) => { + let url = format!( + "{}/xrpc/{KNOT_PROBE}", + crate::clients::endpoints::knot(host) + ); + service_row("knot", host.clone(), url).await + } + None => Check::na( + "knot", + "no Tangled repo here to name a knot, and --knot named none", + ), + } + }, + async { + match &pds_host { + Some(endpoint) => { + service_row("pds", host_of(endpoint), format!("{endpoint}/xrpc/_health")).await + } + None => Check::na( + "pds", + "no account is selected, so there is no PDS to ask (atgc auth login)", + ), + } + }, + ); + + // Report order is widest blast radius first: the appview and the index + // serve every repo, a knot serves one, and your PDS serves you. That is + // not a dependency chain — none of the four needs another — so unlike + // `doctor` this report makes no claim about which to deal with first. + let checks = vec![appview_row, bobbin_row, knot_row, pds_row]; + + let exit = exit_status(&checks); + if args.json { + crate::term::jsonout::emit(&TangledJson { + ok: exit == Exit::Ok, + subject: TangledSubjectJson { + appview: host_of(&appview), + bobbin: host_of(&bobbin), + knot: knot_host, + pds: pds_host.as_deref().map(host_of), + account_did: account, + }, + checks: checks.iter().map(CheckJson::of).collect(), + })?; + } else { + print!("{}", render(&checks)); + } + + if exit == Exit::Ok { + return Ok(()); + } + // The report is on stdout and this is the line that carries the status, + // exactly as `doctor` does it — see that command's module documentation + // for why a report may answer and still exit non-zero. + Err(crate::exit::fail( + exit, + summary(&checks, "status tangled", None), + )) +} + +/// One service, asked one question, as a row. +async fn service_row(name: &'static str, host: String, url: String) -> Check { + verdict(name, &host, probe::get(&url).await) +} + +/// What one answer is worth, with no request in sight. +/// +/// Pure, and split from [`service_row`] for the reason +/// [`crate::docs::testing`] gives: the request is four lines of plumbing and +/// this is the whole of the judgement. +/// +/// The three outcomes are the point of the command and are not the three a +/// client would give you. A host that never answered is broken and carries +/// the retry-unchanged status; a host that answered *badly* is running, and a +/// service that is running is not an outage even when it is useless to you; +/// and a host that answered is asked what it is, because a version and a +/// capability list cost nothing extra and are the two things you cannot find +/// out any other way. +fn verdict(name: &'static str, host: &str, answer: anyhow::Result) -> Check { + let answer = match answer { + Ok(answer) => answer, + Err(err) => { + return Check::bad( + name, + crate::exit::classify(&err), + format!("{host} did not answer\n{err:#}"), + ); + } + }; + + let took = format!("in {}ms", answer.elapsed.as_millis()); + let status = answer.status; + + if status.is_server_error() { + return Check::bad( + name, + server_exit(status), + format!( + "{host} answered {status} {took}\nit is reachable and the service behind it is not serving" + ), + ); + } + if !status.is_success() { + return Check::warn( + name, + format!( + "{host} answered {status} {took}\nit is running, but did not serve what atgc asked it for" + ), + ); + } + + let mut said = format!("{host} answered {took}"); + if let Some(version) = answer.version() { + said.push_str(&format!(", running {version}")); + } + // Only a knot declares these today. Reading the field on every row rather + // than only on that one costs a branch nobody has to maintain, and the + // day a second service declares capabilities the row will print them. + let capabilities = answer.strings("capabilities"); + if !capabilities.is_empty() { + said.push_str(&format!("\ncapabilities: {}", capabilities.join(", "))); + } + Check::ok(name, said) +} + +/// Which exit status a 5xx deserves. +/// +/// The same split [`crate::cmd::repo::knot_exit`] makes, for the same reason: +/// a gateway that never reached the service behind it is worth retrying +/// unchanged, and anything else is a service that is broken in a way waiting +/// may not mend. +fn server_exit(status: reqwest::StatusCode) -> Exit { + match status.as_u16() { + 502..=504 => Exit::Unreachable, + _ => Exit::Failure, + } +} + +/// The knot to ask about: the one named, or the one this checkout's repo +/// belongs to. +/// +/// Read from the repo's *own DID document* rather than from its +/// `sh.tangled.repo` record, which is how `doctor` finds a knot. The record +/// sits in the owner's PDS at an address only Bobbin can supply, so that +/// route goes dark exactly when the index does — and an index outage is one +/// of the things somebody runs this command to confirm. The document is +/// minted by the knot and answers whoever asks. +/// +/// Every step is allowed to come up empty. Standing outside a checkout, in +/// somebody else's forge, or in a Tangled repo whose document names no knot +/// are all `n/a` rather than failures: this command must work when everything +/// is wrong, the same rule `doctor` holds itself to. +async fn knot_here(named: Option) -> Option { + if let Some(host) = named { + return Some(host); + } + let remote = crate::clients::git::run::remote_url("origin").ok()?; + let repo = crate::clients::tangled::resolve::repo_ref(&remote) + .await + .ok()?; + crate::clients::atproto::did::knot_from_did_doc(&repo.did).await +} + +/// The DID of the account atgc would act as, or `None` when there is none. +/// +/// A failed selection is not an error here. Logged out is a legitimate way to +/// run this — every other row is a public read — and it makes exactly one row +/// `n/a`. +async fn acting_account() -> Option { + crate::config::account::select() + .await + .ok() + .map(|selection| selection.did) +} + +/// The authority of a base URL, for the report's left-hand sentence. +/// +/// `https://api.tangled.org` reads as `api.tangled.org`, and a local instance +/// at `http://127.0.0.1:8080` keeps its port, because the port is what tells +/// two of them apart. +fn host_of(base: &str) -> String { + base.split_once("://") + .map_or(base, |(_scheme, rest)| rest) + .trim_end_matches('/') + .to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::term::checks::Status; + use std::time::Duration; + + fn answered(code: u16, body: serde_json::Value) -> anyhow::Result { + Ok(probe::Answer { + status: reqwest::StatusCode::from_u16(code).expect("a real status code"), + elapsed: Duration::from_millis(7), + body: Some(body), + }) + } + + /// The distinction the whole command exists for: a host that is silent is + /// broken, and a host that answers a refusal is not. + /// + /// It is the difference between "wait, it is them" and "it is up, and + /// this particular thing is wrong", and getting it backwards makes the + /// report worse than useless — a `warn` on an outage is a report somebody + /// acts on by rechecking their own machine, which is where they came + /// from. + #[test] + fn a_refusal_is_a_running_service_and_silence_is_not() { + // 2xx: up, and answering what was asked. + let up = verdict( + "bobbin", + "api.tangled.org", + answered(200, serde_json::json!({"hits": []})), + ); + assert_eq!(up.status, Status::Ok); + + // 4xx: still up. A method it does not implement, a parameter it did + // not like, a rate limit — none of those is an outage. + for code in [400, 404, 429] { + let refused = verdict( + "bobbin", + "api.tangled.org", + answered(code, serde_json::json!({})), + ); + assert_eq!(refused.status, Status::Warn, "{code} is not an outage"); + assert!(refused.detail.contains("is running"), "{}", refused.detail); + } + + // 5xx: the address answers and the service behind it does not. A + // gateway failure is the retry-unchanged status; anything else is + // unclassified, because waiting may not mend it. + assert_eq!( + verdict( + "appview", + "tangled.org", + answered(503, serde_json::json!({})) + ) + .status, + Status::Bad(Exit::Unreachable) + ); + assert_eq!( + verdict( + "appview", + "tangled.org", + answered(500, serde_json::json!({})) + ) + .status, + Status::Bad(Exit::Failure) + ); + + // Nothing at all. `classify` reads the status off the error, so an + // ordinary one lands on the unclassified code rather than claiming a + // connect failure this test cannot manufacture. + let silent = verdict( + "knot", + "knot1.tangled.sh", + Err(anyhow::anyhow!("connection refused")), + ); + assert_eq!(silent.status, Status::Bad(Exit::Failure)); + assert!(silent.detail.starts_with("knot1.tangled.sh did not answer")); + + // No row here has a remedy, ever: there is no command that mends + // somebody else's host, and advice that cannot work is worse than + // none. + for row in [up, silent] { + assert_eq!(row.remedy, None); + } + } + + /// A healthy row says what it reached, how long it took, and what the + /// service says it is — the last being the part no other command can tell + /// you, and the reason the probe reads a body it does not need. + #[test] + fn a_healthy_row_carries_the_version_and_capabilities_when_offered() { + let knot = verdict( + "knot", + "knot1.tangled.sh", + answered( + 200, + serde_json::json!({ + "version": "v1.15.0", + "capabilities": ["knot-acl", "repo-did-input"], + }), + ), + ); + assert_eq!(knot.status, Status::Ok); + assert!(knot.detail.contains("knot1.tangled.sh answered in 7ms")); + assert!(knot.detail.contains("running v1.15.0"), "{}", knot.detail); + assert!( + knot.detail + .contains("capabilities: knot-acl, repo-did-input"), + "{}", + knot.detail + ); + + // A service that offers neither says neither, rather than saying + // `null` or `unknown` at somebody. + let plain = verdict( + "appview", + "tangled.org", + answered(200, serde_json::json!({})), + ); + assert_eq!(plain.detail, "tangled.org answered in 7ms"); + } + + /// The left-hand label is an authority and not a URL, and a port is part + /// of one — two local instances on one host are told apart by nothing + /// else, which is exactly the case the integration rig runs in. + #[test] + fn a_host_label_drops_the_scheme_and_keeps_the_port() { + assert_eq!(host_of("https://api.tangled.org"), "api.tangled.org"); + assert_eq!(host_of("https://tangled.org/"), "tangled.org"); + assert_eq!(host_of("http://127.0.0.1:8080"), "127.0.0.1:8080"); + // Already an authority: a knot is named that way in a DID document. + assert_eq!(host_of("knot1.tangled.sh"), "knot1.tangled.sh"); } } diff --git a/src/term/checks.rs b/src/term/checks.rs index 40ba052..9524ecc 100644 --- a/src/term/checks.rs +++ b/src/term/checks.rs @@ -155,7 +155,15 @@ pub fn exit_status(checks: &[Check]) -> Exit { /// `subject` is the command speaking, because the sentence is read on stderr /// with no report beside it as often as not — in a pipeline, in CI, in a /// scrollback with three commands above it. -pub fn summary(checks: &[Check], subject: &str) -> String { +/// +/// `tail` is what the first row being chosen *means*, and only one of the two +/// reports has an answer. `doctor` orders its rows by what depends on what, so +/// the first broken one is the one to fix first and saying so is the whole +/// value of the line. A report about four independent services has no such +/// claim to make and passes `None` rather than inventing one — advice that +/// does not help being worse than none, the same rule [`Check::remedy`] +/// follows. +pub fn summary(checks: &[Check], subject: &str, tail: Option<&str>) -> String { let broken: Vec<&str> = checks .iter() .filter(|check| check.status.exit().is_some()) @@ -163,12 +171,14 @@ pub fn summary(checks: &[Check], subject: &str) -> String { .collect(); match broken.split_first() { Some((first, [])) => format!("{subject} found a problem: {first}"), - Some((first, rest)) => format!( - "{subject} found {} problems: {first}, {}\nthe exit status is {first}'s, which is the \ - one to fix first", - broken.len(), - rest.join(", ") - ), + Some((first, rest)) => { + let tail = tail.map(|t| format!(", {t}")).unwrap_or_default(); + format!( + "{subject} found {} problems: {first}, {}\nthe exit status is {first}'s{tail}", + broken.len(), + rest.join(", ") + ) + } None => format!("{subject} found a problem"), } }