mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-01 10:22:08 +00:00
fix: revalidate externally changed LXC state
This commit is contained in:
@@ -202,6 +202,13 @@ class CommandDescriptionsTests(unittest.TestCase):
|
||||
key, english["backup"]["destinations"][key])
|
||||
messages["backup"]["actions"].setdefault(
|
||||
"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))
|
||||
before = {p: p.read_bytes() for p in root.glob("*/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 { fetchApi } from "../lib/api-config"
|
||||
import { matchesVmSearch, normalizeVmSearchValue } from "../lib/vm-search"
|
||||
import { checkLxcUpdates, isLxcUpdateStale } from "../lib/lxc-update-observation"
|
||||
import DOMPurify from "dompurify"
|
||||
import { marked } from "marked"
|
||||
import { getCountFormKey, useI18n, useT } from "@/lib/i18n/provider"
|
||||
@@ -84,6 +85,9 @@ interface LxcUpdateCheck {
|
||||
helper_app_name?: string | null
|
||||
helper_updateable_known?: boolean
|
||||
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
|
||||
@@ -912,6 +916,8 @@ export function VirtualMachines() {
|
||||
// "settled" when the observed count differs from this baseline
|
||||
// (or after a 15 s safety timeout).
|
||||
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 [updatesBaselineCount, setUpdatesBaselineCount] = useState<number | null>(null)
|
||||
const selectedVMRef = useRef<VMData | null>(null)
|
||||
@@ -1188,6 +1194,31 @@ export function VirtualMachines() {
|
||||
selectedVMRef.current = 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(() => {
|
||||
updatesBaselineCountRef.current = updatesBaselineCount
|
||||
}, [updatesBaselineCount])
|
||||
@@ -1239,6 +1270,7 @@ export function VirtualMachines() {
|
||||
|
||||
const handleVMClick = async (vm: VMData) => {
|
||||
setSelectedVM(vm)
|
||||
setOsCheckError(null)
|
||||
setBulkLoaded(null)
|
||||
setBulkConfigured(false)
|
||||
setBulkTargets(["os"])
|
||||
@@ -2647,6 +2679,26 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
// packages" section needs a clean `available`/`count` to avoid a
|
||||
// false "N package pending" every time a registered app has an
|
||||
// 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 uc = vm.update_check
|
||||
const appCount = (vm.app_watches || []).filter(
|
||||
@@ -2702,7 +2754,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
count: total,
|
||||
available: true,
|
||||
packages: [...(uc?.packages || []), ...pendingNames],
|
||||
last_check: uc?.last_check ?? new Date().toISOString(),
|
||||
last_check: uc?.last_check ?? null,
|
||||
} as LxcUpdateCheck
|
||||
}
|
||||
|
||||
@@ -2711,7 +2763,9 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
compact = false,
|
||||
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
|
||||
? 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,
|
||||
IP and other chips compete for width. Count alone with
|
||||
the up-arrow reads unambiguously as "pending updates". */}
|
||||
<ArrowUpCircle className={iconSize} />
|
||||
{uc.count}
|
||||
{stale ? <Clock className={iconSize} /> : <ArrowUpCircle className={iconSize} />}
|
||||
{stale ? t("vmLxc.updates.checkStale") : uc.count}
|
||||
{/* Chevron only when the badge is wired up as a clickable
|
||||
shortcut — its absence on the dashboard card avoids
|
||||
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?.is_oci_lxc && (() => {
|
||||
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 dockerAppWatch = (selectedVM.app_watches || []).find((a) => a.helper_slug === "docker")
|
||||
const dockerRegistered = !!dockerAppWatch
|
||||
@@ -5925,6 +5980,15 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Package className="h-4 w-4 text-muted-foreground" />
|
||||
<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 className="text-xs text-muted-foreground mb-3 leading-relaxed">
|
||||
{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></>
|
||||
)}
|
||||
</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
|
||||
className="text-sm text-muted-foreground flex items-center gap-2"
|
||||
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 ?? 86400) * 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": {
|
||||
"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 24 hours or has no valid check date. Run a new check before relying on it.",
|
||||
"lastCheckedPrefix": "Last checked:",
|
||||
"familyLabel": "Family:",
|
||||
"applyOsUpdate": "Apply OS update",
|
||||
|
||||
@@ -1334,6 +1334,11 @@
|
||||
"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.",
|
||||
"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ž 24 hodín alebo nemá platný dátum kontroly. Pred použitím spustite novú kontrolu.",
|
||||
"lastCheckedPrefix": "Naposledy skontrolované:",
|
||||
"familyLabel": "Systém:",
|
||||
"applyOsUpdate": "Aktualizovať systém",
|
||||
|
||||
@@ -580,35 +580,34 @@ def parse_lxc_hardware_config(vmid, node):
|
||||
|
||||
|
||||
def _get_lxc_ip_info_cached(vmid):
|
||||
"""Return an LXC's complete IP classification from the lifecycle cache.
|
||||
|
||||
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.
|
||||
"""
|
||||
"""Probe at most once per TTL, also noticing DHCP changes without events."""
|
||||
try:
|
||||
vmid_int = int(vmid)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if vmid_int in _lxc_ip_cache:
|
||||
return _lxc_ip_cache[vmid_int]
|
||||
info = get_lxc_ip_from_lxc_info(vmid_int)
|
||||
_remember_lxc_ip(vmid_int, info)
|
||||
return info
|
||||
# Serialize cold/expired reads so concurrent list polls share one probe.
|
||||
with _lxc_ip_cache_lock:
|
||||
hit = _lxc_ip_cache.get(vmid_int)
|
||||
if hit is not None:
|
||||
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):
|
||||
"""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
|
||||
for its lease, or lost it when it rebooted from inside: that answer is not
|
||||
kept, so the next read looks again instead of serving an address of its
|
||||
inner bridges until the container is restarted.
|
||||
Missing leases and failed probes use a shorter retry TTL. Concurrent
|
||||
polling still shares the result instead of spawning a probe per request.
|
||||
"""
|
||||
if info is None or any(':' not in ip for ip in info.get('real_ips') or []):
|
||||
_lxc_ip_cache[vmid] = info
|
||||
with _lxc_ip_cache_lock:
|
||||
_lxc_ip_cache[int(vmid)] = (time.monotonic(), info)
|
||||
|
||||
|
||||
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]
|
||||
|
||||
|
||||
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):
|
||||
"""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`.
|
||||
Fired from the guest lifecycle handler on start/stop/reboot."""
|
||||
try:
|
||||
_lxc_ip_cache.pop(int(vmid), None)
|
||||
with _lxc_ip_cache_lock:
|
||||
_lxc_ip_cache.pop(int(vmid), None)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
@@ -675,9 +710,8 @@ def _restore_lxc_dhcp_clients():
|
||||
def _warmup_lxc_ip_cache() -> int:
|
||||
"""Populate the LXC IP cache for every running CT at Monitor
|
||||
startup. After this runs, /api/vms serves the IPs from memory
|
||||
without spawning `lxc-info` on the request path — the cache only
|
||||
changes when a CT's lifecycle event (start/stop/reboot) fires the
|
||||
invalidator. Returns the count for the startup log line.
|
||||
until the short TTL expires or a lifecycle event invalidates it.
|
||||
Returns the count for the startup log line.
|
||||
"""
|
||||
try:
|
||||
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_cache: dict = {"ts": 0.0, "signature": (), "notes": {}}
|
||||
_vm_list_search_notes_lock = threading.Lock()
|
||||
# LXC IP classification cache — populated on first read, held indefinitely.
|
||||
# A running CT's IP doesn't change; the cache is invalidated only when
|
||||
# the CT's lifecycle event fires (start/stop/reboot), so no periodic
|
||||
# polling is needed. See `_handle_guest_lifecycle`.
|
||||
# DHCP addresses can change without a lifecycle event.
|
||||
_LXC_IP_TTL = 60
|
||||
_LXC_IP_RETRY_TTL = 5
|
||||
_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
|
||||
# by explicit event-based invalidation (`_vm_cache_invalidate` calls
|
||||
# on start/stop/reboot, add/edit/delete app, apply update, edit
|
||||
# schedule, create backup). No periodic poll — the prewarmer runs
|
||||
# once at startup and then stays quiet. The rationale is that all
|
||||
# of these payloads are backed by files (.conf / sidecar JSON /
|
||||
# storage inventory) that only change through actions the Monitor
|
||||
# either performs itself (invalidates in-line) or that require a
|
||||
# guest restart (start/stop invalidates too). Runtime data that DOES
|
||||
# storage inventory). Details and mounts additionally validate the .conf
|
||||
# signature on each read, including edits made outside Monitor. Runtime data that DOES
|
||||
# change without an invalidation event lives in a different path:
|
||||
# - Live CPU/mem/disk/network per guest → served by /api/vms (SWR
|
||||
# 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
|
||||
|
||||
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'):
|
||||
try:
|
||||
info = os.stat(path)
|
||||
with open(path, 'rb') as handle:
|
||||
digest = hashlib.sha256(handle.read()).digest()
|
||||
except OSError:
|
||||
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
|
||||
|
||||
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
|
||||
with _vm_modal_cache_lock:
|
||||
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:
|
||||
_vm_cache_put(cache, vmid, value)
|
||||
with _vm_modal_cache_lock:
|
||||
cache[vmid] = (time.time(), value)
|
||||
_vm_conf_built[(name, vmid)] = signature
|
||||
|
||||
def _vm_cache_invalidate(vmid: int, *caches) -> None:
|
||||
@@ -1953,6 +1992,7 @@ def _vm_cache_invalidate(vmid: int, *caches) -> None:
|
||||
targets = caches or (
|
||||
_vm_details_cache, _vm_backups_cache,
|
||||
_vm_app_suggestions_cache, _vm_schedule_cache, _vm_mounts_cache,
|
||||
_lxc_os_info_cache,
|
||||
)
|
||||
with _vm_modal_cache_lock:
|
||||
for c in targets:
|
||||
@@ -6635,6 +6675,7 @@ def _get_lxc_update_status_map() -> dict:
|
||||
continue
|
||||
update = it.get('update_check') or {}
|
||||
out[str(vmid)] = {
|
||||
**managed_installs.lxc_update_freshness(update),
|
||||
'available': bool(update.get('available')),
|
||||
'count': int(update.get('_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
|
||||
|
||||
|
||||
_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 ────────────────────────────────────────────
|
||||
# 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
|
||||
@@ -15269,12 +15350,12 @@ def api_vms_modal_cache_all():
|
||||
def get_vm_config(vmid):
|
||||
"""Get detailed configuration for a specific VM/LXC"""
|
||||
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)
|
||||
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()
|
||||
|
||||
# Fast local path — read .conf directly and derive status from
|
||||
@@ -15294,8 +15375,6 @@ def get_vm_config(vmid):
|
||||
if not config:
|
||||
return jsonify({'error': 'VM/LXC not found'}), 404
|
||||
|
||||
status = _fast_guest_status(vmid, vm_type)
|
||||
|
||||
response_data = {
|
||||
'vmid': vmid,
|
||||
'config': config,
|
||||
@@ -15303,46 +15382,17 @@ def get_vm_config(vmid):
|
||||
'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
|
||||
# directly (no pvesh), so it's cheap.
|
||||
hardware_info = {}
|
||||
if vm_type == 'lxc':
|
||||
hardware_info = parse_lxc_hardware_config(vmid, node)
|
||||
|
||||
if os_info:
|
||||
response_data['os_info'] = os_info
|
||||
if hardware_info:
|
||||
response_data['hardware_info'] = hardware_info
|
||||
|
||||
_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:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@@ -846,9 +846,8 @@ def _probe_lxc_os(vmid: str) -> Optional[str]:
|
||||
speak — those CTs are skipped in detection so the framework
|
||||
doesn't keep retrying a checker we can't run.
|
||||
|
||||
Cached per CT in the registry — re-probed only when the entry has
|
||||
no ``_os_family`` yet, since the OS rarely changes for the life of
|
||||
a CT.
|
||||
Detection may reuse the registry hint; each explicit or scheduled update
|
||||
check re-probes this file before selecting the package manager.
|
||||
"""
|
||||
try:
|
||||
r = subprocess.run(
|
||||
@@ -860,13 +859,20 @@ def _probe_lxc_os(vmid: str) -> Optional[str]:
|
||||
return None
|
||||
if r.returncode != 0:
|
||||
return None
|
||||
text = r.stdout.lower()
|
||||
if "id=ubuntu" in text:
|
||||
release = {}
|
||||
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 identity == "ubuntu":
|
||||
return "ubuntu"
|
||||
if "id=debian" in text or "id_like=debian" in text:
|
||||
if identity == "debian":
|
||||
return "debian"
|
||||
if "id=alpine" in text:
|
||||
if identity == "alpine":
|
||||
return "alpine"
|
||||
if "debian" in release.get("ID_LIKE", "").split():
|
||||
return "debian"
|
||||
# Future Phase 1.5: CentOS/Rocky/Alma (dnf check-update), Arch
|
||||
# (checkupdates), openSUSE (zypper list-updates). Each needs a
|
||||
# parser similar to apt/apk — skip silently for now.
|
||||
@@ -877,10 +883,9 @@ def _detect_lxc_containers(only_vmid: Optional[int] = None) -> list[dict]:
|
||||
"""Enumerate running Debian/Ubuntu CTs as registry entries.
|
||||
|
||||
OS detection is cached in the registry entry (`_os_family`), so the
|
||||
expensive ``pct exec cat /etc/os-release`` only runs the first time
|
||||
a CT is seen. CT reinstalls with a different OS will keep the old
|
||||
family cached until the user resets the registry — acceptable
|
||||
trade-off vs paying the probe cost every 24h cycle.
|
||||
expensive ``pct exec cat /etc/os-release`` only runs during detection
|
||||
when a CT is first seen. The checker independently re-reads it on each
|
||||
check, including after a restore or external reinstall.
|
||||
|
||||
Detection respects the dedicated `lxc_updates.detection_enabled`
|
||||
toggle (Settings → LXC Update Detection). When OFF, this returns []
|
||||
@@ -1442,12 +1447,33 @@ def _refresh_lxc_pkg_cache_if_stale(vmid: str, family: str) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _check_lxc_updates(entry: dict) -> dict:
|
||||
LXC_UPDATE_STALE_AFTER_SECONDS = 24 * 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.
|
||||
|
||||
Dispatches to the right package-manager parser based on the cached
|
||||
``_os_family``. If the CT's local apt/apk metadata cache is older
|
||||
than 24h, runs a best-effort refresh first via
|
||||
Re-read /etc/os-release on every explicit/scheduled check, so a restore
|
||||
or external reinstall cannot keep the old registry family. If the CT's
|
||||
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
|
||||
one ever runs ``apt update`` in (long-running appliances) report
|
||||
0 pending updates even when upstream has hundreds queued.
|
||||
@@ -1458,13 +1484,15 @@ def _check_lxc_updates(entry: dict) -> dict:
|
||||
update set does.
|
||||
"""
|
||||
vmid = entry.get("_vmid")
|
||||
family = (entry.get("_os_family") or "").lower()
|
||||
if not vmid:
|
||||
return {
|
||||
"available": False, "latest": None,
|
||||
"last_check": _now_iso(), "error": "no vmid in entry",
|
||||
}
|
||||
|
||||
family = _probe_lxc_os(str(vmid))
|
||||
entry["_os_family"] = family
|
||||
|
||||
# OCI-image CTs are immutable by design — apt/apk upgrade inside
|
||||
# them is the wrong workflow (update = rebuild from a newer image
|
||||
# tag). Skip the package-manager probe entirely so the UI doesn't
|
||||
@@ -1483,7 +1511,15 @@ def _check_lxc_updates(entry: dict) -> dict:
|
||||
"_count": 0, "_security_count": 0, "_packages": [],
|
||||
}
|
||||
|
||||
refresh_diag = _refresh_lxc_pkg_cache_if_stale(vmid, 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"):
|
||||
ok, stdout, err = _run_pct_pkg_listing(
|
||||
@@ -1783,12 +1819,12 @@ def check_for_updates(force: bool = False) -> list[dict]:
|
||||
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.
|
||||
|
||||
This is the lifecycle counterpart of the daily collector. It is called
|
||||
after a stopped container starts and deliberately leaves every other
|
||||
guest's registry row untouched.
|
||||
Called by lifecycle/apply finalizers and the explicit read-only action.
|
||||
The latter passes refresh_package_cache=False to forbid index writes.
|
||||
Every other guest's registry row is left untouched.
|
||||
"""
|
||||
try:
|
||||
target_vmid = int(vmid)
|
||||
@@ -1813,7 +1849,8 @@ def refresh_lxc(vmid: int) -> Optional[dict]:
|
||||
_merge_detected_entry(target, entry, now)
|
||||
|
||||
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:
|
||||
result = {
|
||||
"available": False,
|
||||
@@ -1825,5 +1862,6 @@ def refresh_lxc(vmid: int) -> Optional[dict]:
|
||||
reg["items"] = items
|
||||
reg["version"] = _SCHEMA_VERSION
|
||||
reg["last_targeted_refresh"] = now
|
||||
_write_registry(reg)
|
||||
if not _write_registry(reg):
|
||||
raise OSError("could not persist LXC update check")
|
||||
return dict(target)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import ast
|
||||
import threading
|
||||
from unittest.mock import Mock
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
@@ -109,6 +110,10 @@ class LxcPrimaryAddressTests(unittest.TestCase):
|
||||
import types
|
||||
self.server.update({
|
||||
"_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),
|
||||
"_lxc_isolated_ips": lambda _vmid: set(),
|
||||
"_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):
|
||||
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.assertIn(121, self.server["_lxc_ip_cache"])
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""The VM/LXC modal caches notice a .conf edited outside the Monitor."""
|
||||
import ast
|
||||
import io
|
||||
from pathlib import Path
|
||||
import threading
|
||||
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]
|
||||
self.assertEqual(sorted(n.name for n in nodes), sorted(NAMES))
|
||||
self.files = {'/etc/pve/lxc/111.conf': (1, 300)}
|
||||
self.contents = {}
|
||||
|
||||
def stat(path):
|
||||
if path not in self.files:
|
||||
@@ -24,7 +26,7 @@ class ConfAwareCacheTests(unittest.TestCase):
|
||||
return SimpleNamespace(st_mtime_ns=mtime, st_size=size)
|
||||
|
||||
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)
|
||||
self.cache = {}
|
||||
|
||||
@@ -50,9 +52,19 @@ class ConfAwareCacheTests(unittest.TestCase):
|
||||
self.scope['_vm_cache_put'](self.cache, 111, {'old': True})
|
||||
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):
|
||||
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))
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
"""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_failed_os_probe_does_not_keep_old_family_or_claim_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_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"], "debian")
|
||||
self.assertEqual(len(self.commands) - before, 1)
|
||||
|
||||
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)
|
||||
for age, stale in ((60, False), (86401, 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"], 86400)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,141 @@
|
||||
// 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-12T12:00:00Z" }, { ...recent, stale: true }]) {
|
||||
assert.equal(helpers.isLxcUpdateStale(value, now), true)
|
||||
}
|
||||
|
||||
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