ce7cb702218693d08ea6d83464d2457c9250027c

This commit is contained in:
ms2k 2026-11-12 20:46:28 +09:00
commit 34c5f8963c
16 changed files with 145336 additions and 0 deletions

View file

@ -0,0 +1 @@
/target/

1204
services/cluster-health-api/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

View 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"

View 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/) |

View 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

View 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

View 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

View 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

View 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

View 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

View 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(())
}
}

View 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>;

View 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
}
}
}

View 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(())
}
}

View 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(())
}
}

View 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(())
}
}