From e52460e991f1f0d05088668ab98b88bb7c8a90a1 Mon Sep 17 00:00:00 2001 From: Jonatan Date: Thu, 29 Oct 2026 00:51:58 +0100 Subject: [PATCH] fix(oci): validate managed routes during transaction recovery --- oci/remote/oci_instance_transaction.py | 18 +++++++++- .../test_stack_public_routes_lifecycle.py | 35 +++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/oci/remote/oci_instance_transaction.py b/oci/remote/oci_instance_transaction.py index 35b88352..8fef9429 100644 --- a/oci/remote/oci_instance_transaction.py +++ b/oci/remote/oci_instance_transaction.py @@ -1352,6 +1352,22 @@ def complete_recovery(root, journal, state): msg_info2(translate('Shared host files are kept as they are; the backup does not restore their content.')) +def check_recovery_runtime(config, state): + """Validate owned route assets before excluding their include from GPU/sysctl checks.""" + error = None + for key in ('record', 'candidate_contract'): + saved = state.get(key) or {} + try: + public_routes.check(config, saved.get('deployment', {}), state['vmid'], + state['record']['installation_id']) + break + except public_routes.RouteError as failure: + error = failure + else: + raise error + gpu_devices.check_recovery_entries(without_public_routes(config, state['vmid']), state) + + def recover(root, journal): state = json.loads(journal.read_text()) if state.get('kind') == 'native-modify': @@ -1418,7 +1434,7 @@ def recover(root, journal): if current_path.exists(): stop(vmid) current = owned(vmid, record['installation_id']) - gpu_devices.check_recovery_entries(current, state) + check_recovery_runtime(current, state) if unused_volumes(current).keys() - state.get('retained_unused', {}).keys(): raise ValueError(translate('There are extra disks or bind mounts outside the journal; the rootfs is not replaced')) move_retained_unused(state, to_stage=True) diff --git a/oci/tests/test_stack_public_routes_lifecycle.py b/oci/tests/test_stack_public_routes_lifecycle.py index 3079749b..1defb3e6 100644 --- a/oci/tests/test_stack_public_routes_lifecycle.py +++ b/oci/tests/test_stack_public_routes_lifecycle.py @@ -59,6 +59,41 @@ class ManagedRouteLifecycleTests(unittest.TestCase): with self.assertRaisesRegex(ValueError,'Invalid stack contract'): plans.validate_public_route_members(4900,bad) + def test_recovery_accepts_validated_owned_routes_and_keeps_unknown_include_guard(self): + with tempfile.TemporaryDirectory() as folder,self.assets(folder): + plan=deployment();routes.install(plan,4901,WORKER) + config=f'lxc.include: {routes.include_path(4901)}\n'.encode() + state={'vmid':4901,'record':{'installation_id':WORKER,'deployment':plan}, + 'candidate_contract':{'installation_id':WORKER,'deployment':copy.deepcopy(plan)}} + tx.check_recovery_runtime(config,state) + with self.assertRaisesRegex(ValueError,'not part of the journal'): + tx.check_recovery_runtime(config+b'lxc.include: /unknown/profile.conf\n',state) + + def test_recovery_refuses_tampered_owned_route_assets(self): + with tempfile.TemporaryDirectory() as folder,self.assets(folder): + plan=deployment();routes.install(plan,4901,WORKER) + config=f'lxc.include: {routes.include_path(4901)}\n'.encode() + state={'vmid':4901,'record':{'installation_id':WORKER,'deployment':plan}, + 'candidate_contract':{'installation_id':WORKER,'deployment':copy.deepcopy(plan)}} + routes.include_path(4901).write_text('lxc.hook.start-host: /untrusted\n') + with self.assertRaisesRegex(routes.RouteError,'was changed'): + tx.check_recovery_runtime(config,state) + + def test_recovery_refuses_an_owned_include_not_declared_by_either_contract(self): + with tempfile.TemporaryDirectory() as folder,self.assets(folder): + routes.install(deployment(),4901,WORKER) + state={'vmid':4901,'record':{'installation_id':WORKER,'deployment':{}}, + 'candidate_contract':{'installation_id':WORKER,'deployment':{}}} + with self.assertRaisesRegex(routes.RouteError,'differs from the contract'): + tx.check_recovery_runtime(f'lxc.include: {routes.include_path(4901)}\n'.encode(),state) + + def test_recovery_can_validate_the_candidate_route_contract(self): + with tempfile.TemporaryDirectory() as folder,self.assets(folder): + plan=deployment();routes.install(plan,4901,WORKER) + state={'vmid':4901,'record':{'installation_id':WORKER,'deployment':{}}, + 'candidate_contract':{'installation_id':WORKER,'deployment':plan}} + tx.check_recovery_runtime(f'lxc.include: {routes.include_path(4901)}\n'.encode(),state) + def test_plan_rejects_changed_target_and_stack_identities(self): for field in ('target_installation_id','stack_id'): bad=records();spec=bad[4901]['deployment']['stack_public_routes']