|
| 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 | + } |
0 commit comments