diff --git a/AppImage/scripts/flask_server.py b/AppImage/scripts/flask_server.py index 12ac5703..ec9d029e 100644 --- a/AppImage/scripts/flask_server.py +++ b/AppImage/scripts/flask_server.py @@ -22772,6 +22772,15 @@ def _run_scheduled_update(vmid: int, sched: dict) -> dict: 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() diff --git a/tests/lxc_updates/test_update_contract.py b/tests/lxc_updates/test_update_contract.py index f06894c7..b3d0672d 100644 --- a/tests/lxc_updates/test_update_contract.py +++ b/tests/lxc_updates/test_update_contract.py @@ -1,6 +1,7 @@ import ast import json import os +import re import sys import subprocess import tempfile @@ -429,6 +430,43 @@ class ScheduledStoppedContainerTests(unittest.TestCase): 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):