feat(monitor): discover Docker application endpoints on demand

This commit is contained in:
VAIO73
2026-10-19 08:03:25 +00:00
parent 29778aa709
commit cfb90a094c
13 changed files with 901 additions and 13 deletions
+2
View File
@@ -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",
+43 -8
View File
@@ -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<Suggestions | null>(seed?.suggestions ?? null)
const [nativeDiscovery, setNativeDiscovery] = useState<NativeDiscovery | null>(null)
const nativeRequest = useRef(0)
const [dockerDiscovery, setDockerDiscovery] = useState<NativeDiscovery | null>(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<NativeDiscovery>(`/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<string | null>(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<NativeDiscovery>(`/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<NativeDiscovery>(`/api/vms/${vmid}/apps/native-discovery`)
if (!isCurrent()) return
setNativeDiscovery(native)
docker = await fetchApi<NativeDiscovery>(`/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")}
</div>
{d.source === "native_metadata" && (
{(d.source === "native_metadata" || d.source === "docker_metadata") && (
<div className="text-xs text-muted-foreground mt-1 break-all">
<div>{d.installed_version
? t("vmLxc.appEditor.versionDetected", { version: d.installed_version })
: t("vmLxc.appEditor.nativeVersionUnknown")}</div>
{(nativeDiscovery?.stale || nativeDiscovery?.status === "partial") && (
{(d.source === "docker_metadata"
? dockerDiscovery?.stale || dockerDiscovery?.status === "partial"
: nativeDiscovery?.stale || nativeDiscovery?.status === "partial") && (
<div className="text-amber-400">{t("vmLxc.appEditor.nativeEvidenceStale")}</div>
)}
{d.evidence?.map((e, index) => <div key={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}
</div>)}
<div>{t("vmLxc.appEditor.nativeRegisterOnly")}</div>
<div>{t(d.source === "docker_metadata"
? "vmLxc.appEditor.dockerEndpointReview" : "vmLxc.appEditor.nativeRegisterOnly")}</div>
</div>
)}
{d.tracking_suggestion?.detector_source === "legacy_fallback" && (
+6
View File
@@ -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",
+6
View File
@@ -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.",
+1
View File
@@ -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"
+295
View File
@@ -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))]}
+16
View File
@@ -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/<int:vmid>/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/<int:vmid>/apps/adguard-setup', methods=['GET'])
@require_auth
def api_vm_apps_adguard_setup(vmid):
+11 -2
View File
@@ -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"))
@@ -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": {}
}
]
}
@@ -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()
@@ -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)
@@ -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})
@@ -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})