use std::{
collections::{BTreeMap, BTreeSet},
fs,
path::Path,
};
use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
use serde_json::json;
use crate::model::{Plan, ToolName};
const CONTRACT_VERSION: u32 = 1;
#[derive(Debug, Deserialize)]
pub struct Targets {
pub targets: BTreeMap<String, String>,
}
#[derive(Debug, Serialize)]
struct Contract<'a> {
contract_version: u32,
tool: &'a str,
metrics_path: Option<&'a str>,
required_metrics: &'a [&'a str],
unavailable_reason: Option<&'a str>,
}
fn contract(tool: ToolName) -> Contract<'static> {
match tool {
ToolName::Quipu => Contract {
contract_version: CONTRACT_VERSION,
tool: "quipu",
metrics_path: Some("/metrics"),
required_metrics: &["quipu_graph_facts", "quipu_http_requests_total"],
unavailable_reason: None,
},
ToolName::Bobbin => Contract {
contract_version: CONTRACT_VERSION,
tool: "bobbin",
metrics_path: Some("/metrics"),
required_metrics: &["bobbin_up", "bobbin_index_chunks_total"],
unavailable_reason: None,
},
ToolName::Camayoc => unavailable("camayoc", "no standalone metrics exporter"),
ToolName::Yupana => unavailable("yupana", "no metrics endpoint in the pinned release"),
ToolName::DesirePath => unavailable(
"desire-path",
"no metrics endpoint in the pinned source revision",
),
ToolName::Member(name) => unavailable(name, "a stack member declares no metrics contract"),
}
}
fn unavailable(tool: &'static str, reason: &'static str) -> Contract<'static> {
Contract {
contract_version: CONTRACT_VERSION,
tool,
metrics_path: None,
required_metrics: &[],
unavailable_reason: Some(reason),
}
}
fn validate_target(tool: &str, target: &str) -> Result<()> {
if target.is_empty()
|| target.contains('/')
|| target.contains(char::is_whitespace)
|| !target
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | ':' | '-' | '_' | '[' | ']'))
{
bail!("invalid Prometheus target for {tool}: {target}");
}
Ok(())
}
pub fn render(plan: &Plan, targets_path: &Path, output: &Path) -> Result<()> {
let targets: Targets = toml::from_str(
&fs::read_to_string(targets_path)
.with_context(|| format!("read observability targets {}", targets_path.display()))?,
)
.context("parse observability targets")?;
let contracts: Vec<_> = plan.tools.iter().copied().map(contract).collect();
for item in &contracts {
match item.metrics_path {
Some(_) => {
let target = targets
.targets
.get(item.tool)
.with_context(|| format!("missing metrics target for {}", item.tool))?;
validate_target(item.tool, target)?;
}
None if targets.targets.contains_key(item.tool) => {
bail!(
"{} has a target but its versioned contract has no exporter",
item.tool
)
}
None => {}
}
}
for name in targets.targets.keys() {
if !contracts.iter().any(|item| item.tool == name) {
bail!("metrics target {name} is not selected by the reviewed plan");
}
}
fs::create_dir_all(output)?;
fs::write(
output.join("contracts.json"),
serde_json::to_vec_pretty(&json!({"schema_version": 1, "contracts": contracts}))?,
)?;
let observable: Vec<_> = contracts
.iter()
.filter(|item| item.metrics_path.is_some())
.collect();
let mut prometheus =
String::from("# Generated by caboodle; review before deployment.\nscrape_configs:\n");
let mut alerts = String::from("# Generated by caboodle; review before deployment.\ngroups:\n - name: caboodle-starter\n rules:\n");
let mut panels = Vec::new();
for (index, item) in observable.iter().enumerate() {
let target = &targets.targets[item.tool];
prometheus.push_str(&format!(
" - job_name: 'caboodle-{}'\n metrics_path: '{}'\n static_configs:\n - targets: ['{}']\n",
item.tool,
item.metrics_path.unwrap(),
target
));
let metric = item.required_metrics[0];
alerts.push_str(&format!(
" - alert: Caboodle{}MetricsAbsent\n expr: absent({})\n for: 10m\n labels:\n severity: warning\n annotations:\n summary: '{} metrics contract is not observable'\n",
title(item.tool), metric, item.tool
));
panels.push(json!({
"id": index + 1,
"title": format!("{} contract signal", title(item.tool)),
"type": "timeseries",
"targets": [{"expr": metric, "refId": "A"}]
}));
}
fs::write(output.join("prometheus.yml"), prometheus)?;
fs::write(output.join("alerts.yml"), alerts)?;
fs::write(
output.join("dashboard.json"),
serde_json::to_vec_pretty(&json!({
"schemaVersion": 39,
"title": "CABOODLE selected corpus",
"panels": panels
}))?,
)?;
validate(output)?;
Ok(())
}
pub fn validate(output: &Path) -> Result<()> {
let contracts: serde_json::Value =
serde_json::from_slice(&fs::read(output.join("contracts.json"))?)?;
let entries = contracts["contracts"]
.as_array()
.context("contracts.json omitted contracts array")?;
let mut metrics = BTreeSet::new();
let mut observable = 0;
for entry in entries {
if entry["contract_version"].as_u64() != Some(CONTRACT_VERSION.into()) {
bail!("unsupported or missing observability contract version");
}
if !entry["required_metrics"]
.as_array()
.context("contract omitted required_metrics")?
.is_empty()
{
observable += 1;
}
for metric in entry["required_metrics"].as_array().unwrap() {
metrics.insert(
metric
.as_str()
.context("contract metric is not a string")?
.to_owned(),
);
}
}
let prometheus = fs::read_to_string(output.join("prometheus.yml"))?;
if prometheus.matches("job_name:").count() != observable {
bail!("scrape config does not cover every observable contract");
}
let alerts = fs::read_to_string(output.join("alerts.yml"))?;
for line in alerts
.lines()
.filter(|line| line.trim_start().starts_with("expr: absent("))
{
let metric = line
.trim()
.strip_prefix("expr: absent(")
.and_then(|value| value.strip_suffix(')'))
.context("malformed generated alert expression")?;
if !metrics.contains(metric) {
bail!("alert references uncovered metric {metric}");
}
}
let dashboard: serde_json::Value =
serde_json::from_slice(&fs::read(output.join("dashboard.json"))?)?;
for panel in dashboard["panels"]
.as_array()
.context("dashboard omitted panels")?
{
let metric = panel["targets"][0]["expr"]
.as_str()
.context("dashboard panel omitted expression")?;
if !metrics.contains(metric) {
bail!("dashboard references uncovered metric {metric}");
}
}
Ok(())
}
fn title(name: &str) -> String {
name.split(['-', '_'])
.map(|part| {
let mut chars = part.chars();
chars
.next()
.map(|first| first.to_uppercase().collect::<String>() + chars.as_str())
.unwrap_or_default()
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::Profile;
#[test]
fn render_is_deterministic_and_covers_every_selected_tool() {
let root = tempfile::tempdir().unwrap();
let targets = root.path().join("targets.toml");
fs::write(
&targets,
"[targets]\nquipu='127.0.0.1:8080'\nbobbin='127.0.0.1:3030'\n",
)
.unwrap();
let first = root.path().join("first");
let second = root.path().join("second");
let plan = Plan::for_profile(Profile::Everything);
render(&plan, &targets, &first).unwrap();
render(&plan, &targets, &second).unwrap();
for name in [
"contracts.json",
"prometheus.yml",
"alerts.yml",
"dashboard.json",
] {
assert_eq!(
fs::read(first.join(name)).unwrap(),
fs::read(second.join(name)).unwrap()
);
}
let contracts = fs::read_to_string(first.join("contracts.json")).unwrap();
for tool in ["quipu", "camayoc", "bobbin", "yupana", "desire-path"] {
assert!(contracts.contains(tool));
}
}
#[test]
fn missing_or_malformed_contract_targets_fail() {
let root = tempfile::tempdir().unwrap();
let targets = root.path().join("targets.toml");
let plan = Plan::for_profile(Profile::Retrieval);
fs::write(&targets, "[targets]\nquipu='127.0.0.1:8080'\n").unwrap();
assert!(render(&plan, &targets, &root.path().join("missing")).is_err());
fs::write(
&targets,
"[targets]\nquipu='http://host'\nbobbin='127.0.0.1:3030'\n",
)
.unwrap();
assert!(render(&plan, &targets, &root.path().join("malformed")).is_err());
}
#[test]
fn uncovered_dashboard_metric_fails_validation() {
let root = tempfile::tempdir().unwrap();
let targets = root.path().join("targets.toml");
let output = root.path().join("output");
fs::write(
&targets,
"[targets]\nquipu='127.0.0.1:8080'\nbobbin='127.0.0.1:3030'\n",
)
.unwrap();
render(&Plan::for_profile(Profile::Retrieval), &targets, &output).unwrap();
let path = output.join("dashboard.json");
let mut dashboard: serde_json::Value =
serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
dashboard["panels"][0]["targets"][0]["expr"] = json!("invented_metric");
fs::write(&path, serde_json::to_vec_pretty(&dashboard).unwrap()).unwrap();
assert!(validate(&output).is_err());
}
}