fix(oci): check high availability in the preflight so tests without Proxmox keep working

This commit is contained in:
MacRimi
2026-11-03 23:16:25 +01:00
parent dcb1d65ec8
commit 1dca76aaa5
3 changed files with 35 additions and 6 deletions
+10 -2
View File
@@ -624,7 +624,11 @@ def without_public_routes(config, vmid):
def refuse_high_availability(vmid):
"""Recreating a container that HA manages would race the HA manager, which
restarts or relocates it in the middle of the replacement."""
resources = json.loads(run('pvesh', 'get', '/cluster/ha/resources', '--output-format', 'json'))
try:
answer = run('pvesh', 'get', '/cluster/ha/resources', '--output-format', 'json')
except FileNotFoundError:
return # Not a Proxmox VE node: there is no HA manager to race.
resources = json.loads(answer)
if any(r.get('sid') == f'ct:{vmid}' for r in resources):
raise ValueError(translate(
'This container is managed by high availability. Remove ct:{vmid} from HA (Datacenter → HA, '
@@ -641,6 +645,9 @@ def preflight(record, candidate, config, coordinated=None, in_place=False, basel
raise ValueError(translate('Stack members are updated together with their stack'))
if instances.identity(config) != record['installation_id']:
raise ValueError(translate('The container identity changed; the container is not replaced'))
if not in_place and not coordinated:
# A stack checks its members itself; an in-place edit keeps the container.
refuse_high_availability(record['vmid'])
for key, value in (external_changes(record, config, adopt=not coordinated) if baseline else {}).items():
section, name = ADOPTABLE[key]
recorded = deployment.get(section, {}) if section else deployment
@@ -1213,7 +1220,8 @@ def apply(root, vmid, archive, operation, proposal=None, registry_digest=None, i
runtime_settings.file_binds(before))
original_gpu = gpu_devices.planned(record['deployment'])
desired_gpu = gpu_devices.planned(candidate['deployment'])
refuse_high_availability(vmid)
if coordinated:
refuse_high_availability(vmid)
archive = archive.resolve(strict=True)
check_archive(archive)
image = image_from_archive(str(archive))
+1 -2
View File
@@ -232,9 +232,8 @@ def update(vmid, acknowledge_external_data=False, proposal=None, keep_backup=Non
# An unsupported native edit still uses the existing installer. Decide
# the route before changing anything; actual validation/API failures
# must not silently switch to a destructive fallback.
# Also refuses a container that HA manages, before the image is downloaded.
transaction.preflight(record, desired, config)
# Refused before the image is downloaded, not after.
transaction.refuse_high_availability(vmid)
msg_ok(translate('Container checked'))
current = record['observed']['image'] if operation == 'update' else None
check = lambda: work_backup.locate(
+24 -2
View File
@@ -26,6 +26,28 @@ class HighAvailabilityTests(unittest.TestCase):
with ha_resources(200):
transaction.refuse_high_availability(101)
def test_a_host_without_pvesh_has_no_ha_to_refuse(self):
with patch.object(transaction, 'run', side_effect=FileNotFoundError('pvesh')):
transaction.refuse_high_availability(101)
def test_the_preflight_of_a_replacement_refuses_it(self):
record = {'vmid': 101, 'installation_id': 'install-1', 'deployment': {}}
with patch.object(transaction.instances, 'identity', return_value='install-1'), ha_resources(101), \
self.assertRaises(ValueError) as refused:
transaction.preflight(record, {'deployment': {}}, b'')
self.assertIn('ct:101', str(refused.exception))
def test_an_in_place_edit_and_a_stack_member_are_not_refused_here(self):
record = {'vmid': 101, 'installation_id': 'install-1', 'deployment': {}}
refuse = Mock()
with patch.object(transaction.instances, 'identity', return_value='install-1'), \
patch.object(transaction, 'refuse_high_availability', refuse), \
patch.object(transaction, 'external_changes', side_effect=RuntimeError('past the check')):
for options in ({'in_place': True}, {'coordinated': {'id': 'stack'}}):
with self.subTest(options=options), self.assertRaises(RuntimeError):
transaction.preflight(record, {'deployment': {}}, b'', **options)
refuse.assert_not_called()
def test_an_update_stops_before_downloading_the_image(self):
record = {'status': 'installed', 'observed': {'image': {}}}
resolve = Mock()
@@ -35,11 +57,11 @@ class HighAvailabilityTests(unittest.TestCase):
stack.enter_context(patch.object(update.instances, 'command', return_value=b''))
stack.enter_context(patch.object(update.transaction, 'candidate_contract', return_value={}))
stack.enter_context(patch.object(update.transaction, 'external_changes', return_value={}))
stack.enter_context(patch.object(update.transaction, 'preflight'))
stack.enter_context(patch.object(update.transaction, 'preflight',
side_effect=ValueError('managed by high availability')))
stack.enter_context(patch.object(update, 'resolve_archive', resolve))
stack.enter_context(patch.object(update, 'msg_info'))
stack.enter_context(patch.object(update, 'msg_ok'))
stack.enter_context(ha_resources(101))
with self.assertRaises(ValueError):
update.update(101)
resolve.assert_not_called()