diff --git a/.github/scripts/tests/test_command_descriptions.py b/.github/scripts/tests/test_command_descriptions.py index ec35c3b2..93186be9 100644 --- a/.github/scripts/tests/test_command_descriptions.py +++ b/.github/scripts/tests/test_command_descriptions.py @@ -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. diff --git a/AppImage/components/virtual-machines.tsx b/AppImage/components/virtual-machines.tsx index 87d1bf07..901655d4 100644 --- a/AppImage/components/virtual-machines.tsx +++ b/AppImage/components/virtual-machines.tsx @@ -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(null) const [scheduleLastRunStatus, setScheduleLastRunStatus] = useState(null) const [scheduleLastRunReason, setScheduleLastRunReason] = useState(null) + const [scheduleLastRunReasonCode, setScheduleLastRunReasonCode] = useState(null) const [scheduleLastRunLog, setScheduleLastRunLog] = useState(null) const [scheduleLastRunRebootRequired, setScheduleLastRunRebootRequired] = useState(false) const [scheduleLastRunRebootPackages, setScheduleLastRunRebootPackages] = useState([]) @@ -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 (
@@ -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")} )}
- {scheduleLastRunReason && ( + {scheduleLastRunReasonText && (
- {scheduleLastRunReason} + {scheduleLastRunReasonText}
)} {scheduleLastRunRebootRequired && ( diff --git a/AppImage/messages/en/common.json b/AppImage/messages/en/common.json index ca7e536d..80c4830e 100644 --- a/AppImage/messages/en/common.json +++ b/AppImage/messages/en/common.json @@ -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", diff --git a/AppImage/messages/sk/common.json b/AppImage/messages/sk/common.json index dc5b7b80..c0443d05 100644 --- a/AppImage/messages/sk/common.json +++ b/AppImage/messages/sk/common.json @@ -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", diff --git a/AppImage/scripts/flask_server.py b/AppImage/scripts/flask_server.py index 13da0971..ec9d029e 100644 --- a/AppImage/scripts/flask_server.py +++ b/AppImage/scripts/flask_server.py @@ -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'), diff --git a/AppImage/scripts/lxc_apps.py b/AppImage/scripts/lxc_apps.py index 9ea65ac7..494c9db5 100644 --- a/AppImage/scripts/lxc_apps.py +++ b/AppImage/scripts/lxc_apps.py @@ -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: diff --git a/AppImage/scripts/tests/test_lxc_scheduled_update_records.py b/AppImage/scripts/tests/test_lxc_scheduled_update_records.py index a2d89168..89bb0d59 100644 --- a/AppImage/scripts/tests/test_lxc_scheduled_update_records.py +++ b/AppImage/scripts/tests/test_lxc_scheduled_update_records.py @@ -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, diff --git a/scripts/lxc/apply_updates.sh b/scripts/lxc/apply_updates.sh index 5737e66b..f9bf36b8 100755 --- a/scripts/lxc/apply_updates.sh +++ b/scripts/lxc/apply_updates.sh @@ -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//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() { diff --git a/tests/lxc_updates/test_apply_updates.sh b/tests/lxc_updates/test_apply_updates.sh index 8b2ed500..4595f172 100644 --- a/tests/lxc_updates/test_apply_updates.sh +++ b/tests/lxc_updates/test_apply_updates.sh @@ -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" diff --git a/tests/lxc_updates/test_observed_state_ui.cjs b/tests/lxc_updates/test_observed_state_ui.cjs index 46226904..7b659bed 100644 --- a/tests/lxc_updates/test_observed_state_ui.cjs +++ b/tests/lxc_updates/test_observed_state_ui.cjs @@ -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") diff --git a/tests/lxc_updates/test_scheduled_reason_i18n.cjs b/tests/lxc_updates/test_scheduled_reason_i18n.cjs new file mode 100644 index 00000000..d2941c75 --- /dev/null +++ b/tests/lxc_updates/test_scheduled_reason_i18n.cjs @@ -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') diff --git a/tests/lxc_updates/test_stopped_update_observation.py b/tests/lxc_updates/test_stopped_update_observation.py index 2f348324..64ad79c1 100644 --- a/tests/lxc_updates/test_stopped_update_observation.py +++ b/tests/lxc_updates/test_stopped_update_observation.py @@ -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): diff --git a/tests/lxc_updates/test_update_contract.py b/tests/lxc_updates/test_update_contract.py index 5466a7e2..b3d0672d 100644 --- a/tests/lxc_updates/test_update_contract.py +++ b/tests/lxc_updates/test_update_contract.py @@ -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 { diff --git a/tests/lxc_updates/test_update_method_choice.py b/tests/lxc_updates/test_update_method_choice.py index affacbeb..84124962 100644 --- a/tests/lxc_updates/test_update_method_choice.py +++ b/tests/lxc_updates/test_update_method_choice.py @@ -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)