mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-15 11:16:50 +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>
297 lines
12 KiB
Python
297 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""Checks a journal migration without reading the whole script.
|
|
|
|
Migrating a function to the change journal must not change what the
|
|
function does — only how it writes. That is a narrow claim, and a narrow
|
|
claim can be verified mechanically, which is the point of this: reviewing
|
|
a four-thousand-line shell script by eye is how a byte-level difference
|
|
in a configuration file gets shipped.
|
|
|
|
Run it against the pre-migration version of the same file:
|
|
|
|
verify_journal_migration.py --before original.sh --after migrated.sh
|
|
|
|
The pre-migration version is whatever the repository had before the work
|
|
started, for example:
|
|
|
|
git show HEAD:scripts/post_install/customizable_post_install.sh
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
# Writes that reach the host. A heredoc into /tmp is a composition step,
|
|
# not a change, so paths under /tmp are excluded from the search.
|
|
DIRECT_WRITE = re.compile(
|
|
r"""(?x)
|
|
(?:cat|printf|echo|tee)\s*(?:<<-?\s*['"]?\w+['"]?\s*)?>{1,2}\s*["']?(?:/etc|/usr|/var|/boot|/root|\$\{?(?:config_file|sysctl_conf|conf|target))
|
|
| sed\s+-i(?!\s+[^|;&]*\s/tmp/)
|
|
| systemctl\s+(?:enable|disable)\s+--now
|
|
""")
|
|
|
|
# A heredoc body: what the function actually writes. The delimiter is
|
|
# usually followed by a redirection on the same line — `<<EOF > "$file"`
|
|
# — so everything up to the newline is skipped before the body starts.
|
|
HEREDOC = re.compile(r"<<-?\s*['\"]?(\w+)['\"]?[^\n]*\n(.*?)^\1\s*$", re.M | re.S)
|
|
|
|
# A backup the uninstaller may restore from. The path is often a
|
|
# variable — `cp -n "$conf" "$backup_conf"` — so the copy itself is what
|
|
# is matched, not the .bak suffix.
|
|
BACKUP = re.compile(r"cp\s+(?:-n\s+)?[^\n]*(?:\.bak|backup_conf|_backup|\bbackup\b)")
|
|
|
|
|
|
# Both declaration forms bash accepts, because a file written in the
|
|
# `function name() {` style used to yield no functions at all: the
|
|
# walker saw none, the sanity check counted none, the two agreed, and
|
|
# the file passed without a single one of its bodies being read.
|
|
FUNC_START = re.compile(
|
|
r"^(?:function\s+([A-Za-z_][A-Za-z0-9_-]*)\s*(?:\(\))?"
|
|
r"|([A-Za-z_][A-Za-z0-9_-]*)\s*\(\))\s*\{\s*$", re.M)
|
|
HEREDOC_START = re.compile(r"<<-?\s*['\"]?(\w+)['\"]?")
|
|
|
|
|
|
def functions(source: str) -> list[tuple[str, str]]:
|
|
"""Every top-level shell function and its body, in declaration order.
|
|
|
|
A list rather than a mapping because a script may declare the same
|
|
name twice — the later definition is the one bash keeps, but both are
|
|
in the file. Keyed by name, the first body vanished and its lines
|
|
were then counted as top-level code that nothing had recorded.
|
|
|
|
Walked line by line rather than matched with a regular expression,
|
|
because these scripts embed whole files in heredocs and several of
|
|
those contain a closing brace in the first column — a systemd unit,
|
|
an awk program, a shell script being installed. A regex that ends the
|
|
function at the first such line cuts it in half, and everything after
|
|
the cut looks like top-level code that nothing is checking.
|
|
"""
|
|
found: list[tuple[str, str]] = []
|
|
lines = source.splitlines()
|
|
i, total = 0, len(lines)
|
|
while i < total:
|
|
match = FUNC_START.match(lines[i])
|
|
if not match:
|
|
i += 1
|
|
continue
|
|
name = match.group(1) or match.group(2)
|
|
body, depth, delimiter = [], 1, None
|
|
i += 1
|
|
while i < total and depth > 0:
|
|
line = lines[i]
|
|
if delimiter is not None:
|
|
# Inside a heredoc nothing counts as shell syntax. The
|
|
# closing line is usually the delimiter alone, but these
|
|
# scripts also nest heredocs inside quoted strings passed
|
|
# to `pct exec`, where the terminator carries the closing
|
|
# quote: `EOF"`. Treating only the exact form as a close
|
|
# swallows the rest of the file and silently merges every
|
|
# function after it.
|
|
stripped = line.strip()
|
|
if stripped == delimiter or (
|
|
stripped.startswith(delimiter)
|
|
and stripped[len(delimiter):].strip(" \"';)") == ""):
|
|
delimiter = None
|
|
else:
|
|
opened = HEREDOC_START.search(line)
|
|
if opened:
|
|
delimiter = opened.group(1)
|
|
elif line == "}":
|
|
depth -= 1
|
|
if depth == 0:
|
|
break
|
|
body.append(line)
|
|
i += 1
|
|
found.append((name, "\n".join(body)))
|
|
i += 1
|
|
return found
|
|
|
|
|
|
def heredocs(body: str) -> list[str]:
|
|
"""Contents written by a function, in order, ignoring the delimiters."""
|
|
return [text for _, text in HEREDOC.findall(body)]
|
|
|
|
|
|
def _without_heredocs(source: str) -> str:
|
|
"""The script with heredoc bodies removed, line count preserved.
|
|
|
|
What a script writes into a file is content, not code: a function
|
|
declared inside a heredoc belongs to the file being installed.
|
|
"""
|
|
out, delimiter = [], None
|
|
for line in source.splitlines():
|
|
if delimiter is not None:
|
|
stripped = line.strip()
|
|
if stripped == delimiter or (
|
|
stripped.startswith(delimiter)
|
|
and stripped[len(delimiter):].strip(" \"';)") == ""):
|
|
delimiter = None
|
|
out.append("")
|
|
continue
|
|
opened = HEREDOC_START.search(line)
|
|
out.append(line)
|
|
if opened:
|
|
delimiter = opened.group(1)
|
|
return "\n".join(out)
|
|
|
|
|
|
def _top_level(source: str) -> str:
|
|
"""The script with every function body removed.
|
|
|
|
Built by subtracting the bodies the walker found, so a heredoc
|
|
containing a closing brace cannot make half a function look like
|
|
top-level code.
|
|
"""
|
|
remaining = source
|
|
for _, body in functions(source):
|
|
if body:
|
|
remaining = remaining.replace(body, "", 1)
|
|
return remaining
|
|
|
|
|
|
def _heredocs_of(source: str) -> list[str]:
|
|
return [text for _, text in HEREDOC.findall(source)]
|
|
|
|
|
|
def check(before_path: Path, after_path: Path) -> int:
|
|
before = functions(before_path.read_text())
|
|
after = functions(after_path.read_text())
|
|
problems: list[str] = []
|
|
migrated: list[str] = []
|
|
|
|
# The file has to be valid shell before anything else is worth saying.
|
|
syntax = subprocess.run(["bash", "-n", str(after_path)],
|
|
capture_output=True, text=True)
|
|
if syntax.returncode != 0:
|
|
print(f"FAIL bash -n: {syntax.stderr.strip()}")
|
|
return 1
|
|
|
|
before_by_name: dict[str, list[str]] = {}
|
|
for name, body in before:
|
|
before_by_name.setdefault(name, []).append(body)
|
|
after_by_name: dict[str, list[str]] = {}
|
|
for name, body in after:
|
|
after_by_name.setdefault(name, []).append(body)
|
|
|
|
gone = sorted(set(before_by_name) - set(after_by_name))
|
|
if gone:
|
|
problems.append(f"functions removed: {', '.join(gone)}")
|
|
|
|
# A name declared more than once is a property of the script, not a
|
|
# fault in the migration. Stated so the reader knows which body the
|
|
# results below belong to, and not counted against the file.
|
|
repeated = sorted(n for n, bodies in after_by_name.items() if len(bodies) > 1)
|
|
for name in repeated:
|
|
print(f"note: {name} is declared {len(after_by_name[name])} times; "
|
|
f"each declaration is checked against its own original")
|
|
|
|
# Sanity: the walker must find every function the file declares. If
|
|
# it finds fewer, it merged some, and everything it reported about
|
|
# them is unreliable — a green result on a file it did not read.
|
|
#
|
|
# Counted with the heredocs removed, because these scripts install
|
|
# other scripts by writing them out, and a function declared inside
|
|
# one of those belongs to the installed file, not to this one.
|
|
declared = len(FUNC_START.findall(_without_heredocs(after_path.read_text())))
|
|
if declared != len(after):
|
|
problems.append(
|
|
f"parser found {len(after)} functions but the file declares "
|
|
f"{declared}; the result cannot be trusted")
|
|
|
|
# Everything above only looks inside functions. A script that acts at
|
|
# the top level — and several do — was invisible to this check, which
|
|
# is exactly where an unrecorded write would hide.
|
|
outside_before = _top_level(before_path.read_text())
|
|
outside_after = _top_level(after_path.read_text())
|
|
if "pmx_journal" in outside_after or any(
|
|
"pmx_journal_context" in body for _, body in after):
|
|
# Scanned with the heredoc bodies blanked: a script that installs
|
|
# another script writes that script's own `sed -i` lines as
|
|
# content, and the contract forbids touching what is written.
|
|
direct = [m.group(0).strip()
|
|
for m in DIRECT_WRITE.finditer(_without_heredocs(outside_after))]
|
|
if direct:
|
|
problems.append(
|
|
f"top level: {len(direct)} write(s) still reach the host directly — "
|
|
f"{direct[0][:70]}")
|
|
if _heredocs_of(outside_before) != _heredocs_of(outside_after):
|
|
problems.append("top level: the content written outside any function changed")
|
|
|
|
occurrence: dict[str, int] = {}
|
|
for name, body in after:
|
|
index = occurrence.get(name, 0)
|
|
occurrence[name] = index + 1
|
|
if "pmx_journal_context" not in body:
|
|
continue
|
|
migrated.append(name if index == 0 else f"{name} (declaration {index + 1})")
|
|
originals = before_by_name.get(name, [])
|
|
if index >= len(originals):
|
|
problems.append(f"{name}: declaration {index + 1} was not present "
|
|
f"before the migration")
|
|
continue
|
|
original = originals[index]
|
|
|
|
# 1. One context, naming the function it sits in.
|
|
contexts = re.findall(r'pmx_journal_context\s+"([^"]+)"', body)
|
|
if len(contexts) != 1:
|
|
problems.append(f"{name}: {len(contexts)} calls to pmx_journal_context, expected 1")
|
|
elif contexts[0] != name:
|
|
problems.append(f"{name}: context declares '{contexts[0]}'")
|
|
|
|
# 2. Nothing still writes to the host directly. The heredoc
|
|
# bodies are blanked first: a `sed -i` inside a script this
|
|
# function installs is that script's line, not this one's, and
|
|
# rewriting it is exactly what the contract forbids.
|
|
direct = [m.group(0).strip()
|
|
for m in DIRECT_WRITE.finditer(_without_heredocs(body))]
|
|
if direct:
|
|
problems.append(f"{name}: still writes directly — {direct[0][:70]}")
|
|
|
|
# 3. What it writes has to be what it wrote before. This is the
|
|
# check that matters: a migration that alters a configuration
|
|
# file by one byte is a behaviour change wearing a refactor.
|
|
if heredocs(original) != heredocs(body):
|
|
before_docs, after_docs = heredocs(original), heredocs(body)
|
|
if len(before_docs) != len(after_docs):
|
|
problems.append(
|
|
f"{name}: wrote {len(before_docs)} block(s) before, {len(after_docs)} now")
|
|
else:
|
|
for i, (was, now) in enumerate(zip(before_docs, after_docs)):
|
|
if was != now:
|
|
problems.append(f"{name}: content of block {i + 1} changed")
|
|
|
|
# 4. A backup the uninstaller depends on must survive.
|
|
if BACKUP.search(original) and not BACKUP.search(body):
|
|
problems.append(f"{name}: the .bak copy was removed; "
|
|
f"uninstall-tools.sh restores from it")
|
|
|
|
# 5. Registration is untouched.
|
|
if original.count("register_tool") != body.count("register_tool"):
|
|
problems.append(f"{name}: register_tool calls changed")
|
|
|
|
print(f"functions migrated: {len(migrated)}")
|
|
for name in migrated:
|
|
print(f" {name}")
|
|
if problems:
|
|
print(f"\n{len(problems)} problem(s):")
|
|
for problem in problems:
|
|
print(f" {problem}")
|
|
return 1
|
|
print("\nno problems found")
|
|
return 0
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--before", required=True, type=Path)
|
|
parser.add_argument("--after", required=True, type=Path)
|
|
args = parser.parse_args()
|
|
return check(args.before, args.after)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|