use std::sync::Arc;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ReconcilerError {
#[error("read state: {0}")]
ReadState(String),
#[error("compute plan: {0}")]
ComputePlan(String),
#[error("apply: {0}")]
Apply(String),
#[error("validation: {0}")]
Validation(String),
#[error("transient: {0}")]
Transient(String),
#[error("other: {0}")]
Other(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct PlanId(pub String);
impl PlanId {
pub fn of(bytes: &[u8]) -> Self {
Self(hex::encode(blake3::hash(bytes).as_bytes()))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Action {
Create,
Update,
Delete,
Replace,
NoOp,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ChangeSeverity {
Cosmetic,
Functional,
Critical,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Change {
pub address: String,
pub action: Action,
pub severity: ChangeSeverity,
pub before: Option<Value>,
pub after: Option<Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Plan {
pub id: PlanId,
pub kind: String,
pub created_at: DateTime<Utc>,
pub changes: Vec<Change>,
}
impl Plan {
pub fn derive_id(kind: &str, changes: &[Change]) -> PlanId {
let projection: Vec<Value> = changes
.iter()
.map(|c| {
serde_json::json!({
"address": c.address,
"action": c.action,
"severity": c.severity,
"before": c.before,
"after": c.after,
})
})
.collect();
let canonical = serde_json::json!({ "kind": kind, "changes": projection });
PlanId::of(
serde_json::to_vec(&canonical)
.unwrap_or_default()
.as_slice(),
)
}
pub fn new(kind: impl Into<String>, changes: Vec<Change>) -> Self {
let kind = kind.into();
let id = Self::derive_id(&kind, &changes);
Self {
id,
kind,
created_at: Utc::now(),
changes,
}
}
pub fn is_noop(&self) -> bool {
self.changes
.iter()
.all(|c| matches!(c.action, Action::NoOp))
}
pub fn change_count(&self) -> usize {
self.changes
.iter()
.filter(|c| !matches!(c.action, Action::NoOp))
.count()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppliedChange {
pub address: String,
pub action: Action,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FailedChange {
pub address: String,
pub action: Action,
pub error: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Outcome {
pub plan_id: PlanId,
pub kind: String,
pub applied: Vec<AppliedChange>,
pub failed: Vec<FailedChange>,
pub started_at: DateTime<Utc>,
pub finished_at: DateTime<Utc>,
}
impl Outcome {
pub fn fully_succeeded(&self) -> bool {
self.failed.is_empty()
}
}
#[async_trait]
pub trait Reconciler: Send + Sync {
fn kind(&self) -> &'static str;
async fn read_state(&self) -> Result<Value, ReconcilerError>;
fn compute_plan(&self, config: &Value, state: &Value) -> Result<Plan, ReconcilerError>;
async fn apply(&self, plan: &Plan) -> Result<Outcome, ReconcilerError>;
async fn detect_drift(&self, config: &Value) -> Result<Plan, ReconcilerError> {
let state = self.read_state().await?;
self.compute_plan(config, &state)
}
}
pub type SharedReconciler = Arc<dyn Reconciler>;
pub trait ApplyMetrics: Send + Sync {
fn apply_started(&self, kind: &str);
fn apply_finished(&self, kind: &str);
}
#[derive(Debug, Default, Clone, Copy)]
pub struct NoMetrics;
impl ApplyMetrics for NoMetrics {
fn apply_started(&self, _kind: &str) {}
fn apply_finished(&self, _kind: &str) {}
}
pub fn build_outcome(
plan: &Plan,
applied: Vec<AppliedChange>,
failed: Vec<FailedChange>,
started_at: DateTime<Utc>,
) -> Outcome {
Outcome {
plan_id: plan.id.clone(),
kind: plan.kind.clone(),
applied,
failed,
started_at,
finished_at: Utc::now(),
}
}
pub fn change(
address: impl Into<String>,
action: Action,
before: Option<Value>,
after: Option<Value>,
) -> Change {
let severity = match action {
Action::Create => ChangeSeverity::Functional,
Action::Update => ChangeSeverity::Functional,
Action::Delete => ChangeSeverity::Critical,
Action::Replace => ChangeSeverity::Critical,
Action::NoOp => ChangeSeverity::Cosmetic,
};
Change {
address: address.into(),
action,
severity,
before,
after,
}
}
pub fn change_with_severity(
address: impl Into<String>,
action: Action,
severity: ChangeSeverity,
before: Option<Value>,
after: Option<Value>,
) -> Change {
Change {
address: address.into(),
action,
severity,
before,
after,
}
}
#[cfg(test)]
mod core_tests {
use super::*;
use serde_json::json;
#[test]
fn plan_id_is_deterministic() {
let changes = vec![
change("a", Action::Create, None, Some(json!({"k": 1}))),
change(
"b",
Action::Update,
Some(json!({"v": 1})),
Some(json!({"v": 2})),
),
];
let id1 = Plan::derive_id("test", &changes);
let id2 = Plan::derive_id("test", &changes);
assert_eq!(id1, id2);
assert_eq!(id1.0.len(), 64);
}
#[test]
fn plan_id_changes_with_kind() {
let changes = vec![change("a", Action::Create, None, Some(json!({})))];
let id_terraform = Plan::derive_id("terraform", &changes);
let id_github = Plan::derive_id("github", &changes);
assert_ne!(id_terraform, id_github);
}
#[test]
fn plan_id_changes_with_changes() {
let id1 = Plan::derive_id("k", &[change("a", Action::Create, None, Some(json!({})))]);
let id2 = Plan::derive_id("k", &[change("a", Action::Delete, Some(json!({})), None)]);
assert_ne!(id1, id2);
}
#[test]
fn plan_id_is_independent_of_created_at() {
let changes = vec![change("a", Action::Create, None, Some(json!({})))];
let p1 = Plan::new("k", changes.clone());
std::thread::sleep(std::time::Duration::from_millis(1));
let p2 = Plan::new("k", changes);
assert_eq!(p1.id, p2.id);
assert_ne!(p1.created_at, p2.created_at);
}
#[test]
fn is_noop_handles_mixed_changes() {
let noop_plan = Plan::new("k", vec![change("a", Action::NoOp, None, None)]);
assert!(noop_plan.is_noop());
let mixed = Plan::new(
"k",
vec![
change("a", Action::NoOp, None, None),
change("b", Action::Create, None, Some(json!({}))),
],
);
assert!(!mixed.is_noop());
let empty = Plan::new("k", vec![]);
assert!(empty.is_noop());
}
#[test]
fn change_count_excludes_noops() {
let plan = Plan::new(
"k",
vec![
change("a", Action::Create, None, Some(json!({}))),
change("b", Action::NoOp, None, None),
change("c", Action::Update, Some(json!({})), Some(json!({}))),
],
);
assert_eq!(plan.change_count(), 2);
}
#[test]
fn default_severity_inference() {
assert_eq!(
change("x", Action::Create, None, Some(json!({}))).severity,
ChangeSeverity::Functional
);
assert_eq!(
change("x", Action::Update, Some(json!({})), Some(json!({}))).severity,
ChangeSeverity::Functional
);
assert_eq!(
change("x", Action::Delete, Some(json!({})), None).severity,
ChangeSeverity::Critical
);
assert_eq!(
change("x", Action::Replace, Some(json!({})), Some(json!({}))).severity,
ChangeSeverity::Critical
);
assert_eq!(
change("x", Action::NoOp, None, None).severity,
ChangeSeverity::Cosmetic
);
}
}