swarm-controller reads its forge token from the store, not a host file
The controller's forge token never entered bao: the forge minted it in its container, hive-forge-swarm-controller-token copied it to a host file, and the controller LoadCredential'ed that file. forgeTokenFile defaulted to that path only where the forge ran, so a controller on any other host had no forge access unless an operator copied the file by hand. Now the forge's host publishes it and the controller reads it: - swarm-bao-forge-publisher (replaces hive-forge-swarm-controller-token) waits for the in-container token, logs in with its own forge-publisher leaf and writes it to swarm/controller/swarm-controller/forge/token. It runs on every forge container start, the old unit's trigger. - New principal swarm-forge-publisher: leaf minted by glue-bao-tls.nix, policy create/update on that one path, written by swarm-bao-forge-publisher-policy, CN reserved as a hive name. - The controller policy gains read on that path. swarm-controller reads it at start under its own certificate with the OIDC secret's retry, and runs without forge access if it still cannot. - forgeTokenFile, its LoadCredential and the forgeEnv gate are gone; SWARM_CONTROLLER_FORGE_URL is always set. hostSwarmControllerTokenFile is removed, and a tmpfiles rule deletes the old host copy on the forge host. The in-container file stays: the mint unit's only-once guard reads it, and it is the publisher's source. Refs #4998
This commit is contained in:
parent
c433bdef92
commit
121c4678aa
18 changed files with 580 additions and 277 deletions
|
|
@ -4,16 +4,15 @@
|
|||
//! calls, is premature plumbing; factor out a shared crate later if the
|
||||
//! duplication actually starts to hurt).
|
||||
//!
|
||||
//! Reads its identity from the credentials `swarm-controller.nix`'s
|
||||
//! `forgeEnv` wires in (`SWARM_CONTROLLER_FORGE_URL` +
|
||||
//! `SWARM_CONTROLLER_FORGE_TOKEN_FILE`, the latter pointing at a
|
||||
//! systemd `LoadCredential`-delivered file) — see that module and
|
||||
//! `hive-forge/default.nix`'s `forgejo-swarm-controller-account` /
|
||||
//! `hive-forge-swarm-controller-token` units for how the token itself
|
||||
//! gets minted and delivered. Absent env (forge not configured on this
|
||||
//! host) means [`Client::from_env`] returns `None` — every caller
|
||||
//! treats that as "no forge access here" and continues, the same
|
||||
//! graceful-absence shape the queue coordinates already use.
|
||||
//! The forge's URL comes from `swarm-controller.nix`'s `forgeEnv`
|
||||
//! (`SWARM_CONTROLLER_FORGE_URL`). The token comes from the swarm's secret
|
||||
//! store, at `swarm_secret_client::forge::controller_token_path`, read once at
|
||||
//! start under this daemon's own certificate and held only in memory. The
|
||||
//! forge mints it inside its container (`hive-forge/default.nix`'s
|
||||
//! `forgejo-swarm-controller-account`) and `swarm-bao-forge-publisher`, on the
|
||||
//! forge's host, puts it there. An unset URL means [`Client::from_env`]
|
||||
//! returns `None` — every caller treats that as "no forge access here" and
|
||||
//! continues, the same graceful-absence shape the queue coordinates use.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use forgejo_api::structs::{
|
||||
|
|
@ -130,12 +129,30 @@ const KNOWLEDGE_REPO: &str = "knowledge";
|
|||
/// admin bypass, without adding a `git` shell-out to this daemon.
|
||||
const SWARM_CONTROLLER_FORGE_USER: &str = "swarm-controller";
|
||||
|
||||
/// The env vars `swarm-controller.nix`'s `forgeEnv` sets. Named here
|
||||
/// once so [`Client::from_env`] and the nix module can't drift silently
|
||||
/// — a rename on one side without the other fails loudly (env var
|
||||
/// absent) rather than silently losing forge access.
|
||||
/// The env var `swarm-controller.nix`'s `forgeEnv` sets. Named here once so
|
||||
/// [`Client::from_env`] and the nix module can't drift silently.
|
||||
const URL_ENV: &str = "SWARM_CONTROLLER_FORGE_URL";
|
||||
const TOKEN_FILE_ENV: &str = "SWARM_CONTROLLER_FORGE_TOKEN_FILE";
|
||||
|
||||
/// One login and one read. An empty value is refused like an absent one: it
|
||||
/// would reach the forge as the token and come back as a 401 on the first
|
||||
/// call, which names neither the store nor the publisher.
|
||||
async fn fetch_token() -> Result<String> {
|
||||
let path = swarm_secret_client::forge::controller_token_path()?;
|
||||
let store = crate::store::connect()
|
||||
.await
|
||||
.context("logging in to the swarm secret store")?;
|
||||
let stored: Option<swarm_secret_client::forge::ControllerToken> = store
|
||||
.read_optional(&path)
|
||||
.await
|
||||
.with_context(|| format!("reading {path}"))?;
|
||||
match stored {
|
||||
Some(t) if !t.value.trim().is_empty() => Ok(t.value.trim().to_owned()),
|
||||
_ => anyhow::bail!(
|
||||
"nothing at {path}: swarm-bao-forge-publisher, on the host that runs the forge, has \
|
||||
not published this daemon's forge token"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Typed forgejo client, built once at startup from env.
|
||||
pub struct Client {
|
||||
|
|
@ -147,38 +164,29 @@ pub struct Client {
|
|||
}
|
||||
|
||||
impl Client {
|
||||
/// Build from env. `Ok(None)` (not an error) when neither env var is
|
||||
/// set — forge isn't configured on this host, which is a normal,
|
||||
/// expected deployment shape (see the module doc comment), not a
|
||||
/// failure. `Err` only for a genuine misconfiguration: one of the
|
||||
/// two env vars present without the other, the token file unreadable,
|
||||
/// or the URL/client failing to build — those ARE failures, because
|
||||
/// they mean forge was *meant* to be configured here and isn't
|
||||
/// usable, which should be loud rather than silently downgrading to
|
||||
/// "no forge access."
|
||||
pub fn from_env() -> Result<Option<Self>> {
|
||||
let url = std::env::var(URL_ENV).ok();
|
||||
let token_file = std::env::var(TOKEN_FILE_ENV).ok();
|
||||
let (url, token_file) = match (url, token_file) {
|
||||
(None, None) => return Ok(None),
|
||||
(Some(url), Some(token_file)) => (url, token_file),
|
||||
(Some(_), None) => {
|
||||
anyhow::bail!("{URL_ENV} is set but {TOKEN_FILE_ENV} is not — partial forge config")
|
||||
}
|
||||
(None, Some(_)) => {
|
||||
anyhow::bail!("{TOKEN_FILE_ENV} is set but {URL_ENV} is not — partial forge config")
|
||||
}
|
||||
/// Build from env and the secret store. `Ok(None)` (not an error) when
|
||||
/// `SWARM_CONTROLLER_FORGE_URL` is unset — no forge is configured for this
|
||||
/// daemon (see the module doc comment).
|
||||
///
|
||||
/// # Errors
|
||||
/// An unparseable URL, or a token the store still would not give up after
|
||||
/// `queue_identity::RETRY_DELAYS_S`: the store refusing the login, or
|
||||
/// nothing at the path because the forge's host has not published it.
|
||||
pub async fn from_env() -> Result<Option<Self>> {
|
||||
let Some(url) = std::env::var(URL_ENV).ok() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let token = std::fs::read_to_string(&token_file)
|
||||
.with_context(|| format!("read forge token from {token_file}"))?;
|
||||
let token = token.trim();
|
||||
if token.is_empty() {
|
||||
anyhow::bail!("forge token file {token_file} is empty");
|
||||
}
|
||||
let parsed_url =
|
||||
url::Url::parse(&url).with_context(|| format!("parse {URL_ENV} ({url}) as a URL"))?;
|
||||
let api =
|
||||
Forgejo::new(Auth::Token(token), parsed_url.clone()).context("build forgejo client")?;
|
||||
let delays = crate::queue_identity::RETRY_DELAYS_S.map(std::time::Duration::from_secs);
|
||||
let token = crate::queue_identity::fetch_with_retry(
|
||||
&delays,
|
||||
"the controller's forge token",
|
||||
fetch_token,
|
||||
)
|
||||
.await?;
|
||||
let api = Forgejo::new(Auth::Token(&token), parsed_url.clone())
|
||||
.context("build forgejo client")?;
|
||||
Ok(Some(Self {
|
||||
api,
|
||||
url: parsed_url,
|
||||
|
|
@ -1295,30 +1303,29 @@ mod tests {
|
|||
|
||||
// Both scenarios live in ONE test, not two, deliberately: `cargo
|
||||
// test` runs tests in parallel by default, and two tests mutating
|
||||
// the same process-global env vars would race each other. A single
|
||||
// the same process-global env var would race each other. A single
|
||||
// test's body is one thread, so sequential set/clear within it is
|
||||
// race-free without needing a cross-test lock.
|
||||
#[test]
|
||||
fn from_env_reads_both_vars_or_neither() {
|
||||
// SAFETY: test-only mutation of env vars this module itself
|
||||
// owns (`URL_ENV`/`TOKEN_FILE_ENV`), scoped to this one test
|
||||
// function so there's no cross-test race (see comment above).
|
||||
#[tokio::test]
|
||||
async fn from_env_is_none_without_a_url_and_refuses_a_bad_one_before_the_store() {
|
||||
// SAFETY: test-only mutation of the env var this module itself
|
||||
// owns (`URL_ENV`), scoped to this one test function so there's no
|
||||
// cross-test race (see comment above).
|
||||
unsafe {
|
||||
std::env::remove_var(URL_ENV);
|
||||
std::env::remove_var(TOKEN_FILE_ENV);
|
||||
}
|
||||
assert!(Client::from_env().unwrap().is_none());
|
||||
assert!(Client::from_env().await.unwrap().is_none());
|
||||
|
||||
unsafe {
|
||||
std::env::set_var(URL_ENV, "https://forge.example.invalid");
|
||||
std::env::set_var(URL_ENV, "not a url");
|
||||
}
|
||||
// Not `.unwrap_err()`: that needs `Client: Debug` for its panic
|
||||
// message, and the wrapped `forgejo_api::Forgejo` doesn't derive
|
||||
// it. Match directly instead of adding a `Debug` impl nothing
|
||||
// else needs.
|
||||
match Client::from_env() {
|
||||
Err(e) => assert!(e.to_string().contains(TOKEN_FILE_ENV)),
|
||||
Ok(_) => panic!("expected an error for a partial forge config"),
|
||||
// it. Returning at once, with no `BAO_*` set, is what shows the URL
|
||||
// is checked before the store is asked.
|
||||
match Client::from_env().await {
|
||||
Err(e) => assert!(e.to_string().contains(URL_ENV), "{e:#}"),
|
||||
Ok(_) => panic!("expected an error for an unparseable forge URL"),
|
||||
}
|
||||
|
||||
unsafe {
|
||||
|
|
|
|||
Loading…
Reference in a new issue