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