mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-01 10:22:08 +00:00
Merge develop into local application icon discovery
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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/**'
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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" && (
|
||||
|
||||
@@ -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}` : ""}`}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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. */}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))]}
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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=[]):
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"amd64"
|
||||
],
|
||||
"launch": {
|
||||
"scheme": "http",
|
||||
"scheme": "https",
|
||||
"port": 5800,
|
||||
"path": "/"
|
||||
},
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"amd64"
|
||||
],
|
||||
"launch": {
|
||||
"scheme": "http",
|
||||
"scheme": "https",
|
||||
"port": 5800,
|
||||
"path": "/"
|
||||
},
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
@@ -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:
|
||||
|
||||
@@ -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())
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user