mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-01 18:32:13 +00:00
Merge pull request #441 from Vaso73/fix/lxc-observed-state
fix: revalidate LXC data after external changes
This commit is contained in:
@@ -202,6 +202,13 @@ class CommandDescriptionsTests(unittest.TestCase):
|
|||||||
key, english["backup"]["destinations"][key])
|
key, english["backup"]["destinations"][key])
|
||||||
messages["backup"]["actions"].setdefault(
|
messages["backup"]["actions"].setdefault(
|
||||||
"prepareSshKey", english["backup"]["actions"]["prepareSshKey"])
|
"prepareSshKey", english["backup"]["actions"]["prepareSshKey"])
|
||||||
|
# New LXC observation labels intentionally ship only EN/SK.
|
||||||
|
# Model the bot's future output in temporary catalogs, just
|
||||||
|
# like the other pending keys above; never rewrite locales.
|
||||||
|
for key in ("checkNow", "checkReadOnlyHelp", "checkCooldown",
|
||||||
|
"checkStale", "checkStaleHelp"):
|
||||||
|
messages["vmLxc"]["updates"].setdefault(
|
||||||
|
key, english["vmLxc"]["updates"][key])
|
||||||
path.write_text(json.dumps(messages, ensure_ascii=False))
|
path.write_text(json.dumps(messages, ensure_ascii=False))
|
||||||
before = {p: p.read_bytes() for p in root.glob("*/common.json")}
|
before = {p: p.read_bytes() for p in root.glob("*/common.json")}
|
||||||
argv = [str(SCRIPT), "--source", str(root / "en/common.json"),
|
argv = [str(SCRIPT), "--source", str(root / "en/common.json"),
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { formatStorage } from "../lib/utils"
|
|||||||
import { formatNetworkTraffic, getNetworkUnit } from "../lib/format-network"
|
import { formatNetworkTraffic, getNetworkUnit } from "../lib/format-network"
|
||||||
import { fetchApi } from "../lib/api-config"
|
import { fetchApi } from "../lib/api-config"
|
||||||
import { matchesVmSearch, normalizeVmSearchValue } from "../lib/vm-search"
|
import { matchesVmSearch, normalizeVmSearchValue } from "../lib/vm-search"
|
||||||
|
import { checkLxcUpdates, isLxcUpdateStale } from "../lib/lxc-update-observation"
|
||||||
import DOMPurify from "dompurify"
|
import DOMPurify from "dompurify"
|
||||||
import { marked } from "marked"
|
import { marked } from "marked"
|
||||||
import { getCountFormKey, useI18n, useT } from "@/lib/i18n/provider"
|
import { getCountFormKey, useI18n, useT } from "@/lib/i18n/provider"
|
||||||
@@ -84,6 +85,9 @@ interface LxcUpdateCheck {
|
|||||||
helper_app_name?: string | null
|
helper_app_name?: string | null
|
||||||
helper_updateable_known?: boolean
|
helper_updateable_known?: boolean
|
||||||
os_family?: string | null
|
os_family?: string | null
|
||||||
|
stale?: boolean
|
||||||
|
age_seconds?: number | null
|
||||||
|
stale_after_seconds?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
// Summary attached to LXC rows in /api/vms when the user has
|
// Summary attached to LXC rows in /api/vms when the user has
|
||||||
@@ -912,6 +916,8 @@ export function VirtualMachines() {
|
|||||||
// "settled" when the observed count differs from this baseline
|
// "settled" when the observed count differs from this baseline
|
||||||
// (or after a 15 s safety timeout).
|
// (or after a 15 s safety timeout).
|
||||||
const [updatesRefreshing, setUpdatesRefreshing] = useState(false)
|
const [updatesRefreshing, setUpdatesRefreshing] = useState(false)
|
||||||
|
const [osCheckingVmid, setOsCheckingVmid] = useState<number | null>(null)
|
||||||
|
const [osCheckError, setOsCheckError] = useState<string | null>(null)
|
||||||
const [updatesResult, setUpdatesResult] = useState<{ pendingAfter: number; appliedCount: number } | null>(null)
|
const [updatesResult, setUpdatesResult] = useState<{ pendingAfter: number; appliedCount: number } | null>(null)
|
||||||
const [updatesBaselineCount, setUpdatesBaselineCount] = useState<number | null>(null)
|
const [updatesBaselineCount, setUpdatesBaselineCount] = useState<number | null>(null)
|
||||||
const selectedVMRef = useRef<VMData | null>(null)
|
const selectedVMRef = useRef<VMData | null>(null)
|
||||||
@@ -1188,6 +1194,31 @@ export function VirtualMachines() {
|
|||||||
selectedVMRef.current = selectedVM
|
selectedVMRef.current = selectedVM
|
||||||
}, [selectedVM])
|
}, [selectedVM])
|
||||||
|
|
||||||
|
// Revalidate only the open modal, independently of the 2.5s list poll.
|
||||||
|
// This reads config, addresses and os-release, never package updates.
|
||||||
|
useEffect(() => {
|
||||||
|
const vmid = selectedVM?.vmid
|
||||||
|
if (vmid === undefined) return
|
||||||
|
let cancelled = false
|
||||||
|
let inFlight = false
|
||||||
|
const timer = setInterval(async () => {
|
||||||
|
if (inFlight) return
|
||||||
|
inFlight = true
|
||||||
|
try {
|
||||||
|
const details = await fetchApi<VMDetails>(`/api/vms/${vmid}`)
|
||||||
|
if (!cancelled) {
|
||||||
|
vmModalCacheRef.current.details.set(vmid, details)
|
||||||
|
setVMDetails(details)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Keep the last snapshot; the next interval retries the read.
|
||||||
|
} finally {
|
||||||
|
inFlight = false
|
||||||
|
}
|
||||||
|
}, 30000)
|
||||||
|
return () => { cancelled = true; clearInterval(timer) }
|
||||||
|
}, [selectedVM?.vmid])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
updatesBaselineCountRef.current = updatesBaselineCount
|
updatesBaselineCountRef.current = updatesBaselineCount
|
||||||
}, [updatesBaselineCount])
|
}, [updatesBaselineCount])
|
||||||
@@ -1239,6 +1270,7 @@ export function VirtualMachines() {
|
|||||||
|
|
||||||
const handleVMClick = async (vm: VMData) => {
|
const handleVMClick = async (vm: VMData) => {
|
||||||
setSelectedVM(vm)
|
setSelectedVM(vm)
|
||||||
|
setOsCheckError(null)
|
||||||
setBulkLoaded(null)
|
setBulkLoaded(null)
|
||||||
setBulkConfigured(false)
|
setBulkConfigured(false)
|
||||||
setBulkTargets(["os"])
|
setBulkTargets(["os"])
|
||||||
@@ -2647,6 +2679,26 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
|||||||
// packages" section needs a clean `available`/`count` to avoid a
|
// packages" section needs a clean `available`/`count` to avoid a
|
||||||
// false "N package pending" every time a registered app has an
|
// false "N package pending" every time a registered app has an
|
||||||
// upstream bump.
|
// upstream bump.
|
||||||
|
const checkOsUpdates = async (vmid: number) => {
|
||||||
|
setOsCheckingVmid(vmid)
|
||||||
|
setOsCheckError(null)
|
||||||
|
try {
|
||||||
|
await checkLxcUpdates(vmid)
|
||||||
|
const details = await fetchApi<VMDetails>(`/api/vms/${vmid}`)
|
||||||
|
vmModalCacheRef.current.details.set(vmid, details)
|
||||||
|
if (selectedVMRef.current?.vmid === vmid) setVMDetails(details)
|
||||||
|
await mutate()
|
||||||
|
} catch (error: any) {
|
||||||
|
if (selectedVMRef.current?.vmid === vmid) {
|
||||||
|
setOsCheckError(error.status === 429
|
||||||
|
? t("vmLxc.updates.checkCooldown", { seconds: error.body?.retry_after ?? 60 })
|
||||||
|
: error.message || t("vmLxc.updates.osStatusUnavailable"))
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setOsCheckingVmid(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const getAggregateUpdateCheck = (vm: VMData): LxcUpdateCheck | undefined => {
|
const getAggregateUpdateCheck = (vm: VMData): LxcUpdateCheck | undefined => {
|
||||||
const uc = vm.update_check
|
const uc = vm.update_check
|
||||||
const appCount = (vm.app_watches || []).filter(
|
const appCount = (vm.app_watches || []).filter(
|
||||||
@@ -2702,7 +2754,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
|||||||
count: total,
|
count: total,
|
||||||
available: true,
|
available: true,
|
||||||
packages: [...(uc?.packages || []), ...pendingNames],
|
packages: [...(uc?.packages || []), ...pendingNames],
|
||||||
last_check: uc?.last_check ?? new Date().toISOString(),
|
last_check: uc?.last_check ?? null,
|
||||||
} as LxcUpdateCheck
|
} as LxcUpdateCheck
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2711,7 +2763,9 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
|||||||
compact = false,
|
compact = false,
|
||||||
onClick?: () => void,
|
onClick?: () => void,
|
||||||
) => {
|
) => {
|
||||||
if (!uc?.available || !uc.count || uc.count <= 0) return null
|
if (!uc) return null
|
||||||
|
const stale = uc.stale === true || (!!uc.last_check && isLxcUpdateStale(uc))
|
||||||
|
if (!stale && (!uc.available || !uc.count || uc.count <= 0)) return null
|
||||||
const last = uc.last_check
|
const last = uc.last_check
|
||||||
? new Date(uc.last_check).toLocaleString()
|
? new Date(uc.last_check).toLocaleString()
|
||||||
: "—"
|
: "—"
|
||||||
@@ -2759,8 +2813,8 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
|||||||
space — matters most on the LXC card row where uptime,
|
space — matters most on the LXC card row where uptime,
|
||||||
IP and other chips compete for width. Count alone with
|
IP and other chips compete for width. Count alone with
|
||||||
the up-arrow reads unambiguously as "pending updates". */}
|
the up-arrow reads unambiguously as "pending updates". */}
|
||||||
<ArrowUpCircle className={iconSize} />
|
{stale ? <Clock className={iconSize} /> : <ArrowUpCircle className={iconSize} />}
|
||||||
{uc.count}
|
{stale ? t("vmLxc.updates.checkStale") : uc.count}
|
||||||
{/* Chevron only when the badge is wired up as a clickable
|
{/* Chevron only when the badge is wired up as a clickable
|
||||||
shortcut — its absence on the dashboard card avoids
|
shortcut — its absence on the dashboard card avoids
|
||||||
implying interactivity where there isn't any (the whole
|
implying interactivity where there isn't any (the whole
|
||||||
@@ -5652,7 +5706,8 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
|||||||
{!selectedVM.update_check?.managed_oci_app && !ociInstance && !ociRestored &&
|
{!selectedVM.update_check?.managed_oci_app && !ociInstance && !ociRestored &&
|
||||||
!selectedVM.update_check?.is_oci_lxc && (() => {
|
!selectedVM.update_check?.is_oci_lxc && (() => {
|
||||||
const uc = selectedVM.update_check
|
const uc = selectedVM.update_check
|
||||||
const osUpdateStatusKnown = !!uc && !uc.error
|
const osUpdateStale = isLxcUpdateStale(uc)
|
||||||
|
const osUpdateStatusKnown = !!uc && !uc.error && !osUpdateStale
|
||||||
const hasOsUpdates = osUpdateStatusKnown && !!uc.available
|
const hasOsUpdates = osUpdateStatusKnown && !!uc.available
|
||||||
const dockerAppWatch = (selectedVM.app_watches || []).find((a) => a.helper_slug === "docker")
|
const dockerAppWatch = (selectedVM.app_watches || []).find((a) => a.helper_slug === "docker")
|
||||||
const dockerRegistered = !!dockerAppWatch
|
const dockerRegistered = !!dockerAppWatch
|
||||||
@@ -5925,6 +5980,15 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
|||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
<Package className="h-4 w-4 text-muted-foreground" />
|
<Package className="h-4 w-4 text-muted-foreground" />
|
||||||
<h3 className="text-sm font-semibold text-foreground">{t("vmLxc.updates.osPackagesTitle")}</h3>
|
<h3 className="text-sm font-semibold text-foreground">{t("vmLxc.updates.osPackagesTitle")}</h3>
|
||||||
|
<Button
|
||||||
|
size="sm" variant="outline" className="ml-auto"
|
||||||
|
disabled={selectedVM.status !== "running" || osCheckingVmid !== null}
|
||||||
|
title={t("vmLxc.updates.checkReadOnlyHelp")}
|
||||||
|
onClick={() => checkOsUpdates(selectedVM.vmid)}
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-4 w-4 mr-1.5 ${osCheckingVmid === selectedVM.vmid ? "animate-spin" : ""}`} />
|
||||||
|
{t("vmLxc.updates.checkNow")}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-muted-foreground mb-3 leading-relaxed">
|
<div className="text-xs text-muted-foreground mb-3 leading-relaxed">
|
||||||
{t("vmLxc.updates.lastCheckedPrefix")}{" "}
|
{t("vmLxc.updates.lastCheckedPrefix")}{" "}
|
||||||
@@ -5933,7 +5997,10 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
|||||||
<> · {t("vmLxc.updates.familyLabel")} <code className="text-foreground/80">{uc.os_family}</code></>
|
<> · {t("vmLxc.updates.familyLabel")} <code className="text-foreground/80">{uc.os_family}</code></>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!osUpdateStatusKnown ? (
|
{osCheckError && <p role="alert" className="text-sm text-amber-400 mb-2">{osCheckError}</p>}
|
||||||
|
{osUpdateStale && !uc?.error ? (
|
||||||
|
<p className="text-sm text-amber-400">{t("vmLxc.updates.checkStaleHelp")}</p>
|
||||||
|
) : !osUpdateStatusKnown ? (
|
||||||
<div
|
<div
|
||||||
className="text-sm text-muted-foreground flex items-center gap-2"
|
className="text-sm text-muted-foreground flex items-center gap-2"
|
||||||
title={uc?.error || undefined}
|
title={uc?.error || undefined}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { fetchApi } from "./api-config"
|
||||||
|
|
||||||
|
interface UpdateObservation {
|
||||||
|
last_check?: string | null
|
||||||
|
stale?: boolean
|
||||||
|
stale_after_seconds?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLxcUpdateStale(observation?: UpdateObservation, now = Date.now()): boolean {
|
||||||
|
if (!observation || observation.stale === true) return true
|
||||||
|
const checked = Date.parse(observation.last_check || "")
|
||||||
|
const age = now - checked
|
||||||
|
const limit = (observation.stale_after_seconds ?? 172800) * 1000
|
||||||
|
return !Number.isFinite(checked) || age < 0 || age >= limit
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called only by the OS section's explicit button. Uses installed package
|
||||||
|
// state and existing indexes; fetching this never upgrades or refreshes APT.
|
||||||
|
export function checkLxcUpdates<T>(vmid: number): Promise<T> {
|
||||||
|
return fetchApi<T>(`/api/vms/${vmid}/updates/check`, { method: "POST" })
|
||||||
|
}
|
||||||
@@ -1332,6 +1332,11 @@
|
|||||||
},
|
},
|
||||||
"updates": {
|
"updates": {
|
||||||
"osPackagesTitle": "OS packages",
|
"osPackagesTitle": "OS packages",
|
||||||
|
"checkNow": "Check now",
|
||||||
|
"checkReadOnlyHelp": "Read current OS and pending packages from existing indexes. No installation or package index refresh.",
|
||||||
|
"checkCooldown": "Check already running or recently requested. Try again in {seconds} seconds.",
|
||||||
|
"checkStale": "Check outdated",
|
||||||
|
"checkStaleHelp": "This result is older than 48 hours or has no valid check date. Run a new check before relying on it.",
|
||||||
"lastCheckedPrefix": "Last checked:",
|
"lastCheckedPrefix": "Last checked:",
|
||||||
"familyLabel": "Family:",
|
"familyLabel": "Family:",
|
||||||
"applyOsUpdate": "Apply OS update",
|
"applyOsUpdate": "Apply OS update",
|
||||||
|
|||||||
@@ -1334,6 +1334,11 @@
|
|||||||
"updatedWithDockerImage": "Aktualizuje sa spolu so svojím obrazom Docker.",
|
"updatedWithDockerImage": "Aktualizuje sa spolu so svojím obrazom Docker.",
|
||||||
"dockerBindingUnavailable": "Zatiaľ nie je priradený žiadny obraz. Skontrolujte názov kontajnera a obnovte inventár obrazov.",
|
"dockerBindingUnavailable": "Zatiaľ nie je priradený žiadny obraz. Skontrolujte názov kontajnera a obnovte inventár obrazov.",
|
||||||
"osPackagesTitle": "Balíky systému",
|
"osPackagesTitle": "Balíky systému",
|
||||||
|
"checkNow": "Skontrolovať teraz",
|
||||||
|
"checkReadOnlyHelp": "Zistí aktuálny systém a čakajúce balíky podľa existujúcich zoznamov. Nič nenainštaluje ani neobnoví zoznamy balíkov.",
|
||||||
|
"checkCooldown": "Kontrola už prebieha alebo sa práve vykonala. Skúste znova o {seconds} sekúnd.",
|
||||||
|
"checkStale": "Zastaraná kontrola",
|
||||||
|
"checkStaleHelp": "Výsledok je starší než 48 hodín alebo nemá platný dátum kontroly. Pred použitím spustite novú kontrolu.",
|
||||||
"lastCheckedPrefix": "Naposledy skontrolované:",
|
"lastCheckedPrefix": "Naposledy skontrolované:",
|
||||||
"familyLabel": "Systém:",
|
"familyLabel": "Systém:",
|
||||||
"applyOsUpdate": "Aktualizovať systém",
|
"applyOsUpdate": "Aktualizovať systém",
|
||||||
|
|||||||
@@ -580,35 +580,34 @@ def parse_lxc_hardware_config(vmid, node):
|
|||||||
|
|
||||||
|
|
||||||
def _get_lxc_ip_info_cached(vmid):
|
def _get_lxc_ip_info_cached(vmid):
|
||||||
"""Return an LXC's complete IP classification from the lifecycle cache.
|
"""Probe at most once per TTL, also noticing DHCP changes without events."""
|
||||||
|
|
||||||
First read per CT spawns one `lxc-info` subprocess; subsequent reads are
|
|
||||||
free until the CT's lifecycle event drops the entry via
|
|
||||||
`_invalidate_lxc_ip`. Keeping the complete result (rather than only the
|
|
||||||
primary address) lets `/api/vms` expose every address without changing
|
|
||||||
the cache's lifecycle semantics.
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
vmid_int = int(vmid)
|
vmid_int = int(vmid)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return None
|
return None
|
||||||
if vmid_int in _lxc_ip_cache:
|
# Serialize cold/expired reads so concurrent list polls share one probe.
|
||||||
return _lxc_ip_cache[vmid_int]
|
with _lxc_ip_cache_lock:
|
||||||
info = get_lxc_ip_from_lxc_info(vmid_int)
|
hit = _lxc_ip_cache.get(vmid_int)
|
||||||
_remember_lxc_ip(vmid_int, info)
|
if hit is not None:
|
||||||
return info
|
info = hit[1]
|
||||||
|
ttl = (_LXC_IP_TTL if info and any(':' not in ip for ip in info.get('real_ips') or [])
|
||||||
|
else _LXC_IP_RETRY_TTL)
|
||||||
|
if time.monotonic() - hit[0] < ttl:
|
||||||
|
return info
|
||||||
|
_lxc_ip_cache.pop(vmid_int, None)
|
||||||
|
info = get_lxc_ip_from_lxc_info(vmid_int)
|
||||||
|
_remember_lxc_ip(vmid_int, info)
|
||||||
|
return info
|
||||||
|
|
||||||
|
|
||||||
def _remember_lxc_ip(vmid, info):
|
def _remember_lxc_ip(vmid, info):
|
||||||
"""Keep the addresses of a container until its next lifecycle event.
|
"""Keep the addresses for a short TTL or until its next lifecycle event.
|
||||||
|
|
||||||
A container that shows addresses but no IPv4 of its own is still waiting
|
Missing leases and failed probes use a shorter retry TTL. Concurrent
|
||||||
for its lease, or lost it when it rebooted from inside: that answer is not
|
polling still shares the result instead of spawning a probe per request.
|
||||||
kept, so the next read looks again instead of serving an address of its
|
|
||||||
inner bridges until the container is restarted.
|
|
||||||
"""
|
"""
|
||||||
if info is None or any(':' not in ip for ip in info.get('real_ips') or []):
|
with _lxc_ip_cache_lock:
|
||||||
_lxc_ip_cache[vmid] = info
|
_lxc_ip_cache[int(vmid)] = (time.monotonic(), info)
|
||||||
|
|
||||||
|
|
||||||
def _get_lxc_primary_ip_cached(vmid):
|
def _get_lxc_primary_ip_cached(vmid):
|
||||||
@@ -619,6 +618,41 @@ def _get_lxc_primary_ip_cached(vmid):
|
|||||||
return info.get('primary_ip') or (info.get('real_ips') or [None])[0]
|
return info.get('primary_ip') or (info.get('real_ips') or [None])[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _lxc_runtime_details(payload):
|
||||||
|
"""Keep live addresses and OS separate from the .conf-versioned payload."""
|
||||||
|
if payload.get('vm_type') != 'lxc':
|
||||||
|
return payload
|
||||||
|
result = dict(payload)
|
||||||
|
result.pop('lxc_ip_info', None)
|
||||||
|
result.pop('os_info', None)
|
||||||
|
vmid = payload['vmid']
|
||||||
|
if _fast_guest_status(vmid, 'lxc') != 'running':
|
||||||
|
return result
|
||||||
|
info = _get_lxc_ip_info_cached(vmid)
|
||||||
|
if info:
|
||||||
|
result['lxc_ip_info'] = info
|
||||||
|
os_info = _vm_cache_get(_lxc_os_info_cache, vmid, _LXC_OS_INFO_TTL)
|
||||||
|
if os_info is None:
|
||||||
|
os_info = {}
|
||||||
|
try:
|
||||||
|
probe = subprocess.run(
|
||||||
|
['pct', 'exec', str(vmid), '--', 'cat', '/etc/os-release'],
|
||||||
|
capture_output=True, text=True, timeout=5)
|
||||||
|
if probe.returncode == 0:
|
||||||
|
keys = {'ID': 'id', 'VERSION_ID': 'version_id',
|
||||||
|
'NAME': 'name', 'PRETTY_NAME': 'pretty_name'}
|
||||||
|
for line in probe.stdout.splitlines():
|
||||||
|
key, sep, value = line.partition('=')
|
||||||
|
if sep and key.strip() in keys:
|
||||||
|
os_info[keys[key.strip()]] = value.strip().strip('\"').strip("'")
|
||||||
|
except (OSError, subprocess.SubprocessError):
|
||||||
|
pass
|
||||||
|
_vm_cache_put(_lxc_os_info_cache, vmid, os_info)
|
||||||
|
if os_info:
|
||||||
|
result['os_info'] = os_info
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _normalise_lxc_ips(ip_info):
|
def _normalise_lxc_ips(ip_info):
|
||||||
"""Return a stable, JSON-safe ordered list of LXC addresses.
|
"""Return a stable, JSON-safe ordered list of LXC addresses.
|
||||||
|
|
||||||
@@ -646,7 +680,8 @@ def _invalidate_lxc_ip(vmid):
|
|||||||
"""Drop the cached IP so the next request re-probes `lxc-info`.
|
"""Drop the cached IP so the next request re-probes `lxc-info`.
|
||||||
Fired from the guest lifecycle handler on start/stop/reboot."""
|
Fired from the guest lifecycle handler on start/stop/reboot."""
|
||||||
try:
|
try:
|
||||||
_lxc_ip_cache.pop(int(vmid), None)
|
with _lxc_ip_cache_lock:
|
||||||
|
_lxc_ip_cache.pop(int(vmid), None)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -675,9 +710,8 @@ def _restore_lxc_dhcp_clients():
|
|||||||
def _warmup_lxc_ip_cache() -> int:
|
def _warmup_lxc_ip_cache() -> int:
|
||||||
"""Populate the LXC IP cache for every running CT at Monitor
|
"""Populate the LXC IP cache for every running CT at Monitor
|
||||||
startup. After this runs, /api/vms serves the IPs from memory
|
startup. After this runs, /api/vms serves the IPs from memory
|
||||||
without spawning `lxc-info` on the request path — the cache only
|
until the short TTL expires or a lifecycle event invalidates it.
|
||||||
changes when a CT's lifecycle event (start/stop/reboot) fires the
|
Returns the count for the startup log line.
|
||||||
invalidator. Returns the count for the startup log line.
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
@@ -1815,20 +1849,21 @@ _vm_mounts_cache: dict = {} # vmid -> (ts, payload) — LXC only
|
|||||||
_VM_LIST_SEARCH_NOTES_TTL = 30
|
_VM_LIST_SEARCH_NOTES_TTL = 30
|
||||||
_vm_list_search_notes_cache: dict = {"ts": 0.0, "signature": (), "notes": {}}
|
_vm_list_search_notes_cache: dict = {"ts": 0.0, "signature": (), "notes": {}}
|
||||||
_vm_list_search_notes_lock = threading.Lock()
|
_vm_list_search_notes_lock = threading.Lock()
|
||||||
# LXC IP classification cache — populated on first read, held indefinitely.
|
# DHCP addresses can change without a lifecycle event.
|
||||||
# A running CT's IP doesn't change; the cache is invalidated only when
|
_LXC_IP_TTL = 60
|
||||||
# the CT's lifecycle event fires (start/stop/reboot), so no periodic
|
_LXC_IP_RETRY_TTL = 5
|
||||||
# polling is needed. See `_handle_guest_lifecycle`.
|
|
||||||
_lxc_ip_cache: dict = {}
|
_lxc_ip_cache: dict = {}
|
||||||
|
_lxc_ip_cache_lock = threading.RLock()
|
||||||
|
_lxc_os_info_cache: dict = {}
|
||||||
|
_LXC_OS_INFO_TTL = 60
|
||||||
# Effective TTL is "indefinite": these caches are refreshed only
|
# Effective TTL is "indefinite": these caches are refreshed only
|
||||||
# by explicit event-based invalidation (`_vm_cache_invalidate` calls
|
# by explicit event-based invalidation (`_vm_cache_invalidate` calls
|
||||||
# on start/stop/reboot, add/edit/delete app, apply update, edit
|
# on start/stop/reboot, add/edit/delete app, apply update, edit
|
||||||
# schedule, create backup). No periodic poll — the prewarmer runs
|
# schedule, create backup). No periodic poll — the prewarmer runs
|
||||||
# once at startup and then stays quiet. The rationale is that all
|
# once at startup and then stays quiet. The rationale is that all
|
||||||
# of these payloads are backed by files (.conf / sidecar JSON /
|
# of these payloads are backed by files (.conf / sidecar JSON /
|
||||||
# storage inventory) that only change through actions the Monitor
|
# storage inventory). Details and mounts additionally validate the .conf
|
||||||
# either performs itself (invalidates in-line) or that require a
|
# signature on each read, including edits made outside Monitor. Runtime data that DOES
|
||||||
# guest restart (start/stop invalidates too). Runtime data that DOES
|
|
||||||
# change without an invalidation event lives in a different path:
|
# change without an invalidation event lives in a different path:
|
||||||
# - Live CPU/mem/disk/network per guest → served by /api/vms (SWR
|
# - Live CPU/mem/disk/network per guest → served by /api/vms (SWR
|
||||||
# poll every 2.5 s from the client, no cache here).
|
# poll every 2.5 s from the client, no cache here).
|
||||||
@@ -1923,12 +1958,16 @@ def _vm_cache_put(cache: dict, vmid: int, value) -> None:
|
|||||||
_vm_conf_built: dict = {} # (cache name, vmid) -> .conf signature
|
_vm_conf_built: dict = {} # (cache name, vmid) -> .conf signature
|
||||||
|
|
||||||
def _vm_conf_signature(vmid: int):
|
def _vm_conf_signature(vmid: int):
|
||||||
|
import hashlib
|
||||||
for path in (f'/etc/pve/lxc/{int(vmid)}.conf', f'/etc/pve/qemu-server/{int(vmid)}.conf'):
|
for path in (f'/etc/pve/lxc/{int(vmid)}.conf', f'/etc/pve/qemu-server/{int(vmid)}.conf'):
|
||||||
try:
|
try:
|
||||||
info = os.stat(path)
|
info = os.stat(path)
|
||||||
|
with open(path, 'rb') as handle:
|
||||||
|
digest = hashlib.sha256(handle.read()).digest()
|
||||||
except OSError:
|
except OSError:
|
||||||
continue
|
continue
|
||||||
return (info.st_mtime_ns, info.st_size)
|
# Same-second, same-size changes must also invalidate the payload.
|
||||||
|
return (path, info.st_mtime_ns, info.st_size, digest)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _vm_conf_cache_get(cache: dict, name: str, vmid: int, ttl: int):
|
def _vm_conf_cache_get(cache: dict, name: str, vmid: int, ttl: int):
|
||||||
@@ -1938,11 +1977,11 @@ def _vm_conf_cache_get(cache: dict, name: str, vmid: int, ttl: int):
|
|||||||
return None
|
return None
|
||||||
with _vm_modal_cache_lock:
|
with _vm_modal_cache_lock:
|
||||||
built = _vm_conf_built.get((name, vmid))
|
built = _vm_conf_built.get((name, vmid))
|
||||||
return cached if built == _vm_conf_signature(vmid) else None
|
return cached if built is not None and built == _vm_conf_signature(vmid) else None
|
||||||
|
|
||||||
def _vm_conf_cache_put(cache: dict, name: str, vmid: int, value, signature) -> None:
|
def _vm_conf_cache_put(cache: dict, name: str, vmid: int, value, signature) -> None:
|
||||||
_vm_cache_put(cache, vmid, value)
|
|
||||||
with _vm_modal_cache_lock:
|
with _vm_modal_cache_lock:
|
||||||
|
cache[vmid] = (time.time(), value)
|
||||||
_vm_conf_built[(name, vmid)] = signature
|
_vm_conf_built[(name, vmid)] = signature
|
||||||
|
|
||||||
def _vm_cache_invalidate(vmid: int, *caches) -> None:
|
def _vm_cache_invalidate(vmid: int, *caches) -> None:
|
||||||
@@ -1953,6 +1992,7 @@ def _vm_cache_invalidate(vmid: int, *caches) -> None:
|
|||||||
targets = caches or (
|
targets = caches or (
|
||||||
_vm_details_cache, _vm_backups_cache,
|
_vm_details_cache, _vm_backups_cache,
|
||||||
_vm_app_suggestions_cache, _vm_schedule_cache, _vm_mounts_cache,
|
_vm_app_suggestions_cache, _vm_schedule_cache, _vm_mounts_cache,
|
||||||
|
_lxc_os_info_cache,
|
||||||
)
|
)
|
||||||
with _vm_modal_cache_lock:
|
with _vm_modal_cache_lock:
|
||||||
for c in targets:
|
for c in targets:
|
||||||
@@ -6635,6 +6675,7 @@ def _get_lxc_update_status_map() -> dict:
|
|||||||
continue
|
continue
|
||||||
update = it.get('update_check') or {}
|
update = it.get('update_check') or {}
|
||||||
out[str(vmid)] = {
|
out[str(vmid)] = {
|
||||||
|
**managed_installs.lxc_update_freshness(update),
|
||||||
'available': bool(update.get('available')),
|
'available': bool(update.get('available')),
|
||||||
'count': int(update.get('_count') or 0),
|
'count': int(update.get('_count') or 0),
|
||||||
'security_count': int(update.get('_security_count') or 0),
|
'security_count': int(update.get('_security_count') or 0),
|
||||||
@@ -13594,6 +13635,46 @@ def api_managed_installs_refresh():
|
|||||||
return jsonify({'success': False, 'message': str(e)}), 500
|
return jsonify({'success': False, 'message': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
_lxc_update_check_lock = threading.Lock()
|
||||||
|
_lxc_update_check_attempts: dict = {}
|
||||||
|
_lxc_update_check_inflight: set = set()
|
||||||
|
_LXC_UPDATE_CHECK_INTERVAL = 60
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/vms/<int:vmid>/updates/check', methods=['POST'])
|
||||||
|
@require_auth
|
||||||
|
def api_lxc_updates_check(vmid):
|
||||||
|
"""Explicit read of OS and locally known packages, with no index refresh."""
|
||||||
|
import managed_installs
|
||||||
|
if not managed_installs._lxc_updates_detection_enabled():
|
||||||
|
return jsonify({'error': 'LXC update detection is disabled'}), 403
|
||||||
|
if _detect_vm_type_local(vmid) != 'lxc':
|
||||||
|
return jsonify({'error': 'LXC not found'}), 404
|
||||||
|
if _fast_guest_status(vmid, 'lxc') != 'running':
|
||||||
|
return jsonify({'error': 'LXC is not running'}), 409
|
||||||
|
now = time.monotonic()
|
||||||
|
with _lxc_update_check_lock:
|
||||||
|
last = _lxc_update_check_attempts.get(vmid)
|
||||||
|
if vmid in _lxc_update_check_inflight or (
|
||||||
|
last is not None and now - last < _LXC_UPDATE_CHECK_INTERVAL):
|
||||||
|
retry = max(1, int(_LXC_UPDATE_CHECK_INTERVAL - (now - (last if last is not None else now))) + 1)
|
||||||
|
return jsonify({'error': 'Check already running or recently requested',
|
||||||
|
'retry_after': retry}), 429, {'Retry-After': str(retry)}
|
||||||
|
_lxc_update_check_attempts[vmid] = now
|
||||||
|
_lxc_update_check_inflight.add(vmid)
|
||||||
|
try:
|
||||||
|
item = managed_installs.refresh_lxc(vmid, refresh_package_cache=False)
|
||||||
|
if item is None:
|
||||||
|
return jsonify({'error': 'No supported running LXC detected'}), 409
|
||||||
|
_vm_cache_invalidate(vmid, _lxc_os_info_cache)
|
||||||
|
return jsonify({'vmid': vmid, 'update_check': _get_lxc_update_status_map().get(str(vmid))})
|
||||||
|
except Exception as exc:
|
||||||
|
return jsonify({'error': str(exc)}), 500
|
||||||
|
finally:
|
||||||
|
with _lxc_update_check_lock:
|
||||||
|
_lxc_update_check_inflight.discard(vmid)
|
||||||
|
|
||||||
|
|
||||||
# ─── LXC Update Detection toggle ────────────────────────────────────────────
|
# ─── LXC Update Detection toggle ────────────────────────────────────────────
|
||||||
# Dedicated toggle so the operator can opt out of the per-CT `pct exec apt
|
# Dedicated toggle so the operator can opt out of the per-CT `pct exec apt
|
||||||
# list --upgradable` scan entirely. The Notifications section keeps its own
|
# list --upgradable` scan entirely. The Notifications section keeps its own
|
||||||
@@ -15269,12 +15350,12 @@ def api_vms_modal_cache_all():
|
|||||||
def get_vm_config(vmid):
|
def get_vm_config(vmid):
|
||||||
"""Get detailed configuration for a specific VM/LXC"""
|
"""Get detailed configuration for a specific VM/LXC"""
|
||||||
try:
|
try:
|
||||||
# Taken before reading the .conf, so an edit during the build is seen next time.
|
|
||||||
signature = _vm_conf_signature(vmid)
|
|
||||||
cached = _vm_conf_cache_get(_vm_details_cache, 'details', vmid, _VM_DETAILS_TTL)
|
cached = _vm_conf_cache_get(_vm_details_cache, 'details', vmid, _VM_DETAILS_TTL)
|
||||||
if cached is not None:
|
if cached is not None:
|
||||||
return jsonify(cached)
|
return jsonify(_lxc_runtime_details(cached))
|
||||||
|
|
||||||
|
# Taken before reading the .conf, so an edit during the build is seen next time.
|
||||||
|
signature = _vm_conf_signature(vmid)
|
||||||
node = get_proxmox_node_name()
|
node = get_proxmox_node_name()
|
||||||
|
|
||||||
# Fast local path — read .conf directly and derive status from
|
# Fast local path — read .conf directly and derive status from
|
||||||
@@ -15294,8 +15375,6 @@ def get_vm_config(vmid):
|
|||||||
if not config:
|
if not config:
|
||||||
return jsonify({'error': 'VM/LXC not found'}), 404
|
return jsonify({'error': 'VM/LXC not found'}), 404
|
||||||
|
|
||||||
status = _fast_guest_status(vmid, vm_type)
|
|
||||||
|
|
||||||
response_data = {
|
response_data = {
|
||||||
'vmid': vmid,
|
'vmid': vmid,
|
||||||
'config': config,
|
'config': config,
|
||||||
@@ -15303,46 +15382,17 @@ def get_vm_config(vmid):
|
|||||||
'vm_type': vm_type,
|
'vm_type': vm_type,
|
||||||
}
|
}
|
||||||
|
|
||||||
# For LXC, try to get IP from lxc-info if running
|
|
||||||
if vm_type == 'lxc' and status == 'running':
|
|
||||||
lxc_ip_info = get_lxc_ip_from_lxc_info(vmid)
|
|
||||||
if lxc_ip_info:
|
|
||||||
response_data['lxc_ip_info'] = lxc_ip_info
|
|
||||||
|
|
||||||
# Get OS information for LXC
|
|
||||||
os_info = {}
|
|
||||||
if vm_type == 'lxc' and status == 'running':
|
|
||||||
try:
|
|
||||||
os_release_result = subprocess.run(
|
|
||||||
['pct', 'exec', str(vmid), '--', 'cat', '/etc/os-release'],
|
|
||||||
capture_output=True, text=True, timeout=5)
|
|
||||||
if os_release_result.returncode == 0:
|
|
||||||
for line in os_release_result.stdout.split('\n'):
|
|
||||||
line = line.strip()
|
|
||||||
if line.startswith('ID='):
|
|
||||||
os_info['id'] = line.split('=', 1)[1].strip('"').strip("'")
|
|
||||||
elif line.startswith('VERSION_ID='):
|
|
||||||
os_info['version_id'] = line.split('=', 1)[1].strip('"').strip("'")
|
|
||||||
elif line.startswith('NAME='):
|
|
||||||
os_info['name'] = line.split('=', 1)[1].strip('"').strip("'")
|
|
||||||
elif line.startswith('PRETTY_NAME='):
|
|
||||||
os_info['pretty_name'] = line.split('=', 1)[1].strip('"').strip("'")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Get hardware information for LXC — already reads the .conf
|
# Get hardware information for LXC — already reads the .conf
|
||||||
# directly (no pvesh), so it's cheap.
|
# directly (no pvesh), so it's cheap.
|
||||||
hardware_info = {}
|
hardware_info = {}
|
||||||
if vm_type == 'lxc':
|
if vm_type == 'lxc':
|
||||||
hardware_info = parse_lxc_hardware_config(vmid, node)
|
hardware_info = parse_lxc_hardware_config(vmid, node)
|
||||||
|
|
||||||
if os_info:
|
|
||||||
response_data['os_info'] = os_info
|
|
||||||
if hardware_info:
|
if hardware_info:
|
||||||
response_data['hardware_info'] = hardware_info
|
response_data['hardware_info'] = hardware_info
|
||||||
|
|
||||||
_vm_conf_cache_put(_vm_details_cache, 'details', vmid, response_data, signature)
|
_vm_conf_cache_put(_vm_details_cache, 'details', vmid, response_data, signature)
|
||||||
return jsonify(response_data)
|
return jsonify(_lxc_runtime_details(response_data))
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'error': str(e)}), 500
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|||||||
@@ -839,16 +839,17 @@ def _infer_helper_slug(vmid: str, hostname: str) -> Optional[str]:
|
|||||||
return _identify_helper_slug(vmid, hostname)[0]
|
return _identify_helper_slug(vmid, hostname)[0]
|
||||||
|
|
||||||
|
|
||||||
def _probe_lxc_os(vmid: str) -> Optional[str]:
|
def _probe_lxc_os(vmid: str, *, raise_on_read_error: bool = False) -> Optional[str]:
|
||||||
"""Return a normalized family identifier (``debian`` / ``ubuntu`` /
|
"""Return a normalized family identifier (``debian`` / ``ubuntu`` /
|
||||||
``alpine``) by reading ``/etc/os-release`` inside the running CT.
|
``alpine``) by reading ``/etc/os-release`` inside the running CT.
|
||||||
Returns None for distributions whose package manager we don't yet
|
Returns None for distributions whose package manager we don't yet
|
||||||
speak — those CTs are skipped in detection so the framework
|
speak — those CTs are skipped in detection so the framework
|
||||||
doesn't keep retrying a checker we can't run.
|
doesn't keep retrying a checker we can't run.
|
||||||
|
|
||||||
Cached per CT in the registry — re-probed only when the entry has
|
Detection may reuse the registry hint; each explicit or scheduled update
|
||||||
no ``_os_family`` yet, since the OS rarely changes for the life of
|
check re-probes this file before selecting the package manager.
|
||||||
a CT.
|
Strict checks distinguish an unreadable file (OSError) from a readable
|
||||||
|
unsupported OS (None); legacy detection retains its Optional contract.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
r = subprocess.run(
|
r = subprocess.run(
|
||||||
@@ -857,16 +858,29 @@ def _probe_lxc_os(vmid: str) -> Optional[str]:
|
|||||||
timeout=_LXC_OS_PROBE_TIMEOUT_SEC,
|
timeout=_LXC_OS_PROBE_TIMEOUT_SEC,
|
||||||
)
|
)
|
||||||
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
||||||
|
if raise_on_read_error:
|
||||||
|
raise OSError('current OS release could not be read') from None
|
||||||
return None
|
return None
|
||||||
if r.returncode != 0:
|
if r.returncode != 0:
|
||||||
|
if raise_on_read_error:
|
||||||
|
raise OSError('current OS release could not be read')
|
||||||
return None
|
return None
|
||||||
text = r.stdout.lower()
|
release = {}
|
||||||
if "id=ubuntu" in text:
|
for line in r.stdout.splitlines():
|
||||||
|
key, sep, value = line.partition("=")
|
||||||
|
if sep:
|
||||||
|
release[key.strip()] = value.strip().strip('\"').strip("'").lower()
|
||||||
|
identity = release.get("ID", "")
|
||||||
|
if not identity and raise_on_read_error:
|
||||||
|
raise OSError('current OS release has no usable ID')
|
||||||
|
if identity == "ubuntu":
|
||||||
return "ubuntu"
|
return "ubuntu"
|
||||||
if "id=debian" in text or "id_like=debian" in text:
|
if identity == "debian":
|
||||||
return "debian"
|
return "debian"
|
||||||
if "id=alpine" in text:
|
if identity == "alpine":
|
||||||
return "alpine"
|
return "alpine"
|
||||||
|
if "debian" in release.get("ID_LIKE", "").split():
|
||||||
|
return "debian"
|
||||||
# Future Phase 1.5: CentOS/Rocky/Alma (dnf check-update), Arch
|
# Future Phase 1.5: CentOS/Rocky/Alma (dnf check-update), Arch
|
||||||
# (checkupdates), openSUSE (zypper list-updates). Each needs a
|
# (checkupdates), openSUSE (zypper list-updates). Each needs a
|
||||||
# parser similar to apt/apk — skip silently for now.
|
# parser similar to apt/apk — skip silently for now.
|
||||||
@@ -877,10 +891,9 @@ def _detect_lxc_containers(only_vmid: Optional[int] = None) -> list[dict]:
|
|||||||
"""Enumerate running Debian/Ubuntu CTs as registry entries.
|
"""Enumerate running Debian/Ubuntu CTs as registry entries.
|
||||||
|
|
||||||
OS detection is cached in the registry entry (`_os_family`), so the
|
OS detection is cached in the registry entry (`_os_family`), so the
|
||||||
expensive ``pct exec cat /etc/os-release`` only runs the first time
|
expensive ``pct exec cat /etc/os-release`` only runs during detection
|
||||||
a CT is seen. CT reinstalls with a different OS will keep the old
|
when a CT is first seen. The checker independently re-reads it on each
|
||||||
family cached until the user resets the registry — acceptable
|
check, including after a restore or external reinstall.
|
||||||
trade-off vs paying the probe cost every 24h cycle.
|
|
||||||
|
|
||||||
Detection respects the dedicated `lxc_updates.detection_enabled`
|
Detection respects the dedicated `lxc_updates.detection_enabled`
|
||||||
toggle (Settings → LXC Update Detection). When OFF, this returns []
|
toggle (Settings → LXC Update Detection). When OFF, this returns []
|
||||||
@@ -933,13 +946,12 @@ def _detect_lxc_containers(only_vmid: Optional[int] = None) -> list[dict]:
|
|||||||
# future ProxMenux-shipped OCI app).
|
# future ProxMenux-shipped OCI app).
|
||||||
managed_oci_app = oci_managed.get(str(vmid))
|
managed_oci_app = oci_managed.get(str(vmid))
|
||||||
|
|
||||||
# OS family is only meaningful for non-OCI CTs. We still cache
|
# OCI/managed images have their own updater. Retain an existing OS
|
||||||
# it for OCI (some images ARE Ubuntu/Debian underneath and
|
# hint, but never execute a guest OS probe for generic detection.
|
||||||
# future features might use it), but we don't require it.
|
|
||||||
os_family = prior.get("_os_family")
|
os_family = prior.get("_os_family")
|
||||||
if not os_family:
|
if not os_family and not is_oci and not managed_oci_app:
|
||||||
os_family = _probe_lxc_os(vmid)
|
os_family = _probe_lxc_os(vmid)
|
||||||
if not is_oci and os_family not in _SUPPORTED_OS_FAMILIES:
|
if os_family not in _SUPPORTED_OS_FAMILIES:
|
||||||
# Non-OCI, non-supported family — the framework has
|
# Non-OCI, non-supported family — the framework has
|
||||||
# no way to check its updates. Skip silently.
|
# no way to check its updates. Skip silently.
|
||||||
continue
|
continue
|
||||||
@@ -1442,12 +1454,35 @@ def _refresh_lxc_pkg_cache_if_stale(vmid: str, family: str) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _check_lxc_updates(entry: dict) -> dict:
|
# The daily collector persists at cycle end. Allow a full extra day so a
|
||||||
|
# normal collection delay never briefly marks every guest's result stale.
|
||||||
|
LXC_UPDATE_STALE_AFTER_SECONDS = 48 * 3600
|
||||||
|
|
||||||
|
|
||||||
|
def lxc_update_freshness(update: dict) -> dict:
|
||||||
|
"""Describe a stored observation, without running any guest command."""
|
||||||
|
age = None
|
||||||
|
try:
|
||||||
|
checked = datetime.datetime.fromisoformat(update.get("last_check", "").replace("Z", "+00:00"))
|
||||||
|
if checked.tzinfo is None:
|
||||||
|
checked = checked.replace(tzinfo=datetime.timezone.utc)
|
||||||
|
seconds = (datetime.datetime.now(datetime.timezone.utc) - checked).total_seconds()
|
||||||
|
if seconds >= 0:
|
||||||
|
age = int(seconds)
|
||||||
|
except (ValueError, TypeError, AttributeError):
|
||||||
|
pass
|
||||||
|
stale = age is None or age >= LXC_UPDATE_STALE_AFTER_SECONDS
|
||||||
|
return {"stale": stale, "age_seconds": age,
|
||||||
|
"stale_after_seconds": LXC_UPDATE_STALE_AFTER_SECONDS}
|
||||||
|
|
||||||
|
|
||||||
|
def _check_lxc_updates(entry: dict, *, refresh_package_cache: bool = True) -> dict:
|
||||||
"""Inspect pending package updates inside the LXC and report them.
|
"""Inspect pending package updates inside the LXC and report them.
|
||||||
|
|
||||||
Dispatches to the right package-manager parser based on the cached
|
Re-read /etc/os-release on every explicit/scheduled check, so a restore
|
||||||
``_os_family``. If the CT's local apt/apk metadata cache is older
|
or external reinstall cannot keep the old registry family. If the CT's
|
||||||
than 24h, runs a best-effort refresh first via
|
local apt/apk metadata cache is older than 24h, the existing collector
|
||||||
|
runs a best-effort refresh first via
|
||||||
``_refresh_lxc_pkg_cache_if_stale`` — without this, CTs that no
|
``_refresh_lxc_pkg_cache_if_stale`` — without this, CTs that no
|
||||||
one ever runs ``apt update`` in (long-running appliances) report
|
one ever runs ``apt update`` in (long-running appliances) report
|
||||||
0 pending updates even when upstream has hundreds queued.
|
0 pending updates even when upstream has hundreds queued.
|
||||||
@@ -1458,7 +1493,6 @@ def _check_lxc_updates(entry: dict) -> dict:
|
|||||||
update set does.
|
update set does.
|
||||||
"""
|
"""
|
||||||
vmid = entry.get("_vmid")
|
vmid = entry.get("_vmid")
|
||||||
family = (entry.get("_os_family") or "").lower()
|
|
||||||
if not vmid:
|
if not vmid:
|
||||||
return {
|
return {
|
||||||
"available": False, "latest": None,
|
"available": False, "latest": None,
|
||||||
@@ -1483,7 +1517,26 @@ def _check_lxc_updates(entry: dict) -> dict:
|
|||||||
"_count": 0, "_security_count": 0, "_packages": [],
|
"_count": 0, "_security_count": 0, "_packages": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
refresh_diag = _refresh_lxc_pkg_cache_if_stale(vmid, family)
|
try:
|
||||||
|
family = _probe_lxc_os(str(vmid), raise_on_read_error=True)
|
||||||
|
except OSError:
|
||||||
|
# Keep the last known display hint, but never choose a package
|
||||||
|
# manager from it after a failed read or report a verified zero.
|
||||||
|
return {
|
||||||
|
"available": False, "latest": None, "last_check": _now_iso(),
|
||||||
|
"error": "current OS release could not be read",
|
||||||
|
}
|
||||||
|
entry["_os_family"] = family
|
||||||
|
|
||||||
|
if family not in _SUPPORTED_OS_FAMILIES:
|
||||||
|
return {
|
||||||
|
"available": False, "latest": None, "last_check": _now_iso(),
|
||||||
|
"error": "current OS could not be determined or is unsupported",
|
||||||
|
}
|
||||||
|
|
||||||
|
# The manual read-only action must never run apt-get/apk update.
|
||||||
|
refresh_diag = (_refresh_lxc_pkg_cache_if_stale(vmid, family)
|
||||||
|
if refresh_package_cache else {})
|
||||||
|
|
||||||
if family in ("debian", "ubuntu"):
|
if family in ("debian", "ubuntu"):
|
||||||
ok, stdout, err = _run_pct_pkg_listing(
|
ok, stdout, err = _run_pct_pkg_listing(
|
||||||
@@ -1783,12 +1836,12 @@ def check_for_updates(force: bool = False) -> list[dict]:
|
|||||||
return updates_available
|
return updates_available
|
||||||
|
|
||||||
|
|
||||||
def refresh_lxc(vmid: int) -> Optional[dict]:
|
def refresh_lxc(vmid: int, *, refresh_package_cache: bool = True) -> Optional[dict]:
|
||||||
"""Detect and refresh exactly one running LXC.
|
"""Detect and refresh exactly one running LXC.
|
||||||
|
|
||||||
This is the lifecycle counterpart of the daily collector. It is called
|
Called by lifecycle/apply finalizers and the explicit read-only action.
|
||||||
after a stopped container starts and deliberately leaves every other
|
The latter passes refresh_package_cache=False to forbid index writes.
|
||||||
guest's registry row untouched.
|
Every other guest's registry row is left untouched.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
target_vmid = int(vmid)
|
target_vmid = int(vmid)
|
||||||
@@ -1813,7 +1866,8 @@ def refresh_lxc(vmid: int) -> Optional[dict]:
|
|||||||
_merge_detected_entry(target, entry, now)
|
_merge_detected_entry(target, entry, now)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = _check_lxc_updates(target)
|
result = (_check_lxc_updates(target) if refresh_package_cache else
|
||||||
|
_check_lxc_updates(target, refresh_package_cache=False))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
result = {
|
result = {
|
||||||
"available": False,
|
"available": False,
|
||||||
@@ -1825,5 +1879,6 @@ def refresh_lxc(vmid: int) -> Optional[dict]:
|
|||||||
reg["items"] = items
|
reg["items"] = items
|
||||||
reg["version"] = _SCHEMA_VERSION
|
reg["version"] = _SCHEMA_VERSION
|
||||||
reg["last_targeted_refresh"] = now
|
reg["last_targeted_refresh"] = now
|
||||||
_write_registry(reg)
|
if not _write_registry(reg):
|
||||||
|
raise OSError("could not persist LXC update check")
|
||||||
return dict(target)
|
return dict(target)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import ast
|
import ast
|
||||||
import threading
|
import threading
|
||||||
|
from unittest.mock import Mock
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -109,6 +110,10 @@ class LxcPrimaryAddressTests(unittest.TestCase):
|
|||||||
import types
|
import types
|
||||||
self.server.update({
|
self.server.update({
|
||||||
"_lxc_ip_cache": {},
|
"_lxc_ip_cache": {},
|
||||||
|
"_lxc_ip_cache_lock": threading.RLock(),
|
||||||
|
"_LXC_IP_TTL": 60,
|
||||||
|
"_LXC_IP_RETRY_TTL": 5,
|
||||||
|
"time": Mock(monotonic=Mock(return_value=0)),
|
||||||
"subprocess": types.SimpleNamespace(run=run),
|
"subprocess": types.SimpleNamespace(run=run),
|
||||||
"_lxc_isolated_ips": lambda _vmid: set(),
|
"_lxc_isolated_ips": lambda _vmid: set(),
|
||||||
"_lxc_shares_host_network": lambda _vmid: False,
|
"_lxc_shares_host_network": lambda _vmid: False,
|
||||||
@@ -126,7 +131,8 @@ class LxcPrimaryAddressTests(unittest.TestCase):
|
|||||||
|
|
||||||
def test_an_answer_without_ipv4_of_its_own_is_read_again(self):
|
def test_an_answer_without_ipv4_of_its_own_is_read_again(self):
|
||||||
self.read("172.30.232.1\n172.30.32.1\nfd0c:ac1e:2100::1\n")
|
self.read("172.30.232.1\n172.30.32.1\nfd0c:ac1e:2100::1\n")
|
||||||
self.assertNotIn(121, self.server["_lxc_ip_cache"])
|
self.assertIn(121, self.server["_lxc_ip_cache"])
|
||||||
|
self.server["time"].monotonic.return_value = 5
|
||||||
self.assertEqual(self.read("192.168.0.100\n172.30.32.1\n")["primary_ip"], "192.168.0.100")
|
self.assertEqual(self.read("192.168.0.100\n172.30.32.1\n")["primary_ip"], "192.168.0.100")
|
||||||
self.assertIn(121, self.server["_lxc_ip_cache"])
|
self.assertIn(121, self.server["_lxc_ip_cache"])
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""The VM/LXC modal caches notice a .conf edited outside the Monitor."""
|
"""The VM/LXC modal caches notice a .conf edited outside the Monitor."""
|
||||||
import ast
|
import ast
|
||||||
|
import io
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
@@ -16,6 +17,7 @@ class ConfAwareCacheTests(unittest.TestCase):
|
|||||||
nodes = [n for n in tree.body if isinstance(n, ast.FunctionDef) and n.name in NAMES]
|
nodes = [n for n in tree.body if isinstance(n, ast.FunctionDef) and n.name in NAMES]
|
||||||
self.assertEqual(sorted(n.name for n in nodes), sorted(NAMES))
|
self.assertEqual(sorted(n.name for n in nodes), sorted(NAMES))
|
||||||
self.files = {'/etc/pve/lxc/111.conf': (1, 300)}
|
self.files = {'/etc/pve/lxc/111.conf': (1, 300)}
|
||||||
|
self.contents = {}
|
||||||
|
|
||||||
def stat(path):
|
def stat(path):
|
||||||
if path not in self.files:
|
if path not in self.files:
|
||||||
@@ -24,7 +26,7 @@ class ConfAwareCacheTests(unittest.TestCase):
|
|||||||
return SimpleNamespace(st_mtime_ns=mtime, st_size=size)
|
return SimpleNamespace(st_mtime_ns=mtime, st_size=size)
|
||||||
|
|
||||||
self.scope = {'os': SimpleNamespace(stat=stat), 'time': time, '_vm_modal_cache_lock': threading.Lock(),
|
self.scope = {'os': SimpleNamespace(stat=stat), 'time': time, '_vm_modal_cache_lock': threading.Lock(),
|
||||||
'_vm_conf_built': {}}
|
'_vm_conf_built': {}, 'open': lambda path, _: io.BytesIO(self.contents.get(path, b'onboot: 0'))}
|
||||||
exec(compile(ast.Module(body=nodes, type_ignores=[]), str(SERVER), 'exec'), self.scope)
|
exec(compile(ast.Module(body=nodes, type_ignores=[]), str(SERVER), 'exec'), self.scope)
|
||||||
self.cache = {}
|
self.cache = {}
|
||||||
|
|
||||||
@@ -50,9 +52,19 @@ class ConfAwareCacheTests(unittest.TestCase):
|
|||||||
self.scope['_vm_cache_put'](self.cache, 111, {'old': True})
|
self.scope['_vm_cache_put'](self.cache, 111, {'old': True})
|
||||||
self.assertIsNone(self.get())
|
self.assertIsNone(self.get())
|
||||||
|
|
||||||
|
def test_same_size_same_mtime_content_edit_is_detected(self):
|
||||||
|
self.build({'onboot': 0})
|
||||||
|
self.contents['/etc/pve/lxc/111.conf'] = b'onboot: 1'
|
||||||
|
self.assertIsNone(self.get())
|
||||||
|
|
||||||
|
def test_deleted_configuration_is_never_served_from_cache(self):
|
||||||
|
self.build({'onboot': 0})
|
||||||
|
self.files.clear()
|
||||||
|
self.assertIsNone(self.get())
|
||||||
|
|
||||||
def test_vm_conf_path_and_missing_guest(self):
|
def test_vm_conf_path_and_missing_guest(self):
|
||||||
self.files = {'/etc/pve/qemu-server/200.conf': (5, 10)}
|
self.files = {'/etc/pve/qemu-server/200.conf': (5, 10)}
|
||||||
self.assertEqual(self.scope['_vm_conf_signature'](200), (5, 10))
|
self.assertEqual(self.scope['_vm_conf_signature'](200)[1:3], (5, 10))
|
||||||
self.assertIsNone(self.scope['_vm_conf_signature'](999))
|
self.assertIsNone(self.scope['_vm_conf_signature'](999))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,441 @@
|
|||||||
|
"""Actual Flask routes and registry writes, with only PVE transports mocked.
|
||||||
|
|
||||||
|
Run with Flask installed. AST loading avoids starting Monitor's host workers;
|
||||||
|
route decorators remain intact, authentication is a fixture passthrough.
|
||||||
|
"""
|
||||||
|
import ast
|
||||||
|
import builtins
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
import datetime
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
from types import SimpleNamespace
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
from flask import Flask, jsonify
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(ROOT / "AppImage/scripts"))
|
||||||
|
import managed_installs as managed
|
||||||
|
|
||||||
|
SERVER = ROOT / "AppImage/scripts/flask_server.py"
|
||||||
|
FUNCTIONS = {
|
||||||
|
"_get_lxc_ip_info_cached", "_remember_lxc_ip", "_invalidate_lxc_ip",
|
||||||
|
"_get_lxc_primary_ip_cached", "_normalise_lxc_ips", "_lxc_runtime_details",
|
||||||
|
"_vm_cache_get", "_vm_cache_put", "_vm_conf_signature",
|
||||||
|
"_vm_conf_cache_get", "_vm_conf_cache_put", "_vm_cache_invalidate",
|
||||||
|
"_read_pve_conf_fast", "_detect_vm_type_local", "_get_lxc_update_status_map",
|
||||||
|
"get_proxmox_vms", "api_vms", "get_vm_config", "api_lxc_updates_check",
|
||||||
|
"api_managed_installs_refresh", "api_managed_installs_get",
|
||||||
|
"api_vms_modal_cache_all", "api_lxc_mount_points",
|
||||||
|
}
|
||||||
|
GLOBALS = {
|
||||||
|
"_NUMERIC_CONF_KEYS", "_LXC_IP_TTL", "_LXC_IP_RETRY_TTL", "_lxc_ip_cache",
|
||||||
|
"_lxc_ip_cache_lock", "_lxc_os_info_cache", "_LXC_OS_INFO_TTL",
|
||||||
|
"_vm_details_cache", "_vm_modal_cache_lock", "_vm_conf_built",
|
||||||
|
"_VM_CACHE_INDEFINITE", "_VM_DETAILS_TTL", "_lxc_update_check_lock",
|
||||||
|
"_lxc_update_check_attempts", "_lxc_update_check_inflight",
|
||||||
|
"_LXC_UPDATE_CHECK_INTERVAL",
|
||||||
|
"_vm_backups_cache", "_VM_BACKUPS_TTL", "_vm_mounts_cache", "_VM_MOUNTS_TTL",
|
||||||
|
"_vm_app_suggestions_cache", "_VM_APP_SUGGESTIONS_TTL", "_vm_schedule_cache", "_VM_SCHEDULE_TTL",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ObservedStateRoutesTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.temp.cleanup)
|
||||||
|
self.conf = Path(self.temp.name) / "100.conf"
|
||||||
|
self.conf.write_text("onboot: 0\nnet0: name=eth0,ip=dhcp\ntags: old\n")
|
||||||
|
self.clock = Mock(time=Mock(return_value=1000), monotonic=Mock(return_value=1000))
|
||||||
|
real_open = builtins.open
|
||||||
|
|
||||||
|
def mapped(path):
|
||||||
|
if str(path) == "/etc/pve/lxc/100.conf":
|
||||||
|
return self.conf
|
||||||
|
if str(path).startswith("/etc/pve/"):
|
||||||
|
return Path(self.temp.name) / "missing"
|
||||||
|
return path
|
||||||
|
|
||||||
|
self.app = Flask(__name__)
|
||||||
|
self.scope = {
|
||||||
|
"app": self.app, "jsonify": jsonify, "require_auth": lambda fn: fn,
|
||||||
|
"time": self.clock, "threading": threading,
|
||||||
|
"os": SimpleNamespace(stat=lambda p: os.stat(mapped(p)),
|
||||||
|
path=SimpleNamespace(exists=lambda p: os.path.exists(mapped(p)))),
|
||||||
|
"open": lambda p, *a, **kw: real_open(mapped(p), *a, **kw),
|
||||||
|
"subprocess": subprocess,
|
||||||
|
}
|
||||||
|
nodes = []
|
||||||
|
for node in ast.parse(SERVER.read_text()).body:
|
||||||
|
if isinstance(node, ast.FunctionDef) and node.name in FUNCTIONS:
|
||||||
|
nodes.append(node)
|
||||||
|
elif isinstance(node, (ast.Assign, ast.AnnAssign)):
|
||||||
|
targets = node.targets if isinstance(node, ast.Assign) else [node.target]
|
||||||
|
if any(isinstance(t, ast.Name) and t.id in GLOBALS for t in targets):
|
||||||
|
nodes.append(node)
|
||||||
|
self.assertEqual({n.name for n in nodes if isinstance(n, ast.FunctionDef)}, FUNCTIONS)
|
||||||
|
exec(compile(ast.Module(body=nodes, type_ignores=[]), str(SERVER), "exec"), self.scope)
|
||||||
|
self.running = True
|
||||||
|
self.ip = "10.100.100.222"
|
||||||
|
self.release = 'ID="debian"\nVERSION_ID="13.7"\nPRETTY_NAME="Debian GNU/Linux 13"\n'
|
||||||
|
self.ip_probe = Mock(side_effect=lambda _id: {
|
||||||
|
"primary_ip": self.ip, "real_ips": [self.ip],
|
||||||
|
"all_ips": [self.ip, self.ip, "172.17.0.1"], "docker_ips": ["172.17.0.1"],
|
||||||
|
})
|
||||||
|
self.scope.update({
|
||||||
|
"get_lxc_ip_from_lxc_info": self.ip_probe,
|
||||||
|
"get_proxmox_node_name": lambda: "fixture-pve",
|
||||||
|
"_fast_guest_status": lambda *_: "running" if self.running else "stopped",
|
||||||
|
"_get_cached_vm_list_search_notes": lambda *_: {},
|
||||||
|
"_get_lxc_app_watch_map": lambda: {},
|
||||||
|
"_get_lxc_docker_inventory_map": lambda: {},
|
||||||
|
"get_cached_vm_disk": lambda _: None,
|
||||||
|
"_guest_lifecycle_lock": threading.RLock(),
|
||||||
|
"_guest_modal_cache_revision": {}, "_guest_modal_cache_epoch": 1,
|
||||||
|
"parse_lxc_hardware_config": lambda *_: {"privileged": False},
|
||||||
|
"get_cached_pvesh_cluster_resources_vm": lambda: [
|
||||||
|
{"type": "lxc", "node": "fixture-pve", "vmid": 100, "name": "dev-trixie",
|
||||||
|
"status": "running" if self.running else "stopped"},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
self.commands = []
|
||||||
|
|
||||||
|
def run(command, **_):
|
||||||
|
self.commands.append(command)
|
||||||
|
if command[-2:] == ["cat", "/etc/os-release"]:
|
||||||
|
return SimpleNamespace(returncode=0, stdout=self.release, stderr="")
|
||||||
|
if command[-1] == "apt list --upgradable 2>/dev/null":
|
||||||
|
return SimpleNamespace(returncode=0, stdout="Listing...\n", stderr="")
|
||||||
|
raise AssertionError(f"Unexpected guest command: {command}")
|
||||||
|
|
||||||
|
for ctx in (
|
||||||
|
patch.object(subprocess, "run", side_effect=run),
|
||||||
|
patch.object(managed, "_DB_DIR", self.temp.name),
|
||||||
|
patch.object(managed, "_REGISTRY_PATH", str(Path(self.temp.name) / "registry.json")),
|
||||||
|
patch.object(managed, "_lxc_updates_detection_enabled", return_value=True),
|
||||||
|
patch.object(managed, "_list_pve_lxcs", return_value=[
|
||||||
|
{"vmid": "100", "name": "dev-trixie", "status": "running"},
|
||||||
|
]),
|
||||||
|
patch.object(managed, "_get_oci_managed_vmids", return_value={}),
|
||||||
|
patch.object(managed, "_probe_lxc_is_oci", return_value=False),
|
||||||
|
patch.object(managed, "_identify_helper_slug", return_value=(None, None)),
|
||||||
|
):
|
||||||
|
ctx.start()
|
||||||
|
self.addCleanup(ctx.stop)
|
||||||
|
managed._write_registry({"items": [
|
||||||
|
{"id": "lxc:100", "type": "lxc", "_vmid": "100", "_os_family": "ubuntu",
|
||||||
|
"update_check": {"available": True, "_count": 51, "_security_count": 24,
|
||||||
|
"_packages": [{"name": "old"}], "last_check": "2020-01-01T00:00:00Z"}},
|
||||||
|
{"id": "other", "type": "unhandled", "update_check": {"_count": 7}},
|
||||||
|
]})
|
||||||
|
self.client = self.app.test_client()
|
||||||
|
|
||||||
|
def row(self):
|
||||||
|
response = self.client.get("/api/vms")
|
||||||
|
self.assertEqual(response.status_code, 200, response.json)
|
||||||
|
return response.json[0]
|
||||||
|
|
||||||
|
def test_dhcp_change_after_ttl_without_lifecycle_and_api_compatibility(self):
|
||||||
|
first = self.row()
|
||||||
|
self.assertEqual(first["ip"], "10.100.100.222")
|
||||||
|
self.assertEqual(first["ips"], ["10.100.100.222", "172.17.0.1"])
|
||||||
|
self.ip = "10.100.100.220"
|
||||||
|
self.clock.monotonic.return_value = 1059
|
||||||
|
self.assertEqual(self.row()["ip"], "10.100.100.222")
|
||||||
|
self.ip_probe.assert_called_once_with(100)
|
||||||
|
self.clock.monotonic.return_value = 1060
|
||||||
|
current = self.row()
|
||||||
|
self.assertEqual(current["ip"], "10.100.100.220")
|
||||||
|
self.assertEqual(current["ips"], ["10.100.100.220", "172.17.0.1"])
|
||||||
|
self.assertEqual(self.ip_probe.call_count, 2)
|
||||||
|
self.assertEqual(self.commands, [])
|
||||||
|
|
||||||
|
def test_repeated_list_get_is_read_only_and_does_not_refresh_registry(self):
|
||||||
|
before = managed.get_registry()
|
||||||
|
for _ in range(10):
|
||||||
|
self.assertTrue(self.row()["update_check"]["stale"])
|
||||||
|
self.assertEqual(managed.get_registry(), before)
|
||||||
|
self.assertEqual(self.commands, [])
|
||||||
|
self.ip_probe.assert_called_once()
|
||||||
|
|
||||||
|
def test_concurrent_expired_ip_reads_share_one_probe(self):
|
||||||
|
self.scope["_get_lxc_ip_info_cached"](100)
|
||||||
|
self.clock.monotonic.return_value += 60
|
||||||
|
self.ip = "10.100.100.220"
|
||||||
|
with ThreadPoolExecutor(max_workers=8) as pool:
|
||||||
|
values = list(pool.map(self.scope["_get_lxc_primary_ip_cached"], [100] * 16))
|
||||||
|
self.assertEqual(set(values), {self.ip})
|
||||||
|
self.assertEqual(self.ip_probe.call_count, 2)
|
||||||
|
|
||||||
|
def test_package_listing_failure_stores_error_without_reusing_old_count(self):
|
||||||
|
with patch.object(managed, "_run_pct_pkg_listing", return_value=(False, "", "APT metadata unreadable")):
|
||||||
|
response = self.client.post("/api/vms/100/updates/check")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.json["update_check"]["error"], "APT metadata unreadable")
|
||||||
|
self.assertNotIn("_count", managed.get_registry()["items"][0]["update_check"])
|
||||||
|
|
||||||
|
def test_onboot_network_and_tags_external_edit_rebuilds_next_detail(self):
|
||||||
|
first = self.client.get("/api/vms/100").json
|
||||||
|
self.assertEqual(first["config"]["onboot"], 0)
|
||||||
|
# Ensure distinct metadata even on coarse temporary filesystems.
|
||||||
|
self.conf.write_text("onboot: 1\nnet0: name=eth0,ip=dhcp,bridge=vmbr1\ntags: new;test\n")
|
||||||
|
os.utime(self.conf, ns=(2_000_000_000, 2_000_000_000))
|
||||||
|
current = self.client.get("/api/vms/100")
|
||||||
|
self.assertEqual(current.status_code, 200, current.json)
|
||||||
|
self.assertEqual(current.json["config"]["onboot"], 1)
|
||||||
|
self.assertEqual(current.json["config"]["tags"], "new;test")
|
||||||
|
self.assertIn("vmbr1", current.json["config"]["net0"])
|
||||||
|
self.assertEqual(current.json["vm_type"], "lxc")
|
||||||
|
self.assertIn("digest", current.json["config"])
|
||||||
|
self.assertEqual(current.json["lxc_ip_info"]["primary_ip"], self.ip)
|
||||||
|
self.assertEqual(current.json["os_info"]["version_id"], "13.7")
|
||||||
|
|
||||||
|
def test_runtime_ip_and_os_expire_even_with_unchanged_conf(self):
|
||||||
|
self.release = 'ID=ubuntu\nVERSION_ID=24.04\n'
|
||||||
|
self.assertEqual(self.client.get("/api/vms/100").json["os_info"]["id"], "ubuntu")
|
||||||
|
self.release = 'ID=debian\nVERSION_ID=13.7\n'
|
||||||
|
self.ip = "10.100.100.220"
|
||||||
|
self.clock.time.return_value += 60
|
||||||
|
self.clock.monotonic.return_value += 60
|
||||||
|
current = self.client.get("/api/vms/100").json
|
||||||
|
self.assertEqual(current["os_info"]["id"], "debian")
|
||||||
|
self.assertEqual(current["lxc_ip_info"]["primary_ip"], self.ip)
|
||||||
|
self.assertEqual(current["config"]["onboot"], 0)
|
||||||
|
|
||||||
|
def test_same_size_same_mtime_onboot_edit_is_visible_through_route(self):
|
||||||
|
self.assertEqual(self.client.get("/api/vms/100").json["config"]["onboot"], 0)
|
||||||
|
old = self.conf.stat()
|
||||||
|
self.conf.write_text(self.conf.read_text().replace("onboot: 0", "onboot: 1"))
|
||||||
|
os.utime(self.conf, ns=(old.st_atime_ns, old.st_mtime_ns))
|
||||||
|
self.assertEqual(self.conf.stat().st_size, old.st_size)
|
||||||
|
self.assertEqual(self.conf.stat().st_mtime_ns, old.st_mtime_ns)
|
||||||
|
self.assertEqual(self.client.get("/api/vms/100").json["config"]["onboot"], 1)
|
||||||
|
|
||||||
|
def test_bulk_cache_consumer_rejects_changed_config_without_live_probes(self):
|
||||||
|
import lxc_apps
|
||||||
|
self.client.get("/api/vms/100")
|
||||||
|
count = len(self.commands)
|
||||||
|
with patch.object(lxc_apps, "load_sidecar", return_value={"vmid": 100, "apps": []}):
|
||||||
|
response = self.client.get("/api/vms/modal-cache-all")
|
||||||
|
self.assertEqual(response.status_code, 200, response.json)
|
||||||
|
self.assertEqual(response.json["guests"][0]["details"]["config"]["onboot"], 0)
|
||||||
|
self.conf.write_text("onboot: 1\n")
|
||||||
|
response = self.client.get("/api/vms/modal-cache-all")
|
||||||
|
self.assertIsNone(response.json["guests"][0]["details"])
|
||||||
|
self.assertEqual(len(self.commands), count, "bulk cache remains a snapshot-only reader")
|
||||||
|
self.assertEqual(self.client.get("/api/vms/100").json["config"]["onboot"], 1)
|
||||||
|
|
||||||
|
def test_mount_cache_consumer_rebuilds_after_external_config_change(self):
|
||||||
|
provider = Mock(return_value={"ok": True, "mount_points": [{"target": "/old"}]})
|
||||||
|
with patch.dict(sys.modules, {"lxc_mount_points": SimpleNamespace(get_lxc_mount_points_static=provider)}):
|
||||||
|
path = "/api/lxc/100/mount-points"
|
||||||
|
self.assertEqual(self.client.get(path).json["mount_points"][0]["target"], "/old")
|
||||||
|
self.client.get(path)
|
||||||
|
provider.assert_called_once_with("100")
|
||||||
|
self.conf.write_text("onboot: 0\nmp0: local:100/vm-100-disk-1.raw,mp=/new\n")
|
||||||
|
provider.return_value = {"ok": True, "mount_points": [{"target": "/new"}]}
|
||||||
|
self.assertEqual(self.client.get(path).json["mount_points"][0]["target"], "/new")
|
||||||
|
self.assertEqual(provider.call_count, 2)
|
||||||
|
|
||||||
|
def test_read_only_post_replaces_ubuntu_51_with_debian_zero(self):
|
||||||
|
response = self.client.post("/api/vms/100/updates/check")
|
||||||
|
self.assertEqual(response.status_code, 200, response.json)
|
||||||
|
uc = response.json["update_check"]
|
||||||
|
self.assertEqual(uc["os_family"], "debian")
|
||||||
|
self.assertEqual((uc["count"], uc["security_count"], uc["packages"]), (0, 0, []))
|
||||||
|
self.assertFalse(uc["available"])
|
||||||
|
self.assertFalse(uc["stale"])
|
||||||
|
self.assertIsNone(uc["error"])
|
||||||
|
reg = managed.get_registry()
|
||||||
|
self.assertEqual(reg["items"][0]["_os_family"], "debian")
|
||||||
|
self.assertEqual(reg["items"][1]["update_check"]["_count"], 7)
|
||||||
|
self.assertEqual(self.row()["update_check"], uc)
|
||||||
|
self.assertEqual(len(self.commands), 2)
|
||||||
|
self.assertIn("apt list --upgradable", self.commands[1][-1])
|
||||||
|
self.assertFalse(any("apt-get" in str(cmd) or "upgrade" in str(cmd) and "upgradable" not in str(cmd)
|
||||||
|
for cmd in self.commands))
|
||||||
|
|
||||||
|
def test_per_guest_cooldown_and_inflight(self):
|
||||||
|
self.assertEqual(self.client.post("/api/vms/100/updates/check").status_code, 200)
|
||||||
|
count = len(self.commands)
|
||||||
|
denied = self.client.post("/api/vms/100/updates/check")
|
||||||
|
self.assertEqual(denied.status_code, 429)
|
||||||
|
self.assertGreater(int(denied.headers["Retry-After"]), 0)
|
||||||
|
self.assertEqual(len(self.commands), count)
|
||||||
|
self.clock.monotonic.return_value += 60
|
||||||
|
self.scope["_lxc_update_check_inflight"].add(100)
|
||||||
|
self.assertEqual(self.client.post("/api/vms/100/updates/check").status_code, 429)
|
||||||
|
self.scope["_lxc_update_check_inflight"].clear()
|
||||||
|
self.assertEqual(self.client.post("/api/vms/100/updates/check").status_code, 200)
|
||||||
|
|
||||||
|
def test_disabled_stopped_missing_and_get_action_are_rejected(self):
|
||||||
|
with patch.object(managed, "_lxc_updates_detection_enabled", return_value=False):
|
||||||
|
self.assertEqual(self.client.post("/api/vms/100/updates/check").status_code, 403)
|
||||||
|
self.running = False
|
||||||
|
self.assertEqual(self.client.post("/api/vms/100/updates/check").status_code, 409)
|
||||||
|
self.assertEqual(self.row()["ips"], [])
|
||||||
|
self.assertNotIn("os_info", self.client.get("/api/vms/100").json)
|
||||||
|
self.assertEqual(self.client.post("/api/vms/999/updates/check").status_code, 404)
|
||||||
|
self.assertEqual(self.client.get("/api/vms/100/updates/check").status_code, 405)
|
||||||
|
self.assertEqual(self.commands, [])
|
||||||
|
|
||||||
|
def test_readable_unsupported_os_replaces_old_family_without_claiming_zero(self):
|
||||||
|
self.release = "ID=unsupported\n"
|
||||||
|
response = self.client.post("/api/vms/100/updates/check")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIsNone(response.json["update_check"]["os_family"])
|
||||||
|
self.assertTrue(response.json["update_check"]["error"])
|
||||||
|
self.assertNotIn("_count", managed.get_registry()["items"][0]["update_check"])
|
||||||
|
self.assertEqual(len(self.commands), 1)
|
||||||
|
|
||||||
|
def test_unreadable_os_keeps_last_known_family_but_clears_old_update_count(self):
|
||||||
|
failures = [subprocess.TimeoutExpired('pct', 5), OSError('transport failed'),
|
||||||
|
SimpleNamespace(returncode=1, stdout='', stderr='read failed'),
|
||||||
|
SimpleNamespace(returncode=0, stdout='', stderr='')]
|
||||||
|
for failure in failures:
|
||||||
|
with self.subTest(failure=failure):
|
||||||
|
self.clock.monotonic.return_value += 60
|
||||||
|
with patch.object(subprocess, 'run') as run:
|
||||||
|
if isinstance(failure, Exception):
|
||||||
|
run.side_effect = failure
|
||||||
|
else:
|
||||||
|
run.return_value = failure
|
||||||
|
response = self.client.post('/api/vms/100/updates/check')
|
||||||
|
self.assertEqual(response.status_code, 200, response.json)
|
||||||
|
self.assertEqual(response.json['update_check']['os_family'], 'ubuntu')
|
||||||
|
self.assertTrue(response.json['update_check']['error'])
|
||||||
|
stored = managed.get_registry()['items'][0]
|
||||||
|
self.assertEqual(stored['_os_family'], 'ubuntu')
|
||||||
|
self.assertNotIn('_count', stored['update_check'])
|
||||||
|
run.assert_called_once()
|
||||||
|
|
||||||
|
def test_daily_and_global_refresh_keep_os_hint_on_read_failure(self):
|
||||||
|
for global_route in (False, True):
|
||||||
|
with self.subTest(global_route=global_route), \
|
||||||
|
patch.object(subprocess, 'run', side_effect=OSError('unreadable')), \
|
||||||
|
patch.object(managed, '_DETECTORS', [managed._detect_lxc_containers]), \
|
||||||
|
patch.object(managed, '_refresh_lxc_pkg_cache_if_stale') as refresh:
|
||||||
|
if global_route:
|
||||||
|
self.assertEqual(self.client.post('/api/managed-installs/refresh').status_code, 200)
|
||||||
|
else:
|
||||||
|
managed.check_for_updates()
|
||||||
|
stored = managed.get_registry()['items'][0]
|
||||||
|
self.assertEqual(stored['_os_family'], 'ubuntu')
|
||||||
|
self.assertTrue(stored['update_check']['error'])
|
||||||
|
self.assertNotIn('_count', stored['update_check'])
|
||||||
|
refresh.assert_not_called()
|
||||||
|
|
||||||
|
def test_legacy_os_detection_returns_none_on_transport_failure(self):
|
||||||
|
with patch.object(subprocess, 'run', side_effect=OSError('unreadable')):
|
||||||
|
self.assertIsNone(managed._probe_lxc_os('100'))
|
||||||
|
|
||||||
|
def test_new_oci_detection_and_check_never_probe_os(self):
|
||||||
|
managed._write_registry({'items': []})
|
||||||
|
for is_oci, membership in ((True, {}), (False, {'100': 'secure-gateway'})):
|
||||||
|
with self.subTest(is_oci=is_oci), \
|
||||||
|
patch.object(managed, '_probe_lxc_is_oci', return_value=is_oci), \
|
||||||
|
patch.object(managed, '_get_oci_managed_vmids', return_value=membership), \
|
||||||
|
patch.object(managed, '_probe_lxc_os', side_effect=AssertionError('OCI OS probe forbidden')):
|
||||||
|
entry = managed._detect_lxc_containers(only_vmid=100)[0]
|
||||||
|
self.assertIsNone(entry['_os_family'])
|
||||||
|
self.assertEqual(managed._check_lxc_updates(entry)['_count'], 0)
|
||||||
|
|
||||||
|
def test_registry_write_failure_is_reported_and_releases_inflight(self):
|
||||||
|
with patch.object(managed, "_write_registry", return_value=False):
|
||||||
|
self.assertEqual(self.client.post("/api/vms/100/updates/check").status_code, 500)
|
||||||
|
self.assertNotIn(100, self.scope["_lxc_update_check_inflight"])
|
||||||
|
self.assertEqual(managed.get_registry()["items"][0]["_os_family"], "ubuntu")
|
||||||
|
|
||||||
|
def test_daily_checker_reprobes_os_and_preserves_existing_index_refresh(self):
|
||||||
|
with patch.object(managed, "_refresh_lxc_pkg_cache_if_stale", return_value={}) as refresh:
|
||||||
|
managed.check_for_updates()
|
||||||
|
refresh.assert_called_once_with("100", "debian")
|
||||||
|
self.assertEqual(managed.get_registry()["items"][0]["_os_family"], "debian")
|
||||||
|
self.assertEqual(self.row()["update_check"]["count"], 0)
|
||||||
|
|
||||||
|
def test_existing_global_refresh_route_keeps_its_collector_contract(self):
|
||||||
|
with patch.object(managed, "_DETECTORS", [managed._detect_lxc_containers]), \
|
||||||
|
patch.object(managed, "_refresh_lxc_pkg_cache_if_stale", return_value={}) as refresh:
|
||||||
|
response = self.client.post("/api/managed-installs/refresh")
|
||||||
|
self.assertEqual(response.status_code, 200, response.json)
|
||||||
|
refresh.assert_called_once_with("100", "debian")
|
||||||
|
self.assertEqual(managed.get_registry()["items"][0]["_os_family"], "debian")
|
||||||
|
|
||||||
|
def test_existing_lifecycle_refresh_keeps_index_refresh_default(self):
|
||||||
|
with patch.object(managed, "_refresh_lxc_pkg_cache_if_stale", return_value={}) as refresh:
|
||||||
|
item = managed.refresh_lxc(100)
|
||||||
|
refresh.assert_called_once_with("100", "debian")
|
||||||
|
self.assertEqual(item["update_check"]["_count"], 0)
|
||||||
|
|
||||||
|
def test_other_lxc_has_independent_ip_cache_and_manual_rate_limit(self):
|
||||||
|
conf200 = Path(self.temp.name) / "200.conf"
|
||||||
|
conf200.write_text("onboot: 1\n")
|
||||||
|
original_open = self.scope["open"]
|
||||||
|
self.scope["open"] = lambda path, *args, **kw: (
|
||||||
|
builtins.open(conf200, *args, **kw) if path == "/etc/pve/lxc/200.conf"
|
||||||
|
else original_open(path, *args, **kw))
|
||||||
|
original_exists = self.scope["os"].path.exists
|
||||||
|
self.scope["os"].path.exists = lambda path: path == "/etc/pve/lxc/200.conf" or original_exists(path)
|
||||||
|
self.assertEqual(self.client.post("/api/vms/100/updates/check").status_code, 200)
|
||||||
|
with patch.object(managed, "_list_pve_lxcs", return_value=[
|
||||||
|
{"vmid": "200", "name": "other-ct", "status": "running"}]):
|
||||||
|
response = self.client.post("/api/vms/200/updates/check")
|
||||||
|
self.assertEqual(response.status_code, 200, response.json)
|
||||||
|
self.assertEqual(response.json["vmid"], 200)
|
||||||
|
self.assertEqual(response.json["update_check"]["count"], 0)
|
||||||
|
self.scope["_get_lxc_ip_info_cached"](100)
|
||||||
|
self.scope["_get_lxc_ip_info_cached"](200)
|
||||||
|
self.assertEqual(set(self.scope["_lxc_ip_cache"]), {100, 200})
|
||||||
|
self.assertEqual(self.client.post("/api/vms/100/updates/check").status_code, 429)
|
||||||
|
|
||||||
|
def test_oci_and_managed_apps_never_run_generic_package_check(self):
|
||||||
|
for flags in ({"_is_oci": True}, {"_managed_oci_app": "secure-gateway"}):
|
||||||
|
with self.subTest(flags=flags):
|
||||||
|
entry = {"_vmid": "100", "_os_family": "ubuntu", **flags}
|
||||||
|
before = len(self.commands)
|
||||||
|
result = managed._check_lxc_updates(entry, refresh_package_cache=False)
|
||||||
|
self.assertEqual(result["_count"], 0)
|
||||||
|
self.assertEqual(entry["_os_family"], "ubuntu")
|
||||||
|
self.assertEqual(len(self.commands) - before, 0)
|
||||||
|
|
||||||
|
def test_lifecycle_invalidation_still_forces_probe_and_negative_cache_is_bounded(self):
|
||||||
|
self.row()
|
||||||
|
self.ip = "10.100.100.220"
|
||||||
|
self.scope["_invalidate_lxc_ip"]("100")
|
||||||
|
self.assertEqual(self.row()["ip"], self.ip)
|
||||||
|
self.ip_probe.return_value = None
|
||||||
|
self.ip_probe.side_effect = None
|
||||||
|
self.scope["_invalidate_lxc_ip"](100)
|
||||||
|
for _ in range(5):
|
||||||
|
self.assertNotIn("ip", self.row())
|
||||||
|
self.assertEqual(self.ip_probe.call_count, 3)
|
||||||
|
self.clock.monotonic.return_value += 5
|
||||||
|
self.row()
|
||||||
|
self.assertEqual(self.ip_probe.call_count, 4)
|
||||||
|
|
||||||
|
|
||||||
|
class FreshnessTests(unittest.TestCase):
|
||||||
|
def test_old_missing_invalid_and_future_timestamps_are_stale(self):
|
||||||
|
for stamp in (None, "", "bad", "2020-01-01T00:00:00Z", "2999-01-01T00:00:00Z"):
|
||||||
|
self.assertTrue(managed.lxc_update_freshness({"last_check": stamp})["stale"])
|
||||||
|
|
||||||
|
def test_recent_check_and_threshold(self):
|
||||||
|
now = datetime.datetime.now(datetime.timezone.utc)
|
||||||
|
with patch.object(managed.datetime, 'datetime', wraps=datetime.datetime) as clock:
|
||||||
|
clock.now.return_value = now
|
||||||
|
for age, stale in ((60, False), (86400 + 600, False), (172799, False),
|
||||||
|
(172800, True), (172801, True)):
|
||||||
|
observation = {"last_check": (now - datetime.timedelta(seconds=age)).isoformat()}
|
||||||
|
result = managed.lxc_update_freshness(observation)
|
||||||
|
self.assertEqual(result["stale"], stale)
|
||||||
|
self.assertEqual(result["stale_after_seconds"], 172800)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
// Execute actual UI helpers, button handler and badge render with fake I/O.
|
||||||
|
const assert = require("node:assert/strict")
|
||||||
|
const fs = require("node:fs")
|
||||||
|
const path = require("node:path")
|
||||||
|
const vm = require("node:vm")
|
||||||
|
const root = path.resolve(__dirname, "../../AppImage")
|
||||||
|
const ts = require(path.join(root, "node_modules/typescript"))
|
||||||
|
|
||||||
|
function compile(source, context) {
|
||||||
|
const result = ts.transpileModule(source, { compilerOptions: {
|
||||||
|
module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, jsx: ts.JsxEmit.ReactJSX,
|
||||||
|
}, reportDiagnostics: true })
|
||||||
|
assert.equal(result.diagnostics.length, 0)
|
||||||
|
vm.runInNewContext(result.outputText, context)
|
||||||
|
return context.exports
|
||||||
|
}
|
||||||
|
|
||||||
|
const requests = []
|
||||||
|
const helpers = compile(fs.readFileSync(path.join(root, "lib/lxc-update-observation.ts"), "utf8"), {
|
||||||
|
exports: {}, require: () => ({ fetchApi: async (...args) => { requests.push(args); return {} } }),
|
||||||
|
})
|
||||||
|
assert.equal(requests.length, 0, "loading helpers never schedules a package check")
|
||||||
|
const now = Date.parse("2026-10-13T12:00:00Z")
|
||||||
|
const recent = { last_check: "2026-10-13T11:59:00Z" }
|
||||||
|
assert.equal(helpers.isLxcUpdateStale(recent, now), false)
|
||||||
|
for (const value of [undefined, {}, { last_check: "bad" }, { last_check: "2026-10-14T00:00:00Z" },
|
||||||
|
{ last_check: "2026-10-11T12:00:00Z" }, { ...recent, stale: true }]) {
|
||||||
|
assert.equal(helpers.isLxcUpdateStale(value, now), true)
|
||||||
|
}
|
||||||
|
for (const age of [86400, 86400 + 600, 172799]) {
|
||||||
|
assert.equal(helpers.isLxcUpdateStale({ last_check: new Date(now - age * 1000).toISOString() }, now), false,
|
||||||
|
'daily checks have a full extra day of freshness margin')
|
||||||
|
}
|
||||||
|
assert.equal(helpers.isLxcUpdateStale({ ...recent, stale_after_seconds: 60 }, now), true,
|
||||||
|
'the backend-provided threshold still takes precedence')
|
||||||
|
for (const lang of ['en', 'sk']) {
|
||||||
|
const catalog = JSON.parse(fs.readFileSync(path.join(root, `messages/${lang}/common.json`), 'utf8'))
|
||||||
|
assert.match(catalog.vmLxc.updates.checkStaleHelp, /48/)
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = fs.readFileSync(path.join(root, "components/virtual-machines.tsx"), "utf8")
|
||||||
|
const tree = ts.createSourceFile("vm.tsx", source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
|
||||||
|
assert.equal(tree.parseDiagnostics.length, 0)
|
||||||
|
const wanted = new Set(["checkOsUpdates", "getAggregateUpdateCheck", "renderLxcUpdateBadge"])
|
||||||
|
const definitions = []
|
||||||
|
const status = []
|
||||||
|
let detailEffect
|
||||||
|
function walk(node) {
|
||||||
|
if (ts.isCallExpression(node) && node.expression.getText(tree) === "useEffect"
|
||||||
|
&& node.arguments[1]?.getText(tree) === "[selectedVM?.vmid]") {
|
||||||
|
detailEffect = node.arguments[0].getText(tree)
|
||||||
|
}
|
||||||
|
if (ts.isVariableDeclaration(node)) {
|
||||||
|
const name = node.name.getText(tree)
|
||||||
|
if (wanted.has(name)) definitions.push(`const ${node.getText(tree)};`)
|
||||||
|
if (["osUpdateStale", "osUpdateStatusKnown", "hasOsUpdates"].includes(name))
|
||||||
|
status.push(`const ${node.getText(tree)};`)
|
||||||
|
}
|
||||||
|
ts.forEachChild(node, walk)
|
||||||
|
}
|
||||||
|
walk(tree)
|
||||||
|
assert.equal(definitions.length, 3)
|
||||||
|
assert.equal(status.length, 3)
|
||||||
|
assert.ok(detailEffect)
|
||||||
|
let checking = null
|
||||||
|
let displayedError = null
|
||||||
|
let details = null
|
||||||
|
let errorToThrow = null
|
||||||
|
let polls = 0
|
||||||
|
const context = {
|
||||||
|
exports: {},
|
||||||
|
require: () => ({ jsx: (type, props) => ({ type, props }), jsxs: (type, props) => ({ type, props }) }),
|
||||||
|
Badge: "Badge", Clock: "Clock", ArrowUpCircle: "Arrow", ChevronRight: "Chevron",
|
||||||
|
t: (key, args) => key + (args ? JSON.stringify(args) : ""),
|
||||||
|
isLxcUpdateStale: o => helpers.isLxcUpdateStale(o, now),
|
||||||
|
checkLxcUpdates: async id => {
|
||||||
|
if (errorToThrow) throw errorToThrow
|
||||||
|
return helpers.checkLxcUpdates(id)
|
||||||
|
},
|
||||||
|
fetchApi: async endpoint => { requests.push([endpoint]); return { config: { onboot: 1 }, os_info: { id: "debian" } } },
|
||||||
|
mutate: async () => { polls++ },
|
||||||
|
selectedVMRef: { current: { vmid: 100 } },
|
||||||
|
selectedVM: { vmid: 100 },
|
||||||
|
setInterval: (callback, delay) => { context.detailTimer = { callback, delay }; return 1 },
|
||||||
|
clearInterval: id => { context.clearedTimer = id },
|
||||||
|
vmModalCacheRef: { current: { details: new Map() } },
|
||||||
|
setVMDetails: value => { details = value },
|
||||||
|
setOsCheckingVmid: value => { checking = value },
|
||||||
|
setOsCheckError: value => { displayedError = value },
|
||||||
|
}
|
||||||
|
const ui = compile(definitions.join("\n") + `
|
||||||
|
exports.check = checkOsUpdates;
|
||||||
|
exports.aggregate = getAggregateUpdateCheck;
|
||||||
|
exports.badge = renderLxcUpdateBadge;
|
||||||
|
exports.osStatus = (uc) => { ${status.join("\n")} return { osUpdateStatusKnown, hasOsUpdates }; };
|
||||||
|
exports.revalidateDetails = ${detailEffect};
|
||||||
|
`, context)
|
||||||
|
const legacy = { last_check: "2026-10-12T00:00:00Z", available: true, count: 51, stale: true }
|
||||||
|
assert.equal(ui.osStatus(legacy).osUpdateStatusKnown, false, "old count must not claim live OS status")
|
||||||
|
assert.equal(ui.osStatus({ ...recent, available: false, count: 0 }).osUpdateStatusKnown, true)
|
||||||
|
assert.equal(ui.osStatus({ ...recent, error: "probe failed" }).osUpdateStatusKnown, false)
|
||||||
|
assert.equal(ui.aggregate({ app_watches: [{ update_available: true, name: "App" }] }).last_check, null,
|
||||||
|
"an app badge cannot invent an OS check timestamp")
|
||||||
|
assert.equal(ui.badge(legacy).props.children[0].type, "Clock")
|
||||||
|
assert.equal(ui.badge(legacy).props.children[1], "vmLxc.updates.checkStale")
|
||||||
|
assert.equal(ui.badge({ ...recent, available: true, count: 2 }).props.children[1], 2)
|
||||||
|
assert.equal(ui.badge({ ...recent, available: false, count: 0 }), null)
|
||||||
|
assert.equal(requests.length, 0)
|
||||||
|
|
||||||
|
;(async () => {
|
||||||
|
await ui.check(100)
|
||||||
|
assert.equal(requests[0][0], "/api/vms/100/updates/check")
|
||||||
|
assert.equal(requests[0][1].method, "POST")
|
||||||
|
assert.equal(requests[1][0], "/api/vms/100")
|
||||||
|
assert.equal(polls, 1)
|
||||||
|
assert.equal(details.os_info.id, "debian")
|
||||||
|
assert.equal(checking, null)
|
||||||
|
const before = requests.length
|
||||||
|
errorToThrow = { status: 429, body: { retry_after: 60 } }
|
||||||
|
await ui.check(100)
|
||||||
|
assert.match(displayedError, /checkCooldown.*60/)
|
||||||
|
assert.equal(checking, null)
|
||||||
|
assert.equal(requests.length, before)
|
||||||
|
assert.equal(polls, 1)
|
||||||
|
// Switching guests while a request runs must not replace another modal.
|
||||||
|
errorToThrow = null
|
||||||
|
context.selectedVMRef.current = { vmid: 200 }
|
||||||
|
details = null
|
||||||
|
await ui.check(100)
|
||||||
|
assert.equal(details, null)
|
||||||
|
errorToThrow = { status: 429, body: { retry_after: 60 } }
|
||||||
|
displayedError = null
|
||||||
|
await ui.check(100)
|
||||||
|
assert.equal(displayedError, null, "errors from the previous guest cannot leak into another modal")
|
||||||
|
const beforeTimer = requests.length
|
||||||
|
const cleanup = ui.revalidateDetails()
|
||||||
|
assert.equal(context.detailTimer.delay, 30000)
|
||||||
|
assert.equal(requests.length, beforeTimer, "mounting the detail effect does not check packages")
|
||||||
|
await context.detailTimer.callback()
|
||||||
|
assert.equal(requests.length, beforeTimer + 1)
|
||||||
|
assert.equal(requests.at(-1)[0], "/api/vms/100")
|
||||||
|
cleanup()
|
||||||
|
assert.equal(context.clearedTimer, 1)
|
||||||
|
for (const locale of ["en", "sk"]) {
|
||||||
|
const messages = JSON.parse(fs.readFileSync(path.join(root, `messages/${locale}/common.json`))).vmLxc.updates
|
||||||
|
for (const key of ["checkNow", "checkReadOnlyHelp", "checkCooldown", "checkStale", "checkStaleHelp"])
|
||||||
|
assert.ok(messages[key]?.trim())
|
||||||
|
assert.match(messages.checkCooldown, /\{seconds\}/)
|
||||||
|
}
|
||||||
|
console.log("PASS observed-state UI: freshness, badges, safe button, cooldown, modal isolation, EN/SK")
|
||||||
|
})().catch(error => { console.error(error); process.exitCode = 1 })
|
||||||
Reference in New Issue
Block a user