forjar 1.31.0

Rust-native Infrastructure as Code — bare-metal first, BLAKE3 state, provenance tracing
Documentation
//! How a completed drift scan is REPORTED.
//!
//! Everything here consumes a finished scan and tells someone about it — stdout
//! for a human, `--json` for a machine reader, `--alert-cmd` and
//! `policy.notify.on_drift` for whoever is watching. Nothing here changes the
//! host: remediation (which does) stays with the scan in `drift.rs`, because
//! "describe what was found" and "go change it" are different authorities and
//! the census bug (forjar#380) lived entirely in the first one.

use super::apply_helpers::run_notify;
use super::colors::{green, red, yellow};
use super::drift::DriftScan;
use crate::core::types;
use crate::tripwire::drift;

/// One machine's census, tagged with its name, for the `--json` report.
pub(super) fn census_json(name: &str, census: &drift::DriftCensus) -> serde_json::Value {
    let mut value = census.to_json();
    if let Some(obj) = value.as_object_mut() {
        obj.insert("machine".to_string(), serde_json::json!(name));
    }
    value
}

/// Print drift summary (JSON or text).
pub(super) fn print_drift_summary(scan: &DriftScan, json: bool) -> Result<(), String> {
    // A `--json` consumer was as blind as a human reading the text output:
    // `drift_count: 0` over an unstated population. The census ships in both
    // surfaces or the machine-readable one becomes the lying half.
    let sum = |key: &str| -> u64 { scan.censuses.iter().filter_map(|c| c[key].as_u64()).sum() };
    let (inspected, skipped) = (sum("inspected"), sum("skipped"));
    if json {
        let report = serde_json::json!({
            "machines_checked": scan.machines_checked,
            "drift_count": scan.total_drift,
            "unmeasured_count": scan.total_unmeasured,
            "resources_inspected": inspected,
            "resources_skipped": skipped,
            "resources_unmeasured": sum("unmeasured"),
            "census": scan.censuses,
            "findings": scan.findings,
            "unmeasured": scan.unmeasured,
        });
        let output =
            serde_json::to_string_pretty(&report).map_err(|e| format!("JSON error: {e}"))?;
        println!("{output}");
    } else if scan.total_drift > 0 {
        println!();
        println!(
            "{}",
            red(&format!("Drift detected: {} resource(s)", scan.total_drift))
        );
        if scan.total_unmeasured > 0 {
            println!(
                "  and {} unmeasured: the target did not answer.",
                scan.total_unmeasured
            );
        }
    } else if scan.total_unmeasured > 0 {
        // forjar#549: never the green verdict. Nothing drifted among what was
        // measured, and this line exists for what was not.
        println!();
        println!(
            "{}",
            yellow(&format!(
                "Drift unknown: {} resource(s) unmeasured — the target did not answer.",
                scan.total_unmeasured
            ))
        );
        println!("  {inspected} resource(s) inspected, {skipped} not inspected.");
    } else {
        println!("{}", green("No drift detected."));
        // The verdict and its population on adjacent lines, because the verdict
        // alone is the sentence that has been misread for a year.
        println!("  {inspected} resource(s) inspected, {skipped} not inspected.");
    }
    Ok(())
}

/// Run the alert command when drift is detected.
pub(super) 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(())
}

/// Send drift notification if configured.
pub(super) 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)],
        );
    }
}

/// One finding: a `--json` row, or the three lines a human reads.
pub(super) fn render_finding(
    name: &str,
    f: &drift::DriftFinding,
    label: String,
    json: bool,
    rows: &mut Vec<serde_json::Value>,
) {
    if json {
        rows.push(serde_json::json!({
            "machine": name,
            "resource": f.resource_id,
            "detail": f.detail,
            "expected_hash": f.expected_hash,
            "actual_hash": f.actual_hash,
        }));
        return;
    }
    // forjar#488: NAME THE MACHINE ON THE ROW. Aggregated output whose rows do
    // not say which box they came from cannot be attributed after the fact, and
    // that is precisely how gx10's `bashrc` was read as yoga's — the operator
    // went looking for a resource that the config in hand does not contain.
    println!("  {label}: {} on {name} ({})", f.resource_id, f.detail);
    println!("    Expected: {}", f.expected_hash);
    println!("    Actual:   {}", f.actual_hash);
}