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