diff --git a/oci/remote/install_generic_stack.py b/oci/remote/install_generic_stack.py index 6170e353..2eb88b7e 100644 --- a/oci/remote/install_generic_stack.py +++ b/oci/remote/install_generic_stack.py @@ -25,6 +25,7 @@ import xml.etree.ElementTree as ET from allocate_private_network import allocate_network, existing_bridges, existing_networks import oci_instances +import oci_nested_mounts from oci_ui import translate, msg_info, msg_ok, msg_warn, msg_error, msg_info2, stop_spinner, log HERE = Path(__file__).resolve().parent @@ -446,6 +447,7 @@ def attach_mounts(service, temporary): current = next(line.split(': ', 1)[1] for line in config.splitlines() if line.startswith(f'mp{index}: ')) run('pct', 'set', vmid, f'--mp{index}', current + ',ro=1') + oci_nested_mounts.follow(vmid) def wait_web(primary, url): diff --git a/oci/remote/install_oci.sh b/oci/remote/install_oci.sh index 83796de9..760a1935 100755 --- a/oci/remote/install_oci.sh +++ b/oci/remote/install_oci.sh @@ -2024,6 +2024,12 @@ CREDENTIALS=$(jq -c --argjson environment "$DEPLOYMENT_ENVIRONMENT" ' ' "$TEMPLATE_FILE") RUNTIME_CREDENTIALS=$(jq '[.[] | select(.retrieval.method? == "container-console-pattern")] | length' <<<"$CREDENTIALS") +# A host directory is mounted with what the host has mounted inside it. Written +# once every mount point is set and after the host monitor profile, which +# clears the mount hooks. +python3 "${SCRIPT_DIR}/oci_nested_mounts.py" follow "$VMID" \ + || die "$(translate "Could not add the mount point:") CT $VMID" + # The console of the container is kept as its log, the way `docker logs` keeps # it, and the Proxmox console opens a shell when the image has one. Both are # set before the configuration is recorded, so the record carries them and an diff --git a/oci/remote/oci_instance_transaction.py b/oci/remote/oci_instance_transaction.py index f17b0d86..59579967 100644 --- a/oci/remote/oci_instance_transaction.py +++ b/oci/remote/oci_instance_transaction.py @@ -30,6 +30,7 @@ import oci_instances as instances from oci_installation_state import image_from_archive, parse_config, private_directory, sha from verify_oci_archive import verify_archive import oci_host_mounts as host_mounts +import oci_nested_mounts as nested_mounts import oci_accelerators as gpu_devices import oci_runtime_settings as runtime_settings import oci_image_cache as image_cache @@ -473,7 +474,10 @@ def preflight(record, candidate, config, coordinated=None): raise ValueError(translate('The imported OCI user or group is not numeric')) if 'lxc.init.groups' in cfg and not re.fullmatch(r'(?:[0-9]+(?:[ ,][0-9]+)*)?', cfg['lxc.init.groups']): raise ValueError(translate('The imported OCI groups are not numeric')) - keys = {line.split(': ', 1)[0] for line in config.decode().splitlines() if ': ' in line} + # What shows the content mounted inside a host directory follows the mount + # points: it is checked against them and is not a setting of its own. + nested_mounts.check(config) + keys = {line.split(': ', 1)[0] for line in nested_mounts.without(config).decode().splitlines() if ': ' in line} runtime_keys = {'lxc.mount.entry'} if gpu_devices.nvidia.enabled(deployment) else set() if gpu_devices.dynamic_mode(deployment): runtime_keys = {'lxc.hook.mount', 'lxc.environment'} @@ -597,6 +601,7 @@ def freeze_host_sources(record, candidate, acknowledge_external_data, files=()): def check_runtime_mounts(config, deployment): runtime_settings.check(config, deployment, deployment['vmid']) + nested_mounts.check(config) actual = mounts(config) files = runtime_settings.file_binds(config) if files != {m['source']: bool(m.get('read_only')) for m in deployment.get('mounts', []) if is_file_bind(m, files)}: diff --git a/oci/remote/oci_native_stack.py b/oci/remote/oci_native_stack.py index 4d75d635..bd598c7a 100644 --- a/oci/remote/oci_native_stack.py +++ b/oci/remote/oci_native_stack.py @@ -13,6 +13,7 @@ import time import oci_console import oci_instances as instances +import oci_nested_mounts from oci_installation_state import command, image_from_archive, sha import oci_stack_replay from oci_ui import translate @@ -84,6 +85,7 @@ def create(root, args): # console as a shell, set before the stack records its configuration # so an update rebuilds them the same way. oci_console.configure(vmid) + oci_nested_mounts.follow(vmid) return code diff --git a/oci/remote/oci_nested_mounts.py b/oci/remote/oci_nested_mounts.py new file mode 100644 index 00000000..605c1d31 --- /dev/null +++ b/oci/remote/oci_nested_mounts.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""Show in a container what the host has mounted inside a shared directory. + +Proxmox mounts the directory of a mount point alone: a filesystem mounted +inside it, as a network share under /mnt/pve, is left out and the container +finds an empty folder in its place. Next to each of those mount points goes a +raw LXC entry that mounts the same directory with everything inside it, and +keeps following what the host mounts and unmounts while the container runs. +""" +import argparse +import os +from pathlib import Path +import re + +from oci_installation_state import parse_config +from oci_ui import translate + +ENTRY = re.compile(r'lxc\.mount\.entry: (/\S*) (\S+) none rbind,rslave(,ro)?,create=dir 0 0') +# Read only reaches the directory alone; this takes it to what is inside. +HOOK = re.compile(r"""lxc\.hook\.mount: /bin/sh -c 'mount -o remount,bind,ro=recursive "\$LXC_ROOTFS_MOUNT/(\S+)"'""") +READ_ONLY = "lxc.hook.mount: /bin/sh -c 'mount -o remount,bind,ro=recursive \"$LXC_ROOTFS_MOUNT/{}\"'" +# Trees of the kernel, mounted as the recipe of a host monitor lays them out. +SYSTEM_TREES = ('/proc', '/sys', '/dev', '/run') +# Characters with a meaning of their own in an LXC entry or in the hook. +UNSAFE = re.compile(r'''[\s\\'"$`]''') + + +def own(line): + """Whether a configuration line is one of those written here.""" + return bool(ENTRY.fullmatch(line) or HOOK.fullmatch(line)) + + +def groups(config): + """The lines of each host directory among the mount points, a directory + before the ones mounted inside it.""" + found = {} + for key, value in parse_config(config).items(): + if not re.fullmatch(r'mp[0-9]+', key) or not value.startswith('/'): + continue + source, *rest = value.split(',') + options = dict(item.split('=', 1) for item in rest if '=' in item) + target = options.get('mp', '').strip('/') + if (not target or UNSAFE.search(source) or UNSAFE.search(target) or source == '/' + or any(source == tree or source.startswith(tree + '/') for tree in SYSTEM_TREES)): + continue + if options.get('ro') == '1': + found[target] = [f'lxc.mount.entry: {source} {target} none rbind,rslave,ro,create=dir 0 0', + READ_ONLY.format(target)] + else: + found[target] = [f'lxc.mount.entry: {source} {target} none rbind,rslave,create=dir 0 0'] + return [found[target] for target in sorted(found)] + + +def without(config): + """The configuration without the lines written here.""" + return b''.join(line for line in config.splitlines(keepends=True) if not own(line.decode().strip())) + + +def check(config): + """Every line of this kind belongs to a mount point of the container, and + none of a mount point is missing while another of it is there.""" + present = [line for line in config.decode().splitlines() if own(line)] + expected = [line for group in groups(config) if any(line in present for line in group) for line in group] + if sorted(present) != sorted(expected): + raise ValueError(translate('A container mount has a source, backup or permission different from the saved record')) + + +def follow(vmid): + """Write the lines of the mount points the container has now, and no + others. Any snapshot section is left as it is.""" + conf = Path(f'/etc/pve/lxc/{int(vmid)}.conf') + text = conf.read_text() + current, _, snapshots = text.partition('\n[') + kept = [line for line in current.splitlines() if not own(line)] + wanted = [line for group in groups(current.encode()) for line in group] + rebuilt = '\n'.join(kept + wanted) + '\n' + if snapshots: + rebuilt += '\n[' + snapshots + if rebuilt != text: + conf.write_text(rebuilt) + return wanted + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('action', choices=('follow',)) + parser.add_argument('vmid', type=int) + args = parser.parse_args(argv) + if os.geteuid() != 0: + parser.error(translate('Root privileges are required')) + follow(args.vmid) + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/oci/remote/oci_restore_recovery.py b/oci/remote/oci_restore_recovery.py index 9845edd0..fd9a2d92 100644 --- a/oci/remote/oci_restore_recovery.py +++ b/oci/remote/oci_restore_recovery.py @@ -33,6 +33,7 @@ import oci_carried_record as carried import oci_console import oci_instances as instances import oci_instance_transaction as transaction +import oci_nested_mounts import oci_nvidia_refresh import oci_nvidia_runtime as nvidia import oci_runtime_settings as runtime_settings @@ -460,7 +461,7 @@ def check_host_resources(plan): f"{label}: {translate('it uses an NVIDIA GPU and this host has no working NVIDIA driver and Container Toolkit. Install them and run the recovery again.')}") for line in lines: key, _, value = line.partition(': ') - if key == 'lxc.hook.mount' and value: + if key == 'lxc.hook.mount' and value and not oci_nested_mounts.own(line): hook = carried.NVIDIA_HOOK.fullmatch(value) if not hook: plan['blockers'].append(f"{label}: {translate('unknown mount hook:')} {value}") @@ -737,7 +738,8 @@ def restore_host_files(entry): runtime_settings.restore(deployment, vmid) else: path.write_text(runtime_settings.sysctl_content(deployment)) - elif key == 'lxc.hook.mount' and value and not Path(value).is_file(): + elif (key == 'lxc.hook.mount' and value and not oci_nested_mounts.own(line) + and not Path(value).is_file()): content = nvidia_hook_source(entry, carried.NVIDIA_HOOK.fullmatch(value)[1]) Path(value).parent.mkdir(parents=True, exist_ok=True, mode=0o755) Path(value).write_text(content) diff --git a/oci/remote/oci_runtime_settings.py b/oci/remote/oci_runtime_settings.py index 712ffce5..552d58d3 100644 --- a/oci/remote/oci_runtime_settings.py +++ b/oci/remote/oci_runtime_settings.py @@ -5,6 +5,7 @@ import re import stat import tempfile +import oci_nested_mounts as nested_mounts from oci_ui import translate @@ -125,6 +126,7 @@ def file_binds(config): def filter_config(config, deployment): """The configuration without the entries of the mounts the deployment declares itself: what is left belongs to the acceleration profile.""" + config = nested_mounts.without(config) declared = set(tmpfs_lines(deployment)) files = {m['source'] for m in deployment.get('mounts', []) if m.get('type') == 'host-bind'} diff --git a/oci/remote/oci_stack_modify.py b/oci/remote/oci_stack_modify.py index 867735c8..bad633fa 100644 --- a/oci/remote/oci_stack_modify.py +++ b/oci/remote/oci_stack_modify.py @@ -16,6 +16,7 @@ import oci_gpu_devices as gpu_devices import oci_host_mounts as host_mounts import oci_instance_reconcile as reconcile import oci_instances as instances +import oci_nested_mounts import oci_stack_replay as replay from oci_ui import msg_error, msg_info, msg_ok, translate @@ -129,6 +130,8 @@ def apply(vmid, changes): if unused: run('pct', 'set', str(vmid), '--delete', unused) msg_ok(f"{translate('Path removed:')} {path}") + if changes['add_mounts'] or changes['remove_mounts']: + oci_nested_mounts.follow(vmid) def recorded_mounts(vmid): diff --git a/oci/remote/oci_stack_replay.py b/oci/remote/oci_stack_replay.py index 18202bf6..9abe03e3 100644 --- a/oci/remote/oci_stack_replay.py +++ b/oci/remote/oci_stack_replay.py @@ -9,6 +9,7 @@ import os import oci_console import oci_gpu_devices +import oci_nested_mounts from oci_ui import translate @@ -617,12 +618,13 @@ def normalize(record): preserved = {key: single(key) for key in ('arch', 'ostype', 'cmode', 'console', 'tty', 'cpuunits', 'net0', 'net1', 'startup', 'hookscript', 'features', 'tags') if key in values} devices = [{'key': key, 'value': single(key)} for key in values if re.fullmatch(r'dev[0-9]+', key)] - # The console log line and its start hook are not replayed: the installer - # that rebuilds the member sets them itself, and replaying them too would - # leave two of each. + # The console log line, its start hook and what shows the content mounted + # inside a host directory are not replayed: the installer that rebuilds the + # member sets them itself, and replaying them too would leave two of each. raw_runtime = [line for line in config.splitlines() if line.startswith('lxc.') and line.partition(': ')[0] not in ('lxc.environment.runtime', 'lxc.init.cwd', 'lxc.signal.halt', 'lxc.console.logfile') + and not oci_nested_mounts.own(line) and not oci_console.is_start_mark_hook(line)] return {'schema_version': 1, 'deployment': plan, 'runtime': runtime, 'preserved_native': preserved, 'generated_files': copy.deepcopy(files), diff --git a/oci/remote/oci_ui.sh b/oci/remote/oci_ui.sh index 20e62ac7..ca13907c 100644 --- a/oci/remote/oci_ui.sh +++ b/oci/remote/oci_ui.sh @@ -190,6 +190,8 @@ oci_apply_extra_mounts() { count=$((count + 1)) done < <(jq -r '.extra_mounts[]? | [.type, .container_path, .source, (.size_gb // "-"), (.read_only // false)] | @tsv' "$DEPLOYMENT_FILE") if (( count > 0 )); then + python3 "${SCRIPT_DIR}/oci_nested_mounts.py" follow "$vmid" \ + || die "$(translate "Could not add the mount point:") CT $vmid" msg_ok "$(translate "Mount points added:") $count" fi return 0 diff --git a/oci/src/proxmenux_oci/installer.py b/oci/src/proxmenux_oci/installer.py index 0705ac6f..8cdd5958 100644 --- a/oci/src/proxmenux_oci/installer.py +++ b/oci/src/proxmenux_oci/installer.py @@ -1534,7 +1534,7 @@ def _run_remote_install( shutil.copy2(project_root / 'remote' / 'haos_healthcheck.py', temporary / 'haos_healthcheck.py') shutil.copy2(project_root / 'remote' / 'oci_installation_state.py', temporary / 'oci_installation_state.py') shutil.copy2(project_root / 'remote' / 'oci_instances.py', temporary / 'oci_instances.py') - for helper in ('oci_ui.sh', 'oci_ui.py', 'oci_description.py', 'oci_console.py', 'oci_native_stack.py', 'oci_native_stack.sh', 'oci_instance_transaction.py', 'oci_host_mounts.py', 'oci_runtime_settings.py', 'oci_gpu_devices.py', 'oci_accelerators.py', 'oci_nvidia_runtime.py', 'oci_nvidia_refresh.py', 'oci_nvidia_dynamic.py', 'oci_update_current.py', 'oci_stack_replay.py', 'oci_stack_plan.py', 'oci_stack_transaction.py', 'oci_stack_native.py', 'oci_image_cache.py', 'nvidia_lxc_mount_lab.sh', 'oci_nvidia_setup.sh', 'oci_immich_ml.sh', 'oci_rocm_check.py'): + for helper in ('oci_ui.sh', 'oci_ui.py', 'oci_description.py', 'oci_console.py', 'oci_native_stack.py', 'oci_native_stack.sh', 'oci_instance_transaction.py', 'oci_host_mounts.py', 'oci_nested_mounts.py', 'oci_runtime_settings.py', 'oci_gpu_devices.py', 'oci_accelerators.py', 'oci_nvidia_runtime.py', 'oci_nvidia_refresh.py', 'oci_nvidia_dynamic.py', 'oci_update_current.py', 'oci_stack_replay.py', 'oci_stack_plan.py', 'oci_stack_transaction.py', 'oci_stack_native.py', 'oci_image_cache.py', 'nvidia_lxc_mount_lab.sh', 'oci_nvidia_setup.sh', 'oci_immich_ml.sh', 'oci_rocm_check.py'): shutil.copy2(project_root / 'remote' / helper, temporary / helper) if deployment_kind == 'generic-multi-lxc-stack': shutil.copy2(project_root / 'remote' / 'install_generic_stack.py', temporary / 'install_generic_stack.py') @@ -1571,7 +1571,7 @@ def _run_remote_install( archive.add(project_root / 'remote' / 'haos_healthcheck.py', arcname='haos_healthcheck.py') archive.add(project_root / 'remote' / 'oci_installation_state.py', arcname='oci_installation_state.py') archive.add(project_root / 'remote' / 'oci_instances.py', arcname='oci_instances.py') - for helper in ('oci_ui.sh', 'oci_ui.py', 'oci_description.py', 'oci_console.py', 'oci_native_stack.py', 'oci_native_stack.sh', 'oci_instance_transaction.py', 'oci_host_mounts.py', 'oci_runtime_settings.py', 'oci_gpu_devices.py', 'oci_accelerators.py', 'oci_nvidia_runtime.py', 'oci_nvidia_refresh.py', 'oci_nvidia_dynamic.py', 'oci_update_current.py', 'oci_stack_replay.py', 'oci_stack_plan.py', 'oci_stack_transaction.py', 'oci_stack_native.py', 'oci_image_cache.py', 'nvidia_lxc_mount_lab.sh', 'oci_nvidia_setup.sh', 'oci_immich_ml.sh', 'oci_rocm_check.py'): + for helper in ('oci_ui.sh', 'oci_ui.py', 'oci_description.py', 'oci_console.py', 'oci_native_stack.py', 'oci_native_stack.sh', 'oci_instance_transaction.py', 'oci_host_mounts.py', 'oci_nested_mounts.py', 'oci_runtime_settings.py', 'oci_gpu_devices.py', 'oci_accelerators.py', 'oci_nvidia_runtime.py', 'oci_nvidia_refresh.py', 'oci_nvidia_dynamic.py', 'oci_update_current.py', 'oci_stack_replay.py', 'oci_stack_plan.py', 'oci_stack_transaction.py', 'oci_stack_native.py', 'oci_image_cache.py', 'nvidia_lxc_mount_lab.sh', 'oci_nvidia_setup.sh', 'oci_immich_ml.sh', 'oci_rocm_check.py'): archive.add(project_root / 'remote' / helper, arcname=helper) if deployment_kind == 'generic-multi-lxc-stack': archive.add(project_root / 'remote' / 'install_generic_stack.py', arcname='install_generic_stack.py') diff --git a/oci/tests/test_nested_mounts.py b/oci/tests/test_nested_mounts.py new file mode 100644 index 00000000..04db6658 --- /dev/null +++ b/oci/tests/test_nested_mounts.py @@ -0,0 +1,139 @@ +"""A host directory is mounted with what the host has mounted inside it, as +the network shares under /mnt/pve: Proxmox mounts the directory alone, and the +container would find an empty folder in the place of each share.""" + +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_nested_mounts as nested +import oci_runtime_settings as runtime_settings +import oci_stack_replay + +CONFIG = """arch: amd64 +hostname: duplicati +mp0: local-lvm:vm-113-disk-1,mp=/config,backup=1,size=16G +mp1: /mnt/pve,mp=/source,backup=0,ro=1 +mp2: /mnt/backups,mp=/backups,backup=0 +unprivileged: 1 +""" +READ_WRITE = "lxc.mount.entry: /mnt/backups backups none rbind,rslave,create=dir 0 0" +READ_ONLY = "lxc.mount.entry: /mnt/pve source none rbind,rslave,ro,create=dir 0 0" +HOOK = ("lxc.hook.mount: /bin/sh -c 'mount -o remount,bind,ro=recursive " + "\"$LXC_ROOTFS_MOUNT/source\"'") + + +def lines(config): + return [line for group in nested.groups(config.encode()) for line in group] + + +class LinesTests(unittest.TestCase): + def test_every_host_directory_gets_an_entry_that_follows_the_host(self): + self.assertEqual(lines(CONFIG), [READ_WRITE, READ_ONLY, HOOK]) + + def test_a_volume_gets_none(self): + self.assertEqual(lines("mp0: local-lvm:vm-113-disk-1,mp=/config,backup=1,size=16G\n"), []) + + def test_read_only_reaches_what_is_mounted_inside(self): + self.assertIn("ro=recursive", HOOK) + self.assertEqual(lines("mp0: /mnt/pve,mp=/source,backup=0,ro=1\n"), [READ_ONLY, HOOK]) + + def test_the_kernel_trees_of_a_host_monitor_stay_as_the_recipe_lays_them_out(self): + config = ("mp0: /proc,mp=/host/proc,backup=0,ro=1\nmp1: /sys,mp=/host/sys,backup=0,ro=1\n" + "mp2: /sys/fs/cgroup,mp=/host/sys/fs/cgroup,backup=0,ro=1\n") + self.assertEqual(lines(config), []) + + def test_a_path_that_would_break_the_entry_keeps_the_plain_mount_point(self): + for source in ("/mnt/it's", "/mnt/a$b", "/mnt/a`b", '/mnt/a"b', "/mnt/a\\040b"): + self.assertEqual(lines(f"mp0: {source},mp=/data,backup=0\n"), [], source) + + def test_a_directory_goes_before_the_ones_mounted_inside_it(self): + config = "mp0: /mnt/pve/media,mp=/data/media,backup=0\nmp1: /mnt/pve,mp=/data,backup=0\n" + self.assertEqual([line.split()[2] for line in lines(config)], ["data", "data/media"]) + + +class CheckTests(unittest.TestCase): + def config(self, *extra): + return (CONFIG + "".join(line + "\n" for line in extra)).encode() + + def test_the_lines_of_the_mount_points_are_accepted(self): + nested.check(self.config(READ_WRITE, READ_ONLY, HOOK)) + + def test_a_container_installed_before_has_none_and_is_accepted(self): + nested.check(self.config()) + + def test_an_entry_without_its_mount_point_is_refused(self): + with self.assertRaises(ValueError): + nested.check(self.config("lxc.mount.entry: /etc secrets none rbind,rslave,create=dir 0 0")) + + def test_an_entry_that_is_not_read_only_as_its_mount_point_is_refused(self): + with self.assertRaises(ValueError): + nested.check(self.config("lxc.mount.entry: /mnt/pve source none rbind,rslave,create=dir 0 0")) + + def test_a_read_only_entry_without_its_hook_is_refused(self): + with self.assertRaises(ValueError): + nested.check(self.config(READ_ONLY)) + + def test_a_repeated_entry_is_refused(self): + with self.assertRaises(ValueError): + nested.check(self.config(READ_WRITE, READ_WRITE)) + + +class OtherChecksTests(unittest.TestCase): + def test_they_are_not_seen_as_part_of_an_acceleration_profile(self): + config = (CONFIG + READ_WRITE + "\n" + READ_ONLY + "\n" + HOOK + "\n").encode() + left = runtime_settings.filter_config(config, {"mounts": []}).decode() + self.assertNotIn("lxc.mount.entry", left) + self.assertNotIn("lxc.hook.mount", left) + self.assertIn("mp1: /mnt/pve", left) + + def test_another_hook_is_not_taken_for_one_of_these(self): + other = "lxc.hook.mount: /usr/local/lib/proxmenux/oci/nvidia-mount-" + "a" * 64 + ".sh" + self.assertFalse(nested.own(other)) + self.assertIn(other.encode(), nested.without((CONFIG + other + "\n" + HOOK + "\n").encode())) + + def test_a_rebuilt_stack_member_does_not_replay_them(self): + source = Path(oci_stack_replay.__file__).read_text() + self.assertIn("not oci_nested_mounts.own(line)", source) + + +class FollowTests(unittest.TestCase): + def follow(self, text): + with tempfile.TemporaryDirectory() as directory: + conf = Path(directory) / "113.conf" + conf.write_text(text) + with patch.object(nested, "Path", lambda value: conf): + nested.follow(113) + return conf.read_text() + + def test_the_lines_are_written_after_the_rest_of_the_configuration(self): + self.assertEqual(self.follow(CONFIG), CONFIG + READ_WRITE + "\n" + READ_ONLY + "\n" + HOOK + "\n") + + def test_writing_them_again_changes_nothing(self): + once = self.follow(CONFIG) + self.assertEqual(self.follow(once), once) + + def test_the_lines_of_a_removed_mount_point_go_with_it(self): + once = self.follow(CONFIG) + removed = once.replace("mp1: /mnt/pve,mp=/source,backup=0,ro=1\n", "") + self.assertEqual(self.follow(removed), removed.replace(READ_ONLY + "\n" + HOOK + "\n", "")) + + def test_they_come_after_the_line_that_clears_the_hooks_of_a_host_monitor(self): + text = self.follow(CONFIG + HOOK + "\nlxc.hook.mount: \n") + self.assertLess(text.index("lxc.hook.mount: \n"), text.index(HOOK)) + self.assertEqual(text.count(HOOK), 1) + + def test_a_snapshot_section_is_left_as_it_is(self): + snapshot = "\n[before]\narch: amd64\nmp1: /mnt/old,mp=/old,backup=0\n" + text = self.follow(CONFIG + snapshot) + self.assertTrue(text.endswith(snapshot)) + self.assertNotIn("/mnt/old old", text) + + +if __name__ == "__main__": + unittest.main()