Skip to main content

fez/capability/
mod.rs

1//! Machine-readable descriptions of every capability fez exposes, used to
2//! advertise the command surface (ids, inputs, flags, examples) to agents.
3use serde::Serialize;
4
5pub mod help;
6
7/// A single named input a capability accepts.
8#[derive(Serialize, Clone)]
9pub struct Input {
10    /// Input name as used on the command line.
11    pub name: String,
12    /// Input value type (currently always `"string"`).
13    #[serde(rename = "type")]
14    pub ty: String,
15    /// Whether the input must be supplied.
16    pub required: bool,
17    /// Default value used when the input is omitted, if any.
18    #[serde(skip_serializing_if = "Option::is_none")]
19    pub default: Option<String>,
20}
21
22/// A complete description of one capability.
23#[derive(Serialize, Clone)]
24pub struct Descriptor {
25    /// Dotted capability id (e.g. `services.start`).
26    pub id: String,
27    /// One-line human summary (maps to clap `about`).
28    pub summary: String,
29    /// Full description (maps to clap `long_about`).
30    pub long: String,
31    /// Whether invoking the capability requires elevated privileges.
32    pub privileged: bool,
33    /// The envelope `kind` this capability emits.
34    pub output_kind: String,
35    /// Inputs the capability accepts.
36    pub inputs: Vec<Input>,
37    /// Flags the capability honors.
38    pub flags: Vec<String>,
39    /// Example invocations (maps to clap `after_help`).
40    pub examples: Vec<String>,
41}
42
43fn input(name: &str, required: bool) -> Input {
44    Input {
45        name: name.into(),
46        ty: "string".into(),
47        required,
48        default: None,
49    }
50}
51
52fn mutation(
53    id: &str,
54    summary: &str,
55    long: &str,
56    output_kind: &str,
57    extra_flags: &[&str],
58) -> Descriptor {
59    let mut flags = vec![
60        "--host".to_string(),
61        "--json".to_string(),
62        "--dry-run".to_string(),
63        "--force".to_string(),
64    ];
65    flags.extend(extra_flags.iter().map(|f| f.to_string()));
66    Descriptor {
67        id: id.into(),
68        summary: summary.into(),
69        long: long.into(),
70        privileged: true,
71        output_kind: output_kind.into(),
72        inputs: vec![input("unit", true)],
73        flags,
74        examples: vec![format!("fez {} --json", id.replace('.', " "))],
75    }
76}
77
78fn enablement(id: &str, summary: &str, long: &str) -> Descriptor {
79    let verb = id.rsplit('.').next().expect("capability id has a verb");
80    Descriptor {
81        id: id.into(),
82        summary: summary.into(),
83        long: long.into(),
84        privileged: true,
85        output_kind: "ServiceEnablement".into(),
86        inputs: vec![input("unit", true)],
87        flags: vec![
88            "--host".into(),
89            "--json".into(),
90            "--dry-run".into(),
91            "--force".into(),
92            "--now".into(),
93        ],
94        examples: vec![
95            format!("fez services {verb} chronyd.service --json"),
96            format!("fez services {verb} chronyd.service --now"),
97        ],
98    }
99}
100
101/// The full set of capability descriptors fez supports.
102pub fn registry() -> Vec<Descriptor> {
103    vec![
104        Descriptor {
105            id: "services.list".into(),
106            summary: "List systemd units".into(),
107            long: "List systemd units on the target host. Use --state to filter by \
108active state (e.g. active, failed, inactive). Read-only; never mutates."
109                .into(),
110            privileged: false,
111            output_kind: "ServiceList".into(),
112            inputs: vec![input("state", false)],
113            flags: vec!["--host".into(), "--json".into(), "--state".into()],
114            examples: vec![
115                "fez services list --state failed --json".into(),
116                "fez --host web01 services list".into(),
117            ],
118        },
119        Descriptor {
120            id: "services.status".into(),
121            summary: "Show one unit's status".into(),
122            long: "Show the current status of a single systemd unit (active state, \
123sub-state, enablement). Read-only."
124                .into(),
125            privileged: false,
126            output_kind: "ServiceStatus".into(),
127            inputs: vec![input("unit", true)],
128            flags: vec!["--host".into(), "--json".into()],
129            examples: vec!["fez services status sshd.service --json".into()],
130        },
131        Descriptor {
132            id: "services.logs".into(),
133            summary: "Read a unit's journal".into(),
134            long: "Read journal entries for a unit. Filter with --since and --priority \
135(journalctl syntax), cap with --lines, or stream with --follow. Read-only."
136                .into(),
137            privileged: false,
138            output_kind: "LogEntries".into(),
139            inputs: vec![input("unit", true)],
140            flags: vec![
141                "--host".into(),
142                "--json".into(),
143                "--since".into(),
144                "--priority".into(),
145                "--lines".into(),
146                "--follow".into(),
147            ],
148            examples: vec![
149                "fez services logs sshd.service --lines 100 --json".into(),
150                "fez services logs nginx.service --since '1 hour ago' --priority err".into(),
151            ],
152        },
153        mutation(
154            "services.start",
155            "Start a unit",
156            "Start a systemd unit immediately. Privileged. Protected units are \
157refused unless --force is supplied. Exits 8 on a protected-unit refusal.",
158            "ServiceMutation",
159            &[],
160        ),
161        mutation(
162            "services.stop",
163            "Stop a unit",
164            "Stop a running systemd unit. Privileged. Protected units are refused \
165unless --force is supplied (exit 8).",
166            "ServiceMutation",
167            &[],
168        ),
169        mutation(
170            "services.restart",
171            "Restart a unit",
172            "Restart a systemd unit. Privileged. Protected units are refused unless \
173--force is supplied (exit 8).",
174            "ServiceMutation",
175            &[],
176        ),
177        mutation(
178            "services.reload",
179            "Reload a unit's configuration",
180            "Ask a unit to reload its configuration without a full restart. \
181Privileged. Protected units are refused unless --force is supplied (exit 8).",
182            "ServiceMutation",
183            &[],
184        ),
185        enablement(
186            "services.enable",
187            "Enable a unit",
188            "Enable a unit so it starts at boot. Add --now to also start it \
189immediately. Privileged. Protected units are refused unless --force is supplied (exit 8).",
190        ),
191        enablement(
192            "services.disable",
193            "Disable a unit",
194            "Disable a unit so it no longer starts at boot. Add --now to also \
195stop it immediately. Privileged. Protected units are refused unless --force is supplied (exit 8).",
196        ),
197        Descriptor {
198            id: "packages.list".into(),
199            summary: "List packages".into(),
200            long: "List installed (default) or available packages. Use --available to list \
201available packages, --repo to restrict by repository. Read-only."
202                .into(),
203            privileged: false,
204            output_kind: "PackageList".into(),
205            inputs: vec![],
206            flags: vec![
207                "--host".into(),
208                "--json".into(),
209                "--installed".into(),
210                "--available".into(),
211                "--repo".into(),
212            ],
213            examples: vec![
214                "fez packages list --json".into(),
215                "fez packages list --available --repo fedora".into(),
216            ],
217        },
218        Descriptor {
219            id: "packages.info".into(),
220            summary: "Show one package's attributes".into(),
221            long: "Show the full attributes of a single package (version, arch, repo, size, \
222summary). Read-only."
223                .into(),
224            privileged: false,
225            output_kind: "PackageInfo".into(),
226            inputs: vec![input("spec", true)],
227            flags: vec!["--host".into(), "--json".into()],
228            examples: vec!["fez packages info bash --json".into()],
229        },
230        Descriptor {
231            id: "packages.search".into(),
232            summary: "Search packages".into(),
233            long: "Search available packages by name, summary, or provides. Read-only.".into(),
234            privileged: false,
235            output_kind: "PackageSearch".into(),
236            inputs: vec![input("pattern", true)],
237            flags: vec!["--host".into(), "--json".into()],
238            examples: vec!["fez packages search nginx --json".into()],
239        },
240        Descriptor {
241            id: "packages.check-update".into(),
242            summary: "List available upgrades".into(),
243            long: "List packages with available upgrades. Read-only.".into(),
244            privileged: false,
245            output_kind: "PackageUpdates".into(),
246            inputs: vec![],
247            flags: vec!["--host".into(), "--json".into()],
248            examples: vec!["fez packages check-update --json".into()],
249        },
250        Descriptor {
251            id: "packages.repolist".into(),
252            summary: "List repositories".into(),
253            long: "List repositories and their enabled state. Use --enabled (default), \
254--disabled, or --all. Read-only."
255                .into(),
256            privileged: false,
257            output_kind: "RepoList".into(),
258            inputs: vec![],
259            flags: vec![
260                "--host".into(),
261                "--json".into(),
262                "--enabled".into(),
263                "--disabled".into(),
264                "--all".into(),
265            ],
266            examples: vec!["fez packages repolist --all --json".into()],
267        },
268        Descriptor {
269            id: "packages.install".into(),
270            summary: "Install packages".into(),
271            long: "Install one or more packages. Resolves the transaction first and surfaces \
272the plan; --dry-run stops after the plan. Privileged. Exits 9 if dnf5daemon is \
273missing, 10 if the resolved transaction is refused by removal guardrails (use \
274--force to override)."
275                .into(),
276            privileged: true,
277            output_kind: "PackageMutation".into(),
278            inputs: vec![input("specs", true)],
279            flags: vec![
280                "--host".into(),
281                "--json".into(),
282                "--dry-run".into(),
283                "--force".into(),
284            ],
285            examples: vec![
286                "fez packages install htop --json".into(),
287                "fez packages install nginx --dry-run".into(),
288            ],
289        },
290        Descriptor {
291            id: "packages.remove".into(),
292            summary: "Remove packages".into(),
293            long: "Remove one or more packages. Resolves first and applies removal guardrails: \
294a protected package or a cascade larger than the limit is refused unless --force \
295is supplied (exit 10). --dry-run surfaces the plan without removing. Privileged."
296                .into(),
297            privileged: true,
298            output_kind: "PackageMutation".into(),
299            inputs: vec![input("specs", true)],
300            flags: vec![
301                "--host".into(),
302                "--json".into(),
303                "--dry-run".into(),
304                "--force".into(),
305            ],
306            examples: vec![
307                "fez packages remove htop --json".into(),
308                "fez packages remove oldpkg --dry-run".into(),
309            ],
310        },
311        Descriptor {
312            id: "packages.upgrade".into(),
313            summary: "Upgrade packages".into(),
314            long: "Upgrade named packages, or all packages when no spec is given. Resolves \
315first and surfaces the plan; --dry-run stops after the plan. Privileged. Refusals \
316from removal guardrails (replaced/obsoleted packages) exit 10 unless --force is \
317supplied."
318                .into(),
319            privileged: true,
320            output_kind: "PackageMutation".into(),
321            inputs: vec![input("specs", false)],
322            flags: vec![
323                "--host".into(),
324                "--json".into(),
325                "--dry-run".into(),
326                "--force".into(),
327            ],
328            examples: vec![
329                "fez packages upgrade --json".into(),
330                "fez packages upgrade nginx --force".into(),
331            ],
332        },
333        Descriptor {
334            id: "network.list".into(),
335            summary: "List network devices".into(),
336            long: "List NetworkManager devices with their type, state, primary IPv4/IPv6 \
337address, and MAC. By default unmanaged virtual interfaces (container veth, etc.) are \
338hidden; use --all to show every device. Read-only."
339                .into(),
340            privileged: false,
341            output_kind: "NetworkDeviceList".into(),
342            inputs: vec![],
343            flags: vec!["--host".into(), "--json".into(), "--all".into()],
344            examples: vec![
345                "fez network list --json".into(),
346                "fez network list --all".into(),
347            ],
348        },
349        Descriptor {
350            id: "network.show".into(),
351            summary: "Show one device's network detail".into(),
352            long: "Show the full network detail for one device: addresses (IPv4 and IPv6), \
353gateway, DNS servers, search domains, routes, MAC, MTU, the active connection profile, \
354and DHCP lease. Read-only."
355                .into(),
356            privileged: false,
357            output_kind: "NetworkDeviceDetail".into(),
358            inputs: vec![input("device", true)],
359            flags: vec!["--host".into(), "--json".into()],
360            examples: vec!["fez network show enp1s0 --json".into()],
361        },
362        Descriptor {
363            id: "firewall.status".into(),
364            summary: "Show firewall status".into(),
365            long: "Show firewalld state, the default zone, the panic-mode flag, and any \
366uncommitted runtime-vs-permanent drift (pending_changes). Read-only."
367                .into(),
368            privileged: false,
369            output_kind: "FirewallStatus".into(),
370            inputs: vec![],
371            flags: vec!["--host".into(), "--json".into()],
372            examples: vec!["fez firewall status --json".into()],
373        },
374        Descriptor {
375            id: "firewall.list".into(),
376            summary: "List firewall zones".into(),
377            long: "List all firewalld zones with a per-zone summary (default flag, \
378services, ports, interfaces). Read-only."
379                .into(),
380            privileged: false,
381            output_kind: "FirewallZoneList".into(),
382            inputs: vec![],
383            flags: vec!["--host".into(), "--json".into()],
384            examples: vec!["fez firewall list --json".into()],
385        },
386        Descriptor {
387            id: "firewall.show".into(),
388            summary: "Show one zone's detail".into(),
389            long: "Show one zone's full firewall detail: services, ports, interfaces, \
390and sources. Read-only. Exits 4 for an unknown zone."
391                .into(),
392            privileged: false,
393            output_kind: "FirewallZone".into(),
394            inputs: vec![input("zone", true)],
395            flags: vec!["--host".into(), "--json".into()],
396            examples: vec!["fez firewall show public --json".into()],
397        },
398        Descriptor {
399            id: "firewall.services".into(),
400            summary: "List the firewall service catalog".into(),
401            long: "List the service names firewalld knows about (the valid arguments \
402to add-service). Read-only."
403                .into(),
404            privileged: false,
405            output_kind: "FirewallServiceCatalog".into(),
406            inputs: vec![],
407            flags: vec!["--host".into(), "--json".into()],
408            examples: vec!["fez firewall services --json".into()],
409        },
410        Descriptor {
411            id: "firewall.add-service".into(),
412            summary: "Add a service to a zone".into(),
413            long: "Add a service to a zone at runtime only. Use --zone to target a zone \
414(the default zone otherwise) and --timeout to auto-revert after N seconds. The change \
415is NOT permanent until `fez firewall confirm`. Privileged. An unknown service is \
416rejected by firewalld (exit 7). Protected ops elsewhere need --force."
417                .into(),
418            privileged: true,
419            output_kind: "FirewallChange".into(),
420            inputs: vec![input("service", true)],
421            flags: vec![
422                "--host".into(),
423                "--json".into(),
424                "--zone".into(),
425                "--timeout".into(),
426                "--force".into(),
427            ],
428            examples: vec![
429                "fez firewall add-service http --json".into(),
430                "fez firewall add-service http --zone public --timeout 60".into(),
431            ],
432        },
433        Descriptor {
434            id: "firewall.remove-service".into(),
435            summary: "Remove a service from a zone".into(),
436            long: "Remove a service from a zone at runtime only. Removing the ssh \
437service (which carries the active session) is refused unless --force is supplied \
438(exit 8). NOT permanent until `fez firewall confirm`. Privileged."
439                .into(),
440            privileged: true,
441            output_kind: "FirewallChange".into(),
442            inputs: vec![input("service", true)],
443            flags: vec![
444                "--host".into(),
445                "--json".into(),
446                "--zone".into(),
447                "--force".into(),
448            ],
449            examples: vec!["fez firewall remove-service http --json".into()],
450        },
451        Descriptor {
452            id: "firewall.add-port".into(),
453            summary: "Add a port to a zone".into(),
454            long: "Add a port (port/proto, e.g. 8080/tcp) to a zone at runtime only. \
455Use --zone and --timeout. NOT permanent until `fez firewall confirm`. Privileged. \
456Protected ops elsewhere need --force."
457                .into(),
458            privileged: true,
459            output_kind: "FirewallChange".into(),
460            inputs: vec![input("port", true)],
461            flags: vec![
462                "--host".into(),
463                "--json".into(),
464                "--zone".into(),
465                "--timeout".into(),
466                "--force".into(),
467            ],
468            examples: vec!["fez firewall add-port 8080/tcp --json".into()],
469        },
470        Descriptor {
471            id: "firewall.remove-port".into(),
472            summary: "Remove a port from a zone".into(),
473            long: "Remove a port (port/proto) from a zone at runtime only. Removing the \
474port that carries the active SSH session is refused unless --force is supplied \
475(exit 8). NOT permanent until `fez firewall confirm`. Privileged."
476                .into(),
477            privileged: true,
478            output_kind: "FirewallChange".into(),
479            inputs: vec![input("port", true)],
480            flags: vec![
481                "--host".into(),
482                "--json".into(),
483                "--zone".into(),
484                "--force".into(),
485            ],
486            examples: vec!["fez firewall remove-port 8080/tcp --json".into()],
487        },
488        Descriptor {
489            id: "firewall.set-default-zone".into(),
490            summary: "Set the default zone".into(),
491            long: "Set the default firewall zone. Every default-zone change is gated \
492and refused unless --force is supplied (exit 8), because a different default can \
493sever a connection that relied on the old zone. Runtime only until confirm. Privileged."
494                .into(),
495            privileged: true,
496            output_kind: "FirewallChange".into(),
497            inputs: vec![input("zone", true)],
498            flags: vec!["--host".into(), "--json".into(), "--force".into()],
499            examples: vec!["fez firewall set-default-zone internal --force --json".into()],
500        },
501        Descriptor {
502            id: "firewall.reload".into(),
503            summary: "Reload permanent config into runtime".into(),
504            long: "Reload the permanent config into runtime, discarding any uncommitted \
505runtime changes. With uncommitted drift present the reload is refused unless --force \
506is supplied (exit 8), since it would lose that work. With no drift it runs freely. \
507Privileged."
508                .into(),
509            privileged: true,
510            output_kind: "FirewallChange".into(),
511            inputs: vec![],
512            flags: vec!["--host".into(), "--json".into(), "--force".into()],
513            examples: vec!["fez firewall reload --json".into()],
514        },
515        Descriptor {
516            id: "firewall.confirm".into(),
517            summary: "Persist runtime config to permanent".into(),
518            long: "Commit the current runtime firewall config to permanent \
519(runtimeToPermanent). This is the only persistence path; mutations are runtime-only \
520until confirmed. Privileged. --force is not required for confirm itself."
521                .into(),
522            privileged: true,
523            output_kind: "FirewallConfirm".into(),
524            inputs: vec![],
525            flags: vec!["--host".into(), "--json".into(), "--force".into()],
526            examples: vec!["fez firewall confirm --json".into()],
527        },
528        Descriptor {
529            id: "firewall.panic".into(),
530            summary: "Toggle panic mode".into(),
531            long: "Toggle panic mode. `panic on` drops ALL traffic and is refused unless \
532--force is supplied (exit 8); `panic off` re-enables traffic. Runtime only. Privileged."
533                .into(),
534            privileged: true,
535            output_kind: "FirewallChange".into(),
536            inputs: vec![input("state", true)],
537            flags: vec!["--host".into(), "--json".into(), "--force".into()],
538            examples: vec![
539                "fez firewall panic off --json".into(),
540                "fez firewall panic on --force".into(),
541            ],
542        },
543        Descriptor {
544            id: "firewall.masquerade".into(),
545            summary: "Enable or disable masquerade (SNAT) for a zone".into(),
546            long: "Enable or disable masquerade (source NAT for forwarded traffic) on a \
547zone. Use --zone to target a zone (the default zone otherwise) and --timeout to \
548auto-revert after N seconds (ignored for `off`). Runtime only; NOT permanent until \
549`fez firewall confirm`. Enabling is unguarded; disabling is refused unless --force is \
550supplied (exit 8), because dropping SNAT can sever a gateway's forwarded clients. \
551Privileged."
552                .into(),
553            privileged: true,
554            output_kind: "FirewallChange".into(),
555            inputs: vec![input("state", true)],
556            flags: vec![
557                "--host".into(),
558                "--json".into(),
559                "--zone".into(),
560                "--timeout".into(),
561                "--force".into(),
562            ],
563            examples: vec![
564                "fez firewall masquerade on --json".into(),
565                "fez firewall masquerade off --zone public --force".into(),
566            ],
567        },
568    ]
569}
570
571/// Look up a capability descriptor by its dotted id.
572pub fn find(id: &str) -> Option<Descriptor> {
573    registry().into_iter().find(|d| d.id == id)
574}
575
576#[cfg(test)]
577mod tests {
578    use super::*;
579
580    #[test]
581    fn every_descriptor_has_long_and_examples() {
582        for d in registry() {
583            assert!(!d.long.trim().is_empty(), "{} missing long", d.id);
584            assert!(!d.examples.is_empty(), "{} has no examples", d.id);
585            for ex in &d.examples {
586                assert!(ex.starts_with("fez "), "{}: bad example {:?}", d.id, ex);
587            }
588        }
589    }
590
591    #[test]
592    fn protected_capabilities_document_force() {
593        for d in registry() {
594            if d.privileged {
595                assert!(
596                    d.long.contains("--force") || d.examples.iter().any(|e| e.contains("--force")),
597                    "{}: privileged capability should mention --force",
598                    d.id
599                );
600            }
601        }
602    }
603
604    #[test]
605    fn enable_disable_have_now_example() {
606        for id in ["services.enable", "services.disable"] {
607            let d = find(id).unwrap();
608            assert!(
609                d.examples.iter().any(|e| e.contains("--now")),
610                "{id}: needs --now example"
611            );
612        }
613    }
614}