use std::path::{Path, PathBuf};
use gunnar_grpc::{ControlError, ControlResult};
use crate::mirror::{ReplicationScope, ReplicationTarget, Replicator, VisibilityScope};
pub const REPLICATION_KEY: &str = "replication_key";
pub fn ensure_identity(root: &Path) -> ControlResult<(PathBuf, String)> {
let path = root.join(REPLICATION_KEY);
let bytes = match std::fs::read(&path) {
Ok(bytes) => bytes,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
let minted = gunnar_core::mint_ed25519().map_err(|err| {
ControlError::Internal(format!("minting the replication deploy key: {err}"))
})?;
let tmp = root.join(format!("{REPLICATION_KEY}.tmp"));
std::fs::write(&tmp, &minted).map_err(|err| {
ControlError::Internal(format!("writing {}: {err}", tmp.display()))
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600)).map_err(
|err| ControlError::Internal(format!("chmod 600 {}: {err}", tmp.display())),
)?;
}
std::fs::rename(&tmp, &path).map_err(|err| {
ControlError::Internal(format!("renaming {} into place: {err}", tmp.display()))
})?;
tracing::info!(
target: crate::mirror::TARGET_REPLICATION,
event = "replication.identity_minted",
path = %path.display(),
"minted this server's replication deploy key; its public half must be authorised \
on the standby"
);
minted
}
Err(err) => {
return Err(ControlError::Internal(format!(
"reading {}: {err}",
path.display()
)))
}
};
let key = gunnar_core::HostKey::from_openssh(bytes).map_err(|err| {
ControlError::Internal(format!("{} is not an OpenSSH key: {err}", path.display()))
})?;
let public = key.public_openssh().map_err(|err| {
ControlError::Internal(format!("encoding the replication key's public half: {err}"))
})?;
Ok((path, public))
}
fn opt_path(s: String) -> Option<PathBuf> {
let s = s.trim();
(!s.is_empty()).then(|| PathBuf::from(s))
}
fn path_str(p: &Option<PathBuf>) -> String {
p.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_default()
}
pub fn target_from_proto(t: gunnar_proto::ReplicationTarget) -> ControlResult<ReplicationTarget> {
let name = t.name.trim().to_owned();
if name.is_empty() {
return Err(ControlError::InvalidArgument(
"name is required: every event, log line and status row identifies a standby by \
its name rather than by its URL"
.into(),
));
}
let remote = t.remote.trim().to_owned();
if remote.is_empty() {
return Err(ControlError::InvalidArgument("remote is required".into()));
}
if !(remote.starts_with("ssh://") || remote.starts_with("git+ssh://")) {
return Err(ControlError::InvalidArgument(format!(
"remote {remote:?} is not an ssh url. Replication is a send-pack over SSH; \
the server's client half has no HTTP transport to dial anything else with"
)));
}
let scope = match t.scope.and_then(|s| s.of) {
None | Some(gunnar_proto::replication_scope::Of::Server(_)) => ReplicationScope::Server,
Some(gunnar_proto::replication_scope::Of::Namespace(ns)) => {
let ns = ns.trim().to_owned();
if ns.is_empty() {
return Err(ControlError::InvalidArgument(
"a namespace-scoped target with an empty namespace would take nothing".into(),
));
}
ReplicationScope::Namespace { namespace: ns }
}
Some(gunnar_proto::replication_scope::Of::Repo(path)) => {
path.validate()
.map_err(|err| ControlError::InvalidArgument(err.to_string()))?;
ReplicationScope::Repo {
namespace: path.namespace,
repo: path.repo,
}
}
};
let control = Some(t.control.trim().to_owned()).filter(|c| !c.is_empty());
let visibility = if t.public_only {
if control.is_none() {
return Err(ControlError::InvalidArgument(
"a public-only target needs `control` (the replica's control-plane host:port): \
a repository that goes Private is deleted there over that address, and \
without it the replica would keep a copy it must not"
.into(),
));
}
VisibilityScope::PublicOnly
} else {
VisibilityScope::All
};
Ok(ReplicationTarget {
name,
scope,
remote,
identity: opt_path(t.identity_path),
known_hosts: opt_path(t.known_hosts_path),
strict_host_key: t.strict_host_key,
visibility,
control,
})
}
pub fn require_public_only_identity(t: &ReplicationTarget) -> ControlResult<()> {
if t.visibility == VisibilityScope::PublicOnly && t.identity.is_none() {
return Err(ControlError::InvalidArgument(
"a public-only target needs a deploy key and this box has none: the delete on a \
Private flip is signed with the same key the send-pack authenticates with. The \
server mints one itself unless `identity_path` names another, so this means the \
mint failed"
.into(),
));
}
Ok(())
}
pub fn target_to_proto(t: &ReplicationTarget) -> gunnar_proto::ReplicationTarget {
use gunnar_proto::replication_scope::Of;
let of = match &t.scope {
ReplicationScope::Server => Of::Server(true),
ReplicationScope::Namespace { namespace } => Of::Namespace(namespace.clone()),
ReplicationScope::Repo { namespace, repo } => Of::Repo(gunnar_proto::RepoPath {
namespace: namespace.clone(),
repo: repo.clone(),
}),
};
gunnar_proto::ReplicationTarget {
name: t.name.clone(),
scope: Some(gunnar_proto::ReplicationScope { of: Some(of) }),
remote: t.remote.clone(),
identity_path: path_str(&t.identity),
known_hosts_path: path_str(&t.known_hosts),
strict_host_key: t.strict_host_key,
public_only: t.visibility == VisibilityScope::PublicOnly,
control: t.control.clone().unwrap_or_default(),
}
}
pub fn running_to_proto(
replicator: &Replicator,
include_refs: bool,
out: &mut gunnar_proto::ReplicationStatusResponse,
) {
let report = crate::replication_view::report(replicator);
out.configured = report.configured;
out.observed_since_unix_ms = replicator.observed_since_unix_ms();
let coverage =
include_refs.then(|| replicator.ref_coverage_by_repo(std::time::Duration::ZERO));
out.repos = match replicator.repo_copies(coverage.as_deref()) {
Ok(copies) => copies
.into_iter()
.map(|c| gunnar_proto::RepoReplication {
repo: Some(gunnar_proto::RepoPath::new(&c.namespace, &c.repo)),
refs_total: c.refs.map(|r| r.total),
refs_unmirrored: c.refs.map(|r| r.unmirrored),
standby: c.standby,
last_success_unix_ms: c.last_success_unix_ms,
refs_at_last_success: c.refs_at_last_success,
outstanding: c.outstanding,
last_error: c.last_error,
stopped: c.stopped,
sha1: c.sha1,
})
.collect(),
Err(err) => {
tracing::warn!(
target: crate::mirror::TARGET_REPLICATION,
%err,
"could not list repositories for the per-repository replication report; the \
aggregates below are still answered"
);
Vec::new()
}
};
if let Some(coverage) = coverage.as_deref() {
let mut total = crate::replication_view::RefCoverage::default();
for c in coverage.values() {
total.add(*c);
}
out.refs_total = total.total;
out.refs_unmirrored = total.unmirrored;
}
out.enqueued = report.enqueued;
out.synced = report.synced;
out.failed = report.failed;
out.bus_gaps = report.bus_gaps;
out.standbys = report
.standbys
.into_iter()
.map(|s| gunnar_proto::ReplicationStandby {
verdict: s.verdict().word().to_owned(),
name: s.name,
remote: s.remote,
pending: s.pending,
consecutive_failures: s.consecutive_failures,
last_error: s.last_error,
last_success_unix_ms: s.last_success_unix_ms,
needs_full_resync: s.needs_full_resync,
stale: s.stale,
in_flight: s.in_flight,
outstanding_for_ms: s.outstanding_for_ms,
behind: s.behind,
})
.collect();
}
#[cfg(test)]
mod tests {
use super::*;
fn public_only_wire(identity: &str) -> gunnar_proto::ReplicationTarget {
gunnar_proto::ReplicationTarget {
name: "public".into(),
scope: Some(gunnar_proto::ReplicationScope {
of: Some(gunnar_proto::replication_scope::Of::Server(true)),
}),
remote: "ssh://git@replica:2222".into(),
identity_path: identity.into(),
known_hosts_path: String::new(),
strict_host_key: false,
public_only: true,
control: "replica:50051".into(),
}
}
#[test]
fn a_public_only_target_parses_without_an_identity_because_the_mint_follows() {
let parsed = target_from_proto(public_only_wire("")).expect(
"a public-only target with no identity must parse: the server mints one on the \
next line, and refusing here makes that mint unreachable",
);
assert_eq!(parsed.visibility, VisibilityScope::PublicOnly);
assert!(parsed.identity.is_none(), "nothing invented an identity");
}
#[test]
fn the_identity_requirement_still_bites_after_the_mint() {
let mut minted = target_from_proto(public_only_wire("")).expect("parses");
let err = require_public_only_identity(&minted)
.expect_err("a public-only target with no key must be refused somewhere");
let text = format!("{err:?}");
assert!(
text.contains("mint failed"),
"the refusal has to say what actually went wrong now — the operator did not \
forget a flag, the box could not make a key:\n{text}"
);
minted.identity = Some(std::path::PathBuf::from("/data/replication_key"));
require_public_only_identity(&minted).expect("a minted key satisfies it");
}
#[test]
fn a_public_only_target_without_a_control_address_is_still_refused_at_parse() {
let mut wire = public_only_wire("/data/replication_key");
wire.control = String::new();
let err = target_from_proto(wire).expect_err("no control address");
assert!(
format!("{err:?}").contains("needs `control`"),
"a replica that cannot be told to forget a repository that went Private would \
keep private data"
);
}
}