fix(oci): a shared host directory shows what the host has mounted inside it

This commit is contained in:
MacRimi
2026-10-10 20:52:52 +02:00
parent a1bac7aeec
commit 155cafcb73
12 changed files with 269 additions and 8 deletions
+2
View File
@@ -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):
+6
View File
@@ -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
+6 -1
View File
@@ -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)}:
+2
View File
@@ -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
+96
View File
@@ -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())
+4 -2
View File
@@ -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)
+2
View File
@@ -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'}
+3
View File
@@ -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):
+5 -3
View File
@@ -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),
+2
View File
@@ -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
+2 -2
View File
@@ -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')
+139
View File
@@ -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()