swarm-controller now gives every hive in its directory a forge account, `hive-<hive>`, in a new `hives` team of `agent-configs` (read on `repo.code` + `repo.pulls`, every repo including later ones), and mints one `read:repository` token named `swarm-hive` into the store at `swarm/hives/<hive>/forge/token`. A five-minute pass queues a `MintHiveForgeToken` node for a hive whose token is missing or no longer matches the forge, whose account is missing, or whose team membership or team settings drifted. A hive whose store or forge read failed is never queued, so an outage cannot rotate every hive's token. The token decision reuses `agent_token`'s classifier and replace step, now parameterised by name and scopes. An agent may be called `hive-<x>`. The hive account is created with `hive-<hive>@hives.hyperhive.local` and the job refuses an account by that name with any other email before it locks it down, adds it to the team or mints for it; agent creation already refuses the hive's account by the same email check. The controller's bao policy gains the `swarm/hives/+/forge/token` leaf (create/read/update), pinned by the bao-grants module-eval assertion. The hive's own policy already reads `swarm/hives/<hive>/*`. hive-c0re reads that token through `forge::reader_token()`, falling back to the core token file when the store holds none, and uses it for: fetching a merged config rev (swarm deploy, `hivectl forge reconcile-config`), reseeding a purged `applied`, the meta-update cascade's fast-forward, seeding `proposed` on first deploy, and finding the PR that merged a refused rev. Those reads no longer require a local `hive-forge` container. The `/knowledge` clone moves from the forge host's boot sweep into the knowledge pull, so every hive clones it. Posting the deploy-failure comment on that PR stays on the core token. Refs #4851
159 lines
5 KiB
Rust
159 lines
5 KiB
Rust
//! The credential this hive reads the forge with: its own read-only account,
|
|
//! whose token `swarm-controller` mints into the store at
|
|
//! `swarm/hives/<hive>/forge/token`, and the core token file where the store
|
|
//! holds none.
|
|
//!
|
|
//! The file is the fallback so a hive deployed before the controller mints its
|
|
//! token keeps the reads it had: the forge host holds the file, no other hive
|
|
//! does.
|
|
|
|
use super::{basic_auth_header, core_token};
|
|
|
|
/// The basic-auth user [`ReaderToken::auth_header`] names for the core token.
|
|
const CORE_LOGIN: &str = "core";
|
|
|
|
/// A forge token and the account it belongs to.
|
|
///
|
|
/// No `Debug` derive: `value` is a live forge credential.
|
|
pub struct ReaderToken {
|
|
login: String,
|
|
value: String,
|
|
}
|
|
|
|
impl std::fmt::Debug for ReaderToken {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.debug_struct("ReaderToken")
|
|
.field("login", &self.login)
|
|
.field("value", &"<redacted>")
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
impl ReaderToken {
|
|
/// The token itself, for an API client.
|
|
pub fn value(&self) -> &str {
|
|
&self.value
|
|
}
|
|
|
|
/// The `http.extraHeader` value for git; see [`basic_auth_header`].
|
|
pub fn auth_header(&self) -> String {
|
|
basic_auth_header(&self.login, &self.value)
|
|
}
|
|
}
|
|
|
|
/// The token to read the forge with, or `None` when this hive has neither.
|
|
///
|
|
/// 🩸 Logs the store path and which source won, never a value.
|
|
pub async fn reader_token() -> Option<ReaderToken> {
|
|
pick(stored_reader_token().await, core_token())
|
|
}
|
|
|
|
/// The store's token wins over the core file's.
|
|
fn pick(stored: Option<ReaderToken>, core: Option<String>) -> Option<ReaderToken> {
|
|
let picked = stored.or_else(|| {
|
|
core.map(|value| ReaderToken {
|
|
login: CORE_LOGIN.to_owned(),
|
|
value,
|
|
})
|
|
});
|
|
if let Some(t) = &picked {
|
|
tracing::debug!(login = %t.login, "forge: reading as this account");
|
|
} else {
|
|
tracing::debug!("forge: no read token in the store or the core token file");
|
|
}
|
|
picked
|
|
}
|
|
|
|
/// This hive's token from the store, under the hive's own identity.
|
|
///
|
|
/// `None`, never an error, for every way it can come up empty — no hive name,
|
|
/// no `BAO_*` identity, an unreachable store, nothing at the path — the same
|
|
/// as `matrix::stored_sender_token`, and for the same reason: each sends the
|
|
/// caller to the fallback.
|
|
async fn stored_reader_token() -> Option<ReaderToken> {
|
|
let hive = std::env::var("HYPERHIVE_HIVE_NAME")
|
|
.ok()
|
|
.filter(|h| !h.is_empty())?;
|
|
let path = match swarm_secret_client::forge::hive_token_path(&hive) {
|
|
Ok(path) => path,
|
|
Err(e) => {
|
|
tracing::warn!(error = %e, "forge: this hive's name does not form a store path");
|
|
return None;
|
|
}
|
|
};
|
|
let store = match swarm_secret_client::SecretStore::from_env(&hive).await {
|
|
Ok(store) => store,
|
|
Err(e) => {
|
|
tracing::debug!(error = %e, "forge: no swarm secret store to read the hive token from");
|
|
return None;
|
|
}
|
|
};
|
|
match store
|
|
.read::<swarm_secret_client::forge::Credential>(&path)
|
|
.await
|
|
{
|
|
Ok(credential) if !credential.value.trim().is_empty() => Some(ReaderToken {
|
|
login: swarm_secret_client::forge::hive_login(&hive),
|
|
value: credential.value.trim().to_owned(),
|
|
}),
|
|
Ok(_) => {
|
|
tracing::warn!(%path, "forge: the stored hive token is empty");
|
|
None
|
|
}
|
|
Err(e) => {
|
|
tracing::debug!(%path, error = %e, "forge: no hive token in the store");
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn stored() -> ReaderToken {
|
|
ReaderToken {
|
|
login: "hive-alpha".to_owned(),
|
|
value: "from-the-store".to_owned(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn the_store_wins_over_the_core_file() {
|
|
let t = pick(Some(stored()), Some("from-the-file".to_owned())).expect("a token");
|
|
assert_eq!(t.value(), "from-the-store");
|
|
assert_eq!(t.login, "hive-alpha");
|
|
}
|
|
|
|
#[test]
|
|
fn the_core_file_is_the_fallback() {
|
|
let t = pick(None, Some("from-the-file".to_owned())).expect("a token");
|
|
assert_eq!(t.value(), "from-the-file");
|
|
assert_eq!(t.login, CORE_LOGIN);
|
|
}
|
|
|
|
#[test]
|
|
fn neither_is_none() {
|
|
assert!(pick(None, None).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn the_header_names_the_tokens_own_account() {
|
|
use base64::Engine as _;
|
|
let header = stored().auth_header();
|
|
let encoded = header
|
|
.strip_prefix("Authorization: Basic ")
|
|
.expect("a basic auth header");
|
|
let decoded = base64::engine::general_purpose::STANDARD
|
|
.decode(encoded)
|
|
.expect("base64");
|
|
assert_eq!(decoded, b"hive-alpha:from-the-store");
|
|
}
|
|
|
|
#[test]
|
|
fn debug_never_prints_the_value() {
|
|
let shown = format!("{:?}", stored());
|
|
assert!(!shown.contains("from-the-store"), "{shown}");
|
|
assert!(shown.contains("hive-alpha"), "{shown}");
|
|
}
|
|
}
|