mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-14 18:56:52 +00:00
ProxMenux modifies the host: it rewrites configuration files, installs packages, enables services. Until now nobody could say afterwards what had changed, and showing the script does not answer that question — a four-hundred-line function may alter two values, and the reader has no way to know which two. This adds the two halves of an answer. The change journal records what ProxMenux does as it does it. Eleven bash primitives capture the previous state, apply the change and record it in the same step, writing to a spool that the Monitor reads back. One hundred and thirteen functions across twenty-five scripts are instrumented, covering post-install, shared storage, security tooling, container conversions, disk operations and the PVE 8 to 9 upgrade path. The page shows the difference — rotate 7 becoming rotate 14 — and never the script. Restore and backup scripts are deliberately left out: a restore puts the host back to a state some other script already recorded. The Audit and reports page answers the other half: what state is this host in, regardless of who put it there. Forty-three checks across seven areas read the host and classify each result as critical, warning, observation, conformant, unverified or not applicable, with the evidence they read attached to each one. A declared policy lets the reader say what this particular host is expected to do — which guests must have a backup, which storages are essential — so the report judges the host against its own intent rather than a generic template. An inventory records the hardware, network and guest topology behind those readings, a comparison shows what moved between two runs, and six report profiles produce a printable document scoped to what the reader needs. Everything is available in the eight supported languages. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
127 lines
5.9 KiB
Python
127 lines
5.9 KiB
Python
"""Policy validation and atomic updates. All writes stay in temporary directories."""
|
|
import concurrent.futures
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from unittest.mock import patch
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "AppImage/scripts"))
|
|
import audit_policy as policy
|
|
|
|
|
|
class AuditPolicyTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.temp = tempfile.TemporaryDirectory()
|
|
self.addCleanup(self.temp.cleanup)
|
|
self.path = Path(self.temp.name) / "policy.json"
|
|
|
|
def save(self, raw, **kwargs):
|
|
return policy.save(raw, self.path, **kwargs)
|
|
|
|
def test_missing_is_not_declared(self):
|
|
value = policy.load(self.path)
|
|
self.assertFalse(value.declared)
|
|
self.assertIsNone(value.error)
|
|
self.assertEqual(value.revision, "missing")
|
|
|
|
def test_inheritance_and_explicit_unspecified_round_trip(self):
|
|
value = self.save({"defaults": {"backup": "required", "autostart": "required",
|
|
"storage_role": "essential", "recovery_objective_hours": 48},
|
|
"guests": {"100": {"backup": "unspecified", "autostart": "not_required"}},
|
|
"storages": {"local": {"role": "unspecified"}}})
|
|
self.assertEqual(value.backup_required(100), "unspecified")
|
|
self.assertEqual(value.backup_required(101), "required")
|
|
self.assertEqual(value.autostart_required(100), "not_required")
|
|
self.assertEqual(value.storage_role("local"), "unspecified")
|
|
self.assertEqual(value.storage_role("pbs"), "essential")
|
|
self.assertEqual(value.recovery_objective_hours(100), 48)
|
|
|
|
def test_invalid_numbers_rejected_without_changing_saved_policy(self):
|
|
self.save({"thresholds": {"storage_usage_percent": 90}})
|
|
before = self.path.read_bytes()
|
|
for value in (True, False, 0, -1, float("nan"), float("inf"), -float("inf"), "12", 10**400):
|
|
for raw in ({"thresholds": {"storage_usage_percent": value}},
|
|
{"guests": {"100": {"recovery_objective_hours": value}}},
|
|
{"defaults": {"recovery_objective_hours": value}}):
|
|
with self.subTest(raw=raw), self.assertRaises(ValueError):
|
|
self.save(raw)
|
|
self.assertEqual(before, self.path.read_bytes())
|
|
|
|
def test_percentage_bounds_and_positive_fractional_values(self):
|
|
with self.assertRaises(ValueError):
|
|
self.save({"thresholds": {"storage_usage_percent": 101}})
|
|
value = self.save({"thresholds": {"storage_usage_percent": 100, "thin_overprovision_ratio": 2.5},
|
|
"guests": {"100": {"recovery_objective_hours": 0.5}}})
|
|
self.assertEqual(value.recovery_objective_hours(100), 0.5)
|
|
self.assertEqual(value.threshold("thin_overprovision_ratio"), 2.5)
|
|
|
|
def test_invalid_sections_and_defaults_rejected(self):
|
|
for name in ("guests", "storages", "thresholds", "defaults"):
|
|
for value in ([], False, "", None):
|
|
with self.subTest(name=name, value=value), self.assertRaises(ValueError):
|
|
self.save({name: value})
|
|
for name in ("backup", "autostart", "storage_role"):
|
|
with self.assertRaises(ValueError):
|
|
self.save({"defaults": {name: "invalid"}})
|
|
|
|
def test_manual_invalid_file_is_visible_and_not_overwritten(self):
|
|
self.path.write_text('{"thresholds":{"storage_usage_percent":Infinity}}')
|
|
value = policy.load(self.path)
|
|
self.assertTrue(value.error)
|
|
with self.assertRaises(ValueError):
|
|
self.save({}, expected_revision=value.revision)
|
|
|
|
def test_stale_editor_is_rejected(self):
|
|
first = self.save({})
|
|
second = self.save({"defaults": {"backup": "required"}}, expected_revision=first.revision)
|
|
with self.assertRaises(policy.PolicyConflict):
|
|
self.save({}, expected_revision=first.revision)
|
|
self.assertEqual(policy.load(self.path).revision, second.revision)
|
|
|
|
def test_deleted_file_is_also_a_conflict(self):
|
|
first = self.save({})
|
|
self.path.unlink()
|
|
with self.assertRaises(policy.PolicyConflict):
|
|
self.save({}, expected_revision=first.revision)
|
|
|
|
def test_concurrent_editors_only_one_can_save(self):
|
|
revision = self.save({}).revision
|
|
def write(i):
|
|
try:
|
|
self.save({"guests": {str(i): {"backup": "required"}}}, expected_revision=revision)
|
|
return "saved"
|
|
except policy.PolicyConflict:
|
|
return "conflict"
|
|
with concurrent.futures.ThreadPoolExecutor(max_workers=8) as pool:
|
|
results = list(pool.map(write, range(100, 108)))
|
|
self.assertEqual(results.count("saved"), 1)
|
|
self.assertEqual(results.count("conflict"), 7)
|
|
self.assertEqual(len(json.loads(self.path.read_text())["guests"]), 1)
|
|
self.assertEqual(list(self.path.parent.glob(".audit-policy-*")), [])
|
|
|
|
def test_failed_replace_preserves_original_and_cleans_temp(self):
|
|
self.save({})
|
|
before = self.path.read_bytes()
|
|
with patch.object(Path, "replace", side_effect=OSError("fixture failure")):
|
|
with self.assertRaises(OSError):
|
|
self.save({"defaults": {"backup": "required"}})
|
|
self.assertEqual(before, self.path.read_bytes())
|
|
self.assertEqual(list(self.path.parent.glob(".audit-policy-*")), [])
|
|
|
|
def test_private_permissions_and_same_mtime_changes(self):
|
|
first = self.save({})
|
|
self.assertEqual(self.path.stat().st_mode & 0o777, 0o600)
|
|
stamp = self.path.stat().st_mtime_ns
|
|
self.path.write_text('{"defaults":{"backup":"required"}}')
|
|
os.utime(self.path, ns=(stamp, stamp))
|
|
fresh = policy.load(self.path)
|
|
self.assertNotEqual(first.revision, fresh.revision)
|
|
self.assertEqual(fresh.backup_required(100), "required")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|