Files
ProxMenux/oci/tests/test_stack_public_routes_lifecycle.py

174 lines
11 KiB
Python

"""Managed frontend /32 routes remain bounded and portable across lifecycle APIs."""
import copy
import contextlib
import json
import os
from pathlib import Path
import sys
import tempfile
import unittest
from unittest.mock import patch
ROOT=Path(__file__).resolve().parents[1]
sys.path.insert(0,str(ROOT/'remote'))
import oci_instance_transaction as tx
import oci_restore_recovery as recovery
import oci_stack_plan as plans
import oci_stack_public_routes as routes
import oci_remove as removal
MAIN='11111111-1111-4111-8111-111111111111'
WORKER='22222222-2222-4222-8222-222222222222'
FOREIGN='33333333-3333-4333-8333-333333333333'
def deployment():
return {'vmid':4901,'mounts':[],'stack_public_routes':{'schema':1,'stack_id':MAIN,
'primary_vmid':4900,'vmid':4901,'installation_id':WORKER,'private_bridge':'vmbr15',
'private_address':'10.77.5.31/24','routes':[{'destination':'192.0.2.10','via':'10.77.5.30',
'target_vmid':4900,'target_installation_id':MAIN}]}}
def records():
return {4900:{'vmid':4900,'installation_id':MAIN,'deployment':{},'stack':{'members':[
{'vmid':4900,'installation_id':MAIN},{'vmid':4901,'installation_id':WORKER}]}},
4901:{'vmid':4901,'installation_id':WORKER,'deployment':deployment()}}
class ManagedRouteLifecycleTests(unittest.TestCase):
@contextlib.contextmanager
def assets(self,folder):
with patch.multiple(routes,DIRECTORY=Path(folder)/'private',HELPER=Path(folder)/'canonical.py',ROOT_UID=os.geteuid()):
# The installed engine is trusted; guest-carried code is never used.
routes.HELPER.write_bytes(Path(routes.__file__).read_bytes())
routes.HELPER.chmod(0o755)
yield
def test_managed_include_coexists_with_sysctl_validation_and_rejects_unknown_include(self):
with tempfile.TemporaryDirectory() as folder,self.assets(folder):
plan=deployment();routes.install(plan,4901,WORKER)
config=(f'description: proxmenux-instance={WORKER}\nrootfs: local-zfs:subvol-4901-disk-0,size=8G\n'
f'lxc.include: {routes.include_path(4901)}\n').encode()
tx.check_runtime_mounts(config,plan)
with self.assertRaisesRegex(ValueError,'sysctl include'):
tx.check_runtime_mounts(config+b'lxc.include: /unknown/profile.conf\n',plan)
with self.assertRaisesRegex(ValueError,'differs from the contract'):
tx.check_runtime_mounts(config,{'vmid':4901,'mounts':[]})
def test_plan_rejects_foreign_target_even_if_supplied_in_record_inventory(self):
good=records();plans.validate_public_route_members(4900,good)
bad=copy.deepcopy(good);bad[4999]={'vmid':4999,'installation_id':FOREIGN,'deployment':{}}
route=bad[4901]['deployment']['stack_public_routes']['routes'][0]
route.update(target_vmid=4999,target_installation_id=FOREIGN)
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']
if field=='stack_id':spec[field]=FOREIGN
else:spec['routes'][0][field]=FOREIGN
with self.subTest(field=field),self.assertRaisesRegex(ValueError,'Invalid stack contract'):
plans.validate_public_route_members(4900,bad)
def test_restore_renumbers_only_managed_paths_and_all_route_member_ids(self):
with tempfile.TemporaryDirectory() as folder,self.assets(folder):
mapped=recovery.renumber(records(),{4900:4800,4901:4801})
# Mapping dictionary keys are not VMID-valued fields: select records by their new vmid.
mapped={value['vmid']:value for value in mapped.values()}
plans.validate_public_route_members(4800,mapped)
spec=mapped[4801]['deployment']['stack_public_routes']
self.assertEqual((spec['vmid'],spec['primary_vmid'],spec['routes'][0]['target_vmid']),(4801,4800,4800))
self.assertEqual(spec['installation_id'],WORKER)
original=f'lxc.include: {routes.include_path(4901)}\nlxc.include: /unrelated/4901.conf\n'
changed=recovery.renumbered_lines(original,4901,4801)
self.assertIn(f'lxc.include: {routes.include_path(4801)}',changed)
self.assertIn('lxc.include: /unrelated/4901.conf',changed)
routes.check(changed.splitlines()[0]+'\n',mapped[4801]['deployment'],4801,WORKER,require_host_assets=False)
def test_restore_republishes_only_canonical_host_code_before_start(self):
with tempfile.TemporaryDirectory() as folder,self.assets(folder):
plan=recovery.renumber(deployment(),{4900:4800,4901:4801})
entry={'vmid':4801,'installation_id':WORKER,'config':f'lxc.include: {routes.include_path(4801)}\n',
'copy':{'record':{'deployment':plan},'untrusted_script':'do not execute'}}
with patch.object(recovery,'renumber_config'):
recovery.restore_host_files(entry)
self.assertEqual(routes.HELPER.read_bytes(),Path(routes.__file__).read_bytes())
self.assertEqual(json.loads(routes.contract_path(4801).read_text())['routes'][0]['target_vmid'],4800)
routes.check(entry['config'],plan,4801,WORKER)
def test_restore_plan_refuses_foreign_route_before_host_assets_are_created(self):
bad=records();bad[4901]['deployment']['stack_public_routes']['routes'][0]['target_vmid']=4999
plan={'primary':4900,'members':[{'vmid':v} for v in bad],'restored':[
{'vmid':v,'copy':{'record':record}} for v,record in bad.items()],'blockers':[]}
recovery.check_public_route_members(Path('/unused'),plan)
self.assertTrue(any('Invalid stack contract' in value for value in plan['blockers']))
def test_recreate_keeps_managed_routes_outside_editable_desired_state(self):
old={'vmid':4901,'installation_id':WORKER,'status':'installed',
'template':{'id':'fixture','proxmox':{'installer_profile':{}},
'first_run':{'endpoints':[{'scheme':'http','port':80,'path':'/'}]}},
'deployment':deployment()}
candidate=copy.deepcopy(old);candidate['deployment'].pop('stack_public_routes')
candidate['deployment']['resources']={'cores':2,'memory_mb':1024}
result=tx.candidate_contract(old,'recreate',{'operation':'recreate','candidate':candidate})
self.assertEqual(result['deployment']['stack_public_routes'],old['deployment']['stack_public_routes'])
result['deployment']['stack_public_routes']['routes'][0]['destination']='192.0.2.11'
self.assertEqual(old['deployment']['stack_public_routes']['routes'][0]['destination'],'192.0.2.10')
def test_orphan_sweep_cleans_only_absent_owned_routes_and_keeps_pending_or_reused_ids(self):
for case in ('absent','pending','reused','changed'):
with self.subTest(case=case),tempfile.TemporaryDirectory() as folder,self.assets(folder):
root=Path(folder)/'records';root.mkdir()
routes.install(deployment(),4901,WORKER)
if case=='changed':routes.include_path(4901).write_text('foreign hook')
with patch.multiple(removal,CLUSTER_NODES=Path(folder)/'nodes',
SNIPPETS=Path(folder)/'snippets',MONITOR_APPS=Path(folder)/'apps',
CLUSTER_RECORDS=Path(folder)/'cluster-records'), \
patch.object(removal.runtime_settings,'include_path',lambda vmid:Path(folder)/f'{vmid}.sysctls'), \
patch.object(removal.runtime_settings,'legacy_include_path',lambda vmid:Path(folder)/f'{vmid}.legacy'), \
patch.object(removal.runtime_settings,'seccomp_path',lambda vmid:Path(folder)/f'{vmid}.seccomp'), \
patch.object(removal.oci_console,'LOG_DIR',Path(folder)/'logs'), \
patch.object(removal.instances,'guest_exists',return_value=case=='reused'), \
patch.object(removal.instances,'has_contract',return_value=case=='pending'), \
patch.object(removal.instances,'read',return_value={'installation_id':WORKER}), \
patch.object(removal.instances,'release_orphan',return_value=False), \
patch.object(removal,'remove_host_state'), \
patch.object(removal.oci_console,'remove_log'), \
patch.object(removal,'release_shared_host_files'):
cleaned=removal.sweep_orphans(root)
self.assertEqual(cleaned,[4901] if case=='absent' else [])
self.assertEqual(routes.contract_path(4901).exists(),case!='absent')
self.assertEqual(routes.include_path(4901).exists(),case!='absent')