use crate::core::types::{Machine, Resource, ResourceStatus, ResourceType, StateLock};
use crate::tripwire::hasher;
use file::{detect_drift_impl, detect_drift_with_lifecycle};
use ignore::should_ignore_drift;
#[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,
}
const DRIFT_QUERY_TIMEOUT_SECS: u64 = 60;
pub struct DriftReport {
pub findings: Vec<DriftFinding>,
pub census: DriftCensus,
}
pub fn detect_drift(lock: &StateLock) -> Vec<DriftFinding> {
detect_drift_reported(lock, None).findings
}
pub fn detect_drift_with_machine(lock: &StateLock, machine: &Machine) -> Vec<DriftFinding> {
detect_drift_reported(lock, Some(machine)).findings
}
pub fn detect_drift_reported(lock: &StateLock, machine: Option<&Machine>) -> DriftReport {
let mut census = DriftCensus::new();
let findings = detect_drift_impl(lock, machine, &mut census);
for (id, rl) in &lock.resources {
if rl.resource_type != ResourceType::File {
census.skipped(id, &rl.resource_type, SkipReason::NoConfigLoaded);
}
}
DriftReport { findings, census }
}
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_timeout(machine, &query, Some(DRIFT_QUERY_TIMEOUT_SECS)) {
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> {
detect_drift_full_reported(lock, machine, resources, DriftOptions::default()).findings
}
pub fn detect_drift_full_reported(
lock: &StateLock,
machine: &Machine,
resources: &indexmap::IndexMap<String, Resource>,
opts: DriftOptions,
) -> DriftReport {
let mut census = DriftCensus::new();
let mut findings = detect_drift_with_lifecycle(lock, Some(machine), resources, &mut census);
findings.extend(task_check::detect_task_drift(
lock,
machine,
resources,
opts,
&mut census,
));
findings.extend(detect_nonfile_drift(lock, machine, resources, &mut census));
findings.extend(image::detect_image_drift(
lock,
machine,
resources,
&mut census,
));
census_declared_but_unlocked(lock, resources, &mut census);
DriftReport { findings, census }
}
fn census_declared_but_unlocked(
lock: &StateLock,
resources: &indexmap::IndexMap<String, Resource>,
census: &mut DriftCensus,
) {
for (id, resource) in resources {
if resource.resource_type == ResourceType::Recipe || lock.resources.contains_key(id) {
continue;
}
if resource.machine.iter().any(|m| m == lock.machine) {
census.skipped(id, &resource.resource_type, SkipReason::NotInLock);
}
}
}
fn detect_nonfile_drift(
lock: &StateLock,
machine: &Machine,
resources: &indexmap::IndexMap<String, Resource>,
census: &mut DriftCensus,
) -> Vec<DriftFinding> {
let mut findings = Vec::new();
for (id, rl) in &lock.resources {
if resources.get(id).is_some_and(task_check::owns) {
continue;
}
if rl.status != ResourceStatus::Converged && rl.status != ResourceStatus::Drifted {
census.skipped(id, &rl.resource_type, SkipReason::NotConverged);
continue;
}
if should_ignore_drift(id, resources) {
census.skipped(id, &rl.resource_type, SkipReason::IgnoreDrift);
continue;
}
let Some(stored_live_hash) = rl.observed_state() else {
census.skipped(id, &rl.resource_type, SkipReason::NoObservedState);
continue;
};
let Some(resource) = resources.get(id) else {
census.skipped(id, &rl.resource_type, SkipReason::NotInConfig);
continue;
};
census.inspected(id, &rl.resource_type);
if let Some(f) = check_nonfile_drift(id, rl, resource, machine, stored_live_hash) {
findings.push(f);
}
}
findings
}
mod census;
mod file;
mod ignore;
mod image;
mod task_check;
pub use census::{DriftCensus, SkipReason};
pub use file::{check_file_drift, check_file_drift_via_transport};
pub use image::check_image_drift;
pub use task_check::DriftOptions;
#[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_full_b;
#[cfg(test)]
mod tests_image_drift;
#[cfg(test)]
mod tests_lifecycle;
#[cfg(test)]
mod tests_task_checks;
#[cfg(test)]
mod tests_transport;