mod alert;
mod metadata;
mod recording;
use std::collections::BTreeMap;
use serde::Serialize;
use crate::burn_rate::MwmbrConfig;
use crate::error::{Result, SlokitError};
use crate::sli::Sli;
use crate::slo::Slo;
use crate::spec::{SloSpec, Spec, DEFAULT_PERIOD};
use crate::window::Window;
#[derive(Debug, Clone)]
pub struct GenerateOptions {
pub default_period: Window,
pub mwmbr: MwmbrConfig,
}
impl Default for GenerateOptions {
fn default() -> Self {
Self {
default_period: DEFAULT_PERIOD,
mwmbr: MwmbrConfig::sre_default(),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct Rule {
#[serde(skip_serializing_if = "Option::is_none")]
record: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
alert: Option<String>,
expr: String,
#[serde(rename = "for", skip_serializing_if = "Option::is_none")]
for_: Option<String>,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
labels: BTreeMap<String, String>,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
annotations: BTreeMap<String, String>,
}
impl Rule {
fn record(
name: impl Into<String>,
expr: impl Into<String>,
labels: BTreeMap<String, String>,
) -> Self {
Self {
record: Some(name.into()),
alert: None,
expr: expr.into(),
for_: None,
labels,
annotations: BTreeMap::new(),
}
}
fn alert(
name: impl Into<String>,
expr: impl Into<String>,
labels: BTreeMap<String, String>,
annotations: BTreeMap<String, String>,
) -> Self {
Self {
record: None,
alert: Some(name.into()),
expr: expr.into(),
for_: None,
labels,
annotations,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct RuleGroup {
pub name: String,
pub rules: Vec<Rule>,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct RuleSet {
pub groups: Vec<RuleGroup>,
}
impl RuleSet {
pub fn to_prometheus_yaml(&self) -> Result<String> {
serde_norway::to_string(self).map_err(|e| SlokitError::Spec(e.to_string()))
}
pub fn to_operator_yaml(
&self,
name: &str,
labels: &BTreeMap<String, String>,
) -> Result<String> {
#[derive(Serialize)]
struct Metadata<'a> {
name: &'a str,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
labels: &'a BTreeMap<String, String>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct PrometheusRule<'a> {
api_version: &'a str,
kind: &'a str,
metadata: Metadata<'a>,
spec: &'a RuleSet,
}
let doc = PrometheusRule {
api_version: "monitoring.coreos.com/v1",
kind: "PrometheusRule",
metadata: Metadata { name, labels },
spec: self,
};
serde_norway::to_string(&doc).map_err(|e| SlokitError::Spec(e.to_string()))
}
}
struct SloContext<'a> {
service: &'a str,
spec_labels: &'a BTreeMap<String, String>,
slo_spec: &'a SloSpec,
slo: Slo,
sli: Sli,
id: String,
mwmbr: &'a MwmbrConfig,
}
impl SloContext<'_> {
fn selector(&self) -> String {
format!(
"{{sloth_id=\"{}\", sloth_service=\"{}\", sloth_slo=\"{}\"}}",
self.id, self.service, self.slo_spec.name
)
}
fn base_labels(&self) -> BTreeMap<String, String> {
let mut labels = self.spec_labels.clone();
labels.extend(self.slo_spec.labels.clone());
labels.insert("sloth_id".to_string(), self.id.clone());
labels.insert("sloth_service".to_string(), self.service.to_string());
labels.insert("sloth_slo".to_string(), self.slo_spec.name.clone());
labels
}
}
pub fn generate_rules(spec: &Spec) -> Result<RuleSet> {
generate_rules_with(spec, &GenerateOptions::default())
}
pub fn generate_rules_with(spec: &Spec, opts: &GenerateOptions) -> Result<RuleSet> {
spec.validate()?;
let mut groups = Vec::with_capacity(spec.slos.len() * 3);
for slo_spec in &spec.slos {
let ctx = SloContext {
service: &spec.service,
spec_labels: &spec.labels,
slo_spec,
slo: slo_spec.to_slo(opts.default_period)?,
sli: slo_spec.to_sli()?,
id: slo_spec.sloth_id(&spec.service),
mwmbr: &opts.mwmbr,
};
groups.push(recording::rules(&ctx));
groups.push(metadata::rules(&ctx));
groups.push(alert::rules(&ctx));
}
Ok(RuleSet { groups })
}
pub(crate) fn fmt_num(x: f64) -> String {
let s = format!("{x:.10}");
let trimmed = s.trim_end_matches('0').trim_end_matches('.');
trimmed.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE: &str = r#"
service: myservice
labels:
owner: team-platform
slos:
- name: requests-availability
objective: 99.9
sli:
events:
error_query: sum(rate(http_requests_total{code=~"5.."}[{{.window}}]))
total_query: sum(rate(http_requests_total[{{.window}}]))
alerting:
page_alert:
labels:
severity: page
ticket_alert:
labels:
severity: ticket
"#;
#[test]
fn fmt_num_trims_cleanly() {
assert_eq!(fmt_num(0.001), "0.001");
assert_eq!(fmt_num(0.999), "0.999");
assert_eq!(fmt_num(30.0), "30");
assert_eq!(fmt_num(100.0), "100");
assert_eq!(fmt_num(14.4), "14.4");
assert_eq!(fmt_num(0.0005), "0.0005");
}
#[test]
fn generates_three_groups_per_slo() {
let spec = Spec::from_yaml(SAMPLE).unwrap();
let rs = generate_rules(&spec).unwrap();
assert_eq!(rs.groups.len(), 3);
assert!(rs.groups[0].name.contains("sli-recordings"));
assert!(rs.groups[1].name.contains("meta-recordings"));
assert!(rs.groups[2].name.contains("alerts"));
}
#[test]
fn renders_prometheus_and_operator_yaml() {
let spec = Spec::from_yaml(SAMPLE).unwrap();
let rs = generate_rules(&spec).unwrap();
let prom = rs.to_prometheus_yaml().unwrap();
assert!(prom.contains("groups:"));
assert!(prom.contains("slo:sli_error:ratio_rate5m"));
let op = rs.to_operator_yaml("myservice", &spec.labels).unwrap();
assert!(op.contains("kind: PrometheusRule"));
assert!(op.contains("apiVersion: monitoring.coreos.com/v1"));
}
#[test]
fn invalid_spec_fails_generation() {
let yaml = r#"
service: s
slos:
- name: bad
objective: 150
sli:
raw:
error_ratio_query: r[{{.window}}]
"#;
let spec = Spec::from_yaml(yaml).unwrap();
assert!(generate_rules(&spec).is_err());
}
}