gunnar_common/cli.rs
1//! The `gunnar` command line, in clap derive.
2//!
3//! Both halves live in one enum with `#[cfg]` on the variants, so
4//! `--no-default-features --features server` produces a binary whose `--help`
5//! genuinely has no `clone` in it, rather than one that offers a verb and then
6//! fails at runtime.
7
8// Every use is behind a half's feature: with none on, nothing names it (OX-GUNBR 2026-10-28).
9#[cfg(any(
10 feature = "server",
11 feature = "client",
12 feature = "control",
13 feature = "systemd"
14))]
15use std::path::PathBuf;
16
17#[cfg(feature = "server")]
18use clap::ArgGroup;
19#[cfg(any(feature = "server", feature = "control"))]
20use clap::ValueEnum;
21use clap::{Parser, Subcommand};
22
23/// gunnar — a git server and a git client, in Rust, over SSH and smart HTTP.
24#[derive(Debug, Parser)]
25#[command(
26 name = "gunnar",
27 // Not the bare crate version. `gunnar 0.1.0` names a manifest field, not a
28 // build — and on 2026-08-04/05 several benchmark runs had to identify the
29 // binary under test by file size and mtime because nothing on it said which
30 // commit it was; one silently measured a ten-hour-old build. The git
31 // description and build date come from `build.rs`, which degrades to
32 // `unknown` rather than failing when there is no `.git`.
33 version = concat!(
34 env!("CARGO_PKG_VERSION"),
35 " (",
36 env!("GUNNAR_BUILD_REF"),
37 ", built ",
38 env!("GUNNAR_BUILD_DATE"),
39 ")"
40 ),
41 about = "a git server and a git client, in Rust, over SSH and smart HTTP",
42 long_about = "gunnar serves git over SSH (publickey auth, no shell) and, when built with \
43 `--features http` and started with `--http-bind`, over smart HTTP. It \
44 controls itself over gRPC. The client half is a normal git client.\n\n\
45 Two git transports, one implementation: smart HTTP carries the same \
46 pkt-line conversation SSH carries, through the same authorisation check \
47 against the same key table and the same ACL, and an access token is the \
48 HTTP credential where a public key is the SSH one. What is refused is \
49 refused on both.\n\n\
50 What gunnar does NOT serve: git:// ever, being unauthenticated by \
51 construction; the dumb HTTP protocol, which is refused by name; and no \
52 git or ssh subprocess anywhere in the server, on any transport."
53)]
54pub struct Cli {
55 /// Log at `debug` rather than `info`. `RUST_LOG` still wins.
56 #[arg(long, short = 'v', global = true)]
57 pub verbose: bool,
58
59 /// Where the server keeps its control repository and its repositories.
60 #[cfg(feature = "server")]
61 #[arg(
62 long,
63 global = true,
64 env = "GUNNAR_DATA_ROOT",
65 default_value_os_t = default_data_root(),
66 value_name = "DIR"
67 )]
68 pub data_root: PathBuf,
69
70 #[command(subcommand)]
71 pub command: Command,
72}
73
74/// `$GUNNAR_DATA_ROOT`, else `$HOME/.local/share/gunnar`, else `./gunnar-data`.
75#[cfg(feature = "server")]
76pub fn default_data_root() -> PathBuf {
77 match std::env::var_os("HOME") {
78 Some(home) if !home.is_empty() => PathBuf::from(home).join(".local/share/gunnar"),
79 _ => PathBuf::from("gunnar-data"),
80 }
81}
82
83#[derive(Debug, Subcommand)]
84pub enum Command {
85 // ── server ─────────────────────────────────────────────────────────────
86 /// Start the listeners: SSH for git, gRPC for the control plane, and smart
87 /// HTTP for git as well when `--http-bind` asks for it.
88 // Boxed: `ServeArgs` is several times the size of every other verb.
89 #[cfg(feature = "server")]
90 Serve(Box<ServeArgs>),
91
92 /// Namespaces: the `<ns>` in `git@host:<ns>/<repo>.git`.
93 #[cfg(feature = "server")]
94 #[command(subcommand)]
95 Namespace(NamespaceCommand),
96
97 /// Repositories.
98 #[cfg(feature = "server")]
99 #[command(subcommand)]
100 Repo(RepoCommand),
101
102 /// Principals. Keys and grants hang off these.
103 #[cfg(feature = "server")]
104 #[command(subcommand)]
105 Principal(PrincipalCommand),
106
107 /// **The server's own certificate issuer** — identity from a certificate.
108 ///
109 /// With one of these, creating a user *is* issuing their certificate:
110 /// there is no external PKI to run and no second system to keep in step.
111 ///
112 /// Present in a CONTROL-only build too, where it carries exactly one verb:
113 /// `issuer show --at <addr>`. That is not a convenience. `Pki::read_only`
114 /// exists so this read "works on a client-shaped binary", and the box
115 /// whose CA anyone actually needs to check is the appliance — which has no
116 /// shell, so the asking is always done from somewhere else.
117 #[cfg(any(feature = "server", feature = "control"))]
118 #[command(subcommand)]
119 Issuer(IssuerCommand),
120
121 /// Public keys.
122 #[cfg(feature = "server")]
123 #[command(subcommand)]
124 Key(KeyCommand),
125
126 /// Mint and revoke the access tokens smart HTTP authenticates with.
127 #[cfg(feature = "server")]
128 #[command(subcommand)]
129 Token(TokenCommand),
130
131 /// **The transparency log** over the push attestations.
132 ///
133 /// The attestation log is append-only by discipline; these verbs are what
134 /// make it append-only by evidence. `head` signs what the log currently
135 /// holds, `audit` checks every signed head against the records on disk, and
136 /// `prove` hands out the inclusion proof a client checks offline.
137 #[cfg(feature = "server")]
138 #[command(subcommand)]
139 Attest(AttestCommand),
140
141 /// **Walk the chain backwards from a line of source.**
142 ///
143 /// Which commit wrote the line, whether that commit is signed by a key this
144 /// server knows, which push landed it, and whether that push sits in the
145 /// transparency log under a signed tree head.
146 ///
147 /// Every link is reported as proven, broken or **unproven**, and the three
148 /// are different: an unsigned commit is not a failed signature, and a push
149 /// with no attestation is not a push that was refused. A chain with an
150 /// unproven link is INCOMPLETE, never green.
151 #[cfg(feature = "server")]
152 Verify(VerifyArgs),
153
154 /// **Who wrote each line of a file** (`verify --line` for the whole file).
155 ///
156 /// For every line of `<path>` at a revision, the commit that last touched
157 /// it and that commit's author — the same line→commit attribution `verify`
158 /// closes as link 1, done once per line instead of once for a single line.
159 #[cfg(feature = "server")]
160 Blame(BlameArgs),
161
162 /// **Find a literal string** across the latest revision of a repository.
163 ///
164 /// Walks the tree at a ref and scans each text file, returning `path:line`
165 /// matches. Bounded by a file-size and a match ceiling; it is a live walk,
166 /// not a persistent index.
167 #[cfg(feature = "server")]
168 Search(SearchArgs),
169
170 /// **Build provenance**: record what was built and from what (roadmap
171 /// phase 3, links 5–6).
172 #[cfg(feature = "server")]
173 #[command(subcommand)]
174 Build(BuildCommand),
175
176 /// **The forge**: issues, reviews and merges as signed events (roadmap
177 /// phase 1).
178 #[cfg(feature = "server")]
179 #[command(subcommand)]
180 Forge(ForgeCommand),
181
182 /// **The artifact registry** (roadmap phase 4).
183 #[cfg(feature = "server")]
184 #[command(subcommand)]
185 Registry(RegistryCommand),
186
187 /// Give a principal access.
188 #[cfg(feature = "server")]
189 Grant(GrantArgs),
190
191 /// Take a principal's access away.
192 #[cfg(feature = "server")]
193 Revoke(RevokeArgs),
194
195 /// Repack a repository's objects.
196 #[cfg(feature = "server")]
197 Gc(GcArgs),
198
199 /// Read a repository's reference history, and optionally seal it.
200 #[cfg(feature = "server")]
201 Reflog(ReflogArgs),
202
203 /// WireGuard identity: mint the keypair an appliance is seeded with.
204 #[cfg(feature = "server")]
205 #[command(subcommand)]
206 Wg(WgCommand),
207
208 // ── client ─────────────────────────────────────────────────────────────
209 /// Clone a repository over SSH into a working tree.
210 #[cfg(feature = "client")]
211 Clone(CloneArgs),
212
213 /// Fetch from an SSH remote into an existing repository.
214 #[cfg(feature = "client")]
215 Fetch(FetchArgs),
216
217 /// List the references an SSH remote advertises, without cloning it.
218 ///
219 /// The cheapest way to ask whether a remote is reachable, whether the key
220 /// authenticates, and whether the repository holds what you expect. Prints
221 /// `git ls-remote`'s lines — `<oid>\t<ref>`, `HEAD` first, an annotated tag
222 /// followed by its peeled `^{}` line — because scripts parse them.
223 #[cfg(feature = "client")]
224 #[command(name = "ls-remote")]
225 LsRemote(LsRemoteArgs),
226
227 /// Push to an SSH remote (send-pack, implemented here; gix has none).
228 #[cfg(feature = "client")]
229 Push(PushArgs),
230
231 /// Pull-mirror a remote repository.
232 #[cfg(feature = "client")]
233 #[command(subcommand)]
234 Mirror(MirrorCommand),
235
236 /// Encrypted repositories: the server holds ciphertext only, and only the
237 /// members' SSH keys open it.
238 ///
239 /// Each repository has one key ring, wrapped to every member's SSH PUBLIC
240 /// key (`ssh-ed25519`, `ssh-rsa`) in `.gunnar-crypt/keys`. git's clean and
241 /// smudge filter seal and open file content on this machine; the server
242 /// never sees a key. File names, commit messages, sizes and the shape of
243 /// history stay visible (Level 1). See `.nornir/encrypted-repos.md`.
244 #[cfg(feature = "client")]
245 #[command(subcommand)]
246 Crypt(CryptCommand),
247
248 /// **A repository's settings on a forge** (owner, 2026-10-14): show it,
249 /// make it public or private, or change its default branch — over the
250 /// forge's SSH door, signed in with your SSH key, the way git and `crypt`
251 /// reach an installed Gunnar. The server decides who may (its own rules:
252 /// the account admin for visibility, a repository admin for the branch).
253 ///
254 /// `gunnar settings nordisk/knut`,
255 /// `gunnar settings nordisk/knut --visibility public`,
256 /// `gunnar settings nordisk/knut --default-branch main`.
257 #[cfg(feature = "client")]
258 Settings(SettingsArgs),
259
260 /// **Create an empty repository on a forge** in the object format you
261 /// choose (sha256 unless `--sha1`), over the forge's SSH door: a repository
262 /// made by a first push can only be sha1. `gunnar push` does this by itself
263 /// for a sha256 repository whose remote does not exist yet.
264 #[cfg(feature = "client")]
265 Create(CreateArgs),
266
267 /// Show the commit log.
268 #[cfg(feature = "client")]
269 Log(LogArgs),
270
271 /// Show the working tree status.
272 #[cfg(feature = "client")]
273 Status(RepoArgs),
274
275 /// Commit everything in the working tree.
276 #[cfg(feature = "client")]
277 Commit(CommitArgs),
278
279 /// Branches.
280 #[cfg(feature = "client")]
281 Branch(BranchArgs),
282
283 /// Tags.
284 #[cfg(feature = "client")]
285 Tag(TagArgs),
286
287 // ── the control plane, over the wire ───────────────────────────────────
288 /// Configure a RUNNING gunnar over its gRPC control plane.
289 ///
290 /// The first call needs no credential at all; every one after it is
291 /// authenticated with an ssh key. `gunnar control status` says which of
292 /// those a server is in.
293 ///
294 /// These are the ONLINE verbs. `gunnar principal`, `gunnar key` and
295 /// `gunnar grant` write the control repository directly and therefore need
296 /// the server to be DOWN; these do the same work against one that is up.
297 #[cfg(feature = "control")]
298 #[command(subcommand)]
299 Control(ControlCommand),
300
301 /// **Log this terminal in with your passkey, through a console.**
302 ///
303 /// Mints a fresh ed25519 key here, shows a code, and waits: open the
304 /// console's `/login/device` page, type the code and touch your passkey.
305 /// Gunnar enrols THIS key on you, expiring (at most a day); its private
306 /// half never leaves this machine. Use it with `-i <path>` (printed).
307 /// The other CLI way in is a FIDO2 `sk-ssh-ed25519` key in ssh-agent.
308 #[cfg(feature = "control")]
309 Login(LoginArgs),
310
311 // ── systemd ────────────────────────────────────────────────────────────
312 /// Manage gunnar as a systemd service. Off unless built with `--features systemd`.
313 #[cfg(feature = "systemd")]
314 #[command(subcommand)]
315 Service(ServiceCommand),
316}
317
318/// `gunnar login`, see [`Command::Login`].
319#[cfg(feature = "control")]
320#[derive(Debug, clap::Args)]
321pub struct LoginArgs {
322 /// The console's address, e.g. `https://console.example.com`.
323 pub console: String,
324 /// How long the key lives, in hours (the console allows at most 24).
325 #[arg(long, default_value_t = 12)]
326 pub ttl: u64,
327 /// What the approval page calls this terminal (default: `user@host`).
328 #[arg(long)]
329 pub label: Option<String>,
330 /// Where the key is written (default: `~/.config/gunnar/login`).
331 #[arg(long)]
332 pub dir: Option<PathBuf>,
333}
334
335// ───────────────────────────────────────────────────────────────────────────
336// server
337// ───────────────────────────────────────────────────────────────────────────
338
339/// `--anonymous`, see [`ServeArgs::anonymous`].
340#[cfg(feature = "server")]
341#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
342pub enum AnonymousArg {
343 /// Serve anonymous reads of public repositories on this box.
344 Allow,
345 /// Refuse them, naming `--browse-host` when given.
346 Refuse,
347}
348
349/// `gunnar serve --role`.
350#[cfg(feature = "server")]
351#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
352pub enum ServeRole {
353 /// Serves git; frames every transaction for its twin.
354 Primary,
355 /// Serves no git; holds the primary's log.
356 Twin,
357}
358
359/// `gunnar serve`.
360#[cfg(feature = "server")]
361#[derive(Debug, clap::Args, Clone)]
362pub struct ServeArgs {
363 /// Where the SSH listener binds. Publickey auth, so binding publicly is the
364 /// intended configuration.
365 #[arg(long, default_value = "0.0.0.0:2222", value_name = "ADDR")]
366 pub ssh_bind: std::net::SocketAddr,
367
368 /// Where the gRPC control plane binds — `<addr>:<port>`, or
369 /// `<interface>:<port>` for an address learned at boot.
370 ///
371 /// **Loopback by default and it should stay there.** gRPC now requires an
372 /// `sshsig` credential from a key in gunnar's identity table and a grant from
373 /// the same ACL as SSH (the allow-all hole was closed 2026-08-03), but the
374 /// loopback default is defence in depth and costs nothing: every real
375 /// deployment reaches this port over an SSH tunnel or from the same host
376 /// anyway.
377 ///
378 /// **`0.0.0.0` is not the way to reach it from another host.** A cloud
379 /// appliance has a PUBLIC interface and a private one, and a wildcard
380 /// binds both — which is how this control plane ended up answering
381 /// `repo list` and `repo create` to an uncredentialled caller on the open
382 /// internet (measured 2026-08-31). Name the private interface instead:
383 /// `--grpc-bind eth1:50051`. Its address is not knowable when the medium
384 /// is mastered — DHCP decides it, per box, per lease — and the interface
385 /// is. See [`gunnar_common::bindspec::BindSpec`].
386 #[arg(
387 long,
388 default_value = "127.0.0.1:50051",
389 value_name = "ADDR|IFACE:PORT"
390 )]
391 pub grpc_bind: crate::bindspec::BindSpec,
392
393 /// An OpenSSH public key line, or `@<path>` to read one from a file, to
394 /// install as the bootstrap admin when the identity table is empty.
395 ///
396 /// Without it, an empty table makes gunnar **refuse to serve** rather than
397 /// start unreachable.
398 #[arg(long, value_name = "KEY|@FILE")]
399 pub admin_key: Option<String>,
400
401 /// **A key this box may take a `Bootstrap.Restore` from**: an OpenSSH
402 /// public key line, or `@<path>` to read one. Repeat the flag for more;
403 /// `GUNNAR_RESTORE_PUBKEY` takes several separated by `,`.
404 ///
405 /// A restore stream decides a fresh box's KEK, its admin, its host key and
406 /// its issuer, and a fresh box has no identity table to check its sender
407 /// against, so it takes one only under a credential signed by a key it was
408 /// FED: this flag, or — on an installer-fed box — the fed `admin_pubkey`
409 /// and admin principals. A box with neither refuses every restore by name
410 /// (`restore-no-key`).
411 #[arg(
412 long = "restore-pubkey",
413 value_name = "KEY|@FILE",
414 env = "GUNNAR_RESTORE_PUBKEY",
415 value_delimiter = ','
416 )]
417 pub restore_pubkeys: Vec<String>,
418
419 /// Do not start the gRPC listener. The SSH listener still runs.
420 #[arg(long)]
421 pub no_grpc: bool,
422
423 /// **WireGuard-only** (owner, 2026-10-04): every box-to-box connection
424 /// runs over the estate's overlay. The control plane's bound address (the
425 /// utility network, `eth1:50051`) serves nothing; the control plane and the
426 /// replication listener answer only on the tunnel's address, and SSH drops
427 /// a connection that arrives at the utility address. A plain-IP connection
428 /// between two boxes is refused.
429 #[arg(long = "wg-only", env = "GUNNAR_WG_ONLY")]
430 pub wg_only: bool,
431
432 /// **What this box is.** `primary` (the default) serves git and frames
433 /// every transaction. `twin` serves NO git from its first boot (the forge
434 /// listener is bound to loopback only), waits for `Bootstrap.BecomeTwin`,
435 /// and then follows its primary's log (`.nornir/twin-transaction-log.md`).
436 /// **A twin is not a different medium.** Until 2026-09-17 a `gunnar-twin`
437 /// ISO welded `--role twin` into its `korp.args=`, which made the role part
438 /// of a medium's identity and — because that medium was cloned from the
439 /// KEYLESS row — made gunnar.rs's twin an UNMETERED binary that would have
440 /// served every suspended tenant the moment it was promoted. Since 2026-09-22
441 /// the medium does not say what a box is at all (no `role` install parameter,
442 /// no `GUNNAR_ROLE` env read): every box installs as a fresh appliance and a
443 /// twin is made one over the wire by `Bootstrap.BecomeTwin`, which writes
444 /// `twin.json` — the durable role `stores.is_twin()` reads. This argv flag
445 /// stays for a directly started server (tests, benches); an installed box
446 /// never passes it.
447 #[arg(long, value_enum, default_value_t = ServeRole::Primary)]
448 pub role: ServeRole,
449
450 /// Refuse unauthenticated callers entirely, even for repositories marked
451 /// `--public`.
452 ///
453 /// By default a public repository can be cloned with no key at all, over
454 /// `ssh://anonymous@host/ns/repo.git`. This closes that door at the
455 /// listener, before any repository is resolved, for an appliance that has
456 /// to be able to state it has never served a stranger. It is **not** what
457 /// protects private repositories: those are protected by the ACL and are
458 /// unreachable anonymously either way.
459 #[arg(long)]
460 pub no_anonymous: bool,
461
462 /// **Where anonymous reads are served** — the browse replica. `allow`
463 /// (the default) serves a stranger's clone of a public repository here.
464 /// `refuse` sends every anonymous request away with a line that names the
465 /// browse replica (`--browse-host`) it is served by: strangers cost CPU and
466 /// bandwidth on the box that also takes the authenticated writes, and the
467 /// browse replica is where that cost belongs. Unlike `--no-anonymous`, which
468 /// merely fails the stranger's authentication, `refuse` tells them where
469 /// to go. A box whose role is BROWSE serves anonymous reads whatever this
470 /// says — it is the box the knob points at.
471 #[arg(long, value_enum, default_value_t = AnonymousArg::Allow, env = "GUNNAR_ANONYMOUS")]
472 pub anonymous: AnonymousArg,
473
474 /// The browse replica's `host[:port]`, named in the `--anonymous refuse`
475 /// line (`clone <repo> from <host>`). Optional: without it the refusal
476 /// says only that anonymous reads are not served here.
477 #[arg(long, value_name = "HOST[:PORT]", env = "GUNNAR_BROWSE_HOST")]
478 pub browse_host: Option<String>,
479
480 /// **How much of the store root's disk must stay free** before a push or
481 /// a `Repos.Create` is refused — the community plan gate, the "free lunch"
482 /// (ruled 2026-09-04). Terms separated by commas: a percentage (`5%`)
483 /// and/or a byte count (`2GiB`, `500M`, `4096`); the LARGER of the two is
484 /// the floor. The default keeps 5 % or 2 GiB, whichever is more: 5 % is the
485 /// reserve the box needs to keep operating (gc scratch, control-store
486 /// commits, `replication.json` rewrites) and mirrors ext4's own reserved
487 /// blocks; 2 GiB is the absolute floor for a small disk where 5 % is less
488 /// than one pack. A push under the floor is refused BEFORE any pack byte,
489 /// naming the free bytes and the floor; reads are never refused. Measured
490 /// with `statvfs`, no `df`. `0%,0` is refused by name: a gate that can never
491 /// refuse is not a gate.
492 #[arg(
493 long,
494 value_name = "SPEC",
495 default_value = "5%,2GiB",
496 env = "GUNNAR_DISK_FLOOR"
497 )]
498 pub disk_floor: String,
499 /// **Do not enumerate a push's object ids for the attestation.**
500 ///
501 /// Enumeration waits for the indexer to absorb the pack, which is a drain
502 /// on the push path, so an operator who does not need the ids in the record
503 /// can decline them. Declining does NOT restore silence: the attestation
504 /// then carries the reason the set is absent
505 /// (`PushAttestation.new_object_oids_unknown`), because an absence that
506 /// renders as an empty list is the defect this flag exists beside, not the
507 /// behaviour it restores.
508 ///
509 /// Named in the negative on purpose: the complete attestation is what you
510 /// get by forgetting, and the cheap one is what you opt into. A push that a
511 /// policy gate reads is enumerated either way — the gate needs the set.
512 #[arg(long)]
513 pub no_attest_objects: bool,
514
515 /// How many git transfers may be in flight across the whole server (the
516 /// admission gate). Default: DERIVED from the box — cores x 16, capped by
517 /// the descriptors the handle cache has not spoken for, floor 8, ceiling
518 /// 256.
519 ///
520 /// The server says what it derived, and what from, in one INFO row at
521 /// start: `transfers 64 · open repos 204 · git memory 3.9G (cores 4,
522 /// fds 1048576, MemAvailable 7.8G)`. Setting this replaces the derivation
523 /// exactly — the number is never raised and never lowered.
524 #[arg(long, value_name = "N", env = "GUNNAR_MAX_TRANSFERS")]
525 pub max_concurrent_transfers: Option<usize>,
526 /// The memory budget for git operations in flight, in bytes — the
527 /// admission gate's second budget. Default: half of `MemAvailable` at
528 /// start.
529 ///
530 /// The ledger charges each transfer a small entry cost and then what its
531 /// pack actually moves, so this is a budget rather than a second
532 /// concurrency cap. It used to be a compiled-in 8 GiB against a flat
533 /// 320 MiB per push, which made "25 concurrent pushes" the real ceiling on
534 /// every box regardless of its RAM.
535 #[arg(long, value_name = "BYTES", env = "GUNNAR_GIT_MEMORY_BYTES")]
536 pub git_memory_bytes: Option<u64>,
537
538 /// How long a transfer may WAIT for a slot before it is refused, in
539 /// milliseconds. Default 2000; `0` refuses on the spot.
540 ///
541 /// A refused client reconnects, and a reconnect is a whole SSH handshake —
542 /// more than the wait costs. The wait is bounded, counted, and is what the
543 /// delay governor is driven by.
544 #[arg(long, value_name = "MS", env = "GUNNAR_ADMISSION_WAIT_MS")]
545 pub admission_wait_ms: Option<u64>,
546
547 /// What one identity is **guaranteed** at once, and its share of a
548 /// contended server. Default 32.
549 ///
550 /// A floor, not a wall: on a server that is not near saturation a single
551 /// identity may use every slot `--max-concurrent-transfers` allows. Once
552 /// the server is contended each active identity gets an equal share of it,
553 /// never below this number.
554 #[arg(long, value_name = "N")]
555 pub max_transfers_per_principal: Option<usize>,
556
557 /// How many git reads are served **without a key** at once.
558 /// Default 5.
559 ///
560 /// The companion to `--anonymous`: that decides whether the door is open,
561 /// this decides how wide. Unlike `--max-transfers-per-principal` it is a
562 /// wall and not a floor — it binds on an idle server, because the caller it
563 /// bounds cannot be named, revoked or telephoned, and "the box was quiet so
564 /// I let a stranger have all of it" is not a trade an operator would make
565 /// deliberately.
566 ///
567 /// Raise it on a box whose *purpose* is public serving (a browse replica);
568 /// `0` refuses every anonymous read while leaving the door itself open,
569 /// which is the arm to use when you want the refusal to say so rather than
570 /// have `--anonymous refuse` deny the repository exists.
571 #[arg(long, value_name = "N", env = "GUNNAR_MAX_ANONYMOUS_TRANSFERS")]
572 pub max_anonymous_transfers: Option<usize>,
573
574 /// **The usage share's budget**: bytes (in + out) a namespace may move per
575 /// window at the ordinary per-principal share. Past it the namespace is
576 /// served at the ANONYMOUS ceiling (`--max-anonymous-transfers`) and
577 /// refused fast with its own reason — never queued. `none` turns the
578 /// share off (PassThrough: admission decides as a build without it).
579 /// SI or IEC sizes: `100 GB`, `5 GiB`, or bytes. Default 100 GB.
580 ///
581 /// Only in a build with the `admit-share` feature.
582 #[cfg(feature = "admit-share")]
583 #[arg(long, value_name = "SIZE|none", env = "GUNNAR_ADMIT_LIMIT")]
584 pub admit_limit: Option<String>,
585
586 /// The usage share's rolling window, in UTC days, today included.
587 /// Default 30.
588 ///
589 /// Only in a build with the `admit-share` feature.
590 #[cfg(feature = "admit-share")]
591 #[arg(long, value_name = "DAYS", env = "GUNNAR_ADMIT_WINDOW_DAYS")]
592 pub admit_window_days: Option<u32>,
593
594 /// The largest object `Refs.ReadObject` will read, in bytes.
595 /// Default 67108864 (64 MiB); `0` means no ceiling.
596 ///
597 /// Reading any part of an object costs the WHOLE object in memory — a
598 /// prefix of a delta-encoded object needs its entire base chain — so
599 /// `max_bytes` bounds the answer and not the work. This bounds the work.
600 /// The default is a 512 MiB budget divided by the 8 control-plane workers
601 /// that can be inflating at once; raise it on a box whose repositories hold
602 /// large blobs and whose control port is trusted, and note that the git
603 /// clone path is untouched by this and streams either way.
604 #[arg(long, value_name = "BYTES")]
605 pub max_object_bytes: Option<u64>,
606
607 /// **The metered appliance's key**: monetize-server's Ed25519 public key,
608 /// either spelled inline as its 64 hexadecimal characters or as the path of
609 /// a file holding it (32 raw bytes, 64 hex characters, or
610 /// `ed25519:<base64>`). Every `Entitlement.Set` is verified against it
611 /// before a plan moves. Only a `metered` build honours it; any other build
612 /// REFUSES to start when it is set, by name — an operator who configured a
613 /// metered appliance on a binary that cannot meter must find out at boot,
614 /// not from a tenant who was never suspended.
615 ///
616 /// **This flag is NOT compiled out of the open build, and that is the
617 /// point.** It is the one piece of the monetize seam an open gunnar must
618 /// still RECOGNISE, because a flag clap has never heard of is refused as
619 /// "unexpected argument" — a parser complaint that says nothing about why
620 /// this binary cannot do what was asked. Kept, the open build answers with
621 /// the sentence in `gunnar_server::dispatch` instead: this build contains
622 /// no entitlement code at all and would serve every namespace Free.
623 ///
624 /// **Which of the two it is, is decided by SHAPE — there is no second
625 /// flag.** A value with no `/` in it that is either all hexadecimal or
626 /// exactly 64 characters long is the KEY; anything else is a PATH. So
627 /// `/data/monetize.pub` and `monetize.pub` are files, as they always were,
628 /// and `43cb…8428` is the key itself. A near-miss (63 characters, or a
629 /// typo that is not a hex digit) is refused as a malformed key naming what
630 /// was wrong, rather than silently becoming a filename nothing will find.
631 ///
632 /// Inline is what an appliance actually needs. The installer only stages
633 /// files onto `/data` under `korp.reinstall=wipe`, the ESP is read-only at
634 /// runtime, and so on a keep-upgrade the kernel command line is the only
635 /// writable channel into a booting gunnar — a path there names a file
636 /// nothing was allowed to create. An Ed25519 *public* key is safe to carry
637 /// that way; a private key never would be.
638 #[arg(long, value_name = "HEX_OR_PATH", env = "GUNNAR_MONETIZE_PUBKEY")]
639 pub monetize_pubkey: Option<crate::monetize_key::MonetizePubkey>,
640
641 // ── WHY THIS ONE IS COMPILED OUT AND `--monetize-pubkey` IS NOT ────────
642 //
643 // Kept as a plain comment rather than a doc comment ON PURPOSE: clap turns
644 // `///` into `--help`, and the explanation below is for whoever reads this
645 // file, not for an operator of the open build — writing it as documentation
646 // would reintroduce, in prose, exactly the vocabulary this gate removes.
647 //
648 // gunnar is the OPEN half of the split: it reports `Usage`, it receives a
649 // signed `Entitlement`, and it takes no money ever. The private monetize
650 // half is the only thing that knows what a byte is worth. So an open
651 // `gunnar-server --help` must not advertise a flag whose whole noun is a
652 // payment page — and until this `cfg`, it did, for a field the open binary
653 // has no code to honour.
654 //
655 // `--monetize-pubkey` three declarations up is deliberately NOT gated, and
656 // the asymmetry is the argument, not an oversight. That flag's PRESENCE
657 // changes what the box would do — meter, or silently serve every namespace
658 // Free — so an open build has to recognise it in order to refuse it by
659 // name at boot. This flag cannot even be given without it: `requires`
660 // sees to that, so an open build handed both stops on the pubkey's
661 // sentence either way, and one handed this alone was already going to be
662 // refused. Compiling it out therefore costs no refusal that the pubkey's
663 // own does not already carry, and buys a `--help` with no money in it.
664 //
665 // And there is NO DEFAULT, here or at the use site. The fallback used to
666 // live in `gunnar_server::dispatch` as Vetra's own commercial URL, which
667 // meant anybody building a metered gunnar and forgetting this flag pointed
668 // their tenants at somebody else's payment page. A metered build given
669 // `--monetize-pubkey` without this now refuses at boot; see
670 // `gunnar_server::entitlement::MeteredConfig::load`.
671 /// **Where a lapsed tenant goes to restore its plan**, as a template with
672 /// `{tenant}` for the namespace (without the placeholder, `/{tenant}` is
673 /// appended, so the link always names the tenant). Printed in every
674 /// refusal and Grace warning. Requires `--monetize-pubkey`, and a
675 /// `metered` build that is given that flag without this one refuses to
676 /// start: a page belonging to whoever built the binary is not a default
677 /// anybody else's tenants should be sent to.
678 #[cfg(feature = "metered")]
679 //
680 // No clap `requires = "monetize_pubkey"` (2026-09-24): the key may come from the
681 // installer's door (`gunnar_server::feed`) instead of this argv, so a one-medium
682 // metered box carries the pay page and no key. The same refusal — this flag with
683 // no key from EITHER source — is made in `gunnar_server::dispatch`.
684 #[arg(long, value_name = "URL", env = "GUNNAR_BILLING_URL")]
685 pub billing_url: Option<String>,
686
687 /// **Is monetize VITAL to this box?** Owner ruling 2026-09-22. `true`, the default: stored
688 /// entitlement facts that do not verify at boot (a main restored under another monetize key)
689 /// raise the `Admin.Health` alarm `entitlement.key_mismatch`, naming the namespaces, and the
690 /// box reports status `maintenance` until monetize pushes a fact that verifies. The box
691 /// still starts and serves either way, and gunnar never dials monetize. `false`: the same
692 /// refusal is only logged.
693 #[cfg(feature = "metered")]
694 #[arg(long, value_name = "BOOL", env = "GUNNAR_MONETIZE_VITAL", default_value_t = true, action = clap::ArgAction::Set)]
695 pub monetize_vital: bool,
696
697 /// Make `--max-transfers-per-principal` an unconditional ceiling.
698 ///
699 /// For a host where an identity is a *tenant* and the cap is how the
700 /// box is shared out. On an appliance — one team, a handful of keys — this only
701 /// throttles the owner, which is why it is off by default.
702 #[arg(long)]
703 pub hard_per_principal_limit: bool,
704
705 /// Serve the **counters** on this address, as Prometheus text.
706 ///
707 /// Off unless asked for, and there is no default address: nothing binds
708 /// until you name one. `curl http://<addr>/metrics` then returns the same
709 /// nineteen numbers the `gunnar::metrics` heartbeat writes to the log —
710 /// but on demand, at whatever rate you ask, which is the only way
711 /// `gunnar_transfers_in_flight` tells a slow transfer from a stuck one
712 /// while it is still running.
713 ///
714 /// **It has no authentication.** The body is nineteen integers about this
715 /// process — no repository names, no principals, no references — so it is
716 /// not worth a credential, but anyone who can reach the port learns how
717 /// busy this server is. Bind it to `127.0.0.1` and let the scraper come
718 /// over a tunnel unless you mean otherwise; a non-loopback address is
719 /// accepted and logged as a `warn` at start-up.
720 #[arg(long, value_name = "ADDR")]
721 pub metrics_bind: Option<std::net::SocketAddr>,
722
723 /// **Watch this primary and TAKE OVER if it stops answering** — I11's
724 /// passive heartbeat, the standby half of failover.
725 ///
726 /// Off unless you name an address, and that is the design rather than
727 /// caution: naming a new primary is a decision, so a box never makes it
728 /// because nobody configured it not to. Give the PRIMARY's control-plane
729 /// address; this box probes it every `--watch-probe-ms` and, after
730 /// `--watch-promote-after` **consecutive** failures, does to itself exactly
731 /// what `gunnar control replication promote` does — persists `role:
732 /// primary` and thaws the write freeze. One reachable probe resets the run,
733 /// so a flapping link cannot add up to a takeover.
734 ///
735 /// It does nothing on a box whose role is `primary`: the watch re-reads the
736 /// role every tick and stops when this box is not a standby, so putting the
737 /// flag on BOTH boxes of a pair is safe and is what one unit file wants.
738 ///
739 /// **What the probe proves:** a TCP connect to that address, i.e. *"is
740 /// something listening"* — not *"is it serving git"*. A primary that is
741 /// wedged but still listening reads as alive and this box will NOT promote.
742 /// That is the safe direction to be wrong in: the cost is a manual
743 /// `replication promote`, where the other way round is two writable boxes.
744 ///
745 /// **The address still has to move.** Taking over makes this box writable;
746 /// it does not repoint the front (DNS / the UpCloud DNAT). That is the one
747 /// infrastructure step failover has always had.
748 #[arg(long, value_name = "ADDR")]
749 pub watch_primary: Option<std::net::SocketAddr>,
750
751 /// Milliseconds between probes of `--watch-primary`. Ignored without it.
752 #[arg(long, value_name = "MS", default_value_t = 5_000)]
753 pub watch_probe_ms: u64,
754
755 /// Consecutive failed probes before this box promotes itself. Ignored
756 /// without `--watch-primary`; a zero is read as one.
757 ///
758 /// The default is six, which at the default interval is **thirty seconds of
759 /// continuous unreachability** — longer than a restart of the primary, and
760 /// shorter than a coffee break.
761 #[arg(long, value_name = "N", default_value_t = 6)]
762 pub watch_promote_after: u32,
763
764 /// Milliseconds one connect attempt may take before it counts as a failure.
765 /// Ignored without `--watch-primary`.
766 ///
767 /// Kept below `--watch-probe-ms` deliberately: a connect that outlives the
768 /// interval lets probes overlap, and one dead primary becomes a growing
769 /// pile of pending sockets.
770 #[arg(long, value_name = "MS", default_value_t = 2_000)]
771 pub watch_connect_ms: u64,
772
773 /// **Auto-fix** (5.4 resilience): when this box comes up with its data set
774 /// dead or empty while a healthy twin exists, recharge it from the twin by
775 /// itself. OFF by default: without it the box only SAYS `data lost; recharge
776 /// from twin available`, and an admin's [Recharge from twin] or a signed
777 /// guardian order does it. The estate's opt-in, like `--watch-primary`.
778 #[arg(long, env = "GUNNAR_REPAIR_AUTO")]
779 pub repair_auto: bool,
780
781 /// The OpenSSH private key this box signs repair calls with (reading the
782 /// twin's log, `Bootstrap.Restore` into a fresh main, fencing a primary).
783 /// Default: this box's twin key, when it has one. A fresh main takes a
784 /// restore only from a key it was fed (`--restore-pubkey`).
785 #[arg(long, value_name = "PATH", env = "GUNNAR_REPAIR_KEY")]
786 pub repair_key: Option<std::path::PathBuf>,
787
788 /// The twin the boot check reviews and recharges from (`host:port` of its
789 /// control plane). Default: the twin the installer's re-admit named; a main
790 /// that grows also asks the twin in the door's peer list.
791 #[arg(long, value_name = "ADDR", env = "GUNNAR_REPAIR_TWIN")]
792 pub repair_twin: Option<String>,
793
794 /// **The pull channel**: monetize's `Fleet` endpoint this box reports to and
795 /// pulls signed repair orders from (`https://…`). Off when absent. Monetize
796 /// never calls into the box; every order is verified under the monetize key.
797 #[arg(long, value_name = "URL", env = "GUNNAR_FLEET")]
798 pub fleet: Option<String>,
799
800 /// PEM of the CA monetize's `Fleet` certificate is signed by.
801 #[arg(long, value_name = "PATH", env = "GUNNAR_FLEET_CA")]
802 pub fleet_ca: Option<std::path::PathBuf>,
803
804 /// The estate (monetize instance) this box belongs to. Required with `--fleet`.
805 #[arg(long, value_name = "NAME", env = "GUNNAR_FLEET_INSTANCE")]
806 pub fleet_instance: Option<String>,
807
808 /// The role this box pulls as (`main` / `twin`). Default: what the box is.
809 #[arg(long, value_name = "ROLE", env = "GUNNAR_FLEET_ROLE")]
810 pub fleet_role: Option<String>,
811
812 /// The longest pause between two reports, in seconds.
813 #[arg(long, value_name = "S", default_value_t = 60)]
814 pub fleet_every_s: u64,
815
816 /// Also serve **smart HTTP** on this address.
817 ///
818 /// Off unless asked for. `git clone http://host/ns/repo.git` then works,
819 /// and so does `push`, with an access token as the HTTP Basic password
820 /// (`gunnar token add`). The same repositories, the same grants and the
821 /// same `--no-anonymous` switch as SSH: this is a second transport, not a
822 /// second server.
823 #[cfg(feature = "http")]
824 #[arg(long, value_name = "ADDR")]
825 pub http_bind: Option<std::net::SocketAddr>,
826
827 /// Accept an access token over a **plain** HTTP connection.
828 ///
829 /// gunnar terminates no TLS. By default it therefore refuses to read a
830 /// credential unless a terminator in front of it says the client spoke TLS
831 /// (`X-Forwarded-Proto: https`), because a token in an `Authorization`
832 /// header on a cleartext connection is a password on the wire. This turns
833 /// that off, for loopback, a test, or a link you vouch for.
834 ///
835 /// **Anonymous read is unaffected either way**: it presents no credential.
836 #[cfg(feature = "http")]
837 #[arg(long)]
838 pub http_allow_cleartext_credentials: bool,
839
840 /// Terminate **TLS on the smart-HTTP listener itself**, with a certificate
841 /// gunnar mints from its own issuer at start — the single-executable
842 /// deployment: no proxy, no PEM files, `git clone https://host/ns/repo.git`
843 /// against one binary. Clients pin the issuer (`gunnar issuer show --pem`)
844 /// exactly as they do for the client certificates it already mints.
845 ///
846 /// ALPN offers HTTP/2 then HTTP/1.1, so a client that multiplexes gets to.
847 /// Mutually exclusive with `--http-tls-cert`/`--http-tls-key`, which is the
848 /// bring-your-own-certificate form of the same listener.
849 #[cfg(feature = "http")]
850 #[arg(long, conflicts_with_all = ["http_tls_cert", "http_tls_key"])]
851 pub http_tls_self: bool,
852
853 /// Terminate TLS on the smart-HTTP listener with this certificate chain
854 /// (PEM, leaf first). Needs `--http-tls-key`.
855 #[cfg(feature = "http")]
856 #[arg(long, value_name = "PEM", requires = "http_tls_key")]
857 pub http_tls_cert: Option<std::path::PathBuf>,
858
859 /// The private key for `--http-tls-cert` (PEM).
860 #[cfg(feature = "http")]
861 #[arg(long, value_name = "PEM", requires = "http_tls_cert")]
862 pub http_tls_key: Option<std::path::PathBuf>,
863
864 /// Require a **client certificate** on the gRPC control plane, and mint the
865 /// listener's own identity from this server's issuer at start.
866 ///
867 /// This is what makes the control plane safe to bind off loopback. Two
868 /// things change together: a peer whose certificate this issuer did not
869 /// sign never finishes a handshake, and a certificate that does get through
870 /// is looked up in the identity key table — so one the issuer signed and nobody
871 /// registered is still refused, by the gate, with an audit record.
872 ///
873 /// Clients get their certificate from `gunnar control bootstrap` (which
874 /// writes `<name>.cert.pem`, `<name>.key.pem` and `gunnar-issuer.pem` to
875 /// `~/.config/gunnar/<server>/`, or `--out`) and
876 /// present it with `--client-cert`/`--client-key`/`--ca`.
877 ///
878 /// Off by default: turning it on rewrites how every existing client
879 /// connects, and a default that breaks a working deployment gets turned
880 /// back off everywhere.
881 ///
882 /// **It cannot be the first thing a server does.** A mutual listener
883 /// refuses every peer with no certificate, and on a server that has never
884 /// been enrolled there is no certificate anywhere — including for
885 /// `Bootstrap.Enroll`, the one RPC served without a credential, which is
886 /// behind the same handshake. `gunnar serve` REFUSES to start in that
887 /// state and names the enrolment steps. Every other unhappy state is a
888 /// warning naming the principals that lose access; run
889 /// `gunnar issuer readiness` first to see them before the restart, and
890 /// `gunnar control health` afterwards to confirm the flip took.
891 #[arg(long)]
892 pub grpc_tls_self: bool,
893
894 /// **The mutual replication listener a twin follows this box through.**
895 ///
896 /// Mutual TLS 1.3 only: a peer must present a certificate this server's
897 /// issuer signed (`gunnar control twin certify` gets a twin one). Unset, it is
898 /// DERIVED — the `--grpc-bind` address, port + 1, whenever that address is
899 /// off loopback — so the appliance serves it with no flag on its medium, and
900 /// a loopback control plane serves none.
901 #[arg(long, value_name = "ADDR")]
902 pub grpc_replication_bind: Option<std::net::SocketAddr>,
903 /// **Accepted and ignored since 5.3.3.** The method-blind
904 /// `gunnar-sshsig-v1` credential is refused on every listener, always
905 /// (GUN-001; OWNER 2026-10-10), so there is nothing left for this to turn
906 /// on — and nothing that turns it off. Kept, hidden, only so a unit file
907 /// that still passes it starts instead of failing to parse.
908 #[arg(long, hide = true)]
909 pub grpc_require_bound_credentials: bool,
910
911 /// **A WebAuthn relying party this server answers for**:
912 /// `<rp_id>=<origin>[,<origin>…]`, repeatable (stinky row 13, 2026-10-10).
913 ///
914 /// Without one, a passkey ceremony's `rp_id` and `origin` are the relay's
915 /// word, as before. With any, a ceremony for another relying party, or from
916 /// an origin not listed, is refused — so a relay cannot run one for a
917 /// relying party it merely names.
918 #[arg(long = "web-relying-party", value_name = "RP_ID=ORIGIN[,ORIGIN]")]
919 pub web_relying_party: Vec<String>,
920
921 /// **Every passkey ceremony must verify the user** (a PIN or a biometric,
922 /// not a touch): web logins and the passkey after a company sign-in alike.
923 /// The server decides; a relay may ask for more, never for less (A34-12).
924 /// Off: a touch is enough unless the relay asks.
925 #[arg(long = "web-require-user-verification")]
926 pub web_require_user_verification: bool,
927 /// **Anybody may sign up on this site** (IDENTITY-HOST): the identity core's Root
928 /// site, where anybody may sign up; without it the site is Private (invite
929 /// only), the default.
930 #[arg(long = "identity-root-site")]
931 pub identity_root_site: bool,
932
933 /// **Build every push, in this process, hermetically** (roadmap phase 3).
934 ///
935 /// The runner subscribes to the same event bus a push publishes on, so the
936 /// build happens *because* the push landed and not some time after it. This
937 /// is the golden path's fourth step, the one that is nothing at all.
938 ///
939 /// Needs `--runner-workspace` and `--runner-builder`.
940 #[arg(long, requires_all = ["runner_workspace", "runner_builder"])]
941 pub runner: bool,
942
943 /// Where the runner puts checkouts and build output.
944 #[arg(long, value_name = "PATH")]
945 pub runner_workspace: Option<std::path::PathBuf>,
946
947 /// The identity-table principal the runner attributes every build record to.
948 #[arg(long, value_name = "PRINCIPAL")]
949 pub runner_builder: Option<String>,
950
951 /// Let the runner's builds reach the network.
952 ///
953 /// Off by default, and the server **refuses to start** with the runner on
954 /// and no sandbox available rather than building non-hermetically and
955 /// signing records that say otherwise. Turning this on is recorded in every
956 /// record the runner produces.
957 #[arg(long)]
958 pub runner_allow_network: bool,
959
960 /// Do not offer `filter`, so this server serves no partial clones.
961 ///
962 /// Removes `filter` from the advertisement **and** makes the parser refuse
963 /// the argument, in both protocol versions, so a client cannot get a
964 /// filtered pack by asking for one anyway. `git clone --filter=blob:none`
965 /// then fails with *"this server does not offer the filter capability"*
966 /// rather than quietly serving the whole thing.
967 ///
968 /// For an appliance that has to be able to state it never serves arbitrary
969 /// blob subsets. Everything else about a clone is unchanged.
970 #[arg(long)]
971 pub no_filter: bool,
972
973 /// Which object ids a client may `want`. Default `reachable`.
974 ///
975 /// `advertised-tips` is the strictest and **breaks partial clone**: a
976 /// `blob:none` clone's lazy fetch asks for bare blob ids that were never
977 /// advertised, so every `git checkout` in one becomes "the remote end hung
978 /// up unexpectedly". Pair it with `--no-filter` or not at all. `any` makes
979 /// every object ever pushed fetchable by whoever can name it, including on
980 /// deleted branches.
981 #[arg(long, value_name = "POLICY", value_enum)]
982 pub want_policy: Option<WantPolicyArg>,
983
984 /// Ceiling on **objects emitted** while answering one fetch.
985 ///
986 /// An operator's explicit cap on the size of a clone. It is no longer what
987 /// bounds gunnar's memory — `--max-fetch-walk-bytes` is, in the unit a
988 /// machine is actually sized in — and the reason is that an object count
989 /// does not convert to bytes at a fixed rate: MEASURED across gunnar's
990 /// corpus on 2026-08-05 the same walk cost between 393.8 and 1 572.9 bytes
991 /// per emitted object, a 4x spread set by the repository's shape.
992 ///
993 /// There is no "off" value. `0` would refuse every fetch rather than allow
994 /// every fetch — the walk stops when it reaches the budget — so it is
995 /// rejected at startup instead of being accepted as an unbounded server
996 /// that in fact serves nothing.
997 #[arg(long, value_name = "N")]
998 pub fetch_budget: Option<usize>,
999
1000 /// Bytes of object bookkeeping **one fetch** may hold. `0` removes the
1001 /// ceiling. Default 8589934592 (8 GiB).
1002 ///
1003 /// Charged at a measured 384 bytes per object the walk is holding, so the
1004 /// default admits about 22.4 million objects. That is deliberately past the
1005 /// linux kernel's 13 816 503: MEASURED 2026-08-05, a 14 042 000-object
1006 /// repository with no `.bitmap` clones in 5 460 816 kB of peak RSS and 64
1007 /// seconds, and the object-counted ceiling it shipped with refused it.
1008 ///
1009 /// This is a **per-request** number and on its own it is not a bound on the
1010 /// server: multiply it by `--max-concurrent-transfers`. See
1011 /// `--max-walk-bytes-total`.
1012 #[arg(long, value_name = "BYTES")]
1013 pub max_fetch_walk_bytes: Option<usize>,
1014
1015 /// Bytes of object bookkeeping **the whole server** may hold at once,
1016 /// shared by every transfer in flight. `0` removes the ceiling. Defaults to
1017 /// half of this machine's `MemTotal`.
1018 ///
1019 /// The ceiling that actually bounds the box. `--max-concurrent-transfers`
1020 /// counts transfers, which says nothing about memory: 256 concurrent clones
1021 /// of a kernel-sized repository need well over a terabyte between them, and
1022 /// what the shipped server did about that was let the OOM killer choose.
1023 /// A transfer that cannot get its reservation is refused with a sentence
1024 /// naming both numbers, and the client can retry.
1025 ///
1026 /// A walk holding under 8 MiB never touches the pool, so ordinary clones
1027 /// neither queue nor draw on it.
1028 #[arg(long, value_name = "BYTES")]
1029 pub max_walk_bytes_total: Option<usize>,
1030
1031 /// Bytes of pkt-line payload held for one peer-supplied request. `0`
1032 /// removes the ceiling. Default 8388608 (8 MiB).
1033 ///
1034 /// The size is the **peer's** to choose and costs it about what it costs
1035 /// the server, which is why this is an absolute number.
1036 #[arg(long, value_name = "BYTES")]
1037 pub max_fetch_request_bytes: Option<usize>,
1038
1039 /// Bytes of a single object gunnar will put in a pack. `0` removes the
1040 /// ceiling. Default 1073741824 (1 GiB), the same number `gunnar-pack`
1041 /// applies on the way in.
1042 #[arg(long, value_name = "BYTES")]
1043 pub max_fetch_object_bytes: Option<usize>,
1044
1045 /// Largest LFS object this server will store, in bytes. Default
1046 /// 8589934592 (8 GiB).
1047 ///
1048 /// **There is no "off" value**, and `0` is refused at startup. Unlike the
1049 /// two fetch ceilings above, `gunnar_lfs::Limits` holds a plain `u64` and
1050 /// not an `Option`, so `0` means "refuse every upload" rather than "accept
1051 /// any size". A finite default is deliberate: "no limit" means one client
1052 /// decides how much disk the server has.
1053 #[arg(long, value_name = "BYTES")]
1054 pub max_lfs_object_bytes: Option<u64>,
1055
1056 /// Most objects one LFS `batch` request may name. Default 10000. `0` is
1057 /// refused, for the same reason as above.
1058 #[arg(long, value_name = "N")]
1059 pub max_lfs_batch_objects: Option<usize>,
1060
1061 /// Bytes of a refused LFS upload that will be read and discarded to keep
1062 /// the channel in sync before the session is dropped. Default 131056
1063 /// (two pkt-line payloads). `0` is refused.
1064 ///
1065 /// A refusal cannot simply stop reading: the client is already writing the
1066 /// object into the channel and the framing only ends at its flush-pkt, so a
1067 /// server that answered 413 and then read the next *command* would read
1068 /// object bytes instead.
1069 #[arg(long, value_name = "BYTES")]
1070 pub max_lfs_drain_bytes: Option<u64>,
1071
1072 /// Bind, report, and exit rather than serving. For smoke tests and for
1073 /// checking a configuration without holding the ports.
1074 #[arg(long)]
1075 pub check: bool,
1076}
1077
1078/// `--want-policy`, one arm per [`gunnar_wire::WantPolicy`].
1079#[cfg(feature = "server")]
1080#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
1081pub enum WantPolicyArg {
1082 /// Only ids that appeared in the advertisement. Breaks partial clone.
1083 AdvertisedTips,
1084 /// Anything reachable from a reference. The default.
1085 Reachable,
1086 /// Anything in the store, reachable or not.
1087 Any,
1088}
1089
1090#[cfg(feature = "server")]
1091impl WantPolicyArg {
1092 /// The wire's own enum. One translation, in one place, so the flag and the
1093 /// behaviour cannot drift (one implementation).
1094 pub fn to_wire(self) -> gunnar_wire::WantPolicy {
1095 match self {
1096 WantPolicyArg::AdvertisedTips => gunnar_wire::WantPolicy::AdvertisedTips,
1097 WantPolicyArg::Reachable => gunnar_wire::WantPolicy::ReachableFromRefs,
1098 WantPolicyArg::Any => gunnar_wire::WantPolicy::Any,
1099 }
1100 }
1101}
1102
1103/// Namespace verbs.
1104#[cfg(feature = "server")]
1105#[derive(Debug, Subcommand)]
1106pub enum NamespaceCommand {
1107 /// Create a namespace. Nested names (`team/sub`) are legal; grants do NOT
1108 /// inherit between them.
1109 Create {
1110 /// The namespace name.
1111 name: String,
1112 },
1113 /// List namespaces.
1114 List,
1115 /// Delete a namespace.
1116 Delete {
1117 /// The namespace name.
1118 name: String,
1119 /// Also delete every repository in it. Never implicit.
1120 #[arg(long)]
1121 delete_repos: bool,
1122 },
1123 /// Set this account's read-cache budget.
1124 #[command(alias = "set-quota")]
1125 SetBudget {
1126 /// The namespace name.
1127 name: String,
1128 /// **The exploded read cache each of this account's repositories may
1129 /// hold**: `8G`, `512M`, `unlimited`, or `default` to go back to the
1130 /// server's own (10 % of /data, capped at 8 GiB).
1131 ///
1132 /// A bigger cache means more objects are already inflated and reads
1133 /// are fast; a smaller one means more reads re-inflate their own delta
1134 /// chain out of the pack, ~0.5–2 ms an object. It is never a refusal
1135 /// and never an error: an account at its budget serves everything it
1136 /// has, correctly, more slowly. Nothing it bounds can be lost — every
1137 /// byte of it is re-derivable from the packs, which it does not bound.
1138 #[arg(long, value_name = "BYTES")]
1139 explode_budget: Option<String>,
1140 },
1141}
1142
1143/// Repository verbs.
1144#[cfg(feature = "server")]
1145#[derive(Debug, Subcommand)]
1146pub enum RepoCommand {
1147 /// Create a repository. Its namespace must already exist.
1148 Create {
1149 /// `<namespace>/<name>`, with or without a `.git` suffix.
1150 path: String,
1151 /// sha256 is the default for new repositories.
1152 #[arg(long, value_enum, default_value_t = HashArg::Sha256)]
1153 hash: HashArg,
1154 /// The default branch, as a short name or a full ref.
1155 #[arg(long, value_name = "BRANCH")]
1156 default_branch: Option<String>,
1157 /// **Readable by anyone, with no key**: `git clone
1158 /// ssh://anonymous@host/ns/repo.git` works for a stranger. Writing
1159 /// still needs a grant. Private is the default.
1160 #[arg(long)]
1161 public: bool,
1162 /// A one-line description.
1163 #[arg(long, default_value = "")]
1164 description: String,
1165 /// **Take over a store already on disk at this name** instead of
1166 /// refusing it. Only for a matching hash (the store's `OBJECT_FORMAT`
1167 /// marker). Without this a create never adopts a directory it did not
1168 /// make: that is how a deleted repository came back (2026-08-30).
1169 #[arg(long)]
1170 adopt: bool,
1171 },
1172 /// List repositories.
1173 List {
1174 /// Only this namespace.
1175 #[arg(long, value_name = "NS")]
1176 namespace: Option<String>,
1177 },
1178 /// **Delete a repository. Without `--purge` this is a DATED TOMBSTONE, not
1179 /// a reclaim.**
1180 ///
1181 /// The catalog row goes, so the name stops working and nothing can be
1182 /// pushed to it — but the bytes stay, for `--retention-days` (30 by
1183 /// default), and `repo undelete <path>` puts it back exactly as it was.
1184 /// A background sweep reclaims them when the window closes.
1185 ///
1186 /// Why the window exists: `--purge` returns the bytes in about two seconds
1187 /// — 181 GiB to 33 MiB, measured — and a live mirror does not help,
1188 /// because the delete replicates to the standby too. Deleting the wrong
1189 /// repository is the mistake that will actually happen.
1190 ///
1191 /// **A tombstone still costs storage.** Its bytes are on the account until
1192 /// the window closes; `repo list-deleted` and `Admin.StorageInfo` report
1193 /// them, so the space a tombstone holds is never a surprise.
1194 Delete {
1195 /// `<namespace>/<name>`.
1196 path: String,
1197 /// **Reclaim the bytes NOW**, with no window and no undo.
1198 #[arg(long)]
1199 purge: bool,
1200 /// How long the bytes are kept. `0` means at the next sweep — an
1201 /// operator freeing space meant it.
1202 #[arg(long, value_name = "DAYS")]
1203 retention_days: Option<u32>,
1204 },
1205 /// **Put a soft-deleted repository back**, inside its retention window.
1206 ///
1207 /// It comes back as it was — the recorded hash kind above all. A
1208 /// repository recreated as sha256 over a sha1 store serves refs no client
1209 /// can read, which is why this is a verb and not `repo create --adopt`.
1210 Undelete {
1211 /// `<namespace>/<name>`.
1212 path: String,
1213 },
1214 /// **End a retention window early**: reclaim a soft-deleted repository's
1215 /// bytes now. There is no undo after it.
1216 Purge {
1217 /// `<namespace>/<name>`.
1218 path: String,
1219 },
1220 /// What is deleted and not yet purged, when the window closes, and the
1221 /// bytes each is still costing.
1222 ListDeleted {},
1223 /// **Store directories with no catalog row and no soft-delete tombstone —
1224 /// bytes nothing else on this box can see.**
1225 ///
1226 /// `repo list` reads the catalog and an orphan is not in it. `repo
1227 /// list-deleted`, `repo undelete`, `repo purge` and the retention sweep
1228 /// all key off a tombstone and an orphan has none. So a directory with
1229 /// neither was reachable by nothing at all — while `repo create` refused
1230 /// its name *because the directory was there*.
1231 ///
1232 /// MEASURED on the appliance 2026-08-30: ~25-39 GB standing at
1233 /// `alice/linux`, the wreckage of a failed kernel mirror, with the name
1234 /// permanently unusable and no verb able to touch the bytes.
1235 ///
1236 /// With `repo list` and `repo list-deleted` this closes the set: every
1237 /// `<name>.git` directory is in exactly one of the three.
1238 ListOrphans {
1239 /// Only this namespace.
1240 #[arg(long, value_name = "NS")]
1241 namespace: Option<String>,
1242 },
1243 /// **Reclaim an orphan's bytes.** There is no undo.
1244 ///
1245 /// Refuses anything that is not an orphan by name: a catalogued repository
1246 /// is `repo delete --purge`'s and a soft-deleted one is `repo undelete`'s
1247 /// or `repo purge`'s. To KEEP an orphan's objects instead, run `repo
1248 /// create <path> --adopt --hash <the OBJECT_FORMAT that list-orphans
1249 /// reports>`.
1250 Reclaim {
1251 /// `<namespace>/<name>`.
1252 path: String,
1253 },
1254 /// **One repository's stored bytes, out to ONE FILE.**
1255 ///
1256 /// The file is self-describing and copyable anywhere — another appliance,
1257 /// object storage, a laptop. It carries the verbatim pack bytes, the
1258 /// durable extent journal and the ref log; **no index travels**, because an
1259 /// index is a derivation and the receiver builds its own.
1260 ///
1261 /// `--from-generation` is a WATERMARK, not a diff: the pack is append-only,
1262 /// so everything above a byte offset is new and nothing below it changed.
1263 /// Pass a previous run's `to_generation` for an incremental archive.
1264 ///
1265 /// Safe while the server is running: the cut is taken at one length, and
1266 /// every byte below it is already fsynced.
1267 ArchiveOut {
1268 /// `<namespace>/<name>`.
1269 path: String,
1270 /// Where to write the archive.
1271 #[arg(long, value_name = "FILE")]
1272 to: std::path::PathBuf,
1273 /// Start above this `objects.pack` offset. 0 = a full archive.
1274 #[arg(long, default_value_t = 0, value_name = "N")]
1275 from_generation: u64,
1276 },
1277 /// **Put an archive back**, into a repository this server does not have.
1278 ///
1279 /// Restores the bytes and then puts the catalog row back (adopting the
1280 /// store it just wrote), so a restored repository is servable without a
1281 /// second command. An incremental archive is applied on top of its base and
1282 /// refuses by name if the base is not exactly what it was cut against.
1283 ArchiveIn {
1284 /// The archive file.
1285 file: std::path::PathBuf,
1286 /// `<namespace>/<name>` to restore it as. Its namespace must exist.
1287 #[arg(long = "as", value_name = "NS/NAME")]
1288 as_path: String,
1289 /// Restore the bytes only; do not touch the catalog.
1290 #[arg(long)]
1291 no_catalog: bool,
1292 },
1293}
1294
1295/// `gunnar issuer …` — the server's certificate authority.
1296///
1297/// **Exactly one issuer.** Not a list, not a default among several. A second
1298/// issuer is a second answer to *who may mint a user*, and the failure mode of
1299/// two is that nobody can say which one signed a given client. `create` on a
1300/// server that has one is refused; replacing it invalidates every certificate
1301/// in the field and is therefore a different verb.
1302///
1303/// The trusted-CA **list** is a separate thing and stays a list: a rotation
1304/// needs the old CA and the new one trusted at the same instant, or every
1305/// client is locked out at the swap. `trust`/`untrust` are that list.
1306/// `gunnar registry …` — the artifact registry (roadmap phase 4).
1307///
1308/// One rule, enforced where bytes become pullable: **no artifact without valid,
1309/// logged provenance**. Everything else here is plumbing around it.
1310#[cfg(feature = "server")]
1311#[derive(Debug, Subcommand)]
1312pub enum RegistryCommand {
1313 /// **Land a manifest under a tag** — if the image it names was built and
1314 /// attested, and a signed tree head covers the record.
1315 ///
1316 /// Refuses in four distinguishable ways, because the operator's next action
1317 /// differs for each. The commonest is the fourth: run
1318 /// `gunnar attest head --builds` first.
1319 Push {
1320 /// The image reference's name, e.g. `team/app`.
1321 #[arg(long, value_name = "NAME")]
1322 name: String,
1323 /// The tag to point at it.
1324 #[arg(long, value_name = "TAG", default_value = "latest")]
1325 tag: String,
1326 /// The manifest's digest, as `gunnar build image` printed it.
1327 #[arg(long, value_name = "DIGEST")]
1328 digest: String,
1329 },
1330 /// What a tag points at.
1331 Resolve {
1332 #[arg(long, value_name = "NAME")]
1333 name: String,
1334 #[arg(long, value_name = "TAG", default_value = "latest")]
1335 tag: String,
1336 },
1337 /// **Pull a manifest and verify the whole chain behind it.**
1338 ///
1339 /// The registry's acceptance test in one command: resolve the tag, read the
1340 /// manifest back out of content-addressed storage, and walk the chain from
1341 /// its digest down to the source.
1342 Pull {
1343 #[arg(long, value_name = "NAME")]
1344 name: String,
1345 #[arg(long, value_name = "TAG", default_value = "latest")]
1346 tag: String,
1347 /// `<path>:<line>` — check the chain reaches this line of source.
1348 #[arg(long, value_name = "PATH:LINE")]
1349 line: Option<String>,
1350 },
1351}
1352
1353/// `gunnar forge …` — issues, reviews and merges, as signed events (roadmap
1354/// phase 1).
1355///
1356/// Every verb that changes anything **signs** with the actor's own key, and the
1357/// event goes into an append-only log the same transparency machinery covers.
1358/// An approval is therefore evidence anybody can check later, without trusting
1359/// the server that stored it — which a row in a table saying `approved_by` is
1360/// not.
1361///
1362/// **This is the review half only.** The pull request itself — its number, its
1363/// refs, its state — is `PullRequests`' record under the control store's CAS,
1364/// and there is no CLI for it yet: open and merge one over gRPC.
1365///
1366/// The same events are reachable over gRPC as `Forge.*` — `Append` takes an
1367/// event the CALLER signed, verifies it against the identity key table and appends it,
1368/// because a server that could sign an approval could manufacture one. What is
1369/// deliberately not here yet is the console views, which live in the separate
1370/// `gunnar-UI` repository.
1371#[cfg(feature = "server")]
1372#[derive(Debug, Subcommand)]
1373pub enum ForgeCommand {
1374 /// Comment on a pull request.
1375 Comment {
1376 #[arg(long, value_name = "REPO")]
1377 repo: String,
1378 /// The pull request number, as `PullRequests.Create` assigned it.
1379 #[arg(long, value_name = "N")]
1380 pull: u64,
1381 #[arg(long, value_name = "TEXT")]
1382 body: String,
1383 /// The identity-table principal the event is attributed to.
1384 #[arg(long, value_name = "PRINCIPAL")]
1385 actor: String,
1386 /// The private key to sign with.
1387 #[arg(long, short = 'i', value_name = "PATH")]
1388 identity: std::path::PathBuf,
1389 },
1390 /// **Approve a specific commit.**
1391 ///
1392 /// The commit is part of what is signed, so a force-push after the review
1393 /// leaves an approval that visibly names the commit that was reviewed — and
1394 /// not the one that is there now. `PullRequests.Merge` refuses on exactly
1395 /// that, so this is a gate and not a display.
1396 Approve {
1397 #[arg(long, value_name = "REPO")]
1398 repo: String,
1399 #[arg(long, value_name = "N")]
1400 pull: u64,
1401 #[arg(long, value_name = "OID")]
1402 commit: String,
1403 #[arg(long, value_name = "PRINCIPAL")]
1404 actor: String,
1405 #[arg(long, short = 'i', value_name = "PATH")]
1406 identity: std::path::PathBuf,
1407 },
1408 /// **Withdraw an approval**, naming the commit it covered and why.
1409 ///
1410 /// Appended, never deleted: the log is append-only, so what the fold shows
1411 /// is that this actor no longer approves — and the withdrawal itself stays
1412 /// readable, which is the fact an auditor wants.
1413 Reject {
1414 #[arg(long, value_name = "REPO")]
1415 repo: String,
1416 #[arg(long, value_name = "N")]
1417 pull: u64,
1418 #[arg(long, value_name = "OID")]
1419 commit: String,
1420 #[arg(long, value_name = "TEXT")]
1421 reason: String,
1422 #[arg(long, value_name = "PRINCIPAL")]
1423 actor: String,
1424 #[arg(long, short = 'i', value_name = "PATH")]
1425 identity: std::path::PathBuf,
1426 },
1427 /// Show a pull request's review: its comments, and who approves what.
1428 ///
1429 /// The review only. The pull request itself — its title, its refs, whether
1430 /// it is open — belongs to `PullRequests`, and a second copy printed here
1431 /// would be a second answer that can disagree.
1432 Show {
1433 #[arg(long, value_name = "REPO")]
1434 repo: String,
1435 #[arg(long, value_name = "N")]
1436 pull: u64,
1437 },
1438 /// **Issues**: open, comment, close, list and show — as signed events.
1439 ///
1440 /// An issue is not a pull request and has no record behind it: it IS the
1441 /// fold of its own signed events, so opening one and closing one are events
1442 /// here (a review has neither — those are the pull request record's). A
1443 /// signed opening is the property GitHub, GitLab and Gitea have no
1444 /// equivalent for: who filed it is the author's own key.
1445 #[command(subcommand)]
1446 Issue(IssueCommand),
1447}
1448
1449/// `gunnar forge issue …` — issues as signed events.
1450#[cfg(feature = "server")]
1451#[derive(Debug, Subcommand)]
1452pub enum IssueCommand {
1453 /// **Open an issue.** Signs the title and body with the author's own key
1454 /// and appends it; the number is assigned by the fold and printed back.
1455 Open {
1456 #[arg(long, value_name = "REPO")]
1457 repo: String,
1458 #[arg(long, value_name = "TEXT")]
1459 title: String,
1460 #[arg(long, value_name = "TEXT", default_value = "")]
1461 body: String,
1462 /// The identity-table principal the issue is attributed to.
1463 #[arg(long, value_name = "PRINCIPAL")]
1464 actor: String,
1465 /// The private key to sign with.
1466 #[arg(long, short = 'i', value_name = "PATH")]
1467 identity: std::path::PathBuf,
1468 },
1469 /// Comment on an issue.
1470 Comment {
1471 #[arg(long, value_name = "REPO")]
1472 repo: String,
1473 /// The issue number, as `issue open` (or the fold) assigned it.
1474 #[arg(long, value_name = "N")]
1475 number: u64,
1476 #[arg(long, value_name = "TEXT")]
1477 body: String,
1478 #[arg(long, value_name = "PRINCIPAL")]
1479 actor: String,
1480 #[arg(long, short = 'i', value_name = "PATH")]
1481 identity: std::path::PathBuf,
1482 },
1483 /// **Close an issue**, naming why. Appended, never deleted — the close
1484 /// stays readable.
1485 Close {
1486 #[arg(long, value_name = "REPO")]
1487 repo: String,
1488 #[arg(long, value_name = "N")]
1489 number: u64,
1490 #[arg(long, value_name = "TEXT", default_value = "")]
1491 reason: String,
1492 #[arg(long, value_name = "PRINCIPAL")]
1493 actor: String,
1494 #[arg(long, short = 'i', value_name = "PATH")]
1495 identity: std::path::PathBuf,
1496 },
1497 /// List every issue in a repository, folded.
1498 List {
1499 #[arg(long, value_name = "REPO")]
1500 repo: String,
1501 },
1502 /// Show one issue: its state and its whole thread.
1503 Show {
1504 #[arg(long, value_name = "REPO")]
1505 repo: String,
1506 #[arg(long, value_name = "N")]
1507 number: u64,
1508 },
1509}
1510
1511/// `gunnar build …` — recording what was built, and from what.
1512///
1513/// The verbs a runner calls. There is no `run` here yet: executing a build
1514/// hermetically is roadmap phase 3's sandbox, and this is the half that makes
1515/// its output *evidence* — a signed record, in a log, joinable to the push that
1516/// landed the source.
1517#[cfg(feature = "server")]
1518#[derive(Debug, Subcommand)]
1519pub enum BuildCommand {
1520 /// **Record a build**: sign what was made, from what, by whom, and append
1521 /// it to the build log.
1522 ///
1523 /// Every artifact is named by the SHA-256 of its bytes, computed here from
1524 /// the file — never taken on trust from an argument, because a digest a
1525 /// caller supplies is a digest a caller can choose.
1526 Attest {
1527 /// `<namespace>/<repo>` the source came from.
1528 #[arg(long, value_name = "REPO")]
1529 repo: String,
1530 /// The commit that was built, hex.
1531 #[arg(long, value_name = "OID")]
1532 commit: String,
1533 /// What the build was asked to make: a recipe, an atom, a variant.
1534 #[arg(long, value_name = "NAME")]
1535 recipe: String,
1536 /// The toolchain, as the builder resolved it.
1537 #[arg(long, value_name = "TEXT", default_value = "")]
1538 toolchain: String,
1539 /// The identity-table principal that ran the build.
1540 #[arg(long, value_name = "PRINCIPAL")]
1541 builder: String,
1542 /// Say that the build **had** network access.
1543 ///
1544 /// Off by default and named in the positive, so a hermetic build is the
1545 /// one nobody has to remember a flag for — and a build that reached the
1546 /// network has to say so out loud.
1547 #[arg(long)]
1548 network: bool,
1549 /// A file the build consumed. Repeatable.
1550 #[arg(long = "material", value_name = "PATH")]
1551 materials: Vec<std::path::PathBuf>,
1552 /// A file the build produced. Repeatable, and at least one is required:
1553 /// a build record with no subject attests to nothing.
1554 #[arg(long = "artifact", value_name = "PATH", required = true)]
1555 artifacts: Vec<std::path::PathBuf>,
1556 },
1557 /// **A CycloneDX SBOM for an artifact**, out of the `Cargo.lock` that was
1558 /// locked when it was built.
1559 ///
1560 /// The lockfile and not `cargo metadata`: the lock is what the build
1561 /// resolved and is in the commit, so the document is a function of the
1562 /// source and can be reproduced from it. `cargo metadata` re-resolves
1563 /// against the index as it stands now, which describes a build that may
1564 /// never have happened.
1565 Sbom {
1566 /// The `Cargo.lock` the build used.
1567 #[arg(long, value_name = "PATH")]
1568 lock: std::path::PathBuf,
1569 /// The artifact the document is about.
1570 #[arg(long, value_name = "PATH")]
1571 artifact: std::path::PathBuf,
1572 },
1573 /// **Ask the registry's one question**: may these bytes land?
1574 ///
1575 /// No artifact without valid, logged provenance. Exits zero only when a
1576 /// build record names the artifact, verifies, sits in the build log, and is
1577 /// covered by a signed tree head — and names which of the four is missing
1578 /// when it refuses.
1579 Admit {
1580 /// The artifact to ask about.
1581 #[arg(long, value_name = "PATH")]
1582 artifact: std::path::PathBuf,
1583 },
1584 /// **Build a commit and attest to what came out.**
1585 ///
1586 /// Materialises exactly that commit from the store, builds it in a network
1587 /// namespace of its own, hashes the artifacts and signs a record naming the
1588 /// commit — which is the join `gunnar verify --artifact` walks.
1589 ///
1590 /// Refuses rather than building when the sandbox cannot be entered: a
1591 /// record that says `network: false` has to mean it on every machine.
1592 Run {
1593 /// `<namespace>/<repo>`.
1594 #[arg(long, value_name = "REPO")]
1595 repo: String,
1596 /// The commit to build. A ref name or a hex oid; defaults to the
1597 /// repository's HEAD.
1598 #[arg(long, default_value = "HEAD", value_name = "REV")]
1599 at: String,
1600 /// Where checkouts and build output go.
1601 #[arg(long, value_name = "PATH")]
1602 workspace: std::path::PathBuf,
1603 /// The identity-table principal the record is attributed to.
1604 #[arg(long, value_name = "PRINCIPAL")]
1605 builder: String,
1606 /// **Build with the network up.** Off by default, and recorded in the
1607 /// signed record when it is on, so a non-hermetic build is never
1608 /// mistaken for a hermetic one.
1609 #[arg(long)]
1610 allow_network: bool,
1611 },
1612 /// **Watch for pushes and build them.**
1613 ///
1614 /// The step the golden path calls "nothing at all": a push lands, this
1615 /// builds it hermetically and signs a record, and nobody types anything.
1616 ///
1617 /// Runs in the serving process — `gunnar serve --runner` is the same thing
1618 /// wired to the same bus. One build at a time, on purpose.
1619 Watch {
1620 /// Where checkouts and build output go.
1621 #[arg(long, value_name = "PATH")]
1622 workspace: std::path::PathBuf,
1623 /// The identity-table principal every record is attributed to.
1624 #[arg(long, value_name = "PRINCIPAL")]
1625 builder: String,
1626 /// Only build these refs. Repeatable; empty means every branch.
1627 #[arg(long = "ref", value_name = "REF")]
1628 refs: Vec<String>,
1629 /// Build with the network up. Recorded in every record it produces.
1630 #[arg(long)]
1631 allow_network: bool,
1632 /// Stop after this many builds, instead of watching for ever.
1633 #[arg(long, value_name = "N")]
1634 max_builds: Option<usize>,
1635 },
1636 /// **Build an OCI image around an attested artifact.**
1637 ///
1638 /// `FROM scratch`, one layer, one file — so "zero CVEs" is a statement
1639 /// about surface area rather than about diligence, and the file count is
1640 /// printed so it stays a number.
1641 ///
1642 /// The artifact must already be admitted: an image built from a binary
1643 /// nobody attested to would be an image whose chain stops one link short.
1644 /// The image's own manifest digest is then attested in turn, which is what
1645 /// lets the registry take it.
1646 Image {
1647 /// The binary to put in the image.
1648 #[arg(long, value_name = "PATH")]
1649 artifact: std::path::PathBuf,
1650 /// Where it lands inside the image, and what it is run as.
1651 #[arg(long, value_name = "PATH", default_value = "/gunnar")]
1652 entrypoint: String,
1653 /// The identity-table principal the image's record is attributed to.
1654 #[arg(long, value_name = "PRINCIPAL")]
1655 builder: String,
1656 },
1657 /// List what this server has attested to building.
1658 List,
1659 /// The public key a verifier pins to check build records.
1660 Key,
1661}
1662
1663/// `gunnar verify` — the chain, walked backwards (roadmap phase 2).
1664#[cfg(feature = "server")]
1665#[derive(Debug, clap::Args)]
1666pub struct VerifyArgs {
1667 /// `<namespace>/<repo>`. Omit it when `--artifact` is given: the build
1668 /// record names the repository the artifact was built from, and taking it
1669 /// from the command line instead would let a caller ask about a different
1670 /// one than the record claims.
1671 #[arg(value_name = "REPO", required_unless_present = "artifact")]
1672 pub repo: Option<String>,
1673 /// **Start at an artifact**, by its lowercase-hex SHA-256.
1674 ///
1675 /// The direction a verifier actually stands in: it holds a binary and wants
1676 /// the source. The build record that claims to have produced it supplies
1677 /// the repository and the commit, and the walk continues from there.
1678 #[arg(long, value_name = "SHA256", conflicts_with = "at")]
1679 pub artifact: Option<String>,
1680 /// The revision to start from: a branch, a tag, `HEAD`, or a hex oid.
1681 #[arg(long, default_value = "HEAD", value_name = "REV")]
1682 pub at: String,
1683 /// `<path>:<line>`, one-based — the way an editor and `git blame` count.
1684 ///
1685 /// Without it the chain starts at the revision's own commit and link 1 is
1686 /// reported as unchecked rather than as passed.
1687 #[arg(long, value_name = "PATH:LINE")]
1688 pub line: Option<String>,
1689}
1690
1691/// `gunnar blame <ns>/<repo> <path> [--rev REV]`.
1692#[cfg(feature = "server")]
1693#[derive(Debug, clap::Args)]
1694pub struct BlameArgs {
1695 /// `<namespace>/<repo>`.
1696 #[arg(value_name = "REPO")]
1697 pub repo: String,
1698 /// The file to blame, as a path within the repository's tree.
1699 #[arg(value_name = "PATH")]
1700 pub path: String,
1701 /// The revision to blame at: a branch, a tag, `HEAD`, or a hex oid.
1702 #[arg(long, default_value = "HEAD", value_name = "REV")]
1703 pub rev: String,
1704}
1705
1706/// `gunnar search <ns>/<repo> <pattern> [--rev REV] [--max-*]`.
1707#[cfg(feature = "server")]
1708#[derive(Debug, clap::Args)]
1709pub struct SearchArgs {
1710 /// `<namespace>/<repo>`.
1711 #[arg(value_name = "REPO")]
1712 pub repo: String,
1713 /// The literal string to look for. Not a regular expression — see the verb's
1714 /// help for why the appliance does not link one yet.
1715 #[arg(value_name = "PATTERN")]
1716 pub pattern: String,
1717 /// The revision to search: a branch, a tag, `HEAD`, or a hex oid. Only this
1718 /// one revision is searched, not the history behind it.
1719 #[arg(long, default_value = "HEAD", value_name = "REV")]
1720 pub rev: String,
1721 /// Skip files larger than this many bytes (they are never read).
1722 #[arg(long, default_value_t = 1 << 20, value_name = "BYTES")]
1723 pub max_file_bytes: u64,
1724 /// Stop after this many matches.
1725 #[arg(long, default_value_t = 1000, value_name = "N")]
1726 pub max_matches: usize,
1727}
1728
1729/// The transparency log's verbs, over the push attestations.
1730///
1731/// The log itself is `attestations.jsonl` and is written by every push. These
1732/// verbs do not write records; they sign, check and prove over the records that
1733/// are already there — which is why none of them can change what the log says.
1734#[cfg(feature = "server")]
1735#[derive(Debug, Subcommand)]
1736pub enum AttestCommand {
1737 /// **Sign what the log holds right now**, and append the head.
1738 ///
1739 /// Issue one whenever a claim about the log needs to be checkable later —
1740 /// after a release, on a timer, before an audit. Each head pins every
1741 /// record beneath it, so a rewrite of an old line is caught by every head
1742 /// issued since, including by somebody who never saw the original line.
1743 Head {
1744 /// Head the **build** log instead of the push-attestation log.
1745 ///
1746 /// Two logs, two heads: a head over one says nothing about the other,
1747 /// and signing both under one root would make a build record's proof
1748 /// depend on a push it has nothing to do with.
1749 #[arg(long)]
1750 builds: bool,
1751 },
1752 /// **Check every signed head against the records on disk.**
1753 ///
1754 /// The one verb that can say the log was tampered with. It keeps three
1755 /// failures apart on purpose: a head that does not verify (somebody wrote a
1756 /// head they could not sign, or the key rotated), a head that verifies over
1757 /// a root the records no longer produce (the records were changed), and a
1758 /// head covering more records than exist (lines were removed).
1759 Audit {
1760 /// Audit the **build** log instead of the push-attestation log.
1761 #[arg(long)]
1762 builds: bool,
1763 },
1764 /// The public key a verifier pins to check this server's tree heads.
1765 ///
1766 /// An OpenSSH public-key line, so `ssh-keygen -Y verify` can check a head
1767 /// with no gunnar involved.
1768 Key,
1769 /// **The inclusion proof for one push**, by the pack it landed.
1770 ///
1771 /// What a client checks offline: the record's position, the tree size and
1772 /// the sibling hashes. With a signed head it proves the push is in the log
1773 /// without trusting the server that served the proof.
1774 Prove {
1775 /// The `pack_hash` the attestation carries.
1776 #[arg(value_name = "PACK-HASH")]
1777 pack_hash: String,
1778 },
1779}
1780
1781/// **The certificate issuer.** Every verb but `show` writes, and writing needs
1782/// the storage half; `show` is a read and is here in either build.
1783#[cfg(any(feature = "server", feature = "control"))]
1784#[derive(Debug, Subcommand)]
1785pub enum IssuerCommand {
1786 /// Mint this server's issuer. **Refused if it already has one.**
1787 #[cfg(feature = "server")]
1788 Create {
1789 /// The subject common name the CA certificate carries.
1790 #[arg(long, default_value = "gunnar issuer")]
1791 subject: String,
1792 /// How long it is good for.
1793 #[arg(long, value_name = "DAYS", default_value_t = gunnar_core::DEFAULT_ISSUER_DAYS)]
1794 days: u32,
1795 },
1796 /// Adopt an issuer that already exists. **Refused if this server has one.**
1797 ///
1798 /// Both halves are checked against each other before anything is stored: a
1799 /// certificate that is not this key's is refused, rather than accepted and
1800 /// discovered on the first `principal add`.
1801 #[cfg(feature = "server")]
1802 Import {
1803 /// The CA certificate, PEM.
1804 #[arg(long, value_name = "PATH")]
1805 cert: std::path::PathBuf,
1806 /// Its private key, PEM. It goes straight into the sealed secret store
1807 /// and never into the control database.
1808 #[arg(long, value_name = "PATH")]
1809 key: std::path::PathBuf,
1810 },
1811 /// What is live: the issuer, and every CA this server trusts.
1812 ///
1813 /// **One verb, two places to stand.** With no `--at` it reads the data
1814 /// root under your feet, as it always has. With `--at <addr>` it asks a
1815 /// RUNNING server the same question over the control plane
1816 /// (`Admin.IssuerInfo`, server-wide `Read`) and prints the same lines.
1817 ///
1818 /// There is no second verb for the remote read, and that is a decision.
1819 /// `gunnar namespace` and `gunnar control namespace` are split because
1820 /// they are WRITES with different preconditions — the offline one needs
1821 /// the server stopped. This is a read of one document with no
1822 /// precondition at all: the answer is identical from either side, out of
1823 /// the same `pki_readonly` seam, and two spellings of one question would
1824 /// be two renderings to keep in step.
1825 ///
1826 /// # Why the wire half exists
1827 ///
1828 /// The appliance has no shell, so `--data-root` is unreachable on the box
1829 /// where it matters. The issuer certificate was otherwise handed out by
1830 /// exactly one RPC, `Bootstrap.Enroll`, whose window shuts for good on the
1831 /// first enrol — so a RESTORED appliance could never again tell anyone
1832 /// which CA it mints with, which is precisely when "is this still the CA
1833 /// my clients pinned?" needs an answer.
1834 Show {
1835 /// Print the issuer certificate itself, so it can be piped to a client.
1836 #[arg(long)]
1837 pem: bool,
1838 /// Ask a running server instead of this host's data root.
1839 ///
1840 /// No default and no environment variable on purpose: `issuer show`
1841 /// must not quietly start answering about a different machine because
1842 /// `GUNNAR_CONTROL_ADDR` was exported in the shell.
1843 #[arg(long, value_name = "ADDR", long_help = CONTROL_ADDR_HELP)]
1844 at: Option<String>,
1845 /// The private key to sign the call with. Only with `--at`.
1846 #[arg(long, short = 'i', value_name = "PATH", requires = "at")]
1847 identity: Option<PathBuf>,
1848 },
1849 /// Trust a CA this server does not mint with — the other half of a rotation.
1850 #[cfg(feature = "server")]
1851 Trust {
1852 /// A label. Never part of the credential.
1853 #[arg(long, default_value = "")]
1854 label: String,
1855 /// The CA certificate, PEM.
1856 #[arg(long, value_name = "PATH")]
1857 cert: std::path::PathBuf,
1858 },
1859 /// Stop trusting a CA. Refuses to remove the live issuer's own entry.
1860 #[cfg(feature = "server")]
1861 Untrust {
1862 /// The `SHA256:` fingerprint `issuer show` prints.
1863 fingerprint: String,
1864 },
1865 /// **Can this server be moved to `serve --grpc-tls-self`, and who does it
1866 /// cost?**
1867 ///
1868 /// Mutual TLS on the control plane refuses every peer that cannot present
1869 /// a certificate this server's issuer signed. That is the point of it, and
1870 /// it is also a cliff: a principal that holds only an SSH key can
1871 /// authenticate today and cannot complete a handshake afterwards. This
1872 /// prints, per principal, which side of the cliff they are on, and ends
1873 /// with the verdict `gunnar serve` itself will reach — it is the same
1874 /// function, asked in advance, so the two cannot disagree.
1875 ///
1876 /// Offline and local: it reads this host's identity table, because
1877 /// `--grpc-tls-self` is a serve flag and whoever flips it is already here.
1878 /// It changes nothing.
1879 #[cfg(feature = "server")]
1880 Readiness,
1881}
1882
1883/// Principal verbs.
1884#[cfg(feature = "server")]
1885#[derive(Debug, Subcommand)]
1886pub enum PrincipalCommand {
1887 /// Add a principal, and optionally issue its certificate.
1888 Add {
1889 /// A stable identifier. Grants name it, and it is the `CN` the
1890 /// certificate carries.
1891 id: String,
1892 /// A display name. Defaults to the id.
1893 #[arg(long)]
1894 name: Option<String>,
1895 /// What sort of actor this is.
1896 #[arg(long, value_enum, default_value_t = PrincipalKindArg::User)]
1897 kind: PrincipalKindArg,
1898 /// **Issue from a certificate signing request. This is the strong
1899 /// form.**
1900 ///
1901 /// The private key is generated on the machine that will use it and
1902 /// never leaves it, so this server is structurally incapable of
1903 /// impersonating the principal. Whatever subject the request claims is
1904 /// discarded: the certificate is issued under `id`.
1905 #[arg(long, value_name = "PATH", conflicts_with = "server_generated_key")]
1906 csr: Option<std::path::PathBuf>,
1907 /// **Generate the private key here and hand it back.** The convenient
1908 /// form, and the weaker one.
1909 ///
1910 /// The key existed on this server and travelled over the wire, so the
1911 /// server could have kept a copy and nothing in the protocol proves it
1912 /// did not — *"only alice could have signed this"* stops being true. It
1913 /// is spelled out rather than being what you get by not thinking about
1914 /// it, because a machine account being bootstrapped genuinely needs it.
1915 #[arg(long)]
1916 server_generated_key: bool,
1917 /// Where the issued `<id>_cert.pem` (and, for
1918 /// `--server-generated-key`, `<id>_key.pem`) are written.
1919 #[arg(long, value_name = "DIR", default_value = ".")]
1920 out: std::path::PathBuf,
1921 /// How long the issued certificate is good for.
1922 #[arg(long, value_name = "DAYS", default_value_t = gunnar_core::DEFAULT_LEAF_DAYS)]
1923 days: u32,
1924 },
1925 /// List principals.
1926 List,
1927 /// Remove a principal, with its keys and grants.
1928 Remove {
1929 /// The principal id.
1930 id: String,
1931 },
1932}
1933
1934/// Key verbs.
1935#[cfg(feature = "server")]
1936#[derive(Debug, Subcommand)]
1937pub enum TokenCommand {
1938 /// Mint an access token for a principal, and **print it once**.
1939 ///
1940 /// The secret is not stored: only its sha-256 is. Nothing in gunnar can
1941 /// print it again, which is why this is the only place it appears.
1942 Add {
1943 /// The principal the token authenticates as. It gets exactly that
1944 /// principal's grants and nothing else.
1945 principal: String,
1946 /// A label — which laptop, which CI job. Never part of the credential.
1947 #[arg(long, default_value = "")]
1948 label: String,
1949 /// Stop accepting it after this many days.
1950 #[arg(long, value_name = "DAYS")]
1951 expires_in: Option<u64>,
1952 },
1953 /// List tokens by their hash, which is what `revoke` takes.
1954 List {
1955 /// Only this principal's.
1956 #[arg(long)]
1957 principal: Option<String>,
1958 },
1959 /// Revoke a token by the hash `list` prints.
1960 Revoke {
1961 /// The token hash.
1962 hash: String,
1963 },
1964}
1965
1966/// WireGuard verbs.
1967///
1968/// One verb, and it exists because provisioning an OS-less fleet needs a key
1969/// and `wg genkey` is a SHELL — a thing this product does not have, on the
1970/// appliance or in its build. The curve is already linked into this binary for
1971/// the tunnel itself, so minting a key costs nothing that was not already
1972/// there.
1973#[cfg(feature = "server")]
1974#[derive(Debug, Subcommand)]
1975pub enum WgCommand {
1976 /// Mint a fresh x25519 keypair — what `wg genkey | wg pubkey` prints,
1977 /// without a shell.
1978 Keygen {
1979 /// Write the PRIVATE key here (one base64 line, mode 0600) instead of
1980 /// printing it, and print only the public half.
1981 ///
1982 /// This is the `wg0.key` an appliance is seeded with. An existing file
1983 /// is REFUSED rather than overwritten: a regenerated identity is a
1984 /// silent fleet-wide outage — every hub still allowlists the old public
1985 /// key, and a peer whose key is unknown gets silence, not an error.
1986 #[arg(long, value_name = "PATH")]
1987 private: Option<std::path::PathBuf>,
1988 },
1989}
1990
1991/// Public-key verbs.
1992#[cfg(feature = "server")]
1993#[derive(Debug, Subcommand)]
1994pub enum KeyCommand {
1995 /// Register an OpenSSH public key line to a principal.
1996 Add {
1997 /// The principal id.
1998 principal: String,
1999 /// An OpenSSH public key line, or `@<path>` to read one from a file.
2000 ///
2001 /// Omit when registering a certificate with `--cert`.
2002 #[arg(required_unless_present = "cert")]
2003 key: Option<String>,
2004 /// Register an X.509 certificate instead, from a PEM or DER file.
2005 ///
2006 /// The mTLS credential. It lands in the same table as an SSH key and
2007 /// under the same principal, because a fingerprint is all gunnar knows
2008 /// about a credential — only how it is presented differs.
2009 #[arg(long, conflicts_with = "key", value_name = "PATH")]
2010 cert: Option<std::path::PathBuf>,
2011 },
2012 /// List registered keys.
2013 List {
2014 /// Only this principal's keys.
2015 #[arg(long)]
2016 principal: Option<String>,
2017 },
2018 /// Revoke a key by its `SHA256:` fingerprint.
2019 Revoke {
2020 /// The fingerprint, exactly as `ssh-keygen -lf` prints it.
2021 fingerprint: String,
2022 },
2023}
2024
2025/// `gunnar grant`.
2026#[cfg(feature = "server")]
2027#[derive(Debug, clap::Args)]
2028#[command(group(ArgGroup::new("scope").required(true).args(["server", "namespace", "repo"])))]
2029pub struct GrantArgs {
2030 /// The principal id.
2031 pub principal: String,
2032 /// How much access.
2033 #[arg(value_enum)]
2034 pub level: LevelArg,
2035 /// Scope the grant to the whole server (the bootstrap admin's scope).
2036 #[arg(long)]
2037 pub server: bool,
2038 /// Scope the grant to one namespace, matched EXACTLY: `team` does not reach
2039 /// `team/sub`.
2040 #[arg(long, value_name = "NS")]
2041 pub namespace: Option<String>,
2042 /// Scope the grant to one repository, `<namespace>/<name>`.
2043 #[arg(long, value_name = "NS/NAME")]
2044 pub repo: Option<String>,
2045}
2046
2047/// `gunnar revoke`.
2048#[cfg(feature = "server")]
2049#[derive(Debug, clap::Args)]
2050#[command(group(ArgGroup::new("scope").required(true).args(["server", "namespace", "repo"])))]
2051pub struct RevokeArgs {
2052 /// The principal id.
2053 pub principal: String,
2054 /// The server-wide grant.
2055 #[arg(long)]
2056 pub server: bool,
2057 /// A namespace grant.
2058 #[arg(long, value_name = "NS")]
2059 pub namespace: Option<String>,
2060 /// A repository grant.
2061 #[arg(long, value_name = "NS/NAME")]
2062 pub repo: Option<String>,
2063}
2064
2065/// `gunnar reflog`.
2066///
2067/// Reads the archive `gunnar-store`'s compaction has always written and until
2068/// now nothing read. It opens **no store**, so it works against a repository a
2069/// running server holds the exclusive lock on — which is the only kind of
2070/// repository anyone wants the history of.
2071#[cfg(feature = "server")]
2072#[derive(Debug, clap::Args)]
2073pub struct ReflogArgs {
2074 /// The repository, `<namespace>/<name>`. Omit for every one.
2075 pub path: Option<String>,
2076
2077 /// Write the sealed history to this path instead of listing it.
2078 ///
2079 /// One Arrow IPC stream, one batch, the push boundary carried as a
2080 /// `push_seq` column rather than as a frame boundary — measured at 6.68x
2081 /// smaller per push than the live log it seals. With more than one
2082 /// repository this is treated as a **directory** and one artifact is
2083 /// written per repository, because a single file would silently keep only
2084 /// the last.
2085 #[arg(long, value_name = "PATH")]
2086 pub seal: Option<std::path::PathBuf>,
2087
2088 /// Print at most this many of the most recent transactions per repository.
2089 #[arg(long, default_value_t = 20, value_name = "N")]
2090 pub limit: usize,
2091
2092 /// Print every transaction, ignoring `--limit`.
2093 #[arg(long, conflicts_with = "limit")]
2094 pub all: bool,
2095}
2096
2097/// `gunnar gc`.
2098///
2099/// `Default` exists so that a caller — the tests, and any future programmatic
2100/// `gc` — can name the one field it cares about and inherit the verb's real
2101/// defaults for the rest. Spelling every flag out at each call site is how a
2102/// test ends up pinned to `Preserve` because that was the easiest literal to
2103/// write, and then guards nothing.
2104///
2105/// **It is hand-written, and it has to be.** A derive gives `0` for every
2106/// integer, and `--window 0`/`--depth 0` is a delta search that can never find
2107/// a base: every `GcArgs { .. , ..Default::default() }` in the tests would
2108/// silently stop deltifying and every one of them would still pass, because a
2109/// pack of whole objects is a valid pack. `gc_default_matches_the_command_line`
2110/// asserts the two agree, field by field.
2111#[cfg(feature = "server")]
2112#[derive(Debug, clap::Args)]
2113pub struct GcArgs {
2114 /// The repository to maintain, `<namespace>/<name>`. Omit for every one.
2115 pub path: Option<String>,
2116
2117 /// Also drop objects nothing reaches.
2118 ///
2119 /// Off unless asked, because reachability is computed from the refs as they
2120 /// are at this instant and a push landing mid-run is a race the grace
2121 /// window below exists to cover.
2122 #[arg(long)]
2123 pub prune: bool,
2124
2125 /// How long an unreachable object is kept before `--prune` may drop it.
2126 #[arg(long, value_name = "DURATION", default_value = "2w", value_parser = parse_prune_grace)]
2127 pub prune_grace: std::time::Duration,
2128}
2129
2130/// `now`, `<N>`, or `<N>` with a `s`/`m`/`h`/`d`/`w` suffix.
2131///
2132/// Hand-written rather than pulling in a date parser: the dependency budget is
2133/// a guarded number (`xtask guard`) and this is eleven lines. It deliberately
2134/// does **not** accept git's full `approxidate` grammar (`2.weeks.ago`), because
2135/// half-supporting a grammar is how `--prune=yesterday` ends up silently
2136/// meaning zero.
2137#[cfg(feature = "server")]
2138fn parse_prune_grace(raw: &str) -> std::result::Result<std::time::Duration, String> {
2139 parse_duration_words(raw, "grace period", "2w")
2140}
2141
2142/// `--max-silence` on `control twin replay`: the same grammar as [`parse_prune_grace`].
2143#[cfg(feature = "control")]
2144fn parse_max_silence(raw: &str) -> std::result::Result<std::time::Duration, String> {
2145 parse_duration_words(raw, "silence", "24h")
2146}
2147
2148/// The grammar of [`parse_prune_grace`], naming `what` and an `example` in its refusals.
2149#[cfg(any(feature = "server", feature = "control"))]
2150fn parse_duration_words(
2151 raw: &str,
2152 what: &str,
2153 example: &str,
2154) -> std::result::Result<std::time::Duration, String> {
2155 let raw = raw.trim();
2156 if raw.eq_ignore_ascii_case("now") {
2157 return Ok(std::time::Duration::ZERO);
2158 }
2159 let (digits, unit) = match raw.as_bytes().last() {
2160 Some(b) if b.is_ascii_digit() => (raw, 1u64),
2161 Some(b's') => (&raw[..raw.len() - 1], 1),
2162 Some(b'm') => (&raw[..raw.len() - 1], 60),
2163 Some(b'h') => (&raw[..raw.len() - 1], 60 * 60),
2164 Some(b'd') => (&raw[..raw.len() - 1], 24 * 60 * 60),
2165 Some(b'w') => (&raw[..raw.len() - 1], 7 * 24 * 60 * 60),
2166 _ => {
2167 return Err(format!(
2168 "{raw:?} is not a {what}; write `now`, a number of seconds, or a number \
2169 with a s/m/h/d/w suffix such as `{example}`"
2170 ))
2171 }
2172 };
2173 let n: u64 = digits.parse().map_err(|_| {
2174 format!(
2175 "{raw:?} is not a {what}: {digits:?} is not a number. Write `now`, a number \
2176 of seconds, or a number with a s/m/h/d/w suffix such as `{example}`"
2177 )
2178 })?;
2179 n.checked_mul(unit)
2180 .map(std::time::Duration::from_secs)
2181 .ok_or_else(|| format!("{raw:?} is not a {what}: it does not fit in a duration"))
2182}
2183
2184#[cfg(feature = "server")]
2185impl Default for GcArgs {
2186 fn default() -> Self {
2187 GcArgs {
2188 path: None,
2189 prune: false,
2190 // The same value as `default_value = "2w"` above;
2191 // `gc_default_matches_the_command_line` fails if they drift.
2192 prune_grace: std::time::Duration::from_secs(14 * 24 * 60 * 60),
2193 }
2194 }
2195}
2196
2197#[cfg(feature = "server")]
2198impl GcArgs {}
2199
2200/// The hash kinds gunnar knows.
2201#[cfg(any(feature = "server", feature = "control"))]
2202#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
2203pub enum HashArg {
2204 /// Git's original hash.
2205 Sha1,
2206 /// The default for new repositories.
2207 Sha256,
2208}
2209
2210#[cfg(feature = "server")]
2211impl From<HashArg> for gunnar_core::HashKind {
2212 fn from(arg: HashArg) -> Self {
2213 match arg {
2214 HashArg::Sha1 => gunnar_core::HashKind::Sha1,
2215 HashArg::Sha256 => gunnar_core::HashKind::Sha256,
2216 }
2217 }
2218}
2219
2220/// Grant levels, ordered `read < write < admin`.
2221///
2222/// Shared by the offline `gunnar grant` and the online `gunnar control grant`,
2223/// so `--level admin` means one thing whichever side of the wire it is spelled
2224/// on (one implementation).
2225#[cfg(any(feature = "server", feature = "control"))]
2226#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
2227pub enum LevelArg {
2228 /// `git-upload-pack`: clone and fetch.
2229 Read,
2230 /// `git-receive-pack`: push. Implies read.
2231 Write,
2232 /// Administer. Implies write.
2233 Admin,
2234}
2235
2236#[cfg(feature = "control")]
2237impl LevelArg {
2238 /// The proto enum this level is on the wire.
2239 pub fn to_proto(self) -> gunnar_proto::GrantLevel {
2240 match self {
2241 LevelArg::Read => gunnar_proto::GrantLevel::Read,
2242 LevelArg::Write => gunnar_proto::GrantLevel::Write,
2243 LevelArg::Admin => gunnar_proto::GrantLevel::Admin,
2244 }
2245 }
2246}
2247
2248#[cfg(feature = "server")]
2249impl From<LevelArg> for gunnar_core::Level {
2250 fn from(arg: LevelArg) -> Self {
2251 match arg {
2252 LevelArg::Read => gunnar_core::Level::Read,
2253 LevelArg::Write => gunnar_core::Level::Write,
2254 LevelArg::Admin => gunnar_core::Level::Admin,
2255 }
2256 }
2257}
2258
2259/// The three kinds of principal in the identity table.
2260#[cfg(any(feature = "server", feature = "control"))]
2261#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
2262pub enum PrincipalKindArg {
2263 /// A human.
2264 User,
2265 /// CI, a mirror, a robot with its own identity.
2266 Machine,
2267 /// A key scoped to one repository.
2268 Deploy,
2269 /// A group of principals (punkt 9): holds no key, is granted access like
2270 /// any principal, and its members inherit those grants. Prefer
2271 /// `gunnar control group add`, which is this with a name for it.
2272 Group,
2273}
2274
2275#[cfg(feature = "server")]
2276impl From<PrincipalKindArg> for gunnar_core::PrincipalKind {
2277 fn from(arg: PrincipalKindArg) -> Self {
2278 match arg {
2279 PrincipalKindArg::User => gunnar_core::PrincipalKind::User,
2280 PrincipalKindArg::Machine => gunnar_core::PrincipalKind::Machine,
2281 PrincipalKindArg::Deploy => gunnar_core::PrincipalKind::Deploy,
2282 PrincipalKindArg::Group => gunnar_core::PrincipalKind::Group,
2283 }
2284 }
2285}
2286
2287// ───────────────────────────────────────────────────────────────────────────
2288// the control plane, over the wire
2289// ───────────────────────────────────────────────────────────────────────────
2290
2291/// Where a `gunnar control` verb sends its call.
2292///
2293/// Its own arg rather than a global one, because it must appear in the
2294/// `--help` of each verb that uses it: a first-time user reading
2295/// `gunnar control bootstrap --help` has to see where it is about to send an
2296/// unauthenticated credential-granting call.
2297///
2298/// Declared in the SERVER build too, because `gunnar issuer show --at` is:
2299/// that binary must still parse the flag in order to refuse it BY NAME —
2300/// telling an operator "this binary carries no control client" rather than
2301/// "unexpected argument" — which is the same asymmetry `--monetize-pubkey`
2302/// has in the open build. It is a `&str`; it pulls in nothing.
2303#[cfg(any(feature = "control", feature = "server"))]
2304pub const CONTROL_ADDR_HELP: &str =
2305 "Where the control plane is. gunnar binds it to LOOPBACK by default, so the default here is \
2306 this host's server. To reach another host, tunnel to its loopback \
2307 (`ssh -L 50051:127.0.0.1:50051 <host>`) rather than binding it publicly. An estate is \
2308 reached through its site, `--at https://<site>`: its front carries the call, signed with \
2309 your key, to the main (GUNNAR_SITE_CA names the site's own CA, if a public one did not \
2310 issue its certificate).";
2311
2312/// `gunnar control admit …`
2313#[cfg(feature = "control")]
2314#[derive(Debug, Subcommand)]
2315pub enum AdmitCommand {
2316 /// How each kind of transfer for one namespace would be admitted
2317 /// (`Usage.AdmitPlan`).
2318 Show {
2319 #[arg(value_name = "NAMESPACE")]
2320 namespace: String,
2321 #[arg(long, short = 'i', value_name = "PATH")]
2322 identity: Option<PathBuf>,
2323 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
2324 at: String,
2325 },
2326}
2327
2328/// `gunnar control …`
2329#[cfg(feature = "control")]
2330#[derive(Debug, Subcommand)]
2331pub enum ControlCommand {
2332 /// Ask a server whether it has been enrolled. **Needs no credential.**
2333 ///
2334 /// It is the one question a server that nobody holds a key for can answer,
2335 /// and an unenrolled server is exactly that.
2336 Status {
2337 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
2338 at: String,
2339 },
2340
2341 /// **Step 1: claim a fresh server.** Creates the first user, who is
2342 /// gunnar-admin. Needs NO credential — and works exactly once, ever.
2343 ///
2344 /// The window is open only while no identity has ever been enrolled. It
2345 /// closes permanently on the first success, and deleting every user does
2346 /// not reopen it.
2347 Bootstrap {
2348 /// Your name. It is also your ACCOUNT: you are always admin of
2349 /// `<host>/<name>/<repo>`, and that is not a grant anybody can revoke.
2350 name: String,
2351 /// An OpenSSH public key line, or `@<path>`. Defaults to the public
2352 /// half of the key `-i` names, so neither flag is usually needed.
2353 #[arg(long, value_name = "KEY")]
2354 key: Option<String>,
2355 /// The private key whose public half to enrol, and to authenticate
2356 /// with afterwards.
2357 #[arg(long, short = 'i', value_name = "PATH")]
2358 identity: Option<PathBuf>,
2359 /// Where the client certificate the server issues (`<name>.cert.pem`,
2360 /// `<name>.key.pem`, `gunnar-issuer.pem`) is written. Defaults to a
2361 /// directory of your own, `~/.config/gunnar/<server>/`, created 0700 —
2362 /// never the current directory, which is a git checkout more often
2363 /// than not, and a private key in a checkout is one `git add` from a
2364 /// remote.
2365 #[arg(long, value_name = "DIR")]
2366 out: Option<PathBuf>,
2367 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
2368 at: String,
2369 },
2370
2371 /// **Step 3: log in** with your ssh key, and see what you are.
2372 ///
2373 /// Writes nothing. gunnar keeps no session on either side: every call signs
2374 /// a fresh single-use credential, so there is no state for `bootstrap` to
2375 /// have left behind and none for this to create.
2376 Login {
2377 #[arg(long, short = 'i', value_name = "PATH")]
2378 identity: Option<PathBuf>,
2379 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
2380 at: String,
2381 },
2382
2383 /// **Step 4: users.** Authenticated, and creating one needs gunnar-admin.
2384 #[command(subcommand)]
2385 User(ControlUserCommand),
2386
2387 /// **Groups: grant access to a team, not one person at a time** (punkt 9).
2388 ///
2389 /// A group is a principal that holds no key. Grant it access with the
2390 /// ordinary `gunnar control grant <group> …`, add people to it, and they
2391 /// inherit the grant; remove them and they lose it. Managing a group needs
2392 /// gunnar-admin, exactly like a grant.
2393 #[command(subcommand)]
2394 Group(ControlGroupCommand),
2395
2396 /// Repositories, against a running server.
2397 ///
2398 /// You may always create one in your OWN account with no grant at all;
2399 /// somebody else's needs a grant on their namespace.
2400 #[command(subcommand)]
2401 Repo(ControlRepoCommand),
2402
2403 /// **Is the record of what happened on this box still complete?**
2404 /// (`Admin.AuditTrail`, server-wide `Read`.)
2405 ///
2406 /// The control plane has written a tamper-evident hash chain of its
2407 /// decisions since GUN-044, and until 2026-09-21 there was no way to read
2408 /// it except as root on the box — which an appliance does not have. This
2409 /// prints the chain's shape: how many entries, the tip hash, whether the
2410 /// chain still verifies, and whether it is replicated to the twin.
2411 ///
2412 /// It prints no entry. Who did what is a read of the file; this verb
2413 /// answers completeness.
2414 ///
2415 /// # After a restore
2416 ///
2417 /// A restored box keeps appending, so its TIP has moved by the time you
2418 /// ask. Give `--at-seq <N>` the last sequence the old primary reported and
2419 /// compare the `hash at seq` line: because every entry's hash commits to
2420 /// its `prev`, one matching hash proves the whole prefix came back
2421 /// unaltered.
2422 Audit {
2423 /// Ask for the hash this chain carried at this sequence number — the
2424 /// prefix proof a restore is checked with.
2425 #[arg(long, value_name = "N")]
2426 at_seq: Option<u64>,
2427 #[arg(long, short = 'i', value_name = "PATH")]
2428 identity: Option<PathBuf>,
2429 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
2430 at: String,
2431 },
2432
2433 /// Namespaces, against a running server.
2434 ///
2435 /// The offline `gunnar namespace` writes the control repository directly
2436 /// and therefore needs the server **stopped**. These do not.
2437 #[command(subcommand)]
2438 Namespace(ControlNamespaceCommand),
2439
2440 /// **Accounts: lock a user out, and empty their account for good.**
2441 ///
2442 /// The per-USER half of what `repo` does per repository. `repo delete
2443 /// --purge` reaches one repository at a time; an account can hold hundreds
2444 /// of them plus soft-delete tombstones and orphan stores that no other
2445 /// listing shows, and there was no verb whose unit was a *user*.
2446 ///
2447 /// Every one of these needs **gunnar-admin** — Admin at server scope, not
2448 /// on the namespace. A namespace check would be self-granting: you are
2449 /// unconditionally admin of your own account, so it would let any user
2450 /// hard-purge themselves with one call and no undo.
2451 #[command(subcommand)]
2452 Account(ControlAccountCommand),
2453
2454 /// **Clans: ordinary accounts grouped, each giving part of its own room.**
2455 ///
2456 /// A clan has a namespace of its own (private, like every account's) and
2457 /// an admin — its creator — who invites accounts and administers what is
2458 /// made in it: visibility, who else may reach it, deletion. Its members
2459 /// read, write and create private repositories there. Its room is the sum
2460 /// of what its members give out of their own; a contribution goes down
2461 /// only as far as what the clan stores, and leaving gives it back.
2462 /// Every verb is YOURS: gunnar decides from the clan who may.
2463 #[command(subcommand)]
2464 Clan(ControlClanCommand),
2465
2466 /// **Pull mirroring: where this server's copy comes FROM.**
2467 ///
2468 /// The pull direction, and the exact mirror of `replication` below: that one
2469 /// says where a second copy of gunnar's repositories is pushed TO, this one
2470 /// says which other forge a repository here is pulled from. One service on
2471 /// the wire, because "where does this repository's history also live" is one
2472 /// question asked from two sides.
2473 ///
2474 /// `import` is the door: it lists a whole GitHub/Codeberg/GitLab/Forgejo
2475 /// account over that forge's REST API and mirrors what you choose, which is
2476 /// how a person moves in without a migration project.
2477 ///
2478 /// Not to be confused with `gunnar mirror` (no `control`), which keeps a
2479 /// copy in a data root on the machine you typed it on. This one talks to a
2480 /// server.
2481 #[command(subcommand)]
2482 Mirror(ControlMirrorCommand),
2483
2484 /// **Replication: where a second copy of everything lives.**
2485 ///
2486 /// The appliance has no shell. `<data-root>/replication.json` is the truth
2487 /// and it survives a reboot, but on an OS-less image running as PID 1 there
2488 /// is no editor to write it with — so these are how a standby is named, and
2489 /// they are the only way.
2490 ///
2491 /// A target is configurable BEFORE any repository exists: replication is
2492 /// scoped to the server, a namespace or a repository, and nothing is
2493 /// looked up when you set one.
2494 #[command(subcommand)]
2495 Replication(ControlReplicationCommand),
2496
2497 /// **The twin: the box that holds every transaction, and the restore
2498 /// from it.**
2499 ///
2500 /// A twin follows a primary's transaction log — every pack, ref edit,
2501 /// control-database write, secret and LFS object, in the order the
2502 /// primary was told — into a data root of its own, and never serves. On
2503 /// a gap it fetches the missing range first; when the primary no longer
2504 /// retains what it needs it stops and says `snapshot-needed`. `restore`
2505 /// replays a twin's log into a fresh data root.
2506 #[command(subcommand)]
2507 Twin(ControlTwinCommand),
2508
2509 /// **The WireGuard tunnel address: handed over at bootstrap, never baked
2510 /// into the medium.** `enrol` allocates the next free address of the
2511 /// site's pool (keyed by the box's minted public key), hands it to the box
2512 /// over `Bootstrap.AssignTunnelAddress`, and registers the box's `[Peer]`
2513 /// in the hub's WireGuard config.
2514 #[command(subcommand)]
2515 Tunnel(ControlTunnelCommand),
2516
2517 /// **Outbound events: send them to an external URL (webhooks).**
2518 ///
2519 /// A webhook is a subscriber to gunnar's event stream that POSTs each
2520 /// matching event to a URL — the way a CI/CD pipeline is triggered when a
2521 /// ref moves, without a process camped on `Events.Watch`. Every delivery is
2522 /// SIGNED with the server's key (an `sshsig`, no shared secret), so the
2523 /// receiver verifies it came from this gunnar with `ssh-keygen -Y verify`.
2524 ///
2525 /// Configured over the control plane and durable in
2526 /// `<data-root>/webhooks.json`, for the same reason replication is: the
2527 /// appliance has no shell.
2528 #[command(subcommand)]
2529 Webhook(ControlWebhookCommand),
2530
2531 /// **The pre-receive gate: branch protection and ref-name policy.**
2532 ///
2533 /// A merge already needs a verified signed approval naming the exact
2534 /// commit. Nothing stopped a DIRECT push to the same branch, because the
2535 /// only surface that could was reachable from neither this CLI nor the
2536 /// console — while the server had been serving `Policy.*` out of
2537 /// `refs/gunnar/policy` the whole time. These are that surface.
2538 #[command(subcommand)]
2539 Policy(ControlPolicyCommand),
2540
2541 /// Give a principal access, against a running server — or `grant list`
2542 /// to see who holds what.
2543 ///
2544 /// `gunnar control grant <principal> <level> --repo <ns>/<r>` gives;
2545 /// `gunnar control grant list` reads the whole table back. The one name
2546 /// this shadows is a principal literally called `list`, which `user add`
2547 /// will let you create and this verb can then not grant to.
2548 #[command(args_conflicts_with_subcommands = true)]
2549 Grant {
2550 #[command(subcommand)]
2551 command: Option<ControlGrantCommand>,
2552 /// The principal id.
2553 principal: Option<String>,
2554 /// How much access.
2555 #[arg(value_enum)]
2556 level: Option<LevelArg>,
2557 /// Scope it to the whole server. This IS gunnar-admin at `admin`.
2558 #[arg(long)]
2559 server: bool,
2560 /// Scope it to one namespace.
2561 #[arg(long, value_name = "NAME")]
2562 namespace: Option<String>,
2563 /// Scope it to one repository, `<namespace>/<repo>`.
2564 #[arg(long, value_name = "NS/REPO")]
2565 repo: Option<String>,
2566 #[arg(long, short = 'i', value_name = "PATH")]
2567 identity: Option<PathBuf>,
2568 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
2569 at: String,
2570 },
2571
2572 /// **Take a grant back**, against a running server.
2573 ///
2574 /// The mirror of `grant`, minus the level: a scope holds at most ONE
2575 /// grant per principal — granting on the same scope replaces — so the
2576 /// principal and the scope name the grant, and there is no level to
2577 /// choose. Revoking a grant that is not there is an ERROR, not a quiet
2578 /// success: "revoked" about a grant that never existed reads identically
2579 /// to "revoked" about the one you meant, and the difference is whether
2580 /// somebody still has access.
2581 ///
2582 /// A principal's OWN account is not a grant and cannot be revoked here;
2583 /// `user remove` takes the whole principal.
2584 Revoke {
2585 /// The principal id.
2586 principal: String,
2587 /// The server-wide grant. This is how gunnar-admin is taken back.
2588 #[arg(long)]
2589 server: bool,
2590 /// The grant on one namespace.
2591 #[arg(long, value_name = "NAME")]
2592 namespace: Option<String>,
2593 /// The grant on one repository, `<namespace>/<repo>`.
2594 #[arg(long, value_name = "NS/REPO")]
2595 repo: Option<String>,
2596 #[arg(long, short = 'i', value_name = "PATH")]
2597 identity: Option<PathBuf>,
2598 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
2599 at: String,
2600 },
2601
2602 /// The server's own health, authenticated: version, uptime and both
2603 /// listener binds.
2604 ///
2605 /// `status` is the unauthenticated question ("is this box enrolled, does
2606 /// it gate callers"); this is the authenticated one — what a headless
2607 /// appliance would tell you on a console you cannot see.
2608 Health {
2609 #[arg(long, short = 'i', value_name = "PATH")]
2610 identity: Option<PathBuf>,
2611 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
2612 at: String,
2613 },
2614
2615 /// **The public key this appliance signs actor tickets with**, as 64 hex
2616 /// characters — the string a monetize needs in `[products.<id>]
2617 /// appliance_key` before a tenant can renew their own subscription.
2618 ///
2619 /// `Billing.ApplianceIdentity` has served this since it was written, "so an
2620 /// operator can configure their monetize with it", and until 2026-09-19
2621 /// nothing but a test ever asked: there was no verb. MEASURED on gunnar.rs
2622 /// that day — every tenant's own renewal answered `501 unimplemented`,
2623 /// because the plugin had no key to check a ticket with and correctly
2624 /// refuses rather than trusting one it cannot verify.
2625 ///
2626 /// It is a PUBLIC key. Admin at server scope because it is operator
2627 /// configuration, not because the string is a secret — it VERIFIES tickets,
2628 /// it does not mint them.
2629 ///
2630 /// `metered` only, like the service it calls: `billing.proto` is not in
2631 /// codegen without the feature, and the community build "never mentions
2632 /// money at all" — an appliance that cannot sell a period carries no
2633 /// service that mints the permission to buy one, so a verb asking for one
2634 /// would be a promise the binary cannot keep.
2635 #[cfg(feature = "metered")]
2636 ApplianceIdentity {
2637 #[arg(long, short = 'i', value_name = "PATH")]
2638 identity: Option<PathBuf>,
2639 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
2640 at: String,
2641 },
2642
2643 /// **A namespace's plan, as this box ENFORCES it** — and the one door that
2644 /// hands the box a fact monetize signed.
2645 ///
2646 /// `Entitlement.Get/List/Set` had no verb: the only caller of `Set` was
2647 /// monetize's own poller, so a bench, a restore drill or an operator after
2648 /// a rescue could not put a fact on a box or ask what it was enforcing
2649 /// without writing a gRPC client. The reads answer out of the snapshot the
2650 /// front door reads, so a fact refused at boot — the wrong
2651 /// `--monetize-pubkey` after a restore — reads as Free here too.
2652 ///
2653 /// `metered` only, like `appliance-identity`: `entitlement.proto` is not in
2654 /// codegen without it.
2655 #[cfg(feature = "metered")]
2656 #[command(subcommand)]
2657 Entitlement(ControlEntitlementCommand),
2658
2659 /// The server's counters: principals, keys, grants, repositories, the
2660 /// event bus, tombstones, replication and index work in flight.
2661 Stats {
2662 #[arg(long, short = 'i', value_name = "PATH")]
2663 identity: Option<PathBuf>,
2664 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
2665 at: String,
2666 },
2667
2668 /// **"If a tenant's cap rose to N bytes, could you serve it?"** — the
2669 /// question to ask BEFORE adding a disk.
2670 ///
2671 /// Not "how much is free". The answer is a verdict measured off the same
2672 /// `statvfs` the push door refuses under, AND — separately — whether this
2673 /// data set can ever grow at all. On the appliance it cannot: `/data` is an
2674 /// md set laid once at install, gunnar has no runtime growth path, and
2675 /// gunnar is PID 1 with no shell, so a disk attached to it would be added for nothing
2676 /// and stay invisible. Both halves are printed, on the yes arm as well as
2677 /// the no arm.
2678 Capacity {
2679 /// The cap being contemplated, in bytes: `--bytes 107374182400` asks
2680 /// "could you serve a tenant capped at 100 GiB?".
2681 #[arg(long, value_name = "BYTES")]
2682 bytes: u64,
2683 /// Whose cap is rising. Bytes it ALREADY holds are already spent out
2684 /// of free space, so only the difference needs new room. Without it
2685 /// the harder question is asked: could you serve `--bytes` that are
2686 /// not here yet.
2687 #[arg(long, value_name = "NAMESPACE")]
2688 namespace: Option<String>,
2689 #[arg(long, short = 'i', value_name = "PATH")]
2690 identity: Option<PathBuf>,
2691 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
2692 at: String,
2693 },
2694
2695 /// **Grow the data set WITHOUT a restart** (`DATA-SET-GROWTH-FLOW.md`
2696 /// §3): drain, unmount, wait for every member volume to come back
2697 /// larger, grow in place, mount, reopen. `status` needs no credential.
2698 #[command(subcommand)]
2699 Grow(ControlGrowCommand),
2700
2701 /// What is on disk: which backends are behind the two traits, hot and
2702 /// cold bytes, and one row per repository.
2703 Storage {
2704 /// Only this repository, `<namespace>/<repo>`. Without it, the
2705 /// server-wide totals and no per-repository rows.
2706 #[arg(long, value_name = "NS/REPO")]
2707 repo: Option<String>,
2708 #[arg(long, short = 'i', value_name = "PATH")]
2709 identity: Option<PathBuf>,
2710 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
2711 at: String,
2712 },
2713
2714 /// **The estate's boxes, as this gunnar knows them** (`Admin.Topology`):
2715 /// this box first (primary or twin), then its twins and standbys with their
2716 /// live state, the consoles that call it and the monetize it registers
2717 /// with — one row per box, each with the sentence that says how gunnar
2718 /// knows. The same rows the console's Machine card draws.
2719 Topology {
2720 #[arg(long, short = 'i', value_name = "PATH")]
2721 identity: Option<PathBuf>,
2722 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
2723 at: String,
2724 },
2725
2726 /// **The meters, per namespace: what is held and what moved.**
2727 ///
2728 /// `pack_bytes` (the packs and their indexes — the stored bytes),
2729 /// `cache_bytes` (the exploded read cache, re-derivable), `lfs_bytes`,
2730 /// `tombstoned_bytes` (soft-deleted, still held, still counted), `repos`,
2731 /// and `objects` (the indexed objects of stores open right now — the RAM in
2732 /// use).
2733 ///
2734 /// Since the flow ledger it also prints what MOVED today — `in`, `out`,
2735 /// `pushes`, `fetches`, `refused` — and when the namespace was last seen.
2736 /// Those are counts of events and not readings of a disk; `gunnar control
2737 /// activity <namespace>` is the same numbers day by day.
2738 ///
2739 /// Open source and always on: a self-hoster reads exactly the numbers a
2740 /// service provider meters from. Needs `read` on the namespace, or a
2741 /// server-wide `read` for every namespace.
2742 Usage {
2743 /// One namespace. Without it, every namespace the catalog holds.
2744 #[arg(value_name = "NAMESPACE")]
2745 namespace: Option<String>,
2746 #[arg(long, short = 'i', value_name = "PATH")]
2747 identity: Option<PathBuf>,
2748 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
2749 at: String,
2750 },
2751
2752 /// **What MOVED through a namespace, day by day.**
2753 ///
2754 /// Every number `gunnar control usage` prints is a STOCK meter: it answers
2755 /// *how much is on the disk*, and it reads exactly the same for a namespace
2756 /// that was pushed to this morning and one that was last touched in March.
2757 /// This is the other kind — bytes in and out, pushes, fetches, refusals
2758 /// (with the reason), the keys that were seen, and when the namespace was
2759 /// last used. One row per UTC day that moved something; a day with no
2760 /// traffic has no row.
2761 ///
2762 /// Open source and always on, as `usage` is. Needs `read` on the namespace
2763 /// — a tenant's own traffic is a tenant's own business, so this takes the
2764 /// same grant `usage <namespace>` does and never a server-wide one.
2765 Activity {
2766 /// The namespace. Required: activity is per tenant, and there is no
2767 /// server-wide answer that would mean anything.
2768 #[arg(value_name = "NAMESPACE")]
2769 namespace: String,
2770 /// How many days back, ending today. The server keeps 400.
2771 #[arg(long, default_value_t = 30, value_name = "N")]
2772 days: u32,
2773 #[arg(long, short = 'i', value_name = "PATH")]
2774 identity: Option<PathBuf>,
2775 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
2776 at: String,
2777 },
2778
2779 /// **Admission: what a namespace's next transfer would get, and why.**
2780 ///
2781 /// `gunnar control admit show <namespace>` prints the usage over the
2782 /// rolling window, the budget and what is left of it, and per kind of work
2783 /// (clone, fetch, push, request) the class the gate would give it, the
2784 /// learnt estimate and the gate's own sentence. Needs `read` on the
2785 /// namespace — a tenant may read their own budget.
2786 Admit {
2787 #[command(subcommand)]
2788 command: AdmitCommand,
2789 },
2790
2791 /// **Who wrote each line of a file, against a RUNNING server.**
2792 ///
2793 /// The offline `gunnar blame` opens the data root directly and takes an
2794 /// exclusive lock, so it cannot run while a `gunnar serve` holds the same
2795 /// root — which on a sealed appliance with no shell means it cannot run at
2796 /// all. This asks the running server to walk its own open store and answer
2797 /// over the control plane, so a headless box can finally be blamed. Needs
2798 /// `read` on the repository.
2799 Blame {
2800 /// `<namespace>/<repo>`.
2801 #[arg(value_name = "NS/REPO")]
2802 repo: String,
2803 /// The file to blame, as a path within the repository's tree.
2804 #[arg(value_name = "PATH")]
2805 path: String,
2806 /// The revision to blame at: a branch, a tag, `HEAD`, or a hex oid.
2807 #[arg(long, default_value = "HEAD", value_name = "REV")]
2808 rev: String,
2809 #[arg(long, short = 'i', value_name = "PATH")]
2810 identity: Option<PathBuf>,
2811 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
2812 at: String,
2813 },
2814
2815 /// **A literal string across one revision's files, against a RUNNING
2816 /// server.** The up-server twin of `gunnar search`, reachable on a
2817 /// no-shell appliance for the same reason `control blame` is. Needs `read`
2818 /// on the repository.
2819 Search {
2820 /// `<namespace>/<repo>`.
2821 #[arg(value_name = "NS/REPO")]
2822 repo: String,
2823 /// The literal string to look for. Not a regular expression.
2824 #[arg(value_name = "PATTERN")]
2825 pattern: String,
2826 /// The revision to search: a branch, a tag, `HEAD`, or a hex oid. Only
2827 /// this one revision, not the history behind it.
2828 #[arg(long, default_value = "HEAD", value_name = "REV")]
2829 rev: String,
2830 /// Skip files larger than this many bytes (they are never read). 0
2831 /// means the server default.
2832 #[arg(long, default_value_t = 0, value_name = "BYTES")]
2833 max_file_bytes: u64,
2834 /// Stop after this many matches. 0 means the server default.
2835 #[arg(long, default_value_t = 0, value_name = "N")]
2836 max_matches: u32,
2837 #[arg(long, short = 'i', value_name = "PATH")]
2838 identity: Option<PathBuf>,
2839 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
2840 at: String,
2841 },
2842
2843 /// **Show a pull request's review, against a RUNNING server.**
2844 ///
2845 /// The comments and the approvals, with every approval re-checked against
2846 /// the identity key table right now — the same fold `gunnar forge show` prints,
2847 /// but reached over the control plane. The offline `gunnar forge show`
2848 /// opens the data root under an exclusive lock and so cannot run while a
2849 /// `gunnar serve` holds it, which on a sealed appliance with no shell means
2850 /// an operator has no other door to the review at all. Whether the pull
2851 /// request is open and what it merged at are `gunnar control pull`'s
2852 /// answers, deliberately not duplicated here. Needs `read` on the
2853 /// repository.
2854 Show {
2855 /// `<namespace>/<repo>`.
2856 #[arg(value_name = "NS/REPO")]
2857 repo: String,
2858 /// The pull request number, as `PullRequests.Create` assigned it.
2859 #[arg(value_name = "N")]
2860 pull: u64,
2861 #[arg(long, short = 'i', value_name = "PATH")]
2862 identity: Option<PathBuf>,
2863 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
2864 at: String,
2865 },
2866
2867 /// **Merge a reviewed pull request, against a RUNNING server.**
2868 ///
2869 /// Fast-forward only, and gated: the server refuses unless a verified
2870 /// approval names the exact commit the target ref is about to move to, so a
2871 /// force-push after the review does not inherit it. This is the merge an
2872 /// operator on a sealed appliance runs — there is no other door to
2873 /// `PullRequests.Merge` from a shell-less box — and the gate's rules (the
2874 /// approval names the commit, forbid-self-approval, required-approvals) are
2875 /// the server's, applied unchanged; this is only the verb that reaches
2876 /// them. Needs `write` on the repository.
2877 Merge {
2878 /// `<namespace>/<repo>`.
2879 #[arg(value_name = "NS/REPO")]
2880 repo: String,
2881 /// The pull request number to merge.
2882 #[arg(value_name = "N")]
2883 pull: u64,
2884 #[arg(long, short = 'i', value_name = "PATH")]
2885 identity: Option<PathBuf>,
2886 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
2887 at: String,
2888 },
2889
2890 /// **Who am I on this server?** (`Access.WhoAmI`.) The principal your key
2891 /// belongs to and the highest server-wide level it holds. Needs no grant.
2892 Whoami {
2893 #[arg(long, short = 'i', value_name = "PATH")]
2894 identity: Option<PathBuf>,
2895 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
2896 at: String,
2897 },
2898
2899 /// **Pull requests, against a RUNNING server** (`PullRequests.*`): open,
2900 /// list, show and close one. Merging is `gunnar control merge`.
2901 #[command(subcommand)]
2902 Pr(ControlPrCommand),
2903
2904 /// **Issues, against a RUNNING server**, signed with YOUR key and appended
2905 /// with `Forge.Append` — the online twin of the offline `gunnar forge issue`.
2906 #[command(subcommand)]
2907 Issue(ControlIssueCommand),
2908
2909 /// **A pull request's review, against a RUNNING server**: read it, comment
2910 /// on it, approve a commit — the events signed with YOUR key.
2911 #[command(subcommand)]
2912 Review(ControlReviewCommand),
2913
2914 /// **The live event feed** (`Events.Watch`).
2915 #[command(subcommand)]
2916 Events(ControlEventsCommand),
2917
2918 /// **A first-login link onto an existing person** (`Access.EnrolmentLink`,
2919 /// IDENTITY-HOST): minted by the identity core, a server admin's.
2920 #[command(subcommand)]
2921 Invite(ControlInviteCommand),
2922}
2923
2924/// `gunnar control invite …`
2925#[cfg(feature = "control")]
2926#[derive(Debug, Subcommand)]
2927pub enum ControlInviteCommand {
2928 /// Create one; prints its id and its SECRET — shown here once, never again.
2929 Create {
2930 /// The person whose passkey it enrols.
2931 #[arg(value_name = "PRINCIPAL")]
2932 principal: String,
2933 /// Days until it expires (at most 30).
2934 #[arg(long, value_name = "DAYS", default_value_t = 7)]
2935 expires_in_days: u32,
2936 /// Refuse a person who already holds a passkey (their FIRST one only).
2937 #[arg(long)]
2938 first_only: bool,
2939 #[arg(long, short = 'i', value_name = "PATH")]
2940 identity: Option<PathBuf>,
2941 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
2942 at: String,
2943 },
2944}
2945
2946/// `gunnar control pr …` — pull requests over the control plane.
2947#[cfg(feature = "control")]
2948#[derive(Debug, Subcommand)]
2949pub enum ControlPrCommand {
2950 /// Open a pull request from `--source` into `--target`. Needs `write`.
2951 Create {
2952 /// `<namespace>/<repo>`.
2953 #[arg(value_name = "NS/REPO")]
2954 repo: String,
2955 /// The full ref proposed, e.g. `refs/heads/topic`.
2956 #[arg(long, value_name = "REF")]
2957 source: String,
2958 /// The full ref it would merge into, e.g. `refs/heads/main`.
2959 #[arg(long, value_name = "REF")]
2960 target: String,
2961 #[arg(long, value_name = "TEXT")]
2962 title: String,
2963 #[arg(long, value_name = "TEXT", default_value = "")]
2964 body: String,
2965 #[arg(long, short = 'i', value_name = "PATH")]
2966 identity: Option<PathBuf>,
2967 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
2968 at: String,
2969 },
2970 /// List a repository's pull requests. Needs `read`.
2971 List {
2972 #[arg(value_name = "NS/REPO")]
2973 repo: String,
2974 /// `open`, `merged`, `closed` or `all`.
2975 #[arg(long, value_name = "STATE", default_value = "all")]
2976 state: String,
2977 #[arg(long, short = 'i', value_name = "PATH")]
2978 identity: Option<PathBuf>,
2979 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
2980 at: String,
2981 },
2982 /// Show one pull request. Needs `read`.
2983 Get {
2984 #[arg(value_name = "NS/REPO")]
2985 repo: String,
2986 #[arg(value_name = "N")]
2987 number: u64,
2988 #[arg(long, short = 'i', value_name = "PATH")]
2989 identity: Option<PathBuf>,
2990 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
2991 at: String,
2992 },
2993 /// Close a pull request without merging it. Needs `write`.
2994 Close {
2995 #[arg(value_name = "NS/REPO")]
2996 repo: String,
2997 #[arg(value_name = "N")]
2998 number: u64,
2999 #[arg(long, short = 'i', value_name = "PATH")]
3000 identity: Option<PathBuf>,
3001 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
3002 at: String,
3003 },
3004}
3005
3006/// `gunnar control issue …` — issues over the control plane, signed with the
3007/// caller's own key. The actor is who gunnar says the key is (`WhoAmI`).
3008#[cfg(feature = "control")]
3009#[derive(Debug, Subcommand)]
3010pub enum ControlIssueCommand {
3011 /// Open an issue; prints the number the fold assigned. Needs `write`.
3012 Open {
3013 #[arg(value_name = "NS/REPO")]
3014 repo: String,
3015 #[arg(long, value_name = "TEXT")]
3016 title: String,
3017 #[arg(long, value_name = "TEXT", default_value = "")]
3018 body: String,
3019 #[arg(long, short = 'i', value_name = "PATH")]
3020 identity: Option<PathBuf>,
3021 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
3022 at: String,
3023 },
3024 /// Comment on an issue. Needs `write`.
3025 Comment {
3026 #[arg(value_name = "NS/REPO")]
3027 repo: String,
3028 #[arg(value_name = "N")]
3029 number: u64,
3030 #[arg(long, value_name = "TEXT")]
3031 body: String,
3032 #[arg(long, short = 'i', value_name = "PATH")]
3033 identity: Option<PathBuf>,
3034 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
3035 at: String,
3036 },
3037 /// Close an issue, naming why. Needs `write`.
3038 Close {
3039 #[arg(value_name = "NS/REPO")]
3040 repo: String,
3041 #[arg(value_name = "N")]
3042 number: u64,
3043 #[arg(long, value_name = "TEXT", default_value = "")]
3044 reason: String,
3045 #[arg(long, short = 'i', value_name = "PATH")]
3046 identity: Option<PathBuf>,
3047 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
3048 at: String,
3049 },
3050 /// List a repository's issues. Needs `read`.
3051 List {
3052 #[arg(value_name = "NS/REPO")]
3053 repo: String,
3054 #[arg(long, short = 'i', value_name = "PATH")]
3055 identity: Option<PathBuf>,
3056 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
3057 at: String,
3058 },
3059 /// Show one issue and its whole thread. Needs `read`.
3060 Get {
3061 #[arg(value_name = "NS/REPO")]
3062 repo: String,
3063 #[arg(value_name = "N")]
3064 number: u64,
3065 #[arg(long, short = 'i', value_name = "PATH")]
3066 identity: Option<PathBuf>,
3067 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
3068 at: String,
3069 },
3070}
3071
3072/// `gunnar control review …` — a pull request's review over the control
3073/// plane, the events signed with the caller's own key.
3074#[cfg(feature = "control")]
3075#[derive(Debug, Subcommand)]
3076pub enum ControlReviewCommand {
3077 /// The review: comments and who approves what, each approval re-checked by
3078 /// gunnar. The same as `gunnar control show`. Needs `read`.
3079 Get {
3080 #[arg(value_name = "NS/REPO")]
3081 repo: String,
3082 #[arg(value_name = "N")]
3083 pull: u64,
3084 #[arg(long, short = 'i', value_name = "PATH")]
3085 identity: Option<PathBuf>,
3086 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
3087 at: String,
3088 },
3089 /// Comment on a pull request — on one line of one file with `--path`,
3090 /// `--line` and `--blob`, or on the whole pull request without. Needs
3091 /// `write`.
3092 Comment {
3093 #[arg(value_name = "NS/REPO")]
3094 repo: String,
3095 #[arg(value_name = "N")]
3096 pull: u64,
3097 #[arg(long, value_name = "TEXT")]
3098 body: String,
3099 /// The file the comment anchors to.
3100 #[arg(long, value_name = "PATH", requires_all = ["line", "blob"])]
3101 path: Option<String>,
3102 /// The line in that file (1-based).
3103 #[arg(long, value_name = "N")]
3104 line: Option<u32>,
3105 /// The blob the line is in, so the comment follows the code.
3106 #[arg(long, value_name = "OID")]
3107 blob: Option<String>,
3108 #[arg(long, short = 'i', value_name = "PATH")]
3109 identity: Option<PathBuf>,
3110 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
3111 at: String,
3112 },
3113 /// Approve a specific commit — the one `gunnar control merge` will move
3114 /// the target to. Needs `write`.
3115 Approve {
3116 #[arg(value_name = "NS/REPO")]
3117 repo: String,
3118 #[arg(value_name = "N")]
3119 pull: u64,
3120 #[arg(long, value_name = "OID")]
3121 commit: String,
3122 #[arg(long, short = 'i', value_name = "PATH")]
3123 identity: Option<PathBuf>,
3124 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
3125 at: String,
3126 },
3127}
3128
3129/// `gunnar control events …`
3130#[cfg(feature = "control")]
3131#[derive(Debug, Subcommand)]
3132pub enum ControlEventsCommand {
3133 /// **Print the live feed**, one line per event, until interrupted (or
3134 /// `--count` events). With a server-wide `read`: everything, narrowed by
3135 /// the flags. Without one: naming no `--namespace` watches your own
3136 /// accounts (and accounts whose events are public); naming some asks for
3137 /// exactly those, and gunnar refuses an account you may not watch.
3138 Watch {
3139 /// An account to watch. Repeatable.
3140 #[arg(long, value_name = "NS")]
3141 namespace: Vec<String>,
3142 /// One repository, `<namespace>/<repo>`. Repeatable.
3143 #[arg(long, value_name = "NS/REPO")]
3144 repo: Vec<String>,
3145 /// Only these kinds, e.g. `ref-updated`, `repo-created`,
3146 /// `pull-request-opened`. Repeatable.
3147 #[arg(long, value_name = "KIND")]
3148 kind: Vec<String>,
3149 /// Stop after this many events (the stream's own prologue not counted).
3150 #[arg(long, value_name = "N")]
3151 count: Option<u64>,
3152 /// Resume after this sequence number, as far as the server still holds.
3153 #[arg(long, value_name = "SEQ", default_value_t = 0)]
3154 from_seq: u64,
3155 #[arg(long, short = 'i', value_name = "PATH")]
3156 identity: Option<PathBuf>,
3157 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
3158 at: String,
3159 },
3160}
3161
3162/// `gunnar control grant list` — who holds what.
3163#[cfg(feature = "control")]
3164#[derive(Debug, Clone, Subcommand)]
3165pub enum ControlGrantCommand {
3166 /// **Every grant on the server, per principal** — the answer to "what can
3167 /// this principal see?".
3168 ///
3169 /// One line per grant under the principal that holds it: the scope
3170 /// (`server`, `namespace:<name>`, `repo:<namespace>/<repo>`) and the
3171 /// level. A principal's OWN account does not appear: everyone is admin of
3172 /// `<their-name>/…` by construction, and that is not a grant anybody gave
3173 /// or can revoke.
3174 List {
3175 /// Only this principal's grants.
3176 #[arg(long, value_name = "ID")]
3177 principal: Option<String>,
3178 #[arg(long, short = 'i', value_name = "PATH")]
3179 identity: Option<PathBuf>,
3180 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
3181 at: String,
3182 },
3183}
3184
3185/// `gunnar control tunnel …` — a box's WireGuard tunnel address.
3186#[cfg(feature = "control")]
3187#[derive(Debug, Clone, Subcommand)]
3188pub enum ControlTunnelCommand {
3189 /// What the box holds: its WireGuard public key, its tunnel address, and
3190 /// whether `wg0` is up (`Bootstrap.AssignTunnelAddress` with no address).
3191 Show {
3192 /// The box's control plane.
3193 #[arg(
3194 long,
3195 value_name = "HOST:PORT",
3196 conflicts_with = "ssh",
3197 required_unless_present = "ssh"
3198 )]
3199 at: Option<String>,
3200 /// A SEALED box's SSH door instead (`serve --no-grpc`): the
3201 /// `gunnar-tunnel-address` verb, as a gunnar-admin (`-i`).
3202 #[arg(long, value_name = "HOST:PORT")]
3203 ssh: Option<String>,
3204 /// The gunnar-admin key for `--ssh`.
3205 #[arg(long, short = 'i', value_name = "PATH")]
3206 identity: Option<PathBuf>,
3207 },
3208 /// **Give a fresh box its tunnel address and register it on the hub.**
3209 /// Idempotent: the same box gets the same address back.
3210 Enrol {
3211 /// The box's control plane (gRPC). A fresh box takes the address on
3212 /// `Bootstrap.AssignTunnelAddress`, an enrolled one on
3213 /// `Admin.SetTunnelAddress` (needs `-i`).
3214 #[arg(
3215 long,
3216 value_name = "HOST:PORT",
3217 conflicts_with = "ssh",
3218 required_unless_present = "ssh"
3219 )]
3220 at: Option<String>,
3221 /// A SEALED box instead (`serve --no-grpc --admin-key …`, no control
3222 /// plane): its SSH door, `gunnar-tunnel-address`, as the gunnar-admin
3223 /// whose key `-i` names.
3224 #[arg(long, value_name = "HOST:PORT")]
3225 ssh: Option<String>,
3226 /// The site's tunnel subnet, `a.b.c.d/prefix`. Its first host is the
3227 /// hub's and is never handed out.
3228 #[arg(long, value_name = "CIDR")]
3229 pool: String,
3230 /// The site's ledger of which box holds which address (created if absent).
3231 #[arg(long, value_name = "PATH")]
3232 ledger: PathBuf,
3233 /// The hub's WireGuard config; the box's `[Peer]` is appended once.
3234 #[arg(long, value_name = "PATH")]
3235 hub_peers: PathBuf,
3236 /// A name for the box in the ledger and the hub's comment (default: `--at`).
3237 #[arg(long)]
3238 label: Option<String>,
3239 /// A gunnar-admin key: for `--ssh`, and for a gRPC box PAST its
3240 /// bootstrap window (then the address goes over `Admin.SetTunnelAddress`).
3241 #[arg(long, short = 'i', value_name = "PATH")]
3242 identity: Option<PathBuf>,
3243 },
3244}
3245
3246/// `gunnar control twin …` — the twin, and the restore from it.
3247#[cfg(feature = "control")]
3248#[derive(Debug, Clone, Subcommand)]
3249pub enum ControlTwinCommand {
3250 /// **Make a FRESH box a twin** (`Bootstrap.BecomeTwin` — refused once
3251 /// anything is enrolled, and taken only under a credential from a key the
3252 /// box was FED: the installer's admin, an admin principal of its feed, or
3253 /// `--restore-pubkey`; h-bootstrap-open). Prints the twin's public key:
3254 /// enrol it on the primary as a machine in group `twin`.
3255 Join {
3256 /// The fresh box's control plane.
3257 #[arg(long, value_name = "HOST:PORT")]
3258 at: String,
3259 /// The primary's control plane, as the TWIN reaches it.
3260 #[arg(long, value_name = "HOST:PORT")]
3261 primary: String,
3262 /// A key the fresh box was fed (it signs the call).
3263 #[arg(long, short = 'i', value_name = "PATH")]
3264 identity: Option<PathBuf>,
3265 },
3266 /// **Give a twin its mutual-TLS credential** — the pairing step the one
3267 /// medium cannot carry. Asks the TWIN for a signing request (its key is
3268 /// minted there and never leaves), has the PRIMARY's issuer sign it for
3269 /// `--principal` (Admin; the principal must be in group `twin`), and hands
3270 /// the certificate and the issuer to pin back to the twin. Run it after the
3271 /// twin's key is enrolled on the primary. Run it again under the same
3272 /// issuer to RE-ISSUE it, without a shell on either box.
3273 Certify {
3274 /// The twin's control plane.
3275 #[arg(long, value_name = "HOST:PORT")]
3276 at: String,
3277 /// The primary's control plane, as THIS machine reaches it.
3278 #[arg(long, value_name = "HOST:PORT")]
3279 primary_at: String,
3280 /// The twin's principal on the primary.
3281 #[arg(long, default_value = "twin-box")]
3282 principal: String,
3283 /// An admin key on the primary.
3284 #[arg(long, short = 'i', value_name = "PATH")]
3285 identity: Option<PathBuf>,
3286 },
3287 /// **Restore a fresh primary from its twin, over the wire**: the twin's
3288 /// `Mirror.LogFetch` piped into the fresh box's `Bootstrap.Restore`
3289 /// (no credential there — it is fresh). Restart the box afterwards.
3290 Replay {
3291 /// The twin's control plane.
3292 #[arg(long, value_name = "HOST:PORT")]
3293 twin: String,
3294 /// The fresh box's control plane.
3295 #[arg(long, value_name = "HOST:PORT")]
3296 into: String,
3297 /// The key to read the twin's log with (an admin of the set).
3298 #[arg(long, short = 'i', value_name = "PATH")]
3299 identity: Option<PathBuf>,
3300 /// **Do not tell the box how much is coming, so it cannot refuse for room.**
3301 ///
3302 /// By default the replay announces the twin's log size and the box checks its own
3303 /// data set against it before the first byte lands, refusing by name when it is
3304 /// short — the restore-onto-smaller-hardware case. That check is deliberately
3305 /// conservative: it asks for twice the LOG, and a long-lived box's log is larger
3306 /// than the stores it builds (superseded documents, deleted packs — what
3307 /// `twin retain` exists to trim). So a legitimate restore of an old, busy box onto
3308 /// the same hardware can be refused.
3309 ///
3310 /// This says "I have looked at the numbers in the refusal and this set holds it".
3311 /// The restore then behaves as it always did: if the volume really does fill, the
3312 /// apply fails mid-stream, the box stays `restoring`, and the replay is resumable
3313 /// onto a bigger set. It is never a silent short restore either way.
3314 #[arg(long)]
3315 ignore_room: bool,
3316 /// **Restore from this twin even though it is not a current copy.**
3317 ///
3318 /// By default the replay asks the twin how current it is and REFUSES
3319 /// when the answer is not "level with its primary, as of moments ago":
3320 /// a twin that is behind, that is not following, or whose primary last
3321 /// answered longer ago than the heartbeat's silent bound. The refusal
3322 /// carries both numbers (applied / the primary's head) and the AGE, so
3323 /// the operator is refusing or overriding on facts.
3324 ///
3325 /// The twin exists for the worst case, and the worst case is exactly
3326 /// when nobody has time to notice that the copy stopped following a
3327 /// week ago. Restoring from a stale twin is sometimes still the right
3328 /// call — it is the only copy there is — so this says "I have read the
3329 /// numbers in the refusal and I want this copy anyway". It is never a
3330 /// silent stale restore either way.
3331 #[arg(long)]
3332 stale_ok: bool,
3333 /// **Take the replay although the twin no longer holds its oldest entries.** A twin
3334 /// that trimmed its log (`twin retain`) and keeps no snapshot of what it trimmed
3335 /// cannot give those entries back; by default the replay refuses by name, saying how
3336 /// many and what goes with them (DATA-SET-GROWTH-FLOW §2.1), before a byte streams.
3337 #[arg(long)]
3338 accept_lost: bool,
3339 /// **How long a twin that was LEVEL at its last answer may have heard nothing from its
3340 /// primary before plain replay refuses it** (`--stale-ok` then required). Default 24 h.
3341 ///
3342 /// From the twin, a primary that died and a network cut to a primary still alive and
3343 /// taking writes look the same: level, then silence. Minutes of that is main dying; a
3344 /// day of it is not something to assume. `now` refuses any silence; `<N>` seconds, or
3345 /// `<N>` with a `s`/`m`/`h`/`d`/`w` suffix.
3346 #[arg(long, value_name = "DURATION", default_value = "24h", value_parser = parse_max_silence)]
3347 max_silence: std::time::Duration,
3348 /// **The twin's old primary is fenced (or its machine stopped): replay beside it.**
3349 ///
3350 /// A twin that heard its primary within the last minute is following a box that is
3351 /// ALIVE; filling a fresh box from it would make two writable primaries, so plain
3352 /// replay refuses `not_fenced`. Fence the old primary first (`Mirror.SetRole` standby
3353 /// and `DemoteReplication`, or stop it) and say so here: a fenced primary still
3354 /// answers its twin, so the twin cannot tell. A primary re-laid at the same address
3355 /// reads `diverged` and needs no fence.
3356 #[arg(long)]
3357 primary_fenced: bool,
3358 },
3359 /// **Follow a primary.** Fetch every transaction it has that this data
3360 /// root has not, in order, apply it, keep polling. Needs server Admin on
3361 /// the primary: the log carries every sealed secret.
3362 Follow {
3363 /// The primary's control plane.
3364 #[arg(long, value_name = "HOST:PORT")]
3365 at: String,
3366 /// This twin's data root — its own copy of everything, plus the log.
3367 #[arg(long, value_name = "DIR")]
3368 data_root: PathBuf,
3369 /// The private key to authenticate with.
3370 #[arg(long, short = 'i', value_name = "PATH")]
3371 identity: Option<PathBuf>,
3372 /// Seconds between polls.
3373 #[arg(long, default_value_t = 5, value_name = "SECS")]
3374 poll_secs: u64,
3375 /// One poll, then exit — a cron, a bench, a hand check.
3376 #[arg(long)]
3377 once: bool,
3378 },
3379 /// **Restore: replay a twin's log into a fresh data root.** The restored
3380 /// primary continues the same sequence.
3381 Restore {
3382 /// The twin's data root (its `txlog/` is what is replayed).
3383 #[arg(long, value_name = "DIR")]
3384 from: PathBuf,
3385 /// The fresh data root to rebuild into.
3386 #[arg(long, value_name = "DIR")]
3387 into: PathBuf,
3388 },
3389 /// **What a box's log says**: head, oldest retained, and on a twin what
3390 /// it has applied and whether it is caught up.
3391 Status {
3392 /// The box's control plane.
3393 #[arg(long, value_name = "HOST:PORT")]
3394 at: String,
3395 /// The private key to authenticate with.
3396 #[arg(long, short = 'i', value_name = "PATH")]
3397 identity: Option<PathBuf>,
3398 },
3399 /// **Retention** (`Mirror.LogRetain`, server Admin): drop every whole
3400 /// segment of the PRIMARY's log below `--from` (default: everything up to
3401 /// the head). A twin that has not applied up to there takes a snapshot.
3402 Retain {
3403 /// The primary's control plane.
3404 #[arg(long, value_name = "HOST:PORT")]
3405 at: String,
3406 /// The oldest sequence to keep; 0 = keep nothing older than the next append.
3407 #[arg(long, default_value_t = 0, value_name = "SEQ")]
3408 from: u64,
3409 /// **Retain past what a twin has applied** (`LogRetainRequest.allow_past_twin`). By
3410 /// default the primary refuses to drop a frame the lowest-reporting twin has not applied,
3411 /// naming both numbers. This says the twin is gone or the hole is the point (a drill);
3412 /// the audit trail records who said it.
3413 #[arg(long)]
3414 allow_past_twin: bool,
3415 /// The private key to authenticate with.
3416 #[arg(long, short = 'i', value_name = "PATH")]
3417 identity: Option<PathBuf>,
3418 },
3419 /// **CATCH A TWIN UP THROUGH A RETENTION HOLE** — the one verb that drives
3420 /// `Mirror.SnapshotFetch` on purpose (`.nornir/twin-transaction-log.md`
3421 /// §Snapshot; the KVM row R8 in the same file).
3422 ///
3423 /// A twin that was away longer than the primary's retention window meets a
3424 /// HOLE: the next sequence it needs, `applied + 1`, is below the primary's
3425 /// `first_seq`, and no `LogFetch` can ever answer for it again
3426 /// (`mirror.proto`'s `LogFetch`: "`from` below `first_seq` is
3427 /// FAILED_PRECONDITION naming `first_seq`: the twin needs a snapshot, not a
3428 /// range"). The only way across is a full copy of the primary's state at one
3429 /// sequence, and this is the verb that takes one.
3430 ///
3431 /// **It freezes the primary, and that is why it is its own verb.** The
3432 /// producer holds `Log::freeze()` for the whole of `SnapshotFetch`
3433 /// (`crates/gunnar-grpc/src/service/mirror.rs:117`), so while the snapshot
3434 /// streams EVERY framed write on the primary is stopped — every push, every
3435 /// ref update, every control-plane mutation. A 44 GiB snapshot legitimately
3436 /// takes a long time and the freeze is the cost of a consistent `at`; it is
3437 /// never a cost to incur by accident. So:
3438 ///
3439 /// * it REFUSES BY NAME when there is no hole (`no-hole`), because a twin
3440 /// that is merely behind catches up with `twin follow`, which freezes
3441 /// nothing;
3442 ///
3443 /// * it reports how long the primary's writes were frozen, as a number;
3444 ///
3445 /// * it bounds the wait: no piece for `--stall-secs` fails by name
3446 /// (`stalled`) and drops the stream, which is what releases the freeze.
3447 ///
3448 /// Run it WHERE THE TWIN'S DISK IS, like `twin follow`: `--at` is the
3449 /// PRIMARY's control plane, `--data-root` is this box's own data root. The
3450 /// twin's own follower must not be running against that root at the same
3451 /// time.
3452 ///
3453 /// ```text
3454 /// gunnar control twin catch-up --at primary:50051 --data-root /data
3455 /// ```
3456 ///
3457 /// `--dry-run` establishes the hole and stops, so the decision to stop the
3458 /// primary's write path is made before it happens.
3459 CatchUp {
3460 /// **The PRIMARY's control plane** — the box the snapshot is taken
3461 /// from, and the box whose writes freeze while it streams.
3462 #[arg(long, value_name = "HOST:PORT")]
3463 at: String,
3464 /// **This twin's data root**, on this machine. The snapshot is staged
3465 /// in it (`txlog-snapshot-staging/`) and installed over it.
3466 #[arg(long, value_name = "DIR")]
3467 data_root: PathBuf,
3468 /// The private key to authenticate with: server Admin on the primary,
3469 /// or the twin's own machine key in group `twin` — a snapshot carries
3470 /// every sealed secret and every object.
3471 #[arg(long, short = 'i', value_name = "PATH")]
3472 identity: Option<PathBuf>,
3473 /// **Give up when NO snapshot piece has arrived for this long.** The
3474 /// deadline is on no progress, never on the transfer: a slow primary
3475 /// that keeps sending pieces is never cut off. The default is the
3476 /// primary's own mirror-image guard
3477 /// (`gunnar_grpc::service::mirror::SNAPSHOT_STALL_TIMEOUT`, 120 s),
3478 /// which is what cuts a twin off that stops READING.
3479 #[arg(long, default_value_t = 120, value_name = "SECS")]
3480 stall_secs: u64,
3481 /// **How many times the whole stream may be restarted.**
3482 ///
3483 /// `SnapshotFetchRequest` has no fields (`mirror.proto:165`), so there
3484 /// is no cursor on the wire and a broken stream can only be taken
3485 /// again FROM THE BEGINNING — which re-freezes the primary from
3486 /// scratch. Each restart is announced. A stall is never retried.
3487 #[arg(long, default_value_t = 3, value_name = "N")]
3488 attempts: u32,
3489 /// Establish whether there is a hole and say so, then stop. Nothing is
3490 /// streamed and the primary is never frozen.
3491 #[arg(long)]
3492 dry_run: bool,
3493 },
3494}
3495
3496/// `gunnar control replication …` — replication's standbys.
3497#[cfg(feature = "control")]
3498#[derive(Debug, Clone, Subcommand)]
3499pub enum ControlReplicationCommand {
3500 /// **Add or replace a standby, by name.** Takes effect immediately and
3501 /// survives a reboot.
3502 ///
3503 /// Idempotent: the same `<name>` twice replaces rather than duplicates, so
3504 /// a retry over a flaky link cannot leave two standbys behind.
3505 Add {
3506 /// Your name for this standby. Every event, log line and status row
3507 /// identifies it by this rather than by its URL.
3508 name: String,
3509 /// Where it is: an SSH base — `ssh://git@standby:2222` — onto which
3510 /// `/<namespace>/<repo>.git` is appended, or a complete URL ending in
3511 /// `.git` for a `--repo`-scoped target.
3512 ///
3513 /// SSH only, and anything else is refused by name: the push half is a
3514 /// send-pack over the server's russh transport, which has no HTTP
3515 /// transport to dial with.
3516 remote: String,
3517 /// Scope it to one namespace instead of the whole server.
3518 #[arg(long, value_name = "NAME", conflicts_with = "repo")]
3519 namespace: Option<String>,
3520 /// Scope it to one repository, `<namespace>/<repo>`.
3521 #[arg(long, value_name = "NS/REPO")]
3522 repo: Option<String>,
3523 /// **The deploy key the SERVER authenticates to the standby with**, as
3524 /// a path ON THE SERVER.
3525 ///
3526 /// A server has no ssh-agent and no interactive user. Without this the
3527 /// push falls back to the client key ladder, which on a server is
3528 /// almost never what was meant — and the server warns about it at
3529 /// every start-up.
3530 #[arg(long, value_name = "PATH")]
3531 identity_path: Option<String>,
3532 /// Override the `known_hosts` file for this target, as a path on the
3533 /// server.
3534 #[arg(long, value_name = "PATH")]
3535 known_hosts_path: Option<String>,
3536 /// Refuse an unknown host key instead of trusting it on first use.
3537 /// Off by default so a standby can be brought up without a
3538 /// provisioning step; on is right for anything that outlives an
3539 /// afternoon.
3540 #[arg(long)]
3541 strict_host_key: bool,
3542 /// **Which repositories, by visibility.** `all` (the default) is a
3543 /// standby: everything the scope covers. `public-only` is a browse
3544 /// replica's feed: a private repository is
3545 /// never pushed there, and one that goes private is DELETED there —
3546 /// which is why `public-only` also needs `--control`.
3547 #[arg(long, value_enum, default_value_t = ReplicationScopeArg::All)]
3548 scope: ReplicationScopeArg,
3549 /// The replica's control plane, `host:port`, REQUIRED with
3550 /// `--scope public-only`: the delete on a public → private flip is
3551 /// `Repos.Delete` over this address, signed with `--identity-path`.
3552 #[arg(long, value_name = "HOST:PORT", required_if_eq("scope", "public-only"))]
3553 control: Option<String>,
3554 #[arg(long, short = 'i', value_name = "PATH")]
3555 identity: Option<PathBuf>,
3556 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
3557 at: String,
3558 },
3559 /// Remove a standby by name and stop replicating to it now.
3560 ///
3561 /// A name that is not configured is an error and not a quiet success:
3562 /// "removed" about a standby that was never there reads identically to
3563 /// "removed" about the one you meant, and the difference is whether your
3564 /// data is still going somewhere.
3565 Remove {
3566 name: String,
3567 #[arg(long, short = 'i', value_name = "PATH")]
3568 identity: Option<PathBuf>,
3569 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
3570 at: String,
3571 },
3572 /// **DEMOTE this box: drop EVERY standby at once — "I am no longer a
3573 /// primary".**
3574 ///
3575 /// The failover primitive. When a primary dies and its standby is promoted
3576 /// (promotion is operational — a gunnar standby is already a full gunnar
3577 /// that serves reads and takes writes, so nothing on it flips), the
3578 /// recovered old box comes back up and serving but its
3579 /// `replication.json` still lists the box that is now the NEW primary. Its
3580 /// replicator then resumes pushing its now-stale state and
3581 /// **force-clobbers the new primary's post-failover writes** — MEASURED in
3582 /// `deploy/checks/failover-drill.sh`, where a client commit made against the
3583 /// promoted standby was overwritten back to the old primary's ref.
3584 ///
3585 /// Run this on the recovered box before re-attaching it: it clears the whole
3586 /// target roster in one write, so the box stops being a primary. Then point
3587 /// the NEW primary at it with `replication add` and `replication resync`, and
3588 /// the pair is whole again — replicating the other way.
3589 ///
3590 /// It does not touch the box's repositories or its own control plane: a
3591 /// demoted box still serves git and still takes writes. It has only stopped
3592 /// shipping a second copy anywhere. Idempotent — a box with no targets
3593 /// demotes to the same place, so it is safe to run again.
3594 Demote {
3595 #[arg(long, short = 'i', value_name = "PATH")]
3596 identity: Option<PathBuf>,
3597 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
3598 at: String,
3599 },
3600 /// **Make this box a BROWSE replica**: a
3601 /// read-only standby that also serves anonymous reads of public
3602 /// repositories, refuses every control-plane mutator, and CANNOT be
3603 /// promoted — not by `promote`, not by `abdicate --promote`, not by the
3604 /// passive heartbeat. Feed it from the primary with
3605 /// `replication add <name> <remote> --scope public-only --control <this
3606 /// box>:50051`. The way back is `standby`, which only narrows what it
3607 /// serves.
3608 Browse {
3609 #[arg(long, short = 'i', value_name = "PATH")]
3610 identity: Option<PathBuf>,
3611 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
3612 at: String,
3613 },
3614 /// **ABDICATE: this master steps down cleanly, then hands off.**
3615 ///
3616 /// The voluntary counterpart to a crash. `demote` is what you run on a box
3617 /// that already died; `abdicate` is how a healthy master leaves without
3618 /// losing a write, in order:
3619 ///
3620 /// 1. **freeze** — this box stops taking client writes (role → standby),
3621 /// so nothing lands here after the hand-off begins;
3622 /// 2. **flush** — every standby is resynced and this waits for the queues
3623 /// to drain, so the standby has everything that was pending. The wait
3624 /// is a poll that returns the instant every queue is empty, bounded by
3625 /// a ceiling (default 30 s) so a dead standby cannot wedge the hand-off.
3626 /// On a heavily loaded box a *healthy* standby's backlog can take longer
3627 /// than 30 s; raise the ceiling with `GUNNAR_ABDICATE_FLUSH_SECS=<secs>`
3628 /// in the serving box's environment (a blank/zero/garbage value keeps
3629 /// the 30 s default);
3630 /// 3. **demote** — the roster is cleared, so this box can never later
3631 /// clobber the new primary.
3632 ///
3633 /// Then, as abdication's LAST act, it promotes the new primary — IF you name
3634 /// where its control plane is with `--promote`. That is the takeover: the
3635 /// standby is told `SetRole(PRIMARY)` and begins taking writes. Without
3636 /// `--promote` the three local steps still run and the command prints the
3637 /// one `replication promote` line to run against the standby yourself —
3638 /// naming a new primary is a decision, so it is never silently assumed.
3639 ///
3640 /// **The traffic switch is separate and is infrastructure.** The front
3641 /// (DNS / the UpCloud DNAT) still has to point at the new primary; abdication
3642 /// makes the hand-off safe, it does not move the address.
3643 Abdicate {
3644 /// The NEW primary's control plane, `host:port` — the standby to promote
3645 /// as abdication's last act. Omit to run only the local freeze/flush/
3646 /// demote and be told the `promote` command to run yourself.
3647 #[arg(long, value_name = "ADDR")]
3648 promote: Option<String>,
3649 #[arg(long, short = 'i', value_name = "PATH")]
3650 identity: Option<PathBuf>,
3651 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
3652 at: String,
3653 },
3654 /// **PROMOTE this box to a writable primary — the takeover flip.**
3655 ///
3656 /// A standby serves reads and, once promoted, takes writes. Run it on the
3657 /// standby after the old master abdicated or died: the box records that it
3658 /// is a primary (persisted, so it survives a reboot) and thaws the write
3659 /// freeze. Idempotent — promoting a box that is already a primary is a
3660 /// no-op that prints the same "primary" status.
3661 ///
3662 /// The old master's `abdicate --promote <this box>` runs exactly this for
3663 /// you; this verb is the manual half, and the one a passive-heartbeat
3664 /// standby would call for itself when it decides the master is gone.
3665 Promote {
3666 #[arg(long, short = 'i', value_name = "PATH")]
3667 identity: Option<PathBuf>,
3668 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
3669 at: String,
3670 },
3671 /// **STANDBY: make this box read-only to clients.**
3672 ///
3673 /// A standby serves reads and accepts only its primary's replication push (a
3674 /// `Machine` identity); a direct `git push` from a person or a deploy key is
3675 /// refused with "push to the primary". This is how a second box is
3676 /// deliberately made a standby up front, so a client that is mis-pointed at
3677 /// it cannot leave a write the primary never sees. `promote` is the inverse.
3678 Standby {
3679 #[arg(long, short = 'i', value_name = "PATH")]
3680 identity: Option<PathBuf>,
3681 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
3682 at: String,
3683 },
3684 /// What is configured, and what each standby has actually done — including
3685 /// **"last successful replication: never"**.
3686 ///
3687 /// Cheap by default: it reports the roster, each standby's
3688 /// synced/failed/pending, the verdict word and each repository's copy state
3689 /// without opening a single repository store. The one expensive number —
3690 /// **how many refs live on this box alone** — is behind `--refs`, because
3691 /// counting it opens every repository's reference database.
3692 List {
3693 /// **Also count the refs a mirror does not carry**, per repository and
3694 /// server-wide. This opens every repository's reference database — one
3695 /// refold each on a cold box — so it is off unless you ask: reach for it
3696 /// when you want the durability shortfall, not on every status glance.
3697 #[arg(long)]
3698 refs: bool,
3699 #[arg(long, short = 'i', value_name = "PATH")]
3700 identity: Option<PathBuf>,
3701 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
3702 at: String,
3703 },
3704 /// **BACKFILL a standby with what it never got.**
3705 ///
3706 /// Adding a standby copies NOTHING. Replication rides the push bus, so a
3707 /// standby holds what was pushed after it existed — a repository's whole
3708 /// history arrives on its next push, and a repository nobody pushes to
3709 /// again never arrives at all. MEASURED on the reference appliance 2026-08-31: a
3710 /// standby configured against 22 existing repositories held **0** of them,
3711 /// and `alice/linux-mirror` had 3818 refs on the primary and none on the
3712 /// standby.
3713 ///
3714 /// This queues them. It does not copy anything itself: every byte still
3715 /// moves on the standby's own background worker, through the same bounded
3716 /// queue and the same backoff a push uses, so a backfill never blocks a
3717 /// push and never waits for one. Watch it with `replication list` — the
3718 /// `pending` count falls, and the row names the repository in flight.
3719 ///
3720 /// **It is safe to run again.** A repository already outstanding coalesces;
3721 /// one the standby already has costs one ref advertisement and an empty
3722 /// command list. Interrupted half way — a reboot, a dead standby, a network
3723 /// that went away — the answer is to run it again.
3724 ///
3725 /// **The hash.** A standby PUSH-CREATES sha1 — the first-push prospect since
3726 /// 2026-09-20, because git advertises the object format before the client
3727 /// says anything and sha1 is what an un-negotiated client assumes. A
3728 /// repository of any OTHER hash replicates to nothing until it is
3729 /// pre-created on the standby with
3730 /// `control repo create <ns>/<repo> --hash sha256`. Those repositories are
3731 /// listed by name rather than left to fail quietly.
3732 Resync {
3733 /// The standby, by name, exactly as `list` prints it. A name that is
3734 /// not configured is an error: "resynced" about a standby that was
3735 /// never there reads identically to "resynced" about the one you meant.
3736 name: String,
3737 /// Only this repository, `<namespace>/<repo>`.
3738 ///
3739 /// Without it, every repository the standby's scope covers. With it,
3740 /// one — which is how a repository too large to ship in this maintenance
3741 /// window stays a separate decision from all the others.
3742 #[arg(long, value_name = "NS/REPO")]
3743 repo: Option<String>,
3744 /// Everything the standby covers **except** these. Repeatable.
3745 ///
3746 /// The other half of `--repo`, and the one you actually reach for. On
3747 /// the reference appliance 24 of the 25 repositories are a few hundred
3748 /// megabytes together and one is ~8.7 GB and 13.9 M objects that
3749 /// projects **182–235 GiB** against a standby whose `/data` is
3750 /// **224.9 GiB**. Without this the only way to ship the safe
3751 /// twenty-four is one call each, and a box with no shell is exactly
3752 /// what this control plane is for.
3753 ///
3754 /// Ignored when `--repo` names one. An exclusion that matched nothing
3755 /// is reported rather than passed over: a typo and a real exclusion are
3756 /// the same command with very different outcomes.
3757 #[arg(long, value_name = "NS/REPO")]
3758 except: Vec<String>,
3759 #[arg(long, short = 'i', value_name = "PATH")]
3760 identity: Option<PathBuf>,
3761 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
3762 at: String,
3763 },
3764
3765 /// **Flush — the replication watermark.** Push everything pending to
3766 /// every standby, wait (bounded), and report whether the twin is caught
3767 /// up: the proof step 0 of a data-set growth needs before `/data` is
3768 /// unmounted. Changes no role and no roster.
3769 ///
3770 /// What it proves: every push the replicator saw is on the twin. What
3771 /// it does NOT prove: refs outside heads/tags, LFS objects, the control
3772 /// database, visibility, tombstones — and repositories nobody pushed
3773 /// since the twin existed, unless `--resync` enqueues them all.
3774 Flush {
3775 /// How long to wait for the queues to drain; 0 = the abdicate ceiling.
3776 #[arg(long, default_value_t = 0, value_name = "SECS")]
3777 timeout_secs: u32,
3778 /// Enqueue EVERY repository first (a full backfill).
3779 #[arg(long)]
3780 resync: bool,
3781 #[arg(long, short = 'i', value_name = "PATH")]
3782 identity: Option<PathBuf>,
3783 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
3784 at: String,
3785 },
3786}
3787
3788/// `gunnar control grow …` — the runtime data-set growth
3789/// (`DATA-SET-GROWTH-FLOW.md` §3, ruling 1: the appliance never goes down).
3790#[cfg(feature = "control")]
3791#[derive(Subcommand, Debug, Clone)]
3792pub enum ControlGrowCommand {
3793 /// **Begin a growth.** gunnar-admin. Drains the box (new pushes are
3794 /// refused with "gunnar.rs is growing its storage…"), unmounts `/data`,
3795 /// then WAITS for the outside actor to detach, enlarge and re-attach
3796 /// every member volume; when all are back and larger it grows in place,
3797 /// mounts and serves again. `grow status` follows it; `grow resume` says
3798 /// "look again".
3799 ///
3800 /// Mints an OPERATOR go-ahead (the signed fact D3 asks for): the
3801 /// gunnar-admin credential on this call is the approval. `--go-ahead`
3802 /// sends one minted elsewhere (monetize's, on a metered box) instead.
3803 Start {
3804 /// The per-member size to grow to, GiB; without it, whatever every
3805 /// member comes back larger at.
3806 #[arg(long, value_name = "GIB")]
3807 target_gib: Option<u64>,
3808 /// How long in-flight transfers may take to finish before the growth
3809 /// aborts with nothing changed (never kills a transfer).
3810 #[arg(long, default_value_t = 600, value_name = "SECS")]
3811 drain_timeout_secs: u32,
3812 /// How long `/data` may stay unmounted waiting for the members. Past it,
3813 /// with every member present, the set is mounted back at the size the
3814 /// members carry and the growth fails as `grow.unmounted_too_long`
3815 /// (alarmed on `Admin.Health`); with a member missing it keeps waiting,
3816 /// alarmed.
3817 #[arg(long, default_value_t = 3600, value_name = "SECS")]
3818 wait_members_timeout_secs: u32,
3819 /// A go-ahead file to send verbatim instead of minting an operator one.
3820 #[arg(long, value_name = "PATH")]
3821 go_ahead: Option<PathBuf>,
3822 #[arg(long, short = 'i', value_name = "PATH")]
3823 identity: Option<PathBuf>,
3824 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
3825 at: String,
3826 },
3827 /// The growth's phase, the members and the journal. **Needs no
3828 /// credential** — the actor outside the box that owns the volumes reads
3829 /// this while the identity table is off the stripe.
3830 Status {
3831 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
3832 at: String,
3833 },
3834 /// **Look again**: the members are back and larger. gunnar-admin.
3835 /// `--give-up` stops waiting, assembles what is there at whatever size it
3836 /// is, and serves.
3837 Resume {
3838 #[arg(long)]
3839 give_up: bool,
3840 #[arg(long, short = 'i', value_name = "PATH")]
3841 identity: Option<PathBuf>,
3842 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
3843 at: String,
3844 },
3845}
3846
3847/// `gunnar control webhook …` — the event stream's outbound delivery, over the control
3848/// plane.
3849///
3850/// Each verb returns the endpoint roster AND the server's webhook signing
3851/// public key — the OpenSSH line a receiver pins to verify a delivery with
3852/// `ssh-keygen -Y verify`. The private half never leaves the box.
3853#[cfg(feature = "control")]
3854#[derive(Debug, Clone, Subcommand)]
3855pub enum ControlWebhookCommand {
3856 /// **Add or replace an endpoint, by name.** Takes effect immediately and
3857 /// survives a reboot.
3858 ///
3859 /// Idempotent: the same `<name>` twice replaces rather than duplicates.
3860 Add {
3861 /// Your name for this endpoint. Every log line and status row names it
3862 /// by this rather than by its URL.
3863 name: String,
3864 /// Where deliveries are POSTed: `http://…` or `https://…`.
3865 url: String,
3866 /// Only deliver these event kinds (repeatable, or comma-separated),
3867 /// e.g. `ref.updated`. Omit for every kind. A name that matches no kind
3868 /// is refused — a filter that names nothing delivers nothing.
3869 #[arg(long, value_name = "KIND", value_delimiter = ',')]
3870 kinds: Vec<String>,
3871 /// Only deliver events about this namespace.
3872 #[arg(long, value_name = "NAME", conflicts_with = "repo")]
3873 namespace: Option<String>,
3874 /// Only deliver events about this repository, `<namespace>/<repo>`.
3875 #[arg(long, value_name = "NS/REPO")]
3876 repo: Option<String>,
3877 /// An HMAC shared secret, for a receiver written against GitHub's
3878 /// `X-Hub-Signature-256`. The `sshsig` is always sent and is the one to
3879 /// prefer; this is only for compatibility. Stored on the server, never
3880 /// shown again.
3881 #[arg(long, value_name = "SECRET")]
3882 secret: Option<String>,
3883 #[arg(long, short = 'i', value_name = "PATH")]
3884 identity: Option<PathBuf>,
3885 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
3886 at: String,
3887 },
3888 /// What is configured, plus the signing public key a receiver verifies
3889 /// deliveries with, and per-endpoint delivered/failed/dropped counters.
3890 List {
3891 #[arg(long, short = 'i', value_name = "PATH")]
3892 identity: Option<PathBuf>,
3893 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
3894 at: String,
3895 },
3896 /// Remove an endpoint by name and stop delivering to it now.
3897 ///
3898 /// A name that is not configured is an error, not a quiet success:
3899 /// "removed" about an endpoint that was never there reads identically to
3900 /// removing the one you meant.
3901 Remove {
3902 name: String,
3903 #[arg(long, short = 'i', value_name = "PATH")]
3904 identity: Option<PathBuf>,
3905 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
3906 at: String,
3907 },
3908}
3909
3910/// `gunnar control policy …` — the pre-receive gate, over the control plane.
3911///
3912/// **The asymmetry these close.** Since a merge started requiring a verified
3913/// signed approval naming the exact commit, the review gate has been real —
3914/// and a direct push to `main` walked straight past it, because the surface
3915/// that would have stopped it was reachable from nothing. `Policy.*` has been
3916/// served since `gunnar_core::PolicyStore` gave the document a home at
3917/// `refs/gunnar/policy`; these verbs are the half that lets anyone say so.
3918///
3919/// A rule is stored **per repository, keyed by ref pattern**, and the pattern
3920/// is a git-style glob over full ref names — `refs/heads/main`,
3921/// `refs/heads/release/*`. It is not a branch name: `main` matches no ref and
3922/// protects nothing, which is why `protect` refuses it by name.
3923#[cfg(feature = "control")]
3924#[derive(Debug, Clone, Subcommand)]
3925pub enum ControlPolicyCommand {
3926 /// **Protect refs matching a pattern.** Upserted on the pattern, so the
3927 /// same pattern twice replaces rather than accumulates.
3928 ///
3929 /// The defaults are the strict ones: with no flags at all a matching ref
3930 /// cannot be force-pushed and cannot be deleted. Each `--allow-*` gives one
3931 /// of those back, which makes the permissive choice the one that had to be
3932 /// typed.
3933 Protect {
3934 /// `<account>/<name>`.
3935 path: String,
3936 /// A git-style glob over FULL ref names — `refs/heads/main`,
3937 /// `refs/heads/release/*`. Not a branch name.
3938 pattern: String,
3939 /// Permit a non-fast-forward update. Off by default.
3940 #[arg(long)]
3941 allow_force_push: bool,
3942 /// Permit deleting a matching ref. Off by default.
3943 #[arg(long)]
3944 allow_delete: bool,
3945 /// Every new commit must carry a valid ssh-sig from a key registered
3946 /// to its pusher.
3947 #[arg(long)]
3948 require_signature: bool,
3949 /// Only these principals may push here. Repeatable. Empty — the
3950 /// default — means "any principal with WRITE".
3951 #[arg(long, value_name = "ID")]
3952 principal: Vec<String>,
3953 /// Reject an object larger than this BEFORE it lands. 0 is no limit.
3954 #[arg(long, value_name = "BYTES", default_value_t = 0)]
3955 max_object_bytes: u64,
3956 /// Reject a push carrying more objects than this. 0 is no limit.
3957 #[arg(long, value_name = "N", default_value_t = 0)]
3958 max_objects_per_push: u64,
3959 /// A merge into a matching ref does not count the pull request
3960 /// AUTHOR's own approval. Off by default: a solo operator must stay
3961 /// able to merge.
3962 #[arg(long)]
3963 forbid_self_approval: bool,
3964 /// A merge into a matching ref needs this many DISTINCT verified
3965 /// approvers of the exact tip. Approvals by the same principal count
3966 /// once. 0 keeps the baseline: one verified approval.
3967 #[arg(long, value_name = "N", default_value_t = 0)]
3968 required_approvals: u32,
3969 #[arg(long, short = 'i', value_name = "PATH")]
3970 identity: Option<PathBuf>,
3971 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
3972 at: String,
3973 },
3974 /// **Remove the rule on a pattern**, exactly as `list` prints it.
3975 ///
3976 /// A pattern that has no rule is an error and not a quiet success:
3977 /// "unprotected" about a rule that was never there reads identically to
3978 /// "unprotected" about the one you meant, and the difference is whether
3979 /// `main` is still guarded.
3980 Unprotect {
3981 /// `<account>/<name>`.
3982 path: String,
3983 /// The pattern, byte for byte as `list` prints it.
3984 pattern: String,
3985 #[arg(long, short = 'i', value_name = "PATH")]
3986 identity: Option<PathBuf>,
3987 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
3988 at: String,
3989 },
3990 /// What is protected here — and **"nothing"** said as the word.
3991 ///
3992 /// An unprotected repository and a repository whose rules failed to load
3993 /// are the same empty list and very different facts, so the empty case
3994 /// says which one it is.
3995 List {
3996 /// `<account>/<name>`.
3997 path: String,
3998 /// Only the rule on this pattern. Without it, every rule.
3999 #[arg(long, value_name = "GLOB")]
4000 pattern: Option<String>,
4001 #[arg(long, short = 'i', value_name = "PATH")]
4002 identity: Option<PathBuf>,
4003 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
4004 at: String,
4005 },
4006 /// The ref-name allowlist/denylist. **Deny wins.**
4007 RefNames {
4008 /// `<account>/<name>`.
4009 path: String,
4010 #[arg(long, short = 'i', value_name = "PATH")]
4011 identity: Option<PathBuf>,
4012 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
4013 at: String,
4014 },
4015 /// **Replace** the ref-name allowlist/denylist. Deny wins.
4016 ///
4017 /// Replace and not merge: the document is written whole, so passing
4018 /// neither flag CLEARS the policy rather than leaving the old one in
4019 /// place. That is the only version of this verb whose result can be
4020 /// predicted from the command line alone, and it says so before it writes.
4021 SetRefNames {
4022 /// `<account>/<name>`.
4023 path: String,
4024 /// A ref name may match one of these. Repeatable. Empty means "any
4025 /// name that is not denied".
4026 #[arg(long, value_name = "GLOB")]
4027 allow: Vec<String>,
4028 /// A ref name matching one of these is refused, even if it is also
4029 /// allowed. Repeatable.
4030 #[arg(long, value_name = "GLOB")]
4031 deny: Vec<String>,
4032 #[arg(long, short = 'i', value_name = "PATH")]
4033 identity: Option<PathBuf>,
4034 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
4035 at: String,
4036 },
4037 /// The gates that run on this repository's push path, and **which of them
4038 /// this repository has configuration for**.
4039 ///
4040 /// `enabled` is not "compiled in": a gate with nothing configured cannot
4041 /// reject anything, and a list that claimed otherwise would be a UI
4042 /// rendering a guard that does not exist.
4043 Gates {
4044 /// `<account>/<name>`.
4045 path: String,
4046 #[arg(long, short = 'i', value_name = "PATH")]
4047 identity: Option<PathBuf>,
4048 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
4049 at: String,
4050 },
4051}
4052
4053/// `gunnar control repo …`
4054#[cfg(feature = "control")]
4055#[derive(Debug, Subcommand)]
4056pub enum ControlRepoCommand {
4057 /// Create a repository at `<account>/<name>`.
4058 ///
4059 /// New repositories are **sha256**, so a stock `git init` working
4060 /// tree cannot push to one — `git init --object-format=sha256`, or
4061 /// `--hash sha1` here.
4062 Create {
4063 /// `<account>/<name>`.
4064 path: String,
4065 /// The object hash. sha256 unless you have a sha1 client you cannot
4066 /// change.
4067 #[arg(long, value_enum, default_value_t = HashArg::Sha256)]
4068 hash: HashArg,
4069 /// Readable by anyone, with no key at all.
4070 #[arg(long)]
4071 public: bool,
4072 /// **Take over a store already on disk at this name** (a catalog
4073 /// restored over a surviving data disk). Refused unless the store's
4074 /// hash marker matches `--hash`. Without it a create refuses the name
4075 /// BY NAME: "a store already exists at …; delete reclaims it".
4076 #[arg(long)]
4077 adopt: bool,
4078 #[arg(long, short = 'i', value_name = "PATH")]
4079 identity: Option<PathBuf>,
4080 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
4081 at: String,
4082 },
4083 /// List repositories.
4084 List {
4085 /// Only this account's.
4086 #[arg(long, value_name = "ACCOUNT")]
4087 namespace: Option<String>,
4088 /// Only repositories whose `<account>/<name>` contains this,
4089 /// case-insensitively — gunnar filters, over everything you may see.
4090 #[arg(long, value_name = "TEXT")]
4091 query: Option<String>,
4092 /// Only repositories that are mirrors of an upstream.
4093 #[arg(long)]
4094 mirrors: bool,
4095 #[arg(long, short = 'i', value_name = "PATH")]
4096 identity: Option<PathBuf>,
4097 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
4098 at: String,
4099 },
4100 /// Show one repository: its hash, default branch and visibility.
4101 Get {
4102 /// `<account>/<name>`.
4103 path: String,
4104 #[arg(long, short = 'i', value_name = "PATH")]
4105 identity: Option<PathBuf>,
4106 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
4107 at: String,
4108 },
4109 /// **Delete a repository. Without `--purge` the bytes are KEPT for a
4110 /// retention window and `repo undelete` puts it back.**
4111 ///
4112 /// The catalog row goes either way, so the name stops working immediately.
4113 /// What differs is the bytes: a tombstone holds them for
4114 /// `--retention-days` (30 by default) and a sweep reclaims them when it
4115 /// closes; `--purge` returns them now, in about two seconds, with no undo.
4116 ///
4117 /// A tombstone is still storage on the account until it expires —
4118 /// `repo list-deleted` shows what is held and what it costs.
4119 Delete {
4120 /// `<account>/<name>`.
4121 path: String,
4122 /// Required. Deleting a repository takes its name out of the catalog
4123 /// immediately, and a typo in a path is not distinguishable from an
4124 /// intention.
4125 #[arg(long)]
4126 yes: bool,
4127 /// **Reclaim the bytes NOW.** No window, no undelete.
4128 #[arg(long)]
4129 purge: bool,
4130 /// How long the bytes are kept. `0` means at the next sweep.
4131 #[arg(long, value_name = "DAYS")]
4132 retention_days: Option<u32>,
4133 #[arg(long, short = 'i', value_name = "PATH")]
4134 identity: Option<PathBuf>,
4135 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
4136 at: String,
4137 },
4138 /// **Put a soft-deleted repository back**, inside its retention window.
4139 Undelete {
4140 /// `<account>/<name>`.
4141 path: String,
4142 #[arg(long, short = 'i', value_name = "PATH")]
4143 identity: Option<PathBuf>,
4144 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
4145 at: String,
4146 },
4147 /// **End a retention window early.** There is no undo after it.
4148 Purge {
4149 /// `<account>/<name>`.
4150 path: String,
4151 /// Required, for the same reason `delete --yes` is.
4152 #[arg(long)]
4153 yes: bool,
4154 #[arg(long, short = 'i', value_name = "PATH")]
4155 identity: Option<PathBuf>,
4156 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
4157 at: String,
4158 },
4159 /// **Maintain a live repository: seal, compact, drop the read cache.**
4160 ///
4161 /// The same maintenance `gunnar gc <path>` runs on the box, reachable from
4162 /// wherever the control plane is. It reclaims only what the server can
4163 /// re-derive — the lazily-exploded read cache goes, the commit-and-tree
4164 /// pool stays because that is the graph — so there is nothing to confirm
4165 /// and no `--yes`: the worst outcome is a slower next read.
4166 ///
4167 /// Needs `Write` on the repository, not `Admin`. A principal who may push
4168 /// to a repository may reclaim its cache.
4169 ///
4170 /// The server runs **one gc at a time for the whole box** and queues the
4171 /// rest, so this enqueues and then waits for its turn, printing what it
4172 /// freed. Asking twice for a repository already queued joins the job that
4173 /// is there rather than running a second one.
4174 Gc {
4175 /// `<account>/<name>`.
4176 path: String,
4177 /// Enqueue and return, printing the job instead of waiting for it.
4178 ///
4179 /// For a script that wants the gc started and is not going to sit
4180 /// through it. `repo gc-status <path>` reads the outcome afterwards.
4181 #[arg(long)]
4182 no_wait: bool,
4183 #[arg(long, short = 'i', value_name = "PATH")]
4184 identity: Option<PathBuf>,
4185 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
4186 at: String,
4187 },
4188 /// **How a repository's gc is going, or how the last one went.**
4189 ///
4190 /// Needs only `Read`: seeing that maintenance ran is not asking for it.
4191 /// Says so plainly when the server is holding no job at all, which is a
4192 /// different answer from a gc that reclaimed nothing.
4193 GcStatus {
4194 /// `<account>/<name>`.
4195 path: String,
4196 #[arg(long, short = 'i', value_name = "PATH")]
4197 identity: Option<PathBuf>,
4198 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
4199 at: String,
4200 },
4201 /// What is deleted and not yet purged, with the bytes it is still costing.
4202 ListDeleted {
4203 /// Only this account's.
4204 #[arg(long, value_name = "ACCOUNT")]
4205 namespace: Option<String>,
4206 #[arg(long, short = 'i', value_name = "PATH")]
4207 identity: Option<PathBuf>,
4208 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
4209 at: String,
4210 },
4211 /// **Store directories with no catalog row and no tombstone — bytes
4212 /// nothing else can see, and names nothing else can free.**
4213 ///
4214 /// Every other reclaim path keys off a record: `repo delete` off the
4215 /// catalog row, `undelete`/`purge`/`list-deleted`/the sweep off a
4216 /// tombstone. A directory with neither is invisible to all of them, and
4217 /// `repo create` still refuses its name because the bytes are there.
4218 /// MEASURED on the appliance 2026-08-30: ~25-39 GB at `alice/linux`,
4219 /// stranded.
4220 ///
4221 /// The `HASH` column is the store's `OBJECT_FORMAT` marker, and `-` means
4222 /// the store predates it — that is the value to pass to `repo create
4223 /// --adopt --hash` if you want to KEEP the objects instead of reclaiming
4224 /// them.
4225 ListOrphans {
4226 /// Only this account's.
4227 #[arg(long, value_name = "ACCOUNT")]
4228 namespace: Option<String>,
4229 #[arg(long, short = 'i', value_name = "PATH")]
4230 identity: Option<PathBuf>,
4231 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
4232 at: String,
4233 },
4234 /// **Reclaim an orphan store's bytes.** No window, no undo.
4235 ///
4236 /// To KEEP the objects instead, `repo create <path> --adopt --hash <the
4237 /// HASH column of list-orphans>` takes the store over as a repository.
4238 Reclaim {
4239 /// `<account>/<name>`.
4240 path: String,
4241 /// Required, for the same reason `delete --yes` is.
4242 #[arg(long)]
4243 yes: bool,
4244 #[arg(long, short = 'i', value_name = "PATH")]
4245 identity: Option<PathBuf>,
4246 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
4247 at: String,
4248 },
4249 /// Set the branch a fresh clone checks out.
4250 SetDefaultBranch {
4251 /// `<account>/<name>`.
4252 path: String,
4253 /// A branch name (`main`) or a full ref (`refs/heads/main`). A short
4254 /// name is expanded to `refs/heads/<name>`; anything already under
4255 /// `refs/` is sent as given, so the server refuses a `refs/tags/…`
4256 /// rather than this quietly rewriting it.
4257 branch: String,
4258 #[arg(long, short = 'i', value_name = "PATH")]
4259 identity: Option<PathBuf>,
4260 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
4261 at: String,
4262 },
4263 /// **Pull one repository's stored bytes OFF the box, over the control
4264 /// plane.**
4265 ///
4266 /// `gunnar repo archive-out` writes the same bytes to a path *on* the
4267 /// appliance, which is no use as a backup: the box has no shell to copy it
4268 /// out with. This streams them to a file on the machine running the
4269 /// command, so a cron somewhere else can hold the copy.
4270 ///
4271 /// The file it writes is exactly the file the local verb writes —
4272 /// `gunnar repo archive-in <FILE> --as <ns/name>` restores it, and the
4273 /// digest the server computed while sending is checked against the bytes
4274 /// that arrived.
4275 ///
4276 /// **It needs a Read grant on the repository**, the same grant cloning it
4277 /// privately needs, because that is what it hands over.
4278 ///
4279 /// `--from-generation` is a WATERMARK, not a diff: pass the previous run's
4280 /// `to_generation` and only the new bytes travel. Safe while the server is
4281 /// serving; there is nothing to freeze.
4282 ArchiveOut {
4283 /// `<account>/<name>`.
4284 path: String,
4285 /// Where to write the archive, **on this machine**.
4286 #[arg(long, value_name = "FILE")]
4287 to: PathBuf,
4288 /// Start above this `objects.pack` offset. 0 = a full archive.
4289 #[arg(long, default_value_t = 0, value_name = "N")]
4290 from_generation: u64,
4291 #[arg(long, short = 'i', value_name = "PATH")]
4292 identity: Option<PathBuf>,
4293 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
4294 at: String,
4295 },
4296 /// Make a repository readable with no key at all, or stop it being.
4297 SetVisibility {
4298 /// `<account>/<name>`.
4299 path: String,
4300 /// `public` is anonymous READ. It never makes anything writable.
4301 #[arg(value_enum)]
4302 visibility: VisibilityArg,
4303 #[arg(long, short = 'i', value_name = "PATH")]
4304 identity: Option<PathBuf>,
4305 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
4306 at: String,
4307 },
4308 /// Make a repository ENCRYPTED, or stop it being: on, the server refuses
4309 /// any push that would put plaintext on it. The account admin's decision.
4310 ///
4311 /// The server never holds a key either way; members encrypt with
4312 /// `gunnar crypt`. See `.nornir/encrypted-repos.md`.
4313 SetEncrypted {
4314 /// `<account>/<name>`.
4315 path: String,
4316 /// `on` or `off`.
4317 #[arg(value_parser = ["on", "off"])]
4318 state: String,
4319 #[arg(long, short = 'i', value_name = "PATH")]
4320 identity: Option<PathBuf>,
4321 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
4322 at: String,
4323 },
4324}
4325
4326/// `--visibility`, one arm per [`gunnar_core::Visibility`].
4327#[cfg(feature = "control")]
4328#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
4329pub enum VisibilityArg {
4330 /// Reachable only with a key that holds a grant. The default for a new
4331 /// repository.
4332 Private,
4333 /// Anonymously READABLE. Writing still needs a key and a grant, and the
4334 /// listener's `--no-anonymous` closes the door regardless.
4335 Public,
4336}
4337
4338#[cfg(feature = "control")]
4339impl VisibilityArg {
4340 /// The proto's enum. One translation, in one place.
4341 pub fn to_proto(self) -> gunnar_proto::Visibility {
4342 match self {
4343 VisibilityArg::Private => gunnar_proto::Visibility::Private,
4344 VisibilityArg::Public => gunnar_proto::Visibility::Public,
4345 }
4346 }
4347}
4348
4349/// `gunnar control namespace …`
4350/// `replication add --scope`, see the verb.
4351#[cfg(feature = "control")]
4352#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
4353pub enum ReplicationScopeArg {
4354 /// Every repository the target's scope covers — a standby.
4355 All,
4356 /// Only public repositories — a browse replica's feed.
4357 PublicOnly,
4358}
4359
4360#[cfg(feature = "control")]
4361#[derive(Debug, Subcommand)]
4362pub enum ControlNamespaceCommand {
4363 /// Create a namespace. Nested names (`team/sub`) are legal; grants do NOT
4364 /// inherit between them.
4365 ///
4366 /// There is deliberately **no `--description`**. `CreateNamespaceRequest`
4367 /// carries the field, `CoreControlPlane::create_namespace` takes it as
4368 /// `_description` and drops it, `gunnar_core::Namespace` has nowhere to put
4369 /// it and `list_namespaces` returns `String::new()` for every namespace.
4370 /// A flag that reached none of that would be the same defect this file is
4371 /// closing, one layer up.
4372 Create {
4373 /// The namespace name.
4374 name: String,
4375 #[arg(long, short = 'i', value_name = "PATH")]
4376 identity: Option<PathBuf>,
4377 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
4378 at: String,
4379 },
4380 /// List namespaces.
4381 List {
4382 #[arg(long, short = 'i', value_name = "PATH")]
4383 identity: Option<PathBuf>,
4384 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
4385 at: String,
4386 },
4387 /// Delete a namespace. A namespace that still holds repositories is
4388 /// refused unless `--delete-repos` says otherwise; deleting them is never
4389 /// implicit.
4390 Delete {
4391 /// The namespace name.
4392 name: String,
4393 /// Delete the repositories in it too. **There is no undo.**
4394 #[arg(long)]
4395 delete_repos: bool,
4396 #[arg(long, short = 'i', value_name = "PATH")]
4397 identity: Option<PathBuf>,
4398 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
4399 at: String,
4400 },
4401 /// **The account admin's settings: is the namespace public, are its
4402 /// events public.** Both are private for every account until its admin
4403 /// says otherwise, and only the account admin may (`Repos.
4404 /// SetNamespaceSettings`). With no flag, prints the current settings.
4405 Settings {
4406 /// The namespace (the account).
4407 name: String,
4408 /// Show the namespace to everyone (`on`), or only through its public
4409 /// repositories (`off`).
4410 #[arg(long, value_parser = ["on", "off"])]
4411 public: Option<String>,
4412 /// Show the account's emitted events to everyone (`on`) or only inside
4413 /// the account (`off`).
4414 #[arg(long, value_parser = ["on", "off"])]
4415 events_public: Option<String>,
4416 #[arg(long, short = 'i', value_name = "PATH")]
4417 identity: Option<PathBuf>,
4418 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
4419 at: String,
4420 },
4421}
4422
4423/// `gunnar control user …`
4424#[cfg(feature = "control")]
4425#[derive(Debug, Subcommand)]
4426pub enum ControlUserCommand {
4427 /// Create a user with a key. **Needs gunnar-admin.**
4428 ///
4429 /// The new user is always admin of their own account — the namespace named
4430 /// after them — and that is intrinsic, not a grant. `--gunnar-admin` is the
4431 /// separate, grantable thing, and any gunnar-admin may hand it on.
4432 Add {
4433 /// Their display name. It is also their id — and therefore their
4434 /// account — unless `--id` says otherwise.
4435 name: String,
4436 /// Their OpenSSH public key line, or `@<path>`.
4437 #[arg(long, value_name = "KEY")]
4438 key: String,
4439 /// **Their id, if it is not the name.** The id is the account namespace
4440 /// and the key every grant, gate and membership is filed under; it is
4441 /// chosen here, once, and never moves. The name is a label — see
4442 /// `user rename`. Same character rules as a namespace.
4443 #[arg(long, value_name = "ID")]
4444 id: Option<String>,
4445 /// Also make them gunnar-admin: a server-wide Admin grant, exactly the
4446 /// one the first user got.
4447 #[arg(long)]
4448 gunnar_admin: bool,
4449 /// **What kind of identity this is**: a `user` (a human, the
4450 /// default), a `machine` (CI, a bot, or **a standby's replication
4451 /// source** — a `machine` key is the one a frozen standby lets push),
4452 /// or a `deploy` key. A standby refuses a direct `git push` from a
4453 /// `user` or `deploy` key while accepting the primary's replication
4454 /// push, and that is decided by this kind — so the deploy key a primary
4455 /// replicates with must be enrolled here as `machine`.
4456 #[arg(long, value_enum, default_value_t = PrincipalKindArg::User)]
4457 kind: PrincipalKindArg,
4458 #[arg(long, short = 'i', value_name = "PATH")]
4459 identity: Option<PathBuf>,
4460 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
4461 at: String,
4462 },
4463 /// **Enrol another key on a user who already exists.** One human, several
4464 /// machines, ONE principal.
4465 ///
4466 /// Without this verb the only way to reach a second machine was to create a
4467 /// second user — MEASURED 2026-08-06, when a desktop's key and a laptop's key
4468 /// became `alice` and `alice-desktop`. That is not a cosmetic split: every grant,
4469 /// every ownership and every revoke then fragments per machine, and
4470 /// `gunnar control user remove alice` stops meaning what an operator
4471 /// reads it as.
4472 ///
4473 /// The identity table has always held **many keys per principal** — the key
4474 /// table is `fingerprint -> {principal_id, …}` and `keys_of()` returns a
4475 /// list — and `Access.AddKey` has always existed on the wire. What was
4476 /// missing was only this: a way to call it for a principal that is already
4477 /// there, since `user add` calls `AddPrincipal` first and so can only ever
4478 /// key a brand-new one.
4479 ///
4480 /// **Needs gunnar-admin**, which is `Access.AddKey`'s existing rule; adding
4481 /// a key to yourself without one is a policy question, deliberately not
4482 /// decided here.
4483 AddKey {
4484 /// The user who is getting another key, by id. They must already exist.
4485 name: String,
4486 /// The OpenSSH public key line to add, or `@<path>` to read one from a
4487 /// file. This is the key of the OTHER machine, so it is normally
4488 /// `@~/.ssh/id_ed25519.pub` copied from there — not derivable from the
4489 /// key `-i` signs this call with.
4490 #[arg(long, value_name = "KEY")]
4491 key: String,
4492 /// **The user's own attestation that this key may act as them**, as
4493 /// `user attest-key` printed it, or `@<path>` to read it from a file.
4494 ///
4495 /// Required when the user already holds a key and you are not them:
4496 /// your credential proves who asked, their attestation proves they
4497 /// agreed, and without it an admin could quietly mint access as
4498 /// another person. Not needed for a user's first key (nobody to ask
4499 /// yet), nor when `-i` is one of the user's own keys (the call is
4500 /// already their signed word).
4501 #[arg(long, value_name = "ATTESTATION")]
4502 attestation: Option<String>,
4503 #[arg(long, short = 'i', value_name = "PATH")]
4504 identity: Option<PathBuf>,
4505 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
4506 at: String,
4507 },
4508 /// **Attest, as a user, that a key may act as you.** Run this on a machine
4509 /// that holds one of your keys; hand the output to the admin who runs
4510 /// `user add-key … --attestation`.
4511 ///
4512 /// It signs nothing but the fingerprint of the new key, your id and the
4513 /// time, under the sshsig namespace `gunnar-key-attestation`, so it cannot
4514 /// be spent on any other key, any other principal, a group change or a
4515 /// review. Nothing is sent anywhere: this verb makes no connection.
4516 AttestKey {
4517 /// Your id (what `user list` shows in the first column).
4518 name: String,
4519 /// The public key being added, or `@<path>`.
4520 #[arg(long, value_name = "KEY")]
4521 key: String,
4522 /// The private key you attest WITH — one already enrolled on you.
4523 #[arg(long, short = 'i', value_name = "PATH")]
4524 identity: Option<PathBuf>,
4525 },
4526 /// **Retire ONE key and leave the principal standing** — the online
4527 /// counterpart of `gunnar key revoke`, which needs the server DOWN.
4528 ///
4529 /// The verb that a re-imaged machine needs. MEASURED on the live appliance
4530 /// 2026-09-19, rotating `gunnar-twin`'s one machine key after its box was
4531 /// re-imaged and the private half died with it:
4532 ///
4533 /// ```text
4534 /// gunnar control key revoke <fp> unrecognized subcommand 'key'
4535 /// user add-key gunnar-twin --key … FAILED_PRECONDITION: gunnar-twin already
4536 /// holds 1 key(s), so adding another needs
4537 /// gunnar-twin's own attestation
4538 /// user remove gunnar-twin removed: 1 keys, 1 GRANTS
4539 /// user add gunnar-twin … ALREADY_EXISTS: namespace already exists
4540 /// namespace delete gunnar-twin 0 repositories went with it
4541 /// user add gunnar-twin … created
4542 /// group member add twin gunnar-twin added
4543 /// ```
4544 ///
4545 /// Six steps and four refusals, every one of them individually correct, to
4546 /// change one fingerprint. The first is the root of it: `gunnar key revoke`
4547 /// writes the control repository directly and therefore wants the server
4548 /// stopped — and an appliance is PID 1 with no shell, so on the machine
4549 /// that most needs the verb it can never be run. `Access.RevokeKey` has
4550 /// been on the wire all along and gunnar-ui has called it since the console
4551 /// shipped; no `gunnar control` verb reached it. An RPC nothing can call is
4552 /// a feature nobody has.
4553 ///
4554 /// With this, the rotation is two steps and no refusals:
4555 /// `user revoke-key <old-fp>` then `user add-key <name> --key <new>` — the
4556 /// attestation precondition is gone because the principal then holds no
4557 /// key, and the GRANTS never went anywhere, so nothing has to be put back.
4558 ///
4559 /// **Needs gunnar-admin**, which is `Access.RevokeKey`'s own rule at server
4560 /// scope. Unlike the console's `DELETE /api/account/keys`, this does not
4561 /// check the key is the caller's: see the handler for why.
4562 RevokeKey {
4563 /// The fingerprint to retire, exactly as `user keys` prints it —
4564 /// `SHA256:…`. The key is the thing named, not the person: which
4565 /// principal held it is read back and reported.
4566 fingerprint: String,
4567 /// **Revoke it even if it is the last key that could run this verb.**
4568 ///
4569 /// Without this the last admin key is refused, and that refusal is the
4570 /// one guard here. See the handler: an appliance that loses its last
4571 /// gunnar-admin key cannot get one back, because the offline verb that
4572 /// would needs a shell the appliance does not have.
4573 #[arg(long)]
4574 force: bool,
4575 #[arg(long, short = 'i', value_name = "PATH")]
4576 identity: Option<PathBuf>,
4577 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
4578 at: String,
4579 },
4580 /// The keys a user holds, or every key on the server.
4581 ///
4582 /// The verb that makes the question *"is this one human or three?"*
4583 /// answerable at all.
4584 Keys {
4585 /// Only this user's keys. Omit for every key on the server.
4586 name: Option<String>,
4587 #[arg(long, short = 'i', value_name = "PATH")]
4588 identity: Option<PathBuf>,
4589 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
4590 at: String,
4591 },
4592 /// **A user's passkeys** (`Access.ListCredentials`, Stage L): the web
4593 /// credentials gunnar verifies a console login with. Your own need no
4594 /// grant; somebody else's, or every one (omit the name), need gunnar-admin.
4595 Credentials {
4596 /// Only this user's. Omit for every passkey on the server.
4597 name: Option<String>,
4598 #[arg(long, short = 'i', value_name = "PATH")]
4599 identity: Option<PathBuf>,
4600 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
4601 at: String,
4602 },
4603 /// **Revoke a passkey** (`Access.RevokeCredential`), named by its id
4604 /// exactly as `user credentials` prints it (base64url). Your own need no
4605 /// grant; anybody's need gunnar-admin.
4606 RevokeCredential {
4607 /// The credential id, base64url.
4608 id: String,
4609 #[arg(long, short = 'i', value_name = "PATH")]
4610 identity: Option<PathBuf>,
4611 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
4612 at: String,
4613 },
4614 /// List users.
4615 List {
4616 #[arg(long, short = 'i', value_name = "PATH")]
4617 identity: Option<PathBuf>,
4618 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
4619 at: String,
4620 },
4621 /// **Change a user's display name, and nothing else.** Needs gunnar-admin.
4622 ///
4623 /// The id — the account namespace, and what every key, grant, membership
4624 /// and gate is filed under — stays exactly where it is; only the label
4625 /// moves. A console that pinned the id sees the same person afterwards.
4626 /// Refused if another principal already carries the name, as its name or
4627 /// as its id.
4628 Rename {
4629 /// Their id (what `user list` shows in the first column).
4630 id: String,
4631 /// The new display name.
4632 name: String,
4633 #[arg(long, short = 'i', value_name = "PATH")]
4634 identity: Option<PathBuf>,
4635 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
4636 at: String,
4637 },
4638 /// Remove a user, with their keys and grants.
4639 ///
4640 /// This does NOT reopen the bootstrap window, not even if it removes the
4641 /// last user on the server.
4642 Remove {
4643 /// Their id.
4644 name: String,
4645 #[arg(long, short = 'i', value_name = "PATH")]
4646 identity: Option<PathBuf>,
4647 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
4648 at: String,
4649 },
4650}
4651
4652/// `gunnar control group …` — groups in the ACL (punkt 9).
4653///
4654/// A group is a principal that grants attach to and members inherit. Everything
4655/// here needs gunnar-admin, the same rule as a grant, because adding a member
4656/// to a group that holds a grant is handing that member the grant.
4657#[cfg(feature = "control")]
4658#[derive(Debug, Subcommand)]
4659pub enum ControlGroupCommand {
4660 /// **Create a group.** It holds no key; grant it access with
4661 /// `gunnar control grant <group> …` and add members below.
4662 Add {
4663 /// The group's name, its id in every grant and membership.
4664 name: String,
4665 #[arg(long, short = 'i', value_name = "PATH")]
4666 identity: Option<PathBuf>,
4667 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
4668 at: String,
4669 },
4670 /// **Membership**: add or remove a principal from a group.
4671 #[command(subcommand)]
4672 Member(ControlGroupMemberCommand),
4673 /// **List the groups** on the server.
4674 List {
4675 #[arg(long, short = 'i', value_name = "PATH")]
4676 identity: Option<PathBuf>,
4677 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
4678 at: String,
4679 },
4680 /// **List the members of one group** (its direct members).
4681 Members {
4682 /// The group to list.
4683 group: String,
4684 #[arg(long, short = 'i', value_name = "PATH")]
4685 identity: Option<PathBuf>,
4686 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
4687 at: String,
4688 },
4689 /// **The signed membership history** — every add and remove, in order, each
4690 /// a signed event a third party can verify. This is the evidence behind the
4691 /// live member list: who was in a group, when, and by whose hand.
4692 History {
4693 /// The group to show the history of. Omit for every group's history.
4694 group: Option<String>,
4695 #[arg(long, short = 'i', value_name = "PATH")]
4696 identity: Option<PathBuf>,
4697 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
4698 at: String,
4699 },
4700}
4701
4702/// `gunnar control group member …`
4703#[cfg(feature = "control")]
4704#[derive(Debug, Subcommand)]
4705pub enum ControlGroupMemberCommand {
4706 /// **Add a principal to a group.** They inherit the group's grants at once.
4707 Add {
4708 /// The group.
4709 group: String,
4710 /// The principal to add — a user, a machine, or another group (nesting).
4711 principal: String,
4712 #[arg(long, short = 'i', value_name = "PATH")]
4713 identity: Option<PathBuf>,
4714 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
4715 at: String,
4716 },
4717 /// **Remove a principal from a group.** They lose the inherited access at
4718 /// once.
4719 Remove {
4720 /// The group.
4721 group: String,
4722 /// The principal to remove.
4723 principal: String,
4724 #[arg(long, short = 'i', value_name = "PATH")]
4725 identity: Option<PathBuf>,
4726 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
4727 at: String,
4728 },
4729}
4730
4731// ───────────────────────────────────────────────────────────────────────────
4732// client
4733// ───────────────────────────────────────────────────────────────────────────
4734
4735/// Where a client verb operates.
4736#[cfg(feature = "client")]
4737#[derive(Debug, clap::Args)]
4738pub struct RepoArgs {
4739 /// The repository to work in. Defaults to the current directory.
4740 #[arg(long, short = 'C', value_name = "DIR", default_value = ".")]
4741 pub repo: PathBuf,
4742}
4743
4744/// `gunnar create`.
4745#[cfg(feature = "client")]
4746#[derive(Debug, clap::Args)]
4747pub struct CreateArgs {
4748 /// `<namespace>/<repository>`.
4749 pub repo: String,
4750 /// sha1 instead of sha256.
4751 #[arg(long)]
4752 pub sha1: bool,
4753 /// The forge's SSH address: `ssh://git@git.gunnar.rs:2222` or `git@host`.
4754 /// Default: the `origin` of the clone you stand in.
4755 #[arg(long, value_name = "SSH-URL")]
4756 pub host: Option<String>,
4757 /// The SSH key to sign in with (default: whatever ssh picks).
4758 #[arg(short = 'i', long, value_name = "KEY")]
4759 pub identity: Option<std::path::PathBuf>,
4760}
4761
4762/// `gunnar settings`.
4763#[cfg(feature = "client")]
4764#[derive(Debug, clap::Args)]
4765pub struct SettingsArgs {
4766 /// `<namespace>/<repository>`.
4767 pub repo: String,
4768 /// Make it `public` (anyone may read, no key) or `private`.
4769 #[arg(long, value_name = "public|private")]
4770 pub visibility: Option<String>,
4771 /// The branch a new clone checks out: a short name (`main`) or `refs/heads/…`.
4772 #[arg(long, value_name = "BRANCH")]
4773 pub default_branch: Option<String>,
4774 /// The forge's SSH address: `ssh://git@git.gunnar.rs:2222` or `git@host`.
4775 /// Default: the `origin` of the clone you stand in.
4776 #[arg(long, value_name = "SSH-URL")]
4777 pub host: Option<String>,
4778 /// The SSH key to sign in with (default: whatever ssh picks).
4779 #[arg(short = 'i', long, value_name = "KEY")]
4780 pub identity: Option<std::path::PathBuf>,
4781}
4782
4783/// `gunnar clone`.
4784#[cfg(feature = "client")]
4785#[derive(Debug, clap::Args)]
4786pub struct CloneArgs {
4787 /// `git@host:ns/repo.git` or `ssh://git@host/ns/repo.git`.
4788 pub url: String,
4789 /// Where to put it. Defaults to the repository name.
4790 pub dest: Option<PathBuf>,
4791 /// The private key to authenticate with, and the ONLY one that will be:
4792 /// naming an identity switches off `ssh-agent` and the `~/.ssh` ladder, so
4793 /// a key this server does not accept is refused rather than quietly
4794 /// replaced. Omit it to let gunnar look (agent first, then `~/.ssh`).
4795 #[arg(long, short = 'i', value_name = "PATH")]
4796 pub identity: Option<PathBuf>,
4797 /// Truncate history to `<n>` commits, counted at the remote.
4798 ///
4799 /// The default of the common CI fetch action, and therefore the CI case.
4800 #[arg(long, value_name = "N")]
4801 pub depth: Option<u32>,
4802 /// Only history newer than this date, e.g. `@1700000000` or `2.weeks.ago`.
4803 #[arg(long, value_name = "DATE")]
4804 pub shallow_since: Option<String>,
4805 /// Exclude everything reachable from this revision. Repeatable, and a
4806 /// **short** name like `side` is the normal spelling.
4807 #[arg(long, value_name = "REV")]
4808 pub shallow_exclude: Vec<String>,
4809 /// Omit objects, and fetch them only when something reads them:
4810 /// `blob:none`, `tree:<depth>` or `blob:limit=<n>`.
4811 ///
4812 /// `blob:limit=<n>` omits a blob of exactly `n` bytes, as git does.
4813 #[arg(long, value_name = "SPEC")]
4814 pub filter: Option<String>,
4815}
4816
4817#[cfg(feature = "client")]
4818impl CloneArgs {
4819 /// The shallow flags, as the client's own type.
4820 ///
4821 /// `git clone` has no `--unshallow` and no `--deepen` — both need a boundary
4822 /// to move — so neither is offered here, which is why this is a conversion
4823 /// rather than a field.
4824 pub fn depth_args(&self) -> gunnar_client::DepthArgs {
4825 gunnar_client::DepthArgs {
4826 depth: self.depth,
4827 deepen: None,
4828 since: self.shallow_since.clone(),
4829 exclude: self.shallow_exclude.clone(),
4830 unshallow: false,
4831 }
4832 }
4833}
4834
4835/// `gunnar fetch`.
4836#[cfg(feature = "client")]
4837#[derive(Debug, clap::Args)]
4838pub struct FetchArgs {
4839 /// The remote URL.
4840 pub url: String,
4841 /// The repository to fetch into.
4842 #[arg(long, short = 'C', value_name = "DIR", default_value = ".")]
4843 pub repo: PathBuf,
4844 /// The private key to authenticate with, and the ONLY one that will be:
4845 /// naming an identity switches off `ssh-agent` and the `~/.ssh` ladder, so
4846 /// a key this server does not accept is refused rather than quietly
4847 /// replaced. Omit it to let gunnar look (agent first, then `~/.ssh`).
4848 #[arg(long, short = 'i', value_name = "PATH")]
4849 pub identity: Option<PathBuf>,
4850 /// Refspecs to fetch, repeatable. Defaults to
4851 /// `+refs/heads/*:refs/remotes/origin/*` and `+refs/tags/*:refs/tags/*`.
4852 ///
4853 /// A refspec whose source is a glob and which names no destination —
4854 /// `refs/heads/*` — is REFUSED, the way `git` refuses it, because it would
4855 /// take the pack and write no reference at all. `refs/heads/main`, with no
4856 /// glob and no destination, is legal and updates `FETCH_HEAD` only.
4857 #[arg(long, value_name = "SPEC")]
4858 pub refspec: Vec<String>,
4859 /// Truncate history to `<n>` commits, counted at the remote. On an existing
4860 /// shallow repository this can also **shrink** the boundary.
4861 #[arg(long, value_name = "N")]
4862 pub depth: Option<u32>,
4863 /// `<n>` more commits past the boundary this repository already has.
4864 ///
4865 /// Not the same as `--depth`, which counts at the remote: `--deepen 1` on a
4866 /// depth-1 clone gains a commit, `--depth 1` on it gains nothing.
4867 #[arg(long, value_name = "N")]
4868 pub deepen: Option<u32>,
4869 /// Only history newer than this date, e.g. `@1700000000` or `2.weeks.ago`.
4870 #[arg(long, value_name = "DATE")]
4871 pub shallow_since: Option<String>,
4872 /// Exclude everything reachable from this revision. Repeatable, and a
4873 /// **short** name like `side` is the normal spelling.
4874 #[arg(long, value_name = "REV")]
4875 pub shallow_exclude: Vec<String>,
4876 /// Fetch the whole history and remove the shallow boundary.
4877 #[arg(long)]
4878 pub unshallow: bool,
4879 /// Omit objects, and fetch them only when something reads them:
4880 /// `blob:none`, `tree:<depth>` or `blob:limit=<n>`.
4881 #[arg(long, value_name = "SPEC")]
4882 pub filter: Option<String>,
4883}
4884
4885#[cfg(feature = "client")]
4886impl FetchArgs {
4887 /// The shallow flags, as the client's own type. The refusals live in
4888 /// [`gunnar_client::DepthArgs::to_shallow`], where they can be tested
4889 /// against the real `git` binary.
4890 pub fn depth_args(&self) -> gunnar_client::DepthArgs {
4891 gunnar_client::DepthArgs {
4892 depth: self.depth,
4893 deepen: self.deepen,
4894 since: self.shallow_since.clone(),
4895 exclude: self.shallow_exclude.clone(),
4896 unshallow: self.unshallow,
4897 }
4898 }
4899}
4900
4901/// `gunnar ls-remote`.
4902#[cfg(feature = "client")]
4903#[derive(Debug, clap::Args)]
4904pub struct LsRemoteArgs {
4905 /// The remote URL: `git@host:ns/repo.git` or `ssh://git@host/ns/repo.git`.
4906 pub url: String,
4907 /// The private key to authenticate with, and the ONLY one that will be:
4908 /// naming an identity switches off `ssh-agent` and the `~/.ssh` ladder, so
4909 /// a key this server does not accept is refused rather than quietly
4910 /// replaced. Omit it to let gunnar look (agent first, then `~/.ssh`).
4911 #[arg(long, short = 'i', value_name = "PATH")]
4912 pub identity: Option<PathBuf>,
4913}
4914
4915/// `gunnar push`.
4916#[cfg(feature = "client")]
4917#[derive(Debug, clap::Args)]
4918pub struct PushArgs {
4919 /// The remote URL.
4920 pub url: String,
4921 /// Refspecs, e.g. `HEAD:refs/heads/main` or `+refs/heads/*:refs/heads/*`.
4922 /// Defaults to the current branch.
4923 pub refspecs: Vec<String>,
4924 /// The repository to push from.
4925 #[arg(long, short = 'C', value_name = "DIR", default_value = ".")]
4926 pub repo: PathBuf,
4927 /// The private key to authenticate with, and the ONLY one that will be:
4928 /// naming an identity switches off `ssh-agent` and the `~/.ssh` ladder, so
4929 /// a key this server does not accept is refused rather than quietly
4930 /// replaced. Omit it to let gunnar look (agent first, then `~/.ssh`).
4931 #[arg(long, short = 'i', value_name = "PATH")]
4932 pub identity: Option<PathBuf>,
4933 /// Allow non-fast-forward updates.
4934 #[arg(long, short = 'f')]
4935 pub force: bool,
4936 /// Negotiate and report, but send nothing.
4937 #[arg(long)]
4938 pub dry_run: bool,
4939}
4940
4941/// `gunnar crypt` — encrypted repositories (Level 1: stock git plus a filter).
4942#[cfg(feature = "client")]
4943#[derive(Debug, Subcommand)]
4944pub enum CryptCommand {
4945 /// Make this repository encrypted: a new key ring, wrapped to your
4946 /// encryption key. QUANTUM-SAFE unless you ask for `--level classic`.
4947 ///
4948 /// Writes `.gitattributes`, `.gunnar-crypt/members` and
4949 /// `.gunnar-crypt/keys`, sets up the filter in this clone and commits.
4950 /// Files committed BEFORE this stay plaintext in history. Your encryption
4951 /// key is the forge's (origin's); when you have none yet, one is made
4952 /// (quantum-safe at a quantum-safe repository) and wrapped to your SSH key.
4953 Init {
4954 /// Your member name (your Gunnar user name). Defaults to the name the forge origin
4955 /// points at says you are (its `gunnar-whoami` door); without one it is required.
4956 #[arg(long, value_name = "NAME")]
4957 member: Option<String>,
4958 /// Your encryption public key (`age1…`) when the forge is not asked. Without it
4959 /// the forge origin points at gives yours.
4960 #[arg(long = "key", value_name = "PUBKEY")]
4961 keys: Vec<String>,
4962 /// How the key ring is wrapped. `quantum-safe` (the default): only to
4963 /// age1pq1… keys (ML-KEM-768 + X25519). `classic`: to X25519 age1… keys,
4964 /// for members whose one encryption key is classic; a ring harvested
4965 /// today opens once a quantum computer breaks X25519.
4966 #[arg(long, value_enum, value_name = "LEVEL")]
4967 level: Option<CryptLevel>,
4968 /// Stage the files but do not commit.
4969 #[arg(long)]
4970 no_commit: bool,
4971 },
4972 /// This repository's level, or `level quantum-safe`: re-wrap a classic
4973 /// repository to quantum-safe keys.
4974 ///
4975 /// Every member needs a quantum-safe (age1pq1…) key, given with
4976 /// `--key NAME=…` or the one Gunnar holds for them (`--from-server`); a
4977 /// member without one is named and nothing changes. The ring is rotated
4978 /// (content from now on is sealed under a key never wrapped classically)
4979 /// and wrapped only to those keys. The level only goes up: Gunnar refuses
4980 /// a quantum-safe repository that goes back to classic.
4981 Level {
4982 /// `quantum-safe` to upgrade; none to show the level.
4983 #[arg(value_enum, value_name = "LEVEL")]
4984 level: Option<CryptLevel>,
4985 /// A member's quantum-safe key, `NAME=age1pq1…` or `NAME=<file>` (repeat).
4986 #[arg(long = "key", value_name = "NAME=PUBKEY")]
4987 keys: Vec<String>,
4988 /// For members without `--key`: the encryption key Gunnar holds for them,
4989 /// asked of the forge `origin` pushes to (or, with `--at`, a control plane).
4990 #[arg(long)]
4991 from_server: bool,
4992 /// `NAME=SHA256:…`: the fingerprint of a member's key the first time this
4993 /// machine sees it (`--from-server`; the server's word alone is not taken).
4994 #[arg(
4995 long = "fingerprint",
4996 value_name = "NAME=SHA256:…",
4997 requires = "from_server"
4998 )]
4999 fingerprints: Vec<String>,
5000 /// The repository on the server, `<ns>/<repo>` (with `--from-server`).
5001 #[arg(long, value_name = "NS/REPO", requires = "from_server")]
5002 repo: Option<String>,
5003 /// A control plane to ask instead of the forge's SSH door (with `--from-server`).
5004 #[arg(long, value_name = "ADDR", requires = "from_server")]
5005 at: Option<String>,
5006 /// The key to sign in with (with `--from-server`).
5007 #[arg(long, short = 'i', value_name = "PATH", requires = "from_server")]
5008 identity: Option<PathBuf>,
5009 /// Stage the files but do not commit.
5010 #[arg(long)]
5011 no_commit: bool,
5012 },
5013 /// Make a quantum-safe KEY FILE: an age1pq1… key (age's hybrid
5014 /// ML-KEM-768 + X25519), made on this machine — a quantum-safe way to open
5015 /// your encryption key (`gunnar crypt key add-pq`).
5016 ///
5017 /// The private half is written mode 0600 and never leaves this machine.
5018 /// SSH keys cannot be quantum-safe recipients: their key exchange (X25519,
5019 /// RSA) is what a quantum computer breaks.
5020 Keygen {
5021 /// Where to write the private key. Defaults to
5022 /// `$XDG_CONFIG_HOME/gunnar/crypt-pq.key` (`~/.config/gunnar/…`).
5023 #[arg(long, short = 'o', value_name = "PATH")]
5024 out: Option<PathBuf>,
5025 /// Replace an existing key file. Rings wrapped to the old key no
5026 /// longer open with the new one.
5027 #[arg(long)]
5028 force: bool,
5029 },
5030 /// Wrap the key ring to one more member. They need not be present: their
5031 /// encryption PUBLIC key is enough — given here, or the one gunnar holds
5032 /// for them (`--from-server`). In a quantum-safe repository it is their
5033 /// quantum-safe (age1pq1…) encryption key.
5034 #[command(name = "add-member")]
5035 AddMember {
5036 /// The member's name (their gunnar user name, for `--from-server`).
5037 name: String,
5038 /// Their encryption public key (`age1…`), as a file or a key line.
5039 #[arg(
5040 long = "key",
5041 value_name = "PUBKEY",
5042 required_unless_present = "from_server"
5043 )]
5044 keys: Vec<String>,
5045 /// Use the encryption key gunnar holds for them (they must be able to
5046 /// read the repository, and so must you). Asked of the forge `origin`
5047 /// pushes to, over its SSH door (`gunnar-recipient-keys`, signed in by
5048 /// the ssh git runs) — or, with `--at`, of a control plane
5049 /// (`Repos.RecipientKeys`, `gunnar-cli` only).
5050 #[arg(long)]
5051 from_server: bool,
5052 /// The fingerprint of the member's key (`SHA256:…`, from them: their
5053 /// `gunnar crypt key status` or Account page). Required with
5054 /// `--from-server` the first time this machine sees their key: the
5055 /// server's word alone is not taken (RA4-1).
5056 #[arg(long, value_name = "SHA256:…", requires = "from_server")]
5057 fingerprint: Option<String>,
5058 /// The repository on the server, `<ns>/<repo>`. Defaults to the one
5059 /// `origin` points at.
5060 #[arg(long, value_name = "NS/REPO", requires = "from_server")]
5061 repo: Option<String>,
5062 /// A control plane to ask instead of the forge's SSH door (with
5063 /// `--from-server`; an installed estate binds it to its WireGuard
5064 /// overlay, so its members leave this off).
5065 #[arg(long, value_name = "ADDR", requires = "from_server")]
5066 at: Option<String>,
5067 /// The key to sign in with (with `--from-server`): the control call's
5068 /// signer, or `ssh -i` at the forge's door.
5069 #[arg(long, short = 'i', value_name = "PATH", requires = "from_server")]
5070 identity: Option<PathBuf>,
5071 /// Stage the files but do not commit.
5072 #[arg(long)]
5073 no_commit: bool,
5074 },
5075 /// Remove a member and rotate: content committed from now on is sealed
5076 /// under a new key they never get. What they already had, they keep.
5077 #[command(name = "remove-member")]
5078 RemoveMember {
5079 /// The member's name.
5080 name: String,
5081 /// Stage the files but do not commit.
5082 #[arg(long)]
5083 no_commit: bool,
5084 },
5085 /// Rotate: add a new key epoch; new content is sealed under it.
5086 Rotate {
5087 /// Stage the files but do not commit.
5088 #[arg(long)]
5089 no_commit: bool,
5090 },
5091 /// Open your encryption key with one of its unlock methods — your SSH
5092 /// key FILE, your security key, or your recovery code — and with it this
5093 /// repository's key ring; decrypt this clone.
5094 ///
5095 /// ssh-agent cannot help: it only signs. An encrypted key file asks for
5096 /// its passphrase on the terminal.
5097 Unlock {
5098 #[command(flatten)]
5099 open: KeyOpenArgs,
5100 },
5101 /// Your encryption key: one per person, for every encrypted repository you
5102 /// are a member of, opened by any of its unlock methods (an SSH key, a
5103 /// security key, a recovery code). Adding or removing a method changes no
5104 /// repository.
5105 #[command(subcommand)]
5106 Key(CryptKeyCommand),
5107 /// Forget the local key ring and put ciphertext back in the work tree.
5108 Lock,
5109 /// Members, key epochs, and whether this clone is unlocked.
5110 Status,
5111 /// Diff two revisions in plaintext — the same diff the browser console
5112 /// computes.
5113 Diff {
5114 /// The old revision.
5115 from: String,
5116 /// The new revision.
5117 to: String,
5118 /// Limit to these paths.
5119 #[arg(last = true)]
5120 paths: Vec<String>,
5121 },
5122 /// git filter: seal stdin to stdout (`%f` is the path).
5123 #[command(hide = true)]
5124 Clean {
5125 /// The path git names.
5126 path: Option<String>,
5127 },
5128 /// git filter: open stdin to stdout.
5129 #[command(hide = true)]
5130 Smudge {
5131 /// The path git names.
5132 path: Option<String>,
5133 },
5134 /// git filter, long-running protocol (`filter.<driver>.process`).
5135 #[command(name = "filter-process", hide = true)]
5136 FilterProcess,
5137 /// git diff driver: print a sealed file's plaintext.
5138 #[command(hide = true)]
5139 Textconv {
5140 /// The file git hands over.
5141 file: PathBuf,
5142 },
5143 /// git merge driver: open the three versions, merge the plaintext, seal
5144 /// the result.
5145 #[command(hide = true)]
5146 Merge {
5147 /// `%O`, the common ancestor.
5148 base: PathBuf,
5149 /// `%A`, ours; the result is written here.
5150 ours: PathBuf,
5151 /// `%B`, theirs.
5152 theirs: PathBuf,
5153 /// `%L`, the conflict marker size.
5154 #[arg(default_value_t = 7)]
5155 marker_size: usize,
5156 /// `%P`, the path.
5157 path: Option<String>,
5158 },
5159}
5160
5161/// An encrypted repository's level (`--level`).
5162#[cfg(feature = "client")]
5163#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
5164pub enum CryptLevel {
5165 /// Wrapped only to quantum-safe age1pq1… keys (ML-KEM-768 + X25519). The default.
5166 QuantumSafe,
5167 /// Wrapped to classic X25519 age1… keys.
5168 Classic,
5169}
5170
5171/// How `gunnar crypt` opens your encryption key.
5172#[cfg(feature = "client")]
5173#[derive(Debug, Clone, Default, clap::Args)]
5174pub struct KeyOpenArgs {
5175 /// The key file to open it with: an SSH key file or a `gunnar crypt keygen`
5176 /// file. Defaults to `~/.ssh/id_ed25519`, `~/.ssh/id_rsa` and
5177 /// `~/.config/gunnar/crypt-pq.key`, whichever exist.
5178 #[arg(long, short = 'i', value_name = "PATH")]
5179 pub identity: Vec<PathBuf>,
5180 /// Open it with your FIDO2 security key (a YubiKey 5: its hmac-secret), over USB.
5181 #[arg(long, conflicts_with = "recovery_code")]
5182 pub security_key: bool,
5183 /// Open it with your printed recovery code (asked on the terminal).
5184 #[arg(long)]
5185 pub recovery_code: bool,
5186}
5187
5188/// `gunnar crypt key …`: your encryption key and its unlock methods.
5189#[cfg(feature = "client")]
5190#[derive(Debug, Subcommand)]
5191pub enum CryptKeyCommand {
5192 /// Your encryption key's public half and its unlock methods.
5193 Status,
5194 /// Make your encryption key (the first time), wrapped to your SSH key:
5195 /// quantum-safe (age1pq1…) unless you ask for `--classic`.
5196 New {
5197 /// A classic X25519 key (age1…) instead: for classic repositories only.
5198 /// A person has ONE key, so this cannot be changed later.
5199 #[arg(long)]
5200 classic: bool,
5201 /// The SSH public key that opens it. Defaults to `~/.ssh/id_ed25519.pub`.
5202 #[arg(long, value_name = "PUBKEY")]
5203 ssh_key: Option<String>,
5204 },
5205 /// One more SSH key opens your encryption key.
5206 AddSsh {
5207 /// The SSH public key, as a file or a key line. Defaults to `~/.ssh/id_ed25519.pub`.
5208 key: Option<String>,
5209 #[command(flatten)]
5210 open: KeyOpenArgs,
5211 },
5212 /// Your `gunnar crypt keygen` key (age1pq1…) opens your encryption key.
5213 AddPq {
5214 /// The age1pq1… public key, as a file or a key line. Defaults to the keygen file's.
5215 key: Option<String>,
5216 #[command(flatten)]
5217 open: KeyOpenArgs,
5218 },
5219 /// A FIDO2 security key opens your encryption key, here and in the console.
5220 AddSecurityKey {
5221 /// The console's host name: the WebAuthn relying party the credential is
5222 /// bound to, so the same key opens it in the browser too.
5223 #[arg(long, value_name = "HOST")]
5224 rp_id: String,
5225 /// Your name for this key ("YubiKey 5C, keyring").
5226 #[arg(long)]
5227 label: Option<String>,
5228 #[command(flatten)]
5229 open: KeyOpenArgs,
5230 },
5231 /// A printed recovery code opens your encryption key (shown once).
5232 AddRecoveryCode {
5233 #[command(flatten)]
5234 open: KeyOpenArgs,
5235 },
5236 /// Delete one unlock method (its id from `status`). The last one stays.
5237 Remove {
5238 /// The method's id.
5239 id: String,
5240 },
5241 /// Trust the encryption key the forge holds for you NOW: after a refused
5242 /// changed key, account name or forge host key, once you checked the new
5243 /// key's fingerprint on your Account page.
5244 Repin {
5245 /// The fingerprint of the key the forge holds (`SHA256:…`), as your Account page shows it.
5246 #[arg(long, value_name = "SHA256")]
5247 fingerprint: String,
5248 },
5249}
5250
5251/// Mirror verbs — the pull mirror.
5252#[cfg(feature = "client")]
5253#[derive(Debug, Subcommand)]
5254pub enum MirrorCommand {
5255 /// Register a remote to mirror.
5256 Add {
5257 /// A local name for the mirror.
5258 name: String,
5259 /// The remote URL.
5260 url: String,
5261 /// Where the mirror registry and its bare repositories live.
5262 #[arg(long, env = "GUNNAR_MIRROR_ROOT", value_name = "DIR")]
5263 root: Option<PathBuf>,
5264 },
5265 /// ls-remote, fetch the deltas, apply the refs.
5266 Sync {
5267 /// The mirror name.
5268 name: String,
5269 #[arg(long, env = "GUNNAR_MIRROR_ROOT", value_name = "DIR")]
5270 root: Option<PathBuf>,
5271 /// The private key to authenticate with, and the ONLY one that will be:
5272 /// naming an identity switches off `ssh-agent` and the `~/.ssh` ladder, so
5273 /// a key this server does not accept is refused rather than quietly
5274 /// replaced. Omit it to let gunnar look (agent first, then `~/.ssh`).
5275 #[arg(long, short = 'i', value_name = "PATH")]
5276 identity: Option<PathBuf>,
5277 },
5278 /// What is registered, and what is on disk.
5279 Status {
5280 #[arg(long, env = "GUNNAR_MIRROR_ROOT", value_name = "DIR")]
5281 root: Option<PathBuf>,
5282 },
5283}
5284
5285/// `gunnar log`.
5286#[cfg(feature = "client")]
5287#[derive(Debug, clap::Args)]
5288pub struct LogArgs {
5289 /// The repository.
5290 #[arg(long, short = 'C', value_name = "DIR", default_value = ".")]
5291 pub repo: PathBuf,
5292 /// Where to start. Defaults to `HEAD`.
5293 #[arg(long, value_name = "REV")]
5294 pub start: Option<String>,
5295 /// How many commits to show.
5296 #[arg(long, short = 'n', default_value_t = 20)]
5297 pub max: usize,
5298}
5299
5300/// `gunnar commit`.
5301#[cfg(feature = "client")]
5302#[derive(Debug, clap::Args)]
5303pub struct CommitArgs {
5304 /// The commit message.
5305 #[arg(long, short = 'm')]
5306 pub message: String,
5307 /// The repository.
5308 #[arg(long, short = 'C', value_name = "DIR", default_value = ".")]
5309 pub repo: PathBuf,
5310}
5311
5312/// `gunnar branch`.
5313#[cfg(feature = "client")]
5314#[derive(Debug, clap::Args)]
5315pub struct BranchArgs {
5316 /// Create this branch. Omit to list.
5317 pub name: Option<String>,
5318 /// Delete `name` instead of creating it.
5319 #[arg(long, short = 'd')]
5320 pub delete: bool,
5321 /// The repository.
5322 #[arg(long, short = 'C', value_name = "DIR", default_value = ".")]
5323 pub repo: PathBuf,
5324}
5325
5326/// `gunnar tag`.
5327#[cfg(feature = "client")]
5328#[derive(Debug, clap::Args)]
5329pub struct TagArgs {
5330 /// Create this tag. Omit to list.
5331 pub name: Option<String>,
5332 /// Make it an annotated tag with this message.
5333 #[arg(long, short = 'm')]
5334 pub message: Option<String>,
5335 /// The repository.
5336 #[arg(long, short = 'C', value_name = "DIR", default_value = ".")]
5337 pub repo: PathBuf,
5338}
5339
5340// ───────────────────────────────────────────────────────────────────────────
5341// systemd
5342// ───────────────────────────────────────────────────────────────────────────
5343
5344/// `gunnar service` — the `systemd` integration.
5345#[cfg(feature = "systemd")]
5346#[derive(Debug, Subcommand)]
5347pub enum ServiceCommand {
5348 /// Render and install the unit.
5349 Install(ServiceArgs),
5350 /// Start the service.
5351 Start(ServiceArgs),
5352 /// Stop the service.
5353 Stop(ServiceArgs),
5354 /// Is it active?
5355 Status(ServiceArgs),
5356 /// Remove the unit.
5357 ///
5358 /// Skidbladnir 0.1.12 has no `uninstall.rs`, so this is the thinner
5359 /// `Result<()>` — no `UninstallReport`, no guarded-delete purge law. Those
5360 /// arrived in the unpublished 0.1.13.
5361 Uninstall(ServiceArgs),
5362 /// Print the rendered unit and exit. Touches nothing.
5363 Unit(ServiceArgs),
5364}
5365
5366/// Shared arguments for every `gunnar service` verb.
5367#[cfg(feature = "systemd")]
5368#[derive(Debug, clap::Args, Clone)]
5369pub struct ServiceArgs {
5370 /// The installed binary the unit runs.
5371 #[arg(long, value_name = "PATH")]
5372 pub exec: Option<PathBuf>,
5373 /// Where the unit's `gunnar serve` binds SSH.
5374 #[arg(long, default_value = "0.0.0.0:2222", value_name = "ADDR")]
5375 pub ssh_bind: String,
5376 /// Where the unit's `gunnar serve` binds gRPC. Loopback: two listeners, one identity table.
5377 #[arg(long, default_value = "127.0.0.1:50051", value_name = "ADDR")]
5378 pub grpc_bind: String,
5379 /// Install a `--user` unit rather than a system one.
5380 #[arg(long)]
5381 pub user: bool,
5382 /// The system user the unit runs as. System scope only.
5383 #[arg(long, default_value = "gunnar", value_name = "USER")]
5384 pub run_as: String,
5385 /// The data root the unit passes to `gunnar serve`.
5386 #[arg(long, value_name = "DIR")]
5387 pub data_root: Option<PathBuf>,
5388 /// The `RUST_LOG` the unit runs with.
5389 ///
5390 /// Defaults to `gunnar_core::observe::service_log_filter()`, which names
5391 /// every one of gunnar's targets explicitly. **A unit that names no filter
5392 /// is not a unit with no opinion** — the process then falls through to a
5393 /// bare `info`, and which of gunnar's lines that selects is decided
5394 /// somewhere the operator cannot see. See `service::spec`.
5395 #[arg(long, value_name = "FILTER", env = "RUST_LOG")]
5396 pub log_filter: Option<String>,
5397}
5398
5399#[cfg(test)]
5400mod tests {
5401 use super::*;
5402 use clap::CommandFactory;
5403
5404 /// clap's own consistency check. It catches a duplicated short flag, a
5405 /// group naming an argument that does not exist, and every other way a
5406 /// derive can be wrong that would otherwise only show up as a panic the
5407 /// first time a user runs the binary.
5408 #[test]
5409 fn the_command_tree_is_well_formed() {
5410 Cli::command().debug_assert();
5411 }
5412
5413 /// **The metered appliance's blocker, at the argv it actually boots with.**
5414 ///
5415 /// `--monetize-pubkey <64 hex>` must arrive as monetize's 32 verifying-key
5416 /// bytes. Asserting `is_ok()` here would pass on a byte-swapped key and on
5417 /// a key read as a filename, so the assertion is the bytes — written out
5418 /// by hand, not produced by the parser being tested.
5419 /// **A flag that only means something with `--from-server` is refused
5420 /// without it** (wiring tracer B 2026-10-08): `crypt add-member bob --key
5421 /// k --at addr` parsed and ignored `--at`, `--repo` and `-i`, and a
5422 /// `GUNNAR_CONTROL_ADDR` in the environment quietly turned the forge's
5423 /// door into a control-plane call.
5424 #[cfg(feature = "client")]
5425 #[test]
5426 fn crypt_flags_that_need_from_server_are_refused_without_it() {
5427 for args in [
5428 &[
5429 "gunnar",
5430 "crypt",
5431 "add-member",
5432 "bob",
5433 "--key",
5434 "k.pub",
5435 "--at",
5436 "10.0.0.1:50051",
5437 ][..],
5438 &[
5439 "gunnar",
5440 "crypt",
5441 "add-member",
5442 "bob",
5443 "--key",
5444 "k.pub",
5445 "--repo",
5446 "acme/vault",
5447 ][..],
5448 &[
5449 "gunnar",
5450 "crypt",
5451 "add-member",
5452 "bob",
5453 "--key",
5454 "k.pub",
5455 "-i",
5456 "id",
5457 ][..],
5458 &["gunnar", "crypt", "upgrade-pq", "--key", "bob=age1pq1x"][..],
5459 ] {
5460 assert!(
5461 Cli::try_parse_from(args).is_err(),
5462 "{args:?} parsed with a flag that does nothing"
5463 );
5464 }
5465 assert!(Cli::try_parse_from([
5466 "gunnar",
5467 "crypt",
5468 "add-member",
5469 "bob",
5470 "--from-server",
5471 "--at",
5472 "10.0.0.1:50051",
5473 "-i",
5474 "id"
5475 ])
5476 .is_ok());
5477 std::env::set_var("GUNNAR_CONTROL_ADDR", "10.0.0.9:50051");
5478 let parsed = Cli::try_parse_from(["gunnar", "crypt", "add-member", "bob", "--from-server"])
5479 .expect("parses");
5480 std::env::remove_var("GUNNAR_CONTROL_ADDR");
5481 let text = format!("{parsed:?}");
5482 assert!(
5483 !text.contains("10.0.0.9"),
5484 "the environment chose the control plane for the forge's door: {text}"
5485 );
5486 }
5487
5488 #[cfg(feature = "server")]
5489 #[test]
5490 fn serve_takes_the_monetize_key_inline_on_the_command_line() {
5491 const LIVE: &str = "1c3663248d60900e2996389fba4efa1d7179d6dbd08627d0426f16f94e44c4e6";
5492 const LIVE_BYTES: [u8; 32] = [
5493 0x1c, 0x36, 0x63, 0x24, 0x8d, 0x60, 0x90, 0x0e, 0x29, 0x96, 0x38, 0x9f, 0xba, 0x4e,
5494 0xfa, 0x1d, 0x71, 0x79, 0xd6, 0xdb, 0xd0, 0x86, 0x27, 0xd0, 0x42, 0x6f, 0x16, 0xf9,
5495 0x4e, 0x44, 0xc4, 0xe6,
5496 ];
5497 let cli = Cli::try_parse_from(["gunnar", "serve", "--monetize-pubkey", LIVE])
5498 .expect("the live key is accepted on the command line");
5499 let Command::Serve(args) = cli.command else {
5500 panic!("expected serve")
5501 };
5502 let key = args.monetize_pubkey.expect("the flag was given");
5503 assert_eq!(
5504 key.literal(),
5505 Some(LIVE_BYTES),
5506 "an appliance whose only writable channel is the cmdline must get the \
5507 key itself, not a filename: {key:?}"
5508 );
5509 }
5510
5511 /// **The open build is unaffected by `monetize_vital`** (owner ruling 2026-09-22): it has no
5512 /// monetize, so it has no such flag — neither on its argv nor in its `--help` — and nothing
5513 /// in it can raise `entitlement.key_mismatch` or report maintenance for a key.
5514 #[cfg(all(feature = "server", not(feature = "metered")))]
5515 #[test]
5516 fn the_open_build_has_no_monetize_vital() {
5517 assert!(Cli::try_parse_from(["gunnar", "serve", "--monetize-vital", "true"]).is_err());
5518 let help = <Cli as clap::CommandFactory>::command()
5519 .find_subcommand_mut("serve")
5520 .expect("serve")
5521 .render_long_help()
5522 .to_string();
5523 assert!(!help.contains("monetize-vital"), "{help}");
5524 }
5525
5526 /// **The usage share's flags read the medium's tokens**: `admit_limit`
5527 /// lands `korp.env.GUNNAR_ADMIT_LIMIT` (gunnar-recipe `ADMIT_LIMIT_ENV`)
5528 /// and `admit_window_days` `korp.env.GUNNAR_ADMIT_WINDOW_DAYS`; the flag
5529 /// wins over the env, and neither exists without the feature.
5530 #[cfg(all(feature = "server", feature = "admit-share"))]
5531 #[test]
5532 fn serve_reads_the_admit_flags_and_their_env() {
5533 let cli = Cli::try_parse_from([
5534 "gunnar",
5535 "serve",
5536 "--admit-limit",
5537 "100GB",
5538 "--admit-window-days",
5539 "7",
5540 ])
5541 .expect("parses");
5542 let Command::Serve(args) = cli.command else {
5543 panic!("expected serve")
5544 };
5545 assert_eq!(
5546 (args.admit_limit.as_deref(), args.admit_window_days),
5547 (Some("100GB"), Some(7))
5548 );
5549 let serve = <Cli as clap::CommandFactory>::command();
5550 let serve = serve.find_subcommand("serve").expect("serve");
5551 let env_of = |id: &str| {
5552 serve
5553 .get_arguments()
5554 .find(|a| a.get_id() == id)
5555 .and_then(|a| a.get_env())
5556 .map(|e| e.to_string_lossy().into_owned())
5557 };
5558 assert_eq!(env_of("admit_limit").as_deref(), Some("GUNNAR_ADMIT_LIMIT"));
5559 assert_eq!(
5560 env_of("admit_window_days").as_deref(),
5561 Some("GUNNAR_ADMIT_WINDOW_DAYS")
5562 );
5563 }
5564
5565 #[cfg(all(feature = "server", not(feature = "admit-share")))]
5566 #[test]
5567 fn without_the_share_there_is_no_admit_flag() {
5568 assert!(Cli::try_parse_from(["gunnar", "serve", "--admit-limit", "none"]).is_err());
5569 }
5570
5571 /// The path spelling is unchanged, and the literal arm has not swallowed
5572 /// it: a path that does not exist is still carried as a PATH, to fail when
5573 /// it is read rather than at parse time.
5574 #[cfg(feature = "server")]
5575 #[test]
5576 fn serve_still_takes_the_monetize_key_as_a_path() {
5577 let cli = Cli::try_parse_from([
5578 "gunnar",
5579 "serve",
5580 "--monetize-pubkey",
5581 "/no/such/monetize.pub",
5582 ])
5583 .expect("a path parses whether or not it exists");
5584 let Command::Serve(args) = cli.command else {
5585 panic!("expected serve")
5586 };
5587 let key = args.monetize_pubkey.expect("the flag was given");
5588 assert_eq!(
5589 key.path(),
5590 Some(std::path::Path::new("/no/such/monetize.pub")),
5591 "the literal arm swallowed the path arm: {key:?}"
5592 );
5593 }
5594
5595 /// A near-miss key is refused BY CLAP, naming what was wrong — not read as
5596 /// a filename, and not truncated to 32 bytes.
5597 #[cfg(feature = "server")]
5598 #[test]
5599 fn a_near_miss_monetize_key_is_refused_on_the_command_line() {
5600 let short = "1c3663248d60900e2996389fba4efa1d7179d6dbd08627d0426f16f94e44c4e";
5601 let err = Cli::try_parse_from(["gunnar", "serve", "--monetize-pubkey", short])
5602 .expect_err("a 63-character key must be refused")
5603 .to_string();
5604 assert!(
5605 err.contains("63") && err.contains("64"),
5606 "the refusal must name the length that was wrong: {err}"
5607 );
5608 }
5609
5610 #[cfg(feature = "server")]
5611 #[test]
5612 fn serve_defaults_grpc_to_loopback() {
5613 let cli = Cli::try_parse_from(["gunnar", "serve"]).expect("serve parses with no arguments");
5614 let Command::Serve(args) = cli.command else {
5615 panic!("expected serve")
5616 };
5617 assert_eq!(
5618 args.grpc_bind.to_string(),
5619 "127.0.0.1:50051",
5620 "the gRPC control plane's default bind must be \
5621 loopback. Anything else makes an open admin port the default."
5622 );
5623 assert!(
5624 args.grpc_bind.is_loopback_addr(),
5625 "the default gRPC bind must be a loopback address"
5626 );
5627 assert!(
5628 !args.ssh_bind.ip().is_loopback(),
5629 "SSH authenticates every connection by public key, so its default is public; a \
5630 loopback default here would mean this test is asserting the wrong field"
5631 );
5632 }
5633
5634 /// Every spelling `--prune-grace` accepts, and the ones it must refuse.
5635 ///
5636 /// The refusals are the half that matters: a parser that fell back to zero
5637 /// on anything it did not understand would turn `--prune-grace 2.weeks.ago`
5638 /// — the spelling a git operator will type first — into `--prune=now`
5639 /// against a live server. So `2.weeks.ago` is asserted to be an **error**,
5640 /// not silently anything.
5641 #[cfg(feature = "server")]
5642 #[test]
5643 fn prune_grace_parses_what_it_claims_and_refuses_the_rest() {
5644 use std::time::Duration;
5645 for (raw, want) in [
5646 ("now", Duration::ZERO),
5647 ("NOW", Duration::ZERO),
5648 ("0", Duration::ZERO),
5649 ("90", Duration::from_secs(90)),
5650 ("45s", Duration::from_secs(45)),
5651 ("5m", Duration::from_secs(300)),
5652 ("2h", Duration::from_secs(7200)),
5653 ("3d", Duration::from_secs(259_200)),
5654 ("2w", Duration::from_secs(1_209_600)),
5655 ] {
5656 assert_eq!(
5657 parse_prune_grace(raw),
5658 Ok(want),
5659 "--prune-grace {raw} did not parse to {want:?}"
5660 );
5661 }
5662 for raw in ["2.weeks.ago", "yesterday", "", "w", "-1", "2 weeks", "2y"] {
5663 assert!(
5664 parse_prune_grace(raw).is_err(),
5665 "--prune-grace {raw:?} was accepted; a grace this parser does not understand \
5666 must be refused, never defaulted"
5667 );
5668 }
5669 // And the flag reaches the struct, so a parser that worked in isolation
5670 // and was not wired would still fail here.
5671 let cli = Cli::try_parse_from(["gunnar", "gc", "--prune", "--prune-grace", "now"])
5672 .expect("gc --prune-grace now parses");
5673 let Command::Gc(args) = cli.command else {
5674 panic!("expected gc")
5675 };
5676 assert_eq!(args.prune_grace, Duration::ZERO);
5677 assert_ne!(args.prune_grace, GcArgs::default().prune_grace);
5678 }
5679
5680 #[cfg(feature = "server")]
5681 #[test]
5682 fn grant_requires_exactly_one_scope() {
5683 Cli::try_parse_from(["gunnar", "grant", "alice", "write"])
5684 .expect_err("a grant with no scope must be refused");
5685 Cli::try_parse_from([
5686 "gunnar",
5687 "grant",
5688 "alice",
5689 "write",
5690 "--server",
5691 "--namespace",
5692 "team",
5693 ])
5694 .expect_err("a grant with two scopes must be refused");
5695 let cli = Cli::try_parse_from(["gunnar", "grant", "alice", "write", "--repo", "team/x"])
5696 .expect("one scope parses");
5697 let Command::Grant(args) = cli.command else {
5698 panic!("expected grant")
5699 };
5700 assert_eq!(args.repo.as_deref(), Some("team/x"));
5701 assert_eq!(args.level, LevelArg::Write);
5702 }
5703
5704 #[cfg(feature = "server")]
5705 #[test]
5706 fn repo_create_defaults_to_sha256() {
5707 let cli = Cli::try_parse_from(["gunnar", "repo", "create", "team/x"]).expect("parses");
5708 let Command::Repo(RepoCommand::Create { hash, .. }) = cli.command else {
5709 panic!("expected repo create")
5710 };
5711 assert_eq!(hash, HashArg::Sha256, "sha256 is the default hash kind");
5712 }
5713
5714 /// The appliance build must not offer a verb it cannot perform, and the
5715 /// default build must offer both. Asserted in both directions so the test
5716 /// cannot pass vacuously on a binary that has no subcommands at all.
5717 #[test]
5718 fn the_feature_split_reaches_the_command_line() {
5719 let has = |name: &str| {
5720 Cli::command()
5721 .get_subcommands()
5722 .any(|sub| sub.get_name() == name)
5723 };
5724 assert_eq!(
5725 has("serve"),
5726 cfg!(feature = "server"),
5727 "`serve` exists exactly when the server feature is on"
5728 );
5729 assert_eq!(
5730 has("clone"),
5731 cfg!(feature = "client"),
5732 "`clone` exists exactly when the client feature is on"
5733 );
5734 assert_eq!(
5735 has("service"),
5736 cfg!(feature = "systemd"),
5737 "`service` exists exactly when the systemd feature is on"
5738 );
5739 }
5740
5741 /// ★ **The verb that reads the key a tenant's own renewal depends on.**
5742 ///
5743 /// `Billing.ApplianceIdentity` was served from the day the tenant path
5744 /// shipped, with a proto comment saying it exists "so an operator can
5745 /// configure their monetize with it" — and nothing but a test ever called
5746 /// it, because no verb existed. MEASURED on gunnar.rs 2026-09-19: every
5747 /// self-service renewal answered 501, monetize's `[products.gunnar]`
5748 /// carried no `appliance_key`, and there was no way to obtain one. An RPC
5749 /// nothing can call is a feature nobody has.
5750 ///
5751 /// Metered only, like the service it calls: `billing.proto` is not in
5752 /// codegen without the feature, so on a community build this verb must not
5753 /// exist at all rather than fail at the far end.
5754 #[cfg(all(feature = "metered", feature = "control"))]
5755 #[test]
5756 fn appliance_identity_parses_and_takes_an_address_and_an_identity() {
5757 let cli = Cli::try_parse_from([
5758 "gunnar",
5759 "control",
5760 "appliance-identity",
5761 "--at",
5762 "10.13.8.101:50051",
5763 "-i",
5764 "/var/lib/monetize/gunnar-identity",
5765 ])
5766 .expect("the verb parses");
5767 match cli.command {
5768 Command::Control(ControlCommand::ApplianceIdentity { at, identity }) => {
5769 assert_eq!(at, "10.13.8.101:50051");
5770 assert_eq!(
5771 identity.as_deref(),
5772 Some(std::path::Path::new("/var/lib/monetize/gunnar-identity"))
5773 );
5774 }
5775 other => panic!("parsed as something else: {other:?}"),
5776 }
5777 }
5778
5779 /// And it defaults its address like every other control verb, so the
5780 /// common case is two words.
5781 #[cfg(all(feature = "metered", feature = "control"))]
5782 #[test]
5783 fn appliance_identity_defaults_its_address() {
5784 let cli = Cli::try_parse_from(["gunnar", "control", "appliance-identity"])
5785 .expect("the verb parses bare");
5786 match cli.command {
5787 Command::Control(ControlCommand::ApplianceIdentity { at, .. }) => {
5788 assert_eq!(at, crate::DEFAULT_CONTROL_ADDR);
5789 }
5790 other => panic!("parsed as something else: {other:?}"),
5791 }
5792 }
5793
5794 /// `gunnar control entitlement set|get|list` parse, and `set` takes a PATH
5795 /// to a fact monetize signed — never a namespace and a state, because this
5796 /// verb signs nothing and a verb that took a state would be one that could.
5797 #[cfg(all(feature = "metered", feature = "control"))]
5798 #[test]
5799 fn entitlement_verbs_parse_and_set_takes_only_a_signed_fact() {
5800 let cli = Cli::try_parse_from([
5801 "gunnar",
5802 "control",
5803 "entitlement",
5804 "set",
5805 "/tmp/fact.json",
5806 "--at",
5807 "10.0.0.1:50051",
5808 ])
5809 .expect("set parses");
5810 match cli.command {
5811 Command::Control(ControlCommand::Entitlement(ControlEntitlementCommand::Set {
5812 fact,
5813 at,
5814 ..
5815 })) => {
5816 assert_eq!(fact, std::path::PathBuf::from("/tmp/fact.json"));
5817 assert_eq!(at, "10.0.0.1:50051");
5818 }
5819 other => panic!("parsed as something else: {other:?}"),
5820 }
5821 assert!(
5822 Cli::try_parse_from(["gunnar", "control", "entitlement", "set", "team", "paid"])
5823 .is_err(),
5824 "`set` must not take a namespace and a state"
5825 );
5826 let cli = Cli::try_parse_from(["gunnar", "control", "entitlement", "get", "team"])
5827 .expect("get parses");
5828 match cli.command {
5829 Command::Control(ControlCommand::Entitlement(ControlEntitlementCommand::Get {
5830 namespace,
5831 at,
5832 ..
5833 })) => {
5834 assert_eq!(namespace, "team");
5835 assert_eq!(at, crate::DEFAULT_CONTROL_ADDR);
5836 }
5837 other => panic!("parsed as something else: {other:?}"),
5838 }
5839 Cli::try_parse_from(["gunnar", "control", "entitlement", "list"]).expect("list parses");
5840 }
5841
5842 /// ★ **The ONLINE revoke exists, and it takes a bare fingerprint.**
5843 ///
5844 /// MEASURED on the live appliance 2026-09-19: `gunnar control key revoke
5845 /// <fp>` answered *"unrecognized subcommand 'key'"*, because the only
5846 /// `Revoke` in the CLI was `gunnar key revoke` — a `server`-feature verb
5847 /// that writes the control repository directly and therefore needs the
5848 /// server DOWN. An appliance is PID 1 with no shell, so the machine that
5849 /// most needs the verb is the one that can never run it, and rotating one
5850 /// dead machine key took six steps and four refusals instead.
5851 ///
5852 /// The shape is the transcript's: the fingerprint is positional, as the
5853 /// operator pastes it out of `user keys`, and nothing else is required.
5854 #[cfg(feature = "control")]
5855 #[test]
5856 fn user_revoke_key_parses_a_bare_fingerprint_and_defaults_its_address() {
5857 let cli = Cli::try_parse_from([
5858 "gunnar",
5859 "control",
5860 "user",
5861 "revoke-key",
5862 "SHA256:Bw3OsBmcabcdefghijklmnopqrstuvwxyz0123456789",
5863 ])
5864 .expect("the online revoke parses");
5865 match cli.command {
5866 Command::Control(ControlCommand::User(ControlUserCommand::RevokeKey {
5867 fingerprint,
5868 force,
5869 at,
5870 identity,
5871 })) => {
5872 assert_eq!(
5873 fingerprint,
5874 "SHA256:Bw3OsBmcabcdefghijklmnopqrstuvwxyz0123456789"
5875 );
5876 assert!(
5877 !force,
5878 "the lockout guard is on unless --force says otherwise"
5879 );
5880 assert_eq!(at, crate::DEFAULT_CONTROL_ADDR);
5881 assert!(identity.is_none());
5882 }
5883 other => panic!("parsed as something else: {other:?}"),
5884 }
5885 }
5886
5887 /// And `--force` is spelled, because the guard it turns off is the only
5888 /// thing standing between a slip and a box that must be re-imaged.
5889 #[cfg(feature = "control")]
5890 #[test]
5891 fn user_revoke_key_takes_force_to_get_past_the_last_admin_guard() {
5892 let cli = Cli::try_parse_from([
5893 "gunnar",
5894 "control",
5895 "user",
5896 "revoke-key",
5897 "SHA256:zzz",
5898 "--force",
5899 "-i",
5900 "/root/.ssh/id_ed25519",
5901 "--at",
5902 "10.13.8.101:50051",
5903 ])
5904 .expect("the verb parses with --force");
5905 match cli.command {
5906 Command::Control(ControlCommand::User(ControlUserCommand::RevokeKey {
5907 force,
5908 at,
5909 identity,
5910 ..
5911 })) => {
5912 assert!(force);
5913 assert_eq!(at, "10.13.8.101:50051");
5914 assert_eq!(
5915 identity.as_deref(),
5916 Some(std::path::Path::new("/root/.ssh/id_ed25519"))
5917 );
5918 }
5919 other => panic!("parsed as something else: {other:?}"),
5920 }
5921 }
5922}
5923
5924/// `gunnar control account …`: the gate and the hard purge.
5925///
5926/// Gated on `control` like every other `gunnar control` enum, and it has to be:
5927/// each variant's `--at` carries [`CONTROL_ADDR_HELP`] as its `long_help`, and
5928/// that const is behind the same feature. A build without `control` has no
5929/// control plane to send these to, so an ungated enum would be four subcommands
5930/// that cannot exist referring to a string that does not.
5931#[cfg(feature = "control")]
5932#[derive(Debug, Subcommand)]
5933pub enum ControlAccountCommand {
5934 /// **Lock a user out. Completely.**
5935 ///
5936 /// Not read-only. Every level, both doors: the SSH listener and the control
5937 /// plane ask the same table, so a gated user can neither push nor fetch nor
5938 /// read a repository they own — and nobody else can reach into the account
5939 /// either, including a stranger cloning a public repository out of it.
5940 ///
5941 /// **Reads are not safe to allow**, which is why there is no `--read-only`.
5942 /// A fetch opens the store and lazily explodes objects into the read cache,
5943 /// the first read of a pack with no index builds one and writes it, and a
5944 /// log walk caches: a "harmless" reader puts bytes back into an account
5945 /// something else is emptying.
5946 ///
5947 /// Standalone and idempotent. It is not part of `purge` — a lapsed account
5948 /// is locked out without deleting anything, and an operator freezes an
5949 /// account under investigation the same way.
5950 Gate {
5951 /// The account: a user's name, which is also their namespace.
5952 account: String,
5953 /// **Why.** Required: it is what the locked-out user is shown in every
5954 /// refusal from that moment on, and a lockout with no reason is a
5955 /// support ticket.
5956 #[arg(long, value_name = "TEXT")]
5957 note: String,
5958 #[arg(long, short = 'i', value_name = "PATH")]
5959 identity: Option<PathBuf>,
5960 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
5961 at: String,
5962 },
5963
5964 /// **Let them back in.**
5965 ///
5966 /// Unconditional: it does not care who took the lockout, so a lease left
5967 /// behind by a purge that died can always be cleared by hand. That is the
5968 /// escape hatch, and it is why an account can never be permanently bricked.
5969 ///
5970 /// Ungating an account that is not locked is a success and not an error.
5971 Ungate {
5972 /// The account.
5973 account: String,
5974 #[arg(long, short = 'i', value_name = "PATH")]
5975 identity: Option<PathBuf>,
5976 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
5977 at: String,
5978 },
5979
5980 /// **Who is locked out**, and by what.
5981 ///
5982 /// Purge leases that have expired are not listed: a lease that ran out is
5983 /// not a lockout in force, and showing one would say an account is shut
5984 /// when it is open.
5985 Gates {
5986 #[arg(long, short = 'i', value_name = "PATH")]
5987 identity: Option<PathBuf>,
5988 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
5989 at: String,
5990 },
5991
5992 /// **Empty an account: everything the user owns, gone. NO undo.**
5993 ///
5994 /// Every repository, every soft-delete tombstone still holding bytes, every
5995 /// orphan store with no catalog row, the namespace rows, and — with
5996 /// `--remove-principal` — the user's keys, grants, tokens and group
5997 /// memberships. Nested namespaces under the account's name go with it: they
5998 /// live in the same directory subtree, and leaving them would leave bytes
5999 /// on the disk and in the meters after you were told the account was
6000 /// emptied.
6001 ///
6002 /// **The user is locked out before this command returns**, and let back in
6003 /// when it is over — including when it FAILS. The walk itself is queued
6004 /// (one account at a time for the whole box) and this waits for it, unless
6005 /// `--no-wait`.
6006 ///
6007 /// `repo delete` without `--purge` leaves a dated tombstone precisely so a
6008 /// fat finger is recoverable. This is the verb that says the account is
6009 /// finished: it reclaims tombstones rather than making them, so `--yes` is
6010 /// required.
6011 Purge {
6012 /// The account.
6013 account: String,
6014 /// **Why.** Required, like `gate --note`.
6015 #[arg(long, value_name = "TEXT")]
6016 reason: String,
6017 /// Also remove the user: keys, grants, tokens, memberships, and the
6018 /// account's own lockout row.
6019 ///
6020 /// Without it the account shell survives, still locked, and `ungate`
6021 /// hands the user an empty account back. With it the name is free for
6022 /// somebody else.
6023 #[arg(long)]
6024 remove_principal: bool,
6025 /// **Refuse rather than destroy** if the account still holds a live
6026 /// repository. Names what is in the way and touches nothing.
6027 ///
6028 /// For a caller that believes it has already emptied the account by
6029 /// other means and wants the server to check rather than to act.
6030 #[arg(long)]
6031 expect_empty: bool,
6032 /// Say it. There is no retention window and no undelete after this.
6033 #[arg(long)]
6034 yes: bool,
6035 /// Enqueue and return, printing the job instead of waiting for it.
6036 #[arg(long)]
6037 no_wait: bool,
6038 #[arg(long, short = 'i', value_name = "PATH")]
6039 identity: Option<PathBuf>,
6040 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
6041 at: String,
6042 },
6043
6044 /// **Where an account's purge got to, and whether the lockout is off.**
6045 ///
6046 /// Both, because neither answers the question on its own: a finished purge
6047 /// beside a lockout still in force means the account is empty and still
6048 /// shut, which is either a suspension the purge correctly refused to lift
6049 /// or a lease nothing released — and those need different actions.
6050 PurgeStatus {
6051 /// The account.
6052 account: String,
6053 #[arg(long, short = 'i', value_name = "PATH")]
6054 identity: Option<PathBuf>,
6055 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
6056 at: String,
6057 },
6058 /// **Claim the estate's operator, and record the break-glass token as his
6059 /// first invite** — the installer's word, for a box no installer fed (a
6060 /// standalone gunnar, a test rig). Server admin only.
6061 ///
6062 /// gunnar names the operator (an installer-fed `operator_name` wins over
6063 /// NAME), makes him a person and gunnar-admin with his account namespace
6064 /// (an ownerless namespace of that name is adopted, never a refusal), and
6065 /// keeps the SHA-256 of `--bootstrap-file`'s contents as the invite for his
6066 /// FIRST passkey: the console registers it with `Access.WebRegister`, the
6067 /// token as the invite secret. The token is read here and hashed here; only
6068 /// its hash leaves this machine. Once he holds a passkey the bootstrap is
6069 /// spent and nothing is recorded.
6070 ClaimOperator {
6071 /// The operator's name — his account, and the namespace named after it.
6072 name: String,
6073 /// A file holding the break-glass token (one line). Absent: the
6074 /// operator is claimed and no invite is recorded.
6075 #[arg(long, value_name = "PATH")]
6076 bootstrap_file: Option<PathBuf>,
6077 #[arg(long, short = 'i', value_name = "PATH")]
6078 identity: Option<PathBuf>,
6079 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
6080 at: String,
6081 },
6082 /// **Enroll an employee: a NEW user, created by and bound to the
6083 /// account.** An account with employees is an enterprise account; that is
6084 /// not a tier and not a setting. An existing user can never become an employee. The
6085 /// employee reads the account; only the account admin makes anything
6086 /// public. Account admin only.
6087 ///
6088 /// **"Add users"** is this verb, as the console's dialog asks it: each user
6089 /// holds `--room-gib` of the account's room (default 10, raised never
6090 /// lowered), out of what the account holds and has neither stored nor
6091 /// promised; `--count` above 1 adds that many SEATS with no names, and
6092 /// `--from-seat` fills one with the named user (what an invite does).
6093 #[command(visible_alias = "add-users")]
6094 Enroll {
6095 /// The account (its namespace).
6096 account: String,
6097 /// The new user's id. Absent for seats (`--count` above 1).
6098 id: Option<String>,
6099 /// Display name; the id when absent.
6100 #[arg(long)]
6101 name: Option<String>,
6102 /// How many users: 1 names one (the id), more adds that many seats
6103 /// with no names. At most 10000.
6104 #[arg(long, default_value_t = 1)]
6105 count: u32,
6106 /// Room per user, GiB, out of the account's room. Default 10; less is
6107 /// refused.
6108 #[arg(long, value_name = "GIB")]
6109 room_gib: Option<u64>,
6110 /// Fill one of the account's open seats with this new user; the seat
6111 /// decides the room.
6112 #[arg(long)]
6113 from_seat: bool,
6114 /// Ask whether it fits and how much room is missing; change nothing.
6115 #[arg(long)]
6116 dry_run: bool,
6117 /// Also make the new named user's invite (as the console does) and
6118 /// print it once: its id and the secret a `/login?invite=` link carries.
6119 #[arg(long)]
6120 invite: bool,
6121 /// An OpenSSH public key file to register on the new user at once.
6122 #[arg(long, value_name = "PATH")]
6123 key: Option<PathBuf>,
6124 #[arg(long, short = 'i', value_name = "PATH")]
6125 identity: Option<PathBuf>,
6126 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
6127 at: String,
6128 },
6129 /// The account's employees, and its seats (all, open). Account admin only.
6130 Employees {
6131 /// The account (its namespace).
6132 account: String,
6133 #[arg(long, short = 'i', value_name = "PATH")]
6134 identity: Option<PathBuf>,
6135 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
6136 at: String,
6137 },
6138}
6139
6140/// `gunnar control clan …` — clans (2026-10-05): every verb is the caller's
6141/// own, signed with its key; gunnar decides from the clan. Sizes are typed as
6142/// `10GiB`, `512MiB` or plain bytes.
6143#[cfg(feature = "control")]
6144#[derive(Debug, Subcommand)]
6145pub enum ControlClanCommand {
6146 /// Make a clan: NAME becomes its namespace (private); you are its admin.
6147 Create {
6148 name: String,
6149 /// What you give it out of your own room (e.g. `5GiB`).
6150 #[arg(long, default_value = "0")]
6151 give: String,
6152 #[arg(long, short = 'i', value_name = "PATH")]
6153 identity: Option<PathBuf>,
6154 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
6155 at: String,
6156 },
6157 /// Your clans and invitations; with CLAN, that one in full.
6158 Show {
6159 clan: Option<String>,
6160 #[arg(long, short = 'i', value_name = "PATH")]
6161 identity: Option<PathBuf>,
6162 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
6163 at: String,
6164 },
6165 /// Invite an ordinary account into CLAN (its admin's).
6166 Invite {
6167 clan: String,
6168 account: String,
6169 #[arg(long, short = 'i', value_name = "PATH")]
6170 identity: Option<PathBuf>,
6171 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
6172 at: String,
6173 },
6174 /// Join CLAN, which invited you, giving `--give` of your own room.
6175 Accept {
6176 clan: String,
6177 #[arg(long, default_value = "0")]
6178 give: String,
6179 #[arg(long, short = 'i', value_name = "PATH")]
6180 identity: Option<PathBuf>,
6181 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
6182 at: String,
6183 },
6184 /// Decline your invitation to CLAN — or, as its admin, withdraw
6185 /// `--account`'s.
6186 Decline {
6187 clan: String,
6188 #[arg(long)]
6189 account: Option<String>,
6190 #[arg(long, short = 'i', value_name = "PATH")]
6191 identity: Option<PathBuf>,
6192 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
6193 at: String,
6194 },
6195 /// Set what you give CLAN (up out of your own room; down no lower than
6196 /// what the clan stores).
6197 Give {
6198 clan: String,
6199 size: String,
6200 #[arg(long, short = 'i', value_name = "PATH")]
6201 identity: Option<PathBuf>,
6202 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
6203 at: String,
6204 },
6205 /// Leave CLAN; what you gave comes back (refused while it is in use, and
6206 /// to its last admin).
6207 Leave {
6208 clan: String,
6209 #[arg(long, short = 'i', value_name = "PATH")]
6210 identity: Option<PathBuf>,
6211 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
6212 at: String,
6213 },
6214 /// Remove a member from CLAN (its admin's).
6215 Remove {
6216 clan: String,
6217 account: String,
6218 #[arg(long, short = 'i', value_name = "PATH")]
6219 identity: Option<PathBuf>,
6220 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
6221 at: String,
6222 },
6223 /// Make a member of CLAN admin (`--no` to make it a plain member).
6224 Admin {
6225 clan: String,
6226 account: String,
6227 #[arg(long)]
6228 no: bool,
6229 #[arg(long, short = 'i', value_name = "PATH")]
6230 identity: Option<PathBuf>,
6231 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
6232 at: String,
6233 },
6234 /// Dissolve an EMPTY clan (its admin's): every contribution goes back.
6235 Dissolve {
6236 clan: String,
6237 #[arg(long, short = 'i', value_name = "PATH")]
6238 identity: Option<PathBuf>,
6239 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
6240 at: String,
6241 },
6242 /// Offer CLAN/REPO to ACCOUNT, to move it out of the clan (its admin's);
6243 /// the account's admin takes or declines it.
6244 Offer {
6245 clan: String,
6246 repo: String,
6247 account: String,
6248 #[arg(long, short = 'i', value_name = "PATH")]
6249 identity: Option<PathBuf>,
6250 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
6251 at: String,
6252 },
6253 /// Take CLAN/REPO, offered to your account: it moves to <account>/<repo>.
6254 /// `--no` declines it — or, as the clan's admin, withdraws the offer.
6255 Take {
6256 clan: String,
6257 repo: String,
6258 #[arg(long)]
6259 no: bool,
6260 #[arg(long, short = 'i', value_name = "PATH")]
6261 identity: Option<PathBuf>,
6262 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
6263 at: String,
6264 },
6265}
6266
6267/// `gunnar control mirror …` — **the pull mirror, told to the APPLIANCE.**
6268///
6269/// # Why there are two `mirror` verbs and this is the one with `control` in it
6270///
6271/// `gunnar mirror add/sync/status` (no `control`) already exists and stays. It
6272/// mirrors into a LOCAL data root on the machine you typed it on, over gix
6273/// porcelain, and it is a git client's verb: my laptop keeps a copy. These are
6274/// the opposite job — *tell the server over there to keep the copy* — and the
6275/// `control` prefix is that line, the same line every other `gunnar control`
6276/// verb draws. Confusing the two is not a cosmetic mistake: one of them writes
6277/// to a disk you can `ls`, and the other writes to a box that has no shell.
6278///
6279/// # The one rule that shapes every verb below: `Sync` ENQUEUES
6280///
6281/// A fetch is minutes of somebody else's network, so `Mirror.Sync` puts a job on
6282/// the server's single runner and answers immediately — with `refs_changed = 0`
6283/// and no updates, **deliberately**, because at enqueue nothing has happened
6284/// yet. Reading that as "0 refs changed" is reading "I have not started" as "I
6285/// found nothing", and those are the same sentence about two different worlds.
6286/// So the answer to *what did the mirror do* is always `status`, never the echo
6287/// `sync` returns, and `sync --wait` is `status` in a loop with a human at the
6288/// other end.
6289///
6290/// # `has_progress` is read BEFORE the pair, everywhere
6291///
6292/// `(progress_done, progress_total) = (0, 0)` with `has_progress = false` means
6293/// the server has no denominator yet — the upstream has not said how many
6294/// objects there are. It is not zero percent, and a percentage bar drawn from it
6295/// sits at 0% for a minute and then jumps, which reads as a hang. Every renderer
6296/// here prints `counting…` for that case and `n/N` only when the server says the
6297/// pair is real.
6298/// `gunnar control entitlement …`. See [`ControlCommand::Entitlement`].
6299#[cfg(all(feature = "control", feature = "metered"))]
6300#[derive(Debug, Subcommand)]
6301pub enum ControlEntitlementCommand {
6302 /// **Hand the box ONE fact monetize signed.** Admin at server scope.
6303 ///
6304 /// The file is monetize's own `EntitlementFact` as JSON — the value
6305 /// `monetize_embed::signing::fact_message` canonicalises and signs, with its
6306 /// signatures — so what is pushed is byte for byte what monetize minted. This
6307 /// verb SIGNS NOTHING and holds no monetize key: the box verifies the fact
6308 /// against its own `--monetize-pubkey`, and a fact that does not verify is
6309 /// refused by name and nothing moves.
6310 ///
6311 /// **Operator-only** (monetize v2.0 P2 T13, A28 L13): monetize no longer
6312 /// calls this door. The ONE box pulls every Gunnar estate, so a paid plan
6313 /// reaches the box as a signed order its main pulls. This verb is the
6314 /// operator's hand tool for one fact, under the server's admin key.
6315 Set {
6316 /// Path to the signed fact (JSON), or `-` for stdin.
6317 #[arg(value_name = "FACT.json")]
6318 fact: PathBuf,
6319 #[arg(long, short = 'i', value_name = "PATH")]
6320 identity: Option<PathBuf>,
6321 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
6322 at: String,
6323 },
6324 /// The fact this box ENFORCES for one namespace, or `free` when it enforces
6325 /// none. Read on the namespace (a tenant may ask about their own).
6326 Get {
6327 #[arg(value_name = "NAMESPACE")]
6328 namespace: String,
6329 #[arg(long, short = 'i', value_name = "PATH")]
6330 identity: Option<PathBuf>,
6331 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
6332 at: String,
6333 },
6334 /// Every fact this box ENFORCES, one line per namespace. A namespace absent
6335 /// here is Free — including one whose stored fact was refused at boot.
6336 List {
6337 #[arg(long, short = 'i', value_name = "PATH")]
6338 identity: Option<PathBuf>,
6339 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
6340 at: String,
6341 },
6342}
6343
6344#[cfg(feature = "control")]
6345#[derive(Debug, Subcommand)]
6346pub enum ControlMirrorCommand {
6347 /// **Point a repository at a remote.** A document write; it fetches nothing.
6348 ///
6349 /// `add` then `sync` is deliberate and not a missing convenience: registering
6350 /// where a repository's history comes from and pulling it are different
6351 /// decisions with different failure modes, and an `add` that fetched would
6352 /// make a typo in a URL into a minutes-long timeout instead of a stored
6353 /// string you can look at.
6354 ///
6355 /// Needs **Admin** on the repository — or on the namespace with `--create`,
6356 /// because there is nothing narrower than a namespace to hold the right to
6357 /// make a repository that does not exist yet.
6358 Add {
6359 /// `<namespace>/<repo>` on THIS server: where the mirror lands.
6360 #[arg(value_name = "NS/REPO")]
6361 path: String,
6362 /// The upstream. `ssh://git@host/ns/repo.git`, the scp-like
6363 /// `git@host:ns/repo.git`, or `https://host/ns/repo.git`. The URL — and
6364 /// nothing else — decides which transport is dialled and therefore which
6365 /// kind of credential is legal; see `credential`.
6366 #[arg(value_name = "URL")]
6367 url: String,
6368 /// Delete local refs the upstream has deleted. Off by default: a mirror
6369 /// that prunes is a mirror that can lose a branch because somebody else
6370 /// tidied theirs.
6371 #[arg(long)]
6372 prune: bool,
6373 /// Create the repository if it is not there.
6374 ///
6375 /// The hash kind is **not** a flag here and must not become one: the
6376 /// server takes it from the remote — the wire negotiates it — because a mirror of a sha256
6377 /// repository into a sha1 one is not a mirror, and a caller who guessed
6378 /// would find that out after the first fetch.
6379 #[arg(long)]
6380 create: bool,
6381 /// With `--create`: take over a repository of that name that exists
6382 /// and is NOT a mirror — it becomes a mirror, and its next sync moves
6383 /// its refs to the upstream's. Without it gunnar refuses the take-over.
6384 #[arg(long, requires = "create")]
6385 adopt: bool,
6386 #[arg(long, short = 'i', value_name = "PATH")]
6387 identity: Option<PathBuf>,
6388 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
6389 at: String,
6390 },
6391
6392 /// **Fetch from the upstream now** — enqueued on the server's one runner.
6393 ///
6394 /// Returns as soon as the job is on the queue. `--wait` is the shape a human
6395 /// wants and `--wait`'s absence is the shape a script wants, which is why
6396 /// the default here is the opposite of `repo gc`'s: a gc is seconds and a
6397 /// mirror of a real repository is minutes to hours, so waiting is the thing
6398 /// you ask for rather than the thing you opt out of.
6399 ///
6400 /// Asking twice while a job is live is ONE job, not two. Needs **Write**.
6401 Sync {
6402 /// `<namespace>/<repo>`.
6403 #[arg(value_name = "NS/REPO")]
6404 path: String,
6405 /// Poll until the job is DONE or FAILED, printing each state as it
6406 /// moves, and exit non-zero if it failed.
6407 #[arg(long)]
6408 wait: bool,
6409 /// Give up waiting after this many seconds and say where to look. The
6410 /// job is NOT cancelled — it is the server's, and it keeps running.
6411 ///
6412 /// A bound rather than "forever" because `--wait` is what a person leaves
6413 /// running in a terminal, and a wait with no end is indistinguishable
6414 /// from a hang at exactly the moment you most want to know which it is.
6415 #[arg(long, default_value_t = 3600, value_name = "SECS")]
6416 timeout: u64,
6417 #[arg(long, short = 'i', value_name = "PATH")]
6418 identity: Option<PathBuf>,
6419 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
6420 at: String,
6421 },
6422
6423 /// **What is registered, what it dials with, and where its job got to.**
6424 ///
6425 /// The applied output, as against the request echo `add` and `sync` return.
6426 /// The ref updates are printed only on a DONE job, because a mirror's whole
6427 /// ref update set lands in one compare-and-swap or none of it does — a list of
6428 /// updates from a running job is a list of refs that have not moved.
6429 ///
6430 /// The credential's KIND and username are shown. The value is not, and there
6431 /// is no flag that shows it: no RPC in this service returns it.
6432 Status {
6433 /// One repository, `<namespace>/<repo>`. Omit for every mirror you can
6434 /// read.
6435 #[arg(value_name = "NS/REPO")]
6436 path: Option<String>,
6437 /// Only this namespace's mirrors. Ignored when a path is given.
6438 #[arg(long, value_name = "NAME")]
6439 namespace: Option<String>,
6440 #[arg(long, short = 'i', value_name = "PATH")]
6441 identity: Option<PathBuf>,
6442 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
6443 at: String,
6444 },
6445
6446 /// **Stop mirroring.** The repository and everything already fetched stay;
6447 /// only the upstream link and its credential go.
6448 Remove {
6449 /// `<namespace>/<repo>`.
6450 #[arg(value_name = "NS/REPO")]
6451 path: String,
6452 #[arg(long, short = 'i', value_name = "PATH")]
6453 identity: Option<PathBuf>,
6454 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
6455 at: String,
6456 },
6457
6458 /// **Store what this mirror authenticates to its upstream with.**
6459 ///
6460 /// # The secret is read from a FILE or from stdin, never from an argument
6461 ///
6462 /// There is no `--secret <VALUE>` and there will not be one. An argument is
6463 /// in `~/.bash_history`, in `ps aux` for every user on the box while the
6464 /// command runs, and in the shell's job table — three copies of a long-lived
6465 /// bearer credential made by a convenience nobody needed. `--ssh-key` and
6466 /// `--token-file` name a path (a leading `@` is accepted, and `-` means
6467 /// stdin), which is where a key already lives.
6468 ///
6469 /// # The kind must match the transport, and the server refuses by name
6470 ///
6471 /// An `ssh://` remote takes `--ssh-key`; an `https://` remote takes
6472 /// `--token-file`. The mismatch is refused BEFORE anything is dialled,
6473 /// because it is otherwise the quietest failure in the whole feature: an ssh
6474 /// key has nowhere to go in an HTTP request, so the fetch goes out
6475 /// anonymous, the upstream answers 401, and an operator who can see that a
6476 /// credential IS stored rotates the key. For ever.
6477 ///
6478 /// A public `https://` upstream needs no credential at all, and `--forget`
6479 /// is how a stored one is taken back.
6480 Credential {
6481 /// `<namespace>/<repo>`.
6482 #[arg(value_name = "NS/REPO")]
6483 path: String,
6484 /// An OpenSSH **private** key file, for an `ssh://` upstream. `@<path>`
6485 /// and `-` (stdin) are accepted.
6486 #[arg(long, value_name = "PATH", conflicts_with_all = ["token_file", "forget"])]
6487 ssh_key: Option<String>,
6488 /// A file holding a personal access token, for an `https://` upstream.
6489 /// `@<path>` and `-` (stdin) are accepted. Trailing newlines are
6490 /// stripped — a token file written by a human has one, and a token sent
6491 /// with it is a token the forge rejects.
6492 #[arg(long, value_name = "PATH", conflicts_with = "forget")]
6493 token_file: Option<String>,
6494 /// The username the token is offered with (HTTP Basic). GitHub, Codeberg
6495 /// and GitLab personal access tokens all want one; with none the token
6496 /// is sent as `Authorization: Bearer`.
6497 #[arg(long, value_name = "NAME")]
6498 username: Option<String>,
6499 /// Forget the stored credential. The mirror stays; it dials anonymously
6500 /// from now on, which is right for a public upstream and a 401 for any
6501 /// other.
6502 #[arg(long)]
6503 forget: bool,
6504 #[arg(long, short = 'i', value_name = "PATH")]
6505 identity: Option<PathBuf>,
6506 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
6507 at: String,
6508 },
6509
6510 /// **Take a whole forge account off GitHub, Codeberg, GitLab or a self-hosted
6511 /// Forgejo — list it, choose, mirror it.** The verb that makes gunnar
6512 /// adoptable without a migration project.
6513 ///
6514 /// # Why this needs a REST call at all, in a git server
6515 ///
6516 /// git has no verb for "enumerate an account". The protocol starts from a
6517 /// URL you already know; `git ls-remote github.com` is not a shorter version
6518 /// of this, it is a question with no answer. Every forge exposes the list
6519 /// over its own HTTPS API and nowhere else, which is why `gunnar-forge`
6520 /// exists and why it is linked by **this** binary and never by the
6521 /// appliance: it holds a personal access token for the length of one call,
6522 /// and a box whose whole design is that it holds only repositories has no
6523 /// business with a bearer credential for somebody's entire GitHub.
6524 ///
6525 /// # The order, per chosen repository
6526 ///
6527 /// `Mirror.Add` with `create_repo` (the hash kind comes from the remote
6528 /// — it is not a flag), then `Mirror.SetCredential` if `--https`, then
6529 /// `Mirror.Sync`. A repository that fails does not stop the ones after it;
6530 /// the summary at the end says how many were queued and names every failure
6531 /// with its reason. A forge **401 or 403**, though, stops before anything is
6532 /// queued at all: half an import is worse than none, because it leaves an
6533 /// operator guessing which half.
6534 ///
6535 /// Re-running it is safe. `Add` on a mirror that is already there is a
6536 /// no-op and a second `Sync` while one is live is the same job, so `import
6537 /// --all` after adding one repository upstream mirrors the new one and
6538 /// bores the other twenty.
6539 Import {
6540 /// Which forge, and where. `github`, `codeberg`, `gitlab`, or any of
6541 /// them with `=<base-url>` for a self-hosted instance:
6542 /// `forgejo=https://git.example.com`, `github=https://ghe.example.com/api/v3`,
6543 /// `gitlab=https://gitlab.example.com`.
6544 #[arg(long, value_name = "FORGE[=URL]")]
6545 from: String,
6546 /// A file holding the forge token. `-` means stdin, which
6547 /// `--pick` also wants — so use a file when you use `--pick`.
6548 ///
6549 /// Without it the environment is read, in this order:
6550 /// `GUNNAR_FORGE_TOKEN`, then the forge's own conventional variable
6551 /// (`GITHUB_TOKEN`, `CODEBERG_TOKEN`, `GITLAB_TOKEN`, `FORGEJO_TOKEN`).
6552 /// There is deliberately no `--token <VALUE>`; see `credential`.
6553 #[arg(long, value_name = "PATH")]
6554 token_file: Option<String>,
6555 /// Mirror every listed repository.
6556 #[arg(long, conflicts_with = "pick")]
6557 all: bool,
6558 /// Read the choice from **stdin**: the `#` numbers as printed, or names
6559 /// (`octocat/hello-world` or just `hello-world`), separated by
6560 /// whitespace or commas. A `#` starts a comment to end of line.
6561 ///
6562 /// stdin and not a terminal picker, so the choice can be piped, kept in
6563 /// a file, and re-run. A chooser that only works at a tty is one a
6564 /// script cannot repeat.
6565 #[arg(long)]
6566 pick: bool,
6567 /// The gunnar namespace the mirrors land in — `<into>/<repo-name>`.
6568 ///
6569 /// Defaults to your login name (`$USER`), because that is your account.
6570 /// It is NOT asked of the server: the control plane authenticates a KEY
6571 /// and has no verb that names an account back, so guessing from the
6572 /// fingerprint would be a guess. Say `--into` when the two differ.
6573 #[arg(long, value_name = "NAMESPACE")]
6574 into: Option<String>,
6575 /// Mirror over `ssh://` (the forge's ssh clone URL) and store NO
6576 /// credential: a deploy key has to be enrolled on the upstream side,
6577 /// which is the upstream forge's business and not gunnar's. The command
6578 /// says where to put the private half afterwards.
6579 #[arg(long, conflicts_with = "https")]
6580 ssh: bool,
6581 /// Mirror over `https://` and store the SAME token the listing used —
6582 /// the forge that enumerated these repositories is the forge that will
6583 /// serve the clone. The default.
6584 #[arg(long)]
6585 https: bool,
6586 /// Include archived repositories, which are excluded by default.
6587 ///
6588 /// Excluded rather than shown-and-skipped because an account with ten
6589 /// years of archived experiments is mostly archived experiments, and
6590 /// `--all` should not mean "and 200 things you last touched in 2016".
6591 /// Mirroring one on purpose is exactly why the flag exists.
6592 #[arg(long)]
6593 include_archived: bool,
6594 /// Take over repositories of the same name that exist and are NOT
6595 /// mirrors. Without it gunnar refuses each such import by name and the
6596 /// rest go ahead.
6597 #[arg(long)]
6598 adopt: bool,
6599 #[arg(long, short = 'i', value_name = "PATH")]
6600 identity: Option<PathBuf>,
6601 #[arg(long, default_value = crate::DEFAULT_CONTROL_ADDR, value_name = "ADDR", env = "GUNNAR_CONTROL_ADDR", long_help = CONTROL_ADDR_HELP)]
6602 at: String,
6603 },
6604}