Watch
0
0
Fork
You've already forked hyperhive
0

move client-cert gauges behind an otel feature of swarm-secret-client, drop the crate

This commit is contained in:
atlas 2026-10-25 18:11:05 +01:00 • committed by mara
commit e592078868
19 changed files with 33 additions and 76 deletions

View file

@ -13,6 +13,9 @@ futures-util.workspace = true
# the `BAO_*` files) rather than by its env defaults, so the dependency is
# direct rather than incidental.
reqwest.workspace = true
# `cert_metrics`: the `bao.client_cert.*` gauges, for the services that export
# OTLP. Off by default so the other dependents link no OTel.
opentelemetry = { workspace = true, optional = true }
# vaultrs's own endpoint machinery, for the one endpoint it does not
# implement — see `client::WriteAclPolicy`.
rustify.workspace = true
@ -40,6 +43,8 @@ workspace = true
# provider compiled in rustls picks it without an install, so the provider
# test would pass with `install_crypto_provider` doing nothing.
[dev-dependencies]
# `InMemoryMetricExporter`, to read back what `cert_metrics` exports.
opentelemetry_sdk = { workspace = true, features = ["testing"] }
rustls = { workspace = true, features = ["ring"] }
# `test-util` for `#[tokio::test(start_paused = true)]`: the re-read backoff
# tests run on paused time.
@ -47,3 +52,6 @@ tokio = { workspace = true, features = ["test-util"] }
# Capturing what a re-read logs, to check it names the client and never the
# secret.
tracing-subscriber.workspace = true
[features]
otel = ["dep:opentelemetry"]

View file

@ -46,23 +46,11 @@ pub fn file_validity(path: &Path) -> Result<(i64, i64), Error> {
}
#[cfg(test)]
mod tests {
pub(crate) mod tests {
use super::*;
/// Self-signed, CN `hive-agent-atlas`, valid 2026-01-01T00:00:00Z to
/// 2026-04-01T00:00:00Z. Its key was discarded.
pub(crate) const CERT_PEM: &str = "-----BEGIN CERTIFICATE-----
MIIBizCCATGgAwIBAgIUSFpuYmy1UoHvq/MYiyrAudRaWXcwCgYIKoZIzj0EAwIw
GzEZMBcGA1UEAwwQaGl2ZS1hZ2VudC1hdGxhczAeFw0yNjAxMDEwMDAwMDBaFw0y
NjA0MDEwMDAwMDBaMBsxGTAXBgNVBAMMEGhpdmUtYWdlbnQtYXRsYXMwWTATBgcq
hkjOPQIBBggqhkjOPQMBBwNCAAR7p2eZAxjiE1RdsuSHE3CcTjbnd4swzfnmqUPW
9NJN7mvOtqEzxCKfOSFjaIhhyShIQ41/V6vGi80EPDB+uR56o1MwUTAdBgNVHQ4E
FgQUxgZFsN1VY3ODrPS1NUfY/x3EnmswHwYDVR0jBBgwFoAUxgZFsN1VY3ODrPS1
NUfY/x3EnmswDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAgNIADBFAiEAnU9e
WOioNTUNK9b6FignejpK5FpyrODUuH/iL4TqCh0CIAz6GzJeLebhRPvYlfVUofdV
hWx3sOwmUgjkRQXoxY+p
-----END CERTIFICATE-----
";
pub(crate) const CERT_PEM: &str = include_str!("../test-cert.pem");
pub(crate) const NOT_BEFORE: i64 = 1_767_225_600;
pub(crate) const NOT_AFTER: i64 = 1_775_001_600;

View file

@ -0,0 +1,199 @@
//! (feature `otel`) The two gauges every service holding a store client certificate exports:
//! when that certificate became valid and when it stops being valid.
//!
//! `bao.client_cert.not_before` and `bao.client_cert.not_after`, unix seconds,
//! one data point each, labelled `cert` with the identity's stable name (the
//! file stem the identity is minted under: `client`, `controller`, `agent`).
//! Exporting the two timestamps rather than a precomputed fraction keeps the
//! lifetime fraction right at query time: a wedged process keeps reporting the
//! same timestamps, not a stale low percentage.
//!
//! The file is read at collection time, so a renewed leaf is picked up on the
//! next export. The two gauges read it separately (the SDK has no callback
//! shared between instruments), so one export that straddles a renewal pairs
//! the old `not_before` with the new `not_after`; the next export agrees.
//! A file that cannot be read or parsed yields no data point and a debug line.
//! The export thread never panics and waits on nothing but that local read.
use std::path::Path;
use crate::{cert, client::ENV_CLIENT_CERT};
use opentelemetry::KeyValue;
use opentelemetry::metrics::Meter;
/// Register both gauges on `meter` for the certificate [`ENV_CLIENT_CERT`]
/// names, reported under `cert`.
///
/// Registers nothing, with a debug line, when the variable is unset or empty.
pub fn register(meter: &Meter, cert: &'static str) {
match std::env::var(ENV_CLIENT_CERT) {
Ok(path) if !path.is_empty() => register_at(meter, cert, Path::new(&path)),
_ => tracing::debug!(
cert,
"client-cert gauges: {ENV_CLIENT_CERT} is unset, none registered"
),
}
}
/// Register both gauges on `meter` for the certificate at `path`, reported
/// under `cert`.
pub fn register_at(meter: &Meter, cert: &'static str, path: &Path) {
for (name, pick) in [
(
cert::NOT_BEFORE_METRIC,
Window::not_before as fn(Window) -> i64,
),
(cert::NOT_AFTER_METRIC, Window::not_after),
] {
let path = path.to_path_buf();
meter
.i64_observable_gauge(name)
.with_unit("s")
.with_callback(move |obs| {
if let Some(window) = read(cert, &path) {
obs.observe(pick(window), &[KeyValue::new("cert", cert)]);
}
})
.build();
}
}
#[derive(Clone, Copy)]
struct Window {
not_before: i64,
not_after: i64,
}
impl Window {
const fn not_before(self) -> i64 {
self.not_before
}
const fn not_after(self) -> i64 {
self.not_after
}
}
fn read(cert_name: &str, path: &Path) -> Option<Window> {
match cert::file_validity(path) {
Ok((not_before, not_after)) => Some(Window {
not_before,
not_after,
}),
Err(e) => {
tracing::debug!(cert = cert_name, error = %e, "client-cert gauges: no data point");
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use opentelemetry::metrics::MeterProvider as _;
use opentelemetry_sdk::metrics::data::{
AggregatedMetrics, MetricData, ResourceMetrics, ScopeMetrics,
};
use opentelemetry_sdk::metrics::{InMemoryMetricExporter, PeriodicReader, SdkMeterProvider};
use std::path::PathBuf;
use crate::cert::tests::{CERT_PEM, NOT_AFTER, NOT_BEFORE};
/// A provider with both gauges registered for `path`, and the exporter it
/// flushes into.
fn registered(path: &Path) -> (SdkMeterProvider, InMemoryMetricExporter) {
let exporter = InMemoryMetricExporter::default();
let reader = PeriodicReader::builder(exporter.clone()).build();
let provider = SdkMeterProvider::builder().with_reader(reader).build();
register_at(&provider.meter("test"), "controller", path);
(provider, exporter)
}
/// One collection: `(metric, cert label, value)` for every data point,
/// sorted.
fn collect(
provider: &SdkMeterProvider,
exporter: &InMemoryMetricExporter,
) -> Vec<(String, String, i64)> {
exporter.reset();
provider.force_flush().expect("flush");
let mut points = Vec::new();
for metric in exporter
.get_finished_metrics()
.expect("metrics")
.iter()
.flat_map(ResourceMetrics::scope_metrics)
.flat_map(ScopeMetrics::metrics)
{
let AggregatedMetrics::I64(MetricData::Gauge(gauge)) = metric.data() else {
panic!("not an i64 gauge: {:?}", metric.data());
};
for point in gauge.data_points() {
let label = point
.attributes()
.find(|kv| kv.key.as_str() == "cert")
.map(|kv| kv.value.to_string())
.unwrap_or_default();
points.push((metric.name().to_owned(), label, point.value()));
}
}
points.sort();
points
}
fn scratch(name: &str) -> PathBuf {
let dir =
std::env::temp_dir().join(format!("swarm-cert-metrics-{name}-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("scratch dir");
dir
}
#[test]
fn both_gauges_report_the_files_window_under_the_cert_label() {
let dir = scratch("window");
let path = dir.join("cert.pem");
std::fs::write(&path, CERT_PEM).expect("write");
let (provider, exporter) = registered(&path);
assert_eq!(
collect(&provider, &exporter),
[
(
cert::NOT_AFTER_METRIC.to_owned(),
"controller".to_owned(),
NOT_AFTER
),
(
cert::NOT_BEFORE_METRIC.to_owned(),
"controller".to_owned(),
NOT_BEFORE
),
]
);
std::fs::remove_dir_all(&dir).expect("cleanup");
}
#[test]
fn a_missing_or_unparseable_file_reports_nothing() {
let (provider, exporter) = registered(Path::new("/nonexistent/cert.pem"));
assert_eq!(collect(&provider, &exporter), []);
let dir = scratch("garbage");
let path = dir.join("cert.pem");
std::fs::write(&path, "not a certificate").expect("write");
let (provider, exporter) = registered(&path);
assert_eq!(collect(&provider, &exporter), []);
std::fs::remove_dir_all(&dir).expect("cleanup");
}
/// The file is read on every collection, not once at registration.
#[test]
fn a_file_that_appears_later_is_reported_from_then_on() {
let dir = scratch("later");
let path = dir.join("cert.pem");
let (provider, exporter) = registered(&path);
assert_eq!(collect(&provider, &exporter), []);
std::fs::write(&path, CERT_PEM).expect("write");
assert_eq!(collect(&provider, &exporter).len(), 2);
std::fs::remove_dir_all(&dir).expect("cleanup");
}
}

View file

@ -32,6 +32,8 @@
pub mod acp;
pub mod builder;
pub mod cert;
#[cfg(feature = "otel")]
pub mod cert_metrics;
pub mod ci;
pub mod ci_metrics;
pub mod claude_login;

View file

@ -0,0 +1,11 @@
-----BEGIN CERTIFICATE-----
MIIBizCCATGgAwIBAgIUSFpuYmy1UoHvq/MYiyrAudRaWXcwCgYIKoZIzj0EAwIw
GzEZMBcGA1UEAwwQaGl2ZS1hZ2VudC1hdGxhczAeFw0yNjAxMDEwMDAwMDBaFw0y
NjA0MDEwMDAwMDBaMBsxGTAXBgNVBAMMEGhpdmUtYWdlbnQtYXRsYXMwWTATBgcq
hkjOPQIBBggqhkjOPQMBBwNCAAR7p2eZAxjiE1RdsuSHE3CcTjbnd4swzfnmqUPW
9NJN7mvOtqEzxCKfOSFjaIhhyShIQ41/V6vGi80EPDB+uR56o1MwUTAdBgNVHQ4E
FgQUxgZFsN1VY3ODrPS1NUfY/x3EnmswHwYDVR0jBBgwFoAUxgZFsN1VY3ODrPS1
NUfY/x3EnmswDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAgNIADBFAiEAnU9e
WOioNTUNK9b6FignejpK5FpyrODUuH/iL4TqCh0CIAz6GzJeLebhRPvYlfVUofdV
hWx3sOwmUgjkRQXoxY+p
-----END CERTIFICATE-----