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

nordisk/dwarves

dwarves-gui/src/deploy_key.rs

//! **The 🔑 Deploy key tab — the SSH identity the forge fetches with, made
//! self-service.**
//!
//! ## Why this tab exists
//!
//! The forge polls its git remotes over SSH every 60 seconds. On 2026-07-22 **eight
//! repositories failed every single poll for most of the day** with `SSH remote …
//! needs a deploy key, but none was found`, and the UI said nothing at all. The owner
//! had to read `journalctl` to find out. A CI/CD forge whose only failure channel is
//! the system journal is a forge you cannot hand to a colleague.
//!
//! So: show the public key, give it a copy button, say exactly where to paste it, and
//! show the REAL per-remote fetch state — with the actual error, from a real
//! handshake, not a static banner.
//!
//! ## The division of labour
//!
//! * [`facett_deploykey::DeployKeyDeck`] owns the pixels and the five states. It
//!   touches no filesystem and no network.
//! * **This module is the HOST.** It resolves the key, reads the public half,
//!   discovers the remotes, and runs the probes — all on **ONE NAMED WORKER THREAD**
//!   (ROOT LAW #0: gatling is the constellation's CPU fan-out engine and this is not
//!   CPU fan-out, it is blocking network I/O; one named worker is the sanctioned
//!   shape, never a rayon pool and never a `thread::spawn` fan-out). The UI thread
//!   drains a channel each frame and stays responsive through a multi-second sweep.
//! * [`nornir_git::deploykey`] owns the MODEL — the resolution ladder, the probe
//!   outcome, the five states — and `gitio::nornir_ssh_key_path`, the resolver the
//!   real fetch path uses, delegates to the very same ladder. That is deliberate:
//!   a pane that showed a key the fetch did not use would be worse than no pane.
//!
//! ## The service is dwarves
//!
//! nornir used to run as its own systemd unit. It does not any more — dwarves
//! compiles nornir IN and there is ONE service. So the key's default home is
//! `~/.dwarves/ssh/id_ed25519` and its identity is `dwarves@<host>`, with **no
//! environment variable in the happy path**: a service that owns its identity should
//! not have to be told where its own key is. The nornir-named paths still resolve as
//! read-only fallbacks — a hard cutover is how eight repos start failing again — and
//! the ladder pane names which rung answered.
//!
//! ## Private key material
//!
//! Never read, never rendered, never logged, never copied. The private key appears in
//! this module exactly once: as a `PathBuf` handed to the SSH transport. There is no
//! code path here that opens it.

use std::path::PathBuf;
use std::sync::mpsc::{Receiver, Sender};

use facett_core::Facet;
use facett_deploykey::{DeployKeyDeck, KeySnapshot, Msg};
use nornir_git::deploykey::{self, ProbeOutcome, RemoteProbe, Resolution};

/// What the worker sends back to the UI thread.
///
/// Only the NETWORK work crosses this channel. Resolving the key and minting one are
/// local filesystem operations measured in microseconds; putting them on a worker
/// would buy nothing and add a state machine. Probing is the part that blocks on a
/// remote host, so probing is the part that leaves the UI thread.
enum Report {
    /// One remote's probe landed. Streamed one at a time so a long sweep fills the
    /// pane progressively instead of appearing all at once at the end.
    Probe(RemoteProbe),
    /// The sweep finished.
    SweepDone,
}

/// The key-side half of a snapshot (resolution + the public strings).
struct KeyPart {
    resolution: Resolution,
    public_line: Option<String>,
    identity: Option<String>,
    minted_this_session: bool,
}

/// Resolve the key and read its PUBLIC half. Never opens the private key.
fn read_key(minted: bool) -> KeyPart {
    let resolution = Resolution::resolve();
    let public_line = resolution.public_line();
    let identity = resolution.identity();
    KeyPart {
        resolution,
        public_line,
        identity,
        minted_this_session: minted,
    }
}

/// Discover the remotes this box actually fetches: the default fetch URL of every
/// side-by-side checkout under the constellation root.
///
/// A checkout with no remote is SKIPPED rather than listed as failing — "no remote"
/// is not an auth problem. A checkout that cannot be opened is also skipped; the
/// pane's own "no remotes known" copy covers the empty case honestly, which is the
/// point: an empty list must read as *nothing was checked*, not as *all clear*.
fn discover_remotes(root: &std::path::Path) -> Vec<String> {
    let mut out: Vec<String> = Vec::new();
    let Ok(rd) = std::fs::read_dir(root) else {
        return out;
    };
    let mut dirs: Vec<PathBuf> = rd
        .flatten()
        .map(|e| e.path())
        .filter(|p| p.is_dir() && p.join(".git").exists())
        .collect();
    dirs.sort();
    for d in dirs {
        if let Ok(Some(url)) = nornir_git::gitio::default_remote_url(&d) {
            if nornir_git::gitio::is_ssh_url(&url) && !out.contains(&url) {
                out.push(url);
            }
        }
    }
    out
}

/// The ⚒-style host pane: the shared deck + the worker that feeds it.
pub struct DeployKeyPane {
    deck: DeployKeyDeck,
    /// The constellation root whose checkouts supply the remote list.
    root: PathBuf,
    rx: Receiver<Report>,
    tx: Sender<Report>,
    /// A worker is running (the deck renders a spinner and disables Probe).
    busy: bool,
}

impl Default for DeployKeyPane {
    fn default() -> Self {
        Self::new(dwarves::build_thing::ConstellationBuilds::default_root())
    }
}

impl DeployKeyPane {
    /// A pane over the checkouts under `root`.
    pub fn new(root: PathBuf) -> Self {
        let (tx, rx) = std::sync::mpsc::channel();
        let key = read_key(false);
        // Every discovered remote starts as NotProbed — a ROW, not an absence. The
        // fleet is visible before anything is measured, and it is visibly unmeasured.
        let probes: Vec<RemoteProbe> = discover_remotes(&root)
            .into_iter()
            .map(|remote| RemoteProbe {
                remote,
                outcome: ProbeOutcome::NotProbed,
                detail: String::new(),
                elapsed_ms: 0,
            })
            .collect();
        let snap = KeySnapshot {
            resolution: key.resolution,
            public_line: key.public_line,
            identity: key.identity,
            probes,
            minted_this_session: false,
            probing: false,
            notice: None,
        };
        DeployKeyPane {
            deck: DeployKeyDeck::new(snap),
            root,
            rx,
            tx,
            busy: false,
        }
    }

    /// Drain the worker channel. Called once per frame before painting.
    fn pump(&mut self) {
        while let Ok(r) = self.rx.try_recv() {
            match r {
                Report::Probe(p) => {
                    if let Some(slot) = self
                        .deck
                        .snap
                        .probes
                        .iter_mut()
                        .find(|x| x.remote == p.remote)
                    {
                        *slot = p;
                    } else {
                        self.deck.snap.probes.push(p);
                    }
                }
                Report::SweepDone => {
                    self.busy = false;
                    self.deck.snap.probing = false;
                }
            }
        }
    }

    /// Act on the deck's messages. The blocking work goes to ONE named worker.
    fn handle(&mut self, msgs: Vec<Msg>, ui: &egui::Ui) {
        for m in msgs {
            match m {
                Msg::Copy(text) => {
                    // The PUBLIC key. The deck holds nothing else to give.
                    ui.ctx().copy_text(text);
                    self.deck.snap.notice =
                        Some("public key copied — paste it as a repo DEPLOY KEY".to_string());
                }
                Msg::Mint => match deploykey::mint_default() {
                    Ok((path, created)) => {
                        let key = read_key(created);
                        self.deck.snap.notice = Some(if created {
                            format!(
                                "minted {} — now REGISTER the public key below; until \
                                     you do, this key authenticates nowhere",
                                path.display()
                            )
                        } else {
                            format!("{} already exists — left untouched", path.display())
                        });
                        self.apply_key(key);
                    }
                    Err(e) => self.deck.snap.notice = Some(format!("mint failed: {e:#}")),
                },
                Msg::Relabel => {
                    let target = self.deck.snap.resolution.chosen().map(|c| c.public.clone());
                    match target {
                        Some(p) => {
                            let host = std::fs::read_to_string("/etc/hostname")
                                .ok()
                                .map(|s| s.trim().to_string())
                                .filter(|s| !s.is_empty())
                                .unwrap_or_else(|| "localhost".to_string());
                            match deploykey::relabel_public_identity(&p, &format!("dwarves@{host}"))
                            {
                                Ok(_) => {
                                    self.deck.snap.notice = Some(format!(
                                        "identity renamed to dwarves@{host} in {} — key \
                                         material unchanged, nothing to re-register",
                                        p.display()
                                    ));
                                    let minted = self.deck.snap.minted_this_session;
                                    self.apply_key(read_key(minted));
                                }
                                Err(e) => {
                                    self.deck.snap.notice = Some(format!("rename failed: {e:#}"))
                                }
                            }
                        }
                        None => {
                            self.deck.snap.notice =
                                Some("no key to rename — mint one first".to_string())
                        }
                    }
                }
                Msg::ProbeAll => {
                    let all: Vec<String> = self
                        .deck
                        .snap
                        .probes
                        .iter()
                        .map(|p| p.remote.clone())
                        .collect();
                    self.spawn_sweep(all);
                }
                Msg::Probe(remote) => self.spawn_sweep(vec![remote]),
            }
        }
    }

    fn apply_key(&mut self, key: KeyPart) {
        self.deck.snap.resolution = key.resolution;
        self.deck.snap.public_line = key.public_line;
        self.deck.snap.identity = key.identity;
        self.deck.snap.minted_this_session = key.minted_this_session;
    }

    /// **ONE named worker thread**, SERIAL over the remotes.
    ///
    /// Serial is not laziness. Codeberg connection-rate-limits parallel SSH
    /// handshakes and answers with a `kex_exchange_identification` reset — which is
    /// not an auth failure at all, and a parallel sweep would paint the whole fleet
    /// red for a reason no deploy key can fix. Serial: 19/19 clean.
    fn spawn_sweep(&mut self, remotes: Vec<String>) {
        if self.busy || remotes.is_empty() {
            return;
        }
        self.busy = true;
        self.deck.snap.probing = true;
        let tx = self.tx.clone();
        let key = self
            .deck
            .snap
            .resolution
            .private_path()
            .map(|p| p.to_path_buf());
        let spawned = std::thread::Builder::new()
            .name("dwarves-deploykey-probe".into())
            .spawn(move || {
                for r in remotes {
                    let p = deploykey::probe_remote(&r, key.as_deref());
                    if tx.send(Report::Probe(p)).is_err() {
                        break; // the pane went away
                    }
                }
                let _ = tx.send(Report::SweepDone);
            });
        if let Err(e) = spawned {
            // A worker that failed to start must not leave the pane spinning forever
            // claiming to be measuring something.
            self.busy = false;
            self.deck.snap.probing = false;
            self.deck.snap.notice = Some(format!("could not start the probe worker: {e}"));
        }
    }

    /// Re-resolve the key from disk (a host may call this when the workspace changes).
    pub fn refresh(&mut self) {
        let minted = self.deck.snap.minted_this_session;
        self.apply_key(read_key(minted));
    }

    /// The constellation root the remote list came from.
    pub fn root(&self) -> &std::path::Path {
        &self.root
    }
}

/// **`Facet` only.** facett-core's blanket `impl<T: Facet> Panel for T` supplies
/// `Panel`; implementing both by hand is what produced `E0034` in this repo's build
/// pane earlier today.
impl Facet for DeployKeyPane {
    fn title(&self) -> &str {
        "Deploy key"
    }

    fn ui(&mut self, ui: &mut egui::Ui) {
        self.pump();
        self.deck.ui(ui);
        let msgs = self.deck.take_msgs();
        if !msgs.is_empty() {
            self.handle(msgs, ui);
        }
        if self.busy {
            ui.ctx()
                .request_repaint_after(std::time::Duration::from_millis(120));
        }
    }

    fn state_json(&self) -> serde_json::Value {
        let mut v = Facet::state_json(&self.deck);
        if let Some(o) = v.as_object_mut() {
            o.insert(
                "constellation_root".into(),
                self.root.display().to_string().into(),
            );
            o.insert("worker_busy".into(), self.busy.into());
        }
        v
    }

    fn update_json(&mut self, msg_json: &str) {
        Facet::update_json(&mut self.deck, msg_json);
        // A driver's message is acted on in the same turn, so a headless robot can
        // press Mint / Probe without a paint.
        let msgs = self.deck.take_msgs();
        for m in msgs {
            match m {
                Msg::Copy(_) => {}
                other => self.handle_headless(other),
            }
        }
    }

    fn caps(&self) -> facett_core::FacetCaps {
        Facet::caps(&self.deck)
    }

    fn copy(&mut self) -> Option<String> {
        Facet::copy(&mut self.deck)
    }
}

impl DeployKeyPane {
    /// The non-egui half of [`handle`](Self::handle) — everything a headless driver
    /// can reach. Kept separate only because `Msg::Copy` needs an `egui::Ui` for the
    /// clipboard; nothing else here touches egui.
    fn handle_headless(&mut self, m: Msg) {
        match m {
            Msg::ProbeAll => {
                let all: Vec<String> = self
                    .deck
                    .snap
                    .probes
                    .iter()
                    .map(|p| p.remote.clone())
                    .collect();
                self.spawn_sweep(all);
            }
            Msg::Probe(r) => self.spawn_sweep(vec![r]),
            Msg::Mint | Msg::Relabel => {
                // Minting/renaming from a headless driver would write to the user's
                // real `~/.dwarves/ssh`. Deliberately inert: these are actions a human
                // takes with the button in front of them.
                self.deck.snap.notice = Some(
                    "mint/rename is an interactive action — press the button in the pane"
                        .to_string(),
                );
            }
            Msg::Copy(_) => {}
        }
    }
}

/// The shipped pane over the default constellation root.
pub fn deploy_key_pane() -> DeployKeyPane {
    DeployKeyPane::default()
}

#[cfg(all(test, not(target_arch = "wasm32")))]
mod tests {
    use super::*;

    fn cell(name: &str, ok: bool, detail: &str) {
        #[cfg(feature = "testmatrix")]
        nornir_testmatrix::functional_status("dwarves-gui", name, ok, detail);
        let _ = (name, ok, detail);
    }

    /// The pane resolves through the SAME ladder the fetch path uses. This is the
    /// anti-twin assertion for Task 2: `gitio::nornir_ssh_key_path` — the function
    /// `clone_or_fetch` calls when a remote needs a key — and the pane's
    /// `private_path` must be the same path, or the UI is showing a key that is not
    /// the key.
    #[test]
    fn the_pane_shows_the_key_the_fetch_actually_uses() {
        let pane = DeployKeyPane::new(std::env::temp_dir().join("dk-no-such-root"));
        let shown = pane
            .deck
            .snap
            .resolution
            .private_path()
            .map(|p| p.to_path_buf());
        let used = nornir_git::gitio::nornir_ssh_key_path();
        assert_eq!(shown, used, "the pane must show the key the fetch resolves");
        cell(
            "deploy-key-pane-shows-the-fetch-key",
            shown == used,
            "the 🔑 pane's resolution and gitio::nornir_ssh_key_path are ONE ladder \
             (gitio delegates to nornir_git::deploykey::Resolution), so the key on \
             screen is the key `clone_or_fetch` authenticates with",
        );
    }

    /// An unknown root yields NO remotes — and the pane says "nothing was checked",
    /// never a green. This is the exact conflation the task called out: a missing
    /// measurement must not read as a pass.
    #[test]
    fn an_empty_constellation_is_unmeasured_not_healthy() {
        let pane = DeployKeyPane::new(std::env::temp_dir().join("dk-no-such-root"));
        let s = Facet::state_json(&pane);
        assert_eq!(s["remotes_known"], 0);
        assert_eq!(s["remotes_probed"], 0);
        assert_eq!(s["green"], false, "no data is not good news");
        assert_eq!(s["worker_busy"], false);
        cell(
            "deploy-key-empty-is-not-green",
            s["green"] == false,
            "zero remotes and zero probes render as UNMEASURED; `green` stays false",
        );
    }

    /// The real constellation on this box has SSH remotes, and every one of them
    /// starts as a NotProbed ROW — the fleet is visible before it is measured, and
    /// visibly unmeasured. (Skipped where the checkouts are not present.)
    #[test]
    fn every_discovered_remote_starts_as_an_unprobed_row() {
        let root = dwarves::build_thing::ConstellationBuilds::default_root();
        if !root.is_dir() {
            return;
        }
        let pane = DeployKeyPane::new(root.clone());
        let s = Facet::state_json(&pane);
        let known = s["remotes_known"].as_u64().unwrap_or(0);
        if known == 0 {
            return; // no SSH remotes here; the empty-case test covers the rendering
        }
        assert_eq!(s["remotes_probed"], 0, "nothing is probed until asked");
        assert_eq!(s["remotes_ok"], 0);
        assert_eq!(s["green"], false);
        assert!(pane
            .deck
            .snap
            .probes
            .iter()
            .all(|p| p.outcome == ProbeOutcome::NotProbed));
        cell(
            "deploy-key-remotes-discovered-unprobed",
            true,
            &format!(
                "{known} SSH remote(s) discovered under {} and every one renders as \
                 'not probed' — a row, not an absence",
                root.display()
            ),
        );
    }
}