Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

crates/gunnar-server/src/account_purge.rs

//! **What emptying an account actually means, in one place.**
//!
//! [`crate::purge_queue`] decides *when* a purge runs and guarantees the
//! lockout comes off; this module is the walk itself, and it is the only
//! implementation of it (one implementation) — the gRPC verb, the CLI verb and monetize all
//! arrive here.
//!
//! # The four classes of bytes an account holds
//!
//! The per-repository reclaim was never the hard part. **Finding everything
//! was**, and it is why a purge that looped over `repo list` would report
//! success with the account's disk still full:
//!
//! | class | which listing shows it | which one does not |
//! |---|---|---|
//! | live repository | `Repos.List` | — |
//! | soft-delete tombstone | `Repos.ListDeleted` | `List` (the row is gone) |
//! | orphan store | `Repos.ListOrphans` | both of the above |
//! | nested namespace | `Repos.ListNamespaces` | — |
//!
//! An **orphan** is the one that bites. It has no catalog row and no
//! tombstone, so before 2026-08-30 nothing on the box could see it: on the
//! appliance 2026-08-30 that was ~25-39 GB standing at `alice/linux` that
//! `list` could not show, `list-deleted` could not show, `delete` answered
//! `NoSuchRepo` for, and `create` refused the name of. A purge that skipped
//! that class would leave the single largest thing in the account behind and
//! call the account empty.
//!
//! Together those four are exhaustive over `repos/<account>/`, and that is not
//! a claim about this module's cleverness — it is `Repos.ListOrphans`'s own
//! guarantee: "every `<name>.git` directory under the repository root is in
//! exactly one of the three listings". The purge inherits it by walking all
//! three, and `tests/account_purge.rs` puts one of each on a disk and measures
//! what is left.
//!
//! # Nested namespaces, and the asymmetry that is worth knowing about
//!
//! `alice/team` is a **different** namespace from `alice`: `Scope::Namespace`
//! matches exactly, and `AuthTable::owns_account("alice", "alice/team")` is a
//! string equality that answers false. So alice does not own `alice/team` in
//! the ACL's sense.
//!
//! On the disk it is `repos/alice/team/…`, inside her account's directory, and
//! it goes with the account. That asymmetry is deliberate and it points the
//! safe way: a purge that left it behind would leave bytes on the disk, on the
//! bill, and under a name a later `Repos.Create` would refuse — after an
//! operator had been told the account was emptied. `gunnar_core::gate` closes
//! the matching hole from the other end, by walking ancestors so a gate on
//! `alice` also shuts `alice/team` while this runs.
//!
//! # Why the walk keeps going after a failure
//!
//! One unreadable directory in an account of two hundred must not leave the
//! other hundred and ninety-nine on the disk — the operator would have to run
//! the purge again anyway, and the second run would trip on the same directory.
//! So each failure is recorded by name and the walk continues; the purge as a
//! whole then FAILS, so nothing upstream records the account as finished, and
//! [`PurgeReport::failures`] is the list of what is still there.

use anyhow::{Context, Result};
use gunnar_core::RepoRef;

use crate::stores::ServerStores;

/// What `Accounts.Purge` was asked for.
#[derive(Debug, Clone)]
pub struct PurgeRequest {
    /// The account: a principal id, which is also its namespace.
    pub account: String,
    /// The principal that asked, for the audit trail.
    pub principal_id: String,
    /// Why. Carried into the log line for every store removed, because a purge
    /// is the one operation whose consequence cannot be read back off the box
    /// afterwards — the rows it removes are the rows that would have explained
    /// it.
    pub reason: String,
    /// Also remove the principal: its keys, grants, tokens and memberships.
    pub remove_principal: bool,
    /// Refuse, destroying nothing, unless the account holds no live
    /// repositories.
    pub expect_empty: bool,
}

/// What a purge did.
#[derive(Debug, Clone, Default)]
pub struct PurgeReport {
    /// Stores the walk found across all four classes.
    pub stores_found: u32,
    /// How many are gone.
    pub stores_purged: u32,
    /// Bytes that left the filesystem, measured as `du` would.
    pub bytes_reclaimed: u64,
    /// Whether the principal itself was removed.
    pub principal_removed: bool,
    /// One line per store still on the disk, naming it and why. **The recovery
    /// list.**
    pub failures: Vec<String>,
    /// **Frames erased from the transaction log** — the purged repositories'
    /// packs, refs and LFS objects, rewritten in place as `Redacted` (owner
    /// ruling g-nonpay). The twin erases the same frames when it stores the
    /// `Erase` frame this purge appended.
    pub log_frames_erased: u64,
    /// Their blob bytes, gone from the segment files.
    pub log_bytes_erased: u64,
}

/// Progress a caller wants to see while the walk runs.
///
/// A purge of a large account runs for minutes with a console polling the whole
/// time, so it reports as it goes rather than returning a summary that can only
/// be read once nobody needs it. The queue's handler re-stamps the gate's lease
/// on [`Step::Reclaimed`], which is what keeps a long purge from unlocking
/// underneath itself.
#[derive(Debug, Clone)]
pub enum Step {
    /// The walk finished counting: this many stores, before any were removed.
    Found(u32),
    /// One store is gone, and this is what it was holding.
    Reclaimed { store: String, bytes: u64 },
    /// One store could not be removed, and this is why.
    Failed(String),
    /// The principal was removed.
    PrincipalRemoved,
}

/// **Empty an account.** The one implementation.
///
/// Returns `Err` only when the purge could not be *attempted* — an
/// `expect_empty` refusal, or a listing that could not be read. A walk that ran
/// and hit unremovable stores returns `Ok` with [`PurgeReport::failures`]
/// populated, and it is the caller that turns a non-empty `failures` into a
/// FAILED job. The split is deliberate: a refusal means nothing was destroyed,
/// and a partial means some of it was, and a caller that could not tell those
/// apart would either retry a refusal for ever or report a half-emptied account
/// as untouched.
pub fn purge_account(
    stores: &ServerStores,
    request: PurgeRequest,
    report: &mut dyn FnMut(Step),
) -> Result<PurgeReport> {
    let account = request.account.trim().to_owned();
    anyhow::ensure!(
        !account.is_empty(),
        "an account purge needs an account name"
    );

    // ── the census, all four classes, before anything is removed ───────────
    //
    // Counted first and in full so `stores_found` is a number the walk can be
    // measured against. A count that grew as the walk discovered things would
    // make "purged 5 of 5" true of a purge that missed half the account.
    let namespaces = account_namespaces(stores, &account)?;
    let mut targets: Vec<Target> = Vec::new();

    for namespace in &namespaces {
        for repo in stores
            .catalog()
            .list(Some(namespace))
            .with_context(|| format!("listing the repositories in {namespace}"))?
        {
            targets.push(Target {
                path: repo.path(),
                kind: Kind::Live,
            });
        }
    }
    for deleted in stores
        .tombstones()
        .context("listing the soft-deleted repositories")?
    {
        if covers(&namespaces, deleted.path.namespace()) {
            targets.push(Target {
                path: deleted.path,
                kind: Kind::Tombstoned,
            });
        }
    }
    for orphan in stores.orphans().context("listing the orphan stores")? {
        if covers(&namespaces, orphan.path.namespace()) {
            targets.push(Target {
                path: orphan.path,
                kind: Kind::Orphan,
            });
        }
    }
    // A directory can be BOTH catalogued and tombstoned only in the broken
    // states the reclaim rules describe, and the three listings are otherwise disjoint —
    // but a purge that removed a directory twice would count its bytes twice
    // and report a reclaim that never happened. Deduplicated by name, cheaply,
    // rather than trusted.
    targets.sort_by_key(|a| a.path.key());
    targets.dedup_by(|a, b| a.path.key() == b.path.key());

    if request.expect_empty {
        let live: Vec<String> = targets
            .iter()
            .filter(|t| t.kind == Kind::Live)
            .map(|t| t.path.to_string())
            .collect();
        anyhow::ensure!(
            live.is_empty(),
            "{account} still holds {} live repositor{}: {}. `expect_empty` refuses rather than \
             destroys, so nothing was touched; run the purge without it to reclaim them.",
            live.len(),
            if live.len() == 1 { "y" } else { "ies" },
            live.join(", ")
        );
    }

    let mut out = PurgeReport {
        stores_found: targets.len() as u32,
        ..PurgeReport::default()
    };
    report(Step::Found(out.stores_found));

    // ── the walk ──────────────────────────────────────────────────────────
    let mut purged_keys: Vec<String> = Vec::new();
    for target in targets {
        let name = target.path.to_string();
        // The catalog row first, so a store that is reclaimed but whose row
        // survives cannot be served afterwards. `delete` on a name with no row
        // — a tombstone or an orphan — is expected and is not a failure.
        if target.kind == Kind::Live {
            if let Err(err) = stores.catalog().delete(&target.path) {
                out.failures.push(format!(
                    "{name}: the catalog row could not be removed: {err}"
                ));
                report(Step::Failed(
                    out.failures.last().cloned().unwrap_or_default(),
                ));
                continue;
            }
        }
        match stores.purge_repo(&target.path) {
            Ok((before, after)) => {
                let bytes = before.saturating_sub(after);
                purged_keys.push(target.path.key());
                out.stores_purged = out.stores_purged.saturating_add(1);
                out.bytes_reclaimed = out.bytes_reclaimed.saturating_add(bytes);
                tracing::info!(
                    target: "gunnar::store",
                    event = "account.store_purged",
                    account = %account,
                    store = %name,
                    kind = target.kind.as_str(),
                    bytes,
                    principal = %request.principal_id,
                    reason = %request.reason,
                    "a store was reclaimed by an account purge"
                );
                report(Step::Reclaimed { store: name, bytes });
            }
            Err(err) => {
                // `purge_repo` leaves a ReclaimFailed tombstone of its own, so
                // what is left here is visible to `Repos.ListOrphans` and is
                // not lost when this report is pruned.
                let note = format!("{name}: {err:#}");
                out.failures.push(note.clone());
                report(Step::Failed(note));
            }
        }
    }

    // ── the log ───────────────────────────────────────────────────────────
    //
    // The stores are gone from the disk, and every pack and LFS object they ever
    // took is still in the transaction log: on this box until retention trims
    // it, and on the twin for ever, because the twin's log IS the copy (owner
    // ruling g-nonpay 2026-09-29: "deleted on main AND twin"). So an `Erase`
    // frame goes into the log — the twin erases its own frames when it stores
    // it, a restore when it replays it — and this box erases its own now. Only
    // the stores that WERE purged: a store the walk could not remove still has
    // its data on the disk, and a restore must be able to rebuild it.
    if !purged_keys.is_empty() && !stores.txlog().is_follower() {
        match erase_from_log(stores, &account, &purged_keys, &request.reason) {
            Ok(erased) => {
                out.log_frames_erased = erased.frames;
                out.log_bytes_erased = erased.bytes;
                tracing::info!(
                    target: "gunnar::store",
                    event = "account.log_erased",
                    account = %account,
                    frames = erased.frames,
                    bytes = erased.bytes,
                    principal = %request.principal_id,
                    reason = %request.reason,
                    "an account purge erased the account's frames from the transaction log"
                );
            }
            Err(err) => out.failures.push(format!(
                "the account's frames could not be erased from the transaction log: {err:#}"
            )),
        }
    }

    // ── the namespace rows, deepest first ─────────────────────────────────
    //
    // Deepest first because `delete_namespace` refuses a namespace that still
    // holds repositories, and `alice` holding `alice/team`'s rows would make
    // the outer delete fail on an account that is otherwise empty. Sorting by
    // length descending is enough: a nested name is strictly longer than the
    // one it nests under.
    let mut ordered = namespaces.clone();
    ordered.sort_by_key(|n| std::cmp::Reverse(n.len()));
    for namespace in ordered {
        match stores.catalog().delete_namespace(&namespace, false) {
            Ok(_) => {}
            // Nothing there is the normal case for an account whose namespace
            // row never existed, and it is not a failure of the purge.
            Err(gunnar_core::Error::NoSuchNamespace(_)) => {}
            Err(err) => out.failures.push(format!(
                "the namespace {namespace} could not be removed: {err}"
            )),
        }
    }

    // ── the principal, last ───────────────────────────────────────────────
    //
    // Last, and only if asked. Last because it takes the account's gate with
    // it (`AuthTable::remove_principal`), and a gate removed while stores were
    // still being reclaimed would unlock an account mid-walk — the exact race
    // the lockout exists to prevent. By the time this runs there is nothing
    // left to protect.
    if request.remove_principal {
        match stores.auth().remove_principal(&account) {
            Ok(_) => {
                out.principal_removed = true;
                tracing::info!(
                    target: "gunnar::audit",
                    event = "account.principal_removed",
                    account = %account,
                    principal = %request.principal_id,
                    reason = %request.reason,
                    "an account purge removed the principal, its keys, grants and memberships"
                );
                report(Step::PrincipalRemoved);
            }
            // A principal that is not there is the outcome that was asked for.
            Err(gunnar_core::Error::NoSuchPrincipal(_)) => out.principal_removed = true,
            Err(err) => out.failures.push(format!(
                "the principal {account} could not be removed: {err}"
            )),
        }
    }

    Ok(out)
}

/// Frame the erase, then erase this box's own frames of `repos` before it.
fn erase_from_log(
    stores: &ServerStores,
    account: &str,
    repos: &[String],
    reason: &str,
) -> Result<gunnar_core::txlog::Erased> {
    use gunnar_core::txlog::{Kind, Meta};
    let log = stores.txlog();
    let seq = {
        let _framed = log.mutation();
        log.append(
            Kind::Erase,
            &Meta::Erase {
                account: account.to_owned(),
                repos: repos.to_vec(),
                reason: reason.to_owned(),
            },
            &[],
        )
        .context("framing the erase")?
    };
    log.erase(seq, repos)
        .context("erasing the account's frames")
}

/// What a target is, for the log line and for `expect_empty`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Kind {
    Live,
    Tombstoned,
    Orphan,
}

impl Kind {
    fn as_str(self) -> &'static str {
        match self {
            Kind::Live => "live",
            Kind::Tombstoned => "tombstoned",
            Kind::Orphan => "orphan",
        }
    }
}

struct Target {
    path: RepoRef,
    kind: Kind,
}

/// **Every namespace that belongs to `account`**: the account itself, and every
/// namespace nested under it.
///
/// The account's own name is always in the list even when no namespace row
/// exists for it — an orphan store can stand under a name the catalog never
/// knew, which is what an orphan IS, and a purge that keyed off the namespace
/// rows would skip exactly the class it most needs to reach.
fn account_namespaces(stores: &ServerStores, account: &str) -> Result<Vec<String>> {
    let mut out = vec![account.to_owned()];
    let prefix = format!("{account}/");
    for namespace in stores
        .catalog()
        .namespaces()
        .context("listing the namespaces")?
    {
        if namespace.name.starts_with(&prefix) {
            out.push(namespace.name);
        }
    }
    out.sort();
    out.dedup();
    Ok(out)
}

/// Is `namespace` one of the account's, or nested under one of them?
///
/// On PATH COMPONENTS. `alicehacks` starts with `alice` and is a different
/// account; a purge that caught it by string prefix would take a stranger's
/// repositories, which is the one mistake this whole module must not make.
fn covers(namespaces: &[String], namespace: &str) -> bool {
    namespaces
        .iter()
        .any(|owned| namespace == owned || namespace.starts_with(&format!("{owned}/")))
}

#[cfg(test)]
mod tests {
    use super::*;

    /// The prefix rule, on its own, because it is the difference between
    /// emptying one account and emptying two.
    #[test]
    fn covers_matches_path_components_and_not_characters() {
        let owned = vec!["alice".to_owned()];
        assert!(covers(&owned, "alice"));
        assert!(covers(&owned, "alice/team"));
        assert!(covers(&owned, "alice/team/deep"));
        assert!(
            !covers(&owned, "alicehacks"),
            "`alicehacks` is a different account and a purge of `alice` must not reach it"
        );
        assert!(!covers(&owned, "bob"));
        assert!(
            !covers(&owned, "notalice"),
            "a suffix match would be worse still"
        );
    }
}