Skip to content

Commit 69866fc

Browse files
committed
add plugin readiness audit command
1 parent 9ec047c commit 69866fc

7 files changed

Lines changed: 301 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,7 @@ The public DSL exposes canonical plugin names only. Run:
186186

187187
```bash
188188
automax plugins list
189+
automax plugins audit
189190
```
190191

191192
Current categories:

docs/concepts/plugin-system.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ Aliases are intentionally not exposed by `automax plugins list`.
2323
automax plugins list
2424
automax plugins describe fs.template
2525
automax plugins describe fs.template --json
26+
automax plugins audit
2627
```
2728

2829
## Metadata

docs/guides/creating-plugins.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ Use `automax plugins describe` to inspect a builtin or externally loaded plugin:
6363

6464
```bash
6565
automax plugins describe fs.template
66+
automax plugins audit --plugin-path ./plugins
6667
```
6768

6869
The command prints the canonical name, description, required parameters, optional

docs/reference/cli.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,7 @@ automax artifacts path <run-id> --state-dir .automax/runs
222222
automax plugins list
223223
automax plugins describe fs.template
224224
automax plugins describe fs.template --json
225+
automax plugins audit
225226
```
226227

227228
## Schema export

src/automax/cli/cli.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
from automax.core.models import NodeStatus, Target
3030
from automax.core.state import StateStore
3131
from automax.core.yaml_loader import load_yaml_file
32+
from automax.plugins.audit import audit_plugin_registry
3233
from automax.plugins.registry import build_builtin_registry
3334

3435

@@ -1631,6 +1632,34 @@ def list_plugins(plugin_path: tuple[str, ...], include_aliases: bool) -> None:
16311632

16321633

16331634

1635+
@plugins.command("audit")
1636+
@click.option("--plugin-path", multiple=True, help="External plugin file or directory.")
1637+
@click.option(
1638+
"--format",
1639+
"output_format",
1640+
type=click.Choice(["text", "json"]),
1641+
default="text",
1642+
show_default=True,
1643+
help="Output format.",
1644+
)
1645+
def audit_plugins(plugin_path: tuple[str, ...], output_format: str) -> None:
1646+
"""Audit registered plugins for preview, dry-run and manual recovery coverage."""
1647+
payload = audit_plugin_registry(build_builtin_registry(plugin_path))
1648+
if output_format == "json":
1649+
click.echo(json.dumps(payload, indent=2, sort_keys=True))
1650+
else:
1651+
click.echo("Plugin audit:")
1652+
click.echo(f" checked: {payload['checked']}")
1653+
click.echo(f" failures: {payload['failure_count']}")
1654+
if payload["failures"]:
1655+
click.echo("Failures:")
1656+
for failure in payload["failures"]:
1657+
click.echo(f" - {failure}")
1658+
click.echo(f"Result: {'OK' if payload['ok'] else 'FAILED'}")
1659+
if not payload["ok"]:
1660+
raise click.exceptions.Exit(1)
1661+
1662+
16341663
@plugins.command("describe")
16351664
@click.argument("name")
16361665
@click.option("--plugin-path", multiple=True, help="External plugin file or directory.")

src/automax/plugins/audit.py

Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
# Copyright (C) 2026 Marco Fortina
2+
# SPDX-License-Identifier: AGPL-3.0-or-later
3+
4+
"""Builtin plugin operator-readiness audit helpers."""
5+
6+
from __future__ import annotations
7+
8+
from typing import Any
9+
10+
from automax.core.models import ExecutionContext, Target
11+
from automax.plugins.registry import PluginRegistry
12+
13+
14+
def _sample_value(name: str, schema: dict[str, Any]) -> Any:
15+
value = schema.get("default")
16+
expected = schema.get("types", schema.get("type", "string"))
17+
if isinstance(expected, str):
18+
expected_types = {expected}
19+
else:
20+
expected_types = {str(item) for item in expected}
21+
22+
if value is not None and not isinstance(value, (list, dict)):
23+
if "boolean" in expected_types and isinstance(value, bool):
24+
return value
25+
if "integer" in expected_types and isinstance(value, int) and not isinstance(value, bool):
26+
return value
27+
if "number" in expected_types and isinstance(value, (int, float)) and not isinstance(value, bool):
28+
return value
29+
if ("string" in expected_types or "path" in expected_types) and isinstance(value, str):
30+
return value
31+
if "boolean" in expected_types:
32+
return True
33+
if "integer" in expected_types:
34+
if name in {"port", "smtp_port", "to_port"}:
35+
return 22
36+
if name in {"expected_status", "status"}:
37+
return 200
38+
if name == "max_percent":
39+
return 90
40+
if name == "vlan_id":
41+
return 100
42+
return 1
43+
if "number" in expected_types:
44+
return 1
45+
if "mapping" in expected_types:
46+
if isinstance(value, dict):
47+
return value
48+
return {"demo": "1"}
49+
if "list" in expected_types or "sequence" in expected_types:
50+
if isinstance(value, list):
51+
return value
52+
sample_lists = {
53+
"commands": ["/usr/bin/id"],
54+
"devices": ["/dev/sdb"],
55+
"entries": [{"domain": "*", "type": "soft", "item": "nofile", "value": 1024}],
56+
"interfaces": ["eth1"],
57+
"partitions": [{"number": 1, "start": "1MiB", "end": "100%"}],
58+
"paths": ["etc/hosts"],
59+
"patterns": ["*.bak"],
60+
"statements": ["SELECT 1"],
61+
"syscalls": ["openat"],
62+
"tools": ["sh"],
63+
}
64+
return sample_lists.get(name, ["demo"])
65+
if name == "url":
66+
return "https://example.invalid/health"
67+
if name in {"port", "smtp_port", "to_port"}:
68+
return 22
69+
if name == "mode":
70+
return "0644"
71+
if name == "rule":
72+
return "allow"
73+
if name == "state":
74+
return "present"
75+
if name == "backend":
76+
return "runtime"
77+
if name == "policy":
78+
return "ACCEPT"
79+
if name == "compression":
80+
return "gzip"
81+
if name == "checksum":
82+
return "sha256"
83+
if name == "rich_rule":
84+
return "rule family=ipv4 service name=ssh accept"
85+
if name == "source":
86+
return "10.0.0.0/8"
87+
if name == "archive":
88+
return "/tmp/app.tar.gz"
89+
if name == "value":
90+
return "1"
91+
if name == "query":
92+
return "SELECT 1 AS value"
93+
if name == "acl":
94+
return "u:app:rwx"
95+
if name == "attrs":
96+
return "i"
97+
if name == "schedule":
98+
return "* * * * *"
99+
if name == "command":
100+
return "echo automax"
101+
if name == "content":
102+
return "managed by automax\n"
103+
return "demo"
104+
105+
106+
def sample_params(plugin: Any) -> dict[str, Any]:
107+
"""Build conservative parameters for plugin audit renderers."""
108+
params = {
109+
name: _sample_value(name, plugin.parameter_schema.get(name, {}))
110+
for name in plugin.required_params
111+
}
112+
skipped_optional = {
113+
"attachments",
114+
"bcc",
115+
"cc",
116+
"checks",
117+
"connection",
118+
"env",
119+
"excludes",
120+
"features",
121+
"files",
122+
"groups",
123+
"headers",
124+
"options",
125+
"packages",
126+
"rules",
127+
"search",
128+
"ssh_options",
129+
"values",
130+
}
131+
for name in plugin.optional_params:
132+
if name in skipped_optional:
133+
continue
134+
params.setdefault(name, _sample_value(name, plugin.parameter_schema.get(name, {})))
135+
136+
if plugin.name.startswith("db."):
137+
params.setdefault("connection", {"path": "/tmp/automax.sqlite"})
138+
if plugin.name == "db.health":
139+
params["engine"] = "sqlite"
140+
params["connection"] = {"path": "/tmp/automax.sqlite"}
141+
if plugin.name in {"backup.prune", "backup.restore", "backup.rotate", "fs.remove", "iptables.restore", "lvm.lv_remove", "lvm.pv_remove", "lvm.vg_remove"}:
142+
params["confirm"] = True
143+
if plugin.name == "plugin.requirements":
144+
params["plugin"] = "transfer.rsync"
145+
if plugin.name == "backup.directory":
146+
params["compression"] = "none"
147+
if plugin.name == "backup.verify":
148+
params["checksum"] = "sha256"
149+
if plugin.name == "archive.compress":
150+
params["dest"] = "/tmp/automax-demo.gz"
151+
if plugin.name == "archive.decompress":
152+
params["archive"] = "/tmp/automax-demo.gz"
153+
if plugin.name == "block.wipe_signatures":
154+
params["force"] = True
155+
if plugin.name == "fs.template":
156+
params["src"] = "README.md"
157+
if plugin.name == "network.interface":
158+
params["backend"] = "runtime"
159+
if plugin.name == "fs.remove":
160+
params.update({"path": "/tmp/automax-demo", "allowlist": ["/tmp"], "denylist": ["/etc", "/usr", "/var"], "max_depth": 2, "trash_dir": "/tmp/automax-trash", "backup_path": "/tmp/automax-demo.bak"})
161+
if plugin.name in {"process.kill", "process.signal", "process.wait"}:
162+
params.pop("pid", None)
163+
params["pattern"] = "automax-demo"
164+
if plugin.name == "mail.send":
165+
params["from"] = "automax@example.invalid"
166+
params["to"] = ["ops@example.invalid"]
167+
if plugin.name == "nftables.apply" or plugin.name == "nftables.validate":
168+
params["content"] = "flush ruleset\n"
169+
if plugin.name == "pki.ca_install":
170+
params["name"] = "automax-demo"
171+
params["content"] = "-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----\n"
172+
if plugin.name == "selinux.mode":
173+
params["state"] = "enforcing"
174+
if plugin.name == "fs.quota":
175+
params["type"] = "user"
176+
if plugin.name == "apparmor.profile_assert":
177+
params["state"] = "enforce"
178+
if plugin.name == "auditd.backlog_assert":
179+
params["max_lost"] = 0
180+
params["max_backlog"] = 8192
181+
if plugin.name == "chrony.tracking_assert":
182+
params["max_offset"] = 1.0
183+
params["max_stratum"] = 16
184+
if plugin.name == "iptables.counter_assert":
185+
params["min_packets"] = 1
186+
if plugin.name == "fs.replace":
187+
params["count"] = 0
188+
params["match_count_assert"] = 1
189+
if plugin.name == "sshd.config":
190+
params["match_blocks"] = [{"match": "User deploy", "settings": {"X11Forwarding": "no"}}]
191+
if plugin.name in {"network.dns", "resolver.config"}:
192+
params["backend"] = "plain-file"
193+
if plugin.name in {"network.interface", "network.bond", "network.vlan"}:
194+
params["state"] = "up"
195+
return params
196+
197+
198+
def audit_plugin_registry(registry: PluginRegistry) -> dict[str, Any]:
199+
"""Audit registered plugins for operator preview/readiness coverage."""
200+
context = ExecutionContext(
201+
run_id="plugin-audit",
202+
dry_run=True,
203+
job={},
204+
task={},
205+
step={},
206+
substep={},
207+
target=Target(name="node", host="host"),
208+
vars={},
209+
outputs={},
210+
secrets={},
211+
)
212+
failures: list[str] = []
213+
checked = 0
214+
for name in registry.names():
215+
checked += 1
216+
plugin = registry.get(name)
217+
params = sample_params(plugin)
218+
try:
219+
commands = plugin.manual_commands(params, context)
220+
if not commands or not all(isinstance(command, str) and command.strip() for command in commands):
221+
failures.append(f"{name}: empty manual_commands")
222+
rendered = "\n".join(commands)
223+
if "mktemp" in rendered and "trap 'rm -f" not in rendered:
224+
failures.append(f"{name}: mktemp manual_commands without cleanup trap")
225+
except Exception as exc: # pragma: no cover - surfaced to operator output
226+
failures.append(f"{name}: manual_commands raised {exc!r}")
227+
try:
228+
preview = plugin.diff_preview(params, context)
229+
reason = plugin.diff_preview_reason(params, context)
230+
if not preview and not reason:
231+
failures.append(f"{name}: no diff_preview and no diff_preview_reason")
232+
except Exception as exc: # pragma: no cover - surfaced to operator output
233+
failures.append(f"{name}: diff_preview raised {exc!r}")
234+
try:
235+
dry_run = plugin.dry_run(params, context)
236+
if not dry_run.ok or dry_run.changed:
237+
failures.append(f"{name}: dry_run not safe/unchanged")
238+
except Exception as exc: # pragma: no cover - surfaced to operator output
239+
failures.append(f"{name}: dry_run raised {exc!r}")
240+
241+
return {
242+
"ok": not failures,
243+
"checked": checked,
244+
"failures": failures,
245+
"failure_count": len(failures),
246+
}

tests/test_documentation_and_regressions.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -436,6 +436,28 @@ def test_secret_values_are_masked_in_persisted_result_mapping():
436436
assert mapped["data"] == {"nested": ["***"]}
437437

438438

439+
440+
441+
def test_plugins_audit_command_reports_builtin_readiness():
442+
result = CliRunner().invoke(cli, ["plugins", "audit"])
443+
444+
assert result.exit_code == 0, result.output
445+
assert "Plugin audit:" in result.output
446+
assert "Result: OK" in result.output
447+
448+
json_result = CliRunner().invoke(cli, ["plugins", "audit", "--format", "json"])
449+
assert json_result.exit_code == 0, json_result.output
450+
payload = json.loads(json_result.output)
451+
assert payload["ok"] is True
452+
assert payload["failure_count"] == 0
453+
assert payload["checked"] > 100
454+
455+
docs = "\n".join(
456+
Path(path).read_text(encoding="utf-8")
457+
for path in ["README.md", "docs/reference/cli.md", "docs/concepts/plugin-system.md", "docs/guides/creating-plugins.md"]
458+
)
459+
assert "automax plugins audit" in docs
460+
439461
def test_plugins_describe_outputs_parameter_metadata():
440462
runner = CliRunner()
441463
result = runner.invoke(cli, ["plugins", "describe", "fs.template"])

0 commit comments

Comments
 (0)