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::ser::SerializeStruct;
4use serde::{Serialize, Serializer};
5use serde_json::{json, Value};
6
7pub mod help;
8
9/// A single named input a capability accepts.
10#[derive(Serialize, Clone)]
11pub struct Input {
12    /// Input name as used on the command line.
13    pub name: String,
14    /// Input value type (currently always `"string"`).
15    #[serde(rename = "type")]
16    pub ty: String,
17    /// Whether the input must be supplied.
18    pub required: bool,
19    /// Default value used when the input is omitted, if any.
20    #[serde(skip_serializing_if = "Option::is_none")]
21    pub default: Option<String>,
22    /// Allowed values for constrained inputs, if any.
23    #[serde(skip_serializing_if = "Option::is_none")]
24    pub choices: Option<Vec<String>>,
25}
26
27#[derive(Serialize, Clone)]
28pub(crate) struct FlagSchema {
29    pub(crate) name: String,
30    #[serde(rename = "type")]
31    pub(crate) ty: String,
32    pub(crate) description: String,
33    pub(crate) repeatable: bool,
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub(crate) default: Option<String>,
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub(crate) choices: Option<Vec<String>>,
38    #[serde(skip_serializing_if = "Vec::is_empty")]
39    pub(crate) conflicts_with: Vec<String>,
40}
41
42/// A complete description of one capability.
43#[derive(Clone)]
44pub struct Descriptor {
45    /// Dotted capability id (e.g. `services.start`).
46    pub id: String,
47    /// One-line human summary (maps to clap `about`).
48    pub summary: String,
49    /// Full description (maps to clap `long_about`).
50    pub long: String,
51    /// Whether invoking the capability requires elevated privileges.
52    pub privileged: bool,
53    /// The envelope `kind` this capability emits.
54    pub output_kind: String,
55    /// Inputs the capability accepts.
56    pub inputs: Vec<Input>,
57    /// Flags the capability honors.
58    pub flags: Vec<String>,
59    /// Example invocations (maps to clap `after_help`).
60    pub examples: Vec<String>,
61}
62
63impl Serialize for Descriptor {
64    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
65    where
66        S: Serializer,
67    {
68        let mut s = serializer.serialize_struct("Descriptor", 11)?;
69        s.serialize_field("id", &self.id)?;
70        s.serialize_field("summary", &self.summary)?;
71        s.serialize_field("long", &self.long)?;
72        s.serialize_field("privileged", &self.privileged)?;
73        s.serialize_field("output_kind", &self.output_kind)?;
74        s.serialize_field("output", &self.output_schema())?;
75        s.serialize_field("inputs", &self.inputs)?;
76        s.serialize_field("flags", &self.flags)?;
77        s.serialize_field("flag_schema", &self.flag_schema())?;
78        s.serialize_field("examples", &self.examples)?;
79        s.end()
80    }
81}
82
83impl Descriptor {
84    /// Render the descriptor as a complete plain-text block for `fez describe`
85    /// (no `--json`).
86    ///
87    /// Top-level help promises `describe` prints inputs, output kind, flags,
88    /// and privileged status; this carries the same essential metadata the
89    /// JSON form does so an agent reading text output can act safely without
90    /// switching to JSON (issue #62).
91    #[must_use]
92    pub fn render_text(&self) -> String {
93        let mut s = format!("{}: {}\n\n{}\n\n", self.id, self.summary, self.long);
94        s.push_str(&format!("privileged: {}\n", self.privileged));
95        s.push_str(&format!("output: {}\n", self.output_kind));
96
97        if !self.inputs.is_empty() {
98            s.push_str("\ninputs:\n");
99            for i in &self.inputs {
100                let req = if i.required { "required" } else { "optional" };
101                s.push_str(&format!("  {}: {} {}", i.name, i.ty, req));
102                if let Some(default) = &i.default {
103                    s.push_str(&format!(" (default: {default})"));
104                }
105                if let Some(choices) = &i.choices {
106                    s.push_str(&format!(" choices: {}", choices.join(", ")));
107                }
108                s.push('\n');
109            }
110        }
111
112        if !self.flags.is_empty() {
113            s.push_str("\nflags:\n");
114            for f in &self.flags {
115                s.push_str(&format!("  {f}\n"));
116            }
117        }
118
119        s.push_str("\nexamples:\n");
120        for ex in &self.examples {
121            s.push_str(&format!("  {ex}\n"));
122        }
123        s
124    }
125
126    pub(crate) fn flag_schema(&self) -> Vec<FlagSchema> {
127        self.flags
128            .iter()
129            .map(|flag| flag_schema(&self.id, flag))
130            .collect()
131    }
132
133    fn output_schema(&self) -> Value {
134        let mut output = json!({
135            "kind": self.output_kind,
136            "schema": output_schema(&self.output_kind),
137            "error": error_schema(),
138            "error_envelope": error_envelope_schema(),
139        });
140        if let Some(alternates) = alternate_output_schemas(self) {
141            output["alternates"] = alternates;
142        }
143        output
144    }
145}
146
147fn string_prop() -> Value {
148    json!({"type": "string"})
149}
150
151fn integer_prop() -> Value {
152    json!({"type": "integer"})
153}
154
155fn boolean_prop() -> Value {
156    json!({"type": "boolean"})
157}
158
159fn array_prop() -> Value {
160    json!({"type": "array"})
161}
162
163fn array_of(item: Value) -> Value {
164    json!({"type": "array", "items": item})
165}
166
167fn nullable_integer_prop() -> Value {
168    json!({"type": ["integer", "null"]})
169}
170
171fn nullable_boolean_prop() -> Value {
172    json!({"type": ["boolean", "null"]})
173}
174
175fn nullable_string_prop() -> Value {
176    json!({"type": ["string", "null"]})
177}
178
179fn nullable_object_prop() -> Value {
180    json!({"type": ["object", "null"]})
181}
182
183fn object_schema(properties: Value, required: &[&str]) -> Value {
184    json!({
185        "type": "object",
186        "properties": properties,
187        "required": required,
188    })
189}
190
191fn type_prop(ty: &str) -> Value {
192    json!({"type": ty})
193}
194
195fn table_schema(
196    columns: &[(&str, &str)],
197    extra_properties: Value,
198    required_extra: &[&str],
199) -> Value {
200    let column_names: Vec<&str> = columns.iter().map(|(name, _)| *name).collect();
201    let row_items: Vec<Value> = columns.iter().map(|(_, ty)| type_prop(ty)).collect();
202    let mut properties = json!({
203        "columns": {"type": "array", "items": {"type": "string"}, "const": column_names},
204        "rows": {"type": "array", "items": {"type": "array", "prefixItems": row_items}},
205        "count": integer_prop(),
206    });
207    if let (Some(base), Some(extra)) = (properties.as_object_mut(), extra_properties.as_object()) {
208        base.extend(extra.clone());
209    }
210    let mut required = vec!["columns", "rows", "count"];
211    required.extend(required_extra.iter().copied());
212    object_schema(properties, &required)
213}
214
215fn package_table_schema(extra_properties: Value, required_extra: &[&str]) -> Value {
216    let mut properties = extra_properties;
217    if let Some(map) = properties.as_object_mut() {
218        map.insert("backend".into(), string_prop());
219    }
220    let mut required = required_extra.to_vec();
221    required.push("backend");
222    table_schema(PACKAGE_COLUMNS, properties, &required)
223}
224
225const SERVICE_LIST_COLUMNS: &[(&str, &str)] = &[
226    ("name", "string"),
227    ("description", "string"),
228    ("load_state", "string"),
229    ("active_state", "string"),
230    ("sub_state", "string"),
231];
232
233const PACKAGE_COLUMNS: &[(&str, &str)] = &[
234    ("name", "string"),
235    ("evr", "string"),
236    ("arch", "string"),
237    ("repo_id", "string"),
238    ("install_size", "integer"),
239    ("summary", "string"),
240];
241
242const REPO_COLUMNS: &[(&str, &str)] =
243    &[("id", "string"), ("name", "string"), ("enabled", "boolean")];
244
245const NETWORK_LIST_COLUMNS: &[(&str, &str)] = &[
246    ("interface", "string"),
247    ("type", "string"),
248    ("state", "string"),
249    ("ip4", "string"),
250    ("ip6", "string"),
251    ("mac", "string"),
252];
253
254const FIREWALL_ZONE_LIST_COLUMNS: &[(&str, &str)] = &[
255    ("zone", "string"),
256    ("default", "boolean"),
257    ("services", "string"),
258    ("ports", "string"),
259    ("interfaces", "string"),
260];
261
262fn package_info_schema() -> Value {
263    object_schema(
264        json!({
265            "name": string_prop(),
266            "evr": string_prop(),
267            "arch": string_prop(),
268            "repo_id": string_prop(),
269            "install_size": nullable_integer_prop(),
270            "summary": string_prop(),
271            "backend": string_prop(),
272        }),
273        &[
274            "name",
275            "evr",
276            "arch",
277            "repo_id",
278            "install_size",
279            "summary",
280            "backend",
281        ],
282    )
283}
284
285fn package_mutation_schema() -> Value {
286    object_schema(
287        json!({
288            "operation": string_prop(),
289            "specs": array_prop(),
290            "dry_run": boolean_prop(),
291            "install": array_prop(),
292            "remove": array_prop(),
293            "upgrade": array_prop(),
294            "downgrade": array_prop(),
295            "install_size_total": nullable_integer_prop(),
296            "counts": object_schema(json!({
297                "install": integer_prop(),
298                "remove": integer_prop(),
299                "upgrade": integer_prop(),
300                "downgrade": integer_prop(),
301            }), &["install", "remove", "upgrade", "downgrade"]),
302            "backend": string_prop(),
303        }),
304        &[
305            "operation",
306            "specs",
307            "dry_run",
308            "install",
309            "remove",
310            "upgrade",
311            "downgrade",
312            "install_size_total",
313            "counts",
314            "backend",
315        ],
316    )
317}
318
319fn dry_run_schema() -> Value {
320    object_schema(
321        json!({
322            "operation": string_prop(),
323            "unit": string_prop(),
324            "host": string_prop(),
325            "privileged": boolean_prop(),
326            "command": string_prop(),
327        }),
328        &["operation", "unit", "host", "privileged", "command"],
329    )
330}
331
332fn alternate_output_schemas(descriptor: &Descriptor) -> Option<Value> {
333    if !descriptor.flags.iter().any(|flag| flag == "--dry-run") {
334        return None;
335    }
336    let alternate = match descriptor.output_kind.as_str() {
337        "PackageMutation" => json!({"kind": "PackagePlan", "schema": package_mutation_schema()}),
338        "ServiceMutation" | "ServiceEnablement" => {
339            json!({"kind": "DryRun", "schema": dry_run_schema()})
340        }
341        _ => return None,
342    };
343    Some(json!([alternate]))
344}
345
346fn output_schema(kind: &str) -> Value {
347    match kind {
348        "ServiceList" => table_schema(SERVICE_LIST_COLUMNS, json!({}), &[]),
349        "ServiceStatus" => object_schema(
350            json!({
351                "id": string_prop(),
352                "description": string_prop(),
353                "load_state": string_prop(),
354                "active_state": string_prop(),
355                "sub_state": string_prop(),
356                "unit_file_state": string_prop(),
357            }),
358            &["id", "load_state", "active_state", "sub_state"],
359        ),
360        "LogEntries" => object_schema(
361            json!({
362                "unit": string_prop(),
363                "entries": array_of(object_schema(json!({
364                    "timestamp": string_prop(),
365                    "priority": string_prop(),
366                    "identifier": string_prop(),
367                    "message": string_prop(),
368                    "pid": string_prop(),
369                }), &["timestamp", "priority", "identifier", "message", "pid"])),
370            }),
371            &["unit", "entries"],
372        ),
373        "ServiceMutation" => object_schema(
374            json!({
375                "operation": string_prop(),
376                "unit": string_prop(),
377                "host": string_prop(),
378                "job": nullable_string_prop(),
379            }),
380            &["operation", "unit", "host"],
381        ),
382        "ServiceEnablement" => object_schema(
383            json!({
384                "operation": string_prop(),
385                "unit": string_prop(),
386                "host": string_prop(),
387                "now": boolean_prop(),
388                "changes": array_prop(),
389            }),
390            &["operation", "unit", "host", "now", "changes"],
391        ),
392        "PackageList" => package_table_schema(
393            json!({
394                "scope": string_prop(),
395                "repos": array_prop(),
396                "name": nullable_string_prop(),
397                "total": integer_prop(),
398                "returned": integer_prop(),
399                "limit": nullable_integer_prop(),
400                "offset": integer_prop(),
401                "next_offset": nullable_integer_prop(),
402            }),
403            &[
404                "scope",
405                "repos",
406                "name",
407                "total",
408                "returned",
409                "limit",
410                "offset",
411                "next_offset",
412            ],
413        ),
414        "PackageInfo" => package_info_schema(),
415        "PackageSearch" => package_table_schema(json!({"pattern": string_prop()}), &["pattern"]),
416        "PackageUpdates" => package_table_schema(json!({}), &[]),
417        "RepoList" => {
418            let mut properties = json!({"backend": string_prop()});
419            table_schema(REPO_COLUMNS, properties.take(), &["backend"])
420        }
421        "PackageMutation" => package_mutation_schema(),
422        "NetworkDeviceList" => table_schema(NETWORK_LIST_COLUMNS, json!({}), &[]),
423        "NetworkDeviceDetail" => object_schema(
424            json!({
425                "interface": string_prop(),
426                "type": string_prop(),
427                "state": string_prop(),
428                "mac": string_prop(),
429                "mtu": integer_prop(),
430                "ipv4": object_schema(json!({
431                    "addresses": array_prop(),
432                    "gateway": string_prop(),
433                    "dns": array_prop(),
434                    "domains": array_prop(),
435                }), &["addresses", "gateway", "dns", "domains"]),
436                "ipv6": object_schema(json!({"addresses": array_prop()}), &["addresses"]),
437                "connection": json!({"type": ["object", "null"], "properties": {
438                    "id": string_prop(),
439                    "type": string_prop(),
440                    "default": boolean_prop(),
441                }}),
442                "dhcp4": nullable_object_prop(),
443            }),
444            &[
445                "interface",
446                "type",
447                "state",
448                "mac",
449                "mtu",
450                "ipv4",
451                "ipv6",
452                "connection",
453                "dhcp4",
454            ],
455        ),
456        "FirewallStatus" => object_schema(
457            json!({
458                "running": boolean_prop(),
459                "default_zone": string_prop(),
460                "panic_mode": boolean_prop(),
461                "masquerade": boolean_prop(),
462                "pending_changes": array_prop(),
463                "pending_changes_available": boolean_prop(),
464            }),
465            &[
466                "running",
467                "default_zone",
468                "panic_mode",
469                "masquerade",
470                "pending_changes",
471                "pending_changes_available",
472            ],
473        ),
474        "FirewallZoneList" => table_schema(FIREWALL_ZONE_LIST_COLUMNS, json!({}), &[]),
475        "FirewallZone" => object_schema(
476            json!({
477                "zone": string_prop(),
478                "services": array_prop(),
479                "ports": array_prop(),
480                "interfaces": array_prop(),
481                "sources": array_prop(),
482                "masquerade": boolean_prop(),
483            }),
484            &[
485                "zone",
486                "services",
487                "ports",
488                "interfaces",
489                "sources",
490                "masquerade",
491            ],
492        ),
493        "FirewallServiceCatalog" => object_schema(json!({"services": array_prop()}), &["services"]),
494        "FirewallChange" => object_schema(
495            json!({
496                "operation": string_prop(),
497                "zone": nullable_string_prop(),
498                "change": nullable_string_prop(),
499                "persisted": boolean_prop(),
500                "panic_mode": nullable_boolean_prop(),
501                "timeout": nullable_integer_prop(),
502                "masquerade": nullable_boolean_prop(),
503            }),
504            &["operation", "persisted"],
505        ),
506        "FirewallConfirm" => object_schema(
507            json!({
508                "operation": string_prop(),
509                "persisted": boolean_prop(),
510            }),
511            &["operation", "persisted"],
512        ),
513        _ => object_schema(json!({}), &[]),
514    }
515}
516
517fn error_schema() -> Value {
518    object_schema(
519        json!({
520            "code": string_prop(),
521            "message": string_prop(),
522            "detail": nullable_object_prop(),
523        }),
524        &["code", "message"],
525    )
526}
527
528fn error_envelope_schema() -> Value {
529    object_schema(
530        json!({
531            "apiVersion": string_prop(),
532            "kind": {"type": "string", "const": "Error"},
533            "host": string_prop(),
534            "status": {"type": "string", "const": "error"},
535            "error": error_schema(),
536            "hints": nullable_object_prop(),
537        }),
538        &["apiVersion", "kind", "host", "status", "error"],
539    )
540}
541
542fn input(name: &str, required: bool) -> Input {
543    Input {
544        name: name.into(),
545        ty: "string".into(),
546        required,
547        default: None,
548        choices: None,
549    }
550}
551
552fn input_choices(name: &str, required: bool, choices: &[&str]) -> Input {
553    Input {
554        name: name.into(),
555        ty: "string".into(),
556        required,
557        default: None,
558        choices: Some(choices.iter().map(|choice| (*choice).to_string()).collect()),
559    }
560}
561
562fn flag_schema(capability_id: &str, flag: &str) -> FlagSchema {
563    let (ty, description, repeatable, default, choices, conflicts_with) = match flag {
564        "--host" => (
565            "string",
566            "Target host. Defaults to localhost.",
567            false,
568            Some("localhost"),
569            None,
570            vec![],
571        ),
572        "--json" => (
573            "boolean",
574            "Emit a fez/v1 JSON envelope.",
575            false,
576            None,
577            None,
578            vec![],
579        ),
580        "--dry-run" => (
581            "boolean",
582            "Resolve and report the planned mutation without applying it.",
583            false,
584            None,
585            None,
586            vec![],
587        ),
588        "--force" => (
589            "boolean",
590            "Override command-specific safety guardrails.",
591            false,
592            None,
593            None,
594            vec![],
595        ),
596        "--state" => ("string", "Filter by state.", false, None, None, vec![]),
597        "--since" => (
598            "string",
599            "Only include log entries since this journalctl time expression.",
600            false,
601            None,
602            None,
603            vec![],
604        ),
605        "--priority" => (
606            "string",
607            "Only include log entries at this priority or higher.",
608            false,
609            None,
610            None,
611            vec![],
612        ),
613        "--lines" => (
614            "integer",
615            "Limit log output to the last N entries.",
616            false,
617            None,
618            None,
619            vec![],
620        ),
621        "--follow" => (
622            "boolean",
623            "Stream new log entries.",
624            false,
625            None,
626            None,
627            vec![],
628        ),
629        "--now" => (
630            "boolean",
631            "Start or stop the unit immediately with the enablement change.",
632            false,
633            None,
634            None,
635            vec![],
636        ),
637        "--installed" => (
638            "boolean",
639            "List installed packages.",
640            false,
641            Some("true"),
642            None,
643            vec!["--available"],
644        ),
645        "--available" => (
646            "boolean",
647            "List available packages.",
648            false,
649            None,
650            None,
651            vec!["--installed"],
652        ),
653        "--repo" => (
654            "string",
655            "Restrict packages to this exact repository id.",
656            true,
657            None,
658            None,
659            vec![],
660        ),
661        "--enabled" => (
662            "boolean",
663            "Show only enabled repositories.",
664            false,
665            Some("true"),
666            None,
667            vec!["--disabled", "--all"],
668        ),
669        "--disabled" => (
670            "boolean",
671            "Show only disabled repositories.",
672            false,
673            None,
674            None,
675            vec!["--enabled", "--all"],
676        ),
677        "--all" if capability_id == "packages.repolist" => (
678            "boolean",
679            "Show all repositories.",
680            false,
681            None,
682            None,
683            vec!["--enabled", "--disabled"],
684        ),
685        "--all" => (
686            "boolean",
687            "Include all entries instead of the default subset.",
688            false,
689            None,
690            None,
691            vec![],
692        ),
693        "--zone" => (
694            "string",
695            "Firewall zone to target. Defaults to the target host's default zone.",
696            false,
697            None,
698            None,
699            vec![],
700        ),
701        "--timeout" => (
702            "integer",
703            "Auto-revert the runtime firewall change after this many seconds.",
704            false,
705            None,
706            None,
707            vec![],
708        ),
709        _ => (
710            "string",
711            "Capability-specific flag.",
712            false,
713            None,
714            None,
715            vec![],
716        ),
717    };
718    FlagSchema {
719        name: flag.to_string(),
720        ty: ty.to_string(),
721        description: description.to_string(),
722        repeatable,
723        default: default.map(str::to_string),
724        choices: choices.map(|values: &[&str]| values.iter().map(|v| (*v).to_string()).collect()),
725        conflicts_with: conflicts_with.into_iter().map(str::to_string).collect(),
726    }
727}
728
729fn mutation(
730    id: &str,
731    summary: &str,
732    long: &str,
733    output_kind: &str,
734    extra_flags: &[&str],
735) -> Descriptor {
736    let mut flags = vec![
737        "--host".to_string(),
738        "--json".to_string(),
739        "--dry-run".to_string(),
740        "--force".to_string(),
741    ];
742    flags.extend(extra_flags.iter().map(|f| f.to_string()));
743    Descriptor {
744        id: id.into(),
745        summary: summary.into(),
746        long: long.into(),
747        privileged: true,
748        output_kind: output_kind.into(),
749        inputs: vec![input("unit", true)],
750        flags,
751        // Include the required <UNIT>: agents copy examples verbatim, and an
752        // example without it fails with "required arguments were not provided"
753        // (issue #53).
754        examples: vec![format!("fez {} sshd.service --json", id.replace('.', " "))],
755    }
756}
757
758fn enablement(id: &str, summary: &str, long: &str) -> Descriptor {
759    let verb = id.rsplit('.').next().expect("capability id has a verb");
760    Descriptor {
761        id: id.into(),
762        summary: summary.into(),
763        long: long.into(),
764        privileged: true,
765        output_kind: "ServiceEnablement".into(),
766        inputs: vec![input("unit", true)],
767        flags: vec![
768            "--host".into(),
769            "--json".into(),
770            "--dry-run".into(),
771            "--force".into(),
772            "--now".into(),
773        ],
774        examples: vec![
775            format!("fez services {verb} chronyd.service --json"),
776            format!("fez services {verb} chronyd.service --now"),
777        ],
778    }
779}
780
781/// The full set of capability descriptors fez supports.
782pub fn registry() -> Vec<Descriptor> {
783    vec![
784        Descriptor {
785            id: "services.list".into(),
786            summary: "List systemd units".into(),
787            long: "List systemd units on the target host. Use --state to filter by \
788active state (e.g. active, failed, inactive). Read-only; never mutates."
789                .into(),
790            privileged: false,
791            output_kind: "ServiceList".into(),
792            inputs: vec![input("state", false)],
793            flags: vec!["--host".into(), "--json".into(), "--state".into()],
794            examples: vec![
795                "fez services list --state failed --json".into(),
796                "fez --host web01 services list".into(),
797            ],
798        },
799        Descriptor {
800            id: "services.status".into(),
801            summary: "Show one unit's status".into(),
802            long: "Show the current status of a single systemd unit (active state, \
803sub-state, enablement). Read-only."
804                .into(),
805            privileged: false,
806            output_kind: "ServiceStatus".into(),
807            inputs: vec![input("unit", true)],
808            flags: vec!["--host".into(), "--json".into()],
809            examples: vec!["fez services status sshd.service --json".into()],
810        },
811        Descriptor {
812            id: "services.logs".into(),
813            summary: "Read a unit's journal".into(),
814            long: "Read journal entries for a unit. Filter with --since and --priority \
815(journalctl syntax), cap with --lines, or stream with --follow. Read-only."
816                .into(),
817            privileged: false,
818            output_kind: "LogEntries".into(),
819            inputs: vec![input("unit", true)],
820            flags: vec![
821                "--host".into(),
822                "--json".into(),
823                "--since".into(),
824                "--priority".into(),
825                "--lines".into(),
826                "--follow".into(),
827            ],
828            examples: vec![
829                "fez services logs sshd.service --lines 100 --json".into(),
830                "fez services logs nginx.service --since '1 hour ago' --priority err".into(),
831            ],
832        },
833        mutation(
834            "services.start",
835            "Start a unit",
836            "Start a systemd unit immediately. Privileged. Protected units are \
837refused unless --force is supplied. Exits 8 on a protected-unit refusal.",
838            "ServiceMutation",
839            &[],
840        ),
841        mutation(
842            "services.stop",
843            "Stop a unit",
844            "Stop a running systemd unit. Privileged. Protected units are refused \
845unless --force is supplied (exit 8).",
846            "ServiceMutation",
847            &[],
848        ),
849        mutation(
850            "services.restart",
851            "Restart a unit",
852            "Restart a systemd unit. Privileged. Protected units are refused unless \
853--force is supplied (exit 8).",
854            "ServiceMutation",
855            &[],
856        ),
857        mutation(
858            "services.reload",
859            "Reload a unit's configuration",
860            "Ask a unit to reload its configuration without a full restart. \
861Privileged. Protected units are refused unless --force is supplied (exit 8).",
862            "ServiceMutation",
863            &[],
864        ),
865        enablement(
866            "services.enable",
867            "Enable a unit",
868            "Enable a unit so it starts at boot. Add --now to also start it \
869immediately. Privileged. Protected units are refused unless --force is supplied (exit 8).",
870        ),
871        enablement(
872            "services.disable",
873            "Disable a unit",
874            "Disable a unit so it no longer starts at boot. Add --now to also \
875stop it immediately. Privileged. Protected units are refused unless --force is supplied (exit 8).",
876        ),
877        Descriptor {
878            id: "packages.list".into(),
879            summary: "List packages".into(),
880            long: "List installed (default) or available packages. Use --available to list \
881available packages. Use --name to keep packages whose name contains the given substring, \
882and --repo to restrict packages by exact repo id (repeatable union). Use --limit and \
883--offset to page large results; JSON output echoes filters plus total, returned, limit, \
884offset, and next_offset metadata. Read-only."
885                .into(),
886            privileged: false,
887            output_kind: "PackageList".into(),
888            inputs: vec![],
889            flags: vec![
890                "--host".into(),
891                "--json".into(),
892                "--installed".into(),
893                "--available".into(),
894                "--repo".into(),
895                "--name".into(),
896                "--limit".into(),
897                "--offset".into(),
898            ],
899            examples: vec![
900                "fez packages list --json".into(),
901                "fez packages list --available --name nginx --limit 20".into(),
902                "fez packages list --available --repo fedora --offset 20 --limit 20".into(),
903            ],
904        },
905        Descriptor {
906            id: "packages.info".into(),
907            summary: "Show one package's attributes".into(),
908            long: "Show the full attributes of a single package (version, arch, repo, size, \
909summary). Read-only."
910                .into(),
911            privileged: false,
912            output_kind: "PackageInfo".into(),
913            inputs: vec![input("spec", true)],
914            flags: vec!["--host".into(), "--json".into()],
915            examples: vec!["fez packages info bash --json".into()],
916        },
917        Descriptor {
918            id: "packages.search".into(),
919            summary: "Search packages".into(),
920            long: "Search available packages by name, summary, or provides. Read-only.".into(),
921            privileged: false,
922            output_kind: "PackageSearch".into(),
923            inputs: vec![input("pattern", true)],
924            flags: vec!["--host".into(), "--json".into()],
925            examples: vec!["fez packages search nginx --json".into()],
926        },
927        Descriptor {
928            id: "packages.check-update".into(),
929            summary: "List available upgrades".into(),
930            long: "List packages with available upgrades. Read-only.".into(),
931            privileged: false,
932            output_kind: "PackageUpdates".into(),
933            inputs: vec![],
934            flags: vec!["--host".into(), "--json".into()],
935            examples: vec!["fez packages check-update --json".into()],
936        },
937        Descriptor {
938            id: "packages.repolist".into(),
939            summary: "List repositories".into(),
940            long: "List repositories and their enabled state. Use --enabled (default), \
941--disabled, or --all. Read-only."
942                .into(),
943            privileged: false,
944            output_kind: "RepoList".into(),
945            inputs: vec![],
946            flags: vec![
947                "--host".into(),
948                "--json".into(),
949                "--enabled".into(),
950                "--disabled".into(),
951                "--all".into(),
952            ],
953            examples: vec!["fez packages repolist --all --json".into()],
954        },
955        Descriptor {
956            id: "packages.install".into(),
957            summary: "Install packages".into(),
958            long: "Install one or more packages. Resolves the transaction first and surfaces \
959the plan; --dry-run stops after the plan. Privileged. Uses dnf5daemon, falling \
960back to PackageKit when dnf5daemon is absent (sizes are unavailable on the \
961PackageKit backend; the envelope marks backend and carries a hint). Exits 9 only \
962if both backends are missing, 10 if the resolved transaction is refused by \
963removal guardrails (use --force to override)."
964                .into(),
965            privileged: true,
966            output_kind: "PackageMutation".into(),
967            inputs: vec![input("specs", true)],
968            flags: vec![
969                "--host".into(),
970                "--json".into(),
971                "--dry-run".into(),
972                "--force".into(),
973            ],
974            examples: vec![
975                "fez packages install htop --json".into(),
976                "fez packages install nginx --dry-run".into(),
977            ],
978        },
979        Descriptor {
980            id: "packages.remove".into(),
981            summary: "Remove packages".into(),
982            long: "Remove one or more packages. Resolves first and applies removal guardrails: \
983a protected package or a cascade larger than the limit is refused unless --force \
984is supplied (exit 10). --dry-run surfaces the plan without removing. Privileged."
985                .into(),
986            privileged: true,
987            output_kind: "PackageMutation".into(),
988            inputs: vec![input("specs", true)],
989            flags: vec![
990                "--host".into(),
991                "--json".into(),
992                "--dry-run".into(),
993                "--force".into(),
994            ],
995            examples: vec![
996                "fez packages remove htop --json".into(),
997                "fez packages remove oldpkg --dry-run".into(),
998            ],
999        },
1000        Descriptor {
1001            id: "packages.upgrade".into(),
1002            summary: "Upgrade packages".into(),
1003            long: "Upgrade named packages, or all packages when no spec is given. Resolves \
1004first and surfaces the plan; --dry-run stops after the plan. Privileged. Refusals \
1005from removal guardrails (replaced/obsoleted packages) exit 10 unless --force is \
1006supplied."
1007                .into(),
1008            privileged: true,
1009            output_kind: "PackageMutation".into(),
1010            inputs: vec![input("specs", false)],
1011            flags: vec![
1012                "--host".into(),
1013                "--json".into(),
1014                "--dry-run".into(),
1015                "--force".into(),
1016            ],
1017            examples: vec![
1018                "fez packages upgrade --json".into(),
1019                "fez packages upgrade nginx --force".into(),
1020            ],
1021        },
1022        Descriptor {
1023            id: "network.list".into(),
1024            summary: "List network devices".into(),
1025            long: "List NetworkManager devices with their type, state, primary IPv4/IPv6 \
1026address, and MAC. By default unmanaged virtual interfaces (container veth, etc.) are \
1027hidden; use --all to show every device. Read-only."
1028                .into(),
1029            privileged: false,
1030            output_kind: "NetworkDeviceList".into(),
1031            inputs: vec![],
1032            flags: vec!["--host".into(), "--json".into(), "--all".into()],
1033            examples: vec![
1034                "fez network list --json".into(),
1035                "fez network list --all".into(),
1036            ],
1037        },
1038        Descriptor {
1039            id: "network.show".into(),
1040            summary: "Show one device's network detail".into(),
1041            long: "Show the full network detail for one device: addresses (IPv4 and IPv6), \
1042gateway, DNS servers, search domains, routes, MAC, MTU, the active connection profile, \
1043and DHCP lease. Read-only."
1044                .into(),
1045            privileged: false,
1046            output_kind: "NetworkDeviceDetail".into(),
1047            inputs: vec![input("device", true)],
1048            flags: vec!["--host".into(), "--json".into()],
1049            examples: vec!["fez network show enp1s0 --json".into()],
1050        },
1051        Descriptor {
1052            id: "firewall.status".into(),
1053            summary: "Show firewall status".into(),
1054            long: "Show firewalld state, the default zone, the panic-mode flag, and any \
1055uncommitted runtime-vs-permanent drift (pending_changes). Read-only."
1056                .into(),
1057            privileged: false,
1058            output_kind: "FirewallStatus".into(),
1059            inputs: vec![],
1060            flags: vec!["--host".into(), "--json".into()],
1061            examples: vec!["fez firewall status --json".into()],
1062        },
1063        Descriptor {
1064            id: "firewall.list".into(),
1065            summary: "List firewall zones".into(),
1066            long: "List all firewalld zones with a per-zone summary (default flag, \
1067services, ports, interfaces). Read-only."
1068                .into(),
1069            privileged: false,
1070            output_kind: "FirewallZoneList".into(),
1071            inputs: vec![],
1072            flags: vec!["--host".into(), "--json".into()],
1073            examples: vec!["fez firewall list --json".into()],
1074        },
1075        Descriptor {
1076            id: "firewall.show".into(),
1077            summary: "Show one zone's detail".into(),
1078            long: "Show one zone's full firewall detail: services, ports, interfaces, \
1079and sources. Read-only. Exits 4 for an unknown zone."
1080                .into(),
1081            privileged: false,
1082            output_kind: "FirewallZone".into(),
1083            inputs: vec![input("zone", true)],
1084            flags: vec!["--host".into(), "--json".into()],
1085            examples: vec!["fez firewall show public --json".into()],
1086        },
1087        Descriptor {
1088            id: "firewall.services".into(),
1089            summary: "List the firewall service catalog".into(),
1090            long: "List the service names firewalld knows about (the valid arguments \
1091to add-service). Read-only."
1092                .into(),
1093            privileged: false,
1094            output_kind: "FirewallServiceCatalog".into(),
1095            inputs: vec![],
1096            flags: vec!["--host".into(), "--json".into()],
1097            examples: vec!["fez firewall services --json".into()],
1098        },
1099        Descriptor {
1100            id: "firewall.add-service".into(),
1101            summary: "Add a service to a zone".into(),
1102            long: "Add a service to a zone at runtime only. Use --zone to target a zone \
1103(the default zone otherwise) and --timeout to auto-revert after N seconds. The change \
1104is NOT permanent until `fez firewall confirm`. Privileged. An unknown service is \
1105rejected by firewalld (exit 7). Protected ops elsewhere need --force."
1106                .into(),
1107            privileged: true,
1108            output_kind: "FirewallChange".into(),
1109            inputs: vec![input("service", true)],
1110            flags: vec![
1111                "--host".into(),
1112                "--json".into(),
1113                "--zone".into(),
1114                "--timeout".into(),
1115                "--force".into(),
1116            ],
1117            examples: vec![
1118                "fez firewall add-service http --json".into(),
1119                "fez firewall add-service http --zone public --timeout 60".into(),
1120            ],
1121        },
1122        Descriptor {
1123            id: "firewall.remove-service".into(),
1124            summary: "Remove a service from a zone".into(),
1125            long: "Remove a service from a zone at runtime only. Removing the ssh \
1126service (which carries the active session) is refused unless --force is supplied \
1127(exit 8). NOT permanent until `fez firewall confirm`. Privileged."
1128                .into(),
1129            privileged: true,
1130            output_kind: "FirewallChange".into(),
1131            inputs: vec![input("service", true)],
1132            flags: vec![
1133                "--host".into(),
1134                "--json".into(),
1135                "--zone".into(),
1136                "--force".into(),
1137            ],
1138            examples: vec!["fez firewall remove-service http --json".into()],
1139        },
1140        Descriptor {
1141            id: "firewall.add-port".into(),
1142            summary: "Add a port to a zone".into(),
1143            long: "Add a port (port/proto, e.g. 8080/tcp) to a zone at runtime only. \
1144Use --zone and --timeout. NOT permanent until `fez firewall confirm`. Privileged. \
1145Protected ops elsewhere need --force."
1146                .into(),
1147            privileged: true,
1148            output_kind: "FirewallChange".into(),
1149            inputs: vec![input("port", true)],
1150            flags: vec![
1151                "--host".into(),
1152                "--json".into(),
1153                "--zone".into(),
1154                "--timeout".into(),
1155                "--force".into(),
1156            ],
1157            examples: vec!["fez firewall add-port 8080/tcp --json".into()],
1158        },
1159        Descriptor {
1160            id: "firewall.remove-port".into(),
1161            summary: "Remove a port from a zone".into(),
1162            long: "Remove a port (port/proto) from a zone at runtime only. Removing the \
1163port that carries the active SSH session is refused unless --force is supplied \
1164(exit 8). NOT permanent until `fez firewall confirm`. Privileged."
1165                .into(),
1166            privileged: true,
1167            output_kind: "FirewallChange".into(),
1168            inputs: vec![input("port", true)],
1169            flags: vec![
1170                "--host".into(),
1171                "--json".into(),
1172                "--zone".into(),
1173                "--force".into(),
1174            ],
1175            examples: vec!["fez firewall remove-port 8080/tcp --json".into()],
1176        },
1177        Descriptor {
1178            id: "firewall.set-default-zone".into(),
1179            summary: "Set the default zone".into(),
1180            long: "Set the default firewall zone. Every default-zone change is gated \
1181and refused unless --force is supplied (exit 8), because a different default can \
1182sever a connection that relied on the old zone. Runtime only until confirm. Privileged."
1183                .into(),
1184            privileged: true,
1185            output_kind: "FirewallChange".into(),
1186            inputs: vec![input("zone", true)],
1187            flags: vec!["--host".into(), "--json".into(), "--force".into()],
1188            examples: vec!["fez firewall set-default-zone internal --force --json".into()],
1189        },
1190        Descriptor {
1191            id: "firewall.reload".into(),
1192            summary: "Reload permanent config into runtime".into(),
1193            long: "Reload the permanent config into runtime, discarding any uncommitted \
1194runtime changes. With uncommitted drift present the reload is refused unless --force \
1195is supplied (exit 8), since it would lose that work. With no drift it runs freely. \
1196Privileged."
1197                .into(),
1198            privileged: true,
1199            output_kind: "FirewallChange".into(),
1200            inputs: vec![],
1201            flags: vec!["--host".into(), "--json".into(), "--force".into()],
1202            examples: vec!["fez firewall reload --json".into()],
1203        },
1204        Descriptor {
1205            id: "firewall.confirm".into(),
1206            summary: "Persist runtime config to permanent".into(),
1207            long: "Commit the current runtime firewall config to permanent \
1208(runtimeToPermanent). This is the only persistence path; mutations are runtime-only \
1209until confirmed. Privileged. --force is not required for confirm itself."
1210                .into(),
1211            privileged: true,
1212            output_kind: "FirewallConfirm".into(),
1213            inputs: vec![],
1214            flags: vec!["--host".into(), "--json".into(), "--force".into()],
1215            examples: vec!["fez firewall confirm --json".into()],
1216        },
1217        Descriptor {
1218            id: "firewall.panic".into(),
1219            summary: "Toggle panic mode".into(),
1220            long: "Toggle panic mode. `panic on` drops ALL traffic and is refused unless \
1221--force is supplied (exit 8); `panic off` re-enables traffic. Runtime only. Privileged."
1222                .into(),
1223            privileged: true,
1224            output_kind: "FirewallChange".into(),
1225            inputs: vec![input_choices("state", true, &["on", "off"])],
1226            flags: vec!["--host".into(), "--json".into(), "--force".into()],
1227            examples: vec![
1228                "fez firewall panic off --json".into(),
1229                "fez firewall panic on --force".into(),
1230            ],
1231        },
1232        Descriptor {
1233            id: "firewall.masquerade".into(),
1234            summary: "Enable or disable masquerade (SNAT) for a zone".into(),
1235            long: "Enable or disable masquerade (source NAT for forwarded traffic) on a \
1236zone. Use --zone to target a zone (the default zone otherwise) and --timeout to \
1237auto-revert after N seconds (ignored for `off`). Runtime only; NOT permanent until \
1238`fez firewall confirm`. Enabling is unguarded; disabling is refused unless --force is \
1239supplied (exit 8), because dropping SNAT can sever a gateway's forwarded clients. \
1240Privileged."
1241                .into(),
1242            privileged: true,
1243            output_kind: "FirewallChange".into(),
1244            inputs: vec![input_choices("state", true, &["on", "off"])],
1245            flags: vec![
1246                "--host".into(),
1247                "--json".into(),
1248                "--zone".into(),
1249                "--timeout".into(),
1250                "--force".into(),
1251            ],
1252            examples: vec![
1253                "fez firewall masquerade on --json".into(),
1254                "fez firewall masquerade off --zone public --force".into(),
1255            ],
1256        },
1257    ]
1258}
1259
1260/// Look up a capability descriptor by its dotted id.
1261pub fn find(id: &str) -> Option<Descriptor> {
1262    registry().into_iter().find(|d| d.id == id)
1263}
1264
1265#[cfg(test)]
1266mod tests {
1267    use super::*;
1268
1269    #[test]
1270    fn every_descriptor_has_long_and_examples() {
1271        for d in registry() {
1272            assert!(!d.long.trim().is_empty(), "{} missing long", d.id);
1273            assert!(!d.examples.is_empty(), "{} has no examples", d.id);
1274            for ex in &d.examples {
1275                assert!(ex.starts_with("fez "), "{}: bad example {:?}", d.id, ex);
1276            }
1277        }
1278    }
1279
1280    #[test]
1281    fn every_descriptor_has_output_schema() {
1282        for d in registry() {
1283            let output = d.output_schema();
1284            assert_eq!(output["kind"], d.output_kind, "{} kind mismatch", d.id);
1285            assert_eq!(
1286                output["schema"]["type"], "object",
1287                "{} missing schema",
1288                d.id
1289            );
1290            assert_eq!(
1291                output["error"]["type"], "object",
1292                "{} missing error schema",
1293                d.id
1294            );
1295        }
1296    }
1297
1298    #[test]
1299    fn protected_capabilities_document_force() {
1300        for d in registry() {
1301            if d.privileged {
1302                assert!(
1303                    d.long.contains("--force") || d.examples.iter().any(|e| e.contains("--force")),
1304                    "{}: privileged capability should mention --force",
1305                    d.id
1306                );
1307            }
1308        }
1309    }
1310
1311    #[test]
1312    fn enable_disable_have_now_example() {
1313        for id in ["services.enable", "services.disable"] {
1314            let d = find(id).unwrap();
1315            assert!(
1316                d.examples.iter().any(|e| e.contains("--now")),
1317                "{id}: needs --now example"
1318            );
1319        }
1320    }
1321
1322    #[test]
1323    fn render_text_includes_all_metadata() {
1324        let d = find("services.start").unwrap();
1325        let text = d.render_text();
1326        assert!(text.contains("services.start: Start a unit"));
1327        assert!(text.contains("privileged: true"));
1328        assert!(text.contains("output: ServiceMutation"));
1329        assert!(text.contains("inputs:"));
1330        assert!(text.contains("unit: string required"));
1331        assert!(text.contains("flags:"));
1332        assert!(text.contains("--force"));
1333        assert!(text.contains("examples:"));
1334        assert!(text.contains("fez services start sshd.service --json"));
1335    }
1336
1337    #[test]
1338    fn render_text_marks_readonly_not_privileged() {
1339        let d = find("services.list").unwrap();
1340        let text = d.render_text();
1341        assert!(text.contains("privileged: false"));
1342        assert!(text.contains("output: ServiceList"));
1343    }
1344
1345    #[test]
1346    fn render_text_optional_input_shows_default() {
1347        // Find any descriptor with an optional input carrying a default, and
1348        // confirm the rendered line annotates it. If none exists this is a
1349        // no-op (the format is still covered by the required-input case).
1350        for d in registry() {
1351            for i in &d.inputs {
1352                if let Some(default) = &i.default {
1353                    let text = d.render_text();
1354                    assert!(
1355                        text.contains(&format!("(default: {default})")),
1356                        "{}: optional input {} default not rendered",
1357                        d.id,
1358                        i.name
1359                    );
1360                }
1361            }
1362        }
1363    }
1364}