Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/swarm-controller/src/forge/api.rs
atlas 6ba29c4d33 swarm-controller: re-read the forge token from the store when the forge rejects it
The controller read its forge token from the swarm secret store once, when
its client slot filled, and kept sending it until the daemon restarted. A
token the forge re-minted or revoked left every forge-backed path failing
with 401 until someone restarted the controller, and a restart drops the
runtime-only job graph.

Every forge request now goes through `forge::api::Api::call`. On a 401 it
asks a `StoredSecret` (the queue clients' debounced, single-flight re-read
with backoff) for a token to try instead, rebuilds the forgejo client with
it, and sends the request once more. A second 401, or a store still holding
the refused token, surfaces the error as before. Only 401 counts: unlike an
IdP, the forge answers 400 to a malformed request, and a re-read started by
one would put a real 401 that follows inside the backoff.

forgejo-api bakes the token into its own reqwest client's default headers
and takes no client or middleware from outside, so there is no single
request function to hook. Each `self.api.<endpoint>(..)` site becomes
`self.api.call(|api| api.<endpoint>(..).into_future())`; option structs a
site moved into the call are cloned, since the closure can run twice. The
`OnceLock<Arc<Client>>` slot and its `get()` callers are unchanged.

Refs #5017
2026-10-14 10:16:18 +02:00

261 lines
11 KiB
Rust

//! The forge API under the controller's token, re-read from the swarm secret
//! store when the forge refuses it.
//!
//! [`Api::call`] runs one request. When the forge answers 401, it asks the
//! [`StoredSecret`] for a token to try instead and runs the request once more
//! with it; a second 401 is returned as it came. The `StoredSecret` runs one
//! store read at a time and backs off after a read that brings no new token,
//! so a burst of refusals costs the store one read.
use std::sync::{Arc, Mutex};
use forgejo_api::{ApiErrorKind, Auth, Forgejo, ForgejoError};
use futures_util::future::BoxFuture;
use reqwest::StatusCode;
use swarm_queue_client::stored_secret::StoredSecret;
/// One forge request, as [`Api::call`] runs it.
pub(crate) type Call<'a, R> = BoxFuture<'a, Result<R, ForgejoError>>;
pub(crate) struct Api {
url: url::Url,
token: StoredSecret,
/// A client sending the token it is paired with. Rebuilt when `token`
/// holds a different one.
built: Mutex<(String, Arc<Forgejo>)>,
}
impl Api {
/// A client for the forge at `url`, sending `token`'s current value.
///
/// # Errors
/// `url` is not http(s), or the token is not ASCII.
pub(crate) fn new(url: url::Url, token: StoredSecret) -> Result<Self, ForgejoError> {
let current = token.current();
let api = Arc::new(Forgejo::new(Auth::Token(&current), url.clone())?);
Ok(Self {
url,
token,
built: Mutex::new((current, api)),
})
}
/// The token in use and a client sending it.
fn current(&self) -> Result<(String, Arc<Forgejo>), ForgejoError> {
let token = self.token.current();
let mut built = self.built.lock().expect("unpoisoned");
if built.0 != token {
let api = Arc::new(Forgejo::new(Auth::Token(&token), self.url.clone())?);
*built = (token.clone(), api);
}
Ok((token, Arc::clone(&built.1)))
}
/// `request` against the forge, run a second time with a re-read token
/// when the forge refuses the first. The forge acts on nothing it answers
/// 401 to, so running a non-idempotent request again is safe.
///
/// Boxed, so a caller awaiting several of these does not carry each one's
/// `R` inline.
pub(crate) fn call<'s, R, F>(&'s self, request: F) -> Call<'s, R>
where
F: Fn(&Forgejo) -> Call<'_, R> + Send + 's,
R: Send + 's,
{
Box::pin(async move {
let (token, api) = self.current()?;
match request(&api).await {
Err(e) if refuses_the_token(&e) => {
if self.token.after_refusal(&token).await.is_none() {
return Err(e);
}
let (_, api) = self.current()?;
request(&api).await
}
done => done,
}
})
}
}
/// The HTTP status `e` reports, for the shapes a 401 can arrive in: endpoints
/// that declare a 401 parse its body into [`ApiErrorKind::Unauthorized`], the
/// rest report the bare status.
fn status(e: &ForgejoError) -> Option<StatusCode> {
match e {
ForgejoError::UnexpectedStatusCode(s) => Some(*s),
ForgejoError::ApiError(e) => match e.error_kind() {
ApiErrorKind::Unauthorized => Some(StatusCode::UNAUTHORIZED),
ApiErrorKind::Other(s) => Some(*s),
_ => None,
},
_ => None,
}
}
/// Whether the forge refused the token. Only a 401: to the forge a 400 is a
/// malformed request, and a re-read it started would find the same token and
/// put a real 401 right after it inside the re-read backoff.
fn refuses_the_token(e: &ForgejoError) -> bool {
status(e) == Some(StatusCode::UNAUTHORIZED)
}
#[cfg(test)]
pub(crate) mod tests {
use std::sync::atomic::{AtomicUsize, Ordering};
use super::*;
/// A forge answering 200 to `accepted` and 401 to any other token,
/// counting the requests it serves. The `held`-th request to arrive, from
/// 0, is answered only after one with `accepted` is.
async fn forge_accepting(
accepted: &'static str,
held: Option<usize>,
) -> (url::Url, Arc<AtomicUsize>) {
use axum::http::{HeaderMap, StatusCode, header::AUTHORIZATION, header::CONTENT_TYPE};
let served = Arc::new(AtomicUsize::new(0));
let counted = Arc::clone(&served);
let (accepted_once, seen_accepted) = tokio::sync::watch::channel(false);
let app = axum::Router::new().fallback(move |headers: HeaderMap| {
let counted = Arc::clone(&counted);
let (accepted_once, mut seen_accepted) = (accepted_once.clone(), seen_accepted.clone());
async move {
let n = counted.fetch_add(1, Ordering::SeqCst);
if held == Some(n) {
seen_accepted.wait_for(|seen| *seen).await.unwrap();
}
let sent = headers.get(AUTHORIZATION).and_then(|v| v.to_str().ok());
if sent == Some(&format!("token {accepted}")) {
accepted_once.send_replace(true);
(StatusCode::OK, [(CONTENT_TYPE, "application/json")], "{}")
} else {
let body = r#"{"message":"token is required"}"#;
(
StatusCode::UNAUTHORIZED,
[(CONTENT_TYPE, "application/json")],
body,
)
}
}
});
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = url::Url::parse(&format!("http://{}/", listener.local_addr().unwrap())).unwrap();
tokio::spawn(async move { axum::serve(listener, app).await });
(url, served)
}
/// A token starting at `initial` whose store holds `stored`, counting the
/// reads, each of which takes a second.
fn token(initial: &str, stored: &'static str) -> (StoredSecret, Arc<AtomicUsize>) {
let reads = Arc::new(AtomicUsize::new(0));
let counted = Arc::clone(&reads);
let secret = StoredSecret::new("test", initial.to_owned(), move || {
let counted = Arc::clone(&counted);
async move {
counted.fetch_add(1, Ordering::SeqCst);
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
Ok(stored.to_owned())
}
});
(secret, reads)
}
/// A client for `url` holding `token`, whose store read always fails.
pub(crate) fn fixed(url: url::Url, token: &str) -> Api {
let token = StoredSecret::new("test", token.to_owned(), || async {
Err("no store in this test".into())
});
Api::new(url, token).unwrap()
}
async fn whoami(api: &Api) -> Result<forgejo_api::structs::User, ForgejoError> {
api.call(|api| api.user_get_current().into_future()).await
}
#[tokio::test(start_paused = true)]
async fn a_refused_token_is_re_read_and_the_request_sent_again_with_the_new_one() {
let (url, served) = forge_accepting("new", None).await;
let (token, reads) = token("old", "new");
let api = Api::new(url, token).unwrap();
whoami(&api).await.expect("accepted with the re-read token");
assert_eq!(reads.load(Ordering::SeqCst), 1);
assert_eq!(served.load(Ordering::SeqCst), 2);
whoami(&api).await.expect("the new token stays in use");
assert_eq!(reads.load(Ordering::SeqCst), 1);
assert_eq!(served.load(Ordering::SeqCst), 3);
}
#[tokio::test(start_paused = true)]
async fn a_re_read_token_the_forge_also_refuses_is_returned_as_the_error() {
let (url, served) = forge_accepting("neither", None).await;
let (token, reads) = token("old", "new");
let api = Api::new(url, token).unwrap();
let err = whoami(&api).await.expect_err("both tokens refused");
assert!(refuses_the_token(&err), "{err}");
assert_eq!(reads.load(Ordering::SeqCst), 1);
assert_eq!(served.load(Ordering::SeqCst), 2);
}
#[tokio::test(start_paused = true)]
async fn a_refused_token_still_in_the_store_is_not_sent_again() {
let (url, served) = forge_accepting("new", None).await;
let (token, reads) = token("old", "old");
let api = Api::new(url, token).unwrap();
let err = whoami(&api).await.expect_err("no other token to try");
assert!(refuses_the_token(&err), "{err}");
assert_eq!(reads.load(Ordering::SeqCst), 1);
assert_eq!(served.load(Ordering::SeqCst), 1);
}
#[tokio::test(start_paused = true)]
async fn concurrent_refusals_share_one_store_read() {
let (url, served) = forge_accepting("new", None).await;
let (token, reads) = token("old", "new");
let api = Api::new(url, token).unwrap();
let all = futures_util::future::join_all((0..5).map(|_| whoami(&api))).await;
assert!(all.iter().all(Result::is_ok), "{all:?}");
assert_eq!(reads.load(Ordering::SeqCst), 1);
assert_eq!(served.load(Ordering::SeqCst), 10);
}
// Real time: the held request waits on a socket, and paused time would
// jump the bound below while it does.
#[tokio::test]
async fn a_refusal_landing_after_the_re_read_is_sent_again_without_another_read() {
let (url, served) = forge_accepting("new", Some(1)).await;
let (token, reads) = token("old", "new");
let api = Api::new(url, token).unwrap();
let both = async { tokio::join!(whoami(&api), whoami(&api)) };
let (a, b) = tokio::time::timeout(std::time::Duration::from_secs(10), both)
.await
.expect("the held request is answered once the other is accepted");
a.expect("accepted with the re-read token");
b.expect("accepted with the re-read token");
assert_eq!(reads.load(Ordering::SeqCst), 1);
assert_eq!(served.load(Ordering::SeqCst), 4);
}
#[test]
fn only_a_401_counts_as_refusing_the_token() {
let unauthorized = forgejo_api::ApiError {
message: None,
kind: ApiErrorKind::Unauthorized,
};
assert!(refuses_the_token(&ForgejoError::ApiError(unauthorized)));
assert!(refuses_the_token(&ForgejoError::UnexpectedStatusCode(
StatusCode::UNAUTHORIZED
)));
let other = forgejo_api::ApiError {
message: None,
kind: ApiErrorKind::Other(StatusCode::UNAUTHORIZED),
};
assert!(refuses_the_token(&ForgejoError::ApiError(other)));
for refused in [StatusCode::BAD_REQUEST, StatusCode::FORBIDDEN] {
assert!(!refuses_the_token(&ForgejoError::UnexpectedStatusCode(
refused
)));
}
}
}