use gunnar_proto::mirror_server::Mirror;
use gunnar_proto::{
LogChunk, LogFetchRequest, LogHeadRequest, LogHeadResponse,
AbdicateReplicationRequest, AddMirrorRequest, AddMirrorResponse, DemoteReplicationRequest,
FlushReplicationRequest, FlushReplicationResponse, MirrorCredentialKind, MirrorStatusRequest, MirrorStatusResponse, RemoveMirrorRequest,
RemoveMirrorResponse, RemoveReplicationRequest, ReplicationRole, ReplicationStatusRequest,
ReplicationStatusResponse, ResyncReplicationRequest, ResyncReplicationResponse,
SetMirrorCredentialRequest, SetMirrorCredentialResponse, SetReplicationRequest, SetRoleRequest,
RepoPath, SyncMirrorRequest, SyncMirrorResponse,
};
use tonic::{Request, Response, Status};
use crate::auth::Access;
use crate::service::MirrorService;
use crate::status::{opt_repo_path, opt_str, repo_path};
use gunnar_proto::GrantLevel;
const SNAPSHOT_WINDOW: usize = 4;
const LOG_FETCH_WINDOW: usize = 4;
impl MirrorService {
fn mirror_scope(&self, path: &gunnar_proto::RepoPath) -> Access {
if self.ctx.control.get_repo(path).is_err()
&& matches!(
self.ctx.control.mirror_status(None, Some(path)),
Ok(mirrors) if mirrors.iter().any(|m| m.repo_pending)
)
{
return Access::namespace(&path.namespace);
}
Access::repo(path)
}
fn require_log_reader(&self, caller: &crate::auth::Caller, level: GrantLevel, method: &'static str) -> Result<(), Status> {
if self.ctx.control.is_group_member(&caller.principal_id, crate::TWIN_GROUP).unwrap_or(false) {
tracing::info!(
target: crate::auth::AUDIT_TARGET,
event = "grpc.call",
method,
principal = %caller.principal_id,
fingerprint = %caller.fingerprint,
decision = "allow",
"a member of group twin reads the transaction log"
);
return Ok(());
}
self.ctx.require(caller, Access::Server, level, method)
}
fn refuse_undersized(&self, caller: &crate::auth::Caller, method: &'static str) -> Result<(), Status> {
match self.ctx.heartbeats.follow_refusal(&caller.principal_id) {
None => Ok(()),
Some(why) => {
tracing::info!(target: crate::auth::AUDIT_TARGET, event = "grpc.call", method, principal = %caller.principal_id, decision = "deny", reason = %why, "a twin owed a growth may not follow yet");
Err(Status::failed_precondition(why))
}
}
}
}
#[tonic::async_trait]
impl Mirror for MirrorService {
type LogFetchStream = std::pin::Pin<
Box<dyn tokio_stream::Stream<Item = Result<LogChunk, Status>> + Send + 'static>,
>;
async fn log_head(
&self,
request: Request<LogHeadRequest>,
) -> Result<Response<LogHeadResponse>, Status> {
let caller = self.ctx.caller(&request)?;
self.require_log_reader(&caller, GrantLevel::Read, "Mirror.LogHead")?;
let address = request.remote_addr().map(|a| a.to_string()).unwrap_or_default();
let request = request.into_inner();
let report = request.report;
if let Some(proof) = request.generation_proof.as_ref() {
self.ctx.control.fence_by_generation(proof)?;
}
let head = self.ctx.control.log_head_signed(&request.nonce)?;
if let Some(report) = report {
self.ctx.heartbeats.record(&caller.principal_id, &address, &report, head.head_seq, estate_clock::now_unix_ms());
}
Ok(Response::new(head))
}
async fn log_retain(
&self,
request: Request<gunnar_proto::LogRetainRequest>,
) -> Result<Response<LogHeadResponse>, Status> {
let caller = self.ctx.caller(&request)?;
self.ctx.require(&caller, Access::Server, GrantLevel::Admin, "Mirror.LogRetain")?;
let gunnar_proto::LogRetainRequest { from, allow_past_twin } = request.into_inner();
let wanted = if from == 0 { self.ctx.control.log_head()?.head_seq + 1 } else { from };
let silent: Vec<String> = self
.ctx
.control
.list_group_members(crate::TWIN_GROUP)
.map(|ps| ps.into_iter().map(|p| p.id.to_string()).filter(|id| !self.ctx.heartbeats.has_reported(id)).collect())
.unwrap_or_default();
if !silent.is_empty() {
let why = format!(
"retaining from {wanted} while enrolled twin(s) {} have reported no position since this \
server started: what they have applied is unknown, so nothing says these frames are \
not the ones they need",
silent.iter().map(|t| format!("'{t}'")).collect::<Vec<_>>().join(", ")
);
if !allow_past_twin {
crate::auth::audit_denied("Mirror.LogRetain", &caller, &Access::Server, GrantLevel::Admin, self.ctx.config.auth, &why, self.ctx.ledger());
return Err(Status::failed_precondition(format!(
"retention-twin-position-unknown: {why}. Wait for the twin's next poll (every few \
seconds while it follows), or pass allow_past_twin (`gunnar control twin retain \
--allow-past-twin`) if the twin is gone or the hole is the point"
)));
}
crate::auth::audit_override("Mirror.LogRetain", &caller, &Access::Server, GrantLevel::Admin, self.ctx.config.auth, &why, self.ctx.ledger());
}
match self.ctx.heartbeats.lowest_applied() {
Some((twin, applied)) if wanted > applied + 1 => {
let why = format!(
"retaining from {wanted} drops frames {}..={} that twin '{twin}' has not applied \
(it reported applied {applied}); it would have to take a snapshot to close the hole",
applied + 1,
wanted - 1
);
if !allow_past_twin {
crate::auth::audit_denied("Mirror.LogRetain", &caller, &Access::Server, GrantLevel::Admin, self.ctx.config.auth, &why, self.ctx.ledger());
return Err(Status::failed_precondition(format!(
"retention-past-twin: {why}. Retain from {} or lower, let the twin catch up, or \
pass allow_past_twin (`gunnar control twin retain --allow-past-twin`) if the \
twin is gone or the hole is the point",
applied + 1
)));
}
crate::auth::audit_override("Mirror.LogRetain", &caller, &Access::Server, GrantLevel::Admin, self.ctx.config.auth, &why, self.ctx.ledger());
}
Some(_) => {}
None if silent.is_empty() => tracing::warn!(
target: crate::auth::AUDIT_TARGET,
method = "Mirror.LogRetain",
from = wanted,
"no twin is enrolled and none has polled since this server started; retention is not guarded"
),
None => {}
}
let head = self.ctx.control.log_retain(from)?;
tracing::warn!(target: crate::auth::AUDIT_TARGET, event = "grpc.call", method = "Mirror.LogRetain", principal = %caller.principal_id, from, first = head.first_seq, decision = "allow", "the transaction log's retention moved");
Ok(Response::new(head))
}
async fn log_fetch(
&self,
request: Request<LogFetchRequest>,
) -> Result<Response<Self::LogFetchStream>, Status> {
let caller = self.ctx.caller(&request)?;
self.require_log_reader(&caller, GrantLevel::Admin, "Mirror.LogFetch")?;
self.refuse_undersized(&caller, "Mirror.LogFetch")?;
let request = request.into_inner();
if request.from == 0 {
return Err(Status::invalid_argument(
"from is required: sequence numbers start at 1",
));
}
let produce = self
.ctx
.control
.log_fetch_stream_at(request.from, request.from_offset, request.to, request.max_bytes)?;
let (tx, rx) = tokio::sync::mpsc::channel::<Result<LogChunk, Status>>(LOG_FETCH_WINDOW);
let rt = tokio::runtime::Handle::current();
let (from, to) = (request.from, request.to);
tokio::task::spawn_blocking(move || {
let mut sink = |chunk: LogChunk| send_log_piece(&rt, &tx, chunk, LOG_FETCH_STALL_TIMEOUT);
if let Err(err) = produce(&mut sink) {
tracing::warn!(target: crate::auth::AUDIT_TARGET, event = "grpc.call", method = "Mirror.LogFetch", from, to, reason = %err, "the log stream stopped");
let _ = tx.blocking_send(Err(Status::from(err)));
}
});
Ok(Response::new(Box::pin(tokio_stream::wrappers::ReceiverStream::new(rx))))
}
type SnapshotFetchStream = std::pin::Pin<
Box<dyn tokio_stream::Stream<Item = Result<LogChunk, Status>> + Send + 'static>,
>;
async fn snapshot_fetch(
&self,
request: Request<gunnar_proto::SnapshotFetchRequest>,
) -> Result<Response<Self::SnapshotFetchStream>, Status> {
let caller = self.ctx.caller(&request)?;
self.require_log_reader(&caller, GrantLevel::Admin, "Mirror.SnapshotFetch")?;
self.refuse_undersized(&caller, "Mirror.SnapshotFetch")?;
let asked = request.into_inner();
let (tx, rx) = tokio::sync::mpsc::channel::<Result<LogChunk, Status>>(SNAPSHOT_WINDOW);
let control = std::sync::Arc::clone(&self.ctx.control);
let rt = tokio::runtime::Handle::current();
tokio::task::spawn_blocking(move || {
let mut sink = |chunk: LogChunk| send_piece(&rt, &tx, chunk, SNAPSHOT_STALL_TIMEOUT);
if let Err(err) = control.snapshot_stream_from(asked.from_item, asked.from_offset, &asked.expect_id, &mut sink) {
tracing::warn!(target: crate::auth::AUDIT_TARGET, event = "grpc.call", method = "Mirror.SnapshotFetch", reason = %err, "the snapshot stream stopped");
let _ = tx.blocking_send(Err(Status::from(err)));
}
});
Ok(Response::new(Box::pin(tokio_stream::wrappers::ReceiverStream::new(rx))))
}
async fn add(
&self,
request: Request<AddMirrorRequest>,
) -> Result<Response<AddMirrorResponse>, Status> {
let caller = self.ctx.caller(&request)?;
let request = request.into_inner();
let path = repo_path(request.repo, "repo")?;
let access = if request.create_repo {
Access::namespace(&path.namespace)
} else {
Access::repo(&path)
};
self.ctx
.require(&caller, access, GrantLevel::Admin, "Mirror.Add")?;
if matches!(self.ctx.control.get_repo(&path), Ok(repo) if repo.encrypted) {
return Err(Status::failed_precondition(encrypted_is_never_a_mirror(&path)));
}
if request.create_repo && !request.adopt && self.ctx.control.get_repo(&path).is_ok() {
let already_a_mirror = match self.ctx.control.mirror_status(None, Some(&path)) {
Ok(mirrors) => !mirrors.is_empty(),
Err(crate::seam::ControlError::NotFound(_))
| Err(crate::seam::ControlError::Unimplemented(_)) => false,
Err(err) => return Err(err.into()),
};
if !already_a_mirror {
return Err(Status::failed_precondition(format!(
"{}/{} already exists and is not a mirror, so it is not taken over: \
import under another name, or say `adopt` to make this repository a \
mirror of {} (its next sync moves its refs to the upstream's)",
path.namespace, path.repo, request.remote_url
)));
}
}
let mirror = self.ctx.control.mirror_add(
&path,
&request.remote_url,
request.prune,
request.create_repo,
)?;
Ok(Response::new(AddMirrorResponse {
mirror: Some(mirror),
}))
}
async fn sync(
&self,
request: Request<SyncMirrorRequest>,
) -> Result<Response<SyncMirrorResponse>, Status> {
let caller = self.ctx.caller(&request)?;
let path = repo_path(request.into_inner().repo, "repo")?;
self.ctx.require(
&caller,
self.mirror_scope(&path),
GrantLevel::Write,
"Mirror.Sync",
)?;
if matches!(self.ctx.control.get_repo(&path), Ok(repo) if repo.encrypted) {
return Err(Status::failed_precondition(encrypted_is_never_a_mirror(&path)));
}
let mirror = self.ctx.control.mirror_sync(&path)?;
let job = mirror.job.clone();
Ok(Response::new(SyncMirrorResponse {
mirror: Some(mirror),
refs_changed: 0,
updates: Vec::new(),
job,
}))
}
async fn set_credential(
&self,
request: Request<SetMirrorCredentialRequest>,
) -> Result<Response<SetMirrorCredentialResponse>, Status> {
let caller = self.ctx.caller(&request)?;
let request = request.into_inner();
let path = repo_path(request.repo, "repo")?;
self.ctx.require(
&caller,
self.mirror_scope(&path),
GrantLevel::Admin,
"Mirror.SetCredential",
)?;
let kind = MirrorCredentialKind::try_from(request.kind).map_err(|_| {
Status::invalid_argument(format!(
"{} is not a MirrorCredentialKind this server knows",
request.kind
))
})?;
let mirror = self.ctx.control.mirror_set_credential(
&path,
kind,
&request.secret,
&request.username,
)?;
Ok(Response::new(SetMirrorCredentialResponse {
mirror: Some(mirror),
}))
}
async fn status(
&self,
request: Request<MirrorStatusRequest>,
) -> Result<Response<MirrorStatusResponse>, Status> {
let caller = self.ctx.caller(&request)?;
let request = request.into_inner();
let path = opt_repo_path(request.repo, "repo")?;
let access = match (&path, opt_str(&request.namespace)) {
(Some(path), _) => Access::repo(path),
(None, Some(namespace)) => Access::namespace(namespace),
(None, None) => Access::Server,
};
self.ctx
.require(&caller, access, GrantLevel::Read, "Mirror.Status")?;
let mirrors = self
.ctx
.control
.mirror_status(opt_str(&request.namespace), path.as_ref())?;
Ok(Response::new(MirrorStatusResponse { mirrors }))
}
async fn remove(
&self,
request: Request<RemoveMirrorRequest>,
) -> Result<Response<RemoveMirrorResponse>, Status> {
let caller = self.ctx.caller(&request)?;
let path = repo_path(request.into_inner().repo, "repo")?;
self.ctx.require(
&caller,
self.mirror_scope(&path),
GrantLevel::Admin,
"Mirror.Remove",
)?;
self.ctx.control.mirror_remove(&path)?;
Ok(Response::new(RemoveMirrorResponse {}))
}
async fn set_replication(
&self,
request: Request<SetReplicationRequest>,
) -> Result<Response<ReplicationStatusResponse>, Status> {
let caller = self.ctx.caller(&request)?;
self.ctx.require(
&caller,
Access::Server,
GrantLevel::Admin,
"Mirror.SetReplication",
)?;
let target = request
.into_inner()
.target
.ok_or_else(|| Status::invalid_argument("target is required"))?;
Ok(Response::new(self.ctx.control.replication_set(target)?))
}
async fn remove_replication(
&self,
request: Request<RemoveReplicationRequest>,
) -> Result<Response<ReplicationStatusResponse>, Status> {
let caller = self.ctx.caller(&request)?;
self.ctx.require(
&caller,
Access::Server,
GrantLevel::Admin,
"Mirror.RemoveReplication",
)?;
let name = request.into_inner().name;
if name.trim().is_empty() {
return Err(Status::invalid_argument(
"name is required: a standby is removed BY NAME, and an empty name would remove whichever one happened to be first",
));
}
Ok(Response::new(self.ctx.control.replication_remove(&name)?))
}
async fn demote_replication(
&self,
request: Request<DemoteReplicationRequest>,
) -> Result<Response<ReplicationStatusResponse>, Status> {
let caller = self.ctx.caller(&request)?;
self.ctx.require(
&caller,
Access::Server,
GrantLevel::Admin,
"Mirror.DemoteReplication",
)?;
let _ = request.into_inner();
Ok(Response::new(self.ctx.control.replication_demote()?))
}
async fn set_role(
&self,
request: Request<SetRoleRequest>,
) -> Result<Response<ReplicationStatusResponse>, Status> {
let caller = self.ctx.caller(&request)?;
self.ctx
.require(&caller, Access::Server, GrantLevel::Admin, "Mirror.SetRole")?;
let raw = request.into_inner().role;
let role = ReplicationRole::try_from(raw).map_err(|_| {
Status::invalid_argument(format!(
"unknown replication role {raw}; PRIMARY, STANDBY or BROWSE"
))
})?;
if caller.principal_id == crate::REPAIR_PRINCIPAL && role != ReplicationRole::Standby {
return Err(Status::permission_denied(format!(
"principal {} fences only: Mirror.SetRole to {} is not the repair key's",
caller.principal_id,
role.as_str_name()
)));
}
Ok(Response::new(self.ctx.control.replication_set_role(role)?))
}
async fn abdicate_replication(
&self,
request: Request<AbdicateReplicationRequest>,
) -> Result<Response<ReplicationStatusResponse>, Status> {
let caller = self.ctx.caller(&request)?;
self.ctx.require(
&caller,
Access::Server,
GrantLevel::Admin,
"Mirror.AbdicateReplication",
)?;
let _ = request.into_inner();
Ok(Response::new(self.ctx.control.replication_abdicate()?))
}
async fn replication_status(
&self,
request: Request<ReplicationStatusRequest>,
) -> Result<Response<ReplicationStatusResponse>, Status> {
let caller = self.ctx.caller(&request)?;
self.ctx.require(
&caller,
Access::Server,
GrantLevel::Admin,
"Mirror.ReplicationStatus",
)?;
let include_refs = request.into_inner().include_refs;
Ok(Response::new(
self.ctx.control.replication_status(include_refs)?,
))
}
async fn resync_replication(
&self,
request: Request<ResyncReplicationRequest>,
) -> Result<Response<ResyncReplicationResponse>, Status> {
let caller = self.ctx.caller(&request)?;
self.ctx.require(
&caller,
Access::Server,
GrantLevel::Admin,
"Mirror.ResyncReplication",
)?;
let request = request.into_inner();
if request.name.trim().is_empty() {
return Err(Status::invalid_argument(
"name is required: a standby is backfilled BY NAME, and an empty name would \
backfill whichever one happened to be first",
));
}
let repo = opt_repo_path(request.repo, "repo")?;
let except = request
.except
.into_iter()
.map(|path| repo_path(Some(path), "except"))
.collect::<Result<Vec<_>, _>>()?;
Ok(Response::new(self.ctx.control.replication_resync(
&request.name,
repo.as_ref(),
&except,
)?))
}
async fn flush_replication(
&self,
request: Request<FlushReplicationRequest>,
) -> Result<Response<FlushReplicationResponse>, Status> {
let caller = self.ctx.caller(&request)?;
self.ctx.require(
&caller,
Access::Server,
GrantLevel::Admin,
"Mirror.FlushReplication",
)?;
let req = request.into_inner();
match self.ctx.control.replication_flush(&req) {
Err(crate::seam::ControlError::FailedPrecondition(why)) if why.starts_with("no replicator is running") => {
self.log_twin_flush(&req, why).await
}
other => Ok(Response::new(other?)),
}
}
}
impl MirrorService {
async fn log_twin_flush(&self, req: &FlushReplicationRequest, no_replicator: String) -> Result<Response<FlushReplicationResponse>, Status> {
let members: Vec<String> = self.ctx.control.list_group_members(crate::TWIN_GROUP).map(|ps| ps.into_iter().map(|p| p.id.to_string()).collect()).unwrap_or_default();
if members.is_empty() {
return Err(crate::seam::ControlError::FailedPrecondition(no_replicator).into());
}
let began = estate_clock::now_unix_ms();
let started = std::time::Instant::now();
let head = self.ctx.control.log_head()?.head_seq;
let ceiling = std::time::Duration::from_secs(if req.timeout_secs == 0 { 120 } else { u64::from(req.timeout_secs) });
let verdict = loop {
match crate::heartbeat::log_twins_at(&self.ctx.heartbeats.applied_of(&members), head) {
Ok(v) => break Ok(v),
Err(why) if started.elapsed() >= ceiling => break Err(why),
Err(_) => tokio::time::sleep(std::time::Duration::from_millis(250)).await,
}
};
let caught_up = verdict.is_ok();
let lowest = self.ctx.heartbeats.applied_of(&members).iter().filter_map(|(_, a)| *a).min().unwrap_or(0);
Ok(Response::new(FlushReplicationResponse {
caught_up,
pending: head.saturating_sub(lowest),
flushed_at_unix_ms: estate_clock::now_unix_ms(),
waited_ms: started.elapsed().as_millis() as u64,
last_success_unix_ms: if caught_up { began } else { 0 },
verdict: match verdict {
Ok(v) => format!("LOG twin: {v}"),
Err(why) => format!("LOG twin NOT caught up at head {head} after {} s: {why}", ceiling.as_secs()),
},
..FlushReplicationResponse::default()
}))
}
}
pub const SNAPSHOT_STALL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
fn send_piece(
rt: &tokio::runtime::Handle,
tx: &tokio::sync::mpsc::Sender<Result<LogChunk, Status>>,
chunk: LogChunk,
stall: std::time::Duration,
) -> Result<(), crate::seam::ControlError> {
rt.block_on(async {
match tokio::time::timeout(stall, tx.send(Ok(chunk))).await {
Ok(Ok(())) => Ok(()),
Ok(Err(_)) => Err(crate::seam::ControlError::Unavailable("the twin hung up mid-snapshot".into())),
Err(_) => Err(crate::seam::ControlError::Unavailable(format!(
"the twin took no snapshot piece for {} s; the snapshot is abandoned so this primary's \
writes resume — the twin asks again when it is back",
stall.as_secs()
))),
}
})
}
pub const LOG_FETCH_STALL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
fn send_log_piece(
rt: &tokio::runtime::Handle,
tx: &tokio::sync::mpsc::Sender<Result<LogChunk, Status>>,
chunk: LogChunk,
stall: std::time::Duration,
) -> Result<(), crate::seam::ControlError> {
let seq = chunk.seq;
rt.block_on(async {
match tokio::time::timeout(stall, tx.send(Ok(chunk))).await {
Ok(Ok(())) => Ok(()),
Ok(Err(_)) => Err(crate::seam::ControlError::Unavailable(format!("the reader hung up mid-fetch, at seq {seq}"))),
Err(_) => Err(crate::seam::ControlError::Unavailable(format!(
"the reader took no Mirror.LogFetch piece for {} s (seq {seq}); the fetch is abandoned — \
a twin asks again from its own head",
stall.as_secs()
))),
}
})
}
pub(crate) fn encrypted_is_never_a_mirror(path: &RepoPath) -> String {
format!(
"{}/{} is encrypted, so it cannot be a mirror: a mirror copies another server's \
plaintext into it, and an encrypted repository holds only what its members sealed \
(push sealed content instead, or turn encryption off first)",
path.namespace, path.repo
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_twin_that_stops_taking_pieces_does_not_hold_the_primarys_freeze_for_ever() {
let rt = tokio::runtime::Builder::new_multi_thread().worker_threads(1).enable_all().build().unwrap();
let (tx, _rx) = tokio::sync::mpsc::channel::<Result<LogChunk, Status>>(1);
let piece = || LogChunk { seq: 0, kind: 1, meta: Vec::new(), blob_len: 0, blob: Vec::new(), last: true, restore_head: 0, restore_bytes: 0, log_id: String::new(), offset: None, entry_crc: None, snapshot_item: None, snapshot_id: String::new() };
send_piece(rt.handle(), &tx, piece(), std::time::Duration::from_millis(50)).expect("the first piece fits in the window");
let started = std::time::Instant::now();
let err = send_piece(rt.handle(), &tx, piece(), std::time::Duration::from_millis(50)).unwrap_err();
assert!(started.elapsed() < std::time::Duration::from_secs(5), "the sink waited {:?} for a twin that is not there", started.elapsed());
let say = format!("{err:?}");
assert!(say.contains("abandoned") && say.contains("writes resume"), "the refusal says what it gives back: {say}");
}
#[test]
fn a_twin_that_hung_up_is_named_as_that_and_not_as_a_stall() {
let rt = tokio::runtime::Builder::new_multi_thread().worker_threads(1).enable_all().build().unwrap();
let (tx, rx) = tokio::sync::mpsc::channel::<Result<LogChunk, Status>>(1);
drop(rx);
let chunk = LogChunk { seq: 0, kind: 1, meta: Vec::new(), blob_len: 0, blob: Vec::new(), last: true, restore_head: 0, restore_bytes: 0, log_id: String::new(), offset: None, entry_crc: None, snapshot_item: None, snapshot_id: String::new() };
let err = send_piece(rt.handle(), &tx, chunk, SNAPSHOT_STALL_TIMEOUT).unwrap_err();
assert!(format!("{err:?}").contains("hung up"), "{err:?}");
}
}