holger-ui 0.1.4

Operator/admin UI for holger over the HolgerObject core API — egui via facett, embedded (LocalHolger, direct core calls) or remote (RemoteHolger gRPC).
//! **Tools · Demo appliance — a jera-backed demo container for the holger UI.**
//!
//! The shared "common framework" the whole nordisk suite adopts (nornir / dwarves /
//! korp / holger) gives every app a **Tools menu whose demo entries start containers
//! THROUGH the shared job manager `jera`** — never a bespoke `thread::spawn` or a
//! hand-rolled docker call (LAW 5, reuse-first). korp's `infra.rs` is the reference:
//! a container is declared as a plain [`jera::container::ContainerSpec`] (which maps
//! onto draupnir's ONE OCI engine via `ContainerSpec::to_draupnir`) and its bring-up
//! is *recorded as a durable job* in the shared [`jera::jobs`] ledger — the exact
//! seam holger-server already routes promote/GC through.
//!
//! holger's demo stands up a small, **pinned** upstream artifact registry
//! (`docker.io/library/registry:2.8.3`) — the natural demo for an artifact server:
//! once up, holger can proxy / migrate from it. Declaring + dispatching the spec is
//! testable in the lean default build (draupnir's spec types + the jera ledger always
//! compile; the dispatch opens a real redb job row with **no daemon**); the LIVE boot
//! is opt-in behind holger-ui's `containers` feature (→ `jera/container-oci` →
//! `draupnir/backend-oci`).

use std::collections::BTreeMap;
use std::path::Path;

use jera::container::ContainerSpec;
use serde_json::{json, Value};

// ---------------------------------------------------------------------------
// The pinned demo image + endpoint (env-overridable).
// ---------------------------------------------------------------------------

/// The demo registry version this UI pins (mirrors korp's pinned FalkorDB/Spark
/// versions). **Never `latest`** — a re-launch must be reproducible, not silently
/// drifted by an upstream retag.
pub const DEMO_REGISTRY_VERSION: &str = "2.8.3";

/// The default demo image, **pinned** to [`DEMO_REGISTRY_VERSION`]: the CNCF
/// distribution registry — a real upstream artifact store holger can proxy /
/// migrate from. Env-overridable via `HOLGER_DEMO_IMAGE`.
pub const DEMO_IMAGE_DEFAULT: &str = "docker.io/library/registry:2.8.3";

/// The registry HTTP port — published host↔container 1:1.
pub const DEMO_PORT: u16 = 5000;

/// The stable managed-container name for the demo registry (draupnir prefixes
/// `draupnir-`). A stable name makes teardown / idempotent re-launch addressable.
pub const DEMO_NAME: &str = "holger-demo-registry";

/// The workspace label the demo job is recorded under in the jera ledger.
pub const DEMO_WORKSPACE: &str = "holger-ui";

/// The demo image to run — `HOLGER_DEMO_IMAGE` else [`DEMO_IMAGE_DEFAULT`].
pub fn demo_image() -> String {
    std::env::var("HOLGER_DEMO_IMAGE").unwrap_or_else(|_| DEMO_IMAGE_DEFAULT.to_string())
}

/// The demo registry client URL (`http://127.0.0.1:5000`).
pub fn demo_url() -> String {
    format!("http://127.0.0.1:{DEMO_PORT}")
}

// ---------------------------------------------------------------------------
// The demo container spec (jera).
// ---------------------------------------------------------------------------

/// The **demo registry** container spec as a [`jera::container::ContainerSpec`]:
/// the pinned image, the stable name, and the published `:5000`. Starting it goes
/// THROUGH jera→draupnir ([`start_via_jera`] / [`dispatch_demo`]); no bespoke docker
/// call. Engine defaults for the remaining knobs (net = default NAT, no mounts);
/// `..Default::default()` keeps this forward-compatible as jera grows the spec.
pub fn demo_registry_spec() -> ContainerSpec {
    ContainerSpec {
        image: demo_image(),
        name: DEMO_NAME.to_string(),
        cmd: Vec::new(),
        env: BTreeMap::new(),
        ports: vec![DEMO_PORT],
        ..Default::default()
    }
}

// ---------------------------------------------------------------------------
// The dispatch result + the headless oracle.
// ---------------------------------------------------------------------------

/// The outcome of a demo dispatch: the **jera job id** the bring-up was recorded
/// under, the container spec's image / name / ports, and the terminal status. This
/// is what the Tools#6 pane shows AND what the robot asserts in `state_json` — proof
/// the demo went THROUGH the shared job manager (a non-empty `job_id`), not a bespoke
/// spawn.
#[derive(Debug, Clone, Default)]
pub struct DemoDispatch {
    /// The jera ledger job id the demo bring-up was recorded under (empty only if
    /// the ledger could not be opened).
    pub job_id: String,
    /// The demo container image dispatched.
    pub image: String,
    /// The demo container name.
    pub name: String,
    /// The published ports.
    pub ports: Vec<u16>,
    /// The client URL a caller would reach the demo registry at once up.
    pub url: String,
    /// Terminal status: `"booted"` (the live engine brought it up), `"dispatched"`
    /// (recorded as a job; no live engine in this build — the default), or `"error"`.
    pub status: String,
    /// `true` only when the LIVE engine (`containers` feature) actually booted it.
    pub booted: bool,
    /// A failure reason, if any (a ledger-open or boot error).
    pub error: Option<String>,
}

impl DemoDispatch {
    /// The observable state a robot asserts — carries the job id (proof it went
    /// through jera), the container spec, and the terminal status.
    pub fn state_json(&self) -> Value {
        json!({
            "job_id": self.job_id,
            "image": self.image,
            "name": self.name,
            "ports": self.ports,
            "url": self.url,
            "status": self.status,
            "booted": self.booted,
            "error": self.error,
            "dispatched": !self.job_id.is_empty(),
        })
    }
}

/// A robot/headless oracle for the *declared* demo (before any dispatch): the spec's
/// image · name · ports · url · pinned version. Lets a test assert the demo container
/// is declared + pinned without a live daemon.
pub fn state_json() -> Value {
    let spec = demo_registry_spec();
    json!({
        "version": DEMO_REGISTRY_VERSION,
        "image": spec.image,
        "name": spec.name,
        "ports": spec.ports,
        "url": demo_url(),
    })
}

// ---------------------------------------------------------------------------
// Dispatch — record the bring-up as a jera job (and boot it live when wired).
// ---------------------------------------------------------------------------

/// Dispatch the demo container **through the shared job manager** `jera`: open a
/// durable [`jera::jobs`] ledger at `jobs_root`, start a
/// [`kind::BOOT_CONTAINER`](jera::jobs::kind::BOOT_CONTAINER) job carrying the
/// [`demo_registry_spec`] (image / name / ports), capture its **job id**, then bring
/// the container up THROUGH jera→draupnir when the live engine is compiled in
/// (`containers` feature) — else record it as a planned `dispatched` job (the default
/// laptop/CI build has no daemon). Either way the ledger holds a row a reader (the
/// `nornir jobs` list / a Jobs panel) can enumerate — exactly like holger-server's
/// promote/GC jobs.
///
/// This is the load-bearing "no thread::spawn, no bespoke docker" seam: the demo's
/// bring-up is a tracked job, not an ad-hoc spawn.
pub fn dispatch_demo(jobs_root: &Path) -> DemoDispatch {
    use jera::jobs::{kind, JobStore};

    let spec = demo_registry_spec();
    let mut out = DemoDispatch {
        image: spec.image.clone(),
        name: spec.name.clone(),
        ports: spec.ports.clone(),
        url: demo_url(),
        ..Default::default()
    };

    // The shared durable ledger (redb) — the same seam server-lib routes promote/GC
    // through. Open failure is honest: no job id, status `error`.
    let store = match JobStore::open(jobs_root) {
        Ok(s) => s,
        Err(e) => {
            out.status = "error".to_string();
            out.error = Some(format!("jera ledger open: {e}"));
            return out;
        }
    };

    let detail = json!({ "image": spec.image, "name": spec.name, "ports": spec.ports });
    let job = store.start(kind::BOOT_CONTAINER, &spec.image, DEMO_WORKSPACE, detail);
    out.job_id = job.job_id().to_string();

    // Bring it up THROUGH jera→draupnir when the live engine is linked; else record a
    // planned dispatch (no daemon in this build).
    match boot_live(&spec) {
        Ok(true) => {
            out.booted = true;
            out.status = "booted".to_string();
            job.finish(json!({ "booted": true, "image": spec.image, "name": spec.name }), "");
        }
        Ok(false) => {
            out.status = "dispatched".to_string();
            job.finish(json!({ "dispatched": true, "image": spec.image, "name": spec.name }), "");
        }
        Err(e) => {
            out.status = "error".to_string();
            out.error = Some(e.clone());
            job.fail_with_detail(json!({ "error": e, "image": spec.image }));
        }
    }
    out
}

/// Boot the demo container THROUGH jera→draupnir's ONE OCI engine (the `containers`
/// feature turns on `jera/container-oci` → `draupnir/backend-oci`). Detached: the
/// container keeps running after the boot proc is dropped, so a caller can then reach
/// `http://127.0.0.1:5000`. Returns `Ok(true)` when it came up.
#[cfg(feature = "containers")]
fn boot_live(spec: &ContainerSpec) -> Result<bool, String> {
    start_via_jera(spec)?;
    Ok(true)
}

/// No live engine in this build (`containers` off): nothing to boot — the dispatch is
/// recorded as a job and the spec is validated, which is what the default tests check.
#[cfg(not(feature = "containers"))]
fn boot_live(_spec: &ContainerSpec) -> Result<bool, String> {
    Ok(false)
}

/// Start the demo container **through jera** (→ draupnir's ONE OCI engine), reusing
/// the shared runtime rather than any bespoke docker call (LAW 5). Detached bring-up
/// (the boot proc is dropped; draupnir's start is detached). Only compiled with
/// holger-ui's `containers` feature.
#[cfg(feature = "containers")]
pub fn start_via_jera(spec: &ContainerSpec) -> Result<(), String> {
    use jera::boot::BootTarget;
    let runner = jera::container::connect_default_runner();
    let _proc = runner.spawn(&BootTarget::Container(spec.clone()))?;
    Ok(())
}

// ---------------------------------------------------------------------------
// Tests — spec + dispatch asserted in the LEAN default build (no daemon).
// ---------------------------------------------------------------------------

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

    /// Emit one functional-status row for a real check. Gated behind `testmatrix`
    /// so release builds strip it (the dep is optional on holger-ui).
    #[cfg(feature = "testmatrix")]
    fn fstatus(component: &str, check: &str, ok: bool, detail: &str) {
        nornir_testmatrix::functional_status(component, check, ok, detail);
    }

    /// **RED-when-broken: the demo spec resolves + validates + is PINNED.** The jera
    /// spec maps onto a draupnir container spec that passes draupnir's own
    /// `validate()` (the "the wiring is real" gate) and carries the registry port;
    /// the default image is pinned, never `:latest` (reproducibility). A blank image,
    /// a wrong mapping, or a `:latest` default drift turns this red.
    #[test]
    fn demo_spec_maps_validates_and_is_pinned() {
        let spec = demo_registry_spec();
        assert_eq!(spec.name, DEMO_NAME);
        assert_eq!(spec.ports, vec![DEMO_PORT]);
        assert!(spec.image.contains("registry"), "demo image is a registry: {}", spec.image);
        assert!(
            !DEMO_IMAGE_DEFAULT.ends_with(":latest"),
            "the default demo image must be pinned, not :latest: {DEMO_IMAGE_DEFAULT}"
        );
        assert!(
            DEMO_IMAGE_DEFAULT.ends_with(&format!(":{DEMO_REGISTRY_VERSION}")),
            "the default demo image pins DEMO_REGISTRY_VERSION ({DEMO_REGISTRY_VERSION}): {DEMO_IMAGE_DEFAULT}"
        );
        // The mapped draupnir spec validates (the wiring is real).
        let d = spec.to_draupnir();
        d.validate().expect("mapped demo container spec is valid");
        // The declared-demo oracle carries the pinned version.
        assert_eq!(state_json()["version"], DEMO_REGISTRY_VERSION);

        #[cfg(feature = "testmatrix")]
        fstatus(
            "holger-ui/tools/demo",
            "demo_spec_pinned_and_validates",
            !DEMO_IMAGE_DEFAULT.ends_with(":latest") && spec.ports == vec![DEMO_PORT],
            "the demo container is a pinned jera ContainerSpec that maps + validates onto draupnir",
        );
    }

    /// **RED-when-broken: the demo dispatch routes THROUGH the shared jera job
    /// ledger.** Dispatching opens a real redb ledger and records a
    /// [`kind::BOOT_CONTAINER`] job carrying the spec, returning its **job id** — the
    /// proof the demo went through the shared job manager, not a bespoke spawn. We
    /// then re-open the ledger and find the SAME job id (with the demo image) in the
    /// `BOOT_CONTAINER` list. If the dispatch stopped routing through jera (no row /
    /// empty id), this is red end-to-end. No daemon: the default build records a
    /// terminal `dispatched` job.
    #[test]
    fn demo_dispatch_records_a_jera_boot_container_job() {
        use jera::jobs::{kind, JobSelector, JobStore};

        let dir = tempfile::tempdir().expect("tempdir");
        let out = dispatch_demo(dir.path());

        // The dispatch produced a job id + carried the pinned spec.
        assert!(!out.job_id.is_empty(), "dispatch records a jera job id: {out:?}");
        assert_eq!(out.image, DEMO_IMAGE_DEFAULT, "the dispatched image is the pinned demo image");
        assert_eq!(out.ports, vec![DEMO_PORT]);
        // No live engine in the default build → recorded as a planned `dispatched` job.
        assert_eq!(out.status, "dispatched", "no containers feature → dispatched (not booted): {out:?}");
        assert!(!out.booted, "the default build does not boot a live container");
        assert!(out.error.is_none(), "a clean dispatch has no error: {out:?}");
        assert!(out.state_json()["dispatched"].as_bool().unwrap_or(false), "oracle flags dispatched");

        // Re-open the SAME ledger and find the row — the durable proof.
        let store = JobStore::open(dir.path()).expect("reopen ledger");
        let rows = store
            .list(&JobSelector::Kind(kind::BOOT_CONTAINER.to_string()))
            .expect("list boot_container jobs");
        let found = rows.iter().find(|r| r.job_id == out.job_id);
        assert!(found.is_some(), "the dispatched job id is in the jera ledger: {:?}", rows);
        let row = found.unwrap();
        assert_eq!(row.kind, kind::BOOT_CONTAINER, "recorded under the boot_container kind");
        assert!(row.target.contains("registry"), "the row targets the demo image: {}", row.target);
        assert!(row.detail_json.contains(&DEMO_NAME.to_string()), "the row carries the spec detail");

        #[cfg(feature = "testmatrix")]
        fstatus(
            "holger-ui/tools/demo",
            "demo_dispatch_through_jera",
            !out.job_id.is_empty() && found.is_some(),
            "the Tools demo starts the container as a jera BOOT_CONTAINER job (durable ledger row), not a bespoke spawn",
        );
    }
}