Merge pull request #528 from jcastro/feature/oci-input-review

Support owner-aware OCI tmpfs mounts and enable Input
This commit is contained in:
MacRimi
2026-10-26 18:17:48 +01:00
committed by GitHub
19 changed files with 1299 additions and 27 deletions
+7 -11
View File
@@ -2005,17 +2005,6 @@ for MOUNT_WARNING in ${MOUNT_WARNINGS[@]+"${MOUNT_WARNINGS[@]}"}; do
msg_warn "$MOUNT_WARNING"
done
while IFS=$'\t' read -r TARGET SIZE_MB OPTIONS; do
[[ -n $TARGET ]] || continue
[[ $TARGET == /* && $TARGET != *","* && $TARGET != *[[:space:]]* ]] \
|| die "$(translate "Invalid tmpfs path:") $TARGET"
[[ $SIZE_MB =~ ^[0-9]+$ && $SIZE_MB -gt 0 ]] || die "$(translate "Invalid tmpfs size:") $TARGET"
[[ $OPTIONS =~ ^(rw|ro|nosuid|nodev|noexec|mode=0[0-7]{3})(,(rw|ro|nosuid|nodev|noexec|mode=0[0-7]{3}))*$ ]] || die "$(translate "Invalid tmpfs options:") $TARGET"
TARGET_RELATIVE=${TARGET#/}
printf 'lxc.mount.entry: tmpfs %s tmpfs %s,size=%sM,create=dir 0 0\n' \
"$TARGET_RELATIVE" "$OPTIONS" "$SIZE_MB" >>"$CONF"
done < <(jq -r '.tmpfs_mounts[]? | [.container_path,.size_mb,(.mount_options | join(","))] | @tsv' "$DEPLOYMENT_FILE")
DEVICE_INDEX=0
RESOLVED_CHARACTER_DEVICES=()
NVIDIA_RUNTIME_CONFIGURED=0
@@ -2101,6 +2090,13 @@ prepare_managed_format_directories() {
prepare_managed_format_directories "/var/lib/lxc/${VMID}/rootfs"
apply_extra_hosts
apply_installer_profile
# Render tmpfs entries after the image and installer profile have set the
# effective LXC init uid/gid. The same renderer validates and records them.
TMPFS_CONFIG=$(mktemp)
python3 "${SCRIPT_DIR}/oci_runtime_settings.py" --render-tmpfs "$DEPLOYMENT_FILE" "$CONF" \
>"$TMPFS_CONFIG" || { rm -f "$TMPFS_CONFIG"; die "$(translate "Could not configure the tmpfs mounts")"; }
cat "$TMPFS_CONFIG" >>"$CONF"
rm -f "$TMPFS_CONFIG"
ensure_runtime_home
apply_rlimits
apply_host_monitor
+93 -7
View File
@@ -1,4 +1,6 @@
"""Validate declared volatile mounts and native network sysctl includes."""
import argparse
import json
import os
from pathlib import Path
import re
@@ -25,15 +27,63 @@ def sysctl_content(deployment):
return ''.join(result)
def tmpfs_lines(deployment):
def _config_ids(config):
"""Read the effective OCI init identity written by Proxmox."""
if config is None:
return 0, 0
text = config.decode() if isinstance(config, bytes) else str(config)
values = {}
for line in text.splitlines():
if ': ' not in line:
continue
key, value = line.split(': ', 1)
if key in ('lxc.init.uid', 'lxc.init.gid'):
if key in values:
raise ValueError(translate('The OCI image user or group is ambiguous'))
if not re.fullmatch(r'[0-9]{1,10}', value):
raise ValueError(translate('The OCI image user or group is not numeric'))
number = int(value)
if number > 4294967294:
raise ValueError(translate('The OCI image user or group is outside the supported range'))
values[key] = number
return values.get('lxc.init.uid', 0), values.get('lxc.init.gid', 0)
def _application_ids(deployment, item):
"""Resolve Compose-style application IDs inside the LXC user namespace."""
environment = {}
for variable in deployment.get('environment', []):
environment[variable.get('name')] = variable.get('value')
uid = item.get('uid')
gid = item.get('gid')
if uid is None:
uid = next((environment[name] for name in ('PUID', 'USER_ID', 'UID')
if environment.get(name) not in (None, '')), 0)
if gid is None:
gid = next((environment[name] for name in ('PGID', 'GROUP_ID', 'GID')
if environment.get(name) not in (None, '')), 0)
result = []
for value in (uid, gid):
if isinstance(value, bool) or not re.fullmatch(r'[0-9]{1,10}', str(value)):
raise ValueError(translate('The application user or group is not numeric'))
number = int(value)
if number > 4294967294:
raise ValueError(translate('The application user or group is outside the supported range'))
result.append(number)
return tuple(result)
def tmpfs_lines(deployment, config=None):
result = []
targets = set()
persistent = [m['container_path'].rstrip('/') for m in deployment.get('mounts', [])]
for item in deployment.get('tmpfs_mounts', []):
target, size = item['container_path'], item['size_mb']
owner_strategy = item.get('owner_strategy')
if (not re.fullmatch(r'/[A-Za-z0-9_./-]+', target) or '..' in target.split('/')
or '//' in target or target.endswith('/') or target in ('/etc','/usr','/bin','/lib','/lib64','/sbin','/proc','/sys','/dev','/run')
or not (target.startswith(('/run/', '/tmp/', '/var/cache/')) or target == '/dev/shm')
or '//' in target or target.endswith('/') or target in ('/etc','/usr','/bin','/lib','/lib64','/sbin','/proc','/sys','/dev')
or target == '/run' and owner_strategy not in ('mapped-root', 'mapped-application-user', 'image-user')
or not (target == '/run' or target.startswith(('/run/', '/tmp/', '/var/cache/')) or target == '/dev/shm')
or isinstance(size, bool) or not isinstance(size, int) or size < 1):
raise ValueError(translate('The tmpfs path or size is outside the supported profile'))
if any(target == p or target.startswith(p+'/') or p.startswith(target+'/') for p in [*persistent,*targets]):
@@ -43,11 +93,35 @@ def tmpfs_lines(deployment):
raise ValueError(translate('Unsupported tmpfs options'))
if len(options) != len(set(options)) or ('rw' in options and 'ro' in options):
raise ValueError(translate('Contradictory tmpfs options'))
mode = item.get('mode')
if mode is not None:
if not isinstance(mode, str) or not re.fullmatch(r'0[0-7]{3}', mode):
raise ValueError(translate('Unsupported tmpfs mode'))
if any(option.startswith('mode=') for option in options):
raise ValueError(translate('The tmpfs mode is specified more than once'))
if owner_strategy is not None:
if owner_strategy == 'mapped-root':
uid, gid = 0, 0
elif owner_strategy == 'mapped-application-user':
uid, gid = _application_ids(deployment, item)
elif owner_strategy == 'image-user':
uid, gid = _config_ids(config)
else:
raise ValueError(translate('Unsupported tmpfs owner strategy'))
options = [*options, f'uid={uid}', f'gid={gid}']
if mode is not None:
options.append(f'mode={mode}')
targets.add(target)
result.append(f'tmpfs {target.lstrip("/")} tmpfs {",".join(options)},size={size}M,create=dir 0 0')
return result
def render_tmpfs(deployment_path, config_path):
deployment = json.loads(Path(deployment_path).read_text())
config = Path(config_path).read_bytes()
return ''.join(f'lxc.mount.entry: {line}\n' for line in tmpfs_lines(deployment, config))
# /etc/pve/lxc is the folder of the local node only; this one is the same on
# every node of a cluster, so a migrated container still finds its include.
CLUSTER_DIR = Path('/etc/pve/proxmenux')
@@ -85,7 +159,7 @@ def seccomp_path(vmid):
def check(config, deployment, vmid):
expected = tmpfs_lines(deployment)
expected = tmpfs_lines(deployment, config)
lines = config.decode().splitlines()
actual = [line.split(': ',1)[1] for line in lines if line.startswith('lxc.mount.entry: tmpfs ')]
if sorted(actual) != sorted(expected):
@@ -123,11 +197,11 @@ def file_binds(config):
return found
def filter_config(config, deployment):
def filter_config(config, deployment, runtime_config=None):
"""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))
declared = set(tmpfs_lines(deployment, runtime_config if runtime_config is not None else config))
files = {m['source'] for m in deployment.get('mounts', []) if m.get('type') == 'host-bind'}
def own(line):
@@ -141,7 +215,11 @@ def filter_config(config, deployment):
def check_recovery(config, state):
plans = [state.get(key, {}).get('deployment', {}) for key in ('record', 'candidate_contract')]
permitted = {line for plan in plans for line in tmpfs_lines(plan)}
configs = [config]
before_config = state.get('before_config')
if isinstance(before_config, str):
configs.append(before_config.encode())
permitted = {line for plan in plans for source in configs for line in tmpfs_lines(plan, source)}
for line in config.decode().splitlines():
if line.startswith('lxc.mount.entry: tmpfs ') and line.split(': ', 1)[1] not in permitted:
raise ValueError(translate('A tmpfs mount is not part of the journal; recovery blocked'))
@@ -180,3 +258,11 @@ def restore(deployment, vmid):
finally:
if os.path.exists(temporary):
os.unlink(temporary)
if __name__ == '__main__':
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--render-tmpfs', nargs=2, metavar=('DEPLOYMENT', 'CONFIG'))
args = parser.parse_args()
if args.render_tmpfs:
print(render_tmpfs(*args.render_tmpfs), end='')