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()
),
);
}
}