Watch
0
0
Fork
You've already forked hyperhive
0

move stored_secret from swarm-queue-client to swarm-secret-client

The module is a rename (97% similar); everything else in the diff follows
from the move:

- stored_secret.rs: private `chain()` helper, the 8-line copy of the queue
  crate's `chain` the module called as `crate::chain`; the queue crate's
  public `chain` stays.
- stored_secret.rs: `Duration::from_mins(5)` in `MAX_REREAD_DELAY` is
  unchanged moved code, not a new use.
- swarm-secret-client Cargo.toml: gains futures-util, tokio and tracing as
  dependencies and tokio `test-util` plus tracing-subscriber as dev
  dependencies; the module and its tests use them.
- swarm-queue-client Cargo.toml: drops futures-util and the tracing-subscriber
  dev dependency, which only the moved module used; its tokio dev comment is
  reworded to the tests that remain.
- Cargo.lock: the same dependency moves, nothing else.
- swarm-queue-client lib.rs: imports `swarm_secret_client::stored_secret`,
  and its tests use a small local `Store` in place of the moved module's
  test-only `Fake`, which is no longer reachable.
- callers (hive-c0re, swarm-builder, swarm-ci-metrics, swarm-claude-login,
  swarm-controller, swarm-nats-auth, swarm-review-classifier): import path
  only; no re-export is left behind.
This commit is contained in:
atlas 2026-10-18 06:04:23 +02:00
commit f4f459f0b2
16 changed files with 83 additions and 33 deletions

6
Cargo.lock generated
View file

@ -5022,7 +5022,6 @@ dependencies = [
"axum",
"backon",
"data-encoding",
"futures-util",
"hive-sh4re",
"reqwest",
"serde",
@ -5032,7 +5031,6 @@ dependencies = [
"thiserror 2.0.18",
"tokio",
"tracing",
"tracing-subscriber",
"utoipa",
]
@ -5062,6 +5060,7 @@ dependencies = [
name = "swarm-secret-client"
version = "0.1.0"
dependencies = [
"futures-util",
"reqwest",
"rustify",
"rustify_derive",
@ -5070,6 +5069,9 @@ dependencies = [
"serde_json",
"strum",
"thiserror 2.0.18",
"tokio",
"tracing",
"tracing-subscriber",
"vaultrs",
]

View file

@ -14,7 +14,7 @@
//! environment. The client secret comes from the swarm's secret store, at
//! `swarm_secret_client::queue::hive_client_path`, read under this hive's own
//! certificate, held only in memory and read again when authelia refuses it
//! (`swarm_queue_client::stored_secret`). `swarm-secret-publish`, in authelia's
//! (`swarm_secret_client::stored_secret`). `swarm-secret-publish`, in authelia's
//! container, puts it there.
//!
//! Connects lazily on first use rather than at boot — nothing here
@ -28,9 +28,9 @@ use std::path::PathBuf;
use std::time::Duration;
use anyhow::{Context, Result, anyhow, bail};
use swarm_queue_client::stored_secret::StoredSecret;
use swarm_queue_client::{ClientSecret, QueueConfig};
use swarm_secret_client::queue::{self, HiveClientCredential};
use swarm_secret_client::stored_secret::StoredSecret;
use tokio::sync::Mutex;
use tokio::time::Instant;

View file

@ -23,9 +23,9 @@ use swarm_queue_client::builder::{
ARCHETYPES_DEADLINE, ARCHETYPES_SUBJECT, BUILD_DEADLINE, BuildAgent, QUEUE_GROUP,
REPLY_DEADLINE, Reply, SUBJECT,
};
use swarm_queue_client::stored_secret::StoredSecret;
use swarm_queue_client::{ClientSecret, QueueConfig};
use swarm_secret_client::forge::Credential;
use swarm_secret_client::stored_secret::StoredSecret;
mod archetypes;
mod nix_build;

View file

@ -18,11 +18,11 @@ use std::time::Duration;
use anyhow::{Context as _, Result, bail};
use forgejo_api::structs::{ActionRun, ListActionRunsQuery, RepoSearchQuery};
use forgejo_api::{Auth, Forgejo};
use swarm_queue_client::stored_secret::StoredSecret;
use swarm_queue_client::{ClientSecret, QueueConfig};
use swarm_secret_client::SecretStore;
use swarm_secret_client::ci_metrics::{ClientSecret as StoredClientSecret, client_secret_path};
use swarm_secret_client::forge::Credential;
use swarm_secret_client::stored_secret::StoredSecret;
mod cursor;
mod otlp;

View file

@ -17,9 +17,9 @@ use std::sync::Arc;
use anyhow::{Context, Result, bail};
use futures_util::StreamExt as _;
use swarm_queue_client::claude_login::{Reply, Request, SESSION_DEADLINE, STEP_DEADLINE, SUBJECT};
use swarm_queue_client::stored_secret::StoredSecret;
use swarm_queue_client::{ClientSecret, QueueConfig};
use swarm_secret_client::claude_login::{Credentials, client_secret_path, credentials_path};
use swarm_secret_client::stored_secret::StoredSecret;
mod login;

View file

@ -42,7 +42,7 @@ use utoipa::ToSchema;
use crate::webhook::DeliveryKind;
use swarm_queue_client::review_classifier::Commit;
use swarm_queue_client::stored_secret::StoredSecret;
use swarm_secret_client::stored_secret::StoredSecret;
pub mod agent_token;
mod api;

View file

@ -12,7 +12,7 @@ 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;
use swarm_secret_client::stored_secret::StoredSecret;
/// One forge request, as [`Api::call`] runs it.
pub(crate) type Call<'a, R> = BoxFuture<'a, Result<R, ForgejoError>>;

View file

@ -5,7 +5,7 @@
//! environment. The client secret comes from the swarm's secret store, at
//! `swarm_secret_client::queue::controller_client_path`, read at start under
//! this daemon's own certificate, held only in memory and read again when
//! authelia refuses it (`swarm_queue_client::stored_secret`). It is never on
//! authelia refuses it (`swarm_secret_client::stored_secret`). It is never on
//! this host's disk. `swarm-secret-publish`, in authelia's
//! container, puts it there.
//!
@ -15,9 +15,9 @@ use std::path::PathBuf;
use std::sync::OnceLock;
use anyhow::{Context, Result, bail};
use swarm_queue_client::stored_secret::StoredSecret;
use swarm_queue_client::{ClientSecret, QueueConfig};
use swarm_secret_client::queue::{self, ControllerCredential};
use swarm_secret_client::stored_secret::StoredSecret;
/// The environment prefix the nix module's `queueEnv` sets.
const PREFIX: &str = "SWARM_CONTROLLER";

View file

@ -714,7 +714,7 @@ mod tests {
.with_agent_token_subjects(vec!["$SWARM.term.{agent}".to_owned()])
.expect("valid");
let client_secret =
oidc_secret::OidcSecret::new(swarm_queue_client::stored_secret::StoredSecret::new(
oidc_secret::OidcSecret::new(swarm_secret_client::stored_secret::StoredSecret::new(
"swarm-nats",
"unused".to_owned(),
|| async { Err("never re-read".into()) },

View file

@ -16,9 +16,9 @@ use std::future::Future;
use std::sync::Arc;
use anyhow::{Context, Result};
use swarm_queue_client::stored_secret::{StoredSecret, is_credential_refusal};
use swarm_secret_client::client::{DEFAULT_CERT_MOUNT, Settings};
use swarm_secret_client::path::{Kind, principal_prefix};
use swarm_secret_client::stored_secret::{StoredSecret, is_credential_refusal};
use tokio::time::Instant;
use crate::introspect::{Answer, Caller};

View file

@ -68,9 +68,6 @@ agent-archetype = ["async-nats/jetstream"]
# above - the connect itself needs none of them.
async-nats.workspace = true
backon.workspace = true
# `Shared`, so every caller refused while a secret re-read runs waits on that
# one read (`stored_secret`).
futures-util.workspace = true
hive-sh4re = { workspace = true, optional = true }
# `blocking` on top of the workspace default (`form`/`json`/`rustls`) —
# `mint_token_for_blocking` needs `reqwest::blocking::Client` for a caller
@ -85,7 +82,8 @@ reqwest = { workspace = true, features = ["blocking"] }
serde.workspace = true
serde_json.workspace = true
strum.workspace = true
# The rustls provider `connect` installs.
# `stored_secret::StoredSecret`, the held client secret, and the rustls
# provider `connect` installs.
swarm-secret-client.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.
@ -98,12 +96,9 @@ utoipa = { workspace = true, optional = true }
# A loopback stand-in token endpoint for the client secret re-read tests.
axum.workspace = true
data-encoding.workspace = true
# `test-util` for `#[tokio::test(start_paused = true)]`: the re-read backoff
# tests run on paused time.
# `test-util` for `#[tokio::test(start_paused = true)]`: the startup-read
# retry tests run on paused time.
tokio = { workspace = true, features = ["test-util"] }
# Capturing what a re-read logs, to check it names the client and never the
# secret.
tracing-subscriber.workspace = true
[lints]
workspace = true

View file

@ -31,6 +31,8 @@
use std::path::PathBuf;
use swarm_secret_client::stored_secret::{self, StoredSecret};
/// Everything that can go wrong reaching the swarm queue.
///
/// `thiserror` and not `anyhow` because this is a library: a caller gets a
@ -480,8 +482,6 @@ pub mod subagent_term;
/// same shapes.
pub mod review_classifier;
pub mod stored_secret;
mod startup_read;
pub use startup_read::read_with_startup_retry;
@ -588,13 +588,13 @@ pub enum ClientSecret {
File(PathBuf),
/// The plaintext, held only in this process's memory and read again
/// when the token endpoint refuses it.
Stored(stored_secret::StoredSecret),
Stored(StoredSecret),
}
impl ClientSecret {
/// The secret to read again after `error`: a held one the token endpoint
/// refused as a credential ([`stored_secret::is_credential_refusal`]).
fn refused(&self, error: &Error) -> Option<&stored_secret::StoredSecret> {
fn refused(&self, error: &Error) -> Option<&StoredSecret> {
match (self, error) {
(Self::Stored(stored), Error::TokenRefused { status, .. })
if stored_secret::is_credential_refusal(*status) =>
@ -1096,6 +1096,34 @@ mod tests {
use super::*;
/// A store holding `secret`, counting how often it is read.
struct Store {
secret: &'static str,
reads: Arc<AtomicUsize>,
}
impl Store {
fn holding(secret: &'static str) -> Self {
Self {
secret,
reads: Arc::default(),
}
}
/// `client_id`'s secret, `initial` in use, re-read from this store.
fn secret(&self, client_id: &str, initial: &str) -> StoredSecret {
let (secret, reads) = (self.secret, Arc::clone(&self.reads));
StoredSecret::new(client_id, initial.to_owned(), move || {
reads.fetch_add(1, Ordering::SeqCst);
async move { Ok(secret.to_owned()) }
})
}
fn reads(&self) -> usize {
self.reads.load(Ordering::SeqCst)
}
}
/// The names the publishers, the relays and the queue's grant all spell.
#[test]
fn term_and_agent_state_subjects_are_the_agreed_names() {
@ -1126,7 +1154,7 @@ mod tests {
async fn a_debug_print_of_a_config_does_not_carry_a_held_secret() {
let cfg = QueueConfig {
client_secret: ClientSecret::Stored(
stored_secret::tests::Fake::holding("new").secret("hive-alpha", "s3cret"),
Store::holding("new").secret("hive-alpha", "s3cret"),
),
..token_cfg()
};
@ -1180,7 +1208,7 @@ mod tests {
#[tokio::test]
async fn a_refused_secret_is_re_read_and_the_mint_retried_with_the_new_one() {
let calls = Arc::new(AtomicUsize::new(0));
let store = stored_secret::tests::Fake::holding("secret-b");
let store = Store::holding("secret-b");
let cfg = QueueConfig {
token_endpoint: stub_idp("secret-b", Arc::clone(&calls)).await,
client_secret: ClientSecret::Stored(store.secret("hive-alpha", "secret-a")),
@ -1200,7 +1228,7 @@ mod tests {
#[tokio::test]
async fn a_permanently_refused_secret_is_not_re_read_on_every_mint() {
let calls = Arc::new(AtomicUsize::new(0));
let store = stored_secret::tests::Fake::holding("secret-a");
let store = Store::holding("secret-a");
let cfg = QueueConfig {
token_endpoint: stub_idp("secret-b", Arc::clone(&calls)).await,
client_secret: ClientSecret::Stored(store.secret("hive-alpha", "secret-a")),
@ -1238,7 +1266,7 @@ mod tests {
let url = format!("http://{}/token", listener.local_addr().expect("addr"));
tokio::spawn(async move { axum::serve(listener, app).await });
let store = stored_secret::tests::Fake::holding("secret-b");
let store = Store::holding("secret-b");
let cfg = QueueConfig {
token_endpoint: url,
client_secret: ClientSecret::Stored(store.secret("hive-alpha", "secret-a")),
@ -1261,7 +1289,7 @@ mod tests {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn the_blocking_mint_re_reads_a_refused_secret() {
let calls = Arc::new(AtomicUsize::new(0));
let store = stored_secret::tests::Fake::holding("secret-b");
let store = Store::holding("secret-b");
let cfg = QueueConfig {
token_endpoint: stub_idp("secret-b", Arc::clone(&calls)).await,
client_secret: ClientSecret::Stored(store.secret("hive-alpha", "secret-a")),

View file

@ -15,8 +15,8 @@ use std::path::PathBuf;
use anyhow::{Context, Result, bail};
use futures_util::StreamExt as _;
use swarm_queue_client::review_classifier::{QUEUE_GROUP, Reply, SUBJECT, TryOut};
use swarm_queue_client::stored_secret::StoredSecret;
use swarm_queue_client::{ClientSecret, QueueConfig};
use swarm_secret_client::stored_secret::StoredSecret;
mod acp;
mod aggregate;

View file

@ -4,6 +4,9 @@ version.workspace = true
edition.workspace = true
[dependencies]
# `Shared`, so every caller refused while a secret re-read runs waits on that
# one read (`stored_secret`).
futures-util.workspace = true
# `Identity` is re-exported by vaultrs, but the identity is built here (from
# the `BAO_*` files) rather than by its env defaults, so the dependency is
# direct rather than incidental.
@ -21,6 +24,9 @@ serde.workspace = true
serde_json.workspace = true
strum.workspace = true
thiserror.workspace = true
# `stored_secret` spawns its re-reads and times them out.
tokio.workspace = true
tracing.workspace = true
vaultrs.workspace = true
[lints]
@ -31,3 +37,9 @@ workspace = true
# test would pass with `install_crypto_provider` doing nothing.
[dev-dependencies]
rustls = { workspace = true, features = ["ring"] }
# `test-util` for `#[tokio::test(start_paused = true)]`: the re-read backoff
# tests run on paused time.
tokio = { workspace = true, features = ["test-util"] }
# Capturing what a re-read logs, to check it names the client and never the
# secret.
tracing-subscriber.workspace = true

View file

@ -44,6 +44,7 @@ pub mod path;
pub mod policy;
pub mod queue;
pub mod review_classifier;
pub mod stored_secret;
pub use client::SecretStore;

View file

@ -191,7 +191,7 @@ impl Inner {
return;
}
Ok(Ok(_)) => "the stored client secret is the one the IdP refused".to_owned(),
Ok(Err(e)) => format!("could not re-read the client secret: {}", crate::chain(&*e)),
Ok(Err(e)) => format!("could not re-read the client secret: {}", chain(&*e)),
Err(_) => format!("re-reading the client secret timed out after {REREAD_TIMEOUT:?}"),
};
rereads.fruitless = rereads.fruitless.saturating_add(1);
@ -205,6 +205,18 @@ impl Inner {
}
}
/// `error` and its sources, joined with `: `.
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
}
/// Whether an `IdP` answering `status` refused the client's credential (400 or
/// 401), the one answer a re-read can fix. A 429 or a 5xx says nothing about
/// the secret, and re-reading on one would spend the backoff on an outage.