swarm-logs: an agent's CLI for the swarm log store
An agent can reach VictoriaLogs only through the gateway, and since the
machine query route landed the way to read it has been to hand-roll a
client_credentials token request and a curl, per query. This is the CLI
that closes that: `swarm-logs query '<LogsQL>'`, matched log lines on
stdout, so the answer pipes into grep like any other command's.
Built to the plan posted on the tracker thread: own crate, own
docs/tools reference generated off the clap tree, `query` as the one
verb, and the JSON error body surfaced on a non-200 rather than
swallowed. No `tail`: streaming is a different endpoint with a different
response shape, and folding it in here would be a fatter scope than the
ask.
Minting the token is NOT implemented here — swarm-queue-client already
owns the client_credentials request, its error type and its CA handling,
and a token-endpoint fix has to be findable in one place. What this crate
adds is the agent-shaped half: the client id arrives as a *file* beside
the secret, so nothing outside nix/agent-modules/queue.nix spells
`hive-<name>-agent` twice. That is the same problem hive-agent's
swarm_queue module solves, and swarm-logs/src/auth.rs is its `decide`
restated over this binary's inputs.
⚠️ The plan named one thing to verify empirically before calling the auth
settled: whether authelia's bearer policy for the logs vhost accepts the
agent client's audience. Measured from inside a container: it does not.
The client minted a token fine but with `aud: []` and `scp: []`, asking
for the logs URL as an audience answered `invalid_target`, and presenting
the audience-less token to the gateway answered a bare 401. So
swarm-authelia.nix's agentClients gains `authelia.bearer.authz` and the
query URL as a second audience — authelia authorises a bearer token by
the URL being requested, and that URL is now one binding read by three
places rather than three spellings of one address.
The URL reaches an agent the same way its queue coordinates do: computed
on the host (a container cannot derive a gateway address), forwarded by
hive_c0re::meta into the container's option set, and consumed by a new
agent module that installs the binary *wrapped* with its coordinates —
the shape swarm-controller.nix installs swarmctl in. Gated on the queue
credential as well as on the URL: a binary that can only answer 401 is
worse than no binary, because an agent reads a 401 as "no logs", which is
the exact confusion the store's machine route was added to end.
This commit is contained in:
parent
2186b82485
commit
a39399f037
18 changed files with 939 additions and 5 deletions
188
swarm-logs/src/auth.rs
Normal file
188
swarm-logs/src/auth.rs
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
//! 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::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
|
||||
/// [`QueueConfig::from_env`] 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, exactly as in
|
||||
// `QueueConfig::from_env`: 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 swarm 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_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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue