use super::apply::*;
use super::apply_helpers::*;
use super::helpers::*;
use crate::core::{state, types};
use crate::tripwire::drift;
use std::path::Path;
fn check_machine_drift(
name: &str,
lock: &types::StateLock,
config: Option<&types::ForjarConfig>,
json: bool,
verbose: bool,
all_findings: &mut Vec<serde_json::Value>,
) -> usize {
if verbose {
eprintln!("Checking {} ({} resources)...", name, lock.resources.len());
}
if !json {
println!("Checking {} ({} resources)...", name, lock.resources.len());
}
let machine = config.and_then(|c| c.machines.get(name));
let findings = match (machine, config) {
(Some(m), Some(cfg)) => drift::detect_drift_full(lock, m, &cfg.resources),
(Some(m), None) => drift::detect_drift_with_machine(lock, m),
_ => drift::detect_drift(lock),
};
if findings.is_empty() {
if !json {
println!(" No drift detected.");
}
return 0;
}
for f in &findings {
if json {
all_findings.push(serde_json::json!({
"machine": name,
"resource": f.resource_id,
"detail": f.detail,
"expected_hash": f.expected_hash,
"actual_hash": f.actual_hash,
}));
} else {
println!(" {}: {} ({})", red("DRIFTED"), f.resource_id, f.detail);
println!(" Expected: {}", f.expected_hash);
println!(" Actual: {}", f.actual_hash);
}
}
findings.len()
}
fn print_drift_summary(
machines_checked: u32,
total_drift: usize,
all_findings: &[serde_json::Value],
json: bool,
) -> Result<(), String> {
if json {
let report = serde_json::json!({
"machines_checked": machines_checked,
"drift_count": total_drift,
"findings": all_findings,
});
let output =
serde_json::to_string_pretty(&report).map_err(|e| format!("JSON error: {e}"))?;
println!("{output}");
} else if total_drift > 0 {
println!();
println!(
"{}",
red(&format!("Drift detected: {total_drift} resource(s)"))
);
} else {
println!("{}", green("No drift detected."));
}
Ok(())
}
fn run_drift_alert(alert_cmd: &str, total_drift: usize) -> Result<(), String> {
let status = std::process::Command::new("sh")
.arg("-c")
.arg(alert_cmd)
.env("FORJAR_DRIFT_COUNT", total_drift.to_string())
.status()
.map_err(|e| format!("alert-cmd failed to execute: {e}"))?;
if !status.success() {
eprintln!("alert-cmd exited with code {}", status.code().unwrap_or(-1));
}
Ok(())
}
fn run_drift_remediation(
config_path: &Path,
state_dir: &Path,
machine_filter: Option<&str>,
total_drift: usize,
json: bool,
verbose: bool,
) -> Result<(), String> {
if !json {
println!();
println!("Auto-remediating {total_drift} drifted resource(s)...");
}
cmd_apply(
config_path,
state_dir,
machine_filter,
None, None, None, true, false, false, &[], false, None, false, verbose,
None, None, false, false, None, false, false, 0, true, false,
None, false, None, None, None, false, None, false, None, false, None, )?;
if !json {
println!("Remediation complete.");
}
Ok(())
}
fn send_drift_notification(
config: &types::ForjarConfig,
total_drift: usize,
machine_filter: Option<&str>,
) {
if let Some(ref cmd) = config.policy.notify.on_drift {
let drift_str = total_drift.to_string();
let machine_str = machine_filter.unwrap_or("all");
run_notify(
cmd,
&[("machine", machine_str), ("drift_count", &drift_str)],
);
}
}
fn load_drift_config(
config_path: &Path,
env_file: Option<&Path>,
) -> Result<Option<types::ForjarConfig>, String> {
if !config_path.exists() {
return Ok(None);
}
let mut cfg = parse_and_validate(config_path)?;
if let Some(path) = env_file {
load_env_params(&mut cfg, path)?;
}
Ok(Some(cfg))
}
fn scan_machines_for_drift(
state_dir: &Path,
machine_filter: Option<&str>,
config: Option<&types::ForjarConfig>,
json: bool,
verbose: bool,
) -> Result<(u32, usize, Vec<serde_json::Value>), String> {
let machine_locks = collect_machine_locks(state_dir, machine_filter)?;
if machine_locks.len() <= 1 {
return scan_sequential(&machine_locks, config, json, verbose);
}
let results: Vec<_> = std::thread::scope(|s| {
let handles: Vec<_> = machine_locks
.iter()
.map(|(name, lock)| {
s.spawn(move || {
let mut findings = Vec::new();
let count =
check_machine_drift(name, lock, config, json, verbose, &mut findings);
(count, findings)
})
})
.collect();
handles.into_iter().filter_map(|h| h.join().ok()).collect()
});
let machines_checked = results.len() as u32;
let mut total_drift = 0;
let mut all_findings = Vec::new();
for (count, mut findings) in results {
total_drift += count;
all_findings.append(&mut findings);
}
Ok((machines_checked, total_drift, all_findings))
}
fn collect_machine_locks(
state_dir: &Path,
machine_filter: Option<&str>,
) -> Result<Vec<(String, types::StateLock)>, String> {
let entries = std::fs::read_dir(state_dir)
.map_err(|e| format!("cannot read state dir {}: {}", state_dir.display(), e))?;
let mut locks = Vec::new();
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
if let Some(filter) = machine_filter {
if name != filter {
continue;
}
}
if !entry.path().is_dir() {
continue;
}
if let Some(lock) = state::load_lock(state_dir, &name)? {
locks.push((name, lock));
}
}
Ok(locks)
}
fn scan_sequential(
machine_locks: &[(String, types::StateLock)],
config: Option<&types::ForjarConfig>,
json: bool,
verbose: bool,
) -> Result<(u32, usize, Vec<serde_json::Value>), String> {
let mut total_drift = 0;
let mut all_findings = Vec::new();
for (name, lock) in machine_locks {
total_drift += check_machine_drift(name, lock, config, json, verbose, &mut all_findings);
}
Ok((machine_locks.len() as u32, total_drift, all_findings))
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn cmd_drift(
config_path: &Path,
state_dir: &Path,
machine_filter: Option<&str>,
tripwire_mode: bool,
alert_cmd: Option<&str>,
auto_remediate: bool,
dry_run: bool,
json: bool,
verbose: bool,
env_file: Option<&Path>,
) -> Result<(), String> {
let config = load_drift_config(config_path, env_file)?;
if dry_run {
return cmd_drift_dry_run(state_dir, machine_filter, json);
}
if let Some(ref cfg) = config {
for (_, machine) in &cfg.machines {
if machine.is_container_transport() {
crate::transport::container::ensure_container(machine)?;
}
}
}
let (machines_checked, total_drift, all_findings) =
scan_machines_for_drift(state_dir, machine_filter, config.as_ref(), json, verbose)?;
print_drift_summary(machines_checked, total_drift, &all_findings, json)?;
if total_drift > 0 {
if let Some(cmd) = alert_cmd {
run_drift_alert(cmd, total_drift)?;
}
if auto_remediate {
run_drift_remediation(
config_path,
state_dir,
machine_filter,
total_drift,
json,
verbose,
)?;
}
if let Some(ref cfg) = config {
send_drift_notification(cfg, total_drift, machine_filter);
}
}
if tripwire_mode && total_drift > 0 {
return Err(format!("{total_drift} drift finding(s)"));
}
Ok(())
}
pub(crate) fn cmd_drift_dry_run(
state_dir: &Path,
machine_filter: Option<&str>,
json: bool,
) -> Result<(), String> {
let entries = std::fs::read_dir(state_dir)
.map_err(|e| format!("cannot read state dir {}: {}", state_dir.display(), e))?;
let mut checks: Vec<serde_json::Value> = Vec::new();
let mut total = 0usize;
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
if let Some(filter) = machine_filter {
if name != filter {
continue;
}
}
if !entry.path().is_dir() {
continue;
}
if let Some(lock) = state::load_lock(state_dir, &name)? {
if !json {
println!("Machine: {} ({} resources)", name, lock.resources.len());
}
for (res_id, res_state) in &lock.resources {
total += 1;
if json {
checks.push(serde_json::json!({
"machine": name,
"resource": res_id,
"status": res_state.status,
"hash": res_state.hash,
}));
} else {
println!(" would check: {} (status: {})", res_id, res_state.status);
}
}
}
}
if json {
let report = serde_json::json!({
"dry_run": true,
"total_checks": total,
"checks": checks,
});
let output =
serde_json::to_string_pretty(&report).map_err(|e| format!("JSON error: {e}"))?;
println!("{output}");
} else {
println!();
println!("Dry run: {total} resource(s) would be checked");
}
Ok(())
}