mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-02 19:02:31 +00:00
feat(oci): update and modify every application, and keep its address
- The backup taken before an update or a modification is made on another storage when it does not fit on the system disk; an operation without room says so before anything is stopped. - Privileged containers, relaxed AppArmor or seccomp, process limits, the host monitors Glances and Netdata and single-file host mounts are rebuilt and recovered like any other application. - HAOS One installs the docker-haos image: port 8123, host time zone, unprivileged, watchdog proposed by default; it is updated and modified with Home Assistant's own check on the rebuilt container. - A container that reboots itself gets its DHCP client back. - The installer takes the address of the container's own interface, and the notes of a suite or stack container show its LAN address. - Seerr installs: its volume belongs to the image user and no-new-privileges goes through the include of the container. - Removing an application deletes its seccomp profile.
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
"""The DHCP client of a container that restarted itself is started again."""
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest import TestCase
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / 'remote'))
|
||||
import oci_dhcp_lease as lease
|
||||
|
||||
NET = 'name=eth0,bridge=vmbr0,firewall=1,host-managed=1,hwaddr=BC:24:11:D5:0F:FD,type=veth'
|
||||
|
||||
|
||||
class ManagedInterfaceTests(TestCase):
|
||||
def test_only_host_managed_interfaces_on_dhcp(self):
|
||||
config = (f'net0: {NET},ip=dhcp\n'
|
||||
'net1: name=eth1,bridge=vmbr10,host-managed=1,ip=10.77.1.30/24,type=veth\n'
|
||||
'net2: name=eth2,bridge=vmbr0,ip=dhcp,type=veth\n'
|
||||
'net3: name=eth3,bridge=vmbr0,host-managed=1,ip=dhcp,ip6=dhcp,type=veth\n'
|
||||
'net4: name=eth4,bridge=vmbr0,host-managed=1,ip=dhcp,link_down=1,type=veth\n')
|
||||
self.assertEqual(lease.managed(config), [('eth0', 4), ('eth3', 4), ('eth3', 6)])
|
||||
|
||||
def test_snapshots_and_odd_names_are_ignored(self):
|
||||
config = ('net0: name=eth0;reboot,bridge=vmbr0,host-managed=1,ip=dhcp\n'
|
||||
f'[before-update]\nnet1: {NET},ip=dhcp\n')
|
||||
self.assertEqual(lease.managed(config), [])
|
||||
|
||||
|
||||
class RepairTests(TestCase):
|
||||
def setUp(self):
|
||||
tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(tmp.cleanup)
|
||||
self.root = Path(tmp.name)
|
||||
(self.root / 'conf').mkdir()
|
||||
(self.root / 'proc').mkdir()
|
||||
(self.root / 'conf/121.conf').write_text(f'net0: {NET},ip=dhcp\n')
|
||||
for item in (patch.object(lease, 'CONFIGS', self.root / 'conf'), patch.object(lease, 'PROC', self.root / 'proc'),
|
||||
patch.object(lease, 'init_pid', return_value=53014)):
|
||||
item.start()
|
||||
self.addCleanup(item.stop)
|
||||
|
||||
def client(self, pid, vmid):
|
||||
(self.root / f'proc/{pid}').mkdir()
|
||||
(self.root / f'proc/{pid}/cmdline').write_bytes(
|
||||
f'/sbin/dhclient\0-1\0-4\0-pf\0/var/lib/lxc/{vmid}/hook/dhclient4-eth0.pid\0eth0\0'.encode())
|
||||
|
||||
def test_a_missing_client_is_started(self):
|
||||
self.client(1661, 115)
|
||||
with patch.object(lease, 'start_client', return_value=True) as start:
|
||||
self.assertEqual(lease.repair(121), [('eth0', 4)])
|
||||
start.assert_called_once_with(121, 'eth0', 4, 53014)
|
||||
|
||||
def test_a_running_client_is_left_alone(self):
|
||||
self.client(42092, 121)
|
||||
with patch.object(lease, 'start_client') as start:
|
||||
self.assertEqual(lease.repair(121), [])
|
||||
start.assert_not_called()
|
||||
|
||||
def test_a_stopped_or_unknown_container_is_left_alone(self):
|
||||
with patch.object(lease, 'start_client') as start:
|
||||
with patch.object(lease, 'init_pid', return_value=None):
|
||||
self.assertEqual(lease.repair(121), [])
|
||||
self.assertEqual(lease.repair(999), [])
|
||||
start.assert_not_called()
|
||||
|
||||
def test_a_container_that_just_started_is_left_to_proxmox(self):
|
||||
with patch.object(lease, 'start_client', return_value=True) as start:
|
||||
with patch.object(lease, 'running_for', return_value=5.0):
|
||||
self.assertEqual(lease.repair(121, settle=lease.SETTLE), [])
|
||||
with patch.object(lease, 'running_for', return_value=3600.0):
|
||||
self.assertEqual(lease.repair(121, settle=lease.SETTLE), [('eth0', 4)])
|
||||
start.assert_called_once()
|
||||
|
||||
def test_every_container_with_a_managed_interface_is_listed(self):
|
||||
(self.root / 'conf/109.conf').write_text('net0: name=eth0,bridge=vmbr0,ip=dhcp,type=veth\n')
|
||||
(self.root / 'conf/100.conf').write_text(f'net0: {NET},ip=dhcp\n')
|
||||
self.assertEqual(lease.containers(), [100, 121])
|
||||
|
||||
def test_the_start_hook_queues_the_check(self):
|
||||
hook = (ROOT / 'remote/oci_console_mark.sh').read_text()
|
||||
self.assertIn('lease="${0%/*}/oci_dhcp_lease.py"', hook)
|
||||
self.assertIn('systemd-run --quiet --collect --on-active=20 /usr/bin/python3 "$lease" "$1"', hook)
|
||||
self.assertTrue(hook.rstrip().endswith('exit 0'))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import unittest
|
||||
unittest.main()
|
||||
@@ -0,0 +1,67 @@
|
||||
"""The notes of a container of a suite or stack carry the address it is reached at."""
|
||||
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "remote"))
|
||||
|
||||
import install_generic_stack as generic
|
||||
import oci_native_stack
|
||||
|
||||
RECORD = {"installation_id": "22d65764-750c-4aba-b0ef-55f5eb4dda0d",
|
||||
"template": {"id": "linuxserver-prowlarr", "catalog_ui": {"title": {"en_US": "Prowlarr"},
|
||||
"launch": {"scheme": "http", "port": 9696, "path": "/"}},
|
||||
"container_contract": {"image": {"reference": "lscr.io/linuxserver/prowlarr:latest"}}}}
|
||||
|
||||
|
||||
class GenericStackNotes(unittest.TestCase):
|
||||
def refresh(self, listed, status="status: running\n"):
|
||||
calls = []
|
||||
|
||||
def run(*args, **_):
|
||||
calls.append([str(arg) for arg in args])
|
||||
return status if args[:2] == ("pct", "status") else ""
|
||||
|
||||
def info(command, **_):
|
||||
if command[0] == "lxc-info":
|
||||
return subprocess.CompletedProcess(command, 0, stdout=listed, stderr="")
|
||||
return subprocess.CompletedProcess(command, 0, stdout="net0: name=eth0,bridge=vmbr11,ip=10.77.1.30/24,type=veth\n"
|
||||
"net1: name=eth1,bridge=vmbr0,ip=dhcp,type=veth\n", stderr="")
|
||||
with patch.object(generic, "run", side_effect=run), patch.object(generic.oci_instances, "read", return_value=RECORD), \
|
||||
patch.object(oci_native_stack.subprocess, "run", side_effect=info), \
|
||||
patch.object(oci_native_stack.time, "sleep"):
|
||||
generic.refresh_notes([{"vmid": 180}])
|
||||
written = [call for call in calls if call[:2] == ["pct", "set"]]
|
||||
self.assertEqual(len(written), 1)
|
||||
self.assertEqual(written[0][2:4], ["180", "--description"])
|
||||
return written[0][4]
|
||||
|
||||
def test_the_local_network_address_is_written_not_the_one_of_the_private_network(self):
|
||||
notes = self.refresh("10.77.1.30\n192.168.0.78\n")
|
||||
self.assertIn("http://192.168.0.78:9696/", notes)
|
||||
self.assertNotIn("10.77.1.30", notes)
|
||||
|
||||
def test_it_does_not_depend_on_the_range_of_the_local_network(self):
|
||||
notes = self.refresh("10.77.1.30\n10.0.1.141\n")
|
||||
self.assertIn("http://10.0.1.141:9696/", notes)
|
||||
self.assertNotIn("10.77.1.30", notes)
|
||||
|
||||
def test_a_stopped_container_gets_its_notes_without_an_address(self):
|
||||
notes = self.refresh("", status="status: stopped\n")
|
||||
self.assertNotIn("http://10.", notes)
|
||||
self.assertIn("Prowlarr OCI", notes)
|
||||
self.assertIn("proxmenux-instance=22d65764-750c-4aba-b0ef-55f5eb4dda0d", notes)
|
||||
|
||||
def test_a_container_whose_notes_cannot_be_written_does_not_stop_the_installation(self):
|
||||
with patch.object(generic, "run", side_effect=subprocess.CalledProcessError(1, "pct")), \
|
||||
patch.object(generic.oci_instances, "read", return_value=RECORD), patch.object(generic, "log") as log:
|
||||
generic.refresh_notes([{"vmid": 180}])
|
||||
self.assertIn("CT 180", log.call_args.args[1])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Home Assistant OS is updated and modified like any other application."""
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest import TestCase
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / 'remote'))
|
||||
import oci_instance_transaction as transaction
|
||||
|
||||
HAOS = json.loads((ROOT / 'catalog/curated/haos-one.json').read_text())
|
||||
CONFIG = (b'net0: name=eth0,bridge=vmbr0,firewall=1,host-managed=1,hwaddr=BC:24:11:D5:0F:FD,ip=dhcp,type=veth\n'
|
||||
b'ostype: unmanaged\n')
|
||||
ETH0 = b'2: eth0 inet 192.168.0.100/24 brd 192.168.0.255 scope global eth0\\ valid_lft forever\n'
|
||||
|
||||
|
||||
class HaosCheckTests(TestCase):
|
||||
def test_the_recipe_has_a_check_so_the_operation_is_not_refused(self):
|
||||
check = transaction.effective_healthcheck(HAOS)
|
||||
self.assertEqual(check, {'type': 'haos', 'timeout_seconds': 1200})
|
||||
|
||||
def test_the_check_is_left_to_the_installer_profile(self):
|
||||
contract = transaction.with_default_healthcheck({'template': json.loads(json.dumps(HAOS))})
|
||||
profile = contract['template']['proxmox']['installer_profile']
|
||||
self.assertNotIn('startup_healthcheck', profile)
|
||||
self.assertEqual(profile['haos_healthcheck'], {'timeout_seconds': 1200})
|
||||
|
||||
def run_commands(self, *args):
|
||||
if args[:2] == ('pct', 'config'):
|
||||
return CONFIG
|
||||
if args[:2] == ('pct', 'status'):
|
||||
return b'status: running\n'
|
||||
if args[0] == 'lxc-attach':
|
||||
return ETH0
|
||||
raise AssertionError(args)
|
||||
|
||||
def test_the_container_answers_on_its_own_interface(self):
|
||||
with patch.object(transaction, 'run', side_effect=self.run_commands):
|
||||
self.assertEqual(transaction.own_addresses(121), ['192.168.0.100'])
|
||||
|
||||
def test_a_restored_container_passes_the_check_of_home_assistant(self):
|
||||
import haos_healthcheck
|
||||
urls = [{'label': 'Home Assistant', 'url': 'http://192.168.0.100:8123/'}]
|
||||
with patch.object(transaction, 'run', side_effect=self.run_commands), \
|
||||
patch.object(transaction, 'log'), \
|
||||
patch.object(haos_healthcheck, 'wait_ready', return_value=urls) as wait:
|
||||
self.assertEqual(transaction.healthcheck(121, HAOS), 'http://192.168.0.100:8123/')
|
||||
wait.assert_called_once_with(121, '192.168.0.100', 1200)
|
||||
|
||||
def test_a_restored_container_that_does_not_pass_is_not_confirmed(self):
|
||||
import haos_healthcheck
|
||||
with patch.object(transaction, 'run', side_effect=self.run_commands), \
|
||||
patch.object(transaction, 'log'), \
|
||||
patch.object(haos_healthcheck, 'wait_ready', side_effect=RuntimeError('time is up')):
|
||||
with self.assertRaises(ValueError):
|
||||
transaction.healthcheck(121, HAOS)
|
||||
|
||||
|
||||
class InstallerAddressTests(TestCase):
|
||||
"""The installer takes the address of an interface of the container, not
|
||||
the one of a bridge Docker created inside it."""
|
||||
|
||||
def address(self, listed, attach):
|
||||
script = (ROOT / 'remote/install_oci.sh').read_text()
|
||||
start = script.index('own_ipv4() {')
|
||||
function = script[start:script.index('\n}\n', start) + 3]
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
for name, body in (('lxc-info', f'printf "{listed}"'),
|
||||
('pct', 'echo "net0: name=eth0,bridge=vmbr0,ip=dhcp,type=veth"'),
|
||||
('lxc-attach', attach)):
|
||||
path = Path(tmp) / name
|
||||
path.write_text(f'#!/bin/bash\n{body}\n')
|
||||
path.chmod(0o755)
|
||||
result = subprocess.run(['bash', '-c', f'PATH={tmp}:$PATH; VMID=121; {function}\nown_ipv4; echo "rc=$?"'],
|
||||
capture_output=True, text=True)
|
||||
return result.stdout.split()
|
||||
|
||||
def test_the_inner_bridges_are_skipped(self):
|
||||
attach = 'echo "2: eth0 inet 192.168.0.100/24 brd 192.168.0.255 scope global eth0"'
|
||||
self.assertEqual(self.address('172.30.232.1\\n172.30.32.1\\n192.168.0.100\\n', attach), ['192.168.0.100', 'rc=0'])
|
||||
|
||||
def test_no_address_yet_is_waited_for(self):
|
||||
self.assertEqual(self.address('172.30.232.1\\n', 'true'), ['rc=0'])
|
||||
|
||||
def test_interfaces_that_cannot_be_read_leave_it_to_the_caller(self):
|
||||
self.assertEqual(self.address('192.168.0.100\\n', 'exit 1'), ['rc=1'])
|
||||
self.assertEqual(self.address('', 'true'), ['rc=1'])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import unittest
|
||||
unittest.main()
|
||||
@@ -0,0 +1,177 @@
|
||||
"""A privileged container, relaxed confinement, process limits, a monitor of
|
||||
the host and a single mounted file are rebuilt like any other application."""
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest import TestCase
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / 'remote'))
|
||||
import oci_host_mounts as host_mounts
|
||||
import oci_instance_transaction as transaction
|
||||
import oci_runtime_settings as runtime_settings
|
||||
|
||||
INSTALLER = (ROOT / 'remote/install_oci.sh').read_text()
|
||||
GLANCES = json.loads((ROOT / 'catalog/apps/glances.json').read_text())
|
||||
MONITOR_CONFIG = (b'unprivileged: 0\n'
|
||||
b'lxc.apparmor.profile: unconfined\n'
|
||||
b'lxc.mount.entry: /etc/os-release etc/os-release none bind,create=file,ro 0 0\n'
|
||||
b'lxc.mount.entry: /srv/app.conf etc/app.conf none bind,create=file 0 0\n'
|
||||
b'lxc.mount.entry: tmpfs run/app tmpfs rw,size=64M,create=dir 0 0\n'
|
||||
b'lxc.include: /etc/pve/proxmenux/host-monitor\n'
|
||||
b'lxc.net.0.type: none\n')
|
||||
|
||||
|
||||
def bind(source, target, read_only=True):
|
||||
return {'type': 'host-bind', 'container_path': target, 'source': source, 'backup': False, 'read_only': read_only}
|
||||
|
||||
|
||||
class ProfileKeyTests(TestCase):
|
||||
def test_each_declared_setting_names_the_entries_the_installer_writes(self):
|
||||
deployment = {'security': {'options': {'apparmor_profile': 'unconfined', 'seccomp_profile': 'unconfined',
|
||||
'drop_all_capabilities': True, 'no_new_privileges': True}},
|
||||
'resources': {'rlimits': [{'name': 'nofile', 'soft': '65536', 'hard': '65536'}]},
|
||||
'host_monitor': 'glances'}
|
||||
self.assertEqual(transaction.profile_keys(deployment), {
|
||||
'lxc.apparmor.profile', 'lxc.seccomp.profile', 'lxc.cap.drop', 'lxc.cap.keep', 'lxc.include',
|
||||
'lxc.prlimit.nofile', 'lxc.net.0.type', 'lxc.hook.mount'})
|
||||
|
||||
def test_a_plain_application_adds_none(self):
|
||||
self.assertEqual(transaction.profile_keys({'security': {'unprivileged': True, 'options': {}},
|
||||
'resources': {'rlimits': []}}), set())
|
||||
|
||||
def test_a_host_monitor_is_checked_like_any_other_application(self):
|
||||
self.assertEqual(transaction.effective_healthcheck(GLANCES)['port'], 61208)
|
||||
template = {'proxmox': {'installer_profile': {'host_monitor': 'netdata'}},
|
||||
'first_run': {'endpoints': [{'scheme': 'http', 'port': 19999, 'path': '/'}]}}
|
||||
self.assertEqual(transaction.effective_healthcheck(template)['port'], 19999)
|
||||
|
||||
|
||||
class FileBindTests(TestCase):
|
||||
def test_single_files_are_read_from_the_raw_entries(self):
|
||||
self.assertEqual(runtime_settings.file_binds(MONITOR_CONFIG), {'/etc/os-release': True, '/srv/app.conf': False})
|
||||
|
||||
def test_declared_files_are_not_left_to_the_acceleration_check(self):
|
||||
deployment = {'mounts': [bind('/etc/os-release', '/etc/os-release')],
|
||||
'tmpfs_mounts': [{'container_path': '/run/app', 'size_mb': 64, 'mount_options': ['rw']}]}
|
||||
left = runtime_settings.filter_config(MONITOR_CONFIG, deployment).decode()
|
||||
self.assertNotIn('/etc/os-release', left)
|
||||
self.assertNotIn('tmpfs', left)
|
||||
self.assertNotIn('lxc.include', left)
|
||||
# One the deployment does not declare stays, and is refused there.
|
||||
self.assertIn('/srv/app.conf', left)
|
||||
|
||||
def test_a_file_is_told_apart_from_a_directory(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
file = Path(tmp) / 'os-release'
|
||||
file.write_text('x')
|
||||
self.assertTrue(transaction.is_file_bind(bind(str(file), '/etc/os-release')))
|
||||
self.assertFalse(transaction.is_file_bind(bind(tmp, '/data')))
|
||||
self.assertTrue(transaction.is_file_bind(bind('/gone/file', '/x'), {'/gone/file': True}))
|
||||
self.assertFalse(transaction.is_file_bind({'type': 'managed-volume', 'source': 'local-lvm'}))
|
||||
|
||||
def test_files_are_not_pinned_nor_ask_for_the_host_data_notice(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
file = Path(tmp) / 'os-release'
|
||||
file.write_text('x')
|
||||
record = {'template': {}, 'observed': {},
|
||||
'deployment': {'mounts': [bind(str(file), '/etc/os-release')]}}
|
||||
with patch.object(transaction, 'log'):
|
||||
self.assertEqual(transaction.freeze_host_sources(record, record, False), ({}, {}))
|
||||
|
||||
|
||||
class DeclaredSourceTests(TestCase):
|
||||
def test_only_a_host_monitor_declares_sources(self):
|
||||
contract = {'template': GLANCES, 'deployment': {'host_monitor': 'glances'}}
|
||||
self.assertEqual(transaction.declared_sources(contract), {'/etc/os-release'})
|
||||
self.assertEqual(transaction.declared_sources({'template': GLANCES, 'deployment': {'host_monitor': None}}), set())
|
||||
|
||||
def test_a_declared_system_directory_is_accepted_and_stays_declared(self):
|
||||
with self.assertRaises(ValueError):
|
||||
host_mounts.validate_source('/usr')
|
||||
value = host_mounts.validate_source('/usr', declared=True)
|
||||
self.assertTrue(value['declared'])
|
||||
host_mounts.verify_sources({'/usr': value})
|
||||
with self.assertRaises(ValueError):
|
||||
host_mounts.verify_sources({'/usr': {k: v for k, v in value.items() if k != 'declared'}})
|
||||
|
||||
def test_the_system_mounts_of_a_monitor_need_no_notice_about_host_data(self):
|
||||
record = {'template': {'proxmox': {'installer_profile': {'host_monitor_mounts': [{'source': '/usr', 'target': '/host/usr'}]}}},
|
||||
'observed': {}, 'deployment': {'host_monitor': 'netdata', 'mounts': [bind('/usr', '/host/usr')]}}
|
||||
with patch.object(transaction, 'log'):
|
||||
original, desired = transaction.freeze_host_sources(record, record, False)
|
||||
self.assertTrue(original['/usr']['declared'] and desired['/usr']['declared'])
|
||||
|
||||
|
||||
class IncludeTests(TestCase):
|
||||
def setUp(self):
|
||||
tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(tmp.cleanup)
|
||||
self.include = Path(tmp.name) / 'host-monitor'
|
||||
patcher = patch.object(runtime_settings, 'HOST_MONITOR_INCLUDES', (self.include,))
|
||||
patcher.start()
|
||||
self.addCleanup(patcher.stop)
|
||||
|
||||
def config(self):
|
||||
return f'lxc.include: {self.include}\n'.encode()
|
||||
|
||||
def test_the_include_of_a_host_monitor_is_accepted_unchanged(self):
|
||||
self.include.write_text(runtime_settings.HOST_MONITOR_CONTENT)
|
||||
runtime_settings.check(self.config(), {'host_monitor': 'glances'}, 122)
|
||||
runtime_settings.check_recovery(self.config(), {'vmid': 122, 'record': {'deployment': {'host_monitor': 'glances'}}})
|
||||
|
||||
def test_a_changed_include_or_one_nobody_declared_is_refused(self):
|
||||
self.include.write_text(runtime_settings.HOST_MONITOR_CONTENT + 'lxc.cap.drop =\n')
|
||||
with self.assertRaises(ValueError):
|
||||
runtime_settings.check(self.config(), {'host_monitor': 'glances'}, 122)
|
||||
self.include.write_text(runtime_settings.HOST_MONITOR_CONTENT)
|
||||
with self.assertRaises(ValueError):
|
||||
runtime_settings.check(self.config(), {}, 122)
|
||||
|
||||
def test_no_new_privileges_goes_in_the_include_of_the_container(self):
|
||||
deployment = {'security': {'options': {'no_new_privileges': True},
|
||||
'sysctls': [{'name': 'net.ipv4.ip_forward', 'value': '1'}]}}
|
||||
self.assertEqual(runtime_settings.sysctl_content(deployment),
|
||||
'lxc.sysctl.net.ipv4.ip_forward = 1\nlxc.no_new_privs = 1\n')
|
||||
self.assertEqual(runtime_settings.sysctl_content({'security': {'options': {}}}), '')
|
||||
|
||||
|
||||
class RemovalTests(TestCase):
|
||||
def test_the_seccomp_profile_of_a_removed_container_is_deleted(self):
|
||||
import oci_remove
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
profile = Path(tmp) / '126.proxmenux-seccomp'
|
||||
profile.write_text('2\ndenylist\n[all]\n')
|
||||
with patch.object(runtime_settings, 'seccomp_path', return_value=profile), \
|
||||
patch.object(runtime_settings, 'include_path', return_value=Path(tmp) / 'none'), \
|
||||
patch.object(runtime_settings, 'legacy_include_path', return_value=Path(tmp) / 'none'), \
|
||||
patch.object(oci_remove, 'SNIPPETS', Path(tmp) / 'snippets'), \
|
||||
patch.object(oci_remove, 'MONITOR_APPS', Path(tmp) / 'apps'), \
|
||||
patch.object(oci_remove, 'CLUSTER_RECORDS', Path(tmp) / 'records'):
|
||||
self.assertTrue(oci_remove._leftovers(126))
|
||||
oci_remove.remove_host_state(126)
|
||||
self.assertFalse(profile.exists())
|
||||
|
||||
|
||||
class InstallerTests(TestCase):
|
||||
def test_no_new_privileges_is_not_written_as_a_container_setting(self):
|
||||
self.assertNotIn('set_lxc_directive "lxc.no_new_privs"', INSTALLER)
|
||||
self.assertIn("printf 'lxc.no_new_privs = 1\\n' >>\"$SYSCTL_TEMP\"", INSTALLER)
|
||||
|
||||
def test_a_startup_check_without_its_optional_fields_does_not_end_the_installation(self):
|
||||
self.assertIn(".request_timeout_seconds // 5", INSTALLER)
|
||||
self.assertIn(".verify_tls // false", INSTALLER)
|
||||
|
||||
def test_seerr_can_write_its_configuration(self):
|
||||
for name in ('curated', 'apps'):
|
||||
profile = json.loads((ROOT / f'catalog/{name}/seerr.json').read_text())['proxmox']['installer_profile']
|
||||
self.assertEqual(profile['volume_owner'], {'uid': 1000, 'gid': 1000})
|
||||
self.assertEqual(profile['volume_preparations'][0]['container_path'], '/app/config')
|
||||
self.assertEqual(profile['startup_healthcheck']['request_timeout_seconds'], 5)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import unittest
|
||||
unittest.main()
|
||||
@@ -0,0 +1,258 @@
|
||||
"""Where the backup taken before changing an application is written."""
|
||||
|
||||
import json
|
||||
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_work_backup as work
|
||||
|
||||
GIB = 1024 ** 3
|
||||
|
||||
|
||||
class Location(unittest.TestCase):
|
||||
def locate(self, free, needed=10 * GIB, record=None, storage_free=None, storage_dir=None):
|
||||
def available(path):
|
||||
return storage_free if storage_dir is not None and Path(path) == storage_dir else free
|
||||
with patch.object(work, "required", return_value=needed), patch.object(work, "available", side_effect=available), \
|
||||
patch.object(work, "storage_path", return_value=storage_dir):
|
||||
return work.locate(Path("/registry/104/transactions/abc"), [104], record or {}, 104)
|
||||
|
||||
def test_a_backup_that_fits_stays_next_to_the_record(self):
|
||||
self.assertEqual(self.locate(free=11 * GIB), Path("/registry/104/transactions/abc"))
|
||||
|
||||
def test_without_room_and_without_a_chosen_storage_it_says_what_is_needed(self):
|
||||
with self.assertRaises(ValueError) as failure:
|
||||
self.locate(free=2 * GIB)
|
||||
text = str(failure.exception)
|
||||
for expected in ("does not fit on the system disk", "10.0 GB", "2.0 GB", "The application was not modified."):
|
||||
self.assertIn(expected, text)
|
||||
self.assertNotIn("/registry", text)
|
||||
|
||||
def test_the_chosen_storage_is_used_when_the_system_disk_has_no_room(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
base = self.locate(free=2 * GIB, record={"deployment": {"work_storage": "nas"}},
|
||||
storage_free=50 * GIB, storage_dir=Path(folder))
|
||||
self.assertEqual(base, Path(folder) / "proxmenux-oci-work" / "104" / "abc")
|
||||
self.assertFalse((Path(folder) / "proxmenux-oci-work").exists())
|
||||
|
||||
def test_a_chosen_storage_without_room_is_named(self):
|
||||
with tempfile.TemporaryDirectory() as folder, self.assertRaises(ValueError) as failure:
|
||||
self.locate(free=2 * GIB, record={"deployment": {"work_storage": "nas"}},
|
||||
storage_free=3 * GIB, storage_dir=Path(folder))
|
||||
self.assertIn("nas", str(failure.exception))
|
||||
self.assertIn("3.0 GB", str(failure.exception))
|
||||
|
||||
def test_free_space_is_read_where_the_folder_will_be_created(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
self.assertGreater(work.available(Path(folder) / "not" / "yet" / "there"), 0)
|
||||
|
||||
|
||||
class Remembering(unittest.TestCase):
|
||||
def test_the_folder_of_the_operation_says_where_its_backups_are(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
operation = Path(folder) / "registry" / "abc"
|
||||
operation.mkdir(parents=True)
|
||||
self.assertEqual(work.recall(operation), operation)
|
||||
work.remember(operation, operation)
|
||||
self.assertFalse((operation / work.MARK).exists())
|
||||
outside = Path(folder) / "nas" / work.FOLDER / "104" / "abc"
|
||||
work.remember(operation, outside)
|
||||
self.assertEqual(work.recall(operation), outside)
|
||||
|
||||
def test_a_location_that_is_not_one_of_ours_is_never_followed(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
operation = Path(folder)
|
||||
(operation / work.MARK).write_text("/etc\n")
|
||||
self.assertEqual(work.recall(operation), operation)
|
||||
(operation / work.MARK).write_text("relative/proxmenux-oci-work/1/x\n")
|
||||
self.assertEqual(work.recall(operation), operation)
|
||||
|
||||
|
||||
class Cleaning(unittest.TestCase):
|
||||
def test_the_backups_next_to_the_record_are_deleted_and_the_journal_stays(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
operation = Path(folder)
|
||||
(operation / "backup").mkdir()
|
||||
(operation / "backup-105").mkdir()
|
||||
for name in ("backup/vzdump-lxc-104-x.tar.zst", "backup-105/vzdump-lxc-105-x.tar.zst", "transaction.json"):
|
||||
(operation / name).write_text("x")
|
||||
work.clear(operation)
|
||||
self.assertEqual(sorted(path.name for path in operation.rglob("*") if path.is_file()), ["transaction.json"])
|
||||
|
||||
def test_the_folder_on_another_storage_goes_away_and_the_storage_is_left_alone(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
operation = Path(folder) / "registry" / "abc"
|
||||
operation.mkdir(parents=True)
|
||||
storage = Path(folder) / "nas"
|
||||
base = storage / work.FOLDER / "104" / "abc"
|
||||
(base / "backup").mkdir(parents=True)
|
||||
(base / "backup" / "vzdump-lxc-104-x.tar.zst").write_text("x")
|
||||
(storage / "dump").mkdir()
|
||||
(storage / "dump" / "vzdump-lxc-104-kept.tar.zst").write_text("a backup of the user")
|
||||
other = storage / work.FOLDER / "200" / "zzz"
|
||||
other.mkdir(parents=True)
|
||||
work.remember(operation, base)
|
||||
work.clear(operation)
|
||||
self.assertFalse((storage / work.FOLDER / "104").exists())
|
||||
self.assertTrue(other.is_dir())
|
||||
self.assertTrue((storage / "dump" / "vzdump-lxc-104-kept.tar.zst").is_file())
|
||||
|
||||
def test_removing_an_application_removes_what_its_operations_left_on_another_storage(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
root = Path(folder) / "registry"
|
||||
operation = root / "104" / "transactions" / "abc"
|
||||
operation.mkdir(parents=True)
|
||||
base = Path(folder) / "nas" / work.FOLDER / "104" / "abc"
|
||||
(base / "backup").mkdir(parents=True)
|
||||
(base / "backup" / "vzdump-lxc-104-x.tar.zst").write_text("x")
|
||||
work.remember(operation, base)
|
||||
work.forget(root, 104)
|
||||
self.assertFalse(base.exists())
|
||||
self.assertTrue((Path(folder) / "nas").is_dir())
|
||||
|
||||
|
||||
class Choice(unittest.TestCase):
|
||||
def test_the_choice_is_kept_in_every_container_of_the_application(self):
|
||||
import oci_carried_record
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
root = Path(folder)
|
||||
ids = {104: "11111111-1111-4111-8111-111111111111", 105: "22222222-2222-4222-8222-222222222222"}
|
||||
for vmid, extra in ((104, {"stack": {"members": [{"vmid": 104}, {"vmid": 105}]}, "stack_member": {"primary_vmid": 104}}),
|
||||
(105, {"stack_member": {"primary_vmid": 104}})):
|
||||
(root / str(vmid)).mkdir()
|
||||
(root / str(vmid) / "oci-compose.json").write_text(json.dumps({
|
||||
"schema_version": 1, "vmid": vmid, "installation_id": ids[vmid], "status": "installed",
|
||||
"deployment": {"onboot": True}, **extra}))
|
||||
saved = lambda vmid: json.loads((root / str(vmid) / "oci-compose.json").read_text())["deployment"]
|
||||
with patch.object(work, "storage_path", return_value=Path(folder)), \
|
||||
patch.object(oci_carried_record, "carry", return_value="carried"):
|
||||
self.assertEqual(work.choose(root, 105, "nas"), [104, 105])
|
||||
self.assertEqual((saved(104)["work_storage"], saved(105)["work_storage"]), ("nas", "nas"))
|
||||
work.choose(root, 104, None)
|
||||
self.assertNotIn("work_storage", saved(104))
|
||||
self.assertEqual(saved(105), {"onboot": True})
|
||||
|
||||
def test_a_storage_that_does_not_take_backups_is_not_accepted(self):
|
||||
with patch.object(work, "storage_path", return_value=None), self.assertRaises(ValueError):
|
||||
work.choose(Path("/registry"), 104, "isos")
|
||||
|
||||
|
||||
class Engines(unittest.TestCase):
|
||||
def test_a_modification_keeps_what_the_user_chose_for_the_application(self):
|
||||
import oci_instance_transaction as transaction
|
||||
record = {"status": "installed", "vmid": 109, "installation_id": "x", "template": {"id": "adguard"},
|
||||
"observed": {"config_sha256": "c"},
|
||||
"deployment": {"vmid": 109, "cores": 1, "watchdog": True, "work_storage": "nas"}}
|
||||
edited = {"vmid": 109, "installation_id": "x", "template": {"id": "adguard"},
|
||||
"deployment": {"vmid": 109, "cores": 4, "watchdog": False}}
|
||||
with patch.object(transaction, "with_default_healthcheck", side_effect=lambda value: value):
|
||||
result = transaction.candidate_contract(record, "recreate", {"operation": "recreate", "candidate": edited})
|
||||
self.assertEqual(result["deployment"], {"vmid": 109, "cores": 4, "watchdog": True, "work_storage": "nas"})
|
||||
record["deployment"] = {"vmid": 109, "cores": 1}
|
||||
edited["deployment"]["work_storage"] = "stale"
|
||||
result = transaction.candidate_contract(record, "recreate", {"operation": "recreate", "candidate": edited})
|
||||
self.assertEqual(result["deployment"], {"vmid": 109, "cores": 4})
|
||||
|
||||
def test_a_stack_asks_for_room_only_until_its_first_backup_exists(self):
|
||||
import oci_stack_native as native
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
operation = Path(folder) / "abc"
|
||||
operation.mkdir()
|
||||
adapter = native.NativeAdapter.__new__(native.NativeAdapter)
|
||||
adapter.journal = operation / "transaction.json"
|
||||
adapter.plan = {"primary_vmid": 104}
|
||||
adapter.records = {104: {"deployment": {}}, 105: {"deployment": {}}}
|
||||
with patch.object(native.work_backup, "locate", return_value=operation) as locate:
|
||||
self.assertEqual(adapter.work_base(), operation)
|
||||
(operation / "backup-105").mkdir()
|
||||
self.assertEqual(adapter.work_base(), operation)
|
||||
self.assertEqual(locate.call_count, 1)
|
||||
self.assertEqual(locate.call_args.args[1:], ([104, 105], {"deployment": {}}, 104))
|
||||
|
||||
def test_an_image_that_does_not_fit_is_refused_before_it_is_downloaded(self):
|
||||
import oci_update_current as update
|
||||
desired = {"template": {"container_contract": {"image": {"reference": "docker.io/library/app:latest"}}},
|
||||
"deployment": {"template_storage": "local"}}
|
||||
candidate = {"manifest_digest": "sha256:" + "a" * 64, "registry_digest": None, "version": "1", "size": 3 * GIB}
|
||||
called = []
|
||||
|
||||
def quiet(command, failure, capture=False):
|
||||
called.append(command[0])
|
||||
return json.dumps(candidate)
|
||||
with tempfile.TemporaryDirectory() as folder, \
|
||||
patch.object(update, "run_quiet", side_effect=quiet), \
|
||||
patch.object(update.transaction, "parse_config", return_value={"arch": "amd64"}), \
|
||||
patch.object(update.instances, "command", return_value=(folder + "/image.tar").encode()), \
|
||||
patch.object(update.work_backup, "available", return_value=GIB), \
|
||||
patch.object(update, "msg_info"), patch.object(update, "msg_ok"), patch.object(update.transaction, "log"):
|
||||
with self.assertRaises(ValueError) as failure:
|
||||
update.resolve_archive(desired, b"arch: amd64")
|
||||
text = str(failure.exception)
|
||||
for expected in ("does not fit on the storage where images are downloaded", "local", "1.0 GB", "The application was not modified."):
|
||||
self.assertIn(expected, text)
|
||||
self.assertNotIn("skopeo", called)
|
||||
|
||||
|
||||
class Menu(unittest.TestCase):
|
||||
def ready(self, state, pick=None, unattended=False):
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
from proxmenux_oci import management
|
||||
shown = []
|
||||
|
||||
class UI:
|
||||
def message(self, text, title=None):
|
||||
shown.append(text)
|
||||
|
||||
def choose(self, title, options, default=None):
|
||||
shown.append(options)
|
||||
return pick
|
||||
ui = UI()
|
||||
if unattended:
|
||||
ui.unattended = True
|
||||
with patch.object(work, "status", return_value=state) as status, patch.object(work, "choose") as choose, \
|
||||
patch.object(management, "translate", side_effect=lambda text: text):
|
||||
result = management._work_backup_ready(ROOT, ui, 109)
|
||||
return result, shown, status, choose
|
||||
|
||||
STATE = {"needed": 10 * GIB, "free": 2 * GIB, "fits": False, "saved": None, "saved_fits": False,
|
||||
"candidates": [{"storage": "small", "free": 12 * GIB}, {"storage": "nas", "free": 900 * GIB}]}
|
||||
|
||||
def test_nothing_is_asked_when_the_backup_fits_or_a_storage_with_room_was_chosen(self):
|
||||
for state in ({**self.STATE, "fits": True}, {**self.STATE, "saved": "nas", "saved_fits": True}):
|
||||
result, shown, _, choose = self.ready(state)
|
||||
self.assertTrue(result)
|
||||
self.assertEqual(shown, [])
|
||||
choose.assert_not_called()
|
||||
|
||||
def test_the_storages_with_room_are_offered_with_the_roomiest_first_and_the_choice_is_saved(self):
|
||||
result, shown, _, choose = self.ready(self.STATE, pick="nas")
|
||||
self.assertTrue(result)
|
||||
self.assertIn("10.0 GB", shown[0])
|
||||
self.assertIn("2.0 GB", shown[0])
|
||||
self.assertEqual([tag for tag, _ in shown[1]], ["nas", "small"])
|
||||
self.assertEqual(choose.call_args.args[1:], (109, "nas"))
|
||||
|
||||
def test_leaving_the_list_or_having_no_storage_with_room_does_not_start_the_operation(self):
|
||||
result, _, _, choose = self.ready(self.STATE, pick=None)
|
||||
self.assertFalse(result)
|
||||
choose.assert_not_called()
|
||||
result, shown, _, _ = self.ready({**self.STATE, "candidates": []})
|
||||
self.assertFalse(result)
|
||||
self.assertEqual(len(shown), 1)
|
||||
self.assertIn("No other storage", shown[0])
|
||||
|
||||
def test_a_scheduled_run_asks_nothing_and_lets_the_engine_decide(self):
|
||||
result, shown, status, _ = self.ready(self.STATE, unattended=True)
|
||||
self.assertTrue(result)
|
||||
self.assertEqual(shown, [])
|
||||
status.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user