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
149
swarm-metrics/src/auth.rs
Normal file
149
swarm-metrics/src/auth.rs
Normal 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
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);
|
||||
}
|
||||
}
|
||||
355
swarm-metrics/src/query.rs
Normal file
355
swarm-metrics/src/query.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue