ce7cb702218693d08ea6d83464d2457c9250027c
This commit is contained in:
commit
34c5f8963c
16 changed files with 145336 additions and 0 deletions
1
services/cluster-health-api/.gitignore
vendored
Normal file
1
services/cluster-health-api/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/target/
|
||||
1204
services/cluster-health-api/Cargo.lock
generated
Normal file
1204
services/cluster-health-api/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
26
services/cluster-health-api/Cargo.toml
Normal file
26
services/cluster-health-api/Cargo.toml
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
[package]
|
||||
name = "cluster-health-api"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.93"
|
||||
|
||||
[dependencies]
|
||||
chrono = { version = "0.4.42", default-features = false, features = ["clock"] }
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = "1.0.149"
|
||||
rmp-serde = "1.3"
|
||||
socket2 = "0.6.5"
|
||||
thiserror = "2"
|
||||
tiny_http = "0.12.0"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", default-features = false, features = ["fmt", "json", "std"] }
|
||||
ureq = { version = "3.4.2", default-features = false, features = ["rustls", "json"] }
|
||||
|
||||
[profile.release]
|
||||
strip = true
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
|
||||
[lints.clippy]
|
||||
unwrap_used = "deny"
|
||||
expect_used = "deny"
|
||||
51
services/cluster-health-api/README.md
Normal file
51
services/cluster-health-api/README.md
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
# Cluster health API
|
||||
|
||||
| Delivery stage | Scope |
|
||||
| -------------- | ------------------------------------------------------- |
|
||||
| Build | Release compilation and OCI assembly; no test execution |
|
||||
| Tests | Run separately using the command below |
|
||||
|
||||
| Interface | Behavior |
|
||||
| -------------- | --------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Endpoint | `GET /health`; no request parameters |
|
||||
| Success | `application/msgpack`; `[start, groups]` |
|
||||
| Start | Unix seconds at UTC midnight, 30 days before the current UTC day |
|
||||
| Group | `[header, names[], values[][]]`; parallel name strings and 30-element value arrays |
|
||||
| Values | MessagePack float64 or nil; oldest completed day first; missing samples become nil |
|
||||
| Queries | Deployment-configured MetricsQL expressions, sent unchanged; one range query per group |
|
||||
| Evaluation | Daily step; 30 evaluation timestamps ending at current UTC midnight; each result describes the preceding day |
|
||||
| Validation | Each series has exactly one label; its value becomes the name. Timestamps lie on the daily grid within the requested window |
|
||||
| Interpretation | Queries own fraction calculation and incomplete-observation treatment; API does not clamp values |
|
||||
| Ordering | Configuration group order and query result series order; display ordering belongs to the page |
|
||||
| Cache | Per-process encoded snapshot; refresh failure makes the endpoint unavailable |
|
||||
| Responses | 200 snapshot; 503 unavailable; 404 other paths; 405 other methods; errors are MessagePack strings |
|
||||
| Logging | JSON on stdout; listening, refresh duration/result, process errors |
|
||||
|
||||
| Required environment | Value |
|
||||
| -------------------- | ------------------------------------------------ |
|
||||
| `LISTEN` | Socket address |
|
||||
| `METRICS_URL` | VictoriaMetrics base URL, without trailing slash |
|
||||
| `PAGE_ORIGIN` | CORS origin |
|
||||
| `REFRESH_SECS` | Seconds between completed refreshes |
|
||||
| `GROUPS` | JSON list of `[header, query]` string tuples |
|
||||
|
||||
```json
|
||||
[
|
||||
["Nodes", "avg by (node) (avg_over_time(up{job=\"node-exporter-prometheus-node-exporter\"}[1d]))"],
|
||||
["Public services", "avg by (instance) (avg_over_time(probe_success{job=\"public-health\"}[1d]))"]
|
||||
]
|
||||
```
|
||||
|
||||
| Example boundary | Value |
|
||||
| ---------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Collection | Example jobs are configuration, not built-in selectors; public-health probes must exist for that example to return series |
|
||||
| Missing history | A series returned for some days has nil for absent days; an entirely absent series cannot be enumerated |
|
||||
| Intra-day health | Queries supply fractions; equally spaced 23 passing hourly observations and one failure produce `23/24` |
|
||||
|
||||
| Check | Command from repository root |
|
||||
| -------- | ----------------------------------------------------------------------------------------------------------- |
|
||||
| Tests | `cargo test --locked --manifest-path services/cluster-health-api/Cargo.toml` |
|
||||
| Lint | `cargo clippy --locked --manifest-path services/cluster-health-api/Cargo.toml --all-targets -- -D warnings` |
|
||||
| Format | `cargo fmt --manifest-path services/cluster-health-api/Cargo.toml --check` |
|
||||
| Delivery | [Declaration](../project-delivery/k8s/deliveries/cluster-health.yaml) |
|
||||
| Actions | [Build workflow](../project-delivery/actions/cluster-health/) |
|
||||
63
services/cluster-health-api/k8s/deployment.yaml
Normal file
63
services/cluster-health-api/k8s/deployment.yaml
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: cluster-health-api
|
||||
namespace: cluster-health
|
||||
labels:
|
||||
app.kubernetes.io/name: cluster-health-api
|
||||
app.kubernetes.io/instance: cluster-health-api
|
||||
spec:
|
||||
replicas: 2
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: cluster-health-api
|
||||
app.kubernetes.io/instance: cluster-health-api
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: cluster-health-api
|
||||
app.kubernetes.io/instance: cluster-health-api
|
||||
spec:
|
||||
runtimeClassName: gvisor
|
||||
automountServiceAccountToken: false
|
||||
imagePullSecrets:
|
||||
- name: quay-cluster-pull
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 65532
|
||||
runAsGroup: 65532
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: api
|
||||
image: registry.quorate.internal/quorate/cluster-health-api@sha256:ac076a500fe75f72cb32e702e6721060b1abb5d32232ce65f480f0351ba5c44d
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 8080
|
||||
env:
|
||||
- name: LISTEN
|
||||
value: 0.0.0.0:8080
|
||||
- name: METRICS_URL
|
||||
value: http://vmselect-main.victoriametrics.svc.cluster.local:8481/select/0/prometheus
|
||||
- name: PAGE_ORIGIN
|
||||
value: https://quorate.dev
|
||||
- name: REFRESH_SECS
|
||||
value: "60"
|
||||
- name: GROUPS
|
||||
value: >-
|
||||
[["Nodes","label_keep(last_over_time(node_health_day[1d] offset -10m), \"node\")"],["Public services","label_keep(last_over_time(public_health_day[1d] offset -10m), \"name\")"]]
|
||||
resources:
|
||||
requests:
|
||||
cpu: 10m
|
||||
memory: 32Mi
|
||||
limits:
|
||||
memory: 128Mi
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop: [ALL]
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: http
|
||||
8
services/cluster-health-api/k8s/kustomization.yaml
Normal file
8
services/cluster-health-api/k8s/kustomization.yaml
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
resources:
|
||||
- namespace.yaml
|
||||
- deployment.yaml
|
||||
- service.yaml
|
||||
- vso.yaml
|
||||
- network-policy.yaml
|
||||
7
services/cluster-health-api/k8s/namespace.yaml
Normal file
7
services/cluster-health-api/k8s/namespace.yaml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: cluster-health
|
||||
labels:
|
||||
gateway.quorate.internal/allow-public: "true"
|
||||
security.quorate.internal/public-runtime: gvisor
|
||||
90
services/cluster-health-api/k8s/network-policy.yaml
Normal file
90
services/cluster-health-api/k8s/network-policy.yaml
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: api
|
||||
namespace: cluster-health
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: cluster-health-api
|
||||
ingress:
|
||||
- fromEntities: [host]
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "8080"
|
||||
protocol: TCP
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
k8s:io.kubernetes.pod.namespace: cloudflare-tunnel
|
||||
app.kubernetes.io/name: cloudflared
|
||||
app.kubernetes.io/instance: cloudflare-tunnel
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "8080"
|
||||
protocol: TCP
|
||||
egress:
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
k8s:io.kubernetes.pod.namespace: kube-system
|
||||
k8s-app: kube-dns
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "53"
|
||||
protocol: UDP
|
||||
- port: "53"
|
||||
protocol: TCP
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
k8s:io.kubernetes.pod.namespace: victoriametrics
|
||||
app.kubernetes.io/name: vmselect
|
||||
app.kubernetes.io/instance: main
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "8481"
|
||||
protocol: TCP
|
||||
---
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: cluster-health
|
||||
namespace: victoriametrics
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: vmselect
|
||||
app.kubernetes.io/instance: main
|
||||
ingress:
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
k8s:io.kubernetes.pod.namespace: cluster-health
|
||||
app.kubernetes.io/name: cluster-health-api
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "8481"
|
||||
protocol: TCP
|
||||
---
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumClusterwideNetworkPolicy
|
||||
metadata:
|
||||
name: host-health
|
||||
spec:
|
||||
nodeSelector: {}
|
||||
ingress:
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
k8s:io.kubernetes.pod.namespace: cluster-health
|
||||
app.kubernetes.io/name: cluster-health-api
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "32768"
|
||||
endPort: 60999
|
||||
protocol: TCP
|
||||
egress:
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
k8s:io.kubernetes.pod.namespace: cluster-health
|
||||
app.kubernetes.io/name: cluster-health-api
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "8080"
|
||||
protocol: TCP
|
||||
17
services/cluster-health-api/k8s/service.yaml
Normal file
17
services/cluster-health-api/k8s/service.yaml
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: cluster-health-api
|
||||
namespace: cluster-health
|
||||
labels:
|
||||
app.kubernetes.io/name: cluster-health-api
|
||||
app.kubernetes.io/instance: cluster-health-api
|
||||
health.quorate.dev/name: Health
|
||||
spec:
|
||||
selector:
|
||||
app.kubernetes.io/name: cluster-health-api
|
||||
app.kubernetes.io/instance: cluster-health-api
|
||||
ports:
|
||||
- name: http
|
||||
port: 8080
|
||||
targetPort: http
|
||||
43
services/cluster-health-api/k8s/vso.yaml
Normal file
43
services/cluster-health-api/k8s/vso.yaml
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: cluster-health-vso
|
||||
namespace: cluster-health
|
||||
automountServiceAccountToken: false
|
||||
---
|
||||
apiVersion: secrets.hashicorp.com/v1beta1
|
||||
kind: VaultAuth
|
||||
metadata:
|
||||
name: default
|
||||
namespace: cluster-health
|
||||
spec:
|
||||
method: kubernetes
|
||||
mount: kubernetes
|
||||
kubernetes:
|
||||
role: cluster-health
|
||||
serviceAccount: cluster-health-vso
|
||||
vaultConnectionRef: vault/vault
|
||||
---
|
||||
apiVersion: secrets.hashicorp.com/v1beta1
|
||||
kind: VaultStaticSecret
|
||||
metadata:
|
||||
name: quay-cluster-pull
|
||||
namespace: cluster-health
|
||||
spec:
|
||||
type: kv-v2
|
||||
mount: secret
|
||||
path: quay/robots/cluster_pull
|
||||
refreshAfter: 1h
|
||||
destination:
|
||||
name: quay-cluster-pull
|
||||
create: true
|
||||
overwrite: true
|
||||
type: kubernetes.io/dockerconfigjson
|
||||
transformation:
|
||||
excludeRaw: true
|
||||
excludes: [.*]
|
||||
templates:
|
||||
.dockerconfigjson:
|
||||
text: >-
|
||||
{"auths":{"registry.quorate.internal":{"auth":"{{ printf "%s:%s" (get .Secrets "username") (get .Secrets "token") | b64enc }}"}}}
|
||||
vaultAuthRef: default
|
||||
108
services/cluster-health-api/src/config.rs
Normal file
108
services/cluster-health-api/src/config.rs
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
use crate::{Error, Result};
|
||||
use std::{net::SocketAddr, time::Duration};
|
||||
use tiny_http::Header;
|
||||
|
||||
pub struct Config {
|
||||
pub listen: SocketAddr,
|
||||
pub metrics: String,
|
||||
pub origin: Header,
|
||||
pub interval: Duration,
|
||||
pub groups: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn read(get: impl Fn(&str) -> Option<String>) -> Result<Self> {
|
||||
let required = |key| get(key).ok_or(Error::Config(key));
|
||||
Ok(Self {
|
||||
listen: required("LISTEN")?
|
||||
.parse()
|
||||
.map_err(|_| Error::Config("LISTEN"))?,
|
||||
metrics: required("METRICS_URL")?,
|
||||
groups: serde_json::from_str(&required("GROUPS")?)?,
|
||||
origin: Header::from_bytes("Access-Control-Allow-Origin", required("PAGE_ORIGIN")?)
|
||||
.map_err(|_| Error::Config("PAGE_ORIGIN"))?,
|
||||
interval: Duration::from_secs(
|
||||
required("REFRESH_SECS")?
|
||||
.parse()
|
||||
.map_err(|_| Error::Config("REFRESH_SECS"))?,
|
||||
),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn values(key: &str) -> Option<String> {
|
||||
match key {
|
||||
"LISTEN" => Some("127.0.0.1:0"),
|
||||
"METRICS_URL" => Some("http://metrics:8428"),
|
||||
"PAGE_ORIGIN" => Some("https://quorate.dev"),
|
||||
"REFRESH_SECS" => Some("60"),
|
||||
"GROUPS" => Some(r#"[["Nodes","avg_over_time(up[1d])"]]"#),
|
||||
_ => None,
|
||||
}
|
||||
.map(str::to_owned)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_config() -> Result<()> {
|
||||
let config = Config::read(values)?;
|
||||
assert_eq!(config.interval, Duration::from_secs(60));
|
||||
assert_eq!(config.listen.port(), 0);
|
||||
assert_eq!(
|
||||
config.groups,
|
||||
[("Nodes".into(), "avg_over_time(up[1d])".into())]
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_config() {
|
||||
assert!(matches!(
|
||||
Config::read(|_| None),
|
||||
Err(Error::Config("LISTEN"))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_interval() {
|
||||
assert!(matches!(
|
||||
Config::read(|key| if key == "REFRESH_SECS" {
|
||||
Some("bad".into())
|
||||
} else {
|
||||
values(key)
|
||||
}),
|
||||
Err(Error::Config("REFRESH_SECS"))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_tuples() -> Result<()> {
|
||||
let config = Config::read(|key| {
|
||||
if key == "GROUPS" {
|
||||
Some(
|
||||
r#"[["First","a * on(namespace) group_left() b"],["Second","arbitrary()"]]"#
|
||||
.into(),
|
||||
)
|
||||
} else {
|
||||
values(key)
|
||||
}
|
||||
})?;
|
||||
assert_eq!(
|
||||
config.groups[0],
|
||||
("First".into(), "a * on(namespace) group_left() b".into())
|
||||
);
|
||||
assert_eq!(config.groups[1].0, "Second");
|
||||
assert!(
|
||||
Config::read(|key| if key == "GROUPS" {
|
||||
Some(r#"[["missing query"]]"#.into())
|
||||
} else {
|
||||
values(key)
|
||||
})
|
||||
.is_err()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
28
services/cluster-health-api/src/lib.rs
Normal file
28
services/cluster-health-api/src/lib.rs
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
pub mod config;
|
||||
pub mod metrics;
|
||||
pub mod model;
|
||||
pub mod server;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("configuration {0} is missing or invalid")]
|
||||
Config(&'static str),
|
||||
#[error("metrics transport failed: {0}")]
|
||||
Transport(#[from] ureq::Error),
|
||||
#[error("metrics response reports failure")]
|
||||
Metrics,
|
||||
#[error("invalid metrics sample: {0}")]
|
||||
Sample(&'static str),
|
||||
#[error("timestamp is outside the supported range")]
|
||||
Timestamp,
|
||||
#[error("JSON conversion failed: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
#[error("MessagePack encoding failed: {0}")]
|
||||
Encode(#[from] rmp_serde::encode::Error),
|
||||
#[error("cache lock poisoned")]
|
||||
Cache,
|
||||
#[error("I/O failed: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
58
services/cluster-health-api/src/main.rs
Normal file
58
services/cluster-health-api/src/main.rs
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
use chrono::Utc;
|
||||
use cluster_health_api::{
|
||||
Result,
|
||||
config::Config,
|
||||
metrics::Metrics,
|
||||
server::{Cache, serve},
|
||||
};
|
||||
use std::{net::TcpListener, process::ExitCode, thread, time::Instant};
|
||||
|
||||
fn update(metrics: &Metrics, cache: &Cache) -> Result<()> {
|
||||
let started = Instant::now();
|
||||
let snapshot = match metrics.refresh(Utc::now()) {
|
||||
Ok(body) => {
|
||||
tracing::info!(
|
||||
event = "refresh",
|
||||
bytes = body.len(),
|
||||
elapsed_ms = started.elapsed().as_millis() as u64
|
||||
);
|
||||
Some(body)
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::error!(event = "refresh_failed", error = %error, elapsed_ms = started.elapsed().as_millis() as u64);
|
||||
None
|
||||
}
|
||||
};
|
||||
cache.replace(snapshot)
|
||||
}
|
||||
|
||||
fn run() -> Result<()> {
|
||||
let config = Config::read(|key| std::env::var(key).ok())?;
|
||||
let metrics = Metrics::new(config.metrics, config.groups);
|
||||
let cache = Cache::default();
|
||||
update(&metrics, &cache)?;
|
||||
let writer = cache.clone();
|
||||
thread::Builder::new()
|
||||
.name("refresh".into())
|
||||
.spawn(move || {
|
||||
loop {
|
||||
thread::sleep(config.interval);
|
||||
if let Err(error) = update(&metrics, &writer) {
|
||||
tracing::error!(event = "refresh_stopped", error = %error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
})?;
|
||||
serve(TcpListener::bind(config.listen)?, cache, config.origin)
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
tracing_subscriber::fmt().json().with_target(false).init();
|
||||
match run() {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(error) => {
|
||||
tracing::error!(event = "exit", error = %error);
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
142
services/cluster-health-api/src/metrics.rs
Normal file
142
services/cluster-health-api/src/metrics.rs
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
use crate::{
|
||||
Error, Result,
|
||||
model::{DAY, DAYS, Series, Snapshot, group},
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Envelope {
|
||||
status: String,
|
||||
data: Data,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Data {
|
||||
result: Vec<Series>,
|
||||
}
|
||||
|
||||
pub struct Metrics {
|
||||
agent: ureq::Agent,
|
||||
base: String,
|
||||
groups: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
pub fn range_params(now: DateTime<Utc>) -> [(&'static str, String); 3] {
|
||||
let end = now.timestamp().div_euclid(DAY) * DAY;
|
||||
[
|
||||
("start", (end - (DAYS as i64 - 1) * DAY).to_string()),
|
||||
("end", end.to_string()),
|
||||
("step", DAY.to_string()),
|
||||
]
|
||||
}
|
||||
|
||||
impl Metrics {
|
||||
pub fn new(base: String, groups: Vec<(String, String)>) -> Self {
|
||||
Self {
|
||||
agent: ureq::Agent::new_with_defaults(),
|
||||
base,
|
||||
groups,
|
||||
}
|
||||
}
|
||||
|
||||
fn query(&self, query: &str, now: DateTime<Utc>) -> Result<Vec<Series>> {
|
||||
let mut request = self
|
||||
.agent
|
||||
.get(format!("{}/api/v1/query_range", self.base))
|
||||
.query("query", query);
|
||||
for (key, value) in range_params(now) {
|
||||
request = request.query(key, value);
|
||||
}
|
||||
let body: Envelope = request.call()?.body_mut().read_json()?;
|
||||
if body.status != "success" {
|
||||
return Err(Error::Metrics);
|
||||
}
|
||||
Ok(body.data.result)
|
||||
}
|
||||
|
||||
pub fn refresh(&self, now: DateTime<Utc>) -> Result<Vec<u8>> {
|
||||
let start = now.timestamp().div_euclid(DAY) * DAY - DAYS as i64 * DAY;
|
||||
let groups = self
|
||||
.groups
|
||||
.iter()
|
||||
.map(|(header, query)| group(header.clone(), self.query(query, now)?, start + DAY))
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
Ok(rmp_serde::to_vec(&Snapshot(start, groups))?)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::{net::TcpListener, thread, time::Duration};
|
||||
use tiny_http::{Response, Server};
|
||||
|
||||
fn fetch(status: u16, body: &'static str) -> Result<Vec<u8>> {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")?;
|
||||
let address = listener.local_addr()?;
|
||||
let server = Server::from_listener(listener, None).map_err(std::io::Error::other)?;
|
||||
let worker = thread::spawn(move || -> Result<()> {
|
||||
let request = server
|
||||
.recv_timeout(Duration::from_secs(2))?
|
||||
.ok_or_else(|| std::io::Error::other("mock received no request"))?;
|
||||
assert!(
|
||||
request
|
||||
.url()
|
||||
.starts_with("/api/v1/query_range?query=arbitrary_expression")
|
||||
);
|
||||
assert!(request.url().contains("step=86400"));
|
||||
request.respond(Response::from_string(body).with_status_code(status))?;
|
||||
Ok(())
|
||||
});
|
||||
let result = Metrics::new(
|
||||
format!("http://{address}"),
|
||||
vec![("Configured".into(), "arbitrary_expression".into())],
|
||||
)
|
||||
.refresh(Utc::now());
|
||||
worker
|
||||
.join()
|
||||
.map_err(|_| std::io::Error::other("mock panicked"))??;
|
||||
result
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn query_empty() -> Result<()> {
|
||||
let bytes = fetch(200, r#"{"status":"success","data":{"result":[]}}"#)?;
|
||||
let snapshot: Snapshot = rmp_serde::from_slice(&bytes).map_err(std::io::Error::other)?;
|
||||
assert_eq!(
|
||||
snapshot.1,
|
||||
[crate::model::Group("Configured".into(), vec![], vec![])]
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn query_failure() {
|
||||
assert!(matches!(
|
||||
fetch(200, r#"{"status":"error","data":{"result":[]}}"#),
|
||||
Err(Error::Metrics)
|
||||
));
|
||||
assert!(matches!(
|
||||
fetch(503, "unavailable"),
|
||||
Err(Error::Transport(_))
|
||||
));
|
||||
assert!(fetch(200, "not json").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn range_boundaries() -> Result<()> {
|
||||
let now = DateTime::parse_from_rfc3339("2026-03-01T12:30:00Z")
|
||||
.map_err(|_| Error::Timestamp)?
|
||||
.with_timezone(&Utc);
|
||||
assert_eq!(
|
||||
range_params(now),
|
||||
[
|
||||
("start", "1769817600".into()),
|
||||
("end", "1772323200".into()),
|
||||
("step", "86400".into())
|
||||
]
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
144
services/cluster-health-api/src/model.rs
Normal file
144
services/cluster-health-api/src/model.rs
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
use crate::{Error, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub const DAY: i64 = 86400;
|
||||
pub const DAYS: usize = 30;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Series {
|
||||
pub metric: BTreeMap<String, String>,
|
||||
pub values: Vec<(f64, String)>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct Group(pub String, pub Vec<String>, pub Vec<[Option<f64>; DAYS]>);
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct Snapshot(pub i64, pub Vec<Group>);
|
||||
|
||||
pub fn group(header: String, rows: Vec<Series>, start: i64) -> Result<Group> {
|
||||
let mut names = Vec::with_capacity(rows.len());
|
||||
let mut values = Vec::with_capacity(rows.len());
|
||||
for row in rows {
|
||||
let mut labels = row.metric.into_values();
|
||||
let name = match (labels.next(), labels.next()) {
|
||||
(Some(name), None) => name,
|
||||
_ => return Err(Error::Sample("expected exactly one label")),
|
||||
};
|
||||
let mut days = [None; DAYS];
|
||||
for (time, value) in row.values {
|
||||
let index = (time - start as f64) / DAY as f64;
|
||||
if !(0.0..DAYS as f64).contains(&index) || index.fract() != 0.0 {
|
||||
return Err(Error::Sample("daily timestamp"));
|
||||
}
|
||||
days[index as usize] = Some(value.parse().map_err(|_| Error::Sample("float"))?);
|
||||
}
|
||||
names.push(name);
|
||||
values.push(days);
|
||||
}
|
||||
Ok(Group(header, names, values))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn row(values: Vec<(f64, String)>) -> Series {
|
||||
Series {
|
||||
metric: BTreeMap::from([("name".into(), "example".into())]),
|
||||
values,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fractions_and_gaps() -> Result<()> {
|
||||
let fraction = 23.0 / 24.0;
|
||||
let result = group(
|
||||
"Any header".into(),
|
||||
vec![row(vec![
|
||||
(0.0, fraction.to_string()),
|
||||
((29 * DAY) as f64, "0".into()),
|
||||
])],
|
||||
0,
|
||||
)?;
|
||||
assert_eq!(result.1, ["example"]);
|
||||
assert_eq!(result.2[0][0], Some(fraction));
|
||||
assert_eq!(result.2[0][1], None);
|
||||
assert_eq!(result.2[0][29], Some(0.0));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn daily_window() {
|
||||
for time in [-1.0, 1.0, (30 * DAY) as f64, f64::NAN] {
|
||||
assert!(group("Test".into(), vec![row(vec![(time, "1".into())])], 0).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn label_count() {
|
||||
for metric in [
|
||||
BTreeMap::new(),
|
||||
BTreeMap::from([
|
||||
("namespace".into(), "example".into()),
|
||||
("service".into(), "api".into()),
|
||||
]),
|
||||
] {
|
||||
let series = Series {
|
||||
metric,
|
||||
values: vec![],
|
||||
};
|
||||
assert!(matches!(
|
||||
group("Test".into(), vec![series], 0),
|
||||
Err(Error::Sample("expected exactly one label"))
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn label_value_only() -> Result<()> {
|
||||
for key in ["name", "node", "service"] {
|
||||
let series = Series {
|
||||
metric: BTreeMap::from([(key.into(), "example".into())]),
|
||||
values: vec![],
|
||||
};
|
||||
assert_eq!(group("Test".into(), vec![series], 0)?.1, ["example"]);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_group() -> Result<()> {
|
||||
assert_eq!(
|
||||
group("Empty".into(), vec![], 0)?,
|
||||
Group("Empty".into(), vec![], vec![])
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn messagepack_f64() -> Result<()> {
|
||||
let fraction = 23.0_f64 / 24.0;
|
||||
let mut expected = vec![0xcb];
|
||||
expected.extend(fraction.to_be_bytes());
|
||||
assert_eq!(rmp_serde::to_vec(&Some(fraction))?, expected);
|
||||
assert_eq!(rmp_serde::to_vec(&None::<f64>)?, [0xc0]);
|
||||
let snapshot = Snapshot(
|
||||
0,
|
||||
vec![group(
|
||||
"Test".into(),
|
||||
vec![row(vec![(0.0, fraction.to_string())])],
|
||||
DAY * -29,
|
||||
)?],
|
||||
);
|
||||
let encoded = rmp_serde::to_vec(&snapshot)?;
|
||||
let decoded: Snapshot = rmp_serde::from_slice(&encoded).map_err(std::io::Error::other)?;
|
||||
assert_eq!(snapshot, decoded);
|
||||
let wire: serde_json::Value =
|
||||
rmp_serde::from_slice(&encoded).map_err(std::io::Error::other)?;
|
||||
assert_eq!(wire[1][0][1], serde_json::json!(["example"]));
|
||||
assert_eq!(encoded[0], 0x92);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
114
services/cluster-health-api/src/server.rs
Normal file
114
services/cluster-health-api/src/server.rs
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
use crate::{Error, Result};
|
||||
use std::{
|
||||
io::Cursor,
|
||||
net::TcpListener,
|
||||
sync::{Arc, RwLock},
|
||||
};
|
||||
use tiny_http::{Header, Method, Response, Server};
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct Cache(Arc<RwLock<Option<Vec<u8>>>>);
|
||||
|
||||
impl Cache {
|
||||
pub fn replace(&self, value: Option<Vec<u8>>) -> Result<()> {
|
||||
*self.0.write().map_err(|_| Error::Cache)? = value;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn read(&self) -> Result<Option<Vec<u8>>> {
|
||||
Ok(self.0.read().map_err(|_| Error::Cache)?.clone())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn response(
|
||||
method: &Method,
|
||||
path: &str,
|
||||
cache: &Cache,
|
||||
origin: &Header,
|
||||
) -> Result<Response<Cursor<Vec<u8>>>> {
|
||||
let (status, body) = if path != "/health" {
|
||||
(404, rmp_serde::to_vec("not found")?)
|
||||
} else if method != &Method::Get {
|
||||
(405, rmp_serde::to_vec("GET only")?)
|
||||
} else {
|
||||
match cache.read()? {
|
||||
Some(body) => (200, body),
|
||||
None => (503, rmp_serde::to_vec("observations unavailable")?),
|
||||
}
|
||||
};
|
||||
let content_type = Header::from_bytes("Content-Type", "application/msgpack")
|
||||
.map_err(|_| Error::Config("Content-Type"))?;
|
||||
Ok(Response::from_data(body)
|
||||
.with_status_code(status)
|
||||
.with_header(content_type)
|
||||
.with_header(origin.clone()))
|
||||
}
|
||||
|
||||
pub fn serve(listener: TcpListener, cache: Cache, origin: Header) -> Result<()> {
|
||||
socket2::SockRef::from(&listener).set_tcp_nodelay(true)?;
|
||||
let server = Server::from_listener(listener, None).map_err(std::io::Error::other)?;
|
||||
tracing::info!(event = "listening", address = %server.server_addr());
|
||||
loop {
|
||||
let request = server.recv()?;
|
||||
let response = response(request.method(), request.url(), &cache, &origin)?;
|
||||
request.respond(response)?;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn response_states() -> Result<()> {
|
||||
let cache = Cache::default();
|
||||
let origin = Header::from_bytes("Access-Control-Allow-Origin", "https://quorate.dev")
|
||||
.map_err(|_| Error::Config("PAGE_ORIGIN"))?;
|
||||
for (method, path, status) in [
|
||||
(Method::Get, "/health", 503),
|
||||
(Method::Post, "/health", 405),
|
||||
(Method::Get, "/health?query=x", 404),
|
||||
(Method::Get, "/api/v1/query", 404),
|
||||
] {
|
||||
assert_eq!(
|
||||
response(&method, path, &cache, &origin)?.status_code().0,
|
||||
status
|
||||
);
|
||||
}
|
||||
cache.replace(Some(rmp_serde::to_vec(&Vec::<u8>::new())?))?;
|
||||
let reply = response(&Method::Get, "/health", &cache, &origin)?;
|
||||
assert_eq!(reply.status_code().0, 200);
|
||||
assert!(
|
||||
reply
|
||||
.headers()
|
||||
.iter()
|
||||
.any(|header| header.field.equiv("Content-Type")
|
||||
&& header.value.as_str() == "application/msgpack")
|
||||
);
|
||||
assert!(
|
||||
reply
|
||||
.headers()
|
||||
.iter()
|
||||
.any(|header| header.field == origin.field && header.value == origin.value)
|
||||
);
|
||||
cache.replace(None)?;
|
||||
assert_eq!(
|
||||
response(&Method::Get, "/health", &cache, &origin)?
|
||||
.status_code()
|
||||
.0,
|
||||
503
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_ownership() -> Result<()> {
|
||||
let first = Cache::default();
|
||||
let shared = first.clone();
|
||||
let second = Cache::default();
|
||||
first.replace(Some(vec![1]))?;
|
||||
assert_eq!(shared.read()?, Some(vec![1]));
|
||||
assert_eq!(second.read()?, None);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue