Merge develop into local application icon discovery

This commit is contained in:
VAIO73
2026-10-19 17:30:37 +00:00
65 changed files with 3834 additions and 158 deletions
+6 -1
View File
@@ -1,6 +1,7 @@
"use client"
import { useEffect, useState } from "react"
import { useIsPbs } from "../lib/use-platform"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
import { Input } from "./ui/input"
import {
@@ -283,8 +284,12 @@ function computeVisualRange(
// ─── Component ───────────────────────────────────────────────────────────────
// Guests and PVE storages do not exist on a Proxmox Backup Server.
const PVE_ONLY_SECTIONS = new Set(["lxc_rootfs", "lxc_mount", "pve_storage"])
export function HealthThresholds() {
const t = useT()
const isPbs = useIsPbs()
const tFallback = (key: string, fallback: string) => {
const translated = t(key)
return translated === key ? fallback : translated
@@ -773,7 +778,7 @@ export function HealthThresholds() {
Mobile (<md) stays single-column as today.
*/}
<div className="columns-1 md:columns-2 2xl:columns-3 gap-4 space-y-4 [&>*]:break-inside-avoid">
{SECTIONS.map((section) => {
{SECTIONS.filter((section) => !isPbs || !PVE_ONLY_SECTIONS.has(section.id)).map((section) => {
const Icon = section.icon
return (
<div key={section.id} className="rounded-md border border-border/50 px-3 py-2">
+44 -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
@@ -511,6 +511,8 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci, c
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)
@@ -521,6 +523,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci, c
}
useEffect(() => {
setNativeDiscovery(null)
setDockerDiscovery(null)
setEditing(null)
setDetectionNotice(null)
setSearchingApplications(false)
@@ -538,6 +541,17 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci, c
}).catch(() => { /* cached metadata is optional; never initiate a scan */ })
return () => { cancelled = true }
}, [vmid, managed, cacheRevision])
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)
@@ -753,7 +767,8 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci, c
// (`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.
@@ -779,7 +794,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci, c
}
}
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.
@@ -852,6 +867,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci, c
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))
@@ -871,6 +887,11 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci, c
? "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",
})
@@ -879,6 +900,9 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci, c
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)
@@ -887,11 +911,16 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci, c
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") })
@@ -2649,22 +2678,29 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci, c
? 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" ? (d.source === "docker_metadata"
? "vmLxc.appEditor.dockerEvidencePort" : "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" && (
+23 -5
View File
@@ -22,9 +22,11 @@ interface MonitorLink {
product: "pve" | "pbs"
group: "cluster" | "standalone" | "pbs"
url: string | null
status: "online" | "offline" | "unknown"
status: "online" | "offline" | "unknown" | "local"
native_url?: string | null
manual: boolean
// A PBS installed on this same host.
local?: boolean
hidden: boolean
custom_url: boolean
}
@@ -48,6 +50,15 @@ function bareOrigin(value: unknown): string | null {
} catch { return null }
}
// localhost names the computer that opens the address, never another node.
function isLoopback(url: string): boolean {
try {
const host = new URL(url).hostname.replace(/^\[|\]$/g, "").toLowerCase()
return host === "localhost" || host.endsWith(".localhost") || /^127\./.test(host)
|| host === "::1" || /^::ffff:127\./.test(host) || /^::ffff:7f/.test(host)
} catch { return false }
}
// The Monitors the source already links, carried in the jump so this one can add them too.
function readCarried(raw: string | null): Carried[] {
if (!raw) return []
@@ -58,7 +69,7 @@ function readCarried(raw: string | null): Carried[] {
const out: Carried[] = []
for (const row of rows.slice(0, MAX_CARRIED)) {
const url = bareOrigin(row?.u)
if (!url || url === window.location.origin || seen.has(url)) continue
if (!url || url === window.location.origin || isLoopback(url) || seen.has(url)) continue
seen.add(url)
out.push({ url, name: String(row?.n || new URL(url).hostname).slice(0, 64), product: row?.p === "pbs" ? "pbs" : "pve" })
}
@@ -102,6 +113,7 @@ function takeOrigin(): (Origin & { fresh?: boolean }) | null {
// Where a link leads: its Monitor, or the native PBS interface when no Monitor answers there.
function destination(link: MonitorLink): { href: string; native: boolean } | null {
if (link.local && !link.url) return { href: `https://${window.location.hostname}:8007`, native: true }
if (link.product === "pbs" && link.status !== "online" && link.native_url) return { href: link.native_url, native: true }
return link.url ? { href: link.url, native: false } : null
}
@@ -127,7 +139,7 @@ export function MonitorSwitcher({ nodeName, compact = false }: { nodeName: strin
const originKnown = !!origin && known(origin.url)
// The source Monitor and the Monitors it links that this one does not know yet.
const candidates: Carried[] = origin && data
? [origin, ...(origin.links || [])].filter(row => !known(row.url))
? [origin, ...(origin.links || [])].filter(row => !known(row.url) && !isLoopback(row.url))
: []
const declined = (url: string) => {
try { return (JSON.parse(sessionStorage.getItem(DECLINED_KEY) || "[]") as string[]).includes(url) } catch { return false }
@@ -147,7 +159,8 @@ export function MonitorSwitcher({ nodeName, compact = false }: { nodeName: strin
const withOrigin = (href: string) => {
const target = bareOrigin(href.replace(/\/+$/, ""))
const carried = (data?.links || [])
.filter(link => link.url && link.status === "online" && !destination(link)?.native && link.url !== target)
.filter(link => link.url && link.status === "online" && !destination(link)?.native && link.url !== target
&& !isLoopback(link.url))
.map(link => ({ url: link.url as string, name: link.name, product: link.product }))
const params = new URLSearchParams({ pmx_from: window.location.origin, pmx_name: nodeName, pmx_product: data?.self.product === "pbs" ? "pbs" : "pve" })
if (carried.length > 0) params.set("pmx_links", writeCarried(carried))
@@ -359,7 +372,12 @@ function ManageLinksDialog({ open, onOpenChange }: { open: boolean; onOpenChange
<Trash2 className="h-4 w-4 text-red-500" />
</Button>}
</div>
{isEditing
{link.local && !link.custom_url
? <div className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate text-sm text-foreground">{t("monitorLinks.pbsWebInterface")}</span>
<span className="shrink-0 rounded border border-border px-1.5 py-0.5 text-[11px] text-muted-foreground">{t("monitorLinks.thisNode")}</span>
</div>
: isEditing
? <div className="flex gap-2">
<Input value={draft} disabled={busy} autoFocus aria-label={t("monitorLinks.address")}
placeholder={link.manual ? "" : `${t("monitorLinks.autoAddress")}${shown ? `: ${shown}` : ""}`}
+24 -5
View File
@@ -17,7 +17,8 @@ import { fetchApi } from "../lib/api-config"
import { useT } from "../lib/i18n/provider"
interface Connection { id: string; name: string; url: string; token_id: string; fingerprint: string; has_token: boolean }
interface Connections { local_available: boolean; servers: Connection[] }
interface Detected { name: string; url: string; fingerprint: string }
interface Connections { local_available: boolean; servers: Connection[]; detected?: Detected[] }
interface Task { upid: string; worker_type?: string; worker_id?: string; starttime?: number; endtime?: number; status?: string }
interface Job { id: string; store?: string; schedule?: string; "last-run-state"?: string }
interface PBSStatus {
@@ -122,7 +123,7 @@ export function PBSPanel({ localOnly = false }: { localOnly?: boolean } = {}) {
<Select value={selected || undefined} onValueChange={setSelected}>
<SelectTrigger aria-label={t("pbs.server")} className="w-full sm:ml-auto sm:w-56"><SelectValue placeholder={t("pbs.chooseServer")} /></SelectTrigger>
<SelectContent>
{connections?.local_available && <SelectItem value="local">{t("pbs.local")}</SelectItem>}
{connections?.local_available && <SelectItem value="local">{t("pbs.thisNode")}</SelectItem>}
{connections?.servers.map(row => <SelectItem key={row.id} value={row.id}>{row.name}</SelectItem>)}
</SelectContent>
</Select>
@@ -137,6 +138,22 @@ export function PBSPanel({ localOnly = false }: { localOnly?: boolean } = {}) {
{String((connectionError || statusError)?.message || t("pbs.loadFailed"))}</p>}
{isLoading && <p role="status" className="text-sm text-muted-foreground">{t("pbs.loading")}</p>}
{!selected && !connectionError && connections && <p className="text-sm text-muted-foreground">{t("pbs.noServer")}</p>}
{!localOnly && !manage && (connections?.detected?.length || 0) > 0 && <Card className="bg-card border-border">
<CardHeader><CardTitle className="flex items-center text-foreground"><DatabaseBackup className="mr-2 h-5 w-5" />{t("pbs.detectedTitle")}</CardTitle></CardHeader>
<CardContent className="space-y-3">
<p className="text-sm text-muted-foreground">{t("pbs.detectedHelp")}</p>
{connections?.detected?.map(row => <div key={row.url} className="flex flex-wrap items-center gap-3 rounded-lg border border-border px-3 py-2">
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-foreground">{row.name}</span>
<span className="block break-all font-mono text-xs text-muted-foreground">{row.url}</span>
</span>
<Button size="sm" className="bg-blue-600 text-white hover:bg-blue-700" onClick={() => {
setForm({ ...emptyForm, name: row.name, url: row.url, fingerprint: row.fingerprint })
setError(""); setMessage(""); setManage(true)
}}>{t("pbs.connect")}</Button>
</div>)}
</CardContent>
</Card>}
{manage && !localOnly && <Card>
<CardHeader><CardTitle>{t("pbs.manage")}</CardTitle></CardHeader>
<CardContent className="space-y-4">
@@ -171,9 +188,11 @@ export function PBSPanel({ localOnly = false }: { localOnly?: boolean } = {}) {
</Card>}
{data && <>
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<span>{data.name} · PBS {data.version || "—"} · {t("pbs.sampled")} {new Date(data.fetched_at * 1000).toLocaleTimeString()}</span>
{Object.entries(data.services).map(([service, state]) =>
<Badge key={service} variant="outline" className={state === "active" ? badgeTone.ok : badgeTone.error}>{service}: {state}</Badge>)}
<span>{data.name} · PBS {data.version || "—"} · {t("pbs.updatedAt", { time: new Date(data.fetched_at * 1000).toLocaleTimeString() })}</span>
{Object.keys(data.services).length > 0 && Object.values(data.services).every(state => state === "active")
? <Badge variant="outline" className={badgeTone.ok}>{t("pbs.servicesActive")}</Badge>
: Object.entries(data.services).filter(([, state]) => state !== "active").map(([service, state]) =>
<Badge key={service} variant="outline" className={badgeTone.error}>{service}: {state}</Badge>)}
</div>
{Object.entries(data.errors).map(([section, detail]) => <p key={section} role="alert" className="text-sm text-amber-500">{t(`pbs.${section}`)}: {detail}</p>)}
@@ -843,6 +843,7 @@ function MonitorDashboard({ product }: { product: Product }) {
{isPbs && (
<TabsContent value="datastores" className="space-y-4 md:space-y-6 mt-0">
<PBSPanel key={`datastores-${componentKey}`} localOnly />
<StorageOverview key={`datastore-disks-${componentKey}`} disksOnly />
</TabsContent>
)}
+1 -1
View File
@@ -1878,7 +1878,7 @@ export function Settings() {
the matching toggle in NotificationSettings is hidden (the
preference is preserved in the DB and reappears when detection
is re-enabled). */}
<LxcUpdateDetection />
{!isPbs && <LxcUpdateDetection />}
{!isPbs && <>
{/* GitHub API — optional authentication for app upstream checks. */}
+6 -5
View File
@@ -231,7 +231,8 @@ function translateAtaError(raw: string, t: (key: string) => string): string | nu
return out.length ? out.join('; ') : null
}
export function StorageOverview() {
// `disksOnly` is the PBS Monitor: its disks and pools, without Proxmox VE storages.
export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } = {}) {
const t = useT()
// User-configurable disk temperature thresholds (Settings → Health
@@ -284,7 +285,7 @@ export function StorageOverview() {
try {
const [data, proxmoxData, mountsData] = await Promise.all([
fetchApi<StorageData>("/api/storage"),
fetchApi<ProxmoxStorageData>("/api/proxmox-storage"),
disksOnly ? Promise.resolve(null) : fetchApi<ProxmoxStorageData>("/api/proxmox-storage"),
// Sprint 13 — host-level NFS/CIFS/SMB mounts. Wrapped in catch
// so a failure here doesn't blank the whole storage tab.
fetchApi<RemoteMountsData>("/api/mounts").catch(() => ({ mounts: [], available: false } as RemoteMountsData)),
@@ -1134,7 +1135,7 @@ export function StorageOverview() {
return (
<div className="space-y-6">
{/* Storage Summary */}
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-3 xl:gap-6">
{!disksOnly && <div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-3 xl:gap-6">
{/* ── Total Storage (preview restyle: headline + stacked bar Local·Remote·Free) ── */}
{(() => {
const totalGB = (totalLocalCapacity || 0) + (totalRemoteCapacity || 0)
@@ -1355,9 +1356,9 @@ export function StorageOverview() {
</Card>
)
})()}
</div>
</div>}
{proxmoxStorage && proxmoxStorage.storage && proxmoxStorage.storage.length > 0 && (
{!disksOnly && proxmoxStorage && proxmoxStorage.storage && proxmoxStorage.storage.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
+14 -1
View File
@@ -1748,7 +1748,14 @@
"nativeScanPartial": "Die Anwendungssuche ist unvollständig. Prüfen Sie die gefundenen Hinweise und versuchen Sie es erneut.",
"nativeVersionUnknown": "Installierte Version unbekannt",
"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": {
"ariaLabel": "Virtuelle Maschinen und Container filtern",
@@ -7289,6 +7296,12 @@
"server": "PBS-Server",
"chooseServer": "PBS-Server auswählen",
"local": "Lokaler PBS",
"thisNode": "PBS auf diesem Knoten",
"updatedAt": "Aktualisiert um {time}",
"servicesActive": "Dienste aktiv",
"detectedTitle": "Im Proxmox-Speicher erkannte PBS",
"detectedHelp": "Diese Proxmox Backup Server sind als Backup-Speicher eingerichtet. Verbinden Sie jeden mit einem schreibgeschützten API-Token, um hier seine Datastores, Aufgaben und Jobs zu sehen.",
"connect": "Verbinden",
"refresh": "Aktualisieren",
"manage": "PBS-Verbindungen verwalten",
"loadFailed": "PBS-Status konnte nicht geladen werden.",
+13
View File
@@ -4,6 +4,12 @@
"server": "PBS server",
"chooseServer": "Choose a PBS server",
"local": "Local PBS",
"thisNode": "PBS on this node",
"updatedAt": "Updated at {time}",
"servicesActive": "Services active",
"detectedTitle": "PBS detected in Proxmox storage",
"detectedHelp": "These Proxmox Backup Servers are configured as backup storage. Connect each one with a read-only API token to see its datastores, tasks and jobs here.",
"connect": "Connect",
"refresh": "Refresh",
"manage": "Manage PBS connections",
"loadFailed": "Could not load PBS status.",
@@ -1751,6 +1757,13 @@
"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",
"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.",
"dockerDetectedWithWorkloads": "Docker detected with {count} containerised application(s)",
"dockerWorkloadsHeading": "Running inside Docker",
+13
View File
@@ -4,6 +4,12 @@
"server": "Servidor PBS",
"chooseServer": "Selecciona un servidor PBS",
"local": "PBS local",
"thisNode": "PBS de este nodo",
"updatedAt": "Actualizado a las {time}",
"servicesActive": "Servicios activos",
"detectedTitle": "PBS detectados en el almacenamiento de Proxmox",
"detectedHelp": "Estos Proxmox Backup Server están configurados como almacenamiento de backups. Conecta cada uno con un token API de solo lectura para ver aquí sus datastores, tareas y trabajos.",
"connect": "Conectar",
"refresh": "Actualizar",
"manage": "Gestionar conexiones PBS",
"loadFailed": "No se pudo consultar el estado de PBS.",
@@ -1734,6 +1740,13 @@
"nativeVersionUnknown": "Versión instalada desconocida",
"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.",
"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.",
"oneNewApplicationDetected": "Se ha detectado una aplicación nueva.",
"newApplicationsDetected": "Se han detectado {count} aplicaciones nuevas.",
+14 -1
View File
@@ -1748,7 +1748,14 @@
"nativeScanPartial": "La recherche d'applications est incomplète. Vérifiez les éléments trouvés et réessayez.",
"nativeVersionUnknown": "Version installée inconnue",
"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": {
"ariaLabel": "Filtrer les machines virtuelles et les conteneurs",
@@ -7289,6 +7296,12 @@
"server": "Serveur PBS",
"chooseServer": "Choisir un serveur PBS",
"local": "PBS local",
"thisNode": "PBS de ce nœud",
"updatedAt": "Mis à jour à {time}",
"servicesActive": "Services actifs",
"detectedTitle": "PBS détectés dans le stockage Proxmox",
"detectedHelp": "Ces Proxmox Backup Server sont configurés comme stockage de sauvegarde. Connectez chacun avec un jeton API en lecture seule pour voir ici ses datastores, tâches et travaux.",
"connect": "Connecter",
"refresh": "Actualiser",
"manage": "Gérer les connexions PBS",
"loadFailed": "Impossible de charger l'état de PBS.",
+14 -1
View File
@@ -1748,7 +1748,14 @@
"nativeScanPartial": "La ricerca delle applicazioni è incompleta. Controlla gli elementi trovati e riprova.",
"nativeVersionUnknown": "Versione installata sconosciuta",
"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": {
"ariaLabel": "Filtra macchine virtuali e container",
@@ -7289,6 +7296,12 @@
"server": "Server PBS",
"chooseServer": "Scegli un server PBS",
"local": "PBS locale",
"thisNode": "PBS di questo nodo",
"updatedAt": "Aggiornato alle {time}",
"servicesActive": "Servizi attivi",
"detectedTitle": "PBS rilevati nello storage di Proxmox",
"detectedHelp": "Questi Proxmox Backup Server sono configurati come storage di backup. Collega ciascuno con un token API di sola lettura per vedere qui i suoi datastore, attività e job.",
"connect": "Collega",
"refresh": "Aggiorna",
"manage": "Gestisci le connessioni PBS",
"loadFailed": "Impossibile caricare lo stato di PBS.",
+14 -1
View File
@@ -1748,7 +1748,14 @@
"nativeScanPartial": "A pesquisa de aplicativos está incompleta. Revise os itens encontrados e tente novamente.",
"nativeVersionUnknown": "Versão instalada desconhecida",
"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": {
"ariaLabel": "Filtrar máquinas virtuais e contêineres",
@@ -7289,6 +7296,12 @@
"server": "Servidor PBS",
"chooseServer": "Escolher um servidor PBS",
"local": "PBS local",
"thisNode": "PBS deste nó",
"updatedAt": "Atualizado às {time}",
"servicesActive": "Serviços ativos",
"detectedTitle": "PBS detetados no armazenamento do Proxmox",
"detectedHelp": "Estes Proxmox Backup Server estão configurados como armazenamento de backups. Ligue cada um com um token API só de leitura para ver aqui os seus datastores, tarefas e trabalhos.",
"connect": "Ligar",
"refresh": "Atualizar",
"manage": "Gerir ligações PBS",
"loadFailed": "Não foi possível carregar o estado do PBS.",
+13
View File
@@ -1695,6 +1695,13 @@
"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",
"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.",
"oneNewApplicationDetected": "Bola zistená jedna nová aplikácia.",
"newApplicationsDetected": "Boli zistené {count} nové aplikácie.",
@@ -7293,6 +7300,12 @@
"server": "Server PBS",
"chooseServer": "Vybrať server PBS",
"local": "Miestny PBS",
"thisNode": "PBS na tomto uzle",
"updatedAt": "Aktualizované o {time}",
"servicesActive": "Služby sú aktívne",
"detectedTitle": "PBS zistené v úložisku Proxmox",
"detectedHelp": "Tieto Proxmox Backup Servery sú nastavené ako úložisko záloh. Pripojte každý z nich API tokenom len na čítanie, aby ste tu videli jeho datastory, úlohy a joby.",
"connect": "Pripojiť",
"refresh": "Obnoviť",
"manage": "Spravovať pripojenia PBS",
"loadFailed": "Nepodarilo sa načítať stav PBS.",
+14 -1
View File
@@ -1748,7 +1748,14 @@
"nativeScanPartial": "Programsökningen är ofullständig. Granska det som hittades och försök igen.",
"nativeVersionUnknown": "Installerad version okänd",
"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": {
"ariaLabel": "Filtrera virtuella maskiner och containrar",
@@ -7289,6 +7296,12 @@
"server": "PBS-server",
"chooseServer": "Välj en PBS-server",
"local": "Lokal PBS",
"thisNode": "PBS på den här noden",
"updatedAt": "Uppdaterad {time}",
"servicesActive": "Tjänsterna är aktiva",
"detectedTitle": "PBS som hittats i Proxmox-lagringen",
"detectedHelp": "Dessa Proxmox Backup Server är konfigurerade som backuplagring. Anslut var och en med en skrivskyddad API-token för att se deras datastores, uppgifter och jobb här.",
"connect": "Anslut",
"refresh": "Uppdatera",
"manage": "Hantera PBS-anslutningar",
"loadFailed": "Det gick inte att läsa in PBS-status.",
+2
View File
@@ -151,7 +151,9 @@ 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/local_app_icons.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/oci_menu_bridge.py" "$APP_DIR/usr/bin/"
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"
chmod +x "$APP_DIR/usr/bin/recreate_docker_container.py" 2>/dev/null || true
+301
View File
@@ -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))]}
+20 -1
View File
@@ -24,11 +24,30 @@ def platform():
@require_auth
def servers():
try:
return jsonify(servers=pbs.public_servers(), local_available=pbs.is_pbs_host())
servers = pbs.public_servers()
return jsonify(servers=servers, local_available=pbs.local_available(), detected=_detected(servers))
except pbs.PBSError as exc:
return jsonify(error=str(exc)), 500
def _detected(servers):
"""PBS storages of this PVE that have no monitoring connection yet."""
if pbs.is_pbs_host():
return []
import monitor_links
from urllib.parse import urlsplit
known = {urlsplit(row.get("url", "")).hostname for row in servers}
found = {}
for row in monitor_links._storage_pbs():
host = row["server"]
if host in known or monitor_links._is_this_host(host):
continue
port = row.get("port") if str(row.get("port", "")).isdigit() else monitor_links.PBS_UI_PORT
url = monitor_links._host_url(host, port, "https")
found.setdefault(url, {"name": row["name"], "url": url, "fingerprint": row.get("fingerprint", "")})
return list(found.values())
def _protected_configuration():
local = auth_manager.load_auth_config()
return local.get("enabled") and not local.get("declined") and local.get("password_hash")
+26
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':
@@ -14159,6 +14161,20 @@ def api_vm_apps_logo_cache(vmid, token):
})
@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):
@@ -23176,6 +23192,16 @@ if __name__ == '__main__':
import sys
import logging
# The root terminal menu shares the Monitor's checker and sidecar cache.
# This local socket changes neither the web API nor its authentication.
try:
if not is_pbs_host():
import lxc_apps
import oci_menu_bridge
oci_menu_bridge.start(lxc_apps)
except Exception as _e:
print(f"[startup] OCI menu bridge unavailable: {_e}", file=sys.stderr)
# Regenerate /etc/cron.d/proxmenux-smart from the current schedule
# config on every Monitor start. Pre-existing installs may carry
# a cron file with the broken script path (without /storage/),
+109 -40
View File
@@ -1184,7 +1184,7 @@ def validate_config(payload: dict, *, vmid: Optional[int] = None) -> tuple[bool,
# 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
@@ -4340,8 +4340,87 @@ def _detect_with_alt_healing(vmid, app: dict) -> tuple:
return installed, err, False
_oci_check_locks: dict = {}
_oci_check_locks_guard = threading.Lock()
def check_app(
vmid, app_id: str, force: bool = False, notify: bool = True,
) -> Optional[dict]:
# Keep legacy detectors' locking unchanged. OCI network requests use a
# per-app lock and hold the shared cache lock only while reading/saving.
with _cache_lock:
sidecar = _read_sidecar(vmid)
app = _find_app(sidecar, app_id) if sidecar else None
if not app or app.get("installed_via") != "oci_image":
return _check_non_oci_app(vmid, app_id, force=force, notify=notify)
return _check_oci_app(vmid, app_id, force=force, notify=notify)
def _check_oci_app(vmid, app_id, force=False, notify=True):
key = (int(vmid), app_id)
with _oci_check_locks_guard:
lock = _oci_check_locks.setdefault(key, threading.Lock())
with lock:
with _cache_lock:
sidecar = _read_sidecar(vmid)
app = _find_app(sidecar, app_id) if sidecar else None
if not app or app.get("installed_via") != "oci_image":
return sidecar
state = app.get("state") or _empty_state()
if not force and state.get("checked_at"):
try:
checked = datetime.datetime.strptime(state["checked_at"].rstrip("Z"), "%Y-%m-%dT%H:%M:%S")
age = (datetime.datetime.utcnow() - checked).total_seconds()
if 0 <= age < _UPSTREAM_CACHE_TTL_SEC:
return sidecar
except (ValueError, TypeError):
pass
before_app = copy.deepcopy(app)
before_record = _read_oci_record(vmid)
# No global cache lock is held while waiting for registries. The same
# native resolver, credentials and digest comparisons are retained.
result = _oci_image_versions(
vmid, known=state, with_latest=not _oci_secondary_member(before_record))
if result.get("busy"):
_recheck_after_oci_operation(vmid, app_id)
return _read_sidecar(vmid)
with _cache_lock:
# Re-read rather than writing the pre-query copy: other apps,
# schedules and registrations may have changed while we waited.
sidecar = _read_sidecar(vmid)
app = _find_app(sidecar, app_id) if sidecar else None
if app != before_app or _read_oci_record(vmid) != before_record:
return sidecar # Never publish a result for a replaced/edited app.
app["state"] = {
"installed_version": result.get("installed_version"),
"latest_version": result.get("latest_version"),
"latest_published_at": None,
"update_available": result.get("update_available"),
"error": result.get("error"),
"checked_at": _now_iso(),
"installed_digest": result.get("installed_digest"),
"installed_registry_digest": result.get("installed_registry_digest"),
"registry_retry": bool(result.get("registry_retry")),
"latest_digest": result.get("latest_digest"),
"image_created": result.get("image_created"),
"latest_image_created": result.get("latest_image_created"),
"image_reference": result.get("image_reference"),
"image_repository": result.get("image_repository"),
}
sidecar["updated_at"] = _now_iso()
_write_sidecar(vmid, sidecar)
if app["state"]["registry_retry"]:
_retry_oci_registry(vmid, app_id)
if notify and app["state"]["update_available"]:
_fire_update_notification(vmid, app)
return sidecar
def _check_non_oci_app(
vmid, app_id: str, force: bool = False, notify: bool = True,
) -> Optional[dict]:
with _cache_lock:
sidecar = _read_sidecar(vmid)
@@ -4371,40 +4450,6 @@ def check_app(
except (ValueError, TypeError):
pass
if app.get("installed_via") == "oci_image":
# A secondary container of a stack shows the version it runs; the
# registry is not asked, because it is not updated on its own.
result = _oci_image_versions(
vmid, known=state, with_latest=not _oci_secondary_member(_read_oci_record(vmid)))
if result.get("busy"):
_recheck_after_oci_operation(vmid, app_id)
return sidecar
app["state"] = {
"installed_version": result.get("installed_version"),
"latest_version": result.get("latest_version"),
"latest_published_at": None,
"update_available": result.get("update_available"),
"error": result.get("error"),
"checked_at": _now_iso(),
"installed_digest": result.get("installed_digest"),
"installed_registry_digest": result.get("installed_registry_digest"),
"registry_retry": bool(result.get("registry_retry")),
"latest_digest": result.get("latest_digest"),
"image_created": result.get("image_created"),
"latest_image_created": result.get("latest_image_created"),
"image_reference": result.get("image_reference"),
"image_repository": result.get("image_repository"),
}
sidecar["updated_at"] = _now_iso()
_write_sidecar(vmid, sidecar)
if app["state"]["registry_retry"]:
_retry_oci_registry(vmid, app_id)
# The payload names an image by its build date and digest when it
# states no version, so it decides whether there is anything to send.
if notify and app["state"]["update_available"]:
_fire_update_notification(vmid, app)
return sidecar
installed, inst_err, _healed = _detect_with_alt_healing(vmid, app)
# Trigger the upstream fetch when ANY upstream source is
# configured. The dispatcher inside `fetch_latest_upstream`
@@ -5363,6 +5408,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:
@@ -5418,7 +5467,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"))
@@ -5977,14 +6031,22 @@ def _oci_repository(reference: str) -> str:
return repository
def _oci_resolve(module, reference: str, architecture: str) -> dict:
def _oci_resolve(module, reference: str, architecture: str, known=None) -> dict:
# One retry: an anonymous registry answers the occasional request with an
# error that the next one does not repeat.
try:
def resolve():
# Older installed engines remain compatible; both paths use their
# native resolver. No separate registry client or verdict is added.
metadata = getattr(module, "resolve_candidate_metadata", None)
if known is not None and callable(metadata):
return metadata(reference, architecture, known)
return module.resolve_candidate(reference, architecture)
try:
return resolve()
except RuntimeError:
time.sleep(2)
return module.resolve_candidate(reference, architecture)
return resolve()
# What the panel shows, translated, when the registry of an image did not answer.
@@ -6067,8 +6129,15 @@ def _oci_image_versions(vmid, known: Optional[dict] = None, with_latest: bool =
result["installed_version"] = _oci_guest_version(vmid, meta.get("template_id"))
if not with_latest:
return result
cached_metadata = None
if (known.get("installed_digest") == installed_digest
and known.get("image_reference") == reference and known.get("latest_digest")):
cached_metadata = {"manifest_digest": known["latest_digest"],
"version": known.get("latest_version"),
"created": known.get("latest_image_created"),
"architecture": architecture}
try:
latest = _oci_resolve(module, reference, architecture)
latest = _oci_resolve(module, reference, architecture, known=cached_metadata)
except Exception:
# A registry that does not answer says nothing new about the image:
# what the last check found for this same image still stands.
+41 -3
View File
@@ -107,6 +107,32 @@ def _is_private(host):
return False
def _own_addresses():
addresses = set()
try:
import psutil
for entries in psutil.net_if_addrs().values():
for entry in entries:
if entry.family in (socket.AF_INET, socket.AF_INET6):
addresses.add(entry.address.split("%")[0])
except Exception:
pass
return addresses
def _is_this_host(host):
"""A PBS installed next to PVE is named localhost or one of the host's own addresses."""
try:
resolved = {info[4][0].split("%")[0] for info in socket.getaddrinfo(host, None)}
except OSError:
return False
own = _own_addresses()
try:
return bool(resolved) and all(ipaddress.ip_address(a).is_loopback or a in own for a in resolved)
except ValueError:
return False
def _cluster_nodes():
try:
result = subprocess.run(["pvesh", "get", "/cluster/status", "--output-format", "json"],
@@ -135,7 +161,7 @@ def _storage_pbs():
if current is not None:
servers.append(current)
continue
option = re.match(r"^\s+(server|port)\s+(\S+)", line)
option = re.match(r"^\s+(server|port|fingerprint)\s+(\S+)", line)
if current is not None and option:
current[option.group(1)] = option.group(2)
return [row for row in servers if row.get("server")]
@@ -154,8 +180,17 @@ def _pbs_servers():
found.setdefault(host, {"name": row.get("name") or host, "native_url": normalize_url(row["url"])})
except Exception:
pass
return [{"key": f"pbs:{host}", "name": info["name"], "product": "pbs", "group": "pbs",
"host": host, "native_url": info["native_url"]} for host, info in found.items()]
servers, local = [], None
for host, info in found.items():
if _is_this_host(host):
# Its web interface is on the address the browser already uses.
if local is None:
local = {"key": "pbs:local", "name": info["name"], "product": "pbs", "group": "pbs", "local": True}
servers.append(local)
continue
servers.append({"key": f"pbs:{host}", "name": info["name"], "product": "pbs", "group": "pbs",
"host": host, "native_url": info["native_url"]})
return servers
def _probe(url):
@@ -188,6 +223,8 @@ def _resolve(entry, override):
return override["url"], "online" if _probe(override["url"]) else "offline"
if entry.get("url"):
return entry["url"], "online" if _probe(entry["url"]) else "offline"
if entry.get("local"):
return None, "local"
host = entry["host"]
if not _is_private(host):
return None, "unknown"
@@ -218,6 +255,7 @@ def list_links(include_hidden=False):
links.append({"key": entry["key"], "name": entry["name"], "product": entry["product"],
"group": entry["group"], "url": url, "status": state,
"native_url": entry.get("native_url"), "manual": bool(entry.get("manual")),
"local": bool(entry.get("local")),
"hidden": bool(override.get("hidden")), "custom_url": bool(override.get("url"))})
return {"self": {"name": platform.get("hostname") or socket.gethostname(), "product": platform.get("product")},
"links": links}
+161
View File
@@ -0,0 +1,161 @@
"""Root-only local access to the Monitor's existing OCI update checker.
The terminal menu must not run a second registry checker or write sidecars in
another process. Requests execute in the Monitor, using its normal locks,
registration rules, retries and /etc/proxmenux/apps results. No TCP API or
authentication setting is changed.
"""
from __future__ import annotations
import atexit
from concurrent.futures import ThreadPoolExecutor, as_completed
import errno
import json
import os
from pathlib import Path
import socket
import socketserver
import stat
import struct
import threading
SOCKET = Path('/run/proxmenux-monitor/oci-check.sock')
_check_lock = threading.Lock()
_MAX_REQUEST = 128 * 1024
_CHECK_WORKERS = 16
def _root_peer(connection):
if not hasattr(socket, 'SO_PEERCRED'):
return False
credentials = connection.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, struct.calcsize('3i'))
return struct.unpack('3i', credentials)[1] == 0
def _applications(request):
if not isinstance(request, dict) or set(request) != {'applications'}:
raise ValueError('Invalid request')
rows = request['applications']
if not isinstance(rows, list) or len(rows) > 1000:
raise ValueError('Invalid applications')
seen = set()
for row in rows:
if (not isinstance(row, dict) or set(row) != {'vmid', 'installation_id'}
or type(row['vmid']) is not int or not 100 <= row['vmid'] <= 999999999
or row['vmid'] in seen or not isinstance(row['installation_id'], str)
or not 1 <= len(row['installation_id']) <= 128):
raise ValueError('Invalid application')
seen.add(row['vmid'])
return rows
def check_applications(module, rows, report):
"""Delegate exclusively to check_app, exactly as the Monitor's Check does."""
report({'completed': 0, 'total': len(rows)})
stopped = threading.Event()
def check_one(row):
if stopped.is_set():
return
vmid = row['vmid']
record = module._read_oci_record(vmid) or {}
if (record.get('installation_id') == row['installation_id']
and record.get('status') == 'installed'
and not record.get('pending_transaction')
and not record.get('pending_stack_transaction')
and not module._oci_secondary_member(record)):
# Honour dismissals and reuse the Monitor's registration and
# checker. The menu never invents registrations or notifications.
module.ensure_oci_registration(vmid)
sidecar = module.load_sidecar(vmid) or {}
app = next((app for app in sidecar.get('apps', [])
if app.get('installed_via') == 'oci_image'), None)
if app:
module.check_app(vmid, app['id'], force=True, notify=False)
pool = ThreadPoolExecutor(max_workers=_CHECK_WORKERS, thread_name_prefix='oci-check')
failed = []
try:
futures = {pool.submit(check_one, row): row['vmid'] for row in rows}
for completed, future in enumerate(as_completed(futures), 1):
try:
future.result()
except Exception:
failed.append(futures[future]) # One failure never stops the other applications.
report({'completed': completed, 'total': len(rows)})
finally:
stopped.set()
pool.shutdown(wait=True, cancel_futures=True)
report({'done': True, 'failed': sorted(failed)})
class _Handler(socketserver.StreamRequestHandler):
def handle(self):
if not _root_peer(self.request):
return
self.request.settimeout(10)
acquired = False
try:
line = self.rfile.readline(_MAX_REQUEST + 1)
if len(line) > _MAX_REQUEST or not line.endswith(b'\n'):
return
rows = _applications(json.loads(line))
acquired = _check_lock.acquire(blocking=False)
if not acquired:
self.report({'error': 'check_running'})
return
check_applications(self.server.apps, rows, self.report)
except (BrokenPipeError, ConnectionResetError, TimeoutError):
pass # Closing the menu stops the rest of this request.
except Exception:
try:
self.report({'error': 'check_failed'})
except OSError:
pass
finally:
if acquired:
_check_lock.release()
def report(self, event):
self.wfile.write((json.dumps(event) + '\n').encode())
self.wfile.flush()
class _Server(socketserver.ThreadingUnixStreamServer):
daemon_threads = True
block_on_close = False
def start(apps, path=SOCKET):
"""Start alongside Flask; a private socket avoids web login bypasses."""
path = Path(path)
if os.geteuid() != 0:
return None
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
parent = path.parent.lstat()
if not stat.S_ISDIR(parent.st_mode) or parent.st_uid != 0 or parent.st_mode & 0o077:
raise OSError('Unsafe Monitor runtime directory')
if path.exists() or path.is_symlink():
old = path.lstat()
if not stat.S_ISSOCK(old.st_mode) or old.st_uid != 0:
raise OSError('Unsafe Monitor socket')
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as probe:
probe.settimeout(.2)
try:
probe.connect(str(path))
return None # Already owned by a running Monitor.
except OSError as exc:
if exc.errno not in (errno.ECONNREFUSED, errno.ENOENT):
raise
path.unlink(missing_ok=True)
server = _Server(str(path), _Handler)
server.apps = apps
os.chmod(path, 0o600)
threading.Thread(target=server.serve_forever, name='oci-menu-check', daemon=True).start()
def close():
server.shutdown()
server.server_close()
path.unlink(missing_ok=True)
atexit.register(close)
return server
+23 -2
View File
@@ -11,6 +11,7 @@ from pathlib import Path
import re
import secrets
import shutil
import socket
import ssl
import subprocess
import tempfile
@@ -53,6 +54,26 @@ def is_pbs_host():
return get_platform().get("product") == "pbs"
def local_available():
"""This host runs PBS: as its product, or installed next to PVE."""
if is_pbs_host():
return True
return bool(shutil.which("proxmox-backup-manager")) and any(
Path(folder, "proxmox-backup-proxy.service").exists()
for folder in ("/lib/systemd/system", "/usr/lib/systemd/system"))
def _local_version():
if is_pbs_host():
return get_platform().get("version")
try:
result = subprocess.run(["dpkg-query", "-W", "-f=${Version}", "proxmox-backup-server"],
capture_output=True, text=True, timeout=5)
return result.stdout.strip() or None if result.returncode == 0 else None
except (OSError, subprocess.SubprocessError):
return None
def _servers_path():
return auth_manager.CONFIG_DIR / "pbs_servers.json"
@@ -248,9 +269,9 @@ def get_status(identifier):
return cached[1]
errors = {}
if identifier == "local":
if not is_pbs_host():
if not local_available():
raise PBSError("The local host is not a PBS server.")
name, version = "Local PBS", get_platform().get("version")
name, version = get_platform().get("hostname") or socket.gethostname(), _local_version()
services = _local_services()
operations = {"datastores": _local_datastores,
"tasks": lambda: _cli("task", "list", "--all", "true", "--limit", "20"),
@@ -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()
@@ -62,6 +62,29 @@ class MonitorLinksTests(unittest.TestCase):
self.assertEqual(by_key["pbs:pbs.example.com"]["native_url"], "https://pbs.example.com:8443")
self.assertFalse(any("example.com" in url for url in probed))
def test_pbs_installed_on_this_host_opens_its_own_interface_and_is_never_probed(self):
links.STORAGE_CFG.write_text("pbs: PBS\n\tdatastore PBS\n\tserver localhost\n\n"
"pbs: Same\n\tdatastore x\n\tserver 10.0.0.55\n\n"
"pbs: Lab\n\tdatastore store1\n\tserver 10.0.0.49\n")
probed = []
with mock.patch.object(links, "_own_addresses", return_value={"10.0.0.55"}), \
mock.patch.object(links, "_probe", side_effect=lambda url: probed.append(url) or False), \
mock.patch.object(links, "_is_private", return_value=True):
result = links.list_links()
pbs_rows = [row for row in result["links"] if row["group"] == "pbs"]
self.assertEqual([row["key"] for row in pbs_rows], ["pbs:local", "pbs:10.0.0.49"])
local = pbs_rows[0]
self.assertEqual((local["name"], local["local"], local["url"], local["status"]), ("PBS", True, None, "local"))
self.assertIsNone(local["native_url"])
self.assertFalse(any("localhost" in url or "10.0.0.55" in url for url in probed))
self.assertFalse(pbs_rows[1]["local"])
def test_loopback_names_are_this_host(self):
with mock.patch.object(links, "_own_addresses", return_value=set()):
self.assertTrue(links._is_this_host("127.0.0.1"))
self.assertTrue(links._is_this_host("::1"))
self.assertFalse(links._is_this_host("10.0.0.49"))
def test_pbs_host_lists_only_manual_entries(self):
links.pbs_manager.get_platform.return_value = {"product": "pbs", "hostname": "pbs-lab"}
links.add_link({"name": "amd", "url": "https://10.0.0.55:8008", "product": "pve"})
@@ -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,124 @@
"""OCI registry waits run concurrently; shared app cache changes remain atomic."""
from concurrent.futures import ThreadPoolExecutor
import copy
import json
from pathlib import Path
import sys
import tempfile
import threading
import unittest
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import lxc_apps
DIGEST = 'sha256:' + 'a' * 64
NEW = 'sha256:' + 'b' * 64
class ParallelOciChecksTests(unittest.TestCase):
def setUp(self):
tmp = tempfile.TemporaryDirectory(); self.addCleanup(tmp.cleanup)
self.root = Path(tmp.name)
self.records = {vmid: {'vmid': vmid, 'status': 'installed', 'installation_id': f'uuid-{vmid}'}
for vmid in range(110, 114)}
for vmid in self.records:
sidecar = {'vmid': vmid, 'schedule': {'enabled': False}, 'apps': [
{'id': 'oci', 'installed_via': 'oci_image', 'name': 'App', 'state': {}},
{'id': 'other', 'installed_via': 'manual', 'name': 'Other', 'state': {}}]}
(self.root / f'{vmid}.json').write_text(json.dumps(sidecar))
for item in (patch.object(lxc_apps, '_APPS_DIR', str(self.root)),
patch.object(lxc_apps, '_read_oci_record', side_effect=lambda vmid: copy.deepcopy(self.records.get(int(vmid))))):
item.start(); self.addCleanup(item.stop)
def result(self, vmid, **kwargs):
return {'installed_digest': DIGEST, 'installed_registry_digest': DIGEST,
'latest_digest': NEW, 'update_available': True, 'installed_version': '1',
'latest_version': '2', 'image_reference': 'example/app:latest'}
def test_four_native_checks_enter_registry_before_any_one_can_finish(self):
rendezvous = threading.Barrier(4, timeout=3)
def versions(vmid, **kwargs):
rendezvous.wait()
return self.result(vmid)
with patch.object(lxc_apps, '_oci_image_versions', side_effect=versions) as query:
with ThreadPoolExecutor(max_workers=4) as pool:
results = list(pool.map(lambda vmid: lxc_apps.check_app(vmid, 'oci', force=True, notify=False), self.records))
self.assertEqual(query.call_count, 4)
self.assertTrue(all(result['apps'][0]['state']['update_available'] for result in results))
def during_query(self, edit):
started, release, edited = threading.Event(), threading.Event(), threading.Event()
def versions(vmid, **kwargs):
started.set()
if not release.wait(3): raise AssertionError('Registry wait did not end')
return self.result(vmid)
def change():
with lxc_apps._cache_lock:
sidecar = lxc_apps.load_sidecar(110)
edit(sidecar)
lxc_apps._write_sidecar(110, sidecar)
edited.set()
with patch.object(lxc_apps, '_oci_image_versions', side_effect=versions):
with ThreadPoolExecutor(max_workers=2) as pool:
future = pool.submit(lxc_apps.check_app, 110, 'oci', force=True, notify=False)
try:
self.assertTrue(started.wait(2))
writer = pool.submit(change)
self.assertTrue(edited.wait(2), 'A registry call still holds the shared cache lock')
finally:
release.set()
future.result(); writer.result()
return lxc_apps.load_sidecar(110)
def test_unrelated_apps_and_schedule_edits_survive_registry_wait(self):
def edit(sidecar):
sidecar['schedule']['enabled'] = True
sidecar['apps'][1]['name'] = 'Renamed while checking'
result = self.during_query(edit)
self.assertTrue(result['schedule']['enabled'])
self.assertEqual(result['apps'][1]['name'], 'Renamed while checking')
self.assertTrue(result['apps'][0]['state']['update_available'])
def test_deleted_app_is_not_recreated_by_late_registry_answer(self):
result = self.during_query(lambda sidecar: sidecar['apps'].pop(0))
self.assertEqual([app['id'] for app in result['apps']], ['other'])
def test_edited_app_is_not_overwritten_by_late_registry_answer(self):
result = self.during_query(lambda sidecar: sidecar['apps'][0].update(name='Edited'))
self.assertEqual(result['apps'][0]['name'], 'Edited')
self.assertEqual(result['apps'][0]['state'], {})
def test_replaced_installation_cannot_receive_previous_images_answer(self):
def edit(sidecar): self.records[110]['installation_id'] = 'replacement'
result = self.during_query(edit)
self.assertEqual(result['apps'][0]['state'], {})
def test_checks_for_two_apps_in_same_container_keep_both_results(self):
sidecar = lxc_apps.load_sidecar(110)
sidecar['apps'][1]['installed_via'] = 'oci_image'
lxc_apps._write_sidecar(110, sidecar)
rendezvous = threading.Barrier(2, timeout=3)
def versions(vmid, **kwargs):
rendezvous.wait()
return self.result(vmid)
with patch.object(lxc_apps, '_oci_image_versions', side_effect=versions):
with ThreadPoolExecutor(max_workers=2) as pool:
futures = [pool.submit(lxc_apps.check_app, 110, app, force=True, notify=False) for app in ('oci', 'other')]
for future in futures: future.result()
result = lxc_apps.load_sidecar(110)
self.assertTrue(all(app['state']['update_available'] for app in result['apps']))
def test_native_registry_failure_is_persisted_and_retried_without_blocking_others(self):
def versions(vmid, **kwargs):
return {'error': 'registry unreachable', 'registry_retry': True} if vmid == 110 else self.result(vmid)
with patch.object(lxc_apps, '_oci_image_versions', side_effect=versions), \
patch.object(lxc_apps, '_retry_oci_registry') as retry:
with ThreadPoolExecutor(max_workers=4) as pool:
results = list(pool.map(lambda vmid: lxc_apps.check_app(vmid, 'oci', force=True, notify=False), self.records))
self.assertEqual(results[0]['apps'][0]['state']['error'], 'registry unreachable')
self.assertTrue(all(result['apps'][0]['state']['update_available'] for result in results[1:]))
retry.assert_called_once_with(110, 'oci')
if __name__ == '__main__': unittest.main()
+33
View File
@@ -166,6 +166,39 @@ class PBSManagerTests(unittest.TestCase):
self.assertEqual(result["datastores"][0]["used"], 30)
self.assertEqual(set(result["services"]), set(pbs.PBS_SERVICES))
def test_pbs_installed_next_to_pve_is_read_locally_with_its_own_version(self):
def run(command, **kwargs):
if command[0] == "dpkg-query":
return SimpleNamespace(stdout="4.2.8-1", returncode=0)
return SimpleNamespace(stdout="[]", returncode=0)
with mock.patch.object(pbs, "get_platform", return_value={"product": "pve", "version": "9.1.4", "hostname": "rimegra"}), \
mock.patch.object(pbs.shutil, "which", return_value="/usr/sbin/proxmox-backup-manager"), \
mock.patch.object(pbs.Path, "exists", return_value=True), \
mock.patch.object(pbs, "_local_services", return_value={key: "active" for key in pbs.PBS_SERVICES}), \
mock.patch.object(pbs.subprocess, "run", side_effect=run):
self.assertTrue(pbs.local_available())
result = pbs.get_status("local")
self.assertEqual((result["name"], result["version"]), ("rimegra", "4.2.8-1"))
with mock.patch.object(pbs, "get_platform", return_value={"product": "pve"}), \
mock.patch.object(pbs.shutil, "which", return_value=None):
self.assertFalse(pbs.local_available())
def test_storage_pbs_without_a_connection_is_offered_with_its_address_and_fingerprint(self):
import monitor_links
storage = self.root / "storage.cfg"
storage.write_text("pbs: Local\n\tserver localhost\n\tdatastore a\n\n"
"pbs: Lab\n\tserver 10.0.0.49\n\tdatastore b\n\tfingerprint AA:BB\n\n"
"pbs: Known\n\tserver pbs.test\n\tdatastore c\n\tport 8007\n")
with mock.patch.object(monitor_links, "STORAGE_CFG", storage), \
mock.patch.object(monitor_links, "_own_addresses", return_value=set()), \
mock.patch.object(pbs, "is_pbs_host", return_value=False), \
mock.patch.object(pbs, "local_available", return_value=False), \
mock.patch.object(pbs, "_remote_get", return_value=[]):
pbs.save_server(self.server)
response = self.client.get("/api/pbs/servers", headers=self.headers)
self.assertEqual(response.json["detected"],
[{"name": "Lab", "url": "https://10.0.0.49:8007", "fingerprint": "AA:BB"}])
def test_connection_api_requires_full_admin_and_does_not_return_secret(self):
self.assertEqual(self.client.get("/api/pbs/servers").status_code, 401)
with mock.patch.object(pbs, "_remote_get", return_value=[]):