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    ]
198}
199
200/// Look up a capability descriptor by its dotted id.
201pub fn find(id: &str) -> Option<Descriptor> {
202    registry().into_iter().find(|d| d.id == id)
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    #[test]
210    fn every_descriptor_has_long_and_examples() {
211        for d in registry() {
212            assert!(!d.long.trim().is_empty(), "{} missing long", d.id);
213            assert!(!d.examples.is_empty(), "{} has no examples", d.id);
214            for ex in &d.examples {
215                assert!(ex.starts_with("fez "), "{}: bad example {:?}", d.id, ex);
216            }
217        }
218    }
219
220    #[test]
221    fn protected_capabilities_document_force() {
222        for d in registry() {
223            if d.privileged {
224                assert!(
225                    d.long.contains("--force") || d.examples.iter().any(|e| e.contains("--force")),
226                    "{}: privileged capability should mention --force",
227                    d.id
228                );
229            }
230        }
231    }
232
233    #[test]
234    fn enable_disable_have_now_example() {
235        for id in ["services.enable", "services.disable"] {
236            let d = find(id).unwrap();
237            assert!(
238                d.examples.iter().any(|e| e.contains("--now")),
239                "{id}: needs --now example"
240            );
241        }
242    }
243}