From 9781a2d130c64f75f9fa3cf2cfb6fda42f0af52b Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Mon, 17 Aug 2026 10:57:52 -0400 Subject: [PATCH] feat(status): add `status tangled`, the other half of doctor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `doctor` asks whether you are set up and hands you a command for every bad row. This asks whether the services are up: no row is about you, none has a remedy, and 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 — and the one `doctor` structurally cannot answer, every network row it has being scoped to the repo you are standing in. Four rows, each asked the question atgc's own use of that service depends on rather than a health endpoint invented for the purpose: the appview's site, Bobbin's search, `sh.tangled.knot.version`, the PDS's `_health`. Tangled's appview has `_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. A silent host is an error and exits 6; a host that answered a refusal is a warning, because a service that refuses is running. The knot comes from the repo's own DID document rather than its `sh.tangled.repo` record, which is where `doctor` reads it: that record sits at an address only Bobbin can supply, so `doctor`'s route goes dark exactly when the index does, and an index outage is what somebody runs this to confirm. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 25 +++ TODO.md | 25 +++ docs/output.md | 24 ++- src/clients/mod.rs | 4 + src/clients/probe.rs | 148 +++++++++++++ src/cmd/doctor.rs | 7 +- src/cmd/status.rs | 490 ++++++++++++++++++++++++++++++++++++++++++- src/term/checks.rs | 24 ++- 8 files changed, 727 insertions(+), 20 deletions(-) create mode 100644 src/clients/probe.rs 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"), } } -- 2.51.2