refactor(swarm-queue-client): a library's errors are an enum, not anyhow
Operator ruling: libs should not use anyhow. The queue connect was moved
here verbatim from swarm-controller, which is a binary, so it arrived
still wearing a binary's error handling — the move changed what the code
is without changing how it reports.
Callers get variants they can match on, split by what an operator does
about them: a half-configured environment is a deployment bug, a refused
token is an identity-provider config problem, an unreachable queue is a
network one. The binaries that consume this keep anyhow and `?` converts,
so nothing downstream is more verbose for it. Same split hive-claude uses.
One thing anyhow was doing unpaid: the auth callback hands async-nats a
plain string, and a Display that stops at the top message drops the cause
— the half that says why the mint failed. `chain()` walks the source
chain, which is what `{:#}` was doing before.
This commit is contained in:
parent
62b9c76d69
commit
d71222c206
3 changed files with 96 additions and 29 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -4595,11 +4595,11 @@ dependencies = [
|
||||||
name = "swarm-queue-client"
|
name = "swarm-queue-client"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
|
||||||
"async-nats",
|
"async-nats",
|
||||||
"reqwest 0.13.1",
|
"reqwest 0.13.1",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ readme = "README.md"
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow.workspace = true
|
|
||||||
# No `kv`/`jetstream` feature here on purpose: this crate's job ends at a
|
# No `kv`/`jetstream` feature here on purpose: this crate's job ends at a
|
||||||
# connected client. What a consumer does with it - KV for the controller and
|
# connected client. What a consumer does with it - KV for the controller and
|
||||||
# the hive, plain messaging for anything later - is the consumer's business,
|
# the hive, plain messaging for anything later - is the consumer's business,
|
||||||
|
|
@ -14,6 +13,9 @@ async-nats.workspace = true
|
||||||
reqwest.workspace = true
|
reqwest.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
# A library, so its errors are a matchable enum rather than an opaque
|
||||||
|
# `anyhow::Error`. The binaries that consume this keep anyhow; `?` converts.
|
||||||
|
thiserror.workspace = true
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,78 @@
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use anyhow::{Context, Result, bail};
|
/// Everything that can go wrong reaching the swarm queue.
|
||||||
|
///
|
||||||
|
/// `thiserror` and not `anyhow` because this is a library: a caller gets a
|
||||||
|
/// type it can match on, and the binaries that consume it keep using
|
||||||
|
/// `anyhow` — `?` converts for free, so nothing downstream is more verbose
|
||||||
|
/// for it. The same split `hive-claude` uses.
|
||||||
|
///
|
||||||
|
/// The variants are the failures an operator acts on differently: a
|
||||||
|
/// half-configured environment is a deployment bug, a refused token is an
|
||||||
|
/// identity-provider config problem, an unreachable queue is a network
|
||||||
|
/// one. Collapsing them into one string would make that distinction a
|
||||||
|
/// matter of reading prose.
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum Error {
|
||||||
|
#[error(
|
||||||
|
"swarm queue is half-configured: {prefix}_NATS_URL, \
|
||||||
|
{prefix}_OIDC_TOKEN_ENDPOINT, {prefix}_OIDC_CLIENT_ID and \
|
||||||
|
{prefix}_OIDC_CLIENT_SECRET_FILE must be set together or not at all"
|
||||||
|
)]
|
||||||
|
PartialConfig { prefix: String },
|
||||||
|
|
||||||
|
#[error("reading the queue client secret from {path}")]
|
||||||
|
ClientSecret {
|
||||||
|
path: String,
|
||||||
|
#[source]
|
||||||
|
source: std::io::Error,
|
||||||
|
},
|
||||||
|
|
||||||
|
#[error("building the token-endpoint HTTP client")]
|
||||||
|
HttpClient(#[source] reqwest::Error),
|
||||||
|
|
||||||
|
#[error("requesting an access token from authelia")]
|
||||||
|
TokenRequest(#[source] reqwest::Error),
|
||||||
|
|
||||||
|
/// The body carries authelia's own `error_description`, and it is far
|
||||||
|
/// more useful than the status alone: a wrong grant says
|
||||||
|
/// `unauthorized_client`, a wrong secret says `invalid_client`, and
|
||||||
|
/// those point at different config.
|
||||||
|
#[error("authelia refused the token request ({status}): {body}")]
|
||||||
|
TokenRefused {
|
||||||
|
status: reqwest::StatusCode,
|
||||||
|
body: String,
|
||||||
|
},
|
||||||
|
|
||||||
|
#[error("parsing authelia's token response")]
|
||||||
|
TokenResponse(#[source] serde_json::Error),
|
||||||
|
|
||||||
|
#[error("connecting to the swarm queue at {url}")]
|
||||||
|
Connect {
|
||||||
|
url: String,
|
||||||
|
#[source]
|
||||||
|
source: async_nats::ConnectError,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render an error and its source chain on one line.
|
||||||
|
///
|
||||||
|
/// The auth callback below hands `async-nats` a *string*, so a `Display`
|
||||||
|
/// that stopped at the top message would drop the cause — which is the
|
||||||
|
/// half that says why the mint failed. `anyhow`'s `{:#}` did this for
|
||||||
|
/// free; a library owes its callers the same detail without owing them
|
||||||
|
/// anyhow.
|
||||||
|
fn chain(error: &dyn std::error::Error) -> String {
|
||||||
|
let mut rendered = error.to_string();
|
||||||
|
let mut source = error.source();
|
||||||
|
while let Some(cause) = source {
|
||||||
|
rendered.push_str(": ");
|
||||||
|
rendered.push_str(&cause.to_string());
|
||||||
|
source = cause.source();
|
||||||
|
}
|
||||||
|
rendered
|
||||||
|
}
|
||||||
|
|
||||||
/// Only the one field this needs; authelia returns several.
|
/// Only the one field this needs; authelia returns several.
|
||||||
#[derive(serde::Deserialize)]
|
#[derive(serde::Deserialize)]
|
||||||
|
|
@ -76,7 +147,7 @@ impl QueueConfig {
|
||||||
/// there would trade a missing feature for a dead process. What must NOT
|
/// there would trade a missing feature for a dead process. What must NOT
|
||||||
/// happen is a *half* configuration silently behaving like an absent one —
|
/// happen is a *half* configuration silently behaving like an absent one —
|
||||||
/// hence the explicit partial check below.
|
/// hence the explicit partial check below.
|
||||||
pub fn from_env(prefix: &str) -> Result<Option<Self>> {
|
pub fn from_env(prefix: &str) -> Result<Option<Self>, Error> {
|
||||||
let url = std::env::var(format!("{prefix}_NATS_URL")).ok();
|
let url = std::env::var(format!("{prefix}_NATS_URL")).ok();
|
||||||
let token_endpoint = std::env::var(format!("{prefix}_OIDC_TOKEN_ENDPOINT")).ok();
|
let token_endpoint = std::env::var(format!("{prefix}_OIDC_TOKEN_ENDPOINT")).ok();
|
||||||
let client_id = std::env::var(format!("{prefix}_OIDC_CLIENT_ID")).ok();
|
let client_id = std::env::var(format!("{prefix}_OIDC_CLIENT_ID")).ok();
|
||||||
|
|
@ -94,12 +165,9 @@ impl QueueConfig {
|
||||||
// it would otherwise produce is the expensive kind: the process
|
// it would otherwise produce is the expensive kind: the process
|
||||||
// comes up "fine", never connects, and the data it was supposed to
|
// comes up "fine", never connects, and the data it was supposed to
|
||||||
// move silently stops moving. Naming the variables costs one line.
|
// move silently stops moving. Naming the variables costs one line.
|
||||||
_ => bail!(
|
_ => Err(Error::PartialConfig {
|
||||||
"swarm queue is half-configured: {prefix}_NATS_URL, \
|
prefix: prefix.to_owned(),
|
||||||
{prefix}_OIDC_TOKEN_ENDPOINT, {prefix}_OIDC_CLIENT_ID and \
|
}),
|
||||||
{prefix}_OIDC_CLIENT_SECRET_FILE must be set together or not \
|
|
||||||
at all"
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -110,16 +178,14 @@ impl QueueConfig {
|
||||||
/// authenticates as itself. Authelia refuses the `openid` scope for this grant
|
/// authenticates as itself. Authelia refuses the `openid` scope for this grant
|
||||||
/// (a machine client receives an access token and never an id-token), so no
|
/// (a machine client receives an access token and never an id-token), so no
|
||||||
/// scope is requested.
|
/// scope is requested.
|
||||||
async fn mint_token(http: &reqwest::Client, cfg: &QueueConfig) -> Result<String> {
|
async fn mint_token(http: &reqwest::Client, cfg: &QueueConfig) -> Result<String, Error> {
|
||||||
// Read per call rather than caching: the file is small, and a cached
|
// Read per call rather than caching: the file is small, and a cached
|
||||||
// secret would survive a rotation that the operator believes took effect.
|
// secret would survive a rotation that the operator believes took effect.
|
||||||
let secret = tokio::fs::read_to_string(&cfg.client_secret_file)
|
let secret = tokio::fs::read_to_string(&cfg.client_secret_file)
|
||||||
.await
|
.await
|
||||||
.with_context(|| {
|
.map_err(|source| Error::ClientSecret {
|
||||||
format!(
|
path: cfg.client_secret_file.display().to_string(),
|
||||||
"reading the queue client secret from {}",
|
source,
|
||||||
cfg.client_secret_file.display()
|
|
||||||
)
|
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let response = http
|
let response = http
|
||||||
|
|
@ -131,25 +197,21 @@ async fn mint_token(http: &reqwest::Client, cfg: &QueueConfig) -> Result<String>
|
||||||
])
|
])
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.context("requesting an access token from authelia")?;
|
.map_err(Error::TokenRequest)?;
|
||||||
|
|
||||||
// The body carries authelia's own error description, and it is far more
|
// Why the body and not just the status: see `Error::TokenRefused`.
|
||||||
// useful than the status alone: a wrong grant says `unauthorized_client`,
|
|
||||||
// a wrong secret says `invalid_client`, and those point at different
|
|
||||||
// config.
|
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
let body = response.text().await.unwrap_or_default();
|
let body = response.text().await.unwrap_or_default();
|
||||||
if !status.is_success() {
|
if !status.is_success() {
|
||||||
bail!("authelia refused the controller's token request ({status}): {body}");
|
return Err(Error::TokenRefused { status, body });
|
||||||
}
|
}
|
||||||
|
|
||||||
let parsed: TokenResponse =
|
let parsed: TokenResponse = serde_json::from_str(&body).map_err(Error::TokenResponse)?;
|
||||||
serde_json::from_str(&body).context("parsing authelia's token response")?;
|
|
||||||
Ok(parsed.access_token)
|
Ok(parsed.access_token)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Connect to the swarm queue, minting a token for each connection attempt.
|
/// Connect to the swarm queue, minting a token for each connection attempt.
|
||||||
pub async fn connect(cfg: QueueConfig) -> Result<async_nats::Client> {
|
pub async fn connect(cfg: QueueConfig) -> Result<async_nats::Client, Error> {
|
||||||
// A timeout, because this client runs INSIDE the auth callback: a token
|
// A timeout, because this client runs INSIDE the auth callback: a token
|
||||||
// endpoint that accepts the connection and then never answers would hang
|
// endpoint that accepts the connection and then never answers would hang
|
||||||
// the callback, and with it the connection attempt that invoked it, with
|
// the callback, and with it the connection attempt that invoked it, with
|
||||||
|
|
@ -159,7 +221,7 @@ pub async fn connect(cfg: QueueConfig) -> Result<async_nats::Client> {
|
||||||
let http = reqwest::Client::builder()
|
let http = reqwest::Client::builder()
|
||||||
.timeout(std::time::Duration::from_secs(10))
|
.timeout(std::time::Duration::from_secs(10))
|
||||||
.build()
|
.build()
|
||||||
.context("building the token-endpoint HTTP client")?;
|
.map_err(Error::HttpClient)?;
|
||||||
let url = cfg.url.clone();
|
let url = cfg.url.clone();
|
||||||
|
|
||||||
let client = async_nats::ConnectOptions::with_auth_callback(move |_nonce| {
|
let client = async_nats::ConnectOptions::with_auth_callback(move |_nonce| {
|
||||||
|
|
@ -168,9 +230,9 @@ pub async fn connect(cfg: QueueConfig) -> Result<async_nats::Client> {
|
||||||
async move {
|
async move {
|
||||||
let token = mint_token(&http, &cfg)
|
let token = mint_token(&http, &cfg)
|
||||||
.await
|
.await
|
||||||
// The callback's error type carries a string, so the context
|
// The callback's error type carries a string, so the source
|
||||||
// chain would be lost; flatten it rather than dropping it.
|
// chain would be lost; flatten it rather than dropping it.
|
||||||
.map_err(|e| async_nats::AuthError::new(format!("{e:#}")))?;
|
.map_err(|e| async_nats::AuthError::new(chain(&e)))?;
|
||||||
let mut auth = async_nats::Auth::new();
|
let mut auth = async_nats::Auth::new();
|
||||||
auth.token = Some(token);
|
auth.token = Some(token);
|
||||||
Ok(auth)
|
Ok(auth)
|
||||||
|
|
@ -188,7 +250,10 @@ pub async fn connect(cfg: QueueConfig) -> Result<async_nats::Client> {
|
||||||
.retry_on_initial_connect()
|
.retry_on_initial_connect()
|
||||||
.connect(&url)
|
.connect(&url)
|
||||||
.await
|
.await
|
||||||
.with_context(|| format!("connecting to the swarm queue at {url}"))?;
|
.map_err(|source| Error::Connect {
|
||||||
|
url: url.clone(),
|
||||||
|
source,
|
||||||
|
})?;
|
||||||
|
|
||||||
Ok(client)
|
Ok(client)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue