diff --git a/.github/scripts/tests/test_command_descriptions.py b/.github/scripts/tests/test_command_descriptions.py index 6b34646d..8e48a44d 100644 --- a/.github/scripts/tests/test_command_descriptions.py +++ b/.github/scripts/tests/test_command_descriptions.py @@ -217,6 +217,10 @@ class CommandDescriptionsTests(unittest.TestCase): "reasonStoppedContainer", english["vmLxc"]["scheduled"]["reasonStoppedContainer"], ) + 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 88e0bc6e..a571c426 100644 --- a/AppImage/components/virtual-machines.tsx +++ b/AppImage/components/virtual-machines.tsx @@ -2465,7 +2465,7 @@ 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")} )} diff --git a/AppImage/messages/en/common.json b/AppImage/messages/en/common.json index 512338fa..83b3047e 100644 --- a/AppImage/messages/en/common.json +++ b/AppImage/messages/en/common.json @@ -1288,6 +1288,7 @@ "runFailed": "✗ failed", "runDeferred": "held for safety", "runSkipped": "nothing pending", + "runSkippedStatus": "run skipped", "reasonStoppedContainer": "Container is stopped; scheduled update was skipped.", "releaseDelaySummary": "{days}-day release hold", "releaseDelayLabel": "Wait after a new app release", diff --git a/AppImage/messages/sk/common.json b/AppImage/messages/sk/common.json index 20f9d9f8..42f2541a 100644 --- a/AppImage/messages/sk/common.json +++ b/AppImage/messages/sk/common.json @@ -1288,6 +1288,7 @@ "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.", "releaseDelaySummary": "odklad {days} deň/dní", "releaseDelayLabel": "Počkať po novej verzii aplikácie", diff --git a/AppImage/scripts/flask_server.py b/AppImage/scripts/flask_server.py index a85f7939..12ac5703 100644 --- a/AppImage/scripts/flask_server.py +++ b/AppImage/scripts/flask_server.py @@ -22245,7 +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 = 8 +_SCHEDULED_STOPPED_EXIT = 9 _DOCKER_ENGINE_INTEGRATED_COMMAND = ( 'python3 /usr/local/share/proxmenux/monitor-app/usr/bin/' 'update_docker_engine.py --vmid "$VMID"' diff --git a/scripts/lxc/apply_updates.sh b/scripts/lxc/apply_updates.sh index 9e3e0665..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 @@ -166,7 +167,7 @@ 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 8 + exit 9 fi STARTED_BY_PROXMENUX=0 OBSERVATION_ATTEMPTED=0 diff --git a/tests/lxc_updates/test_apply_updates.sh b/tests/lxc_updates/test_apply_updates.sh index 1e42e2a0..4595f172 100644 --- a/tests/lxc_updates/test_apply_updates.sh +++ b/tests/lxc_updates/test_apply_updates.sh @@ -164,7 +164,7 @@ run_case stopped_restore RUN_HELPER=0 UPDATE_COMMAND='update-custom' MOCK_INITIA 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 8 ]] || fail "scheduled_stopped returned $CASE_RC" +[[ $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" 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 index 61201925..07084740 100644 --- a/tests/lxc_updates/test_scheduled_reason_i18n.cjs +++ b/tests/lxc_updates/test_scheduled_reason_i18n.cjs @@ -73,7 +73,9 @@ for (const locale of ['en', 'sk']) { const render = compile(context) const translated = flattenText(render(true)) const expected = t('vmLxc.scheduled.reasonStoppedContainer') + const skippedLabel = t('vmLxc.scheduled.runSkippedStatus') assert(translated.includes(expected), `${locale}: known reason code must use the locale catalog`) + 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.scheduleLastRunReasonCode = 'unknown_reason' diff --git a/tests/lxc_updates/test_stopped_update_observation.py b/tests/lxc_updates/test_stopped_update_observation.py index 50d518b2..64ad79c1 100644 --- a/tests/lxc_updates/test_stopped_update_observation.py +++ b/tests/lxc_updates/test_stopped_update_observation.py @@ -135,7 +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": 8, + "_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"], diff --git a/tests/lxc_updates/test_update_contract.py b/tests/lxc_updates/test_update_contract.py index 89725469..f06894c7 100644 --- a/tests/lxc_updates/test_update_contract.py +++ b/tests/lxc_updates/test_update_contract.py @@ -362,17 +362,18 @@ class ScheduledStoppedContainerTests(unittest.TestCase): 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": 8, "os": os}) - self.assertTrue(reader(SimpleNamespace(returncode=8, stdout=marker, stderr=""), None, marker)) - self.assertFalse(reader(SimpleNamespace(returncode=8, stdout="", stderr=""), None, marker)) + 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=8, stdout="", stderr=""), log.name, marker)) + 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=8, stdout="", stderr="")) + runner = MagicMock(return_value=SimpleNamespace(returncode=9, stdout="", stderr="")) finalizer = MagicMock(return_value={"recorded": True}) reboot_probe = MagicMock() stopped_marker = MagicMock(return_value=True) @@ -388,7 +389,7 @@ class ScheduledStoppedContainerTests(unittest.TestCase): "os": os, "json": json, "_APPLY_UPDATES_SCRIPT": "/bin/true", - "_SCHEDULED_STOPPED_EXIT": 8, + "_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"), @@ -420,6 +421,7 @@ class ScheduledStoppedContainerTests(unittest.TestCase): 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")