Merge pull request #448 from Vaso73/fix/lxc-package-check-install

fix: separate LXC package checks from installation
This commit is contained in:
MacRimi
2026-10-15 15:44:35 +02:00
committed by GitHub
19 changed files with 1448 additions and 71 deletions
@@ -213,9 +213,16 @@ class CommandDescriptionsTests(unittest.TestCase):
# Model the bot's future output in temporary catalogs, just # Model the bot's future output in temporary catalogs, just
# like the other pending keys above; never rewrite locales. # like the other pending keys above; never rewrite locales.
for key in ("checkNow", "checkReadOnlyHelp", "checkCooldown", for key in ("checkNow", "checkReadOnlyHelp", "checkCooldown",
"checkStale", "checkStaleHelp"): "checkStale", "checkStaleHelp", "checkBeforeUpdate",
"startBeforeCheck", "notCheckedYet",
"checkStillRunning", "refreshCheckHelp",
"checkInProgress", "checkSystemPackages", "runCustomCommand",
"installedVersionUnverified", "versionStatusUnknown"):
messages["vmLxc"]["updates"].setdefault( messages["vmLxc"]["updates"].setdefault(
key, english["vmLxc"]["updates"][key]) 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)) path.write_text(json.dumps(messages, ensure_ascii=False))
before = {p: p.read_bytes() for p in root.glob("*/common.json")} before = {p: p.read_bytes() for p in root.glob("*/common.json")}
argv = [str(SCRIPT), "--source", str(root / "en/common.json"), argv = [str(SCRIPT), "--source", str(root / "en/common.json"),
+145 -54
View File
@@ -4,6 +4,7 @@ import type React from "react"
import { useState, useMemo, useEffect, useRef } from "react" import { useState, useMemo, useEffect, useRef } from "react"
import { fetchLxcApps, getLxcAppsCached, invalidateLxcApps, seedLxcAppsCache, setLxcAppsCached, syncLxcAppsState } from "../lib/lxc-apps-cache" 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 { parseTags, stringifyTags, tagToColor } from "../lib/pve-tag-color"
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card" import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
import { Badge } from "./ui/badge" import { Badge } from "./ui/badge"
@@ -28,7 +29,7 @@ import { formatStorage } from "../lib/utils"
import { formatNetworkTraffic, getNetworkUnit } from "../lib/format-network" import { formatNetworkTraffic, getNetworkUnit } from "../lib/format-network"
import { fetchApi } from "../lib/api-config" import { fetchApi } from "../lib/api-config"
import { matchesVmSearch, normalizeVmSearchValue } from "../lib/vm-search" import { matchesVmSearch, normalizeVmSearchValue } from "../lib/vm-search"
import { checkLxcUpdates, isLxcUpdateStale } from "../lib/lxc-update-observation" import { refreshCheckLxcUpdates, getLxcOsUpdateState, isLxcUpdateStale } from "../lib/lxc-update-observation"
import DOMPurify from "dompurify" import DOMPurify from "dompurify"
import { marked } from "marked" import { marked } from "marked"
import { getCountFormKey, useI18n, useT } from "@/lib/i18n/provider" import { getCountFormKey, useI18n, useT } from "@/lib/i18n/provider"
@@ -917,6 +918,7 @@ export function VirtualMachines() {
// (or after a 15 s safety timeout). // (or after a 15 s safety timeout).
const [updatesRefreshing, setUpdatesRefreshing] = useState(false) const [updatesRefreshing, setUpdatesRefreshing] = useState(false)
const [osCheckingVmid, setOsCheckingVmid] = useState<number | null>(null) const [osCheckingVmid, setOsCheckingVmid] = useState<number | null>(null)
const osCheckRequestRef = useRef<number | null>(null)
const [osCheckError, setOsCheckError] = useState<string | null>(null) const [osCheckError, setOsCheckError] = useState<string | null>(null)
const [updatesResult, setUpdatesResult] = useState<{ pendingAfter: number; appliedCount: number } | null>(null) const [updatesResult, setUpdatesResult] = useState<{ pendingAfter: number; appliedCount: number } | null>(null)
const [updatesBaselineCount, setUpdatesBaselineCount] = useState<number | null>(null) const [updatesBaselineCount, setUpdatesBaselineCount] = useState<number | null>(null)
@@ -1231,6 +1233,12 @@ export function VirtualMachines() {
useEffect(() => { useEffect(() => {
if (!updatesRefreshing) return if (!updatesRefreshing) return
if (!selectedVM) 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 const currentCount = selectedVM.update_check?.count ?? 0
if (updatesBaselineCount !== null && currentCount !== updatesBaselineCount) { if (updatesBaselineCount !== null && currentCount !== updatesBaselineCount) {
const applied = Math.max(0, updatesBaselineCount - currentCount) const applied = Math.max(0, updatesBaselineCount - currentCount)
@@ -1250,7 +1258,8 @@ export function VirtualMachines() {
const safety = setTimeout(() => { const safety = setTimeout(() => {
const currentCount = selectedVMRef.current?.update_check?.count ?? 0 const currentCount = selectedVMRef.current?.update_check?.count ?? 0
const baseline = updatesBaselineCountRef.current const baseline = updatesBaselineCountRef.current
setUpdatesResult({ const observation = selectedVMRef.current?.update_check
setUpdatesResult(!observation || observation.error ? null : {
pendingAfter: currentCount, pendingAfter: currentCount,
appliedCount: Math.max(0, (baseline ?? 0) - 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 [applyRestart, setApplyRestart] = useState(false)
const [applyStartedAt, setApplyStartedAt] = useState<number>(0) const [applyStartedAt, setApplyStartedAt] = useState<number>(0)
const [applyRunId, setApplyRunId] = useState<string>("") const [applyRunId, setApplyRunId] = useState<string>("")
const [applyCheckedOs, setApplyCheckedOs] = useState(false)
const [applyTargetIds, setApplyTargetIds] = useState<string[]>([]) const [applyTargetIds, setApplyTargetIds] = useState<string[]>([])
const [applyTargetLabels, setApplyTargetLabels] = useState<string[]>([]) const [applyTargetLabels, setApplyTargetLabels] = useState<string[]>([])
// Extra state carried alongside applyTarget when the App branch is // 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 // detected via the community-scripts pattern. All loaded once when
// the user opens the Updates tab of a specific LXC. // the user opens the Updates tab of a specific LXC.
const [scheduleLoaded, setScheduleLoaded] = useState<number | null>(null) const [scheduleLoaded, setScheduleLoaded] = useState<number | null>(null)
const scheduleLoadRequestRef = useRef(0)
const [scheduleEnabled, setScheduleEnabled] = useState(false) const [scheduleEnabled, setScheduleEnabled] = useState(false)
const [scheduleCron, setScheduleCron] = useState("0 3 * * *") const [scheduleCron, setScheduleCron] = useState("0 3 * * *")
const [schedulePreset, setSchedulePreset] = useState<string>("daily-3am") 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 // with scheduled updates: changing a one-click manual selection must never
// rewrite the cron automation configured in Options. // rewrite the cron automation configured in Options.
const [bulkLoaded, setBulkLoaded] = useState<number | null>(null) const [bulkLoaded, setBulkLoaded] = useState<number | null>(null)
const bulkLoadRequestRef = useRef(0)
const [bulkConfigured, setBulkConfigured] = useState(false) const [bulkConfigured, setBulkConfigured] = useState(false)
const [bulkTargets, setBulkTargets] = useState<string[]>(["os"]) const [bulkTargets, setBulkTargets] = useState<string[]>(["os"])
const [bulkPersistedTargets, setBulkPersistedTargets] = 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)) ? s.targets.map((value: any) => String(value))
: ([...(legacyTarget !== "app" ? ["os"] : []), ...(legacyTarget !== "os" ? ["apps"] : [])])) : ([...(legacyTarget !== "app" ? ["os"] : []), ...(legacyTarget !== "os" ? ["apps"] : [])]))
setScheduleReleaseDelayDays(Number.isInteger(Number(s.release_delay_days)) ? Number(s.release_delay_days) : 0) setScheduleReleaseDelayDays(Number.isInteger(Number(s.release_delay_days)) ? Number(s.release_delay_days) : 0)
if (s.backup !== undefined) setApplyBackup(!!s.backup) setApplyBackup(!!s.backup)
if (s.backup_storage) setApplyBackupStorage(s.backup_storage) setApplyBackupStorage(typeof s.backup_storage === "string" ? s.backup_storage : "")
if (s.restart !== undefined) setApplyRestart(!!s.restart) setApplyRestart(!!s.restart)
setScheduleLastRunAt(s.last_run_at || null) setScheduleLastRunAt(s.last_run_at || null)
setScheduleLastRunStatus(s.last_run_status || null) setScheduleLastRunStatus(s.last_run_status || null)
setScheduleLastRunReason(s.last_run_reason || null) setScheduleLastRunReason(s.last_run_reason || null)
@@ -2122,30 +2134,46 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
} }
const loadSchedule = async (vmid: number) => { 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) setScheduleError(null)
// Seed from ref cache so tab-switching to Updates isn't a "Loading…" // Seed from ref cache so tab-switching to Updates isn't a "Loading…"
// moment when the payload was already fetched this session. // moment when the payload was already fetched this session.
const cachedSched = vmModalCacheRef.current.schedule.get(vmid) 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 { try {
const s: any = await fetchApi(`/api/vms/${vmid}/schedule`) const s: any = await fetchApi(`/api/vms/${vmid}/schedule`)
if (s && typeof s === "object") { if (!isCurrent()) return
vmModalCacheRef.current.schedule.set(vmid, s) if (!s || typeof s !== "object" || Array.isArray(s))
applySchedulePayload(s) throw new Error(t("vmLxc.scheduled.loadFailed"))
} vmModalCacheRef.current.schedule.set(vmid, s)
applySchedulePayload(s)
} catch (e: any) { } catch (e: any) {
setScheduleError(e?.message || t("vmLxc.scheduled.loadFailed")) if (isCurrent()) setScheduleError(e?.message || t("vmLxc.scheduled.loadFailed"))
} finally { } finally {
setScheduleLoaded(vmid) if (isCurrent()) setScheduleLoaded(vmid)
} }
} }
const loadBulkUpdate = async (vmid: number) => { 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) setBulkError(null)
setBulkLoaded(null) setBulkLoaded(null)
setBulkEditMode(false) setBulkEditMode(false)
setBulkConfigured(false)
setBulkTargets(["os"])
setBulkPersistedTargets(["os"])
try { try {
const config: any = await fetchApi(`/api/vms/${vmid}/bulk-update`) 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) const targets = Array.isArray(config?.targets)
? Array.from(new Set(config.targets.map((value: any) => String(value)))) as string[] ? 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) setBulkTargets(next)
setBulkPersistedTargets(next) setBulkPersistedTargets(next)
} catch (e: any) { } catch (e: any) {
if (!isCurrent()) return
setBulkConfigured(false) setBulkConfigured(false)
setBulkTargets(["os"]) setBulkTargets(["os"])
setBulkPersistedTargets(["os"]) setBulkPersistedTargets(["os"])
setBulkError(e?.message || t("vmLxc.bulkUpdate.loadFailed")) setBulkError(e?.message || t("vmLxc.bulkUpdate.loadFailed"))
} finally { } finally {
setBulkLoaded(vmid) if (isCurrent()) setBulkLoaded(vmid)
} }
} }
@@ -2560,8 +2589,21 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
const openApplyTerminal = ( const openApplyTerminal = (
vmid: number, vmid: number,
target: "os" | "app" | "both", 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" const fallbackTargets = target === "os"
? ["os"] ? ["os"]
: target === "both" : target === "both"
@@ -2584,6 +2626,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
setApplyVmid(vmid) setApplyVmid(vmid)
setApplyTarget(target) setApplyTarget(target)
setApplyRunId(runId) setApplyRunId(runId)
setApplyCheckedOs(opts?.checkedOs === true)
setApplyTargetIds(opts?.targetIds?.length ? opts.targetIds : fallbackTargets) setApplyTargetIds(opts?.targetIds?.length ? opts.targetIds : fallbackTargets)
setApplyTargetLabels(opts?.targetLabels?.length ? opts.targetLabels : fallbackLabels) setApplyTargetLabels(opts?.targetLabels?.length ? opts.targetLabels : fallbackLabels)
setApplyUpdateCommand(opts?.updateCommand || "") 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 // false "N package pending" every time a registered app has an
// upstream bump. // upstream bump.
const checkOsUpdates = async (vmid: number) => { const checkOsUpdates = async (vmid: number) => {
if (osCheckRequestRef.current !== null || applyOpen || controlLoading) return
osCheckRequestRef.current = vmid
setOsCheckingVmid(vmid) setOsCheckingVmid(vmid)
setOsCheckError(null) setOsCheckError(null)
try { 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}`) const details = await fetchApi<VMDetails>(`/api/vms/${vmid}`)
vmModalCacheRef.current.details.set(vmid, details) vmModalCacheRef.current.details.set(vmid, details)
if (selectedVMRef.current?.vmid === vmid) setVMDetails(details) if (selectedVMRef.current?.vmid === vmid) setVMDetails(details)
@@ -2692,9 +2741,12 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
if (selectedVMRef.current?.vmid === vmid) { if (selectedVMRef.current?.vmid === vmid) {
setOsCheckError(error.status === 429 setOsCheckError(error.status === 429
? t("vmLxc.updates.checkCooldown", { seconds: error.body?.retry_after ?? 60 }) ? 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 { } finally {
osCheckRequestRef.current = null
setOsCheckingVmid(null) setOsCheckingVmid(null)
} }
} }
@@ -5707,7 +5759,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
!selectedVM.update_check?.is_oci_lxc && (() => { !selectedVM.update_check?.is_oci_lxc && (() => {
const uc = selectedVM.update_check const uc = selectedVM.update_check
const osUpdateStale = isLxcUpdateStale(uc) const osUpdateStale = isLxcUpdateStale(uc)
const osUpdateStatusKnown = !!uc && !uc.error && !osUpdateStale const osUpdateStatusKnown = getLxcOsUpdateState(uc) !== "unknown"
const hasOsUpdates = osUpdateStatusKnown && !!uc.available const hasOsUpdates = osUpdateStatusKnown && !!uc.available
const dockerAppWatch = (selectedVM.app_watches || []).find((a) => a.helper_slug === "docker") const dockerAppWatch = (selectedVM.app_watches || []).find((a) => a.helper_slug === "docker")
const dockerRegistered = !!dockerAppWatch const dockerRegistered = !!dockerAppWatch
@@ -5863,6 +5915,9 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
return { return {
id: `app:${app.id}`, id: `app:${app.id}`,
label: app.name || t("vmLxc.updates.applicationDefaultName"), label: app.name || t("vmLxc.updates.applicationDefaultName"),
methodLabel: app.update_command?.trim()
? t("vmLxc.updates.customCommandTitle")
: t("vmLxc.updates.helperMethod"),
detail: "", detail: "",
logoUrl: webLinkLogo || app.logo_url?.trim() || "", logoUrl: webLinkLogo || app.logo_url?.trim() || "",
} }
@@ -5877,6 +5932,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
? [{ ? [{
id: "docker-engine", id: "docker-engine",
label: "Docker Engine", label: "Docker Engine",
methodLabel: t("vmLxc.scheduled.dockerEngineTarget"),
detail: "", detail: "",
logoUrl: "", logoUrl: "",
}] }]
@@ -5884,6 +5940,9 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
...dockerUpdateUnits.map((unit) => ({ ...dockerUpdateUnits.map((unit) => ({
id: unit.id, id: unit.id,
label: unit.display_name || unit.primary_reference || unit.primary_service || 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 detail: (unit.dependent_services || []).length
? t("vmLxc.bulkUpdate.includesDependencies", { ? t("vmLxc.bulkUpdate.includesDependencies", {
names: (unit.dependent_services || []).join(", "), names: (unit.dependent_services || []).join(", "),
@@ -5891,7 +5950,14 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
: "", : "",
logoUrl: unit.logo_url || "", 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 = [{ const bulkChoices = [{
id: "os", id: "os",
label: t("vmLxc.bulkUpdate.osTarget"), label: t("vmLxc.bulkUpdate.osTarget"),
@@ -5935,7 +6001,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
// has been checked and is current, otherwise unknown. // has been checked and is current, otherwise unknown.
const selectedBulkUpdateStates = bulkTargets.map((target) => { const selectedBulkUpdateStates = bulkTargets.map((target) => {
if (target === "os") { if (target === "os") {
if (!uc || uc.error) return null if (!osUpdateStatusKnown) return null
return hasOsUpdates return hasOsUpdates
} }
if (target === "docker-engine") { if (target === "docker-engine") {
@@ -5960,6 +6026,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
return null return null
}) })
const bulkHasPendingUpdates = selectedBulkUpdateStates.some((state) => state === true) const bulkHasPendingUpdates = selectedBulkUpdateStates.some((state) => state === true)
const bulkHasUnknownTargets = selectedBulkUpdateStates.some((state) => state === null)
const bulkAllTargetsUpToDate = selectedBulkUpdateStates.length > 0 const bulkAllTargetsUpToDate = selectedBulkUpdateStates.length > 0
&& selectedBulkUpdateStates.every((state) => state === false) && selectedBulkUpdateStates.every((state) => state === false)
const toggleBulkChoice = (id: string, checked: boolean) => { 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])) ? Array.from(new Set(["os", ...current.filter((value) => value !== "os"), id]))
: current.filter((value) => value !== 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 pendingBtnCls = getUpdateActionButtonClass(true, false)
const upToDateBtnCls = "bg-green-500/10 hover:bg-green-500/20 border border-green-500/30 text-green-400 hover:text-green-300" const upToDateBtnCls = getUpdateActionButtonClass(false, true)
const neutralBtnCls = "border border-input bg-background text-foreground/80 hover:bg-accent hover:text-accent-foreground"
return ( return (
<> <>
<Card className="border border-border bg-card/50"> <Card className="border border-border bg-card/50">
@@ -5982,14 +6048,19 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
<h3 className="text-sm font-semibold text-foreground">{t("vmLxc.updates.osPackagesTitle")}</h3> <h3 className="text-sm font-semibold text-foreground">{t("vmLxc.updates.osPackagesTitle")}</h3>
<Button <Button
size="sm" variant="outline" className="ml-auto" size="sm" variant="outline" className="ml-auto"
disabled={selectedVM.status !== "running" || osCheckingVmid !== null} disabled={selectedVM.status !== "running" || osCheckingVmid !== null || updatesRefreshing || controlLoading || applyOpen}
title={t("vmLxc.updates.checkReadOnlyHelp")} title={t("vmLxc.updates.refreshCheckHelp")}
onClick={() => checkOsUpdates(selectedVM.vmid)} onClick={() => checkOsUpdates(selectedVM.vmid)}
> >
<RefreshCw className={`h-4 w-4 mr-1.5 ${osCheckingVmid === selectedVM.vmid ? "animate-spin" : ""}`} /> <RefreshCw className={`h-4 w-4 mr-1.5 ${osCheckingVmid === selectedVM.vmid ? "animate-spin" : ""}`} />
{t("vmLxc.updates.checkNow")} {t("vmLxc.updates.checkSystemPackages")}
</Button> </Button>
</div> </div>
{selectedVM.status !== "running" && (
<p className="text-sm text-muted-foreground mb-3">
{t("vmLxc.updates.startBeforeCheck")}
</p>
)}
<div className="text-xs text-muted-foreground mb-3 leading-relaxed"> <div className="text-xs text-muted-foreground mb-3 leading-relaxed">
{t("vmLxc.updates.lastCheckedPrefix")}{" "} {t("vmLxc.updates.lastCheckedPrefix")}{" "}
{uc?.last_check ? new Date(uc.last_check).toLocaleString() : "—"} {uc?.last_check ? new Date(uc.last_check).toLocaleString() : "—"}
@@ -5998,7 +6069,14 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
)} )}
</div> </div>
{osCheckError && <p role="alert" className="text-sm text-amber-400 mb-2">{osCheckError}</p>} {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> <p className="text-sm text-amber-400">{t("vmLxc.updates.checkStaleHelp")}</p>
) : !osUpdateStatusKnown ? ( ) : !osUpdateStatusKnown ? (
<div <div
@@ -6054,23 +6132,28 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
{t("vmLxc.updates.noOsUpdatesPending")} {t("vmLxc.updates.noOsUpdatesPending")}
</div> </div>
)} )}
<div className="mt-3 flex justify-end"> {osUpdateStatusKnown && osCheckingVmid !== selectedVM.vmid && <div className="mt-3 flex justify-end">
{hasOsUpdates ? (
<Button <Button
size="sm" size="sm"
onClick={() => openApplyTerminal(selectedVM.vmid, "os")} disabled={!hasOsUpdates || osCheckingVmid !== null || updatesRefreshing || controlLoading || !!osCheckError}
className={hasOsUpdates onClick={() => openApplyTerminal(selectedVM.vmid, "os", { checkedOs: true })}
? pendingBtnCls className={getUpdateActionButtonClass(hasOsUpdates, osUpdateStatusKnown)}
: osUpdateStatusKnown
? upToDateBtnCls
: neutralBtnCls}
> >
{hasOsUpdates && <ArrowUpCircle className="h-4 w-4 mr-1.5" />} {hasOsUpdates && <ArrowUpCircle className="h-4 w-4 mr-1.5" />}
{!hasOsUpdates && !osUpdateStatusKnown && <RefreshCw 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.applyOsUpdate")
: t("vmLxc.updates.osUpToDate")} : osUpdateStatusKnown
? t("vmLxc.updates.osUpToDate")
: t("vmLxc.updates.checkBeforeUpdate")}
</Button> </Button>
</div> ) : (
<Button asChild size="sm" className={upToDateBtnCls}>
<span role="status">{t("vmLxc.updates.osUpToDate")}</span>
</Button>
)}
</div>}
</div> </div>
{/* Docker has three independent lifecycles: {/* Docker has three independent lifecycles:
@@ -6200,11 +6283,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
dockerRefresh: true, dockerRefresh: true,
}, },
)} )}
className={`${dockerEngineHasUpdate className={`${getUpdateActionButtonClass(dockerEngineHasUpdate, dockerEngineUpToDate)} flex-shrink-0`}
? pendingBtnCls
: dockerEngineUpToDate
? upToDateBtnCls
: neutralBtnCls} flex-shrink-0`}
> >
{dockerEngineHasUpdate && <ArrowUpCircle className="h-4 w-4 mr-1.5" />} {dockerEngineHasUpdate && <ArrowUpCircle className="h-4 w-4 mr-1.5" />}
{!dockerEngineHasUpdate && !dockerEngineUpToDate && <RefreshCw className="h-4 w-4 mr-1.5" />} {!dockerEngineHasUpdate && !dockerEngineUpToDate && <RefreshCw className="h-4 w-4 mr-1.5" />}
@@ -6480,7 +6559,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
</div> </div>
) : helperTracksVersion ? ( ) : helperTracksVersion ? (
<p className="text-xs text-muted-foreground leading-relaxed"> <p className="text-xs text-muted-foreground leading-relaxed">
{t("vmLxc.updates.versionTrackingPending")} {t("vmLxc.updates.versionStatusUnknown")}
</p> </p>
) : helperExists ? ( ) : helperExists ? (
<p className="text-xs text-muted-foreground leading-relaxed"> <p className="text-xs text-muted-foreground leading-relaxed">
@@ -6503,7 +6582,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
// still force the helper's own // still force the helper's own
// update script. // update script.
const noState = !hasUpd && !upToD 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") const label = hasUpd ? t("vmLxc.updates.applyUpdate") : upToD ? t("vmLxc.updates.upToDate") : t("vmLxc.updates.runUpdater")
return ( return (
<div className="mt-3 flex justify-end"> <div className="mt-3 flex justify-end">
@@ -6639,7 +6718,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
</div> </div>
) : null ) : null
) : ( ) : (
<div>{t("vmLxc.updates.versionTrackingPendingShort")}</div> <div>{t("vmLxc.updates.versionStatusUnknown")}</div>
)} )}
</div> </div>
) : tracksVersion ? ( ) : tracksVersion ? (
@@ -6651,7 +6730,9 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
<a className="text-blue-400 hover:underline" href={`#docker-images-${selectedVM.vmid}`}> <a className="text-blue-400 hover:underline" href={`#docker-images-${selectedVM.vmid}`}>
{t("vmLxc.updates.dockerImagesSubheading")} {t("vmLxc.updates.dockerImagesSubheading")}
</a></> </a></>
: t("vmLxc.updates.versionTrackingPendingShort")} : aw.installed_via === "manual"
? t("vmLxc.updates.installedVersionUnverified")
: t("vmLxc.updates.versionStatusUnknown")}
</div> </div>
) : hasCmd ? ( ) : hasCmd ? (
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
@@ -6673,7 +6754,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
targetLabels: [aw.name || t("vmLxc.updates.applicationDefaultName")], 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 && <ArrowUpCircle className="h-4 w-4 mr-1.5" />}
{!hasUpdate && !upToDate && <RefreshCw className="h-4 w-4 mr-1.5" />} {!hasUpdate && !upToDate && <RefreshCw className="h-4 w-4 mr-1.5" />}
@@ -6681,7 +6762,9 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
? t("vmLxc.updates.applyUpdate") ? t("vmLxc.updates.applyUpdate")
: upToDate : upToDate
? t("vmLxc.updates.upToDate") ? t("vmLxc.updates.upToDate")
: t("vmLxc.updates.runUpdater")} : aw.update_method === "helper"
? t("vmLxc.updates.runUpdater")
: t("vmLxc.updates.runCustomCommand")}
</Button> </Button>
</div> </div>
) : ( ) : (
@@ -6719,6 +6802,11 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
<p className="text-xs text-muted-foreground mt-2 leading-relaxed"> <p className="text-xs text-muted-foreground mt-2 leading-relaxed">
{t("vmLxc.bulkUpdate.description")} {t("vmLxc.bulkUpdate.description")}
</p> </p>
{dockerRegistered && (
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
{t("vmLxc.bulkUpdate.osDockerScope")}
</p>
)}
</div> </div>
{!bulkEditMode && ( {!bulkEditMode && (
<button <button
@@ -6868,19 +6956,20 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
<div className="text-sm text-muted-foreground break-words"> <div className="text-sm text-muted-foreground break-words">
{selectedBulkLabels.join(" · ")} {selectedBulkLabels.join(" · ")}
</div> </div>
{bulkHasUnknownTargets && (
<p className="text-xs text-muted-foreground leading-relaxed">
{t("vmLxc.bulkUpdate.unverifiedTargetsHelp")}
</p>
)}
<div className="flex justify-end"> <div className="flex justify-end">
<Button <Button
size="sm" size="sm"
onClick={() => applyBulkUpdate(selectedVM.vmid)} onClick={() => applyBulkUpdate(selectedVM.vmid)}
disabled={bulkApplying || ( disabled={bulkApplying || osCheckingVmid !== null || updatesRefreshing || !osUpdateStatusKnown || bulkAllTargetsUpToDate || (
bulkTargets.some((target) => target.startsWith("docker-unit:")) bulkTargets.some((target) => target.startsWith("docker-unit:"))
&& (dockerInventoryRefreshing || !dockerInventoryAvailable) && (dockerInventoryRefreshing || !dockerInventoryAvailable)
)} )}
className={bulkHasPendingUpdates className={getUpdateActionButtonClass(bulkHasPendingUpdates, bulkAllTargetsUpToDate)}
? pendingBtnCls
: bulkAllTargetsUpToDate
? upToDateBtnCls
: neutralBtnCls}
> >
{bulkApplying {bulkApplying
? <Loader2 className="h-4 w-4 mr-1.5 animate-spin" /> ? <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />
@@ -7916,7 +8005,9 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
setApplyVmid(null) setApplyVmid(null)
}} }}
onComplete={handleApplyComplete} 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" scriptName="lxc_apply_updates"
title={t("vmLxc.updates.terminalTitle", { vmid: applyVmid })} title={t("vmLxc.updates.terminalTitle", { vmid: applyVmid })}
completedSuccessfullyMessage={t("vmLxc.updates.terminalCompletedSuccessfully")} completedSuccessfullyMessage={t("vmLxc.updates.terminalCompletedSuccessfully")}
+26
View File
@@ -4,6 +4,17 @@ interface UpdateObservation {
last_check?: string | null last_check?: string | null
stale?: boolean stale?: boolean
stale_after_seconds?: number 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 { 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> { export function checkLxcUpdates<T>(vmid: number): Promise<T> {
return fetchApi<T>(`/api/vms/${vmid}/updates/check`, { method: "POST" }) return fetchApi<T>(`/api/vms/${vmid}/updates/check`, { method: "POST" })
} }
// Explicit action: refresh indexes and check an already-running 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": { "updates": {
"osPackagesTitle": "OS packages", "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 while the container is running. Nothing is installed.",
"notCheckedYet": "Updates have not been checked yet.",
"checkStillRunning": "The check is still running. Its result will be saved 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.", "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.", "checkCooldown": "Check already running or recently requested. Try again in {seconds} seconds.",
"checkStale": "Check outdated", "checkStale": "Check outdated",
@@ -1380,6 +1387,9 @@
"applyUpdate": "Apply update", "applyUpdate": "Apply update",
"upToDate": "Up to date", "upToDate": "Up to date",
"runUpdater": "Run updater", "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}", "terminalTitle": "Apply updates — CT {vmid}",
"terminalDescriptionOs": "Applying OS package updates inside the container...", "terminalDescriptionOs": "Applying OS package updates inside the container...",
"terminalDescriptionApp": "Running the application updater inside the container...", "terminalDescriptionApp": "Running the application updater inside the container...",
@@ -1483,9 +1493,12 @@
"notConfigured": "Not configured.", "notConfigured": "Not configured.",
"configure": "Configure", "configure": "Configure",
"edit": "Edit", "edit": "Edit",
"apply": "Apply updates", "apply": "Start bulk update",
"osTarget": "OS", "osTarget": "OS",
"osRequired": "Required for a bulk update.", "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}", "includesDependencies": "Also includes declared dependencies: {names}",
"noMethod": "No executable update method is available.", "noMethod": "No executable update method is available.",
"staleTarget": "This selection is no longer available. Remove it before saving.", "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.", "updatedWithDockerImage": "Aktualizuje sa spolu so svojím obrazom Docker.",
"dockerBindingUnavailable": "Zatiaľ nie je priradený žiadny obraz. Skontrolujte názov kontajnera a obnovte inventár obrazov.", "dockerBindingUnavailable": "Zatiaľ nie je priradený žiadny obraz. Skontrolujte názov kontajnera a obnovte inventár obrazov.",
"osPackagesTitle": "Balíky systému", "osPackagesTitle": "Balíky systému",
"checkNow": "Skontrolovať teraz", "checkNow": "Skontrolovať aktualizácie",
"checkSystemPackages": "Skontrolovať balíky systému",
"checkInProgress": "Prebieha kontrola",
"refreshCheckHelp": "Obnoví zoznamy balíkov a skontroluje čakajúce aktualizácie, kým je kontajner spustený. Nič nenainštaluje.",
"notCheckedYet": "Aktualizácie zatiaľ nie sú skontrolované.",
"checkStillRunning": "Kontrola ešte prebieha. Po dokončení sa uloží jej výsledok.",
"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.", "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.", "checkCooldown": "Kontrola už prebieha alebo sa práve vykonala. Skúste znova o {seconds} sekúnd.",
"checkStale": "Zastaraná kontrola", "checkStale": "Zastaraná kontrola",
@@ -1382,6 +1389,9 @@
"applyUpdate": "Aktualizovať", "applyUpdate": "Aktualizovať",
"upToDate": "Aktuálne", "upToDate": "Aktuálne",
"runUpdater": "Spustiť aktualizáciu", "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}", "terminalTitle": "Aktualizácia — CT {vmid}",
"terminalDescriptionOs": "Aktualizujem balíky systému v kontajneri...", "terminalDescriptionOs": "Aktualizujem balíky systému v kontajneri...",
"terminalDescriptionApp": "Spúšťam aktualizáciu aplikácie v kontajneri...", "terminalDescriptionApp": "Spúšťam aktualizáciu aplikácie v kontajneri...",
@@ -1483,9 +1493,12 @@
"notConfigured": "Nenakonfigurované.", "notConfigured": "Nenakonfigurované.",
"configure": "Nastaviť", "configure": "Nastaviť",
"edit": "Upraviť", "edit": "Upraviť",
"apply": "Použiť aktualizácie", "apply": "Spustiť hromadnú aktualizáciu",
"osTarget": "Operačný systém", "osTarget": "Operačný systém",
"osRequired": "Povinné pre hromadnú aktualizáciu.", "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}", "includesDependencies": "Zahŕňa aj deklarované závislosti: {names}",
"noMethod": "Nie je dostupná žiadna spustiteľná metóda aktualizácie.", "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.", "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/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_observation.py" "$APP_DIR/usr/bin/"
cp "$SCRIPT_DIR/lxc_update_finalization.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/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/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" 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'), 'managed_oci_app': it.get('_managed_oci_app'),
'os_family': it.get('_os_family'), '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 return out
@@ -13669,19 +13674,57 @@ def api_lxc_updates_check(vmid):
'retry_after': retry}), 429, {'Retry-After': str(retry)} 'retry_after': retry}), 429, {'Retry-After': str(retry)}
_lxc_update_check_attempts[vmid] = now _lxc_update_check_attempts[vmid] = now
_lxc_update_check_inflight.add(vmid) _lxc_update_check_inflight.add(vmid)
operation_lock = None
try: try:
import lxc_check_job
operation_lock = lxc_check_job.acquire(vmid)
item = managed_installs.refresh_lxc(vmid, refresh_package_cache=False) item = managed_installs.refresh_lxc(vmid, refresh_package_cache=False)
if item is None: if item is None:
return jsonify({'error': 'No supported running LXC detected'}), 409 return jsonify({'error': 'No supported running LXC detected'}), 409
_vm_cache_invalidate(vmid, _lxc_os_info_cache) _vm_cache_invalidate(vmid, _lxc_os_info_cache)
return jsonify({'vmid': vmid, 'update_check': _get_lxc_update_status_map().get(str(vmid))}) 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: except Exception as exc:
return jsonify({'error': str(exc)}), 500 return jsonify({'error': str(exc)}), 500
finally: finally:
if operation_lock is not None:
operation_lock.close()
with _lxc_update_check_lock: with _lxc_update_check_lock:
_lxc_update_check_inflight.discard(vmid) _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 for a running CT; never installs."""
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 ──────────────────────────────────────────── # ─── LXC Update Detection toggle ────────────────────────────────────────────
# Dedicated toggle so the operator can opt out of the per-CT `pct exec apt # Dedicated toggle so the operator can opt out of the per-CT `pct exec apt
# list --upgradable` scan entirely. The Notifications section keeps its own # list --upgradable` scan entirely. The Notifications section keeps its own
@@ -14258,6 +14301,7 @@ def api_vm_apps_dismiss(vmid):
# ─── LXC update finalization ──────────────────────────────────────────────── # ─── LXC update finalization ────────────────────────────────────────────────
_LXC_APPLY_UPDATES_SCRIPT = "/usr/local/share/proxmenux/scripts/lxc/apply_updates.sh" _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_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}$') _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 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): 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 return
run_id = _normalise_lxc_update_run_id(params.get('RUN_ID'), create=False) run_id = _normalise_lxc_update_run_id(params.get('RUN_ID'), create=False)
if not run_id: if not run_id:
+213
View File
@@ -0,0 +1,213 @@
"""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
for an already-running CT; package indexes are never refreshed by starting one.
"""
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 _invalid_marker(vmid):
return LOCK_DIR / f'proxmenux-lxc-check-invalid-{int(vmid)}'
def _acquire_invalid_marker_guard(vmid):
path = LOCK_DIR / f'proxmenux-lxc-check-invalid-{int(vmid)}.guard'
handle = os.fdopen(os.open(path, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600), 'r+')
try:
fcntl.flock(handle, fcntl.LOCK_EX)
return handle
except BaseException:
handle.close()
raise
def _mark_check_invalid(vmid):
handle = _acquire_invalid_marker_guard(vmid)
try:
_invalid_marker(vmid).touch(mode=0o600)
finally:
handle.close()
def clear_invalid_marker(vmid, *, check_started_ns=None):
"""Clear an obsolete failed-check marker without hiding a newer run."""
try:
handle = _acquire_invalid_marker_guard(vmid)
except OSError:
return False
try:
marker = _invalid_marker(vmid)
try:
if (check_started_ns is not None
and marker.stat().st_mtime_ns > check_started_ns):
return False
marker.unlink()
return True
except FileNotFoundError:
# A concurrent successful check may already have published the
# valid state; there is no stale marker left to clear.
return True
except OSError:
return False
finally:
handle.close()
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):
error = None
check_started = False
try:
_mark_check_invalid(vmid)
guard(vmid)
if status(vmid) != 'running':
raise RuntimeError('Container is stopped; start it first')
check_started = True
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)
# A failed package operation must block installation. A state change
# before the worker runs did not check or alter packages, so keep the
# last verified observation but leave the invalid marker in place.
if check_started:
try:
managed_installs.store_lxc_update_observation(vmid, None)
except Exception:
error += '; package result could not be persisted'
finally:
if not error:
if not clear_invalid_marker(vmid):
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)
if status(vmid) != 'running':
raise RuntimeError('Container is stopped; start it first')
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'}
+29 -1
View File
@@ -1796,6 +1796,22 @@ def _store_update_result(item: dict, result: dict) -> None:
item["update_check"][extra_key] = result[extra_key] item["update_check"][extra_key] = result[extra_key]
def _clear_lxc_check_invalid_marker(vmid: int, check_started_ns: int) -> bool:
"""Publish a persisted newer LXC check to the installation gate.
The marker is created by the explicit refresh worker. Its own lock also
protects this cleanup against a later refresh starting while a scheduled
or lifecycle check is still completing.
"""
try:
import lxc_check_job
return lxc_check_job.clear_invalid_marker(
vmid, check_started_ns=check_started_ns)
except Exception as exc:
print(f'[ProxMenux] could not clear stale LXC check marker for {vmid}: {exc}')
return False
def refresh_oci_app(app_id: str) -> Optional[dict]: def refresh_oci_app(app_id: str) -> Optional[dict]:
"""Read one OCI-managed application again and store what it reports. """Read one OCI-managed application again and store what it reports.
@@ -1834,6 +1850,7 @@ def check_for_updates(force: bool = False) -> list[dict]:
_nvidia_cache["fetched_at"] = 0 _nvidia_cache["fetched_at"] = 0
updates_available: list[dict] = [] updates_available: list[dict] = []
successful_lxc_checks: list[tuple[int, int]] = []
with _lock: with _lock:
reg = _read_registry() reg = _read_registry()
items = reg.get("items", []) items = reg.get("items", [])
@@ -1854,6 +1871,7 @@ def check_for_updates(force: bool = False) -> list[dict]:
checker = _CHECKERS.get(it.get("type")) checker = _CHECKERS.get(it.get("type"))
if not checker: if not checker:
continue continue
check_started_ns = time.time_ns() if it.get('type') == 'lxc' else None
try: try:
result = checker(it) result = checker(it)
except Exception as e: except Exception as e:
@@ -1863,13 +1881,20 @@ def check_for_updates(force: bool = False) -> list[dict]:
"last_check": _now_iso(), "error": str(e)} "last_check": _now_iso(), "error": str(e)}
_store_update_result(it, result) _store_update_result(it, result)
if check_started_ns is not None and result.get('error') is None:
try:
successful_lxc_checks.append((int(it.get('_vmid')), check_started_ns))
except (TypeError, ValueError):
pass
if it["update_check"]["available"]: if it["update_check"]["available"]:
updates_available.append(it) updates_available.append(it)
reg["items"] = items reg["items"] = items
reg["last_check_run"] = _now_iso() reg["last_check_run"] = _now_iso()
_write_registry(reg) if _write_registry(reg):
for vmid, check_started_ns in successful_lxc_checks:
_clear_lxc_check_invalid_marker(vmid, check_started_ns)
return updates_available return updates_available
@@ -1945,6 +1970,7 @@ def refresh_lxc(vmid: int, *, refresh_package_cache: bool = True) -> Optional[di
else: else:
_merge_detected_entry(target, entry, now) _merge_detected_entry(target, entry, now)
check_started_ns = time.time_ns()
try: try:
result = (_check_lxc_updates(target) if refresh_package_cache else result = (_check_lxc_updates(target) if refresh_package_cache else
_check_lxc_updates(target, refresh_package_cache=False)) _check_lxc_updates(target, refresh_package_cache=False))
@@ -1961,4 +1987,6 @@ def refresh_lxc(vmid: int, *, refresh_package_cache: bool = True) -> Optional[di
reg["last_targeted_refresh"] = now reg["last_targeted_refresh"] = now
if not _write_registry(reg): if not _write_registry(reg):
raise OSError("could not persist LXC update check") raise OSError("could not persist LXC update check")
if result.get('error') is None:
_clear_lxc_check_invalid_marker(target_vmid, check_started_ns)
return dict(target) return dict(target)
+14
View File
@@ -63,6 +63,13 @@ ALLOW_HELPER_WITH_CUSTOM="${ALLOW_HELPER_WITH_CUSTOM:-0}"
DOCKER_STANDALONE_TARGETS="${DOCKER_STANDALONE_TARGETS:-}" DOCKER_STANDALONE_TARGETS="${DOCKER_STANDALONE_TARGETS:-}"
UPDATE_DOCKER_ENGINE="${UPDATE_DOCKER_ENGINE:-0}" 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 if [[ ! "$VMID" =~ ^[1-9][0-9]*$ ]]; then
echo "ERROR: VMID must be a positive integer." >&2 echo "ERROR: VMID must be a positive integer." >&2
exit 1 exit 1
@@ -123,6 +130,13 @@ if ! flock -n 9; then
exit 7 exit 7
fi 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) STARTED_AT=$(date -Iseconds)
NODE=$(hostname) NODE=$(hostname)
echo "=== ProxMenux LXC update — CT $VMID on $NODE ===" 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' cat >"$MOCK_BIN/python3" <<'EOF'
#!/bin/bash #!/bin/bash
cat >/dev/null cat >/dev/null
if [[ "${1:-}" == '-c' ]]; then
[[ "${MOCK_CHECK_FAIL:-0}" != '1' ]]
exit $?
fi
if [[ "${2:-}" == 'protect-update-command' ]]; then if [[ "${2:-}" == 'protect-update-command' ]]; then
printf '%s' "$UPDATE_COMMAND" printf '%s' "$UPDATE_COMMAND"
exit 0 exit 0
@@ -82,6 +86,7 @@ EOF
cat >"$MOCK_BIN/vzdump" <<'EOF' cat >"$MOCK_BIN/vzdump" <<'EOF'
#!/bin/bash #!/bin/bash
echo "vzdump $*" >>"$MOCK_LOG" echo "vzdump $*" >>"$MOCK_LOG"
[[ "${MOCK_BACKUP_FAIL:-0}" != '1' ]]
EOF EOF
chmod +x "$MOCK_BIN"/* 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" [[ $CASE_RC -eq 7 ]] || fail "locked expected 7, got $CASE_RC"
[[ $(count CUSTOM_EXEC) -eq 0 ]] || fail "locked run executed an updater" [[ $(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" echo "apply_updates.sh: all deterministic tests passed"
+309
View File
@@ -0,0 +1,309 @@
"""Real job, Flask routes and shared flock; guest transport is isolated."""
import ast
import datetime
import os
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_is_rejected_before_a_job_or_guest_mutation(self):
with patch.object(job, 'command', side_effect=self.transport):
with self.assertRaisesRegex(RuntimeError, 'start it first'):
job.start(100)
self.assertEqual(self.state, 'stopped')
self.assertEqual(self.calls, ['status'])
self.assertEqual(job._jobs, {})
self.assertEqual(job._attempts, {})
self.assertFalse((self.folder / 'proxmenux-lxc-check-invalid-100').exists())
reservation = job.acquire(100)
reservation.close()
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')
self.assertFalse((self.folder / 'proxmenux-lxc-check-invalid-100').exists())
def test_successful_running_check_clears_an_older_failure_marker(self):
self.state = 'running'
(self.folder / 'proxmenux-lxc-check-invalid-100').touch()
result, _, _ = self.run_check()
self.assertEqual(result['state'], 'complete')
self.assertFalse((self.folder / 'proxmenux-lxc-check-invalid-100').exists())
def test_marker_cleanup_failure_reports_failure_and_releases_the_operation_lock(self):
self.state = 'running'
with patch.object(job, 'clear_invalid_marker', return_value=False):
result, _, _ = self.run_check()
self.assertEqual(result['state'], 'failed')
self.assertIn('could not be published', result['error'])
reservation = job.acquire(100)
reservation.close()
def test_marker_created_after_a_check_started_is_not_cleared(self):
marker = self.folder / 'proxmenux-lxc-check-invalid-100'
marker.touch()
os.utime(marker, ns=(2_000, 2_000))
self.assertFalse(job.clear_invalid_marker(100, check_started_ns=1_000))
self.assertTrue(marker.exists())
def test_already_cleared_marker_is_a_successful_publish(self):
self.assertTrue(job.clear_invalid_marker(100))
def test_index_read_timeout_and_persistence_errors_keep_running_guest_running(self):
self.state = 'running'
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, 'running')
result, _, _ = self.run_check(persistence_error=OSError('disk full'))
self.assertEqual(result['state'], 'failed')
self.assertEqual(self.state, 'running')
self.assertIn('could not be persisted', result['error'])
def test_unexpected_stopped_worker_never_starts_or_probes_the_guest(self):
result, capture, store = self.run_check()
self.assertEqual(result['state'], 'failed')
self.assertEqual(self.state, 'stopped')
self.assertEqual(self.calls, ['status'])
capture.assert_not_called()
store.assert_not_called()
self.assertTrue((self.folder / 'proxmenux-lxc-check-invalid-100').exists())
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_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, 'status', return_value='running'), \
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), \
patch.object(job, 'status', return_value='running'):
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 = 'running'
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, 'running')
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') 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') 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() notification = Mock()
ns = dict(os=os, time=time, re=__import__('re'), notification_manager=notification, ns = dict(os=os, time=time, re=__import__('re'), notification_manager=notification,
_LXC_APPLY_UPDATES_SCRIPT=str(ROOT / 'scripts/lxc/apply_updates.sh'), _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_run_id=lambda value, **_: value,
_normalise_lxc_update_targets=lambda values, _: values, _normalise_lxc_update_targets=lambda values, _: values,
_normalise_lxc_update_labels=lambda values: values, _normalise_lxc_update_labels=lambda values: values,
+38
View File
@@ -22,6 +22,7 @@ from flask import Flask, jsonify
ROOT = Path(__file__).resolve().parents[2] ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "AppImage/scripts")) sys.path.insert(0, str(ROOT / "AppImage/scripts"))
import managed_installs as managed import managed_installs as managed
import lxc_check_job
SERVER = ROOT / "AppImage/scripts/flask_server.py" SERVER = ROOT / "AppImage/scripts/flask_server.py"
FUNCTIONS = { FUNCTIONS = {
@@ -50,6 +51,9 @@ class ObservedStateRoutesTests(unittest.TestCase):
def setUp(self): def setUp(self):
self.temp = tempfile.TemporaryDirectory() self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup) 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 = Path(self.temp.name) / "100.conf"
self.conf.write_text("onboot: 0\nnet0: name=eth0,ip=dhcp\ntags: old\n") 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)) 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) self.assertEqual(response.status_code, 200, response.json)
return response.json[0] return response.json[0]
def test_dhcp_change_after_ttl_without_lifecycle_and_api_compatibility(self): def test_dhcp_change_after_ttl_without_lifecycle_and_api_compatibility(self):
first = self.row() first = self.row()
self.assertEqual(first["ip"], "10.100.100.222") self.assertEqual(first["ip"], "10.100.100.222")
@@ -359,6 +365,35 @@ class ObservedStateRoutesTests(unittest.TestCase):
self.assertEqual(managed.get_registry()["items"][0]["_os_family"], "debian") self.assertEqual(managed.get_registry()["items"][0]["_os_family"], "debian")
self.assertEqual(self.row()["update_check"]["count"], 0) self.assertEqual(self.row()["update_check"]["count"], 0)
def test_daily_successful_check_clears_an_older_failed_check_marker(self):
marker = Path(self.temp.name) / 'proxmenux-lxc-check-invalid-100'
marker.touch()
with patch.object(managed, '_refresh_lxc_pkg_cache_if_stale', return_value={}):
managed.check_for_updates()
self.assertFalse(marker.exists())
self.assertIsNone(managed.get_registry()['items'][0]['update_check']['error'])
def test_daily_check_keeps_a_marker_created_after_it_started(self):
marker = Path(self.temp.name) / 'proxmenux-lxc-check-invalid-100'
marker.touch()
os.utime(marker, ns=(2_000, 2_000))
with patch.object(managed.time, 'time_ns', return_value=1_000):
managed.check_for_updates()
self.assertTrue(marker.exists())
def test_daily_check_does_not_clear_marker_when_result_cannot_be_persisted(self):
marker = Path(self.temp.name) / 'proxmenux-lxc-check-invalid-100'
marker.touch()
with patch.object(managed, '_write_registry', return_value=False):
managed.check_for_updates()
self.assertTrue(marker.exists())
def test_existing_global_refresh_route_keeps_its_collector_contract(self): def test_existing_global_refresh_route_keeps_its_collector_contract(self):
with patch.object(managed, "_DETECTORS", [managed._detect_lxc_containers]), \ with patch.object(managed, "_DETECTORS", [managed._detect_lxc_containers]), \
patch.object(managed, "_refresh_lxc_pkg_cache_if_stale", return_value={}) as refresh: patch.object(managed, "_refresh_lxc_pkg_cache_if_stale", return_value={}) as refresh:
@@ -368,10 +403,13 @@ class ObservedStateRoutesTests(unittest.TestCase):
self.assertEqual(managed.get_registry()["items"][0]["_os_family"], "debian") self.assertEqual(managed.get_registry()["items"][0]["_os_family"], "debian")
def test_existing_lifecycle_refresh_keeps_index_refresh_default(self): def test_existing_lifecycle_refresh_keeps_index_refresh_default(self):
marker = Path(self.temp.name) / 'proxmenux-lxc-check-invalid-100'
marker.touch()
with patch.object(managed, "_refresh_lxc_pkg_cache_if_stale", return_value={}) as refresh: with patch.object(managed, "_refresh_lxc_pkg_cache_if_stale", return_value={}) as refresh:
item = managed.refresh_lxc(100) item = managed.refresh_lxc(100)
refresh.assert_called_once_with("100", "debian") refresh.assert_called_once_with("100", "debian")
self.assertEqual(item["update_check"]["_count"], 0) self.assertEqual(item["update_check"]["_count"], 0)
self.assertFalse(marker.exists())
def test_other_lxc_has_independent_ip_cache_and_manual_rate_limit(self): def test_other_lxc_has_independent_ip_cache_and_manual_rate_limit(self):
conf200 = Path(self.temp.name) / "200.conf" conf200 = Path(self.temp.name) / "200.conf"
+336 -10
View File
@@ -19,6 +19,7 @@ const requests = []
const helpers = compile(fs.readFileSync(path.join(root, "lib/lxc-update-observation.ts"), "utf8"), { const helpers = compile(fs.readFileSync(path.join(root, "lib/lxc-update-observation.ts"), "utf8"), {
exports: {}, require: () => ({ fetchApi: async (...args) => { requests.push(args); return {} } }), 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") assert.equal(requests.length, 0, "loading helpers never schedules a package check")
const now = Date.parse("2026-10-13T12:00:00Z") const now = Date.parse("2026-10-13T12:00:00Z")
const recent = { last_check: "2026-10-13T11:59: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 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) const tree = ts.createSourceFile("vm.tsx", source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
assert.equal(tree.parseDiagnostics.length, 0) 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 definitions = []
const preferenceDefinitions = []
const status = [] const status = []
let detailEffect let detailEffect
let resultEffect
let resultTimeout
let osApplyButton
let bulkApplyButton
let osSection
function walk(node) { 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" if (ts.isCallExpression(node) && node.expression.getText(tree) === "useEffect"
&& node.arguments[1]?.getText(tree) === "[selectedVM?.vmid]") { && node.arguments[1]?.getText(tree) === "[selectedVM?.vmid]") {
detailEffect = node.arguments[0].getText(tree) 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)) { if (ts.isVariableDeclaration(node)) {
const name = node.name.getText(tree) const name = node.name.getText(tree)
if (wanted.has(name)) definitions.push(`const ${node.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)) if (["osUpdateStale", "osUpdateStatusKnown", "hasOsUpdates"].includes(name))
status.push(`const ${node.getText(tree)};`) status.push(`const ${node.getText(tree)};`)
} }
ts.forEachChild(node, walk) ts.forEachChild(node, walk)
} }
walk(tree) walk(tree)
assert.equal(definitions.length, 3) assert.equal(definitions.length, 5)
assert.equal(status.length, 3) assert.equal(status.length, 3)
assert.ok(detailEffect) assert.ok(detailEffect)
let checking = null let checking = null
@@ -73,27 +100,61 @@ const context = {
Badge: "Badge", Clock: "Clock", ArrowUpCircle: "Arrow", ChevronRight: "Chevron", Badge: "Badge", Clock: "Clock", ArrowUpCircle: "Arrow", ChevronRight: "Chevron",
t: (key, args) => key + (args ? JSON.stringify(args) : ""), t: (key, args) => key + (args ? JSON.stringify(args) : ""),
isLxcUpdateStale: o => helpers.isLxcUpdateStale(o, now), 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 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" } } }, fetchApi: async endpoint => { requests.push([endpoint]); return { config: { onboot: 1 }, os_info: { id: "debian" } } },
mutate: async () => { polls++ }, mutate: async () => { polls++ },
selectedVMRef: { current: { vmid: 100 } }, selectedVMRef: { current: { vmid: 100 } },
osCheckRequestRef: { current: null },
selectedVM: { vmid: 100 }, selectedVM: { vmid: 100 },
setInterval: (callback, delay) => { context.detailTimer = { callback, delay }; return 1 }, setInterval: (callback, delay) => { context.detailTimer = { callback, delay }; return 1 },
clearInterval: id => { context.clearedTimer = id }, 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 }, setVMDetails: value => { details = value },
setOsCheckingVmid: value => { checking = value }, setOsCheckingVmid: value => { checking = value },
setOsCheckError: value => { displayedError = 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") + ` const ui = compile(definitions.join("\n") + `
exports.check = checkOsUpdates; exports.check = checkOsUpdates;
exports.aggregate = getAggregateUpdateCheck; exports.aggregate = getAggregateUpdateCheck;
exports.badge = renderLxcUpdateBadge; exports.badge = renderLxcUpdateBadge;
exports.osStatus = (uc) => { ${status.join("\n")} return { osUpdateStatusKnown, hasOsUpdates }; }; exports.osStatus = (uc) => { ${status.join("\n")} return { osUpdateStatusKnown, hasOsUpdates }; };
exports.revalidateDetails = ${detailEffect}; 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) `, context)
const legacy = { last_check: "2026-10-12T00:00:00Z", available: true, count: 51, stale: true } const legacy = { last_check: "2026-10-12T00:00:00Z", available: true, count: 51, stale: true }
assert.equal(ui.osStatus(legacy).osUpdateStatusKnown, false, "old count must not claim live OS status") assert.equal(ui.osStatus(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: true, count: 2 }).props.children[1], 2)
assert.equal(ui.badge({ ...recent, available: false, count: 0 }), null) assert.equal(ui.badge({ ...recent, available: false, count: 0 }), null)
assert.equal(requests.length, 0) 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 () => { ;(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) 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[0][1].method, "POST")
assert.equal(requests[1][0], "/api/vms/100") 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(details.os_info.id, "debian")
assert.equal(checking, null) 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 const before = requests.length
errorToThrow = { status: 429, body: { retry_after: 60 } } errorToThrow = { status: 429, body: { retry_after: 60 } }
await ui.check(100) await ui.check(100)
assert.match(displayedError, /checkCooldown.*60/) assert.match(displayedError, /checkCooldown.*60/)
assert.equal(checking, null) assert.equal(checking, null)
assert.equal(requests.length, before) assert.equal(requests.length, before)
assert.equal(polls, 1) assert.equal(polls, 2)
// Switching guests while a request runs must not replace another modal. // Switching guests while a request runs must not replace another modal.
errorToThrow = null errorToThrow = null
context.selectedVMRef.current = { vmid: 200 } context.selectedVMRef.current = { vmid: 200 }
@@ -143,9 +467,11 @@ assert.equal(requests.length, 0)
assert.equal(context.clearedTimer, 1) assert.equal(context.clearedTimer, 1)
for (const locale of ["en", "sk"]) { for (const locale of ["en", "sk"]) {
const messages = JSON.parse(fs.readFileSync(path.join(root, `messages/${locale}/common.json`))).vmLxc.updates 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.ok(messages[key]?.trim())
assert.match(messages.checkCooldown, /\{seconds\}/) 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 }) })().catch(error => { console.error(error); process.exitCode = 1 })
+132
View File
@@ -0,0 +1,132 @@
// 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, status: 'stopped'}, 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.equal(ui.check().props.disabled, true, 'a stopped CT must not start an index refresh from the check button')
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')
}
assert.match(source, /t\("vmLxc\.updates\.startBeforeCheck"\)/,
'the Updates tab must explain why checking a stopped CT is disabled')
;(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})