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