From a1f90ddaf2b6b05372ad7e434a37f9db610b1b72 Mon Sep 17 00:00:00 2001 From: Jonatan Date: Mon, 26 Oct 2026 17:36:21 +0100 Subject: [PATCH] Add owner-aware OCI tmpfs mounts --- oci/README.md | 3 +- oci/catalog/apps/input.json | 22 +++- oci/catalog/curated/input.json | 22 +++- oci/catalog/index.json | 4 +- oci/remote/install_oci.sh | 18 ++-- oci/remote/oci_runtime_settings.py | 100 ++++++++++++++++-- oci/src/proxmenux_oci/installer.py | 8 ++ oci/src/proxmenux_oci/recreation.py | 12 ++- oci/tests/test_runtime_home_contract.py | 11 +- oci/tests/test_tmpfs_ownership.py | 134 ++++++++++++++++++++++++ 10 files changed, 301 insertions(+), 33 deletions(-) create mode 100644 oci/tests/test_tmpfs_ownership.py diff --git a/oci/README.md b/oci/README.md index cf735188..b7111c96 100644 --- a/oci/README.md +++ b/oci/README.md @@ -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 | | `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 | +| `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 | -| `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 diff --git a/oci/catalog/apps/input.json b/oci/catalog/apps/input.json index 88317b39..e4966d74 100644 --- a/oci/catalog/apps/input.json +++ b/oci/catalog/apps/input.json @@ -400,11 +400,27 @@ "request_timeout_seconds": 10, "stability_seconds": 4, "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": { - "automatic_install_candidate": false, + "automatic_install_candidate": true, "validated": false, "supported_compose_keys": [ "image", @@ -414,7 +430,7 @@ "environment" ], "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": { "schema": "passed-at-generation", diff --git a/oci/catalog/curated/input.json b/oci/catalog/curated/input.json index 88317b39..e4966d74 100644 --- a/oci/catalog/curated/input.json +++ b/oci/catalog/curated/input.json @@ -400,11 +400,27 @@ "request_timeout_seconds": 10, "stability_seconds": 4, "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": { - "automatic_install_candidate": false, + "automatic_install_candidate": true, "validated": false, "supported_compose_keys": [ "image", @@ -414,7 +430,7 @@ "environment" ], "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": { "schema": "passed-at-generation", diff --git a/oci/catalog/index.json b/oci/catalog/index.json index 39dd8101..2f334177 100644 --- a/oci/catalog/index.json +++ b/oci/catalog/index.json @@ -6194,7 +6194,7 @@ "curated_path": "catalog/curated/input.json", "template": "apps/input.json", "template_status": "generated-unvalidated", - "automatic_install_candidate": false, + "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, "optional_privileged_lxc": false, @@ -6202,7 +6202,7 @@ "requires_relaxed_confinement": false, "optional_relaxed_confinement": false, "requires_security_confirmation": false, - "hidden": true, + "hidden": false, "template_id": "image-input" }, { diff --git a/oci/remote/install_oci.sh b/oci/remote/install_oci.sh index bcbd03ab..a07cf218 100755 --- a/oci/remote/install_oci.sh +++ b/oci/remote/install_oci.sh @@ -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 diff --git a/oci/remote/oci_runtime_settings.py b/oci/remote/oci_runtime_settings.py index 552d58d3..665dcf42 100644 --- a/oci/remote/oci_runtime_settings.py +++ b/oci/remote/oci_runtime_settings.py @@ -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='') diff --git a/oci/src/proxmenux_oci/installer.py b/oci/src/proxmenux_oci/installer.py index 13177e8b..adddf4f8 100644 --- a/oci/src/proxmenux_oci/installer.py +++ b/oci/src/proxmenux_oci/installer.py @@ -578,8 +578,16 @@ def build_deployment( "container_path": item["container_path"], "size_mb": size_mb, "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: bridge = ask_bridge(ui, translate("Network bridge"), defaults["bridge"]) diff --git a/oci/src/proxmenux_oci/recreation.py b/oci/src/proxmenux_oci/recreation.py index 676c5911..dedfb3a9 100644 --- a/oci/src/proxmenux_oci/recreation.py +++ b/oci/src/proxmenux_oci/recreation.py @@ -326,8 +326,16 @@ def refresh_template(candidate, ui): size = item['default_size_mb'] 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)) - tmpfs.append({'container_path': item['container_path'], 'size_mb': size, - 'mount_options': copy.deepcopy(item.get('mount_options', ['rw', 'nosuid', 'nodev']))}) + tmpfs_mount = {'container_path': item['container_path'], 'size_mb': size, + '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 sysctls = deployment.get('security', {}).get('sysctls', []) for item in profile.get('security', {}).get('sysctls', []): diff --git a/oci/tests/test_runtime_home_contract.py b/oci/tests/test_runtime_home_contract.py index 25e9ca9e..f6893734 100644 --- a/oci/tests/test_runtime_home_contract.py +++ b/oci/tests/test_runtime_home_contract.py @@ -53,10 +53,13 @@ class RuntimeHomeInstallerContractTests(unittest.TestCase): self.assertIn('set_runtime_environment HOME "$home"', self.source) def test_home_fallback_runs_after_runtime_user_is_applied(self): - self.assertIn( - "apply_extra_hosts\napply_installer_profile\nensure_runtime_home\napply_rlimits", - self.source, - ) + profile = self.source.index("apply_installer_profile\n", self.source.index("apply_extra_hosts\n")) + tmpfs = self.source.index("--render-tmpfs \"$DEPLOYMENT_FILE\" \"$CONF\"", profile) + 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__": diff --git a/oci/tests/test_tmpfs_ownership.py b/oci/tests/test_tmpfs_ownership.py new file mode 100644 index 00000000..0f6d116e --- /dev/null +++ b/oci/tests/test_tmpfs_ownership.py @@ -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()