From 664ef981e57a79d9266fd7e4762b52227450cfa6 Mon Sep 17 00:00:00 2001 From: VAIO73 <50487331+Vaso73@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:27:04 +0000 Subject: [PATCH 1/3] fix: separate LXC package checks from installation --- .../tests/test_command_descriptions.py | 9 +- AppImage/components/virtual-machines.tsx | 194 +++++++--- AppImage/lib/lxc-update-observation.ts | 26 ++ AppImage/lib/update-action-style.ts | 7 + AppImage/messages/en/common.json | 17 +- AppImage/messages/sk/common.json | 17 +- AppImage/scripts/build_appimage.sh | 1 + AppImage/scripts/flask_server.py | 48 ++- AppImage/scripts/lxc_check_job.py | 188 ++++++++++ scripts/lxc/apply_updates.sh | 14 + scripts/lxc/install_checked_os_updates.sh | 6 + tests/lxc_updates/test_apply_updates.sh | 19 + tests/lxc_updates/test_check_only_job.py | 296 +++++++++++++++ .../lxc_updates/test_docker_delegated_ui.cjs | 87 +++++ .../lxc_updates/test_download_update_guard.py | 1 + tests/lxc_updates/test_observed_state.py | 6 + tests/lxc_updates/test_observed_state_ui.cjs | 346 +++++++++++++++++- tests/lxc_updates/test_update_status_ui.cjs | 129 +++++++ 18 files changed, 1341 insertions(+), 70 deletions(-) create mode 100644 AppImage/lib/update-action-style.ts create mode 100644 AppImage/scripts/lxc_check_job.py create mode 100644 scripts/lxc/install_checked_os_updates.sh create mode 100644 tests/lxc_updates/test_check_only_job.py create mode 100644 tests/lxc_updates/test_update_status_ui.cjs diff --git a/.github/scripts/tests/test_command_descriptions.py b/.github/scripts/tests/test_command_descriptions.py index e7339492..554c4226 100644 --- a/.github/scripts/tests/test_command_descriptions.py +++ b/.github/scripts/tests/test_command_descriptions.py @@ -213,9 +213,16 @@ class CommandDescriptionsTests(unittest.TestCase): # Model the bot's future output in temporary catalogs, just # like the other pending keys above; never rewrite locales. for key in ("checkNow", "checkReadOnlyHelp", "checkCooldown", - "checkStale", "checkStaleHelp"): + "checkStale", "checkStaleHelp", "checkBeforeUpdate", + "startBeforeCheck", "notCheckedYet", + "checkStillRunning", "refreshCheckHelp", + "checkInProgress", "checkSystemPackages", "runCustomCommand", + "installedVersionUnverified", "versionStatusUnknown"): messages["vmLxc"]["updates"].setdefault( key, english["vmLxc"]["updates"][key]) + for key in ("osDockerScope", "dockerImageMethod", "unverifiedTargetsHelp"): + messages["vmLxc"]["bulkUpdate"].setdefault( + key, english["vmLxc"]["bulkUpdate"][key]) path.write_text(json.dumps(messages, ensure_ascii=False)) before = {p: p.read_bytes() for p in root.glob("*/common.json")} argv = [str(SCRIPT), "--source", str(root / "en/common.json"), diff --git a/AppImage/components/virtual-machines.tsx b/AppImage/components/virtual-machines.tsx index a0660d18..9c8c8e2b 100644 --- a/AppImage/components/virtual-machines.tsx +++ b/AppImage/components/virtual-machines.tsx @@ -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(null) + const osCheckRequestRef = useRef(null) const [osCheckError, setOsCheckError] = useState(null) const [updatesResult, setUpdatesResult] = useState<{ pendingAfter: number; appliedCount: number } | null>(null) const [updatesBaselineCount, setUpdatesBaselineCount] = useState(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(0) const [applyRunId, setApplyRunId] = useState("") + const [applyCheckedOs, setApplyCheckedOs] = useState(false) const [applyTargetIds, setApplyTargetIds] = useState([]) const [applyTargetLabels, setApplyTargetLabels] = useState([]) // 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(null) + const scheduleLoadRequestRef = useRef(0) const [scheduleEnabled, setScheduleEnabled] = useState(false) const [scheduleCron, setScheduleCron] = useState("0 3 * * *") const [schedulePreset, setSchedulePreset] = useState("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(null) + const bulkLoadRequestRef = useRef(0) const [bulkConfigured, setBulkConfigured] = useState(false) const [bulkTargets, setBulkTargets] = useState(["os"]) const [bulkPersistedTargets, setBulkPersistedTargets] = useState(["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(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(`/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 ( <> @@ -5982,12 +6048,12 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {

{t("vmLxc.updates.osPackagesTitle")}

@@ -5998,7 +6064,14 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { )}
{osCheckError &&

{osCheckError}

} - {osUpdateStale && !uc?.error ? ( + {osCheckingVmid === selectedVM.vmid ? ( +
+ + {t("vmLxc.updates.checkInProgress")} +
+ ) : !uc?.last_check && !uc?.error ? ( +

{t("vmLxc.updates.notCheckedYet")}

+ ) : osUpdateStale && !uc?.error ? (

{t("vmLxc.updates.checkStaleHelp")}

) : !osUpdateStatusKnown ? (
{ {t("vmLxc.updates.noOsUpdatesPending")}
)} -
+ {osUpdateStatusKnown && osCheckingVmid !== selectedVM.vmid &&
+ {hasOsUpdates ? ( -
+ ) : ( + + )} +
} {/* 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 && } {!dockerEngineHasUpdate && !dockerEngineUpToDate && } @@ -6480,7 +6554,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { ) : helperTracksVersion ? (

- {t("vmLxc.updates.versionTrackingPending")} + {t("vmLxc.updates.versionStatusUnknown")}

) : helperExists ? (

@@ -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 (

@@ -6639,7 +6713,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
) : null ) : ( -
{t("vmLxc.updates.versionTrackingPendingShort")}
+
{t("vmLxc.updates.versionStatusUnknown")}
)} ) : tracksVersion ? ( @@ -6651,7 +6725,9 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { {t("vmLxc.updates.dockerImagesSubheading")} - : t("vmLxc.updates.versionTrackingPendingShort")} + : aw.installed_via === "manual" + ? t("vmLxc.updates.installedVersionUnverified") + : t("vmLxc.updates.versionStatusUnknown")} ) : hasCmd ? (
@@ -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 && } {!hasUpdate && !upToDate && } @@ -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")}
) : ( @@ -6719,6 +6797,11 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {

{t("vmLxc.bulkUpdate.description")}

+ {dockerRegistered && ( +

+ {t("vmLxc.bulkUpdate.osDockerScope")} +

+ )} {!bulkEditMode && ( + {selectedVM.status !== "running" && ( +

+ {t("vmLxc.updates.startBeforeCheck")} +

+ )}
{t("vmLxc.updates.lastCheckedPrefix")}{" "} {uc?.last_check ? new Date(uc.last_check).toLocaleString() : "—"} diff --git a/AppImage/lib/lxc-update-observation.ts b/AppImage/lib/lxc-update-observation.ts index 9c0bb4f7..8a00d565 100644 --- a/AppImage/lib/lxc-update-observation.ts +++ b/AppImage/lib/lxc-update-observation.ts @@ -31,8 +31,8 @@ export function checkLxcUpdates(vmid: number): Promise { return fetchApi(`/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. +// 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(vmid: number): Promise { const job = await fetchApi<{ job_id: string }>(`/api/vms/${vmid}/updates/refresh-check`, { method: "POST" }) const deadline = Date.now() + 8 * 60_000 diff --git a/AppImage/messages/en/common.json b/AppImage/messages/en/common.json index 0b6dbf80..8d510ee3 100644 --- a/AppImage/messages/en/common.json +++ b/AppImage/messages/en/common.json @@ -1335,9 +1335,9 @@ "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.", + "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. The monitor will restore the container's original state when it finishes.", + "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.", diff --git a/AppImage/messages/sk/common.json b/AppImage/messages/sk/common.json index 3daf0d81..cbc8049b 100644 --- a/AppImage/messages/sk/common.json +++ b/AppImage/messages/sk/common.json @@ -1337,9 +1337,9 @@ "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.", + "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. Monitor po jej dokončení obnoví pôvodný stav kontajnera.", + "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.", diff --git a/AppImage/scripts/flask_server.py b/AppImage/scripts/flask_server.py index 02122f72..78434926 100644 --- a/AppImage/scripts/flask_server.py +++ b/AppImage/scripts/flask_server.py @@ -13697,7 +13697,7 @@ def api_lxc_updates_check(vmid): @app.route('/api/vms//updates/refresh-check', methods=['POST']) @require_auth def api_lxc_updates_refresh_check(vmid): - """Explicit index refresh/check; never installs. Worker owns restoration.""" + """Explicit index refresh/check for a running CT; never installs.""" import lxc_check_job try: result, code = lxc_check_job.start(vmid) diff --git a/AppImage/scripts/lxc_check_job.py b/AppImage/scripts/lxc_check_job.py index 0f69af36..548a3440 100644 --- a/AppImage/scripts/lxc_check_job.py +++ b/AppImage/scripts/lxc_check_job.py @@ -1,7 +1,7 @@ """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. +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 @@ -99,51 +99,30 @@ def checked_os(vmid): def run(vmid, job_id, reservation): - restore = False error = None + check_started = False 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) + 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) - # 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: + # 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: - if status(vmid) != 'stopped': - command(vmid, 'shutdown', '--timeout', '30', timeout=40) - if status(vmid) != 'stopped': - raise RuntimeError('Container is still running') + managed_installs.store_lxc_update_observation(vmid, None) 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' + error += '; package result could not be persisted' + finally: if not error: try: invalid.unlink() @@ -167,6 +146,8 @@ def start(vmid): 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 diff --git a/tests/lxc_updates/test_check_only_job.py b/tests/lxc_updates/test_check_only_job.py index 8517fca2..a6635970 100644 --- a/tests/lxc_updates/test_check_only_job.py +++ b/tests/lxc_updates/test_check_only_job.py @@ -63,14 +63,17 @@ class CheckOnlyTests(unittest.TestCase): job.run(100, 'run', reservation) return job.get(100, 'run'), capture, store - def test_stopped_check_restores_and_never_installs(self): - result, capture, store = self.run_check() - self.assertEqual(result['state'], 'complete') + 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', 'start', 'exec', 'status', 'shutdown', 'status']) - capture.assert_called_once_with(100, 'run', refresh_indexes=True) - store.assert_called_once_with(100, self.result) + 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' @@ -78,29 +81,36 @@ class CheckOnlyTests(unittest.TestCase): 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_index_read_timeout_and_persistence_errors_restore_stopped(self): + 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_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, 'stopped') + self.assertEqual(self.state, 'running') result, _, _ = self.run_check(persistence_error=OSError('disk full')) self.assertEqual(result['state'], 'failed') - self.assertEqual(self.state, 'stopped') + self.assertEqual(self.state, 'running') self.assertIn('could not be persisted', result['error']) - def test_start_timeout_after_effect_is_restored(self): - def transport(vmid, action, *args, **kwargs): - value = self.transport(vmid, action, *args, **kwargs) - if action == 'start': - raise subprocess.TimeoutExpired('start', 60) - return value - result, capture, _ = self.run_check(transport=transport) + 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' @@ -109,26 +119,6 @@ class CheckOnlyTests(unittest.TestCase): self.assertEqual(self.calls, ['status']) capture.assert_not_called() - def test_shutdown_failure_uses_verified_stop(self): - def transport(vmid, action, *args, **kwargs): - if action == 'shutdown': - raise RuntimeError('shutdown failed') - return self.transport(vmid, action, *args, **kwargs) - result, _, _ = self.run_check(transport=transport) - self.assertEqual(result['state'], 'complete') - self.assertEqual(self.state, 'stopped') - self.assertIn('stop', self.calls) - - def test_failed_restore_revokes_installation(self): - def transport(vmid, action, *args, **kwargs): - if action in ('shutdown', 'stop'): - raise RuntimeError('stop failed') - return self.transport(vmid, action, *args, **kwargs) - result, _, _ = self.run_check(transport=transport) - self.assertEqual(result['state'], 'failed') - self.assertIn('could not be restored', result['error']) - self.assertTrue((self.folder / 'proxmenux-lxc-check-invalid-100').exists()) - def test_guard_oci_config_lock_and_disabled_forbid_all_guest_calls(self): for config in ['lxc.environment.runtime: OCI=1\n', 'lock: backup\n']: self.conf.write_text(config) @@ -163,7 +153,8 @@ class CheckOnlyTests(unittest.TestCase): capture.assert_not_called() def test_thread_start_failure_releases_lock_and_job(self): - with patch.object(job.threading, 'Thread') as thread: + 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) @@ -201,7 +192,8 @@ class CheckOnlyTests(unittest.TestCase): 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.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) @@ -244,7 +236,7 @@ class CheckOnlyTests(unittest.TestCase): def test_real_capture_refreshes_lists_and_persists_without_installation(self): for count, index_rc in [(0, 0), (2, 0), (2, 1)]: with self.subTest(count=count, index_rc=index_rc): - self.state = 'stopped' + 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'} @@ -264,7 +256,7 @@ class CheckOnlyTests(unittest.TestCase): patch.object(job.managed_installs.subprocess, 'run', side_effect=transport): job.run(100, 'real', reservation) stored = job.managed_installs.get_registry()['items'][0]['update_check'] - self.assertEqual(self.state, 'stopped') + self.assertEqual(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')) diff --git a/tests/lxc_updates/test_update_status_ui.cjs b/tests/lxc_updates/test_update_status_ui.cjs index 695fda8d..127bf321 100644 --- a/tests/lxc_updates/test_update_status_ui.cjs +++ b/tests/lxc_updates/test_update_status_ui.cjs @@ -44,7 +44,7 @@ for (const locale of ['en', 'sk']) { const t = key => key.split('.').reduce((o, part) => o?.[part], messages) ?? key const calls = [] const context = {exports: {}, t, customCmdEditingApp: null, uc: null, helperExists: false, - helperUsesWebUpdater: false, selectedVM: {vmid: 210}, openCustomCmdEditor() {}, + 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, @@ -58,6 +58,7 @@ for (const locale of ['en', 'sk']) { 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') @@ -108,6 +109,8 @@ for (const locale of ['en', 'sk']) { 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()}}, From 017b73fffdcfa27ad9681f9f63fc6add8f2a7b41 Mon Sep 17 00:00:00 2001 From: VAIO73 <50487331+Vaso73@users.noreply.github.com> Date: Thu, 15 Oct 2026 04:11:10 +0000 Subject: [PATCH 3/3] fix: clear obsolete LXC check failure markers --- AppImage/scripts/lxc_check_job.py | 54 +++++++++++++++++++++--- AppImage/scripts/managed_installs.py | 30 ++++++++++++- tests/lxc_updates/test_check_only_job.py | 21 +++++++++ tests/lxc_updates/test_observed_state.py | 32 ++++++++++++++ 4 files changed, 131 insertions(+), 6 deletions(-) diff --git a/AppImage/scripts/lxc_check_job.py b/AppImage/scripts/lxc_check_job.py index 548a3440..441dbd8d 100644 --- a/AppImage/scripts/lxc_check_job.py +++ b/AppImage/scripts/lxc_check_job.py @@ -38,6 +38,53 @@ def acquire(vmid): 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') @@ -101,9 +148,8 @@ def checked_os(vmid): def run(vmid, job_id, reservation): error = None check_started = False - invalid = LOCK_DIR / f'proxmenux-lxc-check-invalid-{int(vmid)}' try: - invalid.touch(mode=0o600) + _mark_check_invalid(vmid) guard(vmid) if status(vmid) != 'running': raise RuntimeError('Container is stopped; start it first') @@ -124,9 +170,7 @@ def run(vmid, job_id, reservation): error += '; package result could not be persisted' finally: if not error: - try: - invalid.unlink() - except OSError: + if not clear_invalid_marker(vmid): error = 'Successful check could not be published for installation' reservation.close() with _lock: diff --git a/AppImage/scripts/managed_installs.py b/AppImage/scripts/managed_installs.py index e85f3fb7..b05ab307 100644 --- a/AppImage/scripts/managed_installs.py +++ b/AppImage/scripts/managed_installs.py @@ -1796,6 +1796,22 @@ def _store_update_result(item: dict, result: dict) -> None: 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]: """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 updates_available: list[dict] = [] + successful_lxc_checks: list[tuple[int, int]] = [] with _lock: reg = _read_registry() items = reg.get("items", []) @@ -1854,6 +1871,7 @@ def check_for_updates(force: bool = False) -> list[dict]: checker = _CHECKERS.get(it.get("type")) if not checker: continue + check_started_ns = time.time_ns() if it.get('type') == 'lxc' else None try: result = checker(it) except Exception as e: @@ -1863,13 +1881,20 @@ def check_for_updates(force: bool = False) -> list[dict]: "last_check": _now_iso(), "error": str(e)} _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"]: updates_available.append(it) reg["items"] = items 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 @@ -1945,6 +1970,7 @@ def refresh_lxc(vmid: int, *, refresh_package_cache: bool = True) -> Optional[di else: _merge_detected_entry(target, entry, now) + check_started_ns = time.time_ns() try: result = (_check_lxc_updates(target) if refresh_package_cache else _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 if not _write_registry(reg): 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) diff --git a/tests/lxc_updates/test_check_only_job.py b/tests/lxc_updates/test_check_only_job.py index a6635970..f9804861 100644 --- a/tests/lxc_updates/test_check_only_job.py +++ b/tests/lxc_updates/test_check_only_job.py @@ -1,6 +1,7 @@ """Real job, Flask routes and shared flock; guest transport is isolated.""" import ast import datetime +import os import subprocess import sys import tempfile @@ -90,6 +91,26 @@ class CheckOnlyTests(unittest.TestCase): 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'), diff --git a/tests/lxc_updates/test_observed_state.py b/tests/lxc_updates/test_observed_state.py index 1c25828f..1a348259 100644 --- a/tests/lxc_updates/test_observed_state.py +++ b/tests/lxc_updates/test_observed_state.py @@ -365,6 +365,35 @@ class ObservedStateRoutesTests(unittest.TestCase): self.assertEqual(managed.get_registry()["items"][0]["_os_family"], "debian") 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): with patch.object(managed, "_DETECTORS", [managed._detect_lxc_containers]), \ patch.object(managed, "_refresh_lxc_pkg_cache_if_stale", return_value={}) as refresh: @@ -374,10 +403,13 @@ class ObservedStateRoutesTests(unittest.TestCase): self.assertEqual(managed.get_registry()["items"][0]["_os_family"], "debian") 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: item = managed.refresh_lxc(100) refresh.assert_called_once_with("100", "debian") self.assertEqual(item["update_check"]["_count"], 0) + self.assertFalse(marker.exists()) def test_other_lxc_has_independent_ip_cache_and_manual_rate_limit(self): conf200 = Path(self.temp.name) / "200.conf"