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

nordisk/gunnar

crates/gunnar-server/tests/product_report.rs

//! **`Report.product`** (5.4, ONE monetize, G1 pull): monetize never dials an estate, so
//! what it used to ask a main for — its tenants and who each is, their usage, what the box
//! can still serve, its paid door, the room a raise would find, what each tenant is
//! promised, and every name the box holds — leaves the main in its own `Fleet.Report`,
//! as JSON text of `monetize_product::pull::ProductReport`. Read in-process from the
//! box's own control plane and entitlement slot. A box that does not serve sends none.
#![cfg(all(feature = "replication", feature = "metered"))]

use std::collections::BTreeMap;
use std::sync::Arc;

use ed25519_dalek::{Signer, SigningKey};
use gunnar_common::monetize_key::MonetizePubkey;
use gunnar_core::{Principal, PrincipalKind};
use gunnar_server::control_plane::CoreControlPlane;
use gunnar_server::entitlement::{EntitlementSlot, MeteredConfig};
use gunnar_server::repair::Entitlements;
use gunnar_server::stores::ServerStores;
use gunnar_ssh::{DiskFloor, DiskHeadroomGate};
use monetize_embed::{EntitlementFact, State, TenantId};
use serde_json::Value;

const T: u64 = 1_789_000_000_000;
const GIB: u64 = 1 << 30;

struct Rig {
    _dir: tempfile::TempDir,
    stores: Arc<ServerStores>,
    slot: Arc<EntitlementSlot>,
}

impl Rig {
    /// A main that serves: alice (a person), team (no principal), ci (a machine's).
    fn serving() -> Rig {
        let r = Rig::fresh();
        let auth = r.stores.auth();
        auth.add_principal(Principal {
            id: "alice".into(),
            name: "alice".into(),
            kind: PrincipalKind::User,
            created: 0,
        })
        .expect("alice");
        auth.add_principal(Principal {
            id: "ci".into(),
            name: "ci".into(),
            kind: PrincipalKind::Machine,
            created: 0,
        })
        .expect("ci");
        for ns in ["alice", "team", "ci"] {
            r.stores.catalog().create_namespace(ns).expect("namespace");
        }
        let key = SigningKey::from_bytes(&[7u8; 32]);
        let mut f = EntitlementFact {
            tenant: TenantId("alice".into()),
            plan: "10gb".into(),
            state: State::Paid,
            paid_until_unix_ms: None,
            caps: BTreeMap::from([("pack_bytes".to_owned(), 5 * GIB)]),
            source: "payment:alice:1".into(),
            signature: vec![],
            issued_unix_ms: Some(T),
            issued_signature: vec![],
        };
        f.signature = key
            .sign(&monetize_embed::signing::fact_message(&f))
            .to_bytes()
            .to_vec();
        f.issued_signature = key
            .sign(&monetize_embed::signing::fact_message_issued(&f))
            .to_bytes()
            .to_vec();
        r.slot.push(&f).expect("alice's fact");
        r
    }

    fn fresh() -> Rig {
        let dir = tempfile::tempdir().expect("tempdir");
        let stores = Arc::new(ServerStores::open(dir.path()).expect("stores"));
        let key = SigningKey::from_bytes(&[7u8; 32]);
        let pubkey = dir.path().join("monetize.pub");
        std::fs::write(&pubkey, key.verifying_key().to_bytes()).expect("key");
        let config = MeteredConfig::load(
            &MonetizePubkey::Path(pubkey),
            Some("https://gunnar.rs/billing/{tenant}"),
        )
        .expect("config");
        let slot = EntitlementSlot::open(Arc::clone(&stores), &config).expect("slot");
        Rig {
            _dir: dir,
            stores,
            slot,
        }
    }

    fn box_of(&self, plane: CoreControlPlane) -> Entitlements {
        Entitlements::of(Arc::clone(&self.stores))
            .with_slot(Some(Arc::clone(&self.slot)))
            .with_control(Arc::new(plane))
    }

    fn report(&self, plane: CoreControlPlane) -> Value {
        let text = self
            .box_of(plane)
            .product(T)
            .expect("a main that serves reports its product");
        serde_json::from_str(&text).expect("JSON")
    }
}

#[test]
fn a_serving_main_reports_its_tenants_names_usage_promises_and_paid_door() {
    let r = Rig::serving();
    let p = r.report(CoreControlPlane::new(Arc::clone(&r.stores)));

    let tenants = p["tenants"].as_array().expect("tenants");
    let alice = tenants
        .iter()
        .find(|t| t["tenant"] == "alice")
        .expect("alice is a tenant");
    assert_eq!(
        (alice["person"].as_bool(), alice["seller"].as_bool()),
        (Some(true), Some(false)),
        "{alice}"
    );
    let team = tenants
        .iter()
        .find(|t| t["tenant"] == "team")
        .expect("team is a tenant");
    assert_eq!(team["person"], false, "{team}");
    assert!(
        !tenants.iter().any(|t| t["tenant"] == "ci"),
        "a machine's namespace is no tenant: {tenants:?}"
    );

    let names: Vec<&str> = p["names"]
        .as_array()
        .expect("names")
        .iter()
        .filter_map(Value::as_str)
        .collect();
    for n in ["alice", "ci", "team"] {
        assert!(names.contains(&n), "{n} is a name the box holds: {names:?}");
    }

    assert!(
        p["usage"]["alice"]["meters"]["pack_bytes"].is_u64(),
        "alice's usage: {}",
        p["usage"]
    );
    assert_eq!(p["promised"]["alice"]["pack_bytes"], 5 * GIB);
    assert!(
        p["metered"]["Enforcing"].is_string(),
        "a metered build with a key enforces: {}",
        p["metered"]
    );
    assert_eq!(p["measured_unix_ms"], T);

    // No disk-headroom gate: the box has not measured what it can serve, and that is
    // not a number. Nothing is sold on it: every byte meter has no room, and no iron.
    assert!(p["servable"]["Unmeasured"].is_string(), "{}", p["servable"]);
    for m in ["pack_bytes", "lfs_bytes", "cache_bytes"] {
        assert_eq!(p["room"][m], 0, "{m}: {}", p["room"]);
    }
    assert!(p["with_iron"].is_null(), "{}", p["with_iron"]);
}

#[test]
fn a_measured_main_reports_what_it_can_serve_as_the_room_a_raise_finds() {
    let r = Rig::serving();
    let gate = Arc::new(DiskHeadroomGate::new(
        r.stores.root(),
        DiskFloor {
            percent: 0,
            bytes: 1,
        },
    ));
    let p = r.report(CoreControlPlane::new(Arc::clone(&r.stores)).with_headroom(gate));
    let measured = &p["servable"]["Measured"];
    let servable = measured["servable_bytes"].as_u64().expect("servable_bytes");
    assert!(
        servable > 0 && measured["total_bytes"].as_u64() > Some(servable),
        "{measured}"
    );
    let growth = measured["growth"].as_object().expect("growth");
    assert!(
        growth.len() == 1
            && [
                "Sealed",
                "OperatorOnly",
                "Unknown",
                "AtRestart",
                "AtRuntime"
            ]
            .iter()
            .any(|g| growth.contains_key(*g)),
        "{growth:?}"
    );
    for m in ["pack_bytes", "lfs_bytes", "cache_bytes"] {
        assert_eq!(p["room"][m], servable, "{m}: {}", p["room"]);
    }
}

/// ★ UW-10: **each tenant's activity rides the report** (the last 30 days, the plane's own
/// `Usage.Activity`, meter for meter as the plugin names them, with how far back the box
/// remembers), so monetize's tenant page is read without dialling the box. A machine's
/// namespace is no tenant and carries none.
#[test]
fn a_serving_main_reports_each_tenants_activity() {
    let r = Rig::serving();
    r.stores.flow().record(
        "alice",
        gunnar_core::now_ms(),
        &gunnar_core::Transfer {
            bytes_in: 100,
            pushed: true,
            ..Default::default()
        },
    );
    let p = r.report(CoreControlPlane::new(Arc::clone(&r.stores)));
    let alice = &p["activity"]["alice"];
    let days = alice["days"].as_array().expect("alice's days");
    assert_eq!(
        days.iter()
            .map(|d| d["meters"]["pushes"].as_u64().unwrap_or(0))
            .sum::<u64>(),
        1,
        "{alice}"
    );
    assert!(alice["retained_days"].as_u64() > Some(0), "{alice}");
    assert!(
        p["activity"]["team"]["days"]
            .as_array()
            .is_some_and(|d| d.is_empty()),
        "no traffic is an empty series: {}",
        p["activity"]
    );
    assert!(p["activity"].get("ci").is_none(), "{}", p["activity"]);
}

#[test]
fn a_box_that_does_not_serve_sends_no_product_part() {
    let fresh = Rig::fresh();
    assert_eq!(
        fresh
            .box_of(CoreControlPlane::new(Arc::clone(&fresh.stores)))
            .product(T),
        None,
        "a re-laid main nobody restored"
    );
    let r = Rig::serving();
    assert_eq!(
        Entitlements::of(Arc::clone(&r.stores)).product(T),
        None,
        "no control plane, nothing to read"
    );
}