gunnar-core 3.0.0

gunnar's repository model, identity table, ACL and path sandbox. gunnar-ssh, gunnar-grpc and gunnar-cli all sit on it.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
//! **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,
    /// **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,
            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))
        })
    }

    /// 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()))
    }
}