mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-02 10:52:22 +00:00
fix: separate LXC package checks from installation
This commit is contained in:
@@ -4,6 +4,7 @@ import type React from "react"
|
||||
|
||||
import { useState, useMemo, useEffect, useRef } from "react"
|
||||
import { fetchLxcApps, getLxcAppsCached, invalidateLxcApps, seedLxcAppsCache, setLxcAppsCached, syncLxcAppsState } from "../lib/lxc-apps-cache"
|
||||
import { getUpdateActionButtonClass } from "../lib/update-action-style"
|
||||
import { parseTags, stringifyTags, tagToColor } from "../lib/pve-tag-color"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
|
||||
import { Badge } from "./ui/badge"
|
||||
@@ -28,7 +29,7 @@ import { formatStorage } from "../lib/utils"
|
||||
import { formatNetworkTraffic, getNetworkUnit } from "../lib/format-network"
|
||||
import { fetchApi } from "../lib/api-config"
|
||||
import { matchesVmSearch, normalizeVmSearchValue } from "../lib/vm-search"
|
||||
import { checkLxcUpdates, isLxcUpdateStale } from "../lib/lxc-update-observation"
|
||||
import { refreshCheckLxcUpdates, getLxcOsUpdateState, isLxcUpdateStale } from "../lib/lxc-update-observation"
|
||||
import DOMPurify from "dompurify"
|
||||
import { marked } from "marked"
|
||||
import { getCountFormKey, useI18n, useT } from "@/lib/i18n/provider"
|
||||
@@ -917,6 +918,7 @@ export function VirtualMachines() {
|
||||
// (or after a 15 s safety timeout).
|
||||
const [updatesRefreshing, setUpdatesRefreshing] = useState(false)
|
||||
const [osCheckingVmid, setOsCheckingVmid] = useState<number | null>(null)
|
||||
const osCheckRequestRef = useRef<number | null>(null)
|
||||
const [osCheckError, setOsCheckError] = useState<string | null>(null)
|
||||
const [updatesResult, setUpdatesResult] = useState<{ pendingAfter: number; appliedCount: number } | null>(null)
|
||||
const [updatesBaselineCount, setUpdatesBaselineCount] = useState<number | null>(null)
|
||||
@@ -1231,6 +1233,12 @@ export function VirtualMachines() {
|
||||
useEffect(() => {
|
||||
if (!updatesRefreshing) return
|
||||
if (!selectedVM) return
|
||||
if (!selectedVM.update_check || selectedVM.update_check.error) {
|
||||
setUpdatesResult(null)
|
||||
setUpdatesRefreshing(false)
|
||||
setUpdatesBaselineCount(null)
|
||||
return
|
||||
}
|
||||
const currentCount = selectedVM.update_check?.count ?? 0
|
||||
if (updatesBaselineCount !== null && currentCount !== updatesBaselineCount) {
|
||||
const applied = Math.max(0, updatesBaselineCount - currentCount)
|
||||
@@ -1250,7 +1258,8 @@ export function VirtualMachines() {
|
||||
const safety = setTimeout(() => {
|
||||
const currentCount = selectedVMRef.current?.update_check?.count ?? 0
|
||||
const baseline = updatesBaselineCountRef.current
|
||||
setUpdatesResult({
|
||||
const observation = selectedVMRef.current?.update_check
|
||||
setUpdatesResult(!observation || observation.error ? null : {
|
||||
pendingAfter: currentCount,
|
||||
appliedCount: Math.max(0, (baseline ?? 0) - currentCount),
|
||||
})
|
||||
@@ -1983,6 +1992,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
const [applyRestart, setApplyRestart] = useState(false)
|
||||
const [applyStartedAt, setApplyStartedAt] = useState<number>(0)
|
||||
const [applyRunId, setApplyRunId] = useState<string>("")
|
||||
const [applyCheckedOs, setApplyCheckedOs] = useState(false)
|
||||
const [applyTargetIds, setApplyTargetIds] = useState<string[]>([])
|
||||
const [applyTargetLabels, setApplyTargetLabels] = useState<string[]>([])
|
||||
// Extra state carried alongside applyTarget when the App branch is
|
||||
@@ -2029,6 +2039,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
// detected via the community-scripts pattern. All loaded once when
|
||||
// the user opens the Updates tab of a specific LXC.
|
||||
const [scheduleLoaded, setScheduleLoaded] = useState<number | null>(null)
|
||||
const scheduleLoadRequestRef = useRef(0)
|
||||
const [scheduleEnabled, setScheduleEnabled] = useState(false)
|
||||
const [scheduleCron, setScheduleCron] = useState("0 3 * * *")
|
||||
const [schedulePreset, setSchedulePreset] = useState<string>("daily-3am")
|
||||
@@ -2073,6 +2084,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
// with scheduled updates: changing a one-click manual selection must never
|
||||
// rewrite the cron automation configured in Options.
|
||||
const [bulkLoaded, setBulkLoaded] = useState<number | null>(null)
|
||||
const bulkLoadRequestRef = useRef(0)
|
||||
const [bulkConfigured, setBulkConfigured] = useState(false)
|
||||
const [bulkTargets, setBulkTargets] = useState<string[]>(["os"])
|
||||
const [bulkPersistedTargets, setBulkPersistedTargets] = useState<string[]>(["os"])
|
||||
@@ -2107,9 +2119,9 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
? s.targets.map((value: any) => String(value))
|
||||
: ([...(legacyTarget !== "app" ? ["os"] : []), ...(legacyTarget !== "os" ? ["apps"] : [])]))
|
||||
setScheduleReleaseDelayDays(Number.isInteger(Number(s.release_delay_days)) ? Number(s.release_delay_days) : 0)
|
||||
if (s.backup !== undefined) setApplyBackup(!!s.backup)
|
||||
if (s.backup_storage) setApplyBackupStorage(s.backup_storage)
|
||||
if (s.restart !== undefined) setApplyRestart(!!s.restart)
|
||||
setApplyBackup(!!s.backup)
|
||||
setApplyBackupStorage(typeof s.backup_storage === "string" ? s.backup_storage : "")
|
||||
setApplyRestart(!!s.restart)
|
||||
setScheduleLastRunAt(s.last_run_at || null)
|
||||
setScheduleLastRunStatus(s.last_run_status || null)
|
||||
setScheduleLastRunReason(s.last_run_reason || null)
|
||||
@@ -2122,30 +2134,46 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
}
|
||||
|
||||
const loadSchedule = async (vmid: number) => {
|
||||
if (selectedVMRef.current?.vmid !== vmid) return
|
||||
const requestId = ++scheduleLoadRequestRef.current
|
||||
const isCurrent = () => selectedVMRef.current?.vmid === vmid
|
||||
&& scheduleLoadRequestRef.current === requestId
|
||||
setScheduleError(null)
|
||||
// Seed from ref cache so tab-switching to Updates isn't a "Loading…"
|
||||
// moment when the payload was already fetched this session.
|
||||
const cachedSched = vmModalCacheRef.current.schedule.get(vmid)
|
||||
if (cachedSched) applySchedulePayload(cachedSched)
|
||||
// Missing options are this guest's defaults, never the previous guest's.
|
||||
applySchedulePayload(cachedSched || {})
|
||||
try {
|
||||
const s: any = await fetchApi(`/api/vms/${vmid}/schedule`)
|
||||
if (s && typeof s === "object") {
|
||||
vmModalCacheRef.current.schedule.set(vmid, s)
|
||||
applySchedulePayload(s)
|
||||
}
|
||||
if (!isCurrent()) return
|
||||
if (!s || typeof s !== "object" || Array.isArray(s))
|
||||
throw new Error(t("vmLxc.scheduled.loadFailed"))
|
||||
vmModalCacheRef.current.schedule.set(vmid, s)
|
||||
applySchedulePayload(s)
|
||||
} catch (e: any) {
|
||||
setScheduleError(e?.message || t("vmLxc.scheduled.loadFailed"))
|
||||
if (isCurrent()) setScheduleError(e?.message || t("vmLxc.scheduled.loadFailed"))
|
||||
} finally {
|
||||
setScheduleLoaded(vmid)
|
||||
if (isCurrent()) setScheduleLoaded(vmid)
|
||||
}
|
||||
}
|
||||
|
||||
const loadBulkUpdate = async (vmid: number) => {
|
||||
if (selectedVMRef.current?.vmid !== vmid) return
|
||||
const requestId = ++bulkLoadRequestRef.current
|
||||
const isCurrent = () => selectedVMRef.current?.vmid === vmid
|
||||
&& bulkLoadRequestRef.current === requestId
|
||||
setBulkError(null)
|
||||
setBulkLoaded(null)
|
||||
setBulkEditMode(false)
|
||||
setBulkConfigured(false)
|
||||
setBulkTargets(["os"])
|
||||
setBulkPersistedTargets(["os"])
|
||||
try {
|
||||
const config: any = await fetchApi(`/api/vms/${vmid}/bulk-update`)
|
||||
if (!isCurrent()) return
|
||||
if (!config || typeof config !== "object" || Array.isArray(config))
|
||||
throw new Error(t("vmLxc.bulkUpdate.loadFailed"))
|
||||
const targets = Array.isArray(config?.targets)
|
||||
? Array.from(new Set(config.targets.map((value: any) => String(value)))) as string[]
|
||||
: []
|
||||
@@ -2155,12 +2183,13 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
setBulkTargets(next)
|
||||
setBulkPersistedTargets(next)
|
||||
} catch (e: any) {
|
||||
if (!isCurrent()) return
|
||||
setBulkConfigured(false)
|
||||
setBulkTargets(["os"])
|
||||
setBulkPersistedTargets(["os"])
|
||||
setBulkError(e?.message || t("vmLxc.bulkUpdate.loadFailed"))
|
||||
} finally {
|
||||
setBulkLoaded(vmid)
|
||||
if (isCurrent()) setBulkLoaded(vmid)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2560,8 +2589,21 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
const openApplyTerminal = (
|
||||
vmid: number,
|
||||
target: "os" | "app" | "both",
|
||||
opts?: { updateCommand?: string; appName?: string; runHelper?: boolean; allowHelperWithCustom?: boolean; dockerStandaloneTargets?: string[]; dockerEngine?: boolean; dockerRefresh?: boolean; targetIds?: string[]; targetLabels?: string[] },
|
||||
opts?: { checkedOs?: boolean; updateCommand?: string; appName?: string; runHelper?: boolean; allowHelperWithCustom?: boolean; dockerStandaloneTargets?: string[]; dockerEngine?: boolean; dockerRefresh?: boolean; targetIds?: string[]; targetLabels?: string[] },
|
||||
) => {
|
||||
const guest = selectedVMRef.current
|
||||
if (target === "os" || target === "both") {
|
||||
if (osCheckingVmid !== null || osCheckRequestRef.current !== null || updatesRefreshing || (opts?.checkedOs && osCheckError)) return
|
||||
const state = guest?.vmid === vmid ? getLxcOsUpdateState(guest.update_check) : "unknown"
|
||||
if (state === "unknown") {
|
||||
setOsCheckError(t("vmLxc.updates.checkBeforeUpdate"))
|
||||
return
|
||||
}
|
||||
// A combined plan can still update apps when OS packages are current.
|
||||
if (target === "os" && state === "current") return
|
||||
}
|
||||
// The explicit update button is the user's confirmation. The runner
|
||||
// owns the temporary start, backup and restoration of a stopped guest.
|
||||
const fallbackTargets = target === "os"
|
||||
? ["os"]
|
||||
: target === "both"
|
||||
@@ -2584,6 +2626,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
setApplyVmid(vmid)
|
||||
setApplyTarget(target)
|
||||
setApplyRunId(runId)
|
||||
setApplyCheckedOs(opts?.checkedOs === true)
|
||||
setApplyTargetIds(opts?.targetIds?.length ? opts.targetIds : fallbackTargets)
|
||||
setApplyTargetLabels(opts?.targetLabels?.length ? opts.targetLabels : fallbackLabels)
|
||||
setApplyUpdateCommand(opts?.updateCommand || "")
|
||||
@@ -2680,10 +2723,16 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
// false "N package pending" every time a registered app has an
|
||||
// upstream bump.
|
||||
const checkOsUpdates = async (vmid: number) => {
|
||||
if (osCheckRequestRef.current !== null || applyOpen || controlLoading) return
|
||||
osCheckRequestRef.current = vmid
|
||||
setOsCheckingVmid(vmid)
|
||||
setOsCheckError(null)
|
||||
try {
|
||||
await checkLxcUpdates(vmid)
|
||||
const observation = await refreshCheckLxcUpdates<LxcUpdateCheck>(vmid)
|
||||
await mutate((current) => Array.isArray(current)
|
||||
? current.map(vm => vm.vmid === vmid ? { ...vm, update_check: observation } : vm)
|
||||
: current, { revalidate: false })
|
||||
setSelectedVM(current => current?.vmid === vmid ? { ...current, update_check: observation } : current)
|
||||
const details = await fetchApi<VMDetails>(`/api/vms/${vmid}`)
|
||||
vmModalCacheRef.current.details.set(vmid, details)
|
||||
if (selectedVMRef.current?.vmid === vmid) setVMDetails(details)
|
||||
@@ -2692,9 +2741,12 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
if (selectedVMRef.current?.vmid === vmid) {
|
||||
setOsCheckError(error.status === 429
|
||||
? t("vmLxc.updates.checkCooldown", { seconds: error.body?.retry_after ?? 60 })
|
||||
: error.message || t("vmLxc.updates.osStatusUnavailable"))
|
||||
: error.message === "checkStillRunning"
|
||||
? t("vmLxc.updates.checkStillRunning")
|
||||
: t("vmLxc.updates.osStatusUnavailable"))
|
||||
}
|
||||
} finally {
|
||||
osCheckRequestRef.current = null
|
||||
setOsCheckingVmid(null)
|
||||
}
|
||||
}
|
||||
@@ -5707,7 +5759,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
!selectedVM.update_check?.is_oci_lxc && (() => {
|
||||
const uc = selectedVM.update_check
|
||||
const osUpdateStale = isLxcUpdateStale(uc)
|
||||
const osUpdateStatusKnown = !!uc && !uc.error && !osUpdateStale
|
||||
const osUpdateStatusKnown = getLxcOsUpdateState(uc) !== "unknown"
|
||||
const hasOsUpdates = osUpdateStatusKnown && !!uc.available
|
||||
const dockerAppWatch = (selectedVM.app_watches || []).find((a) => a.helper_slug === "docker")
|
||||
const dockerRegistered = !!dockerAppWatch
|
||||
@@ -5863,6 +5915,9 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
return {
|
||||
id: `app:${app.id}`,
|
||||
label: app.name || t("vmLxc.updates.applicationDefaultName"),
|
||||
methodLabel: app.update_command?.trim()
|
||||
? t("vmLxc.updates.customCommandTitle")
|
||||
: t("vmLxc.updates.helperMethod"),
|
||||
detail: "",
|
||||
logoUrl: webLinkLogo || app.logo_url?.trim() || "",
|
||||
}
|
||||
@@ -5877,6 +5932,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
? [{
|
||||
id: "docker-engine",
|
||||
label: "Docker Engine",
|
||||
methodLabel: t("vmLxc.scheduled.dockerEngineTarget"),
|
||||
detail: "",
|
||||
logoUrl: "",
|
||||
}]
|
||||
@@ -5884,6 +5940,9 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
...dockerUpdateUnits.map((unit) => ({
|
||||
id: unit.id,
|
||||
label: unit.display_name || unit.primary_reference || unit.primary_service || unit.id,
|
||||
methodLabel: unit.kind === "compose"
|
||||
? "Docker Compose"
|
||||
: t("vmLxc.bulkUpdate.dockerImageMethod"),
|
||||
detail: (unit.dependent_services || []).length
|
||||
? t("vmLxc.bulkUpdate.includesDependencies", {
|
||||
names: (unit.dependent_services || []).join(", "),
|
||||
@@ -5891,7 +5950,14 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
: "",
|
||||
logoUrl: unit.logo_url || "",
|
||||
})),
|
||||
]
|
||||
].map((choice, _index, choices) => ({
|
||||
...choice,
|
||||
// Keep distinct action IDs; names alone never prove
|
||||
// that a custom updater and an image are one action.
|
||||
label: choices.filter((other) =>
|
||||
other.label.trim().toLowerCase() === choice.label.trim().toLowerCase()
|
||||
).length > 1 ? `${choice.label} — ${choice.methodLabel}` : choice.label,
|
||||
}))
|
||||
const bulkChoices = [{
|
||||
id: "os",
|
||||
label: t("vmLxc.bulkUpdate.osTarget"),
|
||||
@@ -5935,7 +6001,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
// has been checked and is current, otherwise unknown.
|
||||
const selectedBulkUpdateStates = bulkTargets.map((target) => {
|
||||
if (target === "os") {
|
||||
if (!uc || uc.error) return null
|
||||
if (!osUpdateStatusKnown) return null
|
||||
return hasOsUpdates
|
||||
}
|
||||
if (target === "docker-engine") {
|
||||
@@ -5960,6 +6026,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
return null
|
||||
})
|
||||
const bulkHasPendingUpdates = selectedBulkUpdateStates.some((state) => state === true)
|
||||
const bulkHasUnknownTargets = selectedBulkUpdateStates.some((state) => state === null)
|
||||
const bulkAllTargetsUpToDate = selectedBulkUpdateStates.length > 0
|
||||
&& selectedBulkUpdateStates.every((state) => state === false)
|
||||
const toggleBulkChoice = (id: string, checked: boolean) => {
|
||||
@@ -5968,9 +6035,8 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
? Array.from(new Set(["os", ...current.filter((value) => value !== "os"), id]))
|
||||
: current.filter((value) => value !== id))
|
||||
}
|
||||
const pendingBtnCls = "bg-purple-600/15 hover:bg-purple-600/25 border border-purple-500/40 text-purple-300 hover:text-purple-200"
|
||||
const upToDateBtnCls = "bg-green-500/10 hover:bg-green-500/20 border border-green-500/30 text-green-400 hover:text-green-300"
|
||||
const neutralBtnCls = "border border-input bg-background text-foreground/80 hover:bg-accent hover:text-accent-foreground"
|
||||
const pendingBtnCls = getUpdateActionButtonClass(true, false)
|
||||
const upToDateBtnCls = getUpdateActionButtonClass(false, true)
|
||||
return (
|
||||
<>
|
||||
<Card className="border border-border bg-card/50">
|
||||
@@ -5982,12 +6048,12 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
<h3 className="text-sm font-semibold text-foreground">{t("vmLxc.updates.osPackagesTitle")}</h3>
|
||||
<Button
|
||||
size="sm" variant="outline" className="ml-auto"
|
||||
disabled={selectedVM.status !== "running" || osCheckingVmid !== null}
|
||||
title={t("vmLxc.updates.checkReadOnlyHelp")}
|
||||
disabled={osCheckingVmid !== null || updatesRefreshing || controlLoading || applyOpen}
|
||||
title={t("vmLxc.updates.refreshCheckHelp")}
|
||||
onClick={() => checkOsUpdates(selectedVM.vmid)}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 mr-1.5 ${osCheckingVmid === selectedVM.vmid ? "animate-spin" : ""}`} />
|
||||
{t("vmLxc.updates.checkNow")}
|
||||
{t("vmLxc.updates.checkSystemPackages")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mb-3 leading-relaxed">
|
||||
@@ -5998,7 +6064,14 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
)}
|
||||
</div>
|
||||
{osCheckError && <p role="alert" className="text-sm text-amber-400 mb-2">{osCheckError}</p>}
|
||||
{osUpdateStale && !uc?.error ? (
|
||||
{osCheckingVmid === selectedVM.vmid ? (
|
||||
<div role="status" className="text-sm text-muted-foreground flex items-center gap-2">
|
||||
<RefreshCw className="h-4 w-4 animate-spin flex-shrink-0" />
|
||||
{t("vmLxc.updates.checkInProgress")}
|
||||
</div>
|
||||
) : !uc?.last_check && !uc?.error ? (
|
||||
<p className="text-sm text-muted-foreground">{t("vmLxc.updates.notCheckedYet")}</p>
|
||||
) : osUpdateStale && !uc?.error ? (
|
||||
<p className="text-sm text-amber-400">{t("vmLxc.updates.checkStaleHelp")}</p>
|
||||
) : !osUpdateStatusKnown ? (
|
||||
<div
|
||||
@@ -6054,23 +6127,28 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
{t("vmLxc.updates.noOsUpdatesPending")}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3 flex justify-end">
|
||||
{osUpdateStatusKnown && osCheckingVmid !== selectedVM.vmid && <div className="mt-3 flex justify-end">
|
||||
{hasOsUpdates ? (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => openApplyTerminal(selectedVM.vmid, "os")}
|
||||
className={hasOsUpdates
|
||||
? pendingBtnCls
|
||||
: osUpdateStatusKnown
|
||||
? upToDateBtnCls
|
||||
: neutralBtnCls}
|
||||
disabled={!hasOsUpdates || osCheckingVmid !== null || updatesRefreshing || controlLoading || !!osCheckError}
|
||||
onClick={() => openApplyTerminal(selectedVM.vmid, "os", { checkedOs: true })}
|
||||
className={getUpdateActionButtonClass(hasOsUpdates, osUpdateStatusKnown)}
|
||||
>
|
||||
{hasOsUpdates && <ArrowUpCircle className="h-4 w-4 mr-1.5" />}
|
||||
{!hasOsUpdates && !osUpdateStatusKnown && <RefreshCw className="h-4 w-4 mr-1.5" />}
|
||||
{hasOsUpdates || !osUpdateStatusKnown
|
||||
{hasOsUpdates
|
||||
? t("vmLxc.updates.applyOsUpdate")
|
||||
: t("vmLxc.updates.osUpToDate")}
|
||||
: osUpdateStatusKnown
|
||||
? t("vmLxc.updates.osUpToDate")
|
||||
: t("vmLxc.updates.checkBeforeUpdate")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button asChild size="sm" className={upToDateBtnCls}>
|
||||
<span role="status">{t("vmLxc.updates.osUpToDate")}</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>}
|
||||
</div>
|
||||
|
||||
{/* Docker has three independent lifecycles:
|
||||
@@ -6200,11 +6278,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
dockerRefresh: true,
|
||||
},
|
||||
)}
|
||||
className={`${dockerEngineHasUpdate
|
||||
? pendingBtnCls
|
||||
: dockerEngineUpToDate
|
||||
? upToDateBtnCls
|
||||
: neutralBtnCls} flex-shrink-0`}
|
||||
className={`${getUpdateActionButtonClass(dockerEngineHasUpdate, dockerEngineUpToDate)} flex-shrink-0`}
|
||||
>
|
||||
{dockerEngineHasUpdate && <ArrowUpCircle className="h-4 w-4 mr-1.5" />}
|
||||
{!dockerEngineHasUpdate && !dockerEngineUpToDate && <RefreshCw className="h-4 w-4 mr-1.5" />}
|
||||
@@ -6480,7 +6554,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
</div>
|
||||
) : helperTracksVersion ? (
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
{t("vmLxc.updates.versionTrackingPending")}
|
||||
{t("vmLxc.updates.versionStatusUnknown")}
|
||||
</p>
|
||||
) : helperExists ? (
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
@@ -6503,7 +6577,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
// still force the helper's own
|
||||
// update script.
|
||||
const noState = !hasUpd && !upToD
|
||||
const cls = hasUpd ? pendingBtnCls : upToD ? upToDateBtnCls : neutralBtnCls
|
||||
const cls = getUpdateActionButtonClass(hasUpd, upToD)
|
||||
const label = hasUpd ? t("vmLxc.updates.applyUpdate") : upToD ? t("vmLxc.updates.upToDate") : t("vmLxc.updates.runUpdater")
|
||||
return (
|
||||
<div className="mt-3 flex justify-end">
|
||||
@@ -6639,7 +6713,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
</div>
|
||||
) : null
|
||||
) : (
|
||||
<div>{t("vmLxc.updates.versionTrackingPendingShort")}</div>
|
||||
<div>{t("vmLxc.updates.versionStatusUnknown")}</div>
|
||||
)}
|
||||
</div>
|
||||
) : tracksVersion ? (
|
||||
@@ -6651,7 +6725,9 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
<a className="text-blue-400 hover:underline" href={`#docker-images-${selectedVM.vmid}`}>
|
||||
{t("vmLxc.updates.dockerImagesSubheading")}
|
||||
</a></>
|
||||
: t("vmLxc.updates.versionTrackingPendingShort")}
|
||||
: aw.installed_via === "manual"
|
||||
? t("vmLxc.updates.installedVersionUnverified")
|
||||
: t("vmLxc.updates.versionStatusUnknown")}
|
||||
</div>
|
||||
) : hasCmd ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
@@ -6673,7 +6749,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
targetLabels: [aw.name || t("vmLxc.updates.applicationDefaultName")],
|
||||
},
|
||||
)}
|
||||
className={hasUpdate ? pendingBtnCls : upToDate ? upToDateBtnCls : neutralBtnCls}
|
||||
className={getUpdateActionButtonClass(hasUpdate, upToDate)}
|
||||
>
|
||||
{hasUpdate && <ArrowUpCircle className="h-4 w-4 mr-1.5" />}
|
||||
{!hasUpdate && !upToDate && <RefreshCw className="h-4 w-4 mr-1.5" />}
|
||||
@@ -6681,7 +6757,9 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
? t("vmLxc.updates.applyUpdate")
|
||||
: upToDate
|
||||
? t("vmLxc.updates.upToDate")
|
||||
: t("vmLxc.updates.runUpdater")}
|
||||
: aw.update_method === "helper"
|
||||
? t("vmLxc.updates.runUpdater")
|
||||
: t("vmLxc.updates.runCustomCommand")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
@@ -6719,6 +6797,11 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
<p className="text-xs text-muted-foreground mt-2 leading-relaxed">
|
||||
{t("vmLxc.bulkUpdate.description")}
|
||||
</p>
|
||||
{dockerRegistered && (
|
||||
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
|
||||
{t("vmLxc.bulkUpdate.osDockerScope")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{!bulkEditMode && (
|
||||
<button
|
||||
@@ -6868,19 +6951,20 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
<div className="text-sm text-muted-foreground break-words">
|
||||
{selectedBulkLabels.join(" · ")}
|
||||
</div>
|
||||
{bulkHasUnknownTargets && (
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
{t("vmLxc.bulkUpdate.unverifiedTargetsHelp")}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => applyBulkUpdate(selectedVM.vmid)}
|
||||
disabled={bulkApplying || (
|
||||
disabled={bulkApplying || osCheckingVmid !== null || updatesRefreshing || !osUpdateStatusKnown || bulkAllTargetsUpToDate || (
|
||||
bulkTargets.some((target) => target.startsWith("docker-unit:"))
|
||||
&& (dockerInventoryRefreshing || !dockerInventoryAvailable)
|
||||
)}
|
||||
className={bulkHasPendingUpdates
|
||||
? pendingBtnCls
|
||||
: bulkAllTargetsUpToDate
|
||||
? upToDateBtnCls
|
||||
: neutralBtnCls}
|
||||
className={getUpdateActionButtonClass(bulkHasPendingUpdates, bulkAllTargetsUpToDate)}
|
||||
>
|
||||
{bulkApplying
|
||||
? <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />
|
||||
@@ -7916,7 +8000,9 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
|
||||
setApplyVmid(null)
|
||||
}}
|
||||
onComplete={handleApplyComplete}
|
||||
scriptPath="/usr/local/share/proxmenux/scripts/lxc/apply_updates.sh"
|
||||
scriptPath={applyCheckedOs
|
||||
? "/usr/local/share/proxmenux/scripts/lxc/install_checked_os_updates.sh"
|
||||
: "/usr/local/share/proxmenux/scripts/lxc/apply_updates.sh"}
|
||||
scriptName="lxc_apply_updates"
|
||||
title={t("vmLxc.updates.terminalTitle", { vmid: applyVmid })}
|
||||
completedSuccessfullyMessage={t("vmLxc.updates.terminalCompletedSuccessfully")}
|
||||
|
||||
@@ -4,6 +4,17 @@ interface UpdateObservation {
|
||||
last_check?: string | null
|
||||
stale?: boolean
|
||||
stale_after_seconds?: number
|
||||
available?: boolean
|
||||
count?: number | null
|
||||
error?: string | null
|
||||
}
|
||||
|
||||
export function getLxcOsUpdateState(observation?: UpdateObservation, now = Date.now()): "unknown" | "pending" | "current" {
|
||||
if (isLxcUpdateStale(observation, now) || observation?.error
|
||||
|| typeof observation?.available !== "boolean"
|
||||
|| !Number.isInteger(observation.count) || (observation.count ?? -1) < 0
|
||||
|| observation.available !== ((observation.count ?? 0) > 0)) return "unknown"
|
||||
return observation.available ? "pending" : "current"
|
||||
}
|
||||
|
||||
export function isLxcUpdateStale(observation?: UpdateObservation, now = Date.now()): boolean {
|
||||
@@ -19,3 +30,18 @@ export function isLxcUpdateStale(observation?: UpdateObservation, now = Date.now
|
||||
export function checkLxcUpdates<T>(vmid: number): Promise<T> {
|
||||
return fetchApi<T>(`/api/vms/${vmid}/updates/check`, { method: "POST" })
|
||||
}
|
||||
|
||||
// Separate explicit action: refresh indexes, check only, restore stopped CT.
|
||||
// The backend owns the job even if the browser closes or navigates away.
|
||||
export async function refreshCheckLxcUpdates<T>(vmid: number): Promise<T> {
|
||||
const job = await fetchApi<{ job_id: string }>(`/api/vms/${vmid}/updates/refresh-check`, { method: "POST" })
|
||||
const deadline = Date.now() + 8 * 60_000
|
||||
while (Date.now() < deadline) {
|
||||
const result = await fetchApi<{ state: string; error?: string; update_check?: T }>(
|
||||
`/api/vms/${vmid}/updates/refresh-check/${job.job_id}`)
|
||||
if (result.state === "failed") throw new Error("osStatusUnavailable")
|
||||
if (result.state === "complete") return result.update_check as T
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
}
|
||||
throw new Error("checkStillRunning")
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
"osPackagesTitle": "OS packages",
|
||||
"checkNow": "Check now",
|
||||
"checkNow": "Check updates",
|
||||
"checkSystemPackages": "Check system packages",
|
||||
"checkInProgress": "Check in progress",
|
||||
"refreshCheckHelp": "Refreshes package indexes and checks pending updates. Nothing is installed. A stopped container is temporarily started and stopped again after the check.",
|
||||
"notCheckedYet": "Updates have not been checked yet.",
|
||||
"checkStillRunning": "The check is still running. The monitor will restore the container's original state when it finishes.",
|
||||
"checkBeforeUpdate": "Check updates first",
|
||||
"startBeforeCheck": "Start the container, then check for updates. No badge does not mean there are no updates.",
|
||||
"checkReadOnlyHelp": "Read current OS and pending packages from existing indexes. No installation or package index refresh.",
|
||||
"checkCooldown": "Check already running or recently requested. Try again in {seconds} seconds.",
|
||||
"checkStale": "Check outdated",
|
||||
@@ -1380,6 +1387,9 @@
|
||||
"applyUpdate": "Apply update",
|
||||
"upToDate": "Up to date",
|
||||
"runUpdater": "Run updater",
|
||||
"runCustomCommand": "Run custom command",
|
||||
"installedVersionUnverified": "Installed version is unverified. Update availability could not be confirmed.",
|
||||
"versionStatusUnknown": "Update availability is unverified. Version information is unavailable or incomplete.",
|
||||
"terminalTitle": "Apply updates — CT {vmid}",
|
||||
"terminalDescriptionOs": "Applying OS package updates inside the container...",
|
||||
"terminalDescriptionApp": "Running the application updater inside the container...",
|
||||
@@ -1483,9 +1493,12 @@
|
||||
"notConfigured": "Not configured.",
|
||||
"configure": "Configure",
|
||||
"edit": "Edit",
|
||||
"apply": "Apply updates",
|
||||
"apply": "Start bulk update",
|
||||
"osTarget": "OS",
|
||||
"osRequired": "Required for a bulk update.",
|
||||
"osDockerScope": "OS updates also include Docker when it is installed as a system package.",
|
||||
"unverifiedTargetsHelp": "Update availability for some selected items is unverified. Starting will run the selected update methods even without a confirmed new version.",
|
||||
"dockerImageMethod": "Docker image",
|
||||
"includesDependencies": "Also includes declared dependencies: {names}",
|
||||
"noMethod": "No executable update method is available.",
|
||||
"staleTarget": "This selection is no longer available. Remove it before saving.",
|
||||
|
||||
@@ -1334,7 +1334,14 @@
|
||||
"updatedWithDockerImage": "Aktualizuje sa spolu so svojím obrazom Docker.",
|
||||
"dockerBindingUnavailable": "Zatiaľ nie je priradený žiadny obraz. Skontrolujte názov kontajnera a obnovte inventár obrazov.",
|
||||
"osPackagesTitle": "Balíky systému",
|
||||
"checkNow": "Skontrolovať teraz",
|
||||
"checkNow": "Skontrolovať aktualizácie",
|
||||
"checkSystemPackages": "Skontrolovať balíky systému",
|
||||
"checkInProgress": "Prebieha kontrola",
|
||||
"refreshCheckHelp": "Obnoví zoznamy balíkov a skontroluje čakajúce aktualizácie. Nič nenainštaluje. Vypnutý kontajner sa dočasne zapne a po kontrole sa opäť vypne.",
|
||||
"notCheckedYet": "Aktualizácie zatiaľ nie sú skontrolované.",
|
||||
"checkStillRunning": "Kontrola ešte prebieha. Monitor po jej dokončení obnoví pôvodný stav kontajnera.",
|
||||
"checkBeforeUpdate": "Najprv skontrolovať aktualizácie",
|
||||
"startBeforeCheck": "Spustite kontajner a potom skontrolujte aktualizácie. Chýbajúci odznak neznamená, že nie sú žiadne aktualizácie.",
|
||||
"checkReadOnlyHelp": "Zistí aktuálny systém a čakajúce balíky podľa existujúcich zoznamov. Nič nenainštaluje ani neobnoví zoznamy balíkov.",
|
||||
"checkCooldown": "Kontrola už prebieha alebo sa práve vykonala. Skúste znova o {seconds} sekúnd.",
|
||||
"checkStale": "Zastaraná kontrola",
|
||||
@@ -1382,6 +1389,9 @@
|
||||
"applyUpdate": "Aktualizovať",
|
||||
"upToDate": "Aktuálne",
|
||||
"runUpdater": "Spustiť aktualizáciu",
|
||||
"runCustomCommand": "Spustiť vlastný príkaz",
|
||||
"installedVersionUnverified": "Nainštalovaná verzia nie je overená. Dostupnosť aktualizácie sa nepodarilo potvrdiť.",
|
||||
"versionStatusUnknown": "Dostupnosť aktualizácie nie je overená. Údaje o verzii chýbajú alebo sú neúplné.",
|
||||
"terminalTitle": "Aktualizácia — CT {vmid}",
|
||||
"terminalDescriptionOs": "Aktualizujem balíky systému v kontajneri...",
|
||||
"terminalDescriptionApp": "Spúšťam aktualizáciu aplikácie v kontajneri...",
|
||||
@@ -1483,9 +1493,12 @@
|
||||
"notConfigured": "Nenakonfigurované.",
|
||||
"configure": "Nastaviť",
|
||||
"edit": "Upraviť",
|
||||
"apply": "Použiť aktualizácie",
|
||||
"apply": "Spustiť hromadnú aktualizáciu",
|
||||
"osTarget": "Operačný systém",
|
||||
"osRequired": "Povinné pre hromadnú aktualizáciu.",
|
||||
"osDockerScope": "Systémová aktualizácia zahŕňa aj Docker nainštalovaný ako systémový balík.",
|
||||
"unverifiedTargetsHelp": "Dostupnosť aktualizácií pre niektoré vybrané položky nie je overená. Spustenie vykoná vybrané aktualizačné metódy aj bez potvrdenej novej verzie.",
|
||||
"dockerImageMethod": "Docker obraz",
|
||||
"includesDependencies": "Zahŕňa aj deklarované závislosti: {names}",
|
||||
"noMethod": "Nie je dostupná žiadna spustiteľná metóda aktualizácie.",
|
||||
"staleTarget": "Tento výber už nie je dostupný. Pred uložením ho odstráňte.",
|
||||
|
||||
@@ -146,6 +146,7 @@ cp "$SCRIPT_DIR/health_thresholds.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "
|
||||
cp "$SCRIPT_DIR/managed_installs.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ managed_installs.py not found"
|
||||
cp "$SCRIPT_DIR/lxc_update_observation.py" "$APP_DIR/usr/bin/"
|
||||
cp "$SCRIPT_DIR/lxc_update_finalization.py" "$APP_DIR/usr/bin/"
|
||||
cp "$SCRIPT_DIR/lxc_check_job.py" "$APP_DIR/usr/bin/"
|
||||
cp "$SCRIPT_DIR/lxc_apps.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ lxc_apps.py not found"
|
||||
cp "$SCRIPT_DIR/custom_links.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ custom_links.py not found"
|
||||
cp "$SCRIPT_DIR/recreate_docker_container.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ recreate_docker_container.py not found"
|
||||
|
||||
@@ -6728,6 +6728,11 @@ def _get_lxc_update_status_map() -> dict:
|
||||
'managed_oci_app': it.get('_managed_oci_app'),
|
||||
'os_family': it.get('_os_family'),
|
||||
}
|
||||
if os.path.exists(f'/run/lock/proxmenux-lxc-check-invalid-{int(vmid)}'):
|
||||
# Read-only projection: a failed/pending explicit check must not
|
||||
# re-expose a former positive count after a persistence failure.
|
||||
out[str(vmid)].update(available=False, count=0, packages=[],
|
||||
error='Latest explicit package check is running or failed; check again')
|
||||
return out
|
||||
|
||||
|
||||
@@ -13669,19 +13674,57 @@ def api_lxc_updates_check(vmid):
|
||||
'retry_after': retry}), 429, {'Retry-After': str(retry)}
|
||||
_lxc_update_check_attempts[vmid] = now
|
||||
_lxc_update_check_inflight.add(vmid)
|
||||
operation_lock = None
|
||||
try:
|
||||
import lxc_check_job
|
||||
operation_lock = lxc_check_job.acquire(vmid)
|
||||
item = managed_installs.refresh_lxc(vmid, refresh_package_cache=False)
|
||||
if item is None:
|
||||
return jsonify({'error': 'No supported running LXC detected'}), 409
|
||||
_vm_cache_invalidate(vmid, _lxc_os_info_cache)
|
||||
return jsonify({'vmid': vmid, 'update_check': _get_lxc_update_status_map().get(str(vmid))})
|
||||
except BlockingIOError:
|
||||
return jsonify({'error': 'Another container operation is running'}), 409
|
||||
except Exception as exc:
|
||||
return jsonify({'error': str(exc)}), 500
|
||||
finally:
|
||||
if operation_lock is not None:
|
||||
operation_lock.close()
|
||||
with _lxc_update_check_lock:
|
||||
_lxc_update_check_inflight.discard(vmid)
|
||||
|
||||
|
||||
@app.route('/api/vms/<int:vmid>/updates/refresh-check', methods=['POST'])
|
||||
@require_auth
|
||||
def api_lxc_updates_refresh_check(vmid):
|
||||
"""Explicit index refresh/check; never installs. Worker owns restoration."""
|
||||
import lxc_check_job
|
||||
try:
|
||||
result, code = lxc_check_job.start(vmid)
|
||||
if code == 429:
|
||||
return jsonify({'error': 'Check already running or recently requested',
|
||||
'retry_after': 60}), 429, {'Retry-After': '60'}
|
||||
return jsonify(result), code
|
||||
except BlockingIOError:
|
||||
return jsonify({'error': 'Another container operation is running'}), 409
|
||||
except (OSError, RuntimeError, ValueError) as exc:
|
||||
return jsonify({'error': str(exc)}), 409
|
||||
|
||||
|
||||
@app.route('/api/vms/<int:vmid>/updates/refresh-check/<job_id>', methods=['GET'])
|
||||
@require_auth
|
||||
def api_lxc_updates_refresh_check_status(vmid, job_id):
|
||||
import lxc_check_job
|
||||
result = lxc_check_job.get(vmid, job_id)
|
||||
if result is None:
|
||||
return jsonify({'error': 'Check result not found; check again'}), 404
|
||||
if result['state'] != 'running':
|
||||
_vm_cache_invalidate(vmid, _lxc_os_info_cache)
|
||||
_pvesh_cache['cluster_resources_vm_time'] = 0
|
||||
result['update_check'] = _get_lxc_update_status_map().get(str(vmid))
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
# ─── LXC Update Detection toggle ────────────────────────────────────────────
|
||||
# Dedicated toggle so the operator can opt out of the per-CT `pct exec apt
|
||||
# list --upgradable` scan entirely. The Notifications section keeps its own
|
||||
@@ -14258,6 +14301,7 @@ def api_vm_apps_dismiss(vmid):
|
||||
# ─── LXC update finalization ────────────────────────────────────────────────
|
||||
|
||||
_LXC_APPLY_UPDATES_SCRIPT = "/usr/local/share/proxmenux/scripts/lxc/apply_updates.sh"
|
||||
_LXC_INSTALL_CHECKED_OS_SCRIPT = "/usr/local/share/proxmenux/scripts/lxc/install_checked_os_updates.sh"
|
||||
_LXC_UPDATE_RUN_ID_RE = re.compile(r'^[A-Za-z0-9._:-]{1,128}$')
|
||||
_LXC_UPDATE_TARGET_RE = re.compile(r'^[A-Za-z0-9._:-]{1,180}$')
|
||||
from lxc_update_finalization import lock as _lxc_update_finalization_lock, runs as _lxc_update_finalizations
|
||||
@@ -14711,7 +14755,9 @@ def _finalize_lxc_update(
|
||||
|
||||
|
||||
def _terminal_lxc_update_completed(*, script_path, params, exit_code, duration_seconds):
|
||||
if os.path.realpath(script_path) != os.path.realpath(_LXC_APPLY_UPDATES_SCRIPT):
|
||||
if os.path.realpath(script_path) not in {
|
||||
os.path.realpath(_LXC_APPLY_UPDATES_SCRIPT),
|
||||
os.path.realpath(_LXC_INSTALL_CHECKED_OS_SCRIPT)}:
|
||||
return
|
||||
run_id = _normalise_lxc_update_run_id(params.get('RUN_ID'), create=False)
|
||||
if not run_id:
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Explicit check-only jobs; the shared updater lock protects each CT.
|
||||
|
||||
Importing or reading a job never probes a guest. Only start() may launch work.
|
||||
Package observations are saved before restoring a temporarily started guest.
|
||||
"""
|
||||
import datetime
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import managed_installs
|
||||
import lxc_update_observation
|
||||
|
||||
LOCK_DIR = Path(os.environ.get('PROXMENUX_LOCK_DIR', '/run/lock'))
|
||||
CONF_DIR = Path('/etc/pve/lxc')
|
||||
OCI_REGISTRY = Path('/usr/local/share/proxmenux/oci/installed.json')
|
||||
_lock = threading.RLock()
|
||||
_jobs = {}
|
||||
_attempts = {}
|
||||
COOLDOWN = 60
|
||||
JOB_TTL = 3600
|
||||
|
||||
|
||||
def acquire(vmid):
|
||||
path = LOCK_DIR / f'proxmenux-lxc-update-{int(vmid)}.lock'
|
||||
handle = os.fdopen(os.open(path, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600), 'r+')
|
||||
try:
|
||||
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
return handle
|
||||
except BaseException:
|
||||
handle.close()
|
||||
raise
|
||||
|
||||
|
||||
def guard(vmid):
|
||||
if not managed_installs._lxc_updates_detection_enabled():
|
||||
raise RuntimeError('LXC update detection is disabled')
|
||||
# Fail closed if current configuration cannot be read. Never infer OCI
|
||||
# eligibility from an old OS hint or from a failed config read.
|
||||
config = (CONF_DIR / f'{int(vmid)}.conf').read_text()
|
||||
# The normal collector tolerates broken OCI metadata. An action must
|
||||
# instead fail closed: an unreadable registry is not an empty registry.
|
||||
if OCI_REGISTRY.exists():
|
||||
registry = json.loads(OCI_REGISTRY.read_text())
|
||||
instances = registry.get('instances')
|
||||
if not isinstance(instances, dict) or any(not isinstance(item, dict) for item in instances.values()):
|
||||
raise RuntimeError('OCI registry could not be verified')
|
||||
if any(str(item.get('vmid')) == str(vmid) for item in instances.values()):
|
||||
raise RuntimeError('OCI containers require their own updater')
|
||||
if any(line.lstrip().startswith(managed_installs._OCI_LXC_MARKERS)
|
||||
for line in config.splitlines()) or str(vmid) in managed_installs._get_oci_managed_vmids():
|
||||
raise RuntimeError('OCI containers require their own updater')
|
||||
# Retain the independent registered-OCI boundary if its collector is
|
||||
# temporarily unavailable. These hints can only forbid an action.
|
||||
if any(str(item.get('_vmid')) == str(vmid) and item.get('_managed_oci_app')
|
||||
for item in managed_installs.get_active_items()):
|
||||
raise RuntimeError('OCI containers require their own updater')
|
||||
if any(line.startswith('lock:') for line in config.splitlines()):
|
||||
raise RuntimeError('Container is locked by Proxmox')
|
||||
|
||||
|
||||
def command(vmid, *args, timeout=30):
|
||||
result = subprocess.run([managed_installs._PCT_BIN, *args[:1], str(vmid), *args[1:]],
|
||||
capture_output=True, text=True, timeout=timeout)
|
||||
if result.returncode:
|
||||
raise RuntimeError(f'Container {args[0]} failed')
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def status(vmid):
|
||||
value = command(vmid, 'status')
|
||||
if value not in ('status: running', 'status: stopped'):
|
||||
raise RuntimeError('Container state could not be determined')
|
||||
return value.split(': ', 1)[1]
|
||||
|
||||
|
||||
def checked_os(vmid):
|
||||
"""Installation precondition; called under the updater lock before start."""
|
||||
guard(vmid)
|
||||
if (LOCK_DIR / f'proxmenux-lxc-check-invalid-{int(vmid)}').exists():
|
||||
raise RuntimeError('Latest check failed or is still running')
|
||||
item = next((item for item in managed_installs.get_active_items()
|
||||
if item.get('id') == f'lxc:{int(vmid)}'), {})
|
||||
observation = item.get('update_check') or {}
|
||||
checked = datetime.datetime.fromisoformat(observation.get('last_check') or '')
|
||||
if checked.tzinfo is None:
|
||||
checked = checked.replace(tzinfo=datetime.timezone.utc)
|
||||
age = (datetime.datetime.now(datetime.timezone.utc) - checked).total_seconds()
|
||||
if (not 0 <= age < 172800 or observation.get('error')
|
||||
or observation.get('available') is not True
|
||||
or type(observation.get('_count')) is not int or observation['_count'] <= 0):
|
||||
raise RuntimeError('Check OS updates successfully before installation')
|
||||
|
||||
|
||||
def run(vmid, job_id, reservation):
|
||||
restore = False
|
||||
error = None
|
||||
invalid = LOCK_DIR / f'proxmenux-lxc-check-invalid-{int(vmid)}'
|
||||
try:
|
||||
invalid.touch(mode=0o600)
|
||||
guard(vmid)
|
||||
original = status(vmid)
|
||||
if original == 'stopped':
|
||||
# Set before start: a timed-out command can still have started CT.
|
||||
restore = True
|
||||
command(vmid, 'start', timeout=60)
|
||||
deadline = time.monotonic() + 60
|
||||
while True:
|
||||
try:
|
||||
command(vmid, 'exec', '--', 'true', timeout=5)
|
||||
break
|
||||
except (RuntimeError, subprocess.TimeoutExpired):
|
||||
if time.monotonic() >= deadline:
|
||||
raise RuntimeError('Container did not become ready')
|
||||
time.sleep(1)
|
||||
observation = lxc_update_observation.capture(vmid, job_id, refresh_indexes=True)
|
||||
if not managed_installs.store_lxc_update_observation(vmid, observation):
|
||||
raise RuntimeError(observation['result'].get('error') or 'Package check failed')
|
||||
lxc_update_observation.discard(vmid, job_id)
|
||||
except Exception as exc:
|
||||
error = str(exc)
|
||||
# Preflight/start failures must revoke a previous positive observation.
|
||||
try:
|
||||
managed_installs.store_lxc_update_observation(vmid, None)
|
||||
except Exception:
|
||||
error += '; package result could not be persisted'
|
||||
finally:
|
||||
if restore:
|
||||
try:
|
||||
if status(vmid) != 'stopped':
|
||||
command(vmid, 'shutdown', '--timeout', '30', timeout=40)
|
||||
if status(vmid) != 'stopped':
|
||||
raise RuntimeError('Container is still running')
|
||||
except Exception:
|
||||
try:
|
||||
command(vmid, 'stop', timeout=30)
|
||||
if status(vmid) != 'stopped':
|
||||
raise RuntimeError('Container is still running')
|
||||
except Exception:
|
||||
error = (error + '; ' if error else '') + 'Original stopped state could not be restored'
|
||||
if not error:
|
||||
try:
|
||||
invalid.unlink()
|
||||
except OSError:
|
||||
error = 'Successful check could not be published for installation'
|
||||
reservation.close()
|
||||
with _lock:
|
||||
_jobs[job_id].update(state='failed' if error else 'complete', error=error)
|
||||
|
||||
|
||||
def start(vmid):
|
||||
now = time.monotonic()
|
||||
with _lock:
|
||||
for key in list(_jobs):
|
||||
if _jobs[key]['state'] != 'running' and now - _jobs[key]['created'] >= JOB_TTL:
|
||||
del _jobs[key]
|
||||
last = _attempts.get(vmid)
|
||||
if (any(job['vmid'] == vmid and job['state'] == 'running' for job in _jobs.values())
|
||||
or last is not None and now - last < COOLDOWN):
|
||||
return None, 429
|
||||
reservation = acquire(vmid)
|
||||
try:
|
||||
guard(vmid)
|
||||
job_id = uuid.uuid4().hex
|
||||
_jobs[job_id] = dict(vmid=vmid, state='running', error=None, created=now)
|
||||
_attempts[vmid] = now
|
||||
threading.Thread(target=run, args=(vmid, job_id, reservation), daemon=False).start()
|
||||
except BaseException:
|
||||
if 'job_id' in locals():
|
||||
_jobs.pop(job_id, None)
|
||||
_attempts.pop(vmid, None)
|
||||
reservation.close()
|
||||
raise
|
||||
return dict(job_id=job_id, vmid=vmid, state='running'), 202
|
||||
|
||||
|
||||
def get(vmid, job_id):
|
||||
with _lock:
|
||||
job = _jobs.get(job_id)
|
||||
if job is None or job['vmid'] != vmid:
|
||||
return None
|
||||
return {key: value for key, value in job.items() if key != 'created'}
|
||||
Reference in New Issue
Block a user