crates/gunnar-front/src/update.rs
//! **The front does the update its shop opened** (owner 2026-10-14: the ONE box updates the
//! estates and sites it laid; monetize never dials an estate).
//!
//! The step is the ONE every product's front runs: nordisk-pairing `frontside::update`
//! (T16 / P4-REST, A28 M11), re-exported here. Gunnar's own copy moved there with its
//! 2026-10-25 field fix (boxes left at their open install listener are re-admitted first, and
//! together, before a keep upgrade waits on them), so Holger's front has it too. What stays
//! Gunnar's: its hands on a live estate ([`EstateHands`]: the boxes' log doors over the kept
//! material, its own korp slots, the shop's keep upgrade, the re-admission from the kept
//! bundle), the staged-self marker on its own disk, and the words of its front's keep boot.
use std::time::Duration;
pub use nordisk_pairing::frontside::busy::busy;
pub use nordisk_pairing::frontside::update::*;
/// How often the front asks whether an update is open for it.
pub const LOOK_EVERY: Duration = Duration::from_secs(120);
/// **Gunnar's words for its front's keep boot** (`needs-keep`): the medium the shop keep-boots
/// the front from (`gunnar-monetize-guest` `front_keep_medium`).
pub const FRONT_KEEP: &str = "Gunnar's front from front-keep";
/// **Leave the staged-self marker** under `root`: its `update/` made first (a front booted from
/// its keep medium has none yet), the marker written there atomically and that directory flushed.
pub fn mark_staged_self_at(root: &std::path::Path, release: &str) -> Result<(), String> {
let rel = std::path::Path::new(STAGED_SELF_REL);
let dir = root.join(rel.parent().unwrap_or(std::path::Path::new("")));
std::fs::create_dir_all(&dir).map_err(|e| format!("making {}: {e}", dir.display()))?;
let name = rel
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("staged-self");
gunnar_server::durable::write_atomic_unframed(&dir, name, release.as_bytes())
.map_err(|e| e.to_string())
}
/// Whether `declared` carries anything (a release declaration or env) beside the plain stage.
pub fn declares(declared: &nordisk_pairing::live::Declared) -> bool {
declared.release.is_some() || declared.env.as_ref().is_some_and(|e| !e.is_empty())
}
#[cfg(feature = "metered")]
mod live_hands {
use super::*;
use crate::recharge::{held, paths_for, Kept};
use nordisk_pairing::live::{Declared, Running, Stage};
use std::sync::Arc;
/// The front's hands on a live estate: the boxes' log doors over the kept material, its
/// own korp slots, the shop's keep upgrade, the re-admission, an https GET.
pub struct EstateHands {
pub root: std::path::PathBuf,
pub door: crate::shop_door::ShopDoor,
pub vm: Arc<crate::vm::FrontVm>,
pub rt: tokio::runtime::Handle,
}
impl EstateHands {
fn client(&self, role: &str) -> Result<nordisk_pairing::installer::Client, String> {
let kept = Kept::parse(
&held().ok_or("no kept bundle is held yet (it is read from the primary's slot)")?,
)?;
let m = kept.material()?;
let link = m
.link
.clone()
.ok_or("the kept material holds no link keys")?;
let (_, path) = paths_for(&kept.plan, &link)?
.into_iter()
.find(|(vm, _)| vm.name == role)
.ok_or_else(|| format!("the kept plan names no {role}"))?;
nordisk_pairing::installer::Client::new(
path,
&m.ca.cert_pem,
&m.me,
Duration::from_secs(120),
)
}
fn primary(&self) -> impl Fn() -> Option<String> {
let vm = Arc::clone(&self.vm);
move || {
vm.latest()
.as_ref()
.and_then(crate::console::target)
.map(|t| t.control)
}
}
}
impl Hands for EstateHands {
fn running(&self, role: &str) -> Result<Running, String> {
if role == "front" {
return own_running();
}
self.client(role)?.live_running()
}
fn stage(&self, role: &str, stage: &Stage, declared: &Declared) -> Result<String, String> {
if role == "front" {
// The front's own korp slot takes the plain stage only: a release that declares
// more is its keep boot's (the shared step answers `needs-keep`).
if declares(declared) {
return Err("REFUSED [live-declared-unsupported] the front's own slot takes the plain stage only".into());
}
return own_stage(stage);
}
if declares(declared) {
return self.client(role)?.live_stage_declared(stage, declared);
}
self.client(role)?.live_stage(stage)
}
fn fetch(&self, url: &str) -> Result<Vec<u8>, String> {
fetch(url)
}
fn keep(&self, role: &str) -> Result<String, String> {
crate::recharge::upgrade_one(&self.root, &self.door, role, &self.primary(), &self.rt)
}
fn readmit(&self, roles: &[&str]) -> Result<String, String> {
crate::recharge::readmit_boxes(&self.root, roles, &self.primary(), &self.rt)
}
fn wait(&self, d: Duration) {
std::thread::sleep(d);
}
fn staged_self(&self, release: &str) -> bool {
let p = self.root.join(STAGED_SELF_REL);
let was = std::fs::read_to_string(&p).is_ok_and(|r| r.trim() == release.trim());
let _ = std::fs::remove_file(&p);
was
}
fn mark_staged_self(&self, release: &str) -> Result<(), String> {
mark_staged_self_at(&self.root, release)
}
fn front_keep(&self) -> String {
FRONT_KEEP.into()
}
}
#[cfg(target_os = "linux")]
fn own_running() -> Result<Running, String> {
gunnar_server::live_update::running()
}
#[cfg(target_os = "linux")]
fn own_stage(stage: &Stage) -> Result<String, String> {
gunnar_server::live_update::stage(stage)
}
#[cfg(not(target_os = "linux"))]
fn own_running() -> Result<Running, String> {
Err("[live-unsupported] not a korp box".into())
}
#[cfg(not(target_os = "linux"))]
fn own_stage(_: &Stage) -> Result<String, String> {
Err("[live-unsupported] not a korp box".into())
}
/// **An https GET of a presigned release object** — the compiled-in roots, up to the
/// stage's ceiling.
pub fn fetch(url: &str) -> Result<Vec<u8>, String> {
let url = super::reachable(url);
// A28 M18, stays a thread: one fetch on a runtime of its own, joined at once.
std::thread::spawn(move || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| format!("a runtime for the fetch: {e}"))?;
rt.block_on(async move {
use http_body_util::BodyExt as _;
let tls = rustls::ClientConfig::builder_with_provider(std::sync::Arc::new(
rustls::crypto::ring::default_provider(),
))
.with_safe_default_protocol_versions()
.map_err(|e| format!("rustls: {e}"))?
.with_root_certificates(rustls::RootCertStore {
roots: webpki_roots::TLS_SERVER_ROOTS.to_vec(),
})
.with_no_client_auth();
let https = hyper_rustls::HttpsConnectorBuilder::new()
.with_tls_config(tls)
.https_or_http()
.enable_http1()
.build();
let client: hyper_util::client::legacy::Client<
_,
http_body_util::Empty<bytes::Bytes>,
> = hyper_util::client::legacy::Client::builder(
hyper_util::rt::TokioExecutor::new(),
)
.build(https);
let uri: http::Uri = url
.parse()
.map_err(|e| format!("[update-fetch] the url: {e}"))?;
let resp = tokio::time::timeout(Duration::from_secs(600), client.get(uri))
.await
.map_err(|_| "[update-fetch] no answer within 10 min".to_string())?
.map_err(|e| format!("[update-fetch] {e}"))?;
if !resp.status().is_success() {
return Err(format!(
"[update-fetch] the bucket answered {}",
resp.status()
));
}
let limited = http_body_util::Limited::new(
resp.into_body(),
nordisk_pairing::live::MAX_STAGE_BYTES,
);
let body = limited
.collect()
.await
.map_err(|e| format!("[update-fetch] the body: {e}"))?;
Ok(body.to_bytes().to_vec())
})
})
.join()
.unwrap_or_else(|_| Err("the fetch panicked".into()))
}
/// **Look for an update every [`LOOK_EVERY`]** and do the step whose turn it is (the shared
/// [`look`]: take, step, report). A duty (A28 M18): listed, a look that panics is said and the
/// next one runs, stopped at the clean stop; its first round only waits, as the thread's
/// first sleep did. Does nothing on an estate that was not laid by a shop.
pub fn watch(vm: Arc<crate::vm::FrontVm>, rt: tokio::runtime::Handle) {
let say: gunnar_common::jobs::Say = Arc::new(|l: &str| tracing::error!("{l}"));
let spawned = gunnar_common::jobs::start("front-update", LOOK_EVERY, say, {
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 Some(Ok(door)) = crate::recharge::shop_door(&reply.feed) else {
return gunnar_common::jobs::Round::Again;
};
let hands = EstateHands {
root: vm.root().to_path_buf(),
door: door.clone(),
vm: Arc::clone(&vm),
rt: rt.clone(),
};
match look(&door, &hands) {
Ok(None) => {}
Ok(Some((role, outcome, said))) => {
tracing::warn!(role = %role, "update: {outcome} — {said}")
}
// Said at INFO (mock 2026-10-17: a pull that failed every 2 min left nothing to read).
Err(e) => {
tracing::info!(error = %e, "update: the shop did not answer the update pull or the report; asked again in 2 min")
}
}
gunnar_common::jobs::Round::Again
}
});
if let Err(e) = spawned {
tracing::error!(error = %e, "update: no duty to look for updates");
}
}
}
#[cfg(feature = "metered")]
pub use live_hands::{watch, EstateHands};
#[cfg(test)]
mod tests {
use super::*;
/// ★ A front booted from its keep medium has no `update/` under /data yet (test2, 2026-10-20:
/// "the marker before its own stage: writing /data/update/staged-self.1-2.tmp"): the marker
/// is written all the same, and read back as the release it names.
#[test]
fn the_staged_self_marker_is_written_on_a_root_with_no_update_dir() {
let root =
std::env::temp_dir().join(format!("gunnar-front-staged-self-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).unwrap();
mark_staged_self_at(&root, "51042dd23f44").expect("the marker is written");
assert_eq!(
std::fs::read_to_string(root.join(STAGED_SELF_REL)).unwrap(),
"51042dd23f44"
);
let _ = std::fs::remove_dir_all(&root);
}
/// ★ A plain stage declares nothing; a release declaration or env is said to ride it.
#[test]
fn a_plain_stage_declares_nothing() {
use nordisk_pairing::live::Declared;
assert!(!declares(&Declared::default()));
assert!(!declares(&Declared {
release: None,
env: Some(Vec::new())
}));
assert!(declares(&Declared {
release: Some((b"korp".to_vec(), Vec::new())),
env: None
}));
assert!(declares(&Declared {
release: None,
env: Some(vec!["korp.env.GUNNAR_DATA=/data".into()])
}));
}
/// A Gunnar estate after both boxes ran their keep medium in place (gunnar.rs 2026-10-25
/// 11:08Z): main and twin answer as the OPEN install listener until the front re-admits them.
struct Waiting {
open: std::cell::RefCell<Vec<&'static str>>,
readmitted: std::cell::RefCell<Vec<String>>,
app: String,
}
impl Hands for Waiting {
fn running(&self, role: &str) -> Result<nordisk_pairing::live::Running, String> {
if self.open.borrow().contains(&role) {
return Err("REFUSED [install-path] this listener has /install/hello, /install/log, /install/feed, /install/status, /install/offer, /install/secret-offer and /install/close".into());
}
Ok(nordisk_pairing::live::Running {
base: "b".repeat(64),
app: self.app.clone(),
slot: "a".into(),
live_update: true,
})
}
fn stage(
&self,
_: &str,
_: &nordisk_pairing::live::Stage,
_: &nordisk_pairing::live::Declared,
) -> Result<String, String> {
panic!("a box on the release is never staged")
}
fn fetch(&self, _: &str) -> Result<Vec<u8>, String> {
panic!("nothing is fetched")
}
fn keep(&self, role: &str) -> Result<String, String> {
panic!("no second keep boot of the {role}")
}
fn wait(&self, _: Duration) {}
fn staged_self(&self, _: &str) -> bool {
false
}
fn mark_staged_self(&self, _: &str) -> Result<(), String> {
Ok(())
}
fn readmit(&self, roles: &[&str]) -> Result<String, String> {
self.readmitted.borrow_mut().push(roles.join("+"));
self.open.borrow_mut().retain(|r| !roles.contains(r));
Ok(format!(
"{} re-admitted by the front after its upgrade (its data kept); 3 box(es) closed",
roles.join(" and ")
))
}
fn front_keep(&self) -> String {
FRONT_KEEP.into()
}
}
/// ★ **Gunnar's 2026-10-25 behaviour, through the shared step** (gunnar 2cd07aed): both
/// boxes at their open install listener are re-admitted in ONE run, the twin is read again
/// and, on the release already, reported `done:keep`; the front's keep boot is said in
/// Gunnar's words.
#[test]
fn both_gunnar_boxes_left_waiting_are_readmitted_together_through_the_shared_step() {
let app = "1".repeat(64);
let h = Waiting {
open: std::cell::RefCell::new(vec!["main", "twin"]),
readmitted: Default::default(),
app: app.clone(),
};
let live = LiveApp {
base: "b".repeat(64),
app: app.clone(),
sig: "ab".into(),
..LiveApp::default()
};
let t = Take {
open: true,
release: "cb7a98d44166".into(),
live: true,
role: "twin".into(),
app: Some(live.clone()),
..Take::default()
};
let (outcome, words) = step(&h, &t).expect("reported");
assert_eq!(outcome, DONE_KEEP, "{words}");
assert_eq!(
*h.readmitted.borrow(),
vec!["twin+main".to_string()],
"one re-admission of both"
);
assert!(words.contains("never re-admitted"), "{words}");
let front = Waiting {
open: Default::default(),
readmitted: Default::default(),
app: "0".repeat(64),
};
let moved = LiveApp {
base: "c".repeat(64),
..live
};
let t = Take {
role: "front".into(),
front: Some(moved),
..t
};
let (outcome, words) = step(&front, &t).expect("reported");
assert_eq!(outcome, NEEDS_KEEP);
assert!(
words.ends_with("the shop keep-boots Gunnar's front from front-keep"),
"{words}"
);
}
}