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
+2 -1
View File
@@ -504,8 +504,9 @@ A profile may also change the image, with `image`, for projects that publish one
| `external_services` | Names of services the application needs already running, such as `["MariaDB"]`. They are announced before the installation asks how to reach them | | `external_services` | Names of services the application needs already running, such as `["MariaDB"]`. They are announced before the installation asks how to reach them |
| `generated_files` | Files written in the container before its first start, each with `container_path`, `content`, `owner` and `mode`. With `"only_if_missing": true` a file that already exists in a kept volume is not replaced | | `generated_files` | Files written in the container before its first start, each with `container_path`, `content`, `owner` and `mode`. With `"only_if_missing": true` a file that already exists in a kept volume is not replaced |
| `volume_owner` | `uid` and `gid` of the user the application writes with, when it is not 1000 | | `volume_owner` | `uid` and `gid` of the user the application writes with, when it is not 1000 |
| `tmpfs_mounts` | Volatile paths with a size and mount options. An optional `owner_strategy` sets ownership to `mapped-root`, `mapped-application-user` (using `uid`/`gid` or `PUID`/`PGID`-style variables), or `image-user` (the effective `lxc.init.uid`/`lxc.init.gid`); `mode` sets permissions. The exact `/run` path requires an owner strategy. |
| `non_persistent_image_volumes` | Paths the image declares as volumes that are left inside the rootfs | | `non_persistent_image_volumes` | Paths the image declares as volumes that are left inside the rootfs |
| `runtime`, `resources`, `security`, `device_requests`, `tmpfs_mounts`, `extra_hosts` | What the Compose keys of the [translation table](#from-a-compose-file-or-a-docker-run-command-to-an-oci-compose) become | | `runtime`, `resources`, `security`, `device_requests`, `extra_hosts` | What the Compose keys of the [translation table](#from-a-compose-file-or-a-docker-run-command-to-an-oci-compose) become |
### `proxmox.security_profile`: privileges ### `proxmox.security_profile`: privileges
+19 -3
View File
@@ -400,11 +400,27 @@
"request_timeout_seconds": 10, "request_timeout_seconds": 10,
"stability_seconds": 4, "stability_seconds": 4,
"verify_tls": true "verify_tls": true
} },
"tmpfs_mounts": [
{
"id": "runtime",
"container_path": "/run",
"default_size_mb": 64,
"minimum_size_mb": 16,
"prompt_size": false,
"mount_options": [
"rw",
"nosuid",
"nodev"
],
"owner_strategy": "image-user",
"mode": "0755"
}
]
} }
}, },
"compatibility": { "compatibility": {
"automatic_install_candidate": false, "automatic_install_candidate": true,
"validated": false, "validated": false,
"supported_compose_keys": [ "supported_compose_keys": [
"image", "image",
@@ -414,7 +430,7 @@
"environment" "environment"
], ],
"untranslated_blockers": [], "untranslated_blockers": [],
"policy": "Excluded from the installable catalog pending compatibility review. The tested image runs as nobody, but its nginx and PHP-FPM startup requires writable runtime paths under /run; the current native OCI-LXC recipe could not start the service. Do not install until a supported runtime mapping is confirmed." "policy": "The image runs nginx and PHP-FPM as its OCI image user. ProxMenux provides a native /run tmpfs owned by that effective LXC init user, with writable mode for the runtime files. The recipe remains unvalidated until a clean install passes its startup health check."
}, },
"validation": { "validation": {
"schema": "passed-at-generation", "schema": "passed-at-generation",
+19 -3
View File
@@ -400,11 +400,27 @@
"request_timeout_seconds": 10, "request_timeout_seconds": 10,
"stability_seconds": 4, "stability_seconds": 4,
"verify_tls": true "verify_tls": true
} },
"tmpfs_mounts": [
{
"id": "runtime",
"container_path": "/run",
"default_size_mb": 64,
"minimum_size_mb": 16,
"prompt_size": false,
"mount_options": [
"rw",
"nosuid",
"nodev"
],
"owner_strategy": "image-user",
"mode": "0755"
}
]
} }
}, },
"compatibility": { "compatibility": {
"automatic_install_candidate": false, "automatic_install_candidate": true,
"validated": false, "validated": false,
"supported_compose_keys": [ "supported_compose_keys": [
"image", "image",
@@ -414,7 +430,7 @@
"environment" "environment"
], ],
"untranslated_blockers": [], "untranslated_blockers": [],
"policy": "Excluded from the installable catalog pending compatibility review. The tested image runs as nobody, but its nginx and PHP-FPM startup requires writable runtime paths under /run; the current native OCI-LXC recipe could not start the service. Do not install until a supported runtime mapping is confirmed." "policy": "The image runs nginx and PHP-FPM as its OCI image user. ProxMenux provides a native /run tmpfs owned by that effective LXC init user, with writable mode for the runtime files. The recipe remains unvalidated until a clean install passes its startup health check."
}, },
"validation": { "validation": {
"schema": "passed-at-generation", "schema": "passed-at-generation",
+2 -2
View File
@@ -6194,7 +6194,7 @@
"curated_path": "catalog/curated/input.json", "curated_path": "catalog/curated/input.json",
"template": "apps/input.json", "template": "apps/input.json",
"template_status": "generated-unvalidated", "template_status": "generated-unvalidated",
"automatic_install_candidate": false, "automatic_install_candidate": true,
"untranslated_blockers": [], "untranslated_blockers": [],
"requires_privileged_lxc": false, "requires_privileged_lxc": false,
"optional_privileged_lxc": false, "optional_privileged_lxc": false,
@@ -6202,7 +6202,7 @@
"requires_relaxed_confinement": false, "requires_relaxed_confinement": false,
"optional_relaxed_confinement": false, "optional_relaxed_confinement": false,
"requires_security_confirmation": false, "requires_security_confirmation": false,
"hidden": true, "hidden": false,
"template_id": "image-input" "template_id": "image-input"
}, },
{ {
+7 -11
View File
@@ -2005,17 +2005,6 @@ for MOUNT_WARNING in ${MOUNT_WARNINGS[@]+"${MOUNT_WARNINGS[@]}"}; do
msg_warn "$MOUNT_WARNING" msg_warn "$MOUNT_WARNING"
done 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 DEVICE_INDEX=0
RESOLVED_CHARACTER_DEVICES=() RESOLVED_CHARACTER_DEVICES=()
NVIDIA_RUNTIME_CONFIGURED=0 NVIDIA_RUNTIME_CONFIGURED=0
@@ -2101,6 +2090,13 @@ prepare_managed_format_directories() {
prepare_managed_format_directories "/var/lib/lxc/${VMID}/rootfs" prepare_managed_format_directories "/var/lib/lxc/${VMID}/rootfs"
apply_extra_hosts apply_extra_hosts
apply_installer_profile 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 ensure_runtime_home
apply_rlimits apply_rlimits
apply_host_monitor apply_host_monitor
+93 -7
View File
@@ -1,4 +1,6 @@
"""Validate declared volatile mounts and native network sysctl includes.""" """Validate declared volatile mounts and native network sysctl includes."""
import argparse
import json
import os import os
from pathlib import Path from pathlib import Path
import re import re
@@ -25,15 +27,63 @@ def sysctl_content(deployment):
return ''.join(result) 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 = [] result = []
targets = set() targets = set()
persistent = [m['container_path'].rstrip('/') for m in deployment.get('mounts', [])] persistent = [m['container_path'].rstrip('/') for m in deployment.get('mounts', [])]
for item in deployment.get('tmpfs_mounts', []): for item in deployment.get('tmpfs_mounts', []):
target, size = item['container_path'], item['size_mb'] 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('/') 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 '//' in target or target.endswith('/') or target in ('/etc','/usr','/bin','/lib','/lib64','/sbin','/proc','/sys','/dev')
or not (target.startswith(('/run/', '/tmp/', '/var/cache/')) or target == '/dev/shm') 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): 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')) 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]): 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')) raise ValueError(translate('Unsupported tmpfs options'))
if len(options) != len(set(options)) or ('rw' in options and 'ro' in options): if len(options) != len(set(options)) or ('rw' in options and 'ro' in options):
raise ValueError(translate('Contradictory tmpfs 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) targets.add(target)
result.append(f'tmpfs {target.lstrip("/")} tmpfs {",".join(options)},size={size}M,create=dir 0 0') result.append(f'tmpfs {target.lstrip("/")} tmpfs {",".join(options)},size={size}M,create=dir 0 0')
return result 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 # /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. # every node of a cluster, so a migrated container still finds its include.
CLUSTER_DIR = Path('/etc/pve/proxmenux') CLUSTER_DIR = Path('/etc/pve/proxmenux')
@@ -85,7 +159,7 @@ def seccomp_path(vmid):
def check(config, deployment, vmid): def check(config, deployment, vmid):
expected = tmpfs_lines(deployment) expected = tmpfs_lines(deployment, config)
lines = config.decode().splitlines() lines = config.decode().splitlines()
actual = [line.split(': ',1)[1] for line in lines if line.startswith('lxc.mount.entry: tmpfs ')] actual = [line.split(': ',1)[1] for line in lines if line.startswith('lxc.mount.entry: tmpfs ')]
if sorted(actual) != sorted(expected): if sorted(actual) != sorted(expected):
@@ -123,11 +197,11 @@ def file_binds(config):
return found 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 """The configuration without the entries of the mounts the deployment
declares itself: what is left belongs to the acceleration profile.""" declares itself: what is left belongs to the acceleration profile."""
config = nested_mounts.without(config) 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'} files = {m['source'] for m in deployment.get('mounts', []) if m.get('type') == 'host-bind'}
def own(line): def own(line):
@@ -141,7 +215,11 @@ def filter_config(config, deployment):
def check_recovery(config, state): def check_recovery(config, state):
plans = [state.get(key, {}).get('deployment', {}) for key in ('record', 'candidate_contract')] 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(): for line in config.decode().splitlines():
if line.startswith('lxc.mount.entry: tmpfs ') and line.split(': ', 1)[1] not in permitted: 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')) raise ValueError(translate('A tmpfs mount is not part of the journal; recovery blocked'))
@@ -180,3 +258,11 @@ def restore(deployment, vmid):
finally: finally:
if os.path.exists(temporary): if os.path.exists(temporary):
os.unlink(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='')
+8
View File
@@ -578,8 +578,16 @@ def build_deployment(
"container_path": item["container_path"], "container_path": item["container_path"],
"size_mb": size_mb, "size_mb": size_mb,
"mount_options": item.get("mount_options", ["rw", "nosuid", "nodev"]), "mount_options": item.get("mount_options", ["rw", "nosuid", "nodev"]),
**({"owner_strategy": item["owner_strategy"]} if item.get("owner_strategy") else {}),
**({"mode": item["mode"]} if item.get("mode") is not None else {}),
**({"uid": item["uid"]} if item.get("uid") is not None else {}),
**({"gid": item["gid"]} if item.get("gid") is not None else {}),
} }
) )
if item.get("owner_strategy") == "mapped-application-user":
for key in ("uid", "gid"):
if key not in tmpfs_mounts[-1] and installer_profile.get("volume_owner", {}).get(key) is not None:
tmpfs_mounts[-1][key] = installer_profile["volume_owner"][key]
if advanced: if advanced:
bridge = ask_bridge(ui, translate("Network bridge"), defaults["bridge"]) bridge = ask_bridge(ui, translate("Network bridge"), defaults["bridge"])
+10 -2
View File
@@ -326,8 +326,16 @@ def refresh_template(candidate, ui):
size = item['default_size_mb'] size = item['default_size_mb']
if item.get('prompt_size', True): if item.get('prompt_size', True):
size = positive_integer(ui, f"{translate('Tmpfs size in MiB for')} {item['container_path']}", size, item.get('minimum_size_mb', 1)) size = positive_integer(ui, f"{translate('Tmpfs size in MiB for')} {item['container_path']}", size, item.get('minimum_size_mb', 1))
tmpfs.append({'container_path': item['container_path'], 'size_mb': size, tmpfs_mount = {'container_path': item['container_path'], 'size_mb': size,
'mount_options': copy.deepcopy(item.get('mount_options', ['rw', 'nosuid', 'nodev']))}) 'mount_options': copy.deepcopy(item.get('mount_options', ['rw', 'nosuid', 'nodev']))}
for key in ('owner_strategy', 'mode', 'uid', 'gid'):
if item.get(key) is not None:
tmpfs_mount[key] = copy.deepcopy(item[key])
if item.get('owner_strategy') == 'mapped-application-user':
for key in ('uid', 'gid'):
if key not in tmpfs_mount and profile.get('volume_owner', {}).get(key) is not None:
tmpfs_mount[key] = copy.deepcopy(profile['volume_owner'][key])
tmpfs.append(tmpfs_mount)
deployment['tmpfs_mounts'] = tmpfs deployment['tmpfs_mounts'] = tmpfs
sysctls = deployment.get('security', {}).get('sysctls', []) sysctls = deployment.get('security', {}).get('sysctls', [])
for item in profile.get('security', {}).get('sysctls', []): for item in profile.get('security', {}).get('sysctls', []):
+7 -4
View File
@@ -53,10 +53,13 @@ class RuntimeHomeInstallerContractTests(unittest.TestCase):
self.assertIn('set_runtime_environment HOME "$home"', self.source) self.assertIn('set_runtime_environment HOME "$home"', self.source)
def test_home_fallback_runs_after_runtime_user_is_applied(self): def test_home_fallback_runs_after_runtime_user_is_applied(self):
self.assertIn( profile = self.source.index("apply_installer_profile\n", self.source.index("apply_extra_hosts\n"))
"apply_extra_hosts\napply_installer_profile\nensure_runtime_home\napply_rlimits", tmpfs = self.source.index("--render-tmpfs \"$DEPLOYMENT_FILE\" \"$CONF\"", profile)
self.source, home = self.source.index("ensure_runtime_home\n", tmpfs)
) limits = self.source.index("apply_rlimits\n", home)
self.assertLess(profile, tmpfs)
self.assertLess(tmpfs, home)
self.assertLess(home, limits)
if __name__ == "__main__": if __name__ == "__main__":
+134
View File
@@ -0,0 +1,134 @@
"""Ownership and mode are preserved on volatile native OCI-LXC mounts."""
import json
from pathlib import Path
import sys
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'remote'))
sys.path.insert(0, str(ROOT / 'src'))
import oci_runtime_settings as runtime_settings
class TmpfsOwnershipTests(unittest.TestCase):
def test_image_user_is_taken_from_the_effective_lxc_init_identity(self):
plan = {'tmpfs_mounts': [{
'container_path': '/run', 'size_mb': 64, 'mount_options': ['rw', 'nosuid', 'nodev'],
'owner_strategy': 'image-user', 'mode': '0755',
}]}
config = b'lxc.init.uid: 65534\nlxc.init.gid: 65534\n'
self.assertEqual(runtime_settings.tmpfs_lines(plan, config), [
'tmpfs run tmpfs rw,nosuid,nodev,uid=65534,gid=65534,mode=0755,size=64M,create=dir 0 0'
])
def test_mapped_root_and_application_user_strategies(self):
root = {'tmpfs_mounts': [{'container_path': '/run/app', 'size_mb': 32,
'mount_options': ['rw'], 'owner_strategy': 'mapped-root'}]}
app = {'environment': [{'name': 'PUID', 'value': '1001'}, {'name': 'PGID', 'value': '1002'}],
'tmpfs_mounts': [{'container_path': '/run/app', 'size_mb': 32,
'mount_options': ['rw'], 'owner_strategy': 'mapped-application-user'}]}
overridden = {'environment': [{'name': 'PUID', 'value': '1001'}, {'name': 'PGID', 'value': '1002'}],
'tmpfs_mounts': [{'container_path': '/run/app', 'size_mb': 32,
'mount_options': ['rw'], 'owner_strategy': 'mapped-application-user',
'uid': 2001, 'gid': 2002}]}
self.assertIn('uid=0,gid=0', runtime_settings.tmpfs_lines(root)[0])
self.assertIn('uid=1001,gid=1002', runtime_settings.tmpfs_lines(app)[0])
self.assertIn('uid=2001,gid=2002', runtime_settings.tmpfs_lines(overridden)[0])
def test_plain_run_mount_is_rejected_without_an_owner_strategy(self):
plan = {'tmpfs_mounts': [{'container_path': '/run', 'size_mb': 64,
'mount_options': ['rw', 'nosuid', 'nodev']}]}
with self.assertRaises(ValueError):
runtime_settings.tmpfs_lines(plan)
def test_invalid_owner_ids_modes_and_duplicate_init_identity_are_rejected(self):
app = {'tmpfs_mounts': [{'container_path': '/run/app', 'size_mb': 32,
'mount_options': ['rw'], 'owner_strategy': 'mapped-application-user',
'uid': 'root', 'gid': 10}]}
mode = {'tmpfs_mounts': [{'container_path': '/run/app', 'size_mb': 32,
'mount_options': ['rw'], 'owner_strategy': 'mapped-root', 'mode': '777'}]}
image = {'tmpfs_mounts': [{'container_path': '/run/app', 'size_mb': 32,
'mount_options': ['rw'], 'owner_strategy': 'image-user'}]}
with self.assertRaises(ValueError):
runtime_settings.tmpfs_lines(app)
with self.assertRaises(ValueError):
runtime_settings.tmpfs_lines(mode)
with self.assertRaises(ValueError):
runtime_settings.tmpfs_lines(image, b'lxc.init.uid: 1000\nlxc.init.uid: 1001\n')
def test_runtime_validation_requires_the_exact_rendered_owner_and_mode(self):
plan = {'vmid': 120, 'tmpfs_mounts': [{
'container_path': '/run', 'size_mb': 64, 'mount_options': ['rw'],
'owner_strategy': 'image-user', 'mode': '0755',
}]}
entry = 'tmpfs run tmpfs rw,uid=1000,gid=1001,mode=0755,size=64M,create=dir 0 0'
runtime_settings.check(
f'lxc.init.uid: 1000\nlxc.init.gid: 1001\nlxc.mount.entry: {entry}\n'.encode(), plan, 120
)
with self.assertRaises(ValueError):
runtime_settings.check(
f'lxc.init.uid: 1000\nlxc.init.gid: 1001\n'
'lxc.mount.entry: tmpfs run tmpfs rw,uid=0,gid=0,mode=0755,size=64M,create=dir 0 0\n'.encode(),
plan, 120,
)
def test_acceleration_filter_recognises_image_owned_tmpfs_entry(self):
plan = {'tmpfs_mounts': [{'container_path': '/run', 'size_mb': 64, 'mount_options': ['rw'],
'owner_strategy': 'image-user', 'mode': '0755'}]}
line = ('lxc.mount.entry: tmpfs run tmpfs rw,uid=1000,gid=1001,mode=0755,'
'size=64M,create=dir 0 0\n')
config = ('lxc.init.uid: 1000\nlxc.init.gid: 1001\n' + line).encode()
filtered = runtime_settings.filter_config(config, plan).decode()
self.assertNotIn('tmpfs', filtered)
self.assertIn('lxc.init.uid: 1000', filtered)
def test_recovery_accepts_only_journalled_image_user_tmpfs_entries(self):
plan = {'tmpfs_mounts': [{'container_path': '/run', 'size_mb': 64, 'mount_options': ['rw'],
'owner_strategy': 'image-user', 'mode': '0755'}]}
old = b'lxc.init.uid: 1000\nlxc.init.gid: 1001\n'
current_line = ('lxc.mount.entry: tmpfs run tmpfs rw,uid=2000,gid=2001,mode=0755,'
'size=64M,create=dir 0 0\n')
current = ('lxc.init.uid: 2000\nlxc.init.gid: 2001\n' + current_line).encode()
state = {'vmid': 120, 'before_config': old.decode(), 'record': {'deployment': plan},
'candidate_contract': {'deployment': plan}}
runtime_settings.check_recovery(current, state)
unknown = current.replace(b'uid=2000,gid=2001', b'uid=2000,gid=3001')
with self.assertRaises(ValueError):
runtime_settings.check_recovery(unknown, state)
def test_input_is_visible_installable_and_uses_its_image_user_for_run(self):
template = json.loads((ROOT / 'catalog/curated/input.json').read_text())
profile = template['proxmox']['installer_profile']
entry = next(item for item in profile['tmpfs_mounts'] if item['container_path'] == '/run')
index = json.loads((ROOT / 'catalog/index.json').read_text())['applications']
index_item = next(item for item in index if item['id'] == 'input')
self.assertEqual(entry['owner_strategy'], 'image-user')
self.assertEqual(entry['mode'], '0755')
self.assertTrue(template['compatibility']['automatic_install_candidate'])
self.assertTrue(index_item['automatic_install_candidate'])
self.assertFalse(index_item['hidden'])
self.assertFalse(template['compatibility']['validated'])
def test_installer_renders_tmpfs_after_the_profile_sets_the_image_identity(self):
script = (ROOT / 'remote/install_oci.sh').read_text()
profile = script.index('apply_installer_profile\n', script.index('prepare_managed_format_directories "/var/lib'))
render = script.index('--render-tmpfs "$DEPLOYMENT_FILE" "$CONF"')
self.assertLess(profile, render)
def test_recipe_renderer_uses_the_same_lines_as_runtime_validation(self):
plan = {'tmpfs_mounts': [{'container_path': '/run', 'size_mb': 64, 'mount_options': ['rw'],
'owner_strategy': 'image-user', 'mode': '0755'}]}
config = b'lxc.init.uid: 65534\nlxc.init.gid: 65534\n'
with tempfile.TemporaryDirectory() as temporary:
deployment_path = Path(temporary) / 'deployment.json'
config_path = Path(temporary) / 'container.conf'
deployment_path.write_text(json.dumps(plan))
config_path.write_bytes(config)
self.assertEqual(runtime_settings.render_tmpfs(deployment_path, config_path),
'lxc.mount.entry: ' + runtime_settings.tmpfs_lines(plan, config)[0] + '\n')
if __name__ == '__main__':
unittest.main()