mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-02 10:52:22 +00:00
fix(oci): preserve private access to stack frontends across lifecycle operations
This commit is contained in:
@@ -42,6 +42,7 @@ class JobInstallIntegrationTests(unittest.TestCase):
|
||||
staged=Path(command[1]).parent/'oci_compose_jobs.py'
|
||||
self.assertEqual(staged.read_bytes(),(ROOT/'remote/oci_compose_jobs.py').read_bytes())
|
||||
self.assertEqual((staged.parent/'oci_carried_record.py').read_bytes(),(ROOT/'remote/oci_carried_record.py').read_bytes())
|
||||
self.assertEqual((staged.parent/'oci_stack_public_routes.py').read_bytes(),(ROOT/'remote/oci_stack_public_routes.py').read_bytes())
|
||||
return None
|
||||
with patch.object(installer,'_stream_process',side_effect=inspect):
|
||||
installer._run_remote_install(ROOT,template,{'deployment_kind':'generic-multi-lxc-stack'},'local',True)
|
||||
@@ -54,6 +55,7 @@ class JobInstallIntegrationTests(unittest.TestCase):
|
||||
with tarfile.open(fileobj=io.BytesIO(data),mode='r:gz') as archive:
|
||||
self.assertEqual(archive.extractfile('oci_compose_jobs.py').read(),(ROOT/'remote/oci_compose_jobs.py').read_bytes())
|
||||
self.assertEqual(archive.extractfile('oci_carried_record.py').read(),(ROOT/'remote/oci_carried_record.py').read_bytes())
|
||||
self.assertEqual(archive.extractfile('oci_stack_public_routes.py').read(),(ROOT/'remote/oci_stack_public_routes.py').read_bytes())
|
||||
return None
|
||||
with patch.object(installer,'_stream_process',side_effect=inspect):
|
||||
installer._run_remote_install(ROOT,template,{'deployment_kind':'generic-multi-lxc-stack'},'test-node',True)
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Persist route contracts before startup without losing other LXC includes."""
|
||||
import copy
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
import uuid
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / 'remote'))
|
||||
import install_generic_stack as runtime
|
||||
import oci_stack_public_routes as routes
|
||||
|
||||
class PublicRouteInstallTests(unittest.TestCase):
|
||||
def test_private_members_route_to_all_static_frontends_with_real_identities(self):
|
||||
services = [
|
||||
{'name':'web','vmid':4810,'main':True,'ip':'10.77.0.30','frontend_ipv4':'10.0.1.246/24','deployment':{}},
|
||||
{'name':'mail','vmid':4811,'main':False,'frontend':True,'ip':'10.77.0.31','frontend_ipv4':'10.0.1.245/24','deployment':{}},
|
||||
{'name':'worker','vmid':4812,'main':False,'ip':'10.77.0.32','deployment':{}},
|
||||
]
|
||||
identities = {service['vmid']: {'installation_id':str(uuid.uuid4())} for service in services}
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
config = Path(directory) / '4812.conf'
|
||||
config.write_text('hostname: worker\nlxc.include: /existing/sysctl.conf\n')
|
||||
def path(value):
|
||||
return config if str(value) == '/etc/pve/lxc/4812.conf' else Path(value)
|
||||
def install(deployment, vmid, identity):
|
||||
routes.validate(deployment,vmid,identity)
|
||||
return f'lxc.include: {routes.include_path(vmid)}'
|
||||
with patch.object(runtime,'Path',side_effect=path), patch.object(runtime.oci_instances,'read',side_effect=lambda root,vmid:identities[vmid]), patch.object(routes,'install',side_effect=install), patch.object(routes,'check',wraps=lambda text,plan,vmid,identity=None,**kw:routes.validate(plan,vmid,identity)):
|
||||
runtime.install_public_routes(services,4810,'vmbr10')
|
||||
self.assertIn('lxc.include: /existing/sysctl.conf\n',config.read_text())
|
||||
spec=services[2]['deployment']['stack_public_routes']
|
||||
self.assertEqual(spec['installation_id'],identities[4812]['installation_id'])
|
||||
self.assertEqual(spec['stack_id'],identities[4810]['installation_id'])
|
||||
self.assertEqual(spec['primary_vmid'],4810)
|
||||
self.assertEqual(spec['routes'],[
|
||||
{'destination':'10.0.1.245','via':'10.77.0.31','target_vmid':4811,'target_installation_id':identities[4811]['installation_id']},
|
||||
{'destination':'10.0.1.246','via':'10.77.0.30','target_vmid':4810,'target_installation_id':identities[4810]['installation_id']}])
|
||||
self.assertNotIn('stack_public_routes',services[0]['deployment'])
|
||||
self.assertNotIn('stack_public_routes',services[1]['deployment'])
|
||||
|
||||
def test_dhcp_without_static_targets_does_not_invent_routes(self):
|
||||
services=[{'main':True,'frontend_ipv4':'dhcp','deployment':{}}]
|
||||
with patch.object(runtime.oci_instances,'read') as read:
|
||||
runtime.install_public_routes(services,4810,'vmbr10')
|
||||
read.assert_not_called()
|
||||
|
||||
def test_duplicate_static_ip_rejected_before_read_or_write(self):
|
||||
services=[{'main':True,'frontend_ipv4':'10.0.1.246/24'}, {'frontend':True,'frontend_ipv4':'10.0.1.246/24'}]
|
||||
with patch.object(runtime.oci_instances,'read') as read:
|
||||
with self.assertRaisesRegex(RuntimeError,'Duplicate'):runtime.install_public_routes(services,4810,'vmbr10')
|
||||
read.assert_not_called()
|
||||
|
||||
def test_recreate_route_include_is_last_after_other_managed_includes(self):
|
||||
source=(ROOT/'remote/install_oci.sh').read_text()
|
||||
flow=source[source.index('prepare_managed_format_directories "/var/lib/lxc/'):]
|
||||
self.assertLess(flow.index('apply_host_monitor\n'),flow.index('ROUTE_INCLUDE=$(python3'))
|
||||
self.assertLess(flow.index('apply_kept_settings\n'),flow.index('ROUTE_INCLUDE=$(python3'))
|
||||
|
||||
def test_routes_installed_before_jobs_and_main_start(self):
|
||||
source=(ROOT/'remote/install_generic_stack.py').read_text()
|
||||
main=source[source.index('def main('):]
|
||||
self.assertLess(main.index('install_public_routes(services, primary_id, bridge)'),main.index('bind_completed_jobs(services)'))
|
||||
self.assertLess(main.index('install_public_routes(services, primary_id, bridge)'),main.index("run('pct','start',primary_id"))
|
||||
|
||||
if __name__ == '__main__':unittest.main()
|
||||
@@ -0,0 +1,199 @@
|
||||
"""Managed routes cannot configure an unowned guest or the host namespace."""
|
||||
import copy
|
||||
from contextlib import ExitStack
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'remote'))
|
||||
import oci_stack_public_routes as routes
|
||||
|
||||
OWNER = '11111111-1111-4111-8111-111111111111'
|
||||
PRIMARY = '22222222-2222-4222-8222-222222222222'
|
||||
MAC = '02:11:22:33:44:55'
|
||||
|
||||
|
||||
def fixture():
|
||||
return {'stack_public_routes': {'schema': 1, 'stack_id': PRIMARY, 'primary_vmid': 4900,
|
||||
'vmid': 4901, 'installation_id': OWNER, 'private_bridge': 'vmbr15',
|
||||
'private_address': '10.77.5.31/24', 'routes': [
|
||||
{'destination': '172.30.249.90', 'via': '10.77.5.30',
|
||||
'target_vmid': 4900, 'target_installation_id': PRIMARY}]}}
|
||||
|
||||
|
||||
class PublicRouteTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
root = Path(self.temp.name)
|
||||
self.stack = ExitStack()
|
||||
self.addCleanup(self.stack.close)
|
||||
for key, value in {'DIRECTORY': root/'owned', 'HELPER': root/'helper.py',
|
||||
'CONFIGS': root/'configs', 'ROOT_UID': os.getuid()}.items():
|
||||
self.stack.enter_context(patch.object(routes, key, value))
|
||||
routes.CONFIGS.mkdir()
|
||||
routes.HELPER.write_text(Path(routes.__file__).read_text())
|
||||
routes.HELPER.chmod(0o644)
|
||||
self.deployment = fixture()
|
||||
self.spec = self.deployment['stack_public_routes']
|
||||
|
||||
def config(self, vmid, identity, private, public=None, mac=MAC):
|
||||
text = f'# proxmenux-instance={identity}\nnet0: name=eth0,bridge=vmbr15,ip={private},hwaddr={mac}\n'
|
||||
if public:
|
||||
text += f'net1: name=eth1,bridge=vmbr909,ip={public}\n'
|
||||
if vmid == 4901:
|
||||
text += f'lxc.include: {routes.include_path(vmid)}\n'
|
||||
path = routes.CONFIGS/f'{vmid}.conf'
|
||||
path.write_text(text)
|
||||
path.chmod(0o600)
|
||||
return text
|
||||
|
||||
def install(self):
|
||||
return routes.install(self.deployment, 4901, OWNER)
|
||||
|
||||
def test_install_check_canonical_include_and_root_owned_contract(self):
|
||||
line = self.install()
|
||||
self.assertEqual(line, f'lxc.include: {routes.include_path(4901)}')
|
||||
self.assertNotIn('lxc.hook.version', routes.include_path(4901).read_text())
|
||||
self.assertIn(' -I ', routes.include_content())
|
||||
self.assertEqual(routes.check(line, self.deployment, 4901, OWNER), self.spec)
|
||||
self.assertEqual(routes.contract_path(4901).stat().st_mode & 0o777, 0o600)
|
||||
self.assertEqual(routes.HELPER.stat().st_mode & 0o777, 0o644)
|
||||
|
||||
def test_restore_can_validate_without_assets_but_not_a_foreign_include(self):
|
||||
line = f'lxc.include: {routes.include_path(4901)}\n'
|
||||
self.assertEqual(routes.check(line, self.deployment, 4901, OWNER, require_host_assets=False), self.spec)
|
||||
with self.assertRaises(routes.RouteError):
|
||||
routes.check(line.replace('4901.conf','4902.conf'), self.deployment, 4901, OWNER, require_host_assets=False)
|
||||
self.assertEqual(routes.renumber_line(line.encode(),4901,5901), f'lxc.include: {routes.include_path(5901)}\n'.encode())
|
||||
|
||||
def test_reused_vmid_cannot_remove_another_installation_contract(self):
|
||||
self.install()
|
||||
with self.assertRaises(routes.RouteError):routes.remove(4901, PRIMARY)
|
||||
self.assertTrue(routes.contract_path(4901).exists())
|
||||
self.assertTrue(routes.remove(4901, OWNER))
|
||||
self.assertFalse(routes.include_path(4901).exists())
|
||||
|
||||
def test_bad_identity_and_unsafe_routes_fail_before_commands(self):
|
||||
changes = [lambda s:s.update(vmid=4902), lambda s:s.update(installation_id=PRIMARY),
|
||||
lambda s:s['routes'][0].update(destination='0.0.0.0/0'),
|
||||
lambda s:s['routes'][0].update(destination='127.0.0.1'),
|
||||
lambda s:s['routes'][0].update(via='172.30.249.1'),
|
||||
lambda s:s['routes'][0].update(destination=True),
|
||||
lambda s:s['routes'].append(copy.deepcopy(s['routes'][0]))]
|
||||
with patch.object(routes.subprocess,'run') as run:
|
||||
for change in changes:
|
||||
value=copy.deepcopy(self.deployment);change(value['stack_public_routes'])
|
||||
with self.subTest(value=value),self.assertRaises(routes.RouteError):routes.validate(value,4901,OWNER)
|
||||
run.assert_not_called()
|
||||
|
||||
def test_symlink_and_unsafe_helper_fail_closed(self):
|
||||
routes.DIRECTORY.mkdir()
|
||||
target=Path(self.temp.name)/'other';target.write_text('do not touch')
|
||||
routes.include_path(4901).symlink_to(target)
|
||||
with self.assertRaises(routes.RouteError):self.install()
|
||||
self.assertEqual(target.read_text(),'do not touch')
|
||||
routes.include_path(4901).unlink()
|
||||
routes.HELPER.chmod(0o666)
|
||||
with self.assertRaises(routes.RouteError):self.install()
|
||||
|
||||
def test_symlink_directory_and_foreign_file_owner_are_rejected(self):
|
||||
outside=Path(self.temp.name)/'outside';outside.mkdir()
|
||||
routes.DIRECTORY.symlink_to(outside, target_is_directory=True)
|
||||
with self.assertRaises(routes.RouteError):self.install()
|
||||
unsafe=SimpleNamespace(lstat=lambda:SimpleNamespace(st_mode=stat.S_IFREG|0o600,st_uid=routes.ROOT_UID+1,st_size=10))
|
||||
with self.assertRaises(routes.RouteError):routes._read(unsafe)
|
||||
|
||||
def test_live_identity_and_frontend_addresses_are_verified(self):
|
||||
self.install()
|
||||
self.config(4901,OWNER,'10.77.5.31/24')
|
||||
self.config(4900,PRIMARY,'10.77.5.30/24','172.30.249.90/24')
|
||||
self.assertEqual(routes.live_contract(self.spec),MAC)
|
||||
self.config(4900,OWNER,'10.77.5.30/24','172.30.249.90/24')
|
||||
with self.assertRaises(routes.RouteError):routes.live_contract(self.spec)
|
||||
self.config(4900,PRIMARY,'10.77.5.30/24','172.30.249.91/24')
|
||||
with self.assertRaises(routes.RouteError):routes.live_contract(self.spec)
|
||||
|
||||
def test_tampered_or_world_writable_contract_is_rejected(self):
|
||||
self.install();routes.contract_path(4901).chmod(0o666)
|
||||
with self.assertRaises(routes.RouteError):routes.check(f'lxc.include: {routes.include_path(4901)}',self.deployment,4901,OWNER)
|
||||
|
||||
def test_namespace_is_a_net_namespace_and_never_the_host(self):
|
||||
with patch.object(routes.os,'open',return_value=17),patch.object(routes.os,'close') as close, \
|
||||
patch.object(routes.fcntl,'ioctl',return_value=routes.CLONE_NEWNET), \
|
||||
patch.object(routes.os,'fstat',return_value=SimpleNamespace(st_dev=1,st_ino=9)), \
|
||||
patch.object(routes.os,'stat',return_value=SimpleNamespace(st_dev=1,st_ino=9)):
|
||||
with self.assertRaisesRegex(routes.RouteError,'host network'):routes.namespace_fd('/proc/123/fd/4')
|
||||
close.assert_called_once_with(17)
|
||||
with patch.object(routes.os,'open',return_value=17),patch.object(routes.os,'close'), \
|
||||
patch.object(routes.fcntl,'ioctl',return_value=0):
|
||||
with self.assertRaisesRegex(routes.RouteError,'not a network'):routes.namespace_fd('/proc/123/fd/4')
|
||||
with patch.object(routes.os,'open') as opened:
|
||||
for value in (None,'/proc/self/ns/net','/etc/passwd','/proc/123/ns/mnt'):
|
||||
with self.assertRaises(routes.RouteError):routes.namespace_fd(value)
|
||||
opened.assert_not_called()
|
||||
|
||||
def test_routes_are_only_32_in_verified_namespace_with_no_shell(self):
|
||||
interface=[{'address':MAC,'addr_info':[{'family':'inet','local':'10.77.5.31','prefixlen':24}]}]
|
||||
with patch.object(routes,'_namespace_command',side_effect=[json.dumps(interface).encode(),b'']) as command:
|
||||
routes.apply(self.spec,17,MAC)
|
||||
self.assertEqual(command.call_args_list[0].args,(17,'-j','address','show','dev','eth0'))
|
||||
self.assertEqual(command.call_args.args,(17,'-4','route','replace','172.30.249.90/32','via','10.77.5.30','dev','eth0','src','10.77.5.31'))
|
||||
with patch.object(routes.subprocess,'run',return_value=SimpleNamespace(stdout=b'ok')) as run:
|
||||
routes._namespace_command(17,'-4','route','show')
|
||||
self.assertEqual(run.call_args.args[0][:3],[routes.NSENTER,'--net=/proc/self/fd/17','--'])
|
||||
self.assertEqual(run.call_args.kwargs['pass_fds'],(17,));self.assertNotIn('shell',run.call_args.kwargs)
|
||||
|
||||
def test_engine_helper_can_change_normally_without_stale_snapshot_adoption(self):
|
||||
routes.HELPER.write_text('# trusted engine update, same stable path\n')
|
||||
self.install()
|
||||
self.assertEqual(routes.HELPER.read_text(),'# trusted engine update, same stable path\n')
|
||||
|
||||
def test_namespace_with_wrong_owner_address_cannot_receive_routes(self):
|
||||
for value in ([], [{'address':'02:aa:bb:cc:dd:ee','addr_info':[{'family':'inet','local':'10.77.5.31','prefixlen':24}]}]):
|
||||
with patch.object(routes,'_namespace_command',return_value=json.dumps(value).encode()) as command:
|
||||
with self.assertRaises(routes.RouteError):routes.apply(self.spec,17,MAC)
|
||||
self.assertEqual(command.call_count,1)
|
||||
|
||||
def test_ip_failure_propagates_so_startup_cannot_report_success(self):
|
||||
interface=[{'address':MAC,'addr_info':[{'family':'inet','local':'10.77.5.31','prefixlen':24}]}]
|
||||
with patch.object(routes,'_namespace_command',side_effect=[json.dumps(interface).encode(),subprocess.CalledProcessError(2,['ip'])]):
|
||||
with self.assertRaises(subprocess.CalledProcessError):routes.apply(self.spec,17,MAC)
|
||||
|
||||
def test_start_uses_only_the_runtime_pid_namespace(self):
|
||||
self.install()
|
||||
with patch.dict(os.environ,{'LXC_NAME':'4901','LXC_PID':'12345'},clear=True), \
|
||||
patch.object(routes.os,'geteuid',return_value=0), \
|
||||
patch.object(routes,'live_contract',return_value=MAC), \
|
||||
patch.object(routes,'namespace_fd',return_value=17) as namespace, \
|
||||
patch.object(routes,'apply') as apply, patch.object(routes.os,'close') as close:
|
||||
routes.start()
|
||||
namespace.assert_called_once_with('/proc/12345/ns/net')
|
||||
apply.assert_called_once_with(self.spec,17,MAC)
|
||||
close.assert_called_once_with(17)
|
||||
|
||||
def test_default_hook_arguments_must_match_owner_and_phase(self):
|
||||
with patch.dict(os.environ,{'LXC_NAME':'4901'},clear=True), \
|
||||
patch.object(routes.os,'geteuid',return_value=0),patch.object(routes,'start') as start:
|
||||
with patch.object(sys,'argv',['helper','start','4901','lxc','start-host']):routes.main()
|
||||
start.assert_called_once()
|
||||
start.reset_mock()
|
||||
with patch.object(sys,'argv',['helper','start','4902','lxc','start-host']), self.assertRaises(routes.RouteError):routes.main()
|
||||
with patch.object(sys,'argv',['helper','start','4901','lxc','post-stop']), self.assertRaises(routes.RouteError):routes.main()
|
||||
start.assert_not_called()
|
||||
|
||||
def test_missing_lxc_pid_is_not_replaced_by_a_host_fallback(self):
|
||||
self.install()
|
||||
with patch.dict(os.environ,{'LXC_NAME':'4901'},clear=True),patch.object(routes.os,'geteuid',return_value=0),patch.object(routes,'live_contract',return_value=MAC),patch.object(routes,'apply') as apply:
|
||||
with self.assertRaises(routes.RouteError):routes.start()
|
||||
apply.assert_not_called()
|
||||
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
@@ -0,0 +1,131 @@
|
||||
"""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_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.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')
|
||||
Reference in New Issue
Block a user