fix: address scheduled stopped LXC review feedback

This commit is contained in:
VAIO73
2026-10-16 17:21:34 +00:00
parent fdbd420961
commit 9bc83a13ef
11 changed files with 31 additions and 14 deletions
@@ -217,6 +217,10 @@ class CommandDescriptionsTests(unittest.TestCase):
"reasonStoppedContainer", "reasonStoppedContainer",
english["vmLxc"]["scheduled"]["reasonStoppedContainer"], english["vmLxc"]["scheduled"]["reasonStoppedContainer"],
) )
messages["vmLxc"]["scheduled"].setdefault(
"runSkippedStatus",
english["vmLxc"]["scheduled"]["runSkippedStatus"],
)
for key in ("noShell", "enterFailed"): for key in ("noShell", "enterFailed"):
messages["lxcTerminal"]["errors"].setdefault(key, english["lxcTerminal"]["errors"][key]) messages["lxcTerminal"]["errors"].setdefault(key, english["lxcTerminal"]["errors"][key])
# The notification history panel texts await the bot as well. # The notification history panel texts await the bot as well.
+1 -1
View File
@@ -2465,7 +2465,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
: scheduleLastRunStatus === "deferred" : scheduleLastRunStatus === "deferred"
? t("vmLxc.scheduled.runDeferred") ? t("vmLxc.scheduled.runDeferred")
: scheduleLastRunStatus === "skipped" : scheduleLastRunStatus === "skipped"
? t("vmLxc.scheduled.runSkipped") ? t("vmLxc.scheduled.runSkippedStatus")
: t("vmLxc.scheduled.runFailed")} : t("vmLxc.scheduled.runFailed")}
</span></> </span></>
)} )}
+1
View File
@@ -1288,6 +1288,7 @@
"runFailed": "✗ failed", "runFailed": "✗ failed",
"runDeferred": "held for safety", "runDeferred": "held for safety",
"runSkipped": "nothing pending", "runSkipped": "nothing pending",
"runSkippedStatus": "run skipped",
"reasonStoppedContainer": "Container is stopped; scheduled update was skipped.", "reasonStoppedContainer": "Container is stopped; scheduled update was skipped.",
"releaseDelaySummary": "{days}-day release hold", "releaseDelaySummary": "{days}-day release hold",
"releaseDelayLabel": "Wait after a new app release", "releaseDelayLabel": "Wait after a new app release",
+1
View File
@@ -1288,6 +1288,7 @@
"runFailed": "✗ zlyhalo", "runFailed": "✗ zlyhalo",
"runDeferred": "odložené z bezpečnostných dôvodov", "runDeferred": "odložené z bezpečnostných dôvodov",
"runSkipped": "nič nečaká", "runSkipped": "nič nečaká",
"runSkippedStatus": "spustenie bolo preskočené",
"reasonStoppedContainer": "Kontajner je vypnutý; plánovaná aktualizácia sa preskočila.", "reasonStoppedContainer": "Kontajner je vypnutý; plánovaná aktualizácia sa preskočila.",
"releaseDelaySummary": "odklad {days} deň/dní", "releaseDelaySummary": "odklad {days} deň/dní",
"releaseDelayLabel": "Počkať po novej verzii aplikácie", "releaseDelayLabel": "Počkať po novej verzii aplikácie",
+1 -1
View File
@@ -22245,7 +22245,7 @@ def api_proxmenux_self_update_status():
# so scheduled and manual applies cannot overlap. # so scheduled and manual applies cannot overlap.
_APPLY_UPDATES_SCRIPT = _LXC_APPLY_UPDATES_SCRIPT _APPLY_UPDATES_SCRIPT = _LXC_APPLY_UPDATES_SCRIPT
_SCHEDULED_STOPPED_EXIT = 8 _SCHEDULED_STOPPED_EXIT = 9
_DOCKER_ENGINE_INTEGRATED_COMMAND = ( _DOCKER_ENGINE_INTEGRATED_COMMAND = (
'python3 /usr/local/share/proxmenux/monitor-app/usr/bin/' 'python3 /usr/local/share/proxmenux/monitor-app/usr/bin/'
'update_docker_engine.py --vmid "$VMID"' 'update_docker_engine.py --vmid "$VMID"'
+2 -1
View File
@@ -45,6 +45,7 @@
# 6 post-update restart failed # 6 post-update restart failed
# 7 another ProxMenux update is already running for this CT # 7 another ProxMenux update is already running for this CT
# 8 started CT could not record its verification handoff identity # 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 # The frontend surfaces exit code + duration in a follow-up POST to
# /api/lxc-updates/<vmid>/applied so the notification event fires with # /api/lxc-updates/<vmid>/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 if [[ "$STATE" != "running" && "${EXECUTION_MODE:-}" == "scheduled" ]]; then
[[ -n "${SCHEDULED_STOPPED_MARKER:-}" ]] && printf '%s\n' "$SCHEDULED_STOPPED_MARKER" [[ -n "${SCHEDULED_STOPPED_MARKER:-}" ]] && printf '%s\n' "$SCHEDULED_STOPPED_MARKER"
echo "SKIPPED: CT $VMID is stopped; a scheduled update will not start it." echo "SKIPPED: CT $VMID is stopped; a scheduled update will not start it."
exit 8 exit 9
fi fi
STARTED_BY_PROXMENUX=0 STARTED_BY_PROXMENUX=0
OBSERVATION_ATTEMPTED=0 OBSERVATION_ATTEMPTED=0
+1 -1
View File
@@ -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' \ run_case scheduled_stopped RUN_HELPER=0 UPDATE_COMMAND='update-custom' \
EXECUTION_MODE=scheduled SCHEDULED_STOPPED_MARKER='fixture-stopped-marker' MOCK_INITIAL_STATE=stopped 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 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 'pct exec') -eq 0 ]] || fail "scheduled update executed inside a stopped CT"
[[ $(count CUSTOM_EXEC) -eq 0 ]] || fail "scheduled update ran a custom updater" [[ $(count CUSTOM_EXEC) -eq 0 ]] || fail "scheduled update ran a custom updater"
+9 -3
View File
@@ -78,7 +78,7 @@ function walk(node) {
if (ts.isVariableDeclaration(node)) { if (ts.isVariableDeclaration(node)) {
const name = node.name.getText(tree) const name = node.name.getText(tree)
if (wanted.has(name)) definitions.push(`const ${node.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)};`) preferenceDefinitions.push(`const ${node.getText(tree)};`)
if (["osUpdateStale", "osUpdateStatusKnown", "hasOsUpdates"].includes(name)) if (["osUpdateStale", "osUpdateStatusKnown", "hasOsUpdates"].includes(name))
status.push(`const ${node.getText(tree)};`) status.push(`const ${node.getText(tree)};`)
@@ -325,11 +325,11 @@ context.selectedVMRef.current = { vmid: 100 }
} }
for (const name of ["ScheduleEnabled", "ScheduleConfigured", "ScheduleCron", "SchedulePreset", for (const name of ["ScheduleEnabled", "ScheduleConfigured", "ScheduleCron", "SchedulePreset",
"ScheduleTarget", "ScheduleTargets", "ScheduleReleaseDelayDays", "ApplyBackup", "ApplyBackupStorage", "ScheduleTarget", "ScheduleTargets", "ScheduleReleaseDelayDays", "ApplyBackup", "ApplyBackupStorage",
"ApplyRestart", "ScheduleLastRunAt", "ScheduleLastRunStatus", "ScheduleLastRunReason", "ScheduleLastRunLog", "ApplyRestart", "ScheduleLastRunAt", "ScheduleLastRunStatus", "ScheduleLastRunReason", "ScheduleLastRunReasonCode", "ScheduleLastRunLog",
"ScheduleLastRunRebootRequired", "ScheduleLastRunRebootPackages", "ExternalCron", "ScheduleError", "ScheduleLastRunRebootRequired", "ScheduleLastRunRebootPackages", "ExternalCron", "ScheduleError",
"ScheduleLoaded", "BulkError", "BulkLoaded", "BulkEditMode", "BulkConfigured", "BulkTargets", "BulkPersistedTargets"]) "ScheduleLoaded", "BulkError", "BulkLoaded", "BulkEditMode", "BulkConfigured", "BulkTargets", "BulkPersistedTargets"])
preferenceContext[`set${name}`] = value => { preferenceState[name] = value } preferenceContext[`set${name}`] = value => { preferenceState[name] = value }
assert.equal(preferenceDefinitions.length, 3) assert.equal(preferenceDefinitions.length, 5)
const preferences = compile(preferenceDefinitions.join("\n") + ` const preferences = compile(preferenceDefinitions.join("\n") + `
exports.apply = applySchedulePayload; exports.schedule = loadSchedule; exports.bulk = loadBulkUpdate; exports.apply = applySchedulePayload; exports.schedule = loadSchedule; exports.bulk = loadBulkUpdate;
`, preferenceContext) `, preferenceContext)
@@ -340,6 +340,12 @@ context.selectedVMRef.current = { vmid: 100 }
assert.equal(preferenceState.ApplyRestart, false, "missing restart cannot inherit a reboot") assert.equal(preferenceState.ApplyRestart, false, "missing restart cannot inherit a reboot")
preferences.apply({ backup: false, backup_storage: "", restart: false }) preferences.apply({ backup: false, backup_storage: "", restart: false })
assert.equal(preferenceState.ApplyBackupStorage, "") 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 }) preferenceContext.vmModalCacheRef.current.schedule.set(213, { backup: true, backup_storage: "local", restart: true })
const previousSchedule = preferences.schedule(213) const previousSchedule = preferences.schedule(213)
assert.equal(preferenceState.ApplyBackup, true, "use only the selected guest's own cache") assert.equal(preferenceState.ApplyBackup, true, "use only the selected guest's own cache")
@@ -73,7 +73,9 @@ for (const locale of ['en', 'sk']) {
const render = compile(context) const render = compile(context)
const translated = flattenText(render(true)) const translated = flattenText(render(true))
const expected = t('vmLxc.scheduled.reasonStoppedContainer') 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(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`) assert(!translated.includes(context.scheduleLastRunReason), `${locale}: raw stopped reason must not leak into UI`)
context.scheduleLastRunReasonCode = 'unknown_reason' context.scheduleLastRunReasonCode = 'unknown_reason'
@@ -135,7 +135,7 @@ raise SystemExit(h.main())
"_json_list": lambda value: json.loads(value or '[]'), "_json_list": lambda value: json.loads(value or '[]'),
"_lxc_update_finalizations": {}, "_lxc_update_finalization_lock": threading.RLock(), "_lxc_update_finalizations": {}, "_lxc_update_finalization_lock": threading.RLock(),
"_LXC_UPDATE_FINALIZATION_TTL": 3600, "_LXC_UPDATE_FINALIZATION_TTL": 3600,
"_SCHEDULED_STOPPED_EXIT": 8, "_SCHEDULED_STOPPED_EXIT": 9,
"_fast_guest_status": lambda *_: self.state.read_text(), "_fast_guest_status": lambda *_: self.state.read_text(),
"_lxc_update_snapshot": lambda *_: {"ct_name": "fixture"}, "_lxc_update_snapshot": lambda *_: {"ct_name": "fixture"},
"_lxc_update_target_labels": lambda *_: ["OS"], "_lxc_update_target_labels": lambda *_: ["OS"],
+8 -6
View File
@@ -362,17 +362,18 @@ class ScheduledStoppedContainerTests(unittest.TestCase):
def test_stopped_marker_requires_the_scheduler_token_not_only_exit_code(self): def test_stopped_marker_requires_the_scheduler_token_not_only_exit_code(self):
marker = "PROXMENUX_SCHEDULED_STOPPED:fixture" marker = "PROXMENUX_SCHEDULED_STOPPED:fixture"
reader = self._stopped_marker_reader({"_SCHEDULED_STOPPED_EXIT": 8, "os": os}) reader = self._stopped_marker_reader({"_SCHEDULED_STOPPED_EXIT": 9, "os": os})
self.assertTrue(reader(SimpleNamespace(returncode=8, stdout=marker, stderr=""), None, marker)) self.assertTrue(reader(SimpleNamespace(returncode=9, stdout=marker, stderr=""), None, marker))
self.assertFalse(reader(SimpleNamespace(returncode=8, stdout="", 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)) self.assertFalse(reader(SimpleNamespace(returncode=4, stdout=marker, stderr=""), None, marker))
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8") as log: with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8") as log:
log.write("prefix\\n" + marker + "\\n") log.write("prefix\\n" + marker + "\\n")
log.flush() 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): 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}) finalizer = MagicMock(return_value={"recorded": True})
reboot_probe = MagicMock() reboot_probe = MagicMock()
stopped_marker = MagicMock(return_value=True) stopped_marker = MagicMock(return_value=True)
@@ -388,7 +389,7 @@ class ScheduledStoppedContainerTests(unittest.TestCase):
"os": os, "os": os,
"json": json, "json": json,
"_APPLY_UPDATES_SCRIPT": "/bin/true", "_APPLY_UPDATES_SCRIPT": "/bin/true",
"_SCHEDULED_STOPPED_EXIT": 8, "_SCHEDULED_STOPPED_EXIT": 9,
"_DOCKER_ENGINE_INTEGRATED_COMMAND": "integrated-docker", "_DOCKER_ENGINE_INTEGRATED_COMMAND": "integrated-docker",
"_create_lxc_update_log": lambda _vmid, _run_id: (None, None), "_create_lxc_update_log": lambda _vmid, _run_id: (None, None),
"_fast_guest_status": MagicMock(return_value="running"), "_fast_guest_status": MagicMock(return_value="running"),
@@ -420,6 +421,7 @@ class ScheduledStoppedContainerTests(unittest.TestCase):
stopped_marker.reset_mock() stopped_marker.reset_mock()
stopped_marker.return_value = False stopped_marker.return_value = False
runner.return_value = SimpleNamespace(returncode=8, stdout="", stderr="")
reboot_probe.return_value = (None, [], None) reboot_probe.return_value = (None, [], None)
outcome = self._worker(scope)(101, {"target": "os"}) outcome = self._worker(scope)(101, {"target": "os"})
self.assertEqual(outcome["status"], "failure") self.assertEqual(outcome["status"], "failure")