mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-02 19:02:31 +00:00
fix: preserve update observations for stopped LXC guests
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
"""Trusted, run-scoped package observation captured before a temporary CT stops.
|
||||
|
||||
The runner only writes this private handoff, not the shared registry. The
|
||||
Monitor consumes it under its existing registry lock after the process exits.
|
||||
Never accepted as an HTTP payload and never refreshes package indexes.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import stat
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
import managed_installs
|
||||
|
||||
OBSERVATION_DIR = Path("/run/proxmenux/lxc-update-observations")
|
||||
MAX_AGE = 3600
|
||||
MAX_BYTES = 2 * 1024 * 1024
|
||||
|
||||
|
||||
def _path(vmid: int, run_id: str) -> Path:
|
||||
if int(vmid) <= 0 or not re.fullmatch(r"[A-Za-z0-9._:-]{1,128}", run_id):
|
||||
raise ValueError("invalid LXC update identity")
|
||||
return OBSERVATION_DIR / f"{int(vmid)}-{run_id}.json"
|
||||
|
||||
|
||||
def _private_directory(*, create=True) -> None:
|
||||
if create:
|
||||
OBSERVATION_DIR.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
info = OBSERVATION_DIR.lstat()
|
||||
if (not stat.S_ISDIR(info.st_mode) or info.st_uid != os.geteuid()
|
||||
or info.st_mode & 0o077):
|
||||
raise OSError("unsafe LXC observation directory")
|
||||
|
||||
|
||||
def capture(vmid: int, run_id: str, *, refresh_indexes: bool = False,
|
||||
include_apps: bool = False) -> dict:
|
||||
path = _path(vmid, run_id)
|
||||
_private_directory()
|
||||
# Remove only obsolete private handoffs left by terminated backend runs.
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
for old in OBSERVATION_DIR.iterdir():
|
||||
info = old.lstat()
|
||||
if (re.fullmatch(r"[1-9][0-9]*-[A-Za-z0-9._:-]{1,128}\.json", old.name)
|
||||
and stat.S_ISREG(info.st_mode) and info.st_uid == os.geteuid()
|
||||
and now.timestamp() - info.st_mtime > MAX_AGE):
|
||||
old.unlink()
|
||||
entry = {"id": f"lxc:{int(vmid)}", "type": "lxc", "_vmid": str(int(vmid))}
|
||||
native_detected = False
|
||||
try:
|
||||
detected = managed_installs._detect_lxc_containers(only_vmid=int(vmid))
|
||||
if not detected:
|
||||
raise RuntimeError("running LXC detection unavailable or disabled")
|
||||
entry = detected[0]
|
||||
native_detected = not (entry.get('_is_oci') or entry.get('_managed_oci_app'))
|
||||
if refresh_indexes:
|
||||
# This is a separate, explicitly requested maintenance action,
|
||||
# never the read-only button or the normal post-update capture.
|
||||
if entry.get('_is_oci') or entry.get('_managed_oci_app'):
|
||||
raise RuntimeError('OCI containers require their own updater')
|
||||
family = managed_installs._probe_lxc_os(str(vmid))
|
||||
commands = {'debian': ['apt-get', 'update', '--error-on=any'],
|
||||
'ubuntu': ['apt-get', 'update', '--error-on=any'],
|
||||
'alpine': ['apk', 'update']}
|
||||
if family not in commands:
|
||||
raise RuntimeError('unsupported or unknown current OS')
|
||||
refreshed = subprocess.run([managed_installs._PCT_BIN, 'exec', str(vmid),
|
||||
'--', *commands[family]], capture_output=True, text=True,
|
||||
timeout=managed_installs._LXC_CACHE_REFRESH_TIMEOUT_SEC)
|
||||
if refreshed.returncode != 0:
|
||||
raise RuntimeError('package index refresh failed; no installation was attempted')
|
||||
result = managed_installs._check_lxc_updates(entry, refresh_package_cache=False)
|
||||
except Exception as exc:
|
||||
entry["_os_family"] = None
|
||||
result = {"available": False, "last_check": managed_installs._now_iso(),
|
||||
"error": f"post-update package check failed: {exc}"}
|
||||
observation = {"vmid": int(vmid), "run_id": run_id,
|
||||
"captured_at": now.isoformat(), "entry": entry, "result": result}
|
||||
# Only the post-install runner requests app probes. Check-only and the
|
||||
# maintenance preflight keep their original OS-only contract; OCI never
|
||||
# enters the generic app probe path, even when explicitly requested.
|
||||
if include_apps and not refresh_indexes and native_detected:
|
||||
try:
|
||||
import lxc_apps
|
||||
observation['apps'] = lxc_apps.capture_installed_observations(vmid)
|
||||
except Exception:
|
||||
observation['apps'] = None
|
||||
fd, temporary = tempfile.mkstemp(prefix=".observation-", dir=OBSERVATION_DIR)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
json.dump(observation, handle)
|
||||
os.replace(temporary, path)
|
||||
print(f"[ProxMenux] LXC observation captured: vmid={int(vmid)} "
|
||||
f"run_id={run_id} os={entry.get('_os_family')} "
|
||||
f"count={result.get('_count')} error={bool(result.get('error'))}", flush=True)
|
||||
finally:
|
||||
if os.path.exists(temporary):
|
||||
os.unlink(temporary)
|
||||
return observation
|
||||
|
||||
|
||||
def read(vmid: int, run_id: str) -> dict | None:
|
||||
path = _path(vmid, run_id)
|
||||
def reject(reason: str) -> None:
|
||||
# Only identity and a fixed reason, never the guest's payload or secrets.
|
||||
print(f"[ProxMenux] LXC observation rejected: vmid={int(vmid)} "
|
||||
f"run_id={run_id} reason={reason}", flush=True)
|
||||
return None
|
||||
try:
|
||||
_private_directory(create=False)
|
||||
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
||||
with os.fdopen(fd, "r", encoding="utf-8") as handle:
|
||||
info = os.fstat(handle.fileno())
|
||||
if (not stat.S_ISREG(info.st_mode) or info.st_uid != os.geteuid()
|
||||
or info.st_mode & 0o077 or info.st_size > MAX_BYTES):
|
||||
return reject('unsafe-file')
|
||||
observation = json.load(handle)
|
||||
if not isinstance(observation, dict):
|
||||
return reject('invalid-document')
|
||||
captured = datetime.datetime.fromisoformat(observation["captured_at"])
|
||||
age = (datetime.datetime.now(datetime.timezone.utc) - captured).total_seconds()
|
||||
entry, result = observation["entry"], observation["result"]
|
||||
if (observation["vmid"] != int(vmid) or observation["run_id"] != run_id
|
||||
or not 0 <= age <= MAX_AGE or not isinstance(entry, dict)
|
||||
or entry.get("id") != f"lxc:{int(vmid)}" or entry.get("type") != "lxc"
|
||||
or str(entry.get("_vmid")) != str(int(vmid)) or not isinstance(result, dict)):
|
||||
return reject('identity-age-or-shape')
|
||||
if not result.get("error"):
|
||||
# An incomplete result is unknown, not an implicitly successful zero.
|
||||
is_oci = bool(entry.get('_is_oci') or entry.get('_managed_oci_app'))
|
||||
if ((not is_oci and entry.get("_os_family") not in managed_installs._SUPPORTED_OS_FAMILIES)
|
||||
or not isinstance(result.get("available"), bool)
|
||||
or not result.get("last_check")
|
||||
or any(type(result.get(key)) is not int or result[key] < 0
|
||||
for key in ("_count", "_security_count"))
|
||||
or not isinstance(result.get("_packages"), list)):
|
||||
return reject('incomplete-success')
|
||||
# OCI skips the generic OS probe, so no family is required. Its
|
||||
# trusted result must nevertheless be the complete skipped zero.
|
||||
if is_oci and (result['available'] or result['_count'] != 0
|
||||
or result['_security_count'] != 0 or result['_packages']):
|
||||
return reject('invalid-oci-skip')
|
||||
print(f"[ProxMenux] LXC observation accepted: vmid={int(vmid)} "
|
||||
f"run_id={run_id} count={result.get('_count')} "
|
||||
f"error={bool(result.get('error'))}", flush=True)
|
||||
return observation
|
||||
except (OSError, ValueError, TypeError, KeyError, OverflowError) as exc:
|
||||
return reject(type(exc).__name__)
|
||||
|
||||
|
||||
def discard(vmid: int, run_id: str) -> None:
|
||||
try:
|
||||
_private_directory(create=False)
|
||||
_path(vmid, run_id).unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--vmid", required=True, type=int)
|
||||
parser.add_argument("--run-id", required=True)
|
||||
parser.add_argument('--include-apps', action='store_true',
|
||||
help='Capture registered installed versions after an update')
|
||||
args = parser.parse_args(argv)
|
||||
try:
|
||||
capture(args.vmid, args.run_id, include_apps=args.include_apps)
|
||||
return 0
|
||||
except Exception as exc:
|
||||
print(f"Post-update package observation unavailable: {exc}")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user