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,
}
impl DriftReport {
pub(super) fn new(findings: Vec<DriftFinding>, mut census: DriftCensus) -> Self {
unmeasured::census_unmeasured(&findings, &mut census);
Self { findings, census }
}
}
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::new(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,
};
let out = match unmeasured::read(machine, &query) {
unmeasured::Reading::Answered(out) => out,
unmeasured::Reading::Unmeasured(why) => {
return Some(DriftFinding::unmeasured(
id,
rl.resource_type.clone(),
stored_live_hash,
why,
))
}
};
if !out.success() {
return 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()),
});
}
let actual_hash = hasher::hash_string_or_sentinel(&crate::core::observation_mask::masked_for(
&out.stdout,
resource,
));
if actual_hash == stored_live_hash {
return None;
}
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),
})
}
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,
opts,
&mut census,
));
findings.extend(image::detect_image_drift(
lock,
machine,
resources,
&mut census,
));
census_declared_but_unlocked(lock, resources, &mut census);
DriftReport::new(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>,
opts: DriftOptions,
census: &mut DriftCensus,
) -> Vec<DriftFinding> {
let mut findings = Vec::new();
for (id, rl) in &lock.resources {
match nonfile_step(id, rl, resources, opts) {
NonfileStep::NotMine => {}
NonfileStep::Skip(reason) => census.skipped(id, &rl.resource_type, reason),
NonfileStep::Compare(resource, stored_live_hash) => {
census.inspected(id, &rl.resource_type);
findings.extend(check_nonfile_drift(
id,
rl,
resource,
machine,
stored_live_hash,
));
}
}
}
findings
}
enum NonfileStep<'a> {
NotMine,
Skip(SkipReason),
Compare(&'a Resource, &'a str),
}
fn nonfile_step<'a>(
id: &str,
rl: &'a crate::core::types::ResourceLock,
resources: &'a indexmap::IndexMap<String, Resource>,
opts: DriftOptions,
) -> NonfileStep<'a> {
let declared = resources.get(id);
if declared.is_some_and(task_check::owns) {
return NonfileStep::NotMine;
}
if !opts.run_task_checks
&& declared
.is_some_and(|r| r.resource_type == ResourceType::Task && r.completion_check.is_some())
{
return NonfileStep::Skip(SkipReason::TaskChecksDisabled);
}
if rl.status != ResourceStatus::Converged && rl.status != ResourceStatus::Drifted {
return NonfileStep::Skip(SkipReason::NotConverged);
}
if should_ignore_drift(id, resources) {
return NonfileStep::Skip(SkipReason::IgnoreDrift);
}
let Some(stored_live_hash) = rl.observed_state() else {
return NonfileStep::Skip(SkipReason::NoObservedState);
};
let Some(resource) = declared else {
return NonfileStep::Skip(SkipReason::NotInConfig);
};
if crate::core::observation_mask::recorded_mask(rl)
!= crate::core::observation_mask::mask_key(resource)
{
return NonfileStep::Skip(SkipReason::ObservationMaskChanged);
}
NonfileStep::Compare(resource, stored_live_hash)
}
mod census;
mod file;
pub use file::remote_path_digest;
mod ignore;
mod image;
mod lockless;
mod task_check;
mod unmeasured;
pub use census::{DriftCensus, SkipReason};
pub use file::{check_file_drift, check_file_drift_via_transport};
pub use image::check_image_drift;
pub use lockless::{detect_drift_lockless, lockless_dry_run_ids};
pub use task_check::DriftOptions;
pub use unmeasured::UNMEASURED;
#[cfg(test)]
mod tests_basic;
#[cfg(test)]
mod tests_basic_b;
#[cfg(test)]
mod tests_e05_routing;
#[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;
#[cfg(test)]
mod tests_unmeasured;