volant_protocol/modules.rs
1// SPDX-License-Identifier: GPL-3.0-or-later
2//! The native modules and what the controller needs to know about them before the agent runs
3//! them. Written once here; the agent's implementation table and the documentation are
4//! checked against it.
5
6use std::fmt::Write as _;
7
8use serde_json::Value;
9
10/// What one module does with one of its arguments.
11///
12/// A module being in [`NATIVE_MODULES`] says it runs; it says nothing about the rest of its API,
13/// and an argument accepted and then dropped reports success having done something other than
14/// what the playbook asked for. The status is per argument so that neither answer is a guess.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum ArgStatus {
17 /// Read, and the value decides what runs.
18 ///
19 /// It says the argument is read, not that it is coerced the way the reference coerces it.
20 /// `chdir`, `creates` and `removes` are `type='path'` there, which expands `~` and `$VAR`
21 /// before the value is used, and this release takes the value as it is written.
22 Honoured,
23 /// The reference accepts the name on this module and acts on it nowhere, which is what this
24 /// release does with it too - `executable` on `command`, where no shell runs for it to
25 /// select. Nothing to promise and nothing to refuse, so it is out of the generated page.
26 Inert,
27 /// The reference has it and this release does not do what it asks for. Named by the
28 /// pre-flight, before the first connection, rather than accepted and forgotten.
29 ///
30 /// The value carried is the one that is refused, the way `check_mode` is refused for `true`
31 /// and runs for `false`: the other value asks for what this release already does, and
32 /// refusing that would stop a playbook the two engines agreed on. `None` refuses the name
33 /// whatever the value says.
34 Refused(Option<bool>),
35}
36
37/// A value read the way the reference reads an argument declared `type='bool'`.
38///
39/// Measured on ansible-core 2.19.12, whose own refusal lists the spellings it takes: `0, 1, 'n',
40/// 'on', 'true', 'f', 'false', 'y', 'yes', 'no', '0', '1', 't', 'off'`, and a string outside that
41/// set fails the task there. It normalises with `.lower().strip()`, so the value is case folded
42/// **and** trimmed before it is matched: a trailing newline off a `lookup('file')`, or the space a
43/// template leaves behind, reads the same there as the bare word. `Value::as_bool` sees none of
44/// them, so an argument written `"false"` - which a whole-expression template produces on its own
45/// - used to read back as the default and do the opposite of what it says.
46///
47/// This is not the set the YAML scalar resolver uses, and the two must not be merged: that one
48/// has no `y`, `n`, `t` or `f`, and matches three fixed capitalisations where this one folds
49/// case. The `bool` filter has a third set of its own. Three readers, three measurements, one
50/// place each.
51pub fn arg_bool(value: &Value) -> Option<bool> {
52 let text = match value {
53 Value::Bool(b) => return Some(*b),
54 Value::Number(n) => n.to_string(),
55 Value::String(s) => s.trim().to_ascii_lowercase(),
56 _ => return None,
57 };
58 match text.as_str() {
59 "y" | "yes" | "on" | "1" | "1.0" | "true" | "t" => Some(true),
60 "n" | "no" | "off" | "0" | "0.0" | "false" | "f" => Some(false),
61 _ => None,
62 }
63}
64
65/// One argument of one module, and what this release does with it.
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub struct ModuleArg {
68 pub name: &'static str,
69 pub status: ArgStatus,
70}
71
72/// One native module: its Ansible name and how the controller parses its arguments.
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub struct ModuleSpec {
75 /// Short name, without the `ansible.builtin.` prefix.
76 pub name: &'static str,
77 /// The task's string form is a command line kept whole as `_raw_params`,
78 /// instead of `key=value` pairs.
79 pub free_form: bool,
80 /// One line for the generated documentation.
81 pub summary: &'static str,
82 /// Every argument the reference accepts for this module, sorted, with what this release does
83 /// with each. Empty for a module whose arguments nothing here checks yet.
84 pub args: &'static [ModuleArg],
85 /// The name the reference validates this module's arguments under, when it validates them at
86 /// all, and so the name its refusal carries.
87 ///
88 /// Measured on ansible-core 2.19.12: `shell` is executed by the `command` module, so an
89 /// argument neither module has is refused as `ansible.legacy.command` under both names and
90 /// against one list. `raw` never reaches a module at all - its action plugin hands the line
91 /// to the shell - so it takes any argument without looking at it, and a run that refused one
92 /// would refuse a playbook the reference runs.
93 pub validated_as: Option<&'static str>,
94}
95
96/// The parameters the reference's `command` module accepts, sorted, as its own refusal lists
97/// them - which is not what `ansible-doc` reports. Measured on ansible-core 2.19.12: the refusal
98/// names `_raw_params`, `_uses_shell` and `executable`, which the documentation leaves out, and
99/// leaves out `free_form`, which the documentation has. The refusal is the list that decides, so
100/// it is the list written here.
101///
102/// The two internal keys are in it for that reason and not as an exemption: the controller writes
103/// `_raw_params` for every free-form task and `_uses_shell` marks the shell semantics, and the
104/// reference accepts both by name. `cmd` is the ordinary spelling of the same command line.
105///
106/// `shell` shares these names because the reference shares the module. Two statuses differ, so
107/// the names are written once and the differences are arguments: `executable` is the shell
108/// `shell` hands its line to, and on `command` the reference starts no shell for it to select;
109/// `expand_argument_vars` is a `command` argument the reference does not give `shell` at all.
110const fn command_args(executable: ArgStatus, expand_argument_vars: ArgStatus) -> [ModuleArg; 12] {
111 [
112 ModuleArg {
113 name: "_raw_params",
114 status: ArgStatus::Honoured,
115 },
116 ModuleArg {
117 name: "_uses_shell",
118 status: ArgStatus::Honoured,
119 },
120 ModuleArg {
121 name: "argv",
122 status: ArgStatus::Honoured,
123 },
124 ModuleArg {
125 name: "chdir",
126 status: ArgStatus::Honoured,
127 },
128 ModuleArg {
129 name: "cmd",
130 status: ArgStatus::Honoured,
131 },
132 ModuleArg {
133 name: "creates",
134 status: ArgStatus::Honoured,
135 },
136 ModuleArg {
137 name: "executable",
138 status: executable,
139 },
140 // Measured: it decides whether the arguments handed to the program have their shell
141 // variables expanded first - `/bin/echo $HOME` prints the home directory by default and the
142 // five characters `$HOME` when it is off. This release expands nothing, which is what `false`
143 // asks for, so on `command` only `true` is refused: refusing `false` would stop a playbook
144 // that ran the same under both engines. The default is the value that diverges, and it is
145 // the one nobody writes; `docs/src/modules.md` says so, because no refusal can.
146 ModuleArg {
147 name: "expand_argument_vars",
148 status: expand_argument_vars,
149 },
150 ModuleArg {
151 name: "removes",
152 status: ArgStatus::Honoured,
153 },
154 ModuleArg {
155 name: "stdin",
156 status: ArgStatus::Honoured,
157 },
158 ModuleArg {
159 name: "stdin_add_newline",
160 status: ArgStatus::Honoured,
161 },
162 ModuleArg {
163 name: "strip_empty_ends",
164 status: ArgStatus::Honoured,
165 },
166 ]
167}
168
169/// The list `command` answers with: `executable` is accepted and does nothing, there as here.
170const COMMAND_ARGS: [ModuleArg; 12] =
171 command_args(ArgStatus::Inert, ArgStatus::Refused(Some(true)));
172/// The same names, with the argument `shell` reads and `command` does not, and the argument the
173/// reference gives `command` and refuses on `shell`.
174///
175/// Measured on ansible-core 2.19.12: `shell: /bin/echo $HOME` with `expand_argument_vars` set to
176/// either value answers `Unsupported parameters for (shell) module: expand_argument_vars` and
177/// fails the task, while the same line without the argument prints the home directory, because
178/// the shell expands it rather than the module. So a `shell` task diverges here for no value of
179/// this argument, and writing it at all is a playbook the reference refuses: the name is refused
180/// whatever it says, which is the reference's own answer, given before the first connection.
181///
182/// One spelling goes the other way. `expand_argument_vars: "{{ omit }}"` on `shell` takes the
183/// key out of the arguments there and runs the task, while a refusal tied to no value cannot
184/// read a template and refuses it here. It is the only value of the only argument where the two
185/// disagree in that direction, against every literal value where refusing is what the reference
186/// does, and a refusal before the first connection says exactly what it refused.
187const SHELL_ARGS: [ModuleArg; 12] = command_args(ArgStatus::Honoured, ArgStatus::Refused(None));
188
189pub const COMMAND: ModuleSpec = ModuleSpec {
190 name: "command",
191 free_form: true,
192 summary: "Run a program directly, without a shell.",
193 args: &COMMAND_ARGS,
194 validated_as: Some("ansible.legacy.command"),
195};
196pub const RAW: ModuleSpec = ModuleSpec {
197 name: "raw",
198 free_form: true,
199 summary: "Run a command line through the remote shell, with no module machinery around it.",
200 args: &[ModuleArg {
201 name: "executable",
202 status: ArgStatus::Honoured,
203 }],
204 validated_as: None,
205};
206pub const SHELL: ModuleSpec = ModuleSpec {
207 name: "shell",
208 free_form: true,
209 summary: "Run a command line through a shell, `sh` unless `executable` names another.",
210 args: &SHELL_ARGS,
211 validated_as: Some("ansible.legacy.command"),
212};
213
214/// Every module the agent implements natively, sorted by name.
215pub const NATIVE_MODULES: &[ModuleSpec] = &[COMMAND, RAW, SHELL];
216
217pub const DEBUG: ModuleSpec = ModuleSpec {
218 name: "debug",
219 free_form: false,
220 summary: "Print a message or the value of a variable.",
221 args: &[],
222 validated_as: None,
223};
224pub const SET_FACT: ModuleSpec = ModuleSpec {
225 name: "set_fact",
226 free_form: false,
227 summary: "Set facts for a host, for the rest of the run.",
228 args: &[],
229 validated_as: None,
230};
231
232pub const INCLUDE_VARS: ModuleSpec = ModuleSpec {
233 name: "include_vars",
234 free_form: true,
235 summary: "Read a file of variables and set them on the host, for the rest of the run.",
236 args: &[],
237 validated_as: None,
238};
239
240pub const VALIDATE_ARGUMENT_SPEC: ModuleSpec = ModuleSpec {
241 name: "validate_argument_spec",
242 free_form: false,
243 summary: "Check a role's arguments against the specification in `meta/argument_specs.yml`.",
244 args: &[],
245 validated_as: None,
246};
247
248/// Modules the controller runs itself and never sends to a host, sorted by name.
249pub const LOCAL_MODULES: &[ModuleSpec] = &[DEBUG, INCLUDE_VARS, SET_FACT, VALIDATE_ARGUMENT_SPEC];
250
251/// The three statements that read a file while the play is being compiled instead of naming
252/// work for a host, with whether their string form is one raw argument.
253///
254/// They are written as modules and the loader reads them as modules, but nothing sends them
255/// anywhere: the compiler splices what they name into the step list and no step is left behind.
256/// `import_role` is the odd one out on the free-form column - measured, `import_playbook: x.yml`
257/// takes the file as a raw parameter while `import_role` refuses one and wants `name=`.
258pub const IMPORT_MODULES: &[(&str, bool)] = &[
259 ("import_playbook", true),
260 ("import_role", false),
261 ("import_tasks", true),
262];
263
264/// Whether the module is one of the three import statements, and whether its string form is raw.
265pub fn import_module(module: &str) -> Option<bool> {
266 let short = short_name(module);
267 IMPORT_MODULES
268 .iter()
269 .find(|(name, _)| *name == short)
270 .map(|(_, free_form)| *free_form)
271}
272
273/// The two statements that name work read while the play is **running** rather than while it is
274/// being compiled, with whether their string form is one raw argument.
275///
276/// They are the imports' dynamic twins: what they name is not known until the host that reaches
277/// them has rendered its own variables, so the coordinator reads it then and splices the steps in
278/// behind the statement. `include_role` refuses a raw parameter and wants `name=`, the way
279/// `import_role` does - measured on ansible-core 2.19.12.
280///
281/// `include_vars` is not here: it is an ordinary controller-side module in [`LOCAL_MODULES`],
282/// because what it produces is variables rather than steps.
283pub const INCLUDE_MODULES: &[(&str, bool)] = &[("include_role", false), ("include_tasks", true)];
284
285/// Whether the module is one of the two include statements, and whether its string form is raw.
286pub fn include_module(module: &str) -> Option<bool> {
287 let short = short_name(module);
288 INCLUDE_MODULES
289 .iter()
290 .find(|(name, _)| *name == short)
291 .map(|(_, free_form)| *free_form)
292}
293
294/// `ansible.builtin.` and `ansible.legacy.` name the same modules as the bare name does.
295/// Other collections are returned whole: `community.general.command` is not our `command`.
296pub fn short_name(module: &str) -> &str {
297 module
298 .strip_prefix("ansible.builtin.")
299 .or_else(|| module.strip_prefix("ansible.legacy."))
300 .unwrap_or(module)
301}
302
303pub fn native(module: &str) -> Option<&'static ModuleSpec> {
304 let short = short_name(module);
305 NATIVE_MODULES.iter().find(|m| m.name == short)
306}
307
308pub fn local(module: &str) -> Option<&'static ModuleSpec> {
309 let short = short_name(module);
310 LOCAL_MODULES.iter().find(|m| m.name == short)
311}
312
313/// Every module `ansible.builtin` ships in ansible-core 2.19.12, as `ansible-doc -l -t module
314/// ansible.builtin` lists them, sorted and short-named. A playbook naming one of these and
315/// none of `NATIVE_MODULES` or `LOCAL_MODULES` is a playbook this release cannot run yet,
316/// which the operator needs to hear differently from a name that resolves to no module at all:
317/// the first waits on us, the second is a typo.
318pub const BUILTIN_MODULES: &[&str] = &[
319 "add_host",
320 "apt",
321 "apt_key",
322 "apt_repository",
323 "assemble",
324 "assert",
325 "async_status",
326 "blockinfile",
327 "command",
328 "copy",
329 "cron",
330 "deb822_repository",
331 "debconf",
332 "debug",
333 "dnf",
334 "dnf5",
335 "dpkg_selections",
336 "expect",
337 "fail",
338 "fetch",
339 "file",
340 "find",
341 "gather_facts",
342 "get_url",
343 "getent",
344 "git",
345 "group",
346 "group_by",
347 "hostname",
348 "import_playbook",
349 "import_role",
350 "import_tasks",
351 "include_role",
352 "include_tasks",
353 "include_vars",
354 "iptables",
355 "known_hosts",
356 "lineinfile",
357 "meta",
358 "mount_facts",
359 "package",
360 "package_facts",
361 "pause",
362 "ping",
363 "pip",
364 "raw",
365 "reboot",
366 "replace",
367 "rpm_key",
368 "script",
369 "service",
370 "service_facts",
371 "set_fact",
372 "set_stats",
373 "setup",
374 "shell",
375 "slurp",
376 "stat",
377 "subversion",
378 "systemd",
379 "systemd_service",
380 "sysvinit",
381 "tempfile",
382 "template",
383 "unarchive",
384 "uri",
385 "user",
386 "validate_argument_spec",
387 "wait_for",
388 "wait_for_connection",
389 "yum_repository",
390];
391
392/// Whether `module` names a builtin, bare or under the two prefixes that mean the same thing.
393/// Another collection's module is never one, however familiar the short name looks: if
394/// `community.general.command` were taken for a builtin, a playbook naming it would be told to
395/// wait for a release that will never contain it.
396pub fn is_builtin(module: &str) -> bool {
397 let ours = !module.contains('.')
398 || module.starts_with("ansible.builtin.")
399 || module.starts_with("ansible.legacy.");
400 ours && BUILTIN_MODULES.contains(&short_name(module))
401}
402
403/// Whether this engine can run the module at all, natively on the agent or on the controller.
404/// The two tables are all there is, so a playbook naming anything else can be refused while it
405/// is being loaded, which is where the reference refuses a module it cannot resolve.
406pub fn is_known(module: &str) -> bool {
407 native(module).is_some() || local(module).is_some()
408}
409
410/// The Markdown table published in the documentation, generated so it cannot drift.
411pub fn documentation_table() -> String {
412 let mut out = String::from(
413 "# Modules\n\nThese are the modules Volant runs. A playbook naming any other module is refused when it is loaded, before the first task, the way Ansible refuses a module it cannot resolve. A file a dynamic `include_tasks` or `include_role` names is read when a host reaches the statement, so a module named there is refused at that moment instead: the statement fails for the host that asked, and nothing in the file runs. What `import_tasks` and `import_role` name is compiled with the play and checked with it. Everything else waits on the warm Python path.\n\n## On the agent\n\nThe agent runs these on the host, without Python. The arguments column lists what each module reads, and each is read as the playbook writes it. Ansible declares `chdir`, `creates` and `removes` as paths, which expands `~` and `$VAR` in them before the value is used; this release does not, so `creates: ~/.provisioned` looks for a directory named `~`. No other argument is expanded either. Ansible runs `command: /bin/echo $HOME` with the variable already replaced, and here the program is handed the five characters `$HOME`. A `shell` task prints the same thing under both engines, because there the shell does the expanding rather than the module. The list under the table names the arguments Ansible has and this release refuses.\n\n| Module | Free-form arguments | Arguments | What it does |\n|---|---|---|---|\n",
414 );
415 // The internal keys carry the command line itself rather than being written in a playbook,
416 // so they are in the registry and out of the page.
417 let honoured = |m: &ModuleSpec| {
418 m.args
419 .iter()
420 .filter(|a| a.status == ArgStatus::Honoured && !a.name.starts_with('_'))
421 .map(|a| format!("`{}`", a.name))
422 .collect::<Vec<_>>()
423 .join(", ")
424 };
425 // A refusal tied to one value names that value: the other one is what this release does.
426 let refused = |m: &ModuleSpec| {
427 m.args
428 .iter()
429 .filter(|a| !a.name.starts_with('_'))
430 .filter_map(|a| match a.status {
431 ArgStatus::Refused(Some(v)) => Some(format!("`{}: {v}`", a.name)),
432 ArgStatus::Refused(None) => Some(format!("`{}`", a.name)),
433 _ => None,
434 })
435 .collect::<Vec<_>>()
436 .join(", ")
437 };
438 let rows = |specs: &[ModuleSpec], args: bool, out: &mut String| {
439 for m in specs {
440 let _ = write!(
441 out,
442 "| `{}` | {} |",
443 m.name,
444 if m.free_form { "yes" } else { "no" }
445 );
446 if args {
447 let read = honoured(m);
448 let _ = write!(
449 out,
450 " {} |",
451 if read.is_empty() { "-" } else { read.as_str() }
452 );
453 }
454 let _ = writeln!(out, " {} |", m.summary);
455 }
456 };
457 rows(NATIVE_MODULES, true, &mut out);
458 let notes: String = NATIVE_MODULES
459 .iter()
460 .filter_map(|m| {
461 let names = refused(m);
462 (!names.is_empty()).then(|| format!("- `{}`: {names}\n", m.name))
463 })
464 .collect();
465 if !notes.is_empty() {
466 out.push_str("\nVolant refuses these before the run reaches a host:\n\n");
467 out.push_str(¬es);
468 }
469 out.push_str(
470 "\n## On the controller\n\nThe controller runs these itself, so they need no connection to the host.\n\n| Module | Free-form arguments | What it does |\n|---|---|---|\n",
471 );
472 rows(LOCAL_MODULES, false, &mut out);
473 out
474}
475
476#[cfg(test)]
477mod tests {
478 use serde_json::json;
479
480 use super::*;
481
482 #[test]
483 fn builtin_prefixes_are_stripped_and_collections_kept() {
484 assert_eq!(short_name("ansible.builtin.command"), "command");
485 assert_eq!(short_name("ansible.legacy.shell"), "shell");
486 assert_eq!(short_name("command"), "command");
487 assert_eq!(short_name("community.general.ufw"), "community.general.ufw");
488 }
489
490 #[test]
491 fn native_lookup_uses_the_short_name() {
492 assert_eq!(native("ansible.builtin.raw").map(|m| m.name), Some("raw"));
493 assert!(native("file").is_none(), "file is not native yet");
494 assert!(
495 native("community.general.command").is_none(),
496 "another collection's command is not ours"
497 );
498 }
499
500 #[test]
501 fn names_are_unique_and_sorted() {
502 for table in [NATIVE_MODULES, LOCAL_MODULES] {
503 let names: Vec<&str> = table.iter().map(|m| m.name).collect();
504 let mut sorted = names.clone();
505 sorted.sort_unstable();
506 assert_eq!(names, sorted);
507 }
508 let mut all: Vec<&str> = NATIVE_MODULES
509 .iter()
510 .chain(LOCAL_MODULES)
511 .map(|m| m.name)
512 .collect();
513 let total = all.len();
514 all.sort_unstable();
515 all.dedup();
516 assert_eq!(all.len(), total, "a module name is in both tables");
517 }
518
519 #[test]
520 fn only_the_two_tables_are_known() {
521 assert!(is_known("ansible.builtin.shell"));
522 assert!(is_known("set_fact"));
523 assert!(is_known("ansible.legacy.debug"));
524 assert!(!is_known("nosuchmodule"));
525 assert!(!is_known("file"), "not implemented yet, so not known");
526 assert!(!is_known("community.general.debug"));
527 }
528
529 /// The three dynamic statements, each in the table that answers for it.
530 ///
531 /// `include_vars` is a controller-side module - it produces variables, and a result line with
532 /// them - while `include_tasks` and `include_role` produce steps and never run as modules at
533 /// all. Told apart here because the pre-flight reads exactly this: a name in neither table is
534 /// refused as "not available in this release", which is what all three were before the
535 /// coordinator could splice.
536 ///
537 /// What would make this red: `include_vars` left out of the controller-side table, which
538 /// refuses a playbook this release now runs; or either statement added to it, which would
539 /// send a step naming work to `run_local` and fail it as "not a controller-side module".
540 #[test]
541 fn the_three_dynamic_statements_are_each_in_one_table() {
542 assert!(is_known("include_vars"));
543 assert!(is_known("ansible.builtin.include_vars"));
544 assert!(include_module("include_vars").is_none());
545 for statement in ["include_tasks", "include_role"] {
546 assert!(include_module(statement).is_some(), "{statement}");
547 assert!(!is_known(statement), "{statement}");
548 }
549 assert_eq!(include_module("include_tasks"), Some(true));
550 assert_eq!(include_module("ansible.builtin.include_role"), Some(false));
551 }
552
553 /// The three states a module name can be in have to stay three. Collapsing them - which an
554 /// `&&`/`||` precedence slip does in one character - would tell an operator with a typo to
555 /// wait for a release, or an operator waiting for `lineinfile` that they misspelled it.
556 ///
557 /// What would make this red: `is_builtin` answering true for a name no collection has, or
558 /// false for one `ansible.builtin` ships.
559 #[test]
560 fn a_builtin_is_told_apart_from_a_name_that_resolves_to_nothing() {
561 for yes in [
562 "lineinfile",
563 "ansible.builtin.lineinfile",
564 "ansible.legacy.file",
565 "command",
566 ] {
567 assert!(is_builtin(yes), "{yes}");
568 }
569 for no in [
570 "nosuchmodule",
571 "ansible.builtin.nosuchmodule",
572 "community.general.ufw",
573 "community.general.command",
574 ] {
575 assert!(!is_builtin(no), "{no}");
576 }
577 assert!(
578 !is_known("lineinfile") && is_builtin("lineinfile"),
579 "a builtin we have not written is neither runnable nor a typo"
580 );
581 }
582
583 /// What would make this red: a native or controller-side module added under a name
584 /// `ansible.builtin` does not have, which would make it unreachable for anyone writing the
585 /// reference's spelling.
586 #[test]
587 fn the_builtin_table_is_sorted_and_covers_both_registries() {
588 let mut sorted = BUILTIN_MODULES.to_vec();
589 sorted.sort_unstable();
590 sorted.dedup();
591 assert_eq!(BUILTIN_MODULES, sorted.as_slice());
592 for m in NATIVE_MODULES.iter().chain(LOCAL_MODULES) {
593 assert!(
594 BUILTIN_MODULES.contains(&m.name),
595 "{} is not a name ansible.builtin has",
596 m.name
597 );
598 }
599 for table in [IMPORT_MODULES, INCLUDE_MODULES] {
600 let names: Vec<&str> = table.iter().map(|(n, _)| *n).collect();
601 let mut sorted = names.clone();
602 sorted.sort_unstable();
603 sorted.dedup();
604 assert_eq!(names, sorted);
605 for name in names {
606 assert!(BUILTIN_MODULES.contains(&name), "{name}");
607 assert!(
608 !is_known(name),
609 "{name} names work rather than being it, so nothing runs it as a module"
610 );
611 }
612 }
613 }
614
615 /// Every argument list is sorted, free of duplicates, and total: a module the reference
616 /// validates carries every name the reference's own refusal lists, because that refusal is
617 /// built from this list and a name missing here would be quoted as unsupported while the
618 /// reference supports it.
619 ///
620 /// What would make this red: a name appended rather than inserted in order, which puts the
621 /// reference's sentence out of order too; a name listed twice, which prints it twice; or a
622 /// module gaining a `validated_as` with nothing to validate against, which would refuse every
623 /// argument a playbook writes.
624 #[test]
625 fn every_argument_list_is_sorted_and_covers_what_the_reference_validates() {
626 for m in NATIVE_MODULES.iter().chain(LOCAL_MODULES) {
627 let names: Vec<&str> = m.args.iter().map(|a| a.name).collect();
628 let mut sorted = names.clone();
629 sorted.sort_unstable();
630 sorted.dedup();
631 assert_eq!(names, sorted, "{}", m.name);
632 assert!(
633 m.validated_as.is_none() || !names.is_empty(),
634 "{} validates against an empty list",
635 m.name
636 );
637 }
638 // Measured on ansible-core 2.19.12, through `args:` on both modules: one module name,
639 // one list of names, and one refusal. The ad-hoc path validates nothing, because
640 // `free_form` swallows the whole line into `_raw_params`.
641 let names = |m: &ModuleSpec| m.args.iter().map(|a| a.name).collect::<Vec<_>>();
642 assert_eq!(
643 names(&COMMAND),
644 names(&SHELL),
645 "the reference shares the module"
646 );
647 assert_eq!(SHELL.validated_as, Some("ansible.legacy.command"));
648 assert_eq!(
649 RAW.validated_as, None,
650 "raw's action plugin never validates an argument"
651 );
652 }
653
654 /// `executable` names the shell a command line is handed to, so it means something only where
655 /// a shell runs. The reference accepts it on `command` and starts no shell there, and so does
656 /// this release - which is parity, not a gap, and so belongs in neither column of the page.
657 ///
658 /// What would make this red: `command` claiming to read it, which the generated page then
659 /// tells an operator, who writes it and gets no shell, no refusal and no hint why.
660 #[test]
661 fn executable_is_read_only_where_a_shell_runs() {
662 let status = |m: &ModuleSpec| {
663 m.args
664 .iter()
665 .find(|a| a.name == "executable")
666 .map(|a| a.status)
667 };
668 assert_eq!(status(&COMMAND), Some(ArgStatus::Inert));
669 assert_eq!(status(&SHELL), Some(ArgStatus::Honoured));
670 assert_eq!(status(&RAW), Some(ArgStatus::Honoured));
671 let page = documentation_table();
672 let row = |name: &str| {
673 page.lines()
674 .find(|l| l.starts_with(&format!("| `{name}` |")))
675 .unwrap_or_default()
676 .to_string()
677 };
678 assert!(!row("command").contains("executable"), "{}", row("command"));
679 assert!(row("shell").contains("`executable`"), "{}", row("shell"));
680 }
681
682 /// `expand_argument_vars` is a `command` argument, and the two modules answer for it
683 /// differently because the reference does.
684 ///
685 /// Measured on ansible-core 2.19.12: on `command` the default expands and `false` does not,
686 /// so `true` is the only value that asks for something this release cannot do. On `shell`
687 /// the reference has no such argument at all and fails the task with `Unsupported parameters
688 /// for (shell) module: expand_argument_vars` whatever the value says, while a `shell` line
689 /// with the argument left out expands its variables under both engines, the shell doing it
690 /// rather than the module.
691 ///
692 /// What would make this red: `shell` carrying `command`'s value-tied refusal, which lets
693 /// `expand_argument_vars: false` through on a task the reference refuses; or `command`
694 /// refusing the name, which stops a playbook that ran the same under both engines.
695 #[test]
696 fn expand_argument_vars_is_refused_by_the_value_on_one_module_and_by_name_on_the_other() {
697 let status = |m: &ModuleSpec| {
698 m.args
699 .iter()
700 .find(|a| a.name == "expand_argument_vars")
701 .map(|a| a.status)
702 };
703 assert_eq!(status(&COMMAND), Some(ArgStatus::Refused(Some(true))));
704 assert_eq!(status(&SHELL), Some(ArgStatus::Refused(None)));
705 assert_eq!(status(&RAW), None);
706 let page = documentation_table();
707 assert!(
708 page.contains("- `command`: `expand_argument_vars: true`\n"),
709 "{page}"
710 );
711 assert!(
712 page.contains("- `shell`: `expand_argument_vars`\n"),
713 "{page}"
714 );
715 }
716
717 /// The divergence no refusal can name is the default, so the page has to name it instead.
718 /// This is the argument slice of the same class of gap the refusals close: an argument read
719 /// otherwise than the reference reads it, with the playbook saying nothing about it.
720 ///
721 /// What would make this red: the sentence dropped from the generated page, which leaves an
722 /// operator writing `creates: ~/.provisioned` or `command: mkdir -p $HOME/releases` with
723 /// nothing published to read it against.
724 #[test]
725 fn the_page_says_arguments_are_read_as_written() {
726 let page = documentation_table();
727 for phrase in [
728 "read as the playbook writes it",
729 "expands `~` and `$VAR`",
730 "`command: /bin/echo $HOME`",
731 ] {
732 assert!(page.contains(phrase), "{phrase} is missing from:\n{page}");
733 }
734 }
735
736 /// The spellings the reference takes for an argument declared `type='bool'`, read from its
737 /// own refusal of a value outside the set and measured task by task: `"false"`, `"no"`, `0`
738 /// and `"Off"` all turn the argument off there.
739 ///
740 /// The reference normalises with `.lower().strip()`, so surrounding whitespace is part of no
741 /// spelling: `"false "` is `false` there, and a value coming out of a template or a
742 /// `lookup('file')` carries that whitespace more often than a hand-written one does.
743 ///
744 /// What would make this red: reading the value with `Value::as_bool`, which answers `None`
745 /// for every spelling but a YAML boolean and leaves the caller on its default - so a task
746 /// asking for the opposite of the default gets the default and reports success; or matching
747 /// the string untrimmed, which does the same to every value with a newline on the end.
748 #[test]
749 fn a_boolean_argument_reads_every_spelling_the_reference_takes() {
750 for yes in [
751 json!(true),
752 json!("true"),
753 json!("True"),
754 json!("YES"),
755 json!("t"),
756 json!("on"),
757 json!(1),
758 json!(1.0),
759 json!(" true"),
760 ] {
761 assert_eq!(arg_bool(&yes), Some(true), "{yes}");
762 }
763 for no in [
764 json!(false),
765 json!("false"),
766 json!("no"),
767 json!("Off"),
768 json!("f"),
769 json!(0),
770 json!("0"),
771 json!("false "),
772 json!("no\n"),
773 ] {
774 assert_eq!(arg_bool(&no), Some(false), "{no}");
775 }
776 for neither in [
777 json!("maybe"),
778 json!(2),
779 json!(null),
780 json!([true]),
781 json!(""),
782 ] {
783 assert_eq!(arg_bool(&neither), None, "{neither}");
784 }
785 }
786
787 #[test]
788 fn the_documentation_table_matches_the_registry() {
789 let path =
790 std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/src/modules.md");
791 let expected = documentation_table();
792 if std::env::var_os("VOLANT_UPDATE_DOCS").is_some() {
793 std::fs::write(&path, &expected).unwrap();
794 }
795 let actual = std::fs::read_to_string(&path).unwrap_or_default();
796 assert_eq!(
797 actual, expected,
798 "run `just docs-modules` to regenerate docs/src/modules.md"
799 );
800 }
801}