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
+2
View File
@@ -14,6 +14,7 @@ import tempfile
PYTHON_FILES = (
"AppImage/scripts/tests/test_local_app_icons.py",
"AppImage/scripts/tests/test_docker_app_discovery.py",
"AppImage/scripts/tests/test_native_app_discovery.py",
"tests/test_audit_presentation.py",
"tests/test_audit_safety_wording.py",
@@ -27,6 +28,7 @@ PYTHON_FILES = (
)
NODE_FILES = (
"tests/lxc_updates/test_local_app_icons_ui.cjs",
"tests/lxc_updates/test_docker_app_discovery_ui.cjs",
"tests/lxc_updates/test_native_app_discovery_ui.cjs",
"tests/lxc_updates/test_docker_delegated_ui.cjs",
"tests/test_audit_diagnostic_document.cjs",
+23
View File
@@ -4,11 +4,19 @@ on:
pull_request:
paths:
- 'oci/**'
- 'AppImage/scripts/oci_menu_bridge.py'
- 'AppImage/scripts/lxc_apps.py'
- 'AppImage/scripts/tests/test_oci_parallel_checks.py'
- 'AppImage/scripts/flask_server.py'
- '.github/workflows/test-oci-registries.yml'
push:
branches: [main, develop]
paths:
- 'oci/**'
- 'AppImage/scripts/oci_menu_bridge.py'
- 'AppImage/scripts/lxc_apps.py'
- 'AppImage/scripts/tests/test_oci_parallel_checks.py'
- 'AppImage/scripts/flask_server.py'
- '.github/workflows/test-oci-registries.yml'
workflow_dispatch:
@@ -54,6 +62,13 @@ jobs:
env:
PYTHONDONTWRITEBYTECODE: '1'
run: python3 -m unittest discover -s oci/tests -p 'test_registry_*.py' -v
- name: Run OCI menu update checks and selection regressions
env:
PYTHONDONTWRITEBYTECODE: '1'
run: |
python3 -m unittest discover -s oci/tests -p 'test_update*.py' -v
python3 -m unittest discover -s AppImage/scripts/tests -p 'test_oci*.py' -v
python3 -m unittest discover -s oci/tests -p test_recreate_registry_digest.py -v
- name: Run OCI Modify latency regressions
env:
PYTHONDONTWRITEBYTECODE: '1'
@@ -80,3 +95,11 @@ jobs:
python3 -m unittest discover -s oci/tests -p 'test_instance_reconcile.py' -v
python3 -m unittest discover -s oci/tests -p 'test_guessed_endpoint_healthcheck.py' -v
python3 -m unittest discover -s oci/tests -p 'test_runtime_home_*.py' -v
- name: Run managed volume startup regressions
env:
PYTHONDONTWRITEBYTECODE: '1'
run: python3 -m unittest discover -s oci/tests -p 'test_managed_volume_*.py' -v
- name: Run catalog web-link regressions
env:
PYTHONDONTWRITEBYTECODE: '1'
run: python3 -m unittest discover -s oci/tests -p test_catalog_web_links.py -v
@@ -14,6 +14,8 @@ on:
- 'AppImage/scripts/tests/test_health_persistence_recurrence.py'
- 'AppImage/scripts/tests/test_native_app_discovery.py'
- 'AppImage/scripts/tests/test_local_app_icons.py'
- 'AppImage/scripts/tests/test_docker_app_discovery.py'
- 'AppImage/scripts/tests/fixtures/docker_discovery_inspect.json'
- 'AppImage/app/**'
- 'AppImage/components/**'
- 'AppImage/hooks/**'
@@ -36,6 +38,8 @@ on:
- 'AppImage/scripts/tests/test_health_persistence_recurrence.py'
- 'AppImage/scripts/tests/test_native_app_discovery.py'
- 'AppImage/scripts/tests/test_local_app_icons.py'
- 'AppImage/scripts/tests/test_docker_app_discovery.py'
- 'AppImage/scripts/tests/fixtures/docker_discovery_inspect.json'
- 'AppImage/app/**'
- 'AppImage/components/**'
- 'AppImage/hooks/**'
+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=[]):
+11
View File
@@ -389,6 +389,7 @@
"An Industrial-Level Controllable and Efficient Zero-Shot Text-To-Speech System": "Ein industriell steuerbares und effizientes Zero-Shot-Text-to-Speech-System",
"An Intel dedicated GPU has been detected without FLR support": "Es wurde eine dedizierte Intel-GPU ohne FLR-Unterstützung erkannt",
"An accelerated video generation framework that speeds up end-to-end diffusion while preserving video quality": "Ein beschleunigtes Videogenerierungs-Framework, das die End-to-End-Diffusion beschleunigt und gleichzeitig die Videoqualität bewahrt",
"An application changed since the update check. Check for updates again.": "Eine Anwendung hat sich seit der letzten Prüfung geändert. Suche erneut nach Updates.",
"An executable required by the adapter is missing in the new image": "Ein ausführbares required durch den Adapter fehlt im neuen Image",
"An fstab entry already exists for:": "Es existiert bereits ein fstab-Eintrag für:",
"An image probe container was started externally": "Ein Bildsondenbehälter wurde extern gestartet",
@@ -818,6 +819,7 @@
"Check access to an image": "Überprüfen Sie den Zugriff auf ein Bild",
"Check connectivity with another host": "Überprüfen Sie die Konnektivität mit einem anderen Host",
"Check container mount points": "Überprüfen Sie die Container-Mount-Punkte",
"Check for updates": "Nach Updates suchen",
"Check if container is unprivileged:": "Überprüfen Sie, ob der Container nicht privilegiert ist:",
"Check kernel params (IOMMU flags)": "Überprüfen Sie die Kernel-Parameter (IOMMU-Flags).",
"Check loaded GPU/TPU modules": "Überprüfen Sie die geladenen GPU/TPU-Module",
@@ -838,6 +840,7 @@
"Checking ZFS autotrim configuration...": "ZFS-Autotrim-Konfiguration wird überprüft...",
"Checking and repairing old LVM PV headers (if needed)...": "Überprüfung und Reparatur alter LVM-PV-Header (falls erforderlich) ...",
"Checking conflicting drivers blacklist...": "Blacklist mit in Konflikt stehenden Treibern wird überprüft...",
"Checking for updates ({completed}/{total})": "Suche nach Updates ({completed}/{total})",
"Checking for updates...": "Suche nach Updates...",
"Checking free space in /var/cache/apt/archives...": "Überprüfen Sie den freien Speicherplatz in /var/cache/apt/archives...",
"Checking if system disk is SSD or M.2...": "Überprüfen, ob es sich bei der Systemfestplatte um eine SSD oder M.2 handelt ...",
@@ -4812,6 +4815,7 @@
"Press Enter to return...": "Drücken Sie die Eingabetaste, um zurückzukehren...",
"Press Enter when the line has been pasted on the server...": "Drücken Sie die Eingabetaste, wenn die Zeile auf dem Server eingefügt wurde ...",
"Press OK to see the preview, then confirm": "Drücken Sie OK, um die Vorschau anzuzeigen, und bestätigen Sie dann",
"Press Space to select applications to update. Select all includes every application in this list.": "Wähle mit der Leertaste die zu aktualisierenden Anwendungen aus. Alle auswählen umfasst sämtliche Anwendungen in dieser Liste.",
"Press any key to continue...": "Drücken Sie eine beliebige Taste, um fortzufahren...",
"Pretty uptime format": "Hübsches Uptime-Format",
"Preview Backup": "Vorschau des Backups",
@@ -5362,6 +5366,7 @@
"Reverting pigz wrapper...": "Pigz-Wrapper wird zurückgesetzt...",
"Reverting vzdump speed tuning...": "vzdump-Geschwindigkeitsoptimierung wird zurückgesetzt...",
"Review and apply": "Prüfen und anwenden",
"Review and update these applications?": "Auswahl prüfen und diese Anwendungen aktualisieren?",
"Review external OCI changes": "Überprüfen Sie externe OCI-Änderungen",
"Review package upgrade": "Paketaktualisierung prüfen",
"Review passthrough config files": "Überprüfen Sie die Passthrough-Konfigurationsdateien",
@@ -5613,6 +5618,7 @@
"Select a share to delete:": "Wählen Sie eine Freigabe zum Löschen aus:",
"Select a specific Coral or USB node, not the whole /dev": "Wählen Sie einen bestimmten Coral- oder USB-Knoten, nicht den gesamten /dev",
"Select access mode": "Wählen Sie den Zugriffsmodus",
"Select all": "Alle auswählen",
"Select an existing group": "Wählen Sie eine vorhandene Gruppe aus",
"Select an existing group:": "Wählen Sie eine vorhandene Gruppe aus:",
"Select an export to delete:": "Wählen Sie einen Export zum Löschen aus:",
@@ -5934,6 +5940,7 @@
"Snippets — hook scripts / config": "Snippets – Hook-Skripte/Konfiguration",
"SoC-integrated GPU: tight coupling with other SoC components": "SoC-integrierte GPU: enge Kopplung mit anderen SoC-Komponenten",
"Some DKMS removals reported errors; final verification will determine the result.": "Bei einigen DKMS-Entfernungen wurden Fehler gemeldet. Über das Ergebnis entscheidet die abschließende Prüfung.",
"Some applications could not be checked ({count}). Try again later.": "Einige Anwendungen konnten nicht geprüft werden ({count}). Versuchen Sie es später erneut.",
"Some changes require a reboot to take effect. Do you want to restart now?": "Einige Änderungen erfordern einen Neustart, damit sie wirksam werden. Möchten Sie jetzt neu starten?",
"Some essential Proxmox packages may not have been installed": "Einige wichtige Proxmox-Pakete wurden möglicherweise nicht installiert",
"Some log2ram files may still exist. Manual cleanup may be required.": "Möglicherweise sind noch einige Log2RAM-Dateien vorhanden. Möglicherweise ist eine manuelle Bereinigung erforderlich.",
@@ -6293,6 +6300,7 @@
"The Paperless persistent volumes need at least 8 GB": "Die papierlosen persistenten Volumes benötigen mindestens 8 GB",
"The PostgreSQL volume needs at least 4 GB": "Das PostgreSQL-Volume benötigt mindestens 4 GB",
"The PostgreSQL volume needs at least 8 GB": "Das PostgreSQL-Volume benötigt mindestens 8 GB",
"The ProxMenux Monitor update checker is not available. Start or update ProxMenux Monitor and try again.": "Die Update-Prüfung von ProxMenux Monitor ist nicht verfügbar. Starte oder aktualisiere ProxMenux Monitor und versuche es erneut.",
"The Proxmox VE 8 update did not finish. The upgrade to Proxmox VE 9 was not started.": "Die Aktualisierung von Proxmox VE 8 wurde nicht abgeschlossen. Das Upgrade auf Proxmox VE 9 wurde nicht gestartet.",
"The Proxmox archive keyring is missing; Ceph installation cannot continue safely": "Der Archivschlüsselbund Proxmox fehlt;Die Ceph-Installation kann nicht sicher fortgesetzt werden",
"The Proxmox inventory and the local configurations differ": "Das Proxmox-Inventar und die lokalen Konfigurationen unterscheiden sich",
@@ -6787,6 +6795,7 @@
"The tmpfs mounts of the container differ from the saved record": "Die tmpfs Halterungen des Containers unterscheiden sich vom gespeicherten Datensatz",
"The tmpfs path or size is outside the supported profile": "Der Pfad oder die Größe von tmpfs befindet sich außerhalb des unterstützten Profils",
"The translated recipe changed during the preparation": "Das übersetzte Rezept änderte sich während der Vorbereitung",
"The update check could not be completed. Try again later.": "Die Update-Prüfung konnte nicht abgeschlossen werden. Versuchen Sie es später erneut.",
"The value contains an unsupported character": "Der Wert enthält ein nicht unterstütztes Zeichen",
"The value must be a number:": "Der Wert muss eine Zahl sein:",
"The values do not match. Enter them again.": "Die Werte stimmen nicht überein. Betreten Sie sie wieder.",
@@ -7208,6 +7217,7 @@
"Update now?": "Update jetzt?",
"Update package index:": "Paketindex aktualisieren:",
"Update prepared": "Update vorbereitet",
"Update selected OCI applications": "Ausgewählte OCI-Anwendungen aktualisieren",
"Update system to latest PVE 8.4+ (if not done already):": "Aktualisieren Sie das System auf die neueste PVE 8.4+ (falls noch nicht geschehen):",
"Update the image with the saved configuration": "Aktualisieren Sie das Bild mit der gespeicherten Konfiguration",
"Update the whole stack?": "Aktualisieren Sie den gesamten Stack?",
@@ -7219,6 +7229,7 @@
"Updates and Packages Commands": "Befehle für Updates und Pakete",
"Updates are not available yet for this application in this beta": "Updates sind für diese Anwendung in dieser Beta noch nicht verfügbar",
"Updates are not available yet in this beta for applications that use a privileged container or advanced LXC settings": "Updates sind in dieser Beta noch nicht verfügbar für Anwendungen, die einen privilegierten Container oder erweiterte LXC-Einstellungen verwenden",
"Updates available": "Updates verfügbar",
"Updates file is empty or unreadable.": "Die Aktualisierungsdatei ist leer oder nicht lesbar.",
"Updating": "Aktualisierung",
"Updating APT package lists...": "APT-Paketlisten werden aktualisiert...",
+11
View File
@@ -385,6 +385,7 @@
"An Industrial-Level Controllable and Efficient Zero-Shot Text-To-Speech System": "Un sistema de texto a voz zero-shot de nivel industrial, controlable y eficiente",
"An Intel dedicated GPU has been detected without FLR support": "Se ha detectado una GPU dedicada de Intel sin soporte FLR",
"An accelerated video generation framework that speeds up end-to-end diffusion while preserving video quality": "Un framework de generación de vídeo que acelera la difusión de extremo a extremo conservando la calidad del vídeo",
"An application changed since the update check. Check for updates again.": "Una aplicación ha cambiado desde la última comprobación. Vuelve a buscar actualizaciones.",
"An executable required by the adapter is missing in the new image": "Un ejecutable necesario para el adaptador falta en la nueva imagen",
"An fstab entry already exists for:": "Ya existe una entrada fstab para:",
"An image probe container was started externally": "Un contenedor de sonda de imagen se inició externamente",
@@ -818,6 +819,7 @@
"Check access to an image": "Comprobar el acceso a una imagen",
"Check connectivity with another host": "Verificar la conectividad con otro host",
"Check container mount points": "Comprobar los puntos de montaje del contenedor",
"Check for updates": "Buscar actualizaciones",
"Check if container is unprivileged:": "Comprobar si el contenedor no tiene privilegios:",
"Check kernel params (IOMMU flags)": "Comprobar los parámetros del kernel (opciones IOMMU)",
"Check loaded GPU/TPU modules": "Comprobar los módulos de GPU/TPU cargados",
@@ -838,6 +840,7 @@
"Checking ZFS autotrim configuration...": "Comprobando la configuración de recorte automático de ZFS...",
"Checking and repairing old LVM PV headers (if needed)...": "Comprobando y reparando cabeceras antiguas de PV de LVM (si es necesario)...",
"Checking conflicting drivers blacklist...": "Comprobando la lista negra de controladores en conflicto...",
"Checking for updates ({completed}/{total})": "Buscando actualizaciones ({completed}/{total})",
"Checking for updates...": "Buscando actualizaciones...",
"Checking free space in /var/cache/apt/archives...": "Comprobando espacio libre en /var/cache/apt/archives...",
"Checking if system disk is SSD or M.2...": "Comprobando si el disco del sistema es SSD o M.2...",
@@ -4803,6 +4806,7 @@
"Press Enter to return...": "Pulsa Intro para volver...",
"Press Enter when the line has been pasted on the server...": "Pulsa Intro cuando la línea se haya pegado en el servidor...",
"Press OK to see the preview, then confirm": "Pulsa OK para ver la vista previa, luego confirma",
"Press Space to select applications to update. Select all includes every application in this list.": "Pulsa Espacio para seleccionar las aplicaciones que quieres actualizar. Seleccionar todas incluye todas las aplicaciones de esta lista.",
"Press any key to continue...": "Pulsa cualquier tecla para continuar...",
"Pretty uptime format": "Formato bastante tiempo de actividad",
"Preview Backup": "Vista previa de backup",
@@ -5346,6 +5350,7 @@
"Reverting pigz wrapper...": "Revertir la envoltura de pigz...",
"Reverting vzdump speed tuning...": "Revertir el ajuste de velocidad de vzdump...",
"Review and apply": "Revisar y aplicar",
"Review and update these applications?": "¿Quieres actualizar estas aplicaciones tras revisar la selección?",
"Review external OCI changes": "Revisar cambios externos de OCI",
"Review package upgrade": "Revisar la actualización de paquetes",
"Review passthrough config files": "Revisar los archivos de configuración de passthrough",
@@ -5597,6 +5602,7 @@
"Select a share to delete:": "Selecciona un recurso compartido para eliminar:",
"Select a specific Coral or USB node, not the whole /dev": "Selecciona un Coral específico o nodo USB, no todo /dev",
"Select access mode": "Seleccionar modo de acceso",
"Select all": "Seleccionar todas",
"Select an existing group": "Seleccionar un grupo existente",
"Select an existing group:": "Selecciona un grupo existente:",
"Select an export to delete:": "Selecciona una exportación para eliminar:",
@@ -5919,6 +5925,7 @@
"Snippets — hook scripts / config": "Snippets — hook scripts / config",
"SoC-integrated GPU: tight coupling with other SoC components": "GPU integrada en SoC: estrecho acoplamiento con otros componentes de SoC",
"Some DKMS removals reported errors; final verification will determine the result.": "Algunas eliminaciones de DKMS han devuelto errores; la verificación final determinará el resultado.",
"Some applications could not be checked ({count}). Try again later.": "No se han podido comprobar algunas aplicaciones ({count}). Vuelve a intentarlo más tarde.",
"Some changes require a reboot to take effect. Do you want to restart now?": "Algunos cambios requieren un reinicio para que surtan efecto. ¿Quieres reiniciar ahora?",
"Some essential Proxmox packages may not have been installed": "Es posible que algunos paquetes esenciales de Proxmox no se hayan instalado",
"Some log2ram files may still exist. Manual cleanup may be required.": "Es posible que aún existan algunos archivos log2ram. Es posible que se requiera una limpieza manual.",
@@ -6278,6 +6285,7 @@
"The Paperless persistent volumes need at least 8 GB": "Los volúmenes persistentes de Paperless necesitan al menos 8 GB",
"The PostgreSQL volume needs at least 4 GB": "El volumen de PostgreSQL necesita al menos 4 GB",
"The PostgreSQL volume needs at least 8 GB": "El volumen de PostgreSQL necesita al menos 8 GB",
"The ProxMenux Monitor update checker is not available. Start or update ProxMenux Monitor and try again.": "El comprobador de actualizaciones de ProxMenux Monitor no está disponible. Inicia o actualiza ProxMenux Monitor e inténtalo de nuevo.",
"The Proxmox VE 8 update did not finish. The upgrade to Proxmox VE 9 was not started.": "La actualización de Proxmox VE 8 no ha terminado. No se ha iniciado la actualización a Proxmox VE 9.",
"The Proxmox archive keyring is missing; Ceph installation cannot continue safely": "Falta el conjunto de claves de archivo Proxmox; La instalación de Ceph no puede continuar de forma segura",
"The Proxmox inventory and the local configurations differ": "El inventario de Proxmox y las configuraciones locales difieren",
@@ -6763,6 +6771,7 @@
"The tmpfs mounts of the container differ from the saved record": "Los montajes tmpfs del contenedor difieren del registro guardado",
"The tmpfs path or size is outside the supported profile": "El camino o tamaño de tmpfs está fuera del perfil soportado",
"The translated recipe changed during the preparation": "La receta traducida cambió durante la preparación",
"The update check could not be completed. Try again later.": "La búsqueda de actualizaciones no ha podido terminar. Vuelve a intentarlo más tarde.",
"The value contains an unsupported character": "El valor contiene un carácter no admitido",
"The value must be a number:": "El valor debe ser un número:",
"The values do not match. Enter them again.": "Los valores no coinciden. Vuelve a introducirlos.",
@@ -7181,6 +7190,7 @@
"Update now?": "¿Actualizar ahora?",
"Update package index:": "Actualizar índice del paquete:",
"Update prepared": "Actualización preparada",
"Update selected OCI applications": "Actualizar las aplicaciones OCI seleccionadas",
"Update system to latest PVE 8.4+ (if not done already):": "Actualiza el sistema a la última versión de PVE 8.4+ (si aún no lo has hecho):",
"Update the image with the saved configuration": "Actualizar la imagen con la configuración guardada",
"Update the whole stack?": "¿Actualizar toda la pila?",
@@ -7192,6 +7202,7 @@
"Updates and Packages Commands": "Comandos de actualizaciones y paquetes",
"Updates are not available yet for this application in this beta": "Aún no hay actualizaciones para esta aplicación en esta beta",
"Updates are not available yet in this beta for applications that use a privileged container or advanced LXC settings": "Aún no se dispone de actualizaciones en esta beta para aplicaciones que utilizan un contenedor privilegiado o configuración avanzada LXC",
"Updates available": "Actualizaciones disponibles",
"Updates file is empty or unreadable.": "El archivo de actualizaciones está vacío o es ilegible.",
"Updating": "Actualizando",
"Updating APT package lists...": "Actualizando listas de paquetes APT...",
+11
View File
@@ -389,6 +389,7 @@
"An Industrial-Level Controllable and Efficient Zero-Shot Text-To-Speech System": "Un système de discussion texte-à-parler efficace et contrôlable au niveau industriel",
"An Intel dedicated GPU has been detected without FLR support": "Un GPU dédié Intel a été détecté sans prise en charge FLR",
"An accelerated video generation framework that speeds up end-to-end diffusion while preserving video quality": "Un cadre de production vidéo accéléré qui accélère la diffusion de bout en bout tout en préservant la qualité vidéo",
"An application changed since the update check. Check for updates again.": "Une application a changé depuis la dernière vérification. Relancez la recherche de mises à jour.",
"An executable required by the adapter is missing in the new image": "Une requi exécutable est manquante dans la nouvelle image",
"An fstab entry already exists for:": "Une entrée fstab existe déjà pour :",
"An image probe container was started externally": "Un conteneur de sonde d'image a été lancé à l'extérieur",
@@ -818,6 +819,7 @@
"Check access to an image": "Vérifier l'accès à une image",
"Check connectivity with another host": "Vérifiez la connectivité avec un autre hôte",
"Check container mount points": "Vérifier les points de montage du conteneur",
"Check for updates": "Rechercher des mises à jour",
"Check if container is unprivileged:": "Vérifiez si le conteneur n'est pas privilégié :",
"Check kernel params (IOMMU flags)": "Vérifiez les paramètres du noyau (drapeaux IOMMU)",
"Check loaded GPU/TPU modules": "Vérifiez les modules GPU/TPU chargés",
@@ -838,6 +840,7 @@
"Checking ZFS autotrim configuration...": "Vérification de la configuration du découpage automatique ZFS...",
"Checking and repairing old LVM PV headers (if needed)...": "Vérification et réparation des anciens collecteurs PV LVM (si nécessaire)...",
"Checking conflicting drivers blacklist...": "Vérification de la liste noire des pilotes en conflit...",
"Checking for updates ({completed}/{total})": "Recherche de mises à jour ({completed}/{total})",
"Checking for updates...": "Vérification des mises à jour...",
"Checking free space in /var/cache/apt/archives...": "Vérification de l'espace libre dans /var/cache/apt/archives...",
"Checking if system disk is SSD or M.2...": "Vérifier si le disque système est SSD ou M.2...",
@@ -4812,6 +4815,7 @@
"Press Enter to return...": "Appuyez sur Entrée pour revenir...",
"Press Enter when the line has been pasted on the server...": "Appuyez sur Entrée lorsque la ligne a été collée sur le serveur...",
"Press OK to see the preview, then confirm": "Appuyez sur OK pour voir l'aperçu, puis confirmez",
"Press Space to select applications to update. Select all includes every application in this list.": "Appuyez sur Espace pour sélectionner les applications à mettre à jour. Tout sélectionner inclut toutes les applications de cette liste.",
"Press any key to continue...": "Appuyez sur n'importe quelle touche pour continuer...",
"Pretty uptime format": "Joli format de disponibilité",
"Preview Backup": "Aperçu de la sauvegarde",
@@ -5362,6 +5366,7 @@
"Reverting pigz wrapper...": "Rétablissement du wrapper pigz...",
"Reverting vzdump speed tuning...": "Annulation du réglage de la vitesse de vzdump...",
"Review and apply": "Vérifier et appliquer",
"Review and update these applications?": "Vérifier la sélection et mettre à jour ces applications ?",
"Review external OCI changes": "Examiner les modifications externes de OCI",
"Review package upgrade": "Examiner la mise à niveau des paquets",
"Review passthrough config files": "Examiner les fichiers de configuration relais",
@@ -5613,6 +5618,7 @@
"Select a share to delete:": "Sélectionnez un partage à supprimer :",
"Select a specific Coral or USB node, not the whole /dev": "Sélectionnez un noeud spécifique Coral ou USB, pas l'ensemble /dev",
"Select access mode": "Sélectionnez le mode d'accès",
"Select all": "Tout sélectionner",
"Select an existing group": "Sélectionnez un groupe existant",
"Select an existing group:": "Sélectionnez un groupe existant :",
"Select an export to delete:": "Sélectionnez une exportation à supprimer :",
@@ -5934,6 +5940,7 @@
"Snippets — hook scripts / config": "Extraits - scripts de hook / configuration",
"SoC-integrated GPU: tight coupling with other SoC components": "GPU intégré au SoC : couplage étroit avec d'autres composants du SoC",
"Some DKMS removals reported errors; final verification will determine the result.": "Certaines suppressions de DKMS ont signalé des erreurs ;la vérification finale déterminera le résultat.",
"Some applications could not be checked ({count}). Try again later.": "Certaines applications n'ont pas pu être vérifiées ({count}). Réessayez plus tard.",
"Some changes require a reboot to take effect. Do you want to restart now?": "Certaines modifications nécessitent un redémarrage pour prendre effet. Voulez-vous redémarrer maintenant ?",
"Some essential Proxmox packages may not have been installed": "Certains packages Proxmox essentiels n'ont peut-être pas été installés",
"Some log2ram files may still exist. Manual cleanup may be required.": "Certains fichiers log2ram peuvent encore exister. Un nettoyage manuel peut être nécessaire.",
@@ -6292,6 +6299,7 @@
"The Paperless persistent volumes need at least 8 GB": "Les volumes persistants sans papier nécessitent au moins 8 Go",
"The PostgreSQL volume needs at least 4 GB": "Le volume PostgreSQL nécessite au moins 4 Go",
"The PostgreSQL volume needs at least 8 GB": "Le volume PostgreSQL nécessite au moins 8 Go",
"The ProxMenux Monitor update checker is not available. Start or update ProxMenux Monitor and try again.": "La vérification des mises à jour de ProxMenux Monitor est indisponible. Démarrez ou mettez à jour ProxMenux Monitor, puis réessayez.",
"The Proxmox VE 8 update did not finish. The upgrade to Proxmox VE 9 was not started.": "La mise à jour de Proxmox VE 8 n’a pas abouti. La mise à niveau vers Proxmox VE 9 n’a pas été lancée.",
"The Proxmox archive keyring is missing; Ceph installation cannot continue safely": "Le trousseau de clés d'archive Proxmox est manquant ;L'installation de Ceph ne peut pas continuer en toute sécurité",
"The Proxmox inventory and the local configurations differ": "L'inventaire Proxmox et les configurations locales diffèrent",
@@ -6786,6 +6794,7 @@
"The tmpfs mounts of the container differ from the saved record": "Les supports tmpfs du conteneur diffèrent de l'enregistrement enregistré",
"The tmpfs path or size is outside the supported profile": "Le chemin ou la taille de tmpfs est en dehors du profil pris en charge",
"The translated recipe changed during the preparation": "La recette traduite a changé pendant la préparation",
"The update check could not be completed. Try again later.": "La recherche de mises à jour n'a pas pu se terminer. Réessayez plus tard.",
"The value contains an unsupported character": "La valeur contient un caractère non pris en charge",
"The value must be a number:": "La valeur doit être un nombre :",
"The values do not match. Enter them again.": "Les valeurs ne correspondent pas. Entrez encore.",
@@ -7207,6 +7216,7 @@
"Update now?": "Mise à jour ?",
"Update package index:": "Index du package de mise à jour :",
"Update prepared": "Mise à jour préparée",
"Update selected OCI applications": "Mettre à jour les applications OCI sélectionnées",
"Update system to latest PVE 8.4+ (if not done already):": "Mettre à jour le système vers la dernière version PVE 8.4+ (si ce n'est déjà fait) :",
"Update the image with the saved configuration": "Mettre à jour l'image avec la configuration enregistrée",
"Update the whole stack?": "Mettre à jour toute la pile ?",
@@ -7218,6 +7228,7 @@
"Updates and Packages Commands": "Commandes de mises à jour et de packages",
"Updates are not available yet for this application in this beta": "Les mises à jour ne sont pas encore disponibles pour cette application dans cette bêta",
"Updates are not available yet in this beta for applications that use a privileged container or advanced LXC settings": "Les mises à jour ne sont pas encore disponibles dans cette bêta pour les applications qui utilisent un conteneur privilégié ou des paramètres avancés LXC",
"Updates available": "Mises à jour disponibles",
"Updates file is empty or unreadable.": "Le fichier de mises à jour est vide ou illisible.",
"Updating": "Mise à jour",
"Updating APT package lists...": "Mise à jour des listes de packages APT...",
+11
View File
@@ -389,6 +389,7 @@
"An Industrial-Level Controllable and Efficient Zero-Shot Text-To-Speech System": "Un sistema di sintesi vocale da testo zero-shot, controllabile ed efficiente, di livello industriale",
"An Intel dedicated GPU has been detected without FLR support": "È stata rilevata una GPU Intel dedicata senza supporto FLR",
"An accelerated video generation framework that speeds up end-to-end diffusion while preserving video quality": "Un framework per la generazione accelerata di video che velocizza l'intero processo di diffusione preservando la qualità video",
"An application changed since the update check. Check for updates again.": "Un’applicazione è cambiata dall’ultima verifica. Cerca di nuovo gli aggiornamenti.",
"An executable required by the adapter is missing in the new image": "Nella nuova immagine manca un eseguibile richiesto dall'adattatore",
"An fstab entry already exists for:": "Esiste già una voce fstab per:",
"An image probe container was started externally": "Un container usato per verificare l'immagine è stato avviato dall'esterno",
@@ -818,6 +819,7 @@
"Check access to an image": "Controlla l'accesso a un'immagine",
"Check connectivity with another host": "Controlla la connettività con un altro host",
"Check container mount points": "Controlla i punti di montaggio del container",
"Check for updates": "Cerca aggiornamenti",
"Check if container is unprivileged:": "Controlla se il container non è privilegiato:",
"Check kernel params (IOMMU flags)": "Controlla i parametri del kernel (flag IOMMU)",
"Check loaded GPU/TPU modules": "Controlla moduli GPU/TPU caricati",
@@ -838,6 +840,7 @@
"Checking ZFS autotrim configuration...": "Controllo della configurazione autotrim di ZFS...",
"Checking and repairing old LVM PV headers (if needed)...": "Controllo e riparazione delle vecchie intestazioni dei PV LVM (se necessario)...",
"Checking conflicting drivers blacklist...": "Controllo della lista nera dei driver in conflitto...",
"Checking for updates ({completed}/{total})": "Ricerca aggiornamenti ({completed}/{total})",
"Checking for updates...": "Controllo aggiornamenti...",
"Checking free space in /var/cache/apt/archives...": "Controllo dello spazio libero in /var/cache/apt/archives...",
"Checking if system disk is SSD or M.2...": "Verifica se il disco di sistema è SSD o M.2...",
@@ -4812,6 +4815,7 @@
"Press Enter to return...": "Premi Invio per tornare...",
"Press Enter when the line has been pasted on the server...": "Premi Invio quando la riga è stata incollata sul server...",
"Press OK to see the preview, then confirm": "Premi OK per vedere l’anteprima, quindi conferma",
"Press Space to select applications to update. Select all includes every application in this list.": "Premi Spazio per selezionare le applicazioni da aggiornare. Seleziona tutte include tutte le applicazioni in questo elenco.",
"Press any key to continue...": "Premi un tasto per continuare...",
"Pretty uptime format": "Formato leggibile del tempo di attività",
"Preview Backup": "Anteprima backup",
@@ -5362,6 +5366,7 @@
"Reverting pigz wrapper...": "Rimozione del wrapper pigz e ripristino della configurazione precedente...",
"Reverting vzdump speed tuning...": "Rimozione ottimizzazioni velocità vzdump…",
"Review and apply": "Verifica e applica",
"Review and update these applications?": "Vuoi rivedere la selezione e aggiornare queste applicazioni?",
"Review external OCI changes": "Esamina le modifiche OCI esterne",
"Review package upgrade": "Esamina l'aggiornamento dei pacchetti",
"Review passthrough config files": "Esamina i file di configurazione passthrough",
@@ -5613,6 +5618,7 @@
"Select a share to delete:": "Seleziona una condivisione da eliminare:",
"Select a specific Coral or USB node, not the whole /dev": "Seleziona un nodo Coral o USB specifico, non l’intera directory /dev",
"Select access mode": "Seleziona modalità di accesso",
"Select all": "Seleziona tutte",
"Select an existing group": "Seleziona un gruppo esistente",
"Select an existing group:": "Seleziona un gruppo esistente:",
"Select an export to delete:": "Seleziona un'esportazione da eliminare:",
@@ -5934,6 +5940,7 @@
"Snippets — hook scripts / config": "Snippet: script di hook/config",
"SoC-integrated GPU: tight coupling with other SoC components": "GPU integrata nel SoC: stretto accoppiamento con altri componenti SoC",
"Some DKMS removals reported errors; final verification will determine the result.": "Alcune rimozioni DKMS hanno riportato errori; la verifica finale determinerà il risultato.",
"Some applications could not be checked ({count}). Try again later.": "Non è stato possibile controllare alcune applicazioni ({count}). Riprova più tardi.",
"Some changes require a reboot to take effect. Do you want to restart now?": "Alcune modifiche richiedono un riavvio per avere effetto. Vuoi riavviare adesso?",
"Some essential Proxmox packages may not have been installed": "Alcuni pacchetti Proxmox essenziali potrebbero non essere stati installati",
"Some log2ram files may still exist. Manual cleanup may be required.": "Alcuni file log2ram potrebbero ancora esistere. Potrebbe essere necessaria la pulizia manuale.",
@@ -6293,6 +6300,7 @@
"The Paperless persistent volumes need at least 8 GB": "I volumi persistenti di Paperless richiedono almeno 8 GB",
"The PostgreSQL volume needs at least 4 GB": "Il volume PostgreSQL richiede almeno 4 GB",
"The PostgreSQL volume needs at least 8 GB": "Il volume PostgreSQL richiede almeno 8 GB",
"The ProxMenux Monitor update checker is not available. Start or update ProxMenux Monitor and try again.": "Il controllo degli aggiornamenti di ProxMenux Monitor non è disponibile. Avvia o aggiorna ProxMenux Monitor e riprova.",
"The Proxmox VE 8 update did not finish. The upgrade to Proxmox VE 9 was not started.": "L’aggiornamento di Proxmox VE 8 non è stato completato. L’aggiornamento a Proxmox VE 9 non è stato avviato.",
"The Proxmox archive keyring is missing; Ceph installation cannot continue safely": "Manca il portachiavi dell’archivio Proxmox; l’installazione di Ceph non può continuare in sicurezza",
"The Proxmox inventory and the local configurations differ": "L'inventario Proxmox e le configurazioni locali differiscono",
@@ -6787,6 +6795,7 @@
"The tmpfs mounts of the container differ from the saved record": "I mount tmpfs del container differiscono dal record salvato",
"The tmpfs path or size is outside the supported profile": "Il percorso o la dimensione di tmpfs non rientra nel profilo supportato",
"The translated recipe changed during the preparation": "La ricetta tradotta è cambiata durante la preparazione",
"The update check could not be completed. Try again later.": "La ricerca degli aggiornamenti non è stata completata. Riprova più tardi.",
"The value contains an unsupported character": "Il valore contiene un carattere non supportato",
"The value must be a number:": "Il valore deve essere un numero:",
"The values do not match. Enter them again.": "I valori non corrispondono. Inserirli di nuovo.",
@@ -7208,6 +7217,7 @@
"Update now?": "Aggiornare ora?",
"Update package index:": "Aggiornare l’indice pacchetti:",
"Update prepared": "Aggiornamento preparato",
"Update selected OCI applications": "Aggiorna le applicazioni OCI selezionate",
"Update system to latest PVE 8.4+ (if not done already):": "Aggiornare il sistema all’ultima versione PVE 8.4+ (se non già fatto):",
"Update the image with the saved configuration": "Aggiorna l’immagine mantenendo la configurazione salvata",
"Update the whole stack?": "Aggiornare l’intero stack?",
@@ -7219,6 +7229,7 @@
"Updates and Packages Commands": "Comandi aggiornamenti e pacchetti",
"Updates are not available yet for this application in this beta": "Gli aggiornamenti per questa applicazione non sono ancora disponibili in questa beta",
"Updates are not available yet in this beta for applications that use a privileged container or advanced LXC settings": "Gli aggiornamenti non sono ancora disponibili in questa versione beta per applicazioni che usano un container privilegiato o impostazioni LXC avanzate",
"Updates available": "Aggiornamenti disponibili",
"Updates file is empty or unreadable.": "Il file degli aggiornamenti è vuoto o illeggibile.",
"Updating": "Aggiornamento",
"Updating APT package lists...": "Aggiornamento elenchi pacchetti APT...",
+11
View File
@@ -389,6 +389,7 @@
"An Industrial-Level Controllable and Efficient Zero-Shot Text-To-Speech System": "Um Sistema de Texto-A-Shot Controlável e Eficiente de Nível Industrial",
"An Intel dedicated GPU has been detected without FLR support": "Uma GPU dedicada Intel foi detectada sem suporte FLR",
"An accelerated video generation framework that speeds up end-to-end diffusion while preserving video quality": "Um framework acelerado de geração de vídeo que acelera a difusão de ponta a ponta, preservando a qualidade de vídeo",
"An application changed since the update check. Check for updates again.": "Uma aplicação foi alterada desde a última verificação. Procure atualizações novamente.",
"An executable required by the adapter is missing in the new image": "Falta um executável required pelo adaptador na nova imagem",
"An fstab entry already exists for:": "Já existe uma entrada fstab para:",
"An image probe container was started externally": "Foi iniciado um recipiente de sonda de imagem externamente",
@@ -818,6 +819,7 @@
"Check access to an image": "Verifique o acesso a uma imagem",
"Check connectivity with another host": "Verifique a conectividade com outro host",
"Check container mount points": "Verifique os pontos de montagem do contêiner",
"Check for updates": "Procurar atualizações",
"Check if container is unprivileged:": "Verifique se o contêiner não tem privilégios:",
"Check kernel params (IOMMU flags)": "Verifique os parâmetros do kernel (sinalizadores IOMMU)",
"Check loaded GPU/TPU modules": "Verifique os módulos GPU/TPU carregados",
@@ -838,6 +840,7 @@
"Checking ZFS autotrim configuration...": "Verificando a configuração do ajuste automático do ZFS...",
"Checking and repairing old LVM PV headers (if needed)...": "Verificando e reparando coletores LVM PV antigos (se necessário)...",
"Checking conflicting drivers blacklist...": "Verificando a lista negra de drivers conflitantes...",
"Checking for updates ({completed}/{total})": "A procurar atualizações ({completed}/{total})",
"Checking for updates...": "Verificando atualizações...",
"Checking free space in /var/cache/apt/archives...": "Verificando espaço livre em /var/cache/apt/archives...",
"Checking if system disk is SSD or M.2...": "Verificando se o disco do sistema é SSD ou M.2...",
@@ -4812,6 +4815,7 @@
"Press Enter to return...": "Pressione Enter para retornar...",
"Press Enter when the line has been pasted on the server...": "Pressione Enter quando a linha for colada no servidor...",
"Press OK to see the preview, then confirm": "Pressione OK para ver a visualização e confirme",
"Press Space to select applications to update. Select all includes every application in this list.": "Prima Espaço para selecionar as aplicações a atualizar. Selecionar todas inclui todas as aplicações desta lista.",
"Press any key to continue...": "Pressione qualquer tecla para continuar...",
"Pretty uptime format": "Formato de tempo de atividade bonito",
"Preview Backup": "Visualizar backup",
@@ -5362,6 +5366,7 @@
"Reverting pigz wrapper...": "Revertendo o wrapper pigz...",
"Reverting vzdump speed tuning...": "Revertendo o ajuste de velocidade do vzdump...",
"Review and apply": "Rever e aplicar",
"Review and update these applications?": "Rever a seleção e atualizar estas aplicações?",
"Review external OCI changes": "revisar alterações externas do OCI",
"Review package upgrade": "Rever a atualização de pacotes",
"Review passthrough config files": "Revise os arquivos de configuração de passagem",
@@ -5613,6 +5618,7 @@
"Select a share to delete:": "Selecione um compartilhamento para excluir:",
"Select a specific Coral or USB node, not the whole /dev": "Selecione um nó Coral ou USB específico, não o /dev inteiro",
"Select access mode": "Selecione o modo de acesso",
"Select all": "Selecionar todas",
"Select an existing group": "Selecione um grupo existente",
"Select an existing group:": "Selecione um grupo existente:",
"Select an export to delete:": "Selecione uma exportação para excluir:",
@@ -5934,6 +5940,7 @@
"Snippets — hook scripts / config": "Snippets – scripts/configuração de gancho",
"SoC-integrated GPU: tight coupling with other SoC components": "GPU integrada ao SoC: forte acoplamento com outros componentes do SoC",
"Some DKMS removals reported errors; final verification will determine the result.": "Algumas remoções de DKMS relataram erros;a verificação final determinará o resultado.",
"Some applications could not be checked ({count}). Try again later.": "Não foi possível verificar algumas aplicações ({count}). Tente novamente mais tarde.",
"Some changes require a reboot to take effect. Do you want to restart now?": "Algumas alterações exigem uma reinicialização para entrar em vigor. Quer reiniciar agora?",
"Some essential Proxmox packages may not have been installed": "Alguns pacotes essenciais do Proxmox podem não ter sido instalados",
"Some log2ram files may still exist. Manual cleanup may be required.": "Alguns arquivos log2ram ainda podem existir. A limpeza manual pode ser necessária.",
@@ -6293,6 +6300,7 @@
"The Paperless persistent volumes need at least 8 GB": "Os volumes persistentes sem papel precisam de pelo menos 8 GB",
"The PostgreSQL volume needs at least 4 GB": "O volume PostgreSQL precisa de pelo menos 4 GB",
"The PostgreSQL volume needs at least 8 GB": "O volume PostgreSQL precisa de pelo menos 8 GB",
"The ProxMenux Monitor update checker is not available. Start or update ProxMenux Monitor and try again.": "O verificador de atualizações do ProxMenux Monitor não está disponível. Inicie ou atualize o ProxMenux Monitor e tente novamente.",
"The Proxmox VE 8 update did not finish. The upgrade to Proxmox VE 9 was not started.": "A atualização do Proxmox VE 8 não terminou. A atualização para o Proxmox VE 9 não foi iniciada.",
"The Proxmox archive keyring is missing; Ceph installation cannot continue safely": "O chaveiro de arquivo Proxmox está faltando;A instalação do Ceph não pode continuar com segurança",
"The Proxmox inventory and the local configurations differ": "O inventário Proxmox e as configurações locais diferem",
@@ -6787,6 +6795,7 @@
"The tmpfs mounts of the container differ from the saved record": "As montagens tmpfs do recipiente diferem do registo gravado",
"The tmpfs path or size is outside the supported profile": "O caminho ou tamanho do tmpfs está fora do perfil suportado",
"The translated recipe changed during the preparation": "A receita traduzida mudou durante a preparação",
"The update check could not be completed. Try again later.": "A pesquisa de atualizações não pôde ser concluída. Tente novamente mais tarde.",
"The value contains an unsupported character": "O valor contém um caracter não suportado",
"The value must be a number:": "O valor deve ser um número:",
"The values do not match. Enter them again.": "Os valores não correspondem. Entra outra vez.",
@@ -7208,6 +7217,7 @@
"Update now?": "Actualizar agora?",
"Update package index:": "Atualizar índice do pacote:",
"Update prepared": "Actualização preparada",
"Update selected OCI applications": "Atualizar as aplicações OCI selecionadas",
"Update system to latest PVE 8.4+ (if not done already):": "Atualize o sistema para o PVE 8.4+ mais recente (se ainda não tiver feito):",
"Update the image with the saved configuration": "Atualizar a imagem com a configuração salva",
"Update the whole stack?": "Atualizar toda a pilha?",
@@ -7219,6 +7229,7 @@
"Updates and Packages Commands": "Comandos de atualizações e pacotes",
"Updates are not available yet for this application in this beta": "As atualizações ainda não estão disponíveis para esta aplicação neste beta",
"Updates are not available yet in this beta for applications that use a privileged container or advanced LXC settings": "Atualizações ainda não estão disponíveis neste beta para aplicações que usam um recipiente privilegiado ou configurações LXC avançadas",
"Updates available": "Atualizações disponíveis",
"Updates file is empty or unreadable.": "O arquivo de atualizações está vazio ou ilegível.",
"Updating": "Actualização",
"Updating APT package lists...": "Atualizando listas de pacotes APT...",
+11
View File
@@ -389,6 +389,7 @@
"An Industrial-Level Controllable and Efficient Zero-Shot Text-To-Speech System": "Priemyselná úroveň ovládateľná a efektívna zero-Shot Text-To-Speech System",
"An Intel dedicated GPU has been detected without FLR support": "Bola zistená dedikovaná Intel GPU bez podpory FLR",
"An accelerated video generation framework that speeds up end-to-end diffusion while preserving video quality": "Zrýchlený rámec tvorby videa, ktorý urýchľuje šírenie do konca a zároveň zachováva kvalitu videa",
"An application changed since the update check. Check for updates again.": "Od poslednej kontroly sa aplikácia zmenila. Znova vyhľadajte aktualizácie.",
"An executable required by the adapter is missing in the new image": "V novom obrázku chýba spustiteľný requi.",
"An fstab entry already exists for:": "Záznam vo fstab už existuje pre:",
"An image probe container was started externally": "Externe bola spustená obrazová sonda",
@@ -818,6 +819,7 @@
"Check access to an image": "Kontrola prístupu k obrázku",
"Check connectivity with another host": "Skontrolovať spojenie s iným hostom",
"Check container mount points": "Skontrolovať body pripojenia kontajnera",
"Check for updates": "Vyhľadať aktualizácie",
"Check if container is unprivileged:": "Skontrolovať, či je kontajner neprivilegovaný:",
"Check kernel params (IOMMU flags)": "Skontrolovať parametre kernelu (IOMMU voľby)",
"Check loaded GPU/TPU modules": "Skontrolovať načítané GPU/TPU moduly",
@@ -838,6 +840,7 @@
"Checking ZFS autotrim configuration...": "Kontrolujem nastavenie ZFS autotrim...",
"Checking and repairing old LVM PV headers (if needed)...": "Kontrolujem a opravujem staré LVM PV hlavičky (ak treba)...",
"Checking conflicting drivers blacklist...": "Kontrolujem blacklist konfliktných ovládačov...",
"Checking for updates ({completed}/{total})": "Vyhľadávanie aktualizácií ({completed}/{total})",
"Checking for updates...": "Kontrolujem aktualizácie...",
"Checking free space in /var/cache/apt/archives...": "Kontrolujem voľné miesto v /var/cache/apt/archives...",
"Checking if system disk is SSD or M.2...": "Kontrolujem, či je systémový disk SSD alebo M.2...",
@@ -4814,6 +4817,7 @@
"Press Enter to return...": "Stlačte Enter pre návrat...",
"Press Enter when the line has been pasted on the server...": "Po vložení riadka na server stlačte Enter...",
"Press OK to see the preview, then confirm": "Stlačte OK na zobrazenie náhľadu a potom akciu potvrďte",
"Press Space to select applications to update. Select all includes every application in this list.": "Medzerníkom vyberte aplikácie, ktoré chcete aktualizovať. Možnosť Vybrať všetky zahŕňa všetky aplikácie v tomto zozname.",
"Press any key to continue...": "Pokračujte stlačením ľubovoľného klávesu...",
"Pretty uptime format": "Prehľadný formát doby behu systému",
"Preview Backup": "Náhľad zálohy",
@@ -5364,6 +5368,7 @@
"Reverting pigz wrapper...": "Vraciam späť wrapper pigz...",
"Reverting vzdump speed tuning...": "Vraciam späť ladenie rýchlosti vzdump...",
"Review and apply": "Skontrolovať a použiť",
"Review and update these applications?": "Skontrolovať výber a aktualizovať tieto aplikácie?",
"Review external OCI changes": "Skontrolujte externé zmeny OCI",
"Review package upgrade": "Skontrolovať aktualizáciu balíkov",
"Review passthrough config files": "Skontrolovať konfiguračné súbory passthrough",
@@ -5615,6 +5620,7 @@
"Select a share to delete:": "Vyberte zdieľanie na vymazanie:",
"Select a specific Coral or USB node, not the whole /dev": "Vyberte špecifický Coral alebo USB uzol, nie celý /dev",
"Select access mode": "Vyberte režim prístupu",
"Select all": "Vybrať všetky",
"Select an existing group": "Vybrať existujúcu skupinu",
"Select an existing group:": "Vyberte existujúcu skupinu:",
"Select an export to delete:": "Vyberte export na vymazanie:",
@@ -5936,6 +5942,7 @@
"Snippets — hook scripts / config": "Snippets — hook skripty / konfigurácia",
"SoC-integrated GPU: tight coupling with other SoC components": "GPU integrovaná v SoC: úzke prepojenie s ďalšími súčasťami SoC",
"Some DKMS removals reported errors; final verification will determine the result.": "Niektoré odstránenia DKMS hlásili chyby;konečné overenie určí výsledok.",
"Some applications could not be checked ({count}). Try again later.": "Niektoré aplikácie sa nepodarilo skontrolovať ({count}). Skúste to znova neskôr.",
"Some changes require a reboot to take effect. Do you want to restart now?": "Niektoré zmeny sa prejavia až po reštarte. Chcete reštartovať teraz?",
"Some essential Proxmox packages may not have been installed": "Niektoré základné Proxmox balíky sa možno nenainštalovali",
"Some log2ram files may still exist. Manual cleanup may be required.": "Niektoré súbory log2ram môžu stále existovať. Možno bude potrebné ručné čistenie.",
@@ -6294,6 +6301,7 @@
"The Paperless persistent volumes need at least 8 GB": "Trvalé objemy bez dokumentov potrebujú najmenej 8 GB",
"The PostgreSQL volume needs at least 4 GB": "Objem PostgreSQL potrebuje najmenej 4 GB",
"The PostgreSQL volume needs at least 8 GB": "Objem PostgreSQL potrebuje najmenej 8 GB",
"The ProxMenux Monitor update checker is not available. Start or update ProxMenux Monitor and try again.": "Kontrola aktualizácií v ProxMenux Monitor nie je dostupná. Spustite alebo aktualizujte ProxMenux Monitor a skúste to znova.",
"The Proxmox VE 8 update did not finish. The upgrade to Proxmox VE 9 was not started.": "Aktualizácia Proxmox VE 8 sa nedokončila. Prechod na Proxmox VE 9 sa nespustil.",
"The Proxmox archive keyring is missing; Ceph installation cannot continue safely": "Chýba zväzok kľúčov archívu Proxmox;Inštalácia Ceph nemôže bezpečne pokračovať",
"The Proxmox inventory and the local configurations differ": "Súpis Proxmox a miestne konfigurácie sa líšia",
@@ -6788,6 +6796,7 @@
"The tmpfs mounts of the container differ from the saved record": "Tmpfs držiaky kontajnera sa líšia od uloženého záznamu",
"The tmpfs path or size is outside the supported profile": "Tmpfs cesta alebo veľkosť je mimo podporovaný profil",
"The translated recipe changed during the preparation": "Preložený recept sa počas prípravy zmenil",
"The update check could not be completed. Try again later.": "Kontrolu aktualizácií sa nepodarilo dokončiť. Skúste to znova neskôr.",
"The value contains an unsupported character": "Hodnota obsahuje nepodporovaný znak",
"The value must be a number:": "Hodnota musí byť číslo:",
"The values do not match. Enter them again.": "Hodnoty sa nezhodujú. Zadajte ich znova.",
@@ -7209,6 +7218,7 @@
"Update now?": "Aktualizácia?",
"Update package index:": "Aktualizujte index balíkov:",
"Update prepared": "Aktualizácia",
"Update selected OCI applications": "Aktualizovať vybrané aplikácie OCI",
"Update system to latest PVE 8.4+ (if not done already):": "Aktualizujte systém na najnovšie PVE 8.4+ (ak to ešte nie je hotové):",
"Update the image with the saved configuration": "Aktualizovať obrázok pomocou uloženej konfigurácie",
"Update the whole stack?": "Aktualizovať celú kopu?",
@@ -7220,6 +7230,7 @@
"Updates and Packages Commands": "Príkazy pre aktualizácie a balíky",
"Updates are not available yet for this application in this beta": "Aktualizácie zatiaľ nie sú k dispozícii pre túto aplikáciu v tejto beta",
"Updates are not available yet in this beta for applications that use a privileged container or advanced LXC settings": "Aktualizácie zatiaľ nie sú k dispozícii v tomto beta pre aplikácie, ktoré používajú privilegovaný kontajner alebo pokročilé nastavenia LXC",
"Updates available": "Dostupné aktualizácie",
"Updates file is empty or unreadable.": "Súbor s aktualizáciami je prázdny alebo sa nedá prečítať.",
"Updating": "Aktualizácia",
"Updating APT package lists...": "Aktualizujem zoznamy APT balíkov...",
+11
View File
@@ -389,6 +389,7 @@
"An Industrial-Level Controllable and Efficient Zero-Shot Text-To-Speech System": "En industriell nivå kontrollerbar och effektiv noll-shot text-till-talsystem",
"An Intel dedicated GPU has been detected without FLR support": "En dedikerad Intel GPU har upptäckts utan FLR-stöd",
"An accelerated video generation framework that speeds up end-to-end diffusion while preserving video quality": "En accelererad videogenereringsram som påskyndar end-to-end diffusion samtidigt som videokvaliteten bevaras",
"An application changed since the update check. Check for updates again.": "Ett program har ändrats sedan den senaste kontrollen. Sök efter uppdateringar igen.",
"An executable required by the adapter is missing in the new image": "En körbar required av adaptern saknas i den nya bilden",
"An fstab entry already exists for:": "En fstab-post finns redan för:",
"An image probe container was started externally": "En bildsond behållare startades externt",
@@ -818,6 +819,7 @@
"Check access to an image": "Kontrollera åtkomst till en bild",
"Check connectivity with another host": "Kontrollera anslutningen med en annan värd",
"Check container mount points": "Kontrollera behållarens monteringspunkter",
"Check for updates": "Sök efter uppdateringar",
"Check if container is unprivileged:": "Kontrollera om behållaren är oprivilegierad:",
"Check kernel params (IOMMU flags)": "Kontrollera kärnparametrar (IOMMU-flaggor)",
"Check loaded GPU/TPU modules": "Kontrollera laddade GPU/TPU-moduler",
@@ -838,6 +840,7 @@
"Checking ZFS autotrim configuration...": "Kontrollerar ZFS autotrim-konfiguration...",
"Checking and repairing old LVM PV headers (if needed)...": "Kontrollera och reparera gamla LVM PV-huvuden (om det behövs)...",
"Checking conflicting drivers blacklist...": "Kontrollerar svartlista för motstridiga drivrutiner...",
"Checking for updates ({completed}/{total})": "Söker efter uppdateringar ({completed}/{total})",
"Checking for updates...": "Söker efter uppdateringar...",
"Checking free space in /var/cache/apt/archives...": "Kontrollerar ledigt utrymme i /var/cache/apt/archives...",
"Checking if system disk is SSD or M.2...": "Kontrollerar om systemdisken är SSD eller M.2...",
@@ -4812,6 +4815,7 @@
"Press Enter to return...": "Tryck på Enter för att gå tillbaka...",
"Press Enter when the line has been pasted on the server...": "Tryck på Enter när raden har klistrats in på servern...",
"Press OK to see the preview, then confirm": "Tryck på OK för att se förhandsgranskningen och bekräfta sedan",
"Press Space to select applications to update. Select all includes every application in this list.": "Tryck på mellanslag för att välja vilka program som ska uppdateras. Välj alla omfattar alla program i listan.",
"Press any key to continue...": "Tryck på valfri tangent för att fortsätta...",
"Pretty uptime format": "Ganska upptidsformat",
"Preview Backup": "Förhandsgranska säkerhetskopiering",
@@ -5362,6 +5366,7 @@
"Reverting pigz wrapper...": "Återställer pigz-omslag...",
"Reverting vzdump speed tuning...": "Återställer vzdump-hastighetsinställning...",
"Review and apply": "Granska och tillämpa",
"Review and update these applications?": "Granska valet och uppdatera dessa program?",
"Review external OCI changes": "Granska externa OCI ändringar",
"Review package upgrade": "Granska paketuppgraderingen",
"Review passthrough config files": "Granska passthrough-konfigurationsfiler",
@@ -5613,6 +5618,7 @@
"Select a share to delete:": "Välj en delning att radera:",
"Select a specific Coral or USB node, not the whole /dev": "Välj en specifik Coral eller USB-nod, inte hela/dev",
"Select access mode": "Välj åtkomstläge",
"Select all": "Välj alla",
"Select an existing group": "Välj en befintlig grupp",
"Select an existing group:": "Välj en befintlig grupp:",
"Select an export to delete:": "Välj en export att radera:",
@@ -5934,6 +5940,7 @@
"Snippets — hook scripts / config": "Snippets — krokskript / config",
"SoC-integrated GPU: tight coupling with other SoC components": "SoC-integrerad GPU: tät koppling med andra SoC-komponenter",
"Some DKMS removals reported errors; final verification will determine the result.": "Vissa DKMS-borttagningar rapporterade fel;slutlig verifiering kommer att avgöra resultatet.",
"Some applications could not be checked ({count}). Try again later.": "Vissa program kunde inte kontrolleras ({count}). Försök igen senare.",
"Some changes require a reboot to take effect. Do you want to restart now?": "Vissa ändringar kräver en omstart för att träda i kraft. Vill du starta om nu?",
"Some essential Proxmox packages may not have been installed": "Vissa viktiga Proxmox-paket kanske inte har installerats",
"Some log2ram files may still exist. Manual cleanup may be required.": "Vissa log2ram-filer kan fortfarande existera. Manuell rengöring kan krävas.",
@@ -6293,6 +6300,7 @@
"The Paperless persistent volumes need at least 8 GB": "De papperslösa ihållande volymerna behöver minst 8 GB",
"The PostgreSQL volume needs at least 4 GB": "PostgreSQL-volymen behöver minst 4 GB",
"The PostgreSQL volume needs at least 8 GB": "PostgreSQL-volymen behöver minst 8 GB",
"The ProxMenux Monitor update checker is not available. Start or update ProxMenux Monitor and try again.": "Uppdateringskontrollen i ProxMenux Monitor är inte tillgänglig. Starta eller uppdatera ProxMenux Monitor och försök igen.",
"The Proxmox VE 8 update did not finish. The upgrade to Proxmox VE 9 was not started.": "Uppdateringen av Proxmox VE 8 slutfördes inte. Uppgraderingen till Proxmox VE 9 startades inte.",
"The Proxmox archive keyring is missing; Ceph installation cannot continue safely": "Proxmox arkivnyckelringen saknas;Ceph-installationen kan inte fortsätta på ett säkert sätt",
"The Proxmox inventory and the local configurations differ": "Proxmox-inventeringen och de lokala konfigurationerna skiljer sig åt",
@@ -6787,6 +6795,7 @@
"The tmpfs mounts of the container differ from the saved record": "Tmpfs montage av behållaren skiljer sig från den sparade rekordet",
"The tmpfs path or size is outside the supported profile": "Tmpfs-banan eller storleken ligger utanför den stödda profilen",
"The translated recipe changed during the preparation": "Det översatta receptet ändrades under förberedelsen",
"The update check could not be completed. Try again later.": "Uppdateringskontrollen kunde inte slutföras. Försök igen senare.",
"The value contains an unsupported character": "Värdet innehåller en ostödd karaktär",
"The value must be a number:": "Värdet måste vara ett tal:",
"The values do not match. Enter them again.": "Värdena matchar inte. Ange dem igen.",
@@ -7208,6 +7217,7 @@
"Update now?": "Uppdatera nu?",
"Update package index:": "Uppdatera paketindex:",
"Update prepared": "Uppdatering förberedd",
"Update selected OCI applications": "Uppdatera valda OCI-program",
"Update system to latest PVE 8.4+ (if not done already):": "Uppdatera systemet till senaste PVE 8.4+ (om det inte redan är gjort):",
"Update the image with the saved configuration": "Uppdatera bilden med sparad konfiguration",
"Update the whole stack?": "Uppdatera hela stacken?",
@@ -7219,6 +7229,7 @@
"Updates and Packages Commands": "Uppdateringar och paketkommandon",
"Updates are not available yet for this application in this beta": "Uppdateringar är inte tillgängliga ännu för denna ansökan i denna beta",
"Updates are not available yet in this beta for applications that use a privileged container or advanced LXC settings": "Uppdateringar finns ännu inte i denna beta för applikationer som använder en privilegierad behållare eller avancerad LXC-inställningar.",
"Updates available": "Uppdateringar tillgängliga",
"Updates file is empty or unreadable.": "Uppdateringsfilen är tom eller oläsbar.",
"Updating": "Uppdatering",
"Updating APT package lists...": "Uppdaterar APT-paketlistor...",
+44
View File
@@ -137,6 +137,50 @@ An application made of several images is one entry of the catalog and one instal
## Update, modify, recreate and remove
The OCI menu has a **Check for updates** entry. Checking changes that same
entry to **Checking for updates (completed/total)**, then **Updates available (N)** when
newer images are found. Progress counts applications, including coordinated
applications with several containers, and advances as each finishes. When finished, the entry shows only
**Updates available (N)** or **Check for updates**. Only registry metadata is read; no image layers are
downloaded and no containers are changed. The menu remains usable during the
check. Leaving the OCI menu closes its request; the Monitor may finish
metadata queries already in progress before stopping the remaining checks.
Open **Updates available (N)** to choose applications with Space. Nothing is
preselected, even when there is only one update. **Select all** at the top
includes every listed application. After reviewing the selection, each
application uses the existing update workflow, including its identity checks,
backup prompts and recovery. Cancelling or failing an update stops the batch.
The selection screen has only **Update** and **Cancel** buttons.
Opening the menu reads the Monitor's existing `/etc/proxmenux/apps` results
without contacting registries. Its daily checks and the manual menu check use
the same `lxc_apps.check_app` implementation, cache and registry credentials.
There is no second checker, private update-status cache or fallback checker.
Manual checks run in the Monitor process through a root-only local Unix
socket, preserving its locks and registry retries without changing web
authentication. ProxMenux Monitor must be installed, running and include the
menu integration for manual checks.
Manual checks run up to sixteen applications in parallel. The native checker
holds its shared cache lock only while reading or publishing a result; each
app has its own check lock. Before saving it re-reads the app and installation
record, discards answers for a concurrently edited/replaced app, and preserves
unrelated registrations and schedules. A fresh tag/index lookup that confirms
the same platform digest reuses its immutable version/build metadata. Changed
digests still fetch and verify the new metadata; installs and updates retain
the full image-contract verification path. Checks therefore still consult the
registry rather than returning a cached verdict as a new check.
Results older than 24 hours or for a different installed image or reference
are not offered as current. Network failures retain the Monitor's existing
result and retry state, or remain unknown when no previous result exists.
Coordinated applications count once and follow the Monitor's existing policy:
update availability is tracked on the primary container, while secondary
containers are updated together with it. The menu does not add independent
update tracking for secondary images. A reference pinned to an immutable
digest does not follow newer tags.
*Manage installed OCI applications* lists the registered installations. Before any action a container is compared with its record; one that no longer matches is neither modified nor deleted.
| Operation | What changes | What is kept |
+1 -1
View File
@@ -24,7 +24,7 @@
"amd64"
],
"launch": {
"scheme": "http",
"scheme": "https",
"port": 5800,
"path": "/"
},
+9 -1
View File
@@ -135,7 +135,15 @@
"original_compose": "---\nservices:\n prowlarr:\n image: lscr.io/linuxserver/prowlarr:latest\n container_name: prowlarr\n environment:\n - PUID=1000\n - PGID=1000\n - TZ=Etc/UTC\n volumes:\n - /path/to/prowlarr/data:/config\n ports:\n - 9696:9696\n restart: unless-stopped\n"
},
"first_run": {
"endpoints": [],
"endpoints": [
{
"label": "Web UI",
"scheme": "http",
"port": 9696,
"path": "/",
"source": "upstream-linuxserver-readme"
}
],
"credentials": []
},
"proxmox": {
+1 -1
View File
@@ -24,7 +24,7 @@
"amd64"
],
"launch": {
"scheme": "http",
"scheme": "https",
"port": 5800,
"path": "/"
},
+9 -1
View File
@@ -135,7 +135,15 @@
"original_compose": "---\nservices:\n prowlarr:\n image: lscr.io/linuxserver/prowlarr:latest\n container_name: prowlarr\n environment:\n - PUID=1000\n - PGID=1000\n - TZ=Etc/UTC\n volumes:\n - /path/to/prowlarr/data:/config\n ports:\n - 9696:9696\n restart: unless-stopped\n"
},
"first_run": {
"endpoints": [],
"endpoints": [
{
"label": "Web UI",
"scheme": "http",
"port": 9696,
"path": "/",
"source": "upstream-linuxserver-readme"
}
],
"credentials": []
},
"proxmox": {
+5
View File
@@ -2,6 +2,11 @@
"proxmox": {
"installer_profile": {
"volume_preparations": [
{
"container_path": "/config",
"remove_lost_found": true,
"owner_strategy": "mapped-root"
},
{
"container_path": "/output",
"remove_lost_found": true,
+16 -1
View File
@@ -1250,7 +1250,8 @@ apply_installer_profile() {
continue
fi
if [[ $remove_lost_found == true ]]; then
rm -rf "${rootfs}${target}/lost+found" || { failed=1; break; }
oci_quiet python3 "${SCRIPT_DIR}/oci_managed_volumes.py" prepare \
"$rootfs" "$DEPLOYMENT_FILE" --target "$target" || { failed=1; break; }
fi
case "$owner_strategy" in
mapped-root) chown "$HOST_ROOT_UID:$HOST_ROOT_GID" "${rootfs}${target}" || failed=1 ;;
@@ -2058,6 +2059,20 @@ while IFS=$'\t' read -r NAME ENCODED; do
set_runtime_environment "$NAME" "$VALUE"
done < <(jq -r '.environment[] | [.name, (.value | @base64)] | @tsv' "$DEPLOYMENT_FILE")
prepare_managed_format_directories() {
local rootfs=$1 preparation_failed=0
# Stack members receive their real mounts later in attach_mounts().
[[ $(jq -r '.stack_managed // false' "$DEPLOYMENT_FILE") != true ]] || return 0
(( MOUNT_ENTRIES > 0 )) || return 0
mount_ct_rootfs
oci_quiet python3 "${SCRIPT_DIR}/oci_managed_volumes.py" prepare "$rootfs" "$DEPLOYMENT_FILE" \
|| preparation_failed=1
oci_quiet pct unmount "$VMID" || preparation_failed=1
(( preparation_failed == 0 )) \
|| die "$(translate "Could not add the mount point:") CT $VMID"
}
prepare_managed_format_directories "/var/lib/lxc/${VMID}/rootfs"
apply_extra_hosts
apply_installer_profile
ensure_runtime_home
+7
View File
@@ -5,6 +5,13 @@
case "$1" in
''|*[!0-9]*) exit 0 ;;
esac
# Direct PVE restore formats managed filesystems again. The installed record
# authorizes this narrow cleanup; missing/busy records leave the guest alone.
# A hook failure must not prevent the console marker or the DHCP timer.
volumes="${0%/*}/oci_managed_volumes.py"
if [ -r "$volumes" ] && command -v timeout >/dev/null 2>&1; then
timeout --signal=TERM --kill-after=2 10 python3 "$volumes" startup "$1" >/dev/null 2>&1 || :
fi
printf '\n=== ProxMenux: container started %s ===\n' "$(date '+%Y-%m-%d %H:%M:%S')" \
>> "/var/log/proxmenux/oci/$1.console.log" 2>/dev/null
lease="${0%/*}/oci_dhcp_lease.py"
+22 -2
View File
@@ -163,7 +163,7 @@ def record_install(args):
save_record(args.state_dir, record)
def resolve_candidate(reference, architecture):
def resolve_candidate(reference, architecture, *, _known_metadata=None):
repo = reference.split('@', 1)[0]
if ':' in repo.rsplit('/', 1)[-1]:
repo = repo.rsplit(':', 1)[0]
@@ -173,12 +173,27 @@ def resolve_candidate(reference, architecture):
raw = command('skopeo', 'inspect', '--raw', 'docker://' + transport)
registry_digest = 'sha256:' + sha(raw)
manifest = json.loads(raw)
if 'manifests' in manifest:
indexed = 'manifests' in manifest
if indexed:
matches = [m for m in manifest['manifests'] if m.get('platform', {}).get('architecture') == architecture
and m.get('platform', {}).get('os') == 'linux']
if len(matches) != 1:
raise ValueError(translate('A single matching image platform cannot be resolved'))
digest = matches[0]['digest']
else:
digest = registry_digest
# The Monitor needs only the published version/build and digest. A fresh
# tag/index lookup proving the same immutable platform digest also proves
# its previously verified metadata; fetching its manifest and config again
# adds no information. Install/update callers do not pass this option and
# always receive the complete verified image contract below.
if (isinstance(_known_metadata, dict)
and re.fullmatch(r'sha256:[0-9a-f]{64}', str(digest))
and _known_metadata.get('manifest_digest') == digest
and _known_metadata.get('architecture') == architecture):
return {'manifest_digest': digest, 'registry_digest': registry_digest,
'version': _known_metadata.get('version'), 'created': _known_metadata.get('created')}
if indexed:
raw = command('skopeo', 'inspect', '--raw', 'docker://' + repo + '@' + digest)
if 'sha256:' + sha(raw) != digest:
raise ValueError(translate('The manifest does not match its digest'))
@@ -204,6 +219,11 @@ def resolve_candidate(reference, architecture):
'defaults': defaults, 'version': version, 'created': config.get('created')}
def resolve_candidate_metadata(reference, architecture, known=None):
"""Monitor-only metadata fast path through the normal native resolver."""
return resolve_candidate(reference, architecture, _known_metadata=known)
def same_image(candidate, image):
"""Whether what the registry serves is the image of an archive or a record.
+3 -22
View File
@@ -1261,28 +1261,9 @@ def apply(root, vmid, archive, operation, proposal=None, registry_digest=None, i
def remove_empty_format_directories(rootfs, deployment):
"""Remove only empty, unmapped mkfs lost+found on restored managed disks."""
root = Path(rootfs)
for mount in deployment.get('mounts', []):
if mount.get('type') != 'managed-volume' or not mount.get('backup'):
continue
current = root
for part in Path(mount['container_path']).parts[1:]:
if part in ('.', '..'):
raise ValueError(translate('Invalid restored volume path'))
current /= part
if current.is_symlink():
raise ValueError(translate('Symbolic link in a restored volume path'))
candidate = current / 'lost+found'
if not candidate.exists() or candidate.is_symlink():
continue
info = candidate.lstat()
if (stat.S_ISDIR(info.st_mode) and info.st_ino == 11
and info.st_uid == 0 and info.st_gid == 0
and stat.S_IMODE(info.st_mode) == 0o700):
if not any(candidate.iterdir()):
candidate.rmdir()
"""Keep the recovery entrypoint, sharing the guarded format-dir cleanup."""
from oci_managed_volumes import remove_empty_format_directories as clean
return clean(rootfs, deployment)
def cleanup_restored_format_dirs(vmid, deployment, installation_id):
owned(vmid, installation_id)
+179
View File
@@ -0,0 +1,179 @@
#!/usr/bin/env python3
"""Remove only empty, unshifted ext4 format directories on managed mounts."""
from __future__ import annotations
import argparse
import errno
import fcntl
import json
import os
from pathlib import Path
import re
import stat
import subprocess
def mount_path(value):
if (not isinstance(value, str) or not value.startswith('/') or value == '/'
or '\x00' in value or '\n' in value
or not value.strip('/')
or any(part in ('.', '..') for part in value.split('/'))):
raise ValueError('Invalid managed volume path')
return '/' + '/'.join(part for part in value.split('/') if part)
def eligible_paths(deployment, backed_only=True):
mounts = deployment.get('mounts', [])
if not isinstance(mounts, list) or any(not isinstance(m, dict) for m in mounts):
raise ValueError('Invalid managed mount contract')
paths = [(mount_path(m.get('container_path')), m) for m in mounts]
if len({path for path, _ in paths}) != len(paths):
raise ValueError('Duplicate managed volume path')
selected = []
for path, mount in paths:
if (mount.get('type') != 'managed-volume' or mount.get('read_only')
or (backed_only and not mount.get('backup'))):
continue
candidate = path + '/lost+found'
# A nested mount can replace the directory or something inside it.
if any(other == candidate or other.startswith(candidate + '/')
for other, _ in paths):
continue
selected.append(path)
return selected
def format_directory(info):
return (stat.S_ISDIR(info.st_mode) and info.st_ino == 11
and info.st_uid == 0 and info.st_gid == 0
and stat.S_IMODE(info.st_mode) == 0o700)
def remove_empty_at(parent):
try:
before = os.stat('lost+found', dir_fd=parent, follow_symlinks=False)
except FileNotFoundError:
return False
if not format_directory(before):
return False
flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW
child = os.open('lost+found', flags, dir_fd=parent)
try:
opened = os.fstat(child)
if (opened.st_dev, opened.st_ino) != (before.st_dev, before.st_ino):
return False
with os.scandir(child) as entries:
if next(entries, None) is not None:
return False
current = os.stat('lost+found', dir_fd=parent, follow_symlinks=False)
if ((current.st_dev, current.st_ino) != (opened.st_dev, opened.st_ino)
or not format_directory(current)):
return False
# rmdir cannot delete files, and does not follow a replacement link.
os.rmdir('lost+found', dir_fd=parent)
return True
finally:
os.close(child)
def remove_empty_format_directories(rootfs, deployment, backed_only=True, target=None):
paths = eligible_paths(deployment, backed_only)
if target is not None:
wanted = mount_path(target)
paths = [path for path in paths if path == wanted]
if not paths:
return 0
flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW
# A running container is reached through /proc/<pid>/root, a link.
root = os.open(rootfs, os.O_RDONLY | os.O_DIRECTORY)
removed = 0
try:
for path in paths:
current = os.dup(root)
try:
for part in path.lstrip('/').split('/'):
following = os.open(part, flags, dir_fd=current)
os.close(current)
current = following
removed += int(remove_empty_at(current))
except OSError as error:
if error.errno in (errno.ELOOP, errno.ENOTDIR):
raise ValueError('Symbolic link or invalid managed volume path') from error
raise
finally:
os.close(current)
finally:
os.close(root)
return removed
def startup_mounts(config, deployment):
"""Use the current PVE mount type/RO flag, gated by the saved contract."""
declared = {mount_path(m.get('container_path')): m
for m in deployment.get('mounts', [])}
mounts = []
for key, value in config.items():
if not re.fullmatch(r'mp[0-9]+', key):
continue
source, *parts = value.split(',')
options = dict(part.split('=', 1) for part in parts if '=' in part)
path = mount_path(options.get('mp'))
saved = declared.get(path, {})
managed = (saved.get('type') == 'managed-volume' and not source.startswith('/')
and ':' in source)
mounts.append({'container_path': path,
'type': 'managed-volume' if managed else 'host-bind',
'backup': bool(saved.get('backup')) and options.get('backup') == '1',
'read_only': bool(saved.get('read_only')) or options.get('ro', '0') != '0'})
return {'mounts': mounts}
def cleanup_startup(root, vmid, rootfs_base=Path('/var/lib/lxc')):
# Locks are nonblocking. A transaction already prepares its own restore
# before starting it; the hook must not wait on its parent's registry lock.
import oci_instances as instances
from oci_installation_state import parse_config
lock = os.open(root / '.lock', os.O_RDONLY | os.O_NOFOLLOW)
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
record = instances.read(root, vmid)
if record.get('status') != 'installed' or record.get('pending_transaction'):
return 0
if not eligible_paths(record['deployment']):
return 0
result = subprocess.run(['pct', 'config', str(vmid)], capture_output=True, timeout=3)
if result.returncode or instances.identity(result.stdout) != record['installation_id']:
return 0
mounts = startup_mounts(parse_config(result.stdout), record['deployment'])
return remove_empty_format_directories(rootfs_base / str(vmid) / 'rootfs', mounts)
finally:
os.close(lock)
def main():
parser = argparse.ArgumentParser(description=__doc__)
sub = parser.add_subparsers(dest='action', required=True)
prepare = sub.add_parser('prepare')
prepare.add_argument('rootfs', type=Path)
prepare.add_argument('deployment', type=Path)
prepare.add_argument('--target')
startup = sub.add_parser('startup')
startup.add_argument('vmid', type=int)
args = parser.parse_args()
try:
if args.action == 'prepare':
remove_empty_format_directories(args.rootfs, json.loads(args.deployment.read_text()),
backed_only=False, target=args.target)
elif args.vmid > 0:
import oci_instances as instances
cleanup_startup(instances.ROOT, args.vmid)
else:
return 1
except (OSError, ValueError, KeyError, TypeError, subprocess.SubprocessError):
# Private contracts/configuration must never be printed by a hook.
return 1
return 0
if __name__ == '__main__':
raise SystemExit(main())
+10 -5
View File
@@ -757,13 +757,18 @@ def clean_format_directories(entry):
"""A restore formats the volumes again, and the `lost+found` of each new
one belongs to the host: an application that walks its data cannot enter
it and fails to start."""
from oci_managed_volumes import eligible_paths
mounts = []
for key, value in parse_config(entry['config'].encode()).items():
if re.fullmatch(r'mp[0-9]+', key) and not value.startswith('/'):
target = oci_stack_modify.options(value).get('mp')
if target:
mounts.append({'type': 'managed-volume', 'backup': True, 'container_path': target})
if not mounts:
if not re.fullmatch(r'mp[0-9]+', key):
continue
options = oci_stack_modify.options(value)
target = options.get('mp')
if target:
mounts.append({'type': 'host-bind' if value.startswith('/') else 'managed-volume',
'backup': True, 'container_path': target,
'read_only': options.get('ro', '0') != '0'})
if not eligible_paths({'mounts': mounts}):
return
with carried.container_root(entry['vmid']) as rootfs:
if rootfs is not None:
+223
View File
@@ -0,0 +1,223 @@
#!/usr/bin/env python3
"""Adapt the OCI menu to the ProxMenux Monitor checker and its native cache.
This module never contacts a registry or writes an update-status cache.
"""
from __future__ import annotations
import argparse
import datetime
import json
import os
from pathlib import Path
import re
import socket
import stat
import sys
import time
import oci_instances as instances
from oci_installation_state import parse_config
APPS = Path('/etc/proxmenux/apps')
SOCKET = Path('/run/proxmenux-monitor/oci-check.sock')
MAX_AGE = 24 * 60 * 60
DIGEST = re.compile(r'sha256:[0-9a-f]{64}')
def title(record):
try:
template = (record.get('stack') or {}).get('template') or record.get('template') or {}
name = (template.get('catalog_ui') or {}).get('title')
if isinstance(name, dict):
name = name.get('en_US') or name.get('en')
return str(name or record.get('deployment', {}).get('hostname') or f"CT {record['vmid']}")
except (AttributeError, TypeError):
return f"CT {record['vmid']}"
def snapshot(root=instances.ROOT, configs=Path('/etc/pve/lxc')):
"""Use local native config files: opening the menu makes no PVE/API calls."""
records = {}
if not root.is_dir() or root.is_symlink():
return []
for path in sorted(root.iterdir()):
if not path.name.isdecimal() or int(path.name) < 100:
continue
vmid = int(path.name)
native = configs / f'{vmid}.conf'
if not native.is_file():
continue # Deleted guests and guests on another node are not this host's apps.
try:
record = instances.read(root, vmid)
config = native.read_bytes()
# On disk, PVE stores the description in # comments, unlike
# `pct config`. Later [snapshot] and [PENDING] sections describe
# other configurations and must not supply this guest's identity.
current = config.split(b'\n[', 1)[0]
description = '\n'.join(line[1:] for line in current.decode().splitlines() if line.startswith('#'))
identity = instances.identity(json.dumps({'description': description}).encode())
architecture = parse_config(current).get('arch')
reference = record['template']['container_contract']['image']['reference']
image = (record.get('observed') or {}).get('image') or {}
ready = (record.get('status') == 'installed'
and not record.get('pending_transaction')
and not record.get('pending_stack_transaction')
and identity == record['installation_id']
and architecture in ('amd64', 'arm64')
and isinstance(reference, str) and reference
and DIGEST.fullmatch(str(image.get('manifest_digest', ''))))
primary = int((record.get('stack_member') or {}).get('primary_vmid') or vmid)
records[vmid] = {'vmid': vmid, 'primary': primary, 'record': record,
'reference': reference,
'architecture': architecture, 'image': image,
'ready': bool(ready)}
except (OSError, ValueError, KeyError, TypeError, AttributeError):
# Never call an unreadable, incomplete or mismatched guest up to date.
records[vmid] = {'vmid': vmid, 'primary': vmid, 'record': {'vmid': vmid},
'ready': False}
groups = {}
for member in records.values():
groups.setdefault(member['primary'], []).append(member)
applications = []
for primary, members in sorted(groups.items()):
main = records.get(primary)
try:
expected = {int(m['vmid']) for m in ((main or {}).get('record', {}).get('stack') or {}).get('members', [])}
complete = main is not None
except (ValueError, TypeError, KeyError, AttributeError):
expected, complete = set(), False
present = {m['vmid'] for m in members}
complete = complete and expected.issubset(present)
applications.append({'vmid': primary, 'title': title((main or members[0])['record']),
'members': members,
'complete': complete})
return applications
def cached(root=instances.ROOT, configs=Path('/etc/pve/lxc'), now=None, apps=APPS, failed=()):
"""Read the Monitor's sidecars; obey its primary-container stack policy.
An application whose last check failed keeps no older result."""
inventory = snapshot(root, configs)
result = {'checked': False, 'available': [], 'unknown': 0, 'applications': len(inventory)}
instant = time.time() if now is None else now
for app in inventory:
try:
if app['vmid'] in failed:
result['checked'] = True
raise ValueError('Check failed')
if not app['complete'] or not all(member['ready'] for member in app['members']):
raise ValueError('Incomplete installation')
main = next(member for member in app['members'] if member['vmid'] == app['vmid'])
path = apps / f"{app['vmid']}.json"
if path.is_symlink():
raise ValueError('Invalid cache')
data = json.loads(path.read_text())
candidates = [row for row in data['apps'] if row.get('installed_via') == 'oci_image']
if str(data['vmid']) != str(app['vmid']) or len(candidates) != 1:
raise ValueError('Ambiguous application')
state = candidates[0]['state']
stamp = datetime.datetime.strptime(state['checked_at'], '%Y-%m-%dT%H:%M:%SZ')
age = instant - stamp.replace(tzinfo=datetime.timezone.utc).timestamp()
if (not 0 <= age <= MAX_AGE or state.get('error')
or state.get('installed_digest') != main['image']['manifest_digest']
or state.get('image_reference') != main['reference']
or type(state.get('update_available')) is not bool
or not DIGEST.fullmatch(str(state.get('latest_digest', '')))):
raise ValueError('Unusable result')
result['checked'] = True
if state['update_available']:
result['available'].append({'vmid': app['vmid'], 'title': app['title']})
if state.get('registry_retry'):
result['unknown'] += 1 # Same retained result and retry as the Monitor.
except (OSError, ValueError, KeyError, TypeError, AttributeError, StopIteration):
result['unknown'] += 1
return result
def monitor_available(path=SOCKET):
try:
parent, endpoint = path.parent.lstat(), path.lstat()
if not (stat.S_ISDIR(parent.st_mode) and parent.st_uid == 0
and not parent.st_mode & 0o077 and stat.S_ISSOCK(endpoint.st_mode)
and endpoint.st_uid == 0 and not endpoint.st_mode & 0o077):
return False
# A Monitor that stopped without cleaning up leaves the socket behind.
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as probe:
probe.settimeout(0.5)
probe.connect(str(path))
return True
except OSError:
return False
def check(root=instances.ROOT, configs=Path('/etc/pve/lxc'), progress=None, path=SOCKET):
"""Request the existing checker in its own Monitor process, without HTTP."""
if not monitor_available(path):
raise RuntimeError('ProxMenux Monitor checker unavailable')
inventory = snapshot(root, configs)
rows = []
for app in inventory:
if not app['complete'] or not all(member['ready'] for member in app['members']):
continue
main = next(member for member in app['members'] if member['vmid'] == app['vmid'])
rows.append({'vmid': app['vmid'], 'installation_id': main['record']['installation_id']})
total, skipped = len(inventory), len(inventory) - len(rows)
if progress:
progress(0, total)
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection:
connection.settimeout(600) # Native registry timeouts and retries remain authoritative.
connection.connect(str(path))
connection.sendall((json.dumps({'applications': rows}) + '\n').encode())
completed = -1
with connection.makefile('rb') as stream:
while True:
line = stream.readline(4097)
if not line or len(line) > 4096:
raise RuntimeError('Monitor check incomplete')
event = json.loads(line)
if event.get('done') is True and completed == len(rows):
failed = event.get('failed', [])
if not isinstance(failed, list) or any(type(vmid) is not int for vmid in failed):
raise RuntimeError('Monitor check incomplete')
return failed
done, count = event.get('completed'), event.get('total')
if (type(done) is not int or type(count) is not int
or count != len(rows) or not completed <= done <= count or done < 0):
raise RuntimeError('Monitor check unavailable')
completed = done
if progress:
progress(skipped + completed, total)
def emit(event):
message = (json.dumps(event) + '\n').encode()
try:
os.write(sys.stdout.fileno(), message)
except (BlockingIOError, BrokenPipeError):
pass
def emit_progress(completed, total):
emit({'completed': completed, 'total': total})
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--root', type=Path, default=instances.ROOT)
parser.add_argument('--configs', type=Path, default=Path('/etc/pve/lxc'))
parser.add_argument('--progress', action='store_true', help='Emit numeric progress for the OCI menu')
args = parser.parse_args()
try:
if args.progress:
os.set_blocking(sys.stdout.fileno(), False)
failed = check(args.root, args.configs, progress=emit_progress if args.progress else None)
if args.progress and failed:
emit({'failed': failed})
return 0
except (OSError, ValueError, KeyError, TypeError, RuntimeError):
return 1
if __name__ == '__main__':
raise SystemExit(main())
+50 -28
View File
@@ -542,6 +542,8 @@ def _search(catalog: Catalog, ui, applications: list[dict[str, Any]]) -> None:
def interactive(catalog: Catalog) -> int:
from .ui import MenuRefresh
from .updates import UpdateCheck, select_updates
ui = interactive_ui()
if not catalog.index_path.exists():
ui.message(translate("The OCI catalog is not installed. Update ProxMenux and try again."))
@@ -565,34 +567,54 @@ def interactive(catalog: Catalog) -> int:
] + [(number, f"{translate(labels[key]):<35} ({len(categories[key]):>3})")
for number, key in category_by_index.items()]
selection = "search"
while True:
selection = ui.choose(translate("Select a category or search for applications:"), ReadableTags(options),
selection, size=MENU_SIZE)
if selection is None:
return 0
try:
if selection == "search":
_search(catalog, ui, applications)
elif selection == "all":
_app_list(catalog, ui, applications,
f"{translate('All applications')} ({len(applications)})")
elif selection == "manage":
from .management import interactive_management
interactive_management(PROJECT_ROOT, ui)
elif selection == "registries":
from .registry_accounts import open_registry_accounts
open_registry_accounts(ui)
elif selection == "custom":
from .custom import explore
explore(ui)
elif selection in category_by_index:
key = category_by_index[selection]
_app_list(catalog, ui, categories[key], translate(labels[key]))
except UserCancelled:
continue
except (ConversionError, InstallError, OSError, ValueError) as exc:
console.stop_spinner()
ui.message(f"{translate('The operation could not be completed')}:\n\n{exc}")
with UpdateCheck(PROJECT_ROOT) as updates:
while True:
state = updates.status()
entries = ReadableTags(options[:3] + [('updates', updates.label(state))] + options[3:])
text = translate("Select a category or search for applications:")
if notice := updates.notice(state):
text += '\n\n' + notice
if updates.active and not getattr(ui, 'live_refresh', False):
ui.info(translate('Checking for updates...'))
updates.wait()
continue
try:
kwargs = {'refresh': updates.changed} if updates.active else {}
selected = ui.choose(text, entries, selection, size=MENU_SIZE, **kwargs)
except MenuRefresh:
continue
if selected is None:
return 0
selection = selected
try:
if selection == "search":
_search(catalog, ui, applications)
elif selection == "all":
_app_list(catalog, ui, applications,
f"{translate('All applications')} ({len(applications)})")
elif selection == "manage":
from .management import interactive_management
interactive_management(PROJECT_ROOT, ui)
elif selection == "updates":
if not updates.active:
if state['available']:
select_updates(PROJECT_ROOT, ui, state['available'])
else:
updates.start()
elif selection == "registries":
from .registry_accounts import open_registry_accounts
open_registry_accounts(ui)
elif selection == "custom":
from .custom import explore
explore(ui)
elif selection in category_by_index:
key = category_by_index[selection]
_app_list(catalog, ui, categories[key], translate(labels[key]))
except UserCancelled:
continue
except (ConversionError, InstallError, OSError, ValueError) as exc:
console.stop_spinner()
ui.message(f"{translate('The operation could not be completed')}:\n\n{exc}")
# ---------------------------------------------------------------- maintenance CLI
+2 -2
View File
@@ -1588,7 +1588,7 @@ def _run_remote_install(
shutil.copy2(project_root / 'remote' / 'haos_healthcheck.py', temporary / 'haos_healthcheck.py')
shutil.copy2(project_root / 'remote' / 'oci_installation_state.py', temporary / 'oci_installation_state.py')
shutil.copy2(project_root / 'remote' / 'oci_instances.py', temporary / 'oci_instances.py')
for helper in ('oci_ui.sh', 'oci_ui.py', 'oci_description.py', 'oci_console.py', 'oci_native_stack.py', 'oci_native_stack.sh', 'oci_instance_transaction.py', 'oci_host_mounts.py', 'oci_nested_mounts.py', 'oci_runtime_settings.py', 'oci_gpu_devices.py', 'oci_accelerators.py', 'oci_nvidia_runtime.py', 'oci_nvidia_refresh.py', 'oci_nvidia_dynamic.py', 'oci_update_current.py', 'oci_resource_modify.py', 'oci_native_modify.py', 'oci_stack_replay.py', 'oci_stack_plan.py', 'oci_stack_transaction.py', 'oci_stack_native.py', 'oci_image_cache.py', 'nvidia_lxc_mount_lab.sh', 'oci_nvidia_setup.sh', 'oci_immich_ml.sh', 'oci_rocm_check.py'):
for helper in ('oci_ui.sh', 'oci_ui.py', 'oci_description.py', 'oci_console.py', 'oci_native_stack.py', 'oci_native_stack.sh', 'oci_instance_transaction.py', 'oci_managed_volumes.py', 'oci_host_mounts.py', 'oci_nested_mounts.py', 'oci_runtime_settings.py', 'oci_gpu_devices.py', 'oci_accelerators.py', 'oci_nvidia_runtime.py', 'oci_nvidia_refresh.py', 'oci_nvidia_dynamic.py', 'oci_update_current.py', 'oci_resource_modify.py', 'oci_native_modify.py', 'oci_stack_replay.py', 'oci_stack_plan.py', 'oci_stack_transaction.py', 'oci_stack_native.py', 'oci_image_cache.py', 'nvidia_lxc_mount_lab.sh', 'oci_nvidia_setup.sh', 'oci_immich_ml.sh', 'oci_rocm_check.py'):
shutil.copy2(project_root / 'remote' / helper, temporary / helper)
if deployment_kind == 'generic-multi-lxc-stack':
shutil.copy2(project_root / 'remote' / 'install_generic_stack.py', temporary / 'install_generic_stack.py')
@@ -1625,7 +1625,7 @@ def _run_remote_install(
archive.add(project_root / 'remote' / 'haos_healthcheck.py', arcname='haos_healthcheck.py')
archive.add(project_root / 'remote' / 'oci_installation_state.py', arcname='oci_installation_state.py')
archive.add(project_root / 'remote' / 'oci_instances.py', arcname='oci_instances.py')
for helper in ('oci_ui.sh', 'oci_ui.py', 'oci_description.py', 'oci_console.py', 'oci_native_stack.py', 'oci_native_stack.sh', 'oci_instance_transaction.py', 'oci_host_mounts.py', 'oci_nested_mounts.py', 'oci_runtime_settings.py', 'oci_gpu_devices.py', 'oci_accelerators.py', 'oci_nvidia_runtime.py', 'oci_nvidia_refresh.py', 'oci_nvidia_dynamic.py', 'oci_update_current.py', 'oci_resource_modify.py', 'oci_native_modify.py', 'oci_stack_replay.py', 'oci_stack_plan.py', 'oci_stack_transaction.py', 'oci_stack_native.py', 'oci_image_cache.py', 'nvidia_lxc_mount_lab.sh', 'oci_nvidia_setup.sh', 'oci_immich_ml.sh', 'oci_rocm_check.py'):
for helper in ('oci_ui.sh', 'oci_ui.py', 'oci_description.py', 'oci_console.py', 'oci_native_stack.py', 'oci_native_stack.sh', 'oci_instance_transaction.py', 'oci_managed_volumes.py', 'oci_host_mounts.py', 'oci_nested_mounts.py', 'oci_runtime_settings.py', 'oci_gpu_devices.py', 'oci_accelerators.py', 'oci_nvidia_runtime.py', 'oci_nvidia_refresh.py', 'oci_nvidia_dynamic.py', 'oci_update_current.py', 'oci_resource_modify.py', 'oci_native_modify.py', 'oci_stack_replay.py', 'oci_stack_plan.py', 'oci_stack_transaction.py', 'oci_stack_native.py', 'oci_image_cache.py', 'nvidia_lxc_mount_lab.sh', 'oci_nvidia_setup.sh', 'oci_immich_ml.sh', 'oci_rocm_check.py'):
archive.add(project_root / 'remote' / helper, arcname=helper)
if deployment_kind == 'generic-multi-lxc-stack':
archive.add(project_root / 'remote' / 'install_generic_stack.py', arcname='install_generic_stack.py')
+38 -12
View File
@@ -16,6 +16,10 @@ class UserCancelled(RuntimeError):
pass
class MenuRefresh(RuntimeError):
"""The background status changed; redraw the same menu."""
class BackRequested(RuntimeError):
pass
@@ -168,7 +172,7 @@ class TerminalUI:
return options[int(selected) - 1][0]
return None
def checklist(self, text, options, defaults):
def checklist(self, text, options, defaults, title: str | None = None, ok_label=None):
return [tag for tag, label in options if self.confirm(label, tag in defaults)]
def detail_menu(self, text: str, options: list[tuple[str, str]], default: str | None = None,
@@ -214,12 +218,13 @@ class DialogUI:
title: str = APP_TITLE
backtitle: str = "ProxMenux"
back_enabled: bool = False
live_refresh = True
@staticmethod
def available() -> bool:
return bool(shutil.which("dialog") and sys.stdin.isatty() and sys.stdout.isatty())
def _run(self, widget: list[str], title: str | None = None) -> subprocess.CompletedProcess[str]:
def _run(self, widget: list[str], title: str | None = None, refresh=None) -> subprocess.CompletedProcess[str]:
environment = os.environ.copy()
# ncurses otherwise waits a full second to distinguish Escape from
# the prefix of a cursor/function key. Keep a short sequence window,
@@ -231,11 +236,31 @@ class DialogUI:
back_widget = ['--extra-button', '--extra-label', translate('Back')] if self.back_enabled and any(
flag in widget for flag in ('--inputbox', '--passwordbox', '--yesno', '--menu', '--checklist')) else []
confirmation_labels = ['--yes-label', translate('Yes'), '--no-label', translate('No')] if '--yesno' in widget else []
result = subprocess.run(
["dialog", "--no-collapse", "--cancel-label", translate('Cancel'), "--backtitle", self.backtitle, "--title", title or self.title,
*confirmation_labels, *back_widget, *widget],
stdout=None, stderr=subprocess.PIPE, text=True, check=False, env=environment,
)
command = ["dialog", "--no-collapse", "--cancel-label", translate('Cancel'), "--backtitle", self.backtitle,
"--title", title or self.title, *confirmation_labels, *back_widget, *widget]
if refresh is None:
result = subprocess.run(command, stdout=None, stderr=subprocess.PIPE, text=True,
check=False, env=environment)
else:
with subprocess.Popen(command, stdout=None, stderr=subprocess.PIPE, text=True,
env=environment) as process:
try:
while True:
try:
_, output = process.communicate(timeout=0.1)
result = subprocess.CompletedProcess(command, process.returncode, stderr=output)
break
except subprocess.TimeoutExpired:
if refresh() and process.poll() is None:
raise MenuRefresh()
finally:
if process.poll() is None:
process.terminate()
try:
process.communicate(timeout=2)
except subprocess.TimeoutExpired:
process.kill()
process.communicate()
if result.returncode == 3 and back_widget:
raise BackRequested()
return result
@@ -287,7 +312,7 @@ class DialogUI:
def choose(self, text: str, options: list[tuple[str, str]], default: str | None = None,
title: str | None = None, show_tags: bool = True,
size: tuple[int, int, int] | None = None, colors: bool = False) -> str | None:
size: tuple[int, int, int] | None = None, colors: bool = False, refresh=None) -> str | None:
# A word is an internal value: the menu numbers the options, as the
# rest of ProxMenux does, and returns the value of the chosen one.
values: dict[str, str] = {}
@@ -307,7 +332,7 @@ class DialogUI:
widget += ["--menu", text, *map(str, size)]
for tag, label in options:
widget += [tag, label]
result = self._run(widget, title)
result = self._run(widget, title, refresh=refresh) if refresh else self._run(widget, title)
selected = result.stderr.strip() if result.returncode == 0 else None
return values.get(selected, selected) if values and selected else selected
terminal = shutil.get_terminal_size((100, 30))
@@ -324,7 +349,7 @@ class DialogUI:
widget += ["--menu", f"\n{text}", str(height), str(width), str(menu_height)]
for tag, label in options:
widget += [tag, label]
result = self._run(widget, title)
result = self._run(widget, title, refresh=refresh) if refresh else self._run(widget, title)
selected = result.stderr.strip() if result.returncode == 0 else None
return values.get(selected, selected) if values and selected else selected
@@ -349,10 +374,11 @@ class DialogUI:
result = self._run(widget, title)
return result.stderr.strip() if result.returncode == 0 else None
def checklist(self, text, options, defaults, title: str | None = None):
def checklist(self, text, options, defaults, title: str | None = None, ok_label=None):
terminal = shutil.get_terminal_size((100, 30))
height = min(max(len(options) + 9, 14), terminal.lines - 2)
widget = ["--separate-output", "--checklist", f"\n{text}", str(height), str(MENU_WIDTH),
widget = ['--ok-label', ok_label] if ok_label else []
widget += ["--separate-output", "--checklist", f"\n{text}", str(height), str(MENU_WIDTH),
str(max(4, min(len(options), height - 8)))]
for tag, label in options:
widget += [tag, label, "on" if tag in defaults else "off"]
+196
View File
@@ -0,0 +1,196 @@
"""OCI menu update status and explicit, user-selected updates."""
from __future__ import annotations
import importlib
import json
import os
from pathlib import Path
import signal
import subprocess
import sys
from .i18n import translate
from .ui import UserCancelled
class UpdateCheck:
def __init__(self, project):
self.project = Path(project)
sys.path.insert(0, str(self.project / 'remote'))
self.backend = importlib.import_module('oci_update_check')
self.process = None
self.failed = False
self.failed_applications = set()
self.completed = self.total = 0
self._progress_buffer = b''
self._shown_progress = (0, 0)
def __enter__(self):
return self
def __exit__(self, *exc):
self.close()
@property
def running(self):
return self.process is not None and self.process.poll() is None
@property
def active(self):
# A finished child still needs one redraw to publish its final cache.
return self.process is not None
def ready(self):
return self.process is not None and self.process.poll() is not None
def _read_progress(self):
if self.process is None or self.process.stdout is None:
return
try:
while chunk := os.read(self.process.stdout.fileno(), 8192):
self._progress_buffer += chunk
while b'\n' in self._progress_buffer:
line, self._progress_buffer = self._progress_buffer.split(b'\n', 1)
try:
data = json.loads(line)
if isinstance(data.get('failed'), list):
self.failed_applications = {vmid for vmid in data['failed'] if type(vmid) is int}
continue
completed, total = data['completed'], data['total']
if type(completed) is int and type(total) is int and 0 <= completed <= total:
self.completed, self.total = completed, total
except (ValueError, TypeError, KeyError):
pass # Never display worker output as UI text.
if len(self._progress_buffer) > 8192:
self._progress_buffer = b''
except (BlockingIOError, OSError):
pass
def changed(self):
self._read_progress()
return self.ready() or (self.completed, self.total) != self._shown_progress
def _close_output(self):
if self.process is not None and self.process.stdout is not None:
self.process.stdout.close()
def status(self):
self._read_progress()
if self.ready():
self.failed = self.process.wait() != 0
self._close_output()
self.process = None
try:
state = self.backend.cached(failed=self.failed_applications)
except (OSError, ValueError, KeyError, TypeError):
state = {'checked': False, 'available': [], 'unknown': 0, 'applications': 0}
if self.failed:
# A failed worker must not present an older successful check as
# the outcome of the new request, or keep its old update list.
state = dict(state, checked=True, available=[], unknown=state['applications'])
return state
def start(self):
if self.running:
return
if not self.backend.monitor_available():
raise ValueError(translate('The ProxMenux Monitor update checker is not available. Start or update ProxMenux Monitor and try again.'))
state = self.status()
self.failed = False
self.failed_applications = set()
self.completed, self.total = 0, state['applications']
self._progress_buffer = b''
self.process = subprocess.Popen(
[sys.executable, str(self.project / 'remote/oci_update_check.py'), '--progress'],
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
start_new_session=True, bufsize=0)
os.set_blocking(self.process.stdout.fileno(), False)
def wait(self):
if self.process is not None:
while True:
self._read_progress()
try:
self.process.wait(timeout=0.1)
break
except subprocess.TimeoutExpired:
pass
def close(self):
if self.process is None:
return
# Close only this client. The Monitor finishes its current metadata
# request and stops the remaining checks when the connection closes.
if self.process.poll() is None:
try:
os.killpg(self.process.pid, signal.SIGTERM)
except ProcessLookupError:
pass
try:
self.process.wait(timeout=2)
except subprocess.TimeoutExpired:
try:
os.killpg(self.process.pid, signal.SIGKILL)
except ProcessLookupError:
pass
self.process.wait()
self._close_output()
self.process = None
def label(self, state):
self._read_progress()
if self.active:
self._shown_progress = self.completed, self.total
return translate('Checking for updates ({completed}/{total})').format(
completed=self.completed, total=self.total)
count = len(state['available'])
return f"{translate('Updates available')} ({count})" if count else translate('Check for updates')
def notice(self, state):
if self.active:
return ''
if self.failed:
return translate('The update check could not be completed. Try again later.')
if self.failed_applications:
return translate('Some applications could not be checked ({count}). Try again later.').format(
count=len(self.failed_applications))
return ''
def select_updates(project, ui, available):
"""Nothing is preselected, including when exactly one update is available."""
choices = [('all', translate('Select all'))]
choices += [(str(row['vmid']), f"CT {row['vmid']} · {row['title']}") for row in available]
defaults = []
while True:
try:
picked = ui.checklist(
translate('Press Space to select applications to update. Select all includes every application in this list.'),
choices, defaults, title=translate('Updates available'), ok_label=translate('Update'))
except UserCancelled:
return False
selected = [row for row in available if 'all' in picked or str(row['vmid']) in picked]
if not selected:
return False
defaults = [str(row['vmid']) for row in selected]
names = '\n'.join(f" CT {row['vmid']} · {row['title']}" for row in selected)
if ui.review(names, translate('Update selected OCI applications'),
question=translate('Review and update these applications?'), default=False):
break
from .management import manage_instance, saved_inventory
# A change/removal since the check invalidates that application's result.
checker = UpdateCheck(project)
still_available = {row['vmid'] for row in checker.status()['available']}
current = {row['vmid']: row for row in saved_inventory(project)}
for row in selected:
vmid = row['vmid']
if vmid not in still_available or vmid not in current:
ui.message(translate('An application changed since the update check. Check for updates again.'),
translate('Updates available'))
return False
for row in selected:
# Keep the existing identity checks, backup/storage prompts, update
# confirmation, coordinated stack update and recovery path.
if not manage_instance(project, ui, current[row['vmid']], action='update'):
break # A cancellation or failure never updates later selections.
return False
+102
View File
@@ -0,0 +1,102 @@
"""Catalog links agree with the configured service and preserve suite choices."""
import copy
import json
from pathlib import Path
import sys
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
sys.path.insert(0, str(ROOT / "remote"))
sys.path.insert(0, str(Path(__file__).resolve().parent))
from proxmenux_oci.catalog import Catalog
from proxmenux_oci.installer import DEFAULT_MODE, build_deployment
from proxmenux_oci.stack import StackError
from test_advanced_flow_order import RecordingUI, addresses, storages
import oci_description
import oci_instance_transaction as transaction
class CatalogWebLinks(unittest.TestCase):
catalog = Catalog(ROOT)
def test_jdownloader_launch_matches_its_secure_web_service(self):
template = self.catalog.compose("jdownloader")
environment = {entry["name"]: entry.get("example")
for entry in template["container_contract"]["environment"]}
self.assertEqual(environment["SECURE_CONNECTION"], "1")
launch = template["catalog_ui"]["launch"]
self.assertEqual(launch, {"scheme": "https", "port": 5800, "path": "/"})
endpoint = template["first_run"]["endpoints"][0]
self.assertEqual({key: endpoint[key] for key in launch}, launch)
self.assertIn('href="https://192.0.2.10:5800/"',
oci_description.render(template, "", "test-installation", "192.0.2.10"))
def test_shipped_templates_keep_the_curated_link_contract(self):
for app in ("jdownloader", "suite-arr"):
with self.subTest(app=app):
curated = json.loads((ROOT / "catalog/curated" / (app + ".json")).read_text())
generated = json.loads((ROOT / "catalog/apps" / (app + ".json")).read_text())
self.assertEqual(curated["catalog_ui"]["launch"], generated["catalog_ui"]["launch"])
self.assertEqual(curated["first_run"], generated["first_run"])
def test_suite_parent_names_the_documented_prowlarr_web_endpoint(self):
template = self.catalog.compose("suite-arr")
endpoints = template["first_run"]["endpoints"]
self.assertEqual(len(endpoints), 1)
endpoint = endpoints[0]
self.assertEqual((endpoint["scheme"], endpoint["port"], endpoint["path"]),
("http", 9696, "/"))
self.assertEqual(endpoint["label"], "Web UI")
self.assertNotEqual(endpoint["source"], transaction.GUESSED_ENDPOINT)
check = transaction.effective_healthcheck(template)
self.assertEqual((check["scheme"], check["port"]), ("http", 9696))
self.assertNotIn("accept_non_http", check)
@patch("proxmenux_oci.installer.host.gpus", return_value={"intel": [], "amd": [], "nvidia": False})
@patch("proxmenux_oci.i18n.language", return_value="en")
@patch("proxmenux_oci.installer.host.storages", side_effect=storages)
@patch("proxmenux_oci.installer.host.bridges", return_value=[{"iface": "vmbr0", "cidr": "192.0.2.1/24"}])
@patch("proxmenux_oci.installer.host.timezone", return_value="Etc/UTC")
@patch("proxmenux_oci.installer.access.ask_ipv4", return_value=("dhcp", None))
@patch("proxmenux_oci.installer.access.ask_addresses", side_effect=addresses)
def test_suite_members_keep_their_own_links_with_or_without_prowlarr(self, *_):
template = self.catalog.compose("suite-arr")
before = copy.deepcopy(template)
with patch("subprocess.run") as command:
for selected in (["prowlarr", "sonarr"], ["sonarr"]):
ui = RecordingUI({"Arr suite: applications to install": selected,
"Media server": "none"})
plan = build_deployment(copy.deepcopy(template), ui, DEFAULT_MODE)
self.assertEqual([member["name"] for member in plan["services"]], selected)
for member in plan["services"]:
port = 9696 if member["name"] == "prowlarr" else 8989
endpoint = member["template"]["first_run"]["endpoints"][0]
self.assertEqual(endpoint["port"], port)
self.assertEqual(member["healthcheck"]["endpoint"]["port"], port)
notes = oci_description.render(member["template"], "", "test-installation", "192.0.2.10")
self.assertIn(f':{port}/"', notes)
if port != 9696:
self.assertNotIn(':9696/"', notes)
command.assert_not_called()
self.assertEqual(template, before)
def test_empty_suite_selection_has_no_host_or_runtime_effect(self):
template = self.catalog.compose("suite-arr")
before = copy.deepcopy(template)
ui = RecordingUI({"Arr suite: applications to install": [], "Media server": "none"})
with patch("proxmenux_oci.i18n.language", return_value="en"), \
patch("proxmenux_oci.installer.host.storages") as host, \
patch("subprocess.run") as command:
with self.assertRaises(StackError):
build_deployment(template, ui, DEFAULT_MODE)
host.assert_not_called()
command.assert_not_called()
self.assertEqual(template, before)
if __name__ == "__main__":
unittest.main()
+162
View File
@@ -0,0 +1,162 @@
"""Exercise actual installer and persisted hook entrypoints with local fixtures."""
from pathlib import Path
import json
import os
import shlex
import subprocess
import sys
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
class FirstInstallTests(unittest.TestCase):
def run_case(self, mount_type='managed-volume', read_only=False, stack=False,
invalid=False, empty=False):
with tempfile.TemporaryDirectory() as tmp:
base = Path(tmp); rootfs = base / 'rootfs'
lost = rootfs / 'config/lost+found'; lost.mkdir(parents=True, mode=0o700)
sentinel = rootfs / 'config/keep'; sentinel.write_text('application data')
deployment = {'stack_managed': stack, 'mounts': [] if empty else [{
'type': mount_type, 'container_path': 'relative' if invalid else '/config',
'backup': True, 'read_only': read_only}]}
plan = base / 'deployment.json'; plan.write_text(json.dumps(deployment))
wrapper = base / 'python3'
wrapper.write_text('#!' + sys.executable + '\n' +
'import sys\nsys.path[:0]=' + repr([str(ROOT / 'remote'), str(ROOT / 'tests')]) + '\n' +
'from test_managed_volume_startup import ext4_metadata\n' +
'import oci_managed_volumes\nsys.argv=sys.argv[1:]\n' +
'with ext4_metadata():\n raise SystemExit(oci_managed_volumes.main())\n')
wrapper.chmod(0o700)
source = (ROOT / 'remote/install_oci.sh').read_text()
start = source.index('prepare_managed_format_directories() {')
end = source.index('\nprepare_managed_format_directories "/var/lib/lxc/', start)
function = source[start:end]
script = '''set -Eeuo pipefail
VMID=1000
MOUNT_ENTRIES=$COUNT
mount_ct_rootfs() { printf 'MOUNT\n'; }
oci_quiet() {
if [[ $1 == pct ]]; then printf 'UNMOUNT\n'; else "$@"; fi
}
translate() { printf '%s' "$1"; }
die() { printf 'FAILURE\n'; exit 70; }
''' + function + '\nprepare_managed_format_directories "$ROOTFS"\n'
result = subprocess.run(['bash', '-c', script], capture_output=True, text=True, timeout=10,
env=dict(os.environ, PATH=str(base) + ':' + os.environ['PATH'],
SCRIPT_DIR=str(ROOT / 'remote'), DEPLOYMENT_FILE=str(plan),
ROOTFS=str(rootfs), COUNT='0' if empty else '1'))
self.assertEqual(sentinel.read_text(), 'application data')
return result, lost.exists()
def test_unannotated_config_volume_is_prepared_before_first_start(self):
result, exists = self.run_case()
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertFalse(exists)
self.assertEqual(result.stdout.splitlines(), ['MOUNT', 'UNMOUNT'])
def test_readonly_and_bind_sources_are_unchanged(self):
for options in ({'read_only': True}, {'mount_type': 'host-bind'}):
with self.subTest(options=options):
result, exists = self.run_case(**options)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertTrue(exists)
def test_cleanup_failure_unmounts_and_stops_before_start(self):
result, exists = self.run_case(invalid=True)
self.assertEqual(result.returncode, 70)
self.assertEqual(result.stdout.splitlines(), ['MOUNT', 'UNMOUNT', 'FAILURE'])
self.assertTrue(exists)
def test_stack_member_and_empty_mount_list_have_no_mount_or_cleanup_calls(self):
for options in ({'stack': True}, {'empty': True}):
with self.subTest(options=options):
result, exists = self.run_case(**options)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stdout, '')
self.assertTrue(exists)
class PersistedHookTests(unittest.TestCase):
def run_case(self, *, failure=False, helper=True, invalid=False, timeout=False):
with tempfile.TemporaryDirectory() as tmp:
base = Path(tmp); marker = base / '1000.console.log'; events = base / 'events'
source = (ROOT / 'remote/oci_console_mark.sh').read_text()
source = source.replace('/var/log/proxmenux/oci/', str(base) + '/')
script = base / 'oci_console_mark.sh'; script.write_text(source)
(base / 'oci_dhcp_lease.py').touch()
if helper: (base / 'oci_managed_volumes.py').touch()
python = base / 'python3'
python.write_text('#!/bin/sh\n' +
'test ! -f "$MARKER" || exit 80\n' +
'printf "cleanup %s\\n" "$3" >> "$EVENTS"\n' +
('sleep 30\n' if timeout else '') + ('exit 1\n' if failure else 'exit 0\n'))
python.chmod(0o700)
timer = base / 'systemd-run'
timer.write_text('#!/bin/sh\ntest -s "$MARKER" || exit 81\nprintf "dhcp\\n" >> "$EVENTS"\n')
timer.chmod(0o700)
result = subprocess.run(['sh', str(script), 'invalid;id' if invalid else '1000'],
capture_output=True, text=True, timeout=15,
env=dict(os.environ, PATH=str(base) + ':' + os.environ['PATH'],
MARKER=str(marker), EVENTS=str(events)))
return result, marker.exists(), events.read_text().splitlines() if events.exists() else []
def test_cleanup_precedes_console_marker_and_dhcp_timer(self):
result, marker, events = self.run_case()
self.assertEqual(result.returncode, 0, result.stderr)
self.assertTrue(marker)
self.assertEqual(events, ['cleanup 1000', 'dhcp'])
def test_missing_or_failed_helper_keeps_marker_and_dhcp(self):
for options, expected in (({'helper': False}, ['dhcp']),
({'failure': True}, ['cleanup 1000', 'dhcp'])):
with self.subTest(options=options):
result, marker, events = self.run_case(**options)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertTrue(marker)
self.assertEqual(events, expected)
def test_hung_cleanup_is_bounded_and_dhcp_still_runs(self):
result, marker, events = self.run_case(timeout=True)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertTrue(marker)
self.assertEqual(events, ['cleanup 1000', 'dhcp'])
def test_invalid_vmid_has_no_cleanup_marker_or_timer(self):
result, marker, events = self.run_case(invalid=True)
self.assertEqual(result.returncode, 0)
self.assertFalse(marker)
self.assertEqual(events, [])
class InstallerTransportTests(unittest.TestCase):
def test_local_and_ssh_staging_include_the_guarded_helper_without_host_calls(self):
import io
import tarfile
from unittest.mock import patch
sys.path.insert(0, str(ROOT / 'src'))
from proxmenux_oci import installer
expected = (ROOT / 'remote/oci_managed_volumes.py').read_bytes()
with tempfile.TemporaryDirectory() as tmp:
template = Path(tmp) / 'template.json'; template.write_text('{}')
for transport in ('local', 'unused-test-host'):
for kind in (None, 'generic-multi-lxc-stack'):
with self.subTest(transport=transport, kind=kind):
def inspect(command, payload=None):
if transport == 'local':
folder = Path(command[1]).parent
self.assertEqual((folder / 'oci_managed_volumes.py').read_bytes(), expected)
else:
with tarfile.open(fileobj=io.BytesIO(payload), mode='r:gz') as archive:
self.assertEqual(archive.extractfile('oci_managed_volumes.py').read(), expected)
return {'checked': True}
with patch.object(installer, '_stream_process', side_effect=inspect), \
patch.object(installer.subprocess, 'run') as host:
self.assertEqual(installer._run_remote_install(ROOT, template,
{'deployment_kind': kind}, transport, dry_run=True), {'checked': True})
host.assert_not_called()
if __name__ == '__main__':
unittest.main()
+326
View File
@@ -0,0 +1,326 @@
"""Managed filesystem preparation must preserve read-only data."""
from pathlib import Path
import sys
import tempfile
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'remote'))
import oci_instance_transaction as transaction
class RecoveryGuardTests(unittest.TestCase):
def test_read_only_managed_volume_is_not_probed_or_changed(self):
deployment = {'mounts': [{'type': 'managed-volume', 'backup': True,
'container_path': '/config', 'read_only': True}]}
with patch.object(Path, 'is_symlink', return_value=False) as probe, \
patch.object(Path, 'exists', return_value=False):
transaction.remove_empty_format_directories('/fixture/rootfs', deployment)
probe.assert_not_called()
def test_nested_read_only_mount_protects_the_format_directory(self):
deployment = {'mounts': [
{'type': 'managed-volume', 'backup': True, 'container_path': '/config'},
{'type': 'host-bind', 'container_path': '/config/lost+found', 'read_only': True},
]}
with patch.object(Path, 'is_symlink', return_value=False) as probe, \
patch.object(Path, 'exists', return_value=False):
transaction.remove_empty_format_directories('/fixture/rootfs', deployment)
probe.assert_not_called()
def test_a_relative_mount_path_is_rejected_before_filesystem_probes(self):
deployment = {'mounts': [{'type': 'managed-volume', 'backup': True,
'container_path': 'config'}]}
with patch.object(Path, 'is_symlink', return_value=False) as probe, \
patch.object(Path, 'exists', return_value=False):
with self.assertRaises(ValueError):
transaction.remove_empty_format_directories('/fixture/rootfs', deployment)
probe.assert_not_called()
import contextlib
import copy
import fcntl
import json
import os
from types import SimpleNamespace
import subprocess
import oci_managed_volumes as volumes
import oci_restore_recovery as recovery
import oci_instances as instances
@contextlib.contextmanager
def ext4_metadata(**overrides):
"""Use real filesystem operations; only mkfs inode/ownership are fixtures."""
real_stat, real_fstat = os.stat, os.fstat
def info(value):
fields = {key: getattr(value, key) for key in ('st_dev', 'st_ino', 'st_uid', 'st_gid', 'st_mode')}
fields.update(st_ino=11, st_uid=0, st_gid=0)
fields.update(overrides)
return SimpleNamespace(**fields)
def observed(path, *args, **kwargs):
value = real_stat(path, *args, **kwargs)
return info(value) if str(path) == 'lost+found' else value
def opened(fd):
value = real_fstat(fd)
return info(value) if os.readlink(f'/proc/self/fd/{fd}').endswith('/lost+found') else value
with patch.object(volumes.os, 'stat', side_effect=observed), \
patch.object(volumes.os, 'fstat', side_effect=opened):
yield
class FormatDirectoryTests(unittest.TestCase):
def fixture(self, root):
target = root / 'config'
target.mkdir()
lost = target / 'lost+found'
lost.mkdir(mode=0o700)
(target / 'keep').write_bytes(b'application data')
return lost, {'mounts': [{'container_path': '/config', 'type': 'managed-volume', 'backup': True}]}
def test_only_an_empty_format_directory_is_removed(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp); lost, deployment = self.fixture(root)
before = copy.deepcopy(deployment)
with ext4_metadata():
self.assertEqual(volumes.remove_empty_format_directories(root, deployment), 1)
self.assertFalse(lost.exists())
self.assertEqual((root / 'config/keep').read_bytes(), b'application data')
self.assertEqual(deployment, before)
def test_running_container_root_reached_through_a_link_is_cleaned(self):
with tempfile.TemporaryDirectory() as tmp:
base = Path(tmp); real = base / 'rootfs'; real.mkdir()
lost, deployment = self.fixture(real)
proc_root = base / 'root'; proc_root.symlink_to(real, target_is_directory=True)
with ext4_metadata():
self.assertEqual(volumes.remove_empty_format_directories(proc_root, deployment), 1)
self.assertFalse(lost.exists())
self.assertEqual((real / 'config/keep').read_bytes(), b'application data')
def test_nonempty_format_directory_has_no_remove_call(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp); lost, deployment = self.fixture(root)
(lost / 'recovered-file').write_bytes(b'must survive')
with ext4_metadata(), patch.object(volumes.os, 'rmdir') as remove:
self.assertEqual(volumes.remove_empty_format_directories(root, deployment), 0)
remove.assert_not_called()
self.assertEqual((lost / 'recovered-file').read_bytes(), b'must survive')
def test_nonstandard_metadata_is_preserved_without_remove(self):
for override in ({'st_ino': 12}, {'st_uid': 100000}, {'st_gid': 100000}, {'st_mode': 0o40755}):
with self.subTest(override=override), tempfile.TemporaryDirectory() as tmp:
root = Path(tmp); lost, deployment = self.fixture(root)
with ext4_metadata(**override), patch.object(volumes.os, 'rmdir') as remove:
self.assertEqual(volumes.remove_empty_format_directories(root, deployment), 0)
remove.assert_not_called()
self.assertTrue(lost.is_dir())
def test_link_and_parent_link_never_reach_external_data(self):
for parent in (False, True):
with self.subTest(parent=parent), tempfile.TemporaryDirectory() as tmp:
base = Path(tmp); root = base / 'root'; root.mkdir()
outside = base / 'outside'; outside.mkdir()
(outside / 'keep').write_text('external')
if parent:
(root / 'config').symlink_to(outside, target_is_directory=True)
else:
(root / 'config').mkdir()
(root / 'config/lost+found').symlink_to(outside, target_is_directory=True)
deployment = {'mounts': [{'container_path': '/config', 'type': 'managed-volume', 'backup': True}]}
with patch.object(volumes.os, 'rmdir') as remove:
if parent:
with self.assertRaises(ValueError):
volumes.remove_empty_format_directories(root, deployment)
else:
self.assertEqual(volumes.remove_empty_format_directories(root, deployment), 0)
remove.assert_not_called()
self.assertEqual((outside / 'keep').read_text(), 'external')
def test_bind_readonly_and_nonbacked_recovery_make_no_filesystem_calls(self):
for options in ({'type': 'host-bind'}, {'read_only': True}, {'backup': False}):
mount = {'container_path': '/config', 'type': 'managed-volume', 'backup': True, **options}
with self.subTest(options=options), patch.object(volumes.os, 'open') as opened, \
patch.object(volumes.os, 'rmdir') as remove:
self.assertEqual(volumes.remove_empty_format_directories('/rootfs', {'mounts': [mount]}), 0)
opened.assert_not_called(); remove.assert_not_called()
def test_new_nonbacked_managed_disk_is_prepared_without_changing_backup_policy(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp); lost, deployment = self.fixture(root)
deployment['mounts'][0]['backup'] = False
with ext4_metadata():
self.assertEqual(volumes.remove_empty_format_directories(root, deployment, backed_only=False), 1)
self.assertFalse(lost.exists())
self.assertFalse(deployment['mounts'][0]['backup'])
def test_read_failure_does_not_mean_empty_and_does_not_remove(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp); lost, deployment = self.fixture(root)
with ext4_metadata(), patch.object(volumes.os, 'scandir', side_effect=PermissionError), \
patch.object(volumes.os, 'rmdir') as remove:
with self.assertRaises(PermissionError):
volumes.remove_empty_format_directories(root, deployment)
remove.assert_not_called()
self.assertTrue(lost.is_dir())
def test_invalid_paths_are_rejected_before_any_filesystem_call(self):
for path in ('config', '/', '//', '/config/../outside', '/config/./data', '/bad\npath'):
with self.subTest(path=path), patch.object(volumes.os, 'open') as opened:
with self.assertRaises(ValueError):
volumes.remove_empty_format_directories('/rootfs', {'mounts': [
{'container_path': path, 'type': 'managed-volume', 'backup': True}]})
opened.assert_not_called()
def test_duplicate_paths_are_rejected_before_filesystem_calls(self):
mount = {'container_path': '/config', 'type': 'managed-volume', 'backup': True}
with patch.object(volumes.os, 'open') as opened:
with self.assertRaises(ValueError):
volumes.remove_empty_format_directories('/rootfs', {'mounts': [mount, dict(mount)]})
opened.assert_not_called()
def test_missing_rootfs_is_an_error_without_remove(self):
with tempfile.TemporaryDirectory() as tmp, patch.object(volumes.os, 'rmdir') as remove:
with self.assertRaises(FileNotFoundError):
volumes.remove_empty_format_directories(Path(tmp) / 'missing', {'mounts': [
{'container_path': '/config', 'type': 'managed-volume', 'backup': True}]})
remove.assert_not_called()
def test_missing_managed_mount_is_an_error_without_remove(self):
with tempfile.TemporaryDirectory() as tmp, patch.object(volumes.os, 'rmdir') as remove:
with self.assertRaises(FileNotFoundError):
volumes.remove_empty_format_directories(Path(tmp), {'mounts': [
{'container_path': '/missing', 'type': 'managed-volume', 'backup': True}]})
remove.assert_not_called()
class StartupContractTests(unittest.TestCase):
def setUp(self):
directory = tempfile.TemporaryDirectory(); self.addCleanup(directory.cleanup)
self.base = Path(directory.name); self.registry = self.base / 'registry'
self.registry.mkdir(); (self.registry / '.lock').touch(mode=0o600)
self.record = {'schema_version': 1, 'vmid': 1000,
'installation_id': '11111111-1111-4111-8111-111111111111',
'status': 'installed', 'deployment': {'mounts': [
{'container_path': '/config', 'type': 'managed-volume', 'backup': True}]}}
(self.registry / '1000').mkdir()
self.record_path = self.registry / '1000/oci-compose.json'
self.record_path.write_text(json.dumps(self.record))
self.rootfs = self.base / 'guests/1000/rootfs'
self.rootfs.mkdir(parents=True)
self.lost = self.rootfs / 'config/lost+found'; self.lost.mkdir(parents=True, mode=0o700)
(self.rootfs / 'config/keep').write_text('application data')
self.config = (f'description: <!-- proxmenux-instance={self.record["installation_id"]} -->\n'
'mp0: local:1000/vm-1000-disk-1.raw,mp=/config,backup=1,size=16G\n').encode()
def call(self, *, config=None, returncode=0):
response = SimpleNamespace(returncode=returncode, stdout=self.config if config is None else config)
before = self.record_path.read_bytes()
with patch.object(volumes.subprocess, 'run', return_value=response) as command:
result = volumes.cleanup_startup(self.registry, 1000, self.base / 'guests')
self.assertEqual(self.record_path.read_bytes(), before)
return result, command
def test_current_matching_managed_mount_cleans_before_start_without_guest_exec(self):
with ext4_metadata():
result, command = self.call()
self.assertEqual(result, 1); self.assertFalse(self.lost.exists())
command.assert_called_once_with(['pct', 'config', '1000'], capture_output=True, timeout=3)
self.assertEqual((self.rootfs / 'config/keep').read_text(), 'application data')
def test_wrong_identity_failed_command_empty_output_and_changed_mount_do_not_remove(self):
cases = [(self.config.replace(b'11111111', b'22222222'), 0), (b'', 0), (self.config, 1),
(self.config.replace(b'local:1000/vm-1000-disk-1.raw', b'/host/data'), 0),
(self.config.replace(b'backup=1', b'backup=0'), 0),
(self.config.replace(b'backup=1', b'backup=1,ro=1'), 0)]
for config, code in cases:
with self.subTest(config=config, code=code), ext4_metadata(), patch.object(volumes.os, 'rmdir') as remove:
result, _ = self.call(config=config, returncode=code)
self.assertEqual(result, 0); remove.assert_not_called()
self.assertTrue(self.lost.is_dir())
def test_pending_unsupported_or_hostonly_record_never_calls_pct(self):
for change in ({'status': 'updating'}, {'status': 'failed'}, {'pending_transaction': '/private/journal'},
{'deployment': {'mounts': [{'container_path': '/config', 'type': 'host-bind'}]}}):
with self.subTest(change=change):
self.record_path.write_text(json.dumps({**self.record, **change}))
result, command = self.call()
self.assertEqual(result, 0); command.assert_not_called()
self.assertTrue(self.lost.is_dir())
def test_busy_lock_is_immediate_and_leaves_files_unchanged(self):
with (self.registry / '.lock').open('r') as lock:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
with patch.object(volumes.subprocess, 'run') as command, patch.object(volumes.os, 'rmdir') as remove:
with self.assertRaises(BlockingIOError):
volumes.cleanup_startup(self.registry, 1000, self.base / 'guests')
command.assert_not_called(); remove.assert_not_called()
self.assertTrue(self.lost.is_dir())
def test_missing_or_invalid_record_and_timeout_never_remove(self):
for value in ('missing', 'invalid', 'timeout'):
with self.subTest(value=value):
if value == 'missing': self.record_path.unlink()
elif value == 'invalid': self.record_path.write_text('{')
else: self.record_path.write_text(json.dumps(self.record))
with patch.object(volumes.subprocess, 'run', side_effect=subprocess.TimeoutExpired('pct', 3)), \
patch.object(volumes.os, 'rmdir') as remove:
with self.assertRaises((OSError, ValueError, subprocess.TimeoutExpired)):
volumes.cleanup_startup(self.registry, 1000, self.base / 'guests')
remove.assert_not_called()
self.assertTrue(self.lost.is_dir())
class RecoveryConsumerTests(unittest.TestCase):
def test_transaction_recovery_uses_real_cleanup_and_always_unmounts(self):
with tempfile.TemporaryDirectory() as tmp:
base = Path(tmp); root = base / '1000/rootfs'; root.mkdir(parents=True)
lost, deployment = FormatDirectoryTests().fixture(root)
with patch.object(transaction, 'owned') as owned, patch.object(transaction, 'run') as command, \
patch.object(transaction, 'Path', return_value=base), ext4_metadata(), \
patch.object(volumes, 'remove_empty_format_directories', wraps=volumes.remove_empty_format_directories) as clean:
transaction.cleanup_restored_format_dirs(1000, deployment, 'identity')
owned.assert_called_once_with(1000, 'identity')
self.assertEqual([c.args for c in command.call_args_list],
[('pct', 'mount', '1000'), ('pct', 'unmount', '1000')])
clean.assert_called_once()
self.assertFalse(lost.exists())
self.assertEqual((root / 'config/keep').read_bytes(), b'application data')
def test_transaction_failure_still_unmounts_and_identity_failure_never_mounts(self):
deployment = {'mounts': [{'type': 'managed-volume', 'container_path': '/config', 'backup': True}]}
with patch.object(transaction, 'owned'), patch.object(transaction, 'run') as command, \
patch.object(volumes, 'remove_empty_format_directories', side_effect=OSError('read failed')):
with self.assertRaises(OSError):
transaction.cleanup_restored_format_dirs(1000, deployment, 'identity')
self.assertEqual(command.call_args_list[-1].args, ('pct', 'unmount', '1000'))
with patch.object(transaction, 'owned', side_effect=ValueError('identity')), \
patch.object(transaction, 'run') as command:
with self.assertRaises(ValueError):
transaction.cleanup_restored_format_dirs(1000, deployment, 'identity')
command.assert_not_called()
def test_recover_menu_consumer_cleans_managed_and_preserves_ro_or_nested_bind(self):
for protected in ('none', 'readonly', 'nested-bind'):
with self.subTest(protected=protected), tempfile.TemporaryDirectory() as tmp:
root = Path(tmp); lost, _ = FormatDirectoryTests().fixture(root)
config = 'mp0: local:1000/vm-1000-disk-1.raw,mp=/config,backup=1'
if protected == 'readonly': config += ',ro=1'
if protected == 'nested-bind': config += '\nmp1: /host/recovery,mp=/config/lost+found,ro=1'
with ext4_metadata(), patch.object(recovery.carried, 'container_root', return_value=contextlib.nullcontext(root)), \
patch.object(volumes.os, 'rmdir', wraps=os.rmdir) as remove:
recovery.clean_format_directories({'vmid': 1000, 'config': config})
if protected == 'none':
self.assertFalse(lost.exists()); remove.assert_called_once()
else:
self.assertTrue(lost.is_dir()); remove.assert_not_called()
self.assertEqual((root / 'config/keep').read_bytes(), b'application data')
if __name__ == '__main__':
unittest.main()
@@ -1,7 +1,7 @@
"""Run the installer's volume preparation loop against filesystem fixtures.
Write doubles reject every mutation of the protected directory, including
lost+found removal. Writable paths still run real rm/chmod; chown is recorded
lost+found removal. Writable paths keep ordinary directories and run real chmod; chown is recorded
without requiring root. These tests never invoke Proxmox or touch a container.
"""
@@ -62,6 +62,8 @@ TEMPLATE_FILE="$FIXTURE/template.json"
DEPLOYMENT_FILE="$FIXTURE/deployment.json"
HOST_BIND_UID=110001; HOST_BIND_GID=110001
HOST_ROOT_UID=100000; HOST_ROOT_GID=100000
SCRIPT_DIR="$REMOTE"
oci_quiet() { printf "WRITE cleanup %s\n" "${!#}"; "$@"; }
failed=0; volumes_prepared=0
oci_log() { printf 'log %s\n' "$*"; }
write_op() {
@@ -87,7 +89,7 @@ test "$failed" = 0
'''
result = subprocess.run(
["bash", "-c", script], capture_output=True, text=True,
env=dict(os.environ, FIXTURE=str(fixture),
env=dict(os.environ, FIXTURE=str(fixture), REMOTE=str(ROOT / "remote"),
PROTECTED=str(protected) if read_only else ""),
timeout=15,
)
@@ -102,7 +104,7 @@ test "$failed" = 0
self.assertTrue((protected / "lost+found").is_dir())
elif "prepared=1" in result.stdout:
self.assertEqual(protected.stat().st_mode & 0o777, 0o750)
self.assertFalse((protected / "lost+found").exists())
self.assertTrue((protected / "lost+found").is_dir())
return result
def assert_skipped(self, result):
@@ -113,7 +115,7 @@ test "$failed" = 0
def assert_prepared(self, result):
self.assertEqual(result.returncode, 0, result.stderr + result.stdout)
for operation in ("rm", "chown", "chmod"):
for operation in ("cleanup", "chown", "chmod"):
self.assertIn(f"WRITE {operation} ", result.stdout)
self.assertIn("prepared=1", result.stdout)
+308
View File
@@ -0,0 +1,308 @@
"""The OCI menu shares the Monitor's checker and cache, with no fallback."""
import datetime
import json
import os
from pathlib import Path
import socket
import stat
import subprocess
import sys
import tempfile
import threading
from types import SimpleNamespace
import unittest
from unittest.mock import Mock, patch
import uuid
ROOT = Path(__file__).resolve().parents[1]
sys.path[:0] = [str(ROOT / 'src'), str(ROOT / 'remote'), str(ROOT.parent / 'AppImage/scripts')]
import oci_update_check as check
import oci_menu_bridge as bridge
import lxc_apps
OLD = 'sha256:' + 'a' * 64
NEW = 'sha256:' + 'b' * 64
class UpdateCheckTests(unittest.TestCase):
def setUp(self):
tmp = tempfile.TemporaryDirectory(); self.addCleanup(tmp.cleanup)
self.folder = Path(tmp.name)
self.root, self.configs, self.apps = [self.folder / name for name in ('instances', 'configs', 'apps')]
for path in (self.root, self.configs, self.apps): path.mkdir()
self.now = datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0)
def record(self, vmid=110, reference='example/app:latest', **changes):
identifier = str(uuid.uuid4())
record = {'schema_version': 1, 'vmid': vmid, 'installation_id': identifier,
'status': 'installed', 'deployment': {'hostname': f'app-{vmid}'},
'template': {'container_contract': {'image': {'reference': reference}}},
'observed': {'image': {'manifest_digest': OLD}}}
record.update(changes)
directory = self.root / str(vmid); directory.mkdir(exist_ok=True)
(directory / 'oci-compose.json').write_text(json.dumps(record))
(self.configs / f'{vmid}.conf').write_text(f'#proxmenux-instance={identifier}\narch: amd64\n')
return record
def sidecar(self, vmid=110, **changes):
state = {'checked_at': self.now.strftime('%Y-%m-%dT%H:%M:%SZ'),
'installed_digest': OLD, 'latest_digest': NEW,
'image_reference': 'example/app:latest', 'update_available': True}
state.update(changes)
data = {'vmid': vmid, 'apps': [{'id': f'app-{vmid}', 'installed_via': 'oci_image', 'state': state}]}
(self.apps / f'{vmid}.json').write_text(json.dumps(data))
return data
def status(self, **kwargs):
return check.cached(self.root, self.configs, apps=self.apps, now=self.now.timestamp(), **kwargs)
def test_menu_reads_only_native_cache_without_subprocess_or_socket(self):
self.record(); self.sidecar()
before = {str(p): p.read_bytes() for p in self.folder.rglob('*') if p.is_file()}
with patch.object(subprocess, 'run') as run, patch.object(socket, 'socket') as connection:
state = self.status()
self.assertEqual([r['vmid'] for r in state['available']], [110])
run.assert_not_called(); connection.assert_not_called()
after = {str(p): p.read_bytes() for p in self.folder.rglob('*') if p.is_file()}
self.assertEqual(before, after)
self.assertNotIn('resolve_candidate', check.__dict__)
self.assertNotIn('query', check.__dict__)
def test_missing_native_cache_is_unknown_even_with_old_separate_cache(self):
self.record()
(self.root / '.update-check.json').write_text(json.dumps({'applications': [{'vmid': 110, 'status': 'available'}]}))
state = self.status()
self.assertFalse(state['checked']); self.assertEqual(state['unknown'], 1)
self.assertFalse(state['available'])
def test_native_false_is_current_without_recomputing_digests(self):
self.record(); self.sidecar(update_available=False)
state = self.status()
self.assertTrue(state['checked']); self.assertEqual(state['unknown'], 0)
self.assertFalse(state['available'])
def test_native_retry_retains_available_result_and_unknown_state(self):
self.record(); self.sidecar(registry_retry=True)
self.assertEqual(len(self.status()['available']), 1)
self.assertEqual(self.status()['unknown'], 1)
def test_native_error_or_missing_result_never_means_up_to_date(self):
self.record()
for changes in ({'error': 'registry unreachable'}, {'update_available': None}, {'latest_digest': None}):
self.sidecar(**changes)
state = self.status()
self.assertFalse(state['available']); self.assertEqual(state['unknown'], 1)
def test_stale_future_or_different_image_cache_is_not_available(self):
self.record()
for changes in ({'checked_at': (self.now - datetime.timedelta(days=2)).strftime('%Y-%m-%dT%H:%M:%SZ')},
{'checked_at': (self.now + datetime.timedelta(days=2)).strftime('%Y-%m-%dT%H:%M:%SZ')},
{'installed_digest': NEW}, {'image_reference': 'other/image:latest'}):
self.sidecar(**changes)
self.assertFalse(self.status()['available'])
self.assertEqual(self.status()['unknown'], 1)
def test_invalid_identity_or_pending_native_edit_invalidates_cached_update(self):
self.record(); self.sidecar()
(self.configs / '110.conf').write_text('arch: amd64\n')
self.assertFalse(self.status()['available'])
self.record(pending_transaction={'operation': 'update'})
self.assertFalse(self.status()['available'])
def test_snapshot_identity_cannot_replace_current_identity(self):
record = self.record(); self.sidecar()
(self.configs / '110.conf').write_text(
f"arch: amd64\n\n[previous]\n#proxmenux-instance={record['installation_id']}\n")
self.assertFalse(self.status()['available'])
def test_remote_or_deleted_guest_is_excluded(self):
self.record(); self.sidecar(); (self.configs / '110.conf').unlink()
self.assertEqual(self.status()['applications'], 0)
self.assertFalse(self.status()['available'])
def test_corrupt_ambiguous_or_symlinked_sidecars_are_unknown(self):
self.record(); path = self.apps / '110.json'
path.write_text('{broken')
self.assertEqual(self.status()['unknown'], 1)
data = self.sidecar(); data['apps'] *= 2; path.write_text(json.dumps(data))
self.assertEqual(self.status()['unknown'], 1)
path.unlink(); path.symlink_to(self.root / '110/oci-compose.json')
self.assertEqual(self.status()['unknown'], 1)
def test_stack_counts_once_and_follows_monitors_primary_tracking_rule(self):
self.record(110, stack_member={'primary_vmid': 110}, stack={'members': [{'vmid': 110}, {'vmid': 111}]})
self.record(111, stack_member={'primary_vmid': 110})
self.sidecar(110, update_available=False); self.sidecar(111, update_available=True)
self.assertEqual(self.status()['applications'], 1)
self.assertFalse(self.status()['available'])
self.sidecar(110)
self.assertEqual([r['vmid'] for r in self.status()['available']], [110])
def test_incomplete_stack_is_not_offered_for_update(self):
self.record(110, stack={'members': [{'vmid': 110}, {'vmid': 111}]}); self.sidecar()
self.assertFalse(self.status()['available'])
self.assertEqual(self.status()['unknown'], 1)
def test_real_bridge_calls_native_checker_and_publishes_its_sidecar(self):
record = self.record(); self.sidecar(update_available=False)
path = self.folder / 'check.sock'
server = bridge._Server(str(path), bridge._Handler); server.apps = lxc_apps
worker = threading.Thread(target=server.serve_forever, daemon=True); worker.start()
self.addCleanup(server.server_close); self.addCleanup(server.shutdown)
result = {'installed_digest': OLD, 'installed_registry_digest': OLD,
'image_reference': 'example/app:latest', 'installed_version': '1.0',
'latest_version': '2.0', 'latest_digest': NEW, 'update_available': True}
events = []
with patch.object(bridge, '_root_peer', return_value=True), \
patch.object(check, 'monitor_available', return_value=True), \
patch.object(lxc_apps, '_APPS_DIR', str(self.apps)), \
patch.object(lxc_apps, '_read_oci_record', return_value=record), \
patch.object(lxc_apps, 'ensure_oci_registration'), \
patch.object(lxc_apps, '_oci_image_versions', return_value=result) as native, \
patch.object(lxc_apps, '_fire_update_notification') as notify, \
patch.object(subprocess, 'run') as external:
check.check(self.root, self.configs, path=path, progress=lambda *event: events.append(event))
native.assert_called_once(); notify.assert_not_called(); external.assert_not_called()
self.assertEqual(events[0], (0, 1)); self.assertEqual(events[-1], (1, 1))
self.assertEqual([r['vmid'] for r in self.status()['available']], [110])
self.assertFalse((self.root / '.update-check.json').exists())
def test_missing_monitor_never_falls_back_to_registry_commands(self):
self.record()
with patch.object(check, 'monitor_available', return_value=False), patch.object(subprocess, 'run') as run:
with self.assertRaisesRegex(RuntimeError, 'Monitor'):
check.check(self.root, self.configs)
run.assert_not_called()
def test_readonly_monitor_availability_rejects_non_private_endpoints(self):
path = Mock(); path.parent.lstat.return_value = SimpleNamespace(st_mode=stat.S_IFDIR | 0o700, st_uid=0)
path.lstat.return_value = SimpleNamespace(st_mode=stat.S_IFSOCK | 0o600, st_uid=0)
with patch.object(check.socket, 'socket'):
self.assertTrue(check.monitor_available(path))
path.lstat.return_value.st_mode |= 0o066
self.assertFalse(check.monitor_available(path))
path.lstat.return_value.st_mode = stat.S_IFREG | 0o600
self.assertFalse(check.monitor_available(path))
path.lstat.return_value.st_mode = stat.S_IFSOCK | 0o600
path.parent.lstat.return_value.st_uid = 1000
self.assertFalse(check.monitor_available(path))
def test_socket_left_by_a_stopped_monitor_is_not_available(self):
folder = Path(tempfile.mkdtemp(dir='/tmp')); self.addCleanup(lambda: __import__('shutil').rmtree(folder))
folder.chmod(0o700); path = folder / 'check.sock'
listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM); listener.bind(str(path)); path.chmod(0o600)
listener.listen(1)
owner = SimpleNamespace(st_uid=0)
real_lstat = Path.lstat
def lstat(self):
value = real_lstat(self)
return SimpleNamespace(st_mode=value.st_mode, st_uid=owner.st_uid)
with patch.object(Path, 'lstat', lstat):
self.assertTrue(check.monitor_available(path))
listener.close() # The socket file stays, nothing listens.
self.assertTrue(path.exists())
self.assertFalse(check.monitor_available(path))
def test_failed_application_keeps_no_older_result(self):
self.record(); self.sidecar(update_available=True)
self.assertEqual([r['vmid'] for r in self.status()['available']], [110])
state = self.status(failed={110})
self.assertEqual(state['available'], [])
self.assertEqual(state['unknown'], 1)
self.assertTrue(state['checked'])
def test_full_or_closed_progress_pipe_does_not_abort_client(self):
for error in (BlockingIOError(), BrokenPipeError()):
with patch.object(check.os, 'write', side_effect=error), patch.object(check.sys, 'stdout'):
check.emit_progress(1, 2)
class BridgeTests(unittest.TestCase):
def module(self):
module = Mock()
module._read_oci_record.return_value = {'vmid': 110, 'installation_id': 'id-110', 'status': 'installed'}
module._oci_secondary_member.return_value = False
module.load_sidecar.return_value = {'apps': [{'id': 'native-app', 'installed_via': 'oci_image'},
{'id': 'other-app', 'installed_via': 'dpkg'}]}
return module
def test_only_native_oci_app_is_checked_without_unsolicited_notifications(self):
module = self.module(); events = []
bridge.check_applications(module, [{'vmid': 110, 'installation_id': 'id-110'}], events.append)
module.check_app.assert_called_once_with(110, 'native-app', force=True, notify=False)
self.assertEqual(events, [{'completed': 0, 'total': 1}, {'completed': 1, 'total': 1}, {'done': True, 'failed': []}])
def test_replaced_guest_pending_operation_and_secondary_are_not_queried(self):
for changes, secondary in (({'installation_id': 'replacement'}, False),
({'pending_transaction': {'operation': 'update'}}, False),
({'pending_stack_transaction': {'operation': 'update'}}, False),
({}, True)):
module = self.module(); module._read_oci_record.return_value.update(changes)
module._oci_secondary_member.return_value = secondary
bridge.check_applications(module, [{'vmid': 110, 'installation_id': 'id-110'}], lambda _: None)
module.check_app.assert_not_called()
def test_dismissed_registration_is_not_reinvented(self):
module = self.module(); module.load_sidecar.return_value = None
bridge.check_applications(module, [{'vmid': 110, 'installation_id': 'id-110'}], lambda _: None)
module.check_app.assert_not_called()
def test_progress_advances_when_fast_app_finishes_before_first_slow_app(self):
module = self.module(); release = threading.Event(); events = []
module._read_oci_record.side_effect = lambda vmid: {
'vmid': vmid, 'installation_id': f'id-{vmid}', 'status': 'installed'}
def run(vmid, *args, **kwargs):
if vmid == 110 and not release.wait(3):
raise AssertionError('The fast app was blocked by the slow app')
def report(event):
events.append(event)
if event.get('completed') == 1: release.set()
module.check_app.side_effect = run
bridge.check_applications(module, [{'vmid': 110, 'installation_id': 'id-110'},
{'vmid': 111, 'installation_id': 'id-111'}], report)
self.assertEqual(events, [{'completed': 0, 'total': 2}, {'completed': 1, 'total': 2},
{'completed': 2, 'total': 2}, {'done': True, 'failed': []}])
self.assertEqual(module.check_app.call_count, 2)
def test_unexpected_failure_does_not_skip_other_apps_and_names_the_failed_app(self):
module = self.module(); events = []
module._read_oci_record.side_effect = lambda vmid: {
'vmid': vmid, 'installation_id': f'id-{vmid}', 'status': 'installed'}
module.check_app.side_effect = lambda vmid, *a, **kw: (_ for _ in ()).throw(RuntimeError()) if vmid == 110 else None
bridge.check_applications(module, [{'vmid': 110, 'installation_id': 'id-110'},
{'vmid': 111, 'installation_id': 'id-111'}], events.append)
self.assertEqual(module.check_app.call_count, 2)
self.assertEqual(events[-2], {'completed': 2, 'total': 2})
self.assertEqual(events[-1], {'done': True, 'failed': [110]})
def test_requests_cannot_specify_commands_images_paths_or_duplicate_guests(self):
valid = {'vmid': 110, 'installation_id': 'id'}
for request in ({'command': ['sh']}, {'applications': '110'}, {'applications': [valid, valid]},
{'applications': [{**valid, 'image': 'private:image'}]},
{'applications': [{**valid, 'vmid': '../110'}]},
{'applications': [{**valid, 'vmid': True}]}):
with self.subTest(request=request), self.assertRaises(ValueError): bridge._applications(request)
def test_client_disconnect_stops_before_next_application(self):
module = self.module(); calls = []
def report(event):
calls.append(event)
if event.get('completed') == 1: raise BrokenPipeError()
with self.assertRaises(BrokenPipeError):
bridge.check_applications(module, [{'vmid': 110, 'installation_id': 'id-110'},
{'vmid': 111, 'installation_id': 'id-111'}], report)
self.assertEqual(module.check_app.call_count, 1)
def test_non_root_peers_are_rejected_before_reading_requests(self):
handler = object.__new__(bridge._Handler); handler.request = Mock(); handler.rfile = Mock()
with patch.object(bridge, '_root_peer', return_value=False): handler.handle()
handler.rfile.readline.assert_not_called()
def test_build_and_monitor_startup_include_bridge(self):
scripts = ROOT.parent / 'AppImage/scripts'
self.assertIn('oci_menu_bridge.py', (scripts / 'build_appimage.sh').read_text())
self.assertIn('oci_menu_bridge.start(lxc_apps)', (scripts / 'flask_server.py').read_text())
if __name__ == '__main__':
unittest.main()
+297
View File
@@ -0,0 +1,297 @@
"""Explicit update selection and live status redraw without lifecycle shortcuts."""
from pathlib import Path
import ast
import json
import os
import re
import signal
import subprocess
import sys
from types import SimpleNamespace
import unittest
from unittest.mock import Mock, patch
ROOT = Path(__file__).resolve().parents[1]
sys.path[:0] = [str(ROOT / 'src'), str(ROOT / 'remote')]
from proxmenux_oci import cli, management, updates
from proxmenux_oci.ui import DialogUI, MenuRefresh, ReadableTags, UserCancelled
AVAILABLE = [{'vmid': 110, 'title': 'First app'}, {'vmid': 111, 'title': 'Second app'}]
class SelectionTests(unittest.TestCase):
def select(self, picked, available=AVAILABLE, review=True, fresh=None, outcomes=None):
ui = Mock()
ui.checklist.side_effect = picked if isinstance(picked, Exception) else [picked]
ui.review.return_value = review
current = [{**row, 'reason': 'matched'} for row in available]
with patch.object(updates, 'UpdateCheck') as checker, \
patch.object(management, 'saved_inventory', return_value=current), \
patch.object(management, 'manage_instance', side_effect=outcomes or [True] * len(available)) as run:
checker.return_value.status.return_value = {'available': available if fresh is None else fresh}
result = updates.select_updates(ROOT, ui, available)
return result, ui, run
def test_one_update_is_not_checked_by_default(self):
_, ui, run = self.select([], available=AVAILABLE[:1])
self.assertEqual(ui.checklist.call_args.args[2], [])
self.assertEqual(ui.checklist.call_args.args[1][0], ('all', 'Select all'))
run.assert_not_called()
def test_only_selected_app_uses_existing_update_flow(self):
_, ui, run = self.select(['111'])
self.assertEqual(run.call_count, 1)
self.assertEqual(run.call_args.args[2]['vmid'], 111)
self.assertEqual(run.call_args.kwargs, {'action': 'update'})
self.assertIn('Second app', ui.review.call_args.args[0])
self.assertNotIn('First app', ui.review.call_args.args[0])
def test_select_all_includes_every_app_once(self):
_, ui, run = self.select(['all', '111'])
self.assertEqual([c.args[2]['vmid'] for c in run.call_args_list], [110, 111])
self.assertFalse(ui.review.call_args.kwargs['default'])
def test_cancel_does_nothing(self):
_, ui, run = self.select(UserCancelled())
ui.review.assert_not_called(); run.assert_not_called()
def test_selection_has_an_update_button_and_no_extra_action(self):
result, ui, run = self.select([])
self.assertEqual(ui.checklist.call_args.kwargs['ok_label'], 'Update')
self.assertNotIn('extra_button', ui.checklist.call_args.kwargs)
self.assertFalse(result); ui.review.assert_not_called(); run.assert_not_called()
def test_changed_app_blocks_batch_before_any_lifecycle(self):
_, ui, run = self.select(['all'], fresh=AVAILABLE[1:])
run.assert_not_called()
self.assertIn('changed since', ui.message.call_args.args[0])
def test_failure_or_per_app_cancel_stops_later_selections(self):
_, _, run = self.select(['all'], outcomes=[False])
self.assertEqual(run.call_count, 1)
def test_declining_review_returns_to_selection_instead_of_updating(self):
ui = Mock(); ui.checklist.side_effect = [['all'], []]; ui.review.return_value = False
with patch.object(management, 'manage_instance') as run:
self.assertFalse(updates.select_updates(ROOT, ui, AVAILABLE))
run.assert_not_called()
self.assertEqual(ui.checklist.call_args.args[2], ['110', '111'])
class StatusTests(unittest.TestCase):
def checker(self):
return updates.UpdateCheck(ROOT)
def test_labels_show_cached_count_or_manual_check(self):
checker = self.checker()
self.assertEqual(checker.label({'available': []}), 'Check for updates')
self.assertEqual(checker.label({'available': AVAILABLE}), 'Updates available (2)')
checker.process = Mock(stdout=None); checker.process.poll.return_value = None
self.assertEqual(checker.label({'available': AVAILABLE}), 'Checking for updates (0/0)')
def test_partial_check_keeps_the_entry_clean_without_a_header_warning(self):
checker = self.checker()
state = {'checked': True, 'available': [], 'unknown': 1, 'applications': 2}
self.assertEqual(checker.label(state), 'Check for updates')
self.assertEqual(checker.notice(state), '')
def test_completed_checks_show_only_the_update_count(self):
state = {'checked': True, 'available': AVAILABLE, 'unknown': 0, 'applications': 2}
self.assertEqual(self.checker().label(state), 'Updates available (2)')
def progress_checker(self):
reader, writer = os.pipe()
stream = os.fdopen(reader, 'rb', buffering=0)
self.addCleanup(stream.close); self.addCleanup(os.close, writer)
os.set_blocking(reader, False)
checker = self.checker(); checker.total = 24
checker.process = Mock(stdout=stream); checker.process.poll.return_value = None
return checker, writer
def test_numeric_progress_redraws_only_when_the_count_changes(self):
checker, writer = self.progress_checker()
self.assertEqual(checker.label({}), 'Checking for updates (0/24)')
self.assertFalse(checker.changed())
os.write(writer, b'{"completed":12,"total":24}\n')
self.assertTrue(checker.changed())
self.assertEqual(checker.label({}), 'Checking for updates (12/24)')
self.assertFalse(checker.changed())
os.write(writer, b'{"completed":12,"total":24}\n')
self.assertFalse(checker.changed())
def test_split_and_invalid_worker_output_never_appears_in_the_menu(self):
checker, writer = self.progress_checker()
os.write(writer, b'password=secret\n{"completed":-1,"total":24}\n{"completed":')
self.assertEqual(checker.label({}), 'Checking for updates (0/24)')
os.write(writer, b'7,"total":24}\n{"completed":25,"total":24}\n')
self.assertEqual(checker.label({}), 'Checking for updates (7/24)')
def test_worker_failure_does_not_reuse_an_old_successful_result(self):
checker = self.checker(); checker.process = Mock(stdout=None)
checker.process.poll.return_value = checker.process.wait.return_value = 1
old = {'checked': True, 'available': AVAILABLE, 'unknown': 0, 'applications': 2}
with patch.object(checker.backend, 'cached', return_value=old):
state = checker.status()
self.assertEqual(checker.label(state), 'Check for updates')
self.assertEqual(state['available'], [])
self.assertEqual(checker.notice(state), 'The update check could not be completed. Try again later.')
def test_finished_worker_keeps_the_final_redraw_pending_until_status_is_consumed(self):
checker = self.checker(); checker.completed = checker.total = 2
checker.process = Mock(stdout=None)
checker.process.poll.return_value = checker.process.wait.return_value = 0
self.assertFalse(checker.running)
self.assertTrue(checker.active)
self.assertEqual(checker.label({}), 'Checking for updates (2/2)')
self.assertTrue(checker.changed())
state = {'checked': True, 'available': AVAILABLE, 'unknown': 0, 'applications': 2}
with patch.object(checker.backend, 'cached', return_value=state):
final = checker.status()
self.assertFalse(checker.active)
self.assertEqual(checker.label(final), 'Updates available (2)')
def test_check_process_is_isolated_and_displays_no_registry_errors(self):
checker = self.checker()
with patch.object(subprocess, 'Popen') as popen, patch.object(updates.os, 'set_blocking') as blocking, \
patch.object(checker.backend, 'monitor_available', return_value=True):
checker.start()
args = popen.call_args
self.assertTrue(args.kwargs['start_new_session'])
self.assertEqual(args.kwargs['stderr'], subprocess.DEVNULL)
self.assertEqual(args.args[0][1], str(ROOT / 'remote/oci_update_check.py'))
self.assertIn('--progress', args.args[0])
self.assertEqual(args.kwargs['stdout'], subprocess.PIPE)
blocking.assert_called_once_with(checker.process.stdout.fileno(), False)
def test_failed_applications_are_reported_and_cleared_by_a_new_check(self):
checker = self.checker(); read, write = os.pipe()
os.write(write, b'{"completed": 2, "total": 2}\n{"failed": [110]}\n'); os.close(write)
os.set_blocking(read, False)
checker.process = Mock(stdout=Mock(fileno=Mock(return_value=read)))
checker.process.poll.return_value = checker.process.wait.return_value = 0
state = {'checked': True, 'available': [], 'unknown': 1, 'applications': 2}
with patch.object(checker.backend, 'cached', return_value=state) as cached:
final = checker.status()
os.close(read)
self.assertEqual(cached.call_args.kwargs['failed'], {110})
self.assertEqual(checker.notice(final),
'Some applications could not be checked (1). Try again later.')
with patch.object(subprocess, 'Popen'), patch.object(updates.os, 'set_blocking'), \
patch.object(checker.backend, 'monitor_available', return_value=True), \
patch.object(checker.backend, 'cached', return_value=state):
checker.start()
self.assertEqual(checker.failed_applications, set())
def test_a_failed_check_is_reported_in_the_menu(self):
checker = self.checker(); checker.failed = True
self.assertEqual(checker.notice({}), 'The update check could not be completed. Try again later.')
def test_missing_monitor_never_starts_a_fallback_checker(self):
checker = self.checker()
with patch.object(checker.backend, 'monitor_available', return_value=False), \
patch.object(subprocess, 'Popen') as popen:
with self.assertRaisesRegex(ValueError, 'ProxMenux Monitor'):
checker.start()
popen.assert_not_called()
def test_cancelling_stops_the_worker_process_group(self):
checker = self.checker(); process = Mock(); checker.process = process
process.pid = 4321; process.poll.return_value = None
with patch.object(updates.os, 'killpg') as kill:
checker.close()
kill.assert_called_once_with(4321, signal.SIGTERM)
process.wait.assert_called_once_with(timeout=2)
def test_finished_worker_is_not_signalled_after_its_pid_could_be_reused(self):
checker = self.checker(); checker.process = Mock(); checker.process.poll.return_value = 0
with patch.object(updates.os, 'killpg') as kill:
checker.close()
kill.assert_not_called()
class DialogTests(unittest.TestCase):
def test_status_change_redraws_menu_and_reaps_dialog(self):
process = Mock(); process.poll.return_value = None
process.communicate.side_effect = [subprocess.TimeoutExpired('dialog', .1), (None, '')]
popen = Mock(); popen.__enter__ = Mock(return_value=process); popen.__exit__ = Mock(return_value=False)
with patch.object(subprocess, 'Popen', return_value=popen):
with self.assertRaises(MenuRefresh):
DialogUI().choose('OCI', ReadableTags([('updates', 'Checking for updates...')]),
'updates', size=(20, 75, 12), refresh=lambda: True)
process.terminate.assert_called_once()
self.assertEqual(process.communicate.call_count, 2)
def test_escape_from_live_menu_remains_cancel(self):
process = Mock(); process.poll.return_value = 255; process.returncode = 255
process.communicate.return_value = (None, '')
popen = Mock(); popen.__enter__ = Mock(return_value=process); popen.__exit__ = Mock(return_value=False)
with patch.object(subprocess, 'Popen', return_value=popen):
self.assertIsNone(DialogUI().choose('OCI', [('1', 'Checking')], refresh=lambda: False))
process.terminate.assert_not_called()
def test_checklist_starts_unselected_with_update_and_cancel_only(self):
ui = DialogUI()
with patch.object(ui, '_run', return_value=SimpleNamespace(returncode=0, stderr='111\n')) as run:
picked = ui.checklist('Updates', [('all', 'Select all'), ('111', 'App')], [],
ok_label='Update')
self.assertEqual(picked, ['111'])
widget = run.call_args.args[0]
self.assertNotIn('--extra-button', widget)
self.assertEqual(widget[widget.index('--ok-label') + 1], 'Update')
self.assertNotIn('on', widget)
class MainMenuTests(unittest.TestCase):
def test_status_changes_in_the_same_main_menu_entry(self):
class Checker:
running = False
completed = 0
@property
def active(self): return self.running
def __enter__(self): return self
def __exit__(self, *exc): pass
def start(self): self.running = True
def status(self): return {'available': AVAILABLE if getattr(self, 'done', False) else []}
def changed(self): return True
def label(self, state):
return (f'Checking for updates ({self.completed}/2)' if self.running else
'Updates available (2)' if state['available'] else 'Check for updates')
def notice(self, state): return ''
checker = Checker(); seen = []
class UI:
live_refresh = True
def choose(self, text, entries, default, **kw):
seen.append(dict(entries)['updates'])
if len(seen) == 1: return 'updates'
if len(seen) == 2:
checker.completed = 1
raise MenuRefresh()
if len(seen) == 3:
checker.running = False; checker.done = True
raise MenuRefresh()
return None
with patch.object(cli, 'interactive_ui', return_value=UI()), \
patch.object(updates, 'UpdateCheck', return_value=checker):
self.assertEqual(cli.interactive(cli.Catalog(ROOT)), 0)
self.assertEqual(seen, ['Check for updates', 'Checking for updates (0/2)',
'Checking for updates (1/2)', 'Updates available (2)'])
class TranslationTests(unittest.TestCase):
def test_update_menu_literals_are_translated_in_every_shipped_language(self):
source = ast.parse((ROOT / 'src/proxmenux_oci/updates.py').read_text())
texts = {node.args[0].value for node in ast.walk(source)
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name)
and node.func.id == 'translate' and node.args
and isinstance(node.args[0], ast.Constant)}
for path in sorted((ROOT.parent / 'lang').glob('*.json')):
dictionary = json.loads(path.read_text())
for text in texts:
with self.subTest(locale=path.stem, text=text):
self.assertTrue(dictionary.get(text))
self.assertEqual(set(re.findall(r'\{\w+\}', text)),
set(re.findall(r'\{\w+\}', dictionary[text])))
if __name__ == '__main__':
unittest.main()
+73
View File
@@ -0,0 +1,73 @@
"""Native image checks reuse immutable metadata only after a fresh digest lookup."""
import json
from pathlib import Path
import sys
import unittest
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'remote'))
import oci_installation_state as state
def digest(value): return 'sha256:' + state.sha(value)
class MetadataTests(unittest.TestCase):
def setUp(self):
self.platform = json.dumps({'schemaVersion': 2, 'config': {'digest': 'sha256:' + 'c' * 64},
'layers': [{'digest': 'sha256:' + 'd' * 64, 'size': 15}]}).encode()
self.index = json.dumps({'schemaVersion': 2, 'manifests': [
{'digest': digest(self.platform), 'platform': {'architecture': 'amd64', 'os': 'linux'}}]}).encode()
self.config = json.dumps({'architecture': 'amd64', 'os': 'linux', 'created': '2026-10-18T00:00:00Z',
'config': {'Labels': {'org.opencontainers.image.version': '2.0'}, 'Env': []}}).encode()
self.known = {'manifest_digest': digest(self.platform), 'architecture': 'amd64',
'version': '1.0', 'created': '2026-10-01T00:00:00Z'}
def test_unchanged_multiarch_image_requires_only_a_fresh_index_lookup(self):
with patch.object(state, 'command', return_value=self.index) as command:
candidate = state.resolve_candidate_metadata('ghcr.io/example/private:latest', 'amd64', self.known)
command.assert_called_once_with('skopeo', 'inspect', '--raw', 'docker://ghcr.io/example/private:latest')
self.assertEqual(candidate['manifest_digest'], self.known['manifest_digest'])
self.assertEqual(candidate['version'], '1.0')
self.assertEqual(candidate['registry_digest'], digest(self.index))
def test_unchanged_singlearch_image_requires_only_a_fresh_manifest_lookup(self):
with patch.object(state, 'command', return_value=self.platform) as command:
candidate = state.resolve_candidate_metadata('example/app:latest', 'amd64', self.known)
self.assertEqual(command.call_count, 1)
self.assertEqual(candidate['version'], '1.0')
def test_changed_digest_fetches_and_validates_new_metadata(self):
old = dict(self.known, manifest_digest='sha256:' + 'a' * 64)
with patch.object(state, 'command', side_effect=[self.index, self.platform, self.config]) as command:
candidate = state.resolve_candidate_metadata('example/app:latest', 'amd64', old)
self.assertEqual(command.call_count, 3)
self.assertEqual(candidate['version'], '2.0')
self.assertEqual(candidate['manifest_digest'], digest(self.platform))
def test_different_architecture_cannot_reuse_metadata(self):
known = dict(self.known, architecture='arm64')
with patch.object(state, 'command', side_effect=[self.index, self.platform, self.config]) as command:
candidate = state.resolve_candidate_metadata('example/app:latest', 'amd64', known)
self.assertEqual(command.call_count, 3)
self.assertEqual(candidate['version'], '2.0')
def test_install_and_update_still_fetch_complete_verified_contract(self):
with patch.object(state, 'command', side_effect=[self.index, self.platform, self.config]) as command:
candidate = state.resolve_candidate('example/app:latest', 'amd64')
self.assertEqual(command.call_count, 3)
self.assertEqual(candidate['layers'], ['sha256:' + 'd' * 64])
self.assertIn('Env', candidate['defaults'])
def test_changed_manifest_must_still_match_the_registry_digest(self):
with patch.object(state, 'command', side_effect=[self.index, b'{}']):
with self.assertRaises(ValueError):
state.resolve_candidate_metadata('example/app:latest', 'amd64', None)
def test_registry_failure_cannot_be_hidden_by_saved_metadata(self):
with patch.object(state, 'command', side_effect=RuntimeError('Registry unavailable')):
with self.assertRaises(RuntimeError):
state.resolve_candidate_metadata('example/app:latest', 'amd64', self.known)
if __name__ == '__main__': unittest.main()
@@ -0,0 +1,48 @@
// Real panel interactions with inert API fixtures; no guest or registration.
const assert = require('node:assert/strict')
const {fixture, text} = require('./test_native_app_discovery_ui.cjs')
const candidate = {
vmid:101,slug:'docker-'+'b'.repeat(24),discovery_id:'docker-'+'b'.repeat(24),
source:'docker_metadata',name:'Uncatalogued web app',default_ports:[8080],
installed_version:null,tracking_suggestion:null,
evidence:[{kind:'docker_container',value:'web-1'},{kind:'docker_image',value:'example/web:7'},{kind:'tcp_listener',value:'8080'}],
}
async function main() {
const f = fixture({nativeCandidate:null,dockerCandidate:candidate})
f.render(); await f.settle()
assert.equal(f.calls.filter(c=>c.method==='POST').length,0)
await f.button('searchApplications').props.onClick(); f.render()
assert.equal(f.calls.filter(c=>c.url.endsWith('/docker-discovery')&&c.method==='POST').length,1)
assert.ok(text(f.tree()).includes('example/web:7'))
assert.ok(text(f.tree()).includes('dockerEndpointReview'))
assert.ok(text(f.tree()).includes('dockerEvidencePort'),'published mapping is not claimed as a probed listener')
assert.ok(text(f.tree()).includes('nativeVersionUnknown'))
await f.button('registerButton').props.onClick(); f.render()
f.button('cancelButton').props.onClick(); f.render()
assert.equal(f.calls.filter(c=>/\/apps$/.test(c.url)&&c.method==='POST').length,0)
await f.button('registerButton').props.onClick(); f.render()
await f.button('saveButton').props.onClick(); f.render()
const saved=JSON.parse(f.calls.find(c=>/\/apps$/.test(c.url)&&c.method==='POST').body)
assert.equal(saved.discovery_id,candidate.discovery_id)
assert.equal(saved.update_method,'none')
assert.ok(!saved.repo && !saved.helper_slug && !saved.update_command)
for (const status of ['error','partial','busy']) {
const failed=fixture({nativeCandidate:null,dockerCandidate:candidate})
failed.render(); await failed.settle(); failed.setDockerStatus(status)
await failed.button('searchApplications').props.onClick(); failed.render()
assert.ok(text(failed.tree()).includes(status==='partial'?'dockerScanPartial':'dockerScanFailed'))
assert.ok(!text(failed.tree()).includes('noApplicationsDetected'))
}
const changed=fixture({nativeCandidate:null,dockerCandidate:candidate})
changed.render(); await changed.settle(); changed.holdDocker()
const pending=changed.button('searchApplications').props.onClick()
await changed.settle(); changed.render({vmid:102,initialData:{sidecar:{apps:[]},suggestions:{extras:[],port_suggestions:[]}}})
changed.resolveDocker(); await pending; changed.render()
assert.ok(!text(changed.tree()).includes(candidate.name),'late Docker result never crosses CT')
const oci=fixture({nativeCandidate:null,dockerCandidate:candidate})
oci.render({vmid:101,oci:{instance:true},initialData:{sidecar:{apps:[]},suggestions:{oci_instance:{template_id:'image-demo'},port_suggestions:[]}}})
await oci.settle(); await oci.button('refreshData').props.onClick()
assert.equal(oci.calls.filter(c=>c.url.endsWith('/docker-discovery')&&c.method==='POST').length,0)
console.log('PASS Docker Find: explicit scan, generic evidence, Cancel/Save, failure/partial, CT switch, OCI no-scan')
}
main().catch(error=>{console.error(error);process.exitCode=1})
@@ -23,11 +23,13 @@ const candidate = {
evidence: [{kind: 'installation_file', value: '/opt/handmade/server'}], default_ports: [],
}
function fixture({nativeCandidate = candidate, apps = []} = {}) {
function fixture({nativeCandidate = candidate, dockerCandidate = null, apps = []} = {}) {
let cursor = 0, tree, props, effects = [], states = [], refs = [], effectStates = [], memoStates = []
const calls = [], cachedWrites = []
let native = {vmid: 101, status: 'not_scanned', candidates: [], stale: false}
let holdScan, scanGate, holdSave, saveGate, scanStatus = 'complete', scanIssues
let dockerStatus = "complete", dockerCached = {vmid:101,status:"not_scanned",candidates:[],stale:false}
let holdDocker, dockerGate
let cacheGate, resolveCache, cachePending = false
let iconGate, resolveIcon, iconResult = {logo_url: '/api/vms/101/apps/logo-cache/' + 'b'.repeat(32)}, iconError
const seed = {sidecar: {vmid: 101, apps}, suggestions: {extras: [], port_suggestions: []}}
@@ -68,10 +70,18 @@ function fixture({nativeCandidate = candidate, apps = []} = {}) {
}
if (options.method === 'POST') {
if (scanGate) await scanGate
native = {...native, status: scanStatus, issues: scanIssues, candidates: [nativeCandidate]}
native = {...native, status: scanStatus, issues: scanIssues, candidates: nativeCandidate ? [nativeCandidate] : []}
}
return structuredClone(native)
}
if (url.endsWith('/docker-discovery')) {
if (options.method === 'POST') {
if (dockerGate) await dockerGate
dockerCached = {vmid:Number(url.split('/')[3]),status:dockerStatus,
stale:dockerStatus !== 'complete',candidates:dockerCandidate ? [dockerCandidate] : []}
}
return structuredClone(dockerCached)
}
if (url.endsWith('/apps/suggestions')) return seed.suggestions
if (url.endsWith('/apps') && options.method === 'POST') {
if (saveGate) await saveGate
@@ -138,6 +148,8 @@ function fixture({nativeCandidate = candidate, apps = []} = {}) {
holdInitialCache: () => {cachePending = true; cacheGate = new Promise(resolve => {resolveCache = resolve})},
resolveInitialCache: () => resolveCache(),
setScanStatus: value => {scanStatus = value},
setDockerStatus: value => {dockerStatus = value},
holdDocker: () => {dockerGate = new Promise(resolve => {holdDocker = resolve})}, resolveDocker: () => holdDocker(),
setScanIssues: value => {scanIssues = value},
holdScan: () => {scanGate = new Promise(resolve => {holdScan = resolve})}, resolveScan: () => holdScan(),
holdSave: () => {saveGate = new Promise(resolve => {holdSave = resolve})}, resolveSave: () => holdSave(),