Merge pull request #456 from Vaso73/codex/lxc-scheduled-stopped-guard

fix: skip scheduled updates for stopped LXC
This commit is contained in:
MacRimi
2026-10-16 22:56:15 +02:00
committed by GitHub
14 changed files with 405 additions and 9 deletions
@@ -213,6 +213,18 @@ class CommandDescriptionsTests(unittest.TestCase):
# Model the bot's future output in temporary catalogs, just
# like the other pending keys above; never rewrite locales.
messages["scriptTerminal"].setdefault("endOfInput", english["scriptTerminal"]["endOfInput"])
messages["vmLxc"]["scheduled"].setdefault(
"reasonStoppedContainer",
english["vmLxc"]["scheduled"]["reasonStoppedContainer"],
)
messages["vmLxc"]["scheduled"].setdefault(
"reasonStoppedContainerHistorical",
english["vmLxc"]["scheduled"]["reasonStoppedContainerHistorical"],
)
messages["vmLxc"]["scheduled"].setdefault(
"runSkippedStatus",
english["vmLxc"]["scheduled"]["runSkippedStatus"],
)
for key in ("noShell", "enterFailed"):
messages["lxcTerminal"]["errors"].setdefault(key, english["lxcTerminal"]["errors"][key])
# The notification history panel texts await the bot as well.
+27 -3
View File
@@ -35,6 +35,23 @@ import DOMPurify from "dompurify"
import { marked } from "marked"
import { getCountFormKey, useI18n, useT } from "@/lib/i18n/provider"
// A schedule record predating the structured reason code still contains one
// of these exact diagnostic messages. Recognise only those known historical
// values so an already-recorded safe skip localizes after an upgrade, while
// unrelated freeform diagnostics remain verbatim for troubleshooting.
const LEGACY_STOPPED_SCHEDULE_REASONS = new Set([
"container is stopped; scheduled update was skipped",
"container was stopped before scheduled update could begin",
])
const scheduleReasonCodeFromPersisted = (reasonCode: unknown, reason: unknown): string | null => {
if (typeof reasonCode === "string" && reasonCode) return reasonCode
if (typeof reason === "string" && LEGACY_STOPPED_SCHEDULE_REASONS.has(reason.toLowerCase())) {
return "stopped_container"
}
return null
}
// Sent by /api/vms only for LXC rows, only when the user has enabled
// `lxc_updates_available` notifications. The Monitor populates this
// from managed_installs registry → frontend uses it to render the
@@ -2077,6 +2094,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
const [scheduleLastRunAt, setScheduleLastRunAt] = useState<string | null>(null)
const [scheduleLastRunStatus, setScheduleLastRunStatus] = useState<string | null>(null)
const [scheduleLastRunReason, setScheduleLastRunReason] = useState<string | null>(null)
const [scheduleLastRunReasonCode, setScheduleLastRunReasonCode] = useState<string | null>(null)
const [scheduleLastRunLog, setScheduleLastRunLog] = useState<string | null>(null)
const [scheduleLastRunRebootRequired, setScheduleLastRunRebootRequired] = useState(false)
const [scheduleLastRunRebootPackages, setScheduleLastRunRebootPackages] = useState<string[]>([])
@@ -2153,6 +2171,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
setScheduleLastRunAt(s.last_run_at || null)
setScheduleLastRunStatus(s.last_run_status || null)
setScheduleLastRunReason(s.last_run_reason || null)
setScheduleLastRunReasonCode(scheduleReasonCodeFromPersisted(s.last_run_reason_code, s.last_run_reason))
setScheduleLastRunLog(s.last_run_log || null)
setScheduleLastRunRebootRequired(s.last_run_reboot_required === true)
setScheduleLastRunRebootPackages(Array.isArray(s.last_run_reboot_packages)
@@ -2430,6 +2449,11 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
const renderScheduleRunDetails = (indented = false) => {
if (!scheduleLastRunAt || !selectedVM) return null
const spacing = indented ? "pl-4" : ""
const scheduleLastRunReasonText = scheduleLastRunReasonCode === "stopped_container"
? selectedVM.status === "stopped"
? t("vmLxc.scheduled.reasonStoppedContainer")
: t("vmLxc.scheduled.reasonStoppedContainerHistorical")
: scheduleLastRunReason
return (
<div className={`space-y-1.5 ${spacing}`}>
<div className="text-xs text-muted-foreground">
@@ -2443,14 +2467,14 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
: scheduleLastRunStatus === "deferred"
? t("vmLxc.scheduled.runDeferred")
: scheduleLastRunStatus === "skipped"
? t("vmLxc.scheduled.runSkipped")
? t("vmLxc.scheduled.runSkippedStatus")
: t("vmLxc.scheduled.runFailed")}
</span></>
)}
</div>
{scheduleLastRunReason && (
{scheduleLastRunReasonText && (
<div className="text-xs text-muted-foreground break-words">
{scheduleLastRunReason}
{scheduleLastRunReasonText}
</div>
)}
{scheduleLastRunRebootRequired && (
+3
View File
@@ -1288,6 +1288,9 @@
"runFailed": "✗ failed",
"runDeferred": "held for safety",
"runSkipped": "nothing pending",
"runSkippedStatus": "run skipped",
"reasonStoppedContainer": "Container is stopped; scheduled update was skipped.",
"reasonStoppedContainerHistorical": "Container was stopped at the scheduled update time.",
"releaseDelaySummary": "{days}-day release hold",
"releaseDelayLabel": "Wait after a new app release",
"releaseDelayNone": "No waiting period",
+3
View File
@@ -1288,6 +1288,9 @@
"runFailed": "✗ zlyhalo",
"runDeferred": "odložené z bezpečnostných dôvodov",
"runSkipped": "nič nečaká",
"runSkippedStatus": "spustenie bolo preskočené",
"reasonStoppedContainer": "Kontajner je vypnutý; plánovaná aktualizácia sa preskočila.",
"reasonStoppedContainerHistorical": "Kontajner bol v čase plánovanej aktualizácie vypnutý.",
"releaseDelaySummary": "odklad {days} deň/dní",
"releaseDelayLabel": "Počkať po novej verzii aplikácie",
"releaseDelayNone": "Bez čakacej doby",
+44 -1
View File
@@ -22245,6 +22245,7 @@ def api_proxmenux_self_update_status():
# so scheduled and manual applies cannot overlap.
_APPLY_UPDATES_SCRIPT = _LXC_APPLY_UPDATES_SCRIPT
_SCHEDULED_STOPPED_EXIT = 9
_DOCKER_ENGINE_INTEGRATED_COMMAND = (
'python3 /usr/local/share/proxmenux/monitor-app/usr/bin/'
'update_docker_engine.py --vmid "$VMID"'
@@ -22286,6 +22287,23 @@ def _append_lxc_update_log(path: str | None, text: str) -> None:
print(f'[ProxMenux] scheduler: could not append update log: {exc}', flush=True)
def _scheduled_runner_reported_stopped(result, log_path: str | None, marker: str) -> bool:
"""Return true only for the scheduler's own pre-start stopped marker."""
if result.returncode != _SCHEDULED_STOPPED_EXIT or not marker:
return False
if log_path:
try:
with open(log_path, 'rb') as stream:
stream.seek(0, os.SEEK_END)
stream.seek(max(0, stream.tell() - 4096))
output = stream.read().decode('utf-8', errors='replace')
except OSError:
return False
else:
output = (result.stdout or '') + (result.stderr or '')
return marker in output
def _prune_lxc_update_logs(vmid: int) -> None:
try:
candidates = sorted(
@@ -22679,8 +22697,10 @@ def _run_scheduled_update(vmid: int, sched: dict) -> dict:
requested_targets = _normalise_schedule_targets(sched)
targets = list(requested_targets)
before = _lxc_update_snapshot(vmid)
requested_target = sched.get("target") or "both"
deferred_targets: list[str] = []
reasons: list[str] = []
reason_code: str | None = None
reboot_required: bool | None = None
reboot_packages: list[str] = []
reboot_check_error: str | None = None
@@ -22732,6 +22752,7 @@ def _run_scheduled_update(vmid: int, sched: dict) -> dict:
'status': status,
'target': actual_target,
'reason': reason,
'reason_code': reason_code,
'run_id': run_id,
'requested_targets': requested_targets,
'executed_targets': list(executed),
@@ -22743,10 +22764,23 @@ def _run_scheduled_update(vmid: int, sched: dict) -> dict:
'finalization': finalization,
}
requested_target = sched.get("target") or "both"
if not originally_running:
reason_code = 'stopped_container'
reasons.append('container is stopped; scheduled update was skipped')
return finish('skipped', requested_target, [])
if requested_targets == ['oci_image']:
# A container installed by OCI manager Apps is updated by replacing
# its image, through the same flow as the OCI menu, unattended.
# OCI updates have their own runner rather than apply_updates.sh.
# Check once more immediately before handing control to it: a user
# may have intentionally stopped the CT after the scheduler's first
# observation, and the OCI transaction can start a replacement CT
# while validating its new image.
if _fast_guest_status(vmid, 'lxc') != 'running':
reason_code = 'stopped_container'
reasons.append('container was stopped before scheduled update could begin')
return finish('skipped', 'oci_image', [])
command = ['bash', '/usr/local/share/proxmenux/scripts/oci/oci_manager_apps.sh',
'manage', str(vmid), '--action', 'update', '--unattended']
storage = (sched.get('backup_storage') or '').strip()
@@ -22887,6 +22921,9 @@ def _run_scheduled_update(vmid: int, sched: dict) -> dict:
target = "both" if has_os_target and has_app_targets else ("os" if has_os_target else "app")
env = dict(os.environ)
env["VMID"] = str(vmid)
env["EXECUTION_MODE"] = "scheduled"
scheduled_stopped_marker = f"PROXMENUX_SCHEDULED_STOPPED:{run_id}"
env["SCHEDULED_STOPPED_MARKER"] = scheduled_stopped_marker
env["TARGET"] = target
env["BACKUP"] = "1" if sched.get("backup") else "0"
env["BACKUP_STORAGE"] = sched.get("backup_storage") or ""
@@ -22952,6 +22989,10 @@ def _run_scheduled_update(vmid: int, sched: dict) -> dict:
text=True,
timeout=60 * 60,
)
if _scheduled_runner_reported_stopped(r, log_path, scheduled_stopped_marker):
reason_code = 'stopped_container'
reasons.append('container was stopped before scheduled update could begin')
return finish('skipped', target, [])
reboot_required, reboot_packages, reboot_check_error = (
_inspect_lxc_reboot_requirement(
vmid,
@@ -23027,12 +23068,14 @@ def _scheduler_loop():
status = outcome.get('status') or 'failure'
actual_target = outcome.get('target') or 'both'
reason = outcome.get('reason')
reason_code = outcome.get('reason_code')
try:
lxc_apps.record_schedule_run(
_vmid,
status,
actual_target,
reason,
reason_code=reason_code,
log_name=outcome.get('log_name'),
reboot_required=outcome.get('reboot_required'),
reboot_packages=outcome.get('reboot_packages'),
+6
View File
@@ -94,6 +94,7 @@ _VALID_UPSTREAM_TYPES = ("github", "http_json", "docker_hub")
# `*/N` step, and comma lists — that covers every preset the UI
# exposes and the freeform "custom" text field.
_VALID_SCHEDULE_TARGETS = ("os", "app", "both")
_SCHEDULE_REASON_CODES = frozenset({"stopped_container"})
_SCHEDULE_TARGET_ID_RE = re.compile(
r"^(?:os|apps|oci_image|app:[A-Za-z0-9_-]{1,64}|docker-engine|docker-(?:compose|container):[A-Za-z0-9][A-Za-z0-9_.-]{0,127}|docker-unit:[a-f0-9]{20})$"
)
@@ -3943,6 +3944,7 @@ def record_schedule_run(
target: str,
reason: Optional[str] = None,
*,
reason_code: Optional[str] = None,
log_name: Optional[str] = None,
reboot_required: Optional[bool] = None,
reboot_packages: Optional[list[str]] = None,
@@ -3962,6 +3964,10 @@ def record_schedule_run(
sidecar["schedule"]["last_run_reason"] = str(reason)[:300]
else:
sidecar["schedule"].pop("last_run_reason", None)
if reason_code in _SCHEDULE_REASON_CODES:
sidecar["schedule"]["last_run_reason_code"] = reason_code
else:
sidecar["schedule"].pop("last_run_reason_code", None)
if log_name:
sidecar["schedule"]["last_run_log"] = os.path.basename(str(log_name))[:220]
else:
@@ -33,6 +33,8 @@ class ScheduledUpdateRecordTests(unittest.TestCase):
self.vmid,
'success',
'both',
reason='container is stopped; scheduled update was skipped',
reason_code='stopped_container',
log_name='../9911-scheduled-' + ('a' * 32) + '.log',
reboot_required=True,
reboot_packages=['linux-image-amd64', 'libc6'],
@@ -43,11 +45,31 @@ class ScheduledUpdateRecordTests(unittest.TestCase):
'9911-scheduled-' + ('a' * 32) + '.log',
)
self.assertTrue(schedule['last_run_reboot_required'])
self.assertEqual(schedule['last_run_reason_code'], 'stopped_container')
self.assertEqual(
schedule['last_run_reboot_packages'],
['linux-image-amd64', 'libc6'],
)
def test_record_rejects_unknown_reason_code_and_clears_the_previous_one(self):
self.assertTrue(lxc_apps.record_schedule_run(
self.vmid,
'skipped',
'os',
reason='container is stopped; scheduled update was skipped',
reason_code='stopped_container',
))
self.assertTrue(lxc_apps.record_schedule_run(
self.vmid,
'failure',
'os',
reason='update runner exited with code 9',
reason_code='untrusted_value',
))
schedule = lxc_apps._read_sidecar(self.vmid)['schedule']
self.assertEqual(schedule['last_run_reason'], 'update runner exited with code 9')
self.assertNotIn('last_run_reason_code', schedule)
def test_lifecycle_clear_preserves_run_and_log(self):
lxc_apps.record_schedule_run(
self.vmid,
+6
View File
@@ -45,6 +45,7 @@
# 6 post-update restart failed
# 7 another ProxMenux update is already running for this CT
# 8 started CT could not record its verification handoff identity
# 9 scheduled run skipped because the CT was intentionally stopped
#
# The frontend surfaces exit code + duration in a follow-up POST to
# /api/lxc-updates/<vmid>/applied so the notification event fires with
@@ -163,6 +164,11 @@ fi
# 2) CT must be running for pct exec. Auto-start stopped CTs, then
# restore their original stopped state on every exit path.
STATE=$(pct status "$VMID" | awk '{print $2}')
if [[ "$STATE" != "running" && "${EXECUTION_MODE:-}" == "scheduled" ]]; then
[[ -n "${SCHEDULED_STOPPED_MARKER:-}" ]] && printf '%s\n' "$SCHEDULED_STOPPED_MARKER"
echo "SKIPPED: CT $VMID is stopped; a scheduled update will not start it."
exit 9
fi
STARTED_BY_PROXMENUX=0
OBSERVATION_ATTEMPTED=0
capture_before_stop() {
+9
View File
@@ -162,6 +162,15 @@ run_case stopped_restore RUN_HELPER=0 UPDATE_COMMAND='update-custom' MOCK_INITIA
[[ $(count 'pct start 101') -eq 1 ]] || fail "stopped CT was not started exactly once"
[[ $(count 'pct shutdown 101 --timeout 60') -eq 1 ]] || fail "stopped CT state was not restored"
run_case scheduled_stopped RUN_HELPER=0 UPDATE_COMMAND='update-custom' \
EXECUTION_MODE=scheduled SCHEDULED_STOPPED_MARKER='fixture-stopped-marker' MOCK_INITIAL_STATE=stopped
[[ $CASE_RC -eq 9 ]] || fail "scheduled_stopped returned $CASE_RC"
[[ $(count 'pct start 101') -eq 0 ]] || fail "scheduled update started a stopped CT"
[[ $(count 'pct exec') -eq 0 ]] || fail "scheduled update executed inside a stopped CT"
[[ $(count CUSTOM_EXEC) -eq 0 ]] || fail "scheduled update ran a custom updater"
grep -qF 'fixture-stopped-marker' "$TEST_ROOT/scheduled_stopped.out" \
|| fail "scheduled stopped result did not include its marker"
run_case locked RUN_HELPER=0 UPDATE_COMMAND='update-custom' MOCK_FLOCK_FAIL=1
[[ $CASE_RC -eq 7 ]] || fail "locked expected 7, got $CASE_RC"
[[ $(count CUSTOM_EXEC) -eq 0 ]] || fail "locked run executed an updater"
+9 -3
View File
@@ -78,7 +78,7 @@ function walk(node) {
if (ts.isVariableDeclaration(node)) {
const name = node.name.getText(tree)
if (wanted.has(name)) definitions.push(`const ${node.getText(tree)};`)
if (["applySchedulePayload", "loadSchedule", "loadBulkUpdate"].includes(name))
if (["LEGACY_STOPPED_SCHEDULE_REASONS", "scheduleReasonCodeFromPersisted", "applySchedulePayload", "loadSchedule", "loadBulkUpdate"].includes(name))
preferenceDefinitions.push(`const ${node.getText(tree)};`)
if (["osUpdateStale", "osUpdateStatusKnown", "hasOsUpdates"].includes(name))
status.push(`const ${node.getText(tree)};`)
@@ -325,11 +325,11 @@ context.selectedVMRef.current = { vmid: 100 }
}
for (const name of ["ScheduleEnabled", "ScheduleConfigured", "ScheduleCron", "SchedulePreset",
"ScheduleTarget", "ScheduleTargets", "ScheduleReleaseDelayDays", "ApplyBackup", "ApplyBackupStorage",
"ApplyRestart", "ScheduleLastRunAt", "ScheduleLastRunStatus", "ScheduleLastRunReason", "ScheduleLastRunLog",
"ApplyRestart", "ScheduleLastRunAt", "ScheduleLastRunStatus", "ScheduleLastRunReason", "ScheduleLastRunReasonCode", "ScheduleLastRunLog",
"ScheduleLastRunRebootRequired", "ScheduleLastRunRebootPackages", "ExternalCron", "ScheduleError",
"ScheduleLoaded", "BulkError", "BulkLoaded", "BulkEditMode", "BulkConfigured", "BulkTargets", "BulkPersistedTargets"])
preferenceContext[`set${name}`] = value => { preferenceState[name] = value }
assert.equal(preferenceDefinitions.length, 3)
assert.equal(preferenceDefinitions.length, 5)
const preferences = compile(preferenceDefinitions.join("\n") + `
exports.apply = applySchedulePayload; exports.schedule = loadSchedule; exports.bulk = loadBulkUpdate;
`, preferenceContext)
@@ -340,6 +340,12 @@ context.selectedVMRef.current = { vmid: 100 }
assert.equal(preferenceState.ApplyRestart, false, "missing restart cannot inherit a reboot")
preferences.apply({ backup: false, backup_storage: "", restart: false })
assert.equal(preferenceState.ApplyBackupStorage, "")
preferences.apply({ last_run_reason_code: "stopped_container", last_run_reason: "raw stopped reason" })
assert.equal(preferenceState.ScheduleLastRunReasonCode, "stopped_container",
"schedule payload publishes its structured stopped reason")
preferences.apply({})
assert.equal(preferenceState.ScheduleLastRunReasonCode, null,
"a missing reason code cannot leak from the previously selected guest")
preferenceContext.vmModalCacheRef.current.schedule.set(213, { backup: true, backup_storage: "local", restart: true })
const previousSchedule = preferences.schedule(213)
assert.equal(preferenceState.ApplyBackup, true, "use only the selected guest's own cache")
@@ -0,0 +1,102 @@
// Execute the real scheduled-run detail JSX: a known backend reason code is
// localized, while an unknown/missing code keeps its raw diagnostic text.
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const vm = require('node:vm')
const root = path.resolve(__dirname, '../../AppImage')
const ts = require(path.join(root, 'node_modules/typescript'))
const source = fs.readFileSync(path.join(root, 'components/virtual-machines.tsx'), 'utf8')
const tree = ts.createSourceFile('vm.tsx', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
let declaration
let legacyReasons
let reasonCodeHelper
function walk(node) {
if (ts.isVariableDeclaration(node) && node.name.getText(tree) === 'renderScheduleRunDetails') declaration = node.getText(tree)
if (ts.isVariableDeclaration(node) && node.name.getText(tree) === 'LEGACY_STOPPED_SCHEDULE_REASONS') legacyReasons = node.getText(tree)
if (ts.isVariableDeclaration(node) && node.name.getText(tree) === 'scheduleReasonCodeFromPersisted') reasonCodeHelper = node.getText(tree)
ts.forEachChild(node, walk)
}
walk(tree)
assert(declaration, 'scheduled run detail renderer must exist')
assert(legacyReasons, 'legacy stopped-reason list must exist')
assert(reasonCodeHelper, 'persisted schedule reason-code helper must exist')
function compile(context) {
const result = ts.transpileModule(`const ${declaration}; exports.render = renderScheduleRunDetails;`, {
compilerOptions: { target: ts.ScriptTarget.ES2020, module: ts.ModuleKind.CommonJS, jsx: ts.JsxEmit.ReactJSX },
reportDiagnostics: true,
})
assert.equal(result.diagnostics.length, 0)
vm.runInNewContext(result.outputText, context)
return context.exports.render
}
function compileReasonCodeHelper() {
const result = ts.transpileModule(`const ${legacyReasons}; const ${reasonCodeHelper}; exports.reasonCode = scheduleReasonCodeFromPersisted;`, {
compilerOptions: { target: ts.ScriptTarget.ES2020, module: ts.ModuleKind.CommonJS },
reportDiagnostics: true,
})
assert.equal(result.diagnostics.length, 0)
const context = { exports: {}, Set }
vm.runInNewContext(result.outputText, context)
return context.exports.reasonCode
}
function flattenText(node) {
if (Array.isArray(node)) return node.map(flattenText).join(' ')
if (!node || typeof node !== 'object') return typeof node === 'string' ? node : ''
return flattenText(node.props?.children)
}
const persistedReasonCode = compileReasonCodeHelper()
assert.equal(persistedReasonCode('stopped_container', 'anything'), 'stopped_container')
assert.equal(persistedReasonCode(null, 'Container is stopped; scheduled update was skipped'), 'stopped_container')
assert.equal(persistedReasonCode(null, 'container was stopped before scheduled update could begin'), 'stopped_container')
assert.equal(persistedReasonCode('future_reason', 'raw diagnostic'), 'future_reason')
assert.equal(persistedReasonCode(null, 'raw diagnostic'), null)
for (const locale of ['en', 'sk']) {
const messages = JSON.parse(fs.readFileSync(path.join(root, `messages/${locale}/common.json`), 'utf8'))
const t = (key, variables = {}) => {
const message = key.split('.').reduce((value, part) => value?.[part], messages)
return String(message ?? key).replace(/\{([^}]+)\}/g, (_, name) => String(variables[name] ?? `{${name}}`))
}
const context = {
exports: {}, t, selectedVM: { vmid: 100, status: 'stopped' }, scheduleLastRunAt: '2026-10-16T05:10:02Z',
scheduleLastRunStatus: 'skipped', scheduleLastRunReasonCode: 'stopped_container',
scheduleLastRunReason: 'container is stopped; scheduled update was skipped',
scheduleLastRunRebootRequired: false, scheduleLastRunRebootPackages: [], scheduleLastRunLog: null,
openScheduleLog() {}, AlertTriangle: 'AlertTriangle', FileText: 'FileText',
require: () => ({ jsx: (type, props) => ({ type, props }), jsxs: (type, props) => ({ type, props }), Fragment: 'Fragment' }),
}
const render = compile(context)
const translated = flattenText(render(true))
const expectedCurrentReason = t('vmLxc.scheduled.reasonStoppedContainer')
const expectedHistoricalReason = t('vmLxc.scheduled.reasonStoppedContainerHistorical')
const skippedLabel = t('vmLxc.scheduled.runSkippedStatus')
const expectedCurrentText = locale === 'sk'
? 'Kontajner je vypnutý; plánovaná aktualizácia sa preskočila.'
: 'Container is stopped; scheduled update was skipped.'
const expectedHistoricalText = locale === 'sk'
? 'Kontajner bol v čase plánovanej aktualizácie vypnutý.'
: 'Container was stopped at the scheduled update time.'
assert.equal(expectedCurrentReason, expectedCurrentText,
`${locale}: stopped CT must describe its current stopped state`)
assert.equal(expectedHistoricalReason, expectedHistoricalText,
`${locale}: running CT must describe the stopped state at the prior scheduled run`)
assert(translated.includes(expectedCurrentReason), `${locale}: currently stopped CT must use the present-state catalog`)
assert(!translated.includes(expectedHistoricalReason), `${locale}: currently stopped CT must not use historical wording`)
assert(translated.includes(skippedLabel), `${locale}: skipped run must not claim that no updates are pending`)
assert(!translated.includes(context.scheduleLastRunReason), `${locale}: raw stopped reason must not leak into UI`)
context.selectedVM.status = 'running'
const historical = flattenText(render(true))
assert(historical.includes(expectedHistoricalReason), `${locale}: running CT must use historical wording for a past skipped run`)
assert(!historical.includes(expectedCurrentReason), `${locale}: running CT must not claim it is currently stopped`)
context.scheduleLastRunReasonCode = 'unknown_reason'
const fallback = flattenText(render(true))
assert(fallback.includes(context.scheduleLastRunReason), `${locale}: unknown code must retain the raw diagnostic`)
}
console.log('PASS scheduled reason i18n: EN/SK structured stopped reason and raw fallback')
@@ -119,7 +119,8 @@ raise SystemExit(h.main())
self.write("sleep", "#!/bin/sh\nexit 0\n")
self.write("flock", "#!/bin/sh\nexit 0\n")
names = {"_finalize_lxc_update", "_terminal_lxc_update_completed",
"api_lxc_updates_applied", "_run_scheduled_update"}
"api_lxc_updates_applied", "_run_scheduled_update",
"_scheduled_runner_reported_stopped"}
nodes = [n for n in ast.parse(observed.SERVER.read_text()).body
if isinstance(n, ast.FunctionDef) and n.name in names]
self.scope = self.fixture.scope
@@ -134,6 +135,7 @@ raise SystemExit(h.main())
"_json_list": lambda value: json.loads(value or '[]'),
"_lxc_update_finalizations": {}, "_lxc_update_finalization_lock": threading.RLock(),
"_LXC_UPDATE_FINALIZATION_TTL": 3600,
"_SCHEDULED_STOPPED_EXIT": 9,
"_fast_guest_status": lambda *_: self.state.read_text(),
"_lxc_update_snapshot": lambda *_: {"ct_name": "fixture"},
"_lxc_update_target_labels": lambda *_: ["OS"],
@@ -315,6 +317,7 @@ raise SystemExit(h.main())
self.scope['notification_manager'].emit_event.assert_called_once()
def test_scheduled_path_executes_real_runner_and_real_finalization(self):
self.state.write_text("running")
def run(command, **kwargs):
kwargs['env'].update({key: value for key, value in self.env.items()
if key != 'RUN_ID'})
@@ -322,7 +325,7 @@ raise SystemExit(h.main())
self.scope['subprocess'] = SimpleNamespace(run=run, TimeoutExpired=subprocess.TimeoutExpired)
result = self.scope['_run_scheduled_update'](100, {'targets': ['os'], 'target': 'os'})
self.assertEqual(result['status'], 'success', result)
self.assertEqual(self.state.read_text(), 'stopped')
self.assertEqual(self.state.read_text(), 'running')
self.assertEqual(self.fixture.row()['update_check']['count'], 0)
def test_legacy_cli_without_run_id_preserves_update_and_original_state(self):
+156
View File
@@ -1,4 +1,7 @@
import ast
import json
import os
import re
import sys
import subprocess
import tempfile
@@ -312,6 +315,159 @@ class AppCacheWriteThroughContractTests(unittest.TestCase):
self.assertIn("before_snapshot=before", scheduler_block)
class ScheduledStoppedContainerTests(unittest.TestCase):
@staticmethod
def _worker(scope):
source = ast.parse((REPO_ROOT / "AppImage" / "scripts" / "flask_server.py").read_text())
node = next(item for item in source.body
if isinstance(item, ast.FunctionDef) and item.name == "_run_scheduled_update")
exec(compile(ast.Module(body=[node], type_ignores=[]), "scheduled-update", "exec"), scope)
return scope["_run_scheduled_update"]
@staticmethod
def _stopped_marker_reader(scope):
source = ast.parse((REPO_ROOT / "AppImage" / "scripts" / "flask_server.py").read_text())
node = next(item for item in source.body
if isinstance(item, ast.FunctionDef) and item.name == "_scheduled_runner_reported_stopped")
exec(compile(ast.Module(body=[node], type_ignores=[]), "scheduled-marker", "exec"), scope)
return scope["_scheduled_runner_reported_stopped"]
def test_stopped_container_skips_before_runner_or_any_guest_effect(self):
runner = MagicMock()
finalizer = MagicMock(return_value={"recorded": True})
append_log = MagicMock()
scope = {
"time": SimpleNamespace(monotonic=MagicMock(return_value=10.0)),
"uuid": SimpleNamespace(uuid4=lambda: SimpleNamespace(hex="a" * 32)),
"datetime": __import__("datetime").datetime,
"subprocess": SimpleNamespace(run=runner, STDOUT=object(), TimeoutExpired=subprocess.TimeoutExpired),
"_create_lxc_update_log": lambda _vmid, _run_id: ("fixture.log", "/tmp/fixture.log"),
"_fast_guest_status": MagicMock(return_value="stopped"),
"_normalise_schedule_targets": lambda _sched: ["os"],
"_lxc_update_snapshot": lambda _vmid: {"ct_name": "fixture"},
"_lxc_update_target_labels": lambda *_: ["OS"],
"_append_lxc_update_log": append_log,
"_prune_lxc_update_logs": MagicMock(),
"_finalize_lxc_update": finalizer,
}
outcome = self._worker(scope)(101, {"target": "os"})
self.assertEqual(outcome["status"], "skipped")
self.assertEqual(outcome["executed_targets"], [])
self.assertIn("container is stopped", outcome["reason"])
self.assertEqual(outcome["reason_code"], "stopped_container")
runner.assert_not_called()
self.assertEqual(finalizer.call_args.kwargs["status"], "skipped")
self.assertEqual(finalizer.call_args.kwargs["source"], "scheduled")
def test_stopped_marker_requires_the_scheduler_token_not_only_exit_code(self):
marker = "PROXMENUX_SCHEDULED_STOPPED:fixture"
reader = self._stopped_marker_reader({"_SCHEDULED_STOPPED_EXIT": 9, "os": os})
self.assertTrue(reader(SimpleNamespace(returncode=9, stdout=marker, stderr=""), None, marker))
self.assertFalse(reader(SimpleNamespace(returncode=9, stdout="", stderr=""), None, marker))
self.assertFalse(reader(SimpleNamespace(returncode=8, stdout=marker, stderr=""), None, marker))
self.assertFalse(reader(SimpleNamespace(returncode=4, stdout=marker, stderr=""), None, marker))
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8") as log:
log.write("prefix\\n" + marker + "\\n")
log.flush()
self.assertTrue(reader(SimpleNamespace(returncode=9, stdout="", stderr=""), log.name, marker))
def test_runner_race_to_stopped_is_reported_as_skipped_without_reboot_probe(self):
runner = MagicMock(return_value=SimpleNamespace(returncode=9, stdout="", stderr=""))
finalizer = MagicMock(return_value={"recorded": True})
reboot_probe = MagicMock()
stopped_marker = MagicMock(return_value=True)
lxc = SimpleNamespace(
_read_sidecar=lambda _vmid: {"apps": []},
partition_scheduled_release_targets=lambda targets, _apps: (set(), targets),
)
scope = {
"time": SimpleNamespace(monotonic=MagicMock(return_value=10.0)),
"uuid": SimpleNamespace(uuid4=lambda: SimpleNamespace(hex="b" * 32)),
"datetime": __import__("datetime").datetime,
"subprocess": SimpleNamespace(run=runner, STDOUT=object(), TimeoutExpired=subprocess.TimeoutExpired),
"os": os,
"json": json,
"_APPLY_UPDATES_SCRIPT": "/bin/true",
"_SCHEDULED_STOPPED_EXIT": 9,
"_DOCKER_ENGINE_INTEGRATED_COMMAND": "integrated-docker",
"_create_lxc_update_log": lambda _vmid, _run_id: (None, None),
"_fast_guest_status": MagicMock(return_value="running"),
"_normalise_schedule_targets": lambda _sched: ["os"],
"_lxc_update_snapshot": lambda _vmid: {"ct_name": "fixture"},
"_lxc_update_target_labels": lambda *_: ["OS"],
"_append_lxc_update_log": MagicMock(),
"_prune_lxc_update_logs": MagicMock(),
"_finalize_lxc_update": finalizer,
"_scheduled_helper_enabled": lambda *_: False,
"_compose_scheduled_update_command": lambda *_: "",
"_inspect_lxc_reboot_requirement": reboot_probe,
"_scheduled_runner_reported_stopped": stopped_marker,
}
with patch.dict(sys.modules, {"lxc_apps": lxc}):
outcome = self._worker(scope)(101, {"target": "os"})
self.assertEqual(outcome["status"], "skipped")
self.assertEqual(outcome["executed_targets"], [])
self.assertIn("before scheduled update could begin", outcome["reason"])
self.assertEqual(outcome["reason_code"], "stopped_container")
self.assertEqual(runner.call_args.args[0], ["bash", "/bin/true"])
self.assertEqual(runner.call_args.kwargs["env"]["EXECUTION_MODE"], "scheduled")
self.assertTrue(runner.call_args.kwargs["env"]["SCHEDULED_STOPPED_MARKER"].startswith(
"PROXMENUX_SCHEDULED_STOPPED:scheduled-"))
stopped_marker.assert_called_once()
reboot_probe.assert_not_called()
stopped_marker.reset_mock()
stopped_marker.return_value = False
runner.return_value = SimpleNamespace(returncode=8, stdout="", stderr="")
reboot_probe.return_value = (None, [], None)
outcome = self._worker(scope)(101, {"target": "os"})
self.assertEqual(outcome["status"], "failure")
self.assertIn("exited with code 8", outcome["reason"])
stopped_marker.assert_called_once()
reboot_probe.assert_called_once()
def test_oci_race_to_stopped_skips_before_the_oci_engine_runs(self):
runner = MagicMock(return_value=SimpleNamespace(returncode=0, stdout="", stderr=""))
finalizer = MagicMock(return_value={"recorded": True})
status = MagicMock(side_effect=["running", "stopped"])
scope = {
"time": SimpleNamespace(monotonic=MagicMock(return_value=10.0)),
"uuid": SimpleNamespace(uuid4=lambda: SimpleNamespace(hex="c" * 32)),
"datetime": __import__("datetime").datetime,
"subprocess": SimpleNamespace(
run=runner,
STDOUT=object(),
DEVNULL=object(),
TimeoutExpired=subprocess.TimeoutExpired,
),
"os": os,
"re": re,
"_create_lxc_update_log": lambda _vmid, _run_id: (None, None),
"_fast_guest_status": status,
"_normalise_schedule_targets": lambda _sched: ["oci_image"],
"_lxc_update_snapshot": lambda _vmid: {"ct_name": "fixture"},
"_lxc_update_target_labels": lambda *_: ["OCI image"],
"_append_lxc_update_log": MagicMock(),
"_prune_lxc_update_logs": MagicMock(),
"_finalize_lxc_update": finalizer,
}
outcome = self._worker(scope)(101, {"targets": ["oci_image"]})
self.assertEqual(outcome["status"], "skipped")
self.assertEqual(outcome["target"], "oci_image")
self.assertEqual(outcome["executed_targets"], [])
self.assertEqual(outcome["reason_code"], "stopped_container")
self.assertIn("before scheduled update could begin", outcome["reason"])
self.assertEqual(status.call_count, 2)
runner.assert_not_called()
self.assertEqual(finalizer.call_args.kwargs["status"], "skipped")
class DockerStackNotificationTests(unittest.TestCase):
def _docker_app(self):
return {
@@ -37,6 +37,7 @@ def routes():
_lxc_update_target_labels=lambda *_: [],
_append_lxc_update_log=lambda *_: None,
_prune_lxc_update_logs=lambda *_: None,
_scheduled_runner_reported_stopped=lambda *_: False,
_finalize_lxc_update=Mock(return_value={}),
_inspect_lxc_reboot_requirement=lambda *_, **__: (False, [], None))
exec(compile(ast.Module(body=nodes, type_ignores=[]), 'update-routes', 'exec'), ns)