fix: separate LXC package checks from installation

This commit is contained in:
VAIO73
2026-10-15 03:49:58 +00:00
parent f3add354e6
commit 664ef981e5
18 changed files with 1341 additions and 70 deletions
@@ -213,9 +213,16 @@ class CommandDescriptionsTests(unittest.TestCase):
# 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"):
"checkStale", "checkStaleHelp", "checkBeforeUpdate",
"startBeforeCheck", "notCheckedYet",
"checkStillRunning", "refreshCheckHelp",
"checkInProgress", "checkSystemPackages", "runCustomCommand",
"installedVersionUnverified", "versionStatusUnknown"):
messages["vmLxc"]["updates"].setdefault(
key, english["vmLxc"]["updates"][key])
for key in ("osDockerScope", "dockerImageMethod", "unverifiedTargetsHelp"):
messages["vmLxc"]["bulkUpdate"].setdefault(
key, english["vmLxc"]["bulkUpdate"][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"),
+140 -54
View File
@@ -4,6 +4,7 @@ import type React from "react"
import { useState, useMemo, useEffect, useRef } from "react"
import { fetchLxcApps, getLxcAppsCached, invalidateLxcApps, seedLxcAppsCache, setLxcAppsCached, syncLxcAppsState } from "../lib/lxc-apps-cache"
import { getUpdateActionButtonClass } from "../lib/update-action-style"
import { parseTags, stringifyTags, tagToColor } from "../lib/pve-tag-color"
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
import { Badge } from "./ui/badge"
@@ -28,7 +29,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 { refreshCheckLxcUpdates, getLxcOsUpdateState, isLxcUpdateStale } from "../lib/lxc-update-observation"
import DOMPurify from "dompurify"
import { marked } from "marked"
import { getCountFormKey, useI18n, useT } from "@/lib/i18n/provider"
@@ -917,6 +918,7 @@ export function VirtualMachines() {
// (or after a 15 s safety timeout).
const [updatesRefreshing, setUpdatesRefreshing] = useState(false)
const [osCheckingVmid, setOsCheckingVmid] = useState<number | null>(null)
const osCheckRequestRef = useRef<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)
@@ -1231,6 +1233,12 @@ export function VirtualMachines() {
useEffect(() => {
if (!updatesRefreshing) return
if (!selectedVM) return
if (!selectedVM.update_check || selectedVM.update_check.error) {
setUpdatesResult(null)
setUpdatesRefreshing(false)
setUpdatesBaselineCount(null)
return
}
const currentCount = selectedVM.update_check?.count ?? 0
if (updatesBaselineCount !== null && currentCount !== updatesBaselineCount) {
const applied = Math.max(0, updatesBaselineCount - currentCount)
@@ -1250,7 +1258,8 @@ export function VirtualMachines() {
const safety = setTimeout(() => {
const currentCount = selectedVMRef.current?.update_check?.count ?? 0
const baseline = updatesBaselineCountRef.current
setUpdatesResult({
const observation = selectedVMRef.current?.update_check
setUpdatesResult(!observation || observation.error ? null : {
pendingAfter: currentCount,
appliedCount: Math.max(0, (baseline ?? 0) - currentCount),
})
@@ -1983,6 +1992,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
const [applyRestart, setApplyRestart] = useState(false)
const [applyStartedAt, setApplyStartedAt] = useState<number>(0)
const [applyRunId, setApplyRunId] = useState<string>("")
const [applyCheckedOs, setApplyCheckedOs] = useState(false)
const [applyTargetIds, setApplyTargetIds] = useState<string[]>([])
const [applyTargetLabels, setApplyTargetLabels] = useState<string[]>([])
// Extra state carried alongside applyTarget when the App branch is
@@ -2029,6 +2039,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
// detected via the community-scripts pattern. All loaded once when
// the user opens the Updates tab of a specific LXC.
const [scheduleLoaded, setScheduleLoaded] = useState<number | null>(null)
const scheduleLoadRequestRef = useRef(0)
const [scheduleEnabled, setScheduleEnabled] = useState(false)
const [scheduleCron, setScheduleCron] = useState("0 3 * * *")
const [schedulePreset, setSchedulePreset] = useState<string>("daily-3am")
@@ -2073,6 +2084,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
// with scheduled updates: changing a one-click manual selection must never
// rewrite the cron automation configured in Options.
const [bulkLoaded, setBulkLoaded] = useState<number | null>(null)
const bulkLoadRequestRef = useRef(0)
const [bulkConfigured, setBulkConfigured] = useState(false)
const [bulkTargets, setBulkTargets] = useState<string[]>(["os"])
const [bulkPersistedTargets, setBulkPersistedTargets] = useState<string[]>(["os"])
@@ -2107,9 +2119,9 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
? s.targets.map((value: any) => String(value))
: ([...(legacyTarget !== "app" ? ["os"] : []), ...(legacyTarget !== "os" ? ["apps"] : [])]))
setScheduleReleaseDelayDays(Number.isInteger(Number(s.release_delay_days)) ? Number(s.release_delay_days) : 0)
if (s.backup !== undefined) setApplyBackup(!!s.backup)
if (s.backup_storage) setApplyBackupStorage(s.backup_storage)
if (s.restart !== undefined) setApplyRestart(!!s.restart)
setApplyBackup(!!s.backup)
setApplyBackupStorage(typeof s.backup_storage === "string" ? s.backup_storage : "")
setApplyRestart(!!s.restart)
setScheduleLastRunAt(s.last_run_at || null)
setScheduleLastRunStatus(s.last_run_status || null)
setScheduleLastRunReason(s.last_run_reason || null)
@@ -2122,30 +2134,46 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
}
const loadSchedule = async (vmid: number) => {
if (selectedVMRef.current?.vmid !== vmid) return
const requestId = ++scheduleLoadRequestRef.current
const isCurrent = () => selectedVMRef.current?.vmid === vmid
&& scheduleLoadRequestRef.current === requestId
setScheduleError(null)
// Seed from ref cache so tab-switching to Updates isn't a "Loading…"
// moment when the payload was already fetched this session.
const cachedSched = vmModalCacheRef.current.schedule.get(vmid)
if (cachedSched) applySchedulePayload(cachedSched)
// Missing options are this guest's defaults, never the previous guest's.
applySchedulePayload(cachedSched || {})
try {
const s: any = await fetchApi(`/api/vms/${vmid}/schedule`)
if (s && typeof s === "object") {
vmModalCacheRef.current.schedule.set(vmid, s)
applySchedulePayload(s)
}
if (!isCurrent()) return
if (!s || typeof s !== "object" || Array.isArray(s))
throw new Error(t("vmLxc.scheduled.loadFailed"))
vmModalCacheRef.current.schedule.set(vmid, s)
applySchedulePayload(s)
} catch (e: any) {
setScheduleError(e?.message || t("vmLxc.scheduled.loadFailed"))
if (isCurrent()) setScheduleError(e?.message || t("vmLxc.scheduled.loadFailed"))
} finally {
setScheduleLoaded(vmid)
if (isCurrent()) setScheduleLoaded(vmid)
}
}
const loadBulkUpdate = async (vmid: number) => {
if (selectedVMRef.current?.vmid !== vmid) return
const requestId = ++bulkLoadRequestRef.current
const isCurrent = () => selectedVMRef.current?.vmid === vmid
&& bulkLoadRequestRef.current === requestId
setBulkError(null)
setBulkLoaded(null)
setBulkEditMode(false)
setBulkConfigured(false)
setBulkTargets(["os"])
setBulkPersistedTargets(["os"])
try {
const config: any = await fetchApi(`/api/vms/${vmid}/bulk-update`)
if (!isCurrent()) return
if (!config || typeof config !== "object" || Array.isArray(config))
throw new Error(t("vmLxc.bulkUpdate.loadFailed"))
const targets = Array.isArray(config?.targets)
? Array.from(new Set(config.targets.map((value: any) => String(value)))) as string[]
: []
@@ -2155,12 +2183,13 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
setBulkTargets(next)
setBulkPersistedTargets(next)
} catch (e: any) {
if (!isCurrent()) return
setBulkConfigured(false)
setBulkTargets(["os"])
setBulkPersistedTargets(["os"])
setBulkError(e?.message || t("vmLxc.bulkUpdate.loadFailed"))
} finally {
setBulkLoaded(vmid)
if (isCurrent()) setBulkLoaded(vmid)
}
}
@@ -2560,8 +2589,21 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
const openApplyTerminal = (
vmid: number,
target: "os" | "app" | "both",
opts?: { updateCommand?: string; appName?: string; runHelper?: boolean; allowHelperWithCustom?: boolean; dockerStandaloneTargets?: string[]; dockerEngine?: boolean; dockerRefresh?: boolean; targetIds?: string[]; targetLabels?: string[] },
opts?: { checkedOs?: boolean; updateCommand?: string; appName?: string; runHelper?: boolean; allowHelperWithCustom?: boolean; dockerStandaloneTargets?: string[]; dockerEngine?: boolean; dockerRefresh?: boolean; targetIds?: string[]; targetLabels?: string[] },
) => {
const guest = selectedVMRef.current
if (target === "os" || target === "both") {
if (osCheckingVmid !== null || osCheckRequestRef.current !== null || updatesRefreshing || (opts?.checkedOs && osCheckError)) return
const state = guest?.vmid === vmid ? getLxcOsUpdateState(guest.update_check) : "unknown"
if (state === "unknown") {
setOsCheckError(t("vmLxc.updates.checkBeforeUpdate"))
return
}
// A combined plan can still update apps when OS packages are current.
if (target === "os" && state === "current") return
}
// The explicit update button is the user's confirmation. The runner
// owns the temporary start, backup and restoration of a stopped guest.
const fallbackTargets = target === "os"
? ["os"]
: target === "both"
@@ -2584,6 +2626,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
setApplyVmid(vmid)
setApplyTarget(target)
setApplyRunId(runId)
setApplyCheckedOs(opts?.checkedOs === true)
setApplyTargetIds(opts?.targetIds?.length ? opts.targetIds : fallbackTargets)
setApplyTargetLabels(opts?.targetLabels?.length ? opts.targetLabels : fallbackLabels)
setApplyUpdateCommand(opts?.updateCommand || "")
@@ -2680,10 +2723,16 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
// false "N package pending" every time a registered app has an
// upstream bump.
const checkOsUpdates = async (vmid: number) => {
if (osCheckRequestRef.current !== null || applyOpen || controlLoading) return
osCheckRequestRef.current = vmid
setOsCheckingVmid(vmid)
setOsCheckError(null)
try {
await checkLxcUpdates(vmid)
const observation = await refreshCheckLxcUpdates<LxcUpdateCheck>(vmid)
await mutate((current) => Array.isArray(current)
? current.map(vm => vm.vmid === vmid ? { ...vm, update_check: observation } : vm)
: current, { revalidate: false })
setSelectedVM(current => current?.vmid === vmid ? { ...current, update_check: observation } : current)
const details = await fetchApi<VMDetails>(`/api/vms/${vmid}`)
vmModalCacheRef.current.details.set(vmid, details)
if (selectedVMRef.current?.vmid === vmid) setVMDetails(details)
@@ -2692,9 +2741,12 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
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"))
: error.message === "checkStillRunning"
? t("vmLxc.updates.checkStillRunning")
: t("vmLxc.updates.osStatusUnavailable"))
}
} finally {
osCheckRequestRef.current = null
setOsCheckingVmid(null)
}
}
@@ -5707,7 +5759,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
!selectedVM.update_check?.is_oci_lxc && (() => {
const uc = selectedVM.update_check
const osUpdateStale = isLxcUpdateStale(uc)
const osUpdateStatusKnown = !!uc && !uc.error && !osUpdateStale
const osUpdateStatusKnown = getLxcOsUpdateState(uc) !== "unknown"
const hasOsUpdates = osUpdateStatusKnown && !!uc.available
const dockerAppWatch = (selectedVM.app_watches || []).find((a) => a.helper_slug === "docker")
const dockerRegistered = !!dockerAppWatch
@@ -5863,6 +5915,9 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
return {
id: `app:${app.id}`,
label: app.name || t("vmLxc.updates.applicationDefaultName"),
methodLabel: app.update_command?.trim()
? t("vmLxc.updates.customCommandTitle")
: t("vmLxc.updates.helperMethod"),
detail: "",
logoUrl: webLinkLogo || app.logo_url?.trim() || "",
}
@@ -5877,6 +5932,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
? [{
id: "docker-engine",
label: "Docker Engine",
methodLabel: t("vmLxc.scheduled.dockerEngineTarget"),
detail: "",
logoUrl: "",
}]
@@ -5884,6 +5940,9 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
...dockerUpdateUnits.map((unit) => ({
id: unit.id,
label: unit.display_name || unit.primary_reference || unit.primary_service || unit.id,
methodLabel: unit.kind === "compose"
? "Docker Compose"
: t("vmLxc.bulkUpdate.dockerImageMethod"),
detail: (unit.dependent_services || []).length
? t("vmLxc.bulkUpdate.includesDependencies", {
names: (unit.dependent_services || []).join(", "),
@@ -5891,7 +5950,14 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
: "",
logoUrl: unit.logo_url || "",
})),
]
].map((choice, _index, choices) => ({
...choice,
// Keep distinct action IDs; names alone never prove
// that a custom updater and an image are one action.
label: choices.filter((other) =>
other.label.trim().toLowerCase() === choice.label.trim().toLowerCase()
).length > 1 ? `${choice.label} — ${choice.methodLabel}` : choice.label,
}))
const bulkChoices = [{
id: "os",
label: t("vmLxc.bulkUpdate.osTarget"),
@@ -5935,7 +6001,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
// has been checked and is current, otherwise unknown.
const selectedBulkUpdateStates = bulkTargets.map((target) => {
if (target === "os") {
if (!uc || uc.error) return null
if (!osUpdateStatusKnown) return null
return hasOsUpdates
}
if (target === "docker-engine") {
@@ -5960,6 +6026,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
return null
})
const bulkHasPendingUpdates = selectedBulkUpdateStates.some((state) => state === true)
const bulkHasUnknownTargets = selectedBulkUpdateStates.some((state) => state === null)
const bulkAllTargetsUpToDate = selectedBulkUpdateStates.length > 0
&& selectedBulkUpdateStates.every((state) => state === false)
const toggleBulkChoice = (id: string, checked: boolean) => {
@@ -5968,9 +6035,8 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
? Array.from(new Set(["os", ...current.filter((value) => value !== "os"), id]))
: current.filter((value) => value !== id))
}
const pendingBtnCls = "bg-purple-600/15 hover:bg-purple-600/25 border border-purple-500/40 text-purple-300 hover:text-purple-200"
const upToDateBtnCls = "bg-green-500/10 hover:bg-green-500/20 border border-green-500/30 text-green-400 hover:text-green-300"
const neutralBtnCls = "border border-input bg-background text-foreground/80 hover:bg-accent hover:text-accent-foreground"
const pendingBtnCls = getUpdateActionButtonClass(true, false)
const upToDateBtnCls = getUpdateActionButtonClass(false, true)
return (
<>
<Card className="border border-border bg-card/50">
@@ -5982,12 +6048,12 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
<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")}
disabled={osCheckingVmid !== null || updatesRefreshing || controlLoading || applyOpen}
title={t("vmLxc.updates.refreshCheckHelp")}
onClick={() => checkOsUpdates(selectedVM.vmid)}
>
<RefreshCw className={`h-4 w-4 mr-1.5 ${osCheckingVmid === selectedVM.vmid ? "animate-spin" : ""}`} />
{t("vmLxc.updates.checkNow")}
{t("vmLxc.updates.checkSystemPackages")}
</Button>
</div>
<div className="text-xs text-muted-foreground mb-3 leading-relaxed">
@@ -5998,7 +6064,14 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
)}
</div>
{osCheckError && <p role="alert" className="text-sm text-amber-400 mb-2">{osCheckError}</p>}
{osUpdateStale && !uc?.error ? (
{osCheckingVmid === selectedVM.vmid ? (
<div role="status" className="text-sm text-muted-foreground flex items-center gap-2">
<RefreshCw className="h-4 w-4 animate-spin flex-shrink-0" />
{t("vmLxc.updates.checkInProgress")}
</div>
) : !uc?.last_check && !uc?.error ? (
<p className="text-sm text-muted-foreground">{t("vmLxc.updates.notCheckedYet")}</p>
) : osUpdateStale && !uc?.error ? (
<p className="text-sm text-amber-400">{t("vmLxc.updates.checkStaleHelp")}</p>
) : !osUpdateStatusKnown ? (
<div
@@ -6054,23 +6127,28 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
{t("vmLxc.updates.noOsUpdatesPending")}
</div>
)}
<div className="mt-3 flex justify-end">
{osUpdateStatusKnown && osCheckingVmid !== selectedVM.vmid && <div className="mt-3 flex justify-end">
{hasOsUpdates ? (
<Button
size="sm"
onClick={() => openApplyTerminal(selectedVM.vmid, "os")}
className={hasOsUpdates
? pendingBtnCls
: osUpdateStatusKnown
? upToDateBtnCls
: neutralBtnCls}
disabled={!hasOsUpdates || osCheckingVmid !== null || updatesRefreshing || controlLoading || !!osCheckError}
onClick={() => openApplyTerminal(selectedVM.vmid, "os", { checkedOs: true })}
className={getUpdateActionButtonClass(hasOsUpdates, osUpdateStatusKnown)}
>
{hasOsUpdates && <ArrowUpCircle className="h-4 w-4 mr-1.5" />}
{!hasOsUpdates && !osUpdateStatusKnown && <RefreshCw className="h-4 w-4 mr-1.5" />}
{hasOsUpdates || !osUpdateStatusKnown
{hasOsUpdates
? t("vmLxc.updates.applyOsUpdate")
: t("vmLxc.updates.osUpToDate")}
: osUpdateStatusKnown
? t("vmLxc.updates.osUpToDate")
: t("vmLxc.updates.checkBeforeUpdate")}
</Button>
</div>
) : (
<Button asChild size="sm" className={upToDateBtnCls}>
<span role="status">{t("vmLxc.updates.osUpToDate")}</span>
</Button>
)}
</div>}
</div>
{/* Docker has three independent lifecycles:
@@ -6200,11 +6278,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
dockerRefresh: true,
},
)}
className={`${dockerEngineHasUpdate
? pendingBtnCls
: dockerEngineUpToDate
? upToDateBtnCls
: neutralBtnCls} flex-shrink-0`}
className={`${getUpdateActionButtonClass(dockerEngineHasUpdate, dockerEngineUpToDate)} flex-shrink-0`}
>
{dockerEngineHasUpdate && <ArrowUpCircle className="h-4 w-4 mr-1.5" />}
{!dockerEngineHasUpdate && !dockerEngineUpToDate && <RefreshCw className="h-4 w-4 mr-1.5" />}
@@ -6480,7 +6554,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
</div>
) : helperTracksVersion ? (
<p className="text-xs text-muted-foreground leading-relaxed">
{t("vmLxc.updates.versionTrackingPending")}
{t("vmLxc.updates.versionStatusUnknown")}
</p>
) : helperExists ? (
<p className="text-xs text-muted-foreground leading-relaxed">
@@ -6503,7 +6577,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
// still force the helper's own
// update script.
const noState = !hasUpd && !upToD
const cls = hasUpd ? pendingBtnCls : upToD ? upToDateBtnCls : neutralBtnCls
const cls = getUpdateActionButtonClass(hasUpd, upToD)
const label = hasUpd ? t("vmLxc.updates.applyUpdate") : upToD ? t("vmLxc.updates.upToDate") : t("vmLxc.updates.runUpdater")
return (
<div className="mt-3 flex justify-end">
@@ -6639,7 +6713,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
</div>
) : null
) : (
<div>{t("vmLxc.updates.versionTrackingPendingShort")}</div>
<div>{t("vmLxc.updates.versionStatusUnknown")}</div>
)}
</div>
) : tracksVersion ? (
@@ -6651,7 +6725,9 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
<a className="text-blue-400 hover:underline" href={`#docker-images-${selectedVM.vmid}`}>
{t("vmLxc.updates.dockerImagesSubheading")}
</a></>
: t("vmLxc.updates.versionTrackingPendingShort")}
: aw.installed_via === "manual"
? t("vmLxc.updates.installedVersionUnverified")
: t("vmLxc.updates.versionStatusUnknown")}
</div>
) : hasCmd ? (
<div className="text-sm text-muted-foreground">
@@ -6673,7 +6749,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
targetLabels: [aw.name || t("vmLxc.updates.applicationDefaultName")],
},
)}
className={hasUpdate ? pendingBtnCls : upToDate ? upToDateBtnCls : neutralBtnCls}
className={getUpdateActionButtonClass(hasUpdate, upToDate)}
>
{hasUpdate && <ArrowUpCircle className="h-4 w-4 mr-1.5" />}
{!hasUpdate && !upToDate && <RefreshCw className="h-4 w-4 mr-1.5" />}
@@ -6681,7 +6757,9 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
? t("vmLxc.updates.applyUpdate")
: upToDate
? t("vmLxc.updates.upToDate")
: t("vmLxc.updates.runUpdater")}
: aw.update_method === "helper"
? t("vmLxc.updates.runUpdater")
: t("vmLxc.updates.runCustomCommand")}
</Button>
</div>
) : (
@@ -6719,6 +6797,11 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
<p className="text-xs text-muted-foreground mt-2 leading-relaxed">
{t("vmLxc.bulkUpdate.description")}
</p>
{dockerRegistered && (
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
{t("vmLxc.bulkUpdate.osDockerScope")}
</p>
)}
</div>
{!bulkEditMode && (
<button
@@ -6868,19 +6951,20 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
<div className="text-sm text-muted-foreground break-words">
{selectedBulkLabels.join(" · ")}
</div>
{bulkHasUnknownTargets && (
<p className="text-xs text-muted-foreground leading-relaxed">
{t("vmLxc.bulkUpdate.unverifiedTargetsHelp")}
</p>
)}
<div className="flex justify-end">
<Button
size="sm"
onClick={() => applyBulkUpdate(selectedVM.vmid)}
disabled={bulkApplying || (
disabled={bulkApplying || osCheckingVmid !== null || updatesRefreshing || !osUpdateStatusKnown || bulkAllTargetsUpToDate || (
bulkTargets.some((target) => target.startsWith("docker-unit:"))
&& (dockerInventoryRefreshing || !dockerInventoryAvailable)
)}
className={bulkHasPendingUpdates
? pendingBtnCls
: bulkAllTargetsUpToDate
? upToDateBtnCls
: neutralBtnCls}
className={getUpdateActionButtonClass(bulkHasPendingUpdates, bulkAllTargetsUpToDate)}
>
{bulkApplying
? <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />
@@ -7916,7 +8000,9 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
setApplyVmid(null)
}}
onComplete={handleApplyComplete}
scriptPath="/usr/local/share/proxmenux/scripts/lxc/apply_updates.sh"
scriptPath={applyCheckedOs
? "/usr/local/share/proxmenux/scripts/lxc/install_checked_os_updates.sh"
: "/usr/local/share/proxmenux/scripts/lxc/apply_updates.sh"}
scriptName="lxc_apply_updates"
title={t("vmLxc.updates.terminalTitle", { vmid: applyVmid })}
completedSuccessfullyMessage={t("vmLxc.updates.terminalCompletedSuccessfully")}
+26
View File
@@ -4,6 +4,17 @@ interface UpdateObservation {
last_check?: string | null
stale?: boolean
stale_after_seconds?: number
available?: boolean
count?: number | null
error?: string | null
}
export function getLxcOsUpdateState(observation?: UpdateObservation, now = Date.now()): "unknown" | "pending" | "current" {
if (isLxcUpdateStale(observation, now) || observation?.error
|| typeof observation?.available !== "boolean"
|| !Number.isInteger(observation.count) || (observation.count ?? -1) < 0
|| observation.available !== ((observation.count ?? 0) > 0)) return "unknown"
return observation.available ? "pending" : "current"
}
export function isLxcUpdateStale(observation?: UpdateObservation, now = Date.now()): boolean {
@@ -19,3 +30,18 @@ export function isLxcUpdateStale(observation?: UpdateObservation, now = Date.now
export function checkLxcUpdates<T>(vmid: number): Promise<T> {
return fetchApi<T>(`/api/vms/${vmid}/updates/check`, { method: "POST" })
}
// Separate explicit action: refresh indexes, check only, restore stopped CT.
// The backend owns the job even if the browser closes or navigates away.
export async function refreshCheckLxcUpdates<T>(vmid: number): Promise<T> {
const job = await fetchApi<{ job_id: string }>(`/api/vms/${vmid}/updates/refresh-check`, { method: "POST" })
const deadline = Date.now() + 8 * 60_000
while (Date.now() < deadline) {
const result = await fetchApi<{ state: string; error?: string; update_check?: T }>(
`/api/vms/${vmid}/updates/refresh-check/${job.job_id}`)
if (result.state === "failed") throw new Error("osStatusUnavailable")
if (result.state === "complete") return result.update_check as T
await new Promise(resolve => setTimeout(resolve, 1000))
}
throw new Error("checkStillRunning")
}
+7
View File
@@ -0,0 +1,7 @@
// Use the dashboard's existing update colors for every executable action.
// Version availability remains separate from action styling and enablement.
export function getUpdateActionButtonClass(hasUpdate: boolean, isCurrent: boolean): string {
return !hasUpdate && isCurrent
? "bg-green-500/10 hover:bg-green-500/20 border border-green-500/30 text-green-400 hover:text-green-300"
: "bg-purple-600/15 hover:bg-purple-600/25 border border-purple-500/40 text-purple-300 hover:text-purple-200"
}
+15 -2
View File
@@ -1332,7 +1332,14 @@
},
"updates": {
"osPackagesTitle": "OS packages",
"checkNow": "Check now",
"checkNow": "Check updates",
"checkSystemPackages": "Check system packages",
"checkInProgress": "Check in progress",
"refreshCheckHelp": "Refreshes package indexes and checks pending updates. Nothing is installed. A stopped container is temporarily started and stopped again after the check.",
"notCheckedYet": "Updates have not been checked yet.",
"checkStillRunning": "The check is still running. The monitor will restore the container's original state when it finishes.",
"checkBeforeUpdate": "Check updates first",
"startBeforeCheck": "Start the container, then check for updates. No badge does not mean there are no updates.",
"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",
@@ -1380,6 +1387,9 @@
"applyUpdate": "Apply update",
"upToDate": "Up to date",
"runUpdater": "Run updater",
"runCustomCommand": "Run custom command",
"installedVersionUnverified": "Installed version is unverified. Update availability could not be confirmed.",
"versionStatusUnknown": "Update availability is unverified. Version information is unavailable or incomplete.",
"terminalTitle": "Apply updates — CT {vmid}",
"terminalDescriptionOs": "Applying OS package updates inside the container...",
"terminalDescriptionApp": "Running the application updater inside the container...",
@@ -1483,9 +1493,12 @@
"notConfigured": "Not configured.",
"configure": "Configure",
"edit": "Edit",
"apply": "Apply updates",
"apply": "Start bulk update",
"osTarget": "OS",
"osRequired": "Required for a bulk update.",
"osDockerScope": "OS updates also include Docker when it is installed as a system package.",
"unverifiedTargetsHelp": "Update availability for some selected items is unverified. Starting will run the selected update methods even without a confirmed new version.",
"dockerImageMethod": "Docker image",
"includesDependencies": "Also includes declared dependencies: {names}",
"noMethod": "No executable update method is available.",
"staleTarget": "This selection is no longer available. Remove it before saving.",
+15 -2
View File
@@ -1334,7 +1334,14 @@
"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",
"checkNow": "Skontrolovať aktualizácie",
"checkSystemPackages": "Skontrolovať balíky systému",
"checkInProgress": "Prebieha kontrola",
"refreshCheckHelp": "Obnoví zoznamy balíkov a skontroluje čakajúce aktualizácie. Nič nenainštaluje. Vypnutý kontajner sa dočasne zapne a po kontrole sa opäť vypne.",
"notCheckedYet": "Aktualizácie zatiaľ nie sú skontrolované.",
"checkStillRunning": "Kontrola ešte prebieha. Monitor po jej dokončení obnoví pôvodný stav kontajnera.",
"checkBeforeUpdate": "Najprv skontrolovať aktualizácie",
"startBeforeCheck": "Spustite kontajner a potom skontrolujte aktualizácie. Chýbajúci odznak neznamená, že nie sú žiadne aktualizácie.",
"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",
@@ -1382,6 +1389,9 @@
"applyUpdate": "Aktualizovať",
"upToDate": "Aktuálne",
"runUpdater": "Spustiť aktualizáciu",
"runCustomCommand": "Spustiť vlastný príkaz",
"installedVersionUnverified": "Nainštalovaná verzia nie je overená. Dostupnosť aktualizácie sa nepodarilo potvrdiť.",
"versionStatusUnknown": "Dostupnosť aktualizácie nie je overená. Údaje o verzii chýbajú alebo sú neúplné.",
"terminalTitle": "Aktualizácia — CT {vmid}",
"terminalDescriptionOs": "Aktualizujem balíky systému v kontajneri...",
"terminalDescriptionApp": "Spúšťam aktualizáciu aplikácie v kontajneri...",
@@ -1483,9 +1493,12 @@
"notConfigured": "Nenakonfigurované.",
"configure": "Nastaviť",
"edit": "Upraviť",
"apply": "Použiť aktualizácie",
"apply": "Spustiť hromadnú aktualizáciu",
"osTarget": "Operačný systém",
"osRequired": "Povinné pre hromadnú aktualizáciu.",
"osDockerScope": "Systémová aktualizácia zahŕňa aj Docker nainštalovaný ako systémový balík.",
"unverifiedTargetsHelp": "Dostupnosť aktualizácií pre niektoré vybrané položky nie je overená. Spustenie vykoná vybrané aktualizačné metódy aj bez potvrdenej novej verzie.",
"dockerImageMethod": "Docker obraz",
"includesDependencies": "Zahŕňa aj deklarované závislosti: {names}",
"noMethod": "Nie je dostupná žiadna spustiteľná metóda aktualizácie.",
"staleTarget": "Tento výber už nie je dostupný. Pred uložením ho odstráňte.",
+1
View File
@@ -146,6 +146,7 @@ cp "$SCRIPT_DIR/health_thresholds.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "
cp "$SCRIPT_DIR/managed_installs.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ managed_installs.py not found"
cp "$SCRIPT_DIR/lxc_update_observation.py" "$APP_DIR/usr/bin/"
cp "$SCRIPT_DIR/lxc_update_finalization.py" "$APP_DIR/usr/bin/"
cp "$SCRIPT_DIR/lxc_check_job.py" "$APP_DIR/usr/bin/"
cp "$SCRIPT_DIR/lxc_apps.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ lxc_apps.py not found"
cp "$SCRIPT_DIR/custom_links.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ custom_links.py not found"
cp "$SCRIPT_DIR/recreate_docker_container.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ recreate_docker_container.py not found"
+47 -1
View File
@@ -6728,6 +6728,11 @@ def _get_lxc_update_status_map() -> dict:
'managed_oci_app': it.get('_managed_oci_app'),
'os_family': it.get('_os_family'),
}
if os.path.exists(f'/run/lock/proxmenux-lxc-check-invalid-{int(vmid)}'):
# Read-only projection: a failed/pending explicit check must not
# re-expose a former positive count after a persistence failure.
out[str(vmid)].update(available=False, count=0, packages=[],
error='Latest explicit package check is running or failed; check again')
return out
@@ -13669,19 +13674,57 @@ def api_lxc_updates_check(vmid):
'retry_after': retry}), 429, {'Retry-After': str(retry)}
_lxc_update_check_attempts[vmid] = now
_lxc_update_check_inflight.add(vmid)
operation_lock = None
try:
import lxc_check_job
operation_lock = lxc_check_job.acquire(vmid)
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 BlockingIOError:
return jsonify({'error': 'Another container operation is running'}), 409
except Exception as exc:
return jsonify({'error': str(exc)}), 500
finally:
if operation_lock is not None:
operation_lock.close()
with _lxc_update_check_lock:
_lxc_update_check_inflight.discard(vmid)
@app.route('/api/vms/<int:vmid>/updates/refresh-check', methods=['POST'])
@require_auth
def api_lxc_updates_refresh_check(vmid):
"""Explicit index refresh/check; never installs. Worker owns restoration."""
import lxc_check_job
try:
result, code = lxc_check_job.start(vmid)
if code == 429:
return jsonify({'error': 'Check already running or recently requested',
'retry_after': 60}), 429, {'Retry-After': '60'}
return jsonify(result), code
except BlockingIOError:
return jsonify({'error': 'Another container operation is running'}), 409
except (OSError, RuntimeError, ValueError) as exc:
return jsonify({'error': str(exc)}), 409
@app.route('/api/vms/<int:vmid>/updates/refresh-check/<job_id>', methods=['GET'])
@require_auth
def api_lxc_updates_refresh_check_status(vmid, job_id):
import lxc_check_job
result = lxc_check_job.get(vmid, job_id)
if result is None:
return jsonify({'error': 'Check result not found; check again'}), 404
if result['state'] != 'running':
_vm_cache_invalidate(vmid, _lxc_os_info_cache)
_pvesh_cache['cluster_resources_vm_time'] = 0
result['update_check'] = _get_lxc_update_status_map().get(str(vmid))
return jsonify(result)
# ─── 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
@@ -14258,6 +14301,7 @@ def api_vm_apps_dismiss(vmid):
# ─── LXC update finalization ────────────────────────────────────────────────
_LXC_APPLY_UPDATES_SCRIPT = "/usr/local/share/proxmenux/scripts/lxc/apply_updates.sh"
_LXC_INSTALL_CHECKED_OS_SCRIPT = "/usr/local/share/proxmenux/scripts/lxc/install_checked_os_updates.sh"
_LXC_UPDATE_RUN_ID_RE = re.compile(r'^[A-Za-z0-9._:-]{1,128}$')
_LXC_UPDATE_TARGET_RE = re.compile(r'^[A-Za-z0-9._:-]{1,180}$')
from lxc_update_finalization import lock as _lxc_update_finalization_lock, runs as _lxc_update_finalizations
@@ -14711,7 +14755,9 @@ def _finalize_lxc_update(
def _terminal_lxc_update_completed(*, script_path, params, exit_code, duration_seconds):
if os.path.realpath(script_path) != os.path.realpath(_LXC_APPLY_UPDATES_SCRIPT):
if os.path.realpath(script_path) not in {
os.path.realpath(_LXC_APPLY_UPDATES_SCRIPT),
os.path.realpath(_LXC_INSTALL_CHECKED_OS_SCRIPT)}:
return
run_id = _normalise_lxc_update_run_id(params.get('RUN_ID'), create=False)
if not run_id:
+188
View File
@@ -0,0 +1,188 @@
"""Explicit check-only jobs; the shared updater lock protects each CT.
Importing or reading a job never probes a guest. Only start() may launch work.
Package observations are saved before restoring a temporarily started guest.
"""
import datetime
import fcntl
import json
import os
import re
from pathlib import Path
import subprocess
import threading
import time
import uuid
import managed_installs
import lxc_update_observation
LOCK_DIR = Path(os.environ.get('PROXMENUX_LOCK_DIR', '/run/lock'))
CONF_DIR = Path('/etc/pve/lxc')
OCI_REGISTRY = Path('/usr/local/share/proxmenux/oci/installed.json')
_lock = threading.RLock()
_jobs = {}
_attempts = {}
COOLDOWN = 60
JOB_TTL = 3600
def acquire(vmid):
path = LOCK_DIR / f'proxmenux-lxc-update-{int(vmid)}.lock'
handle = os.fdopen(os.open(path, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600), 'r+')
try:
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
return handle
except BaseException:
handle.close()
raise
def guard(vmid):
if not managed_installs._lxc_updates_detection_enabled():
raise RuntimeError('LXC update detection is disabled')
# Fail closed if current configuration cannot be read. Never infer OCI
# eligibility from an old OS hint or from a failed config read.
config = (CONF_DIR / f'{int(vmid)}.conf').read_text()
# The normal collector tolerates broken OCI metadata. An action must
# instead fail closed: an unreadable registry is not an empty registry.
if OCI_REGISTRY.exists():
registry = json.loads(OCI_REGISTRY.read_text())
instances = registry.get('instances')
if not isinstance(instances, dict) or any(not isinstance(item, dict) for item in instances.values()):
raise RuntimeError('OCI registry could not be verified')
if any(str(item.get('vmid')) == str(vmid) for item in instances.values()):
raise RuntimeError('OCI containers require their own updater')
if any(line.lstrip().startswith(managed_installs._OCI_LXC_MARKERS)
for line in config.splitlines()) or str(vmid) in managed_installs._get_oci_managed_vmids():
raise RuntimeError('OCI containers require their own updater')
# Retain the independent registered-OCI boundary if its collector is
# temporarily unavailable. These hints can only forbid an action.
if any(str(item.get('_vmid')) == str(vmid) and item.get('_managed_oci_app')
for item in managed_installs.get_active_items()):
raise RuntimeError('OCI containers require their own updater')
if any(line.startswith('lock:') for line in config.splitlines()):
raise RuntimeError('Container is locked by Proxmox')
def command(vmid, *args, timeout=30):
result = subprocess.run([managed_installs._PCT_BIN, *args[:1], str(vmid), *args[1:]],
capture_output=True, text=True, timeout=timeout)
if result.returncode:
raise RuntimeError(f'Container {args[0]} failed')
return result.stdout.strip()
def status(vmid):
value = command(vmid, 'status')
if value not in ('status: running', 'status: stopped'):
raise RuntimeError('Container state could not be determined')
return value.split(': ', 1)[1]
def checked_os(vmid):
"""Installation precondition; called under the updater lock before start."""
guard(vmid)
if (LOCK_DIR / f'proxmenux-lxc-check-invalid-{int(vmid)}').exists():
raise RuntimeError('Latest check failed or is still running')
item = next((item for item in managed_installs.get_active_items()
if item.get('id') == f'lxc:{int(vmid)}'), {})
observation = item.get('update_check') or {}
checked = datetime.datetime.fromisoformat(observation.get('last_check') or '')
if checked.tzinfo is None:
checked = checked.replace(tzinfo=datetime.timezone.utc)
age = (datetime.datetime.now(datetime.timezone.utc) - checked).total_seconds()
if (not 0 <= age < 172800 or observation.get('error')
or observation.get('available') is not True
or type(observation.get('_count')) is not int or observation['_count'] <= 0):
raise RuntimeError('Check OS updates successfully before installation')
def run(vmid, job_id, reservation):
restore = False
error = None
invalid = LOCK_DIR / f'proxmenux-lxc-check-invalid-{int(vmid)}'
try:
invalid.touch(mode=0o600)
guard(vmid)
original = status(vmid)
if original == 'stopped':
# Set before start: a timed-out command can still have started CT.
restore = True
command(vmid, 'start', timeout=60)
deadline = time.monotonic() + 60
while True:
try:
command(vmid, 'exec', '--', 'true', timeout=5)
break
except (RuntimeError, subprocess.TimeoutExpired):
if time.monotonic() >= deadline:
raise RuntimeError('Container did not become ready')
time.sleep(1)
observation = lxc_update_observation.capture(vmid, job_id, refresh_indexes=True)
if not managed_installs.store_lxc_update_observation(vmid, observation):
raise RuntimeError(observation['result'].get('error') or 'Package check failed')
lxc_update_observation.discard(vmid, job_id)
except Exception as exc:
error = str(exc)
# Preflight/start failures must revoke a previous positive observation.
try:
managed_installs.store_lxc_update_observation(vmid, None)
except Exception:
error += '; package result could not be persisted'
finally:
if restore:
try:
if status(vmid) != 'stopped':
command(vmid, 'shutdown', '--timeout', '30', timeout=40)
if status(vmid) != 'stopped':
raise RuntimeError('Container is still running')
except Exception:
try:
command(vmid, 'stop', timeout=30)
if status(vmid) != 'stopped':
raise RuntimeError('Container is still running')
except Exception:
error = (error + '; ' if error else '') + 'Original stopped state could not be restored'
if not error:
try:
invalid.unlink()
except OSError:
error = 'Successful check could not be published for installation'
reservation.close()
with _lock:
_jobs[job_id].update(state='failed' if error else 'complete', error=error)
def start(vmid):
now = time.monotonic()
with _lock:
for key in list(_jobs):
if _jobs[key]['state'] != 'running' and now - _jobs[key]['created'] >= JOB_TTL:
del _jobs[key]
last = _attempts.get(vmid)
if (any(job['vmid'] == vmid and job['state'] == 'running' for job in _jobs.values())
or last is not None and now - last < COOLDOWN):
return None, 429
reservation = acquire(vmid)
try:
guard(vmid)
job_id = uuid.uuid4().hex
_jobs[job_id] = dict(vmid=vmid, state='running', error=None, created=now)
_attempts[vmid] = now
threading.Thread(target=run, args=(vmid, job_id, reservation), daemon=False).start()
except BaseException:
if 'job_id' in locals():
_jobs.pop(job_id, None)
_attempts.pop(vmid, None)
reservation.close()
raise
return dict(job_id=job_id, vmid=vmid, state='running'), 202
def get(vmid, job_id):
with _lock:
job = _jobs.get(job_id)
if job is None or job['vmid'] != vmid:
return None
return {key: value for key, value in job.items() if key != 'created'}
+14
View File
@@ -63,6 +63,13 @@ ALLOW_HELPER_WITH_CUSTOM="${ALLOW_HELPER_WITH_CUSTOM:-0}"
DOCKER_STANDALONE_TARGETS="${DOCKER_STANDALONE_TARGETS:-}"
UPDATE_DOCKER_ENGINE="${UPDATE_DOCKER_ENGINE:-0}"
# Reject an abandoned local-only mode rather than silently installing if an
# older launcher still exports it. The check now has its own non-install job.
if [[ "${CHECK_APPLY_OS:-0}" != "0" ]]; then
echo "ERROR: combined check-and-update was removed; run the explicit package check first." >&2
exit 4
fi
if [[ ! "$VMID" =~ ^[1-9][0-9]*$ ]]; then
echo "ERROR: VMID must be a positive integer." >&2
exit 1
@@ -123,6 +130,13 @@ if ! flock -n 9; then
exit 7
fi
if [[ "${REQUIRE_CHECKED_OS:-0}" == "1" ]]; then
if ! python3 -c 'import sys; sys.path.insert(0, "/usr/local/share/proxmenux/monitor-app/usr/bin"); import lxc_check_job; lxc_check_job.checked_os(int(sys.argv[1]))' "$VMID"; then
echo "ERROR: a fresh successful positive OS check is required; nothing was installed." >&2
exit 4
fi
fi
STARTED_AT=$(date -Iseconds)
NODE=$(hostname)
echo "=== ProxMenux LXC update — CT $VMID on $NODE ==="
@@ -0,0 +1,6 @@
#!/bin/bash
# Install OS packages only after a valid positive check. The shared runner
# verifies that precondition under its CT lock before any lifecycle effect.
export REQUIRE_CHECKED_OS=1 TARGET=os RUN_HELPER=0 UPDATE_COMMAND=""
export ALLOW_HELPER_WITH_CUSTOM=0 DOCKER_STANDALONE_TARGETS="" UPDATE_DOCKER_ENGINE=0
exec /bin/bash "$(dirname -- "$(readlink -f -- "$0")")/apply_updates.sh"
+19
View File
@@ -67,6 +67,10 @@ EOF
cat >"$MOCK_BIN/python3" <<'EOF'
#!/bin/bash
cat >/dev/null
if [[ "${1:-}" == '-c' ]]; then
[[ "${MOCK_CHECK_FAIL:-0}" != '1' ]]
exit $?
fi
if [[ "${2:-}" == 'protect-update-command' ]]; then
printf '%s' "$UPDATE_COMMAND"
exit 0
@@ -82,6 +86,7 @@ EOF
cat >"$MOCK_BIN/vzdump" <<'EOF'
#!/bin/bash
echo "vzdump $*" >>"$MOCK_LOG"
[[ "${MOCK_BACKUP_FAIL:-0}" != '1' ]]
EOF
chmod +x "$MOCK_BIN"/*
@@ -161,4 +166,18 @@ run_case locked RUN_HELPER=0 UPDATE_COMMAND='update-custom' MOCK_FLOCK_FAIL=1
[[ $CASE_RC -eq 7 ]] || fail "locked expected 7, got $CASE_RC"
[[ $(count CUSTOM_EXEC) -eq 0 ]] || fail "locked run executed an updater"
run_case checked_missing TARGET=os REQUIRE_CHECKED_OS=1 MOCK_CHECK_FAIL=1
[[ $CASE_RC -eq 4 ]] || fail "unchecked installation was not rejected"
[[ $(count 'pct ') -eq 0 ]] || fail "unchecked installation probed or started a CT"
[[ $(count OS_EXEC) -eq 0 ]] || fail "unchecked installation upgraded packages"
run_case checked_backup_fail TARGET=os REQUIRE_CHECKED_OS=1 MOCK_INITIAL_STATE=stopped BACKUP=1 BACKUP_STORAGE=local MOCK_BACKUP_FAIL=1
[[ $CASE_RC -eq 3 ]] || fail "backup failure was not reported"
[[ $(count OS_EXEC) -eq 0 ]] || fail "backup failure upgraded packages"
[[ $(count 'pct shutdown 101 --timeout 60') -eq 1 ]] || fail "backup failure did not restore stopped state"
run_case checked_positive TARGET=os REQUIRE_CHECKED_OS=1
[[ $CASE_RC -eq 0 ]] || fail "positive checked installation failed"
[[ $(count OS_EXEC) -gt 0 ]] || fail "positive checked installation did not use existing OS path"
echo "apply_updates.sh: all deterministic tests passed"
+296
View File
@@ -0,0 +1,296 @@
"""Real job, Flask routes and shared flock; guest transport is isolated."""
import ast
import datetime
import subprocess
import sys
import tempfile
import threading
import time
from pathlib import Path
from types import SimpleNamespace
import unittest
from unittest.mock import Mock, patch
from flask import Flask, jsonify
from flask import request
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / 'AppImage/scripts'))
import lxc_check_job as job
class CheckOnlyTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.folder = Path(self.temp.name)
self.conf = self.folder / '100.conf'
self.conf.write_text('hostname: fixture\n')
self.patches = [patch.object(job, 'LOCK_DIR', self.folder),
patch.object(job, 'CONF_DIR', self.folder),
patch.object(job, 'OCI_REGISTRY', self.folder / 'installed.json'),
patch.object(job.managed_installs, '_lxc_updates_detection_enabled', return_value=True),
patch.object(job.managed_installs, '_get_oci_managed_vmids', return_value={}),
patch.object(job.managed_installs, 'get_active_items', return_value=[])]
for p in self.patches:
p.start()
self.addCleanup(p.stop)
job._jobs.clear()
job._attempts.clear()
self.state = 'stopped'
self.calls = []
self.result = {'result': {'available': True, '_count': 2}, 'entry': {}}
def transport(self, vmid, action, *args, **kwargs):
self.calls.append(action)
if action == 'status':
return 'status: ' + self.state
if action == 'start':
self.state = 'running'
if action in ('stop', 'shutdown'):
self.state = 'stopped'
return ''
def run_check(self, capture_error=None, persistence_error=None, transport=None):
reservation = job.acquire(100)
job._jobs['run'] = dict(vmid=100, state='running', created=time.monotonic())
with patch.object(job, 'command', side_effect=transport or self.transport), \
patch.object(job.lxc_update_observation, 'capture', return_value=self.result,
side_effect=capture_error) as capture, \
patch.object(job.lxc_update_observation, 'discard'), \
patch.object(job.managed_installs, 'store_lxc_update_observation',
return_value=True, side_effect=persistence_error) as store:
job.run(100, 'run', reservation)
return job.get(100, 'run'), capture, store
def test_stopped_check_restores_and_never_installs(self):
result, capture, store = self.run_check()
self.assertEqual(result['state'], 'complete')
self.assertEqual(self.state, 'stopped')
self.assertEqual(self.calls, ['status', 'start', 'exec', 'status', 'shutdown', 'status'])
capture.assert_called_once_with(100, 'run', refresh_indexes=True)
store.assert_called_once_with(100, self.result)
self.assertFalse((self.folder / 'proxmenux-lxc-check-invalid-100').exists())
def test_running_check_has_no_lifecycle_effect(self):
self.state = 'running'
result, _, _ = self.run_check()
self.assertEqual(result['state'], 'complete')
self.assertEqual(self.calls, ['status'])
self.assertEqual(self.state, 'running')
def test_index_read_timeout_and_persistence_errors_restore_stopped(self):
for error in [RuntimeError('index failed'), OSError('read failed'),
subprocess.TimeoutExpired('package listing', 30)]:
with self.subTest(error=type(error).__name__):
result, _, _ = self.run_check(capture_error=error)
self.assertEqual(result['state'], 'failed')
self.assertEqual(self.state, 'stopped')
result, _, _ = self.run_check(persistence_error=OSError('disk full'))
self.assertEqual(result['state'], 'failed')
self.assertEqual(self.state, 'stopped')
self.assertIn('could not be persisted', result['error'])
def test_start_timeout_after_effect_is_restored(self):
def transport(vmid, action, *args, **kwargs):
value = self.transport(vmid, action, *args, **kwargs)
if action == 'start':
raise subprocess.TimeoutExpired('start', 60)
return value
result, capture, _ = self.run_check(transport=transport)
self.assertEqual(result['state'], 'failed')
self.assertEqual(self.state, 'stopped')
capture.assert_not_called()
def test_unknown_status_does_not_start(self):
self.state = 'unknown'
result, capture, _ = self.run_check()
self.assertEqual(result['state'], 'failed')
self.assertEqual(self.calls, ['status'])
capture.assert_not_called()
def test_shutdown_failure_uses_verified_stop(self):
def transport(vmid, action, *args, **kwargs):
if action == 'shutdown':
raise RuntimeError('shutdown failed')
return self.transport(vmid, action, *args, **kwargs)
result, _, _ = self.run_check(transport=transport)
self.assertEqual(result['state'], 'complete')
self.assertEqual(self.state, 'stopped')
self.assertIn('stop', self.calls)
def test_failed_restore_revokes_installation(self):
def transport(vmid, action, *args, **kwargs):
if action in ('shutdown', 'stop'):
raise RuntimeError('stop failed')
return self.transport(vmid, action, *args, **kwargs)
result, _, _ = self.run_check(transport=transport)
self.assertEqual(result['state'], 'failed')
self.assertIn('could not be restored', result['error'])
self.assertTrue((self.folder / 'proxmenux-lxc-check-invalid-100').exists())
def test_guard_oci_config_lock_and_disabled_forbid_all_guest_calls(self):
for config in ['lxc.environment.runtime: OCI=1\n', 'lock: backup\n']:
self.conf.write_text(config)
result, capture, _ = self.run_check()
self.assertEqual(result['state'], 'failed')
self.assertEqual(self.calls, [])
capture.assert_not_called()
self.conf.unlink()
result, capture, _ = self.run_check()
self.assertEqual(result['state'], 'failed')
self.assertEqual(self.calls, [])
capture.assert_not_called()
self.conf.write_text('hostname: fixture\n')
with patch.object(job.managed_installs, '_lxc_updates_detection_enabled', return_value=False):
result, capture, _ = self.run_check()
self.assertEqual(self.calls, [])
capture.assert_not_called()
def test_registered_oci_is_excluded_before_probe(self):
with patch.object(job.managed_installs, '_get_oci_managed_vmids', return_value={'100': 'fixture'}):
result, capture, _ = self.run_check()
self.assertEqual(result['state'], 'failed')
self.assertEqual(self.calls, [])
capture.assert_not_called()
def test_unreadable_or_invalid_oci_registry_never_means_non_oci(self):
for content in ['broken', '{"instances": []}', '{"instances": {"app": {"vmid": 100}}}']:
job.OCI_REGISTRY.write_text(content)
result, capture, _ = self.run_check()
self.assertEqual(result['state'], 'failed')
self.assertEqual(self.calls, [])
capture.assert_not_called()
def test_thread_start_failure_releases_lock_and_job(self):
with patch.object(job.threading, 'Thread') as thread:
thread.return_value.start.side_effect = RuntimeError('cannot create worker')
with self.assertRaises(RuntimeError):
job.start(100)
self.assertEqual(job._jobs, {})
self.assertEqual(job._attempts, {})
reservation = job.acquire(100)
reservation.close()
def test_corrupt_registry_is_preserved_and_installation_stays_blocked(self):
registry = self.folder / 'registry.json'
registry.write_text('malformed-but-must-not-be-overwritten')
with patch.object(job.managed_installs, '_REGISTRY_PATH', str(registry)):
result, _, _ = self.run_check(persistence_error=job.managed_installs.store_lxc_update_observation)
self.assertEqual(result['state'], 'failed')
self.assertEqual(self.state, 'stopped')
self.assertEqual(registry.read_text(), 'malformed-but-must-not-be-overwritten')
self.assertTrue((self.folder / 'proxmenux-lxc-check-invalid-100').exists())
def test_shared_lock_excludes_scheduler_and_manual_runner(self):
reservation = job.acquire(100)
self.addCleanup(reservation.close)
with self.assertRaises(BlockingIOError):
job.start(100)
process = subprocess.run(['/usr/bin/flock', '-n',
str(self.folder / 'proxmenux-lxc-update-100.lock'), '/bin/true'])
self.assertEqual(process.returncode, 1)
self.assertEqual(job._jobs, {})
reservation.close()
other = job.acquire(100)
other.close()
def test_cooldown_boundaries_and_inflight(self):
for elapsed, expected in [(59.999, 429), (60, 202), (60.001, 202)]:
job._jobs.clear()
job._attempts[100] = 100
reservation = Mock()
with patch.object(job.time, 'monotonic', return_value=100 + elapsed), \
patch.object(job.threading, 'Thread'), patch.object(job, 'acquire', return_value=reservation):
result, code = job.start(100)
self.assertEqual(code, expected)
job._jobs['active'] = dict(vmid=100, state='running', created=100)
job._attempts[100] = 0
with patch.object(job.time, 'monotonic', return_value=10000):
self.assertEqual(job.start(100)[1], 429)
def test_poll_is_read_only_and_scoped_to_guest(self):
job._jobs['run'] = dict(vmid=100, state='running', error=None, created=1)
with patch.object(job, 'command') as command, patch.object(job, 'guard') as guard:
self.assertEqual(job.get(100, 'run')['state'], 'running')
self.assertIsNone(job.get(101, 'run'))
self.assertIsNone(job.get(100, 'missing'))
command.assert_not_called()
guard.assert_not_called()
def test_install_gate_positive_zero_unknown_error_and_freshness(self):
now = datetime.datetime.now(datetime.timezone.utc)
for count, available, error, age, allowed in [
(2, True, None, 60, True), (0, False, None, 60, False),
(None, True, None, 60, False), (2, True, 'failed', 60, False),
(2, True, None, 172799, True), (2, True, None, 172800, False),
(2, True, None, -60, False)]:
with self.subTest(count=count, age=age, error=error):
item = {'id': 'lxc:100', 'update_check': {'_count': count,
'available': available, 'error': error,
'last_check': (now - datetime.timedelta(seconds=age)).isoformat()}}
with patch.object(job.managed_installs, 'get_active_items', return_value=[item]):
if allowed:
job.checked_os(100)
else:
with self.assertRaises((RuntimeError, ValueError)):
job.checked_os(100)
def test_pending_failure_marker_forbids_install_before_probe(self):
(self.folder / 'proxmenux-lxc-check-invalid-100').touch()
with self.assertRaises(RuntimeError):
job.checked_os(100)
def test_real_capture_refreshes_lists_and_persists_without_installation(self):
for count, index_rc in [(0, 0), (2, 0), (2, 1)]:
with self.subTest(count=count, index_rc=index_rc):
self.state = 'stopped'
reservation = job.acquire(100)
job._jobs['real'] = dict(vmid=100, state='running', created=time.monotonic())
entry = {'id': 'lxc:100', 'type': 'lxc', '_vmid': '100', '_os_family': 'debian'}
output = 'Listing...\n' + '\n'.join(
f'pkg{i}/stable 2.0 amd64 [upgradable from: 1.0]' for i in range(count))
commands = []
def transport(command, **kwargs):
commands.append(command)
return subprocess.CompletedProcess(command, index_rc if 'update' in command else 0,
output if 'list' in ' '.join(command) else '', '')
with patch.object(job, 'command', side_effect=self.transport), \
patch.object(job.managed_installs, '_DB_DIR', str(self.folder)), \
patch.object(job.managed_installs, '_REGISTRY_PATH', str(self.folder / 'registry.json')), \
patch.object(job.lxc_update_observation, 'OBSERVATION_DIR', self.folder / 'observations'), \
patch.object(job.managed_installs, '_detect_lxc_containers', return_value=[entry]), \
patch.object(job.managed_installs, '_probe_lxc_os', return_value='debian'), \
patch.object(job.managed_installs.subprocess, 'run', side_effect=transport):
job.run(100, 'real', reservation)
stored = job.managed_installs.get_registry()['items'][0]['update_check']
self.assertEqual(self.state, 'stopped')
self.assertEqual(job.get(100, 'real')['state'], 'failed' if index_rc else 'complete')
self.assertTrue(any('update' in command for command in commands))
self.assertFalse(any(any(word in command for word in ('upgrade', 'install', 'full-upgrade'))
for command in commands))
if not index_rc:
self.assertEqual(stored['_count'], count)
else:
self.assertTrue(stored['error'])
self.assertFalse(any('apt list' in ' '.join(command) for command in commands))
def test_actual_routes_submit_poll_and_control_lock(self):
app = Flask(__name__)
scope = {'app': app, 'jsonify': jsonify, 'require_auth': lambda fn: fn,
'_vm_cache_invalidate': Mock(), '_lxc_os_info_cache': {},
'_pvesh_cache': {}, '_get_lxc_update_status_map': lambda: {'100': {'count': 2}}}
names = {'api_lxc_updates_refresh_check', 'api_lxc_updates_refresh_check_status'}
nodes = [n for n in ast.parse((ROOT / 'AppImage/scripts/flask_server.py').read_text()).body
if isinstance(n, ast.FunctionDef) and n.name in names]
exec(compile(ast.Module(body=nodes, type_ignores=[]), 'routes', 'exec'), scope)
client = app.test_client()
with patch.object(job, 'start', return_value=({'job_id': 'run'}, 202)):
self.assertEqual(client.post('/api/vms/100/updates/refresh-check').status_code, 202)
job._jobs['run'] = dict(vmid=100, state='complete', error=None, created=1)
with patch.object(job, 'command') as command:
response = client.get('/api/vms/100/updates/refresh-check/run')
self.assertEqual(response.json['update_check']['count'], 2)
self.assertEqual(client.get('/api/vms/101/updates/refresh-check/run').status_code, 404)
command.assert_not_called()
@@ -67,3 +67,90 @@ for (const locale of ['en', 'es', 'de', 'fr', 'it', 'pt', 'sk', 'sv']) {
for (const key of ['dockerWorkloadsHeading', 'runsInsideDocker', 'upstreamDelegatedTitle', 'upstreamDelegatedHelp']) assert.equal(typeof messages.vmLxc.appEditor[key], 'string')
}
console.log('PASS Docker delegation: live cache, lifecycle invalidation, edit protection, no extra HTTP, unique image counts, eight locales')
// Exercise the actual bulk-choice construction, keeping target identities
// intact while distinguishing a custom updater from a same-named image.
const choicesSource = source.slice(source.indexOf('const bulkAppChoices ='), source.indexOf('const bulkChoiceIds ='))
assert(choicesSource.startsWith('const bulkAppChoices ='))
for (const locale of ['en', 'sk']) {
const messages = JSON.parse(fs.readFileSync(path.join(root, `messages/${locale}/common.json`)))
const t = (key, values = {}) => {
const value = key.split('.').reduce((obj, part) => obj?.[part], messages)
assert.equal(typeof value, 'string', key)
return value.replace(/\{(\w+)\}/g, (_, name) => values[name] ?? '')
}
const registeredApps = [
{id: 'custom', name: 'ghstats', update_command: 'existing updater'},
{id: 'helper', name: 'Helper app', update_method: 'helper', helper_slug: 'helper-app'},
{id: 'unique', name: 'Only custom', update_command: 'another updater'},
{id: 'engine', name: 'Docker', helper_slug: 'docker'},
].map(Object.freeze)
const units = [
{id: 'docker-unit:ghstats', kind: 'standalone', display_name: 'ghstats', dependent_services: []},
{id: 'docker-unit:helper', kind: 'compose', display_name: 'Helper app', dependent_services: ['database']},
{id: 'docker-unit:unique', kind: 'standalone', display_name: 'Only image', dependent_services: []},
].map(Object.freeze)
const before = JSON.stringify({registeredApps, units})
const result = compile(choicesSource + '\nexports.choices = bulkChoices;', {
exports: {}, t, registeredApps, helperExists: true, uc: {helper_slug: 'helper-app'},
dockerWorkloadsRegistered: true, dockerRegistered: true,
selectedVM: {docker_inventory: {update_units: units}}, followedImageReferences: new Set(),
}).choices
assert.deepEqual(Array.from(result, x => x.id), ['os', 'app:custom', 'app:helper', 'app:unique',
'docker-engine', 'docker-unit:ghstats', 'docker-unit:helper', 'docker-unit:unique'])
assert.equal(result.find(x => x.id === 'app:custom').label, `ghstats — ${t('vmLxc.updates.customCommandTitle')}`)
assert.equal(result.find(x => x.id === 'docker-unit:ghstats').label, `ghstats — ${t('vmLxc.bulkUpdate.dockerImageMethod')}`)
assert.equal(result.find(x => x.id === 'app:helper').label, `Helper app — ${t('vmLxc.updates.helperMethod')}`)
assert.equal(result.find(x => x.id === 'docker-unit:helper').label, 'Helper app — Docker Compose')
assert.equal(result.find(x => x.id === 'app:unique').label, 'Only custom')
assert.equal(result.find(x => x.id === 'docker-unit:unique').label, 'Only image')
assert.equal(result.find(x => x.id === 'docker-unit:helper').detail,
t('vmLxc.bulkUpdate.includesDependencies', {names: 'database'}))
assert.equal(JSON.stringify({registeredApps, units}), before, 'display must never edit registrations or units')
assert.equal(typeof messages.vmLxc.bulkUpdate.osDockerScope, 'string')
}
console.log('PASS bulk display: duplicate method names, unchanged unique names/target IDs/dependencies and frozen input, EN/SK')
const actionStyle = compile(fs.readFileSync(path.join(root, 'lib/update-action-style.ts'), 'utf8'), {exports: {}})
let bulkButton
function findBulkButton(node) {
if (ts.isJsxElement(node) && node.openingElement.tagName.getText(tree) === 'Button') {
const props = node.openingElement.attributes.properties
if (props.some(p => ts.isJsxAttribute(p) && p.name.text === 'onClick'
&& p.initializer?.getText(tree).includes('applyBulkUpdate('))) bulkButton = props
}
ts.forEachChild(node, findBulkButton)
}
findBulkButton(tree)
assert(bulkButton, 'actual bulk execution button exists')
function bulkProp(name, context) {
const prop = bulkButton.find(p => p.name?.text === name)
assert(prop && ts.isJsxExpression(prop.initializer))
return compile('exports.value = (' + prop.initializer.expression.getText(tree) + ');', {
exports: {}, getUpdateActionButtonClass: actionStyle.getUpdateActionButtonClass, ...context,
}).value
}
const enabledBulk = {bulkApplying: false, osCheckingVmid: null, updatesRefreshing: false,
osUpdateStatusKnown: true, bulkAllTargetsUpToDate: false, bulkHasPendingUpdates: true,
bulkTargets: ['os', 'app:one'], dockerInventoryRefreshing: false, dockerInventoryAvailable: true}
assert.equal(bulkProp('disabled', enabledBulk), false)
assert.match(bulkProp('className', enabledBulk), /text-purple-300/)
const unknownBulk = {...enabledBulk, osUpdateStatusKnown: false, bulkHasPendingUpdates: false}
assert.equal(bulkProp('disabled', unknownBulk), true, 'consistent action color must not enable an unknown OS')
assert.equal(bulkProp('className', unknownBulk), bulkProp('className', enabledBulk))
const currentBulk = {...enabledBulk, bulkAllTargetsUpToDate: true, bulkHasPendingUpdates: false}
assert.equal(bulkProp('disabled', currentBulk), true)
assert.match(bulkProp('className', currentBulk), /text-green-400/)
assert.equal(bulkProp('disabled', {...enabledBulk, bulkApplying: true}), true)
assert.equal(bulkProp('disabled', {...enabledBulk, bulkTargets: ['os', 'docker-unit:one'],
dockerInventoryAvailable: false}), true)
const helperLabelSource = source.slice(source.indexOf('const cls = getUpdateActionButtonClass(hasUpd'),
source.indexOf('const cls = getUpdateActionButtonClass(hasUpd') + 550)
const helperLabel = helperLabelSource.slice(0, helperLabelSource.indexOf('return ('))
const unknownHelper = compile(helperLabel + '\nexports.value = {cls, label};', {
exports: {}, hasUpd: false, upToD: false, t: k => k,
getUpdateActionButtonClass: actionStyle.getUpdateActionButtonClass,
}).value
assert.match(unknownHelper.cls, /text-purple-300/)
assert.equal(unknownHelper.label, 'vmLxc.updates.runUpdater', 'unknown version must not be presented as a detected upgrade')
console.log('PASS actual update actions: shared author colors, unknown remains unknown, bulk guards unchanged')
@@ -138,6 +138,7 @@ esac
notification = Mock()
ns = dict(os=os, time=time, re=__import__('re'), notification_manager=notification,
_LXC_APPLY_UPDATES_SCRIPT=str(ROOT / 'scripts/lxc/apply_updates.sh'),
_LXC_INSTALL_CHECKED_OS_SCRIPT=str(ROOT / 'scripts/lxc/install_checked_os_updates.sh'),
_normalise_lxc_update_run_id=lambda value, **_: value,
_normalise_lxc_update_targets=lambda values, _: values,
_normalise_lxc_update_labels=lambda values: values,
+6
View File
@@ -22,6 +22,7 @@ from flask import Flask, jsonify
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "AppImage/scripts"))
import managed_installs as managed
import lxc_check_job
SERVER = ROOT / "AppImage/scripts/flask_server.py"
FUNCTIONS = {
@@ -50,6 +51,9 @@ class ObservedStateRoutesTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
lock_patch = patch.object(lxc_check_job, 'LOCK_DIR', Path(self.temp.name))
lock_patch.start()
self.addCleanup(lock_patch.stop)
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))
@@ -141,6 +145,8 @@ class ObservedStateRoutesTests(unittest.TestCase):
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")
+336 -10
View File
@@ -19,6 +19,7 @@ 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 {} } }),
})
const actionStyle = compile(fs.readFileSync(path.join(root, "lib/update-action-style.ts"), "utf8"), { exports: {} })
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" }
@@ -41,25 +42,51 @@ for (const lang of ['en', 'sk']) {
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 wanted = new Set(["checkOsUpdates", "getAggregateUpdateCheck", "renderLxcUpdateBadge", "openApplyTerminal", "handleVMControl"])
const definitions = []
const preferenceDefinitions = []
const status = []
let detailEffect
let resultEffect
let resultTimeout
let osApplyButton
let bulkApplyButton
let osSection
function walk(node) {
if (ts.isJsxElement(node) && node.openingElement.tagName.getText(tree) === "div"
&& node.openingElement.attributes.properties.some(p => p.name?.getText(tree) === "className"
&& p.initializer?.getText(tree) === '"pb-4"')
&& node.getText(tree).includes('vmLxc.updates.osPackagesTitle')) osSection = node.getText(tree)
if (ts.isJsxElement(node) && node.openingElement.tagName.getText(tree) === "Button"
&& node.openingElement.attributes.properties.some(p => p.name?.getText(tree) === "onClick"
&& p.initializer?.getText(tree).includes('openApplyTerminal(selectedVM.vmid, "os", { checkedOs: true })')))
osApplyButton = node.getText(tree)
if (ts.isJsxElement(node) && node.openingElement.tagName.getText(tree) === "Button"
&& node.openingElement.attributes.properties.some(p => p.name?.getText(tree) === "onClick"
&& p.initializer?.getText(tree).includes('applyBulkUpdate(selectedVM.vmid)')))
bulkApplyButton = node.getText(tree)
if (ts.isCallExpression(node) && node.expression.getText(tree) === "useEffect"
&& node.arguments[1]?.getText(tree) === "[selectedVM?.vmid]") {
detailEffect = node.arguments[0].getText(tree)
}
if (ts.isCallExpression(node) && node.expression.getText(tree) === "useEffect"
&& node.arguments[1]?.getText(tree) === "[selectedVM, updatesRefreshing, updatesBaselineCount]")
resultEffect = node.arguments[0].getText(tree)
if (ts.isCallExpression(node) && node.expression.getText(tree) === "useEffect"
&& node.arguments[1]?.getText(tree) === "[updatesRefreshing]")
resultTimeout = 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 (["applySchedulePayload", "loadSchedule", "loadBulkUpdate"].includes(name))
preferenceDefinitions.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(definitions.length, 5)
assert.equal(status.length, 3)
assert.ok(detailEffect)
let checking = null
@@ -73,27 +100,61 @@ const context = {
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 => {
getLxcOsUpdateState: o => helpers.getLxcOsUpdateState(o, now),
getUpdateActionButtonClass: actionStyle.getUpdateActionButtonClass,
refreshCheckLxcUpdates: async id => {
if (errorToThrow) throw errorToThrow
return helpers.checkLxcUpdates(id)
requests.push([`/api/vms/${id}/updates/refresh-check`, { method: "POST" }])
return { ...recent, available: true, count: 2 }
},
fetchApi: async endpoint => { requests.push([endpoint]); return { config: { onboot: 1 }, os_info: { id: "debian" } } },
mutate: async () => { polls++ },
selectedVMRef: { current: { vmid: 100 } },
osCheckRequestRef: { current: null },
selectedVM: { vmid: 100 },
setInterval: (callback, delay) => { context.detailTimer = { callback, delay }; return 1 },
clearInterval: id => { context.clearedTimer = id },
vmModalCacheRef: { current: { details: new Map() } },
vmModalCacheRef: { current: { details: new Map(), backups: new Map(), mountPoints: new Map(), schedule: new Map(), firewall: new Map() } },
setVMDetails: value => { details = value },
setOsCheckingVmid: value => { checking = value },
setOsCheckError: value => { displayedError = value },
updatesRefreshing: true,
updatesBaselineCount: 20,
updatesBaselineCountRef: { current: 20 },
setUpdatesResult: value => { context.updateResult = value },
setUpdatesRefreshing: value => { context.refreshing = value },
setUpdatesBaselineCount: value => { context.baseline = value },
setTimeout: callback => { context.resultTimer = callback; return 2 },
clearTimeout: () => {},
Button: "Button", RefreshCw: "Refresh", confirm: () => context.confirmed,
window: { crypto: { randomUUID: () => "ui-test-run" } },
applyBackupStorage: "", selectedBackupStorage: "", backupStorages: [],
osCheckingVmid: null, pendingBtnCls: "pending", upToDateBtnCls: "current", neutralBtnCls: "unknown",
bulkApplying: false, bulkTargets: ["os", "app:test"], bulkHasPendingUpdates: true,
bulkAllTargetsUpToDate: false, dockerInventoryRefreshing: false, dockerInventoryAvailable: true,
controlLoading: false, Package: "Package", Shield: "Shield", CheckCircle2: "Current", AlertTriangle: "Unknown",
osCheckError: null, uc: undefined, osUpdateStale: true,
setControlLoading: value => { context.controlLoading = value },
invalidateLxcApps: () => {}, setSelectedVM: value => { context.selectionChanged = value },
alert: value => { context.alerted = value },
}
for (const name of ["Vmid", "Target", "RunId", "TargetIds", "TargetLabels", "UpdateCommand", "AppName",
"RunHelper", "AllowHelperWithCustom", "DockerStandaloneTargets", "DockerEngine", "DockerRefresh",
"BackupStorage", "StartedAt", "Open", "CheckAndUpdate", "CheckedOs"])
context[`setApply${name}`] = value => { context[`apply${name}`] = 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};
exports.settleResult = ${resultEffect};
exports.resultTimeout = ${resultTimeout};
exports.openApply = openApplyTerminal;
exports.osApplyButton = () => (${osApplyButton});
exports.bulkApplyButton = () => (${bulkApplyButton});
exports.osSection = () => (${osSection});
exports.control = handleVMControl;
`, 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")
@@ -106,22 +167,285 @@ 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)
context.updatesRefreshing = false
for (const [observation, expected] of [
[undefined, "unknown"], [legacy, "unknown"], [{ ...recent, error: "failed" }, "unknown"],
[{ ...recent, available: false }, "unknown"],
[{ ...recent, available: true, count: 0 }, "unknown"],
[{ ...recent, available: false, count: 0 }, "current"],
[{ ...recent, available: true, count: 3 }, "pending"],
]) {
assert.equal(helpers.getLxcOsUpdateState(observation, now), expected)
context.selectedVMRef.current = { vmid: 100, status: "stopped", update_check: observation }
context.selectedVM = context.selectedVMRef.current
Object.assign(context, ui.osStatus(observation))
context.applyOpen = false
context.confirmed = true
assert.equal(ui.osApplyButton().props.disabled, expected !== "pending")
assert.equal(ui.bulkApplyButton().props.disabled, expected === "unknown")
ui.openApply(100, "os")
assert.equal(context.applyOpen, expected === "pending", "handler must enforce the same boundary as the button")
}
context.confirmed = false
context.applyOpen = false
ui.openApply(100, "os")
assert.equal(context.applyOpen, true, "an explicit installation click must not ask for a second stopped-guest confirmation")
const oldConfirm = context.confirm
let confirmationCalls = 0
context.confirm = () => { confirmationCalls++; return false }
for (const [target, options] of [
['os', { checkedOs: true }],
['both', { updateCommand: 'registered-app-update', targetIds: ['os', 'app:test'] }],
['app', { updateCommand: 'registered-app-update' }],
['app', { dockerEngine: true }],
['app', { dockerStandaloneTargets: ['registered-container'] }],
]) {
context.applyOpen = false
ui.openApply(100, target, options)
assert.equal(context.applyOpen, true, `explicit ${target} update opens the terminal directly`)
}
assert.equal(confirmationCalls, 0, 'ordinary update entry points never open a browser confirmation')
assert(!source.includes('checkAndUpdate'), 'the abandoned combined check/install action has no UI path')
assert(!fs.existsSync(path.join(root, '../scripts/lxc/check_apply_updates.sh')))
context.confirm = oldConfirm
context.confirmed = true
context.applyOpen = false
context.osCheckingVmid = 100
ui.openApply(100, "os")
assert.equal(context.applyOpen, false, "checking cannot race an update launch")
context.osCheckingVmid = null
context.bulkAllTargetsUpToDate = true
assert.equal(ui.bulkApplyButton().props.disabled, true)
context.bulkAllTargetsUpToDate = false
context.confirmed = true
context.selectedVMRef.current = { vmid: 100, status: "stopped", update_check: { ...recent, available: false, count: 0 } }
ui.openApply(100, "both", { updateCommand: "app-updater", targetIds: ["os", "app:test"] })
assert.equal(context.applyOpen, true, "combined app update remains supported when OS packages are current")
assert.equal(context.applyUpdateCommand, "app-updater")
context.selectedVMRef.current = { vmid: 100, status: "running", update_check: { ...recent, error: "failed" } }
context.applyOpen = false
ui.openApply(100, "both")
assert.equal(context.applyOpen, false, "a combined plan cannot bypass unknown OS state")
ui.openApply(100, "app", { updateCommand: "app-updater" })
assert.equal(context.applyOpen, true, "app-only updates do not depend on OS package observation")
function collectButtons(node) {
if (!node || typeof node !== "object") return []
if (Array.isArray(node)) return node.flatMap(collectButtons)
return [...(node.type === "Button" ? [node] : []), ...collectButtons(node.props?.children)]
}
function collectText(node) {
if (typeof node === "string") return node
if (!node || typeof node !== "object") return ""
if (Array.isArray(node)) return node.map(collectText).join(" ")
return collectText(node.props?.children)
}
for (const observation of [undefined, legacy, { ...recent, error: "check in progress" },
{ ...recent, available: false, count: 0 }, { ...recent, available: true, count: 3 }]) {
context.selectedVM = { vmid: 100, status: "running" }
context.uc = observation
context.osUpdateStale = helpers.isLxcUpdateStale(observation, now)
Object.assign(context, ui.osStatus(observation))
context.osCheckingVmid = 100
const rendered = ui.osSection()
assert.match(collectText(rendered), /vmLxc\.updates\.checkInProgress/,
"an active check takes precedence over old, missing and invalidated observations")
assert.doesNotMatch(collectText(rendered), /osStatusUnavailable|osUpToDate|noPending/)
assert.equal(collectButtons(rendered).length, 1, "hide the previous result during a check")
context.osCheckingVmid = 211
assert.doesNotMatch(collectText(ui.osSection()), /checkInProgress/,
"another container's check cannot replace this container's result")
}
context.osCheckingVmid = null
context.uc = { ...recent, error: "check failed" }
Object.assign(context, ui.osStatus(context.uc))
assert.match(collectText(ui.osSection()), /osStatusUnavailable/, "completed failures remain visible")
for (const status of ["running", "stopped"]) {
for (const observation of [undefined, legacy, { ...recent, error: 'read failed' },
{ ...recent, available: true, count: 0 },
{ ...recent, available: false, count: 0 }, { ...recent, available: true, count: 3 }]) {
context.selectedVM = { vmid: 100, status, update_check: observation }
context.selectedVMRef.current = context.selectedVM
context.uc = observation
context.osUpdateStale = helpers.isLxcUpdateStale(observation, now)
Object.assign(context, ui.osStatus(observation))
const buttons = collectButtons(ui.osSection())
assert.equal(buttons.length, context.osUpdateStatusKnown ? 2 : 1,
'retain the original bottom-right OS status when a verified zero is available')
assert.equal(buttons[0].props.children[1], "vmLxc.updates.checkSystemPackages")
if (context.osUpdateStatusKnown && !context.hasOsUpdates) {
const currentStatus = buttons[1]
assert.equal(currentStatus.props.asChild, true, 'current is a status, not an installation action')
assert.equal(currentStatus.props.onClick, undefined)
assert.equal(currentStatus.props.className, context.upToDateBtnCls)
assert.equal(currentStatus.props.children.type, 'span')
assert.equal(currentStatus.props.children.props.role, 'status')
assert.equal(currentStatus.props.children.props.children, 'vmLxc.updates.osUpToDate')
}
assert.equal((osSection.match(/vmLxc\.updates\.refreshCheckHelp/g) || []).length, 1,
'keep the explanation in the check tooltip without duplicating it as an inline paragraph')
if (status === "stopped") {
context.applyOpen = false
context.confirmed = false
ui.openApply(100, "os", { checkedOs: true })
assert.equal(context.applyOpen, context.hasOsUpdates, "explicit OS installation has no second popup")
if (context.hasOsUpdates) assert.equal(context.applyCheckedOs, true)
}
}
}
context.selectedVMRef.current = { vmid: 100, status: "running" }
context.updatesRefreshing = true
context.selectedVM = { update_check: { count: 0, error: "verification failed" } }
ui.settleResult()
assert.equal(context.updateResult, null, "unknown must not produce an all-updated banner")
context.selectedVMRef.current = context.selectedVM
ui.resultTimeout()
context.resultTimer()
assert.equal(context.updateResult, null, "timeout must not turn an unknown observation into success")
context.selectedVM = { update_check: { count: 0, error: null } }
ui.settleResult()
assert.equal(context.updateResult.pendingAfter, 0)
assert.equal(context.updateResult.appliedCount, 20)
context.selectedVM = { vmid: 100 }
context.selectedVMRef.current = { vmid: 100 }
;(async () => {
// Execute the actual loaders with delayed GETs to test guest isolation.
const preferenceState = {}
const pendingReads = []
const preferenceContext = {
exports: {}, CRON_PRESETS: [{ value: "daily-3am", cron: "0 3 * * *" }],
selectedVMRef: { current: { vmid: 213 } },
scheduleLoadRequestRef: { current: 0 }, bulkLoadRequestRef: { current: 0 },
vmModalCacheRef: { current: { schedule: new Map() } },
t: key => key,
fetchApi: (url, options) => new Promise((resolve, reject) => {
assert.equal(options, undefined, "loading preferences never writes or starts an update")
pendingReads.push({ url, resolve, reject })
}),
}
for (const name of ["ScheduleEnabled", "ScheduleConfigured", "ScheduleCron", "SchedulePreset",
"ScheduleTarget", "ScheduleTargets", "ScheduleReleaseDelayDays", "ApplyBackup", "ApplyBackupStorage",
"ApplyRestart", "ScheduleLastRunAt", "ScheduleLastRunStatus", "ScheduleLastRunReason", "ScheduleLastRunLog",
"ScheduleLastRunRebootRequired", "ScheduleLastRunRebootPackages", "ExternalCron", "ScheduleError",
"ScheduleLoaded", "BulkError", "BulkLoaded", "BulkEditMode", "BulkConfigured", "BulkTargets", "BulkPersistedTargets"])
preferenceContext[`set${name}`] = value => { preferenceState[name] = value }
assert.equal(preferenceDefinitions.length, 3)
const preferences = compile(preferenceDefinitions.join("\n") + `
exports.apply = applySchedulePayload; exports.schedule = loadSchedule; exports.bulk = loadBulkUpdate;
`, preferenceContext)
preferences.apply({ backup: true, backup_storage: "local", restart: true })
preferences.apply({})
assert.equal(preferenceState.ApplyBackup, false, "a guest without saved options cannot inherit backup")
assert.equal(preferenceState.ApplyBackupStorage, "", "missing storage clears another guest's choice")
assert.equal(preferenceState.ApplyRestart, false, "missing restart cannot inherit a reboot")
preferences.apply({ backup: false, backup_storage: "", restart: false })
assert.equal(preferenceState.ApplyBackupStorage, "")
preferenceContext.vmModalCacheRef.current.schedule.set(213, { backup: true, backup_storage: "local", restart: true })
const previousSchedule = preferences.schedule(213)
assert.equal(preferenceState.ApplyBackup, true, "use only the selected guest's own cache")
preferenceContext.selectedVMRef.current = { vmid: 210 }
const currentSchedule = preferences.schedule(210)
assert.equal(preferenceState.ApplyBackup, false, "clear inherited options before the GET completes")
assert.equal(preferenceState.ApplyRestart, false)
pendingReads[1].resolve({})
await currentSchedule
pendingReads[0].resolve({ backup: true, backup_storage: "wrong", restart: true })
await previousSchedule
assert.equal(preferenceState.ApplyBackup, false)
assert.equal(preferenceState.ScheduleLoaded, 210, "old completion cannot mark another guest loaded")
const older = preferences.schedule(210)
const newer = preferences.schedule(210)
pendingReads[3].resolve({ backup: true, backup_storage: "new", restart: false })
await newer
pendingReads[2].resolve({ backup: false, backup_storage: "old", restart: true })
await older
assert.equal(preferenceState.ApplyBackupStorage, "new", "only the latest request may publish preferences")
assert.equal(preferenceState.ApplyRestart, false)
const closed = preferences.schedule(210)
const closedState = JSON.stringify(preferenceState)
preferenceContext.selectedVMRef.current = null
pendingReads[4].reject(new Error("old guest failure"))
await closed
assert.equal(JSON.stringify(preferenceState), closedState, "closing the modal forbids late error and loaded writes")
preferenceContext.selectedVMRef.current = { vmid: 213 }
const oldBulk = preferences.bulk(213)
preferenceContext.selectedVMRef.current = { vmid: 210 }
const newBulk = preferences.bulk(210)
pendingReads[6].resolve({ targets: ["os", "app:ghstats"] })
await newBulk
pendingReads[5].resolve({ targets: ["os", "docker-engine"] })
await oldBulk
assert.equal(JSON.stringify(preferenceState.BulkTargets), '["os","app:ghstats"]')
assert.equal(preferenceState.BulkLoaded, 210)
const oldBulkSameGuest = preferences.bulk(210)
const newBulkSameGuest = preferences.bulk(210)
pendingReads[8].resolve({ targets: ["os", "docker-engine"] })
await newBulkSameGuest
pendingReads[7].reject(new Error("superseded bulk failure"))
await oldBulkSameGuest
assert.equal(preferenceState.BulkError, null)
assert.equal(JSON.stringify(preferenceState.BulkTargets), '["os","docker-engine"]')
const failedSchedule = preferences.schedule(210)
pendingReads[9].resolve(null)
await failedSchedule
assert.equal(preferenceState.ScheduleError, "vmLxc.scheduled.loadFailed", "invalid responses are failed reads")
const failedBulk = preferences.bulk(210)
pendingReads[10].resolve([])
await failedBulk
assert.equal(preferenceState.BulkError, "vmLxc.bulkUpdate.loadFailed")
const closedBulk = preferences.bulk(210)
const closedBulkState = JSON.stringify(preferenceState)
preferenceContext.selectedVMRef.current = null
pendingReads[11].resolve({ targets: ["os", "app:wrong-guest"] })
await closedBulk
assert.equal(JSON.stringify(preferenceState), closedBulkState)
const readsBeforeClosedLoad = pendingReads.length
await preferences.schedule(210)
await preferences.bulk(210)
assert.equal(pendingReads.length, readsBeforeClosedLoad, "an unselected guest cannot start a preferences GET")
assert(pendingReads.every(read => /\/(schedule|bulk-update)$/.test(read.url)), "no guest or updater routes")
// Exercise the real separate helper: POST submits, GET waits, no apply.
const io = []
let jobState = 'running'
const jobHelpers = compile(fs.readFileSync(path.join(root, 'lib/lxc-update-observation.ts'), 'utf8'), {
exports: {}, Date, setTimeout: callback => { jobState = 'complete'; callback() },
require: () => ({ fetchApi: async (url, opts) => {
io.push([url, opts])
if (opts?.method === 'POST') return { job_id: 'check-only' }
return { state: jobState, update_check: { count: 0, available: false }, error: 'index failed' }
} }),
})
assert.equal((await jobHelpers.refreshCheckLxcUpdates(100)).count, 0)
assert.equal(io[0][0], '/api/vms/100/updates/refresh-check')
assert.equal(io.filter(entry => entry[1]?.method === 'POST').length, 1)
assert(io.slice(1).every(entry => entry[0].endsWith('/refresh-check/check-only')))
jobState = 'failed'
await assert.rejects(jobHelpers.refreshCheckLxcUpdates(100), /osStatusUnavailable/)
io.length = 0
await jobHelpers.checkLxcUpdates(100)
assert.equal(io[0][0], '/api/vms/100/updates/check', 'legacy helper retains read-only endpoint')
context.applyOpen = false
context.updatesRefreshing = false
await ui.check(100)
assert.equal(requests[0][0], "/api/vms/100/updates/check")
assert.equal(requests[0][0], "/api/vms/100/updates/refresh-check")
assert.equal(requests[0][1].method, "POST")
assert.equal(requests[1][0], "/api/vms/100")
assert.equal(polls, 1)
assert.equal(polls, 2)
assert.equal(details.os_info.id, "debian")
assert.equal(checking, null)
errorToThrow = new Error('raw transport error must not appear in translated UI')
await ui.check(100)
assert.equal(displayedError, 'vmLxc.updates.osStatusUnavailable')
errorToThrow = new Error('checkStillRunning')
await ui.check(100)
assert.equal(displayedError, 'vmLxc.updates.checkStillRunning')
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)
assert.equal(polls, 2)
// Switching guests while a request runs must not replace another modal.
errorToThrow = null
context.selectedVMRef.current = { vmid: 200 }
@@ -143,9 +467,11 @@ assert.equal(requests.length, 0)
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"])
for (const key of ["checkNow", "checkReadOnlyHelp", "checkCooldown", "checkStale", "checkStaleHelp",
"checkBeforeUpdate", "startBeforeCheck", "notCheckedYet",
"checkStillRunning"])
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")
console.log("PASS observed-state UI: freshness, safe actions, per-guest preferences/bulk races, EN/SK")
})().catch(error => { console.error(error); process.exitCode = 1 })
+129
View File
@@ -0,0 +1,129 @@
// Execute the real Updates JSX and handlers with independent state fixtures.
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'))
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)
let appRender, bulkRender, checkButton, checkHandler
const declarations = []
function walk(node) {
if (ts.isCallExpression(node) && node.expression.getText(tree) === 'appSections.map') appRender = node.arguments[0].getText(tree)
if (ts.isConditionalExpression(node) && node.condition.getText(tree) === 'bulkConfigured'
&& node.whenTrue.getText(tree).includes('applyBulkUpdate(')) bulkRender = node.whenTrue.getText(tree)
if (ts.isVariableDeclaration(node)) {
if (node.name.getText(tree) === 'checkOsUpdates') checkHandler = node.getText(tree)
if (['selectedBulkUpdateStates', 'bulkHasPendingUpdates', 'bulkAllTargetsUpToDate', 'bulkHasUnknownTargets'].includes(node.name.getText(tree)))
declarations.push('const ' + node.getText(tree) + ';')
}
if (ts.isJsxElement(node) && node.openingElement.tagName.getText(tree) === 'Button'
&& node.openingElement.attributes.getText(tree).includes('checkOsUpdates(selectedVM.vmid)')) checkButton = node.getText(tree)
ts.forEachChild(node, walk)
}
walk(tree)
assert(appRender && bulkRender && checkButton && checkHandler)
function compile(code, context) {
const result = ts.transpileModule(code, {compilerOptions: {target: ts.ScriptTarget.ES2020,
module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX}, reportDiagnostics: true})
assert.equal(result.diagnostics.length, 0)
vm.runInNewContext(result.outputText, context)
return context.exports
}
function flatten(node) {
if (Array.isArray(node)) return node.flatMap(flatten)
return node && typeof node === 'object' ? [node, ...flatten(node.props?.children)] : []
}
function text(node) {
if (Array.isArray(node)) return node.map(text).join(' ')
return node && typeof node === 'object' ? text(node.props?.children) : typeof node === 'string' ? node : ''
}
for (const locale of ['en', 'sk']) {
const messages = JSON.parse(fs.readFileSync(path.join(root, `messages/${locale}/common.json`)))
const t = key => key.split('.').reduce((o, part) => o?.[part], messages) ?? key
const calls = []
const context = {exports: {}, t, customCmdEditingApp: null, uc: null, helperExists: false,
helperUsesWebUpdater: false, selectedVM: {vmid: 210}, openCustomCmdEditor() {},
openApplyTerminal: (...args) => calls.push(args), getUpdateActionButtonClass: () => 'original-style',
bulkApplying: false, osCheckingVmid: null, updatesRefreshing: false, controlLoading: false,
applyOpen: false, osUpdateStatusKnown: true, hasOsUpdates: false,
dockerEngineHasUpdate: false, dockerEngineUpToDate: true, dockerInventoryRefreshing: false,
dockerInventoryAvailable: true, dockerUpdateUnits: [], selectedBulkLabels: ['OS', 'Custom app'],
bulkTargets: ['os', 'app:custom'], applyBulkUpdate: () => calls.push('bulk'), checkOsUpdates() {},
require: () => ({jsx: (type, props) => ({type, props}), jsxs: (type, props) => ({type, props})})}
for (const name of ['Button', 'Package', 'Settings2', 'ArrowUpCircle', 'CheckCircle2', 'RefreshCw', 'Pin', 'Loader2']) context[name] = name
const ui = compile('exports.app = (' + appRender + '); exports.check = () => (' + checkButton + ');', context)
const original = {id: 'custom', name: 'Custom app', installed_via: 'manual', installed_version: null,
latest_version: '2.0', update_available: null, update_command: 'approved-command', checked_at: '2026-10-14T10:43:19Z'}
const before = JSON.stringify(original)
const rendered = ui.app(original)
assert.match(text(rendered), locale === 'sk' ? /Nainštalovaná verzia nie je overená/ : /Installed version is unverified/)
assert.doesNotMatch(text(rendered), /Čaká sa|pending|upstreamAvailable/)
const action = flatten(rendered).find(n => n.type === 'Button')
assert.equal(text(action).trim(), locale === 'sk' ? 'Spustiť vlastný príkaz' : 'Run custom command')
assert.equal(calls.length, 0, 'render/check timestamp must not execute an updater')
action.props.onClick()
assert.equal(calls.length, 1)
assert.equal(calls[0][0], 210)
assert.equal(calls[0][1], 'app')
assert.equal(calls[0][2].updateCommand, 'approved-command')
assert.deepEqual(Array.from(calls[0][2].targetIds), ['app:custom'])
assert.equal(JSON.stringify(original), before, 'UI must not confirm or edit the stored version')
calls.length = 0
const knownVersion = ui.app({...original, installed_version: '1.0'})
assert(text(knownVersion).includes('1.0'), 'keep the known installed version when comparison is unavailable')
assert.doesNotMatch(text(knownVersion), /Čaká sa|Version tracking pending/)
const noDetector = ui.app({...original, installed_via: undefined})
assert(text(noDetector).includes(t('vmLxc.updates.versionTrackingNotConfigured')))
assert.equal(text(flatten(noDetector).find(n => n.type === 'Button')).trim(), t('vmLxc.updates.runCustomCommand'))
assert.equal(text(ui.check()).trim(), locale === 'sk' ? 'Skontrolovať balíky systému' : 'Check system packages')
for (const installed_via of ['binary', 'file', 'dpkg', 'apk']) {
const unavailable = ui.app({...original, installed_via, error: 'probe failed'})
assert.match(text(unavailable), locale === 'sk' ? /Dostupnosť aktualizácie nie je overená/ : /Update availability is unverified/)
assert.doesNotMatch(text(unavailable), /Čaká sa|next scheduled|upToDateAtLabel/)
}
const current = {...original, installed_version: '2.0', update_available: false}
const pending = {...original, installed_version: '1.0', update_available: true}
assert(text(ui.app(current)).includes(t('vmLxc.updates.upToDateAtLabel')))
assert(text(ui.app(pending)).includes(t('vmLxc.updates.installedLabel')))
assert.equal(text(flatten(ui.app(pending)).find(n => n.type === 'Button')).trim(), t('vmLxc.updates.applyUpdate'))
assert(!flatten(ui.app({...original, update_command: ''})).some(n => n.type === 'Button'), 'no command means no execution')
function bulk(app, osKnown = true, extra = {}) {
return compile(declarations.join('\n') + '\nexports.rendered=(' + bulkRender + ');',
{...context, exports: {}, registeredApps: [app], osUpdateStatusKnown: osKnown, ...extra}).rendered
}
const unknownBulk = bulk(original)
assert.match(text(unknownBulk), locale === 'sk' ? /nie je overená/ : /is unverified/)
assert.equal(flatten(unknownBulk).find(n => n.type === 'Button').props.disabled, false, 'preserve explicit manual execution')
assert.equal(flatten(bulk(current)).find(n => n.type === 'Button').props.disabled, true)
assert.equal(flatten(bulk(original, false)).find(n => n.type === 'Button').props.disabled, true, 'unknown OS blocks bulk')
assert.equal(flatten(bulk(original, true, {bulkApplying: true})).find(n => n.type === 'Button').props.disabled, true)
const mixed = bulk(pending, true, {registeredApps: [pending, {...original, id: 'unknown'}],
bulkTargets: ['os', 'app:custom', 'app:unknown']})
assert.match(text(mixed), locale === 'sk' ? /nie je overená/ : /is unverified/)
assert.equal(flatten(mixed).find(n => n.type === 'Button').props.disabled, false)
const imageUnavailable = bulk(original, true, {bulkTargets: ['os', 'docker-unit:one'], dockerInventoryAvailable: false})
assert.equal(flatten(imageUnavailable).find(n => n.type === 'Button').props.disabled, true, 'unavailable image cannot be run')
assert.doesNotMatch(text(bulk(pending)), locale === 'sk' ? /nie je overená/ : /is unverified/)
assert.equal(calls.length, 0, 'all status fixtures are side-effect free')
}
;(async () => {
const context = {exports: {}, osCheckRequestRef: {current: null}, applyOpen: false, controlLoading: false,
selectedVMRef: {current: {vmid: 210}}, vmModalCacheRef: {current: {details: new Map()}},
setOsCheckingVmid() {}, setOsCheckError() {}, setSelectedVM() {}, setVMDetails() {}, mutate: async () => {},
t: key => key, requests: []}
context.refreshCheckLxcUpdates = async vmid => {context.requests.push(['check', vmid]); return {count: 0}}
context.fetchApi = async (url, options) => {context.requests.push([url, options]); return {}}
const handler = compile('const ' + checkHandler + '; exports.check = checkOsUpdates;', context)
await handler.check(210)
assert.deepEqual(context.requests, [['check', 210], ['/api/vms/210', undefined]])
context.requests.length = 0
for (const guard of ['applyOpen', 'controlLoading']) {
context[guard] = true
await handler.check(210)
context[guard] = false
}
assert.equal(context.requests.length, 0, 'blocked checks make no API/probe requests')
console.log('PASS common update status: EN/SK unknown/current/pending/error/custom/no-command/bulk/OS check guards; actual JSX and handlers')
})().catch(error => {console.error(error); process.exitCode = 1})