crates/gunnar-core/src/mirror_store.rs
//! **Where a repository's pull-mirror registration lives** (`refs/gunnar/mirrors`).
//!
//! The fourth document in the control store, and the one that closes
//! `Mirror.Add` / `Mirror.Status` / `Mirror.Remove`. Those answered
//! `Unimplemented` with one reason:
//!
//! > `Mirror.*`: mirroring runs over gunnar-client (nornir-git ls-remote +
//! > fetch), which an S crate must never link (the dependency budget). Use `gunnar mirror` on a
//! > normal-OS host instead.
//!
//! That reason is exactly right about **`Sync`** and wrong about the other
//! three. Adding, listing and removing a mirror registration is bookkeeping:
//! a URL, a flag and a few counters. It needs no fetch, no `ls-remote` and no
//! client code — only somewhere to keep the record, which is what this is.
//! `Sync` still needs the client half and is still refused where that half is
//! absent, which is now a much smaller and much more honest gap.
//!
//! # It is deliberately not `mirrors.json`
//!
//! `gunnar-client`'s `MirrorRegistry` keeps a `mirrors.json` beside the data
//! root, and that file stays: it is the **pull-mirror client's** own state, on
//! a host running `gunnar mirror`. This is the *server's* registry, reachable
//! over the control plane, versioned with the catalog and replicated with it.
//! Two files would be two answers to "what does this server mirror"; two
//! *systems* with one file each is one answer per system.
use std::sync::Arc;
use crate::control::ControlStore;
use crate::doc::{impl_doc, DocRef, ShardedMap};
use crate::error::Result;
use crate::sandbox::RepoRef;
/// **The name the mirror document is stored under** in the control store.
pub const MIRROR_REF: &str = "refs/gunnar/mirrors";
/// **Which kind of credential a mirror dials its upstream with.**
///
/// The kind lives in the mirror *document*; the VALUE lives in the sealed
/// [`SecretStore`](gunnar_store::SecretStore), under
/// [`credential_secret_key`]. That split is the whole design and it is the
/// webhook secret's, one step wider:
///
/// * a **listing** must be able to say "this mirror has a key" without being a
/// way to read the key, so the fact and the value are in different stores and
/// only one of them is reachable from a `Mirror.Status`;
/// * the *kind* is not a secret and is load-bearing — an SSH key against an
/// `https://` remote is a mirror that fails on authentication for ever, and
/// an operator has to be able to see that before it does;
/// * the value is sealed under the KEK, exactly as the SSH host key is, so a
/// copy of `control.redb` is not a copy of a customer's forge token.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MirrorCredentialKind {
/// Nothing stored. A public upstream needs nothing, and mirrors fine.
#[default]
None,
/// An OpenSSH private key, for an `ssh://` upstream.
SshKey,
/// A token, for an `https://` upstream.
Token,
}
impl MirrorCredentialKind {
/// Whether a credential is stored at all — the `has_secret` question, kept
/// as a method so no caller has to spell the `None` comparison itself.
pub fn is_set(self) -> bool {
!matches!(self, MirrorCredentialKind::None)
}
/// The word an operator sees in a refusal. Not `Debug`: these strings are
/// in errors an operator reads and acts on, and `Debug` is free to change.
pub fn name(self) -> &'static str {
match self {
MirrorCredentialKind::None => "none",
MirrorCredentialKind::SshKey => "an ssh private key",
MirrorCredentialKind::Token => "a token",
}
}
}
/// **Where one mirror's credential is sealed**, in the server's secret store.
///
/// Namespaced under `mirror/` so it cannot collide with the host key, the
/// issuer key or the transparency key, all of which live in the same table
/// under bare names. The repository key (`<namespace>/<name>`) is the rest of
/// it, so the credential is removed by the same identity the registration is.
///
/// One function rather than a `format!` at each call site: a `put` and a `get`
/// that spelled the key differently would be a credential that is stored and
/// never found, which reads exactly like a credential that was never set.
pub fn credential_secret_key(repo: &RepoRef) -> String {
format!("mirror/{}", repo.key())
}
/// What this server knows about one repository's upstream.
///
/// Every field is a **fact that was observed**, not a derivation: a mirror that
/// has never synced carries `last_sync_at_unix_ms: None` rather than a zero
/// that reads like 1970, and `last_error` empty rather than a cheerful "".
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MirrorRecord {
/// `<namespace>/<repo>` — the key this record is filed under, carried
/// inside it as well.
///
/// The catalog's `Repo` does the same with its namespace and name, for the
/// same reason: a listing reads values and would otherwise have no way to
/// say which repository each one belongs to.
pub repo: String,
/// Where the upstream is.
pub remote_url: String,
/// Delete local references the upstream no longer has.
pub prune: bool,
/// When the registration was made.
pub added_at_unix_ms: i64,
/// When a sync last **completed**, or `None` if none ever has.
///
/// `Option`, not `0`: "never synced" and "synced at the epoch" are
/// different facts and a UI renders them differently. A zero here would be
/// the same absence-as-a-value defect the push attestation had.
pub last_sync_at_unix_ms: Option<i64>,
/// Why the last sync failed, empty if the last one succeeded or none has
/// run. Kept beside the timestamp so a mirror that failed *after*
/// succeeding still shows both facts.
pub last_error: String,
/// How many syncs have completed.
pub sync_count: u64,
/// **Which kind of credential is stored for this upstream — never the
/// credential.** See [`MirrorCredentialKind`].
///
/// `#[serde(default)]` and not a new document version: a registration
/// written by a server that predates credentials is a registration with
/// none, which is exactly what [`MirrorCredentialKind::None`] means.
#[serde(default)]
pub credential: MirrorCredentialKind,
/// The Basic-auth username a [`MirrorCredentialKind::Token`] is paired
/// with; empty means `Bearer`.
///
/// **Not a secret.** A forge token's username is `x-access-token`,
/// `oauth2` or the account name, and every forge documents it in public. It
/// is in the document rather than beside the sealed value so a listing can
/// show the operator which half of the pair they configured.
#[serde(default)]
pub credential_username: String,
/// **The repository is created by the first sync that reaches the
/// upstream** (2026-10-18), at the upstream's object format and default
/// branch. Set by `Mirror.Add { create_repo }` for a repository that does
/// not exist; cleared by the first sync that lands. While it is set there
/// is a registration and no repository, so a failed first import leaves no
/// empty repository behind.
///
/// Not written while false, so a registration that never had it reads the
/// same to a server that predates it.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub creates_repo: bool,
/// The last successful sync found Git LFS in use (`filter=lfs` in the
/// default branch's `.gitattributes`): this mirror holds the pointer files
/// and not the LFS content, which is not copied.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub lfs_detected: bool,
/// **The private CA this mirror's https upstream is issued under**
/// (GUN-MIRROR-CA, `Mirror.SetCa`): the PEM as judged when it was set. Not a
/// secret (a CA certificate is public); trusted for THIS mirror's calls alone.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ca_pem: Option<String>,
/// That CA's subject, as shown (`MirrorConfig.ca_subject`).
#[serde(default, skip_serializing_if = "String::is_empty")]
pub ca_subject: String,
/// That CA's SHA-256 over its DER, hex (`MirrorConfig.ca_fingerprint_sha256`).
#[serde(default, skip_serializing_if = "String::is_empty")]
pub ca_fingerprint_sha256: String,
/// **A sync of this mirror started at this time and has not ended**
/// (import-big, 2026-10-26). Written when the worker starts a sync and
/// cleared when it settles, so a sync the process did not live to finish
/// (a restart, an out-of-memory kill) is still known after the restart:
/// the job queue is memory and forgets it, this document does not. Without
/// it an interrupted import was a repository with nothing in it and a
/// registration that said nothing was wrong.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sync_started_at_unix_ms: Option<i64>,
/// How many syncs in a row ended because the server stopped under them.
/// Reset by a sync that settles. A box that restarts during every attempt
/// (one that runs out of memory on this upstream) stops resuming at
/// [`MAX_RESUMES`] and says so, rather than restarting for ever.
#[serde(default, skip_serializing_if = "is_zero")]
pub interrupted: u32,
}
fn is_zero(n: &u32) -> bool {
*n == 0
}
/// **How many times an interrupted sync is resumed by itself** after a
/// restart, before it is left FAILED for a person to look at.
pub const MAX_RESUMES: u32 = 3;
impl MirrorRecord {
/// A fresh registration: never synced, no error.
pub fn new(
repo: &RepoRef,
remote_url: impl Into<String>,
prune: bool,
now_unix_ms: i64,
) -> Self {
MirrorRecord {
repo: repo.key(),
remote_url: remote_url.into(),
prune,
added_at_unix_ms: now_unix_ms,
last_sync_at_unix_ms: None,
last_error: String::new(),
sync_count: 0,
credential: MirrorCredentialKind::None,
credential_username: String::new(),
creates_repo: false,
lfs_detected: false,
ca_pem: None,
ca_subject: String::new(),
ca_fingerprint_sha256: String::new(),
sync_started_at_unix_ms: None,
interrupted: 0,
}
}
}
/// The mirror registrations as they are stored.
#[derive(Default, Clone, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct MirrorDoc {
/// Keyed by `<namespace>/<repo>`, the same key the catalog uses.
#[serde(default)]
repos: ShardedMap<MirrorRecord>,
}
impl_doc!(MirrorDoc { repos });
/// Register, list and remove pull-mirror upstreams.
///
/// Cheap to clone; every clone reads and writes the same document.
#[derive(Clone, Debug)]
pub struct MirrorStore {
doc: DocRef<MirrorDoc>,
}
impl MirrorStore {
/// Open the mirror document held in `store` (the server's control store).
pub fn new(store: Arc<dyn ControlStore>) -> Result<Self> {
Ok(MirrorStore {
doc: DocRef::new(store, MIRROR_REF)?,
})
}
/// The name the document is stored under.
pub fn ref_name(&self) -> &str {
self.doc.ref_name()
}
/// One repository's registration, if it has one.
pub fn get(&self, repo: &RepoRef) -> Result<Option<MirrorRecord>> {
self.doc.load()?.repos.get(&repo.key())
}
/// Every registration. Each one names its own repository.
///
/// **O(mirrors on the server)**, the listing path, exactly like
/// [`crate::RepoCatalog::namespaces`]. Mirrors are not indexed by namespace
/// because nothing looks one up that way: the push path never asks, and the
/// only caller is an operator listing them.
pub fn all(&self) -> Result<Vec<MirrorRecord>> {
self.doc.load()?.repos.values()
}
/// Register `repo`'s upstream, replacing any earlier registration.
pub fn set(&self, repo: &RepoRef, record: MirrorRecord) -> Result<MirrorRecord> {
let key = repo.key();
self.doc.update(|doc| {
doc.repos.insert(key.clone(), record.clone())?;
Ok(record.clone())
})
}
/// Record the outcome of a sync against an existing registration.
///
/// Returns `None` when there is no registration to record against — which
/// is a different answer from "recorded a failure", and the caller must not
/// collapse them: a sync of an unregistered repository is a bug in the
/// caller, not a mirror that failed.
pub fn record_sync(
&self,
repo: &RepoRef,
at_unix_ms: i64,
error: Option<&str>,
) -> Result<Option<MirrorRecord>> {
let key = repo.key();
let error = error.map(str::to_owned);
self.doc.update(|doc| {
let Some(mut record) = doc.repos.get(&key)? else {
return Ok(None);
};
// The sync settled, either way: it is no longer in flight.
record.sync_started_at_unix_ms = None;
record.interrupted = 0;
match &error {
None => {
record.last_sync_at_unix_ms = Some(at_unix_ms);
record.last_error.clear();
record.sync_count += 1;
// A sync that landed made the repository if it was to come.
record.creates_repo = false;
}
// A failure does **not** move `last_sync_at_unix_ms`: that
// field means "when this mirror was last correct", and moving
// it on a failure would make a mirror that has been broken for
// a week look fresh.
Some(why) => record.last_error = why.clone(),
}
doc.repos.insert(key.clone(), record.clone())?;
Ok(Some(record))
})
}
/// **A sync of `repo` has started** — kept until [`MirrorStore::record_sync`]
/// settles it, so a restart in between is known (see
/// [`MirrorRecord::sync_started_at_unix_ms`]). `None` when there is no
/// registration.
pub fn mark_started(&self, repo: &RepoRef, at_unix_ms: i64) -> Result<Option<MirrorRecord>> {
let key = repo.key();
self.doc.update(|doc| {
let Some(mut record) = doc.repos.get(&key)? else {
return Ok(None);
};
record.sync_started_at_unix_ms = Some(at_unix_ms);
doc.repos.insert(key.clone(), record.clone())?;
Ok(Some(record))
})
}
/// **Every registration whose sync was in flight when the process
/// stopped**, each settled as interrupted: `last_error` says so, the
/// in-flight mark is cleared and [`MirrorRecord::interrupted`] counts it.
/// Called once at start, before any new sync can begin. Returns the
/// settled records.
pub fn settle_interrupted(&self, at_unix_ms: i64) -> Result<Vec<MirrorRecord>> {
self.doc.update(|doc| {
let mut settled = Vec::new();
for record in doc.repos.values()? {
let Some(started) = record.sync_started_at_unix_ms else {
continue;
};
let mut record = record;
record.sync_started_at_unix_ms = None;
record.interrupted = record.interrupted.saturating_add(1);
let minutes = (at_unix_ms - started).max(0) / 60_000;
record.last_error = if record.interrupted > MAX_RESUMES {
format!(
"The import stopped because the server restarted while it ran, {} times \
in a row (the last one {minutes} minute(s) in). It is not tried again by \
itself: this repository may be too big for this server's memory or \
disk. Press Sync to try once more.",
record.interrupted
)
} else {
format!(
"The import stopped because the server restarted while it ran \
({minutes} minute(s) in). It starts again by itself."
)
};
doc.repos.insert(record.repo.clone(), record.clone())?;
settled.push(record);
}
Ok(settled)
})
}
/// **Say which kind of credential this mirror now has**, leaving every
/// other field alone.
///
/// Separate from [`MirrorStore::set`] for the reason `Mirror.SetCredential`
/// is separate from `Mirror.Add`: `set` writes a whole record, so routing
/// the credential through it would make every later re-registration —
/// somebody flipping `prune` — silently forget the key. This edits the two
/// fields and nothing else.
///
/// **It does not touch the value.** The sealed secret is the caller's to
/// write and to delete, and it is written FIRST: a document that claims a
/// credential the store does not hold is a mirror that fails at dial time
/// with "no credential stored", which is a confusing lie, whereas a sealed
/// value with no document pointing at it is unreachable and harmless.
///
/// Returns `None` when there is no registration to record against — a
/// different answer from "recorded", and the caller must not collapse them.
pub fn set_credential(
&self,
repo: &RepoRef,
kind: MirrorCredentialKind,
username: &str,
) -> Result<Option<MirrorRecord>> {
let key = repo.key();
let username = username.to_owned();
self.doc.update(|doc| {
let Some(mut record) = doc.repos.get(&key)? else {
return Ok(None);
};
record.credential = kind;
record.credential_username = if kind == MirrorCredentialKind::Token {
username.clone()
} else {
// A username on an SSH key would be a field that is read by
// nothing and shown to an operator as if it meant something.
String::new()
};
doc.repos.insert(key.clone(), record.clone())?;
Ok(Some(record))
})
}
/// **Set or forget this mirror's private CA** (GUN-MIRROR-CA): `ca` is the
/// judged PEM with its subject and fingerprint, `None` forgets it. `None` when
/// there is no registration.
pub fn set_ca(
&self,
repo: &RepoRef,
ca: Option<(String, String, String)>,
) -> Result<Option<MirrorRecord>> {
let key = repo.key();
self.doc.update(|doc| {
let Some(mut record) = doc.repos.get(&key)? else {
return Ok(None);
};
match ca.clone() {
Some((pem, subject, fingerprint)) => {
record.ca_pem = Some(pem);
record.ca_subject = subject;
record.ca_fingerprint_sha256 = fingerprint;
}
None => {
record.ca_pem = None;
record.ca_subject.clear();
record.ca_fingerprint_sha256.clear();
}
}
doc.repos.insert(key.clone(), record.clone())?;
Ok(Some(record))
})
}
/// Say whether the last successful sync found Git LFS in use, leaving
/// every other field alone. `None` when there is no registration.
pub fn set_lfs_detected(&self, repo: &RepoRef, detected: bool) -> Result<Option<MirrorRecord>> {
let key = repo.key();
self.doc.update(|doc| {
let Some(mut record) = doc.repos.get(&key)? else {
return Ok(None);
};
if record.lfs_detected != detected {
record.lfs_detected = detected;
doc.repos.insert(key.clone(), record.clone())?;
}
Ok(Some(record))
})
}
/// Remove `repo`'s registration, reporting whether there was one.
pub fn remove(&self, repo: &RepoRef) -> Result<bool> {
let key = repo.key();
self.doc.update(|doc| Ok(doc.repos.remove(&key)?.is_some()))
}
}