fix(oci): a shared host directory is recognised by its filesystem, not its device number

This commit is contained in:
MacRimi
2026-10-09 21:00:33 +02:00
parent 9f09272889
commit c2936d4587
7 changed files with 358 additions and 5 deletions
+9 -1
View File
@@ -58,6 +58,14 @@ def running_pid(vmid):
return int(pid) if result.returncode == 0 and pid.isdigit() else None
def _host_directory_missing(vmid):
"""Whether the container mounts a directory of the host that is not
there. It is not mounted then: Proxmox would leave it locked."""
from oci_host_mounts import missing_sources
result = _run('pct', 'config', str(int(vmid)))
return result.returncode != 0 or bool(missing_sources(result.stdout.encode()))
@contextlib.contextmanager
def container_root(vmid, mount_stopped=True):
"""The root filesystem of the container as the host sees it, or None. A
@@ -67,7 +75,7 @@ def container_root(vmid, mount_stopped=True):
if pid:
yield Path(f'/proc/{pid}/root')
return
if not mount_stopped or _run('pct', 'mount', str(int(vmid))).returncode != 0:
if not mount_stopped or _host_directory_missing(vmid) or _run('pct', 'mount', str(int(vmid))).returncode != 0:
yield None
return
try:
+95 -2
View File
@@ -1,9 +1,11 @@
"""Read-only identity checks for directory bind mounts; never manage their data."""
from __future__ import annotations
import os
from pathlib import Path, PurePosixPath
import re
import stat
import subprocess
from oci_installation_state import parse_config
from oci_ui import translate
@@ -18,6 +20,74 @@ def valid_path(value):
return value
MOUNTINFO = Path('/proc/self/mountinfo')
BY_UUID = Path('/dev/disk/by-uuid')
# Filesystems that number their folders again every time they are mounted.
RENUMBERED = ('vfat', 'exfat', 'msdos')
def _field(value):
return re.sub(r'\\([0-7]{3})', lambda match: chr(int(match.group(1), 8)), value)
def _mounted_at(resolved):
"""Type and source of the filesystem a path is on: the mount with the
longest mount point above it, the last one when several share it."""
found = None
try:
lines = MOUNTINFO.read_text().splitlines()
except OSError:
return None
for line in lines:
before, _, after = line.partition(' - ')
fields, origin = before.split(), after.split()
if len(fields) < 5 or len(origin) < 2:
continue
point = _field(fields[4])
if resolved == point or resolved.startswith(point.rstrip('/') + '/'):
if found is None or len(point) >= len(found[0]):
found = (point, origin[0], _field(origin[1]))
return found and found[1:]
def _uuid(device):
"""UUID of the filesystem on a block device, as the host publishes it."""
try:
number = os.stat(device).st_rdev
for link in BY_UUID.iterdir():
if os.stat(link).st_rdev == number:
return link.name
except OSError:
pass
try:
result = subprocess.run(['blkid', '-o', 'value', '-s', 'UUID', device],
capture_output=True, text=True, timeout=10, check=False)
except (OSError, subprocess.SubprocessError):
return None
return result.stdout.strip() or None
def filesystem(resolved):
"""What tells the filesystem of a directory apart across restarts, and its
type. The device number does not: the host gives it again on every boot,
and to a disk every time it is connected. A filesystem on a block device
is told by its UUID; one without, as a ZFS dataset or a network share, by
what is mounted. The identity is None when the host does not tell."""
mounted = _mounted_at(resolved)
if mounted is None:
return None, None
kind, origin = mounted
if origin.startswith('/'):
try:
block = stat.S_ISBLK(os.stat(origin).st_mode)
except OSError:
block = False
if block:
uuid = _uuid(origin)
return (f'uuid:{uuid}' if uuid else None), kind
return f'{kind}:{origin}', kind
def snapshot(source, allow_missing=False):
path = Path(source)
resolved = str(path.resolve())
@@ -29,9 +99,10 @@ def snapshot(source, allow_missing=False):
return {'resolved_path': resolved, 'exists': False}
if not stat.S_ISDIR(info.st_mode):
raise ValueError(translate('This profile only supports directory bind mounts'))
identity, kind = filesystem(resolved)
return {'resolved_path': resolved, 'exists': True, 'device': info.st_dev,
'inode': info.st_ino, 'uid': info.st_uid, 'gid': info.st_gid,
'mode': stat.S_IMODE(info.st_mode)}
'mode': stat.S_IMODE(info.st_mode), 'filesystem': identity, 'fstype': kind}
def validate_source(source, allow_missing=False, declared=False):
@@ -51,7 +122,20 @@ def validate_source(source, allow_missing=False, declared=False):
def same_source(a, b):
# Native application init may legitimately change permissions, not identity.
return all(a.get(k) == b.get(k) for k in ('resolved_path', 'exists', 'device', 'inode'))
if any(a.get(k) != b.get(k) for k in ('resolved_path', 'exists')):
return False
known = bool(a.get('filesystem') and b.get('filesystem'))
if known and a['filesystem'] != b['filesystem']:
return False
if a.get('inode') != b.get('inode') and not (known and a.get('fstype') in RENUMBERED):
return False
if known:
return True
# A record written before the filesystem was looked at has only the device
# number, which does not survive a restart: there is nothing to compare.
if 'filesystem' not in a or 'filesystem' not in b:
return True
return a.get('device') == b.get('device')
def verify_sources(expected):
@@ -69,6 +153,15 @@ def verify_observation(expected, observed):
raise ValueError(translate('The mount evidence does not match the verified directories'))
def missing_sources(config):
"""Host directories a container mounts that are not there, as when their
disk is not connected. Proxmox cannot mount the container without them,
and leaves it locked when it tries."""
return [value.split(',', 1)[0] for key, value in parse_config(config).items()
if re.fullmatch(r'mp[0-9]+', key) and value.startswith('/')
and not Path(value.split(',', 1)[0]).exists()]
def capture_sources(config):
result = {}
for key, value in parse_config(config).items():
+7 -1
View File
@@ -590,6 +590,8 @@ def freeze_host_sources(record, candidate, acknowledge_external_data, files=()):
log('host directories: not included in the backup and not reverted by a recovery')
if old - set(baseline):
log('host directories without a recorded identity: their current identity is pinned')
if any('filesystem' not in previous for source, previous in baseline.items() if source in original):
log('host directories recorded without the identity of their filesystem: it is pinned now')
return original, desired
@@ -914,11 +916,15 @@ def gib(size):
def backup_size(vmid):
"""Bytes in use on the volumes that vzdump includes: the rootfs and the
mount points with backup enabled."""
cfg = parse_config(run('pct', 'config', str(vmid)))
config = run('pct', 'config', str(vmid))
cfg = parse_config(config)
included = {'rootfs'} | {key for key, value in cfg.items()
if re.fullmatch(r'mp[0-9]+', key) and 'backup=1' in value.split(',')}
units = {'': 1, 'K': 1024, 'M': 1024**2, 'G': 1024**3, 'T': 1024**4, 'P': 1024**5}
total = 0
for source in host_mounts.missing_sources(config):
# A stopped container is mounted to be measured.
raise ValueError(f"{translate('The container is not modified because a host directory is not available:')} {source}")
for line in run('pct', 'df', str(vmid)).decode().splitlines()[1:]:
fields = line.split()
if not fields or fields[0] not in included: