use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::time::{Instant, SystemTime, UNIX_EPOCH};
use serde::Serialize;
pub(crate) const EVIDENCE_SCHEMA_VERSION: u32 = 2;
pub(crate) const EVIDENCE_DIR: &str = "target/recovery";
#[derive(Debug, Clone, Serialize)]
pub(crate) struct Artifact {
pub(crate) schema_version: u32,
pub(crate) scenario: String,
pub(crate) stage: String,
pub(crate) runner: String,
pub(crate) capability: String,
pub(crate) evidence: Vec<String>,
pub(crate) run: RunMeta,
pub(crate) timeline: Vec<Event>,
pub(crate) observations: BTreeMap<String, Observation>,
pub(crate) gates: Vec<Verdict>,
pub(crate) checks: Vec<Verdict>,
}
impl Artifact {
pub(crate) fn write(&self) -> PathBuf {
let dir = workspace_root().join(EVIDENCE_DIR);
std::fs::create_dir_all(&dir).expect("the recovery artifact directory is writable");
let path = dir.join(format!("{}.{}.json", self.scenario, self.stage));
let json = serde_json::to_string_pretty(self).expect("the evidence artifact serializes");
std::fs::write(&path, format!("{json}\n")).expect("the recovery artifact is writable");
path
}
pub(crate) fn failures(&self) -> Vec<&Verdict> {
self.gates
.iter()
.chain(&self.checks)
.filter(|verdict| verdict.outcome == Outcome::Failed)
.collect()
}
pub(crate) fn summary(&self) -> String {
let evaluated = self
.gates
.iter()
.filter(|verdict| verdict.outcome != Outcome::NotEvaluated)
.count();
format!(
"{}/{}: {} events, {} observations, {evaluated}/{} gates evaluated, {} checks, {} \
failed",
self.scenario,
self.stage,
self.timeline.len(),
self.observations.len(),
self.gates.len(),
self.checks.len(),
self.failures().len(),
)
}
}
#[derive(Debug, Clone, Serialize)]
pub(crate) struct RunMeta {
pub(crate) started_at_unix_ms: u128,
pub(crate) elapsed_ms: u128,
pub(crate) axond_version: &'static str,
pub(crate) control_plane: String,
pub(crate) schema: String,
pub(crate) schema_identity: String,
}
#[derive(Debug, Clone, Serialize)]
pub(crate) struct Event {
pub(crate) at_ms: u128,
pub(crate) event: String,
pub(crate) detail: String,
}
#[derive(Debug, Clone, Serialize)]
#[serde(untagged)]
pub(crate) enum Observation {
Text(String),
Count(u64),
Seconds(f64),
}
impl From<&str> for Observation {
fn from(value: &str) -> Self {
Self::Text(value.to_owned())
}
}
impl From<String> for Observation {
fn from(value: String) -> Self {
Self::Text(value)
}
}
impl From<u64> for Observation {
fn from(value: u64) -> Self {
Self::Count(value)
}
}
impl From<std::time::Duration> for Observation {
fn from(value: std::time::Duration) -> Self {
Self::Seconds(value.as_secs_f64())
}
}
#[derive(Debug, Clone, Serialize)]
pub(crate) struct Verdict {
pub(crate) gate: String,
pub(crate) bound: String,
pub(crate) observed: String,
pub(crate) outcome: Outcome,
pub(crate) detail: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum Outcome {
Met,
Failed,
NotEvaluated,
}
pub(crate) struct Recorder {
scenario: String,
stage: String,
runner: String,
capability: String,
evidence: Vec<String>,
schema: String,
schema_identity: String,
control_plane: String,
started: Instant,
started_at_unix_ms: u128,
timeline: Vec<Event>,
observations: BTreeMap<String, Observation>,
gates: Vec<Verdict>,
checks: Vec<Verdict>,
}
impl Recorder {
pub(crate) fn new(
scenario: &str,
stage: &str,
runner: &str,
capability: &str,
evidence: &[&str],
schema: &str,
schema_identity: &str,
) -> Self {
Self {
scenario: scenario.to_owned(),
stage: stage.to_owned(),
runner: runner.to_owned(),
capability: capability.to_owned(),
evidence: evidence.iter().map(|class| (*class).to_owned()).collect(),
schema: schema.to_owned(),
schema_identity: schema_identity.to_owned(),
control_plane: "postgres".to_owned(),
started: Instant::now(),
started_at_unix_ms: SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("the clock is after the epoch")
.as_millis(),
timeline: Vec::new(),
observations: BTreeMap::new(),
gates: Vec::new(),
checks: Vec::new(),
}
}
pub(crate) fn mark(&mut self, event: &str, detail: impl Into<String>) {
self.timeline.push(Event {
at_ms: self.started.elapsed().as_millis(),
event: event.to_owned(),
detail: detail.into(),
});
}
pub(crate) fn observe(&mut self, key: &str, value: impl Into<Observation>) {
self.observations.insert(key.to_owned(), value.into());
}
pub(crate) fn gate(
&mut self,
gate: &str,
bound: impl Into<String>,
observed: impl Into<String>,
met: bool,
detail: impl Into<String>,
) {
self.gates.push(Verdict {
gate: gate.to_owned(),
bound: bound.into(),
observed: observed.into(),
outcome: if met { Outcome::Met } else { Outcome::Failed },
detail: detail.into(),
});
}
pub(crate) fn deferred(
&mut self,
gate: &str,
bound: impl Into<String>,
why: impl Into<String>,
) {
self.gates.push(Verdict {
gate: gate.to_owned(),
bound: bound.into(),
observed: "not measured".to_owned(),
outcome: Outcome::NotEvaluated,
detail: why.into(),
});
}
pub(crate) fn require(
&mut self,
check: &str,
expected: impl std::fmt::Display,
observed: impl std::fmt::Display,
detail: impl Into<String>,
) {
let (expected, observed) = (expected.to_string(), observed.to_string());
let met = expected == observed;
self.checks.push(Verdict {
gate: check.to_owned(),
bound: expected,
observed,
outcome: if met { Outcome::Met } else { Outcome::Failed },
detail: detail.into(),
});
}
pub(crate) fn require_that(&mut self, check: &str, held: bool, detail: impl Into<String>) {
self.require(check, true, held, detail);
}
pub(crate) fn held(&self, check: &str) -> bool {
let mut recorded = self
.checks
.iter()
.filter(|verdict| verdict.gate == check)
.peekable();
recorded.peek().is_some() && recorded.all(|verdict| verdict.outcome != Outcome::Failed)
}
pub(crate) fn finish(self) -> Artifact {
Artifact {
schema_version: EVIDENCE_SCHEMA_VERSION,
scenario: self.scenario,
stage: self.stage,
runner: self.runner,
capability: self.capability,
evidence: self.evidence,
run: RunMeta {
started_at_unix_ms: self.started_at_unix_ms,
elapsed_ms: self.started.elapsed().as_millis(),
axond_version: env!("CARGO_PKG_VERSION"),
control_plane: self.control_plane,
schema: self.schema,
schema_identity: self.schema_identity,
},
timeline: self.timeline,
observations: self.observations,
gates: self.gates,
checks: self.checks,
}
}
}
pub(crate) fn workspace_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../..")
}
#[cfg(test)]
mod tests {
use super::*;
fn recorder() -> Recorder {
Recorder::new(
"control-plane-outage",
"journal-outage",
"stateful-tests",
"control_plane_outage",
&["outage_timeline"],
"recovery_1",
"Current { version: 9 }",
)
}
#[test]
fn a_deferred_gate_is_neither_a_pass_nor_a_failure() {
let mut recorder = recorder();
recorder.gate(
"admin_writes",
"unavailable",
"unavailable",
true,
"refused",
);
recorder.deferred("readiness", "serves", "the `serving` stage is blocked");
recorder.gate("max_data_loss_revisions", "0", "1", false, "lost one");
let artifact = recorder.finish();
assert_eq!(artifact.gates.len(), 3);
assert_eq!(
artifact.failures().len(),
1,
"only the evaluated, unmet gate fails the stage"
);
assert!(artifact.summary().contains("2/3 gates evaluated"));
}
#[test]
fn a_failed_check_fails_the_stage_through_the_artifact() {
let mut recorder = recorder();
recorder.require("active_revision_survived_the_cut", "rev_1", "rev_1", "held");
recorder.require_that("the_publish_was_retryable", false, "it was not");
assert!(recorder.held("active_revision_survived_the_cut"));
assert!(!recorder.held("the_publish_was_retryable"));
assert!(
!recorder.held("a_check_nobody_recorded"),
"an unrecorded condition did not hold, so a mistyped name cannot read as a pass"
);
let artifact = recorder.finish();
let failures = artifact.failures();
assert_eq!(failures.len(), 1);
assert_eq!(failures[0].gate, "the_publish_was_retryable");
assert!(artifact.summary().contains("2 checks, 1 failed"));
}
#[test]
fn the_artifact_carries_the_build_and_the_schema_it_ran_against() {
let artifact = recorder().finish();
assert_eq!(artifact.run.control_plane, "postgres");
assert_eq!(artifact.run.schema, "recovery_1");
assert_eq!(artifact.run.schema_identity, "Current { version: 9 }");
assert_eq!(artifact.run.axond_version, env!("CARGO_PKG_VERSION"));
assert_eq!(artifact.schema_version, EVIDENCE_SCHEMA_VERSION);
}
#[test]
fn observations_serialize_as_plain_scalars() {
let mut recorder = recorder();
recorder.observe("active_revision", "rev_1");
recorder.observe("consecutive_convergence_failures", 3u64);
recorder.observe("cold_start_seconds", std::time::Duration::from_millis(1500));
let json = serde_json::to_value(recorder.finish()).expect("serializes");
let observations = &json["observations"];
assert_eq!(observations["active_revision"], "rev_1");
assert_eq!(observations["consecutive_convergence_failures"], 3);
assert_eq!(observations["cold_start_seconds"], 1.5);
}
}