feat(lxc-updates): revamp Updates + Options card + curated hints

Updates tab
- Unified OS + Application update card with per-section Apply buttons
  and a combined "Apply OS + <app>" footer button
- Helper-scripts install detection: uses helper_slug from managed_installs
  cache (hostname fuzzy-match against helpers_cache) so the button
  surfaces even when /usr/bin/update was removed
- Runs the community-scripts helper INSIDE the CT via pct exec so
  build.func picks the silent update path (PHS_SILENT=1) instead of the
  install menu — works with and without /usr/bin/update
- HELPER_SLUG env passthrough from backend to apply_updates.sh: falls
  back to constructing the ct/<slug>.sh URL when the CT no longer
  carries the marker file
- Post-apply state refresh via managed_installs.check_for_updates(force)
  in the /applied hook so the badge updates without a manual reload

Options card
- Rewrote as view / edit mode split with a single Edit button
- Unified apply defaults (snapshot + storage + restart) shared by
  manual and scheduled runs
- Scheduled updates (M5): cron picker + preset dropdown + What-to-update
  target + Delete schedule button, wired to a background scheduler thread
  that fires apply_updates.sh headless with the schedule's env vars
- External host cron detection with variant + scope reporting
  (tteck-legacy / community-scripts / custom, OS-only), shown as an
  informational chip only in edit mode

App tab editor
- Multi-app registration with per-app upstream tracking method
  (github / http_json / docker_hub)
- Card-contrast pattern in edit mode (bg-card + bg-background inputs)
- Auto-heal for missing installed_version via alt_detectors +
  file_fallbacks

Curated tracking hints (M6)
- Add http_json upstream for Plex (plex.tv API)
- Add binary+github hints for Emby (MediaBrowser/Emby.Releases) and
  PhotoPrism (photoprism/photoprism)
- Extend CI merge whitelist with upstream_type / upstream_url /
  upstream_json_path / docker_image

Tab reorder
- LXC modal tabs: Status | App | Updates | Mounts | Backups | Firewall

apply_updates.sh
- New helper execution path: parse ct/<slug>.sh URL, run inside CT
  with PHS_SILENT=1, respecting HELPER_SLUG fallback when
  /usr/bin/update is missing
This commit is contained in:
MacRimi
2026-08-09 00:49:44 +02:00
parent fb8b41b445
commit 06f41f5792
16 changed files with 9607 additions and 148 deletions
+2
View File
@@ -126,6 +126,8 @@ cp "$SCRIPT_DIR/lxc_mount_points.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "
cp "$SCRIPT_DIR/disk_temperature_history.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ disk_temperature_history.py not found"
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 "$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"
cp "$SCRIPT_DIR/proxmox_storage_monitor.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ proxmox_storage_monitor.py not found"
+534
View File
@@ -5882,15 +5882,115 @@ def _get_lxc_update_status_map() -> dict:
'error': update.get('error'),
# Cap packages list shipped to UI — modal uses first 30 max
'packages': (update.get('_packages') or [])[:30],
# OCI-image CTs: suppress apt/apk detection (managed_installs
# short-circuits the checker), UI hides the "N packages
# pending" badge and shows a dedicated OCI panel in the
# Updates modal instead.
'is_oci_lxc': bool(it.get('_is_oci')),
# Community-scripts convention: /usr/bin/update present in
# the CT AND the app is marked `updateable: true` in the
# helpers_cache. Only then does the modal offer the
# "Apply application update" button — 47/733 apps ship an
# updater that's known to fail, so blindly running
# /usr/bin/update on every CT would be a footgun.
'app_updater_present': bool(it.get('_has_app_updater')),
# App identity + updateable-known flag for the modal:
# when we detected /usr/bin/update but the slug wasn't in
# the cache, `app_updater_present` is False and
# `helper_updateable_known` is False — the modal renders a
# neutral "Unknown app" hint instead of the Apply button.
# When we know the slug but it's flagged updateable=false,
# `app_updater_present` is False and `helper_updateable_known`
# is True — the modal shows an explicit "This app doesn't
# support in-place updates" note so the user isn't left
# wondering why the button is missing.
'helper_slug': it.get('_helper_slug'),
'helper_app_name': it.get('_helper_app_name'),
'helper_updateable_known': bool(it.get('_helper_updateable_known')),
# ProxMenux-managed OCI apps (Secure Gateway / Tailscale etc)
# share the same underlying LXC. When set, the Updates modal
# redirects to the OCI dashboard's own updater instead of
# exposing our generic apt/apk flow — those apps carry
# per-package hooks (e.g. tailscale service restart) that
# the generic runner is blind to.
'managed_oci_app': it.get('_managed_oci_app'),
'os_family': it.get('_os_family'),
}
return out
def _get_lxc_app_watch_map() -> dict:
"""Read every /etc/proxmenux/apps/<vmid>.json sidecar into a
``{vmid_str: summary}`` lookup, so `/api/vms` can decorate each
LXC row without a second frontend call. Never triggers a check
that's the caller's responsibility (the daily poll or the modal's
"Check now" button).
For CTs managed by oci_manager (Secure Gateway etc.) we synthesise
a matching summary from that module's own state so the App tab
doesn't ask the user to "register" what ProxMenux itself
installed. The synthesised entry carries `managed_oci_app_id` so
the frontend renders a read-only view + wires the Update button
to /api/oci/installed/<app_id>/update instead of user CRUD.
Managed data always wins over any stale user sidecar for the same
vmid (rare, but possible if a CT was registered before it got
adopted by oci_manager).
"""
# {vmid_str: [app_summary, ...]} — user-registered apps
out: dict = {}
try:
import lxc_apps
out.update(lxc_apps.get_active_apps() or {})
except Exception:
pass
# Overlay managed OCI-app state. Managed apps prepend a synthetic
# entry with `managed_oci_app_id` set — the frontend renders it
# read-only and never allows user CRUD on it.
try:
import managed_installs
items = managed_installs.get_active_items() or []
for it in items:
if it.get("type") != "oci_app":
continue
vmid = it.get("_vmid")
if vmid is None:
continue
uc = it.get("update_check") or {}
managed_entry = {
"id": f"managed:{it.get('_oci_app_id')}",
"name": it.get("name") or "Managed app",
"installed_via": "managed",
"ports": [],
"health_path": None,
"installed_version": it.get("current_version") or uc.get("current"),
"latest_version": uc.get("latest"),
"update_available": bool(uc.get("available")),
"error": uc.get("error"),
"checked_at": uc.get("last_check"),
"has_repo": False,
"managed_oci_app_id": it.get("_oci_app_id"),
# `packages` mirrors the Security page — a short list
# of "N other packages pending" alongside the primary
# tailscale bump.
"packages": (uc.get("_packages") or [])[:30],
}
existing = out.get(str(vmid)) or []
# Managed goes first; user-registered apps follow.
out[str(vmid)] = [managed_entry] + [a for a in existing
if not a.get("managed_oci_app_id")]
except Exception as e:
print(f"[ProxMenux] lxc_apps overlay for managed OCI failed: {e}")
return out
def get_proxmox_vms():
"""Get Proxmox VM and LXC information (requires pvesh command) - only from local node"""
try:
all_vms = []
lxc_updates_map = _get_lxc_update_status_map()
lxc_app_map = _get_lxc_app_watch_map()
try:
# local_node = socket.gethostname()
@@ -5931,6 +6031,13 @@ def get_proxmox_vms():
upd = lxc_updates_map.get(str(resource.get('vmid')))
if upd is not None:
vm_data['update_check'] = upd
# App Watch (Phase 2c) — list of registered
# apps per CT (0..N). Populates header badge,
# Updates modal connected row, and the App
# tab. Absent key = no apps registered.
app_list = lxc_app_map.get(str(resource.get('vmid')))
if app_list:
vm_data['app_watches'] = app_list
# PVE's cluster resources API reports disk=0 for most
# QEMU VMs — it can't see inside the guest filesystem
@@ -12347,6 +12454,273 @@ def api_lxc_updates_detection_set():
return jsonify({'success': False, 'message': str(e)}), 500
# ─── LXC App Watch (Phase 2c) ───────────────────────────────────────────────
# Per-CT user-registered application metadata + upstream version tracking.
# Sidecar at /etc/proxmenux/apps/<vmid>.json managed by the lxc_apps module.
# Endpoints kept in the same "/api/vms/<vmid>/…" family so the UI's fetch
# footprint stays symmetric with the rest of the per-CT resources.
#
# Notification (`app_update_available`) is fired from `check_app` — not from
# here — so scheduled re-checks and on-demand /check both trigger it.
# Per-CT App Watch CRUD. Multi-app: each sidecar is a list of apps.
# GET /api/vms/<vmid>/apps → list all apps for this CT
# POST /api/vms/<vmid>/apps → add a new app (server assigns id)
# PUT /api/vms/<vmid>/apps/<app_id> → update an existing app
# DELETE /api/vms/<vmid>/apps/<app_id> → remove one app
# DELETE /api/vms/<vmid>/apps → remove all apps for this CT
# POST /api/vms/<vmid>/apps/<app_id>/check → force check one app
# POST /api/vms/<vmid>/apps/check → force check every app
# GET /api/vms/<vmid>/apps/suggestions → name + listening ports hint
# POST /api/vms/<vmid>/apps/dismiss → hide an auto-detected chip
@app.route('/api/vms/<int:vmid>/apps', methods=['GET'])
@require_auth
def api_vm_apps_get(vmid):
try:
import lxc_apps
sidecar = lxc_apps.load_sidecar(vmid)
if not sidecar:
return jsonify({'vmid': vmid, 'apps': []}), 200
return jsonify(sidecar)
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/vms/<int:vmid>/apps', methods=['POST'])
@require_auth
def api_vm_apps_add(vmid):
payload = request.get_json(silent=True) or {}
try:
import lxc_apps
ok, result = lxc_apps.add_app(vmid, payload)
if not ok:
return jsonify({'error': result}), 400
return jsonify(result)
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/vms/<int:vmid>/apps/<app_id>', methods=['PUT'])
@require_auth
def api_vm_apps_update(vmid, app_id):
payload = request.get_json(silent=True) or {}
try:
import lxc_apps
ok, result = lxc_apps.update_app(vmid, app_id, payload)
if not ok:
code = 404 if 'not found' in str(result).lower() else 400
return jsonify({'error': result}), code
return jsonify(result)
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/vms/<int:vmid>/apps/<app_id>', methods=['DELETE'])
@require_auth
def api_vm_apps_delete_one(vmid, app_id):
try:
import lxc_apps
ok = lxc_apps.delete_app(vmid, app_id)
return jsonify({'success': ok, 'vmid': vmid, 'app_id': app_id}), 200
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/vms/<int:vmid>/apps', methods=['DELETE'])
@require_auth
def api_vm_apps_delete_all(vmid):
try:
import lxc_apps
ok = lxc_apps.delete_all(vmid)
return jsonify({'success': ok, 'vmid': vmid}), 200
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/vms/<int:vmid>/apps/<app_id>/check', methods=['POST'])
@require_auth
def api_vm_apps_check_one(vmid, app_id):
try:
import lxc_apps
sidecar = lxc_apps.check_app(vmid, app_id, force=True)
if not sidecar:
return jsonify({'error': 'app not found'}), 404
return jsonify(sidecar)
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/vms/<int:vmid>/apps/check', methods=['POST'])
@require_auth
def api_vm_apps_check_all(vmid):
try:
import lxc_apps
sidecar = lxc_apps.check_all(vmid, force=True)
if not sidecar:
return jsonify({'vmid': vmid, 'apps': []}), 200
return jsonify(sidecar)
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/vms/<int:vmid>/schedule', methods=['GET', 'PUT', 'DELETE'])
@require_auth
def api_vm_apps_schedule(vmid):
"""Scheduled update CRUD. GET returns the current schedule (or
{} if unset), PUT persists a new one, DELETE removes it entirely
(equivalent to setting enabled=false + wiping the cron). Handled
on the sidecar directly by lxc_apps the scheduler thread reads
the same source of truth on its next tick."""
try:
import lxc_apps
except Exception as e:
return jsonify({'error': f'lxc_apps unavailable: {e}'}), 500
if request.method == 'GET':
sched = lxc_apps.get_schedule(vmid) or {}
# Enrich with detection of any host-level community-scripts
# update cron so the UI can render the "leverage what's
# already there" state without a second round-trip.
try:
ext = lxc_apps.detect_external_update_cron()
except Exception:
ext = None
if ext:
sched = dict(sched)
sched["external_cron"] = ext
return jsonify(sched)
if request.method == 'DELETE':
ok = lxc_apps.delete_schedule(vmid)
return jsonify({'success': bool(ok), 'vmid': vmid}), 200
payload = request.get_json(silent=True) or {}
ok, result = lxc_apps.update_schedule(vmid, payload)
if not ok:
return jsonify({'error': result}), 400
return jsonify(result)
@app.route('/api/vms/<int:vmid>/apps/suggestions', methods=['GET'])
@require_auth
def api_vm_apps_suggestions(vmid):
try:
import lxc_apps
return jsonify(lxc_apps.get_suggestions(vmid))
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/apps/catalog', methods=['GET'])
@require_auth
def api_apps_catalog():
"""Compact catalog of every registerable app the frontend picker
can offer [{slug, name, logo, default_port, has_tracking}].
Cache-friendly: same content for every user, only changes when
helpers_cache.json or app_tracking_hints.json refresh."""
try:
import lxc_apps
return jsonify(lxc_apps.get_catalog())
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/apps/catalog/<slug>', methods=['GET'])
@require_auth
def api_apps_catalog_slug(slug):
"""Detail for a single catalog slug — used to seed the editor
after the user picks an app in the Name Combobox. Includes the
curated tracking_suggestion when we have one for the slug."""
try:
import lxc_apps
entry = lxc_apps.get_catalog_entry(slug)
if entry is None:
return jsonify({'error': 'not found'}), 404
return jsonify(entry)
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/vms/<int:vmid>/apps/dismiss', methods=['POST'])
@require_auth
def api_vm_apps_dismiss(vmid):
"""Persist a per-CT dismiss / un-dismiss for an auto-detected
slug. Body: ``{"slug": str, "dismissed": bool}``. Detected chips
hidden this way don't come back on future page loads.
"""
try:
import lxc_apps
payload = request.get_json(silent=True) or {}
slug = payload.get('slug', '')
dismissed = bool(payload.get('dismissed', True))
ok, result = lxc_apps.set_dismissed_slug(vmid, slug, dismissed)
if not ok:
return jsonify({'error': result}), 400
return jsonify(result)
except Exception as e:
return jsonify({'error': str(e)}), 500
# ─── LXC Update — post-apply hook ───────────────────────────────────────────
# Called by the UI right after `apply_updates.sh` exits in the
# ScriptTerminalModal. Two responsibilities:
# 1. Emit the `lxc_update_applied` notification event with the actual
# exit code + duration + target, so the user's chosen channels
# (Telegram/Discord/email/…) confirm completion. Firing from here
# keeps the shell script free of Python coupling.
# 2. Force-refresh the affected CT's update state so the badge in
# the VMs & Containers list disappears immediately instead of
# waiting for the next 24 h polling cycle.
@app.route('/api/lxc-updates/<int:vmid>/applied', methods=['POST'])
@require_auth
def api_lxc_updates_applied(vmid):
payload = request.get_json(silent=True) or {}
success = bool(payload.get('success'))
target = str(payload.get('target') or 'os').lower()
duration_seconds = payload.get('duration_seconds')
ct_name = str(payload.get('ct_name') or f'CT-{vmid}')
# Duration formatting mirrors the backup runner's convention:
# <60s → seconds, otherwise Nm Ms.
try:
secs = int(duration_seconds) if duration_seconds is not None else 0
except (TypeError, ValueError):
secs = 0
duration_str = f'{secs}s' if secs < 60 else f'{secs // 60}m {secs % 60}s'
# Fire via notification_manager.emit_event — same public API the
# health monitor uses (see line 1328 for the reference call), so we
# inherit templating, per-channel fan-out and suppression logic.
try:
notification_manager.emit_event(
event_type='lxc_update_applied',
severity='INFO' if success else 'WARNING',
data={
'hostname': get_proxmox_node_name(),
'vmid': vmid,
'ct_name': ct_name,
'target': target,
'result': 'succeeded' if success else 'failed',
'duration': duration_str,
},
source='api',
entity='ct',
entity_id=str(vmid),
)
except Exception as e:
# Don't fail the whole hook on notif error — the refresh below
# is still valuable, and the UI already saw the exit code.
print(f'[ProxMenux] lxc_update_applied notif enqueue failed: {e}')
# Force-refresh the LXC update check so the badge state matches
# reality without the 24 h wait.
try:
import managed_installs
managed_installs.check_for_updates(force=True)
except Exception as e:
print(f'[ProxMenux] managed_installs.check_for_updates failed: {e}')
return jsonify({'success': True})
@app.route('/api/health/thresholds', methods=['GET'])
@require_auth
def api_health_thresholds_get():
@@ -19257,6 +19631,154 @@ def api_proxmenux_self_update_status():
return jsonify(_action_state('proxmenux-action-self-update'))
# ── Scheduled LXC updates ───────────────────────────────────────────
#
# Ticks every 60s. For every sidecar with an enabled schedule whose
# cron matches the current minute, invokes `apply_updates.sh` in a
# subprocess with the schedule's env vars, then records the outcome
# back to the sidecar via `record_schedule_run`. UPDATE_COMMAND for
# `target in (app, both)` chains each registered app's own
# `update_command` with `;` so a failure in one doesn't abort the
# rest. The community-scripts helper `/usr/bin/update` is handled
# by apply_updates.sh itself (invoked from host with CTID env).
#
# Runs are dedup-guarded by minute+vmid so a schedule that fires at
# `* * * * *` doesn't ever double-fire on the same minute inside
# one process. Nothing races against manual applies — those go
# through the WS terminal path and the shell script itself takes
# care of concurrent invocation (last one wins with vzdump lock).
_APPLY_UPDATES_SCRIPT = "/usr/local/share/proxmenux/scripts/lxc/apply_updates.sh"
_scheduled_fired_this_minute: set = set()
def _compose_scheduled_update_command(vmid: int, target: str) -> str:
"""Chain every registered app's own `update_command` for the
scheduled run. Returns empty string when target == "os" or when
no custom commands are registered. The helper `/usr/bin/update`
is handled internally by apply_updates.sh (invoked from host
with CTID env), so it does NOT belong in UPDATE_COMMAND."""
if target not in ("app", "both"):
return ""
try:
import lxc_apps
sidecar = lxc_apps._read_sidecar(vmid) or {}
except Exception:
return ""
apps = sidecar.get("apps") or []
parts: list = []
for a in apps:
if a.get("managed_oci_app_id"):
continue
cmd = (a.get("update_command") or "").strip()
if cmd:
parts.append(cmd)
return "; ".join(parts)
def _run_scheduled_update(vmid: int, sched: dict) -> str:
"""Fire `apply_updates.sh` headless with the schedule's env vars.
Returns "success" | "failure" | "skipped" the last one when the
script binary isn't installed. Blocks until the run completes;
caller runs us in a worker thread so the 60s scheduler tick isn't
held up by a long-running apply."""
if not os.path.isfile(_APPLY_UPDATES_SCRIPT):
return "skipped"
target = sched.get("target") or "both"
env = dict(os.environ)
env["VMID"] = str(vmid)
env["TARGET"] = target
env["BACKUP"] = "1" if sched.get("backup") else "0"
env["BACKUP_STORAGE"] = sched.get("backup_storage") or ""
env["RESTART"] = "1" if sched.get("restart") else "0"
env["UPDATE_COMMAND"] = _compose_scheduled_update_command(vmid, target)
env["APP_NAME"] = ""
# HELPER_SLUG lets apply_updates.sh run the community-scripts helper
# from host even when /usr/bin/update was removed inside the CT
# (older installs). Read from the same source the UI uses.
try:
import managed_installs as _mi
for _it in _mi.get_active_items() or []:
if _it.get("type") == "lxc" and str(_it.get("_vmid")) == str(vmid):
env["HELPER_SLUG"] = _it.get("_helper_slug") or ""
break
else:
env["HELPER_SLUG"] = ""
except Exception:
env["HELPER_SLUG"] = ""
try:
r = subprocess.run(
["bash", _APPLY_UPDATES_SCRIPT],
env=env,
capture_output=True,
text=True,
timeout=60 * 60, # 1h hard cap so a stuck run doesn't
# block the queue forever
)
return "success" if r.returncode == 0 else "failure"
except subprocess.TimeoutExpired:
return "failure"
except Exception:
return "failure"
def _scheduler_loop():
"""Every 60s, sweep every sidecar with an enabled schedule and
fire the ones whose cron matches the current minute. Each fire
runs in its own worker thread so a slow apply on one CT doesn't
delay the next tick or hold up other CTs' schedules."""
global _scheduled_fired_this_minute
# Wait a bit after startup so we don't race with the initial
# sidecar load / migration path.
time.sleep(30)
print("[ProxMenux] LXC update scheduler started (60s interval)")
last_minute_key = None
while True:
try:
now = datetime.now()
minute_key = now.strftime("%Y-%m-%d %H:%M")
if minute_key != last_minute_key:
_scheduled_fired_this_minute = set()
last_minute_key = minute_key
try:
import lxc_apps
items = lxc_apps.get_all_schedules() or []
except Exception as e:
print(f"[ProxMenux] scheduler: reading schedules failed: {e}")
items = []
for entry in items:
vmid = entry.get("vmid")
sched = entry.get("schedule") or {}
if not sched.get("enabled") or not sched.get("cron"):
continue
if vmid in _scheduled_fired_this_minute:
continue
try:
if not lxc_apps.cron_matches(sched["cron"], now):
continue
except Exception:
continue
_scheduled_fired_this_minute.add(vmid)
# Fire in a worker so we don't block the loop.
def _worker(_vmid=vmid, _sched=dict(sched)):
print(f"[ProxMenux] scheduler: firing update for CT {_vmid} "
f"(target={_sched.get('target')}, backup={_sched.get('backup')})")
status = _run_scheduled_update(_vmid, _sched)
try:
lxc_apps.record_schedule_run(_vmid, status, _sched.get("target") or "both")
except Exception as e:
print(f"[ProxMenux] scheduler: could not record run for {_vmid}: {e}")
print(f"[ProxMenux] scheduler: CT {_vmid} finished with status={status}")
threading.Thread(target=_worker, daemon=True).start()
except Exception as e:
print(f"[ProxMenux] scheduler loop error: {e}")
# Sleep to the next minute boundary + a small offset so we
# tick predictably in phase with the wall clock's minute.
now = datetime.now()
secs_left = 60 - now.second
time.sleep(max(1, secs_left) + 2)
if __name__ == '__main__':
import sys
import logging
@@ -19461,6 +19983,18 @@ if __name__ == '__main__':
except Exception as e:
print(f"[ProxMenux] Notification service failed to start: {e}")
# ── Scheduled LXC update scheduler ──
# Ticks every minute, fires apply_updates.sh headless for CTs
# whose schedule cron matches. Runs go through the same shell
# script as the manual "Apply update" flow so behaviour stays
# identical (backup, restart, /usr/bin/update + custom command
# chain) between manual and scheduled invocations.
try:
scheduler_thread = threading.Thread(target=_scheduler_loop, daemon=True)
scheduler_thread.start()
except Exception as e:
print(f"[ProxMenux] LXC update scheduler failed to start: {e}")
# Check for SSL configuration
ssl_ctx = None
ssl_cert = None
File diff suppressed because it is too large Load Diff
+346 -7
View File
@@ -297,10 +297,40 @@ def _detect_oci_apps() -> list[dict]:
# Stash the raw app_id so the checker can find it without
# parsing the prefixed registry id.
"_oci_app_id": app_id,
# Cache the CT vmid so `_detect_lxc_containers` can flag the
# matching LXC row as OCI-managed (avoids the LXC update flow
# competing with the Secure Gateway panel's own updater).
"_vmid": app.get("vmid"),
})
return out
def _get_oci_managed_vmids() -> dict[str, str]:
"""Return {vmid_str: oci_app_id} for every CT under oci_manager.
Used by `_detect_lxc_containers` to route those CTs through the
Secure Gateway update flow instead of the generic apt/apk path —
the two share the same `apk upgrade` at the bottom but the OCI
manager also does app-specific hooks (e.g. restarting tailscale
when the package moved) that the generic runner is blind to.
"""
try:
import oci_manager
except Exception:
return {}
try:
installed = oci_manager.list_installed_apps() or []
except Exception:
return {}
mapping: dict[str, str] = {}
for app in installed:
vmid = app.get("vmid")
app_id = app.get("id") or app.get("app_id")
if vmid is None or not app_id:
continue
mapping[str(vmid)] = str(app_id)
return mapping
# ── LXC containers (Phase 1: apt-based update detection) ────────────
#
# Each running Debian/Ubuntu CT becomes a registry entry of type "lxc".
@@ -461,6 +491,246 @@ def _list_pve_lxcs() -> list[dict]:
_SUPPORTED_OS_FAMILIES = ("debian", "ubuntu", "alpine")
# Detectors for the CT origin. `pct config` writes machine-friendly
# keys that reveal how a container was created. The most reliable
# OCI-image indicator across PVE 9.1+ is `lxc.environment.runtime:` —
# it's populated from every Dockerfile ENV (nearly universal) whereas
# `entrypoint:` requires the image to define ENTRYPOINT (CMD-only
# images lack it). We match by prefix, one hit is enough.
_OCI_LXC_MARKERS = (
"lxc.environment.runtime:",
"lxc.init.cwd:",
"lxc.signal.halt:",
)
def _probe_lxc_is_oci(vmid: str) -> bool:
"""Return True if the CT was created from an OCI (Docker) image via
PVE 9.1+'s native ``pct create <vmid> <oci-ref>`` path.
OCI-image containers are IMMUTABLE by design — running apt/apk
upgrade inside them contradicts the container model and can break
the image (bootstrap deps, baked-in configs). The correct workflow
is to pull a newer image tag and rebuild. We use this probe to
SUPPRESS the apt/apk detection for these CTs so the UI doesn't
show a misleading "packages pending" badge that would nudge users
toward the anti-pattern.
Reads the CT config file directly (cheaper than `pct config`) —
the file lives at /etc/pve/lxc/<vmid>.conf and is always present
on the node hosting the CT.
"""
conf_path = f"/etc/pve/lxc/{vmid}.conf"
try:
with open(conf_path) as f:
for line in f:
stripped = line.lstrip()
for marker in _OCI_LXC_MARKERS:
if stripped.startswith(marker):
return True
except (FileNotFoundError, PermissionError, OSError):
pass
return False
# Cross-reference against the ProxMenux helpers catalogue (generated
# by .github/scripts/generate_helpers_cache.py from the
# community-scripts registry). Each entry carries `updateable: bool`
# — the community-scripts folks know which of their apps ship a
# working updater and which don't (47 out of 733 at last count are
# updateable=false). Without this we'd offer an Apply button on
# every CT with /usr/bin/update, and 6-7% of them would fail hard.
_HELPERS_CACHE_URL = (
"https://raw.githubusercontent.com/MacRimi/ProxMenux/"
"refs/heads/main/json/helpers_cache.json"
)
_HELPERS_CACHE_DISK = "/var/lib/proxmenux/helpers_cache.json"
_HELPERS_CACHE_TTL = 7 * 24 * 3600 # 7 days — the catalogue changes rarely
_HELPERS_CACHE_HTTP_TIMEOUT = 10
_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")
def _fetch_helpers_cache() -> dict:
"""Return the slug→metadata index for community-scripts apps.
Shape: ``{slug: {"name": str, "updateable": bool}}``. Fetched on
demand from the ProxMenux repo, cached in memory for 7 days and
persisted to :data:`_HELPERS_CACHE_DISK` so a Monitor restart
doesn't refetch. On any network failure returns the last known
good copy — never raises, so callers can just ``.get(slug)``.
"""
global _helpers_cache, _helpers_cache_ts
with _helpers_cache_lock:
now = time.time()
if _helpers_cache is not None and (now - _helpers_cache_ts) < _HELPERS_CACHE_TTL:
return _helpers_cache
# In-memory expired or empty — try network first, then disk.
try:
req = urllib.request.Request(
_HELPERS_CACHE_URL,
headers={"User-Agent": "ProxMenux-Monitor"},
)
with urllib.request.urlopen(req, timeout=_HELPERS_CACHE_HTTP_TIMEOUT) as r:
raw = json.loads(r.read().decode("utf-8"))
index: dict = {}
for entry in raw or []:
slug = entry.get("slug")
if not slug:
continue
# `default_port` powers the App tab's port pre-fill
# fallback for apps that don't have a curated
# default_ports entry in app_tracking_hints.json.
# `logo` is the selfh.st/icons URL from the
# community-scripts catalog — fallback for slugs
# whose curated tracking hint doesn't ship one.
index[slug] = {
"name": entry.get("name") or slug,
"updateable": bool(entry.get("updateable")),
"default_port": entry.get("port") or 0,
"logo": entry.get("logo") or "",
}
_helpers_cache = index
_helpers_cache_ts = now
try:
os.makedirs(os.path.dirname(_HELPERS_CACHE_DISK), exist_ok=True)
tmp = f"{_HELPERS_CACHE_DISK}.tmp.{os.getpid()}"
with open(tmp, "w") as f:
json.dump({"ts": now, "index": index}, f)
os.replace(tmp, _HELPERS_CACHE_DISK)
except OSError:
# Persistence is best-effort — memory copy is enough.
pass
return index
except Exception:
# Network failed. Fall back to whatever we have in memory,
# then to the on-disk copy from a previous run.
if _helpers_cache is not None:
return _helpers_cache
try:
with open(_HELPERS_CACHE_DISK) as f:
disk = json.load(f)
_helpers_cache = disk.get("index") or {}
_helpers_cache_ts = float(disk.get("ts") or 0)
return _helpers_cache
except (OSError, json.JSONDecodeError):
_helpers_cache = {}
_helpers_cache_ts = now # avoid hammering the retry loop
return _helpers_cache
def _probe_helper_scripts_slug(vmid: str) -> Optional[str]:
"""Return the community-scripts app slug for a CT by extracting the
``ct/<slug>.sh`` reference embedded in ``/usr/bin/update``.
The community-scripts installers write ``/usr/bin/update`` as a
single line: ``bash -c "$(curl -fsSL …/ct/<slug>.sh)"``. Parsing
that URL gives us both the app identity AND a stable key into
:func:`_fetch_helpers_cache`. Returns None when the file is
missing, unreadable, or doesn't match the expected pattern.
"""
try:
r = subprocess.run(
[_PCT_BIN, "exec", str(vmid), "--", "cat", "/usr/bin/update"],
capture_output=True, text=True,
timeout=_LXC_OS_PROBE_TIMEOUT_SEC,
)
if r.returncode != 0:
return None
m = _UPDATE_SLUG_RE.search(r.stdout)
return m.group(1) if m else None
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
return None
# Tags the community-scripts installers stamp on the CT config so we
# can recognise a CT as a helper-scripts install even when /usr/bin/
# update has been deleted or was never created (very old installs).
_HELPER_SCRIPTS_TAGS = frozenset({"proxmox-helper-scripts", "community-scripts"})
def _probe_lxc_tags(vmid: str) -> set:
"""Return the set of tags configured on the CT (from ``pct config``).
Returns empty set on any failure — never raises.
"""
try:
r = subprocess.run(
[_PCT_BIN, "config", str(vmid)],
capture_output=True, text=True, timeout=5,
)
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
return set()
if r.returncode != 0:
return set()
for line in r.stdout.splitlines():
if line.startswith("tags:"):
raw = line.split(":", 1)[1].strip()
return {t.strip().lower() for t in raw.split(";") if t.strip()}
return set()
def _normalize_for_fuzzy(s: str) -> str:
"""Lowercase + strip non-alphanumeric, for hostname↔slug matching."""
return "".join(ch for ch in (s or "").lower() if ch.isalnum())
def _guess_helper_slug_from_hostname(hostname: str) -> Optional[str]:
"""Fuzzy-match a CT hostname against community-scripts catalog slugs.
Tried in this order:
1. Exact-normalized match — the safest and only unambiguous case
2. Prefix match (hostname is a proper prefix of the slug —
e.g. `nginxproxy` → `nginxproxymanager`) — only accepted when
there is EXACTLY ONE candidate. A hostname like `paperless`
matching all of {paperless-ai, paperless-gpt, paperless-ngx}
returns None: the guess would be wrong more often than right.
3. Contains match — same "unique or bust" rule.
Ambiguity → None. The user then goes through the catalog picker
or types the app name themselves — accurate manual choice beats
silently-wrong auto-suggestion.
"""
norm_host = _normalize_for_fuzzy(hostname)
if not norm_host:
return None
cache = _fetch_helpers_cache() or {}
if not cache:
return None
norm_slugs = {slug: _normalize_for_fuzzy(slug) for slug in cache}
for slug, ns in norm_slugs.items():
if ns == norm_host:
return slug
prefix = [slug for slug, ns in norm_slugs.items() if ns.startswith(norm_host)]
if len(prefix) == 1:
return prefix[0]
if prefix:
return None # ambiguous — refuse to guess
contains = [slug for slug, ns in norm_slugs.items() if norm_host in ns]
if len(contains) == 1:
return contains[0]
return None
def _infer_helper_slug(vmid: str, hostname: str) -> Optional[str]:
"""Best-effort identification of the community-scripts slug for a 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.
"""
slug = _probe_helper_scripts_slug(vmid)
if slug:
return slug
tags = _probe_lxc_tags(vmid)
if not (tags & _HELPER_SCRIPTS_TAGS):
return None
return _guess_helper_slug_from_hostname(hostname)
def _probe_lxc_os(vmid: str) -> Optional[str]:
"""Return a normalized family identifier (``debian`` / ``ubuntu`` /
@@ -531,6 +801,12 @@ def _detect_lxc_containers() -> list[dict]:
}
cts = _list_pve_lxcs()
# Set of CTs currently managed by oci_manager (Secure Gateway etc).
# Their update path is the OCI app's own updater — we mark them so
# the LXC row in the UI redirects the user there instead of running
# our generic apt/apk flow.
oci_managed = _get_oci_managed_vmids()
out: list[dict] = []
for ct in cts:
if ct["status"] != "running":
@@ -538,15 +814,56 @@ def _detect_lxc_containers() -> list[dict]:
vmid = ct["vmid"]
cid = f"lxc:{vmid}"
prior = existing_by_id.get(cid) or {}
# OCI-image marker is cached — the CT origin doesn't change
# over its lifetime, and reading the pct config file is cheap
# enough that we don't gain much from skipping the re-probe.
is_oci = _probe_lxc_is_oci(vmid)
# Managed OCI-app membership (Secure Gateway / Tailscale / any
# future ProxMenux-shipped OCI app).
managed_oci_app = oci_managed.get(str(vmid))
# OS family is only meaningful for non-OCI CTs. We still cache
# it for OCI (some images ARE Ubuntu/Debian underneath and
# future features might use it), but we don't require it.
os_family = prior.get("_os_family")
if not os_family:
os_family = _probe_lxc_os(vmid)
if os_family not in _SUPPORTED_OS_FAMILIES:
# Distribution we don't yet have a package-manager
# parser for. Skip silently. The framework marks any
# existing entry as removed_at if it stops appearing
# in the detector output.
if not is_oci and os_family not in _SUPPORTED_OS_FAMILIES:
# Non-OCI, non-supported family — the framework has
# no way to check its updates. Skip silently.
continue
# Helper-scripts updater detection — only meaningful for
# non-OCI, non-managed CTs. Managed OCI apps have their own
# updater; OCI-image CTs almost never carry /usr/bin/update
# since apps are baked into the image at build time.
#
# `_has_app_updater` gates whether the "Apply application
# update" button appears in the modal. It's only True when
# BOTH:
# (a) /usr/bin/update exists AND we can extract the
# community-scripts slug from it, and
# (b) that slug is marked `updateable: true` in the
# helpers_cache — 47/733 entries are false, and running
# their updaters is a known-broken action.
# `_helper_slug` and `_helper_app_name` are surfaced to the UI
# so users see which app they'd be updating (e.g. "Update
# Jellyfin" rather than a generic "Update").
has_app_updater = False
helper_slug: 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 "")
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"))
out.append({
"id": cid,
"type": "lxc",
@@ -556,8 +873,12 @@ def _detect_lxc_containers() -> list[dict]:
"menu_script": None,
"_vmid": vmid,
"_os_family": os_family,
# Phase 2 hook: populate `_helper_script_app` here once we
# learn how to read the community-scripts marker.
"_is_oci": is_oci,
"_managed_oci_app": managed_oci_app,
"_has_app_updater": has_app_updater,
"_helper_slug": helper_slug,
"_helper_app_name": helper_app_name,
"_helper_updateable_known": helper_updateable_known,
})
return out
@@ -1113,6 +1434,24 @@ def _check_lxc_updates(entry: dict) -> dict:
"last_check": _now_iso(), "error": "no vmid in entry",
}
# OCI-image CTs are immutable by design — apt/apk upgrade inside
# them is the wrong workflow (update = rebuild from a newer image
# tag). Skip the package-manager probe entirely so the UI doesn't
# surface a misleading "N packages pending" badge that would nudge
# users toward the anti-pattern. The Updates modal renders a
# dedicated OCI-container panel using the flag propagated below.
#
# Same treatment for CTs managed by oci_manager (Secure Gateway
# etc.) — those have their own dashboard-driven updater with
# app-specific hooks; running our generic apt/apk in parallel
# would race and could restart the wrong services.
if entry.get("_is_oci") or entry.get("_managed_oci_app"):
return {
"available": False, "latest": None,
"last_check": _now_iso(), "error": None,
"_count": 0, "_security_count": 0, "_packages": [],
}
refresh_diag = _refresh_lxc_pkg_cache_if_stale(vmid, family)
if family in ("debian", "ubuntu"):
+11
View File
@@ -3508,6 +3508,17 @@ class PollingCollector:
print(f"[PollingCollector] managed_installs update run failed: {e}")
return
# Piggy-back on the same 24 h cycle to refresh every
# user-registered app watch. Keeps the header badge accurate
# in the VMs list without needing a dedicated timer. Errors
# are absorbed inside refresh_all_apps — one broken CT never
# blocks the others.
try:
import lxc_apps
lxc_apps.refresh_all_apps(force=False)
except Exception as e:
print(f"[PollingCollector] lxc_apps refresh failed: {e}")
# Split LXC updates out of the per-item event stream — they get
# one grouped notification per cycle instead of one per CT, to
# avoid spamming the user when 15 CTs have pending updates the
@@ -510,6 +510,27 @@ TEMPLATES = {
'group': 'vm_ct',
'default_enabled': False,
},
'lxc_update_applied': {
'title': '{hostname}: LXC {ct_name} ({vmid}) update {result}',
'body': (
'Container {ct_name} (CT {vmid}) — update {result}.\n'
'Target: {target} Duration: {duration}'
),
'label': 'LXC update applied',
'group': 'vm_ct',
'default_enabled': True,
},
'app_update_available': {
'title': '{hostname}: {app_name} update available on CT {vmid}',
'body': (
'{app_name} on CT {vmid} ({ct_name}) has a new version:\n'
' {installed}{latest}\n'
'Registered via ProxMenux App Watch.'
),
'label': 'App update available (App Watch)',
'group': 'vm_ct',
'default_enabled': False,
},
'vm_start': {
'title': '{hostname}: VM {vmname} ({vmid}) started',
'body': 'Virtual machine {vmname} (ID: {vmid}) is now running.',
@@ -1696,6 +1717,8 @@ CATEGORY_EMOJI = {
EVENT_EMOJI = {
# VM / CT
'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
'vm_start': '\u25B6\uFE0F', # play button
'vm_start_warning': '\u26A0\uFE0F', # warning sign - started with warnings
'vm_stop': '\u23F9\uFE0F', # stop button