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:
MacRimi
2026-10-08 17:14:36 +02:00
parent b629938cf1
commit f5ad4072b5
31 changed files with 1784 additions and 390 deletions
+89
View File
@@ -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()
+67
View File
@@ -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()
+95
View File
@@ -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()
+177
View File
@@ -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()
+258
View File
@@ -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()