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

nordisk/monetize

crates/monetize-product-wasm/src/hot.rs

//! **A guest that can be replaced while monetize runs** — `.nornir/02`'s
//! promise, *"a signed `.wasm` is replaced without rebuilding or restarting
//! monetize"*, kept.
//!
//! ```text
//!   HotGuest ── RwLock<Arc<GuestProduct>> ──▶ the guest serving NOW
//!      │                                        ▲
//!      │ replace(block):                        │ one pointer write, after
//!      │   the whole load order (vouching,      │ everything below succeeded
//!      │   digest, signature, compile, ABI,     │
//!      │   manifest, gates, id, meters) ────────┘
//!      │   with NO lock held
//!      ▼
//!   a call: clone the Arc under the read lock (nanoseconds), release, call.
//! ```
//!
//! * **In-flight calls finish on the instance they started on.** A call holds
//!   its own `Arc<GuestProduct>`; the swap only changes which one the NEXT call
//!   clones. The old guest is dropped when its last call returns.
//! * **A replacement passes exactly the gates a start does** —
//!   [`GuestProduct::build_after`] is [`GuestProduct::build`] — and it is
//!   built completely (compiled, negotiated, its manifest and facts asked)
//!   before it is swapped in. Any refusal leaves the serving guest untouched and
//!   comes back as `Err`, by name, wearing `[products.<id>]`.
//! * **The allow-list is the operator's.** Grants, `http_hosts` and
//!   `guest_grpc_hosts` come from the block handed in (the config the operator
//!   wrote); the module's manifest can only be refused against them, never
//!   widen them (gates A and B in [`crate::load`]).
//! * **The host's memory for the product survives** ([`crate::doors::Doors::state`]):
//!   a replacement is not a restart, so honoured nonces and a restore's phase
//!   are shared by the old and the new instance.
//! * **The id cannot change.** The block's id is the registry's key; a module
//!   whose manifest names another product is refused by the load order already.

use std::sync::{Arc, RwLock};

use monetize_product::{ProductBuild, ProductError};

use crate::GuestProduct;

type Cell = Arc<RwLock<Arc<GuestProduct>>>;

/// What a replacement did — for the operator's log line.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Replaced {
    pub id: &'static str,
    /// blake3 of the module that served before, and of the one that serves now.
    pub from_digest: String,
    pub to_digest: String,
    /// The signer of the new module, when it is signed.
    pub signer: Option<String>,
}

/// **One product, served by whichever guest was last swapped in.**
pub struct HotGuest {
    id: &'static str,
    cell: Cell,
    /// The fingerprint of the block the serving guest was built from
    /// ([`block_fingerprint`]): what tells a changed block from an unchanged one.
    source: RwLock<String>,
    /// Serialises writers: two replacements racing would each build from the
    /// same previous guest and the loser's state share would be lost.
    writer: std::sync::Mutex<()>,
}

impl std::fmt::Debug for HotGuest {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("HotGuest")
            .field("id", &self.id)
            .field("serving", &*self.current())
            .finish()
    }
}

/// **A block's identity as far as a replacement is concerned**: the module's
/// bytes (by digest) and every key of the block a guest is built from. Two
/// equal fingerprints build the same guest; a changed one is worth a reload.
/// Hashed, so a bearer token in the block is never kept as text.
pub fn block_fingerprint(b: &ProductBuild<'_>) -> Result<String, String> {
    let spec = b
        .guest
        .ok_or_else(|| b.err("names no `guest = \"<path>.wasm\"`"))?;
    let bytes = std::fs::read(&spec.module).map_err(|e| {
        b.err(format!(
            "guest {}: could not be read: {e}",
            spec.module.display()
        ))
    })?;
    let text = format!(
        "{}|{:?}|{}|{:?}|{:?}|{:?}|{:?}|{:?}|{:?}",
        guest_host::digest_hex(&bytes),
        spec,
        b.endpoint,
        b.identity_key,
        b.appliance_key,
        b.bearer_token,
        b.api_key_env,
        b.restoring_from_twin,
        b.tls_ca
    );
    Ok(guest_host::digest_hex(text.as_bytes()))
}

impl HotGuest {
    /// The first guest for `[products.<id>]`: [`GuestProduct::build`], held
    /// where it can later be replaced.
    pub fn build(b: &ProductBuild<'_>) -> Result<HotGuest, String> {
        let source = block_fingerprint(b)?;
        let g = Arc::new(GuestProduct::build(b)?);
        let cell: Cell = Arc::new(RwLock::new(g));
        Ok(HotGuest {
            id: crate::leak_id(b.id),
            cell,
            source: RwLock::new(source),
            writer: std::sync::Mutex::new(()),
        })
    }

    /// The guest serving NOW. A caller that holds it keeps that instance alive
    /// until it lets go, whatever is swapped in meanwhile.
    pub fn current(&self) -> Arc<GuestProduct> {
        Arc::clone(&self.cell.read().unwrap_or_else(|p| p.into_inner()))
    }

    /// Has the block changed since the serving guest was built from it? An
    /// unreadable module is `Err` — the caller logs it, and nothing is swapped.
    pub fn is_current_for(&self, b: &ProductBuild<'_>) -> Result<bool, String> {
        let now = block_fingerprint(b)?;
        Ok(*self.source.read().unwrap_or_else(|p| p.into_inner()) == now)
    }

    /// **Replace the serving guest with the one `b` describes**, live.
    ///
    /// The new guest is built completely — every gate a start passes, compiled
    /// and asked its manifest and facts — before anything is swapped;
    /// the only work done while a lock is held is one pointer write. On `Err`
    /// the serving guest is untouched and keeps answering.
    pub fn replace(&self, b: &ProductBuild<'_>) -> Result<Replaced, String> {
        if b.id != self.id {
            return Err(b.err(format!(
                "is not [products.{}]: a live replacement cannot move a guest to another id",
                self.id
            )));
        }
        let _w = self.writer.lock().unwrap_or_else(|p| p.into_inner());
        let source = block_fingerprint(b)?;
        let previous = self.current();
        let next = Arc::new(GuestProduct::build_after(b, Some(&previous))?);
        let r = Replaced {
            id: self.id,
            from_digest: previous.digest_hex().to_string(),
            to_digest: next.digest_hex().to_string(),
            signer: next.signer().map(str::to_string),
        };
        *self.cell.write().unwrap_or_else(|p| p.into_inner()) = next;
        *self.source.write().unwrap_or_else(|p| p.into_inner()) = source;
        Ok(r)
    }
}

impl HotGuest {
    /// **The guardian the SERVING guest declared** (lane W3): this slot, whose calls are answered
    /// by whichever guest serves when each is made; `None` while the serving guest declares none.
    pub fn guardian(&self) -> Option<&dyn monetize_product::guardian::EstateGuardian> {
        self.current()
            .guardian()
            .is_some()
            .then_some(self as &dyn monetize_product::guardian::EstateGuardian)
    }
}

impl HotGuest {
    /// The serving guest's facts under this slot's id; its meter table is interned by
    /// `load::leak_meters` (one copy per distinct table, however often the guest reloads), so it
    /// outlives the guest it came from and a swap cannot dangle it.
    pub fn facts(&self) -> monetize_product::ProductFacts {
        monetize_product::ProductFacts {
            id: self.id,
            ..self.current().facts()
        }
    }
}

/// **The estate guardian of the serving guest** (lane W3). A guest replaced by one that
/// declares no guardian finds nothing and plans nothing, by name.
impl monetize_product::guardian::EstateGuardian for HotGuest {
    fn diagnose(
        &self,
        view: &monetize_product::guardian::EstateView,
    ) -> Vec<monetize_product::guardian::Finding> {
        let g = self.current();
        g.guardian().map(|l| l.diagnose(view)).unwrap_or_default()
    }
    fn plan(
        &self,
        remedy: monetize_product::guardian::Remedy,
        view: &monetize_product::guardian::EstateView,
    ) -> Result<Vec<monetize_product::guardian::Step>, String> {
        let g = self.current();
        g.guardian()
            .ok_or_else(|| lane_gone(self.id, "guardian").to_string())?
            .plan(remedy, view)
    }
    fn observe(
        &self,
        role: &str,
        what: &str,
        view: &monetize_product::guardian::EstateView,
    ) -> monetize_product::guardian::StepState {
        let g = self.current();
        match g.guardian() {
            Some(l) => l.observe(role, what, view),
            None => monetize_product::guardian::StepState::Waiting(
                lane_gone(self.id, "guardian").to_string(),
            ),
        }
    }
}

/// A lane the serving guest no longer declares, asked for by a caller that saw
/// it on the previous one.
fn lane_gone(id: &str, lane: &str) -> ProductError {
    ProductError::Unavailable(format!("[products.{id}] the guest serving now declares no {lane} lane (it was replaced since the lane was looked up)"))
}