Merge remote-tracking branch 'origin/develop' into HEAD

This commit is contained in:
MacRimi
2026-10-19 17:17:17 +02:00
16 changed files with 846 additions and 38 deletions
+16 -1
View File
@@ -1250,7 +1250,8 @@ apply_installer_profile() {
continue
fi
if [[ $remove_lost_found == true ]]; then
rm -rf "${rootfs}${target}/lost+found" || { failed=1; break; }
oci_quiet python3 "${SCRIPT_DIR}/oci_managed_volumes.py" prepare \
"$rootfs" "$DEPLOYMENT_FILE" --target "$target" || { failed=1; break; }
fi
case "$owner_strategy" in
mapped-root) chown "$HOST_ROOT_UID:$HOST_ROOT_GID" "${rootfs}${target}" || failed=1 ;;
@@ -2058,6 +2059,20 @@ while IFS=$'\t' read -r NAME ENCODED; do
set_runtime_environment "$NAME" "$VALUE"
done < <(jq -r '.environment[] | [.name, (.value | @base64)] | @tsv' "$DEPLOYMENT_FILE")
prepare_managed_format_directories() {
local rootfs=$1 preparation_failed=0
# Stack members receive their real mounts later in attach_mounts().
[[ $(jq -r '.stack_managed // false' "$DEPLOYMENT_FILE") != true ]] || return 0
(( MOUNT_ENTRIES > 0 )) || return 0
mount_ct_rootfs
oci_quiet python3 "${SCRIPT_DIR}/oci_managed_volumes.py" prepare "$rootfs" "$DEPLOYMENT_FILE" \
|| preparation_failed=1
oci_quiet pct unmount "$VMID" || preparation_failed=1
(( preparation_failed == 0 )) \
|| die "$(translate "Could not add the mount point:") CT $VMID"
}
prepare_managed_format_directories "/var/lib/lxc/${VMID}/rootfs"
apply_extra_hosts
apply_installer_profile
ensure_runtime_home
+7
View File
@@ -5,6 +5,13 @@
case "$1" in
''|*[!0-9]*) exit 0 ;;
esac
# Direct PVE restore formats managed filesystems again. The installed record
# authorizes this narrow cleanup; missing/busy records leave the guest alone.
# A hook failure must not prevent the console marker or the DHCP timer.
volumes="${0%/*}/oci_managed_volumes.py"
if [ -r "$volumes" ] && command -v timeout >/dev/null 2>&1; then
timeout --signal=TERM --kill-after=2 10 python3 "$volumes" startup "$1" >/dev/null 2>&1 || :
fi
printf '\n=== ProxMenux: container started %s ===\n' "$(date '+%Y-%m-%d %H:%M:%S')" \
>> "/var/log/proxmenux/oci/$1.console.log" 2>/dev/null
lease="${0%/*}/oci_dhcp_lease.py"
+3 -22
View File
@@ -1261,28 +1261,9 @@ def apply(root, vmid, archive, operation, proposal=None, registry_digest=None, i
def remove_empty_format_directories(rootfs, deployment):
"""Remove only empty, unmapped mkfs lost+found on restored managed disks."""
root = Path(rootfs)
for mount in deployment.get('mounts', []):
if mount.get('type') != 'managed-volume' or not mount.get('backup'):
continue
current = root
for part in Path(mount['container_path']).parts[1:]:
if part in ('.', '..'):
raise ValueError(translate('Invalid restored volume path'))
current /= part
if current.is_symlink():
raise ValueError(translate('Symbolic link in a restored volume path'))
candidate = current / 'lost+found'
if not candidate.exists() or candidate.is_symlink():
continue
info = candidate.lstat()
if (stat.S_ISDIR(info.st_mode) and info.st_ino == 11
and info.st_uid == 0 and info.st_gid == 0
and stat.S_IMODE(info.st_mode) == 0o700):
if not any(candidate.iterdir()):
candidate.rmdir()
"""Keep the recovery entrypoint, sharing the guarded format-dir cleanup."""
from oci_managed_volumes import remove_empty_format_directories as clean
return clean(rootfs, deployment)
def cleanup_restored_format_dirs(vmid, deployment, installation_id):
owned(vmid, installation_id)
+179
View File
@@ -0,0 +1,179 @@
#!/usr/bin/env python3
"""Remove only empty, unshifted ext4 format directories on managed mounts."""
from __future__ import annotations
import argparse
import errno
import fcntl
import json
import os
from pathlib import Path
import re
import stat
import subprocess
def mount_path(value):
if (not isinstance(value, str) or not value.startswith('/') or value == '/'
or '\x00' in value or '\n' in value
or not value.strip('/')
or any(part in ('.', '..') for part in value.split('/'))):
raise ValueError('Invalid managed volume path')
return '/' + '/'.join(part for part in value.split('/') if part)
def eligible_paths(deployment, backed_only=True):
mounts = deployment.get('mounts', [])
if not isinstance(mounts, list) or any(not isinstance(m, dict) for m in mounts):
raise ValueError('Invalid managed mount contract')
paths = [(mount_path(m.get('container_path')), m) for m in mounts]
if len({path for path, _ in paths}) != len(paths):
raise ValueError('Duplicate managed volume path')
selected = []
for path, mount in paths:
if (mount.get('type') != 'managed-volume' or mount.get('read_only')
or (backed_only and not mount.get('backup'))):
continue
candidate = path + '/lost+found'
# A nested mount can replace the directory or something inside it.
if any(other == candidate or other.startswith(candidate + '/')
for other, _ in paths):
continue
selected.append(path)
return selected
def format_directory(info):
return (stat.S_ISDIR(info.st_mode) and info.st_ino == 11
and info.st_uid == 0 and info.st_gid == 0
and stat.S_IMODE(info.st_mode) == 0o700)
def remove_empty_at(parent):
try:
before = os.stat('lost+found', dir_fd=parent, follow_symlinks=False)
except FileNotFoundError:
return False
if not format_directory(before):
return False
flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW
child = os.open('lost+found', flags, dir_fd=parent)
try:
opened = os.fstat(child)
if (opened.st_dev, opened.st_ino) != (before.st_dev, before.st_ino):
return False
with os.scandir(child) as entries:
if next(entries, None) is not None:
return False
current = os.stat('lost+found', dir_fd=parent, follow_symlinks=False)
if ((current.st_dev, current.st_ino) != (opened.st_dev, opened.st_ino)
or not format_directory(current)):
return False
# rmdir cannot delete files, and does not follow a replacement link.
os.rmdir('lost+found', dir_fd=parent)
return True
finally:
os.close(child)
def remove_empty_format_directories(rootfs, deployment, backed_only=True, target=None):
paths = eligible_paths(deployment, backed_only)
if target is not None:
wanted = mount_path(target)
paths = [path for path in paths if path == wanted]
if not paths:
return 0
flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW
# A running container is reached through /proc/<pid>/root, a link.
root = os.open(rootfs, os.O_RDONLY | os.O_DIRECTORY)
removed = 0
try:
for path in paths:
current = os.dup(root)
try:
for part in path.lstrip('/').split('/'):
following = os.open(part, flags, dir_fd=current)
os.close(current)
current = following
removed += int(remove_empty_at(current))
except OSError as error:
if error.errno in (errno.ELOOP, errno.ENOTDIR):
raise ValueError('Symbolic link or invalid managed volume path') from error
raise
finally:
os.close(current)
finally:
os.close(root)
return removed
def startup_mounts(config, deployment):
"""Use the current PVE mount type/RO flag, gated by the saved contract."""
declared = {mount_path(m.get('container_path')): m
for m in deployment.get('mounts', [])}
mounts = []
for key, value in config.items():
if not re.fullmatch(r'mp[0-9]+', key):
continue
source, *parts = value.split(',')
options = dict(part.split('=', 1) for part in parts if '=' in part)
path = mount_path(options.get('mp'))
saved = declared.get(path, {})
managed = (saved.get('type') == 'managed-volume' and not source.startswith('/')
and ':' in source)
mounts.append({'container_path': path,
'type': 'managed-volume' if managed else 'host-bind',
'backup': bool(saved.get('backup')) and options.get('backup') == '1',
'read_only': bool(saved.get('read_only')) or options.get('ro', '0') != '0'})
return {'mounts': mounts}
def cleanup_startup(root, vmid, rootfs_base=Path('/var/lib/lxc')):
# Locks are nonblocking. A transaction already prepares its own restore
# before starting it; the hook must not wait on its parent's registry lock.
import oci_instances as instances
from oci_installation_state import parse_config
lock = os.open(root / '.lock', os.O_RDONLY | os.O_NOFOLLOW)
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
record = instances.read(root, vmid)
if record.get('status') != 'installed' or record.get('pending_transaction'):
return 0
if not eligible_paths(record['deployment']):
return 0
result = subprocess.run(['pct', 'config', str(vmid)], capture_output=True, timeout=3)
if result.returncode or instances.identity(result.stdout) != record['installation_id']:
return 0
mounts = startup_mounts(parse_config(result.stdout), record['deployment'])
return remove_empty_format_directories(rootfs_base / str(vmid) / 'rootfs', mounts)
finally:
os.close(lock)
def main():
parser = argparse.ArgumentParser(description=__doc__)
sub = parser.add_subparsers(dest='action', required=True)
prepare = sub.add_parser('prepare')
prepare.add_argument('rootfs', type=Path)
prepare.add_argument('deployment', type=Path)
prepare.add_argument('--target')
startup = sub.add_parser('startup')
startup.add_argument('vmid', type=int)
args = parser.parse_args()
try:
if args.action == 'prepare':
remove_empty_format_directories(args.rootfs, json.loads(args.deployment.read_text()),
backed_only=False, target=args.target)
elif args.vmid > 0:
import oci_instances as instances
cleanup_startup(instances.ROOT, args.vmid)
else:
return 1
except (OSError, ValueError, KeyError, TypeError, subprocess.SubprocessError):
# Private contracts/configuration must never be printed by a hook.
return 1
return 0
if __name__ == '__main__':
raise SystemExit(main())
+10 -5
View File
@@ -757,13 +757,18 @@ def clean_format_directories(entry):
"""A restore formats the volumes again, and the `lost+found` of each new
one belongs to the host: an application that walks its data cannot enter
it and fails to start."""
from oci_managed_volumes import eligible_paths
mounts = []
for key, value in parse_config(entry['config'].encode()).items():
if re.fullmatch(r'mp[0-9]+', key) and not value.startswith('/'):
target = oci_stack_modify.options(value).get('mp')
if target:
mounts.append({'type': 'managed-volume', 'backup': True, 'container_path': target})
if not mounts:
if not re.fullmatch(r'mp[0-9]+', key):
continue
options = oci_stack_modify.options(value)
target = options.get('mp')
if target:
mounts.append({'type': 'host-bind' if value.startswith('/') else 'managed-volume',
'backup': True, 'container_path': target,
'read_only': options.get('ro', '0') != '0'})
if not eligible_paths({'mounts': mounts}):
return
with carried.container_root(entry['vmid']) as rootfs:
if rootfs is not None: