mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-14 18:56:52 +00:00
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:
Regular → Executable
+4
@@ -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"
|
||||
|
||||
+1424
-126
File diff suppressed because it is too large
Load Diff
@@ -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')
|
||||
|
||||
+1848
-97
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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 TaskWatcher—the 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',
|
||||
|
||||
@@ -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
@@ -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())
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user