pub mod exposition;
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use serde::Deserialize;
use shep_client::Client;
use shep_core::protocol::{ProcessInfo, Request, Response};
use sysinfo::{MemoryRefreshKind, ProcessRefreshKind, RefreshKind, System};
use tokio::net::{TcpListener, TcpStream};
use tokio::signal::unix::{SignalKind, signal};
use super::DogRuntime;
use crate::exit::ExitCode;
use crate::http::{self, HttpError};
const READ_TIMEOUT: Duration = Duration::from_secs(5);
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct MetricsConfig {
pub bind: SocketAddr,
}
impl Default for MetricsConfig {
fn default() -> Self {
Self {
bind: SocketAddr::from(([127, 0, 0, 1], 9615)),
}
}
}
#[cfg(test)]
impl MetricsConfig {
fn default_on_port(port: u16) -> Self {
Self {
bind: SocketAddr::from(([127, 0, 0, 1], port)),
}
}
}
#[derive(Debug, Default)]
pub struct Reading {
pub flock: Vec<ProcessInfo>,
pub daemon_version: String,
pub daemon_pid: u32,
pub host: Option<HostReading>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HostReading {
pub memory_total_bytes: u64,
pub memory_used_bytes: u64,
pub processes: usize,
pub uptime_seconds: u64,
}
pub(crate) fn sample_host() -> Option<HostReading> {
if !sysinfo::IS_SUPPORTED_SYSTEM {
return None;
}
let system = System::new_with_specifics(
RefreshKind::nothing()
.with_memory(MemoryRefreshKind::everything())
.with_processes(ProcessRefreshKind::nothing()),
);
Some(HostReading {
memory_total_bytes: system.total_memory(),
memory_used_bytes: system.used_memory(),
processes: system.processes().len(),
uptime_seconds: System::uptime(),
})
}
pub async fn run(runtime: DogRuntime) -> ExitCode {
let config = match runtime.config::<MetricsConfig>() {
Ok(config) => config,
Err(_err) => {
eprintln!("shep dog metrics: [dog.metrics] does not parse; see `shep dogs`");
return ExitCode::InvalidConfig;
}
};
let listener = match TcpListener::bind(config.bind).await {
Ok(listener) => listener,
Err(err) => {
eprintln!("shep dog metrics: could not bind {}: {err}", config.bind);
return ExitCode::Failure;
}
};
let mut sigterm = match signal(SignalKind::terminate()) {
Ok(sigterm) => sigterm,
Err(err) => {
eprintln!("shep dog metrics: could not install a SIGTERM handler: {err}");
return ExitCode::Failure;
}
};
let client = Arc::new(runtime.client);
tokio::select! {
_ = tokio::signal::ctrl_c() => {}
_ = sigterm.recv() => {}
() = accept_forever(listener, client) => {}
}
ExitCode::Success
}
async fn accept_forever(listener: TcpListener, client: Arc<Client>) {
loop {
match listener.accept().await {
Ok((stream, _peer)) => {
let client = Arc::clone(&client);
tokio::spawn(handle_connection(stream, client));
}
Err(err) => {
eprintln!("shep dog metrics: accept failed: {err}");
}
}
}
}
async fn handle_connection(mut stream: TcpStream, client: Arc<Client>) {
let request = match http::read_request(&mut stream, READ_TIMEOUT).await {
Ok(request) => request,
Err(_err) => return,
};
let path = request
.target
.split('?')
.next()
.unwrap_or(request.target.as_str());
if path != "/metrics" {
let _: Result<(), HttpError> = http::write_response(
&mut stream,
404,
"text/plain",
b"not found; metrics are served at /metrics\n",
)
.await;
return;
}
let flock = match client.request(Request::ListFlock).await {
Ok(Response::Flock(flock)) => flock,
Ok(_) | Err(_) => {
let _: Result<(), HttpError> = http::write_response(
&mut stream,
503,
"text/plain",
b"the shepherd did not answer\n",
)
.await;
return;
}
};
let reading = Reading {
flock,
daemon_version: client.daemon().daemon_version.clone(),
daemon_pid: client.daemon().pid,
host: sample_host(),
};
let body = exposition::render(&reading);
let _: Result<(), HttpError> = http::write_response(
&mut stream,
200,
"text/plain; version=0.0.4",
body.as_bytes(),
)
.await;
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use shep_client::testing::{fake_client_on, fake_client_that_dies_mid_request};
use shep_core::protocol::ProcessInfo;
use shep_core::status::ProcStatus;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::task::JoinHandle;
use super::*;
fn sample_info(name: &str) -> ProcessInfo {
ProcessInfo::builder(1, name, ProcStatus::Online)
.pid(Some(4242))
.uptime_ms(1_000)
.cpu_percent(Some(0.5))
.memory_bytes(Some(1024))
.build()
}
struct RunningDog {
addr: SocketAddr,
handle: JoinHandle<()>,
}
impl RunningDog {
fn addr(&self) -> SocketAddr {
self.addr
}
}
impl Drop for RunningDog {
fn drop(&mut self) {
self.handle.abort();
}
}
async fn serve_on_free_port(client: Client, config: MetricsConfig) -> RunningDog {
let listener = TcpListener::bind(config.bind).await.unwrap();
let addr = listener.local_addr().unwrap();
let handle = tokio::spawn(accept_forever(listener, Arc::new(client)));
RunningDog { addr, handle }
}
async fn scrape(addr: SocketAddr, path: &str) -> String {
let mut stream = tokio::time::timeout(Duration::from_secs(5), TcpStream::connect(addr))
.await
.expect("connect must not hang")
.unwrap();
let request = format!("GET {path} HTTP/1.1\r\nHost: localhost\r\n\r\n");
tokio::time::timeout(Duration::from_secs(5), stream.write_all(request.as_bytes()))
.await
.expect("write must not hang")
.unwrap();
let mut buf = Vec::new();
tokio::time::timeout(Duration::from_secs(5), stream.read_to_end(&mut buf))
.await
.expect("read must not hang")
.unwrap();
String::from_utf8(buf).expect("the exposition is ASCII/UTF-8")
}
#[tokio::test]
async fn every_scrape_asks_the_shepherd_again() {
let dir = tempfile::tempdir().unwrap();
let socket = dir.path().join("s.sock");
let (client, daemon) = fake_client_on(&socket).await;
daemon.reply_to_list_sequence(vec![
vec![sample_info("web")],
vec![sample_info("web"), sample_info("api")],
]);
let dog = serve_on_free_port(client, MetricsConfig::default_on_port(0)).await;
let first = scrape(dog.addr(), "/metrics").await;
assert!(first.contains(r#"sheep="web""#), "{first}");
assert!(!first.contains(r#"sheep="api""#), "{first}");
let second = scrape(dog.addr(), "/metrics").await;
assert!(
second.contains(r#"sheep="api""#),
"the second scrape must see the second listing: {second}"
);
assert_eq!(daemon.list_flock_count(), 2, "one ListFlock per scrape");
}
#[test]
fn the_default_bind_is_loopback() {
assert_eq!(
MetricsConfig::default().bind,
"127.0.0.1:9615".parse::<SocketAddr>().unwrap()
);
let parsed: MetricsConfig = toml::from_str("").unwrap();
assert_eq!(parsed, MetricsConfig::default());
}
#[tokio::test]
async fn a_shepherd_that_will_not_answer_produces_a_503() {
let dir = tempfile::tempdir().unwrap();
let socket = dir.path().join("s.sock");
let (client, _task) = fake_client_that_dies_mid_request(&socket).await;
let dog = serve_on_free_port(client, MetricsConfig::default_on_port(0)).await;
let response = scrape(dog.addr(), "/metrics").await;
assert!(response.starts_with("HTTP/1.1 503 "), "{response}");
}
#[tokio::test]
async fn only_the_metrics_path_serves_metrics() {
let dir = tempfile::tempdir().unwrap();
let socket = dir.path().join("s.sock");
let (client, _daemon) = fake_client_on(&socket).await;
let dog = serve_on_free_port(client, MetricsConfig::default_on_port(0)).await;
let root = scrape(dog.addr(), "/").await;
assert!(root.starts_with("HTTP/1.1 404 "), "{root}");
assert!(root.contains("/metrics"), "{root}");
let metrics = scrape(dog.addr(), "/metrics").await;
assert!(metrics.starts_with("HTTP/1.1 200 "), "{metrics}");
}
}