hive-c0re publishes this hive's status to the swarm queue as the OIDC client `hive-<name>`. Its secret came from `deploy.hive-controller.statusPublish.clientSecretFile`, which defaulted to authelia's minted file only on a host running both authelia and the queue. Every other hive had no secret and never published, so the controller showed it `never_reported`. Now `swarm-secret-publish`, beside authelia, also copies each hive's `hive-<name>` secret to `swarm/hives/<hive>/oidc/client`. Its policy already allows `swarm/hives/*`. hive-c0re reads that path under the hive's own store leaf, which the existing `swarm/hives/<hive>/*` grant covers, and holds the value in memory. The shared queue client no longer remembers a failed first connect for the life of the process. A failed attempt is retried with a backoff from 5s, doubling to a cap of 5 minutes; the dashboard banner stays up until an attempt succeeds. The two status publishers wait for the client instead of giving up on the first `None`, so a secret published after boot is picked up without a restart. Removed: the `statusPublish.clientSecretFile` option (both spellings, now removed options with a message), its same-host default, its LoadCredential, the assertion tying it to the other two coordinates, and `swarm_queue_client::QueueConfig::from_env`, whose only caller was this read. Publishing is now on wherever the queue URL and token endpoint are set and the hive holds a store leaf. Refs #5015
187 lines
8.7 KiB
Rust
187 lines
8.7 KiB
Rust
//! The access token this CLI presents to the log store's gateway route.
|
|
//!
|
|
//! Minting it is **not** implemented here. `swarm-queue-client` already owns
|
|
//! the `client_credentials` request against authelia — the basic-auth form,
|
|
//! the per-call secret read, the CA-trust builder and the error type that
|
|
//! surfaces authelia's own `error_description` — and this crate calls
|
|
//! [`swarm_queue_client::mint_token_for_blocking`] rather than writing a
|
|
//! second copy of it. A token-endpoint fix has to be findable in one place.
|
|
//!
|
|
//! What *is* here is the shape of an agent's identity, which differs from the
|
|
//! daemons that crate was written for in exactly one way: **the client id
|
|
//! arrives as a file, not as a value.** It rides in the same systemd
|
|
//! credential pair as the secret so that nothing outside `queue.nix` ever
|
|
//! spells `hive-<name>-agent` again. That is the same problem
|
|
//! `hive-agent/src/swarm_queue.rs` solves, and [`Config::from_env`] below is
|
|
//! that module's `decide` restated over this binary's inputs — including its
|
|
//! rule that a *half*-set environment is a deployment bug rather than an
|
|
//! absent integration.
|
|
//!
|
|
//! 🩸 Every credential in here is a **path**. The secret's contents are read
|
|
//! by `swarm-queue-client` at the moment of the request and are never bound to
|
|
//! a name in this crate, never logged and never rendered into an error.
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use anyhow::{Context as _, Result, bail};
|
|
use swarm_queue_client::{ClientSecret, QueueConfig};
|
|
|
|
/// Variable prefix for this binary's coordinates.
|
|
///
|
|
/// `HIVE_AGENT`, deliberately the **same** prefix the harness reads, because
|
|
/// it is the same identity: an agent authenticates as its hive's agent client
|
|
/// whether the caller is the harness or a CLI the agent typed. A prefix of its
|
|
/// own would be a second set of variables carrying one fact, which is the
|
|
/// disagreement `queue.nix`'s credential pair exists to prevent.
|
|
const ENV_PREFIX: &str = "HIVE_AGENT";
|
|
|
|
/// Where this CLI queries, and who it queries as.
|
|
pub struct Config {
|
|
/// Full URL of the LogsQL query endpoint, gateway side.
|
|
///
|
|
/// The whole URL rather than a host to build one from, because this same
|
|
/// string is **also the audience** the token is minted for — the rule
|
|
/// `swarm-otel.nix` states over its own push targets ("one binding
|
|
/// because the same string is both the address requested and the audience
|
|
/// the token is minted for — two spellings present as a valid token
|
|
/// refused at the store"). Splitting it would reintroduce exactly that.
|
|
pub query_url: String,
|
|
/// Everything needed to mint a token, in the form the minter wants it.
|
|
pub queue: QueueConfig,
|
|
}
|
|
|
|
impl Config {
|
|
/// Read the configuration out of the environment the wrapper sets.
|
|
///
|
|
/// All four coordinates or none, for the reason `swarm-queue-client`'s
|
|
/// README gives and `hive-agent`'s `swarm_queue` repeats: a half-set
|
|
/// environment produces a process that looks configured and never
|
|
/// authenticates. Unlike the harness, this binary *errors* on the absent
|
|
/// case rather than degrading — a CLI whose only
|
|
/// job is the query has nothing left to do without it, and an operator
|
|
/// running it wants to be told why rather than handed an empty result
|
|
/// they will read as "no logs matched".
|
|
pub fn from_env() -> Result<Self> {
|
|
let var = |suffix: &str| std::env::var(format!("{ENV_PREFIX}_{suffix}")).ok();
|
|
|
|
let query_url = var("LOGS_QUERY_URL");
|
|
let token_endpoint = var("OIDC_TOKEN_ENDPOINT");
|
|
let client_id_file = var("OIDC_CLIENT_ID_FILE");
|
|
let client_secret_file = var("OIDC_CLIENT_SECRET_FILE");
|
|
|
|
// Outside the all-or-none group on purpose: a swarm behind a
|
|
// publicly-trusted certificate needs no extra anchor, so a CA path
|
|
// with no endpoint is meaningless rather than half-configured. Nothing in this tree sets
|
|
// it for an agent — a container already trusts the store's root, which
|
|
// `hive_c0re::meta` embeds at build time.
|
|
let ca_file = var("OIDC_CA_FILE").map(PathBuf::from);
|
|
|
|
let (query_url, token_endpoint, client_id_file, client_secret_file) = match (
|
|
query_url,
|
|
token_endpoint,
|
|
client_id_file,
|
|
client_secret_file,
|
|
) {
|
|
(Some(u), Some(t), Some(i), Some(s)) => (u, t, i, s),
|
|
(None, None, None, None) => bail!(
|
|
"this agent has no swarm log store configured: \
|
|
{ENV_PREFIX}_LOGS_QUERY_URL, {ENV_PREFIX}_OIDC_TOKEN_ENDPOINT, \
|
|
{ENV_PREFIX}_OIDC_CLIENT_ID_FILE and \
|
|
{ENV_PREFIX}_OIDC_CLIENT_SECRET_FILE are all unset"
|
|
),
|
|
_ => bail!(
|
|
"swarm log store half-configured: {ENV_PREFIX}_LOGS_QUERY_URL, \
|
|
{ENV_PREFIX}_OIDC_TOKEN_ENDPOINT, {ENV_PREFIX}_OIDC_CLIENT_ID_FILE \
|
|
and {ENV_PREFIX}_OIDC_CLIENT_SECRET_FILE are set together or not \
|
|
at all — this is a deployment bug, not a missing feature"
|
|
),
|
|
};
|
|
|
|
let client_id = read_client_id(Path::new(&client_id_file))?;
|
|
|
|
Ok(Self {
|
|
query_url,
|
|
queue: QueueConfig {
|
|
// 🩸 Unused by the token mint and deliberately not read from
|
|
// the environment. `QueueConfig` is the queue's connect
|
|
// config as well as its token config, and this binary needs
|
|
// only the second half — taking `HIVE_AGENT_NATS_URL` for it
|
|
// would make a broker address a prerequisite for reading
|
|
// logs, which is a coupling with no cause. Named so a reader
|
|
// of a stack trace is not left wondering.
|
|
url: String::from("unused: swarm-logs mints a token and never connects"),
|
|
token_endpoint,
|
|
client_id,
|
|
client_secret: ClientSecret::File(PathBuf::from(client_secret_file)),
|
|
ca_file,
|
|
},
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Read the OIDC client id out of the file the systemd credential landed at.
|
|
///
|
|
/// Trailing newline stripped and an empty file refused, both copied from
|
|
/// `hive-agent`'s `read_client_id`: the writing unit ends the file with a
|
|
/// newline, and an id carrying one authenticates as nobody. Where the harness
|
|
/// treats absence as the ordinary state of a hive whose credential has not
|
|
/// been published yet and carries on, this errors — see [`Config::from_env`]
|
|
/// for why a CLI has nothing to carry on with.
|
|
fn read_client_id(path: &Path) -> Result<String> {
|
|
let raw = std::fs::read_to_string(path)
|
|
.with_context(|| format!("reading the OIDC client id from {}", path.display()))?;
|
|
let id = raw.trim();
|
|
if id.is_empty() {
|
|
bail!(
|
|
"the OIDC client id at {} is empty — the queue credential has not \
|
|
been published to this hive yet",
|
|
path.display()
|
|
);
|
|
}
|
|
Ok(id.to_owned())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn client_id_loses_its_trailing_newline() {
|
|
let dir = std::env::temp_dir().join(format!("swarm-logs-id-{}", std::process::id()));
|
|
std::fs::create_dir_all(&dir).expect("temp dir");
|
|
let path = dir.join("client-id");
|
|
std::fs::write(&path, "hive-alpha-agent\n").expect("write");
|
|
assert_eq!(read_client_id(&path).expect("read"), "hive-alpha-agent");
|
|
std::fs::remove_dir_all(&dir).expect("cleanup");
|
|
}
|
|
|
|
/// An empty credential file is the shape a hive has before its secret
|
|
/// store has published anything, and it must not become a token request
|
|
/// that authenticates as the empty string.
|
|
#[test]
|
|
fn an_empty_client_id_is_refused_by_name() {
|
|
let dir = std::env::temp_dir().join(format!("swarm-logs-empty-{}", std::process::id()));
|
|
std::fs::create_dir_all(&dir).expect("temp dir");
|
|
let path = dir.join("client-id");
|
|
std::fs::write(&path, "\n").expect("write");
|
|
let err = read_client_id(&path).expect_err("empty id must not be accepted");
|
|
assert!(
|
|
err.to_string().contains("is empty"),
|
|
"error should say the id is empty, got: {err}"
|
|
);
|
|
std::fs::remove_dir_all(&dir).expect("cleanup");
|
|
}
|
|
|
|
/// A missing file names the path, because the operator's next move is to
|
|
/// look at whether the credential was delivered at all.
|
|
#[test]
|
|
fn a_missing_client_id_file_names_its_path() {
|
|
let path = Path::new("/nonexistent/swarm-logs/client-id");
|
|
let err = read_client_id(path).expect_err("missing file must not be accepted");
|
|
assert!(
|
|
err.to_string()
|
|
.contains("/nonexistent/swarm-logs/client-id"),
|
|
"error should name the path, got: {err}"
|
|
);
|
|
}
|
|
}
|