1use serde::ser::SerializeStruct;
4use serde::{Serialize, Serializer};
5use serde_json::{json, Value};
6
7pub mod help;
8
9#[derive(Serialize, Clone)]
11pub struct Input {
12 pub name: String,
14 #[serde(rename = "type")]
16 pub ty: String,
17 pub required: bool,
19 #[serde(skip_serializing_if = "Option::is_none")]
21 pub default: Option<String>,
22 #[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#[derive(Clone)]
44pub struct Descriptor {
45 pub id: String,
47 pub summary: String,
49 pub long: String,
51 pub privileged: bool,
53 pub output_kind: String,
55 pub inputs: Vec<Input>,
57 pub flags: Vec<String>,
59 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 #[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 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
781pub 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
1260pub 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 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}