gunnar-server 3.0.0

The gunnar git server: SSH and smart-HTTP git transports and the gRPC control plane. The appliance half, extracted from gunnar-cli so the server code lives in its own crate.
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
//! **One account purge at a time, and the lockout always comes off.**
//!
//! `Accounts.Purge` empties a user's whole account: every repository, every
//! soft-delete tombstone still holding bytes, every orphan store under the
//! account's directory subtree, and the namespace rows themselves. That is
//! minutes of pure unlink I/O on a large account, so it is queued rather than
//! run inline, for exactly the reason [`crate::gc_queue`] gives for a gc.
//!
//! # Why this is a SECOND LINE and not more jobs on the gc one
//!
//! The mechanism is shared — both kinds run on [`crate::job_queue`], which is
//! one worker, one FIFO, dedup by subject and a finished job kept for a poller.
//! What is **not** shared is the line, and that is the ruling this module has
//! always argued for. It survives the extraction unchanged:
//!
//! **A purge holds a total lockout on a customer's account for the whole time
//! it is queued.** A gc holds nothing — it is maintenance, it is re-derivable,
//! and it can wait behind anything without costing anybody anything. Put them
//! in one queue and a user is locked out of their account while the box works
//! through an hour of somebody else's repository maintenance, because a gc that
//! arrived first is ahead of them. The queue a job waits in is part of what the
//! job costs, and these two jobs cost different things while waiting.
//!
//! So `CoreControlPlane` holds two [`crate::job_queue::JobQueue`]s and each has
//! its own jera lane. What used to be two copies of the queue is now two
//! instances of one, which is the same two lines and half the code.
//!
//! # The property this module exists for
//!
//! The gate goes on **before** the job is enqueued, in the RPC handler, so
//! there is no window in which a purge is accepted and the user can still push.
//! **That is deliberately not the queue's business** — the queue cannot promise
//! it, because the promise is about what is true when `Accounts.Purge`
//! *returns*, which is before the queue has done anything at all. The gate is
//! taken by the caller, under an id the caller takes from
//! [`PurgeQueue::next_job_id`] first.
//!
//! It comes off **here**, in [`JobKind::settle`], and it comes off on **every**
//! path out of the runner:
//!
//! * the runner returns `Ok`   → released;
//! * the runner returns `Err`  → released, and the job is FAILED with the
//!   reason and the list of stores still on the disk;
//! * the runner **panics**     → caught, released, the job is FAILED saying so,
//!   **and the worker survives**. An unwinding worker takes the queue with it,
//!   so the account after the panicking one would sit QUEUED for ever behind a
//!   thread that no longer exists — one account's bug bricking the next one's.
//!
//! The catch and the always-runs settle are now [`crate::job_queue`]'s, which
//! is the whole gain from the extraction: a gc, and a mirror, and anything
//! after them get the same two guarantees without a second person having to
//! rediscover why they matter.
//!
//! What this module cannot cover is the process dying between two unlinks.
//! That is what the lease in `gunnar_core::gate` is for: a purge's gate carries
//! a deadline, the runner re-stamps it as it goes, and a gate whose deadline
//! has passed is not a gate. Belt here, braces there, because the outcome they
//! both prevent — an account shut by a row nobody remembers, held by a process
//! that is gone — is worse than an unfinished purge. The boot sweep that
//! releases every lease a killed process left behind is
//! `CoreControlPlane::release_stale_purge_leases`, called once at start-up: it
//! is about rows in the identity table and not about jobs, so it is not the
//! queue's and did not move here.
//!
//! # What this module is not
//!
//! It is not the purge. That is [`crate::admin::purge_account`], reached
//! through a runner handed in at construction, so the queue can be tested with
//! no disk under it and there is one implementation of what emptying an account
//! means (one implementation). The release is handed in the same way and is
//! `AuthTable::lift_gate_held_by` in the real server — *held_by*, so a purge
//! finishing cannot lift a suspension an operator put on while it ran.

use std::sync::Arc;
use std::time::Duration;

use crate::job_queue::{Job, JobKind, JobQueue, JobState, Reporter};

/// How long a finished purge stays readable. The same ten minutes
/// [`crate::gc_queue::FINISHED_RETENTION`] uses, and for the same reason: far
/// longer than any console poll, far shorter than a session.
///
/// Its own constant rather than a re-export, because the two queues holding the
/// same number today is a coincidence and not a contract — a purge's result is
/// the more valuable of the two to still have when somebody looks back. The
/// runner takes it per kind ([`JobKind::FINISHED_RETENTION`]) for that reason.
pub const FINISHED_RETENTION: Duration = <Purge as JobKind>::FINISHED_RETENTION;

/// Finished jobs kept regardless of age, most recent first.
pub const FINISHED_KEPT: usize = <Purge as JobKind>::FINISHED_KEPT;

/// Where a purge is. [`JobState`]'s four words, shared with every other kind of
/// job so `PurgeStatus` and `GcStatus` cannot drift apart about what `Done`
/// means.
pub type PurgeState = JobState;

/// One account purge, running or completed.
///
/// A [`Job`] of kind [`Purge`]: the queue's fields (`job_id`, `state`,
/// `queue_position`, `error`, the timestamps) plus a purge's [`PurgeReport`],
/// which dereferences flat — `job.stores_purged` reads the report and
/// `job.state` the queue. The account is `job.key`.
pub type PurgeJob = Job<Purge>;

/// What a purge found, reclaimed, and gave back.
#[derive(Debug, Clone, Default)]
pub struct PurgeReport {
    /// Stores the walk found. **0 until the job starts.**
    pub stores_found: u32,
    pub stores_purged: u32,
    pub bytes_reclaimed: u64,
    pub principal_removed: bool,
    /// **Whether the lockout this job took has been released**, as observed —
    /// set from what the release closure actually answered, never optimistically.
    pub gate_released: bool,
    /// One line per store that could not be reclaimed. The recovery list.
    pub failures: Vec<String>,
}

/// **What a purge was asked for**, carried on the job so the worker can hand it
/// back to the runner minutes later.
///
/// The queue holds the whole order rather than the account name alone because
/// `remove_principal` and `expect_empty` change what a purge *is*, and a queue
/// that kept them in a side map keyed by account would be a second place the
/// two could disagree — with the more destructive reading winning whenever they
/// did.
#[derive(Debug, Clone)]
pub struct PurgeOrder {
    /// The account: a principal id, which is also its namespace.
    pub account: String,
    /// **The job id, minted before the gate was taken**, because the gate's
    /// holder has to be this job or the release could not tell this lockout
    /// from anybody else's. See [`PurgeQueue::next_job_id`].
    pub job_id: String,
    /// The principal that asked, for the audit trail.
    pub principal_id: String,
    /// Why, shown to the locked-out user and written into every reclaim's log
    /// line.
    pub reason: String,
    /// Also remove the principal, its keys, grants, tokens and memberships.
    pub remove_principal: bool,
    /// Refuse rather than destroy unless the account holds no live
    /// repositories.
    pub expect_empty: bool,
}

impl PurgeOrder {
    /// The ordinary order: empty the account, keep the principal.
    pub fn new(
        account: impl Into<String>,
        job_id: impl Into<String>,
        reason: impl Into<String>,
    ) -> Self {
        PurgeOrder {
            account: account.into(),
            job_id: job_id.into(),
            principal_id: String::new(),
            reason: reason.into(),
            remove_principal: false,
            expect_empty: false,
        }
    }

    /// Record who asked.
    pub fn by(mut self, principal_id: impl Into<String>) -> Self {
        self.principal_id = principal_id.into();
        self
    }

    /// Take the principal with the data.
    pub fn removing_principal(mut self, remove: bool) -> Self {
        self.remove_principal = remove;
        self
    }

    /// Refuse rather than destroy if anything is still live.
    pub fn expecting_empty(mut self, expect: bool) -> Self {
        self.expect_empty = expect;
        self
    }
}

/// **What a purge actually is**, handed in so this module does not have to know.
///
/// Takes the order and a [`Progress`] to report through, and returns `Ok` when
/// the account is empty. The real one is
/// [`crate::account_purge::purge_account`].
///
/// It reports through a handle rather than returning a summary because a purge
/// of a large account runs for minutes and a console is polling the whole time:
/// a return value can only be read once the walk is over, which is precisely
/// when nobody needs it any more.
pub type PurgeRunner = Arc<dyn Fn(&PurgeOrder, &Progress) -> Result<(), String> + Send + Sync>;

/// **How the lockout this job took is released.**
///
/// `(account, job_id) -> Ok(true)` when this job's gate was lifted, `Ok(false)`
/// when there was nothing of this job's to lift — an operator replaced the row
/// while the purge ran, or the lease expired and something else took it. The
/// real one is `AuthTable::lift_gate_held_by`.
///
/// `Ok(false)` is **not** a failure: the account is shut on somebody else's
/// authority, which is a correct outcome. `Err` is, and it is the loudest
/// state this queue can reach — the account is empty AND locked.
pub type GateRelease = Arc<dyn Fn(&str, &str) -> Result<bool, String> + Send + Sync>;

/// The handle a running purge reports through.
///
/// The shared runner's [`Reporter`], with a purge's vocabulary put on it: a
/// walk says `found`, `reclaimed`, `failed`, not "edit field 3 of the report".
/// Every method takes `&self` and writes into the job under the queue's lock,
/// so the console polling `PurgeStatus` sees a count that moves rather than a
/// job that sits at zero for four minutes and then jumps.
pub type Progress = Reporter<Purge>;

impl Progress {
    /// **How many stores the walk found.** Called once, when the walk has
    /// finished counting and before it starts unlinking.
    pub fn found(&self, stores: u32) {
        self.report(|job| job.stores_found = stores);
    }

    /// One store is gone, and this is what it was holding.
    pub fn reclaimed(&self, bytes: u64) {
        self.report(|job| {
            job.stores_purged = job.stores_purged.saturating_add(1);
            job.bytes_reclaimed = job.bytes_reclaimed.saturating_add(bytes);
        });
    }

    /// One store could NOT be reclaimed, and this is why.
    ///
    /// Recorded and the walk continues. Stopping at the first failure would
    /// leave the rest of an account that is being closed on the disk because
    /// one directory had bad permissions — and the operator would have to run
    /// the purge again anyway.
    pub fn failed(&self, note: String) {
        self.report(|job| job.failures.push(note));
    }

    /// The principal itself was removed.
    pub fn principal_removed(&self) {
        self.report(|job| job.principal_removed = true);
    }

    /// The account this purge is emptying, for a runner that wants it back.
    pub fn account(&self) -> &str {
        self.key()
    }
}

/// **The purge kind.** What a purge is, and — the part a gc has no equivalent
/// of — what a purge has to give back when it ends, however it ends.
pub struct Purge {
    run: PurgeRunner,
    release: GateRelease,
}

impl JobKind for Purge {
    const NAME: &'static str = "purge";

    type Order = PurgeOrder;
    type Report = PurgeReport;

    /// Keyed by ACCOUNT, because both questions the queue answers are per
    /// account: "is one already running for this account" on enqueue, and "how
    /// is mine going" on status.
    fn key(order: &PurgeOrder) -> &str {
        &order.account
    }

    fn run(&self, order: &PurgeOrder, progress: &Progress) -> Result<(), String> {
        (self.run)(order, progress)
    }

    /// **The release, on every path.**
    ///
    /// Unconditional, and the runner reaches it after a success, after a
    /// failure and after a panic — that is what [`JobKind::settle`] is for and
    /// it is the whole reason the release is the queue's job and not the
    /// runner's: a runner that returned early, failed, or blew up would each be
    /// a different missed unlock, and "release it in the runner's error path
    /// too" is a rule that holds until the next early return somebody adds.
    fn settle(
        &self,
        order: &PurgeOrder,
        outcome: Result<(), String>,
        progress: &Progress,
    ) -> (JobState, Option<String>) {
        let released = (self.release)(&order.account, &order.job_id);
        // What the closure ACTUALLY answered, never optimism: an `Err` is a
        // lockout still on, and a job claiming otherwise would send an operator
        // away from the one account that needs them.
        let lifted = matches!(released, Ok(true));
        progress.report(|job| job.gate_released = lifted);
        match (outcome, released) {
            // The ordinary end: emptied, and unlocked (or left locked by
            // whoever else holds the row, which is `Ok(false)` and is not this
            // job's failure).
            (Ok(()), Ok(_)) => (JobState::Done, None),
            // **Emptied but still locked.** The single worst outcome this queue
            // can produce and it must never read as success: the customer's
            // data is gone AND they cannot use what is left of the account.
            (Ok(()), Err(why)) => (
                JobState::Failed,
                Some(format!(
                    "the account was emptied but the lockout could NOT be lifted: {why}. The \
                     account is unusable until the gate is lifted by hand"
                )),
            ),
            (Err(why), Ok(_)) => (JobState::Failed, Some(why)),
            (Err(why), Err(unlock)) => (
                JobState::Failed,
                Some(format!(
                    "{why}; and the lockout could NOT be lifted either: {unlock}. The account is \
                     unusable until the gate is lifted by hand"
                )),
            ),
        }
    }

    /// Reported with `gate_released: false` on purpose: the caller has already
    /// taken a lockout under this id and has to know that nothing is going to
    /// take it off.
    fn shutdown_error(&self) -> String {
        "the purge queue is shutting down; the account is LOCKED and nothing here will unlock \
         it — lift the gate by hand"
            .to_owned()
    }

    /// No worker means the job will never run — and the account is already
    /// locked. Say both, on the job, rather than leave it QUEUED for ever.
    fn spawn_error(&self, err: &dyn std::fmt::Display) -> String {
        format!(
            "the purge worker could not be started: {err}. The account is LOCKED and nothing \
             here will unlock it — lift the gate by hand"
        )
    }
}

/// The queue: one worker, one purge at a time, FIFO, deduplicated by account.
///
/// A named type over [`JobQueue<Purge>`] rather than an alias, so
/// `Accounts.Purge`'s door keeps taking a [`PurgeOrder`] and taking its job id
/// out first, which is the shape the gate demands.
#[derive(Clone)]
pub struct PurgeQueue(JobQueue<Purge>);

impl PurgeQueue {
    /// A queue over `run`, releasing gates through `release`. No thread is
    /// started until something is enqueued.
    pub fn new(run: PurgeRunner, release: GateRelease) -> Self {
        PurgeQueue(JobQueue::new(Purge { run, release }))
    }

    /// **The job id the NEXT enqueue would use**, without enqueuing.
    ///
    /// The gate has to be taken before the job exists — that is what makes the
    /// lockout a fact by the time `Accounts.Purge` returns — and the gate's
    /// `holder` has to be the job id, or the release could not tell this job's
    /// lockout from anybody else's. So the id is minted here, handed to the
    /// gate, and handed back in on [`PurgeQueue::enqueue`] inside the order.
    ///
    /// Minting from the same counter as `enqueue`, so an id handed out here and
    /// never used simply leaves a gap. A gap in a job-id sequence costs
    /// nothing; two jobs sharing an id would make the release ambiguous.
    pub fn next_job_id(&self) -> String {
        self.0.next_job_id()
    }

    /// **Ask for an account to be emptied.**
    ///
    /// Returns the job now responsible for the account: a new one, or the one
    /// that was already queued or running — in which case this order's
    /// `job_id` is *not* used, and the caller must not assume its gate is the
    /// one that will be released. `Accounts.Purge` handles that by reading the
    /// gate back out of the table rather than trusting the one it wrote.
    pub fn enqueue(&self, order: PurgeOrder) -> PurgeJob {
        let job_id = order.job_id.clone();
        self.0.enqueue_as(order, job_id)
    }

    /// The job the queue is holding for `account`, if it still has one.
    pub fn status(&self, account: &str) -> Option<PurgeJob> {
        self.0.status(account)
    }

    /// The highest number of purges that have ever run together. **Must be 1.**
    pub fn peak_in_flight(&self) -> usize {
        self.0.peak_in_flight()
    }

    /// How many times the runner has been called.
    pub fn runs(&self) -> u64 {
        self.0.runs()
    }

    /// Stop the worker after the job it is on.
    pub fn stop(&self) {
        self.0.stop()
    }
}