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:
parent
1e8c398132
commit
e92f118bda
30 changed files with 1417 additions and 88 deletions
247
swarm-metrics/src/main.rs
Normal file
247
swarm-metrics/src/main.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue