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/recharge.rs

// The pairing loop and its door are `wg-client` code; the gix engine arm builds without it.
#![cfg(feature = "wg-client")]
//! **The dead main, recharged AT A NEW ADDRESS** (2026-09-29, topic `recharge`).
//!
//! A dead main is laid again by the provider — at whatever address it hands
//! the new machine — and re-admitted into its closed estate by the installer
//! (Skidbladnir nordisk-pairing `Door::readmit`, fed `recharge_from`). Two
//! things of gunnar's are proven here on the real wire:
//!
//! * the fresh box SAYS it awaits its twin on the credential-free
//!   `Bootstrap.Status` (`recharge_from`, `restoring`, `log_head_seq`) — what
//!   monetize's restore lane reads to restore it on the tick — and stops saying
//!   so once it holds its identities;
//! * the twin, RE-POINTED at the new address (`twin::repoint`, what the
//!   installer's re-feed does), follows the box restored from it past the
//!   head the dead primary had, under its supervised follower, with the same
//!   pins. `twin_follows_restored.rs` proves the same box at the SAME address.

use std::net::SocketAddr;
use std::sync::Arc;
use std::time::{Duration, Instant};

use gunnar_core::{NewRepo, RepoRef};
use gunnar_grpc::{EventBus, ServerConfig, ServiceContext};
use gunnar_server::stores::ServerStores;
use gunnar_server::twin;
use gunnar_server::CoreControlPlane;

fn free_addr() -> SocketAddr {
    let l = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
    l.local_addr().expect("addr")
}

/// A control plane for `stores`, served at `addr` until the sender fires.
struct Served {
    _rt: tokio::runtime::Runtime,
    stop: Option<tokio::sync::oneshot::Sender<()>>,
    addr: SocketAddr,
}

fn serve(stores: &Arc<ServerStores>, addr: SocketAddr) -> Served {
    let plane: Arc<dyn gunnar_grpc::ControlPlane> =
        Arc::new(CoreControlPlane::new(Arc::clone(stores)));
    let ctx = ServiceContext::new(
        plane,
        Arc::new(EventBus::new(16)),
        ServerConfig::default().insecure_allow_all_for_local_development(),
    );
    let rt = tokio::runtime::Builder::new_multi_thread()
        .worker_threads(2)
        .enable_all()
        .build()
        .expect("rt");
    let (tx, rx) = tokio::sync::oneshot::channel::<()>();
    rt.spawn(async move {
        let _ = gunnar_grpc::router(ctx, || 0)
            .serve_with_shutdown(addr, async {
                let _ = rx.await;
            })
            .await;
    });
    wait("the box listens", Duration::from_secs(10), || {
        std::net::TcpStream::connect(addr).ok()
    });
    Served {
        _rt: rt,
        stop: Some(tx),
        addr,
    }
}

impl Served {
    fn kill(mut self) {
        if let Some(tx) = self.stop.take() {
            let _ = tx.send(());
        }
        let addr = self.addr;
        wait("the box stops listening", Duration::from_secs(10), || {
            std::net::TcpStream::connect(addr).is_err().then_some(())
        });
    }
}

fn wait<T>(what: &str, within: Duration, mut probe: impl FnMut() -> Option<T>) -> T {
    let t0 = Instant::now();
    loop {
        if let Some(v) = probe() {
            return v;
        }
        assert!(t0.elapsed() < within, "{what}: not within {within:?}");
        std::thread::sleep(Duration::from_millis(25));
    }
}

fn write_repo(stores: &ServerStores, ns: &str, name: &str) {
    if stores
        .catalog()
        .namespaces()
        .unwrap()
        .iter()
        .all(|n| n.name != ns)
    {
        stores.catalog().create_namespace(ns).expect("namespace");
    }
    stores
        .create_repo(NewRepo::new(RepoRef::new(ns, name).expect("legal")), false)
        .expect("repo");
}

fn status(addr: SocketAddr) -> gunnar_proto::BootstrapStatusResponse {
    let rt = tokio::runtime::Builder::new_current_thread()
        .enable_all()
        .build()
        .unwrap();
    rt.block_on(async {
        let mut c =
            gunnar_proto::bootstrap_client::BootstrapClient::connect(format!("http://{addr}"))
                .await
                .expect("connect");
        c.status(gunnar_proto::BootstrapStatusRequest {})
            .await
            .expect("Bootstrap.Status")
            .into_inner()
    })
}

#[test]
fn a_recharged_main_names_the_twin_it_awaits_on_bootstrap_status_until_it_holds_its_identities() {
    let d = tempfile::tempdir().unwrap();
    let stores = Arc::new(ServerStores::open(d.path()).expect("fresh"));
    assert_eq!(
        gunnar_server::pairing::recharge_hold_of(&stores, "10.13.8.3:50051").unwrap(),
        gunnar_server::pairing::Hold::Awaiting
    );
    let served = serve(&stores, free_addr());
    let s = status(served.addr);
    assert!(s.window_open, "{s:?}");
    assert_eq!(s.recharge_from, "10.13.8.3:50051", "{s:?}");
    assert!(!s.restoring);
    assert_eq!(s.log_head_seq, stores.txlog().head_seq());

    let pem = gunnar_core::mint_ed25519().unwrap();
    let op = gunnar_core::ssh_key::PrivateKey::from_openssh(&pem)
        .unwrap()
        .public_key()
        .to_openssh()
        .unwrap();
    stores.auth().bootstrap_admin(&op).unwrap();
    let s = status(served.addr);
    assert_eq!(
        s.recharge_from, "",
        "a box holding identities awaits nothing: {s:?}"
    );
    served.kill();
}

#[test]
fn the_twin_repointed_at_the_main_recharged_from_it_follows_it_at_its_new_address() {
    let (old_addr, new_addr) = (free_addr(), free_addr());

    let pd = tempfile::tempdir().unwrap();
    let primary = Arc::new(ServerStores::open(pd.path()).expect("primary"));
    let real_id = twin::mint_log_id(pd.path()).expect("the primary's log name");
    write_repo(&primary, "alice", "one");
    let served = serve(&primary, old_addr);

    let td = tempfile::tempdir().unwrap();
    let twin_stores = Arc::new(ServerStores::open(td.path()).expect("twin stores"));
    twin::become_twin(&twin_stores, &old_addr.to_string()).expect("a fresh box becomes a twin");
    twin::start_follower_every(Arc::clone(&twin_stores), Duration::from_millis(200));

    let dead_head = primary.txlog().head_seq();
    wait(
        "the twin levels with the primary",
        Duration::from_secs(30),
        || (twin_stores.txlog().head_seq() >= dead_head).then_some(()),
    );
    let pinned_key = wait(
        "the twin pins its primary's key",
        Duration::from_secs(5),
        || twin::pinned_primary_key(td.path()),
    );

    served.kill();
    drop(primary);
    // What the dead primary gave its twin is what a restore brings back: the twin re-polls at
    // once after it applies (T1), so it may hold the primary's last write past the head read
    // above, and a restore is measured against the TWIN's head, not a head read earlier.
    let dead_head = twin_stores.txlog().head_seq();

    // ── recharged: a fresh box at ANOTHER address, filled from the twin ──
    let fd = tempfile::tempdir().unwrap();
    let fresh = Arc::new(ServerStores::open(fd.path()).expect("fresh"));
    let twins_name = twin::log_id(td.path()).expect("the twin knows its log's name");
    assert_eq!(
        twin::restore(
            Arc::clone(twin_stores.txlog()),
            Arc::clone(&fresh),
            &twins_name
        )
        .expect("restore from the twin"),
        dead_head
    );
    drop(fresh);
    let restored = Arc::new(ServerStores::open(fd.path()).expect("the restored box, restarted"));
    let served = serve(&restored, new_addr);
    write_repo(&restored, "alice", "after-the-recharge");
    let new_head = restored.txlog().head_seq();

    // ── the installer's re-feed: the twin re-points ──
    assert!(
        twin::repoint(&twin_stores, &new_addr.to_string()).expect("re-point"),
        "it moved"
    );
    assert!(
        !twin::repoint(&twin_stores, &new_addr.to_string()).expect("again"),
        "a repeat changes nothing"
    );
    assert_eq!(
        twin::TwinConfig::load(td.path()).unwrap().unwrap().primary,
        new_addr.to_string()
    );
    // Applied, not only appended: the twin's log takes an entry before its stores do.
    wait(
        "the twin applies what only the recharged main has",
        Duration::from_secs(30),
        || {
            (twin_stores.txlog().head_seq() >= new_head
                && twin_stores
                    .catalog()
                    .get(&RepoRef::new("alice", "after-the-recharge").unwrap())
                    .ok()
                    .flatten()
                    .is_some())
            .then_some(())
        },
    );
    assert_eq!(
        twin::pinned_primary_key(td.path()).as_deref(),
        Some(pinned_key.as_str()),
        "the same box to the twin"
    );
    assert_eq!(
        twin::log_id(td.path()).as_deref(),
        Some(real_id.as_str()),
        "the same log"
    );
    assert!(twin_stores
        .catalog()
        .get(&RepoRef::new("alice", "after-the-recharge").unwrap())
        .unwrap()
        .is_some());
    println!("ROW recharge-follow: dead {dead_head} at {old_addr} → recharged {new_head} at {new_addr}, the twin re-pointed and applied it");
    twin::stop_follower();
    served.kill();
}