nordisk/gunnar
crates/gunnar-common/src/cli.rs
//! The `gunnar` command line, in clap derive.
//!
//! Both halves live in one enum with `#[cfg]` on the variants, so
//! `--no-default-features --features server` produces a binary whose `--help`
//! genuinely has no `clone` in it, rather than one that offers a verb and then
//! fails at runtime.
// Every use is behind a half's feature: with none on, nothing names it (OX-GUNBR 2026-10-28).
#[cfg(any(
feature = "server",
feature = "client",
feature = "control",
feature = "systemd"
))]
use std::path::PathBuf;
#[cfg(feature = "server")]
use clap::ArgGroup;
#[cfg(any(feature = "server", feature = "control"))]
use clap::ValueEnum;
use clap::{Parser, Subcommand};
/// gunnar — a git server and a git client, in Rust, over SSH and smart HTTP.
#[derive(Debug, Parser)]
#[command(
name = "gunnar",
// Not the bare crate version. `gunnar 0.1.0` names a manifest field, not a
// build — and on 2026-08-04/05 several benchmark runs had to identify the
// binary under test by file size and mtime because nothing on it said which
// commit it was; one silently measured a ten-hour-old build. The git
// description and build date come from `build.rs`: a published package names
// its commit from `.cargo_vcs_info.json`, and a build that knows no date leaves
// `, built …` out (GUNNAR_BUILD_DATE_PART is empty) rather than making one up.
version = concat!(
env!("CARGO_PKG_VERSION"),
" (",
env!("GUNNAR_BUILD_REF"),
env!("GUNNAR_BUILD_DATE_PART"),
")"
),
about = "a git server and a git client, in Rust, over SSH and smart HTTP",
long_about = "gunnar serves git over SSH (publickey auth, no shell) and, when built with \
`--features http` and started with `--http-bind`, over smart HTTP. It \
controls itself over gRPC. The client half is a normal git client.\n\n\
Two git transports, one implementation: smart HTTP carries the same \
pkt-line conversation SSH carries, through the same authorisation check \
against the same key table and the same ACL, and an access token is the \
HTTP credential where a public key is the SSH one. What is refused is \
refused on both.\n\n\
What gunnar does NOT serve: git:// ever, being unauthenticated by \
construction; the dumb HTTP protocol, which is refused by name; and no \
git or ssh subprocess anywhere in the server, on any transport."
)]
pub struct Cli {
/// Log at `debug` rather than `info`. `RUST_LOG` still wins.
#[arg(long, short = 'v', global = true)]
pub verbose: bool,
/// Where the server keeps its control repository and its repositories.
#[cfg(feature = "server")]
#[arg(
long,
global = true,
env = "GUNNAR_DATA_ROOT",
default_value_os_t = default_data_root(),
value_name = "DIR"
)]
pub data_root: PathBuf,
#[command(subcommand)]
pub command: Command,
}
/// `$GUNNAR_DATA_ROOT`, else `$HOME/.local/share/gunnar`, else `./gunnar-data`.
#[cfg(feature = "server")]
pub fn default_data_root() -> PathBuf {
match std::env::var_os("HOME") {
Some(home) if !home.is_empty() => PathBuf::from(home).join(".local/share/gunnar"),
_ => PathBuf::from("gunnar-data"),
}
}
#[derive(Debug, Subcommand)]
pub enum Command {
// ── server ─────────────────────────────────────────────────────────────
/// Start the listeners: SSH for git, gRPC for the control plane, and smart
/// HTTP for git as well when `--http-bind` asks for it.
// Boxed: `ServeArgs` is several times the size of every other verb.
#[cfg(feature = "server")]
Serve(Box<ServeArgs>),
/// Namespaces: the `<ns>` in `git@host:<ns>/<repo>.git`.
#[cfg(feature = "server")]
#[command(subcommand)]
Namespace(NamespaceCommand),
/// Repositories.
#[cfg(feature = "server")]
#[command(subcommand)]
Repo(RepoCommand),
/// Principals. Keys and grants hang off these.
#[cfg(feature = "server")]
#[command(subcommand)]
Principal(PrincipalCommand),
/// **The server's own certificate issuer** — identity from a certificate.
///
/// With one of these, creating a user *is* issuing their certificate:
/// there is no external PKI to run and no second system to keep in step.
///
/// Present in a CONTROL-only build too, where it carries exactly one verb:
/// `issuer show --at <addr>`. That is not a convenience. `Pki::read_only`
/// exists so this read "works on a client-shaped binary", and the box
/// whose CA anyone actually needs to check is the appliance — which has no
/// shell, so the asking is always done from somewhere else.
#[cfg(any(feature = "server", feature = "control"))]
#[command(subcommand)]
Issuer(IssuerCommand),
/// Public keys.
#[cfg(feature = "server")]
#[command(subcommand)]
Key(KeyCommand),
/// Mint and revoke the access tokens smart HTTP authenticates with.
#[cfg(feature = "server")]
#[command(subcommand)]
Token(TokenCommand),
/// **The transparency log** over the push attestations.
///
/// The attestation log is append-only by discipline; these verbs are what
/// make it append-only by evidence. `head` signs what the log currently
/// holds, `audit` checks every signed head against the records on disk, and
/// `prove` hands out the inclusion proof a client checks offline.
#[cfg(feature = "server")]
#[command(subcommand)]
Attest(AttestCommand),
/// **Walk the chain backwards from a line of source.**
///
/// Which commit wrote the line, whether that commit is signed by a key this
/// server knows, which push landed it, and whether that push sits in the
/// transparency log under a signed tree head.
///
/// Every link is reported as proven, broken or **unproven**, and the three
/// are different: an unsigned commit is not a failed signature, and a push
/// with no attestation is not a push that was refused. A chain with an
/// unproven link is INCOMPLETE, never green.
#[cfg(feature = "server")]
Verify(VerifyArgs),
/// **Who wrote each line of a file** (`verify --line` for the whole file).
///
/// For every line of `<path>` at a revision, the commit that last touched
/// it and that commit's author — the same line→commit attribution `verify`
/// closes as link 1, done once per line instead of once for a single line.
#[cfg(feature = "server")]
Blame(BlameArgs),
/// **Find a literal string** across the latest revision of a repository.
///
/// Walks the tree at a ref and scans each text file, returning `path:line`
/// matches. Bounded by a file-size and a match ceiling; it is a live walk,
/// not a persistent index.
#[cfg(feature = "server")]
Search(SearchArgs),
/// **The forge**: issues, reviews and merges as signed events (roadmap
/// phase 1).
#[cfg(feature = "server")]
#[command(subcommand)]
Forge(ForgeCommand),
/// Give a principal access.
#[cfg(feature = "server")]
Grant(GrantArgs),
/// Take a principal's access away.
#[cfg(feature = "server")]
Revoke(RevokeArgs),
/// Repack a repository's objects.
#[cfg(feature = "server")]
Gc(GcArgs),
/// Read a repository's reference history, and optionally seal it.
#[cfg(feature = "server")]
Reflog(ReflogArgs),
/// WireGuard identity: mint the keypair an appliance is seeded with.
#[cfg(feature = "server")]
#[command(subcommand)]
Wg(WgCommand),
// ── client ─────────────────────────────────────────────────────────────
/// Clone a repository over SSH into a working tree.
#[cfg(feature = "client")]
Clone(CloneArgs),
/// Fetch from an SSH remote into an existing repository.
#[cfg(feature = "client")]
Fetch(FetchArgs),
/// List the references an SSH remote advertises, without cloning it.
///
/// The cheapest way to ask whether a remote is reachable, whether the key
/// authenticates, and whether the repository holds what you expect. Prints
/// `git ls-remote`'s lines — `<oid>\t<ref>`, `HEAD` first, an annotated tag
/// followed by its peeled `^{}` line — because scripts parse them.
#[cfg(feature = "client")]
#[command(name = "ls-remote")]
LsRemote(LsRemoteArgs),
/// Push to an SSH remote (send-pack, implemented here; gix has none).
#[cfg(feature = "client")]
Push(PushArgs),
/// Pull-mirror a remote repository.
#[cfg(feature = "client")]
#[command(subcommand)]
Mirror(MirrorCommand),
/// Encrypted repositories: the server holds ciphertext only, and only the
/// members' SSH keys open it.
///
/// Each repository has one key ring, wrapped to every member's SSH PUBLIC
/// key (`ssh-ed25519`, `ssh-rsa`) in `.gunnar-crypt/keys`. git's clean and
/// smudge filter seal and open file content on this machine; the server
/// never sees a key. File names, commit messages, sizes and the shape of
/// history stay visible (Level 1). See `.nornir/encrypted-repos.md`.
#[cfg(feature = "client")]
#[command(subcommand)]
Crypt(CryptCommand),
/// **A repository's settings on a forge** (owner, 2026-10-14): show it,
/// make it public or private, or change its default branch — over the
/// forge's SSH door, signed in with your SSH key, the way git and `crypt`
/// reach an installed Gunnar. The server decides who may (its own rules:
/// the account admin for visibility, a repository admin for the branch).
///
/// `gunnar settings nordisk/knut`,
/// `gunnar settings nordisk/knut --visibility public`,
/// `gunnar settings nordisk/knut --default-branch main`.
#[cfg(feature = "client")]
Settings(SettingsArgs),
/// **Create an empty repository on a forge** in the object format you
/// choose (sha256 unless `--sha1`), over the forge's SSH door: a repository
/// made by a first push can only be sha1. `gunnar push` does this by itself
/// for a sha256 repository whose remote does not exist yet.
#[cfg(feature = "client")]
Create(CreateArgs),
/// Show the commit log.
#[cfg(feature = "client")]
Log(LogArgs),
/// Show the working tree status.
#[cfg(feature = "client")]
Status(RepoArgs),
/// Commit everything in the working tree.
#[cfg(feature = "client")]
Commit(CommitArgs),
/// Branches.
#[cfg(feature = "client")]
Branch(BranchArgs),
/// Tags.
#[cfg(feature = "client")]
Tag(TagArgs),
// ── the control plane, over the wire ───────────────────────────────────
/// Configure a RUNNING gunnar over its gRPC control plane.
///
/// The first call needs no credential at all; every one after it is
/// authenticated with an ssh key. `gunnar control status` says which of
/// those a server is in.
///
/// These are the ONLINE verbs. `gunnar principal`, `gunnar key` and
/// `gunnar grant` write the control repository directly and therefore need
/// the server to be DOWN; these do the same work against one that is up.
#[cfg(feature = "control")]
#[command(subcommand)]
Control(ControlCommand),
/// **Log this terminal in with your passkey, through a console.**
///
/// Mints a fresh ed25519 key here, shows a code, and waits: open the
/// console's `/login/device` page, type the code and touch your passkey.
/// Gunnar enrols THIS key on you, expiring (at most a day); its private
/// half never leaves this machine. Use it with `-i <path>` (printed).
/// The other CLI way in is a FIDO2 `sk-ssh-ed25519` key in ssh-agent.
#[cfg(feature = "control")]
Login(LoginArgs),
// ── systemd ────────────────────────────────────────────────────────────
/// Manage gunnar as a systemd service. Off unless built with `--features systemd`.
#[cfg(feature = "systemd")]
#[command(subcommand)]
Service(ServiceCommand),
}
/// `gunnar login`, see [`Command::Login`].
#[cfg(feature = "control")]
#[derive(Debug, clap::Args)]
pub struct LoginArgs {
/// The console's address, e.g. `https://console.example.com`.
pub console: String,
/// How long the key lives, in hours (the console allows at most 24).
#[arg(long, default_value_t = 12)]
pub ttl: u64,
/// What the approval page calls this terminal (default: `user@host`).
#[arg(long)]
pub label: Option<String>,
/// Where the key is written (default: `~/.config/gunnar/login`).
#[arg(long)]
pub dir: Option<PathBuf>,
}
// ───────────────────────────────────────────────────────────────────────────
// server
// ───────────────────────────────────────────────────────────────────────────
/// `--anonymous`, see [`ServeArgs::anonymous`].
#[cfg(feature = "server")]
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub enum AnonymousArg {
/// Serve anonymous reads of public repositories on this box.
Allow,
/// Refuse them, naming `--browse-host` when given.
Refuse,
}
/// `gunnar serve --role`.
#[cfg(feature = "server")]
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub enum ServeRole {
/// Serves git; frames every transaction for its twin.
Primary,
/// Serves no git; holds the primary's log.
Twin,
}
/// `gunnar serve`.
#[cfg(feature = "server")]
#[derive(Debug, clap::Args, Clone)]
pub struct ServeArgs {
/// Where the SSH listener binds. Publickey auth, so binding publicly is the
/// intended configuration.
#[arg(long, default_value = "0.0.0.0:2222", value_name = "ADDR")]
pub ssh_bind: std::net::SocketAddr,
/// Where the gRPC control plane binds — `<addr>:<port>`, or
/// `<interface>:<port>` for an address learned at boot.
///
/// **Loopback by default and it should stay there.** gRPC now requires an
/// `sshsig` credential from a key in gunnar's identity table and a grant from
/// the same ACL as SSH (the allow-all hole was closed 2026-08-03), but the
/// loopback default is defence in depth and costs nothing: every real
/// deployment reaches this port over an SSH tunnel or from the same host
/// anyway.
///
/// **`0.0.0.0` is not the way to reach it from another host.** A cloud
/// appliance has a PUBLIC interface and a private one, and a wildcard
/// binds both — which is how this control plane ended up answering
/// `repo list` and `repo create` to an uncredentialled caller on the open
/// internet (measured 2026-08-31). Name the private interface instead:
/// `--grpc-bind eth1:50051`. Its address is not knowable when the medium
/// is mastered — DHCP decides it, per box, per lease — and the interface
/// is. See [`gunnar_common::bindspec::BindSpec`].
#[arg(
long,
default_value = "127.0.0.1:50051",
value_name = "ADDR|IFACE:PORT"
)]
pub grpc_bind: crate::bindspec::BindSpec,
/// An OpenSSH public key line, or `@<path>` to read one from a file, to
/// install as the bootstrap admin when the identity table is empty.
///
/// Without it, an empty table makes gunnar **refuse to serve** rather than
/// start unreachable.
#[arg(long, value_name = "KEY|@FILE")]
pub admin_key: Option<String>,
/// **A key this box may take a `Bootstrap.Restore` from**: an OpenSSH
/// public key line, or `@<path>` to read one. Repeat the flag for more;
/// `GUNNAR_RESTORE_PUBKEY` takes several separated by `,`.
///
/// A restore stream decides a fresh box's KEK, its admin, its host key and
/// its issuer, and a fresh box has no identity table to check its sender
/// against, so it takes one only under a credential signed by a key it was
/// FED: this flag, or — on an installer-fed box — the fed `admin_pubkey`
/// and admin principals. A box with neither refuses every restore by name
/// (`restore-no-key`).
#[arg(
long = "restore-pubkey",
value_name = "KEY|@FILE",
env = "GUNNAR_RESTORE_PUBKEY",
value_delimiter = ','
)]
pub restore_pubkeys: Vec<String>,
/// Do not start the gRPC listener. The SSH listener still runs.
#[arg(long)]
pub no_grpc: bool,
/// **WireGuard-only** (owner, 2026-10-04): every box-to-box connection
/// runs over the estate's overlay. The control plane's bound address (the
/// utility network, `eth1:50051`) serves nothing; the control plane and the
/// replication listener answer only on the tunnel's address, and SSH drops
/// a connection that arrives at the utility address. A plain-IP connection
/// between two boxes is refused.
#[arg(long = "wg-only", env = "GUNNAR_WG_ONLY")]
pub wg_only: bool,
/// **What this box is.** `primary` (the default) serves git and frames
/// every transaction. `twin` serves NO git from its first boot (the forge
/// listener is bound to loopback only), waits for `Bootstrap.BecomeTwin`,
/// and then follows its primary's log (`.nornir/twin-transaction-log.md`).
/// **A twin is not a different medium.** Until 2026-09-17 a `gunnar-twin`
/// ISO welded `--role twin` into its `korp.args=`, which made the role part
/// of a medium's identity and — because that medium was cloned from the
/// KEYLESS row — made gunnar.rs's twin an UNMETERED binary that would have
/// served every suspended tenant the moment it was promoted. Since 2026-09-22
/// the medium does not say what a box is at all (no `role` install parameter,
/// no `GUNNAR_ROLE` env read): every box installs as a fresh appliance and a
/// twin is made one over the wire by `Bootstrap.BecomeTwin`, which writes
/// `twin.json` — the durable role `stores.is_twin()` reads. This argv flag
/// stays for a directly started server (tests, benches); an installed box
/// never passes it.
#[arg(long, value_enum, default_value_t = ServeRole::Primary)]
pub role: ServeRole,
/// Refuse unauthenticated callers entirely, even for repositories marked
/// `--public`.
///
/// By default a public repository can be cloned with no key at all, over
/// `ssh://anonymous@host/ns/repo.git`. This closes that door at the
/// listener, before any repository is resolved, for an appliance that has
/// to be able to state it has never served a stranger. It is **not** what
/// protects private repositories: those are protected by the ACL and are
/// unreachable anonymously either way.
#[arg(long)]
pub no_anonymous: bool,
/// **Where anonymous reads are served** — the browse replica. `allow`
/// (the default) serves a stranger's clone of a public repository here.
/// `refuse` sends every anonymous request away with a line that names the
/// browse replica (`--browse-host`) it is served by: strangers cost CPU and
/// bandwidth on the box that also takes the authenticated writes, and the
/// browse replica is where that cost belongs. Unlike `--no-anonymous`, which
/// merely fails the stranger's authentication, `refuse` tells them where
/// to go. A box whose role is BROWSE serves anonymous reads whatever this
/// says — it is the box the knob points at.
#[arg(long, value_enum, default_value_t = AnonymousArg::Allow, env = "GUNNAR_ANONYMOUS")]
pub anonymous: AnonymousArg,
/// The browse replica's `host[:port]`, named in the `--anonymous refuse`
/// line (`clone <repo> from <host>`). Optional: without it the refusal
/// says only that anonymous reads are not served here.
#[arg(long, value_name = "HOST[:PORT]", env = "GUNNAR_BROWSE_HOST")]
pub browse_host: Option<String>,
/// **How much of the store root's disk must stay free** before a push or
/// a `Repos.Create` is refused — the community plan gate, the "free lunch"
/// (ruled 2026-09-04). Terms separated by commas: a percentage (`5%`)
/// and/or a byte count (`2GiB`, `500M`, `4096`); the LARGER of the two is
/// the floor. The default keeps 5 % or 2 GiB, whichever is more: 5 % is the
/// reserve the box needs to keep operating (gc scratch, control-store
/// commits, `replication.json` rewrites) and mirrors ext4's own reserved
/// blocks; 2 GiB is the absolute floor for a small disk where 5 % is less
/// than one pack. A push under the floor is refused BEFORE any pack byte,
/// naming the free bytes and the floor; reads are never refused. Measured
/// with `statvfs`, no `df`. `0%,0` is refused by name: a gate that can never
/// refuse is not a gate.
#[arg(
long,
value_name = "SPEC",
default_value = "5%,2GiB",
env = "GUNNAR_DISK_FLOOR"
)]
pub disk_floor: String,
/// **Do not enumerate a push's object ids for the attestation.**
///
/// Enumeration waits for the indexer to absorb the pack, which is a drain
/// on the push path, so an operator who does not need the ids in the record
/// can decline them. Declining does NOT restore silence: the attestation
/// then carries the reason the set is absent
/// (`PushAttestation.new_object_oids_unknown`), because an absence that
/// renders as an empty list is the defect this flag exists beside, not the
/// behaviour it restores.
///
/// Named in the negative on purpose: the complete attestation is what you
/// get by forgetting, and the cheap one is what you opt into. A push that a
/// policy gate reads is enumerated either way — the gate needs the set.
#[arg(long)]
pub no_attest_objects: bool,
/// How many git transfers may be in flight across the whole server (the
/// admission gate). Default: DERIVED from the box — cores x 16, capped by
/// the descriptors the handle cache has not spoken for, floor 8, ceiling
/// 256.
///
/// The server says what it derived, and what from, in one INFO row at
/// start: `transfers 64 · open repos 204 · git memory 3.9G (cores 4,
/// fds 1048576, MemAvailable 7.8G)`. Setting this replaces the derivation
/// exactly — the number is never raised and never lowered.
#[arg(long, value_name = "N", env = "GUNNAR_MAX_TRANSFERS")]
pub max_concurrent_transfers: Option<usize>,
/// The memory budget for git operations in flight, in bytes — the
/// admission gate's second budget. Default: half of `MemAvailable` at
/// start.
///
/// The ledger charges each transfer a small entry cost and then what its
/// pack actually moves, so this is a budget rather than a second
/// concurrency cap. It used to be a compiled-in 8 GiB against a flat
/// 320 MiB per push, which made "25 concurrent pushes" the real ceiling on
/// every box regardless of its RAM.
#[arg(long, value_name = "BYTES", env = "GUNNAR_GIT_MEMORY_BYTES")]
pub git_memory_bytes: Option<u64>,
/// How long a transfer may WAIT for a slot before it is refused, in
/// milliseconds. Default 2000; `0` refuses on the spot.
///
/// A refused client reconnects, and a reconnect is a whole SSH handshake —
/// more than the wait costs. The wait is bounded, counted, and is what the
/// delay governor is driven by.
#[arg(long, value_name = "MS", env = "GUNNAR_ADMISSION_WAIT_MS")]
pub admission_wait_ms: Option<u64>,
/// Close a git transfer that moves NO bytes either way for this many seconds while
/// it waits on the client, and give its slot back. Default 180; `0` turns it off.
///
/// The connection's own timeouts never catch it: russh resets the inactivity timer
/// on every keepalive reply, so a live client that sends nothing (a stuck
/// `git-lfs-transfer`) held its slot for good — 35 minutes of a key's 32 on
/// git.gunnar.rs, 2026-11-01. Server-side work (building a pack) is never timed.
#[arg(long, value_name = "SECS", env = "GUNNAR_TRANSFER_IDLE_SECS")]
pub transfer_idle_secs: Option<u64>,
/// What one identity is **guaranteed** at once, and its share of a
/// contended server. Default 32.
///
/// A floor, not a wall: on a server that is not near saturation a single
/// identity may use every slot `--max-concurrent-transfers` allows. Once
/// the server is contended each active identity gets an equal share of it,
/// never below this number.
#[arg(long, value_name = "N")]
pub max_transfers_per_principal: Option<usize>,
/// How many git reads are served **without a key** at once.
/// Default 5.
///
/// The companion to `--anonymous`: that decides whether the door is open,
/// this decides how wide. Unlike `--max-transfers-per-principal` it is a
/// wall and not a floor — it binds on an idle server, because the caller it
/// bounds cannot be named, revoked or telephoned, and "the box was quiet so
/// I let a stranger have all of it" is not a trade an operator would make
/// deliberately.
///
/// Raise it on a box whose *purpose* is public serving (a browse replica);
/// `0` refuses every anonymous read while leaving the door itself open,
/// which is the arm to use when you want the refusal to say so rather than
/// have `--anonymous refuse` deny the repository exists.
#[arg(long, value_name = "N", env = "GUNNAR_MAX_ANONYMOUS_TRANSFERS")]
pub max_anonymous_transfers: Option<usize>,
/// **The usage share's budget**: bytes (in + out) a namespace may move per
/// window at the ordinary per-principal share. Past it the namespace is
/// served at the ANONYMOUS ceiling (`--max-anonymous-transfers`) and
/// refused fast with its own reason — never queued. `none` turns the
/// share off (PassThrough: admission decides as a build without it).
/// SI or IEC sizes: `100 GB`, `5 GiB`, or bytes. Default 100 GB.
///
/// Only in a build with the `admit-share` feature.
#[cfg(feature = "admit-share")]
#[arg(long, value_name = "SIZE|none", env = "GUNNAR_ADMIT_LIMIT")]
pub admit_limit: Option<String>,
/// The usage share's rolling window, in UTC days, today included.
/// Default 30.
///
/// Only in a build with the `admit-share` feature.
#[cfg(feature = "admit-share")]
#[arg(long, value_name = "DAYS", env = "GUNNAR_ADMIT_WINDOW_DAYS")]
pub admit_window_days: Option<u32>,
/// The largest object `Refs.ReadObject` will read, in bytes.
/// Default 67108864 (64 MiB); `0` means no ceiling.
///
/// Reading any part of an object costs the WHOLE object in memory — a
/// prefix of a delta-encoded object needs its entire base chain — so
/// `max_bytes` bounds the answer and not the work. This bounds the work.
/// The default is a 512 MiB budget divided by the 8 control-plane workers
/// that can be inflating at once; raise it on a box whose repositories hold
/// large blobs and whose control port is trusted, and note that the git
/// clone path is untouched by this and streams either way.
#[arg(long, value_name = "BYTES")]
pub max_object_bytes: Option<u64>,
/// **The metered appliance's key**: monetize-server's Ed25519 public key,
/// either spelled inline as its 64 hexadecimal characters or as the path of
/// a file holding it (32 raw bytes, 64 hex characters, or
/// `ed25519:<base64>`). Every `Entitlement.Set` is verified against it
/// before a plan moves. Only a `metered` build honours it; any other build
/// REFUSES to start when it is set, by name — an operator who configured a
/// metered appliance on a binary that cannot meter must find out at boot,
/// not from a tenant who was never suspended.
///
/// **This flag is NOT compiled out of the open build, and that is the
/// point.** It is the one piece of the monetize seam an open gunnar must
/// still RECOGNISE, because a flag clap has never heard of is refused as
/// "unexpected argument" — a parser complaint that says nothing about why
/// this binary cannot do what was asked. Kept, the open build answers with
/// the sentence in `gunnar_server::dispatch` instead: this build contains
/// no entitlement code at all and would serve every namespace Free.
///
/// **Which of the two it is, is decided by SHAPE — there is no second
/// flag.** A value with no `/` in it that is either all hexadecimal or
/// exactly 64 characters long is the KEY; anything else is a PATH. So
/// `/data/monetize.pub` and `monetize.pub` are files, as they always were,
/// and `43cb…8428` is the key itself. A near-miss (63 characters, or a
/// typo that is not a hex digit) is refused as a malformed key naming what
/// was wrong, rather than silently becoming a filename nothing will find.
///
/// Inline is what an appliance actually needs. The installer only stages
/// files onto `/data` under `korp.reinstall=wipe`, the ESP is read-only at
/// runtime, and so on a keep-upgrade the kernel command line is the only
/// writable channel into a booting gunnar — a path there names a file
/// nothing was allowed to create. An Ed25519 *public* key is safe to carry
/// that way; a private key never would be.
#[arg(long, value_name = "HEX_OR_PATH", env = "GUNNAR_MONETIZE_PUBKEY")]
pub monetize_pubkey: Option<crate::monetize_key::MonetizePubkey>,
// ── WHY THIS ONE IS COMPILED OUT AND `--monetize-pubkey` IS NOT ────────
//
// Kept as a plain comment rather than a doc comment ON PURPOSE: clap turns
// `///` into `--help`, and the explanation below is for whoever reads this
// file, not for an operator of the open build — writing it as documentation
// would reintroduce, in prose, exactly the vocabulary this gate removes.
//
// gunnar is the OPEN half of the split: it reports `Usage`, it receives a
// signed `Entitlement`, and it takes no money ever. The private monetize
// half is the only thing that knows what a byte is worth. So an open
// `gunnar-server --help` must not advertise a flag whose whole noun is a
// payment page — and until this `cfg`, it did, for a field the open binary
// has no code to honour.
//
// `--monetize-pubkey` three declarations up is deliberately NOT gated, and
// the asymmetry is the argument, not an oversight. That flag's PRESENCE
// changes what the box would do — meter, or silently serve every namespace
// Free — so an open build has to recognise it in order to refuse it by
// name at boot. This flag cannot even be given without it: `requires`
// sees to that, so an open build handed both stops on the pubkey's
// sentence either way, and one handed this alone was already going to be
// refused. Compiling it out therefore costs no refusal that the pubkey's
// own does not already carry, and buys a `--help` with no money in it.
//
// And there is NO DEFAULT, here or at the use site. The fallback used to
// live in `gunnar_server::dispatch` as Vetra's own commercial URL, which
// meant anybody building a metered gunnar and forgetting this flag pointed
// their tenants at somebody else's payment page. A metered build given
// `--monetize-pubkey` without this now refuses at boot; see
// `gunnar_server::entitlement::MeteredConfig::load`.
/// **Where a lapsed tenant goes to restore its plan**, as a template with
/// `{tenant}` for the namespace (without the placeholder, `/{tenant}` is
/// appended, so the link always names the tenant). Printed in every
/// refusal and Grace warning. Requires `--monetize-pubkey`, and a
/// `metered` build that is given that flag without this one refuses to
/// start: a page belonging to whoever built the binary is not a default
/// anybody else's tenants should be sent to.
#[cfg(feature = "metered")]
//
// No clap `requires = "monetize_pubkey"` (2026-09-24): the key may come from the
// installer's door (`gunnar_server::feed`) instead of this argv, so a one-medium
// metered box carries the pay page and no key. The same refusal — this flag with
// no key from EITHER source — is made in `gunnar_server::dispatch`.
#[arg(long, value_name = "URL", env = "GUNNAR_BILLING_URL")]
pub billing_url: Option<String>,
/// **Is monetize VITAL to this box?** Owner ruling 2026-09-22. `true`, the default: stored
/// entitlement facts that do not verify at boot (a main restored under another monetize key)
/// raise the `Admin.Health` alarm `entitlement.key_mismatch`, naming the namespaces, and the
/// box reports status `maintenance` until monetize pushes a fact that verifies. The box
/// still starts and serves either way, and gunnar never dials monetize. `false`: the same
/// refusal is only logged.
#[cfg(feature = "metered")]
#[arg(long, value_name = "BOOL", env = "GUNNAR_MONETIZE_VITAL", default_value_t = true, action = clap::ArgAction::Set)]
pub monetize_vital: bool,
/// Make `--max-transfers-per-principal` an unconditional ceiling.
///
/// For a host where an identity is a *tenant* and the cap is how the
/// box is shared out. On an appliance — one team, a handful of keys — this only
/// throttles the owner, which is why it is off by default.
#[arg(long)]
pub hard_per_principal_limit: bool,
/// Serve the **counters** on this address, as Prometheus text.
///
/// Off unless asked for, and there is no default address: nothing binds
/// until you name one. `curl http://<addr>/metrics` then returns the same
/// nineteen numbers the `gunnar::metrics` heartbeat writes to the log —
/// but on demand, at whatever rate you ask, which is the only way
/// `gunnar_transfers_in_flight` tells a slow transfer from a stuck one
/// while it is still running.
///
/// **It has no authentication.** The body is nineteen integers about this
/// process — no repository names, no principals, no references — so it is
/// not worth a credential, but anyone who can reach the port learns how
/// busy this server is. Bind it to `127.0.0.1` and let the scraper come
/// over a tunnel unless you mean otherwise; a non-loopback address is
/// accepted and logged as a `warn` at start-up.
#[arg(long, value_name = "ADDR")]
pub metrics_bind: Option<std::net::SocketAddr>,
/// **Watch this primary and TAKE OVER if it stops answering** — I11's
/// passive heartbeat, the standby half of failover.
///
/// Off unless you name an address, and that is the design rather than
/// caution: naming a new primary is a decision, so a box never makes it
/// because nobody configured it not to. Give the PRIMARY's control-plane
/// address; this box probes it every `--watch-probe-ms` and, after
/// `--watch-promote-after` **consecutive** failures, does to itself exactly
/// what `gunnar control replication promote` does — persists `role:
/// primary` and thaws the write freeze. One reachable probe resets the run,
/// so a flapping link cannot add up to a takeover.
///
/// It does nothing on a box whose role is `primary`: the watch re-reads the
/// role every tick and stops when this box is not a standby, so putting the
/// flag on BOTH boxes of a pair is safe and is what one unit file wants.
///
/// **What the probe proves:** a TCP connect to that address, i.e. *"is
/// something listening"* — not *"is it serving git"*. A primary that is
/// wedged but still listening reads as alive and this box will NOT promote.
/// That is the safe direction to be wrong in: the cost is a manual
/// `replication promote`, where the other way round is two writable boxes.
///
/// **The address still has to move.** Taking over makes this box writable;
/// it does not repoint the front (DNS / the UpCloud DNAT). That is the one
/// infrastructure step failover has always had.
#[arg(long, value_name = "ADDR")]
pub watch_primary: Option<std::net::SocketAddr>,
/// Milliseconds between probes of `--watch-primary`. Ignored without it.
#[arg(long, value_name = "MS", default_value_t = 5_000)]
pub watch_probe_ms: u64,
/// Consecutive failed probes before this box promotes itself. Ignored
/// without `--watch-primary`; a zero is read as one.
///
/// The default is six, which at the default interval is **thirty seconds of
/// continuous unreachability** — longer than a restart of the primary, and
/// shorter than a coffee break.
#[arg(long, value_name = "N", default_value_t = 6)]
pub watch_promote_after: u32,
/// Milliseconds one connect attempt may take before it counts as a failure.
/// Ignored without `--watch-primary`.
///
/// Kept below `--watch-probe-ms` deliberately: a connect that outlives the
/// interval lets probes overlap, and one dead primary becomes a growing
/// pile of pending sockets.
#[arg(long, value_name = "MS", default_value_t = 2_000)]
pub watch_connect_ms: u64,
/// **Auto-fix** (5.4 resilience): when this box comes up with its data set
/// dead or empty while a healthy twin exists, recharge it from the twin by
/// itself. OFF by default: without it the box only SAYS `data lost; recharge
/// from twin available`, and an admin's [Recharge from twin] or a signed
/// guardian order does it. The estate's opt-in, like `--watch-primary`.
#[arg(long, env = "GUNNAR_REPAIR_AUTO")]
pub repair_auto: bool,
/// The OpenSSH private key this box signs repair calls with (reading the
/// twin's log, `Bootstrap.Restore` into a fresh main, fencing a primary).
/// Default: this box's twin key, when it has one. A fresh main takes a
/// restore only from a key it was fed (`--restore-pubkey`).
#[arg(long, value_name = "PATH", env = "GUNNAR_REPAIR_KEY")]
pub repair_key: Option<std::path::PathBuf>,
/// The twin the boot check reviews and recharges from (`host:port` of its
/// control plane). Default: the twin the installer's re-admit named; a main
/// that grows also asks the twin in the door's peer list.
#[arg(long, value_name = "ADDR", env = "GUNNAR_REPAIR_TWIN")]
pub repair_twin: Option<String>,
/// **The pull channel**: monetize's `Fleet` endpoint this box reports to and
/// pulls signed repair orders from (`https://…`). Off when absent. Monetize
/// never calls into the box; every order is verified under the monetize key.
#[arg(long, value_name = "URL", env = "GUNNAR_FLEET")]
pub fleet: Option<String>,
/// PEM of the CA monetize's `Fleet` certificate is signed by.
#[arg(long, value_name = "PATH", env = "GUNNAR_FLEET_CA")]
pub fleet_ca: Option<std::path::PathBuf>,
/// The estate (monetize instance) this box belongs to. Required with `--fleet`.
#[arg(long, value_name = "NAME", env = "GUNNAR_FLEET_INSTANCE")]
pub fleet_instance: Option<String>,
/// The role this box pulls as (`main` / `twin`). Default: what the box is.
#[arg(long, value_name = "ROLE", env = "GUNNAR_FLEET_ROLE")]
pub fleet_role: Option<String>,
/// The longest pause between two reports, in seconds.
#[arg(long, value_name = "S", default_value_t = 60)]
pub fleet_every_s: u64,
/// Also serve **smart HTTP** on this address.
///
/// Off unless asked for. `git clone http://host/ns/repo.git` then works,
/// and so does `push`, with an access token as the HTTP Basic password
/// (`gunnar token add`). The same repositories, the same grants and the
/// same `--no-anonymous` switch as SSH: this is a second transport, not a
/// second server.
#[cfg(feature = "http")]
#[arg(long, value_name = "ADDR")]
pub http_bind: Option<std::net::SocketAddr>,
/// Accept an access token over a **plain** HTTP connection.
///
/// gunnar terminates no TLS. By default it therefore refuses to read a
/// credential unless a terminator in front of it says the client spoke TLS
/// (`X-Forwarded-Proto: https`), because a token in an `Authorization`
/// header on a cleartext connection is a password on the wire. This turns
/// that off, for loopback, a test, or a link you vouch for.
///
/// **Anonymous read is unaffected either way**: it presents no credential.
#[cfg(feature = "http")]
#[arg(long)]
pub http_allow_cleartext_credentials: bool,
/// Terminate **TLS on the smart-HTTP listener itself**, with a certificate
/// gunnar mints from its own issuer at start — the single-executable
/// deployment: no proxy, no PEM files, `git clone https://host/ns/repo.git`
/// against one binary. Clients pin the issuer (`gunnar issuer show --pem`)
/// exactly as they do for the client certificates it already mints.
///
/// ALPN offers HTTP/2 then HTTP/1.1, so a client that multiplexes gets to.
/// Mutually exclusive with `--http-tls-cert`/`--http-tls-key`, which is the
/// bring-your-own-certificate form of the same listener.
#[cfg(feature = "http")]
#[arg(long, conflicts_with_all = ["http_tls_cert", "http_tls_key"])]
pub http_tls_self: bool,
/// Terminate TLS on the smart-HTTP listener with this certificate chain
/// (PEM, leaf first). Needs `--http-tls-key`.
#[cfg(feature = "http")]
#[arg(long, value_name = "PEM", requires = "http_tls_key")]
pub http_tls_cert: Option<std::path::PathBuf>,
/// The private key for `--http-tls-cert` (PEM).
#[cfg(feature = "http")]
#[arg(long, value_name = "PEM", requires = "http_tls_cert")]
pub http_tls_key: Option<std::path::PathBuf>,
/// Require a **client certificate** on the gRPC control plane, and mint the
/// listener's own identity from this server's issuer at start.
///
/// This is what makes the control plane safe to bind off loopback. Two
/// things change together: a peer whose certificate this issuer did not
/// sign never finishes a handshake, and a certificate that does get through
/// is looked up in the identity key table — so one the issuer signed and nobody
/// registered is still refused, by the gate, with an audit record.
///
/// Clients get their certificate from `gunnar control bootstrap` (which
/// writes `<name>.cert.pem`, `<name>.key.pem` and `gunnar-issuer.pem` to
/// `~/.config/gunnar/<server>/`, or `--out`) and
/// present it with `--client-cert`/`--client-key`/`--ca`.
///
/// Off by default: turning it on rewrites how every existing client
/// connects, and a default that breaks a working deployment gets turned
/// back off everywhere.
///
/// **It cannot be the first thing a server does.** A mutual listener
/// refuses every peer with no certificate, and on a server that has never
/// been enrolled there is no certificate anywhere — including for
/// `Bootstrap.Enroll`, the one RPC served without a credential, which is
/// behind the same handshake. `gunnar serve` REFUSES to start in that
/// state and names the enrolment steps. Every other unhappy state is a
/// warning naming the principals that lose access; run
/// `gunnar issuer readiness` first to see them before the restart, and
/// `gunnar control health` afterwards to confirm the flip took.
#[arg(long)]
pub grpc_tls_self: bool,
/// **The mutual replication listener a twin follows this box through.**
///
/// Mutual TLS 1.3 only: a peer must present a certificate this server's
/// issuer signed (`gunnar control twin certify` gets a twin one). Unset, it is
/// DERIVED — the `--grpc-bind` address, port + 1, whenever that address is
/// off loopback — so the appliance serves it with no flag on its medium, and
/// a loopback control plane serves none.
#[arg(long, value_name = "ADDR")]
pub grpc_replication_bind: Option<std::net::SocketAddr>,
/// **Accepted and ignored since 5.3.3.** The method-blind
/// `gunnar-sshsig-v1` credential is refused on every listener, always
/// (GUN-001; OWNER 2026-10-10), so there is nothing left for this to turn
/// on — and nothing that turns it off. Kept, hidden, only so a unit file
/// that still passes it starts instead of failing to parse.
#[arg(long, hide = true)]
pub grpc_require_bound_credentials: bool,
/// **A WebAuthn relying party this server answers for**:
/// `<rp_id>=<origin>[,<origin>…]`, repeatable (stinky row 13, 2026-10-10).
///
/// Without one, a passkey ceremony's `rp_id` and `origin` are the relay's
/// word, as before. With any, a ceremony for another relying party, or from
/// an origin not listed, is refused — so a relay cannot run one for a
/// relying party it merely names.
#[arg(long = "web-relying-party", value_name = "RP_ID=ORIGIN[,ORIGIN]")]
pub web_relying_party: Vec<String>,
/// **Every passkey ceremony must verify the user** (a PIN or a biometric,
/// not a touch): web logins and the passkey after a company sign-in alike.
/// The server decides; a relay may ask for more, never for less (A34-12).
/// Off: a touch is enough unless the relay asks.
#[arg(long = "web-require-user-verification")]
pub web_require_user_verification: bool,
/// **Anybody may sign up on this site** (IDENTITY-HOST): the identity core's Root
/// site, where anybody may sign up; without it the site is Private (invite
/// only), the default.
#[arg(long = "identity-root-site")]
pub identity_root_site: bool,
/// Do not offer `filter`, so this server serves no partial clones.
///
/// Removes `filter` from the advertisement **and** makes the parser refuse
/// the argument, in both protocol versions, so a client cannot get a
/// filtered pack by asking for one anyway. `git clone --filter=blob:none`
/// then fails with *"this server does not offer the filter capability"*
/// rather than quietly serving the whole thing.
///
/// For an appliance that has to be able to state it never serves arbitrary
/// blob subsets. Everything else about a clone is unchanged.
#[arg(long)]
pub no_filter: bool,
/// Which object ids a client may `want`. Default `reachable`.
///
/// `advertised-tips` is the strictest and **breaks partial clone**: a
/// `blob:none` clone's lazy fetch asks for bare blob ids that were never
/// advertised, so every `git checkout` in one becomes "the remote end hung
/// up unexpectedly". Pair it with `--no-filter` or not at all. `any` makes
/// every object ever pushed fetchable by whoever can name it, including on
/// deleted branches.
#[arg(long, value_name = "POLICY", value_enum)]
pub want_policy: Option<WantPolicyArg>,
/// Ceiling on **objects emitted** while answering one fetch.
///
/// An operator's explicit cap on the size of a clone. It is no longer what
/// bounds gunnar's memory — `--max-fetch-walk-bytes` is, in the unit a
/// machine is actually sized in — and the reason is that an object count
/// does not convert to bytes at a fixed rate: MEASURED across gunnar's
/// corpus on 2026-08-05 the same walk cost between 393.8 and 1 572.9 bytes
/// per emitted object, a 4x spread set by the repository's shape.
///
/// There is no "off" value. `0` would refuse every fetch rather than allow
/// every fetch — the walk stops when it reaches the budget — so it is
/// rejected at startup instead of being accepted as an unbounded server
/// that in fact serves nothing.
#[arg(long, value_name = "N")]
pub fetch_budget: Option<usize>,
/// Bytes of object bookkeeping **one fetch** may hold. `0` removes the
/// ceiling. Default 8589934592 (8 GiB).
///
/// Charged at a measured 384 bytes per object the walk is holding, so the
/// default admits about 22.4 million objects. That is deliberately past the
/// linux kernel's 13 816 503: MEASURED 2026-08-05, a 14 042 000-object
/// repository with no `.bitmap` clones in 5 460 816 kB of peak RSS and 64
/// seconds, and the object-counted ceiling it shipped with refused it.
///
/// This is a **per-request** number and on its own it is not a bound on the
/// server: multiply it by `--max-concurrent-transfers`. See
/// `--max-walk-bytes-total`.
#[arg(long, value_name = "BYTES")]
pub max_fetch_walk_bytes: Option<usize>,
/// Bytes of object bookkeeping **the whole server** may hold at once,
/// shared by every transfer in flight. `0` removes the ceiling. Defaults to
/// half of this machine's `MemTotal`.
///
/// The ceiling that actually bounds the box. `--max-concurrent-transfers`
/// counts transfers, which says nothing about memory: 256 concurrent clones
/// of a kernel-sized repository need well over a terabyte between them, and
/// what the shipped server did about that was let the OOM killer choose.
/// A transfer that cannot get its reservation is refused with a sentence
/// naming both numbers, and the client can retry.
///
/// A walk holding under 8 MiB never touches the pool, so ordinary clones
/// neither queue nor draw on it.
#[arg(long, value_name = "BYTES")]
pub max_walk_bytes_total: Option<usize>,
/// Bytes of pkt-line payload held for one peer-supplied request. `0`
/// removes the ceiling. Default 8388608 (8 MiB).
///
/// The size is the **peer's** to choose and costs it about what it costs
/// the server, which is why this is an absolute number.
#[arg(long, value_name = "BYTES")]
pub max_fetch_request_bytes: Option<usize>,
/// Bytes of a single object gunnar will put in a pack. `0` removes the
/// ceiling. Default 1073741824 (1 GiB), the same number `gunnar-pack`
/// applies on the way in.
#[arg(long, value_name = "BYTES")]
pub max_fetch_object_bytes: Option<usize>,
/// Largest LFS object this server will store, in bytes. Default
/// 8589934592 (8 GiB).
///
/// **There is no "off" value**, and `0` is refused at startup. Unlike the
/// two fetch ceilings above, `gunnar_lfs::Limits` holds a plain `u64` and
/// not an `Option`, so `0` means "refuse every upload" rather than "accept
/// any size". A finite default is deliberate: "no limit" means one client
/// decides how much disk the server has.
#[arg(long, value_name = "BYTES")]
pub max_lfs_object_bytes: Option<u64>,
/// Most objects one LFS `batch` request may name. Default 10000. `0` is
/// refused, for the same reason as above.
#[arg(long, value_name = "N")]
pub max_lfs_batch_objects: Option<usize>,
/// Bytes of a refused LFS upload that will be read and discarded to keep
/// the channel in sync before the session is dropped. Default 131056
/// (two pkt-line payloads). `0` is refused.
///
/// A refusal cannot simply stop reading: the client is already writing the
/// object into the channel and the framing only ends at its flush-pkt, so a
/// server that answered 413 and then read the next *command* would read
/// object bytes instead.
#[arg(long, value_name = "BYTES")]
pub max_lfs_drain_bytes: Option<u64>,
/// Bind, report, and exit rather than serving. For smoke tests and for
/// checking a configuration without holding the ports.
#[arg(long)]
pub check: bool,
}
/// `--want-policy`, one arm per [`gunnar_wire::WantPolicy`].
#[cfg(feature = "server")]
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub enum WantPolicyArg {
/// Only ids that appeared in the advertisement. Breaks partial clone.
AdvertisedTips,
/// Anything reachable from a reference. The default.
Reachable,
/// Anything in the store, reachable or not.
Any,
}
#[cfg(feature = "server")]
impl WantPolicyArg {
/// The wire's own enum. One translation, in one place, so the flag and the
/// behaviour cannot drift (one implementation).
pub fn to_wire(self) -> gunnar_wire::WantPolicy {
match self {
WantPolicyArg::AdvertisedTips => gunnar_wire::WantPolicy::AdvertisedTips,
WantPolicyArg::Reachable => gunnar_wire::WantPolicy::ReachableFromRefs,
WantPolicyArg::Any => gunnar_wire::WantPolicy::Any,
}
}
}
/// Namespace verbs.
#[cfg(feature = "server")]
#[derive(Debug, Subcommand)]
pub enum NamespaceCommand {
/// Create a namespace. Nested names (`team/sub`) are legal; grants do NOT
/// inherit between them.
Create {
/// The namespace name.
name: String,
},
/// List namespaces.
List,
/// Delete a namespace.
Delete {
/// The namespace name.
name: String,
/// Also delete every repository in it. Never implicit.
#[arg(long)]
delete_repos: bool,
},
/// Set this account's read-cache budget.
#[command(alias = "set-quota")]
SetBudget {
/// The namespace name.
name: String,
/// **The exploded read cache each of this account's repositories may
/// hold**: `8G`, `512M`, `unlimited`, or `default` to go back to the
/// server's own (10 % of /data, capped at 8 GiB).
///
/// A bigger cache means more objects are already inflated and reads
/// are fast; a smaller one means more reads re-inflate their own delta
/// chain out of the pack, ~0.5–2 ms an object. It is never a refusal
/// and never an error: an account at its budget serves everything it
/// has, correctly, more slowly. Nothing it bounds can be lost — every
/// byte of it is re-derivable from the packs, which it does not bound.
#[arg(long, value_name = "BYTES")]
explode_budget: Option<String>,
},
}
/// Repository verbs.
#[cfg(feature = "server")]
#[derive(Debug, Subcommand)]
pub enum RepoCommand {
/// Create a repository. Its namespace must already exist.
Create {
/// `<namespace>/<name>`, with or without a `.git` suffix.
path: String,
/// sha256 is the default for new repositories.
#[arg(long, value_enum, default_value_t = HashArg::Sha256)]
hash: HashArg,
/// The default branch, as a short name or a full ref.
#[arg(long, value_name = "BRANCH")]
default_branch: Option<String>,
/// **Readable by anyone, with no key**: `git clone
/// ssh://anonymous@host/ns/repo.git` works for a stranger. Writing
/// still needs a grant. Private is the default.
#[arg(long)]
public: bool,
/// A one-line description.
#[arg(long, default_value = "")]
description: String,
/// **Take over a store already on disk at this name** instead of
/// refusing it. Only for a matching hash (the store's `OBJECT_FORMAT`
/// marker). Without this a create never adopts a directory it did not
/// make: that is how a deleted repository came back (2026-08-30).
#[arg(long)]
adopt: bool,
},
/// List repositories.
List {
/// Only this namespace.
#[arg(long, value_name = "NS")]
namespace: Option<String>,
},
/// **Delete a repository. Without `--purge` this is a DATED TOMBSTONE, not
/// a reclaim.**
///
/// The catalog row goes, so the name stops working and nothing can be
/// pushed to it — but the bytes stay, for `--retention-days` (30 by
/// default), and `repo undelete <path>` puts it back exactly as it was.
/// A background sweep reclaims them when the window closes.
///
/// Why the window exists: `--purge` returns the bytes in about two seconds
/// — 181 GiB to 33 MiB, measured — and a live mirror does not help,
/// because the delete replicates to the standby too. Deleting the wrong
/// repository is the mistake that will actually happen.
///
/// **A tombstone still costs storage.** Its bytes are on the account until
/// the window closes; `repo list-deleted` and `Admin.StorageInfo` report
/// them, so the space a tombstone holds is never a surprise.
Delete {
/// `<namespace>/<name>`.
path: String,
/// **Reclaim the bytes NOW**, with no window and no undo.
#[arg(long)]
purge: bool,
/// How long the bytes are kept. `0` means at the next sweep — an
/// operator freeing space meant it.
#[arg(long, value_name = "DAYS")]
retention_days: Option<u32>,
},
/// **Put a soft-deleted repository back**, inside its retention window.
///
/// It comes back as it was — the recorded hash kind above all. A
/// repository recreated as sha256 over a sha1 store serves refs no client
/// can read, which is why this is a verb and not `repo create --adopt`.
Undelete {
/// `<namespace>/<name>`.
path: String,
},
/// **End a retention window early**: reclaim a soft-deleted repository's
/// bytes now. There is no undo after it.
Purge {
/// `<namespace>/<name>`.
path: String,
},
/// What is deleted and not yet purged, when the window closes, and the
/// bytes each is still costing.
ListDeleted {},
/// **Store directories with no catalog row and no soft-delete tombstone —
/// bytes nothing else on this box can see.**
///
/// `repo list` reads the catalog and an orphan is not in it. `repo
/// list-deleted`, `repo undelete`, `repo purge` and the retention sweep
/// all key off a tombstone and an orphan has none. So a directory with
/// neither was reachable by nothing at all — while `repo create` refused
/// its name *because the directory was there*.
///
/// MEASURED on the appliance 2026-08-30: ~25-39 GB standing at
/// `alice/linux`, the wreckage of a failed kernel mirror, with the name
/// permanently unusable and no verb able to touch the bytes.
///
/// With `repo list` and `repo list-deleted` this closes the set: every
/// `<name>.git` directory is in exactly one of the three.
ListOrphans {
/// Only this namespace.
#[arg(long, value_name = "NS")]
namespace: Option<String>,
},
/// **Reclaim an orphan's bytes.** There is no undo.
///
/// Refuses anything that is not an orphan by name: a catalogued repository
/// is `repo delete --purge`'s and a soft-deleted one is `repo undelete`'s
/// or `repo purge`'s. To KEEP an orphan's objects instead, run `repo
/// create <path> --adopt --hash <the OBJECT_FORMAT that list-orphans
/// reports>`.
Reclaim {
/// `<namespace>/<name>`.
path: String,
},
/// **One repository's stored bytes, out to ONE FILE.**
///
/// The file is self-describing and copyable anywhere — another appliance,
/// object storage, a laptop. It carries the verbatim pack bytes, the
/// durable extent journal and the ref log; **no index travels**, because an
/// index is a derivation and the receiver builds its own.
///
/// `--from-generation` is a WATERMARK, not a diff: the pack is append-only,
/// so everything above a byte offset is new and nothing below it changed.
/// Pass a previous run's `to_generation` for an incremental archive.
///
/// Safe while the server is running: the cut is taken at one length, and
/// every byte below it is already fsynced.
ArchiveOut {
/// `<namespace>/<name>`.
path: String,
/// Where to write the archive.
#[arg(long, value_name = "FILE")]
to: std::path::PathBuf,
/// Start above this `objects.pack` offset. 0 = a full archive.
#[arg(long, default_value_t = 0, value_name = "N")]
from_generation: u64,
},
/// **Put an archive back**, into a repository this server does not have.
///
/// Restores the bytes and then puts the catalog row back (adopting the
/// store it just wrote), so a restored repository is servable without a
/// second command. An incremental archive is applied on top of its base and
/// refuses by name if the base is not exactly what it was cut against.
ArchiveIn {
/// The archive file.
file: std::path::PathBuf,
/// `<namespace>/<name>` to restore it as. Its namespace must exist.
#[arg(long = "as", value_name = "NS/NAME")]
as_path: String,
/// Restore the bytes only; do not touch the catalog.
#[arg(long)]
no_catalog: bool,
},
}
/// `gunnar forge …` — issues, reviews and merges, as signed events (roadmap
/// phase 1).
///
/// Every verb that changes anything **signs** with the actor's own key, and the
/// event goes into an append-only log the same transparency machinery covers.
/// An approval is therefore evidence anybody can check later, without trusting
/// the server that stored it — which a row in a table saying `approved_by` is
/// not.
///
/// **This is the review half only.** The pull request itself — its number, its
/// refs, its state — is `PullRequests`' record under the control store's CAS,
/// and there is no CLI for it yet: open and merge one over gRPC.
///
/// The same events are reachable over gRPC as `Forge.*` — `Append` takes an
/// event the CALLER signed, verifies it against the identity key table and appends it,
/// because a server that could sign an approval could manufacture one. What is
/// deliberately not here yet is the console views, which live in the separate
/// `gunnar-UI` repository.
#[cfg(feature = "server")]
#[derive(Debug, Subcommand)]
pub enum ForgeCommand {
/// Comment on a pull request.
Comment {
#[arg(long, value_name = "REPO")]
repo: String,
/// The pull request number, as `PullRequests.Create` assigned it.
#[arg(long, value_name = "N")]
pull: u64,
#[arg(long, value_name = "TEXT")]
body: String,
/// The identity-table principal the event is attributed to.
#[arg(long, value_name = "PRINCIPAL")]
actor: String,
/// The private key to sign with.
#[arg(long, short = 'i', value_name = "PATH")]
identity: std::path::PathBuf,
},
/// **Approve a specific commit.**
///
/// The commit is part of what is signed, so a force-push after the review
/// leaves an approval that visibly names the commit that was reviewed — and
/// not the one that is there now. `PullRequests.Merge` refuses on exactly
/// that, so this is a gate and not a display.
Approve {
#[arg(long, value_name = "REPO")]
repo: String,
#[arg(long, value_name = "N")]
pull: u64,
#[arg(long, value_name = "OID")]
commit: String,
#[arg(long, value_name = "PRINCIPAL")]
actor: String,
#[arg(long, short = 'i', value_name = "PATH")]
identity: std::path::PathBuf,
},
/// **Withdraw an approval**, naming the commit it covered and why.
///
/// Appended, never deleted: the log is append-only, so what the fold shows
/// is that this actor no longer approves — and the withdrawal itself stays
/// readable, which is the fact an auditor wants.
Reject {
#[arg(long, value_name = "REPO")]
repo: String,
#[arg(long, value_name = "N")]
pull: u64,
#[arg(long, value_name = "OID")]
commit: String,
#[arg(long, value_name = "TEXT")]
reason: String,
#[arg(long, value_name = "PRINCIPAL")]
actor: String,
#[arg(long, short = 'i', value_name = "PATH")]
identity: std::path::PathBuf,
},
/// Show a pull request's review: its comments, and who approves what.
///
/// The review only. The pull request itself — its title, its refs, whether
/// it is open — belongs to `PullRequests`, and a second copy printed here
/// would be a second answer that can disagree.
Show {
#[arg(long, value_name = "REPO")]
repo: String,
#[arg(long, value_name = "N")]
pull: u64,
},
/// **Issues**: open, comment, close, list and show — as signed events.
///
/// An issue is not a pull request and has no record behind it: it IS the
/// fold of its own signed events, so opening one and closing one are events
/// here (a review has neither — those are the pull request record's). A
/// signed opening is the property GitHub, GitLab and Gitea have no
/// equivalent for: who filed it is the author's own key.
#[command(subcommand)]
Issue(IssueCommand),
}
/// `gunnar forge issue …` — issues as signed events.
#[cfg(feature = "server")]
#[derive(Debug, Subcommand)]
pub enum IssueCommand {
/// **Open an issue.** Signs the title and body with the author's own key
/// and appends it; the number is assigned by the fold and printed back.
Open {
#[arg(long, value_name = "REPO")]
repo: String,
#[arg(long, value_name = "TEXT")]
title: String,
#[arg(long, value_name = "TEXT", default_value = "")]
body: String,
/// The identity-table principal the issue is attributed to.
#[arg(long, value_name = "PRINCIPAL")]
actor: String,
/// The private key to sign with.
#[arg(long, short = 'i', value_name = "PATH")]
identity: std::path::PathBuf,
},
/// Comment on an issue.
Comment {
#[arg(long, value_name = "REPO")]
repo: String,
/// The issue number, as `issue open` (or the fold) assigned it.
#[arg(long, value_name = "N")]
number: u64,
#[arg(long, value_name = "TEXT")]
body: String,
#[arg(long, value_name = "PRINCIPAL")]
actor: String,
#[arg(long, short = 'i', value_name = "PATH")]
identity: std::path::PathBuf,
},
/// **Close an issue**, naming why. Appended, never deleted — the close
/// stays readable.
Close {
#[arg(long, value_name = "REPO")]
repo: String,
#[arg(long, value_name = "N")]
number: u64,
#[arg(long, value_name = "TEXT", default_value = "")]
reason: String,
#[arg(long, value_name = "PRINCIPAL")]
actor: String,
#[arg(long, short = 'i', value_name = "PATH")]
identity: std::path::PathBuf,
},
/// List every issue in a repository, folded.
List {
#[arg(long, value_name = "REPO")]
repo: String,
},
/// Show one issue: its state and its whole thread.
Show {
#[arg(long, value_name = "REPO")]
repo: String,
#[arg(long, value_name = "N")]
number: u64,
},
}
/// `gunnar verify` — the chain, walked backwards (roadmap phase 2).
#[cfg(feature = "server")]
#[derive(Debug, clap::Args)]
pub struct VerifyArgs {
/// `<namespace>/<repo>`.
#[arg(value_name = "REPO")]
pub repo: String,
/// The revision to start from: a branch, a tag, `HEAD`, or a hex oid.
#[arg(long, default_value = "HEAD", value_name = "REV")]
pub at: String,
/// `<path>:<line>`, one-based — the way an editor and `git blame` count.
///
/// Without it the chain starts at the revision's own commit and link 1 is
/// reported as unchecked rather than as passed.
#[arg(long, value_name = "PATH:LINE")]
pub line: Option<String>,
}
/// `gunnar blame <ns>/<repo> <path> [--rev REV]`.
#[cfg(feature = "server")]
#[derive(Debug, clap::Args)]
pub struct BlameArgs {
/// `<namespace>/<repo>`.
#[arg(value_name = "REPO")]
pub repo: String,
/// The file to blame, as a path within the repository's tree.
#[arg(value_name = "PATH")]
pub path: String,
/// The revision to blame at: a branch, a tag, `HEAD`, or a hex oid.
#[arg(long, default_value = "HEAD", value_name = "REV")]
pub rev: String,
}
/// `gunnar search <ns>/<repo> <pattern> [--rev REV] [--max-*]`.
#[cfg(feature = "server")]
#[derive(Debug, clap::Args)]
pub struct SearchArgs {
/// `<namespace>/<repo>`.
#[arg(value_name = "REPO")]
pub repo: String,
/// The literal string to look for. Not a regular expression — see the verb's
/// help for why the appliance does not link one yet.
#[arg(value_name = "PATTERN")]
pub pattern: String,
/// The revision to search: a branch, a tag, `HEAD`, or a hex oid. Only this
/// one revision is searched, not the history behind it.
#[arg(long, default_value = "HEAD", value_name = "REV")]
pub rev: String,
/// Skip files larger than this many bytes (they are never read).
#[arg(long, default_value_t = 1 << 20, value_name = "BYTES")]
pub max_file_bytes: u64,
/// Stop after this many matches.
#[arg(long, default_value_t = 1000, value_name = "N")]
pub max_matches: usize,
}
/// The transparency log's verbs, over the push attestations.
///
/// The log itself is `attestations.jsonl` and is written by every push. These
/// verbs do not write records; they sign, check and prove over the records that
/// are already there — which is why none of them can change what the log says.
#[cfg(feature = "server")]
#[derive(Debug, Subcommand)]
pub enum AttestCommand {
/// **Sign what the log holds right now**, and append the head.
///
/// Issue one whenever a claim about the log needs to be checkable later —
/// after a release, on a timer, before an audit. Each head pins every
/// record beneath it, so a rewrite of an old line is caught by every head
/// issued since, including by somebody who never saw the original line.
Head,
/// **Check every signed head against the records on disk.**
///
/// The one verb that can say the log was tampered with. It keeps three
/// failures apart on purpose: a head that does not verify (somebody wrote a
/// head they could not sign, or the key rotated), a head that verifies over
/// a root the records no longer produce (the records were changed), and a
/// head covering more records than exist (lines were removed).
Audit,
/// The public key a verifier pins to check this server's tree heads.
///
/// An OpenSSH public-key line, so `ssh-keygen -Y verify` can check a head
/// with no gunnar involved.
Key,
/// **The inclusion proof for one push**, by the pack it landed.
///
/// What a client checks offline: the record's position, the tree size and
/// the sibling hashes. With a signed head it proves the push is in the log
/// without trusting the server that served the proof.
Prove {
/// The `pack_hash` the attestation carries.
#[arg(value_name = "PACK-HASH")]
pack_hash: String,
},
}
/// `gunnar issuer …` — the server's certificate authority.
///
/// **Exactly one issuer.** Not a list, not a default among several. A second
/// issuer is a second answer to *who may mint a user*, and the failure mode of
/// two is that nobody can say which one signed a given client. `create` on a
/// server that has one is refused; replacing it invalidates every certificate
/// in the field and is therefore a different verb.
///
/// The trusted-CA **list** is a separate thing and stays a list: a rotation
/// needs the old CA and the new one trusted at the same instant, or every
/// client is locked out at the swap. `trust`/`untrust` are that list.
///
/// **The certificate issuer.** Every verb but `show` writes, and writing needs
/// the storage half; `show` is a read and is here in either build.
#[cfg(any(feature = "server", feature = "control"))]
#[derive(Debug, Subcommand)]
pub enum IssuerCommand {
/// Mint this server's issuer. **Refused if it already has one.**
#[cfg(feature = "server")]
Create {
/// The subject common name the CA certificate carries.
#[arg(long, default_value = "gunnar issuer")]
subject: String,
/// How long it is good for.
#[arg(long, value_name = "DAYS", default_value_t = gunnar_core::DEFAULT_ISSUER_DAYS)]
days: u32,
},
/// Adopt an issuer that already exists. **Refused if this server has one.**
///
/// Both halves are checked against each other before anything is stored: a
/// certificate that is not this key's is refused, rather than accepted and
/// discovered on the first `principal add`.
#[cfg(feature = "server")]
Import {
/// The CA certificate, PEM.
#[arg(long, value_name = "PATH")]
cert: std::path::PathBuf,
/// Its private key, PEM. It goes straight into the sealed secret store
/// and never into the control database.
#[arg(long, value_name = "PATH")]
key: std::path::PathBuf,
},
/// What is live: the issuer, and every CA this server trusts.
///
/// **One verb, two places to stand.** With no `--at` it reads the data
/// root under your feet, as it always has. With `--at <addr>` it asks a
/// RUNNING server the same question over the control plane
/// (`Admin.IssuerInfo`, server-wide `Read`) and prints the same lines.
///
/// There is no second verb for the remote read, and that is a decision.
/// `gunnar namespace` and `gunnar control namespace` are split because
/// they are WRITES with different preconditions — the offline one needs
/// the server stopped. This is a read of one document with no
/// precondition at all: the answer is identical from either side, out of
/// the same `pki_readonly` seam, and two spellings of one question would
/// be two renderings to keep in step.
///
/// # Why the wire half exists
///
/// The appliance has no shell, so `--data-root` is unreachable on the box
/// where it matters. The issuer certificate was otherwise handed out by
/// exactly one RPC, `Bootstrap.Enroll`, whose window shuts for good on the
/// first enrol — so a RESTORED appliance could never again tell anyone
/// which CA it mints with, which is precisely when "is this still the CA
/// my clients pinned?" needs an answer.
Show {
/// Print the issuer certificate itself, so it can be piped to a client.
#[arg(long)]
pem: bool,
/// Ask a running server instead of this host's data root.
///
/// No default and no environment variable on purpose: `issuer show`
/// must not quietly start answering about a different machine because
/// `GUNNAR_CONTROL_ADDR` was exported in the shell.
#[arg(long, value_name = "ADDR", long_help = CONTROL_ADDR_HELP)]
at: Option<String>,
/// The private key to sign the call with. Only with `--at`.
#[arg(long, short = 'i', value_name = "PATH", requires = "at")]
identity: Option<PathBuf>,
},
/// Trust a CA this server does not mint with — the other half of a rotation.
#[cfg(feature = "server")]
Trust {
/// A label. Never part of the credential.
#[arg(long, default_value = "")]
label: String,
/// The CA certificate, PEM.
#[arg(long, value_name = "PATH")]
cert: std::path::PathBuf,
},
/// Stop trusting a CA. Refuses to remove the live issuer's own entry.
#[cfg(feature = "server")]
Untrust {
/// The `SHA256:` fingerprint `issuer show` prints.
fingerprint: String,
},
/// **Can this server be moved to `serve --grpc-tls-self`, and who does it
/// cost?**
///
/// Mutual TLS on the control plane refuses every peer that cannot present
/// a certificate this server's issuer signed. That is the point of it, and
/// it is also a cliff: a principal that holds only an SSH key can
/// authenticate today and cannot complete a handshake afterwards. This
/// prints, per principal, which side of the cliff they are on, and ends
/// with the verdict `gunnar serve` itself will reach — it is the same
/// function, asked in advance, so the two cannot disagree.
///
/// Offline and local: it reads this host's identity table, because
/// `--grpc-tls-self` is a serve flag and whoever flips it is already here.
/// It changes nothing.
#[cfg(feature = "server")]
Readiness,
}
/// Principal verbs.
#[cfg(feature = "server")]
#[derive(Debug, Subcommand)]
pub enum PrincipalCommand {
/// Add a principal, and optionally issue its certificate.
Add {
/// A stable identifier. Grants name it, and it is the `CN` the
/// certificate carries.
id: String,
/// A display name. Defaults to the id.
#[arg(long)]
name: Option<String>,
/// What sort of actor this is.
#[arg(long, value_enum, default_value_t = PrincipalKindArg::User)]
kind: PrincipalKindArg,
/// **Issue from a certificate signing request. This is the strong
/// form.**
///
/// The private key is generated on the machine that will use it and
/// never leaves it, so this server is structurally incapable of
/// impersonating the principal. Whatever subject the request claims is
/// discarded: the certificate is issued under `id`.
#[arg(long, value_name = "PATH", conflicts_with = "server_generated_key")]
csr: Option<std::path::PathBuf>,
/// **Generate the private key here and hand it back.** The convenient
/// form, and the weaker one.
///
/// The key existed on this server and travelled over the wire, so the
/// server could have kept a copy and nothing in the protocol proves it
/// did not — *"only alice could have signed this"* stops being true. It
/// is spelled out rather than being what you get by not thinking about
/// it, because a machine account being bootstrapped genuinely needs it.
#[arg(long)]
server_generated_key: bool,
/// Where the issued `<id>_cert.pem` (and, for
/// `--server-generated-key`, `<id>_key.pem`) are written.
#[arg(long, value_name = "DIR", default_value = ".")]
out: std::path::PathBuf,
/// How long the issued certificate is good for.
#[arg(long, value_name = "DAYS", default_value_t = gunnar_core::DEFAULT_LEAF_DAYS)]
days: u32,
},
/// List principals.
List,
/// Remove a principal, with its keys and grants.
Remove {
/// The principal id.
id: String,
},
}
/// Key verbs.
#[cfg(feature = "server")]
#[derive(Debug, Subcommand)]
pub enum TokenCommand {
/// Mint an access token for a principal, and **print it once**.
///
/// The secret is not stored: only its sha-256 is. Nothing in gunnar can
/// print it again, which is why this is the only place it appears.
Add {
/// The principal the token authenticates as. It gets exactly that
/// principal's grants and nothing else.
principal: String,
/// A label — which laptop, which CI job. Never part of the credential.
#[arg(long, default_value = "")]
label: String,
/// Stop accepting it after this many days.
#[arg(long, value_name = "DAYS")]
expires_in: Option<u64>,
},
/// List tokens by their hash, which is what `revoke` takes.
List {
/// Only this principal's.
#[arg(long)]
principal: Option<String>,
},
/// Revoke a token by the hash `list` prints.
Revoke {
/// The token hash.
hash: String,
},
}
/// WireGuard verbs.
///
/// One verb, and it exists because provisioning an OS-less fleet needs a key
/// and `wg genkey` is a SHELL — a thing this product does not have, on the
/// appliance or in its build. The curve is already linked into this binary for
/// the tunnel itself, so minting a key costs nothing that was not already
/// there.
#[cfg(feature = "server")]
#[derive(Debug, Subcommand)]
pub enum WgCommand {
/// Mint a fresh x25519 keypair — what `wg genkey | wg pubkey` prints,
/// without a shell.
Keygen {
/// Write the PRIVATE key here (one base64 line, mode 0600) instead of
/// printing it, and print only the public half.
///
/// This is the `wg0.key` an appliance is seeded with. An existing file
/// is REFUSED rather than overwritten: a regenerated identity is a
/// silent fleet-wide outage — every hub still allowlists the old public
/// key, and a peer whose key is unknown gets silence, not an error.
#[arg(long, value_name = "PATH")]
private: Option<std::path::PathBuf>,
},
}
/// Public-key verbs.
#[cfg(feature = "server")]
#[derive(Debug, Subcommand)]
pub enum KeyCommand {
/// Register an OpenSSH public key line to a principal.
Add {
/// The principal id.
principal: String,
/// An OpenSSH public key line, or `@<path>` to read one from a file.
///
/// Omit when registering a certificate with `--cert`.
#[arg(required_unless_present = "cert")]
key: Option<String>,
/// Register an X.509 certificate instead, from a PEM or DER file.
///
/// The mTLS credential. It lands in the same table as an SSH key and
/// under the same principal, because a fingerprint is all gunnar knows
/// about a credential — only how it is presented differs.
#[arg(long, conflicts_with = "key", value_name = "PATH")]
cert: Option<std::path::PathBuf>,
},
/// List registered keys.
List {
/// Only this principal's keys.
#[arg(long)]
principal: Option<String>,
},
/// Revoke a key by its `SHA256:` fingerprint.
Revoke {
/// The fingerprint, exactly as `ssh-keygen -lf` prints it.
fingerprint: String,
},
}
/// `gunnar grant`.
#[cfg(feature = "server")]
#[derive(Debug, clap::Args)]
#[command(group(ArgGroup::new("scope").required(true).args(["server", "namespace", "repo"])))]
pub struct GrantArgs {
/// The principal id.
pub principal: String,
/// How much access.
#[arg(value_enum)]
pub level: LevelArg,
/// Scope the grant to the whole server (the bootstrap admin's scope).
#[arg(long)]
pub server: bool,
/// Scope the grant to one namespace, matched EXACTLY: `team` does not reach
/// `team/sub`.
#[arg(long, value_name = "NS")]
pub namespace: Option<String>,
/// Scope the grant to one repository, `<namespace>/<name>`.
#[arg(long, value_name = "NS/NAME")]
pub repo: Option<String>,
}
/// `gunnar revoke`.
#[cfg(feature = "server")]
#[derive(Debug, clap::Args)]
#[command(group(ArgGroup::new("scope").required(true).args(["server", "namespace", "repo"])))]
pub struct RevokeArgs {
/// The principal id.
pub principal: String,
/// The server-wide grant.
#[arg(long)]
pub server: bool,
/// A namespace grant.
#[arg(long, value_name = "NS")]
pub namespace: Option<String>,
/// A repository grant.
#[arg(long, value_name = "NS/NAME")]
pub repo: Option<String>,
}
/// `gunnar reflog`.
///
/// Reads the archive `gunnar-store`'s compaction has always written and until
/// now nothing read. It opens **no store**, so it works against a repository a
/// running server holds the exclusive lock on — which is the only kind of
/// repository anyone wants the history of.
#[cfg(feature = "server")]
#[derive(Debug, clap::Args)]
pub struct ReflogArgs {
/// The repository, `<namespace>/<name>`. Omit for every one.
pub path: Option<String>,
/// Write the sealed history to this path instead of listing it.
///
/// One Arrow IPC stream, one batch, the push boundary carried as a
/// `push_seq` column rather than as a frame boundary — measured at 6.68x
/// smaller per push than the live log it seals. With more than one
/// repository this is treated as a **directory** and one artifact is
/// written per repository, because a single file would silently keep only
/// the last.
#[arg(long, value_name = "PATH")]
pub seal: Option<std::path::PathBuf>,
/// Print at most this many of the most recent transactions per repository.
#[arg(long, default_value_t = 20, value_name = "N")]
pub limit: usize,
/// Print every transaction, ignoring `--limit`.
#[arg(long, conflicts_with = "limit")]
pub all: bool,
}
/// `gunnar gc`.
///
/// `Default` exists so that a caller — the tests, and any future programmatic
/// `gc` — can name the one field it cares about and inherit the verb's real
/// defaults for the rest. Spelling every flag out at each call site is how a
/// test ends up pinned to `Preserve` because that was the easiest literal to
/// write, and then guards nothing.
///
/// **It is hand-written, and it has to be.** A derive gives `0` for every
/// integer, and `--window 0`/`--depth 0` is a delta search that can never find
/// a base: every `GcArgs { .. , ..Default::default() }` in the tests would
/// silently stop deltifying and every one of them would still pass, because a
/// pack of whole objects is a valid pack. `gc_default_matches_the_command_line`
/// asserts the two agree, field by field.
#[cfg(feature = "server")]
#[derive(Debug, clap::Args)]
pub struct GcArgs {
/// The repository to maintain, `<namespace>/<name>`. Omit for every one.
pub path: Option<String>,
/// Also drop objects nothing reaches.
///
/// Off unless asked, because reachability is computed from the refs as they
/// are at this instant and a push landing mid-run is a race the grace
/// window below exists to cover.
#[arg(long)]
pub prune: bool,
/// How long an unreachable object is kept before `--prune` may drop it.
#[arg(long, value_name = "DURATION", default_value = "2w", value_parser = parse_prune_grace)]
pub prune_grace: std::time::Duration,
}
/// `now`, `<N>`, or `<N>` with a `s`/`m`/`h`/`d`/`w` suffix.
///
/// Hand-written rather than pulling in a date parser: the dependency budget is
/// a guarded number (`xtask guard`) and this is eleven lines. It deliberately
/// does **not** accept git's full `approxidate` grammar (`2.weeks.ago`), because
/// half-supporting a grammar is how `--prune=yesterday` ends up silently
/// meaning zero.
#[cfg(feature = "server")]
fn parse_prune_grace(raw: &str) -> std::result::Result<std::time::Duration, String> {
parse_duration_words(raw, "grace period", "2w")
}
/// `--max-silence` on `control twin replay`: the same grammar as [`parse_prune_grace`].
#[cfg(feature = "control")]
fn parse_max_silence(raw: &str) -> std::result::Result<std::time::Duration, String> {
parse_duration_words(raw, "silence", "24h")
}
/// The grammar of [`parse_prune_grace`], naming `what` and an `example` in its refusals.
#[cfg(any(feature = "server", feature = "control"))]
fn parse_duration_words(
raw: &str,
what: &str,
example: &str,
) -> std::result::Result<std::time::Duration, String> {
let raw = raw.trim();
if raw.eq_ignore_ascii_case("now") {
return Ok(std::time::Duration::ZERO);
}
let (digits, unit) = match raw.as_bytes().last() {
Some(b) if b.is_ascii_digit() => (raw, 1u64),
Some(b's') => (&raw[..raw.len() - 1], 1),
Some(b'm') => (&raw[..raw.len() - 1], 60),
Some(b'h') => (&raw[..raw.len() - 1], 60 * 60),
Some(b'd') => (&raw[..raw.len() - 1], 24 * 60 * 60),
Some(b'w') => (&raw[..raw.len() - 1], 7 * 24 * 60 * 60),
_ => {
return Err(format!(
"{raw:?} is not a {what}; write `now`, a number of seconds, or a number \
with a s/m/h/d/w suffix such as `{example}`"
))
}
};
let n: u64 = digits.parse().map_err(|_| {
format!(
"{raw:?} is not a {what}: {digits:?} is not a number. Write `now`, a number \
of seconds, or a number with a s/m/h/d/w suffix such as `{example}`"
)
})?;
n.checked_mul(unit)
.map(std::time::Duration::from_secs)
.ok_or_else(|| format!("{raw:?} is not a {what}: it does not fit in a duration"))
}
#[cfg(feature = "server")]
impl Default for GcArgs {
fn default() -> Self {
GcArgs {
path: None,
prune: false,
// The same value as `default_value = "2w"` above;
// `gc_default_matches_the_command_line` fails if they drift.
prune_grace: std::time::Duration::from_secs(14 * 24 * 60 * 60),
}
}
}
#[cfg(feature = "server")]
impl GcArgs {}
/// The hash kinds gunnar knows.
#[cfg(any(feature = "server", feature = "control"))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum HashArg {
/// Git's original hash.
Sha1,
/// The default for new repositories.
Sha256,
}
#[cfg(feature = "server")]
impl From<HashArg> for gunnar_core::HashKind {
fn from(arg: HashArg) -> Self {
match arg {
HashArg::Sha1 => gunnar_core::HashKind::Sha1,
HashArg::Sha256 => gunnar_core::HashKind::Sha256,
}
}
}
/// Grant levels, ordered `read < write < admin`.
///
/// Shared by the offline `gunnar grant` and the online `gunnar control grant`,
/// so `--level admin` means one thing whichever side of the wire it is spelled
/// on (one implementation).
#[cfg(any(feature = "server", feature = "control"))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum LevelArg {
/// `git-upload-pack`: clone and fetch.
Read,
/// `git-receive-pack`: push. Implies read.
Write,
/// Administer. Implies write.
Admin,
}
#[cfg(feature = "control")]
impl LevelArg {
/// The proto enum this level is on the wire.
pub fn to_proto(self) -> gunnar_proto::GrantLevel {
match self {
LevelArg::Read => gunnar_proto::GrantLevel::Read,
LevelArg::Write => gunnar_proto::GrantLevel::Write,
LevelArg::Admin => gunnar_proto::GrantLevel::Admin,
}
}
}
#[cfg(feature = "server")]
impl From<LevelArg> for gunnar_core::Level {
fn from(arg: LevelArg) -> Self {
match arg {
LevelArg::Read => gunnar_core::Level::Read,
LevelArg::Write => gunnar_core::Level::Write,
LevelArg::Admin => gunnar_core::Level::Admin,
}
}
}
/// The three kinds of principal in the identity table.
#[cfg(any(feature = "server", feature = "control"))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum PrincipalKindArg {
/// A human.
User,
/// CI, a mirror, a robot with its own identity.
Machine,
/// A key scoped to one repository.
Deploy,
/// A group of principals (punkt 9): holds no key, is granted access like
/// any principal, and its members inherit those grants. Prefer
/// `gunnar control group add`, which is this with a name for it.
Group,
}
#[cfg(feature = "server")]
impl From<PrincipalKindArg> for gunnar_core::PrincipalKind {
fn from(arg: PrincipalKindArg) -> Self {
match arg {
PrincipalKindArg::User => gunnar_core::PrincipalKind::User,
PrincipalKindArg::Machine => gunnar_core::PrincipalKind::Machine,
PrincipalKindArg::Deploy => gunnar_core::PrincipalKind::Deploy,
PrincipalKindArg::Group => gunnar_core::PrincipalKind::Group,
}
}
}
// ───────────────────────────────────────────────────────────────────────────
// the control plane, over the wire
// ───────────────────────────────────────────────────────────────────────────
/// Where a `gunnar control` verb sends its call.
///
/// Its own arg rather than a global one, because it must appear in the
/// `--help` of each verb that uses it: a first-time user reading
/// `gunnar control bootstrap --help` has to see where it is about to send an
/// unauthenticated credential-granting call.
///
/// Declared in the SERVER build too, because `gunnar issuer show --at` is:
/// that binary must still parse the flag in order to refuse it BY NAME —
/// telling an operator "this binary carries no control client" rather than
/// "unexpected argument" — which is the same asymmetry `--monetize-pubkey`
/// has in the open build. It is a `&str`; it pulls in nothing.
#[cfg(any(feature = "control", feature = "server"))]
pub const CONTROL_ADDR_HELP: &str =
"Where the control plane is. gunnar binds it to LOOPBACK by default, so the default here is \
this host's server. To reach another host, tunnel to its loopback \
(`ssh -L 50051:127.0.0.1:50051 <host>`) rather than binding it publicly. An estate is \
reached through its site, `--at https://<site>`: its front carries the call, signed with \
your key, to the main (GUNNAR_SITE_CA names the site's own CA, if a public one did not \
issue its certificate).";
/// `gunnar control admit …`
#[cfg(feature = "control")]
#[derive(Debug, Subcommand)]
pub enum AdmitCommand {
/// How each kind of transfer for one namespace would be admitted
/// (`Usage.AdmitPlan`).
Show {
#[arg(value_name = "NAMESPACE")]
namespace: String,
#[arg(long, short = 'i', value_name = "PATH")]
identity: Option<PathBuf>,
#[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
at: String,
},
}
/// `gunnar control …`
#[cfg(feature = "control")]
#[derive(Debug, Subcommand)]
pub enum ControlCommand {
/// Ask a server whether it has been enrolled. **Needs no credential.**
///
/// It is the one question a server that nobody holds a key for can answer,
/// and an unenrolled server is exactly that.
Status {
#[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
at: String,
},
/// **Step 1: claim a fresh server.** Creates the first user, who is
/// gunnar-admin. Needs NO credential — and works exactly once, ever.
///
/// The window is open only while no identity has ever been enrolled. It
/// closes permanently on the first success, and deleting every user does
/// not reopen it.
Bootstrap {
/// Your name. It is also your ACCOUNT: you are always admin of
/// `<host>/<name>/<repo>`, and that is not a grant anybody can revoke.
name: String,
/// An OpenSSH public key line, or `@<path>`. Defaults to the public
/// half of the key `-i` names, so neither flag is usually needed.
#[arg(long, value_name = "KEY")]
key: Option<String>,
/// The private key whose public half to enrol, and to authenticate
/// with afterwards.
#[arg(long, short = 'i', value_name = "PATH")]
identity: Option<PathBuf>,
/// Where the client certificate the server issues (`<name>.cert.pem`,
/// `<name>.key.pem`, `gunnar-issuer.pem`) is written. Defaults to a
/// directory of your own, `~/.config/gunnar/<server>/`, created 0700 —
/// never the current directory, which is a git checkout more often
/// than not, and a private key in a checkout is one `git add` from a
/// remote.
#[arg(long, value_name = "DIR")]
out: Option<PathBuf>,
#[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
at: String,
},
/// **Step 3: log in** with your ssh key, and see what you are.
///
/// Writes nothing. gunnar keeps no session on either side: every call signs
/// a fresh single-use credential, so there is no state for `bootstrap` to
/// have left behind and none for this to create.
Login {
#[arg(long, short = 'i', value_name = "PATH")]
identity: Option<PathBuf>,
#[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
at: String,
},
/// **Step 4: users.** Authenticated, and creating one needs gunnar-admin.
#[command(subcommand)]
User(ControlUserCommand),
/// **Groups: grant access to a team, not one person at a time** (punkt 9).
///
/// A group is a principal that holds no key. Grant it access with the
/// ordinary `gunnar control grant <group> …`, add people to it, and they
/// inherit the grant; remove them and they lose it. Managing a group needs
/// gunnar-admin, exactly like a grant.
#[command(subcommand)]
Group(ControlGroupCommand),
/// Repositories, against a running server.
///
/// You may always create one in your OWN account with no grant at all;
/// somebody else's needs a grant on their namespace.
#[command(subcommand)]
Repo(ControlRepoCommand),
/// **Is the record of what happened on this box still complete?**
/// (`Admin.AuditTrail`, server-wide `Read`.)
///
/// The control plane has written a tamper-evident hash chain of its
/// decisions since GUN-044, and until 2026-09-21 there was no way to read
/// it except as root on the box — which an appliance does not have. This
/// prints the chain's shape: how many entries, the tip hash, whether the
/// chain still verifies, and whether it is replicated to the twin.
///
/// It prints no entry. Who did what is a read of the file; this verb
/// answers completeness.
///
/// # After a restore
///
/// A restored box keeps appending, so its TIP has moved by the time you
/// ask. Give `--at-seq <N>` the last sequence the old primary reported and
/// compare the `hash at seq` line: because every entry's hash commits to
/// its `prev`, one matching hash proves the whole prefix came back
/// unaltered.
Audit {
/// Ask for the hash this chain carried at this sequence number — the
/// prefix proof a restore is checked with.
#[arg(long, value_name = "N")]
at_seq: Option<u64>,
#[arg(long, short = 'i', value_name = "PATH")]
identity: Option<PathBuf>,
#[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
at: String,
},
/// Namespaces, against a running server.
///
/// The offline `gunnar namespace` writes the control repository directly
/// and therefore needs the server **stopped**. These do not.
#[command(subcommand)]
Namespace(ControlNamespaceCommand),
/// **Accounts: lock a user out, and empty their account for good.**
///
/// The per-USER half of what `repo` does per repository. `repo delete
/// --purge` reaches one repository at a time; an account can hold hundreds
/// of them plus soft-delete tombstones and orphan stores that no other
/// listing shows, and there was no verb whose unit was a *user*.
///
/// Every one of these needs **gunnar-admin** — Admin at server scope, not
/// on the namespace. A namespace check would be self-granting: you are
/// unconditionally admin of your own account, so it would let any user
/// hard-purge themselves with one call and no undo.
#[command(subcommand)]
Account(ControlAccountCommand),
/// **Clans: ordinary accounts grouped, each giving part of its own room.**
///
/// A clan has a namespace of its own (private, like every account's) and
/// an admin — its creator — who invites accounts and administers what is
/// made in it: visibility, who else may reach it, deletion. Its members
/// read, write and create private repositories there. Its room is the sum
/// of what its members give out of their own; a contribution goes down
/// only as far as what the clan stores, and leaving gives it back.
/// Every verb is YOURS: gunnar decides from the clan who may.
#[command(subcommand)]
Clan(ControlClanCommand),
/// **Pull mirroring: where this server's copy comes FROM.**
///
/// The pull direction, and the exact mirror of `replication` below: that one
/// says where a second copy of gunnar's repositories is pushed TO, this one
/// says which other forge a repository here is pulled from. One service on
/// the wire, because "where does this repository's history also live" is one
/// question asked from two sides.
///
/// `import` is the door: it lists a whole GitHub/Codeberg/GitLab/Forgejo
/// account over that forge's REST API and mirrors what you choose, which is
/// how a person moves in without a migration project.
///
/// Not to be confused with `gunnar mirror` (no `control`), which keeps a
/// copy in a data root on the machine you typed it on. This one talks to a
/// server.
#[command(subcommand)]
Mirror(ControlMirrorCommand),
/// **Replication: where a second copy of everything lives.**
///
/// The appliance has no shell. `<data-root>/replication.json` is the truth
/// and it survives a reboot, but on an OS-less image running as PID 1 there
/// is no editor to write it with — so these are how a standby is named, and
/// they are the only way.
///
/// A target is configurable BEFORE any repository exists: replication is
/// scoped to the server, a namespace or a repository, and nothing is
/// looked up when you set one.
#[command(subcommand)]
Replication(ControlReplicationCommand),
/// **The twin: the box that holds every transaction, and the restore
/// from it.**
///
/// A twin follows a primary's transaction log — every pack, ref edit,
/// control-database write, secret and LFS object, in the order the
/// primary was told — into a data root of its own, and never serves. On
/// a gap it fetches the missing range first; when the primary no longer
/// retains what it needs it stops and says `snapshot-needed`. `restore`
/// replays a twin's log into a fresh data root.
#[command(subcommand)]
Twin(ControlTwinCommand),
/// **The WireGuard tunnel address: handed over at bootstrap, never baked
/// into the medium.** `enrol` allocates the next free address of the
/// site's pool (keyed by the box's minted public key), hands it to the box
/// over `Bootstrap.AssignTunnelAddress`, and registers the box's `[Peer]`
/// in the hub's WireGuard config.
#[command(subcommand)]
Tunnel(ControlTunnelCommand),
/// **Outbound events: send them to an external URL (webhooks).**
///
/// A webhook is a subscriber to gunnar's event stream that POSTs each
/// matching event to a URL — the way a CI/CD pipeline is triggered when a
/// ref moves, without a process camped on `Events.Watch`. Every delivery is
/// SIGNED with the server's key (an `sshsig`, no shared secret), so the
/// receiver verifies it came from this gunnar with `ssh-keygen -Y verify`.
///
/// Configured over the control plane and durable in
/// `<data-root>/webhooks.json`, for the same reason replication is: the
/// appliance has no shell.
#[command(subcommand)]
Webhook(ControlWebhookCommand),
/// **The pre-receive gate: branch protection and ref-name policy.**
///
/// A merge already needs a verified signed approval naming the exact
/// commit. Nothing stopped a DIRECT push to the same branch, because the
/// only surface that could was reachable from neither this CLI nor the
/// console — while the server had been serving `Policy.*` out of
/// `refs/gunnar/policy` the whole time. These are that surface.
#[command(subcommand)]
Policy(ControlPolicyCommand),
/// Give a principal access, against a running server — or `grant list`
/// to see who holds what.
///
/// `gunnar control grant <principal> <level> --repo <ns>/<r>` gives;
/// `gunnar control grant list` reads the whole table back. The one name
/// this shadows is a principal literally called `list`, which `user add`
/// will let you create and this verb can then not grant to.
#[command(args_conflicts_with_subcommands = true)]
Grant {
#[command(subcommand)]
command: Option<ControlGrantCommand>,
/// The principal id.
principal: Option<String>,
/// How much access.
#[arg(value_enum)]
level: Option<LevelArg>,
/// Scope it to the whole server. This IS gunnar-admin at `admin`.
#[arg(long)]
server: bool,
/// Scope it to one namespace.
#[arg(long, value_name = "NAME")]
namespace: Option<String>,
/// Scope it to one repository, `<namespace>/<repo>`.
#[arg(long, value_name = "NS/REPO")]
repo: Option<String>,
#[arg(long, short = 'i', value_name = "PATH")]
identity: Option<PathBuf>,
#[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
at: String,
},
/// **Take a grant back**, against a running server.
///
/// The mirror of `grant`, minus the level: a scope holds at most ONE
/// grant per principal — granting on the same scope replaces — so the
/// principal and the scope name the grant, and there is no level to
/// choose. Revoking a grant that is not there is an ERROR, not a quiet
/// success: "revoked" about a grant that never existed reads identically
/// to "revoked" about the one you meant, and the difference is whether
/// somebody still has access.
///
/// A principal's OWN account is not a grant and cannot be revoked here;
/// `user remove` takes the whole principal.
Revoke {
/// The principal id.
principal: String,
/// The server-wide grant. This is how gunnar-admin is taken back.
#[arg(long)]
server: bool,
/// The grant on one namespace.
#[arg(long, value_name = "NAME")]
namespace: Option<String>,
/// The grant on one repository, `<namespace>/<repo>`.
#[arg(long, value_name = "NS/REPO")]
repo: Option<String>,
#[arg(long, short = 'i', value_name = "PATH")]
identity: Option<PathBuf>,
#[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
at: String,
},
/// The server's own health, authenticated: version, uptime and both
/// listener binds.
///
/// `status` is the unauthenticated question ("is this box enrolled, does
/// it gate callers"); this is the authenticated one — what a headless
/// appliance would tell you on a console you cannot see.
Health {
#[arg(long, short = 'i', value_name = "PATH")]
identity: Option<PathBuf>,
#[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
at: String,
},
/// **The public key this appliance signs actor tickets with**, as 64 hex
/// characters — the string a monetize needs in `[products.<id>]
/// appliance_key` before a tenant can renew their own subscription.
///
/// `Billing.ApplianceIdentity` has served this since it was written, "so an
/// operator can configure their monetize with it", and until 2026-09-19
/// nothing but a test ever asked: there was no verb. MEASURED on gunnar.rs
/// that day — every tenant's own renewal answered `501 unimplemented`,
/// because the plugin had no key to check a ticket with and correctly
/// refuses rather than trusting one it cannot verify.
///
/// It is a PUBLIC key. Admin at server scope because it is operator
/// configuration, not because the string is a secret — it VERIFIES tickets,
/// it does not mint them.
///
/// `metered` only, like the service it calls: `billing.proto` is not in
/// codegen without the feature, and the community build "never mentions
/// money at all" — an appliance that cannot sell a period carries no
/// service that mints the permission to buy one, so a verb asking for one
/// would be a promise the binary cannot keep.
#[cfg(feature = "metered")]
ApplianceIdentity {
#[arg(long, short = 'i', value_name = "PATH")]
identity: Option<PathBuf>,
#[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
at: String,
},
/// **A namespace's plan, as this box ENFORCES it** — and the one door that
/// hands the box a fact monetize signed.
///
/// `Entitlement.Get/List/Set` had no verb: the only caller of `Set` was
/// monetize's own poller, so a bench, a restore drill or an operator after
/// a rescue could not put a fact on a box or ask what it was enforcing
/// without writing a gRPC client. The reads answer out of the snapshot the
/// front door reads, so a fact refused at boot — the wrong
/// `--monetize-pubkey` after a restore — reads as Free here too.
///
/// `metered` only, like `appliance-identity`: `entitlement.proto` is not in
/// codegen without it.
#[cfg(feature = "metered")]
#[command(subcommand)]
Entitlement(ControlEntitlementCommand),
/// The server's counters: principals, keys, grants, repositories, the
/// event bus, tombstones, replication and index work in flight.
Stats {
#[arg(long, short = 'i', value_name = "PATH")]
identity: Option<PathBuf>,
#[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
at: String,
},
/// **"If a tenant's cap rose to N bytes, could you serve it?"** — the
/// question to ask BEFORE adding a disk.
///
/// Not "how much is free". The answer is a verdict measured off the same
/// `statvfs` the push door refuses under, AND — separately — whether this
/// data set can ever grow at all. On the appliance it cannot: `/data` is an
/// md set laid once at install, gunnar has no runtime growth path, and
/// gunnar is PID 1 with no shell, so a disk attached to it would be added for nothing
/// and stay invisible. Both halves are printed, on the yes arm as well as
/// the no arm.
Capacity {
/// The cap being contemplated, in bytes: `--bytes 107374182400` asks
/// "could you serve a tenant capped at 100 GiB?".
#[arg(long, value_name = "BYTES")]
bytes: u64,
/// Whose cap is rising. Bytes it ALREADY holds are already spent out
/// of free space, so only the difference needs new room. Without it
/// the harder question is asked: could you serve `--bytes` that are
/// not here yet.
#[arg(long, value_name = "NAMESPACE")]
namespace: Option<String>,
#[arg(long, short = 'i', value_name = "PATH")]
identity: Option<PathBuf>,
#[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
at: String,
},
/// **Grow the data set WITHOUT a restart** (`DATA-SET-GROWTH-FLOW.md`
/// §3): drain, unmount, wait for every member volume to come back
/// larger, grow in place, mount, reopen. `status` needs no credential.
#[command(subcommand)]
Grow(ControlGrowCommand),
/// What is on disk: which backends are behind the two traits, hot and
/// cold bytes, and one row per repository.
Storage {
/// Only this repository, `<namespace>/<repo>`. Without it, the
/// server-wide totals and no per-repository rows.
#[arg(long, value_name = "NS/REPO")]
repo: Option<String>,
#[arg(long, short = 'i', value_name = "PATH")]
identity: Option<PathBuf>,
#[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
at: String,
},
/// **The estate's boxes, as this gunnar knows them** (`Admin.Topology`):
/// this box first (primary or twin), then its twins and standbys with their
/// live state, the consoles that call it and the monetize it registers
/// with — one row per box, each with the sentence that says how gunnar
/// knows. The same rows the console's Machine card draws.
Topology {
#[arg(long, short = 'i', value_name = "PATH")]
identity: Option<PathBuf>,
#[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
at: String,
},
/// **The meters, per namespace: what is held and what moved.**
///
/// `pack_bytes` (the packs and their indexes — the stored bytes),
/// `cache_bytes` (the exploded read cache, re-derivable), `lfs_bytes`,
/// `tombstoned_bytes` (soft-deleted, still held, still counted), `repos`,
/// and `objects` (the indexed objects of stores open right now — the RAM in
/// use).
///
/// Since the flow ledger it also prints what MOVED today — `in`, `out`,
/// `pushes`, `fetches`, `refused` — and when the namespace was last seen.
/// Those are counts of events and not readings of a disk; `gunnar control
/// activity <namespace>` is the same numbers day by day.
///
/// Open source and always on: a self-hoster reads exactly the numbers a
/// service provider meters from. Needs `read` on the namespace, or a
/// server-wide `read` for every namespace.
Usage {
/// One namespace. Without it, every namespace the catalog holds.
#[arg(value_name = "NAMESPACE")]
namespace: Option<String>,
#[arg(long, short = 'i', value_name = "PATH")]
identity: Option<PathBuf>,
#[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
at: String,
},
/// **What MOVED through a namespace, day by day.**
///
/// Every number `gunnar control usage` prints is a STOCK meter: it answers
/// *how much is on the disk*, and it reads exactly the same for a namespace
/// that was pushed to this morning and one that was last touched in March.
/// This is the other kind — bytes in and out, pushes, fetches, refusals
/// (with the reason), the keys that were seen, and when the namespace was
/// last used. One row per UTC day that moved something; a day with no
/// traffic has no row.
///
/// Open source and always on, as `usage` is. Needs `read` on the namespace
/// — a tenant's own traffic is a tenant's own business, so this takes the
/// same grant `usage <namespace>` does and never a server-wide one.
Activity {
/// The namespace. Required: activity is per tenant, and there is no
/// server-wide answer that would mean anything.
#[arg(value_name = "NAMESPACE")]
namespace: String,
/// How many days back, ending today. The server keeps 400.
#[arg(long, default_value_t = 30, value_name = "N")]
days: u32,
#[arg(long, short = 'i', value_name = "PATH")]
identity: Option<PathBuf>,
#[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
at: String,
},
/// **Admission: what a namespace's next transfer would get, and why.**
///
/// `gunnar control admit show <namespace>` prints the usage over the
/// rolling window, the budget and what is left of it, and per kind of work
/// (clone, fetch, push, request) the class the gate would give it, the
/// learnt estimate and the gate's own sentence. Needs `read` on the
/// namespace — a tenant may read their own budget.
Admit {
#[command(subcommand)]
command: AdmitCommand,
},
/// **Who wrote each line of a file, against a RUNNING server.**
///
/// The offline `gunnar blame` opens the data root directly and takes an
/// exclusive lock, so it cannot run while a `gunnar serve` holds the same
/// root — which on a sealed appliance with no shell means it cannot run at
/// all. This asks the running server to walk its own open store and answer
/// over the control plane, so a headless box can finally be blamed. Needs
/// `read` on the repository.
Blame {
/// `<namespace>/<repo>`.
#[arg(value_name = "NS/REPO")]
repo: String,
/// The file to blame, as a path within the repository's tree.
#[arg(value_name = "PATH")]
path: String,
/// The revision to blame at: a branch, a tag, `HEAD`, or a hex oid.
#[arg(long, default_value = "HEAD", value_name = "REV")]
rev: String,
#[arg(long, short = 'i', value_name = "PATH")]
identity: Option<PathBuf>,
#[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
at: String,
},
/// **A literal string across one revision's files, against a RUNNING
/// server.** The up-server twin of `gunnar search`, reachable on a
/// no-shell appliance for the same reason `control blame` is. Needs `read`
/// on the repository.
Search {
/// `<namespace>/<repo>`.
#[arg(value_name = "NS/REPO")]
repo: String,
/// The literal string to look for. Not a regular expression.
#[arg(value_name = "PATTERN")]
pattern: String,
/// The revision to search: a branch, a tag, `HEAD`, or a hex oid. Only
/// this one revision, not the history behind it.
#[arg(long, default_value = "HEAD", value_name = "REV")]
rev: String,
/// Skip files larger than this many bytes (they are never read). 0
/// means the server default.
#[arg(long, default_value_t = 0, value_name = "BYTES")]
max_file_bytes: u64,
/// Stop after this many matches. 0 means the server default.
#[arg(long, default_value_t = 0, value_name = "N")]
max_matches: u32,
#[arg(long, short = 'i', value_name = "PATH")]
identity: Option<PathBuf>,
#[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
at: String,
},
/// **Show a pull request's review, against a RUNNING server.**
///
/// The comments and the approvals, with every approval re-checked against
/// the identity key table right now — the same fold `gunnar forge show` prints,
/// but reached over the control plane. The offline `gunnar forge show`
/// opens the data root under an exclusive lock and so cannot run while a
/// `gunnar serve` holds it, which on a sealed appliance with no shell means
/// an operator has no other door to the review at all. Whether the pull
/// request is open and what it merged at are `gunnar control pull`'s
/// answers, deliberately not duplicated here. Needs `read` on the
/// repository.
Show {
/// `<namespace>/<repo>`.
#[arg(value_name = "NS/REPO")]
repo: String,
/// The pull request number, as `PullRequests.Create` assigned it.
#[arg(value_name = "N")]
pull: u64,
#[arg(long, short = 'i', value_name = "PATH")]
identity: Option<PathBuf>,
#[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
at: String,
},
/// **Merge a reviewed pull request, against a RUNNING server.**
///
/// Fast-forward only, and gated: the server refuses unless a verified
/// approval names the exact commit the target ref is about to move to, so a
/// force-push after the review does not inherit it. This is the merge an
/// operator on a sealed appliance runs — there is no other door to
/// `PullRequests.Merge` from a shell-less box — and the gate's rules (the
/// approval names the commit, forbid-self-approval, required-approvals) are
/// the server's, applied unchanged; this is only the verb that reaches
/// them. Needs `write` on the repository.
Merge {
/// `<namespace>/<repo>`.
#[arg(value_name = "NS/REPO")]
repo: String,
/// The pull request number to merge.
#[arg(value_name = "N")]
pull: u64,
#[arg(long, short = 'i', value_name = "PATH")]
identity: Option<PathBuf>,
#[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
at: String,
},
/// **The key gunnar signs its merge commits with** (`PullRequests.MergeSigningKey`).
///
/// Prints the public half (one OpenSSH line), its fingerprint, and the line for
/// git's allowed signers file, so `git verify-commit` (with
/// `gpg.ssh.allowedSignersFile`) checks a merge commit gunnar wrote. The private
/// half never leaves the box.
MergeKey {
#[arg(long, short = 'i', value_name = "PATH")]
identity: Option<PathBuf>,
#[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
at: String,
},
/// **Who am I on this server?** (`Access.WhoAmI`.) The principal your key
/// belongs to and the highest server-wide level it holds. Needs no grant.
Whoami {
#[arg(long, short = 'i', value_name = "PATH")]
identity: Option<PathBuf>,
#[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
at: String,
},
/// **Pull requests, against a RUNNING server** (`PullRequests.*`): open,
/// list, show and close one. Merging is `gunnar control merge`.
#[command(subcommand)]
Pr(ControlPrCommand),
/// **Issues, against a RUNNING server**, signed with YOUR key and appended
/// with `Forge.Append` — the online twin of the offline `gunnar forge issue`.
#[command(subcommand)]
Issue(ControlIssueCommand),
/// **A pull request's review, against a RUNNING server**: read it, comment
/// on it, approve a commit — the events signed with YOUR key.
#[command(subcommand)]
Review(ControlReviewCommand),
/// **The live event feed** (`Events.Watch`).
#[command(subcommand)]
Events(ControlEventsCommand),
/// **A first-login link onto an existing person** (`Access.EnrolmentLink`,
/// IDENTITY-HOST): minted by the identity core, a server admin's.
#[command(subcommand)]
Invite(ControlInviteCommand),
}
/// `gunnar control invite …`
#[cfg(feature = "control")]
#[derive(Debug, Subcommand)]
pub enum ControlInviteCommand {
/// Create one; prints its id and its SECRET — shown here once, never again.
Create {
/// The person whose passkey it enrols.
#[arg(value_name = "PRINCIPAL")]
principal: String,
/// Days until it expires (at most 30).
#[arg(long, value_name = "DAYS", default_value_t = 7)]
expires_in_days: u32,
/// Refuse a person who already holds a passkey (their FIRST one only).
#[arg(long)]
first_only: bool,
#[arg(long, short = 'i', value_name = "PATH")]
identity: Option<PathBuf>,
#[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
at: String,
},
}
/// `gunnar control pr …` — pull requests over the control plane.
#[cfg(feature = "control")]
#[derive(Debug, Subcommand)]
pub enum ControlPrCommand {
/// Open a pull request from `--source` into `--target`. Needs `write`.
Create {
/// `<namespace>/<repo>`.
#[arg(value_name = "NS/REPO")]
repo: String,
/// The full ref proposed, e.g. `refs/heads/topic`.
#[arg(long, value_name = "REF")]
source: String,
/// The full ref it would merge into, e.g. `refs/heads/main`.
#[arg(long, value_name = "REF")]
target: String,
#[arg(long, value_name = "TEXT")]
title: String,
#[arg(long, value_name = "TEXT", default_value = "")]
body: String,
#[arg(long, short = 'i', value_name = "PATH")]
identity: Option<PathBuf>,
#[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
at: String,
},
/// List a repository's pull requests. Needs `read`.
List {
#[arg(value_name = "NS/REPO")]
repo: String,
/// `open`, `merged`, `closed` or `all`.
#[arg(long, value_name = "STATE", default_value = "all")]
state: String,
#[arg(long, short = 'i', value_name = "PATH")]
identity: Option<PathBuf>,
#[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
at: String,
},
/// Show one pull request. Needs `read`.
Get {
#[arg(value_name = "NS/REPO")]
repo: String,
#[arg(value_name = "N")]
number: u64,
#[arg(long, short = 'i', value_name = "PATH")]
identity: Option<PathBuf>,
#[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
at: String,
},
/// Close a pull request without merging it. Needs `write`.
Close {
#[arg(value_name = "NS/REPO")]
repo: String,
#[arg(value_name = "N")]
number: u64,
#[arg(long, short = 'i', value_name = "PATH")]
identity: Option<PathBuf>,
#[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
at: String,
},
}
/// `gunnar control issue …` — issues over the control plane, signed with the
/// caller's own key. The actor is who gunnar says the key is (`WhoAmI`).
#[cfg(feature = "control")]
#[derive(Debug, Subcommand)]
pub enum ControlIssueCommand {
/// Open an issue; prints the number the fold assigned. Needs `write`.
Open {
#[arg(value_name = "NS/REPO")]
repo: String,
#[arg(long, value_name = "TEXT")]
title: String,
#[arg(long, value_name = "TEXT", default_value = "")]
body: String,
#[arg(long, short = 'i', value_name = "PATH")]
identity: Option<PathBuf>,
#[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
at: String,
},
/// Comment on an issue. Needs `write`.
Comment {
#[arg(value_name = "NS/REPO")]
repo: String,
#[arg(value_name = "N")]
number: u64,
#[arg(long, value_name = "TEXT")]
body: String,
#[arg(long, short = 'i', value_name = "PATH")]
identity: Option<PathBuf>,
#[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
at: String,
},
/// Close an issue, naming why. Needs `write`.
Close {
#[arg(value_name = "NS/REPO")]
repo: String,
#[arg(value_name = "N")]
number: u64,
#[arg(long, value_name = "TEXT", default_value = "")]
reason: String,
#[arg(long, short = 'i', value_name = "PATH")]
identity: Option<PathBuf>,
#[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
at: String,
},
/// List a repository's issues. Needs `read`.
List {
#[arg(value_name = "NS/REPO")]
repo: String,
#[arg(long, short = 'i', value_name = "PATH")]
identity: Option<PathBuf>,
#[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
at: String,
},
/// Show one issue and its whole thread. Needs `read`.
Get {
#[arg(value_name = "NS/REPO")]
repo: String,
#[arg(value_name = "N")]
number: u64,
#[arg(long, short = 'i', value_name = "PATH")]
identity: Option<PathBuf>,
#[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
at: String,
},
}
/// `gunnar control review …` — a pull request's review over the control
/// plane, the events signed with the caller's own key.
#[cfg(feature = "control")]
#[derive(Debug, Subcommand)]
pub enum ControlReviewCommand {
/// The review: comments and who approves what, each approval re-checked by
/// gunnar. The same as `gunnar control show`. Needs `read`.
Get {
#[arg(value_name = "NS/REPO")]
repo: String,
#[arg(value_name = "N")]
pull: u64,
#[arg(long, short = 'i', value_name = "PATH")]
identity: Option<PathBuf>,
#[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
at: String,
},
/// Comment on a pull request — on one line of one file with `--path`,
/// `--line` and `--blob`, or on the whole pull request without. Needs
/// `write`.
Comment {
#[arg(value_name = "NS/REPO")]
repo: String,
#[arg(value_name = "N")]
pull: u64,
#[arg(long, value_name = "TEXT")]
body: String,
/// The file the comment anchors to.
#[arg(long, value_name = "PATH", requires_all = ["line", "blob"])]
path: Option<String>,
/// The line in that file (1-based).
#[arg(long, value_name = "N")]
line: Option<u32>,
/// The blob the line is in, so the comment follows the code.
#[arg(long, value_name = "OID")]
blob: Option<String>,
#[arg(long, short = 'i', value_name = "PATH")]
identity: Option<PathBuf>,
#[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
at: String,
},
/// Approve a specific commit — the one `gunnar control merge` will move
/// the target to. Needs `write`.
Approve {
#[arg(value_name = "NS/REPO")]
repo: String,
#[arg(value_name = "N")]
pull: u64,
#[arg(long, value_name = "OID")]
commit: String,
#[arg(long, short = 'i', value_name = "PATH")]
identity: Option<PathBuf>,
#[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
at: String,
},
}
/// `gunnar control events …`
#[cfg(feature = "control")]
#[derive(Debug, Subcommand)]
pub enum ControlEventsCommand {
/// **Print the live feed**, one line per event, until interrupted (or
/// `--count` events). With a server-wide `read`: everything, narrowed by
/// the flags. Without one: naming no `--namespace` watches your own
/// accounts (and accounts whose events are public); naming some asks for
/// exactly those, and gunnar refuses an account you may not watch.
Watch {
/// An account to watch. Repeatable.
#[arg(long, value_name = "NS")]
namespace: Vec<String>,
/// One repository, `<namespace>/<repo>`. Repeatable.
#[arg(long, value_name = "NS/REPO")]
repo: Vec<String>,
/// Only these kinds, e.g. `ref-updated`, `repo-created`,
/// `pull-request-opened`. Repeatable.
#[arg(long, value_name = "KIND")]
kind: Vec<String>,
/// Stop after this many events (the stream's own prologue not counted).
#[arg(long, value_name = "N")]
count: Option<u64>,
/// Resume after this sequence number, as far as the server still holds.
#[arg(long, value_name = "SEQ", default_value_t = 0)]
from_seq: u64,
#[arg(long, short = 'i', value_name = "PATH")]
identity: Option<PathBuf>,
#[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
at: String,
},
}
/// `gunnar control grant list` — who holds what.
#[cfg(feature = "control")]
#[derive(Debug, Clone, Subcommand)]
pub enum ControlGrantCommand {
/// **Every grant on the server, per principal** — the answer to "what can
/// this principal see?".
///
/// One line per grant under the principal that holds it: the scope
/// (`server`, `namespace:<name>`, `repo:<namespace>/<repo>`) and the
/// level. A principal's OWN account does not appear: everyone is admin of
/// `<their-name>/…` by construction, and that is not a grant anybody gave
/// or can revoke.
List {
/// Only this principal's grants.
#[arg(long, value_name = "ID")]
principal: Option<String>,
#[arg(long, short = 'i', value_name = "PATH")]
identity: Option<PathBuf>,
#[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
at: String,
},
}
/// `gunnar control tunnel …` — a box's WireGuard tunnel address.
#[cfg(feature = "control")]
#[derive(Debug, Clone, Subcommand)]
pub enum ControlTunnelCommand {
/// What the box holds: its WireGuard public key, its tunnel address, and
/// whether `wg0` is up (`Bootstrap.AssignTunnelAddress` with no address).
Show {
/// The box's control plane.
#[arg(
long,
value_name = "HOST:PORT",
conflicts_with = "ssh",
required_unless_present = "ssh"
)]
at: Option<String>,
/// A SEALED box's SSH door instead (`serve --no-grpc`): the
/// `gunnar-tunnel-address` verb, as a gunnar-admin (`-i`).
#[arg(long, value_name = "HOST:PORT")]
ssh: Option<String>,
/// The gunnar-admin key for `--ssh`.
#[arg(long, short = 'i', value_name = "PATH")]
identity: Option<PathBuf>,
},
/// **Give a fresh box its tunnel address and register it on the hub.**
/// Idempotent: the same box gets the same address back.
Enrol {
/// The box's control plane (gRPC). A fresh box takes the address on
/// `Bootstrap.AssignTunnelAddress`, an enrolled one on
/// `Admin.SetTunnelAddress` (needs `-i`).
#[arg(
long,
value_name = "HOST:PORT",
conflicts_with = "ssh",
required_unless_present = "ssh"
)]
at: Option<String>,
/// A SEALED box instead (`serve --no-grpc --admin-key …`, no control
/// plane): its SSH door, `gunnar-tunnel-address`, as the gunnar-admin
/// whose key `-i` names.
#[arg(long, value_name = "HOST:PORT")]
ssh: Option<String>,
/// The site's tunnel subnet, `a.b.c.d/prefix`. Its first host is the
/// hub's and is never handed out.
#[arg(long, value_name = "CIDR")]
pool: String,
/// The site's ledger of which box holds which address (created if absent).
#[arg(long, value_name = "PATH")]
ledger: PathBuf,
/// The hub's WireGuard config; the box's `[Peer]` is appended once.
#[arg(long, value_name = "PATH")]
hub_peers: PathBuf,
/// A name for the box in the ledger and the hub's comment (default: `--at`).
#[arg(long)]
label: Option<String>,
/// A gunnar-admin key: for `--ssh`, and for a gRPC box PAST its
/// bootstrap window (then the address goes over `Admin.SetTunnelAddress`).
#[arg(long, short = 'i', value_name = "PATH")]
identity: Option<PathBuf>,
},
}
/// `gunnar control twin …` — the twin, and the restore from it.
#[cfg(feature = "control")]
#[derive(Debug, Clone, Subcommand)]
pub enum ControlTwinCommand {
/// **Make a FRESH box a twin** (`Bootstrap.BecomeTwin` — refused once
/// anything is enrolled, and taken only under a credential from a key the
/// box was FED: the installer's admin, an admin principal of its feed, or
/// `--restore-pubkey`; h-bootstrap-open). Prints the twin's public key:
/// enrol it on the primary as a machine in group `twin`.
Join {
/// The fresh box's control plane.
#[arg(long, value_name = "HOST:PORT")]
at: String,
/// The primary's control plane, as the TWIN reaches it.
#[arg(long, value_name = "HOST:PORT")]
primary: String,
/// A key the fresh box was fed (it signs the call).
#[arg(long, short = 'i', value_name = "PATH")]
identity: Option<PathBuf>,
},
/// **Give a twin its mutual-TLS credential** — the pairing step the one
/// medium cannot carry. Asks the TWIN for a signing request (its key is
/// minted there and never leaves), has the PRIMARY's issuer sign it for
/// `--principal` (Admin; the principal must be in group `twin`), and hands
/// the certificate and the issuer to pin back to the twin. Run it after the
/// twin's key is enrolled on the primary. Run it again under the same
/// issuer to RE-ISSUE it, without a shell on either box.
Certify {
/// The twin's control plane.
#[arg(long, value_name = "HOST:PORT")]
at: String,
/// The primary's control plane, as THIS machine reaches it.
#[arg(long, value_name = "HOST:PORT")]
primary_at: String,
/// The twin's principal on the primary.
#[arg(long, default_value = "twin-box")]
principal: String,
/// An admin key on the primary.
#[arg(long, short = 'i', value_name = "PATH")]
identity: Option<PathBuf>,
},
/// **Restore a fresh primary from its twin, over the wire**: the twin's
/// `Mirror.LogFetch` piped into the fresh box's `Bootstrap.Restore`
/// (no credential there — it is fresh). Restart the box afterwards.
Replay {
/// The twin's control plane.
#[arg(long, value_name = "HOST:PORT")]
twin: String,
/// The fresh box's control plane.
#[arg(long, value_name = "HOST:PORT")]
into: String,
/// The key to read the twin's log with (an admin of the set).
#[arg(long, short = 'i', value_name = "PATH")]
identity: Option<PathBuf>,
/// **Do not tell the box how much is coming, so it cannot refuse for room.**
///
/// By default the replay announces the twin's log size and the box checks its own
/// data set against it before the first byte lands, refusing by name when it is
/// short — the restore-onto-smaller-hardware case. That check is deliberately
/// conservative: it asks for twice the LOG, and a long-lived box's log is larger
/// than the stores it builds (superseded documents, deleted packs — what
/// `twin retain` exists to trim). So a legitimate restore of an old, busy box onto
/// the same hardware can be refused.
///
/// This says "I have looked at the numbers in the refusal and this set holds it".
/// The restore then behaves as it always did: if the volume really does fill, the
/// apply fails mid-stream, the box stays `restoring`, and the replay is resumable
/// onto a bigger set. It is never a silent short restore either way.
#[arg(long)]
ignore_room: bool,
/// **Restore from this twin even though it is not a current copy.**
///
/// By default the replay asks the twin how current it is and REFUSES
/// when the answer is not "level with its primary, as of moments ago":
/// a twin that is behind, that is not following, or whose primary last
/// answered longer ago than the heartbeat's silent bound. The refusal
/// carries both numbers (applied / the primary's head) and the AGE, so
/// the operator is refusing or overriding on facts.
///
/// The twin exists for the worst case, and the worst case is exactly
/// when nobody has time to notice that the copy stopped following a
/// week ago. Restoring from a stale twin is sometimes still the right
/// call — it is the only copy there is — so this says "I have read the
/// numbers in the refusal and I want this copy anyway". It is never a
/// silent stale restore either way.
#[arg(long)]
stale_ok: bool,
/// **Take the replay although the twin no longer holds its oldest entries.** A twin
/// that trimmed its log (`twin retain`) and keeps no snapshot of what it trimmed
/// cannot give those entries back; by default the replay refuses by name, saying how
/// many and what goes with them (DATA-SET-GROWTH-FLOW §2.1), before a byte streams.
#[arg(long)]
accept_lost: bool,
/// **How long a twin that was LEVEL at its last answer may have heard nothing from its
/// primary before plain replay refuses it** (`--stale-ok` then required). Default 24 h.
///
/// From the twin, a primary that died and a network cut to a primary still alive and
/// taking writes look the same: level, then silence. Minutes of that is main dying; a
/// day of it is not something to assume. `now` refuses any silence; `<N>` seconds, or
/// `<N>` with a `s`/`m`/`h`/`d`/`w` suffix.
#[arg(long, value_name = "DURATION", default_value = "24h", value_parser = parse_max_silence)]
max_silence: std::time::Duration,
/// **The twin's old primary is fenced (or its machine stopped): replay beside it.**
///
/// A twin that heard its primary within the last minute is following a box that is
/// ALIVE; filling a fresh box from it would make two writable primaries, so plain
/// replay refuses `not_fenced`. Fence the old primary first (`Mirror.SetRole` standby
/// and `DemoteReplication`, or stop it) and say so here: a fenced primary still
/// answers its twin, so the twin cannot tell. A primary re-laid at the same address
/// reads `diverged` and needs no fence.
#[arg(long)]
primary_fenced: bool,
},
/// **Follow a primary.** Fetch every transaction it has that this data
/// root has not, in order, apply it, keep polling. Needs server Admin on
/// the primary: the log carries every sealed secret.
Follow {
/// The primary's control plane.
#[arg(long, value_name = "HOST:PORT")]
at: String,
/// This twin's data root — its own copy of everything, plus the log.
#[arg(long, value_name = "DIR")]
data_root: PathBuf,
/// The private key to authenticate with.
#[arg(long, short = 'i', value_name = "PATH")]
identity: Option<PathBuf>,
/// Seconds between polls.
#[arg(long, default_value_t = 5, value_name = "SECS")]
poll_secs: u64,
/// One poll, then exit — a cron, a bench, a hand check.
#[arg(long)]
once: bool,
},
/// **Restore: replay a twin's log into a fresh data root.** The restored
/// primary continues the same sequence.
Restore {
/// The twin's data root (its `txlog/` is what is replayed).
#[arg(long, value_name = "DIR")]
from: PathBuf,
/// The fresh data root to rebuild into.
#[arg(long, value_name = "DIR")]
into: PathBuf,
},
/// **What a box's log says**: head, oldest retained, and on a twin what
/// it has applied and whether it is caught up.
Status {
/// The box's control plane.
#[arg(long, value_name = "HOST:PORT")]
at: String,
/// The private key to authenticate with.
#[arg(long, short = 'i', value_name = "PATH")]
identity: Option<PathBuf>,
},
/// **Retention** (`Mirror.LogRetain`, server Admin): drop every whole
/// segment of the PRIMARY's log below `--from` (default: everything up to
/// the head). A twin that has not applied up to there takes a snapshot.
Retain {
/// The primary's control plane.
#[arg(long, value_name = "HOST:PORT")]
at: String,
/// The oldest sequence to keep; 0 = keep nothing older than the next append.
#[arg(long, default_value_t = 0, value_name = "SEQ")]
from: u64,
/// **Retain past what a twin has applied** (`LogRetainRequest.allow_past_twin`). By
/// default the primary refuses to drop a frame the lowest-reporting twin has not applied,
/// naming both numbers. This says the twin is gone or the hole is the point (a drill);
/// the audit trail records who said it.
#[arg(long)]
allow_past_twin: bool,
/// The private key to authenticate with.
#[arg(long, short = 'i', value_name = "PATH")]
identity: Option<PathBuf>,
},
/// **CATCH A TWIN UP THROUGH A RETENTION HOLE** — the one verb that drives
/// `Mirror.SnapshotFetch` on purpose (`.nornir/twin-transaction-log.md`
/// §Snapshot; the KVM row R8 in the same file).
///
/// A twin that was away longer than the primary's retention window meets a
/// HOLE: the next sequence it needs, `applied + 1`, is below the primary's
/// `first_seq`, and no `LogFetch` can ever answer for it again
/// (`mirror.proto`'s `LogFetch`: "`from` below `first_seq` is
/// FAILED_PRECONDITION naming `first_seq`: the twin needs a snapshot, not a
/// range"). The only way across is a full copy of the primary's state at one
/// sequence, and this is the verb that takes one.
///
/// **It freezes the primary, and that is why it is its own verb.** The
/// producer holds `Log::freeze()` for the whole of `SnapshotFetch`
/// (`crates/gunnar-grpc/src/service/mirror.rs:117`), so while the snapshot
/// streams EVERY framed write on the primary is stopped — every push, every
/// ref update, every control-plane mutation. A 44 GiB snapshot legitimately
/// takes a long time and the freeze is the cost of a consistent `at`; it is
/// never a cost to incur by accident. So:
///
/// * it REFUSES BY NAME when there is no hole (`no-hole`), because a twin
/// that is merely behind catches up with `twin follow`, which freezes
/// nothing;
///
/// * it reports how long the primary's writes were frozen, as a number;
///
/// * it bounds the wait: no piece for `--stall-secs` fails by name
/// (`stalled`) and drops the stream, which is what releases the freeze.
///
/// Run it WHERE THE TWIN'S DISK IS, like `twin follow`: `--at` is the
/// PRIMARY's control plane, `--data-root` is this box's own data root. The
/// twin's own follower must not be running against that root at the same
/// time.
///
/// ```text
/// gunnar control twin catch-up --at primary:50051 --data-root /data
/// ```
///
/// `--dry-run` establishes the hole and stops, so the decision to stop the
/// primary's write path is made before it happens.
CatchUp {
/// **The PRIMARY's control plane** — the box the snapshot is taken
/// from, and the box whose writes freeze while it streams.
#[arg(long, value_name = "HOST:PORT")]
at: String,
/// **This twin's data root**, on this machine. The snapshot is staged
/// in it (`txlog-snapshot-staging/`) and installed over it.
#[arg(long, value_name = "DIR")]
data_root: PathBuf,
/// The private key to authenticate with: server Admin on the primary,
/// or the twin's own machine key in group `twin` — a snapshot carries
/// every sealed secret and every object.
#[arg(long, short = 'i', value_name = "PATH")]
identity: Option<PathBuf>,
/// **Give up when NO snapshot piece has arrived for this long.** The
/// deadline is on no progress, never on the transfer: a slow primary
/// that keeps sending pieces is never cut off. The default is the
/// primary's own mirror-image guard
/// (`gunnar_grpc::service::mirror::SNAPSHOT_STALL_TIMEOUT`, 120 s),
/// which is what cuts a twin off that stops READING.
#[arg(long, default_value_t = 120, value_name = "SECS")]
stall_secs: u64,
/// **How many times the whole stream may be restarted.**
///
/// `SnapshotFetchRequest` has no fields (`mirror.proto:165`), so there
/// is no cursor on the wire and a broken stream can only be taken
/// again FROM THE BEGINNING — which re-freezes the primary from
/// scratch. Each restart is announced. A stall is never retried.
#[arg(long, default_value_t = 3, value_name = "N")]
attempts: u32,
/// Establish whether there is a hole and say so, then stop. Nothing is
/// streamed and the primary is never frozen.
#[arg(long)]
dry_run: bool,
},
}
/// `gunnar control replication …` — replication's standbys.
#[cfg(feature = "control")]
#[derive(Debug, Clone, Subcommand)]
pub enum ControlReplicationCommand {
/// **Add or replace a standby, by name.** Takes effect immediately and
/// survives a reboot.
///
/// Idempotent: the same `<name>` twice replaces rather than duplicates, so
/// a retry over a flaky link cannot leave two standbys behind.
Add {
/// Your name for this standby. Every event, log line and status row
/// identifies it by this rather than by its URL.
name: String,
/// Where it is: an SSH base — `ssh://git@standby:2222` — onto which
/// `/<namespace>/<repo>.git` is appended, or a complete URL ending in
/// `.git` for a `--repo`-scoped target.
///
/// SSH only, and anything else is refused by name: the push half is a
/// send-pack over the server's russh transport, which has no HTTP
/// transport to dial with.
remote: String,
/// Scope it to one namespace instead of the whole server.
#[arg(long, value_name = "NAME", conflicts_with = "repo")]
namespace: Option<String>,
/// Scope it to one repository, `<namespace>/<repo>`.
#[arg(long, value_name = "NS/REPO")]
repo: Option<String>,
/// **The deploy key the SERVER authenticates to the standby with**, as
/// a path ON THE SERVER.
///
/// A server has no ssh-agent and no interactive user. Without this the
/// push falls back to the client key ladder, which on a server is
/// almost never what was meant — and the server warns about it at
/// every start-up.
#[arg(long, value_name = "PATH")]
identity_path: Option<String>,
/// Override the `known_hosts` file for this target, as a path on the
/// server.
#[arg(long, value_name = "PATH")]
known_hosts_path: Option<String>,
/// Refuse an unknown host key instead of trusting it on first use.
/// Off by default so a standby can be brought up without a
/// provisioning step; on is right for anything that outlives an
/// afternoon.
#[arg(long)]
strict_host_key: bool,
/// **Which repositories, by visibility.** `all` (the default) is a
/// standby: everything the scope covers. `public-only` is a browse
/// replica's feed: a private repository is
/// never pushed there, and one that goes private is DELETED there —
/// which is why `public-only` also needs `--control`.
#[arg(long, value_enum, default_value_t = ReplicationScopeArg::All)]
scope: ReplicationScopeArg,
/// The replica's control plane, `host:port`, REQUIRED with
/// `--scope public-only`: the delete on a public → private flip is
/// `Repos.Delete` over this address, signed with `--identity-path`.
#[arg(long, value_name = "HOST:PORT", required_if_eq("scope", "public-only"))]
control: Option<String>,
#[arg(long, short = 'i', value_name = "PATH")]
identity: Option<PathBuf>,
#[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
at: String,
},
/// Remove a standby by name and stop replicating to it now.
///
/// A name that is not configured is an error and not a quiet success:
/// "removed" about a standby that was never there reads identically to
/// "removed" about the one you meant, and the difference is whether your
/// data is still going somewhere.
Remove {
name: String,
#[arg(long, short = 'i', value_name = "PATH")]
identity: Option<PathBuf>,
#[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
at: String,
},
/// **DEMOTE this box: drop EVERY standby at once — "I am no longer a
/// primary".**
///
/// The failover primitive. When a primary dies and its standby is promoted
/// (promotion is operational — a gunnar standby is already a full gunnar
/// that serves reads and takes writes, so nothing on it flips), the
/// recovered old box comes back up and serving but its
/// `replication.json` still lists the box that is now the NEW primary. Its
/// replicator then resumes pushing its now-stale state and
/// **force-clobbers the new primary's post-failover writes** — MEASURED in
/// `deploy/checks/failover-drill.sh`, where a client commit made against the
/// promoted standby was overwritten back to the old primary's ref.
///
/// Run this on the recovered box before re-attaching it: it clears the whole
/// target roster in one write, so the box stops being a primary. Then point
/// the NEW primary at it with `replication add` and `replication resync`, and
/// the pair is whole again — replicating the other way.
///
/// It does not touch the box's repositories or its own control plane: a
/// demoted box still serves git and still takes writes. It has only stopped
/// shipping a second copy anywhere. Idempotent — a box with no targets
/// demotes to the same place, so it is safe to run again.
Demote {
#[arg(long, short = 'i', value_name = "PATH")]
identity: Option<PathBuf>,
#[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
at: String,
},
/// **Make this box a BROWSE replica**: a
/// read-only standby that also serves anonymous reads of public
/// repositories, refuses every control-plane mutator, and CANNOT be
/// promoted — not by `promote`, not by `abdicate --promote`, not by the
/// passive heartbeat. Feed it from the primary with
/// `replication add <name> <remote> --scope public-only --control <this
/// box>:50051`. The way back is `standby`, which only narrows what it
/// serves.
Browse {
#[arg(long, short = 'i', value_name = "PATH")]
identity: Option<PathBuf>,
#[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
at: String,
},
/// **ABDICATE: this master steps down cleanly, then hands off.**
///
/// The voluntary counterpart to a crash. `demote` is what you run on a box
/// that already died; `abdicate` is how a healthy master leaves without
/// losing a write, in order:
///
/// 1. **freeze** — this box stops taking client writes (role → standby),
/// so nothing lands here after the hand-off begins;
/// 2. **flush** — every standby is resynced and this waits for the queues
/// to drain, so the standby has everything that was pending. The wait
/// is a poll that returns the instant every queue is empty, bounded by
/// a ceiling (default 30 s) so a dead standby cannot wedge the hand-off.
/// On a heavily loaded box a *healthy* standby's backlog can take longer
/// than 30 s; raise the ceiling with `GUNNAR_ABDICATE_FLUSH_SECS=<secs>`
/// in the serving box's environment (a blank/zero/garbage value keeps
/// the 30 s default);
/// 3. **demote** — the roster is cleared, so this box can never later
/// clobber the new primary.
///
/// Then, as abdication's LAST act, it promotes the new primary — IF you name
/// where its control plane is with `--promote`. That is the takeover: the
/// standby is told `SetRole(PRIMARY)` and begins taking writes. Without
/// `--promote` the three local steps still run and the command prints the
/// one `replication promote` line to run against the standby yourself —
/// naming a new primary is a decision, so it is never silently assumed.
///
/// **The traffic switch is separate and is infrastructure.** The front
/// (DNS / the UpCloud DNAT) still has to point at the new primary; abdication
/// makes the hand-off safe, it does not move the address.
Abdicate {
/// The NEW primary's control plane, `host:port` — the standby to promote
/// as abdication's last act. Omit to run only the local freeze/flush/
/// demote and be told the `promote` command to run yourself.
#[arg(long, value_name = "ADDR")]
promote: Option<String>,
#[arg(long, short = 'i', value_name = "PATH")]
identity: Option<PathBuf>,
#[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
at: String,
},
/// **PROMOTE this box to a writable primary — the takeover flip.**
///
/// A standby serves reads and, once promoted, takes writes. Run it on the
/// standby after the old master abdicated or died: the box records that it
/// is a primary (persisted, so it survives a reboot) and thaws the write
/// freeze. Idempotent — promoting a box that is already a primary is a
/// no-op that prints the same "primary" status.
///
/// The old master's `abdicate --promote <this box>` runs exactly this for
/// you; this verb is the manual half, and the one a passive-heartbeat
/// standby would call for itself when it decides the master is gone.
Promote {
#[arg(long, short = 'i', value_name = "PATH")]
identity: Option<PathBuf>,
#[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
at: String,
},
/// **STANDBY: make this box read-only to clients.**
///
/// A standby serves reads and accepts only its primary's replication push (a
/// `Machine` identity); a direct `git push` from a person or a deploy key is
/// refused with "push to the primary". This is how a second box is
/// deliberately made a standby up front, so a client that is mis-pointed at
/// it cannot leave a write the primary never sees. `promote` is the inverse.
Standby {
#[arg(long, short = 'i', value_name = "PATH")]
identity: Option<PathBuf>,
#[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
at: String,
},
/// What is configured, and what each standby has actually done — including
/// **"last successful replication: never"**.
///
/// Cheap by default: it reports the roster, each standby's
/// synced/failed/pending, the verdict word and each repository's copy state
/// without opening a single repository store. The one expensive number —
/// **how many refs live on this box alone** — is behind `--refs`, because
/// counting it opens every repository's reference database.
List {
/// **Also count the refs a mirror does not carry**, per repository and
/// server-wide. This opens every repository's reference database — one
/// refold each on a cold box — so it is off unless you ask: reach for it
/// when you want the durability shortfall, not on every status glance.
#[arg(long)]
refs: bool,
#[arg(long, short = 'i', value_name = "PATH")]
identity: Option<PathBuf>,
#[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
at: String,
},
/// **BACKFILL a standby with what it never got.**
///
/// Adding a standby copies NOTHING. Replication rides the push bus, so a
/// standby holds what was pushed after it existed — a repository's whole
/// history arrives on its next push, and a repository nobody pushes to
/// again never arrives at all. MEASURED on the reference appliance 2026-08-31: a
/// standby configured against 22 existing repositories held **0** of them,
/// and `alice/linux-mirror` had 3818 refs on the primary and none on the
/// standby.
///
/// This queues them. It does not copy anything itself: every byte still
/// moves on the standby's own background worker, through the same bounded
/// queue and the same backoff a push uses, so a backfill never blocks a
/// push and never waits for one. Watch it with `replication list` — the
/// `pending` count falls, and the row names the repository in flight.
///
/// **It is safe to run again.** A repository already outstanding coalesces;
/// one the standby already has costs one ref advertisement and an empty
/// command list. Interrupted half way — a reboot, a dead standby, a network
/// that went away — the answer is to run it again.
///
/// **The hash.** A standby PUSH-CREATES sha1 — the first-push prospect since
/// 2026-09-20, because git advertises the object format before the client
/// says anything and sha1 is what an un-negotiated client assumes. A
/// repository of any OTHER hash replicates to nothing until it is
/// pre-created on the standby with
/// `control repo create <ns>/<repo> --hash sha256`. Those repositories are
/// listed by name rather than left to fail quietly.
Resync {
/// The standby, by name, exactly as `list` prints it. A name that is
/// not configured is an error: "resynced" about a standby that was
/// never there reads identically to "resynced" about the one you meant.
name: String,
/// Only this repository, `<namespace>/<repo>`.
///
/// Without it, every repository the standby's scope covers. With it,
/// one — which is how a repository too large to ship in this maintenance
/// window stays a separate decision from all the others.
#[arg(long, value_name = "NS/REPO")]
repo: Option<String>,
/// Everything the standby covers **except** these. Repeatable.
///
/// The other half of `--repo`, and the one you actually reach for. On
/// the reference appliance 24 of the 25 repositories are a few hundred
/// megabytes together and one is ~8.7 GB and 13.9 M objects that
/// projects **182–235 GiB** against a standby whose `/data` is
/// **224.9 GiB**. Without this the only way to ship the safe
/// twenty-four is one call each, and a box with no shell is exactly
/// what this control plane is for.
///
/// Ignored when `--repo` names one. An exclusion that matched nothing
/// is reported rather than passed over: a typo and a real exclusion are
/// the same command with very different outcomes.
#[arg(long, value_name = "NS/REPO")]
except: Vec<String>,
#[arg(long, short = 'i', value_name = "PATH")]
identity: Option<PathBuf>,
#[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
at: String,
},
/// **Flush — the replication watermark.** Push everything pending to
/// every standby, wait (bounded), and report whether the twin is caught
/// up: the proof step 0 of a data-set growth needs before `/data` is
/// unmounted. Changes no role and no roster.
///
/// What it proves: every push the replicator saw is on the twin. What
/// it does NOT prove: refs outside heads/tags, LFS objects, the control
/// database, visibility, tombstones — and repositories nobody pushed
/// since the twin existed, unless `--resync` enqueues them all.
Flush {
/// How long to wait for the queues to drain; 0 = the abdicate ceiling.
#[arg(long, default_value_t = 0, value_name = "SECS")]
timeout_secs: u32,
/// Enqueue EVERY repository first (a full backfill).
#[arg(long)]
resync: bool,
#[arg(long, short = 'i', value_name = "PATH")]
identity: Option<PathBuf>,
#[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
at: String,
},
}
/// `gunnar control grow …` — the runtime data-set growth
/// (`DATA-SET-GROWTH-FLOW.md` §3, ruling 1: the appliance never goes down).
#[cfg(feature = "control")]
#[derive(Subcommand, Debug, Clone)]
pub enum ControlGrowCommand {
/// **Begin a growth.** gunnar-admin. Drains the box (new pushes are
/// refused with "gunnar.rs is growing its storage…"), unmounts `/data`,
/// then WAITS for the outside actor to detach, enlarge and re-attach
/// every member volume; when all are back and larger it grows in place,
/// mounts and serves again. `grow status` follows it; `grow resume` says
/// "look again".
///
/// Mints an OPERATOR go-ahead (the signed fact D3 asks for): the
/// gunnar-admin credential on this call is the approval. `--go-ahead`
/// sends one minted elsewhere (monetize's, on a metered box) instead.
Start {
/// The per-member size to grow to, GiB; without it, whatever every
/// member comes back larger at.
#[arg(long, value_name = "GIB")]
target_gib: Option<u64>,
/// How long in-flight transfers may take to finish before the growth
/// aborts with nothing changed (never kills a transfer).
#[arg(long, default_value_t = 600, value_name = "SECS")]
drain_timeout_secs: u32,
/// How long `/data` may stay unmounted waiting for the members. Past it,
/// with every member present, the set is mounted back at the size the
/// members carry and the growth fails as `grow.unmounted_too_long`
/// (alarmed on `Admin.Health`); with a member missing it keeps waiting,
/// alarmed.
#[arg(long, default_value_t = 3600, value_name = "SECS")]
wait_members_timeout_secs: u32,
/// A go-ahead file to send verbatim instead of minting an operator one.
#[arg(long, value_name = "PATH")]
go_ahead: Option<PathBuf>,
#[arg(long, short = 'i', value_name = "PATH")]
identity: Option<PathBuf>,
#[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
at: String,
},
/// The growth's phase, the members and the journal. **Needs no
/// credential** — the actor outside the box that owns the volumes reads
/// this while the identity table is off the stripe.
Status {
#[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
at: String,
},
/// **Look again**: the members are back and larger. gunnar-admin.
/// `--give-up` stops waiting, assembles what is there at whatever size it
/// is, and serves.
Resume {
#[arg(long)]
give_up: bool,
#[arg(long, short = 'i', value_name = "PATH")]
identity: Option<PathBuf>,
#[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
at: String,
},
}
/// `gunnar control webhook …` — the event stream's outbound delivery, over the control
/// plane.
///
/// Each verb returns the endpoint roster AND the server's webhook signing
/// public key — the OpenSSH line a receiver pins to verify a delivery with
/// `ssh-keygen -Y verify`. The private half never leaves the box.
#[cfg(feature = "control")]
#[derive(Debug, Clone, Subcommand)]
pub enum ControlWebhookCommand {
/// **Add or replace an endpoint, by name.** Takes effect immediately and
/// survives a reboot.
///
/// Idempotent: the same `<name>` twice replaces rather than duplicates.
Add {
/// Your name for this endpoint. Every log line and status row names it
/// by this rather than by its URL.
name: String,
/// Where deliveries are POSTed: `http://…` or `https://…`.
url: String,
/// Only deliver these event kinds (repeatable, or comma-separated),
/// e.g. `ref.updated`. Omit for every kind. A name that matches no kind
/// is refused — a filter that names nothing delivers nothing.
#[arg(long, value_name = "KIND", value_delimiter = ',')]
kinds: Vec<String>,
/// Only deliver events about this namespace.
#[arg(long, value_name = "NAME", conflicts_with = "repo")]
namespace: Option<String>,
/// Only deliver events about this repository, `<namespace>/<repo>`.
#[arg(long, value_name = "NS/REPO")]
repo: Option<String>,
/// An HMAC shared secret, for a receiver written against GitHub's
/// `X-Hub-Signature-256`. The `sshsig` is always sent and is the one to
/// prefer; this is only for compatibility. Stored on the server, never
/// shown again.
#[arg(long, value_name = "SECRET")]
secret: Option<String>,
#[arg(long, short = 'i', value_name = "PATH")]
identity: Option<PathBuf>,
#[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
at: String,
},
/// What is configured, plus the signing public key a receiver verifies
/// deliveries with, and per-endpoint delivered/failed/dropped counters.
List {
#[arg(long, short = 'i', value_name = "PATH")]
identity: Option<PathBuf>,
#[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
at: String,
},
/// Remove an endpoint by name and stop delivering to it now.
///
/// A name that is not configured is an error, not a quiet success:
/// "removed" about an endpoint that was never there reads identically to
/// removing the one you meant.
Remove {
name: String,
#[arg(long, short = 'i', value_name = "PATH")]
identity: Option<PathBuf>,
#[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
at: String,
},
}
/// `gunnar control policy …` — the pre-receive gate, over the control plane.
///
/// **The asymmetry these close.** Since a merge started requiring a verified
/// signed approval naming the exact commit, the review gate has been real —
/// and a direct push to `main` walked straight past it, because the surface
/// that would have stopped it was reachable from nothing. `Policy.*` has been
/// served since `gunnar_core::PolicyStore` gave the document a home at
/// `refs/gunnar/policy`; these verbs are the half that lets anyone say so.
///
/// A rule is stored **per repository, keyed by ref pattern**, and the pattern
/// is a git-style glob over full ref names — `refs/heads/main`,
/// `refs/heads/release/*`. It is not a branch name: `main` matches no ref and
/// protects nothing, which is why `protect` refuses it by name.
#[cfg(feature = "control")]
#[derive(Debug, Clone, Subcommand)]
pub enum ControlPolicyCommand {
/// **Protect refs matching a pattern.** Upserted on the pattern, so the
/// same pattern twice replaces rather than accumulates.
///
/// The defaults are the strict ones: with no flags at all a matching ref
/// cannot be force-pushed and cannot be deleted. Each `--allow-*` gives one
/// of those back, which makes the permissive choice the one that had to be
/// typed.
Protect {
/// `<account>/<name>`.
path: String,
/// A git-style glob over FULL ref names — `refs/heads/main`,
/// `refs/heads/release/*`. Not a branch name.
pattern: String,
/// Permit a non-fast-forward update. Off by default.
#[arg(long)]
allow_force_push: bool,
/// Permit deleting a matching ref. Off by default.
#[arg(long)]
allow_delete: bool,
/// Every new commit must carry a valid ssh-sig from a key registered
/// to its pusher.
#[arg(long)]
require_signature: bool,
/// Only these principals may push here. Repeatable. Empty — the
/// default — means "any principal with WRITE".
#[arg(long, value_name = "ID")]
principal: Vec<String>,
/// Reject an object larger than this BEFORE it lands. 0 is no limit.
#[arg(long, value_name = "BYTES", default_value_t = 0)]
max_object_bytes: u64,
/// Reject a push carrying more objects than this. 0 is no limit.
#[arg(long, value_name = "N", default_value_t = 0)]
max_objects_per_push: u64,
/// A merge into a matching ref does not count the pull request
/// AUTHOR's own approval. Off by default: a solo operator must stay
/// able to merge.
#[arg(long)]
forbid_self_approval: bool,
/// A merge into a matching ref needs this many DISTINCT verified
/// approvers of the exact tip. Approvals by the same principal count
/// once. 0 keeps the baseline: one verified approval.
#[arg(long, value_name = "N", default_value_t = 0)]
required_approvals: u32,
#[arg(long, short = 'i', value_name = "PATH")]
identity: Option<PathBuf>,
#[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
at: String,
},
/// **Remove the rule on a pattern**, exactly as `list` prints it.
///
/// A pattern that has no rule is an error and not a quiet success:
/// "unprotected" about a rule that was never there reads identically to
/// "unprotected" about the one you meant, and the difference is whether
/// `main` is still guarded.
Unprotect {
/// `<account>/<name>`.
path: String,
/// The pattern, byte for byte as `list` prints it.
pattern: String,
#[arg(long, short = 'i', value_name = "PATH")]
identity: Option<PathBuf>,
#[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
at: String,
},
/// What is protected here — and **"nothing"** said as the word.
///
/// An unprotected repository and a repository whose rules failed to load
/// are the same empty list and very different facts, so the empty case
/// says which one it is.
List {
/// `<account>/<name>`.
path: String,
/// Only the rule on this pattern. Without it, every rule.
#[arg(long, value_name = "GLOB")]
pattern: Option<String>,
#[arg(long, short = 'i', value_name = "PATH")]
identity: Option<PathBuf>,
#[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
at: String,
},
/// The ref-name allowlist/denylist. **Deny wins.**
RefNames {
/// `<account>/<name>`.
path: String,
#[arg(long, short = 'i', value_name = "PATH")]
identity: Option<PathBuf>,
#[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
at: String,
},
/// **Replace** the ref-name allowlist/denylist. Deny wins.
///
/// Replace and not merge: the document is written whole, so passing
/// neither flag CLEARS the policy rather than leaving the old one in
/// place. That is the only version of this verb whose result can be
/// predicted from the command line alone, and it says so before it writes.
SetRefNames {
/// `<account>/<name>`.
path: String,
/// A ref name may match one of these. Repeatable. Empty means "any
/// name that is not denied".
#[arg(long, value_name = "GLOB")]
allow: Vec<String>,
/// A ref name matching one of these is refused, even if it is also
/// allowed. Repeatable.
#[arg(long, value_name = "GLOB")]
deny: Vec<String>,
#[arg(long, short = 'i', value_name = "PATH")]
identity: Option<PathBuf>,
#[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
at: String,
},
/// The gates that run on this repository's push path, and **which of them
/// this repository has configuration for**.
///
/// `enabled` is not "compiled in": a gate with nothing configured cannot
/// reject anything, and a list that claimed otherwise would be a UI
/// rendering a guard that does not exist.
Gates {
/// `<account>/<name>`.
path: String,
#[arg(long, short = 'i', value_name = "PATH")]
identity: Option<PathBuf>,
#[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
at: String,
},
}
/// `gunnar control repo …`
#[cfg(feature = "control")]
#[derive(Debug, Subcommand)]
pub enum ControlRepoCommand {
/// Create a repository at `<account>/<name>`.
///
/// New repositories are **sha256**, so a stock `git init` working
/// tree cannot push to one — `git init --object-format=sha256`, or
/// `--hash sha1` here.
Create {
/// `<account>/<name>`.
path: String,
/// The object hash. sha256 unless you have a sha1 client you cannot
/// change.
#[arg(long, value_enum, default_value_t = HashArg::Sha256)]
hash: HashArg,
/// Readable by anyone, with no key at all.
#[arg(long)]
public: bool,
/// **Take over a store already on disk at this name** (a catalog
/// restored over a surviving data disk). Refused unless the store's
/// hash marker matches `--hash`. Without it a create refuses the name
/// BY NAME: "a store already exists at …; delete reclaims it".
#[arg(long)]
adopt: bool,
#[arg(long, short = 'i', value_name = "PATH")]
identity: Option<PathBuf>,
#[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
at: String,
},
/// List repositories.
List {
/// Only this account's.
#[arg(long, value_name = "ACCOUNT")]
namespace: Option<String>,
/// Only repositories whose `<account>/<name>` contains this,
/// case-insensitively — gunnar filters, over everything you may see.
#[arg(long, value_name = "TEXT")]
query: Option<String>,
/// Only repositories that are mirrors of an upstream.
#[arg(long)]
mirrors: bool,
#[arg(long, short = 'i', value_name = "PATH")]
identity: Option<PathBuf>,
#[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
at: String,
},
/// Show one repository: its hash, default branch and visibility.
Get {
/// `<account>/<name>`.
path: String,
#[arg(long, short = 'i', value_name = "PATH")]
identity: Option<PathBuf>,
#[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
at: String,
},
/// **Delete a repository. Without `--purge` the bytes are KEPT for a
/// retention window and `repo undelete` puts it back.**
///
/// The catalog row goes either way, so the name stops working immediately.
/// What differs is the bytes: a tombstone holds them for
/// `--retention-days` (30 by default) and a sweep reclaims them when it
/// closes; `--purge` returns them now, in about two seconds, with no undo.
///
/// A tombstone is still storage on the account until it expires —
/// `repo list-deleted` shows what is held and what it costs.
Delete {
/// `<account>/<name>`.
path: String,
/// Required. Deleting a repository takes its name out of the catalog
/// immediately, and a typo in a path is not distinguishable from an
/// intention.
#[arg(long)]
yes: bool,
/// **Reclaim the bytes NOW.** No window, no undelete.
#[arg(long)]
purge: bool,
/// How long the bytes are kept. `0` means at the next sweep.
#[arg(long, value_name = "DAYS")]
retention_days: Option<u32>,
#[arg(long, short = 'i', value_name = "PATH")]
identity: Option<PathBuf>,
#[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
at: String,
},
/// **Put a soft-deleted repository back**, inside its retention window.
Undelete {
/// `<account>/<name>`.
path: String,
#[arg(long, short = 'i', value_name = "PATH")]
identity: Option<PathBuf>,
#[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
at: String,
},
/// **End a retention window early.** There is no undo after it.
Purge {
/// `<account>/<name>`.
path: String,
/// Required, for the same reason `delete --yes` is.
#[arg(long)]
yes: bool,
#[arg(long, short = 'i', value_name = "PATH")]
identity: Option<PathBuf>,
#[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
at: String,
},
/// **Maintain a live repository: seal, compact, drop the read cache.**
///
/// The same maintenance `gunnar gc <path>` runs on the box, reachable from
/// wherever the control plane is. It reclaims only what the server can
/// re-derive — the lazily-exploded read cache goes, the commit-and-tree
/// pool stays because that is the graph — so there is nothing to confirm
/// and no `--yes`: the worst outcome is a slower next read.
///
/// Needs `Write` on the repository, not `Admin`. A principal who may push
/// to a repository may reclaim its cache.
///
/// The server runs **one gc at a time for the whole box** and queues the
/// rest, so this enqueues and then waits for its turn, printing what it
/// freed. Asking twice for a repository already queued joins the job that
/// is there rather than running a second one.
Gc {
/// `<account>/<name>`.
path: String,
/// Enqueue and return, printing the job instead of waiting for it.
///
/// For a script that wants the gc started and is not going to sit
/// through it. `repo gc-status <path>` reads the outcome afterwards.
#[arg(long)]
no_wait: bool,
#[arg(long, short = 'i', value_name = "PATH")]
identity: Option<PathBuf>,
#[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
at: String,
},
/// **How a repository's gc is going, or how the last one went.**
///
/// Needs only `Read`: seeing that maintenance ran is not asking for it.
/// Says so plainly when the server is holding no job at all, which is a
/// different answer from a gc that reclaimed nothing.
GcStatus {
/// `<account>/<name>`.
path: String,
#[arg(long, short = 'i', value_name = "PATH")]
identity: Option<PathBuf>,
#[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
at: String,
},
/// What is deleted and not yet purged, with the bytes it is still costing.
ListDeleted {
/// Only this account's.
#[arg(long, value_name = "ACCOUNT")]
namespace: Option<String>,
#[arg(long, short = 'i', value_name = "PATH")]
identity: Option<PathBuf>,
#[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
at: String,
},
/// **Store directories with no catalog row and no tombstone — bytes
/// nothing else can see, and names nothing else can free.**
///
/// Every other reclaim path keys off a record: `repo delete` off the
/// catalog row, `undelete`/`purge`/`list-deleted`/the sweep off a
/// tombstone. A directory with neither is invisible to all of them, and
/// `repo create` still refuses its name because the bytes are there.
/// MEASURED on the appliance 2026-08-30: ~25-39 GB at `alice/linux`,
/// stranded.
///
/// The `HASH` column is the store's `OBJECT_FORMAT` marker, and `-` means
/// the store predates it — that is the value to pass to `repo create
/// --adopt --hash` if you want to KEEP the objects instead of reclaiming
/// them.
ListOrphans {
/// Only this account's.
#[arg(long, value_name = "ACCOUNT")]
namespace: Option<String>,
#[arg(long, short = 'i', value_name = "PATH")]
identity: Option<PathBuf>,
#[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
at: String,
},
/// **Reclaim an orphan store's bytes.** No window, no undo.
///
The file goes on; the console shows all of it.