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"
);
}
}