Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger

server/lib/tests/retention_executor.rs

//! End-to-end coverage for the retention/GC **executor** — `Holger::execute_repo_retention`
//! (and the plan-then-execute convenience `run_repo_retention`). The planner's
//! algebra is pinned in `holger_server_lib::retention`; the adapter (list → plan)
//! in `retention_adapter.rs`. This file proves the *acting* side:
//!
//! * dry-run (the default `GcOptions`) reclaims **nothing** — the store is intact;
//! * an executed plan drops **exactly** the plan's delete-set and keeps the rest;
//! * a protected/latest generation is **never** dropped (even if a corrupted plan
//!   flags it delete — defense in depth);
//! * every actual reclaim is **historized** to the audit warehouse.

use std::sync::Arc;

use holger_server_lib::audit::{AuditAction, MemoryAuditLog};
use holger_server_lib::retention::{
    ArtifactRecord, GcOptions, GcOutcome, RetentionDecision, RetentionPlan, RetentionPolicy,
    RetentionReason,
};
use holger_server_lib::{read_ron_config, wire_holger, Holger};
use traits::ArtifactId;

/// A live, writable file-backed `rust` repo in a temp dir, plus the temp guard
/// (dropped by the caller to clean up).
fn live_repo() -> (Holger, tempfile::TempDir) {
    let dir = tempfile::tempdir().unwrap();
    let d = dir.path().display();
    let ron = format!(
        r#"(
    exposed_endpoints: [ ( ron_name: "dev", ron_url: "127.0.0.1:0", ron_http_url: None ) ],
    storage_endpoints: [ ( ron_name: "inert", ron_storage_type: "znippy", ron_path: "{d}/inert/" ) ],
    repositories: [
        ( ron_name: "rust-dev", ron_repo_type: "rust", ron_data_dir: Some("{d}/rust-dev"), ron_base_url: Some("http://x"), ron_upstreams: [], ron_in: None, ron_out: Some(( ron_storage_endpoint: "inert", ron_exposed_endpoint: "dev" )) ),
    ],
)
"#
    );
    use std::io::Write;
    let mut f = tempfile::NamedTempFile::new().unwrap();
    f.write_all(ron.as_bytes()).unwrap();
    let mut holger = read_ron_config(f.path()).expect("RON must parse");
    holger.instantiate_backends().expect("backend builds");
    wire_holger(&mut holger).expect("wires");
    (holger, dir)
}

fn id(name: &str, version: &str) -> ArtifactId {
    ArtifactId {
        namespace: Some("crates".into()),
        name: name.into(),
        version: version.into(),
    }
}

/// Seed five serde versions; return the Holger.
fn seed_serde(holger: &Holger) {
    for v in ["1.0.0", "1.1.0", "1.2.0", "1.10.0", "2.0.0"] {
        holger
            .put("rust-dev", &id("serde", v), format!("serde-{v}").as_bytes())
            .unwrap();
    }
}

#[test]
fn dry_run_reclaims_nothing() {
    let (holger, _guard) = live_repo();
    seed_serde(&holger);

    let policy = RetentionPolicy::new().keep_last(2); // would reclaim 3 oldest
    let audit = MemoryAuditLog::new();
    let report = holger
        .run_repo_retention(
            "rust-dev",
            &policy,
            1_700_000_000,
            &GcOptions::dry_run(),
            &audit,
            None,
        )
        .expect("dry-run executes");

    assert!(report.dry_run);
    assert_eq!(report.deleted_count, 0, "dry-run deletes nothing");
    assert_eq!(report.reclaimed_bytes, 0);
    assert_eq!(
        report.would_delete_count, 3,
        "but reports it WOULD reclaim 3"
    );
    assert!(report
        .actions
        .iter()
        .all(|a| matches!(a.outcome, GcOutcome::WouldDelete)
            || !a.reasons.contains(&RetentionReason::SupersededByNewer)));

    // Every seeded version is still on disk.
    for v in ["1.0.0", "1.1.0", "1.2.0", "1.10.0", "2.0.0"] {
        assert!(
            holger.fetch("rust-dev", &id("serde", v)).unwrap().is_some(),
            "dry-run must leave {v} in the store"
        );
    }
    // Nothing historized (nothing reclaimed).
    assert!(audit.events().is_empty(), "dry-run historizes no delete");
}

#[test]
fn execute_drops_exactly_the_delete_set_and_keeps_the_rest() {
    let (holger, _guard) = live_repo();
    seed_serde(&holger);

    // keep_last 2 → keep {2.0.0, 1.10.0}, reclaim {1.2.0, 1.1.0, 1.0.0}.
    let policy = RetentionPolicy::new().keep_last(2);
    let audit = MemoryAuditLog::new();
    let plan = holger
        .plan_repo_retention("rust-dev", &policy, 1_700_000_000)
        .unwrap();
    let report = holger
        .execute_repo_retention("rust-dev", &plan, &GcOptions::execute(), &audit, None)
        .expect("execute");

    assert!(!report.dry_run);
    assert_eq!(report.deleted_count, 3);
    assert_eq!(report.failed_count, 0);
    assert_eq!(report.skipped_count, 0);
    assert!(report.reclaimed_bytes > 0);

    // The delete-set is gone; the kept set survives.
    for v in ["1.0.0", "1.1.0", "1.2.0"] {
        assert!(
            holger.fetch("rust-dev", &id("serde", v)).unwrap().is_none(),
            "{v} must be reclaimed"
        );
    }
    for v in ["1.10.0", "2.0.0"] {
        assert!(
            holger.fetch("rust-dev", &id("serde", v)).unwrap().is_some(),
            "{v} (kept) must survive"
        );
    }

    // Historized: exactly one Delete audit event per reclaimed artifact.
    let evs = audit.events();
    assert_eq!(evs.len(), 3, "one GC audit record per reclaim");
    assert!(evs.iter().all(|e| e.action == AuditAction::Delete));
    assert!(evs.iter().all(|e| e.repo == "rust-dev"));
    assert!(evs.iter().all(|e| e.detail.contains("retention-gc")));
}

#[test]
fn execute_never_drops_a_protected_latest() {
    let (holger, _guard) = live_repo();
    // A single version: keep_last 1 with protection ON keeps it; nothing to drop.
    holger
        .put("rust-dev", &id("serde", "1.0.0"), b"only-one")
        .unwrap();

    let policy = RetentionPolicy::new().keep_last(1); // protect_latest defaults ON
    let audit = MemoryAuditLog::new();
    let report = holger
        .run_repo_retention(
            "rust-dev",
            &policy,
            1_700_000_000,
            &GcOptions::execute(),
            &audit,
            None,
        )
        .unwrap();

    assert_eq!(report.deleted_count, 0, "the lone latest is protected");
    assert!(
        holger
            .fetch("rust-dev", &id("serde", "1.0.0"))
            .unwrap()
            .is_some(),
        "protected latest survives execute"
    );
}

/// Defense in depth: even a hand-built plan that WRONGLY marks a protected-latest
/// record for deletion must be refused by the executor — a protected generation
/// can never be dropped through this path.
#[test]
fn executor_refuses_a_delete_flag_on_a_protected_record() {
    let (holger, _guard) = live_repo();
    holger
        .put("rust-dev", &id("serde", "1.0.0"), b"precious")
        .unwrap();

    // Forge a plan: delete=true but reasons say ProtectedLatest (a corrupt plan).
    let rec = ArtifactRecord {
        id: id("serde", "1.0.0"),
        size_bytes: 8,
        created_at: None,
        protected_by_property: false,
    };
    let plan = RetentionPlan {
        decisions: vec![RetentionDecision {
            record: rec,
            delete: true,
            reasons: vec![RetentionReason::ProtectedLatest],
        }],
        kept_count: 0,
        delete_count: 1,
        kept_bytes: 0,
        reclaimed_bytes: 8,
        name_count: 1,
        evaluated_at: 1_700_000_000,
    };

    let audit = MemoryAuditLog::new();
    let report = holger
        .execute_repo_retention("rust-dev", &plan, &GcOptions::execute(), &audit, None)
        .unwrap();

    assert_eq!(
        report.deleted_count, 0,
        "protected record must not be dropped"
    );
    assert_eq!(report.skipped_count, 1);
    assert!(matches!(report.actions[0].outcome, GcOutcome::Skipped(_)));
    assert!(
        holger
            .fetch("rust-dev", &id("serde", "1.0.0"))
            .unwrap()
            .is_some(),
        "the protected artifact must survive a corrupt plan"
    );
    assert!(audit.events().is_empty());
}

/// The GC run is routed through the shared jera job ledger when one is attached
/// (the same durable seam promotion uses): a `retention-gc` job is recorded.
#[test]
fn execute_records_a_jera_job_when_a_ledger_is_attached() {
    let (holger, _guard) = live_repo();
    seed_serde(&holger);

    let led_dir = tempfile::tempdir().unwrap();
    let jobs = Arc::new(jera::jobs::JobStore::open(led_dir.path()).expect("open jera ledger"));

    let policy = RetentionPolicy::new().keep_last(2);
    let audit = MemoryAuditLog::new();
    holger
        .run_repo_retention(
            "rust-dev",
            &policy,
            1_700_000_000,
            &GcOptions::execute(),
            &audit,
            Some(&jobs),
        )
        .expect("execute with ledger");

    let recorded = jobs
        .list(&jera::jobs::JobSelector::Kind("retention-gc".into()))
        .expect("list jobs");
    assert_eq!(recorded.len(), 1, "one retention-gc job recorded");
    assert!(recorded[0].is_terminal(), "the GC job settled");
}

/// ★ **Retention does not drop what the twin has not yet followed** (stinky SB
/// #35): with a twin enrolled on this primary's roster, every delete row WAITS
/// (skipped, `holger-twin-22`) until the twin has followed past the newest
/// write into the repository; then the same plan reclaims.
#[test]
fn a_reclaim_waits_until_the_twin_has_followed() {
    use holger_server_lib::audit::{AuditEvent, AuditLog};
    // Its OWN data root (an audit dir under the tempdir): the roster written here
    // must not be read by the other tests of this binary.
    let dir = tempfile::tempdir().unwrap();
    let d = dir.path().display();
    let ron = format!(
        r#"(
    exposed_endpoints: [ ( ron_name: "dev", ron_url: "127.0.0.1:0", ron_http_url: None ) ],
    storage_endpoints: [ ( ron_name: "inert", ron_storage_type: "znippy", ron_path: "{d}/inert/" ) ],
    repositories: [
        ( ron_name: "rust-dev", ron_repo_type: "rust", ron_data_dir: Some("{d}/rust-dev"), ron_base_url: Some("http://x"), ron_upstreams: [], ron_in: None, ron_out: Some(( ron_storage_endpoint: "inert", ron_exposed_endpoint: "dev" )) ),
    ],
    audit: ( dir: "{d}/audit", enabled: true ),
)
"#
    );
    use std::io::Write;
    let mut f = tempfile::NamedTempFile::new().unwrap();
    f.write_all(ron.as_bytes()).unwrap();
    let mut holger = read_ron_config(f.path()).expect("RON must parse");
    holger.instantiate_backends().expect("backend builds");
    wire_holger(&mut holger).expect("wires");
    seed_serde(&holger);
    let roster = holger.peers_root().join("holger-twins");
    assert!(
        roster.starts_with(dir.path()),
        "the roster is this test's own: {}",
        roster.display()
    );
    std::fs::create_dir_all(&roster).unwrap();
    std::fs::write(roster.join("vm3"), "10.13.8.3:50051\n").unwrap();
    let audit = MemoryAuditLog::new();
    audit
        .record(AuditEvent::new(
            "adm",
            AuditAction::Upload,
            "rust-dev",
            "serde 2.0.0",
            "",
            201,
            9,
        ))
        .unwrap();
    let policy = RetentionPolicy::new().keep_last(2);

    let waits = holger
        .run_repo_retention(
            "rust-dev",
            &policy,
            1_700_000_000,
            &GcOptions::execute(),
            &audit,
            None,
        )
        .unwrap();
    assert_eq!(
        (waits.deleted_count, waits.skipped_count),
        (0, 3),
        "a twin that never followed: nothing is reclaimed"
    );
    assert!(waits.actions.iter().all(|a| matches!(&a.outcome, GcOutcome::Skipped(w) if w.contains("holger-twin-22") && w.contains("vm3"))), "{:?}", waits.actions);
    for v in ["1.0.0", "1.1.0", "1.2.0"] {
        assert!(
            holger.fetch("rust-dev", &id("serde", v)).unwrap().is_some(),
            "{v} is kept for the twin"
        );
    }

    // The twin followed past the newest write: the same policy reclaims.
    let later = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap()
        .as_millis() as i64
        + 60_000;
    std::fs::write(
        roster.join("vm3"),
        format!("10.13.8.3:50051\nfollowed_to_ms {later}\n"),
    )
    .unwrap();
    let done = holger
        .run_repo_retention(
            "rust-dev",
            &policy,
            1_700_000_000,
            &GcOptions::execute(),
            &audit,
            None,
        )
        .unwrap();
    assert_eq!(
        (done.deleted_count, done.skipped_count),
        (3, 0),
        "{:?}",
        done.actions
    );
}

/// An audit sink that refuses every record (a full disk, a released file).
struct RefusingAudit;

impl holger_server_lib::audit::AuditLog for RefusingAudit {
    fn record(
        &self,
        _event: holger_server_lib::audit::AuditEvent,
    ) -> Result<(), holger_server_lib::audit::AuditError> {
        Err(holger_server_lib::audit::AuditError::Other(
            "the audit file is released".into(),
        ))
    }
}

/// ★ **A reclaim whose audit record cannot be written is counted, never swallowed** (A28 M17).
/// Before, `let _ = audit.record(...)` dropped the error: the artifact was gone and the
/// append-only trail of a security gateway said nothing, with no number to alert on.
#[test]
fn a_reclaim_whose_audit_record_is_lost_is_counted_and_published() {
    let (holger, _guard) = live_repo();
    seed_serde(&holger);
    let policy = RetentionPolicy::new().keep_last(2);
    let plan = holger
        .plan_repo_retention("rust-dev", &policy, 1_700_000_000)
        .unwrap();
    let before = holger_server_lib::metrics::global().audit_missed();
    let report = holger
        .execute_repo_retention(
            "rust-dev",
            &plan,
            &GcOptions::execute(),
            &RefusingAudit,
            None,
        )
        .expect("the reclaim itself succeeds");
    assert_eq!(report.deleted_count, 3);
    assert_eq!(
        report.audit_missed, 3,
        "every reclaim whose record was lost is in the report"
    );
    assert!(
        holger_server_lib::metrics::global().audit_missed() >= before + 3,
        "the misses are on the global counters /metrics renders"
    );
    let page = holger_server_lib::metrics::global().render_prometheus(&[]);
    assert!(
        page.contains("holger_audit_records_missed_total "),
        "{page}"
    );
}