use std::collections::HashMap;
use tracing::debug;
use crate::config::{ConfigHasher, DeployConfig, PodConfig};
use crate::runpod::ObservedPod;
use crate::state::DeploymentState;
#[derive(Debug, Default)]
pub struct DiffEngine {
hasher: ConfigHasher,
}
#[derive(Debug, Clone)]
pub struct ResourceDiff {
pub name: String,
pub diff_type: DiffType,
pub details: Vec<DiffDetail>,
pub old_hash: Option<String>,
pub new_hash: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiffType {
Create,
Update,
Delete,
NoChange,
Drift,
}
#[derive(Debug, Clone)]
pub struct DiffDetail {
pub field: String,
pub old_value: Option<String>,
pub new_value: Option<String>,
}
#[derive(Debug)]
pub struct DiffResult {
pub diffs: Vec<ResourceDiff>,
pub creates: usize,
pub updates: usize,
pub deletes: usize,
pub unchanged: usize,
}
impl DiffEngine {
#[must_use]
pub const fn new() -> Self {
Self {
hasher: ConfigHasher::new(),
}
}
pub fn compute_diff(
&self,
config: &DeployConfig,
state: Option<&DeploymentState>,
observed: &[ObservedPod],
) -> DiffResult {
let mut diffs = Vec::new();
let observed_by_name: HashMap<&str, &ObservedPod> = observed
.iter()
.filter_map(|p| p.pod_name.as_deref().map(|name| (name, p)))
.collect();
let state_pods: HashMap<&str, _> = state
.map(|s| s.pods.iter().map(|(k, v)| (k.as_str(), v)).collect())
.unwrap_or_default();
for pod_config in &config.pods {
let new_hash = self.hasher.hash_pod(pod_config);
let observed_pod = observed_by_name.get(pod_config.name.as_str());
let state_pod = state_pods.get(pod_config.name.as_str());
let diff = Self::compute_pod_diff(pod_config, observed_pod.copied(), state_pod.copied(), &new_hash);
diffs.push(diff);
}
for observed_pod in observed {
if let Some(pod_name) = &observed_pod.pod_name {
let in_config = config.pods.iter().any(|p| p.name == *pod_name);
if !in_config {
debug!("Found orphaned pod: {pod_name}");
diffs.push(ResourceDiff {
name: pod_name.clone(),
diff_type: DiffType::Delete,
details: vec![DiffDetail {
field: String::from("pod"),
old_value: Some(observed_pod.id.clone()),
new_value: None,
}],
old_hash: observed_pod.spec_hash.clone(),
new_hash: None,
});
}
}
}
let creates = diffs.iter().filter(|d| d.diff_type == DiffType::Create).count();
let updates = diffs
.iter()
.filter(|d| matches!(d.diff_type, DiffType::Update | DiffType::Drift))
.count();
let deletes = diffs.iter().filter(|d| d.diff_type == DiffType::Delete).count();
let unchanged = diffs.iter().filter(|d| d.diff_type == DiffType::NoChange).count();
DiffResult {
diffs,
creates,
updates,
deletes,
unchanged,
}
}
fn compute_pod_diff(
config: &PodConfig,
observed: Option<&ObservedPod>,
state: Option<&crate::state::PodState>,
new_hash: &str,
) -> ResourceDiff {
match (observed, state) {
(None, None) => {
debug!("Pod {} needs to be created", config.name);
ResourceDiff {
name: config.name.clone(),
diff_type: DiffType::Create,
details: vec![DiffDetail {
field: String::from("pod"),
old_value: None,
new_value: Some(config.name.clone()),
}],
old_hash: None,
new_hash: Some(new_hash.to_string()),
}
}
(Some(obs), _) => {
let old_hash = obs.spec_hash.as_deref();
if old_hash == Some(new_hash) {
debug!("Pod {} is up to date", config.name);
ResourceDiff {
name: config.name.clone(),
diff_type: DiffType::NoChange,
details: vec![],
old_hash: old_hash.map(String::from),
new_hash: Some(new_hash.to_string()),
}
} else {
let details = Self::compute_detailed_diff(config, obs);
let diff_type = if old_hash.is_some() {
DiffType::Update
} else {
DiffType::Drift
};
debug!("Pod {} needs update ({:?})", config.name, diff_type);
ResourceDiff {
name: config.name.clone(),
diff_type,
details,
old_hash: old_hash.map(String::from),
new_hash: Some(new_hash.to_string()),
}
}
}
(None, Some(st)) => {
debug!("Pod {} exists in state but not on RunPod, recreating", config.name);
ResourceDiff {
name: config.name.clone(),
diff_type: DiffType::Create,
details: vec![DiffDetail {
field: String::from("pod"),
old_value: Some(format!("missing (was {})", st.runpod_id)),
new_value: Some(config.name.clone()),
}],
old_hash: Some(st.config_hash.clone()),
new_hash: Some(new_hash.to_string()),
}
}
}
}
fn compute_detailed_diff(config: &PodConfig, observed: &ObservedPod) -> Vec<DiffDetail> {
let mut details = Vec::new();
if config.runtime.image != observed.image {
details.push(DiffDetail {
field: String::from("image"),
old_value: Some(observed.image.clone()),
new_value: Some(config.runtime.image.clone()),
});
}
if let Some(obs_gpu) = &observed.gpu_type
&& config.gpu.gpu_type != *obs_gpu {
details.push(DiffDetail {
field: String::from("gpu_type"),
old_value: Some(obs_gpu.clone()),
new_value: Some(config.gpu.gpu_type.clone()),
});
}
if config.gpu.count != observed.gpu_count {
details.push(DiffDetail {
field: String::from("gpu_count"),
old_value: Some(observed.gpu_count.to_string()),
new_value: Some(config.gpu.count.to_string()),
});
}
details
}
}
impl DiffResult {
#[must_use]
pub const fn has_changes(&self) -> bool {
self.creates > 0 || self.updates > 0 || self.deletes > 0
}
#[must_use]
pub const fn total_changes(&self) -> usize {
self.creates + self.updates + self.deletes
}
#[must_use]
pub fn actionable_diffs(&self) -> Vec<&ResourceDiff> {
self.diffs
.iter()
.filter(|d| d.diff_type != DiffType::NoChange)
.collect()
}
}
impl std::fmt::Display for DiffType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s = match self {
Self::Create => "create",
Self::Update => "update",
Self::Delete => "delete",
Self::NoChange => "no change",
Self::Drift => "drift",
};
write!(f, "{s}")
}
}
impl std::fmt::Display for ResourceDiff {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}: {}", self.name, self.diff_type)?;
if !self.details.is_empty() {
write!(f, " (")?;
for (i, detail) in self.details.iter().enumerate() {
if i > 0 {
write!(f, ", ")?;
}
write!(f, "{}", detail.field)?;
}
write!(f, ")")?;
}
Ok(())
}
}