From cfb90a094c82ef0d2208c52c8e31eba3a7fb92d6 Mon Sep 17 00:00:00 2001 From: VAIO73 <50487331+Vaso73@users.noreply.github.com> Date: Mon, 19 Oct 2026 08:03:25 +0000 Subject: [PATCH] feat(monitor): discover Docker application endpoints on demand --- .github/scripts/run_offline_qualified.py | 2 + AppImage/components/lxc-app-panel.tsx | 51 ++- AppImage/messages/en/common.json | 6 + AppImage/messages/sk/common.json | 6 + AppImage/scripts/build_appimage.sh | 1 + AppImage/scripts/docker_app_discovery.py | 295 ++++++++++++++++++ AppImage/scripts/flask_server.py | 16 + AppImage/scripts/lxc_apps.py | 13 +- .../fixtures/docker_discovery_inspect.json | 158 ++++++++++ .../tests/test_docker_app_discovery.py | 294 +++++++++++++++++ .../tests/test_native_app_discovery.py | 6 + .../test_docker_app_discovery_ui.cjs | 47 +++ .../test_native_app_discovery_ui.cjs | 19 +- 13 files changed, 901 insertions(+), 13 deletions(-) create mode 100644 AppImage/scripts/docker_app_discovery.py create mode 100644 AppImage/scripts/tests/fixtures/docker_discovery_inspect.json create mode 100644 AppImage/scripts/tests/test_docker_app_discovery.py create mode 100644 tests/lxc_updates/test_docker_app_discovery_ui.cjs diff --git a/.github/scripts/run_offline_qualified.py b/.github/scripts/run_offline_qualified.py index fe6c26d4..a1404590 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_docker_app_discovery.py", "AppImage/scripts/tests/test_native_app_discovery.py", "tests/test_audit_presentation.py", "tests/test_audit_safety_wording.py", @@ -25,6 +26,7 @@ PYTHON_FILES = ( "AppImage/scripts/tests/test_health_persistence_recurrence.py", ) NODE_FILES = ( + "tests/lxc_updates/test_docker_app_discovery_ui.cjs", "tests/lxc_updates/test_native_app_discovery_ui.cjs", "tests/lxc_updates/test_docker_delegated_ui.cjs", "tests/test_audit_diagnostic_document.cjs", diff --git a/AppImage/components/lxc-app-panel.tsx b/AppImage/components/lxc-app-panel.tsx index 25fc0461..895a4133 100644 --- a/AppImage/components/lxc-app-panel.tsx +++ b/AppImage/components/lxc-app-panel.tsx @@ -132,7 +132,7 @@ interface NativeDiscovery { interface DetectedApp { discovery_id?: string - source?: "native_metadata" + source?: "native_metadata" | "docker_metadata" installed_version?: string | null evidence?: Array<{ kind: string; value: string }> slug: string @@ -477,6 +477,8 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }: const [suggestions, setSuggestions] = useState(seed?.suggestions ?? null) const [nativeDiscovery, setNativeDiscovery] = useState(null) const nativeRequest = useRef(0) + const [dockerDiscovery, setDockerDiscovery] = useState(null) + const dockerRequest = useRef(0) const targetRef = useRef(vmid) targetRef.current = vmid const targetGeneration = useRef(0) @@ -489,6 +491,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }: const generation = targetGeneration.current const request = ++nativeRequest.current setNativeDiscovery(null) + setDockerDiscovery(null) setEditing(null) setDetectionNotice(null) setSearchingApplications(false) @@ -500,6 +503,17 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }: }).catch(() => { /* cached metadata is optional; never initiate a scan */ }) return () => { targetGeneration.current += 1 } }, [vmid, managed]) + useEffect(() => { + const generation = targetGeneration.current + const request = dockerRequest.current + let cancelled = false + if (managed) return + fetchApi(`/api/vms/${vmid}/apps/docker-discovery`).then(result => { + if (!cancelled && targetRef.current === vmid && targetGeneration.current === generation + && dockerRequest.current === request) setDockerDiscovery(result) + }).catch(() => { /* optional cached result; never initiate a scan */ }) + return () => { cancelled = true } + }, [vmid, managed]) const [adguardSetupAvailable, setAdguardSetupAvailable] = useState(false) const [error, setError] = useState(null) const [searchingApplications, setSearchingApplications] = useState(false) @@ -693,7 +707,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(() => { - const out: DetectedApp[] = [...(nativeDiscovery?.vmid === vmid ? nativeDiscovery.candidates : [])] + const out: DetectedApp[] = [...(nativeDiscovery?.vmid === vmid ? nativeDiscovery.candidates : []), + ...(dockerDiscovery?.vmid === vmid ? dockerDiscovery.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. @@ -719,7 +734,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }: } } return out - }, [suggestions, nativeDiscovery, vmid]) + }, [suggestions, nativeDiscovery, dockerDiscovery, vmid]) // Registered slugs — used to filter the detection list down to // what the user hasn't already registered on this CT. @@ -792,6 +807,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }: const searchInstalledApplications = async () => { const generation = targetGeneration.current const request = ++nativeRequest.current + ++dockerRequest.current const isCurrent = () => targetRef.current === vmid && targetGeneration.current === generation && nativeRequest.current === request const before = new Set([...visibleDetected, ...visibleWorkloads].map((item) => item.slug)) @@ -811,6 +827,11 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }: ? "vmLxc.appEditor.nativeScanStopped" : "vmLxc.appEditor.nativeScanFailed") }) return } + let docker = await fetchApi(`/api/vms/${vmid}/apps/docker-discovery`, { + method: "POST", + }) + if (!isCurrent()) return + setDockerDiscovery(docker) const result: Suggestions = await fetchApi(`/api/vms/${vmid}/apps/suggestions`, { method: "POST", }) @@ -819,6 +840,9 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }: native = await fetchApi(`/api/vms/${vmid}/apps/native-discovery`) if (!isCurrent()) return setNativeDiscovery(native) + docker = await fetchApi(`/api/vms/${vmid}/apps/docker-discovery`) + if (!isCurrent()) return + setDockerDiscovery(docker) setSuggestions(result) if (sidecar) setLxcAppsCached(vmid, sidecar, result) @@ -827,11 +851,16 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }: 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) + for (const item of docker.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 (native.status === "partial") { + if (docker.status === "error" || docker.status === "busy") { + setDetectionNotice({ found: visible.length > 0, text: t("vmLxc.appEditor.dockerScanFailed") }) + } else if (docker.status === "partial" || docker.stale) { + setDetectionNotice({ found: visible.length > 0, text: t("vmLxc.appEditor.dockerScanPartial") }) + } else 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") }) @@ -2526,22 +2555,28 @@ 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.source === "native_metadata" || d.source === "docker_metadata") && (
{d.installed_version ? t("vmLxc.appEditor.versionDetected", { version: d.installed_version }) : t("vmLxc.appEditor.nativeVersionUnknown")}
- {(nativeDiscovery?.stale || nativeDiscovery?.status === "partial") && ( + {(d.source === "docker_metadata" + ? dockerDiscovery?.stale || dockerDiscovery?.status === "partial" + : nativeDiscovery?.stale || nativeDiscovery?.status === "partial") && (
{t("vmLxc.appEditor.nativeEvidenceStale")}
)} {d.evidence?.map((e, index) =>
- {t(e.kind === "tcp_listener" ? "vmLxc.appEditor.nativeEvidencePort" + {t(e.kind === "docker_container" ? "vmLxc.appEditor.dockerEvidenceContainer" + : e.kind === "docker_image" ? "vmLxc.appEditor.dockerEvidenceImage" + : e.kind === "compose_project" ? "vmLxc.appEditor.dockerEvidenceProject" + : 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")}
+
{t(d.source === "docker_metadata" + ? "vmLxc.appEditor.dockerEndpointReview" : "vmLxc.appEditor.nativeRegisterOnly")}
)} {d.tracking_suggestion?.detector_source === "legacy_fallback" && ( diff --git a/AppImage/messages/en/common.json b/AppImage/messages/en/common.json index 050cd5ab..bae0c2f9 100644 --- a/AppImage/messages/en/common.json +++ b/AppImage/messages/en/common.json @@ -1751,6 +1751,12 @@ "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.", + "dockerScanFailed": "Docker search could not finish. Previous results may be outdated; try again.", + "dockerScanPartial": "Docker search is incomplete. Some containers could not be checked.", + "dockerEvidenceContainer": "Docker container", + "dockerEvidenceImage": "Image", + "dockerEvidenceProject": "Compose project", + "dockerEndpointReview": "Container with published TCP ports. Confirm the name and web address before saving. Registration does not configure updates.", "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 e2156568..1980ff79 100644 --- a/AppImage/messages/sk/common.json +++ b/AppImage/messages/sk/common.json @@ -1695,6 +1695,12 @@ "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ý.", + "dockerScanFailed": "Docker vyhľadávanie sa nepodarilo dokončiť. Predchádzajúce výsledky môžu byť zastarané; skús to znova.", + "dockerScanPartial": "Docker vyhľadávanie je neúplné. Niektoré kontajnery sa nepodarilo skontrolovať.", + "dockerEvidenceContainer": "Docker kontajner", + "dockerEvidenceImage": "Obraz", + "dockerEvidenceProject": "Compose projekt", + "dockerEndpointReview": "Kontajner s publikovanými TCP portmi. Pred uložením potvrď názov a webovú adresu. Registrácia nenastaví aktualizácie.", "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 4bee9953..8a3330b8 100755 --- a/AppImage/scripts/build_appimage.sh +++ b/AppImage/scripts/build_appimage.sh @@ -150,6 +150,7 @@ 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/docker_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/docker_app_discovery.py b/AppImage/scripts/docker_app_discovery.py new file mode 100644 index 00000000..a61d43b3 --- /dev/null +++ b/AppImage/scripts/docker_app_discovery.py @@ -0,0 +1,295 @@ +#!/usr/bin/env python3 +"""Explicit Docker endpoint discovery; no registry, updater or registration. + +The Docker inventory remains the owner of image updates. These candidates +describe running containers with published TCP endpoints for user review. +Only selected inspect fields leave the guest; Config.Env is never collected. +""" +import copy +from contextlib import suppress +import hashlib +import ipaddress +import json +import os +import re +import selectors +import signal +import subprocess +import threading +import time + +import native_app_discovery as native + +MAX_CONTAINERS = 50 +OUTPUT_BYTES = 256 * 1024 +TOTAL_SECONDS = 20 +PROBE_SECONDS = 8 +CACHE_SECONDS = 300 +SNAPSHOT_SECONDS = 60 +# Port hints prefill only common web endpoints; other TCP ports remain evidence. +WEB_PORTS = {80, 443, 3000, 3001, 5000, 5001, 8000, 8008, 8080, 8081, 8083, 8090, 8096, 8123, 8443, 9000, 9090, 9443} +_cache = {} +_locks = {} +_generations = {} +_mutex = threading.RLock() +_NAME = re.compile(r'[A-Za-z0-9][A-Za-z0-9_.-]{0,127}\Z') +_ID = re.compile(r'[0-9a-f]{12,64}\Z') +_VERSION = re.compile(r'v?\d+(?:\.\d+){1,4}(?:[-+._][A-Za-z0-9.-]+)?\Z') +_SUPPORT = re.compile(r'(?:postgres(?:ql)?|mysql|mariadb|redis|valkey|memcached|mongo(?:db)?|rabbitmq|watchtower)(?:[_.-].*)?\Z', re.I) +# A template is evaluated for each object even when another ID disappears. +# Docker CLI inspect/inspect.go writes successful objects before returning an +# error for failed IDs. Parse stdout and retain those objects as partial data. +INSPECT_FORMAT = ( + '{"id":{{json .Id}},"name":{{json .Name}},"image":{{json .Config.Image}},' + '"running":{{json .State.Running}},' + '"project":{{json (index .Config.Labels "com.docker.compose.project")}},' + '"service":{{json (index .Config.Labels "com.docker.compose.service")}},' + '"title":{{json (index .Config.Labels "org.opencontainers.image.title")}},' + '"version":{{json (index .Config.Labels "org.opencontainers.image.version")}},' + '"bindings":{{json .HostConfig.PortBindings}},"ports":{{json .NetworkSettings.Ports}}}' +) + + +def read(argv, deadline): + """Bound combined stdout/stderr during collection, including failed CLI calls.""" + remaining = min(PROBE_SECONDS, deadline - time.monotonic()) + if remaining <= 0: + raise native.ProbeError('deadline') + end = time.monotonic() + remaining + try: + proc = subprocess.Popen(argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + start_new_session=True) + except OSError: + raise native.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 native.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 native.ProbeError('output_limit') + outputs[key.data].extend(block) + try: + proc.wait(timeout=max(0.001, end - time.monotonic())) + except subprocess.TimeoutExpired: + raise native.ProbeError('timeout') from None + return proc.returncode, outputs[0].decode('utf-8', errors='strict') + except UnicodeError: + raise native.ProbeError('invalid_data') from None + finally: + with suppress(ProcessLookupError): + os.killpg(proc.pid, signal.SIGKILL) + proc.wait() + proc.stdout.close() + proc.stderr.close() + + +def _text(value, limit=128): + return value if isinstance(value, str) and len(value) <= limit and not any(ord(c) < 32 for c in value) else '' + + +def public_ports(record): + """Published TCP mappings, excluding the full IPv4/IPv6 loopback ranges.""" + bindings, live = record.get('bindings'), record.get('ports') + if bindings is not None and not isinstance(bindings, dict) or live is not None and not isinstance(live, dict): + raise ValueError('invalid ports') + ports = dict(bindings or {}) + ports.update({key: value for key, value in (live or {}).items() if value}) + result = {} + for endpoint, values in ports.items(): + if not re.fullmatch(r'\d{1,5}/tcp', endpoint) or values is None: + continue + inner = int(endpoint.split('/')[0]) + if not 1 <= inner <= 65535 or not isinstance(values, list): + raise ValueError('invalid binding') + for value in values: + if not isinstance(value, dict): + raise ValueError('invalid binding') + address = value.get('HostIp') or '0.0.0.0' + try: + ip = ipaddress.ip_address(address) + if ip.is_loopback or getattr(ip, 'ipv4_mapped', None) and ip.ipv4_mapped.is_loopback: + continue + raw = value.get('HostPort') + if not isinstance(raw, str) or not raw.isdecimal(): + raise ValueError('invalid port') + outer = int(raw) + except (ValueError, TypeError): + raise ValueError('invalid binding') from None + if not 1 <= outer <= 65535: + raise ValueError('invalid port') + result[outer] = {'host_port': outer, 'container_port': inner, + 'scheme': 'https' if inner in {443, 4443, 8443, 9443} or outer in {443, 4443, 8443, 9443} else 'http'} + return sorted(result.values(), key=lambda item: item['host_port']) + + +def parse_record(value): + if not isinstance(value, dict): + raise ValueError('invalid record') + name = _text(value.get('name')).lstrip('/') + image = _text(value.get('image'), 512) + if not _ID.fullmatch(_text(value.get('id'))) or not _NAME.fullmatch(name) or not image or not isinstance(value.get('running'), bool): + raise ValueError('invalid identity') + project, service = _text(value.get('project')), _text(value.get('service')) + if project and not _NAME.fullmatch(project) or service and not _NAME.fullmatch(service): + raise ValueError('invalid compose identity') + version = _text(value.get('version'), 64) + return {'id': value['id'], 'name': name, 'image': image, 'running': value['running'], + 'project': project, 'service': service, 'title': _text(value.get('title')), + 'version': version if _VERSION.fullmatch(version) else None, + 'endpoints': public_ports(value)} + + +def _candidate(vmid, record): + base = record['image'].split('@')[0].rsplit('/')[-1].split(':')[0] + if not record['running'] or not record['endpoints'] or _SUPPORT.fullmatch(base) or _SUPPORT.fullmatch(record['service']): + return None + key = ':'.join([record['project'], record['service']]) if record['project'] and record['service'] else record['name'] + identity = 'docker-' + hashlib.sha256(f'{vmid}:{key}'.encode()).hexdigest()[:24] + # A display name is metadata, not proof of application identity/version. + name = record['title'] or record['service'] or record['name'] + evidence = [{'kind': 'docker_container', 'value': record['name']}, + {'kind': 'docker_image', 'value': record['image']}] + if record['project']: + evidence.append({'kind': 'compose_project', 'value': record['project']}) + evidence.extend({'kind': 'tcp_listener', 'value': str(port['host_port'])} for port in record['endpoints']) + return {'slug': identity, 'discovery_id': identity, 'source': 'docker_metadata', + 'container_name': record['name'], 'name': name, + 'installed_version': record['version'], 'evidence': evidence, + 'default_ports': [port['host_port'] for port in record['endpoints'] if port['container_port'] in WEB_PORTS], + 'tracking_suggestion': None} + + +def scan(vmid, run=read, guard_run=native.bounded_run): + deadline = time.monotonic() + TOTAL_SECONDS + native._guard(vmid, deadline, guard_run) + with native._scan_reservation(vmid): + prefix = ['pct', 'exec', str(vmid), '--'] + rc, _ = run(prefix + ['sh', '-c', 'command -v docker >/dev/null 2>&1'], deadline) + if rc not in (0, 1): + raise native.ProbeError('read_failed') + records, issues = [], [] + if rc == 0: + rc, output = run(prefix + ['docker', 'ps', '-aq', '--no-trunc'], deadline) + if rc: + raise native.ProbeError('docker_unavailable') + ids = list(dict.fromkeys(output.splitlines())) + if any(not _ID.fullmatch(value) for value in ids): + raise native.ProbeError('invalid_data') + if len(ids) > MAX_CONTAINERS: + issues.append('container_limit') + ids = ids[:MAX_CONTAINERS] + if ids: + rc, output = run(prefix + ['docker', 'inspect', '--format', INSPECT_FORMAT, *ids], deadline) + if rc: + issues.append('inspect_failed') + seen = set() + for line in output.splitlines(): + try: + record = parse_record(json.loads(line)) + if record['id'] not in ids or record['id'] in seen: + raise ValueError('unexpected record') + seen.add(record['id']) + records.append(record) + except (ValueError, TypeError): + issues.append('invalid_data') + if seen != set(ids): + issues.append('incomplete_inspect') + native._guard(vmid, deadline, guard_run) + candidates = {} + for record in records: + candidate = _candidate(vmid, record) + if candidate: + candidates.setdefault(candidate['slug'], candidate) + return {'vmid': vmid, 'status': 'partial' if issues else 'complete', + 'issues': sorted(set(issues)), 'stale': False, 'scanned_at': time.time(), + 'candidates': list(candidates.values()), '_containers': records} + + +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, 'issues': []} + if time.time() - value['scanned_at'] >= CACHE_SECONDS: + value['stale'] = True + return {key: val for key, val in value.items() if not key.startswith('_')} + + +def mark_stale(vmid): + with _mutex: + _generations[int(vmid)] = _generations.get(int(vmid), 0) + 1 + if int(vmid) in _cache: + _cache[int(vmid)]['stale'] = True + _cache[int(vmid)]['_retired'] = 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: + with _mutex: + generation = _generations.get(vmid, 0) + try: + result = scan(vmid) + except native.ProbeError as exc: + result = {**cached(vmid), 'status': 'error', 'issues': [str(exc)], 'stale': True} + result.setdefault('scanned_at', time.time()) + with _mutex: + previous = copy.deepcopy(_cache.get(vmid)) + if generation != _generations.get(vmid, 0): + return {**cached(vmid), 'status': 'error', 'issues': ['guest_changed'], 'stale': True} + if result['status'] != 'complete' and previous: + known = {item['discovery_id'] for item in result['candidates']} + result['candidates'].extend(item for item in previous['candidates'] if item['discovery_id'] not in known) + result['candidates'] = result['candidates'][:MAX_CONTAINERS] + containers = {item['name']: item for item in previous.get('_containers', [])} + containers.update({item['name']: item for item in result.get('_containers', [])}) + result['_containers'] = list(containers.values())[:MAX_CONTAINERS] + result['stale'] = True + result['scanned_at'] = previous['scanned_at'] + result['_attempt_at'] = time.time() + _cache[vmid] = copy.deepcopy(result) + return cached(vmid) + finally: + lock.release() + + +def cached_web_links(vmid, lookup): + """Reuse a Find snapshot in existing web-link suggestions without another scan.""" + with _mutex: + value = copy.deepcopy(_cache.get(int(vmid))) + if not value or value.get('_retired') or time.time() - value['_attempt_at'] >= SNAPSHOT_SECONDS: + return None + result = [] + for record in value.get('_containers', []): + meta = lookup(record['service'], record['name'], record['image']) + for endpoint in record['endpoints']: + result.append({**endpoint, 'container_name': record['name'], + 'service_name': meta['name'], 'service_slug': meta['slug'], + 'image': record['image'], 'web_path': '/', 'logo_url': meta['logo_url']}) + return result + + +def filter_candidates(result, suggestions, apps): + """Suppress existing registrations/catalog entries; no probe or persistence.""" + known = [item for item in [*apps, *(suggestions.get('docker_workloads') or []), *(suggestions.get('extras') or [])] if isinstance(item, dict)] + ids = {item.get('discovery_id') for item in known} + containers = {item.get('container_name') or (item.get('tracking_suggestion') or {}).get('container_name') for item in known} + ports = {p.get('port') for app in apps if isinstance(app, dict) for p in app.get('ports') or [] if isinstance(p, dict)} + return {**result, 'candidates': [item for item in result['candidates'] + if item['discovery_id'] not in ids and item['container_name'] not in containers + and not (item['default_ports'] and set(item['default_ports']).issubset(ports))]} diff --git a/AppImage/scripts/flask_server.py b/AppImage/scripts/flask_server.py index 70cc4174..4099096c 100644 --- a/AppImage/scripts/flask_server.py +++ b/AppImage/scripts/flask_server.py @@ -2280,6 +2280,8 @@ def _handle_guest_lifecycle(vmid: str, vm_type: str, action: str) -> None: if guest_type == 'lxc': import native_app_discovery native_app_discovery.mark_stale(guest_id) + import docker_app_discovery + docker_app_discovery.mark_stale(guest_id) _invalidate_lxc_ip(guest_id) if action in ('start', 'reboot'): if guest_type == 'lxc': @@ -14130,6 +14132,20 @@ def api_vm_apps_native_discovery(vmid): result, suggestions, sidecar.get('apps') or [])) +@app.route('/api/vms//apps/docker-discovery', methods=['GET', 'POST']) +@require_auth +def api_vm_apps_docker_discovery(vmid): + """New discovery contract: explicit POST scans; GET only composes cached data.""" + import docker_app_discovery + import lxc_apps + result = (docker_app_discovery.discover(vmid) if request.method == 'POST' + else docker_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(docker_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 4b6a5a2e..bce7cd00 100644 --- a/AppImage/scripts/lxc_apps.py +++ b/AppImage/scripts/lxc_apps.py @@ -1182,7 +1182,7 @@ def validate_config(payload: dict) -> tuple[bool, Any]: # 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): + if not isinstance(discovery_id, str) or not re.fullmatch(r"(?:native|docker)-[0-9a-f]{24}", discovery_id): return _err("invalid discovery_id") conf["discovery_id"] = discovery_id @@ -5361,6 +5361,10 @@ def _probe_docker_web_links(vmid) -> list[dict]: Stopped containers are included from their persistent HostConfig bindings, so their links remain registrable before the workload is started again. """ + import docker_app_discovery + shared = docker_app_discovery.cached_web_links(vmid, _docker_service_catalog_meta) + if shared is not None: + return shared key = str(vmid) now = time.time() with _docker_web_links_lock: @@ -5416,7 +5420,12 @@ def _probe_docker_web_links(vmid) -> list[dict]: if not isinstance(binding, dict): continue host_ip = str(binding.get("HostIp") or "").strip() - if host_ip in {"127.0.0.1", "::1"}: + try: + import ipaddress + bind_ip = ipaddress.ip_address(host_ip or "0.0.0.0") + if bind_ip.is_loopback or (getattr(bind_ip, "ipv4_mapped", None) and bind_ip.ipv4_mapped.is_loopback): + continue + except ValueError: continue try: host_port = int(binding.get("HostPort")) diff --git a/AppImage/scripts/tests/fixtures/docker_discovery_inspect.json b/AppImage/scripts/tests/fixtures/docker_discovery_inspect.json new file mode 100644 index 00000000..7e1bcb3d --- /dev/null +++ b/AppImage/scripts/tests/fixtures/docker_discovery_inspect.json @@ -0,0 +1,158 @@ +{ + "normal_rc": 0, + "records": [ + { + "id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "name": "/manual-app-current", + "image": "example/manual-app:2", + "running": false, + "project": "manual-app", + "service": "web-current", + "title": null, + "version": null, + "bindings": { + "8080/tcp": [ + { + "HostIp": "127.0.0.1", + "HostPort": "8081" + } + ] + }, + "ports": {} + }, + { + "id": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "name": "/manual-app-previous", + "image": "example/manual-app:1", + "running": true, + "project": "manual-app", + "service": "web-previous", + "title": null, + "version": null, + "bindings": { + "21115/tcp": [ + { + "HostIp": "", + "HostPort": "21115" + } + ], + "21116/tcp": [ + { + "HostIp": "", + "HostPort": "21116" + } + ], + "21116/udp": [ + { + "HostIp": "", + "HostPort": "21116" + } + ], + "21117/tcp": [ + { + "HostIp": "", + "HostPort": "21117" + } + ], + "8080/tcp": [ + { + "HostIp": "", + "HostPort": "8080" + } + ] + }, + "ports": { + "21115/tcp": [ + { + "HostIp": "0.0.0.0", + "HostPort": "21115" + }, + { + "HostIp": "::", + "HostPort": "21115" + } + ], + "21116/tcp": [ + { + "HostIp": "0.0.0.0", + "HostPort": "21116" + }, + { + "HostIp": "::", + "HostPort": "21116" + } + ], + "21116/udp": [ + { + "HostIp": "0.0.0.0", + "HostPort": "21116" + }, + { + "HostIp": "::", + "HostPort": "21116" + } + ], + "21117/tcp": [ + { + "HostIp": "0.0.0.0", + "HostPort": "21117" + }, + { + "HostIp": "::", + "HostPort": "21117" + } + ], + "21118/tcp": null, + "21119/tcp": null, + "8080/tcp": [ + { + "HostIp": "0.0.0.0", + "HostPort": "8080" + }, + { + "HostIp": "::", + "HostPort": "8080" + } + ], + "9000/tcp": null + } + }, + { + "id": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "name": "/db-1", + "image": "mysql:8.4", + "running": true, + "project": "manual-app", + "service": "db", + "title": null, + "version": null, + "bindings": {}, + "ports": { + "3306/tcp": null, + "33060/tcp": null + } + } + ], + "partial_rc": 1, + "partial_records": [ + { + "id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "name": "/manual-app-current", + "image": "example/manual-app:2", + "running": false, + "project": "manual-app", + "service": "web-current", + "title": null, + "version": null, + "bindings": { + "8080/tcp": [ + { + "HostIp": "127.0.0.1", + "HostPort": "8081" + } + ] + }, + "ports": {} + } + ] +} diff --git a/AppImage/scripts/tests/test_docker_app_discovery.py b/AppImage/scripts/tests/test_docker_app_discovery.py new file mode 100644 index 00000000..876cfba2 --- /dev/null +++ b/AppImage/scripts/tests/test_docker_app_discovery.py @@ -0,0 +1,294 @@ +"""Docker Find contracts; no real guests, registry, auth or app writes.""" +import ast +import copy +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 patch, Mock + +SCRIPTS = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(SCRIPTS)) +import docker_app_discovery as docker +import native_app_discovery as native +import lxc_apps + +ONE, TWO = 'a' * 64, 'b' * 64 + + +def record(**values): + return {'id': ONE, 'name': '/cortendesk-v1-11-0-cortendesk-1', + 'image': 'cortendesk-v1-11-0-cortendesk', 'running': True, + 'project': 'cortendesk-v1-11-0', 'service': 'cortendesk', + 'title': None, 'version': None, + 'bindings': {'8080/tcp': [{'HostIp': '0.0.0.0', 'HostPort': '8080'}]}, + 'ports': None, **values} + + +class Scan(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + root = Path(self.temp.name) + (root / '101.conf').write_text('ostype: debian\n') + self.patches = [patch.object(native, 'LOCAL_CONFIG', root), + patch.object(native, 'OCI_ROOT', root / 'oci'), + patch.object(native, 'UPDATE_LOCK_ROOT', root)] + for p in self.patches: + p.start() + self.addCleanup(p.stop) + self.calls = [] + + def runner(self, records=None, ids=None, inspect_rc=0, docker_present=True): + def execute(argv, deadline): + self.calls.append(argv) + self.assertEqual(argv[:4], ['pct', 'exec', '101', '--']) + if argv[4] == 'sh': + self.assertEqual(argv[5:], ['-c', 'command -v docker >/dev/null 2>&1']) + return (0 if docker_present else 1), '' + if argv[4:6] == ['docker', 'ps']: + return 0, '\n'.join(ids if ids is not None else [ONE]) + self.assertEqual(argv[4:8], ['docker', 'inspect', '--format', docker.INSPECT_FORMAT]) + return inspect_rc, '\n'.join(json.dumps(r) if not isinstance(r, str) else r + for r in (records if records is not None else [record()])) + return execute + + def scan(self, **kwargs): + return docker.scan(101, run=self.runner(**kwargs), guard_run=lambda argv, deadline: 'status: running') + + def test_cortendesk_and_unrelated_compose_use_the_same_generic_contract(self): + for app in [record(), record(name='/notes-1', image='example/notes:3', project='notes', service='web')]: + result = self.scan(records=[app]) + self.assertEqual(result['status'], 'complete') + item, = result['candidates'] + self.assertEqual(item['source'], 'docker_metadata') + self.assertEqual(item['default_ports'], [8080]) + self.assertIsNone(item['installed_version'], 'no version guessed from image/project/tag') + self.assertIsNone(item['tracking_suggestion']) + self.assertTrue(item['discovery_id'].startswith('docker-')) + self.assertNotIn('repo', item) + self.assertNotIn('.Env', docker.INSPECT_FORMAT) + self.assertNotIn('json .}}', docker.INSPECT_FORMAT, 'no whole inspect object') + self.assertTrue(all(c[4] == 'sh' or c[4:6] in (['docker', 'ps'], ['docker', 'inspect']) for c in self.calls)) + + def test_known_version_is_display_metadata_and_never_an_updater(self): + item, = self.scan(records=[record(version='1.11.0')])['candidates'] + self.assertEqual(item['installed_version'], '1.11.0') + self.assertIsNone(item['tracking_suggestion']) + + def test_database_stopped_and_no_published_ports_are_excluded(self): + for app in [record(image='mysql:8.4', service='db'), record(running=False), + record(bindings={}), record(image='redis:7', service='cache')]: + self.assertEqual(self.scan(records=[app])['candidates'], []) + + def test_all_loopback_bindings_excluded_and_live_mapping_preferred(self): + for address in ['127.0.0.1', '127.0.0.2', '127.255.255.254', '::1', '::ffff:127.0.0.2']: + value = record(bindings={'8080/tcp': [{'HostIp': address, 'HostPort': '8080'}]}) + self.assertEqual(self.scan(records=[value])['candidates'], [], address) + value = record(ports={'8080/tcp': [{'HostIp': '::', 'HostPort': '9090'}]}) + self.assertEqual(self.scan(records=[value])['candidates'][0]['default_ports'], [9090]) + + def test_service_tcp_ports_are_evidence_not_prefilled_web_addresses(self): + app = record(bindings={'21115/tcp':[{'HostIp':'0.0.0.0','HostPort':'21115'}], + '8080/tcp':[{'HostIp':'0.0.0.0','HostPort':'8080'}]}) + item, = self.scan(records=[app])['candidates'] + self.assertEqual(item['default_ports'],[8080]) + self.assertIn({'kind':'tcp_listener','value':'21115'},item['evidence']) + + def test_empty_engine_is_complete_and_missing_docker_never_calls_docker(self): + self.assertEqual(self.scan(ids=[])['status'], 'complete') + self.calls.clear() + result = self.scan(docker_present=False) + self.assertEqual(result['candidates'], []) + self.assertEqual(len(self.calls), 1) + + def test_nonzero_inspect_retains_valid_stdout_and_marks_partial(self): + result = self.scan(ids=[ONE, TWO], inspect_rc=1) + self.assertEqual(result['status'], 'partial') + self.assertEqual(len(result['candidates']), 1) + self.assertIn('inspect_failed', result['issues']) + self.assertIn('incomplete_inspect', result['issues']) + + def test_invalid_and_duplicate_records_are_partial_without_crashing(self): + for bad in ['{broken', [], record(id=TWO), record(bindings=[]), record(running='false')]: + result = self.scan(records=[record(), bad]) + self.assertEqual(result['status'], 'partial') + self.assertEqual(len(result['candidates']), 1) + + def test_record_limit_is_visible_and_inspect_is_bounded(self): + ids = [f'{i:064x}' for i in range(51)] + result = self.scan(ids=ids, records=[record(id=ids[0])]) + self.assertIn('container_limit', result['issues']) + self.assertEqual(len(self.calls[-1][8:]), docker.MAX_CONTAINERS) + + def test_stopped_missing_locked_and_oci_guests_have_zero_guest_probes(self): + root = Path(self.temp.name) + cases = [('', 'status: stopped', 'guest_stopped'), + ('lock: backup\nostype: debian\n', 'status: running', 'guest_busy')] + for config, status, error in cases: + (root / '101.conf').write_text(config or 'ostype: debian\n') + with self.assertRaisesRegex(native.ProbeError, error): + docker.scan(101, run=self.runner(), guard_run=lambda a, d: status) + (root/'101.conf').unlink() + with self.assertRaisesRegex(native.ProbeError, 'not_local_lxc'): + self.scan() + (root/'101.conf').write_text('ostype: unmanaged\n') + (root/'oci'/'101').mkdir(parents=True) + with self.assertRaisesRegex(native.ProbeError, 'managed_oci'): + self.scan() + self.assertEqual(self.calls, []) + + def test_guest_stopping_during_scan_never_publishes_results(self): + states = iter(['status: running', 'status: stopped']) + with self.assertRaisesRegex(native.ProbeError, 'guest_stopped'): + docker.scan(101, run=self.runner(), guard_run=lambda a,d: next(states)) + + +class Cache(unittest.TestCase): + def setUp(self): + docker._cache.clear(); docker._generations.clear(); docker._locks.clear() + self.addCleanup(docker._cache.clear) + self.addCleanup(docker._generations.clear) + self.value = {'vmid': 101, 'status': 'complete', 'stale': False, 'issues': [], + 'scanned_at': 1000, '_containers': [docker.parse_record(record())], + 'candidates': [docker._candidate(101, docker.parse_record(record()))]} + + def test_get_never_scans_and_freshness_boundary_has_no_mutation(self): + with patch.object(docker, 'scan', return_value=self.value) as scan, patch.object(docker.time, 'time', return_value=1000): + self.assertEqual(docker.cached(101)['status'], 'not_scanned') + scan.assert_not_called() + docker.discover(101) + for clock, stale in [(1299.999, False), (1300, True), (1300.001, True)]: + with patch.object(docker.time, 'time', return_value=clock): + self.assertEqual(docker.cached(101)['stale'], stale) + self.assertFalse(docker._cache[101]['stale']) + + def test_failure_partial_and_lifecycle_preserve_previous_without_new_probes(self): + with patch.object(docker, 'scan', return_value=self.value): docker.discover(101) + with patch.object(docker, 'scan', side_effect=native.ProbeError('timeout')): + failed = docker.discover(101) + self.assertEqual(failed['status'], 'error'); self.assertTrue(failed['stale']) + self.assertEqual(failed['candidates'], self.value['candidates']) + with patch.object(docker, 'scan') as scan: + docker.mark_stale(101) + self.assertTrue(docker.cached(101)['stale']) + scan.assert_not_called() + + def test_inflight_lifecycle_rejects_late_result_and_busy_has_no_second_scan(self): + with patch.object(docker, 'scan', side_effect=lambda v: (docker.mark_stale(v), self.value)[1]): + self.assertEqual(docker.discover(101)['issues'], ['guest_changed']) + self.assertEqual(docker.cached(101)['status'], 'not_scanned') + lock = docker._locks[101]; lock.acquire() + try: + with patch.object(docker, 'scan') as scan: + self.assertEqual(docker.discover(101)['status'], 'busy'); scan.assert_not_called() + finally: lock.release() + + def test_shared_web_links_avoid_duplicate_inspect_and_stop_at_snapshot_boundary(self): + lookup = Mock(return_value={'slug': None, 'name': 'web', 'logo_url': None}) + with patch.object(docker, 'scan', return_value=self.value), patch.object(docker.time, 'time', return_value=1000): + docker.discover(101) + for clock, valid in [(1059.999, True), (1060, False), (1060.001, False)]: + with patch.object(docker.time, 'time', return_value=clock), patch.object(lxc_apps, '_pct_exec') as execute: + result = docker.cached_web_links(101, lookup) + self.assertEqual(result is not None, valid); execute.assert_not_called() + with patch.object(docker.time, 'time', return_value=1001), patch.object(lxc_apps, '_pct_exec') as execute: + with patch.object(lxc_apps, '_docker_service_catalog_meta', lookup): + self.assertEqual(lxc_apps._probe_docker_web_links(101)[0]['host_port'], 8080) + execute.assert_not_called() + + def test_registered_container_image_app_or_existing_port_is_not_duplicated(self): + item = self.value['candidates'][0] + for app in [{'discovery_id': item['discovery_id']}, {'container_name': item['container_name']}, + {'ports': [{'port': 8080}]}]: + self.assertEqual(docker.filter_candidates(self.value, {}, [app])['candidates'], []) + self.assertEqual(docker.filter_candidates(self.value, {'docker_workloads': [{'container_name': item['container_name']}]}, [])['candidates'], []) + + def test_same_display_name_is_not_container_identity(self): + item = self.value['candidates'][0] + other = {'name': item['name'], 'discovery_id': 'docker-'+'f'*24, 'ports':[{'port':9090}]} + self.assertEqual(docker.filter_candidates(self.value, {}, [other])['candidates'], [item]) + + def test_save_identity_accepts_docker_but_does_not_enable_update(self): + identity = self.value['candidates'][0]['discovery_id'] + ok, value = lxc_apps.validate_config({'name': 'Docker endpoint', 'discovery_id': identity, + 'ports': [{'port': 8080}], 'update_method': 'none'}) + self.assertTrue(ok, value); self.assertEqual(value['discovery_id'], identity) + self.assertFalse(value.get('update_command')); self.assertFalse(value.get('repo')) + self.assertFalse(lxc_apps.validate_config({'name':'bad', 'discovery_id':'docker-bad'})[0]) + + +class RealContract(unittest.TestCase): + def test_sanitized_testhost_template_and_partial_stdout_are_parsed(self): + value = json.loads((SCRIPTS/'tests/fixtures/docker_discovery_inspect.json').read_text()) + self.assertEqual(value['normal_rc'], 0) + self.assertEqual(value['partial_rc'], 1) + self.assertTrue(value['partial_records']) + for item in [*value['records'], *value['partial_records']]: + parsed = docker.parse_record(item) + self.assertTrue(parsed['id']) + self.assertNotIn('Env', json.dumps(parsed)) + if parsed['running'] and parsed['service'] != 'db': + self.assertEqual(docker._candidate(101,parsed)['default_ports'],[8080]) + + +class Reader(unittest.TestCase): + def test_nonzero_exit_preserves_stdout_without_leaking_stderr(self): + rc, output = docker.read([sys.executable, '-c', 'import sys; print("valid"); print("private-fixture",file=sys.stderr); sys.exit(1)'], time.monotonic()+3) + self.assertEqual((rc, output), (1, 'valid\n')) + + def test_timeout_and_output_limit_are_bounded_errors(self): + with patch.object(docker, 'PROBE_SECONDS', .05): + with self.assertRaisesRegex(native.ProbeError, 'timeout'): + docker.read([sys.executable, '-c', 'import time; time.sleep(2)'], time.monotonic()+3) + with patch.object(docker, 'OUTPUT_BYTES', 100): + with self.assertRaisesRegex(native.ProbeError, 'output_limit'): + docker.read([sys.executable, '-c', 'print("x"*1000)'], time.monotonic()+3) + + +class Routes(unittest.TestCase): + def setUp(self): + from flask import Flask, request, jsonify + self.app = Flask(__name__) + def auth(fn): + @wraps(fn) + def wrapped(*args, **kwargs): + return fn(*args, **kwargs) if request.headers.get('X-Fixture') == 'yes' else ({'error':'unauthorized'},401) + return wrapped + source = SCRIPTS/'flask_server.py' + routes = [n for n in ast.parse(source.read_text()).body if isinstance(n,ast.FunctionDef) and n.name in ('api_vm_apps_docker_discovery','api_vm_apps_suggestions')] + self.cache = {} + scope = {'app':self.app, 'request':request, 'jsonify':jsonify, 'require_auth':auth, + '_vm_cache_get':lambda c,v,t:c.get(v), '_vm_cache_put':lambda c,v,p:c.update({v:p}), + '_vm_app_suggestions_cache':self.cache, '_VM_APP_SUGGESTIONS_TTL':60} + exec(compile(ast.Module(body=routes, type_ignores=[]), str(source), 'exec'),scope) + self.client = self.app.test_client() + + def test_existing_suggestions_refresh_preserves_docker_cache_without_new_scan(self): + value = {'vmid':101,'status':'complete','candidates':[],'scanned_at':time.time(),'stale':False} + docker._cache[101] = copy.deepcopy(value) + self.addCleanup(docker._cache.clear) + with patch.object(docker,'discover') as scan, patch.object(lxc_apps,'get_suggestions',return_value={'extras':[]}) as catalog, patch.object(lxc_apps,'_write_sidecar') as write: + response = self.client.post('/api/vms/101/apps/suggestions',headers={'X-Fixture':'yes'}) + self.assertEqual(response.status_code,200) + catalog.assert_called_once_with(101,force=True) + scan.assert_not_called(); write.assert_not_called() + self.assertEqual(docker.cached(101),value) + + def test_real_route_only_explicit_post_scans_and_never_registers(self): + value = {'vmid':101,'status':'complete','candidates':[],'stale':False} + with patch.object(docker,'discover',return_value=value) as scan, patch.object(docker,'cached',return_value=value), patch.object(lxc_apps,'load_sidecar',return_value={'apps':[]}), patch.object(lxc_apps,'_write_sidecar') as write: + self.assertEqual(self.client.post('/api/vms/101/apps/docker-discovery').status_code,401) + self.assertEqual(self.client.put('/api/vms/101/apps/docker-discovery',headers={'X-Fixture':'yes'}).status_code,405) + self.assertEqual(self.client.get('/api/vms/101/apps/docker-discovery',headers={'X-Fixture':'yes'}).status_code,200) + scan.assert_not_called(); write.assert_not_called() + self.assertEqual(self.client.post('/api/vms/101/apps/docker-discovery',headers={'X-Fixture':'yes'}).status_code,200) + scan.assert_called_once_with(101); write.assert_not_called() + + +if __name__ == '__main__': unittest.main() diff --git a/AppImage/scripts/tests/test_native_app_discovery.py b/AppImage/scripts/tests/test_native_app_discovery.py index 3d92533d..5c89ed16 100644 --- a/AppImage/scripts/tests/test_native_app_discovery.py +++ b/AppImage/scripts/tests/test_native_app_discovery.py @@ -768,13 +768,19 @@ class Routes(unittest.TestCase): write.assert_not_called() def test_actual_lifecycle_marks_last_known_stale_without_scanning(self): + import docker_app_discovery as docker + docker._cache[101] = {'vmid':101, 'status':'complete', 'stale':False, 'candidates':[], 'scanned_at':time.time()} + self.addCleanup(docker._cache.clear) native._cache[101] = {'vmid': 101, 'status': 'complete', 'stale': False, 'candidates': [], 'scanned_at': time.time()} with patch.object(native, 'discover') as scan, \ + patch.object(docker, 'discover') as docker_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']) + self.assertTrue(docker.cached(101)['stale']) + docker_scan.assert_not_called() scan.assert_not_called() self.assertEqual(clear.call_count, 2) diff --git a/tests/lxc_updates/test_docker_app_discovery_ui.cjs b/tests/lxc_updates/test_docker_app_discovery_ui.cjs new file mode 100644 index 00000000..621d2f2d --- /dev/null +++ b/tests/lxc_updates/test_docker_app_discovery_ui.cjs @@ -0,0 +1,47 @@ +// Real panel interactions with inert API fixtures; no guest or registration. +const assert = require('node:assert/strict') +const {fixture, text} = require('./test_native_app_discovery_ui.cjs') +const candidate = { + vmid:101,slug:'docker-'+'b'.repeat(24),discovery_id:'docker-'+'b'.repeat(24), + source:'docker_metadata',name:'Uncatalogued web app',default_ports:[8080], + installed_version:null,tracking_suggestion:null, + evidence:[{kind:'docker_container',value:'web-1'},{kind:'docker_image',value:'example/web:7'}], +} +async function main() { + const f = fixture({nativeCandidate:null,dockerCandidate:candidate}) + f.render(); await f.settle() + assert.equal(f.calls.filter(c=>c.method==='POST').length,0) + await f.button('searchApplications').props.onClick(); f.render() + assert.equal(f.calls.filter(c=>c.url.endsWith('/docker-discovery')&&c.method==='POST').length,1) + assert.ok(text(f.tree()).includes('example/web:7')) + assert.ok(text(f.tree()).includes('dockerEndpointReview')) + assert.ok(text(f.tree()).includes('nativeVersionUnknown')) + await f.button('registerButton').props.onClick(); f.render() + f.button('cancelButton').props.onClick(); f.render() + assert.equal(f.calls.filter(c=>/\/apps$/.test(c.url)&&c.method==='POST').length,0) + await f.button('registerButton').props.onClick(); f.render() + await f.button('saveButton').props.onClick(); f.render() + const saved=JSON.parse(f.calls.find(c=>/\/apps$/.test(c.url)&&c.method==='POST').body) + assert.equal(saved.discovery_id,candidate.discovery_id) + assert.equal(saved.update_method,'none') + assert.ok(!saved.repo && !saved.helper_slug && !saved.update_command) + for (const status of ['error','partial','busy']) { + const failed=fixture({nativeCandidate:null,dockerCandidate:candidate}) + failed.render(); await failed.settle(); failed.setDockerStatus(status) + await failed.button('searchApplications').props.onClick(); failed.render() + assert.ok(text(failed.tree()).includes(status==='partial'?'dockerScanPartial':'dockerScanFailed')) + assert.ok(!text(failed.tree()).includes('noApplicationsDetected')) + } + const changed=fixture({nativeCandidate:null,dockerCandidate:candidate}) + changed.render(); await changed.settle(); changed.holdDocker() + const pending=changed.button('searchApplications').props.onClick() + await changed.settle(); changed.render({vmid:102,initialData:{sidecar:{apps:[]},suggestions:{extras:[],port_suggestions:[]}}}) + changed.resolveDocker(); await pending; changed.render() + assert.ok(!text(changed.tree()).includes(candidate.name),'late Docker result never crosses CT') + const oci=fixture({nativeCandidate:null,dockerCandidate:candidate}) + oci.render({vmid:101,oci:{instance:true},initialData:{sidecar:{apps:[]},suggestions:{oci_instance:{template_id:'image-demo'},port_suggestions:[]}}}) + await oci.settle(); await oci.button('refreshData').props.onClick() + assert.equal(oci.calls.filter(c=>c.url.endsWith('/docker-discovery')&&c.method==='POST').length,0) + console.log('PASS Docker Find: explicit scan, generic evidence, Cancel/Save, failure/partial, CT switch, OCI no-scan') +} +main().catch(error=>{console.error(error);process.exitCode=1}) diff --git a/tests/lxc_updates/test_native_app_discovery_ui.cjs b/tests/lxc_updates/test_native_app_discovery_ui.cjs index 3cfc5a09..668a98c4 100644 --- a/tests/lxc_updates/test_native_app_discovery_ui.cjs +++ b/tests/lxc_updates/test_native_app_discovery_ui.cjs @@ -23,11 +23,13 @@ const candidate = { evidence: [{kind: 'installation_file', value: '/opt/handmade/server'}], default_ports: [], } -function fixture({nativeCandidate = candidate} = {}) { +function fixture({nativeCandidate = candidate, dockerCandidate = null} = {}) { 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', scanIssues + let dockerStatus = "complete", dockerCached = {vmid:101,status:"not_scanned",candidates:[],stale:false} + let holdDocker, dockerGate let cacheGate, resolveCache, cachePending = false const seed = {sidecar: {vmid: 101, apps: []}, suggestions: {extras: [], port_suggestions: []}} const react = { @@ -56,10 +58,18 @@ function fixture({nativeCandidate = candidate} = {}) { } if (options.method === 'POST') { if (scanGate) await scanGate - native = {...native, status: scanStatus, issues: scanIssues, candidates: [nativeCandidate]} + native = {...native, status: scanStatus, issues: scanIssues, candidates: nativeCandidate ? [nativeCandidate] : []} } return structuredClone(native) } + if (url.endsWith('/docker-discovery')) { + if (options.method === 'POST') { + if (dockerGate) await dockerGate + dockerCached = {vmid:Number(url.split('/')[3]),status:dockerStatus, + stale:dockerStatus !== 'complete',candidates:dockerCandidate ? [dockerCandidate] : []} + } + return structuredClone(dockerCached) + } if (url.endsWith('/apps/suggestions')) return seed.suggestions if (url.endsWith('/apps') && options.method === 'POST') { if (saveGate) await saveGate @@ -102,6 +112,8 @@ function fixture({nativeCandidate = candidate} = {}) { holdInitialCache: () => {cachePending = true; cacheGate = new Promise(resolve => {resolveCache = resolve})}, resolveInitialCache: () => resolveCache(), setScanStatus: value => {scanStatus = value}, + setDockerStatus: value => {dockerStatus = value}, + holdDocker: () => {dockerGate = new Promise(resolve => {holdDocker = resolve})}, resolveDocker: () => holdDocker(), setScanIssues: value => {scanIssues = value}, holdScan: () => {scanGate = new Promise(resolve => {holdScan = resolve})}, resolveScan: () => holdScan(), holdSave: () => {saveGate = new Promise(resolve => {holdSave = resolve})}, resolveSave: () => holdSave(), @@ -230,4 +242,5 @@ async function main() { 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}) +module.exports = {fixture, flatten, text} +if (require.main === module) main().catch(error => {console.error(error); process.exitCode = 1})