crates/gunnar-repair/src/adopt.rs
//! **`adopt_twin`**: certify a twin for its primary — moved here from the pairing
//! door (`gunnar-pairing-door` `certify::once`, 2026-09-24), which now calls this.
//! The twin's key, enrolled on the primary as [`TWIN_PRINCIPAL`] in group `twin`,
//! then its TLS certificate: `Bootstrap.TwinCsr` on the twin,
//! `Admin.IssueTwinCertificate` on the primary, `Bootstrap.InstallTwinCertificate`
//! on the twin. The private key crosses no call; every step is idempotent, so a
//! courier that dies half-way is simply run again.
use serde::{Deserialize, Serialize};
/// The principal the twin is enrolled as on its primary — the CN its replication
/// request carries.
pub const TWIN_PRINCIPAL: &str = "gunnar-twin";
/// What `Admin.IssueTwinCertificate` answers.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Issued {
pub cert_pem: String,
pub issuer_pem: String,
pub port: u32,
/// The address the primary's replication listener is bound on (its tunnel
/// address), empty when it is the joined one.
pub host: String,
}
/// **The calls, one per step** — tonic on the estate, a fake in the tests.
pub trait Boxes {
/// `Bootstrap.BecomeTwin` on the twin: its public key line.
fn twin_key(&self, twin_ctl: &str, primary_for_twin: &str) -> Result<String, String>;
/// On the primary, signed with an admin key: `principal` as a machine with `key`
/// — and ONLY that ssh key: one twin per principal, so a dead twin's key is
/// revoked — group `twin`, `principal` a member. "Already there" is fine.
fn enrol(&self, primary_ctl: &str, principal: &str, key: &str) -> Result<(), String>;
/// `Bootstrap.TwinCsr` on the twin.
fn twin_csr(&self, twin_ctl: &str) -> Result<String, String>;
/// `Admin.IssueTwinCertificate` on the primary, signed with an admin key.
fn issue(&self, primary_ctl: &str, principal: &str, csr_pem: &str) -> Result<Issued, String>;
/// `Bootstrap.InstallTwinCertificate` on the twin: days of certificate left.
fn install(&self, twin_ctl: &str, issued: &Issued) -> Result<i64, String>;
}
/// Which twin, for which primary.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Adopt {
/// The twin's control plane (`<addr>:50051`).
pub twin_ctl: String,
/// The primary's control plane.
pub primary_ctl: String,
/// The primary as the TWIN was told it — `BecomeTwin` is idempotent only for
/// the same string, and a twin is never re-pointed.
pub primary_for_twin: String,
/// Empty = [`TWIN_PRINCIPAL`].
#[serde(default)]
pub principal: String,
}
/// A successful adoption.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Paired {
/// Where the twin now follows over mutual TLS: `host:port`.
pub replication: String,
pub days_left: i64,
}
/// **One attempt, every step.** `Err` names the step it stopped at.
pub fn adopt_twin(boxes: &dyn Boxes, t: &Adopt) -> Result<Paired, String> {
let principal = if t.principal.trim().is_empty() {
TWIN_PRINCIPAL
} else {
t.principal.trim()
};
let key = boxes
.twin_key(&t.twin_ctl, &t.primary_for_twin)
.map_err(|e| format!("twin-key: the twin at {} gave no key: {e}", t.twin_ctl))?;
let key = key.trim();
if !key.starts_with("ssh-") {
return Err(format!(
"twin-key: the twin at {} answered no public key line: `{key}`",
t.twin_ctl
));
}
boxes.enrol(&t.primary_ctl, principal, key).map_err(|e| {
format!(
"enrol: the primary at {} would not enrol {principal}: {e}",
t.primary_ctl
)
})?;
let csr = boxes.twin_csr(&t.twin_ctl).map_err(|e| {
format!(
"twin-csr: the twin at {} would not give a signing request: {e}",
t.twin_ctl
)
})?;
let issued = boxes.issue(&t.primary_ctl, principal, &csr).map_err(|e| {
format!(
"issue: the primary at {} would not certify {principal}: {e}",
t.primary_ctl
)
})?;
let days_left = boxes.install(&t.twin_ctl, &issued).map_err(|e| {
format!(
"install: the twin at {} refused its certificate: {e}",
t.twin_ctl
)
})?;
let host = if issued.host.is_empty() {
t.primary_ctl
.rsplit_once(':')
.map(|(h, _)| h.to_string())
.unwrap_or_default()
} else {
issued.host.clone()
};
Ok(Paired {
replication: format!("{host}:{}", issued.port),
days_left,
})
}