Watch
0
0
Fork
You've already forked hyperhive
0

add swarm-metrics, an agent CLI for the metrics store

swarm-metrics reads the swarm's VictoriaMetrics from inside an agent
container: query, query-range, series, labels and label-values, each one
GET against the store's Prometheus read API through the gateway, printing
the store's JSON. It authenticates the way swarm-logs does, with the
agent's own queue secret read from bao under the agent's leaf, sent as
a swarm-agent bearer.

On the metrics store's host the read API's auth map gains an agent arm
ahead of the Grafana one. It routes to swarm-metrics-agent-auth, a
swarm-logs-auth instance logged in under the existing swarm-metrics-auth
leaf, whose bao grant now includes swarm/agents/+/queue read. The check is
rate-limited per address like the log store's
(deploy.victoriametrics.agentAuth{Rate,Burst,VerdictTtl}).

nix/agent-modules/metrics.nix installs the wrapped binary when the hive
forwards HIVE_AGENT_METRICS_URL (from swarm.victoriametrics.domain) and a
store address, the same gate as swarm-logs. Adds the base:swarm-metrics
skill and the CLI reference with its freshness check.

Fixes #5395
This commit is contained in:
atlas 2026-10-17 00:55:46 +02:00 • committed by mara
commit e92f118bda
30 changed files with 1417 additions and 88 deletions

149
swarm-metrics/src/auth.rs Normal file
View file

@ -0,0 +1,149 @@
//! The credential this CLI presents to the metrics store's gateway: the
//! agent's own queue secret, read from the swarm secret store with the agent's
//! store certificate.
//!
//! The same read and the same bearer as `swarm-logs`' `auth` module: path
//! `swarm/agents/<agent>/queue`, the agent's cert-auth role, and
//! `Bearer swarm-agent.<agent>.<secret>` spelled by
//! `swarm_queue_client::agent_token`. On the metrics store's host a
//! `swarm-logs-auth` instance checks it against the store.
//!
//! 🩸 The secret is never logged and never rendered into an error.
use anyhow::{Context as _, Result, bail};
use swarm_secret_client::client::{DEFAULT_CERT_MOUNT, Settings};
use swarm_secret_client::queue::{self, AgentCredential};
use swarm_secret_client::{SecretStore, policy};
/// The metrics store's gateway origin; the API paths are appended to it.
const ENV_URL: &str = "HIVE_AGENT_METRICS_URL";
/// This agent's name, which the store path and the role are built from.
const ENV_AGENT_NAME: &str = "HIVE_AGENT_NAME";
/// The store's address; the rest of its coordinates are `Settings`'s.
const ENV_ADDR: &str = "BAO_ADDR";
/// The store's CA bundle, which may name an undelivered credential.
const ENV_CACERT: &str = "BAO_CACERT";
/// Where this CLI queries, and who it queries as.
pub struct Config {
/// The metrics store's gateway origin, e.g. `https://metrics.example`.
pub base_url: String,
agent: String,
settings: Settings,
role: String,
path: String,
}
impl Config {
/// Read the configuration out of the environment the wrapper sets.
///
/// Errors rather than degrading: an empty result would read as "no such
/// series".
pub fn from_env() -> Result<Self> {
Self::from_lookup(|k| std::env::var(k).ok())
}
fn from_lookup(get: impl Fn(&str) -> Option<String>) -> Result<Self> {
let set = |k: &str| get(k).filter(|v| !v.is_empty());
let Some(base_url) = set(ENV_URL) else {
bail!("this agent has no swarm metrics store configured: {ENV_URL} is unset");
};
if set(ENV_ADDR).is_none() {
bail!(
"{ENV_URL} is set but {ENV_ADDR} is not, so there is no secret store \
to read this agent's credential from — this is a deployment bug"
);
}
let agent =
set(ENV_AGENT_NAME).with_context(|| format!("{ENV_AGENT_NAME} is unset or empty"))?;
let settings =
Settings::from_lookup(|k| set(k).filter(|v| k != ENV_CACERT || ca_is_usable(v)))
.context("reading the swarm secret store's coordinates from the environment")?;
Ok(Self {
base_url,
role: policy::agent_object_name(&agent)?,
path: queue::agent_queue_path(&agent)?,
agent,
settings,
})
}
/// Read this agent's secret from the store and spell it as the bearer.
///
/// Read on every call, so a caller that was refused can call again and
/// present a secret re-minted since.
pub fn bearer(&self) -> Result<String> {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.context("starting the runtime for the secret store read")?;
let stored: Option<AgentCredential> = runtime.block_on(async {
SecretStore::connect(&self.settings, &self.role, DEFAULT_CERT_MOUNT)
.await
.context("logging in to the swarm secret store with this agent's certificate")?
.read_optional(&self.path)
.await
.with_context(|| format!("reading {}", self.path))
})?;
let stored = stored.with_context(|| format!("nothing is stored at {}", self.path))?;
swarm_queue_client::agent_token::format_agent_token(&self.agent, &stored.value)
.with_context(|| format!("spelling the credential stored at {}", self.path))
}
}
/// Whether the CA bundle at `path` is a file with bytes in it. `BAO_CACERT`
/// names a systemd credential that may not have been delivered; absent means
/// the container's own trust store.
fn ca_is_usable(path: &str) -> bool {
std::fs::metadata(path).is_ok_and(|m| m.len() > 0)
}
#[cfg(test)]
mod tests {
use super::*;
fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
let pairs: Vec<(String, String)> = pairs
.iter()
.map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
.collect();
move |k| pairs.iter().find(|(n, _)| n == k).map(|(_, v)| v.clone())
}
const FULL: &[(&str, &str)] = &[
(ENV_URL, "https://metrics.example"),
(ENV_AGENT_NAME, "atlas"),
(ENV_ADDR, "https://bao.example:8200"),
("BAO_CLIENT_CERT", "/nonexistent/cert"),
("BAO_CLIENT_KEY", "/nonexistent/key"),
];
#[test]
fn no_url_says_there_is_no_metrics_store() {
let err = Config::from_lookup(env(&[])).err().expect("refused");
assert!(err.to_string().contains("no swarm metrics store"), "{err}");
}
#[test]
fn a_url_without_a_store_is_a_deployment_bug() {
let err = Config::from_lookup(env(&FULL[..2])).err().expect("refused");
assert!(err.to_string().contains("deployment bug"), "{err}");
}
#[test]
fn a_missing_agent_name_is_named() {
let mut pairs = FULL.to_vec();
pairs.retain(|(k, _)| *k != ENV_AGENT_NAME);
let err = Config::from_lookup(env(&pairs)).err().expect("refused");
assert!(err.to_string().contains(ENV_AGENT_NAME), "{err}");
}
/// The path and role are the agent's own, the ones its policy grants.
#[test]
fn the_read_is_the_agents_own_queue_secret() {
let cfg = Config::from_lookup(env(FULL)).expect("configured");
assert_eq!(cfg.base_url, "https://metrics.example");
assert_eq!(cfg.path, "swarm/agents/atlas/queue");
assert_eq!(cfg.role, policy::agent_object_name("atlas").expect("valid"));
}
}

247
swarm-metrics/src/main.rs Normal file
View file

@ -0,0 +1,247 @@
//! `swarm-metrics` — an agent's CLI for the swarm metrics store.
//!
//! The metrics-store sibling of `swarm-logs`: the same credential, the same
//! wrapper shape, and the store's own query language. Each verb is one GET
//! against the store's Prometheus-compatible read API through the
//! gateway, and prints the store's JSON answer unchanged for `jq`.
//!
//! **Read-only.** The verbs map onto `/api/v1/query`, `query_range`,
//! `series`, `labels` and `label/<name>/values`; nothing here writes.
//!
//! ⚠️ **The route applies no scoping.** Any authenticated caller reads the
//! whole swarm's metrics, every hive's.
mod auth;
mod query;
use anyhow::Result;
use clap::{Args, Parser, Subcommand};
use crate::query::{Request, Span};
#[derive(Parser)]
#[command(
name = "swarm-metrics",
version,
about = "query the swarm's metrics store from an agent"
)]
struct Cli {
#[command(subcommand)]
command: Verb,
}
/// Window and cap for the discovery verbs.
#[derive(Args)]
struct SpanArgs {
/// Start of the window: RFC 3339, a unix timestamp, or relative (`-1h`).
///
/// Unset means the store's default: the last day, from 00:00 UTC.
#[arg(long, allow_hyphen_values = true)]
start: Option<String>,
/// End of the window, in the same forms. Unset means now.
#[arg(long, allow_hyphen_values = true)]
end: Option<String>,
/// Return at most this many entries.
#[arg(long)]
limit: Option<u64>,
}
impl From<SpanArgs> for Span {
fn from(a: SpanArgs) -> Self {
Self {
start: a.start,
end: a.end,
limit: a.limit,
}
}
}
#[derive(Subcommand)]
enum Verb {
/// Evaluate a `MetricsQL` (`PromQL`) expression at one instant.
Query {
/// The expression. Quote it: `{`, `|` and `"` are the shell's too.
///
/// OTLP metric names keep their dots, so match them by
/// `{__name__="jobq.nodes"}` rather than as a bare name.
promql: String,
/// The instant: RFC 3339, a unix timestamp, or relative (`-5m`).
/// Unset means now.
#[arg(long, allow_hyphen_values = true)]
time: Option<String>,
},
/// Evaluate an expression over a window, one point per step.
QueryRange {
/// The expression, quoted as for `query`.
promql: String,
/// Start of the window: RFC 3339, a unix timestamp, or relative
/// (`-1h`). Required, so a range is always bounded.
#[arg(long, allow_hyphen_values = true)]
start: String,
/// End of the window, in the same forms. Unset means now.
#[arg(long, allow_hyphen_values = true)]
end: Option<String>,
/// Seconds or a duration (`1m`) between points. Unset means the
/// store's default.
#[arg(long)]
step: Option<String>,
},
/// List the series that match one or more selectors.
Series {
/// A series selector, such as `{"service.name"="nats"}`. Repeatable.
#[arg(required = true)]
matchers: Vec<String>,
#[command(flatten)]
span: SpanArgs,
},
/// List label names, optionally only those on matching series.
Labels {
/// A series selector to narrow to. Repeatable.
#[arg(long = "match")]
matchers: Vec<String>,
#[command(flatten)]
span: SpanArgs,
},
/// List one label's values; `__name__` lists every metric name.
LabelValues {
/// The label name.
name: String,
/// A series selector to narrow to. Repeatable.
#[arg(long = "match")]
matchers: Vec<String>,
#[command(flatten)]
span: SpanArgs,
},
/// Print this CLI's reference as markdown (source of `docs/tools/swarm-metrics-cli.md`).
#[command(hide = true)]
MarkdownDocs,
}
impl Verb {
/// The store request this verb makes; `None` for the local-only verb.
fn request(self) -> Option<Request> {
Some(match self {
Self::Query { promql, time } => Request::Query { promql, time },
Self::QueryRange {
promql,
start,
end,
step,
} => Request::QueryRange {
promql,
start,
end,
step,
},
Self::Series { matchers, span } => Request::Series {
matchers,
span: span.into(),
},
Self::Labels { matchers, span } => Request::Labels {
matchers,
span: span.into(),
},
Self::LabelValues {
name,
matchers,
span,
} => Request::LabelValues {
name,
matchers,
span: span.into(),
},
Self::MarkdownDocs => return None,
})
}
}
fn main() -> Result<()> {
let Some(request) = Cli::parse().command.request() else {
// `show_footer(false)`: same as swarm-logs, the footer is not our prose.
let options = clap_markdown::MarkdownOptions::new().show_footer(false);
print!("{}", clap_markdown::help_markdown_custom::<Cli>(&options));
return Ok(());
};
// Config read only for a store verb: `markdown-docs` runs in a nix check,
// where no credential exists.
swarm_queue_client::install_crypto_provider();
let cfg = auth::Config::from_env()?;
let stdout = std::io::stdout();
query::run(&cfg, &request, &mut stdout.lock())
}
#[cfg(test)]
mod tests {
use super::*;
use clap::CommandFactory as _;
fn parse(args: &[&str]) -> Option<Request> {
Cli::try_parse_from(std::iter::once("swarm-metrics").chain(args.iter().copied()))
.expect("parses")
.command
.request()
}
#[test]
fn the_command_tree_is_well_formed() {
Cli::command().debug_assert();
}
#[test]
fn a_range_query_without_a_start_is_refused() {
assert!(Cli::try_parse_from(["swarm-metrics", "query-range", "up"]).is_err());
}
#[test]
fn series_needs_a_selector() {
assert!(Cli::try_parse_from(["swarm-metrics", "series"]).is_err());
}
#[test]
fn label_values_maps_name_matchers_and_span() {
assert_eq!(
parse(&[
"label-values",
"__name__",
"--match",
"{service.name=\"nats\"}",
"--start",
"-1d",
"--limit",
"5",
]),
Some(Request::LabelValues {
name: "__name__".into(),
matchers: vec!["{service.name=\"nats\"}".into()],
span: Span {
start: Some("-1d".into()),
end: None,
limit: Some(5),
},
})
);
}
#[test]
fn query_range_maps_its_window() {
assert_eq!(
parse(&["query-range", "up", "--start", "-1h", "--step", "1m"]),
Some(Request::QueryRange {
promql: "up".into(),
start: "-1h".into(),
end: None,
step: Some("1m".into()),
})
);
}
#[test]
fn markdown_docs_makes_no_request() {
assert_eq!(parse(&["markdown-docs"]), None);
}
}

355
swarm-metrics/src/query.rs Normal file
View file

@ -0,0 +1,355 @@
//! The one request this binary makes: a read against the metrics store's
//! Prometheus-compatible API on the gateway.
//!
//! The gateway's read locations (`readPaths` in `swarm-victoriametrics.nix`)
//! answer an unauthenticated caller with a bare 401, never a login redirect,
//! so [`run`] treats any non-2xx as a failure with its body rather than as an
//! empty result.
use std::io::Write;
use anyhow::{Context as _, Result, bail};
use reqwest::Url;
use crate::auth::Config;
/// One read against the store, with the store's own parameter names.
#[derive(Debug, PartialEq, Eq)]
pub enum Request {
/// `/api/v1/query`: a `MetricsQL` expression at one instant.
Query {
promql: String,
time: Option<String>,
},
/// `/api/v1/query_range`: a `MetricsQL` expression over a window.
QueryRange {
promql: String,
start: String,
end: Option<String>,
step: Option<String>,
},
/// `/api/v1/series`: the series matching the selectors.
Series { matchers: Vec<String>, span: Span },
/// `/api/v1/labels`: label names.
Labels { matchers: Vec<String>, span: Span },
/// `/api/v1/label/<name>/values`: one label's values.
LabelValues {
name: String,
matchers: Vec<String>,
span: Span,
},
}
/// The window and result cap shared by the discovery endpoints.
#[derive(Debug, Default, PartialEq, Eq)]
pub struct Span {
pub start: Option<String>,
pub end: Option<String>,
pub limit: Option<u64>,
}
/// The full request URL for `request` against the gateway at `base`.
///
/// Pure so every verb's path and parameters are testable without a store.
pub fn url(base: &str, request: &Request) -> Result<Url> {
let mut url = Url::parse(base).with_context(|| format!("{base} is not a metrics store URL"))?;
let mut params: Vec<(&str, &str)> = Vec::new();
let (path, matchers, span): (Vec<&str>, &[String], Option<&Span>) = match request {
Request::Query { promql, time } => {
params.push(("query", promql));
params.extend(time.as_deref().map(|t| ("time", t)));
(vec!["query"], &[], None)
}
Request::QueryRange {
promql,
start,
end,
step,
} => {
params.push(("query", promql));
params.push(("start", start));
params.extend(end.as_deref().map(|t| ("end", t)));
params.extend(step.as_deref().map(|s| ("step", s)));
(vec!["query_range"], &[], None)
}
Request::Series { matchers, span } => (vec!["series"], matchers, Some(span)),
Request::Labels { matchers, span } => (vec!["labels"], matchers, Some(span)),
Request::LabelValues {
name,
matchers,
span,
} => (vec!["label", name, "values"], matchers, Some(span)),
};
params.extend(matchers.iter().map(|m| ("match[]", m.as_str())));
let limit = span.and_then(|s| s.limit).map(|n| n.to_string());
if let Some(span) = span {
params.extend(span.start.as_deref().map(|t| ("start", t)));
params.extend(span.end.as_deref().map(|t| ("end", t)));
params.extend(limit.as_deref().map(|n| ("limit", n)));
}
// `extend` percent-encodes each segment, `/` included.
url.path_segments_mut()
.map_err(|()| anyhow::anyhow!("{base} cannot carry a path"))?
.pop_if_empty()
.extend(["api", "v1"])
.extend(path);
url.query_pairs_mut().extend_pairs(params);
Ok(url)
}
/// Send `request` and copy the store's JSON answer to `out`.
pub fn run(cfg: &Config, request: &Request, out: &mut impl Write) -> Result<()> {
let url = url(&cfg.base_url, request)?;
let http = build_http_client()?;
let send = || -> Result<reqwest::blocking::Response> {
let bearer = cfg
.bearer()
.context("reading this agent's credential for the swarm metrics store")?;
http.get(url.clone())
.bearer_auth(bearer)
.send()
.with_context(|| format!("querying the swarm metrics store at {}", url.path()))
};
let mut response = send()?;
// A 401 can be a secret re-minted between the store read and the check,
// so it is read again and the request sent once more.
if response.status() == reqwest::StatusCode::UNAUTHORIZED {
response = send()?;
}
let status = response.status();
if !status.is_success() {
// nginx answers HTML for an `auth_request` denial, the store answers
// JSON naming the bad expression. Truncated to the first screen.
let body = response.text().unwrap_or_default();
let body = body.trim();
let shown: String = body.chars().take(2000).collect();
bail!(
"the swarm metrics store refused the request ({status}): {shown}{}",
if shown.len() < body.len() {
" […]"
} else {
""
}
);
}
copy_with_newline(&mut response, out)
}
/// Copy `from` to `out` unchanged, ending with exactly one newline when the
/// body did not end in one. Streams: a broad `series` answer can be large.
fn copy_with_newline(from: &mut impl std::io::Read, out: &mut impl Write) -> Result<()> {
let mut buf = [0u8; 8192];
let mut last = b'\n';
loop {
let n = from
.read(&mut buf)
.context("reading the answer from the swarm metrics store")?;
if n == 0 {
break;
}
out.write_all(&buf[..n]).context("writing to stdout")?;
last = buf[n - 1];
}
if last != b'\n' {
writeln!(out).context("writing to stdout")?;
}
Ok(())
}
/// The HTTP client, trusting the container's own roots.
///
/// No timeout: a range query over a long window legitimately takes long, and
/// a client-side cut would read as a failure the store never had.
fn build_http_client() -> Result<reqwest::blocking::Client> {
reqwest::blocking::Client::builder()
.build()
.context("building the metrics-store HTTP client")
}
#[cfg(test)]
mod tests {
use super::*;
const BASE: &str = "https://metrics.example";
fn pairs(u: &Url) -> Vec<(String, String)> {
u.query_pairs()
.map(|(k, v)| (k.into_owned(), v.into_owned()))
.collect()
}
fn owned(p: &[(&str, &str)]) -> Vec<(String, String)> {
p.iter()
.map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
.collect()
}
#[test]
fn an_instant_query_carries_the_expression_and_time() {
let u = url(
BASE,
&Request::Query {
promql: r#"count by (__name__) ({__name__=~"jobq\\..*"})"#.into(),
time: Some("-5m".into()),
},
)
.expect("builds");
assert_eq!(u.path(), "/api/v1/query");
assert_eq!(
pairs(&u),
owned(&[
("query", r#"count by (__name__) ({__name__=~"jobq\\..*"})"#),
("time", "-5m"),
])
);
}
/// An unset optional parameter is left to the store, not sent empty.
#[test]
fn an_unset_time_is_not_sent() {
let u = url(
BASE,
&Request::Query {
promql: "up".into(),
time: None,
},
)
.expect("builds");
assert_eq!(pairs(&u), owned(&[("query", "up")]));
}
#[test]
fn a_range_query_carries_its_window_and_step() {
let u = url(
BASE,
&Request::QueryRange {
promql: "rate(x[5m])".into(),
start: "-1h".into(),
end: Some("now".into()),
step: Some("1m".into()),
},
)
.expect("builds");
assert_eq!(u.path(), "/api/v1/query_range");
assert_eq!(
pairs(&u),
owned(&[
("query", "rate(x[5m])"),
("start", "-1h"),
("end", "now"),
("step", "1m"),
])
);
}
/// Each selector is its own `match[]`, the Prometheus API's spelling.
#[test]
fn series_sends_every_selector_and_the_span() {
let u = url(
BASE,
&Request::Series {
matchers: vec![r#"{service.name="nats"}"#.into(), "up".into()],
span: Span {
start: Some("-1d".into()),
end: None,
limit: Some(20),
},
},
)
.expect("builds");
assert_eq!(u.path(), "/api/v1/series");
assert_eq!(
pairs(&u),
owned(&[
("match[]", r#"{service.name="nats"}"#),
("match[]", "up"),
("start", "-1d"),
("limit", "20"),
])
);
}
#[test]
fn labels_has_its_own_path() {
let u = url(
BASE,
&Request::Labels {
matchers: vec![],
span: Span::default(),
},
)
.expect("builds");
assert_eq!(u.path(), "/api/v1/labels");
assert_eq!(u.query(), Some(""));
}
/// The label name is a path segment, so one with a dot (an OTLP
/// attribute) stays one segment and a `/` cannot reach another route.
#[test]
fn a_label_name_is_one_encoded_path_segment() {
let u = url(
BASE,
&Request::LabelValues {
name: "service.name".into(),
matchers: vec![],
span: Span::default(),
},
)
.expect("builds");
assert_eq!(u.path(), "/api/v1/label/service.name/values");
let u = url(
BASE,
&Request::LabelValues {
name: "../../admin".into(),
matchers: vec![],
span: Span::default(),
},
)
.expect("builds");
assert_eq!(u.path(), "/api/v1/label/..%2F..%2Fadmin/values");
}
/// A base with a trailing slash or a path prefix still lands on
/// `<base>/api/v1/...`, with no doubled slash.
#[test]
fn the_base_url_keeps_its_prefix_without_a_doubled_slash() {
let q = Request::Query {
promql: "up".into(),
time: None,
};
assert_eq!(
url("https://metrics.example/", &q).expect("builds").path(),
"/api/v1/query"
);
for base in ["https://gw.example/metrics", "https://gw.example/metrics/"] {
assert_eq!(
url(base, &q).expect("builds").path(),
"/metrics/api/v1/query",
"{base}"
);
}
}
#[test]
fn a_relative_base_is_refused() {
let q = Request::Query {
promql: "up".into(),
time: None,
};
assert!(url("metrics.example", &q).is_err());
}
#[test]
fn the_body_is_copied_and_newline_terminated_once() {
let mut out = Vec::new();
copy_with_newline(&mut &br#"{"status":"success"}"#[..], &mut out).expect("copies");
assert_eq!(out, b"{\"status\":\"success\"}\n");
let mut out = Vec::new();
copy_with_newline(&mut &b"{}\n"[..], &mut out).expect("copies");
assert_eq!(out, b"{}\n");
}
}