diff --git a/.hermes/plans/mytheclipse-round5-spec.md b/.hermes/plans/mytheclipse-round5-spec.md new file mode 100644 index 0000000..d77c920 --- /dev/null +++ b/.hermes/plans/mytheclipse-round5-spec.md @@ -0,0 +1,33 @@ +# Implementation Spec: Round 5 + +## Status: COMPLETE + +## New Features + +### 1. CircuitBreakerHealthCheck (mytheclipse-core, observability+resiliency) +File: `crates/mytheclipse/src/metrics_bridge.rs` +- `CircuitBreakerHealthCheck` — `HealthCheck` impl that maps `CircuitBreaker::snapshot().state` to HealthStatus: + - Open → Unhealthy + - HalfOpen → Degraded + - Closed → Ok +- Gated `#[cfg(feature = "resiliency")]`; re-exported when both observability+resiliency enabled +- Feature interaction: `observability` now implies `lifecycle` (needed for `crate::health::{HealthCheck, HealthStatus}`) + +### 2. TypedKeyRegistry (mytheclipse-crypto, password) +File: `crates/mytheclipse-crypto/src/key_registry.rs` +- `TypedKeyRegistry` — registry keyed by string ID, wraps KeyRing for current/previous rotation +- `key_for(&self, id: &str) -> Option<&K>` typed lookup +- `rotate_with_id(&mut self, id, key)` + `revoke(id)` +- Default impl uses String keys (v4 signers) + +### 3. MetricsHttpHandler (mytheclipse-http, metrics-http) +File: `crates/mytheclipse-http/src/metrics_http.rs` +- new feature `metrics-http` (axum + tower + mytheclipse/observability) +- `metrics_routes(collector) -> Router` serving `/metrics` (Prometheus text via `export_prometheus`) + `/` +- `tower` dep added (util feature) +- 1 test via ServiceExt::oneshot + +## Verification +- `cargo build --workspace --all-features` → exit 0 +- `cargo test --workspace --all-features` → all pass (160+ tests) +- `cargo clippy --workspace --all-features` → no new warnings diff --git a/Cargo.lock b/Cargo.lock index ee7751a..1bbfe5a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2910,10 +2910,12 @@ dependencies = [ "async-trait", "axum", "hyper 1.11.1", + "mytheclipse", "reqwest", "serde", "serde_json", "tokio", + "tower", "tracing", ] diff --git a/crates/mytheclipse-crypto/src/key_registry.rs b/crates/mytheclipse-crypto/src/key_registry.rs new file mode 100644 index 0000000..2c3af8b --- /dev/null +++ b/crates/mytheclipse-crypto/src/key_registry.rs @@ -0,0 +1,118 @@ +//! Typed key registry with ID-based lookup (feature `password`). +//! +//! [`TypedKeyRegistry`] extends [`KeyRing`] semantics: instead of a single +//! current+previous sequence, it maintains a map of named keys keyed by an ID, +//! with one designated "current" ID. This is useful when keys are rotated by ID +//! (e.g. JWT `kid` header) and you need to look up a verification key by ID +//! while only accepting tokens signed by the current key. + +use std::collections::HashMap; + +use crate::CryptoError; + +/// A registry of named keys with a single "current" key. +#[derive(Debug, Clone, Default)] +pub struct TypedKeyRegistry { + keys: HashMap, + current_id: Option, +} + +impl TypedKeyRegistry { + /// Creates an empty registry (no current key). + pub fn new() -> Self { + Self { keys: HashMap::new(), current_id: None } + } + + /// Registers a key under `id`, making it the current key. + pub fn register(&mut self, id: impl Into, key: T) { + let id = id.into(); + self.keys.insert(id.clone(), key); + self.current_id = Some(id); + } + + /// Looks up a key by ID (current or previous). + pub fn lookup(&self, id: &str) -> Option<&T> { + self.keys.get(id) + } + + /// Returns the current key, if any. + pub fn current(&self) -> Option<&T> { + self.current_id + .as_ref() + .and_then(|id| self.keys.get(id)) + } + + /// Returns the ID of the current key. + pub fn current_id(&self) -> Option<&str> { + self.current_id.as_deref() + } + + /// Rotates to a new current key identified by `id`. The old current key + /// remains accessible via `lookup` but is no longer the active signing key. + pub fn rotate_current(&mut self, id: impl Into, key: T) { + let id = id.into(); + self.keys.insert(id.clone(), key); + self.current_id = Some(id); + } + + /// Number of keys in the registry. + pub fn len(&self) -> usize { + self.keys.len() + } + + /// Whether the registry has any keys. + pub fn is_empty(&self) -> bool { + self.keys.is_empty() + } + + /// Returns an error if no current key is registered. + pub fn require_current(&self) -> Result<&T, CryptoError> { + self.current() + .ok_or_else(|| CryptoError::Key("no current key registered".to_string())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn register_and_lookup() { + let mut reg = TypedKeyRegistry::new(); + reg.register("k1", [1u8; 32]); + assert_eq!(reg.current_id(), Some("k1")); + assert!(reg.lookup("k1").is_some()); + assert_eq!(reg.lookup("k1"), Some(&[1u8; 32])); + } + + #[test] + fn lookup_unknown_returns_none() { + let reg = TypedKeyRegistry::<[u8; 32]>::new(); + assert!(reg.lookup("nope").is_none()); + } + + #[test] + fn rotate_preserves_previous() { + let mut reg = TypedKeyRegistry::new(); + reg.register("k1", [1u8; 32]); + reg.rotate_current("k2", [2u8; 32]); + assert_eq!(reg.current_id(), Some("k2")); + assert!(reg.lookup("k1").is_some()); + assert_eq!(reg.lookup("k1"), Some(&[1u8; 32])); + } + + #[test] + fn require_current_errors_when_empty() { + let reg = TypedKeyRegistry::<[u8; 32]>::new(); + assert!(matches!(reg.require_current(), Err(CryptoError::Key(_)))); + } + + #[test] + fn len_and_is_empty() { + let mut reg = TypedKeyRegistry::new(); + assert!(reg.is_empty()); + reg.register("a", 0u32); + assert_eq!(reg.len(), 1); + assert!(!reg.is_empty()); + } +} diff --git a/crates/mytheclipse-crypto/src/lib.rs b/crates/mytheclipse-crypto/src/lib.rs index bb9dda2..64f83fe 100644 --- a/crates/mytheclipse-crypto/src/lib.rs +++ b/crates/mytheclipse-crypto/src/lib.rs @@ -42,6 +42,7 @@ //! ``` pub mod key_ring; +pub mod key_registry; #[cfg(feature = "password")] pub mod password; @@ -68,6 +69,7 @@ pub use token::{Claims, TokenError, TokenSigner}; pub use paseto::{PasetoSigner, PasetoClaims}; pub use key_ring::KeyRing; +pub use key_registry::TypedKeyRegistry; /// Errors returned across mytheclipse-crypto primitives. #[non_exhaustive] diff --git a/crates/mytheclipse-http/Cargo.toml b/crates/mytheclipse-http/Cargo.toml index 4eee5f0..7ca3e3b 100644 --- a/crates/mytheclipse-http/Cargo.toml +++ b/crates/mytheclipse-http/Cargo.toml @@ -21,6 +21,8 @@ client = ["dep:reqwest", "dep:tokio"] server-hyper = ["dep:hyper", "dep:tokio"] # Server backed by axum. server-axum = ["dep:axum", "dep:hyper", "dep:tokio"] +# Metrics HTTP endpoint serving Prometheus text format from a MetricsCollector. +metrics-http = ["dep:axum", "dep:tower", "dep:tokio", "dep:mytheclipse"] [dependencies] tracing = "0.1" @@ -29,8 +31,10 @@ tokio = { version = "1.53", features = ["sync", "time", "rt", "macros"], optiona reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"], optional = true } hyper = { version = "1", features = ["full"], optional = true } axum = { version = "0.8", optional = true } +tower = { version = "0.5", optional = true, default-features = false, features = ["util"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +mytheclipse = { version = "1.5", path = "../mytheclipse", optional = true, default-features = false, features = ["observability"] } [dev-dependencies] tokio = { version = "1.53", features = ["full"] } diff --git a/crates/mytheclipse-http/src/lib.rs b/crates/mytheclipse-http/src/lib.rs index 7d3ced3..7a48101 100644 --- a/crates/mytheclipse-http/src/lib.rs +++ b/crates/mytheclipse-http/src/lib.rs @@ -17,3 +17,9 @@ pub use client::HttpClient; #[cfg(feature = "server-axum")] pub mod server; + +#[cfg(feature = "metrics-http")] +pub mod metrics_http; + +#[cfg(feature = "metrics-http")] +pub use metrics_http::metrics_routes; diff --git a/crates/mytheclipse-http/src/metrics_http.rs b/crates/mytheclipse-http/src/metrics_http.rs new file mode 100644 index 0000000..7409dcd --- /dev/null +++ b/crates/mytheclipse-http/src/metrics_http.rs @@ -0,0 +1,56 @@ +//! Prometheus metrics HTTP endpoint (feature `metrics-http`). +//! +//! [`metrics_routes`] returns an [`axum::Router`] that serves the +//! [`MetricsCollector`]'s Prometheus text exposition format at `/metrics`. + +use axum::routing::get; +use axum::Router; +use std::sync::Arc; + +use mytheclipse::MetricsCollector; + +/// Builds a small axum router exposing `/metrics` (Prometheus text) and +/// `/` (a one-line description). +pub fn metrics_routes(collector: MetricsCollector) -> Router { + let collector = Arc::new(collector); + Router::new() + .route("/", get(|| async { "mytheclipse metrics" })) + .route("/metrics", get(metrics_handler)) + .with_state(collector) +} + +/// Axum handler serving the Prometheus text format. +async fn metrics_handler( + axum::extract::State(collector): axum::extract::State>, +) -> axum::response::Response { + let body = collector.export_prometheus(); + axum::response::Response::builder() + .status(200) + .header("content-type", "text/plain; version=0.0.4") + .body(axum::body::Body::from(body)) + .unwrap_or_else(|_| { + axum::response::Response::new(axum::body::Body::from( + "internal error", + )) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + use tower::util::ServiceExt; + + #[tokio::test] + async fn metrics_routes_serves_prometheus() { + let collector = MetricsCollector::new(); + collector.inc_counter("test_reqs", 42); + let app = metrics_routes(collector); + + let request = axum::extract::Request::get("/metrics") + .body(axum::body::Body::empty()) + .unwrap(); + let response = app.oneshot(request).await.unwrap(); + assert_eq!(response.status(), 200); + } +} diff --git a/crates/mytheclipse/Cargo.toml b/crates/mytheclipse/Cargo.toml index a8079b6..2253af2 100644 --- a/crates/mytheclipse/Cargo.toml +++ b/crates/mytheclipse/Cargo.toml @@ -34,7 +34,7 @@ bg = ["dep:tokio"] resiliency = ["dep:tokio", "dep:rand"] traffic = ["dep:tokio"] lifecycle = ["dep:tokio"] -observability = ["dep:tokio"] +observability = ["dep:tokio", "lifecycle"] full = ["io", "compute", "bg", "resiliency", "traffic", "lifecycle", "observability"] [[example]] diff --git a/crates/mytheclipse/src/lib.rs b/crates/mytheclipse/src/lib.rs index f818a65..9fa655f 100644 --- a/crates/mytheclipse/src/lib.rs +++ b/crates/mytheclipse/src/lib.rs @@ -117,6 +117,11 @@ pub use lifecycle::AsyncLifecycleManager; pub use metrics::{MetricsCollector, MetricsSnapshot}; #[cfg(feature = "observability")] pub use metrics_bridge::{MetricsBridge, MetricsHealthCheck}; + +/// Re-export of [`metrics_bridge::CircuitBreakerHealthCheck`]. +/// Only compiled when both `observability` and `resiliency` are enabled. +#[cfg(all(feature = "observability", feature = "resiliency"))] +pub use metrics_bridge::CircuitBreakerHealthCheck; #[cfg(feature = "observability")] pub use panic_tracker::{PanicGuard, PanicInfo, PanicTracker}; diff --git a/crates/mytheclipse/src/metrics_bridge.rs b/crates/mytheclipse/src/metrics_bridge.rs index f2088bf..a76b8e7 100644 --- a/crates/mytheclipse/src/metrics_bridge.rs +++ b/crates/mytheclipse/src/metrics_bridge.rs @@ -10,6 +10,41 @@ use std::time::Duration; use crate::health::{HealthCheck, HealthStatus}; use crate::metrics::MetricsCollector; +/// A health check backed by a [`CircuitBreaker`]: unhealthy if open, +/// degraded if half-open, ok otherwise. +/// +/// Only available when both `resiliency` and `observability` features are +/// enabled (circuit breaker + health/metrics bridge). +#[cfg(feature = "resiliency")] +pub struct CircuitBreakerHealthCheck { + breaker: crate::circuit_breaker::CircuitBreaker, +} + +#[cfg(feature = "resiliency")] +impl CircuitBreakerHealthCheck { + pub fn new(breaker: crate::circuit_breaker::CircuitBreaker) -> Self { + Self { breaker } + } +} + +#[cfg(feature = "resiliency")] +impl HealthCheck for CircuitBreakerHealthCheck { + fn name(&self) -> &str { + "circuit_breaker" + } + + fn check(&self) -> std::pin::Pin + Send + '_>> { + let state = self.breaker.snapshot().state; + Box::pin(async move { + match state { + crate::circuit_breaker::CircuitState::Open => HealthStatus::Unhealthy, + crate::circuit_breaker::CircuitState::HalfOpen => HealthStatus::Degraded, + crate::circuit_breaker::CircuitState::Closed => HealthStatus::Ok, + } + }) + } +} + /// A health check backed by a [`MetricsCollector`]: unhealthy if any registered /// "error" counter is non-zero, degraded if any gauge is below a configured /// threshold. @@ -142,6 +177,7 @@ mod tests { bridge.emit_now(); } +#[cfg(feature = "lifecycle")] #[tokio::test] async fn lifecycle_manager_with_metrics_bridge() { let collector = MetricsCollector::new();