overhaul app tracking and update orchestration

- Generate and ship a verified 389-app tracking catalog with 23 runtime overrides, fallback detectors, ports, logos, and Docker Hub tag previews.
- Support modern Proxmox VE Helper-Scripts markers, historical installations, and official or manual app deployments.
- Rework the LXC App and Updates tabs with cached suggestions, explicit discovery, version tracking, web links, custom updaters, and complete i18n.
- Add independent OS, app, Docker Engine, Docker image, bulk, and scheduled update targets.
- Add digest-based Docker inventory, Compose dependency grouping, safe standalone-container recreation with rollback, and package-scoped Docker Engine updates.
- Refresh per-LXC caches after lifecycle and update tasks, then emit idempotent notifications based on the verified final state.
- Harden Coral USB recovery by removing orphaned gasket DKMS registrations and validating that dpkg is healthy before reporting success.
This commit is contained in:
MacRimi
2026-08-23 12:43:03 +02:00
parent 7244201810
commit 0251f77331
27 changed files with 11631 additions and 893 deletions
+4
View File
@@ -127,6 +127,10 @@ cp "$SCRIPT_DIR/disk_temperature_history.py" "$APP_DIR/usr/bin/" 2>/dev/null ||
cp "$SCRIPT_DIR/health_thresholds.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ health_thresholds.py not found"
cp "$SCRIPT_DIR/managed_installs.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ managed_installs.py not found"
cp "$SCRIPT_DIR/lxc_apps.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ lxc_apps.py not found"
cp "$SCRIPT_DIR/recreate_docker_container.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ recreate_docker_container.py not found"
chmod +x "$APP_DIR/usr/bin/recreate_docker_container.py" 2>/dev/null || true
cp "$SCRIPT_DIR/update_docker_engine.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ update_docker_engine.py not found"
chmod +x "$APP_DIR/usr/bin/update_docker_engine.py" 2>/dev/null || true
cp "$APPIMAGE_ROOT/../json/app_tracking_hints.json" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ app_tracking_hints.json not found"
cp "$SCRIPT_DIR/flask_terminal_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_terminal_routes.py not found"
cp "$SCRIPT_DIR/hardware_monitor.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ hardware_monitor.py not found"
File diff suppressed because it is too large Load Diff
+39
View File
@@ -108,6 +108,32 @@ sock = Sock()
# Active terminal sessions
active_sessions = {}
_script_completion_hook = None
_script_completion_hook_lock = threading.Lock()
def set_script_completion_hook(callback):
"""Register the backend hook invoked after a streamed script exits."""
global _script_completion_hook
with _script_completion_hook_lock:
_script_completion_hook = callback
def _run_script_completion_hook(script_path, params, exit_code, duration_seconds):
with _script_completion_hook_lock:
callback = _script_completion_hook
if callback is None:
return
try:
callback(
script_path=script_path,
params=dict(params or {}),
exit_code=int(exit_code),
duration_seconds=max(0, int(duration_seconds)),
)
except Exception as exc:
print(f"[ProxMenux] script completion hook failed: {exc}", flush=True)
@terminal_bp.route('/api/terminal/health', methods=['GET'])
def terminal_health():
"""Health check for terminal service"""
@@ -470,6 +496,7 @@ def script_websocket(ws, session_id):
env['PYTHONUNBUFFERED'] = '1'
env['TERM'] = 'xterm-256color'
script_started_at = time.monotonic()
script_process = subprocess.Popen(
['/bin/bash', script_path],
stdin=slave_fd,
@@ -579,6 +606,18 @@ def script_websocket(ws, session_id):
script_process.wait()
exit_code = script_process.returncode if script_process.returncode is not None else 0
threading.Thread(
target=_run_script_completion_hook,
args=(
script_path,
params,
exit_code,
time.monotonic() - script_started_at,
),
daemon=True,
name=f'script-complete-{session_id}',
).start()
try:
ws.send(f'\r\n[Script exited with code {exit_code}]\r\n')
File diff suppressed because it is too large Load Diff
+242 -107
View File
@@ -167,6 +167,26 @@ def _detect_nvidia_xfree86() -> Optional[dict]:
# libedgetpu1-std from Google's apt repo).
def _coral_pcie_hardware_present() -> bool:
"""True when a Coral PCIe/M.2 device (vendor 0x1ac1, Global Unichip
Corp.) is visible on the PCI bus. Used together with the gasket-dkms
package state to detect orphan installs left behind by the legacy
installer (`scripts/install_coral_pve.sh` before 2026-04) that
installed the DKMS driver unconditionally on USB-only hosts."""
try:
for entry in os.listdir("/sys/bus/pci/devices"):
try:
with open(f"/sys/bus/pci/devices/{entry}/vendor",
"r", encoding="utf-8") as fh:
if fh.read().strip() == "0x1ac1":
return True
except OSError:
continue
except OSError:
pass
return False
def _detect_coral_host() -> list[dict]:
out: list[dict] = []
@@ -180,61 +200,105 @@ def _detect_coral_host() -> list[dict]:
# knows the fork's patch level.
# 2. `dpkg-query gasket-dkms` — the Debian package version, only
# present when the user installed via .deb rather than the
# ProxMenux script.
# ProxMenux script. Package state matters: only `ok installed`
# is trusted as a real version; broken states surface as
# "package present but not usable" so the UI can offer cleanup
# instead of a spurious "update available".
# 3. `dkms status` — the upstream module version registered with
# DKMS, which is always the bare `1.0`. Useful as a "modules
# are present" indicator but doesn't reveal the fork patch
# level, so the update-availability check would always fire a
# false positive against feranick's `1.0-N` tags. Reported on
# .50 after a successful re-install kept showing the update
# notification.
pcie_version: Optional[str] = None
# false positive against feranick's `1.0-N` tags.
#
# Orphan detection: gasket-dkms package present + no PCIe/M.2
# hardware = residue from the legacy installer. `_gasket_orphan`
# is exposed so `install_coral.sh` and the notification pipeline
# can offer cleanup without ever calling it "an update".
pcie_hw_present = _coral_pcie_hardware_present()
marker_version: Optional[str] = None
try:
with open("/var/lib/proxmenux/coral_gasket_version",
"r", encoding="utf-8", errors="replace") as fh:
marker = fh.read().strip()
# Sanity check: the file should hold something that looks
# like a version tag, not an error message or empty line.
if marker and re.match(r"^[A-Za-z0-9._+-]+$", marker):
pcie_version = marker
marker_version = marker
except OSError:
pass
if not pcie_version:
try:
r = subprocess.run(
["dpkg-query", "-W", "-f=${Status}|${Version}", "gasket-dkms"],
capture_output=True, text=True, timeout=3,
)
if r.returncode == 0 and "ok installed" in r.stdout:
pcie_version = r.stdout.split("|", 1)[1].strip()
except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
pass
if not pcie_version:
try:
r = subprocess.run(
["dkms", "status"], capture_output=True, text=True, timeout=3,
)
if r.returncode == 0:
for line in r.stdout.splitlines():
if line.startswith("gasket"):
# "gasket, 1.0, ..." or "gasket/1.0, ..."
m = re.match(r"^gasket[, /]([^,\s]+)", line)
if m:
pcie_version = m.group(1)
break
except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
pass
if pcie_version:
out.append({
# gasket-dkms package inspection: state + version, kept separate.
dpkg_state: str = "absent" # "healthy" | "broken" | "absent"
dpkg_version: Optional[str] = None
try:
r = subprocess.run(
["dpkg-query", "-W", "-f=${Status}|${Version}", "gasket-dkms"],
capture_output=True, text=True, timeout=3,
)
if r.returncode == 0 and "|" in r.stdout:
status_part, _, version_part = r.stdout.partition("|")
if "ok installed" in status_part:
dpkg_state = "healthy"
dpkg_version = version_part.strip() or None
elif any(tok in status_part for tok in (
"half-configured", "half-installed", "unpacked",
"failed-config", "reinst-required", "trigger",
)):
dpkg_state = "broken"
dpkg_version = version_part.strip() or None
except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
pass
# Version resolution: emit only when we can actually trust it, i.e.
# the hardware is present AND either we have a marker file or the
# package is healthy. On broken or orphan states we intentionally
# omit `current_version` so the update comparator never fires a
# false "update available" against feranick's tags.
pcie_version: Optional[str] = None
if pcie_hw_present:
if marker_version:
pcie_version = marker_version
elif dpkg_state == "healthy" and dpkg_version:
pcie_version = dpkg_version
else:
# Fallback to dkms status ONLY when hardware is present and
# no better source exists. Kept for backwards compatibility
# with hosts that lost the marker file after a manual dkms
# rebuild but still have working hardware + working modules.
try:
r = subprocess.run(
["dkms", "status"], capture_output=True, text=True, timeout=3,
)
if r.returncode == 0:
for line in r.stdout.splitlines():
if line.startswith("gasket"):
m = re.match(r"^gasket[, /]([^,\s]+)", line)
if m:
pcie_version = m.group(1)
break
except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
pass
is_orphan = (dpkg_state != "absent") and not pcie_hw_present
# Emit the entry whenever we have a trustworthy version OR whenever
# there is package state to surface (broken / orphan). This lets the
# frontend and the notification pipeline see both healthy installs
# and the two remediation cases in the same registry shape.
if pcie_version or dpkg_state != "absent":
entry = {
"id": "coral-host-pcie",
"type": "coral_host",
"name": "Coral TPU Driver (gasket-dkms)",
"current_version": pcie_version,
"menu_label": "GPU & TPU → Coral TPU",
"menu_script": "scripts/gpu_tpu/install_coral.sh",
"_coral_variant": "pcie",
})
"_gasket_pkg_state": dpkg_state,
"_gasket_orphan": is_orphan,
"_gasket_pcie_hardware_present": pcie_hw_present,
}
if pcie_version:
entry["current_version"] = pcie_version
out.append(entry)
# USB — libedgetpu1-std (default) or libedgetpu1-max if the user
# opted into the overclocked runtime. Either one means the USB
@@ -551,7 +615,11 @@ _helpers_cache_lock = threading.RLock()
_helpers_cache: Optional[dict] = None
_helpers_cache_ts: float = 0.0
_UPDATE_SLUG_RE = re.compile(r"ct/([a-z0-9_-]+)\.sh")
_UPDATE_SLUG_RE = re.compile(r"ct/([a-z0-9._-]+)\.sh")
_BASE_OS_HELPER_SLUGS = frozenset({
"alpine", "archlinux", "archlinux-vm", "debian", "fedora",
"gentoo", "opensuse", "ubuntu",
})
def _fetch_helpers_cache() -> dict:
@@ -714,22 +782,30 @@ def _guess_helper_slug_from_hostname(hostname: str) -> Optional[str]:
return None
def _infer_helper_slug(vmid: str, hostname: str) -> Optional[str]:
"""Best-effort identification of the community-scripts slug for a CT.
def _identify_helper_slug(vmid: str, hostname: str) -> tuple[Optional[str], Optional[str]]:
"""Return ``(slug, evidence_source)`` for a community-scripts CT.
Primary: extract from /usr/bin/update (present on installs from a
reasonably modern community-scripts installer). Fallback: if the
CT carries a helper-scripts tag but /usr/bin/update is missing
(very old installs, or the file was removed), guess by
fuzzy-matching the hostname against the helpers_cache slug list.
``update_wrapper`` is executable evidence: the slug was extracted
from /usr/bin/update. ``tag_hostname`` is only an identity hint for
old installs and must never enable an update action by itself.
"""
slug = _probe_helper_scripts_slug(vmid)
if slug:
return slug
return slug, "update_wrapper"
tags = _probe_lxc_tags(vmid)
if not (tags & _HELPER_SCRIPTS_TAGS):
return None
return _guess_helper_slug_from_hostname(hostname)
return None, None
slug = _guess_helper_slug_from_hostname(hostname)
return (slug, "tag_hostname") if slug else (None, None)
def _infer_helper_slug(vmid: str, hostname: str) -> Optional[str]:
"""Backward-compatible identity-only wrapper.
Callers deciding whether an updater may run must use
:func:`_identify_helper_slug` and require ``update_wrapper``.
"""
return _identify_helper_slug(vmid, hostname)[0]
def _probe_lxc_os(vmid: str) -> Optional[str]:
@@ -766,7 +842,7 @@ def _probe_lxc_os(vmid: str) -> Optional[str]:
return None
def _detect_lxc_containers() -> list[dict]:
def _detect_lxc_containers(only_vmid: Optional[int] = None) -> list[dict]:
"""Enumerate running Debian/Ubuntu CTs as registry entries.
OS detection is cached in the registry entry (`_os_family`), so the
@@ -809,6 +885,8 @@ def _detect_lxc_containers() -> list[dict]:
out: list[dict] = []
for ct in cts:
if only_vmid is not None and str(ct.get("vmid")) != str(int(only_vmid)):
continue
if ct["status"] != "running":
continue
vmid = ct["vmid"]
@@ -853,16 +931,23 @@ def _detect_lxc_containers() -> list[dict]:
# Jellyfin" rather than a generic "Update").
has_app_updater = False
helper_slug: Optional[str] = None
helper_slug_source: Optional[str] = None
helper_app_name: Optional[str] = None
helper_updateable_known = False # True when we found the slug in the cache
if not is_oci and not managed_oci_app:
helper_slug = _infer_helper_slug(vmid, ct.get("name") or "")
helper_slug, helper_slug_source = _identify_helper_slug(
vmid, ct.get("name") or ""
)
if helper_slug:
entry = _fetch_helpers_cache().get(helper_slug)
if entry:
helper_updateable_known = True
helper_app_name = entry.get("name") or helper_slug
has_app_updater = bool(entry.get("updateable"))
has_app_updater = bool(
helper_slug_source == "update_wrapper"
and helper_slug not in _BASE_OS_HELPER_SLUGS
and entry.get("updateable")
)
out.append({
"id": cid,
@@ -877,6 +962,7 @@ def _detect_lxc_containers() -> list[dict]:
"_managed_oci_app": managed_oci_app,
"_has_app_updater": has_app_updater,
"_helper_slug": helper_slug,
"_helper_slug_source": helper_slug_source,
"_helper_app_name": helper_app_name,
"_helper_updateable_known": helper_updateable_known,
})
@@ -904,6 +990,45 @@ def _normalise_detector_result(result: Any) -> list[dict]:
return []
def _merge_detected_entry(existing: dict, entry: dict, now: str) -> dict:
"""Refresh one registry row from detector evidence without touching peers."""
if existing.get("removed_at"):
existing.pop("removed_at", None)
existing["reactivated_at"] = now
for key in ("name", "current_version", "menu_label", "menu_script"):
if key in entry and entry[key] is not None:
existing[key] = entry[key]
for key, value in entry.items():
if key.startswith("_"):
existing[key] = value
existing["last_seen"] = now
return existing
def _new_detected_entry(entry: dict, now: str) -> dict:
new_entry = {
"id": entry["id"],
"type": entry.get("type", "unknown"),
"name": entry.get("name", entry["id"]),
"current_version": entry.get("current_version"),
"menu_label": entry.get("menu_label"),
"menu_script": entry.get("menu_script"),
"installed_by": "detected",
"first_seen": now,
"last_seen": now,
"update_check": {
"last_check": None,
"available": False,
"latest": None,
"error": None,
},
}
for key, value in entry.items():
if key.startswith("_"):
new_entry[key] = value
return new_entry
def detect_and_register() -> dict:
"""Run every detector, merge results into the registry, persist.
@@ -938,44 +1063,9 @@ def detect_and_register() -> dict:
# 1. Add new + reactivate / refresh existing.
for item_id, entry in discovered.items():
if item_id in index:
existing = items[index[item_id]]
# Reactivate if it was previously removed
if existing.get("removed_at"):
existing.pop("removed_at", None)
existing["reactivated_at"] = now
# Refresh metadata fields that may have evolved
for k in ("name", "current_version", "menu_label", "menu_script"):
if k in entry and entry[k] is not None:
existing[k] = entry[k]
# Preserve internal helpers like `_oci_app_id`
for k, v in entry.items():
if k.startswith("_"):
existing[k] = v
existing["last_seen"] = now
_merge_detected_entry(items[index[item_id]], entry, now)
else:
# Brand new entry
new_entry = {
"id": entry["id"],
"type": entry.get("type", "unknown"),
"name": entry.get("name", entry["id"]),
"current_version": entry.get("current_version"),
"menu_label": entry.get("menu_label"),
"menu_script": entry.get("menu_script"),
"installed_by": "detected",
"first_seen": now,
"last_seen": now,
"update_check": {
"last_check": None,
"available": False,
"latest": None,
"error": None,
},
}
# Carry over internals (`_oci_app_id` etc.)
for k, v in entry.items():
if k.startswith("_"):
new_entry[k] = v
items.append(new_entry)
items.append(_new_detected_entry(entry, now))
# 2. Mark missing items as removed (don't delete — preserve
# history so a reinstall doesn't lose the audit trail).
@@ -1588,6 +1678,23 @@ _CHECKERS: dict[str, Callable[[dict], dict]] = {
}
def _store_update_result(item: dict, result: dict) -> None:
"""Apply one checker result using the registry's canonical shape."""
item["update_check"] = {
"available": bool(result.get("available")),
"latest": result.get("latest"),
"last_check": result.get("last_check") or _now_iso(),
"error": result.get("error"),
}
if result.get("current") and not item.get("current_version"):
item["current_version"] = result["current"]
for extra_key in ("_packages", "_upgrade_kind", "_kernel",
"_kernel_note", "_count", "_security_count",
"_coral_variant", "_coral_pkg"):
if extra_key in result:
item["update_check"][extra_key] = result[extra_key]
def check_for_updates(force: bool = False) -> list[dict]:
"""Run every type-specific checker over active items, persist
the updated state, return the list of items that have an update
@@ -1622,25 +1729,7 @@ def check_for_updates(force: bool = False) -> list[dict]:
result = {"available": False, "latest": None,
"last_check": _now_iso(), "error": str(e)}
it["update_check"] = {
"available": bool(result.get("available")),
"latest": result.get("latest"),
"last_check": result.get("last_check") or _now_iso(),
"error": result.get("error"),
}
if result.get("current") and not it.get("current_version"):
it["current_version"] = result["current"]
# Per-checker extras carried through into the persisted
# `update_check` blob. Add new keys here when a future
# checker needs to surface fields beyond available/latest.
# `_count` + `_security_count` were missing originally, so
# the LXC checker's counts dropped on the floor and the
# frontend badge couldn't render.
for extra_key in ("_packages", "_upgrade_kind", "_kernel",
"_kernel_note", "_count", "_security_count",
"_coral_variant", "_coral_pkg"):
if extra_key in result:
it["update_check"][extra_key] = result[extra_key]
_store_update_result(it, result)
if it["update_check"]["available"]:
updates_available.append(it)
@@ -1650,3 +1739,49 @@ def check_for_updates(force: bool = False) -> list[dict]:
_write_registry(reg)
return updates_available
def refresh_lxc(vmid: int) -> Optional[dict]:
"""Detect and refresh exactly one running LXC.
This is the lifecycle counterpart of the daily collector. It is called
after a stopped container starts and deliberately leaves every other
guest's registry row untouched.
"""
try:
target_vmid = int(vmid)
except (TypeError, ValueError):
return None
detected = _detect_lxc_containers(only_vmid=target_vmid)
if not detected:
return None
entry = detected[0]
item_id = entry["id"]
now = _now_iso()
with _lock:
reg = _read_registry()
items: list[dict] = list(reg.get("items", []))
target = next((item for item in items if item.get("id") == item_id), None)
if target is None:
target = _new_detected_entry(entry, now)
items.append(target)
else:
_merge_detected_entry(target, entry, now)
try:
result = _check_lxc_updates(target)
except Exception as exc:
result = {
"available": False,
"latest": None,
"last_check": _now_iso(),
"error": str(exc),
}
_store_update_result(target, result)
reg["items"] = items
reg["version"] = _SCHEMA_VERSION
reg["last_targeted_refresh"] = now
_write_registry(reg)
return dict(target)
+45 -2
View File
@@ -22,7 +22,7 @@ import sqlite3
import subprocess
import threading
from queue import Queue
from typing import Optional, Dict, Any, Tuple
from typing import Optional, Dict, Any, Tuple, Callable
from pathlib import Path
@@ -1939,8 +1939,16 @@ class TaskWatcher:
'vzmigrate': ('migration_start', 'INFO'),
}
def __init__(self, event_queue: Queue):
def __init__(
self,
event_queue: Queue,
guest_lifecycle_callback: Optional[Callable[[str, str, str], None]] = None,
):
self._queue = event_queue
# Reuse the exact PVE task transition already responsible for
# VM/CT lifecycle notifications. Consumers such as the modal cache
# can subscribe without introducing a second status poller.
self._guest_lifecycle_callback = guest_lifecycle_callback
self._running = False
self._thread: Optional[threading.Thread] = None
# `_hostname` is exposed as a @property below so every read returns
@@ -2250,6 +2258,31 @@ class TaskWatcher:
# Determine entity type from task type
entity = 'ct' if task_type.startswith('vz') else 'vm'
# A completed PVE lifecycle task is the existing source of truth for
# start/stop/restart notifications. Publish the same transition to
# the optional cache listener before notification-only suppression
# (backup/startup aggregation, disabled channels, cooldowns) so cache
# correctness never depends on whether a message is delivered.
lifecycle_actions = {
'qmstart': ('qemu', 'start'),
'qmstop': ('qemu', 'stop'),
'qmshutdown': ('qemu', 'stop'),
'qmreboot': ('qemu', 'reboot'),
'qmreset': ('qemu', 'reboot'),
'vzstart': ('lxc', 'start'),
'vzstop': ('lxc', 'stop'),
'vzshutdown': ('lxc', 'stop'),
'vzreboot': ('lxc', 'reboot'),
}
lifecycle = lifecycle_actions.get(task_type)
if (lifecycle and self._guest_lifecycle_callback
and not is_error and (status == 'OK' or is_warning)):
try:
self._guest_lifecycle_callback(vmid, lifecycle[0], lifecycle[1])
except Exception as exc:
print(f'[TaskWatcher] guest lifecycle callback failed for '
f'{lifecycle[0]} {vmid}: {exc}', flush=True)
# Backup completion/failure and replication events are handled
# EXCLUSIVELY by the PVE webhook, which delivers richer data (full
@@ -3516,6 +3549,15 @@ class PollingCollector:
try:
import lxc_apps
lxc_apps.refresh_all_apps(force=False)
# Docker images have an independent lifecycle from both the OS
# packages and the Docker engine. Refresh their read-only
# registry digest inventory on the same daily cadence; this never
# pulls or recreates containers.
# This is the single automatic Docker registry comparison. Force
# the rolling pass itself so a user-triggered check shortly after
# yesterday's cycle cannot postpone the next automatic scan by an
# additional day. Normal UI reads remain cache-only for 24 hours.
lxc_apps.refresh_docker_inventories(force=True)
# After the refresh, emit `app_update_available` for every
# sidecar entry currently flagged with a pending upstream
# release. `check_app(force=False)` short-circuits on a
@@ -3526,6 +3568,7 @@ class PollingCollector:
# moment. `notification_manager` dedups by entity_id
# (vmid + app_id + latest_version) so repeated calls only
# deliver one notification per release.
lxc_apps.emit_all_pending_docker_stacks()
lxc_apps.emit_all_pending_updates()
except Exception as e:
print(f"[PollingCollector] lxc_apps refresh failed: {e}")
+19 -1
View File
@@ -520,6 +520,8 @@ _AGGREGATION_EXEMPT_EVENTS = frozenset({
# at once, so without this exemption only the first 1-2 land and
# the rest get buffered into a useless summary.
'app_update_available',
'docker_stack_update_available',
'lxc_update_applied',
})
@@ -791,6 +793,7 @@ class NotificationManager:
self._task_watcher: Optional[TaskWatcher] = None
self._polling_collector: Optional[PollingCollector] = None
self._dispatch_thread: Optional[threading.Thread] = None
self._guest_lifecycle_callback = None
# Webhook receiver (no thread, passive)
self._hook_watcher: Optional[ProxmoxHookWatcher] = None
@@ -981,6 +984,17 @@ class NotificationManager:
with self._lock:
self._load_config()
return {'success': True, 'channels': list(self._channels.keys())}
def set_guest_lifecycle_callback(self, callback) -> None:
"""Attach a consumer to the existing PVE task lifecycle watcher.
Detection stays in TaskWatcherthe same source that emits VM/CT
start/stop notifications. This setter only lets Flask invalidate and
rebuild its guest caches when that already-detected event completes.
"""
self._guest_lifecycle_callback = callback
if self._task_watcher is not None:
self._task_watcher._guest_lifecycle_callback = callback
# ─── Server Mode (Background) ──────────────────────────────
@@ -1017,7 +1031,10 @@ class NotificationManager:
# polling collector keep the managed_installs registry, the
# error history, and the task state up to date.
self._journal_watcher = JournalWatcher(self._event_queue)
self._task_watcher = TaskWatcher(self._event_queue)
self._task_watcher = TaskWatcher(
self._event_queue,
guest_lifecycle_callback=self._guest_lifecycle_callback,
)
self._polling_collector = PollingCollector(self._event_queue)
self._journal_watcher.start()
@@ -1966,6 +1983,7 @@ class NotificationManager:
'coral_driver_update_available',
'secure_gateway_update_available',
'app_update_available',
'docker_stack_update_available',
# Security events that must not be silenced by stale cooldowns
# following a Monitor reinstall (Pedro Rico, 19/05).
'auth_fail',
+12 -4
View File
@@ -512,10 +512,7 @@ TEMPLATES = {
},
'lxc_update_applied': {
'title': '{hostname}: LXC {ct_name} ({vmid}) update {result}',
'body': (
'Container {ct_name} (CT {vmid}) — update {result}.\n'
'Target: {target} Duration: {duration}'
),
'body': '{details}',
'label': 'LXC update applied',
'group': 'vm_ct',
'default_enabled': True,
@@ -540,6 +537,16 @@ TEMPLATES = {
# never received the notification they explicitly asked for.
'default_enabled': True,
},
'docker_stack_update_available': {
'title': '{hostname}: Docker updates available on CT {vmid}',
'body': (
'Container {ct_name} (CT {vmid}) has {count} Docker update(s):\n'
'{details}'
),
'label': 'Docker updates available',
'group': 'updates',
'default_enabled': True,
},
'vm_start': {
'title': '{hostname}: VM {vmname} ({vmid}) started',
'body': 'Virtual machine {vmname} (ID: {vmid}) is now running.',
@@ -1728,6 +1735,7 @@ EVENT_EMOJI = {
'lxc_updates_available': '\U0001F4E6', # \uD83D\uDCE6 package \u2014 pending CT updates
'lxc_update_applied': '\u2705', # \u2705 check \u2014 update applied
'app_update_available': '\U0001F195', # \ud83c\udd95 NEW \u2014 upstream app release
'docker_stack_update_available': '\U0001F433',
'vm_start': '\u25B6\uFE0F', # play button
'vm_start_warning': '\u26A0\uFE0F', # warning sign - started with warnings
'vm_stop': '\u23F9\uFE0F', # stop button
+176
View File
@@ -0,0 +1,176 @@
#!/usr/bin/env python3
"""Safely recreate one standalone Docker container inside an LXC.
The container's create-time Config/HostConfig is read from Docker's API,
the referenced image is pulled, and a replacement is validated before the
old container is removed. If create/start/validation fails, the original
container name and running state are restored.
Compose-owned containers are deliberately rejected: their declarative
project is the authoritative and safer update path.
"""
from __future__ import annotations
import argparse
import json
import re
import subprocess
import sys
import time
NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$")
def pct_exec(vmid: int, argv: list[str], *, input_text: str | None = None, timeout: int = 300) -> subprocess.CompletedProcess:
return subprocess.run(
["/usr/sbin/pct", "exec", str(vmid), "--", *argv],
input=input_text,
capture_output=True,
text=True,
timeout=timeout,
)
def checked(vmid: int, argv: list[str], *, input_text: str | None = None, timeout: int = 300) -> str:
result = pct_exec(vmid, argv, input_text=input_text, timeout=timeout)
if result.returncode != 0:
detail = (result.stderr or result.stdout or "command failed").strip()
raise RuntimeError(f"{' '.join(argv[:3])}: {detail}")
return result.stdout or ""
def inspect_one(vmid: int, name: str) -> dict:
payload = json.loads(checked(vmid, ["docker", "inspect", name], timeout=30))
if not isinstance(payload, list) or len(payload) != 1:
raise RuntimeError("docker inspect returned an unexpected response")
return payload[0]
def create_payload(inspect: dict, image: str) -> dict:
config = dict(inspect.get("Config") or {})
config["Image"] = image
host_config = dict(inspect.get("HostConfig") or {})
if host_config.get("AutoRemove"):
raise RuntimeError("containers with AutoRemove cannot be recreated safely")
endpoints: dict[str, dict] = {}
for network_name, endpoint in ((inspect.get("NetworkSettings") or {}).get("Networks") or {}).items():
if not NAME_RE.match(str(network_name)):
continue
# Preserve names/aliases and driver options, but deliberately let
# Docker allocate a fresh IP while the stopped rollback container
# still owns its old endpoint.
target: dict = {}
for key in ("Aliases", "Links", "DriverOpts"):
if endpoint.get(key) is not None:
target[key] = endpoint[key]
endpoints[str(network_name)] = target
return {
**config,
"HostConfig": host_config,
"NetworkingConfig": {"EndpointsConfig": endpoints},
}
def api_create(vmid: int, name: str, payload: dict) -> str:
body = json.dumps(payload, separators=(",", ":"))
result = pct_exec(
vmid,
[
"curl", "--silent", "--show-error", "--fail-with-body",
"--unix-socket", "/var/run/docker.sock",
"-H", "Content-Type: application/json",
"-X", "POST", "--data-binary", "@-",
f"http://localhost/v1.41/containers/create?name={name}",
],
input_text=body,
timeout=60,
)
if result.returncode != 0:
raise RuntimeError((result.stderr or result.stdout or "Docker create API failed").strip())
response = json.loads(result.stdout or "{}")
container_id = str(response.get("Id") or "")
if not container_id:
raise RuntimeError(str(response.get("message") or "Docker create API returned no container id"))
return container_id
def recreate(vmid: int, name: str) -> None:
original = inspect_one(vmid, name)
labels = ((original.get("Config") or {}).get("Labels") or {})
if labels.get("com.docker.compose.project"):
raise RuntimeError("container belongs to Docker Compose; use its project update action")
image = str((original.get("Config") or {}).get("Image") or "").strip()
if not image:
raise RuntimeError("container has no reusable image reference")
was_running = bool((original.get("State") or {}).get("Running"))
backup_name = f"{name}.proxmenux-rollback-{int(time.time())}"
replacement_created = False
print(f"=== Docker protected recreation: CT {vmid} / {name} ===", flush=True)
print(f"Image: {image}", flush=True)
print("Pulling the referenced image…", flush=True)
pull = pct_exec(vmid, ["docker", "pull", image], timeout=1800)
if pull.stdout:
print(pull.stdout.rstrip(), flush=True)
if pull.returncode != 0:
raise RuntimeError((pull.stderr or "docker pull failed").strip())
payload = create_payload(original, image)
try:
if was_running:
print("Stopping the original container…", flush=True)
checked(vmid, ["docker", "stop", "--time", "30", name], timeout=60)
print(f"Keeping rollback container as {backup_name}", flush=True)
checked(vmid, ["docker", "rename", name, backup_name], timeout=30)
print("Creating replacement from the inspected configuration…", flush=True)
api_create(vmid, name, payload)
replacement_created = True
if was_running:
checked(vmid, ["docker", "start", name], timeout=60)
deadline = time.time() + 20
while True:
state = inspect_one(vmid, name).get("State") or {}
if not state.get("Running"):
raise RuntimeError(str(state.get("Error") or "replacement stopped during validation"))
health = ((state.get("Health") or {}).get("Status") or "").lower()
if health == "unhealthy":
raise RuntimeError("replacement healthcheck is unhealthy")
if health != "starting" or time.time() >= deadline:
break
time.sleep(2)
print("Replacement validated; removing rollback container…", flush=True)
checked(vmid, ["docker", "rm", "-f", backup_name], timeout=60)
print("Docker container recreation completed successfully.", flush=True)
except Exception:
print("Recreation failed; restoring the original container…", file=sys.stderr, flush=True)
if replacement_created:
pct_exec(vmid, ["docker", "rm", "-f", name], timeout=60)
pct_exec(vmid, ["docker", "rename", backup_name, name], timeout=30)
if was_running:
pct_exec(vmid, ["docker", "start", name], timeout=60)
raise
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--vmid", required=True, type=int)
parser.add_argument("--container", required=True)
args = parser.parse_args()
if args.vmid <= 0 or not NAME_RE.match(args.container):
parser.error("invalid VMID or container name")
try:
recreate(args.vmid, args.container)
return 0
except Exception as exc:
print(f"ERROR: {exc}", file=sys.stderr, flush=True)
return 1
if __name__ == "__main__":
raise SystemExit(main())
+201
View File
@@ -0,0 +1,201 @@
#!/usr/bin/env python3
"""Update only the Docker Engine stack inside one Proxmox LXC.
This intentionally does not run the community-scripts Docker updater:
that updater also performs a full apt/apk upgrade. ProxMenux resolves a
small allow-list of Docker packages that are already installed and asks the
guest package manager to upgrade only those packages (and required
dependencies). Static/manual installations without a supported package
manager fail closed instead of guessing how they were installed.
"""
from __future__ import annotations
import argparse
import subprocess
import sys
import time
APT_PACKAGES = (
"docker-ce",
"docker-ce-cli",
"docker-ce-rootless-extras",
"docker-buildx-plugin",
"docker-compose-plugin",
"docker-model-plugin",
"containerd.io",
"docker.io",
"docker-compose-v2",
"docker-compose",
"docker-buildx",
"docker-cli",
"containerd",
"runc",
"moby-engine",
"moby-cli",
"moby-buildx",
"moby-compose",
"moby-containerd",
)
APK_PACKAGES = (
"docker",
"docker-cli",
"docker-openrc",
"docker-cli-buildx",
"docker-cli-compose",
"docker-compose",
"containerd",
"runc",
)
RPM_PACKAGES = (
"docker-ce",
"docker-ce-cli",
"docker-ce-rootless-extras",
"docker-buildx-plugin",
"docker-compose-plugin",
"containerd.io",
"moby-engine",
"moby-cli",
"moby-buildx",
"moby-compose",
"moby-containerd",
)
def pct(vmid: int, argv: list[str], *, capture: bool = False) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["/usr/sbin/pct", "exec", str(vmid), "--", *argv],
text=True,
capture_output=capture,
check=False,
)
def command_exists(vmid: int, name: str) -> bool:
return pct(vmid, ["sh", "-c", f"command -v {name} >/dev/null 2>&1"]).returncode == 0
def docker_version(vmid: int) -> str:
result = pct(vmid, ["docker", "version", "--format", "{{.Server.Version}}"], capture=True)
return result.stdout.strip() if result.returncode == 0 else ""
def apt_installed(vmid: int) -> list[str]:
installed: list[str] = []
for package in APT_PACKAGES:
result = pct(
vmid,
["dpkg-query", "-W", "-f=${db:Status-Abbrev}", package],
capture=True,
)
if result.returncode == 0 and result.stdout.startswith("ii"):
installed.append(package)
return installed
def apk_installed(vmid: int) -> list[str]:
return [package for package in APK_PACKAGES if pct(vmid, ["apk", "info", "-e", package], capture=True).returncode == 0]
def rpm_installed(vmid: int) -> list[str]:
return [package for package in RPM_PACKAGES if pct(vmid, ["rpm", "-q", package], capture=True).returncode == 0]
def resolve_method(vmid: int) -> tuple[str, list[str]]:
if command_exists(vmid, "apt-get") and command_exists(vmid, "dpkg-query"):
return "apt", apt_installed(vmid)
if command_exists(vmid, "apk"):
return "apk", apk_installed(vmid)
if command_exists(vmid, "dnf") and command_exists(vmid, "rpm"):
return "dnf", rpm_installed(vmid)
if command_exists(vmid, "snap") and pct(vmid, ["snap", "list", "docker"], capture=True).returncode == 0:
return "snap", ["docker"]
return "unsupported", []
def run_update(vmid: int, method: str, packages: list[str]) -> int:
if method == "apt":
if pct(vmid, ["apt-get", "update"]).returncode != 0:
return 1
return pct(
vmid,
[
"env",
"DEBIAN_FRONTEND=noninteractive",
"apt-get",
"-y",
"-o",
"Dpkg::Options::=--force-confold",
"install",
"--only-upgrade",
*packages,
],
).returncode
if method == "apk":
if pct(vmid, ["apk", "update"]).returncode != 0:
return 1
return pct(vmid, ["apk", "upgrade", "--no-cache", *packages]).returncode
if method == "dnf":
return pct(vmid, ["dnf", "-y", "upgrade", *packages]).returncode
if method == "snap":
return pct(vmid, ["snap", "refresh", "docker"]).returncode
return 1
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--vmid", required=True, type=int)
parser.add_argument("--dry-run", action="store_true")
args = parser.parse_args()
if args.vmid <= 0:
parser.error("--vmid must be a positive integer")
before = docker_version(args.vmid)
if not before:
print("ERROR: Docker Engine is not running or was not detected in this container.", file=sys.stderr)
return 2
method, packages = resolve_method(args.vmid)
if method == "unsupported" or not packages:
print(
"ERROR: Docker was detected, but no supported packaged installation was found. "
"Configure a custom update command for this installation.",
file=sys.stderr,
)
return 3
print(f"Docker Engine before: {before}")
print(f"Update method: {method}")
print("Installed Docker stack: " + ", ".join(packages))
if args.dry_run:
print("Dry run: no packages were changed.")
return 0
print("--- Updating only the installed Docker Engine stack ---")
if run_update(args.vmid, method, packages) != 0:
print("ERROR: the Docker package update failed.", file=sys.stderr)
return 4
after = ""
for _ in range(12):
after = docker_version(args.vmid)
if after:
break
time.sleep(1)
if not after:
print("ERROR: Docker did not become available again after the package update.", file=sys.stderr)
return 5
print(f"Docker Engine after: {after}")
if after == before:
print("Docker Engine was already at the newest package version available.")
else:
print("Docker Engine updated successfully.")
return 0
if __name__ == "__main__":
raise SystemExit(main())