Add owner-aware OCI tmpfs mounts

This commit is contained in:
Jonatan
2026-10-26 17:36:21 +01:00
parent e82d95f1cf
commit a1f90ddaf2
10 changed files with 301 additions and 33 deletions
+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='')