mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-01 10:22:08 +00:00
Merge pull request #448 from Vaso73/fix/lxc-package-check-install
fix: separate LXC package checks from installation
This commit is contained in:
@@ -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"),
|
||||||
|
|||||||
@@ -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")}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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'}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
|||||||
@@ -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})
|
||||||
Reference in New Issue
Block a user