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