mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-01 10:22:08 +00:00
fix(oci): a shared host directory is recognised by its filesystem, not its device number
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user