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

nordisk/monetize

crates/monetize-server/src/estate_order/setup_log.rs

//! **The setup's own log, in monetize** (the test button, owner 2026-10-11:
//! "logs from setup need to go to monetize").
//!
//! Two kinds of line, one shape ([`pb::SetupLine`]):
//!
//! * a PHASE the order moved to — the run's [`super::Transition`]s, kept in the
//!   run already; [`super::save`] publishes each new one on the bus;
//! * a line the INSTALLER said while it paired the boxes (its own narration and
//!   each box's filtered log) — handed to the installer as a [`Narrate`] in the
//!   [`super::InstallRequest`], kept in `<run>/`[`SETUP_LINES_FILE`] and published.
//!
//! The bus is the live copy (`Events.Watch`, the console's feed); the file and
//! the run's history are what a console opened later reads back
//! ([`read_tail`]). Generic: nothing here names a product.

use std::io::Write as _;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;

use monetize_proto as pb;

use crate::bus::EventBus;

/// The installer's lines for one order, beside its `laid.json`: one JSON object per line.
pub const SETUP_LINES_FILE: &str = "setup-lines.jsonl";

/// At most this many installer lines are KEPT per install (all are published):
/// a box that logs without end must not fill the shop's disk.
pub const MAX_KEPT_LINES: usize = 5_000;

/// One kept line.
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct KeptLine {
    pub at_unix_ms: u64,
    pub source: String,
    #[serde(default)]
    pub phase: String,
    pub line: String,
}

type Say = dyn Fn(&str, &str) + Send + Sync;

/// **Where an installer says what it is doing.** Cheap to clone; a silent one
/// (tests, a caller with no order) says nothing anywhere.
#[derive(Clone)]
pub struct Narrate(Option<Arc<Say>>);

impl std::fmt::Debug for Narrate {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(if self.0.is_some() { "Narrate(on)" } else { "Narrate(silent)" })
    }
}

impl Default for Narrate {
    fn default() -> Self {
        Narrate::silent()
    }
}

impl Narrate {
    pub fn silent() -> Narrate {
        Narrate(None)
    }

    /// Any sayer (tests watch with one).
    pub fn with(say: impl Fn(&str, &str) + Send + Sync + 'static) -> Narrate {
        Narrate(Some(Arc::new(say)))
    }

    /// **The narrator of one order**: kept in `<dir>/`[`SETUP_LINES_FILE`] (up
    /// to [`MAX_KEPT_LINES`]) and published on `bus` as a `SetupLine`.
    pub fn for_order(bus: Arc<EventBus>, dir: PathBuf, product: &str, tenant: &str, order: &str, phase: &str) -> Narrate {
        let (product, tenant, order, phase) = (product.to_owned(), tenant.to_owned(), order.to_owned(), phase.to_owned());
        let kept = AtomicUsize::new(0);
        Narrate::with(move |source, line| {
            let at = crate::bus::now_unix_ms();
            if kept.fetch_add(1, Ordering::Relaxed) < MAX_KEPT_LINES {
                keep(&dir, &KeptLine { at_unix_ms: at, source: source.to_owned(), phase: phase.clone(), line: line.to_owned() });
            }
            bus.publish(pb::event::Payload::SetupLine(pb::SetupLine {
                product: product.clone(),
                tenant: tenant.clone(),
                order: order.clone(),
                source: source.to_owned(),
                phase: phase.clone(),
                line: line.to_owned(),
                at_unix_ms: at,
            }));
        })
    }

    /// Say one line from `source` (`installer`, a box's name).
    pub fn say(&self, source: &str, line: &str) {
        if let Some(s) = &self.0 {
            s(source, line)
        }
    }
}

/// Append one line; a line that cannot be kept is said on the journal and lost
/// from the file only (the bus still carries it).
fn keep(dir: &Path, l: &KeptLine) {
    let path = dir.join(SETUP_LINES_FILE);
    let written = std::fs::create_dir_all(dir).and_then(|()| {
        let mut opts = std::fs::OpenOptions::new();
        opts.create(true).append(true);
        #[cfg(unix)]
        std::os::unix::fs::OpenOptionsExt::mode(&mut opts, 0o600);
        let mut f = opts.open(&path)?;
        let mut bytes = serde_json::to_vec(l).map_err(std::io::Error::other)?;
        bytes.push(b'\n');
        f.write_all(&bytes)
    });
    if let Err(e) = written {
        eprintln!("monetize-server: a setup line was not kept at {}: {e}", path.display());
    }
}

/// **The last `max` installer lines kept for an order**, oldest first. Nothing
/// kept (or no file): empty. A line that does not parse is skipped.
pub fn read_tail(dir: &Path, max: usize) -> Vec<KeptLine> {
    let Ok(text) = std::fs::read_to_string(dir.join(SETUP_LINES_FILE)) else { return Vec::new() };
    let all: Vec<KeptLine> = text.lines().filter_map(|l| serde_json::from_str(l).ok()).collect();
    let skip = all.len().saturating_sub(max);
    all.into_iter().skip(skip).collect()
}

/// **Each transition `run` gained since `before` entries, on the bus** — what
/// [`super::save`] calls, so every phase an order moves to is a live line.
pub fn publish_transitions(bus: &EventBus, product: &str, run: &super::EstateRun, before: usize) {
    for t in run.history.iter().skip(before) {
        bus.publish(pb::event::Payload::SetupLine(pb::SetupLine {
            product: product.to_owned(),
            tenant: run.account.clone(),
            order: run.company.clone(),
            source: "order".into(),
            phase: if t.phase.is_empty() { run.phase.clone() } else { t.phase.clone() },
            line: t.note.clone(),
            at_unix_ms: t.at_ms,
        }));
    }
}

/// **Where the shop's pairing keeps each box's install log**, under the run's directory: the
/// installer's run folder (`pairing/`, nordisk-pairing's `FolderSink`) writes `logs/<vm>.log`,
/// every line of the VM's installer log door (`/install/log`) it read over the installer's link.
pub const PAIRING_LOGS: &str = "pairing/logs";

/// At most this many lines are answered per read (the next read asks from `next`).
pub const INSTALL_LOG_MAX_LINES: usize = 20_000;

/// **One box's install log, from a line on.**
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct InstallLog {
    pub estate: String,
    pub vm: String,
    /// Whole lines from `since`, oldest first, each ending in a newline.
    pub text: String,
    /// Ask from here next time.
    pub next: u64,
    /// The lines kept for the box in all.
    pub lines: u64,
}

/// **`EstateOrder.InstallLog`** (2026-10-20: the narrate store had trimmed a front's first
/// boot lines): `vm`'s install log as this box's pairing read it, from line `since` — `estate`
/// is a site's label or its run's company. The operator's alone (the service checks).
pub fn install_log(state: &crate::state::AppState, estate: &str, vm: &str, since: u64) -> Result<InstallLog, String> {
    let run = state.estates.get(estate.trim()).ok_or_else(|| format!("{estate} is no estate laid here"))?;
    let dir = state.estates.run_dir(&run.company).ok_or("this box keeps no estate-orders directory")?.join(PAIRING_LOGS);
    let mut roles: Vec<String> = run.laid.as_ref().map(|l| l.machines.iter().map(|m| m.role.clone()).collect()).unwrap_or_default();
    if let Ok(rd) = std::fs::read_dir(&dir) {
        for e in rd.flatten() {
            if let Some(role) = e.file_name().to_str().and_then(|n| n.strip_suffix(".log")) {
                if !roles.iter().any(|r| r == role) {
                    roles.push(role.to_string());
                }
            }
        }
    }
    let vm = vm.trim();
    if !roles.iter().any(|r| r == vm) {
        return Err(format!("{estate} has no machine {vm:?} (it has {})", roles.join(", ")));
    }
    let label = if run.estate.is_empty() { run.company.clone() } else { run.estate.clone() };
    let all = match std::fs::read(dir.join(format!("{vm}.log"))) {
        Ok(b) => String::from_utf8_lossy(&b).into_owned(),
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
            return Err(format!("{label}'s {vm} has no install log kept here: this box's pairing never read it (laid by a kit, or the install never reached the box)"));
        }
        Err(e) => return Err(format!("{label}'s {vm} install log: {e}")),
    };
    let lines: Vec<&str> = all.lines().collect();
    let from = (since as usize).min(lines.len());
    let to = (from + INSTALL_LOG_MAX_LINES).min(lines.len());
    let text: String = lines[from..to].iter().map(|l| format!("{l}\n")).collect();
    Ok(InstallLog { estate: label, vm: vm.to_string(), text, next: to as u64, lines: lines.len() as u64 })
}

#[cfg(test)]
mod tests {
    use super::*;

    /// A fresh directory, removed when dropped.
    struct TempDir(std::path::PathBuf);
    impl TempDir {
        fn new(name: &str) -> TempDir {
            let d = std::env::temp_dir().join(format!("monetize-{name}-{}-{}", std::process::id(), crate::bus::now_unix_ms()));
            std::fs::create_dir_all(&d).unwrap();
            TempDir(d)
        }
        fn path(&self) -> &std::path::Path {
            &self.0
        }
    }
    impl Drop for TempDir {
        fn drop(&mut self) {
            let _ = std::fs::remove_dir_all(&self.0);
        }
    }

    /// ★ **An installer's line is kept with the order AND published**: the
    /// console opened later reads it back, the console open now sees it live,
    /// and both name the order, the account and the box that said it.
    #[test]
    fn an_installer_line_is_kept_with_the_order_and_published_on_the_bus() {
        let dir = TempDir::new("setup-log");
        let bus = Arc::new(EventBus::default());
        let n = Narrate::for_order(bus.clone(), dir.path().join("acme"), "gunnar", "test-1", "acme", "installing");
        n.say("installer", "pairing 3 machine(s)");
        n.say("acme-main", "gunnar-server: serving");
        let kept = read_tail(&dir.path().join("acme"), 10);
        assert_eq!(kept.iter().map(|l| (l.source.as_str(), l.line.as_str(), l.phase.as_str())).collect::<Vec<_>>(), [("installer", "pairing 3 machine(s)", "installing"), ("acme-main", "gunnar-server: serving", "installing")]);
        assert_eq!(read_tail(&dir.path().join("acme"), 1).len(), 1, "the tail is the last lines");
        let seen = bus.recent();
        assert_eq!(seen.len(), 2, "every line is published");
        match seen[0].payload.clone() {
            Some(pb::event::Payload::SetupLine(l)) => assert_eq!((l.product.as_str(), l.tenant.as_str(), l.order.as_str(), l.source.as_str()), ("gunnar", "test-1", "acme", "installer")),
            other => panic!("{other:?}"),
        }
        Narrate::silent().say("x", "nothing anywhere");
        assert!(read_tail(&dir.path().join("none"), 5).is_empty());
    }

    /// ★ **The kept lines are the shop's alone** (ST-18): every sold install's boxes say
    /// what they do — names, addresses, links — so the file beside `laid.json` is 0600
    /// (as custody writes its own), never the process umask's world-readable default.
    #[cfg(unix)]
    #[test]
    fn the_kept_setup_lines_are_readable_by_the_shop_only() {
        use std::os::unix::fs::PermissionsExt as _;
        let dir = TempDir::new("setup-log-mode");
        let n = Narrate::for_order(Arc::new(EventBus::default()), dir.path().join("acme"), "gunnar", "test-1", "acme", "installing");
        n.say("acme-main", "gunnar-server: serving");
        let mode = std::fs::metadata(dir.path().join("acme").join(SETUP_LINES_FILE)).unwrap().permissions().mode() & 0o777;
        assert_eq!(mode, 0o600, "setup-lines.jsonl is {mode:o}");
    }
}