use crate::core::types::{Machine, Resource, ResourceStatus, ResourceType, StateLock};
use crate::tripwire::hasher;
use std::path::Path;
#[derive(Debug, Clone)]
pub struct DriftFinding {
pub resource_id: String,
pub resource_type: ResourceType,
pub expected_hash: String,
pub actual_hash: String,
pub detail: String,
}
pub fn check_file_drift(
resource_id: &str,
path: &str,
expected_hash: &str,
) -> Option<DriftFinding> {
let file_path = Path::new(path);
if !file_path.exists() {
return Some(DriftFinding {
resource_id: resource_id.to_string(),
resource_type: ResourceType::File,
expected_hash: expected_hash.to_string(),
actual_hash: "MISSING".to_string(),
detail: format!("{path} does not exist"),
});
}
let actual = if file_path.is_dir() {
hasher::hash_directory(file_path).unwrap_or_else(|e| format!("ERROR:{e}"))
} else {
hasher::hash_file(file_path).unwrap_or_else(|e| format!("ERROR:{e}"))
};
if actual != expected_hash {
Some(DriftFinding {
resource_id: resource_id.to_string(),
resource_type: ResourceType::File,
expected_hash: expected_hash.to_string(),
actual_hash: actual,
detail: format!("{path} content changed"),
})
} else {
None
}
}
fn hash_remote_content(
out: &crate::transport::ExecOutput,
path: &str,
machine: &Machine,
) -> Option<String> {
if out.stdout.trim() == "__DIR__" {
let ls_script = format!("ls -la '{path}'");
match crate::transport::exec_script(machine, &ls_script) {
Ok(ls_out) if ls_out.success() => Some(hasher::hash_string_or_sentinel(&ls_out.stdout)),
_ => None,
}
} else {
Some(hasher::hash_string_or_sentinel(&out.stdout))
}
}
fn file_drift_finding(
resource_id: &str,
expected_hash: &str,
actual_hash: String,
detail: String,
) -> DriftFinding {
DriftFinding {
resource_id: resource_id.to_string(),
resource_type: ResourceType::File,
expected_hash: expected_hash.to_string(),
actual_hash,
detail,
}
}
pub fn check_file_drift_via_transport(
resource_id: &str,
path: &str,
expected_hash: &str,
machine: &Machine,
) -> Option<DriftFinding> {
let script = format!(
"set -euo pipefail\nif [ -d '{path}' ]; then echo '__DIR__'; else cat '{path}'; fi"
);
match crate::transport::exec_script(machine, &script) {
Ok(out) if out.success() => {
let actual = hash_remote_content(&out, path, machine)?;
if actual != expected_hash {
Some(file_drift_finding(
resource_id,
expected_hash,
actual,
format!("{path} content changed"),
))
} else {
None
}
}
Ok(out) => Some(file_drift_finding(
resource_id,
expected_hash,
"MISSING".to_string(),
format!("{} not accessible: {}", path, out.stderr.trim()),
)),
Err(e) => Some(file_drift_finding(
resource_id,
expected_hash,
"ERROR".to_string(),
format!("transport error: {e}"),
)),
}
}
pub fn detect_drift(lock: &StateLock) -> Vec<DriftFinding> {
detect_drift_impl(lock, None)
}
pub fn detect_drift_with_machine(lock: &StateLock, machine: &Machine) -> Vec<DriftFinding> {
detect_drift_impl(lock, Some(machine))
}
fn check_nonfile_drift(
id: &str,
rl: &crate::core::types::ResourceLock,
resource: &Resource,
machine: &Machine,
stored_live_hash: &str,
) -> Option<DriftFinding> {
let query = match crate::core::codegen::state_query_script(resource) {
Ok(q) => q,
Err(_) => return None,
};
match crate::transport::exec_script(machine, &query) {
Ok(out) if out.success() => {
let actual_hash = hasher::hash_string_or_sentinel(&out.stdout);
if actual_hash != stored_live_hash {
Some(DriftFinding {
resource_id: id.to_string(),
resource_type: rl.resource_type.clone(),
expected_hash: stored_live_hash.to_string(),
actual_hash,
detail: format!("{} state changed", rl.resource_type),
})
} else {
None
}
}
Ok(out) => Some(DriftFinding {
resource_id: id.to_string(),
resource_type: rl.resource_type.clone(),
expected_hash: stored_live_hash.to_string(),
actual_hash: "ERROR".to_string(),
detail: format!("state query failed: {}", out.stderr.trim()),
}),
Err(e) => Some(DriftFinding {
resource_id: id.to_string(),
resource_type: rl.resource_type.clone(),
expected_hash: stored_live_hash.to_string(),
actual_hash: "ERROR".to_string(),
detail: format!("transport error: {e}"),
}),
}
}
pub fn detect_drift_full(
lock: &StateLock,
machine: &Machine,
resources: &indexmap::IndexMap<String, Resource>,
) -> Vec<DriftFinding> {
let mut findings = detect_drift_with_lifecycle(lock, Some(machine), resources);
findings.extend(detect_nonfile_drift(lock, machine, resources));
findings.extend(detect_image_drift(lock, machine, resources));
findings
}
fn detect_nonfile_drift(
lock: &StateLock,
machine: &Machine,
resources: &indexmap::IndexMap<String, Resource>,
) -> Vec<DriftFinding> {
let mut findings = Vec::new();
for (id, rl) in &lock.resources {
if rl.status != ResourceStatus::Converged || rl.resource_type == ResourceType::File {
continue;
}
if should_ignore_drift(id, resources) {
continue;
}
let stored_live_hash = match rl.details.get("live_hash") {
Some(serde_yaml_ng::Value::String(s)) => s.as_str(),
_ => continue,
};
let resource = match resources.get(id) {
Some(r) => r,
None => continue,
};
if let Some(f) = check_nonfile_drift(id, rl, resource, machine, stored_live_hash) {
findings.push(f);
}
}
findings
}
fn detect_image_drift(
lock: &StateLock,
machine: &Machine,
resources: &indexmap::IndexMap<String, Resource>,
) -> Vec<DriftFinding> {
let mut findings = Vec::new();
for (id, rl) in &lock.resources {
if rl.status != ResourceStatus::Converged || rl.resource_type != ResourceType::Image {
continue;
}
if should_ignore_drift(id, resources) {
continue;
}
let expected_digest = match rl.details.get("manifest_digest") {
Some(serde_yaml_ng::Value::String(s)) => s.as_str(),
_ => continue,
};
let container_name = match rl.details.get("container_name") {
Some(serde_yaml_ng::Value::String(s)) => s.as_str(),
_ => continue,
};
if let Some(f) = check_image_drift(id, container_name, expected_digest, machine) {
findings.push(f);
}
}
findings
}
pub fn check_image_drift(
resource_id: &str,
container_name: &str,
expected_digest: &str,
machine: &Machine,
) -> Option<DriftFinding> {
let script = format!(
"docker inspect {container_name} --format '{{{{.Image}}}}' 2>/dev/null || echo 'NOT_RUNNING'"
);
match crate::transport::exec_script(machine, &script) {
Ok(out) if out.success() => {
let actual = out.stdout.trim().to_string();
if actual == "NOT_RUNNING" {
Some(DriftFinding {
resource_id: resource_id.to_string(),
resource_type: ResourceType::Image,
expected_hash: expected_digest.to_string(),
actual_hash: "NOT_RUNNING".to_string(),
detail: format!("container {container_name} is not running"),
})
} else if actual != expected_digest {
Some(DriftFinding {
resource_id: resource_id.to_string(),
resource_type: ResourceType::Image,
expected_hash: expected_digest.to_string(),
actual_hash: actual,
detail: "deployed image differs from built image".to_string(),
})
} else {
None
}
}
Ok(out) => Some(DriftFinding {
resource_id: resource_id.to_string(),
resource_type: ResourceType::Image,
expected_hash: expected_digest.to_string(),
actual_hash: "ERROR".to_string(),
detail: format!("docker inspect failed: {}", out.stderr.trim()),
}),
Err(e) => Some(DriftFinding {
resource_id: resource_id.to_string(),
resource_type: ResourceType::Image,
expected_hash: expected_digest.to_string(),
actual_hash: "ERROR".to_string(),
detail: format!("transport error: {e}"),
}),
}
}
fn should_ignore_drift(
resource_id: &str,
resources: &indexmap::IndexMap<String, Resource>,
) -> bool {
if let Some(resource) = resources.get(resource_id) {
if let Some(ref lifecycle) = resource.lifecycle {
return !lifecycle.ignore_drift.is_empty();
}
}
false
}
fn detect_drift_with_lifecycle(
lock: &StateLock,
machine: Option<&Machine>,
resources: &indexmap::IndexMap<String, Resource>,
) -> Vec<DriftFinding> {
let mut findings = Vec::new();
for (id, rl) in &lock.resources {
if rl.status != ResourceStatus::Converged || rl.resource_type != ResourceType::File {
continue;
}
if should_ignore_drift(id, resources) {
continue;
}
if let Some(f) = check_file_resource_drift(id, rl, machine) {
findings.push(f);
}
}
findings
}
fn check_file_resource_drift(
id: &str,
rl: &crate::core::types::ResourceLock,
machine: Option<&Machine>,
) -> Option<DriftFinding> {
let path = match rl.details.get("path") {
Some(serde_yaml_ng::Value::String(s)) => s.as_str(),
_ => return None,
};
let expected = match rl.details.get("content_hash") {
Some(serde_yaml_ng::Value::String(s)) => s.as_str(),
_ => return None,
};
match machine {
Some(m) if m.is_container_transport() => {
check_file_drift_via_transport(id, path, expected, m)
}
_ => check_file_drift(id, path, expected),
}
}
fn detect_drift_impl(lock: &StateLock, machine: Option<&Machine>) -> Vec<DriftFinding> {
let mut findings = Vec::new();
for (id, rl) in &lock.resources {
if rl.status != ResourceStatus::Converged || rl.resource_type != ResourceType::File {
continue;
}
if let Some(f) = check_file_resource_drift(id, rl, machine) {
findings.push(f);
}
}
findings
}
#[cfg(test)]
mod tests_basic;
#[cfg(test)]
mod tests_basic_b;
#[cfg(test)]
mod tests_edge_fj131;
#[cfg(test)]
mod tests_edge_fj132;
#[cfg(test)]
mod tests_edge_fj132_b;
#[cfg(test)]
mod tests_fj036;
#[cfg(test)]
mod tests_full;
#[cfg(test)]
mod tests_image_drift;
#[cfg(test)]
mod tests_lifecycle;
#[cfg(test)]
mod tests_transport;