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 |
| `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
+19 -3
View File
@@ -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",
+19 -3
View File
@@ -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",
+2 -2
View File
@@ -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"
},
{
+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='')
+8
View File
@@ -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"])
+10 -2
View File
@@ -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', []):
+7 -4
View File
@@ -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__":
+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()