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

nordisk/edda

crates/nornir-zone/src/lib.rs

//! # nornir-zone
//!
//! The ONE shared **`Zone`** primitive for the nordisk constellation — the
//! platform-level **environment axis** every product understands.
//!
//! A [`Zone`] is an isolated environment/namespace a full default-infrastructure
//! stack is populated into — like `dev`/`staging`/`prod` in ops. It is a
//! **platform concept**, exactly like a JetBrains "project" is understood by every
//! IDE in the suite: `korp` populates a `demo` zone with the SAME plumbing `dwarves`
//! uses for a `test` zone or `nornir` for a `prod` zone.
//!
//! ## Orthogonal to "mode" (never conflate)
//! - **Mode** = HOW an app runs (korp: cases / analysis / infra).
//! - **Zone** = WHICH environment its infra + data live in (demo / test / prod).
//!
//! "korp analysis-mode against the prod zone" is a valid combination. `demo` is
//! merely the **default zone value** — NOT a mode and NOT a "demo button".
//!
//! ## Why it exists (LAW #5 — DRY)
//! `korp` grew a `korp::zone::Zone` (commit `a053966`) as the seed. Zone is a
//! platform concept every product needs, so the ONE definition lives **here** and
//! korp + every app (`nornir` / `dwarves` / `holger` / `modgunn` / `ordning`)
//! re-export/consume it — the korp-local twin is retired. This is a **true leaf**:
//! the enum + pure namespacing helpers, std-only, ZERO runtime deps, no domain
//! coupling (an app supplies its own **base port** and **app name**; the leaf owns
//! only the per-zone *offset* / *suffix* / *namespace* math).
//!
//! ## What a zone scopes (so two zones never collide)
//! Populating two zones at once would clobber each other if they shared a container
//! name, a published port, or a data namespace. A zone therefore scopes:
//! - the **container-name suffix** ([`Zone::name_suffix`]) — empty for [`Zone::Demo`]
//!   (byte-identical to a pre-zone name), `-test` / `-prod` for the rest;
//! - the **published-port offset** ([`Zone::port_offset`]) — demo `+0`, test `+1`,
//!   prod `+2`, added to an app's base port; and
//! - the **data namespace** ([`Zone::namespace`]) — `<app>_<zone>` (e.g.
//!   `korp_demo`), the graph/warehouse/table prefix an app's population lands in.
//!
//! The enum is **extensible**: add a variant + its arm in [`Zone::ALL`] and every
//! `match` here (each exhaustive) forces the scoping helpers to follow. The default
//! is [`Zone::Demo`] so every pre-zone caller keeps landing where it did.

#![forbid(unsafe_code)]

/// **Introspection / emit marker** — record one functional-status row for the
/// nornir test matrix. Wraps `nornir_testmatrix::functional_status` behind the
/// optional `testmatrix` feature: ON, it emits a real row nornir reads back; OFF,
/// it is a compiled-out `#[inline]` no-op with NO nornir dependency, so the lean
/// default build never pulls it. Mirrors the sibling edda leaves.
#[inline]
pub fn functional_status(component: &str, check: &str, ok: bool, detail: &str) {
    #[cfg(feature = "testmatrix")]
    nornir_testmatrix::functional_status(component, check, ok, detail);
    #[cfg(not(feature = "testmatrix"))]
    {
        let _ = (component, check, ok, detail);
    }
}

/// One **environment** the constellation's default infrastructure + data can be
/// populated into. Orthogonal to any app's "mode"; [`Zone::Demo`] is the default
/// value (what a pre-zone caller, passing no zone, lands in).
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Default)]
pub enum Zone {
    /// The throwaway **demonstration** environment — the DEFAULT. Empty container
    /// suffix (pre-zone-identical), port offset `0`, namespace `<app>_demo`.
    #[default]
    Demo,
    /// A **test/staging** environment — isolated container/port/namespace so a test
    /// populate never clobbers `demo` (or `prod`).
    Test,
    /// The **production** environment.
    Prod,
}

impl Zone {
    /// Every zone, in canonical display order (extend here when adding a variant —
    /// the exhaustive `match`es below then force every helper to cover it).
    pub const ALL: [Zone; 3] = [Zone::Demo, Zone::Test, Zone::Prod];

    /// The stable machine key (`demo` / `test` / `prod`) — used in state JSON, the
    /// CLI, and container/namespace scoping. Lowercase, stable, never localized.
    pub fn key(self) -> &'static str {
        match self {
            Zone::Demo => "demo",
            Zone::Test => "test",
            Zone::Prod => "prod",
        }
    }

    /// The human title shown on an app's zone picker (currently == [`key`](Self::key),
    /// kept separate so a product can localize the label without touching the key).
    pub fn title(self) -> &'static str {
        match self {
            Zone::Demo => "demo",
            Zone::Test => "test",
            Zone::Prod => "prod",
        }
    }

    /// Parse a zone key (the canonical key + a few friendly aliases). Unknown →
    /// `None`; the caller decides the fallback (usually [`Zone::Demo`]).
    pub fn from_key(s: &str) -> Option<Zone> {
        match s.trim().to_ascii_lowercase().as_str() {
            "demo" | "demonstration" | "dev" => Some(Zone::Demo),
            "test" | "staging" | "stage" => Some(Zone::Test),
            "prod" | "production" | "live" => Some(Zone::Prod),
            _ => None,
        }
    }

    /// The published-port **OFFSET** added to an app's base port so zones don't
    /// collide on the host: demo `+0`, test `+1`, prod `+2`. An app computes its
    /// per-zone port as `base + zone.port_offset()` (see [`port_on`](Self::port_on)).
    pub fn port_offset(self) -> u16 {
        match self {
            Zone::Demo => 0,
            Zone::Test => 1,
            Zone::Prod => 2,
        }
    }

    /// An app's per-zone **published port** for a `base` port: `base +
    /// `[`port_offset`](Self::port_offset). Demo returns `base` unchanged (pre-zone
    /// parity); test/prod step up by one each. Saturating so a base near `u16::MAX`
    /// never wraps.
    pub fn port_on(self, base: u16) -> u16 {
        base.saturating_add(self.port_offset())
    }

    /// The **container-name suffix** for this zone — empty for [`Zone::Demo`] (so a
    /// managed container keeps its pre-zone name, byte-identical), `-test` / `-prod`
    /// for the rest. An app names its per-zone container `<base-name><suffix>`
    /// (see [`container_name`](Self::container_name)).
    pub fn name_suffix(self) -> &'static str {
        match self {
            Zone::Demo => "",
            Zone::Test => "-test",
            Zone::Prod => "-prod",
        }
    }

    /// An app's per-zone **container name** for a `base` name: `<base><suffix>`.
    /// Demo returns `base` unchanged (pre-zone parity), test/prod append the suffix
    /// (e.g. `korp-falkordb` → `korp-falkordb-test`).
    pub fn container_name(self, base: &str) -> String {
        format!("{base}{}", self.name_suffix())
    }

    /// The **data namespace** for an `app` in this zone: `<app>_<zone-key>` — the
    /// graph / warehouse / table prefix the app's population lands in
    /// (`namespace("korp")` → `korp_demo` / `korp_test` / `korp_prod`). Distinct per
    /// zone so populating one never overwrites another; this is the ONE data-scoping
    /// helper every product shares (korp's old `graph_name()` == `namespace("korp")`).
    pub fn namespace(self, app: &str) -> String {
        format!("{app}_{}", self.key())
    }
}

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

    /// **RED-when-broken: the default zone is Demo** (every pre-zone caller keeps
    /// landing where it did). If the default drifted, existing populate call-sites
    /// would silently retarget to another environment.
    #[test]
    fn default_zone_is_demo() {
        assert_eq!(Zone::default(), Zone::Demo);
        assert_eq!(Zone::default().key(), "demo");

        functional_status(
            "nornir-zone",
            "default_is_demo",
            Zone::default() == Zone::Demo,
            "the default zone is Demo — pre-zone callers land unchanged",
        );
    }

    /// **RED-when-broken: zones DON'T COLLIDE** — every zone has a distinct
    /// container suffix, port offset, and data namespace. If two zones shared any of
    /// these, populating one would clobber the other (the whole reason zones scope).
    /// Also pins Demo to pre-zone-identical scoping (no suffix, offset 0).
    #[test]
    fn zones_do_not_collide() {
        use std::collections::BTreeSet;
        let names: BTreeSet<&str> = Zone::ALL.iter().map(|z| z.name_suffix()).collect();
        let ports: BTreeSet<u16> = Zone::ALL.iter().map(|z| z.port_offset()).collect();
        let spaces: BTreeSet<String> = Zone::ALL.iter().map(|z| z.namespace("korp")).collect();
        assert_eq!(names.len(), Zone::ALL.len(), "distinct container suffixes");
        assert_eq!(ports.len(), Zone::ALL.len(), "distinct published-port offsets");
        assert_eq!(spaces.len(), Zone::ALL.len(), "distinct data namespaces");

        // Demo stays byte-identical to the pre-zone default.
        assert_eq!(Zone::Demo.name_suffix(), "");
        assert_eq!(Zone::Demo.port_offset(), 0);
        assert_eq!(Zone::Demo.namespace("korp"), "korp_demo");
        // ...and the derived per-app helpers compose the base with the zone scope.
        assert_eq!(Zone::Demo.port_on(6379), 6379, "demo keeps the base port");
        assert_eq!(Zone::Test.port_on(6379), 6380, "test steps the port up by one");
        assert_eq!(Zone::Prod.port_on(6379), 6381, "prod steps the port up by two");
        assert_eq!(Zone::Demo.container_name("korp-falkordb"), "korp-falkordb");
        assert_eq!(Zone::Test.container_name("korp-falkordb"), "korp-falkordb-test");

        functional_status(
            "nornir-zone",
            "zones_do_not_collide",
            names.len() == Zone::ALL.len()
                && ports.len() == Zone::ALL.len()
                && spaces.len() == Zone::ALL.len(),
            "every zone has a distinct container suffix / port offset / data namespace — no clobber",
        );
    }

    /// Keys round-trip through `from_key`, and a few friendly aliases resolve.
    #[test]
    fn keys_round_trip_and_aliases_resolve() {
        for z in Zone::ALL {
            assert_eq!(Zone::from_key(z.key()), Some(z));
        }
        assert_eq!(Zone::from_key("staging"), Some(Zone::Test));
        assert_eq!(Zone::from_key("production"), Some(Zone::Prod));
        assert_eq!(Zone::from_key("  DEV  "), Some(Zone::Demo), "trims + case-folds");
        assert_eq!(Zone::from_key("bogus"), None);
    }
}