From e89f2596b2f0270a49178c1c3f0c73846d53fdb7 Mon Sep 17 00:00:00 2001 From: VAIO73 <50487331+Vaso73@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:09:07 +0000 Subject: [PATCH] feat: discover native LXC applications on demand with evidence --- .github/scripts/run_offline_qualified.py | 2 + .github/workflows/test-offline-qualified.yml | 8 +- AppImage/components/lxc-app-panel.tsx | 119 ++++- AppImage/messages/en/common.json | 10 + AppImage/messages/sk/common.json | 10 + AppImage/scripts/build_appimage.sh | 1 + AppImage/scripts/flask_server.py | 16 + AppImage/scripts/lxc_apps.py | 7 + AppImage/scripts/native_app_discovery.py | 487 ++++++++++++++++++ .../tests/test_native_app_discovery.py | 441 ++++++++++++++++ CONTRIBUTING.md | 6 +- .../test_native_app_discovery_ui.cjs | 164 ++++++ 12 files changed, 1248 insertions(+), 23 deletions(-) create mode 100644 AppImage/scripts/native_app_discovery.py create mode 100644 AppImage/scripts/tests/test_native_app_discovery.py create mode 100644 tests/lxc_updates/test_native_app_discovery_ui.cjs diff --git a/.github/scripts/run_offline_qualified.py b/.github/scripts/run_offline_qualified.py index 9bd13abc..fe6c26d4 100644 --- a/.github/scripts/run_offline_qualified.py +++ b/.github/scripts/run_offline_qualified.py @@ -13,6 +13,7 @@ import sys import tempfile PYTHON_FILES = ( + "AppImage/scripts/tests/test_native_app_discovery.py", "tests/test_audit_presentation.py", "tests/test_audit_safety_wording.py", "tests/test_audit_policy.py", @@ -24,6 +25,7 @@ PYTHON_FILES = ( "AppImage/scripts/tests/test_health_persistence_recurrence.py", ) NODE_FILES = ( + "tests/lxc_updates/test_native_app_discovery_ui.cjs", "tests/lxc_updates/test_docker_delegated_ui.cjs", "tests/test_audit_diagnostic_document.cjs", "tests/test_audit_policy.cjs", diff --git a/.github/workflows/test-offline-qualified.yml b/.github/workflows/test-offline-qualified.yml index 29af7416..a562c6b3 100644 --- a/.github/workflows/test-offline-qualified.yml +++ b/.github/workflows/test-offline-qualified.yml @@ -12,6 +12,7 @@ on: - 'AppImage/package-lock.json' - 'AppImage/scripts/*.py' - 'AppImage/scripts/tests/test_health_persistence_recurrence.py' + - 'AppImage/scripts/tests/test_native_app_discovery.py' - 'AppImage/app/**' - 'AppImage/components/**' - 'AppImage/hooks/**' @@ -32,6 +33,7 @@ on: - 'AppImage/package-lock.json' - 'AppImage/scripts/*.py' - 'AppImage/scripts/tests/test_health_persistence_recurrence.py' + - 'AppImage/scripts/tests/test_native_app_discovery.py' - 'AppImage/app/**' - 'AppImage/components/**' - 'AppImage/hooks/**' @@ -56,7 +58,9 @@ jobs: - uses: actions/setup-python@v5 with: python-version: '3.11' - - name: Run nine qualified Python files + - name: Install native discovery route fixture dependency + run: python3 -m pip install 'Flask>=3,<4' + - name: Run qualified Python files env: PYTHONDONTWRITEBYTECODE: '1' run: python3 -I -B .github/scripts/run_offline_qualified.py --lane python @@ -82,7 +86,7 @@ jobs: run: | npm ci --prefix .github/ci/offline-node --legacy-peer-deps --ignore-scripts --no-audit --no-fund ln -s ../.github/ci/offline-node/node_modules AppImage/node_modules - - name: Run eleven qualified Node files + - name: Run qualified Node files env: NODE_PATH: ${{ github.workspace }}/AppImage/node_modules PYTHONDONTWRITEBYTECODE: '1' diff --git a/AppImage/components/lxc-app-panel.tsx b/AppImage/components/lxc-app-panel.tsx index 59939f0b..666009cb 100644 --- a/AppImage/components/lxc-app-panel.tsx +++ b/AppImage/components/lxc-app-panel.tsx @@ -19,7 +19,7 @@ * Secure Gateway. */ -import { useCallback, useEffect, useMemo, useState } from "react" +import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { Loader2, Save, RefreshCw, Trash2, Package, ExternalLink, AlertTriangle, Info, PlusCircle, Pencil, ChevronDown, ChevronRight, EyeOff, @@ -93,6 +93,7 @@ interface AppConfig { health_path?: string logo_url?: string helper_slug?: string + discovery_id?: string // Set when the application runs inside Docker: registered for its identity // and installed version, updated by the image it comes from. update_via?: string @@ -121,7 +122,18 @@ interface PortDetail { logo_url: string } +interface NativeDiscovery { + vmid: number + status: "not_scanned" | "complete" | "partial" | "error" | "busy" + stale: boolean + candidates: DetectedApp[] +} + interface DetectedApp { + discovery_id?: string + source?: "native_metadata" + installed_version?: string | null + evidence?: Array<{ kind: string; value: string }> slug: string name: string logo_url?: string | null @@ -462,12 +474,34 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }: const [loading, setLoading] = useState(!seed) const [sidecar, setSidecar] = useState(seed?.sidecar ?? null) const [suggestions, setSuggestions] = useState(seed?.suggestions ?? null) + const [nativeDiscovery, setNativeDiscovery] = useState(null) + const targetRef = useRef(vmid) + targetRef.current = vmid + const targetGeneration = useRef(0) + const previousTarget = useRef(vmid) + if (previousTarget.current !== vmid) { + previousTarget.current = vmid + targetGeneration.current += 1 + } + useEffect(() => { + const generation = targetGeneration.current + setNativeDiscovery(null) + setEditing(null) + setDetectionNotice(null) + setSearchingApplications(false) + setSaving(false) + if (managed) return + fetchApi(`/api/vms/${vmid}/apps/native-discovery`).then(result => { + if (targetRef.current === vmid && targetGeneration.current === generation) setNativeDiscovery(result) + }).catch(() => { /* cached metadata is optional; never initiate a scan */ }) + return () => { targetGeneration.current += 1 } + }, [vmid, managed]) const [adguardSetupAvailable, setAdguardSetupAvailable] = useState(false) const [error, setError] = useState(null) const [searchingApplications, setSearchingApplications] = useState(false) const [detectionNotice, setDetectionNotice] = useState<{ found: boolean; text: string } | null>(null) // Editor state - const [editing, setEditing] = useState<{ appId: string | null; draft: AppConfig } | null>(null) + const [editing, setEditing] = useState<{ appId: string | null; draft: AppConfig; targetVmid: number } | null>(null) const [binaryArgsInput, setBinaryArgsInput] = useState("") const [commandArgvInput, setCommandArgvInput] = useState("") const [saving, setSaving] = useState(false) @@ -522,6 +556,8 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }: // `initialData` or the shared cache, the sidecar is already // populated and the user shouldn't see a flash of "Loading…" // while we revalidate. + const generation = targetGeneration.current + const isCurrent = () => targetRef.current === vmid && targetGeneration.current === generation if (!sidecar) setLoading(true) setError(null) try { @@ -533,14 +569,14 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }: // the "Loading applications…" flash that used to show while a // second, racing fetch caught up. const bundle = await fetchLxcApps(vmid) - if (bundle) { + if (bundle && isCurrent()) { setSidecar(bundle.sidecar) setSuggestions(bundle.suggestions) } } catch (e: any) { - setError(e?.message || t("vmLxc.appEditor.loadFailed")) + if (isCurrent()) setError(e?.message || t("vmLxc.appEditor.loadFailed")) } finally { - setLoading(false) + if (isCurrent()) setLoading(false) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [vmid, managed]) @@ -617,7 +653,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }: return msg } - const editorOpen = !!editing + const editorOpen = !!editing && editing.targetVmid === vmid // The picker catalog is only needed after the user opens the editor. useEffect(() => { @@ -653,8 +689,8 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }: // (`extras[]` from the backend). Both use the same DetectedApp // shape so the empty state renders them uniformly. const detectedList: DetectedApp[] = useMemo(() => { - if (!suggestions) return [] - const out: DetectedApp[] = [] + const out: DetectedApp[] = [...(nativeDiscovery?.vmid === vmid ? nativeDiscovery.candidates : [])] + if (!suggestions) return out // A ProxMenux OCI install has no helper slug — nothing was installed by a // community script — so its template id identifies it instead. const primarySlug = suggestions.helper_slug || suggestions.oci_instance?.template_id @@ -679,12 +715,12 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }: } } return out - }, [suggestions]) + }, [suggestions, nativeDiscovery, vmid]) // Registered slugs — used to filter the detection list down to // what the user hasn't already registered on this CT. const registeredSlugs = useMemo( - () => new Set(apps.map((a) => a.helper_slug).filter(Boolean) as string[]), + () => new Set(apps.flatMap((a) => [a.helper_slug, a.discovery_id]).filter(Boolean) as string[]), [apps], ) // Dismissed slugs — persisted in the sidecar. Chips the user @@ -750,14 +786,30 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }: } const searchInstalledApplications = async () => { + const generation = targetGeneration.current + const isCurrent = () => targetRef.current === vmid && targetGeneration.current === generation const before = new Set([...visibleDetected, ...visibleWorkloads].map((item) => item.slug)) setSearchingApplications(true) setDetectionNotice(null) setError(null) try { + let native = await fetchApi(`/api/vms/${vmid}/apps/native-discovery`, { + method: "POST", + }) + if (!isCurrent()) return + setNativeDiscovery(native) + if (native.status === "error" || native.status === "busy") { + setDetectionNotice({ found: false, text: t("vmLxc.appEditor.nativeScanFailed") }) + return + } const result: Suggestions = await fetchApi(`/api/vms/${vmid}/apps/suggestions`, { method: "POST", }) + if (!isCurrent()) return + // Recompose against the refreshed catalog without another native scan. + native = await fetchApi(`/api/vms/${vmid}/apps/native-discovery`) + if (!isCurrent()) return + setNativeDiscovery(native) setSuggestions(result) if (sidecar) setLxcAppsCached(vmid, sidecar, result) @@ -765,11 +817,14 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }: if (result.helper_slug) detected.add(result.helper_slug) for (const item of result.extras || []) detected.add(item.slug) for (const item of result.docker_workloads || []) detected.add(item.slug) + for (const item of native.candidates) detected.add(item.slug) const visible = [...detected].filter( (slug) => !registeredSlugs.has(slug) && !dismissedSlugs.has(slug), ) const newCount = visible.filter((slug) => !before.has(slug)).length - if (newCount === 1) { + if (native.status === "partial") { + setDetectionNotice({ found: visible.length > 0, text: t("vmLxc.appEditor.nativeScanPartial") }) + } else if (newCount === 1) { setDetectionNotice({ found: true, text: t("vmLxc.appEditor.oneNewApplicationDetected") }) } else if (newCount > 1) { setDetectionNotice({ @@ -785,9 +840,9 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }: }) } } catch (e: any) { - setError(e?.message || t("vmLxc.appEditor.detectionFailed")) + if (isCurrent()) setError(e?.message || t("vmLxc.appEditor.detectionFailed")) } finally { - setSearchingApplications(false) + if (isCurrent()) setSearchingApplications(false) } } @@ -804,6 +859,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }: existing?: AppEntry, opts?: { withTracking?: boolean, preset?: DetectedApp }, ) => { + const generation = targetGeneration.current let seed: AppConfig if (existing) { seed = { @@ -832,6 +888,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }: health_path: existing.health_path || "", logo_url: existing.logo_url || "", helper_slug: existing.helper_slug || "", + ...(existing.discovery_id ? { discovery_id: existing.discovery_id } : {}), // Preserved explicitly: saving replaces the whole record, so dropping // it here would silently un-delegate the app the first time someone // edits one of its ports. @@ -862,7 +919,11 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }: const p = opts.preset seed.name = p.name seed.logo_url = p.logo_url || "" - seed.helper_slug = p.slug + if (p.discovery_id) { + seed.discovery_id = p.discovery_id + seed.update_method = "none" + seed.update_command = "" + } else seed.helper_slug = p.slug // Docker endpoints come from the real published host-port mappings // listed under Web links. Do not pre-save a catalog default such as // 9000; the user explicitly chooses which workload links to add. @@ -943,9 +1004,10 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }: setShowAdvanced(false) } } + if (targetRef.current !== vmid || targetGeneration.current !== generation) return setBinaryArgsInput((seed.binary_args || []).join(", ")) setCommandArgvInput((seed.command_argv || []).join(", ")) - setEditing({ appId: existing?.id || null, draft: seed }) + setEditing({ appId: existing?.id || null, draft: seed, targetVmid: vmid }) setDetectorTest(null) setError(null) }, [suggestions, vmid, sidecar, isOciAdguard, adguardSetupAvailable]) @@ -959,7 +1021,9 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }: } const save = async () => { - if (!editing) return + if (!editing || editing.targetVmid !== vmid || targetRef.current !== vmid) return + const generation = targetGeneration.current + const isCurrent = () => targetRef.current === vmid && targetGeneration.current === generation setSaving(true) setError(null) try { @@ -973,6 +1037,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }: body: JSON.stringify(editing.draft), }) if ((r as any).error) throw new Error((r as any).error) + if (!isCurrent()) return setSidecar(r) setLxcAppsCached(vmid, r, suggestions) setEditing(null) @@ -980,9 +1045,9 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }: setCommandArgvInput("") onChange?.() } catch (e: any) { - setError(e?.message || t("vmLxc.appEditor.saveFailed")) + if (isCurrent()) setError(e?.message || t("vmLxc.appEditor.saveFailed")) } finally { - setSaving(false) + if (isCurrent()) setSaving(false) } } @@ -2452,6 +2517,24 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }: ? t("vmLxc.appEditor.versionDetected", { version: d.tracking_suggestion.detected_version || "" }) : t("vmLxc.appEditor.detectedInContainer")} + {d.source === "native_metadata" && ( +
+
{d.installed_version + ? t("vmLxc.appEditor.versionDetected", { version: d.installed_version }) + : t("vmLxc.appEditor.nativeVersionUnknown")}
+ {(nativeDiscovery?.stale || nativeDiscovery?.status === "partial") && ( +
{t("vmLxc.appEditor.nativeEvidenceStale")}
+ )} + {d.evidence?.map((e, index) =>
+ {t(e.kind === "tcp_listener" ? "vmLxc.appEditor.nativeEvidencePort" + : e.kind === "installation_file" ? "vmLxc.appEditor.nativeEvidenceFile" + : e.kind === "process_executable" ? "vmLxc.appEditor.nativeEvidenceProcess" + : e.kind === "working_directory" ? "vmLxc.appEditor.nativeEvidenceDirectory" + : "vmLxc.appEditor.nativeEvidenceService")}: {e.value} +
)} +
{t("vmLxc.appEditor.nativeRegisterOnly")}
+
+ )} {d.tracking_suggestion?.detector_source === "legacy_fallback" && (
{t("vmLxc.appEditor.legacyDetectorUsed")} diff --git a/AppImage/messages/en/common.json b/AppImage/messages/en/common.json index fa596302..64129339 100644 --- a/AppImage/messages/en/common.json +++ b/AppImage/messages/en/common.json @@ -1730,6 +1730,16 @@ "searchApplications": "Find applications", "searchingApplications": "Searching applications…", "noApplicationsDetected": "No applications were detected.", + "nativeEvidencePort": "Listening TCP port", + "nativeEvidenceFile": "Installed file", + "nativeEvidenceProcess": "Running executable", + "nativeEvidenceDirectory": "Working directory", + "nativeEvidenceService": "Service definition", + "nativeScanFailed": "Application search could not finish. Previous evidence may be outdated; try again.", + "nativeScanPartial": "Application search is incomplete. Review the available evidence and try again.", + "nativeVersionUnknown": "Installed version unknown", + "nativeEvidenceStale": "Evidence may be outdated or incomplete.", + "nativeRegisterOnly": "Confirm registration in the editor. A known version is a manual value; no updater is configured.", "noNewApplicationsDetected": "No new applications were detected.", "dockerDetectedWithWorkloads": "Docker detected with {count} containerised application(s)", "dockerWorkloadsHeading": "Running inside Docker", diff --git a/AppImage/messages/sk/common.json b/AppImage/messages/sk/common.json index 692fcce6..5d792990 100644 --- a/AppImage/messages/sk/common.json +++ b/AppImage/messages/sk/common.json @@ -1682,6 +1682,16 @@ "searchApplications": "Hľadať aplikácie", "searchingApplications": "Hľadajú sa aplikácie…", "noApplicationsDetected": "Neboli zistené žiadne aplikácie.", + "nativeEvidencePort": "Otvorený TCP port", + "nativeEvidenceFile": "Nainštalovaný súbor", + "nativeEvidenceProcess": "Spustený program", + "nativeEvidenceDirectory": "Pracovný priečinok", + "nativeEvidenceService": "Definícia služby", + "nativeScanFailed": "Vyhľadávanie aplikácií sa nepodarilo dokončiť. Predchádzajúce dôkazy môžu byť zastarané; skús to znova.", + "nativeScanPartial": "Vyhľadávanie aplikácií je neúplné. Skontroluj dostupné dôkazy a skús to znova.", + "nativeVersionUnknown": "Nainštalovaná verzia nie je známa", + "nativeEvidenceStale": "Dôkazy môžu byť zastarané alebo neúplné.", + "nativeRegisterOnly": "Registráciu potvrď v editore. Známa verzia je ručný údaj; aktualizátor nie je nastavený.", "noNewApplicationsDetected": "Neboli zistené žiadne nové aplikácie.", "oneNewApplicationDetected": "Bola zistená jedna nová aplikácia.", "newApplicationsDetected": "Boli zistené {count} nové aplikácie.", diff --git a/AppImage/scripts/build_appimage.sh b/AppImage/scripts/build_appimage.sh index 40560c21..0b02bbb6 100755 --- a/AppImage/scripts/build_appimage.sh +++ b/AppImage/scripts/build_appimage.sh @@ -149,6 +149,7 @@ cp "$SCRIPT_DIR/managed_installs.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo " cp "$SCRIPT_DIR/lxc_update_observation.py" "$APP_DIR/usr/bin/" cp "$SCRIPT_DIR/lxc_update_finalization.py" "$APP_DIR/usr/bin/" cp "$SCRIPT_DIR/lxc_check_job.py" "$APP_DIR/usr/bin/" +cp "$SCRIPT_DIR/native_app_discovery.py" "$APP_DIR/usr/bin/" cp "$SCRIPT_DIR/lxc_apps.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ lxc_apps.py not found" cp "$SCRIPT_DIR/custom_links.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ custom_links.py not found" cp "$SCRIPT_DIR/recreate_docker_container.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ recreate_docker_container.py not found" diff --git a/AppImage/scripts/flask_server.py b/AppImage/scripts/flask_server.py index 3ffa0c4c..f1d6c302 100644 --- a/AppImage/scripts/flask_server.py +++ b/AppImage/scripts/flask_server.py @@ -2276,6 +2276,8 @@ def _handle_guest_lifecycle(vmid: str, vm_type: str, action: str) -> None: # or stops; drop the cached IP so the next /api/vms poll re-reads # it via `lxc-info`. QEMU guests do not touch this cache. if guest_type == 'lxc': + import native_app_discovery + native_app_discovery.mark_stale(guest_id) _invalidate_lxc_ip(guest_id) if action in ('start', 'reboot'): if guest_type == 'lxc': @@ -14090,6 +14092,20 @@ def api_vm_apps_suggestions(vmid): return jsonify({'error': str(e)}), 500 +@app.route('/api/vms//apps/native-discovery', methods=['GET', 'POST']) +@require_auth +def api_vm_apps_native_discovery(vmid): + """Only the Find applications button requests POST. GET never probes.""" + import native_app_discovery + import lxc_apps + result = (native_app_discovery.discover(vmid) if request.method == 'POST' + else native_app_discovery.cached(vmid)) + suggestions = _vm_cache_get(_vm_app_suggestions_cache, vmid, _VM_APP_SUGGESTIONS_TTL) or {} + sidecar = lxc_apps.load_sidecar(vmid) or {} + return jsonify(native_app_discovery.filter_candidates( + result, suggestions, sidecar.get('apps') or [])) + + @app.route('/api/vms//apps/adguard-setup', methods=['GET']) @require_auth def api_vm_apps_adguard_setup(vmid): diff --git a/AppImage/scripts/lxc_apps.py b/AppImage/scripts/lxc_apps.py index 494c9db5..4b6a5a2e 100644 --- a/AppImage/scripts/lxc_apps.py +++ b/AppImage/scripts/lxc_apps.py @@ -1179,6 +1179,13 @@ def validate_config(payload: dict) -> tuple[bool, Any]: return _err("logo_url must be an http(s) URL (max 512 chars)") conf["logo_url"] = logo + # Native discovery identity is separate from a curated helper/updater. + discovery_id = payload.get("discovery_id") + if discovery_id is not None: + if not isinstance(discovery_id, str) or not re.fullmatch(r"native-[0-9a-f]{24}", discovery_id): + return _err("invalid discovery_id") + conf["discovery_id"] = discovery_id + # Optional helper_slug — set by the frontend when the user picks # an auto-detected app (primary or extra). Persisted so we can # filter the "also detected" chip list against apps already diff --git a/AppImage/scripts/native_app_discovery.py b/AppImage/scripts/native_app_discovery.py new file mode 100644 index 00000000..a87dfc6e --- /dev/null +++ b/AppImage/scripts/native_app_discovery.py @@ -0,0 +1,487 @@ +#!/usr/bin/env python3 +# ========================================================== +# ProxMenux - A menu-driven script for Proxmox VE management +# Author : VAIO73 +# GitHub : github.com/Vaso73 +# Maintainer : MacRimi +# Copyright : (c) 2026 MacRimi & contributors +# License : (GPL-3.0) (https://github.com/MacRimi/ProxMenux/blob/main/LICENSE) +# Version : 1.0 +# Last Updated: 14/10/2026 +# ========================================================== +# Description: +# On-demand native LXC discovery from service and installation metadata. +# Reads bounded data only; never executes discovered programs, starts guests, +# registers applications or infers their update mechanism. +# ========================================================== +"""Separate from catalog detection: callers must explicitly request a scan.""" + +import copy +import fcntl +import hashlib +import json +import os +from pathlib import Path +import re +import selectors +import signal +import subprocess +import threading +import time + +TOTAL_SECONDS = 20 +PROBE_SECONDS = 3 +OUTPUT_BYTES = 64 * 1024 +MAX_RECORDS = 256 +MAX_CANDIDATES = 50 +CACHE_SECONDS = 300 +LOCAL_CONFIG = Path('/etc/pve/local/lxc') +OCI_ROOT = Path('/usr/local/share/proxmenux/oci/instances') +UPDATE_LOCK_ROOT = Path('/run/lock') +_cache = {} +_locks = {} +_mutex = threading.Lock() +_PATH = re.compile(r'/[A-Za-z0-9_.+/@-]{1,510}\Z') +_NAME = re.compile(r'[A-Za-z0-9][A-Za-z0-9_.+@-]{0,63}\Z') +_VERSION = re.compile(r'v?\d+(?:\.\d+){1,4}(?:[-+._][A-Za-z0-9.-]+)?\Z') +_SYSTEM = { + 'ssh', 'sshd', 'cron', 'crond', 'postfix', 'rsyslog', 'syslog', 'syslog-ng', + 'dbus', 'dbus-broker', 'systemd', 'udev', 'networking', 'network', 'networkmanager', + 'dhcpcd', 'dhclient', 'chrony', 'chronyd', 'ntp', 'ntpd', 'openntpd', 'agetty', + 'getty', 'login', 'polkit', 'polkitd', 'accounts-daemon', 'acpid', 'anacron', + 'docker', 'containerd', 'containerd-shim', 'podman', 'qemu-guest-agent', 'rngd', + 'auditd', 'logrotate', 'local', 'bootmisc', 'hostname', 'hwclock', 'modules', 'master', + 'proxmox-boot-sync', 'pve-container', 'pve-firewall', 'pvefw-logger', + 'pvedaemon', 'pvestatd', 'pveproxy', 'pvescheduler', 'spiceproxy', 'lxc', + 'mount-ro', 'mdev', 'sysctl', 'sysfs', 'devfs', 'cgroups', 'rc', 'rcs', 'swap', + 'sleep', 'true', 'false', 'echo', 'cat', 'head', 'tail', 'tee', 'logger', + 'apt', 'apt-get', 'dpkg', 'dpkg-query', 'apk', 'unattended-upgrades', + 'fstrim', 'fsck', 'fsckd', 'e2scrub', 'e2scrub-all', 'e2scrub-reap', + 'irqbalance', 'smartd', 'smartmontools', 'rpcbind', 'nfs-common', 'nfs-server', + 'rpc-statd', 'rpc-statd-notify', 'rpc-gssd', 'rpc-svcgssd', 'nftables', 'iptables', + 'ip6tables', 'firewalld', 'ufw', 'resolvconf', 'wpa-supplicant', 'modemmanager', + 'console-setup', 'keyboard-setup', 'plymouth', 'halt', 'reboot', 'shutdown', + 'rc-local', 'ntpsec', 'networkd-dispatcher', 'busybox', 'atd', 'uuidd', +} +_RUNTIMES = re.compile(r'(?:python[0-9.]*|node(?:js)?|sh|bash|dash|env|java|ruby|perl)\Z') + + +class ProbeError(Exception): + """Safe error code only; guest output must never become an API error.""" + + +def bounded_run(argv, deadline): + """Cap combined stdout/stderr while reading, and kill/reap the process group.""" + remaining = min(PROBE_SECONDS, deadline - time.monotonic()) + if remaining <= 0: + raise ProbeError('deadline') + end = time.monotonic() + remaining + try: + proc = subprocess.Popen(argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + start_new_session=True) + except OSError: + raise ProbeError('unavailable') from None + outputs = [bytearray(), bytearray()] + try: + with selectors.DefaultSelector() as poll: + for index, stream in enumerate((proc.stdout, proc.stderr)): + os.set_blocking(stream.fileno(), False) + poll.register(stream, selectors.EVENT_READ, index) + while poll.get_map(): + wait = end - time.monotonic() + if wait <= 0: + raise ProbeError('timeout') + for key, _ in poll.select(wait): + block = os.read(key.fileobj.fileno(), 4096) + if not block: + poll.unregister(key.fileobj) + continue + if sum(map(len, outputs)) + len(block) > OUTPUT_BYTES: + raise ProbeError('output_limit') + outputs[key.data].extend(block) + try: + proc.wait(timeout=max(0.001, end - time.monotonic())) + except subprocess.TimeoutExpired: + raise ProbeError('timeout') from None + if proc.returncode: + raise ProbeError('read_failed') + return outputs[0].decode('utf-8', errors='strict') + except UnicodeError: + raise ProbeError('invalid_data') from None + finally: + # Also clean up descendants that outlive a successfully exited wrapper. + try: + os.killpg(proc.pid, signal.SIGKILL) + except ProcessLookupError: + pass + proc.wait() + proc.stdout.close() + proc.stderr.close() + + +# These are fixed read-only scripts, not guest-supplied shell or init scripts. +# awk emits only executable/script paths and WorkingDirectory, never arguments +# or environment values. OpenRC files are parsed as data, never sourced. +SERVICE_SCRIPT = r''' +command -v awk >/dev/null && command -v head >/dev/null || exit 1 +n=0 +for dir in /etc/systemd/system /usr/lib/systemd/system /lib/systemd/system /etc/init.d; do + [ ! -d "$dir" ] || [ -r "$dir" ] || exit 1 + for f in "$dir"/*; do + [ -f "$f" ] || continue + case "$dir:$f" in /etc/init.d:*) kind=openrc;; *:*.service) kind=systemd;; *) continue;; esac + n=$((n+1)); [ "$n" -le 256 ] || { printf 'LIMIT\n'; exit 0; } + [ -r "$f" ] || exit 1 + content=$(timeout -s KILL 1 head -c 16385 "$f") || exit 1 + [ "${#content}" -le 16384 ] || { printf 'LIMIT\n'; exit 0; } + printf '%s\n' "$content" | awk -v kind="$kind" -v file="$f" ' + function clean(v) {gsub(/^[ \t]+|[ \t]+$/, "", v); gsub(/^["\047]|["\047]$/, "", v); return v} + /^ExecStart=/ {v=substr($0,11); sub(/^[-+!:@]+/, "", v); split(v,a,/[ \t]+/); exe=clean(a[1]); script=clean(a[2]); if(script !~ /^\//) script=""} + /^WorkingDirectory=/ {dir=clean(substr($0,18))} + /^command=/ {exe=clean(substr($0,9))} + /^directory=/ {dir=clean(substr($0,11))} + /^command_args=/ {v=clean(substr($0,14)); split(v,a,/[ \t]+/); script=clean(a[1]); if(script !~ /^\//) script=""} + END {printf "S\t%s\t%s\t%s\t%s\t%s\n", file, kind, exe, script, dir}' + done +done +printf 'END\n' +''' +PROCESS_SCRIPT = r''' +command -v readlink >/dev/null && command -v head >/dev/null || exit 1 +n=0 +for dir in /proc/[0-9]*; do + [ -d "$dir" ] || continue + n=$((n+1)); [ "$n" -le "$1" ] || { printf 'LIMIT\n'; exit 0; } + # Never inspect Docker/OCI child workloads or read cmdline/environ. + [ -d "$dir" ] || continue + [ -r "$dir/cgroup" ] || exit 1 + group=$(timeout -s KILL 1 head -c 8193 "$dir/cgroup") || exit 1 + [ "${#group}" -le 8192 ] || { printf 'LIMIT\n'; exit 0; } + case "$group" in *docker*|*libpod*|*kubepods*|*containerd*) continue;; esac + # Kernel threads and vanished processes have no application executable. + [ -L "$dir/exe" ] || continue + exe=$(timeout -s KILL 1 readlink "$dir/exe") || continue + cwd=$(timeout -s KILL 1 readlink "$dir/cwd") || continue + printf 'P\t%s\t%s\t%s\n' "${dir##*/}" "$exe" "$cwd" +done +printf 'END\n' +''' +PORT_SCRIPT = r''' +command -v awk >/dev/null || exit 1 +if command -v ss >/dev/null; then + (timeout -s KILL 1 ss -H -lntp || printf 'FAILED\n') | awk -v cap="$1" ' + /^FAILED$/ {failed=1; exit} + NR > cap {limited=1; exit} + {port=$4; sub(/^.*:/,"",port); rest=$0 + # More than one owner is ambiguous; never choose the first arbitrarily. + if (gsub(/pid=/,"pid=",rest)!=1) next + if (match(rest,/pid=[0-9]+/)) {pid=substr(rest,RSTART+4,RLENGTH-4); printf "L\t%s\t%s\n",pid,port}} + END {print failed ? "FAILED" : (limited ? "LIMIT" : "END")}' +elif command -v netstat >/dev/null; then + (timeout -s KILL 1 netstat -lntp || printf 'FAILED\n') | awk -v cap="$1" ' + /^FAILED$/ {failed=1; exit} + /^tcp/ {n++; if(n>cap) {limited=1; exit}; port=$4; sub(/^.*:/,"",port) + pid=$7; sub(/\/.*$/,"",pid); if(pid ~ /^[0-9]+$/) printf "L\t%s\t%s\n",pid,port} + END {print failed ? "FAILED" : (limited ? "LIMIT" : "END")}' +else + exit 1 +fi +''' +FOOTPRINT_SCRIPT = r''' +[ -f "$1" ] && [ -r "$1" ] || exit 1 +printf 'FILE\n' +if [ -f "$2/package.json" ]; then + [ -r "$2/package.json" ] || exit 1 + timeout -s KILL 1 head -c 8192 "$2/package.json" +fi +''' + + +def _path(value): + return bool(isinstance(value, str) and _PATH.fullmatch(value) + and '..' not in value.split('/')) + + +def _system(name): + raw = name.lower().removesuffix('.service') + stem = raw.split('@')[0] + return raw.startswith('user@') or stem in _SYSTEM or stem.startswith(( + 'systemd-', 'dbus-', 'getty', 'containerd-', 'apt-', 'dpkg-', 'e2scrub', + 'unattended-', 'ifup', 'ifdown', 'user@', 'btrfs-', 'lvm2-', 'lxc-')) + + +def _custom(path): + return _path(path) and path.startswith(('/opt/', '/srv/', '/usr/local/')) + + +def parse_records(text, kind): + lines = text.splitlines() + if not lines or lines[-1] not in ('END', 'LIMIT'): + raise ProbeError('incomplete') + if len(lines) - 1 > MAX_RECORDS: + raise ProbeError('metadata_limit') + records = [] + seen_services = set() + for line in lines[:-1]: + parts = line.split('\t') + if kind == 'S' and len(parts) == 6 and parts[0] == 'S': + _, source, manager, exe, script, directory = parts + if not _path(source) or manager not in ('systemd', 'openrc'): + raise ProbeError('invalid_data') + service = Path(source).name.removesuffix('.service') + if service in seen_services: + continue # /etc overrides vendor units; no duplicate installations. + seen_services.add(service) + if not _NAME.fullmatch(service) or _system(service) or not _path(exe): + continue + runtime = bool(_RUNTIMES.fullmatch(Path(exe).name)) + install = script if runtime else exe + if (not _path(install) or _system(Path(install).name) + or install.startswith(('/usr/lib/systemd/', '/lib/systemd/', + '/usr/lib/udev/', '/lib/udev/', '/usr/lib/apt/'))): + continue + root = directory if _custom(directory) else str(Path(install).parent) + # Generic runtime without an absolute application script is ambiguous. + if runtime and not _custom(install): + continue + records.append({'name': service, 'path': install, 'root': root, + 'service': source, 'manager': manager, 'executable': exe}) + elif kind == 'P' and len(parts) == 4 and parts[0] == 'P': + _, pid, exe, directory = parts + if not pid.isdigit() or not _path(exe) or not _path(directory): + continue + records.append({'pid': pid, 'executable': exe, 'root': directory}) + elif kind == 'L' and len(parts) == 3 and parts[0] == 'L': + _, pid, port = parts + if pid.isdigit() and port.isdigit() and 0 < int(port) < 65536: + records.append({'pid': pid, 'port': int(port)}) + else: + raise ProbeError('invalid_data') + else: + raise ProbeError('invalid_data') + return records, lines[-1] == 'LIMIT', len(lines) - 1 + + +def candidate(vmid, record, footprint): + if not footprint.startswith('FILE\n'): + raise ProbeError('invalid_data') + version, metadata_issue = None, None + metadata = footprint[5:].strip() + if metadata: + try: + manifest = json.loads(metadata) + except (ValueError, TypeError): + manifest = None + # Only the whitelisted version field leaves this function. Never retain + # manifest scripts, repository guesses, config or arbitrary extra fields. + if not isinstance(manifest, dict): + metadata_issue = 'invalid_metadata' + manifest = {} + value = manifest.get('version') + if (isinstance(value, str) and len(value) <= 64 and _VERSION.fullmatch(value) + and record['path'].startswith(record['root'].rstrip('/') + '/')): + version = value + identity = 'native-' + hashlib.sha256( + f'{int(vmid)}:{record["path"]}:{record["root"]}'.encode()).hexdigest()[:24] + evidence = [{'kind': 'installation_file', 'value': record['path']}] + if record.get('service'): + evidence.append({'kind': record['manager'], 'value': record['service']}) + else: + evidence.extend([{'kind': 'process_executable', 'value': record['process']}, + {'kind': 'working_directory', 'value': record['root']}]) + ports = sorted(set(record.get('listening_ports') or [])) + evidence.extend({'kind': 'tcp_listener', 'value': str(port)} for port in ports) + return {'slug': identity, 'discovery_id': identity, 'source': 'native_metadata', + 'name': record['name'], 'installed_version': version, 'evidence': evidence, + 'installation_path': record['path'], 'default_ports': [], + 'listening_ports': ports, + 'metadata_issue': metadata_issue, + 'tracking_suggestion': ({'installed_via': 'manual', 'installed_version': version, + 'detected_version': version, 'detector_verified': True} + if version else None)} + + +def _guard(vmid, deadline, run): + # The local node path rejects QEMU, missing and remote CTs before pct exec. + path = LOCAL_CONFIG / f'{vmid}.conf' + if not path.is_file(): + raise ProbeError('not_local_lxc') + # Presence, including a damaged/in-progress record, excludes generic probes. + try: + os.lstat(OCI_ROOT / str(vmid)) + except FileNotFoundError: + pass + except OSError: + raise ProbeError('oci_unknown') from None + else: + raise ProbeError('managed_oci') + try: + with path.open('rb') as handle: + raw = handle.read(OUTPUT_BYTES + 1) + if len(raw) > OUTPUT_BYTES: + raise ProbeError('config_limit') + config = raw.decode('utf-8') + except (OSError, UnicodeError): + raise ProbeError('config_unavailable') from None + if re.search(r'^lock:', config, re.M): + raise ProbeError('guest_busy') + update_lock = UPDATE_LOCK_ROOT / f'proxmenux-lxc-update-{vmid}.lock' + try: + with update_lock.open('rb') as handle: + try: + fcntl.flock(handle, fcntl.LOCK_SH | fcntl.LOCK_NB) + except BlockingIOError: + raise ProbeError('guest_busy') from None + finally: + fcntl.flock(handle, fcntl.LOCK_UN) + except FileNotFoundError: + pass + except OSError: + raise ProbeError('lock_unavailable') from None + if re.search(r'^ostype:\s*unmanaged\s*$', config, re.M): + raise ProbeError('unsupported_guest') + if not re.search(r'^ostype:\s*(?:debian|ubuntu|alpine|archlinux|centos|fedora|opensuse|gentoo)\s*$', config, re.M): + raise ProbeError('unsupported_guest') + status = run(['pct', 'status', str(vmid)], deadline).strip() + if status != 'status: running': + raise ProbeError('guest_stopped' if status == 'status: stopped' else 'status_unknown') + + +def scan(vmid, run=bounded_run): + deadline = time.monotonic() + TOTAL_SECONDS + _guard(vmid, deadline, run) + records, issues, consumed, processes, listeners = [], [], 0, [], [] + for kind, script in (('S', SERVICE_SCRIPT), ('P', PROCESS_SCRIPT), ('L', PORT_SCRIPT)): + try: + if consumed >= MAX_RECORDS: + issues.append('metadata_limit') + break + parsed, limited, count = parse_records(run( + ['pct', 'exec', str(vmid), '--', 'timeout', '-s', 'KILL', '2', + 'sh', '-c', script, 'discovery', str(MAX_RECORDS - consumed)], deadline), kind) + if consumed + count > MAX_RECORDS: + issues.append('metadata_limit') + parsed = parsed[:MAX_RECORDS - consumed] + consumed = min(MAX_RECORDS, consumed + count) + # The entire collector, not each metadata source, has 256 records. + capacity = MAX_RECORDS - len(records) + if kind == 'S': + records.extend(parsed[:capacity]) + elif kind == 'P': + processes = parsed[:capacity] + else: + listeners = parsed[:capacity] + if limited or len(parsed) > capacity: + issues.append('metadata_limit') + except ProbeError as exc: + issues.append(str(exc)) + for process in processes: + exe, directory = process['executable'], process['root'] + related = [record for record in records if record['executable'] == exe and ( + not _RUNTIMES.fullmatch(Path(exe).name) or record['root'] == directory)] + ports = [entry['port'] for entry in listeners if entry['pid'] == process['pid']] + if len(related) == 1: + related[0].setdefault('listening_ports', []).extend(ports) + elif not related and (_custom(exe) and _custom(directory) + and not _system(Path(exe).name) + and not _RUNTIMES.fullmatch(Path(exe).name) + and exe.startswith(directory.rstrip('/') + '/') + and _NAME.fullmatch(Path(exe).name)): + records.append({'name': Path(exe).name, 'path': exe, 'root': directory, + 'process': exe, 'executable': exe, 'listening_ports': ports}) + items, seen = [], set() + for record in records: + key = (record['path'], record['root']) + if key in seen: + continue + seen.add(key) + if len(items) >= MAX_CANDIDATES: + issues.append('candidate_limit') + break + try: + footprint = run(['pct', 'exec', str(vmid), '--', 'timeout', '-s', 'KILL', '2', + 'sh', '-c', FOOTPRINT_SCRIPT, + 'discovery', record['path'], record['root']], deadline) + item = candidate(vmid, record, footprint) + items.append(item) + if item['metadata_issue']: + issues.append(item['metadata_issue']) + except ProbeError as exc: + issues.append(str(exc)) + if str(exc) == 'deadline': + break + return {'vmid': vmid, 'status': 'partial' if issues else 'complete', + 'candidates': items, 'issues': sorted(set(issues)), 'stale': False, + 'scanned_at': time.time()} + + +def cached(vmid): + with _mutex: + value = copy.deepcopy(_cache.get(int(vmid))) + if value is None: + return {'vmid': int(vmid), 'status': 'not_scanned', 'candidates': [], 'stale': False} + if time.time() - value['scanned_at'] >= CACHE_SECONDS: + value['stale'] = True + return value + + +def mark_stale(vmid): + """Lifecycle events retire freshness, never collect new guest metadata.""" + with _mutex: + if int(vmid) in _cache: + _cache[int(vmid)]['stale'] = True + + +def discover(vmid): + vmid = int(vmid) + with _mutex: + lock = _locks.setdefault(vmid, threading.Lock()) + if not lock.acquire(blocking=False): + return {**cached(vmid), 'status': 'busy', 'stale': True} + try: + try: + result = scan(vmid) + except ProbeError as exc: + result = {**cached(vmid), 'status': 'error', 'issues': [str(exc)], 'stale': True} + result.setdefault('scanned_at', time.time()) + with _mutex: + _cache[vmid] = copy.deepcopy(result) + return result + previous = cached(vmid) + if result['status'] != 'complete' and previous['candidates']: + # Partial output cannot prove that previously seen apps disappeared. + ids = {item['discovery_id'] for item in result['candidates']} + result['candidates'].extend(item for item in previous['candidates'] + if item['discovery_id'] not in ids) + result['candidates'] = result['candidates'][:MAX_CANDIDATES] + result['stale'] = True + with _mutex: + _cache[vmid] = copy.deepcopy(result) + return result + finally: + lock.release() + + +def filter_candidates(result, suggestions, apps): + """Pure composition: never run catalog probes, load or write registries.""" + known = [*apps, *(suggestions.get('extras') or []), + *(suggestions.get('docker_workloads') or [])] + if suggestions.get('helper_slug'): + known.append({'name': suggestions.get('name_suggestion'), + 'helper_slug': suggestions['helper_slug'], + 'tracking_suggestion': suggestions.get('tracking_suggestion')}) + identities = {a.get('discovery_id') for a in known if a.get('discovery_id')} + names, paths = set(), set() + for app in known: + for field in ('name', 'slug', 'helper_slug', 'package'): + if app.get(field): + names.add(str(app[field]).casefold()) + detector = app.get('tracking_suggestion') or app + for field in ('binary_path', 'file_path'): + if detector.get(field): + paths.add(detector[field]) + items = [item for item in result['candidates'] + if item['discovery_id'] not in identities + and item['name'].casefold() not in names + and item['installation_path'] not in paths] + return {**result, 'candidates': items} diff --git a/AppImage/scripts/tests/test_native_app_discovery.py b/AppImage/scripts/tests/test_native_app_discovery.py new file mode 100644 index 00000000..cc1539b1 --- /dev/null +++ b/AppImage/scripts/tests/test_native_app_discovery.py @@ -0,0 +1,441 @@ +"""Host-independent discovery fixtures; no real pct, guest, auth or registry.""" +import ast +import copy +import fcntl +from functools import wraps +import json +from pathlib import Path +import subprocess +import sys +import tempfile +import threading +import time +import unittest +from unittest.mock import Mock, patch + +SCRIPTS = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(SCRIPTS)) +import native_app_discovery as native +import lxc_apps + + +def service(name='handmade', exe='/opt/handmade/server', directory='/opt/handmade', manager='systemd', script=''): + path = ('/etc/systemd/system/' + name + '.service' if manager == 'systemd' + else '/etc/init.d/' + name) + return '\t'.join(['S', path, manager, exe, script, directory]) + '\nEND\n' + + +class DiscoveryFixtures(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.config = self.root / 'lxc' + self.config.mkdir() + (self.config / '101.conf').write_text('ostype: debian\n') + self.oci = self.root / 'oci' + self.oci.mkdir() + for context in (patch.object(native, 'LOCAL_CONFIG', self.config), + patch.object(native, 'OCI_ROOT', self.oci), + patch.object(native, 'UPDATE_LOCK_ROOT', self.root)): + context.start() + self.addCleanup(context.stop) + native._cache.clear() + native._locks.clear() + self.calls = [] + + def runner(self, services=None, processes='END\n', footprint='FILE\n', status='status: running', ports='END\n'): + def run(argv, deadline): + self.calls.append(argv) + self.assertLessEqual(deadline - time.monotonic(), native.TOTAL_SECONDS) + if argv[1] == 'status': + return status + self.assertEqual(argv[:6], ['pct', 'exec', '101', '--', 'timeout', '-s']) + self.assertEqual(argv[6:9], ['KILL', '2', 'sh']) + script = argv[10] + if script == native.SERVICE_SCRIPT: + return services if services is not None else service() + if script == native.PROCESS_SCRIPT: + return processes + if script == native.PORT_SCRIPT: + return ports + if script == native.FOOTPRINT_SCRIPT: + return footprint + self.fail('Unexpected probe') + return run + + def test_unknown_version_is_present_with_evidence_and_no_capabilities(self): + result = native.scan(101, self.runner()) + self.assertEqual(result['status'], 'complete') + item, = result['candidates'] + self.assertIsNone(item['installed_version']) + self.assertIsNone(item['tracking_suggestion']) + self.assertEqual(item['default_ports'], []) + self.assertEqual({e['kind'] for e in item['evidence']}, {'installation_file', 'systemd'}) + for field in ('helper_slug', 'repo', 'update_command', 'binary_path', 'upstream_type'): + self.assertNotIn(field, item) + + def test_openrc_node_script_known_version_is_manual_and_secret_free(self): + result = native.scan(101, self.runner( + service(exe='/usr/bin/node', script='/opt/handmade/server.js', manager='openrc'), + footprint='FILE\n' + json.dumps({'version': '1.2.3', 'scripts': {'start': 'SECRET'}, 'env': 'PASSWORD'}))) + item, = result['candidates'] + self.assertEqual(item['installed_version'], '1.2.3') + self.assertEqual(item['tracking_suggestion']['installed_via'], 'manual') + self.assertNotIn('SECRET', json.dumps(result)) + self.assertNotIn('PASSWORD', json.dumps(result)) + self.assertFalse(any('systemctl' in arg for call in self.calls for arg in call)) + + def test_inactive_service_and_packaged_application_are_not_hidden(self): + result = native.scan(101, self.runner(service(name='legit-app', exe='/usr/bin/legit-app', directory=''))) + self.assertEqual(result['candidates'][0]['name'], 'legit-app') + + def test_system_services_and_ambiguous_runtimes_have_zero_footprint_probes(self): + for name, exe in [('ssh', '/usr/sbin/sshd'), ('cron', '/usr/sbin/cron'), + ('postfix', '/usr/lib/postfix/master'), ('rsyslog', '/usr/sbin/rsyslogd'), + ('systemd-networkd', '/usr/lib/systemd/systemd-networkd'), + ('ambiguous', '/usr/bin/python3')]: + self.calls.clear() + result = native.scan(101, self.runner(service(name=name, exe=exe))) + self.assertEqual(result['candidates'], []) + self.assertFalse(any(native.FOOTPRINT_SCRIPT in call for call in self.calls)) + + def test_missing_remote_qemu_stopped_busy_oci_guards_precede_exec(self): + scenarios = ['missing', 'stopped', 'busy', 'oci', 'invalid-oci', 'unmanaged'] + for scenario in scenarios: + with self.subTest(scenario=scenario): + (self.config / '101.conf').write_text('ostype: debian\n') + if scenario == 'missing': + (self.config / '101.conf').unlink() + if scenario == 'busy': + (self.config / '101.conf').write_text('lock: backup\n') + if scenario == 'unmanaged': + (self.config / '101.conf').write_text('ostype: unmanaged\n') + if 'oci' in scenario: + (self.oci / '101').mkdir(exist_ok=True) + (self.oci / '101' / 'oci-compose.json').write_text('broken') + self.calls.clear() + with self.assertRaises(native.ProbeError): + native.scan(101, self.runner(status='status: stopped' if scenario == 'stopped' else 'status: running')) + self.assertFalse(any(call[1] == 'exec' for call in self.calls)) + if (self.oci / '101').exists(): + (self.oci / '101' / 'oci-compose.json').unlink() + (self.oci / '101').rmdir() + + def test_failure_and_partial_output_are_not_successful_empty_results(self): + for text in ('', 'malformed\nEND\n', 'S\tpartial', 'LIMIT\n'): + result = native.scan(101, self.runner(services=text)) + self.assertEqual(result['status'], 'partial') + run = self.runner() + def failed(argv, deadline): + if argv[1] == 'exec': + raise native.ProbeError('read_failed') + return run(argv, deadline) + self.assertEqual(native.scan(101, failed)['status'], 'partial') + + def test_optional_invalid_version_metadata_keeps_proven_candidate(self): + result = native.scan(101, self.runner(footprint='FILE\n{broken')) + self.assertEqual(result['status'], 'partial') + item, = result['candidates'] + self.assertIsNone(item['installed_version']) + self.assertIsNone(item['tracking_suggestion']) + + def test_working_directory_manifest_does_not_version_an_unrelated_executable(self): + result = native.scan(101, self.runner(service(exe='/opt/other/server'), + footprint='FILE\n{"version":"1.2.3"}')) + self.assertIsNone(result['candidates'][0]['installed_version']) + + def test_path_injection_and_port_only_data_do_not_become_candidates(self): + for text in (service(exe='/opt/app/$(id)'), service(exe='/opt/../server'), + service(name='app;id'), service(exe='/bin/sh')): + self.assertEqual(native.scan(101, self.runner(text))['candidates'], []) + with self.assertRaises(native.ProbeError): + native.parse_records('PORT\t8080\nEND\n', 'P') + + def test_same_process_deduplicates_but_two_apps_using_node_do_not_merge(self): + services = service(exe='/usr/bin/node', script='/opt/handmade/server.js').removesuffix('END\n') + services += service(name='second', exe='/usr/bin/node', script='/opt/second/server.js', directory='/opt/second') + result = native.scan(101, self.runner(services)) + self.assertEqual(len(result['candidates']), 2) + result = native.scan(101, self.runner(processes='P\t123\t/opt/handmade/server\t/opt/handmade\nEND\n')) + self.assertEqual(len(result['candidates']), 1) + other = native.candidate(102, native.parse_records(service(), 'S')[0][0], 'FILE\n') + self.assertNotEqual(other['discovery_id'], result['candidates'][0]['discovery_id']) + + def test_record_and_candidate_caps(self): + too_many = 'P\t123\t/opt/a/app\t/opt/a\n' * 257 + 'END\n' + with self.assertRaises(native.ProbeError): + native.parse_records(too_many, 'P') + services = ''.join(service(name=f'app{i}', exe=f'/opt/app{i}/server', directory=f'/opt/app{i}').removesuffix('END\n') for i in range(51)) + 'END\n' + result = native.scan(101, self.runner(services)) + self.assertEqual(len(result['candidates']), 50) + self.assertIn('candidate_limit', result['issues']) + + def test_cached_ttl_never_runs_scan_and_error_preserves_last_known(self): + complete = native.scan(101, self.runner()) + with patch.object(native, 'scan', return_value=complete) as scan: + native.discover(101) + for age, stale in ((299.9, False), (300, True), (300.1, True)): + with patch.object(native.time, 'time', return_value=complete['scanned_at'] + age): + self.assertEqual(native.cached(101)['stale'], stale) + self.assertEqual(scan.call_count, 1) + with patch.object(native, 'scan', side_effect=native.ProbeError('timeout')): + result = native.discover(101) + self.assertEqual(result['candidates'], complete['candidates']) + self.assertEqual(native.cached(101)['status'], 'error') + self.assertTrue(native.cached(101)['stale']) + partial = {**complete, 'status': 'partial', 'candidates': [], 'issues': ['read_failed']} + with patch.object(native, 'scan', return_value=partial): + self.assertEqual(native.discover(101)['candidates'], complete['candidates']) + with patch.object(native, 'scan', return_value={**complete, 'candidates': []}): + self.assertEqual(native.discover(101)['candidates'], []) + + def test_same_ct_busy_and_different_ct_cache_isolation(self): + lock = threading.Lock() + lock.acquire() + native._locks[101] = lock + with patch.object(native, 'scan') as scanner: + self.assertEqual(native.discover(101)['status'], 'busy') + scanner.assert_not_called() + self.assertEqual(native.cached(102)['status'], 'not_scanned') + + def test_filter_registered_curated_and_docker_children_without_mutation(self): + result = native.scan(101, self.runner()) + original = copy.deepcopy(result) + for suggestions, apps in (({}, [{'discovery_id': result['candidates'][0]['discovery_id']}]), + ({'extras': [{'name': 'Handmade'}]}, []), + ({'docker_workloads': [{'name': 'Handmade'}]}, []), + ({'helper_slug': 'handmade', 'name_suggestion': 'Handmade'}, [])): + self.assertEqual(native.filter_candidates(result, suggestions, apps)['candidates'], []) + self.assertEqual(result, original) + + def test_actual_save_first_check_and_recheck_never_probe_or_configure_updater(self): + with patch.object(lxc_apps, '_APPS_DIR', str(self.root / 'apps')), \ + patch.object(lxc_apps, '_pct_exec', side_effect=AssertionError('forbidden guest execution')) as execute, \ + patch.object(lxc_apps, 'fetch_latest_upstream_details', side_effect=AssertionError('forbidden upstream')) as upstream: + for version in (None, '1.2.3'): + item = native.candidate(101, native.parse_records(service(), 'S')[0][0], + 'FILE\n' + (json.dumps({'version': version}) if version else '')) + payload = {'name': item['name'], 'discovery_id': item['discovery_id'], + 'update_method': 'none', **(item['tracking_suggestion'] or {})} + ok, sidecar = lxc_apps.add_app(101, payload) + self.assertTrue(ok, sidecar) + app = sidecar['apps'][-1] + self.assertEqual(app['state']['installed_version'], version) + self.assertIsNone(app['state']['update_available']) + self.assertEqual(app['update_method'], 'none') + for field in ('helper_slug', 'update_command', 'repo', 'upstream_type'): + self.assertNotIn(field, app) + lxc_apps.check_app(101, app['id'], force=True) + self.assertIsNone(lxc_apps.get_schedule(101)) + self.assertIsNone(lxc_apps.get_bulk_update(101)) + execute.assert_not_called() + upstream.assert_not_called() + + def test_failed_save_has_no_first_check(self): + with patch.object(lxc_apps, '_write_sidecar', return_value=False), \ + patch.object(lxc_apps, '_read_sidecar', return_value=None), \ + patch.object(lxc_apps, 'check_app') as check: + self.assertFalse(lxc_apps.add_app(101, {'name': 'handmade'})[0]) + check.assert_not_called() + + def test_active_update_lock_blocks_all_guest_probes_without_writing(self): + path = self.root / 'proxmenux-lxc-update-101.lock' + path.write_bytes(b'unchanged') + with path.open('rb') as handle: + fcntl.flock(handle, fcntl.LOCK_EX) + with self.assertRaisesRegex(native.ProbeError, 'guest_busy'): + native.scan(101, self.runner()) + self.assertEqual(path.read_bytes(), b'unchanged') + self.assertEqual(self.calls, []) + + def test_actual_service_parser_reads_data_never_executes_or_sources_it(self): + for manager in ('systemd', 'openrc'): + directory = self.root / manager + directory.mkdir() + marker = self.root / ('forbidden-' + manager) + unit = directory / ('handmade.service' if manager == 'systemd' else 'handmade') + content = ('[Service]\nExecStart=/usr/bin/node /opt/handmade/server.js --password SECRET\nWorkingDirectory=/opt/handmade\n' + if manager == 'systemd' else 'command="/usr/bin/node"\ncommand_args="/opt/handmade/server.js --password SECRET"\ndirectory="/opt/handmade"\n') + unit.write_text(content + '\ntouch ' + str(marker) + '\n') + script = native.SERVICE_SCRIPT.replace('/etc/systemd/system /usr/lib/systemd/system /lib/systemd/system /etc/init.d', str(directory)) + # Substitute only fixture paths, not the collector/parser behavior. + if manager == 'openrc': + script = script.replace('/etc/init.d:*)', str(directory) + ':*)') + output = native.bounded_run(['sh', '-c', script], time.monotonic() + 3) + record, = native.parse_records(output, 'S')[0] + self.assertEqual(record['path'], '/opt/handmade/server.js') + self.assertNotIn('SECRET', output) + self.assertFalse(marker.exists()) + + def test_actual_process_parser_skips_docker_and_never_reads_environ_cmdline(self): + proc = self.root / 'proc' + proc.mkdir() + for pid, group in ((1, '0::/\n'), (2, '0::/docker/child\n')): + directory = proc / str(pid) + directory.mkdir() + (directory / 'cgroup').write_text(group) + (directory / 'exe').symlink_to('/opt/handmade/server') + (directory / 'cwd').symlink_to('/opt/handmade') + (directory / 'cmdline').write_text('SECRET') + (directory / 'environ').write_text('PASSWORD') + script = native.PROCESS_SCRIPT.replace('/proc/[0-9]*', str(proc) + '/[0-9]*') + output = native.bounded_run(['sh', '-c', script, 'fixture', '256'], time.monotonic() + 3) + self.assertEqual(output, 'P\t1\t/opt/handmade/server\t/opt/handmade\nEND\n') + self.assertNotIn('"$dir/cmdline"', native.PROCESS_SCRIPT) + self.assertNotIn('"$dir/environ"', native.PROCESS_SCRIPT) + self.assertNotIn('SECRET', output) + + def test_ports_require_exact_process_ownership_and_do_not_infer_http(self): + process = 'P\t123\t/opt/handmade/server\t/opt/handmade\nEND\n' + result = native.scan(101, self.runner(processes=process, ports='L\t123\t8080\nL\t999\t9000\nEND\n')) + item, = result['candidates'] + self.assertEqual(item['listening_ports'], [8080]) + self.assertEqual(item['default_ports'], [], 'TCP listener does not prove HTTP') + result = native.scan(101, self.runner(ports='L\t123\t8080\nEND\n')) + self.assertEqual(result['candidates'][0]['listening_ports'], []) + + def test_shared_runtime_ports_are_assigned_only_by_working_directory(self): + services = service(exe='/usr/bin/node', script='/opt/handmade/server.js').removesuffix('END\n') + services += service(name='second', exe='/usr/bin/node', script='/opt/second/server.js', directory='/opt/second') + process = 'P\t123\t/usr/bin/node\t/opt/second\nEND\n' + result = native.scan(101, self.runner(services, processes=process, ports='L\t123\t8080\nEND\n')) + self.assertEqual(result['candidates'][0]['listening_ports'], []) + self.assertEqual(result['candidates'][1]['listening_ports'], [8080]) + + def test_ss_and_alpine_netstat_parser_are_bounded_and_secret_free(self): + for tool, line in (('ss', 'LISTEN 0 128 0.0.0.0:8080 0.0.0.0:* users:(("SECRET",pid=123,fd=7))'), + ('netstat', 'tcp 0 0 0.0.0.0:8080 0.0.0.0:* LISTEN 123/SECRET')): + bin_dir = self.root / tool + bin_dir.mkdir() + executable = bin_dir / tool + executable.write_text('#!/bin/sh\nprintf \'%s\\n\' \'' + line + '\'\n') + executable.chmod(0o755) + (bin_dir / 'awk').symlink_to('/usr/bin/awk') + (bin_dir / 'timeout').symlink_to('/usr/bin/timeout') + script = 'PATH=' + str(bin_dir) + '\n' + native.PORT_SCRIPT + output = native.bounded_run(['sh', '-c', script, 'fixture', '256'], time.monotonic() + 3) + self.assertEqual(output, 'L\t123\t8080\nEND\n') + self.assertNotIn('SECRET', output) + executable.write_text('#!/bin/sh\nexit 1\n') + output = native.bounded_run(['sh', '-c', script, 'fixture', '256'], time.monotonic() + 3) + with self.assertRaises(native.ProbeError): + native.parse_records(output, 'L') + + +class BoundedRunner(unittest.TestCase): + def test_output_limit_is_enforced_during_read(self): + with self.assertRaisesRegex(native.ProbeError, 'output_limit'): + native.bounded_run([sys.executable, '-c', 'import os; os.write(2,b"x"*1000000)'], time.monotonic() + 3) + + def test_pipe_holding_child_does_not_bypass_timeout(self): + start = time.monotonic() + with self.assertRaisesRegex(native.ProbeError, 'timeout'): + native.bounded_run([sys.executable, '-c', 'import os,time; os.fork(); time.sleep(10)'], start + .15) + self.assertLess(time.monotonic() - start, .8) + + def test_failures_never_reveal_stderr_and_deadline_blocks_spawn(self): + with self.assertRaisesRegex(native.ProbeError, '^read_failed$'): + native.bounded_run([sys.executable, '-c', 'import sys; sys.stderr.write("SECRET"); sys.exit(1)'], time.monotonic() + 3) + with patch.object(native.subprocess, 'Popen') as launch: + with self.assertRaisesRegex(native.ProbeError, 'deadline'): + native.bounded_run(['forbidden'], time.monotonic() - 1) + launch.assert_not_called() + + +class Routes(unittest.TestCase): + """Execute actual Flask route bodies; fixture auth, no server startup jobs.""" + def setUp(self): + from flask import Flask, request, jsonify, g + self.app = Flask('native-fixture') + self.authorized = True + def require_auth(fn): + @wraps(fn) + def wrapped(*args, **kwargs): + if not self.authorized: + return jsonify({'error': 'auth_required'}), 401 + return fn(*args, **kwargs) + return wrapped + self.globals = {'app': self.app, 'request': request, 'jsonify': jsonify, + 'require_auth': require_auth, '_vm_app_suggestions_cache': {}, + '_VM_APP_SUGGESTIONS_TTL': 300, + '_vm_cache_get': lambda cache, vmid, ttl: cache.get(vmid), + '_vm_cache_put': lambda cache, vmid, value: cache.update({vmid: value}), + '_pvesh_cache': {}, '_vm_disk_cache': {}, '_vm_cache_invalidate': Mock(), + '_invalidate_lxc_ip': Mock(), '_schedule_started_guest_refresh': Mock(), + '_publish_guest_modal_cache_revision': Mock()} + tree = ast.parse((SCRIPTS / 'flask_server.py').read_text()) + routes = [n for n in tree.body if isinstance(n, ast.FunctionDef) and n.name in ( + 'api_vm_apps_suggestions', 'api_vm_apps_native_discovery', 'api_vm_apps_add', + '_handle_guest_lifecycle')] + self.assertEqual(len(routes), 4) + exec(compile(ast.Module(body=routes, type_ignores=[]), str(SCRIPTS / 'flask_server.py'), 'exec'), self.globals) + self.client = self.app.test_client() + + def test_explicit_post_only_get_and_existing_post_never_native_scan(self): + result = {'vmid': 101, 'status': 'complete', 'candidates': [], 'stale': False} + with patch.object(native, 'discover', return_value=result) as scan, \ + patch.object(native, 'cached', return_value=result), \ + patch.object(lxc_apps, 'get_suggestions', return_value={'extras': []}) as suggestions, \ + patch.object(lxc_apps, 'load_sidecar', return_value={'apps': []}), \ + patch.object(lxc_apps, '_write_sidecar') as write: + self.assertEqual(self.client.get('/api/vms/101/apps/native-discovery').status_code, 200) + self.client.get('/api/vms/101/apps/suggestions') + # OCI refresh and Find's existing catalog path retain their purpose. + self.client.post('/api/vms/101/apps/suggestions') + with self.app.test_request_context('/api/vms/101/apps/suggestions'): + from flask import g + g._internal_call = True + self.globals['_vm_app_suggestions_cache'].clear() + self.globals['api_vm_apps_suggestions'](101) + scan.assert_not_called() + self.client.post('/api/vms/101/apps/native-discovery') + scan.assert_called_once_with(101) + self.assertEqual(suggestions.call_count, 2) + write.assert_not_called() + + def test_unauthorized_and_wrong_method_have_zero_scans(self): + with patch.object(native, 'discover') as scan, patch.object(lxc_apps, 'load_sidecar') as read: + self.authorized = False + self.assertEqual(self.client.post('/api/vms/101/apps/native-discovery').status_code, 401) + self.assertEqual(self.client.put('/api/vms/101/apps/native-discovery').status_code, 405) + scan.assert_not_called() + read.assert_not_called() + + def test_actual_lifecycle_marks_last_known_stale_without_scanning(self): + native._cache[101] = {'vmid': 101, 'status': 'complete', 'stale': False, + 'candidates': [], 'scanned_at': time.time()} + with patch.object(native, 'discover') as scan, \ + patch.object(lxc_apps, 'clear_schedule_reboot_required') as clear: + for action in ('start', 'reboot', 'stop'): + self.globals['_handle_guest_lifecycle']('101', 'lxc', action) + self.assertTrue(native.cached(101)['stale']) + scan.assert_not_called() + self.assertEqual(clear.call_count, 2) + + def test_actual_repeated_prewarmer_has_zero_native_scans(self): + tree = ast.parse((SCRIPTS / 'flask_server.py').read_text()) + warmer = next(node for node in tree.body if isinstance(node, ast.FunctionDef) + and node.name == '_vm_modal_prewarmer_pass') + environment = dict(self.globals) + for name in ('_vm_details_cache', '_vm_backups_cache', '_vm_schedule_cache', '_vm_mounts_cache'): + environment[name] = {} + for name in ('_VM_DETAILS_TTL', '_VM_BACKUPS_TTL', '_VM_SCHEDULE_TTL', '_VM_MOUNTS_TTL'): + environment[name] = 300 + for name in ('get_vm_config', 'api_vm_backups', 'api_vm_apps_schedule', 'api_lxc_mount_points'): + environment[name] = lambda vmid: None + environment.update({'get_cached_pvesh_cluster_resources_vm': lambda: [ + {'vmid': 101, 'type': 'lxc', 'status': 'running'}], 'time': Mock(), 'sys': sys}) + exec(compile(ast.Module(body=[warmer], type_ignores=[]), 'flask_server.py', 'exec'), environment) + with patch.object(native, 'discover') as scan, \ + patch.object(lxc_apps, 'ensure_oci_registration'), \ + patch.object(lxc_apps, 'load_sidecar', return_value={'apps': []}), \ + patch.object(lxc_apps, 'get_suggestions', return_value={'extras': []}): + for _ in range(2): + environment['_vm_app_suggestions_cache'].clear() + self.assertEqual(environment['_vm_modal_prewarmer_pass'](), 1) + scan.assert_not_called() + + +if __name__ == '__main__': + unittest.main() diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5e76a028..6e645e4d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -876,8 +876,8 @@ and traceback before changing catalogs or translation-provider settings. #### Qualified offline `tests/` CI lanes `.github/workflows/test-offline-qualified.yml` runs **only** the explicitly listed -nine Python files (including the inert active-notification payload fixture and -the focused health recurrence tests) and eleven +ten Python files (including the native discovery fixtures, the inert active-notification payload fixture and +the focused health recurrence tests) and twelve Node files in `.github/scripts/run_offline_qualified.py`. The Python runner gives each test file a private temporary directory through `TMPDIR`. It removes that directory after the test file finishes. @@ -900,7 +900,7 @@ dangling symlink) rather than overwrite or reuse it. In Bash: ) ``` -Requires Python 3.11, Node 22.14, npm and Bash/coreutils for the bounded shell +Requires Python 3.11, Flask 3.x for the native route fixtures, Node 22.14, npm and Bash/coreutils for the bounded shell fixtures. The separate lockfile installs only TypeScript, React, React DOM, Lucide icons and the locked scheduler dependency; it does not build the Monitor or run npm lifecycle scripts. `--legacy-peer-deps` admits the existing Lucide diff --git a/tests/lxc_updates/test_native_app_discovery_ui.cjs b/tests/lxc_updates/test_native_app_discovery_ui.cjs new file mode 100644 index 00000000..51f759b7 --- /dev/null +++ b/tests/lxc_updates/test_native_app_discovery_ui.cjs @@ -0,0 +1,164 @@ +// Execute the real panel, handlers and editor with inert hook/UI/API fixtures. +// This is component interaction evidence, not browser or Proxmox acceptance. +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') +const vm = require('node:vm') +const root = path.resolve(__dirname, '../../AppImage') +const ts = require(path.join(root, 'node_modules/typescript')) +const source = fs.readFileSync(path.join(root, 'components/lxc-app-panel.tsx'), 'utf8') +const compiled = ts.transpileModule(source, {compilerOptions: { + module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, jsx: ts.JsxEmit.ReactJSX, +}, reportDiagnostics: true}) +assert.equal(compiled.diagnostics.length, 0) + +const flatten = node => !node || typeof node !== 'object' ? [] : [node, ...( + [node.props?.children].flat(Infinity).flatMap(flatten) +)] +const text = node => node == null || node === false ? '' : typeof node !== 'object' + ? String(node) : [node.props?.children].flat(Infinity).map(text).join(' ') +const candidate = { + slug: 'native-' + 'a'.repeat(24), discovery_id: 'native-' + 'a'.repeat(24), + name: 'handmade', source: 'native_metadata', installed_version: null, + evidence: [{kind: 'installation_file', value: '/opt/handmade/server'}], default_ports: [], +} + +function fixture() { + let cursor = 0, tree, props, effects = [], states = [], refs = [], effectStates = [] + const calls = [], cachedWrites = [] + let native = {vmid: 101, status: 'not_scanned', candidates: [], stale: false} + let holdScan, scanGate, holdSave, saveGate, scanStatus = 'complete' + const seed = {sidecar: {vmid: 101, apps: []}, suggestions: {extras: [], port_suggestions: []}} + const react = { + useState(initial) { + const index = cursor++ + if (!(index in states)) states[index] = typeof initial === 'function' ? initial() : initial + return [states[index], value => {states[index] = typeof value === 'function' ? value(states[index]) : value}] + }, + useRef(value) {const index = cursor++; return refs[index] ||= {current: value}}, + useMemo(fn) {cursor++; return fn()}, useCallback(fn) {cursor++; return fn}, + useEffect(fn, deps) { + const index = cursor++, prior = effectStates[index] + if (!prior || !deps || deps.some((value, i) => value !== prior.deps?.[i])) { + effects.push(() => {prior?.cleanup?.(); effectStates[index] = {deps, cleanup: fn()}}) + } + }, + } + const api = async (url, options = {}) => { + calls.push({url, ...options}) + if (url.endsWith('/native-discovery')) { + if (options.method === 'POST') { + if (scanGate) await scanGate + native = {...native, status: scanStatus, candidates: [candidate]} + } + return structuredClone(native) + } + if (url.endsWith('/apps/suggestions')) return seed.suggestions + if (url.endsWith('/apps') && options.method === 'POST') { + if (saveGate) await saveGate + return {vmid: Number(url.split('/')[3]), apps: [{id: 'saved', ...JSON.parse(options.body), state: {}}]} + } + if (url.endsWith('/adguard-setup')) return {available: false} + return [] + } + const cache = { + getLxcAppsCached: () => null, fetchLxcApps: async () => seed, + subscribeLxcApps: () => () => {}, setLxcAppsCached: (...args) => cachedWrites.push(args), + } + const exports = {} + vm.runInNewContext(compiled.outputText, {exports, console, setTimeout, clearTimeout, + require: name => { + if (name === 'react') return react + if (name === 'react/jsx-runtime') return {jsx: (type, props) => ({type, props}), jsxs: (type, props) => ({type, props})} + if (name.includes('api-config')) return {fetchApi: api} + if (name.includes('lxc-apps-cache')) return cache + if (name.includes('i18n/provider')) return {useT: () => (key, values) => key + (values ? JSON.stringify(values) : '')} + if (name.includes('category-color')) return {useIsLightTheme: () => false, categoryChipStyle: () => ({})} + if (name.includes('category-label')) return {getCategoryLabel: value => value} + return new Proxy({}, {get: (_, key) => key}) + }, + }) + function render(next = props || {vmid: 101, initialData: seed}) { + props = next; cursor = 0; effects = [] + tree = exports.LxcAppPanel(props) + for (const effect of effects) effect() + return tree + } + const settle = async () => {for (let i = 0; i < 6; i++) await Promise.resolve(); render()} + const button = key => { + const match = flatten(tree).find(n => n.type === 'Button' && text(n).includes('vmLxc.appEditor.' + key)) + assert.ok(match, 'Missing button ' + key) + return match + } + return {render, settle, calls, cachedWrites, button, tree: () => tree, + setNative: value => {native = value}, + setScanStatus: value => {scanStatus = value}, + holdScan: () => {scanGate = new Promise(resolve => {holdScan = resolve})}, resolveScan: () => holdScan(), + holdSave: () => {saveGate = new Promise(resolve => {holdSave = resolve})}, resolveSave: () => holdSave(), + } +} + +async function main() { + const f = fixture() + f.render(); await f.settle() + assert.equal(f.calls.filter(c => c.method === 'POST').length, 0, 'mount and cache reads never scan') + await f.button('searchApplications').props.onClick(); f.render() + assert.equal(f.calls.filter(c => c.url.endsWith('/native-discovery') && c.method === 'POST').length, 1) + assert.ok(text(f.tree()).includes('nativeVersionUnknown'), 'unknown version remains visible') + assert.ok(text(f.tree()).includes('/opt/handmade/server')) + await f.button('registerButton').props.onClick(); f.render() + assert.ok(text(f.tree()).includes('saveButton'), 'candidate opens actual editor') + f.button('cancelButton').props.onClick(); f.render() + assert.equal(f.calls.filter(c => /\/apps$/.test(c.url) && c.method === 'POST').length, 0, 'cancel never registers') + await f.button('registerButton').props.onClick(); f.render() + await f.button('saveButton').props.onClick(); f.render() + const saved = f.calls.find(c => /\/apps$/.test(c.url) && c.method === 'POST') + const payload = JSON.parse(saved.body) + assert.equal(saved.url, '/api/vms/101/apps') + assert.equal(payload.discovery_id, candidate.discovery_id) + assert.ok(!payload.helper_slug, 'native identity never becomes a helper') + assert.equal(payload.installed_via, '') + assert.equal(payload.update_method, 'none') + assert.equal(payload.update_command, '') + assert.equal(payload.repo, '') + assert.equal(payload.upstream_type, '') + + const partial = fixture() + partial.render(); await partial.settle(); partial.setScanStatus('partial') + await partial.button('searchApplications').props.onClick(); partial.render() + assert.ok(text(partial.tree()).includes('nativeScanPartial')) + assert.ok(text(partial.tree()).includes('nativeEvidenceStale')) + const failed = fixture() + failed.render(); await failed.settle(); failed.setScanStatus('error') + await failed.button('searchApplications').props.onClick(); failed.render() + assert.ok(text(failed.tree()).includes('nativeScanFailed')) + assert.equal(failed.calls.filter(c => c.url.endsWith('/apps/suggestions') && c.method === 'POST').length, 0) + + const late = fixture() + late.render(); await late.settle(); late.holdScan() + const scan = late.button('searchApplications').props.onClick() + late.render({vmid: 102}); await late.settle() + late.resolveScan(); await scan; late.render() + assert.ok(!text(late.tree()).includes('/opt/handmade/server'), 'late A scan cannot appear in B') + assert.equal(late.cachedWrites.length, 0) + assert.equal(late.calls.filter(c => c.url.endsWith('/apps/suggestions') && c.method === 'POST').length, 0) + + const saveLate = fixture() + saveLate.render(); await saveLate.settle() + await saveLate.button('searchApplications').props.onClick(); saveLate.render() + await saveLate.button('registerButton').props.onClick(); saveLate.render() + saveLate.holdSave(); const saving = saveLate.button('saveButton').props.onClick() + const priorWrites = saveLate.cachedWrites.length + saveLate.render({vmid: 102}); await saveLate.settle() + saveLate.resolveSave(); await saving; saveLate.render() + assert.equal(saveLate.cachedWrites.length, priorWrites, 'late save response does not update new target') + assert.equal(saveLate.calls.filter(c => /\/vms\/102\/apps$/.test(c.url) && c.method === 'POST').length, 0) + + const oci = fixture() + oci.render({vmid: 101, oci: {instance: true}, initialData: {sidecar: {apps: []}, suggestions: {oci_instance: {template_id: 'image-app'}, extras: []}}}) + await oci.settle() + await oci.button('refreshData').props.onClick() + assert.equal(oci.calls.filter(c => c.url.endsWith('/native-discovery') && c.method === 'POST').length, 0, 'OCI refresh never native-scans') + console.log('PASS native discovery: real panel Find → editor → Cancel/Save, no automatic scan, safe payload, CT switching and OCI refresh (fixtures)') +} +main().catch(error => {console.error(error); process.exitCode = 1})