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

nordisk/gunnar

crates/gunnar-front/src/autofill.rs

//! **A re-laid main is filled from its twin by the FRONT** (main rebuild, owner 2026-10-12).
//!
//! A main laid again (its server gone, or its /data emptied) is re-admitted fresh and holds
//! its admin back: it AWAITS its twin (`Bootstrap.Status.recharge_from`). Nothing filled it
//! since the restore lane was retired (2026-10-10), and the box cannot fill itself: a
//! gunnar-to-gunnar call never leaves a box in the clear, and the boxes hold no client
//! certificate for each other's control planes. The front is the one place that already
//! reaches both over the tunnel with a key both accept — the console's (an admin on the twin,
//! whose identity table is the primary's; a fed restore key of the fresh main) — as the Twin
//! panel's [Recharge from twin] does. So, when the estate opted into auto-fix (the installer
//! feeds the front `repair_auto = true`), this watch does that press by itself:
//! `gunnar_repair::recharge_from_twin`, the ONE pump, into a main that SAYS it awaits — a box
//! with data is never streamed into (the pump refuses a box that is not fresh).

use std::sync::Arc;
use std::time::Duration;

/// The fed opt-in (gunnar-server `feed::FEED_REPAIR_AUTO`, the same word), shared with
/// Holger's front (nordisk-pairing `autofill`, lane G11).
pub const FEED_REPAIR_AUTO: &str = nordisk_pairing::autofill::FEED_REPAIR_AUTO;
/// How often the front looks.
pub const LOOK_EVERY: Duration = nordisk_pairing::autofill::LOOK_EVERY;
/// How long one look waits for each box.
const ASK_WITHIN: Duration = Duration::from_secs(10);

/// Pure: **may the front fill the main now?** Auto-fix fed exactly `true`, and the main
/// SAYS it awaits its twin: fresh (or a restore it began) and naming the twin in
/// `recharge_from`.
/// The decision is the shared one (nordisk-pairing `autofill::due`, lane G11); this reads
/// Gunnar's `Bootstrap.Status` into it.
pub fn due(auto: Option<&str>, main: &gunnar_repair::MainStatus) -> bool {
    nordisk_pairing::autofill::due(
        auto,
        &nordisk_pairing::autofill::Awaits {
            fresh: main.is_fresh(),
            restoring: main.restoring,
            recharge_from: &main.recharge_from,
        },
    )
}

/// **One look, and the fill when it is due.** `Ok(None)`: nothing to do.
pub async fn fill_once(
    main_at: &str,
    twin_at: &str,
    key: &gunnar_core::ssh_key::PrivateKey,
    auto: Option<&str>,
) -> Result<Option<String>, String> {
    use gunnar_repair::pump::Main as _;
    let main = gunnar_repair::proto::ProtoMain {
        addr: main_at.to_owned(),
        channel: crate::operator_door::control_channel(main_at)?,
        key: key.clone(),
    };
    let status = match tokio::time::timeout(ASK_WITHIN, main.status()).await {
        Ok(Ok(s)) => s,
        Ok(Err(f)) => return Err(format!("the main at {main_at}: Bootstrap.Status: {f}")),
        Err(_) => {
            return Err(format!(
                "the main at {main_at} did not answer within {} s",
                ASK_WITHIN.as_secs()
            ))
        }
    };
    if !due(auto, &status) {
        return Ok(None);
    }
    let twin = gunnar_repair::proto::ProtoTwin {
        addr: twin_at.to_owned(),
        channel: crate::operator_door::control_channel(twin_at)?,
        key: key.clone(),
    };
    let opts = gunnar_repair::RechargeOpts {
        in_place: true,
        ..gunnar_repair::RechargeOpts::default()
    };
    tracing::warn!(main = %main_at, twin = %twin_at, "auto-fix: the re-laid main awaits its twin — the front fills it (the console's key, the one pump)");
    match gunnar_repair::recharge_from_twin(&twin, &main, &opts, &|| false).await {
        Ok(r) => Ok(Some(format!(
            "filled from the twin to seq {}: {:?} — {}",
            r.head_seq, r.phase, r.detail
        ))),
        Err(refusal) => Err(format!("the recharge was refused: {refusal}")),
    }
}

/// **The watch** — a jera job, every [`LOOK_EVERY`]: the estate's opt-in from the
/// front's own feed, the main and the twin from the verdict.
pub fn watch(vm: Arc<crate::vm::FrontVm>, rt: tokio::runtime::Handle) {
    // A jera job (A28 M18, A32-14): listed, a look that panics is said and a FAILED job and the next one runs, stopped at the
    // clean stop. Its first round only waits, as the thread's first sleep did.
    let say: gunnar_common::jobs::Say = Arc::new(|l: &str| tracing::error!("{l}"));
    let spawned = gunnar_common::jobs::start("front-autofill", LOOK_EVERY, say, {
        let mut said = String::new();
        let mut first = true;
        move || {
            if std::mem::take(&mut first) {
                return gunnar_common::jobs::Round::Again;
            }
            let Some(reply) = vm.latest() else {
                return gunnar_common::jobs::Round::Again;
            };
            let auto = reply.feed.get(FEED_REPAIR_AUTO).cloned();
            if !nordisk_pairing::autofill::auto_on(auto.as_deref()) {
                return gunnar_common::jobs::Round::Again;
            }
            let Some(t) = crate::console::target(&reply) else {
                return gunnar_common::jobs::Round::Again;
            };
            let Some(twin) = t.twin.clone() else {
                return gunnar_common::jobs::Round::Again;
            };
            let key = match std::fs::read_to_string(vm.root().join(crate::console::IDENTITY_REL))
                .map_err(|e| e.to_string())
                .and_then(|pem| {
                    gunnar_core::ssh_key::PrivateKey::from_openssh(&pem).map_err(|e| e.to_string())
                }) {
                Ok(k) => k,
                Err(_) => return gunnar_common::jobs::Round::Again,
            };
            match rt.block_on(fill_once(&t.control, &twin, &key, auto.as_deref())) {
                Ok(Some(words)) => tracing::warn!("auto-fix: the re-laid main {words}"),
                Ok(None) => {}
                Err(why) if why != said => {
                    tracing::info!("auto-fix: {why}");
                    said = why;
                }
                Err(_) => {}
            }
            gunnar_common::jobs::Round::Again
        }
    });
    if let Err(e) = spawned {
        tracing::error!(error = %e, "auto-fix: the front's watch could not start");
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// The front fills only a main that SAYS it awaits its twin, and only with the opt-in fed.
    #[test]
    fn the_front_fills_only_an_awaiting_main_with_auto_fix_fed() {
        let awaiting = gunnar_repair::MainStatus {
            window_open: true,
            recharge_from: "10.44.0.3:50051".into(),
            ..Default::default()
        };
        assert!(due(Some("true"), &awaiting));
        assert!(
            !due(None, &awaiting) && !due(Some("yes"), &awaiting),
            "auto-fix not fed"
        );
        let restoring = gunnar_repair::MainStatus {
            restoring: true,
            ..awaiting.clone()
        };
        assert!(
            due(Some("true"), &restoring),
            "a restore it began is resumed"
        );
        let whole = gunnar_repair::MainStatus {
            principals: 3,
            keys: 4,
            bootstrapped: true,
            recharge_from: "10.44.0.3:50051".into(),
            ..Default::default()
        };
        assert!(
            !due(Some("true"), &whole),
            "a main with data is never streamed into"
        );
        let fresh_unasked = gunnar_repair::MainStatus {
            window_open: true,
            ..Default::default()
        };
        assert!(
            !due(Some("true"), &fresh_unasked),
            "a fresh main nobody re-admitted as a recharge (a first install) is not filled"
        );
    }
}