mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-02 10:52:22 +00:00
Merge pull request #505 from Vaso73/feature/docker-app-discovery
feat(monitor): discover Docker application links on demand
This commit is contained in:
@@ -13,6 +13,7 @@ import sys
|
|||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
PYTHON_FILES = (
|
PYTHON_FILES = (
|
||||||
|
"AppImage/scripts/tests/test_docker_app_discovery.py",
|
||||||
"AppImage/scripts/tests/test_native_app_discovery.py",
|
"AppImage/scripts/tests/test_native_app_discovery.py",
|
||||||
"tests/test_audit_presentation.py",
|
"tests/test_audit_presentation.py",
|
||||||
"tests/test_audit_safety_wording.py",
|
"tests/test_audit_safety_wording.py",
|
||||||
@@ -25,6 +26,7 @@ PYTHON_FILES = (
|
|||||||
"AppImage/scripts/tests/test_health_persistence_recurrence.py",
|
"AppImage/scripts/tests/test_health_persistence_recurrence.py",
|
||||||
)
|
)
|
||||||
NODE_FILES = (
|
NODE_FILES = (
|
||||||
|
"tests/lxc_updates/test_docker_app_discovery_ui.cjs",
|
||||||
"tests/lxc_updates/test_native_app_discovery_ui.cjs",
|
"tests/lxc_updates/test_native_app_discovery_ui.cjs",
|
||||||
"tests/lxc_updates/test_docker_delegated_ui.cjs",
|
"tests/lxc_updates/test_docker_delegated_ui.cjs",
|
||||||
"tests/test_audit_diagnostic_document.cjs",
|
"tests/test_audit_diagnostic_document.cjs",
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ on:
|
|||||||
- 'AppImage/scripts/*.py'
|
- 'AppImage/scripts/*.py'
|
||||||
- 'AppImage/scripts/tests/test_health_persistence_recurrence.py'
|
- 'AppImage/scripts/tests/test_health_persistence_recurrence.py'
|
||||||
- 'AppImage/scripts/tests/test_native_app_discovery.py'
|
- 'AppImage/scripts/tests/test_native_app_discovery.py'
|
||||||
|
- 'AppImage/scripts/tests/test_docker_app_discovery.py'
|
||||||
|
- 'AppImage/scripts/tests/fixtures/docker_discovery_inspect.json'
|
||||||
- 'AppImage/app/**'
|
- 'AppImage/app/**'
|
||||||
- 'AppImage/components/**'
|
- 'AppImage/components/**'
|
||||||
- 'AppImage/hooks/**'
|
- 'AppImage/hooks/**'
|
||||||
@@ -34,6 +36,8 @@ on:
|
|||||||
- 'AppImage/scripts/*.py'
|
- 'AppImage/scripts/*.py'
|
||||||
- 'AppImage/scripts/tests/test_health_persistence_recurrence.py'
|
- 'AppImage/scripts/tests/test_health_persistence_recurrence.py'
|
||||||
- 'AppImage/scripts/tests/test_native_app_discovery.py'
|
- 'AppImage/scripts/tests/test_native_app_discovery.py'
|
||||||
|
- 'AppImage/scripts/tests/test_docker_app_discovery.py'
|
||||||
|
- 'AppImage/scripts/tests/fixtures/docker_discovery_inspect.json'
|
||||||
- 'AppImage/app/**'
|
- 'AppImage/app/**'
|
||||||
- 'AppImage/components/**'
|
- 'AppImage/components/**'
|
||||||
- 'AppImage/hooks/**'
|
- 'AppImage/hooks/**'
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ interface NativeDiscovery {
|
|||||||
|
|
||||||
interface DetectedApp {
|
interface DetectedApp {
|
||||||
discovery_id?: string
|
discovery_id?: string
|
||||||
source?: "native_metadata"
|
source?: "native_metadata" | "docker_metadata"
|
||||||
installed_version?: string | null
|
installed_version?: string | null
|
||||||
evidence?: Array<{ kind: string; value: string }>
|
evidence?: Array<{ kind: string; value: string }>
|
||||||
slug: 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 [suggestions, setSuggestions] = useState<Suggestions | null>(seed?.suggestions ?? null)
|
||||||
const [nativeDiscovery, setNativeDiscovery] = useState<NativeDiscovery | null>(null)
|
const [nativeDiscovery, setNativeDiscovery] = useState<NativeDiscovery | null>(null)
|
||||||
const nativeRequest = useRef(0)
|
const nativeRequest = useRef(0)
|
||||||
|
const [dockerDiscovery, setDockerDiscovery] = useState<NativeDiscovery | null>(null)
|
||||||
|
const dockerRequest = useRef(0)
|
||||||
const targetRef = useRef(vmid)
|
const targetRef = useRef(vmid)
|
||||||
targetRef.current = vmid
|
targetRef.current = vmid
|
||||||
const targetGeneration = useRef(0)
|
const targetGeneration = useRef(0)
|
||||||
@@ -489,6 +491,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
|||||||
const generation = targetGeneration.current
|
const generation = targetGeneration.current
|
||||||
const request = ++nativeRequest.current
|
const request = ++nativeRequest.current
|
||||||
setNativeDiscovery(null)
|
setNativeDiscovery(null)
|
||||||
|
setDockerDiscovery(null)
|
||||||
setEditing(null)
|
setEditing(null)
|
||||||
setDetectionNotice(null)
|
setDetectionNotice(null)
|
||||||
setSearchingApplications(false)
|
setSearchingApplications(false)
|
||||||
@@ -500,6 +503,17 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
|||||||
}).catch(() => { /* cached metadata is optional; never initiate a scan */ })
|
}).catch(() => { /* cached metadata is optional; never initiate a scan */ })
|
||||||
return () => { targetGeneration.current += 1 }
|
return () => { targetGeneration.current += 1 }
|
||||||
}, [vmid, managed])
|
}, [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 [adguardSetupAvailable, setAdguardSetupAvailable] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [searchingApplications, setSearchingApplications] = useState(false)
|
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
|
// (`extras[]` from the backend). Both use the same DetectedApp
|
||||||
// shape so the empty state renders them uniformly.
|
// shape so the empty state renders them uniformly.
|
||||||
const detectedList: DetectedApp[] = useMemo(() => {
|
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
|
if (!suggestions) return out
|
||||||
// A ProxMenux OCI install has no helper slug — nothing was installed by a
|
// A ProxMenux OCI install has no helper slug — nothing was installed by a
|
||||||
// community script — so its template id identifies it instead.
|
// community script — so its template id identifies it instead.
|
||||||
@@ -719,7 +734,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}, [suggestions, nativeDiscovery, vmid])
|
}, [suggestions, nativeDiscovery, dockerDiscovery, vmid])
|
||||||
|
|
||||||
// Registered slugs — used to filter the detection list down to
|
// Registered slugs — used to filter the detection list down to
|
||||||
// what the user hasn't already registered on this CT.
|
// 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 searchInstalledApplications = async () => {
|
||||||
const generation = targetGeneration.current
|
const generation = targetGeneration.current
|
||||||
const request = ++nativeRequest.current
|
const request = ++nativeRequest.current
|
||||||
|
++dockerRequest.current
|
||||||
const isCurrent = () => targetRef.current === vmid && targetGeneration.current === generation
|
const isCurrent = () => targetRef.current === vmid && targetGeneration.current === generation
|
||||||
&& nativeRequest.current === request
|
&& nativeRequest.current === request
|
||||||
const before = new Set([...visibleDetected, ...visibleWorkloads].map((item) => item.slug))
|
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") })
|
? "vmLxc.appEditor.nativeScanStopped" : "vmLxc.appEditor.nativeScanFailed") })
|
||||||
return
|
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`, {
|
const result: Suggestions = await fetchApi(`/api/vms/${vmid}/apps/suggestions`, {
|
||||||
method: "POST",
|
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`)
|
native = await fetchApi<NativeDiscovery>(`/api/vms/${vmid}/apps/native-discovery`)
|
||||||
if (!isCurrent()) return
|
if (!isCurrent()) return
|
||||||
setNativeDiscovery(native)
|
setNativeDiscovery(native)
|
||||||
|
docker = await fetchApi<NativeDiscovery>(`/api/vms/${vmid}/apps/docker-discovery`)
|
||||||
|
if (!isCurrent()) return
|
||||||
|
setDockerDiscovery(docker)
|
||||||
setSuggestions(result)
|
setSuggestions(result)
|
||||||
if (sidecar) setLxcAppsCached(vmid, sidecar, 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.extras || []) detected.add(item.slug)
|
||||||
for (const item of result.docker_workloads || []) 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 native.candidates) detected.add(item.slug)
|
||||||
|
for (const item of docker.candidates || []) detected.add(item.slug)
|
||||||
const visible = [...detected].filter(
|
const visible = [...detected].filter(
|
||||||
(slug) => !registeredSlugs.has(slug) && !dismissedSlugs.has(slug),
|
(slug) => !registeredSlugs.has(slug) && !dismissedSlugs.has(slug),
|
||||||
)
|
)
|
||||||
const newCount = visible.filter((slug) => !before.has(slug)).length
|
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") })
|
setDetectionNotice({ found: visible.length > 0, text: t("vmLxc.appEditor.nativeScanPartial") })
|
||||||
} else if (newCount === 1) {
|
} else if (newCount === 1) {
|
||||||
setDetectionNotice({ found: true, text: t("vmLxc.appEditor.oneNewApplicationDetected") })
|
setDetectionNotice({ found: true, text: t("vmLxc.appEditor.oneNewApplicationDetected") })
|
||||||
@@ -2526,22 +2555,29 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
|
|||||||
? t("vmLxc.appEditor.versionDetected", { version: d.tracking_suggestion.detected_version || "" })
|
? t("vmLxc.appEditor.versionDetected", { version: d.tracking_suggestion.detected_version || "" })
|
||||||
: t("vmLxc.appEditor.detectedInContainer")}
|
: t("vmLxc.appEditor.detectedInContainer")}
|
||||||
</div>
|
</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 className="text-xs text-muted-foreground mt-1 break-all">
|
||||||
<div>{d.installed_version
|
<div>{d.installed_version
|
||||||
? t("vmLxc.appEditor.versionDetected", { version: d.installed_version })
|
? t("vmLxc.appEditor.versionDetected", { version: d.installed_version })
|
||||||
: t("vmLxc.appEditor.nativeVersionUnknown")}</div>
|
: 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>
|
<div className="text-amber-400">{t("vmLxc.appEditor.nativeEvidenceStale")}</div>
|
||||||
)}
|
)}
|
||||||
{d.evidence?.map((e, index) => <div key={index}>
|
{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" ? (d.source === "docker_metadata"
|
||||||
|
? "vmLxc.appEditor.dockerEvidencePort" : "vmLxc.appEditor.nativeEvidencePort")
|
||||||
: e.kind === "installation_file" ? "vmLxc.appEditor.nativeEvidenceFile"
|
: e.kind === "installation_file" ? "vmLxc.appEditor.nativeEvidenceFile"
|
||||||
: e.kind === "process_executable" ? "vmLxc.appEditor.nativeEvidenceProcess"
|
: e.kind === "process_executable" ? "vmLxc.appEditor.nativeEvidenceProcess"
|
||||||
: e.kind === "working_directory" ? "vmLxc.appEditor.nativeEvidenceDirectory"
|
: e.kind === "working_directory" ? "vmLxc.appEditor.nativeEvidenceDirectory"
|
||||||
: "vmLxc.appEditor.nativeEvidenceService")}: {e.value}
|
: "vmLxc.appEditor.nativeEvidenceService")}: {e.value}
|
||||||
</div>)}
|
</div>)}
|
||||||
<div>{t("vmLxc.appEditor.nativeRegisterOnly")}</div>
|
<div>{t(d.source === "docker_metadata"
|
||||||
|
? "vmLxc.appEditor.dockerEndpointReview" : "vmLxc.appEditor.nativeRegisterOnly")}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{d.tracking_suggestion?.detector_source === "legacy_fallback" && (
|
{d.tracking_suggestion?.detector_source === "legacy_fallback" && (
|
||||||
|
|||||||
@@ -1748,7 +1748,14 @@
|
|||||||
"nativeScanPartial": "Die Anwendungssuche ist unvollständig. Prüfen Sie die gefundenen Hinweise und versuchen Sie es erneut.",
|
"nativeScanPartial": "Die Anwendungssuche ist unvollständig. Prüfen Sie die gefundenen Hinweise und versuchen Sie es erneut.",
|
||||||
"nativeVersionUnknown": "Installierte Version unbekannt",
|
"nativeVersionUnknown": "Installierte Version unbekannt",
|
||||||
"nativeEvidenceStale": "Die Hinweise sind möglicherweise veraltet oder unvollständig.",
|
"nativeEvidenceStale": "Die Hinweise sind möglicherweise veraltet oder unvollständig.",
|
||||||
"nativeRegisterOnly": "Bestätigen Sie die Registrierung im Editor. Eine bekannte Version wird als manueller Wert gespeichert; es wird kein Updater eingerichtet."
|
"nativeRegisterOnly": "Bestätigen Sie die Registrierung im Editor. Eine bekannte Version wird als manueller Wert gespeichert; es wird kein Updater eingerichtet.",
|
||||||
|
"dockerScanFailed": "Die Docker-Suche konnte nicht abgeschlossen werden. Frühere Ergebnisse sind möglicherweise veraltet; versuchen Sie es erneut.",
|
||||||
|
"dockerScanPartial": "Die Docker-Suche ist unvollständig. Einige Container konnten nicht geprüft werden.",
|
||||||
|
"dockerEvidenceContainer": "Docker-Container",
|
||||||
|
"dockerEvidenceImage": "Image",
|
||||||
|
"dockerEvidencePort": "Veröffentlichter TCP-Port",
|
||||||
|
"dockerEvidenceProject": "Compose-Projekt",
|
||||||
|
"dockerEndpointReview": "Container mit veröffentlichten TCP-Ports. Bestätigen Sie Namen und Webadresse vor dem Speichern. Die Registrierung richtet keine Updates ein."
|
||||||
},
|
},
|
||||||
"statusFilter": {
|
"statusFilter": {
|
||||||
"ariaLabel": "Virtuelle Maschinen und Container filtern",
|
"ariaLabel": "Virtuelle Maschinen und Container filtern",
|
||||||
|
|||||||
@@ -1751,6 +1751,13 @@
|
|||||||
"nativeVersionUnknown": "Installed version unknown",
|
"nativeVersionUnknown": "Installed version unknown",
|
||||||
"nativeEvidenceStale": "Evidence may be outdated or incomplete.",
|
"nativeEvidenceStale": "Evidence may be outdated or incomplete.",
|
||||||
"nativeRegisterOnly": "Confirm registration in the editor. A known version is a manual value; no updater is configured.",
|
"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",
|
||||||
|
"dockerEvidencePort": "Published TCP port",
|
||||||
|
"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.",
|
"noNewApplicationsDetected": "No new applications were detected.",
|
||||||
"dockerDetectedWithWorkloads": "Docker detected with {count} containerised application(s)",
|
"dockerDetectedWithWorkloads": "Docker detected with {count} containerised application(s)",
|
||||||
"dockerWorkloadsHeading": "Running inside Docker",
|
"dockerWorkloadsHeading": "Running inside Docker",
|
||||||
|
|||||||
@@ -1734,6 +1734,13 @@
|
|||||||
"nativeVersionUnknown": "Versión instalada desconocida",
|
"nativeVersionUnknown": "Versión instalada desconocida",
|
||||||
"nativeEvidenceStale": "Lo detectado puede estar desactualizado o incompleto.",
|
"nativeEvidenceStale": "Lo detectado puede estar desactualizado o incompleto.",
|
||||||
"nativeRegisterOnly": "Confirma el registro en el editor. Una versión conocida se guarda como valor manual; no se configura ningún actualizador.",
|
"nativeRegisterOnly": "Confirma el registro en el editor. Una versión conocida se guarda como valor manual; no se configura ningún actualizador.",
|
||||||
|
"dockerScanFailed": "La búsqueda en Docker no pudo terminar. Los resultados anteriores pueden estar desactualizados; vuelve a intentarlo.",
|
||||||
|
"dockerScanPartial": "La búsqueda en Docker está incompleta. No se pudieron revisar algunos contenedores.",
|
||||||
|
"dockerEvidenceContainer": "Contenedor Docker",
|
||||||
|
"dockerEvidenceImage": "Imagen",
|
||||||
|
"dockerEvidencePort": "Puerto TCP publicado",
|
||||||
|
"dockerEvidenceProject": "Proyecto Compose",
|
||||||
|
"dockerEndpointReview": "Contenedor con puertos TCP publicados. Confirma el nombre y la dirección web antes de guardar. El registro no configura actualizaciones.",
|
||||||
"noNewApplicationsDetected": "No se han detectado aplicaciones nuevas.",
|
"noNewApplicationsDetected": "No se han detectado aplicaciones nuevas.",
|
||||||
"oneNewApplicationDetected": "Se ha detectado una aplicación nueva.",
|
"oneNewApplicationDetected": "Se ha detectado una aplicación nueva.",
|
||||||
"newApplicationsDetected": "Se han detectado {count} aplicaciones nuevas.",
|
"newApplicationsDetected": "Se han detectado {count} aplicaciones nuevas.",
|
||||||
|
|||||||
@@ -1748,7 +1748,14 @@
|
|||||||
"nativeScanPartial": "La recherche d'applications est incomplète. Vérifiez les éléments trouvés et réessayez.",
|
"nativeScanPartial": "La recherche d'applications est incomplète. Vérifiez les éléments trouvés et réessayez.",
|
||||||
"nativeVersionUnknown": "Version installée inconnue",
|
"nativeVersionUnknown": "Version installée inconnue",
|
||||||
"nativeEvidenceStale": "Les éléments trouvés peuvent être obsolètes ou incomplets.",
|
"nativeEvidenceStale": "Les éléments trouvés peuvent être obsolètes ou incomplets.",
|
||||||
"nativeRegisterOnly": "Confirmez l'enregistrement dans l'éditeur. Une version connue est enregistrée comme valeur manuelle ; aucun outil de mise à jour n'est configuré."
|
"nativeRegisterOnly": "Confirmez l'enregistrement dans l'éditeur. Une version connue est enregistrée comme valeur manuelle ; aucun outil de mise à jour n'est configuré.",
|
||||||
|
"dockerScanFailed": "La recherche Docker n'a pas pu se terminer. Les résultats précédents peuvent être obsolètes ; réessayez.",
|
||||||
|
"dockerScanPartial": "La recherche Docker est incomplète. Certains conteneurs n'ont pas pu être vérifiés.",
|
||||||
|
"dockerEvidenceContainer": "Conteneur Docker",
|
||||||
|
"dockerEvidenceImage": "Image",
|
||||||
|
"dockerEvidencePort": "Port TCP publié",
|
||||||
|
"dockerEvidenceProject": "Projet Compose",
|
||||||
|
"dockerEndpointReview": "Conteneur avec des ports TCP publiés. Confirmez le nom et l'adresse web avant d'enregistrer. L'enregistrement ne configure pas les mises à jour."
|
||||||
},
|
},
|
||||||
"statusFilter": {
|
"statusFilter": {
|
||||||
"ariaLabel": "Filtrer les machines virtuelles et les conteneurs",
|
"ariaLabel": "Filtrer les machines virtuelles et les conteneurs",
|
||||||
|
|||||||
@@ -1748,7 +1748,14 @@
|
|||||||
"nativeScanPartial": "La ricerca delle applicazioni è incompleta. Controlla gli elementi trovati e riprova.",
|
"nativeScanPartial": "La ricerca delle applicazioni è incompleta. Controlla gli elementi trovati e riprova.",
|
||||||
"nativeVersionUnknown": "Versione installata sconosciuta",
|
"nativeVersionUnknown": "Versione installata sconosciuta",
|
||||||
"nativeEvidenceStale": "Gli elementi trovati potrebbero non essere aggiornati o essere incompleti.",
|
"nativeEvidenceStale": "Gli elementi trovati potrebbero non essere aggiornati o essere incompleti.",
|
||||||
"nativeRegisterOnly": "Conferma la registrazione nell'editor. Una versione nota viene salvata come valore manuale; non viene configurato alcun aggiornamento automatico."
|
"nativeRegisterOnly": "Conferma la registrazione nell'editor. Una versione nota viene salvata come valore manuale; non viene configurato alcun aggiornamento automatico.",
|
||||||
|
"dockerScanFailed": "La ricerca Docker non è stata completata. I risultati precedenti potrebbero non essere aggiornati; riprova.",
|
||||||
|
"dockerScanPartial": "La ricerca Docker è incompleta. Non è stato possibile controllare alcuni container.",
|
||||||
|
"dockerEvidenceContainer": "Container Docker",
|
||||||
|
"dockerEvidenceImage": "Immagine",
|
||||||
|
"dockerEvidencePort": "Porta TCP pubblicata",
|
||||||
|
"dockerEvidenceProject": "Progetto Compose",
|
||||||
|
"dockerEndpointReview": "Container con porte TCP pubblicate. Conferma il nome e l'indirizzo web prima di salvare. La registrazione non configura gli aggiornamenti."
|
||||||
},
|
},
|
||||||
"statusFilter": {
|
"statusFilter": {
|
||||||
"ariaLabel": "Filtra macchine virtuali e container",
|
"ariaLabel": "Filtra macchine virtuali e container",
|
||||||
|
|||||||
@@ -1748,7 +1748,14 @@
|
|||||||
"nativeScanPartial": "A pesquisa de aplicativos está incompleta. Revise os itens encontrados e tente novamente.",
|
"nativeScanPartial": "A pesquisa de aplicativos está incompleta. Revise os itens encontrados e tente novamente.",
|
||||||
"nativeVersionUnknown": "Versão instalada desconhecida",
|
"nativeVersionUnknown": "Versão instalada desconhecida",
|
||||||
"nativeEvidenceStale": "Os itens encontrados podem estar desatualizados ou incompletos.",
|
"nativeEvidenceStale": "Os itens encontrados podem estar desatualizados ou incompletos.",
|
||||||
"nativeRegisterOnly": "Confirme o registro no editor. Uma versão conhecida é salva como valor manual; nenhum atualizador é configurado."
|
"nativeRegisterOnly": "Confirme o registro no editor. Uma versão conhecida é salva como valor manual; nenhum atualizador é configurado.",
|
||||||
|
"dockerScanFailed": "A pesquisa no Docker não pôde ser concluída. Os resultados anteriores podem estar desatualizados; tente novamente.",
|
||||||
|
"dockerScanPartial": "A pesquisa no Docker está incompleta. Não foi possível verificar alguns contentores.",
|
||||||
|
"dockerEvidenceContainer": "Contentor Docker",
|
||||||
|
"dockerEvidenceImage": "Imagem",
|
||||||
|
"dockerEvidencePort": "Porta TCP publicada",
|
||||||
|
"dockerEvidenceProject": "Projeto Compose",
|
||||||
|
"dockerEndpointReview": "Contentor com portas TCP publicadas. Confirme o nome e o endereço web antes de guardar. O registo não configura atualizações."
|
||||||
},
|
},
|
||||||
"statusFilter": {
|
"statusFilter": {
|
||||||
"ariaLabel": "Filtrar máquinas virtuais e contêineres",
|
"ariaLabel": "Filtrar máquinas virtuais e contêineres",
|
||||||
|
|||||||
@@ -1695,6 +1695,13 @@
|
|||||||
"nativeVersionUnknown": "Nainštalovaná verzia nie je známa",
|
"nativeVersionUnknown": "Nainštalovaná verzia nie je známa",
|
||||||
"nativeEvidenceStale": "Dôkazy môžu byť zastarané alebo neúplné.",
|
"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ý.",
|
"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",
|
||||||
|
"dockerEvidencePort": "Publikovaný TCP port",
|
||||||
|
"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.",
|
"noNewApplicationsDetected": "Neboli zistené žiadne nové aplikácie.",
|
||||||
"oneNewApplicationDetected": "Bola zistená jedna nová aplikácia.",
|
"oneNewApplicationDetected": "Bola zistená jedna nová aplikácia.",
|
||||||
"newApplicationsDetected": "Boli zistené {count} nové aplikácie.",
|
"newApplicationsDetected": "Boli zistené {count} nové aplikácie.",
|
||||||
|
|||||||
@@ -1748,7 +1748,14 @@
|
|||||||
"nativeScanPartial": "Programsökningen är ofullständig. Granska det som hittades och försök igen.",
|
"nativeScanPartial": "Programsökningen är ofullständig. Granska det som hittades och försök igen.",
|
||||||
"nativeVersionUnknown": "Installerad version okänd",
|
"nativeVersionUnknown": "Installerad version okänd",
|
||||||
"nativeEvidenceStale": "Det som hittades kan vara inaktuellt eller ofullständigt.",
|
"nativeEvidenceStale": "Det som hittades kan vara inaktuellt eller ofullständigt.",
|
||||||
"nativeRegisterOnly": "Bekräfta registreringen i redigeraren. En känd version sparas som ett manuellt värde; ingen uppdatering konfigureras."
|
"nativeRegisterOnly": "Bekräfta registreringen i redigeraren. En känd version sparas som ett manuellt värde; ingen uppdatering konfigureras.",
|
||||||
|
"dockerScanFailed": "Docker-sökningen kunde inte slutföras. Tidigare resultat kan vara inaktuella; försök igen.",
|
||||||
|
"dockerScanPartial": "Docker-sökningen är ofullständig. Vissa containrar kunde inte kontrolleras.",
|
||||||
|
"dockerEvidenceContainer": "Docker-container",
|
||||||
|
"dockerEvidenceImage": "Avbild",
|
||||||
|
"dockerEvidencePort": "Publicerad TCP-port",
|
||||||
|
"dockerEvidenceProject": "Compose-projekt",
|
||||||
|
"dockerEndpointReview": "Container med publicerade TCP-portar. Bekräfta namnet och webbadressen innan du sparar. Registreringen konfigurerar inga uppdateringar."
|
||||||
},
|
},
|
||||||
"statusFilter": {
|
"statusFilter": {
|
||||||
"ariaLabel": "Filtrera virtuella maskiner och containrar",
|
"ariaLabel": "Filtrera virtuella maskiner och containrar",
|
||||||
|
|||||||
@@ -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_update_finalization.py" "$APP_DIR/usr/bin/"
|
||||||
cp "$SCRIPT_DIR/lxc_check_job.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/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/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/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"
|
cp "$SCRIPT_DIR/recreate_docker_container.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ recreate_docker_container.py not found"
|
||||||
|
|||||||
@@ -0,0 +1,301 @@
|
|||||||
|
#!/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'
|
||||||
|
raw = value.get('HostPort')
|
||||||
|
# An unassigned ("") or ranged host port is not a single address.
|
||||||
|
if not isinstance(raw, str) or not raw.isdecimal():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
ip = ipaddress.ip_address(address)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
if ip.is_loopback or getattr(ip, 'ipv4_mapped', None) and ip.ipv4_mapped.is_loopback:
|
||||||
|
continue
|
||||||
|
outer = int(raw)
|
||||||
|
if not 1 <= outer <= 65535:
|
||||||
|
continue
|
||||||
|
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],
|
||||||
|
'port_details': [{'port': port['host_port'], 'scheme': port['scheme'], 'path': None,
|
||||||
|
'description': '', 'logo_url': ''}
|
||||||
|
for port in record['endpoints'] if port['container_port'] in WEB_PORTS] or None,
|
||||||
|
'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)))
|
||||||
|
# Only a complete search lists every container; otherwise probe as before.
|
||||||
|
if (not value or value.get('_retired') or value.get('status') != 'complete'
|
||||||
|
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 sorted(result, key=lambda item: (item["service_name"].lower(), item["host_port"]))
|
||||||
|
|
||||||
|
|
||||||
|
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))]}
|
||||||
@@ -2280,6 +2280,8 @@ def _handle_guest_lifecycle(vmid: str, vm_type: str, action: str) -> None:
|
|||||||
if guest_type == 'lxc':
|
if guest_type == 'lxc':
|
||||||
import native_app_discovery
|
import native_app_discovery
|
||||||
native_app_discovery.mark_stale(guest_id)
|
native_app_discovery.mark_stale(guest_id)
|
||||||
|
import docker_app_discovery
|
||||||
|
docker_app_discovery.mark_stale(guest_id)
|
||||||
_invalidate_lxc_ip(guest_id)
|
_invalidate_lxc_ip(guest_id)
|
||||||
if action in ('start', 'reboot'):
|
if action in ('start', 'reboot'):
|
||||||
if guest_type == 'lxc':
|
if guest_type == 'lxc':
|
||||||
@@ -14130,6 +14132,20 @@ def api_vm_apps_native_discovery(vmid):
|
|||||||
result, suggestions, sidecar.get('apps') or []))
|
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'])
|
@app.route('/api/vms/<int:vmid>/apps/adguard-setup', methods=['GET'])
|
||||||
@require_auth
|
@require_auth
|
||||||
def api_vm_apps_adguard_setup(vmid):
|
def api_vm_apps_adguard_setup(vmid):
|
||||||
|
|||||||
@@ -1182,7 +1182,7 @@ def validate_config(payload: dict) -> tuple[bool, Any]:
|
|||||||
# Native discovery identity is separate from a curated helper/updater.
|
# Native discovery identity is separate from a curated helper/updater.
|
||||||
discovery_id = payload.get("discovery_id")
|
discovery_id = payload.get("discovery_id")
|
||||||
if discovery_id is not None:
|
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")
|
return _err("invalid discovery_id")
|
||||||
conf["discovery_id"] = 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,
|
Stopped containers are included from their persistent HostConfig bindings,
|
||||||
so their links remain registrable before the workload is started again.
|
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)
|
key = str(vmid)
|
||||||
now = time.time()
|
now = time.time()
|
||||||
with _docker_web_links_lock:
|
with _docker_web_links_lock:
|
||||||
@@ -5416,7 +5420,12 @@ def _probe_docker_web_links(vmid) -> list[dict]:
|
|||||||
if not isinstance(binding, dict):
|
if not isinstance(binding, dict):
|
||||||
continue
|
continue
|
||||||
host_ip = str(binding.get("HostIp") or "").strip()
|
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
|
continue
|
||||||
try:
|
try:
|
||||||
host_port = int(binding.get("HostPort"))
|
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,336 @@
|
|||||||
|
"""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_unassigned_or_ranged_host_ports_are_skipped_not_invalid(self):
|
||||||
|
app = record(running=False, bindings={'8080/tcp': [{'HostIp': '', 'HostPort': ''}],
|
||||||
|
'9000/tcp': [{'HostIp': '', 'HostPort': '9000-9010'}],
|
||||||
|
'443/tcp': [{'HostIp': '', 'HostPort': '8444'}]})
|
||||||
|
result = self.scan(records=[app])
|
||||||
|
self.assertEqual(result['status'], 'complete')
|
||||||
|
endpoints = result['_containers'][0]['endpoints']
|
||||||
|
self.assertEqual([e['host_port'] for e in endpoints], [8444])
|
||||||
|
|
||||||
|
def test_https_container_port_prefills_its_scheme(self):
|
||||||
|
app = record(bindings={'443/tcp': [{'HostIp': '0.0.0.0', 'HostPort': '8444'}]})
|
||||||
|
item, = self.scan(records=[app])['candidates']
|
||||||
|
self.assertEqual(item['default_ports'], [8444])
|
||||||
|
self.assertEqual([(d['port'], d['scheme']) for d in item['port_details']], [(8444, 'https')])
|
||||||
|
|
||||||
|
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_incomplete_search_never_replaces_the_web_link_probe(self):
|
||||||
|
lookup = Mock(return_value={'slug': None, 'name': 'web', 'logo_url': None})
|
||||||
|
partial = {**copy.deepcopy(self.value), 'status': 'partial', 'issues': ['inspect_failed']}
|
||||||
|
with patch.object(docker, 'scan', return_value=partial), patch.object(docker.time, 'time', return_value=1000):
|
||||||
|
docker.discover(101)
|
||||||
|
with patch.object(docker.time, 'time', return_value=1001):
|
||||||
|
self.assertIsNone(docker.cached_web_links(101, lookup))
|
||||||
|
docker._cache.clear()
|
||||||
|
with patch.object(docker, 'scan', side_effect=native.ProbeError('timeout')), \
|
||||||
|
patch.object(docker.time, 'time', return_value=1000):
|
||||||
|
docker.discover(101)
|
||||||
|
with patch.object(docker.time, 'time', return_value=1001):
|
||||||
|
self.assertIsNone(docker.cached_web_links(101, lookup))
|
||||||
|
|
||||||
|
def test_shared_links_keep_existing_name_then_port_order(self):
|
||||||
|
value = copy.deepcopy(self.value)
|
||||||
|
value['_containers'][0]['name'] = 'z-last'
|
||||||
|
second = copy.deepcopy(value['_containers'][0]); second['name'] = 'a-first'
|
||||||
|
second['endpoints'] = [{'host_port':9090,'container_port':8080,'scheme':'http'},
|
||||||
|
{'host_port':8080,'container_port':8080,'scheme':'http'}]
|
||||||
|
value['_containers'].append(second)
|
||||||
|
lookup = lambda service,name,image: {'slug':None,'name':name,'logo_url':None}
|
||||||
|
with patch.object(docker,'scan',return_value=value): docker.discover(101)
|
||||||
|
links = docker.cached_web_links(101,lookup)
|
||||||
|
self.assertEqual([(x['service_name'],x['host_port']) for x in links],
|
||||||
|
[('a-first',8080),('a-first',9090),('z-last',8080)])
|
||||||
|
|
||||||
|
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()
|
write.assert_not_called()
|
||||||
|
|
||||||
def test_actual_lifecycle_marks_last_known_stale_without_scanning(self):
|
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,
|
native._cache[101] = {'vmid': 101, 'status': 'complete', 'stale': False,
|
||||||
'candidates': [], 'scanned_at': time.time()}
|
'candidates': [], 'scanned_at': time.time()}
|
||||||
with patch.object(native, 'discover') as scan, \
|
with patch.object(native, 'discover') as scan, \
|
||||||
|
patch.object(docker, 'discover') as docker_scan, \
|
||||||
patch.object(lxc_apps, 'clear_schedule_reboot_required') as clear:
|
patch.object(lxc_apps, 'clear_schedule_reboot_required') as clear:
|
||||||
for action in ('start', 'reboot', 'stop'):
|
for action in ('start', 'reboot', 'stop'):
|
||||||
self.globals['_handle_guest_lifecycle']('101', 'lxc', action)
|
self.globals['_handle_guest_lifecycle']('101', 'lxc', action)
|
||||||
self.assertTrue(native.cached(101)['stale'])
|
self.assertTrue(native.cached(101)['stale'])
|
||||||
|
self.assertTrue(docker.cached(101)['stale'])
|
||||||
|
docker_scan.assert_not_called()
|
||||||
scan.assert_not_called()
|
scan.assert_not_called()
|
||||||
self.assertEqual(clear.call_count, 2)
|
self.assertEqual(clear.call_count, 2)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// 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'},{kind:'tcp_listener',value:'8080'}],
|
||||||
|
}
|
||||||
|
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('dockerEvidencePort'),'published mapping is not claimed as a probed listener')
|
||||||
|
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: [],
|
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 = []
|
let cursor = 0, tree, props, effects = [], states = [], refs = [], effectStates = []
|
||||||
const calls = [], cachedWrites = []
|
const calls = [], cachedWrites = []
|
||||||
let native = {vmid: 101, status: 'not_scanned', candidates: [], stale: false}
|
let native = {vmid: 101, status: 'not_scanned', candidates: [], stale: false}
|
||||||
let holdScan, scanGate, holdSave, saveGate, scanStatus = 'complete', scanIssues
|
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
|
let cacheGate, resolveCache, cachePending = false
|
||||||
const seed = {sidecar: {vmid: 101, apps: []}, suggestions: {extras: [], port_suggestions: []}}
|
const seed = {sidecar: {vmid: 101, apps: []}, suggestions: {extras: [], port_suggestions: []}}
|
||||||
const react = {
|
const react = {
|
||||||
@@ -56,10 +58,18 @@ function fixture({nativeCandidate = candidate} = {}) {
|
|||||||
}
|
}
|
||||||
if (options.method === 'POST') {
|
if (options.method === 'POST') {
|
||||||
if (scanGate) await scanGate
|
if (scanGate) await scanGate
|
||||||
native = {...native, status: scanStatus, issues: scanIssues, candidates: [nativeCandidate]}
|
native = {...native, status: scanStatus, issues: scanIssues, candidates: nativeCandidate ? [nativeCandidate] : []}
|
||||||
}
|
}
|
||||||
return structuredClone(native)
|
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/suggestions')) return seed.suggestions
|
||||||
if (url.endsWith('/apps') && options.method === 'POST') {
|
if (url.endsWith('/apps') && options.method === 'POST') {
|
||||||
if (saveGate) await saveGate
|
if (saveGate) await saveGate
|
||||||
@@ -102,6 +112,8 @@ function fixture({nativeCandidate = candidate} = {}) {
|
|||||||
holdInitialCache: () => {cachePending = true; cacheGate = new Promise(resolve => {resolveCache = resolve})},
|
holdInitialCache: () => {cachePending = true; cacheGate = new Promise(resolve => {resolveCache = resolve})},
|
||||||
resolveInitialCache: () => resolveCache(),
|
resolveInitialCache: () => resolveCache(),
|
||||||
setScanStatus: value => {scanStatus = value},
|
setScanStatus: value => {scanStatus = value},
|
||||||
|
setDockerStatus: value => {dockerStatus = value},
|
||||||
|
holdDocker: () => {dockerGate = new Promise(resolve => {holdDocker = resolve})}, resolveDocker: () => holdDocker(),
|
||||||
setScanIssues: value => {scanIssues = value},
|
setScanIssues: value => {scanIssues = value},
|
||||||
holdScan: () => {scanGate = new Promise(resolve => {holdScan = resolve})}, resolveScan: () => holdScan(),
|
holdScan: () => {scanGate = new Promise(resolve => {holdScan = resolve})}, resolveScan: () => holdScan(),
|
||||||
holdSave: () => {saveGate = new Promise(resolve => {holdSave = resolve})}, resolveSave: () => holdSave(),
|
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')
|
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)')
|
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})
|
||||||
|
|||||||
Reference in New Issue
Block a user