use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use autumn_web::slo::{self, BURN_WINDOWS, PPM, ROLLBACK_BURN_TENTHS, Sli, Slo, format_decimal};
use serde::Serialize;
pub const RECORDING_WINDOWS: [&str; 6] = ["5m", "30m", "1h", "6h", "3d", "30d"];
const RAW_WINDOWS: [&str; 4] = ["5m", "30m", "1h", "6h"];
const ANALYSIS_WINDOW: &str = "5m";
const RESERVED_LABELS: [&str; 13] = [
"__name__",
"alertname",
"app",
"le",
"long_window",
"route",
"severity",
"short_window",
"slo",
"slo_scope",
"status",
"status_class",
"version",
];
pub const DEFAULT_OUT_DIR: &str = "deploy/slo";
pub const DEFAULT_PROMETHEUS_URL: &str = "http://prometheus.monitoring.svc:9090";
#[derive(Debug, Clone)]
pub struct GenerateOptions {
pub app: String,
pub selector: Option<String>,
pub prometheus_url: String,
pub out_dir: String,
pub rule_labels: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GeneratedFile {
pub name: &'static str,
pub contents: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Generated {
pub files: Vec<GeneratedFile>,
pub warnings: Vec<String>,
}
pub fn generate(slos: &[Slo], options: &GenerateOptions) -> Result<Generated, String> {
if slos.is_empty() {
return Err(
"autumn.toml has no [[slo]] tables. Add one, for example:\n\n\
[[slo]]\nname = \"availability\"\nobjective = 99.9\nsli = \"availability\"\n"
.to_owned(),
);
}
let app = kube_name(&options.app)?;
let matchers = parse_selector(options.selector.as_deref().unwrap_or(""))?;
let object_labels = parse_rule_labels(&options.rule_labels)?;
for slo in slos {
let name = metric_template_name(&app, slo);
if name.len() > 63 {
return Err(format!(
"the object name {name:?} is longer than 63 characters; \
use a shorter --app or SLO name"
));
}
}
let selector = matchers
.iter()
.map(Matcher::render)
.collect::<Vec<_>>()
.join(",");
let scope = format!("{:08x}", fnv1a(&selector) & 0xffff_ffff);
let rule_labels: Vec<(String, String)> = matchers
.iter()
.filter(|m| m.op == "=")
.map(|m| (m.name.clone(), unescape(&m.value)))
.collect();
let own = [format!("app=\"{app}\""), format!("slo_scope=\"{scope}\"")]
.into_iter()
.chain(
matchers
.iter()
.filter(|m| m.op == "=")
.map(|m| format!("{}=\"{}\"", m.name, m.value)),
)
.collect::<Vec<_>>()
.join(",");
let ctx = Context {
app,
selector,
scope,
rule_labels,
object_labels,
own,
prometheus_url: options.prometheus_url.clone(),
out_dir: options.out_dir.clone(),
};
let files = vec![
GeneratedFile {
name: "prometheus-rules.yaml",
contents: render_rule_file(slos, &ctx),
},
GeneratedFile {
name: "prometheus-rule.yaml",
contents: render_prometheus_rule(slos, &ctx),
},
GeneratedFile {
name: "grafana-dashboard.json",
contents: render_dashboard(slos, &ctx),
},
GeneratedFile {
name: "argo-analysis-template.yaml",
contents: render_argo(slos, &ctx),
},
GeneratedFile {
name: "flagger-metric-templates.yaml",
contents: render_flagger(slos, &ctx),
},
GeneratedFile {
name: "helm-values.yaml",
contents: render_helm_values(slos, &ctx),
},
];
Ok(Generated {
files,
warnings: warnings(slos),
})
}
struct Context {
app: String,
selector: String,
scope: String,
rule_labels: Vec<(String, String)>,
object_labels: Vec<(String, String)>,
own: String,
prometheus_url: String,
out_dir: String,
}
impl Context {
fn own(&self, slo: &Slo) -> String {
format!("{},slo=\"{}\"", self.own, slo.name)
}
}
pub fn kube_name(raw: &str) -> Result<String, String> {
let name = raw.trim().to_ascii_lowercase().replace('_', "-");
let valid = !name.is_empty()
&& name.len() <= 50
&& name.starts_with(|c: char| c.is_ascii_lowercase())
&& !name.ends_with('-')
&& name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
if valid {
Ok(name)
} else {
Err(format!(
"app name {raw:?} is not a valid Kubernetes name; use --app with 1 to 50 \
lowercase letters, digits or '-', starting with a letter"
))
}
}
fn fnv1a(text: &str) -> u64 {
text.bytes().fold(0xcbf2_9ce4_8422_2325_u64, |h, b| {
(h ^ u64::from(b)).wrapping_mul(0x0100_0000_01b3)
})
}
fn unescape(value: &str) -> String {
let mut out = String::with_capacity(value.len());
let mut chars = value.chars();
while let Some(c) = chars.next() {
if c == '\\' {
if let Some(next) = chars.next() {
out.push(next);
}
} else {
out.push(c);
}
}
out
}
const OWN_OBJECT_LABELS: [&str; 2] = ["app.kubernetes.io/name", "app.kubernetes.io/managed-by"];
fn is_label_segment(text: &str) -> bool {
text.len() <= 63
&& text
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
&& text.starts_with(|c: char| c.is_ascii_alphanumeric())
&& text.ends_with(|c: char| c.is_ascii_alphanumeric())
}
fn parse_rule_labels(raw: &[String]) -> Result<Vec<(String, String)>, String> {
let mut labels = std::collections::BTreeMap::new();
for item in raw {
let fail = |why: &str| Err(format!("--rule-label {item:?} is not valid: {why}"));
let Some((key, value)) = item.split_once('=') else {
return fail("give NAME=VALUE");
};
let (prefix, name) = key
.split_once('/')
.map_or((None, key), |(p, n)| (Some(p), n));
let prefix_ok = prefix.is_none_or(|p| {
p.len() <= 253
&& p.split('.').all(|part| {
!part.is_empty()
&& part.len() <= 63
&& part
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
&& !part.starts_with('-')
&& !part.ends_with('-')
})
});
if !prefix_ok || !is_label_segment(name) {
return fail("the name is not a Kubernetes label key");
}
if !value.is_empty() && !is_label_segment(value) {
return fail("the value is not a Kubernetes label value");
}
if OWN_OBJECT_LABELS.contains(&key) {
return fail("the generator sets this label");
}
if labels.insert(key.to_owned(), value.to_owned()).is_some() {
return fail("the label is given twice");
}
}
Ok(labels.into_iter().collect())
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct Matcher {
name: String,
op: &'static str,
value: String,
}
impl Matcher {
fn render(&self) -> String {
format!("{}{}\"{}\"", self.name, self.op, self.value)
}
}
fn parse_selector(raw: &str) -> Result<Vec<Matcher>, String> {
let fail = |why: &str| {
Err(format!(
"--selector {raw:?} is not valid: {why}. Give label matchers such as \
'job=\"shop\",namespace=\"prod\"'"
))
};
let chars: Vec<char> = raw.chars().collect();
let mut matchers = Vec::new();
let mut i = 0;
loop {
while i < chars.len() && (chars[i] == ' ' || chars[i] == ',') {
i += 1;
}
if i == chars.len() {
break;
}
let start = i;
while i < chars.len() && (chars[i].is_ascii_alphanumeric() || chars[i] == '_') {
i += 1;
}
let name: String = chars[start..i].iter().collect();
if name.is_empty() || name.starts_with(|c: char| c.is_ascii_digit()) {
return fail("a label name must start with a letter or '_'");
}
if RESERVED_LABELS.contains(&name.as_str()) {
return fail(&format!("the label {name} is set by the generator"));
}
let rest: String = chars[i..].iter().take(2).collect();
let op = ["=~", "!~", "!=", "="]
.into_iter()
.find(|op| rest.starts_with(op));
let Some(op) = op else {
return fail("a matcher needs =, !=, =~ or !~");
};
i += op.len();
if chars.get(i) != Some(&'"') {
return fail("a value must be in double quotes");
}
i += 1;
let value_start = i;
loop {
match chars.get(i) {
None | Some('\n' | '\r') => return fail("a value has no closing quote"),
Some('\\') if matches!(chars.get(i + 1), Some('"' | '\\')) => i += 2,
Some('\\') => return fail("a value may escape only \\\" and \\\\"),
Some('"') => break,
Some(_) => i += 1,
}
}
let value: String = chars[value_start..i.min(chars.len())].iter().collect();
i += 1;
if value.contains("{{") || value.contains("}}") {
return fail("a value must not contain {{ or }}");
}
if !value.chars().all(|c| c == ' ' || c.is_ascii_graphic()) {
return fail("a value must hold printable ASCII only");
}
if op == "="
&& matchers
.iter()
.any(|m: &Matcher| m.op == "=" && m.name == name)
{
return fail(&format!("the label {name} has two = matchers"));
}
matchers.push(Matcher { name, op, value });
if i < chars.len() && chars[i] != ',' && chars[i] != ' ' {
return fail("put a comma between matchers");
}
}
Ok(matchers)
}
fn warnings(slos: &[Slo]) -> Vec<String> {
let mut out = Vec::new();
let histogram: Vec<&str> = slos
.iter()
.filter(|slo| matches!(slo.sli, Sli::Latency { .. }) || slo.route().is_some())
.map(|slo| slo.name.as_str())
.collect();
if !histogram.is_empty() {
out.push(format!(
"SLO {} reads the {DURATION_BUCKET} histogram (issue #3064). Make sure \
/actuator/prometheus exports it, or these rules return no data and their \
alerts and canary checks never fail.",
histogram.join(", "),
));
}
for slo in slos {
let fast = u64::from(slo.budget_ppm()) * u64::from(BURN_WINDOWS[0].factor_tenths) / 10;
if fast >= u64::from(PPM) {
out.push(format!(
"SLO {}: the error budget is so large that the {}x burn-rate page can never \
fire. Use a higher objective, or rely on the slower alerts.",
slo.name,
format_decimal(u64::from(BURN_WINDOWS[0].factor_tenths), 1),
));
}
}
out
}
const RESPONSES: &str = "autumn_http_responses_total";
const DURATION_COUNT: &str = "autumn_http_request_duration_seconds_count";
const DURATION_BUCKET: &str = "autumn_http_request_duration_seconds_bucket";
#[must_use]
pub fn recording_name(window: &str) -> String {
format!("autumn_slo:sli_error:ratio_rate{window}")
}
fn series(metric: &str, matchers: &[&str]) -> String {
let parts: Vec<&str> = matchers.iter().copied().filter(|m| !m.is_empty()).collect();
if parts.is_empty() {
metric.to_owned()
} else {
format!("{metric}{{{}}}", parts.join(","))
}
}
#[must_use]
pub fn le_matcher(threshold_ms: u64) -> String {
let le = format_decimal(threshold_ms, 3);
if threshold_ms.is_multiple_of(1_000) {
format!("le=~\"{le}(\\\\.0)?\"")
} else {
format!("le=\"{le}\"")
}
}
fn bad_and_total(slo: &Slo, extra: &[&str], window: &str) -> (String, String) {
let route = slo.route().map(|r| format!("route=\"{r}\""));
let route = route.as_deref().unwrap_or("");
let with = |base: &[&str]| -> Vec<String> {
base.iter().chain(extra).map(|m| (*m).to_owned()).collect()
};
let rate = |metric, matchers: Vec<String>| {
let matchers: Vec<&str> = matchers.iter().map(String::as_str).collect();
format!("sum(rate({}[{window}]))", series(metric, &matchers))
};
match &slo.sli {
Sli::Availability { route: None } => (
format!(
"({} or vector(0))",
rate(RESPONSES, with(&["status=\"5xx\""]))
),
rate(RESPONSES, with(&[])),
),
Sli::Availability { route: Some(_) } => (
format!(
"({} or vector(0))",
rate(DURATION_COUNT, with(&[route, "status_class=\"5xx\""]))
),
rate(DURATION_COUNT, with(&[route])),
),
Sli::Latency { threshold_ms, .. } => {
let scope = if route.is_empty() {
"route!=\"_unmatched\""
} else {
route
};
let le = le_matcher(*threshold_ms);
let total = rate(DURATION_COUNT, with(&[scope, "status_class!=\"5xx\""]));
let good = rate(
DURATION_BUCKET,
with(&[scope, "status_class!=\"5xx\"", le.as_str()]),
);
(format!("(\n {total}\n -\n {good}\n)"), total)
}
}
}
#[must_use]
pub fn error_ratio(slo: &Slo, extra: &[&str], window: &str) -> String {
let (bad, total) = bad_and_total(slo, extra, window);
format!("{bad}\n/\n{total}")
}
fn rate_name(kind: &str) -> String {
format!("autumn_slo:sli_{kind}:rate5m")
}
fn ratio(ppm: u64) -> String {
format_decimal(ppm, 6)
}
fn burn_threshold(slo: &Slo, factor_tenths: u32) -> String {
format_decimal(u64::from(slo.budget_ppm()) * u64::from(factor_tenths), 7)
}
fn sli_text(slo: &Slo) -> String {
let scope = slo
.route()
.map_or_else(|| "all routes".to_owned(), |route| format!("route {route}"));
match &slo.sli {
Sli::Availability { .. } => format!("non-5xx responses, {scope}"),
Sli::Latency { threshold_ms, .. } => {
format!("requests faster than {threshold_ms} ms, {scope}")
}
}
}
fn yaml_str(value: &str) -> String {
serde_json::to_string(value).unwrap_or_else(|_| "\"\"".to_owned())
}
fn block(out: &mut String, indent: usize, text: &str) {
for line in text.lines() {
let _ = writeln!(out, "{:indent$}{line}", "");
}
}
fn header(out: &mut String) {
out.push_str(
"# Generated by `autumn slo generate` from the [[slo]] tables in autumn.toml.\n\
# Do not edit. Run the command again after you change an SLO.\n",
);
}
fn render_groups(out: &mut String, slos: &[Slo], ctx: &Context, indent: usize) {
let pad = " ".repeat(indent);
let _ = writeln!(out, "{pad}groups:");
let selector = ctx.selector.as_str();
for slo in slos {
let labels = |out: &mut String, extra: &[(&str, &str)]| {
let _ = writeln!(out, "{pad} labels:");
let _ = writeln!(out, "{pad} app: {}", yaml_str(&ctx.app));
let _ = writeln!(out, "{pad} slo: {}", yaml_str(&slo.name));
let _ = writeln!(out, "{pad} slo_scope: {}", yaml_str(&ctx.scope));
for (key, value) in &ctx.rule_labels {
let _ = writeln!(out, "{pad} {}: {}", yaml_str(key), yaml_str(value));
}
for (key, value) in extra {
let _ = writeln!(out, "{pad} {key}: {}", yaml_str(value));
}
};
let _ = writeln!(
out,
"{pad} - name: {}",
yaml_str(&format!("autumn-slo-{}-{}", ctx.app, slo.name))
);
let own = ctx.own(slo);
let _ = writeln!(out, "{pad} rules:");
let _ = writeln!(out, "{pad} - record: autumn_slo:objective:ratio");
let _ = writeln!(
out,
"{pad} expr: vector({})",
ratio(u64::from(slo.objective_ppm))
);
labels(out, &[]);
let (bad, total) = bad_and_total(slo, &[selector], "5m");
for (kind, expr) in [("bad", bad), ("total", total)] {
let _ = writeln!(out, "{pad} - record: {}", rate_name(kind));
let _ = writeln!(out, "{pad} expr: |-");
block(out, indent + 10, &expr);
labels(out, &[]);
}
for window in RECORDING_WINDOWS {
let expr = if RAW_WINDOWS.contains(&window) {
error_ratio(slo, &[selector], window)
} else {
format!(
"sum_over_time({bad}{{{own}}}[{window}])\n/\nsum_over_time({total}{{{own}}}[{window}])",
bad = rate_name("bad"),
total = rate_name("total"),
)
};
let _ = writeln!(out, "{pad} - record: {}", recording_name(window));
let _ = writeln!(out, "{pad} expr: |-");
block(out, indent + 10, &expr);
labels(out, &[]);
}
for window in BURN_WINDOWS {
let threshold = burn_threshold(slo, window.factor_tenths);
let factor = format_decimal(u64::from(window.factor_tenths), 1);
let _ = writeln!(out, "{pad} - alert: AutumnSloErrorBudgetBurn");
let _ = writeln!(out, "{pad} expr: |-");
let expr = format!(
"{long}{{{own}}} > {threshold}\nand\n{short}{{{own}}} > {threshold}",
long = recording_name(window.long),
short = recording_name(window.short),
);
block(out, indent + 10, &expr);
labels(
out,
&[
("severity", window.severity),
("long_window", window.long),
("short_window", window.short),
],
);
let _ = writeln!(out, "{pad} annotations:");
let _ = writeln!(
out,
"{pad} summary: {}",
yaml_str(&format!(
"{} SLO {} burns its error budget at {factor}x or more",
ctx.app, slo.name
))
);
let objective = format_decimal(u64::from(slo.objective_ppm), 4);
let mut description = format!(
"Objective {objective}% ({}). The error ratio is above {threshold} over \
{} and {}.",
sli_text(slo),
window.long,
window.short,
);
if let Some(extra) = &slo.description {
description.push(' ');
description.push_str(extra);
}
let _ = writeln!(
out,
"{pad} description: {}",
yaml_str(&description)
);
}
}
}
fn render_rule_file(slos: &[Slo], ctx: &Context) -> String {
let mut out = String::new();
header(&mut out);
render_groups(&mut out, slos, ctx, 0);
out
}
fn object_labels(out: &mut String, app: &str) {
out.push_str(" labels:\n");
let _ = writeln!(out, " app.kubernetes.io/name: {}", yaml_str(app));
out.push_str(" app.kubernetes.io/managed-by: autumn\n");
}
fn render_prometheus_rule(slos: &[Slo], ctx: &Context) -> String {
let mut out = String::new();
header(&mut out);
out.push_str("apiVersion: monitoring.coreos.com/v1\nkind: PrometheusRule\nmetadata:\n");
let _ = writeln!(out, " name: {}", yaml_str(&format!("{}-slo", ctx.app)));
object_labels(&mut out, &ctx.app);
for (key, value) in &ctx.object_labels {
let _ = writeln!(out, " {}: {}", yaml_str(key), yaml_str(value));
}
out.push_str("spec:\n");
render_groups(&mut out, slos, ctx, 2);
out
}
#[must_use]
pub fn metric_template_name(app: &str, slo: &Slo) -> String {
format!("{app}-{}", slo.name)
}
fn burn_rate(slo: &Slo, extra: &[&str], window: &str) -> String {
format!(
"(\n{}\n)\n/ {}",
indent_lines(&error_ratio(slo, extra, window), 2),
ratio(u64::from(slo.budget_ppm()))
)
}
fn indent_lines(text: &str, by: usize) -> String {
text.lines()
.map(|line| format!("{:by$}{line}", ""))
.collect::<Vec<_>>()
.join("\n")
}
fn max_burn_text() -> String {
format_decimal(u64::from(ROLLBACK_BURN_TENTHS), 1)
}
fn render_argo(slos: &[Slo], ctx: &Context) -> String {
let mut out = String::new();
header(&mut out);
out.push_str(
"# The Helm chart sets AUTUMN_DEPLOY_VERSION to the pod-template hash, so the\n\
# `version` label tells canary pods from stable pods.\n",
);
out.push_str("apiVersion: argoproj.io/v1alpha1\nkind: AnalysisTemplate\nmetadata:\n");
let _ = writeln!(out, " name: {}", yaml_str(&format!("{}-slo", ctx.app)));
object_labels(&mut out, &ctx.app);
out.push_str("spec:\n args:\n - name: canary-hash\n - name: prometheus-address\n");
let _ = writeln!(out, " value: {}", yaml_str(&ctx.prometheus_url));
out.push_str(" metrics:\n");
let canary = "version=\"{{args.canary-hash}}\"";
let selector = ctx.selector.as_str();
let max = max_burn_text();
for slo in slos {
let _ = writeln!(out, " - name: {}", yaml_str(&slo.name));
out.push_str(" interval: 1m\n failureLimit: 1\n");
let _ = writeln!(
out,
" successCondition: {}",
yaml_str(&format!(
"len(result) == 0 || isNaN(result[0]) || result[0] <= {max}"
))
);
out.push_str(
" provider:\n prometheus:\n address: \"{{args.prometheus-address}}\"\n query: |-\n",
);
block(
&mut out,
12,
&burn_rate(slo, &[canary, selector], ANALYSIS_WINDOW),
);
}
out
}
fn render_flagger(slos: &[Slo], ctx: &Context) -> String {
let mut out = String::new();
header(&mut out);
out.push_str(
"# Each query returns the canary burn rate. The Helm chart fails the canary\n\
# above analysis.maxBurnRate.\n",
);
let canary = "namespace=\"{{ namespace }}\",pod=~\"{{ target }}-[0-9a-zA-Z]+(-[0-9a-zA-Z]+)\"";
let selector = ctx.selector.as_str();
for (index, slo) in slos.iter().enumerate() {
if index > 0 {
out.push_str("---\n");
}
out.push_str("apiVersion: flagger.app/v1beta1\nkind: MetricTemplate\nmetadata:\n");
let _ = writeln!(
out,
" name: {}",
yaml_str(&metric_template_name(&ctx.app, slo))
);
object_labels(&mut out, &ctx.app);
out.push_str("spec:\n provider:\n type: prometheus\n");
let _ = writeln!(out, " address: {}", yaml_str(&ctx.prometheus_url));
out.push_str(" query: |-\n");
block(
&mut out,
4,
&burn_rate(slo, &[canary, selector], ANALYSIS_WINDOW),
);
}
out
}
fn comment_dir(out_dir: &str) -> String {
let dir = out_dir.trim_start_matches("./").trim_end_matches('/');
if dir.is_empty() {
".".to_owned()
} else if Path::new(dir).is_absolute()
|| dir.starts_with(['/', '\\'])
|| dir.contains(':')
{
"<out-dir>".to_owned()
} else {
dir.to_owned()
}
}
fn render_helm_values(slos: &[Slo], ctx: &Context) -> String {
let mut out = String::new();
header(&mut out);
let _ = writeln!(
out,
"# Use it with: helm upgrade --install {app} deploy/helm -f {out_dir}/helm-values.yaml",
out_dir = comment_dir(&ctx.out_dir),
app = ctx.app
);
out.push_str("analysis:\n");
let _ = writeln!(
out,
" templateName: {}",
yaml_str(&format!("{}-slo", ctx.app))
);
let _ = writeln!(out, " maxBurnRate: {}", max_burn_text());
out.push_str(" metricTemplates:\n");
for slo in slos {
let _ = writeln!(
out,
" - {}",
yaml_str(&metric_template_name(&ctx.app, slo))
);
}
out
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct Dashboard {
uid: String,
title: String,
tags: Vec<&'static str>,
timezone: &'static str,
schema_version: u32,
version: u32,
editable: bool,
refresh: &'static str,
time: TimeRange,
templating: Templating,
panels: Vec<Panel>,
}
#[derive(Serialize)]
struct TimeRange {
from: &'static str,
to: &'static str,
}
#[derive(Serialize)]
struct Templating {
list: Vec<Variable>,
}
#[derive(Serialize)]
struct Variable {
name: &'static str,
label: &'static str,
#[serde(rename = "type")]
kind: &'static str,
query: &'static str,
}
#[derive(Serialize)]
struct DataSource {
#[serde(rename = "type")]
kind: &'static str,
uid: &'static str,
}
const DATASOURCE: DataSource = DataSource {
kind: "prometheus",
uid: "${datasource}",
};
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct Panel {
id: u32,
#[serde(rename = "type")]
kind: &'static str,
title: String,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
grid_pos: GridPos,
#[serde(skip_serializing_if = "Option::is_none")]
datasource: Option<DataSource>,
#[serde(skip_serializing_if = "Vec::is_empty")]
targets: Vec<Target>,
#[serde(skip_serializing_if = "Option::is_none")]
field_config: Option<FieldConfig>,
}
#[derive(Serialize)]
struct GridPos {
h: u32,
w: u32,
x: u32,
y: u32,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct Target {
ref_id: &'static str,
expr: String,
legend_format: &'static str,
datasource: DataSource,
}
#[derive(Serialize)]
struct FieldConfig {
defaults: FieldDefaults,
}
#[derive(Serialize)]
struct FieldDefaults {
unit: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
decimals: Option<u32>,
}
const fn target(ref_id: &'static str, expr: String, legend_format: &'static str) -> Target {
Target {
ref_id,
expr,
legend_format,
datasource: DATASOURCE,
}
}
fn slo_panels(slo: &Slo, ctx: &Context, y: u32, first_id: u32) -> [Panel; 4] {
let own = ctx.own(slo);
let objective = format_decimal(u64::from(slo.objective_ppm), 4);
let budget = format!("(1 - autumn_slo:objective:ratio{{{own}}})");
let burn = |window: &str| format!("{}{{{own}}} / {budget}", recording_name(window));
let stat = |id, title: &str, x, expr, legend, decimals| Panel {
id,
kind: "stat",
title: title.to_owned(),
description: None,
grid_pos: GridPos {
h: 8,
w: 6,
x,
y: y + 1,
},
datasource: Some(DATASOURCE),
targets: vec![target("A", expr, legend)],
field_config: Some(FieldConfig {
defaults: FieldDefaults {
unit: "percentunit",
decimals: Some(decimals),
},
}),
};
let mut sli = stat(
first_id + 1,
"SLI (30d)",
0,
format!("1 - {}{{{own}}}", recording_name("30d")),
"SLI",
3,
);
sli.description.clone_from(&slo.description);
[
Panel {
id: first_id,
kind: "row",
title: format!("{} — objective {objective}% ({})", slo.name, sli_text(slo)),
description: None,
grid_pos: GridPos {
h: 1,
w: 24,
x: 0,
y,
},
datasource: None,
targets: Vec::new(),
field_config: None,
},
sli,
stat(
first_id + 2,
"Error budget left (30d)",
6,
format!("1 - {}", burn("30d")),
"budget left",
1,
),
Panel {
id: first_id + 3,
kind: "timeseries",
title: "Burn rate".to_owned(),
description: Some(format!(
"1 spends the budget in exactly 30 days. A page fires at {}x (1h and 5m) \
and at 6x (6h and 30m).",
format_decimal(u64::from(BURN_WINDOWS[0].factor_tenths), 1)
)),
grid_pos: GridPos {
h: 8,
w: 12,
x: 12,
y: y + 1,
},
datasource: Some(DATASOURCE),
targets: vec![
target("A", burn("1h"), "1h"),
target("B", burn("6h"), "6h"),
target("C", burn("3d"), "3d"),
],
field_config: Some(FieldConfig {
defaults: FieldDefaults {
unit: "none",
decimals: None,
},
}),
},
]
}
fn dashboard_uid(app: &str, selector: &str) -> String {
let uid = format!("autumn-slo-{app}");
if uid.len() <= 40 && selector.is_empty() {
return uid;
}
let hash = fnv1a(&format!("{uid}\n{selector}")) & 0xffff_ffff;
let keep = uid.len().min(31);
format!("{}-{hash:08x}", &uid[..keep])
}
fn render_dashboard(slos: &[Slo], ctx: &Context) -> String {
let panels = slos
.iter()
.zip(0u32..)
.flat_map(|(slo, index)| slo_panels(slo, ctx, index * 9, index * 4 + 1))
.collect();
let dashboard = Dashboard {
uid: dashboard_uid(&ctx.app, &ctx.selector),
title: if ctx.selector.is_empty() {
format!("{} SLOs", ctx.app)
} else {
format!("{} SLOs ({})", ctx.app, ctx.selector)
},
tags: vec!["autumn", "slo"],
timezone: "browser",
schema_version: 39,
version: 1,
editable: true,
refresh: "1m",
time: TimeRange {
from: "now-7d",
to: "now",
},
templating: Templating {
list: vec![Variable {
name: "datasource",
label: "Data source",
kind: "datasource",
query: "prometheus",
}],
},
panels,
};
let mut text = serde_json::to_string_pretty(&dashboard).unwrap_or_default();
text.push('\n');
text
}
#[derive(Debug, Clone)]
pub struct GenerateArgs {
pub out_dir: PathBuf,
pub app: Option<String>,
pub selector: Option<String>,
pub prometheus_url: String,
pub rule_labels: Vec<String>,
pub check: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Outcome {
Written(Vec<PathBuf>),
UpToDate,
}
pub fn execute(
configs: &[slo::SloConfig],
default_app: &str,
args: &GenerateArgs,
) -> Result<(Outcome, Vec<String>), String> {
let slos = slo::validate(configs).map_err(|e| e.to_string())?;
let options = GenerateOptions {
app: args.app.clone().unwrap_or_else(|| default_app.to_owned()),
selector: args.selector.clone(),
prometheus_url: args.prometheus_url.clone(),
out_dir: args.out_dir.display().to_string(),
rule_labels: args.rule_labels.clone(),
};
let generated = generate(&slos, &options)?;
if args.check {
let drifted: Vec<String> = generated
.files
.iter()
.filter(|file| {
std::fs::read_to_string(args.out_dir.join(file.name))
.map_or(true, |on_disk| on_disk != file.contents)
})
.map(|file| args.out_dir.join(file.name).display().to_string())
.collect();
if drifted.is_empty() {
return Ok((Outcome::UpToDate, generated.warnings));
}
return Err(format!(
"these SLO files are missing or out of date:\n {}\nRun `autumn slo generate` \
and commit the result.",
drifted.join("\n ")
));
}
std::fs::create_dir_all(&args.out_dir)
.map_err(|e| format!("cannot create {}: {e}", args.out_dir.display()))?;
let mut written = Vec::new();
for file in &generated.files {
let path = args.out_dir.join(file.name);
std::fs::write(&path, &file.contents)
.map_err(|e| format!("cannot write {}: {e}", path.display()))?;
written.push(path);
}
Ok((Outcome::Written(written), generated.warnings))
}
fn default_app(config: &autumn_web::config::AutumnConfig, project: &Path) -> String {
if let Some(name) = config
.deploy
.as_ref()
.and_then(|d| d.app_name.as_deref())
.filter(|n| !n.trim().is_empty())
{
return name.to_owned();
}
std::fs::read_to_string(project.join("Cargo.toml"))
.ok()
.and_then(|text| text.parse::<toml::Table>().ok())
.and_then(|table| {
table
.get("package")?
.get("name")?
.as_str()
.map(str::to_owned)
})
.unwrap_or_else(|| "app".to_owned())
}
pub fn run_generate(args: &GenerateArgs) {
let config = match autumn_web::config::AutumnConfig::load_lenient_unknown_roots() {
Ok(config) => config,
Err(e) => {
eprintln!("Error: failed to load configuration: {e}");
std::process::exit(1);
}
};
let app = default_app(&config, Path::new("."));
match execute(&config.slo, &app, args) {
Ok((outcome, warnings)) => {
for warning in warnings {
eprintln!("Warning: {warning}");
}
match outcome {
Outcome::Written(paths) => {
for path in paths {
println!("wrote {}", path.display());
}
}
Outcome::UpToDate => println!("SLO files are up to date."),
}
}
Err(e) => {
eprintln!("Error: {e}");
std::process::exit(1);
}
}
}
#[cfg(test)]
mod tests;