Files
ProxMenux/AppImage/scripts/post_install_versions.py
2026-07-23 22:55:28 +02:00

474 lines
18 KiB
Python

"""Sprint 12A: Detect ProxMenux post-install function updates.
Parses /usr/local/share/proxmenux/scripts/post_install/{auto,customizable}_post_install.sh,
extracting the ``# version: X.Y`` and ``# description: ...`` comments
declared inside each top-level function. Compares the parsed versions
against the per-tool entries in ``installed_tools.json`` and returns the
list of tools where the on-disk script has bumped past what the user
installed.
The detection runs once at AppImage startup, before the rest of the
update-check pipeline kicks in, and the result is cached in memory and
persisted to ``updates_available.json`` so the bash menu and the
notification poller can read it without re-parsing.
Backward compatibility: ``installed_tools.json`` was originally a flat
dict of ``{key: bool}``. Sprint 12A adds the structured
``{key: {installed, version, source}}`` shape. Legacy booleans are read
as installed (true) at version ``1.0`` with source unknown. Unknown
source means the detector still flags an available update, but the UI
falls back to asking the user which flow (auto vs custom) to run.
"""
from __future__ import annotations
import json
import re
import threading
import time
from pathlib import Path
import os
from typing import Any, Callable
_BASE = Path("/usr/local/share/proxmenux")
_POST_INSTALL_DIR = _BASE / "scripts" / "post_install"
_AUTO_SCRIPT = _POST_INSTALL_DIR / "auto_post_install.sh"
_CUSTOM_SCRIPT = _POST_INSTALL_DIR / "customizable_post_install.sh"
_INSTALLED_JSON = _BASE / "installed_tools.json"
_UPDATES_JSON = _BASE / "updates_available.json"
# Match a top-level bash function definition: func_name() {
_FN_DEF_RE = re.compile(r"^(?P<name>[a-zA-Z_][a-zA-Z0-9_]*)\s*\(\)\s*\{\s*$")
# Sprint 12A v2: read `local FUNC_VERSION="X.Y"` rather than a
# `# version:` comment. Bash's `declare -f` strips comments at parse
# time, so the comment-based version was lost the moment the update
# wrapper sourced the script and re-ran the function — register_tool
# always saw the default 1.0 fallback. A `local` assignment survives
# `declare -f` round-trip and runs at function invocation time.
_VERSION_RE = re.compile(r'local\s+FUNC_VERSION\s*=\s*"([0-9]+(?:\.[0-9]+)+)"')
_DESC_RE = re.compile(r"#\s*description\s*:\s*([^\n]+)")
_REGISTER_RE = re.compile(r'\bregister_tool\s+"([^"]+)"\s+true\b')
# Matches a heredoc opener and captures its terminator word. Handles
# `<<EOF`, `<<-EOF`, `<< EOF`, `<<'EOF'` and `<<"EOF"`.
_HEREDOC_RE = re.compile(r'<<[-~]?\s*["\']?([A-Za-z_][A-Za-z0-9_]*)["\']?')
# In-memory cache of the last scan. Sprint 12A uses a single startup scan
# plus on-demand re-scan via the API; no automatic refresh.
_cache_lock = threading.Lock()
_cache: dict[str, Any] = {
"scanned_at": 0.0,
"auto": {}, # tool_key -> {function, version, description}
"custom": {}, # same shape
"installed": {}, # normalized installed_tools.json
"updates": [], # list of update dicts
}
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _version_tuple(value: str) -> tuple[int, ...]:
"""Convert "1.2.3" → (1, 2, 3) for safe ordered comparison.
Non-numeric segments are dropped silently so a stray "1.0a" doesn't
crash the comparator. An empty/None input returns (0,) so missing
metadata is treated as the lowest possible version.
"""
if not value:
return (0,)
parts: list[int] = []
for chunk in str(value).split("."):
m = re.match(r"\d+", chunk)
if m:
parts.append(int(m.group(0)))
return tuple(parts) if parts else (0,)
def _read_text(path: Path) -> str:
try:
return path.read_text(encoding="utf-8", errors="replace")
except OSError:
return ""
# ---------------------------------------------------------------------------
# Bash script parser
# ---------------------------------------------------------------------------
def parse_post_install_script(path: Path) -> dict[str, dict[str, str]]:
"""Walk a post-install bash script and return ``{tool_key: meta}``.
For each top-level ``func_name() {`` block, scan the body for the
first ``# version:`` and ``# description:`` comments and the first
``register_tool "key" true`` call. The tool key is taken from that
register_tool — bash function names like ``install_log2ram_auto``
don't match the user-facing key ``log2ram`` directly, so we use the
register_tool argument as the source of truth.
Returns an empty dict if the file is missing or unparseable so the
detector keeps running on partial installs.
"""
text = _read_text(path)
if not text:
return {}
lines = text.splitlines()
result: dict[str, dict[str, str]] = {}
i = 0
while i < len(lines):
line = lines[i]
match = _FN_DEF_RE.match(line)
if not match:
i += 1
continue
func_name = match.group("name")
# Find the matching closing brace at column 0. Top-level post-install
# functions close with `}` alone on a line. A bare scan for that line
# breaks when the body embeds a heredoc whose content also has `}` at
# column 0 — e.g. a logrotate stanza written via `cat >... <<EOF`.
# That truncated the body before its `register_tool` call, so tools
# like log2ram / network_optimization were never registered and never
# flagged as updatable. Track heredoc state and ignore `}` inside one.
body_start = i + 1
body_end = body_start
heredoc_term = None
while body_end < len(lines):
current = lines[body_end]
if heredoc_term is not None:
# Inside a heredoc — only its terminator line ends it.
if current.strip() == heredoc_term:
heredoc_term = None
body_end += 1
continue
if current.rstrip() == "}":
break
# A line may open a heredoc (use the last opener on the line, so
# `cmd <<A | cmd <<B` picks B, matching shell behaviour).
openers = _HEREDOC_RE.findall(current)
if openers:
heredoc_term = openers[-1]
body_end += 1
body = "\n".join(lines[body_start:body_end])
version_match = _VERSION_RE.search(body)
desc_match = _DESC_RE.search(body)
register_match = _REGISTER_RE.search(body)
if register_match:
tool_key = register_match.group(1)
entry = {
"function": func_name,
"version": version_match.group(1) if version_match else "1.0",
"description": desc_match.group(1).strip() if desc_match else "",
}
# If the same tool key is registered by multiple functions
# within the same script (rare — usually a tool has one
# canonical install function per script), keep the highest
# version — that's the one the user would land on after a
# full re-run.
existing = result.get(tool_key)
if existing is None or _version_tuple(entry["version"]) > _version_tuple(existing["version"]):
result[tool_key] = entry
i = body_end + 1
return result
# ---------------------------------------------------------------------------
# Installed tools loader (backward compat)
# ---------------------------------------------------------------------------
def load_installed_tools(path: Path = _INSTALLED_JSON) -> dict[str, dict[str, Any]]:
"""Load installed_tools.json normalising both the legacy boolean
shape and the new structured object shape.
Returns ``{tool_key: {"installed": bool, "version": str, "source": str}}``.
Legacy ``true`` entries become ``{installed: true, version: "1.0",
source: ""}``. Legacy ``false`` entries (uninstalled marker) come
back as ``{installed: false, ...}`` and the detector skips them.
"""
try:
raw = json.loads(_read_text(path) or "{}")
except json.JSONDecodeError:
return {}
normalized: dict[str, dict[str, Any]] = {}
for key, value in raw.items():
if isinstance(value, bool):
normalized[key] = {
"installed": value,
"version": "1.0" if value else "",
"source": "",
}
elif isinstance(value, dict):
normalized[key] = {
"installed": bool(value.get("installed", False)),
"version": str(value.get("version", "1.0")) or "1.0",
"source": str(value.get("source", "") or ""),
}
else:
# Unknown shape — treat as not installed rather than crash.
normalized[key] = {"installed": False, "version": "", "source": ""}
_apply_legacy_detectors(normalized)
return normalized
# Tool keys that were added later than 1.2.2 and therefore are missing
# from installed_tools.json on hosts that already had the underlying
# config in place. For each, declare how to recognise "this host has
# the thing configured already" so the registry surfaces it as a v1.0
# legacy install — the per-tool wrapper then bumps it to the current
# FUNC_VERSION on first apply, and from then on the regular update
# detector takes over without any special-casing.
_LEGACY_DETECTORS: dict[str, Callable[[], bool]] = {
"proxmox_repos": lambda: any(
os.path.exists(p) for p in (
"/etc/apt/sources.list.d/proxmox.sources",
"/etc/apt/sources.list.d/pve-no-subscription.list",
"/etc/apt/sources.list.d/pve-enterprise.list",
)
),
}
def _apply_legacy_detectors(normalized: dict[str, dict[str, Any]]) -> None:
"""Inject synthetic v1.0 entries for tools that the host already
has configured but were never recorded in installed_tools.json.
Skips any tool that already has an entry — once the user applies
the update, ``register_tool`` writes the real entry and the
detector path becomes a no-op for that key.
"""
for key, probe in _LEGACY_DETECTORS.items():
if key in normalized:
continue
try:
if probe():
normalized[key] = {
"installed": True,
"version": "1.0",
"source": "detected",
}
except Exception:
# Detectors must never break the registry load.
continue
# ---------------------------------------------------------------------------
# Detection logic
# ---------------------------------------------------------------------------
def _detect_updates(
auto_meta: dict[str, dict[str, str]],
custom_meta: dict[str, dict[str, str]],
installed: dict[str, dict[str, Any]],
) -> list[dict[str, Any]]:
"""Compare declared versions vs installed versions for each tool.
The source recorded in installed_tools.json picks which script to
compare against:
- source == "auto" → auto_meta[key]
- source == "custom" → custom_meta[key]
- source missing → falls back to whichever script declares the
tool. If both do, prefer auto (the simpler flow). The UI can
still ask the user which flow to run on update — Sprint 12A only
exposes the available version, not the runner.
"""
updates: list[dict[str, Any]] = []
for key, info in installed.items():
if not info.get("installed"):
continue
installed_version = info.get("version") or "1.0"
source = info.get("source") or ""
meta = None
chosen_source = source
if source == "auto":
meta = auto_meta.get(key)
elif source == "custom":
meta = custom_meta.get(key)
else:
meta = auto_meta.get(key) or custom_meta.get(key)
chosen_source = "auto" if key in auto_meta else ("custom" if key in custom_meta else "")
if not meta:
# Tool is installed but not declared in either script (could
# be from a global helper script — see Sprint 12A scope
# notes). Skip silently rather than flag a phantom update.
continue
declared_version = meta.get("version", "1.0")
if _version_tuple(declared_version) > _version_tuple(installed_version):
updates.append({
"key": key,
"function": meta.get("function", ""),
"description": meta.get("description", ""),
"current_version": installed_version,
"available_version": declared_version,
"source": chosen_source,
"source_certain": bool(source),
})
# Stable ordering helps the UI render a deterministic list.
updates.sort(key=lambda u: u["key"])
return updates
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def scan(persist: bool = True) -> dict[str, Any]:
"""Run a full scan and refresh the in-memory cache.
Parses both post-install scripts, reads the installed_tools JSON,
computes the update list, and (optionally) writes the result to
``updates_available.json`` for non-Python consumers (the bash menu
in Sprint 12C).
"""
auto_meta = parse_post_install_script(_AUTO_SCRIPT)
custom_meta = parse_post_install_script(_CUSTOM_SCRIPT)
installed = load_installed_tools()
updates = _detect_updates(auto_meta, custom_meta, installed)
snapshot = {
"scanned_at": time.time(),
"auto": auto_meta,
"custom": custom_meta,
"installed": installed,
"updates": updates,
}
with _cache_lock:
_cache.update(snapshot)
if persist:
try:
_UPDATES_JSON.parent.mkdir(parents=True, exist_ok=True)
_UPDATES_JSON.write_text(
json.dumps(
{"scanned_at": snapshot["scanned_at"], "updates": updates},
indent=2,
),
encoding="utf-8",
)
except OSError:
# Writing the on-disk cache is best-effort. If /usr/local
# is read-only (some hardened setups) the in-memory cache
# still serves the API.
pass
return snapshot
def scan_at_startup() -> dict[str, Any]:
"""Convenience wrapper called from flask_server startup.
Wraps ``scan()`` with broad exception handling so a parse failure
can never break the AppImage boot sequence — the rest of the
update-check pipeline (Proxmox upgrade scan, ProxMenux self-update)
must run regardless of whether post-install detection works.
"""
try:
return scan(persist=True)
except Exception as e: # noqa: BLE001 — startup best-effort
print(f"[post_install_versions] startup scan failed: {e}")
return {"scanned_at": time.time(), "updates": []}
def _ensure_fresh_cache() -> None:
"""Re-run a scan when any of the inputs to the last scan have been
modified since it completed.
The relevant inputs are:
• ``installed_tools.json`` — bumped by ``register_tool`` in bash
after a successful install/update. Without this, the badge count
would lag a successful update until the next 24h cycle.
• ``auto_post_install.sh`` / ``customizable_post_install.sh`` —
bumped when the user pulls a new version of the ProxMenux repo
(or when ``scripts/`` is rsynced). Without this, scripts on
disk could declare a newer ``FUNC_VERSION`` than the cached
scan saw, so updates would silently fail to surface until the
AppImage is restarted.
"""
latest_input_mtime = 0.0
for path in (_INSTALLED_JSON, _AUTO_SCRIPT, _CUSTOM_SCRIPT):
try:
mtime = path.stat().st_mtime
except OSError:
continue
if mtime > latest_input_mtime:
latest_input_mtime = mtime
if latest_input_mtime == 0.0:
return
with _cache_lock:
last_scanned = _cache.get("scanned_at", 0.0)
if latest_input_mtime > last_scanned:
try:
scan(persist=True)
except Exception as e: # noqa: BLE001 — best-effort refresh
print(f"[post_install_versions] auto-refresh scan failed: {e}")
def get_updates() -> list[dict[str, Any]]:
"""Return the cached update list (most recent scan)."""
_ensure_fresh_cache()
with _cache_lock:
return list(_cache.get("updates", []))
def get_snapshot() -> dict[str, Any]:
"""Return a shallow copy of the entire cache snapshot."""
_ensure_fresh_cache()
with _cache_lock:
return {
"scanned_at": _cache.get("scanned_at", 0.0),
"auto": dict(_cache.get("auto", {})),
"custom": dict(_cache.get("custom", {})),
"installed": dict(_cache.get("installed", {})),
"updates": list(_cache.get("updates", [])),
}
def get_metadata_for_tool(key: str) -> dict[str, str] | None:
"""Return ``{version, description, function, source}`` for a tool.
Used by the existing ``/api/proxmenux/installed-tools`` endpoint so
it can serve the live declared version + description instead of the
hard-coded TOOL_METADATA table. Picks the entry that matches the
installed source when available; falls back to whichever script
declares the tool.
"""
snapshot = get_snapshot()
installed = snapshot["installed"].get(key, {})
source = installed.get("source") or ""
auto = snapshot["auto"].get(key)
custom = snapshot["custom"].get(key)
if source == "auto" and auto:
chosen, chosen_source = auto, "auto"
elif source == "custom" and custom:
chosen, chosen_source = custom, "custom"
elif auto:
chosen, chosen_source = auto, "auto"
elif custom:
chosen, chosen_source = custom, "custom"
else:
return None
return {
"version": chosen.get("version", "1.0"),
"description": chosen.get("description", ""),
"function": chosen.get("function", ""),
"source": chosen_source,
}