mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-02 10:52:22 +00:00
fix: revalidate externally changed LXC state
This commit is contained in:
@@ -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))
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user