Files
ProxMenux/oci/remote/oci_stack_replay.py
T
MacRimiandClaude Opus 5 bcabcb618c feat(oci): run official container images as native LXC containers
Adds the OCI manager: an engine that turns a Docker Compose file into an
LXC definition, a catalog of 365 applications drawn from LinuxServer.io
and other container image sources, and a per-instance registry recording
what each container was built from. Reachable from the main menu.

Catalog text is translated like every other string in the project: the
taglines go through translate() and land in lang/*.json, so the entries
read in all eight languages instead of only English.

Translation cache builder:
- a failed translation leaves the key absent rather than writing English,
  which previously made the string count as translated forever
- a result identical to a 3+ word source is rejected, catching a provider
  that silently returns the text it was given
- strings that are nothing but glossary terms keep their source spelling
  instead of being discarded as failures
- no backoff between attempts when the provider is deterministic
- application names are protected so "HAOS One" survives translation
- argos joins the provider list, and the workflow reads the OCI sources

Audit & Report:
- findings that moved in the wrong direction between runs are reported
  alongside the ones that improved
- an accepted risk can carry a review date and is flagged when it falls due
- backup checks explain in plain language what they looked at and what to
  do next

Monitor:
- disks can be excluded from periodic reads, and an idle disk says so
  instead of showing a stale temperature
- per-disk identity survives a controller or enclosure change
- scheduled Borg backups resolve their SSH key from the repository entry
- PVE upgrades log the package list and the resulting dpkg changes

The web build no longer copies scripts/ into public/: the documentation
links to GitHub, so nothing read that folder.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 18:24:59 +02:00

572 lines
28 KiB
Python

"""Capture only declared, generated rootfs adapters; never application data."""
import hashlib
import copy
from pathlib import Path
import re
import stat
import shlex
import os
from oci_ui import translate
def nextcloud_menu_ready(primary):
"""Offer only captured members whose declared persistence is fully covered."""
return captured_menu_ready(primary, 'install_nextcloud_stack.sh', nextcloud_record,
{'application', 'database', 'cache'})
def paperless_menu_ready(primary):
return captured_menu_ready(primary, 'install_paperless_stack.sh', paperless_record,
{'application', 'database', 'broker'})
def tandoor_menu_ready(primary):
return captured_menu_ready(primary, 'install_tandoor_stack.sh', tandoor_record,
{'application', 'database'})
def immich_menu_ready(primary):
try:
return captured_menu_ready(primary, 'install_immich_stack.sh', immich_record,
{'server', 'database', 'valkey', 'machine-learning'})
except (RuntimeError, OSError):
return False
def immich_prerequisites(rootfs, role, image):
required = {
'server': ('/bin/bash', 'tini', 'node', 'start.sh', 'grep', 'seq', 'sleep'),
'database': ('/bin/sh', '/usr/local/bin/immich-docker-entrypoint.sh', 'postgres', 'pg_isready'),
'valkey': ('/bin/sh', 'docker-entrypoint.sh', 'valkey-server', 'valkey-cli'),
'machine-learning': ('/bin/sh', 'env', 'tini', 'python'),
}
# PostgreSQL's official entrypoint generates its configuration at startup.
return adapter_prerequisites(rootfs, role, image, 'install_immich_stack.sh', required)
def immich_record(record):
projection = normalize(record)
recipe = record['deployment']['rootfs_replay']
if recipe['adapter'] != 'install_immich_stack.sh':
raise ValueError(translate('Unrecognized Immich adapter'))
role = recipe['role']
# PostgreSQL's saved listen address is private, never guessed from the host.
net0 = projection['deployment']['network']['ipv4'].split('/')[0]
expected = {
'server': ['tini', '--', '/usr/local/bin/immich-lxc-start'],
'database': ['/usr/local/bin/immich-docker-entrypoint.sh', 'postgres', '-c',
'config_file=/etc/postgresql/postgresql.conf', '-c',
'listen_addresses=127.0.0.1,' + net0],
'valkey': ['docker-entrypoint.sh', 'valkey-server'],
'machine-learning': ['env', 'LD_PRELOAD=/usr/lib/libmimalloc.so.2', 'tini', '--',
'python', '-m', 'immich_ml'],
}
runtime = projection['runtime']
if shlex.split(runtime.get('entrypoint', '')) != expected[role]:
raise ValueError(translate('The Immich startup was modified or cannot be reproduced'))
acceleration = record['deployment'].get('machine_learning', {}).get('acceleration', 'cpu')
if role == 'machine-learning' and acceleration not in ('cpu', 'openvino', 'cuda'):
raise ValueError(translate('Immich GPU profile not validated'))
cuda = role == 'machine-learning' and acceleration == 'cuda'
devices = [{'kind': 'nvidia-runtime', 'runtime_mode': 'dynamic'}] if cuda else []
for item in projection['native_devices']:
fields = dict(p.split('=', 1) for p in item['value'].split(',') if '=' in p)
path = fields.get('path')
if cuda and path and path.startswith('/dev/nvidia'):
continue
if not path or not re.fullmatch(r'/dev/dri/renderD[0-9]+', path):
raise ValueError(translate('Immich device without a validated translation'))
devices.append({'kind': 'character-device', 'host_path': path, 'container_path': path,
'gid_strategy': 'host-device-gid', 'mode': fields.get('mode', '0660'),
'drm_vendor_ids': ['0x8086'] if role == 'machine-learning' else ['0x8086', '0x1002']})
if projection['preserved_raw_runtime'] and not cuda:
raise ValueError(translate('Immich runtime without a validated translation'))
if cuda:
import oci_accelerators
candidate = {'devices': devices, 'environment': [
{'name': 'NVIDIA_DRIVER_CAPABILITIES', 'value': 'compute,utility'}]}
oci_accelerators.check(record['observed']['config'].encode(), candidate)
translated = {'compose_entrypoint': expected[role], 'command': []}
for native, target in (('lxc.init.cwd', 'working_directory'), ('lxc.signal.halt', 'halt_signal')):
if native in runtime:
translated[target] = runtime[native]
result = portable_record(record, {'generated_files': projection['generated_files'], 'runtime': translated})
result['deployment']['devices'] = devices
if cuda:
result['deployment']['environment'] = [e for e in result['deployment']['environment']
if e['name'] != 'NVIDIA_DRIVER_CAPABILITIES']
result['deployment']['environment'].append({'name': 'NVIDIA_DRIVER_CAPABILITIES', 'value': 'compute,utility'})
result['deployment']['machine_learning'] = copy.deepcopy(record['deployment'].get('machine_learning', {}))
return result
def captured_menu_ready(primary, adapter, convert, expected_roles):
if primary.get('native_stack_intent', {}).get('adapter', {}).get('name') != adapter:
return False
members = primary.get('stack', {}).get('members', [])
if len(members) != len(expected_roles):
return False
try:
converted = [convert(member) for member in members]
roles = {r['deployment']['replay_profile']['role'] for r in converted}
if roles != expected_roles:
return False
for member in converted:
targets = [m['container_path'] for m in member['deployment']['mounts']]
declared = member['observed']['image']['defaults'].get('Volumes') or {}
if any(not any(p == target or p.startswith(target.rstrip('/') + '/') for target in targets)
for p in declared):
return False
except (ValueError, KeyError, TypeError):
return False
return True
def image_executable(rootfs, path, executable=True):
"""Resolve container symlinks inside its root, never against the host root."""
root = Path(rootfs)
pending = list(Path(path).parts[1:])
resolved, links = [], 0
while pending:
part = pending.pop(0)
if part in ('', '.'):
continue
if part == '..':
if not resolved:
raise ValueError(translate('Symbolic link outside the rootfs of the new image'))
resolved.pop()
continue
destination = root.joinpath(*resolved, part)
info = destination.lstat()
if stat.S_ISLNK(info.st_mode):
links += 1
if links > 40:
raise ValueError(translate('Symbolic link loop in the new image'))
target = os.readlink(destination)
if target.startswith('/'):
resolved = []
pending = [p for p in target.split('/') if p] + pending
else:
resolved.append(part)
info = root.joinpath(*resolved).stat()
if not stat.S_ISREG(info.st_mode) or not info.st_mode & (0o111 if executable else 0o444):
raise ValueError(translate('The new image does not keep a required executable'))
return '/' + '/'.join(resolved)
def nextcloud_prerequisites(rootfs, role, image):
required = {
'application': ('/bin/sh', '/entrypoint.sh', '/cron.sh', 'apache2-foreground', 'php'),
'database': ('/bin/sh', 'docker-entrypoint.sh', 'postgres', 'pg_isready'),
'cache': ('/bin/sh', 'docker-entrypoint.sh', 'redis-server', 'redis-cli'),
}
return adapter_prerequisites(rootfs, role, image, 'install_nextcloud_stack.sh', required)
def paperless_prerequisites(rootfs, role, image):
required = {
'application': ('/bin/sh', '/init', 'python3'),
'database': ('/bin/sh', 'docker-entrypoint.sh', 'postgres', 'pg_isready'),
'broker': ('/bin/sh', 'tini', 'docker-entrypoint.sh', 'valkey-server', 'valkey-cli'),
}
return adapter_prerequisites(rootfs, role, image, 'install_paperless_stack.sh', required)
def tandoor_prerequisites(rootfs, role, image):
defaults = image.get('defaults', {})
entrypoint = defaults.get('Entrypoint') or defaults.get('Cmd') or []
if role == 'application' and (not isinstance(entrypoint, list) or not entrypoint
or not isinstance(entrypoint[0], str) or not entrypoint[0]):
raise ValueError(translate('The official Tandoor startup executable is missing'))
required = {
'application': ('/bin/sh', entrypoint[0] if entrypoint else '/bin/sh', 'python3'),
'database': ('/bin/sh', 'docker-entrypoint.sh', 'postgres', 'pg_isready'),
}
return adapter_prerequisites(rootfs, role, image, 'install_tandoor_stack.sh', required)
def adapter_prerequisites(rootfs, role, image, adapter, required):
if role not in required:
raise ValueError(translate('Unknown adapter role'))
defaults = image.get('defaults', {})
if defaults.get('User') not in (None, '', 'root', '0', '0:0'):
raise ValueError(translate('The image changes the user expected by the adapter'))
path = next((entry[5:] for entry in defaults.get('Env') or []
if entry.startswith('PATH=')), '')
directories = path.split(':') if path else []
if any(not directory.startswith('/') or '..' in Path(directory).parts for directory in directories):
raise ValueError(translate('The PATH of the new image is outside the reproducible profile'))
checked = []
for command in required[role]:
paths = [command] if command.startswith('/') else [directory.rstrip('/') + '/' + command for directory in directories]
for candidate in paths:
try:
checked.append(image_executable(rootfs, candidate))
break
except FileNotFoundError:
continue
else:
raise ValueError(translate('An executable required by the adapter is missing in the new image'))
for path in FILES[adapter][role]:
current = Path(rootfs)
for part in Path(path).parts[1:]:
current /= part
if current.is_symlink():
raise ValueError(translate('The new image adds a symbolic link in a generated path'))
return checked
def nextcloud_record(record):
"""Build portable desired state from evidence, without writing the registry."""
return portable_record(record, nextcloud_installer_profile(record))
def paperless_record(record):
"""Translate captured Paperless state; native activation remains separate."""
return portable_record(record, paperless_installer_profile(record))
def tandoor_record(record):
"""Project the two-member Tandoor recipe without activating replacement."""
return portable_record(record, tandoor_installer_profile(record))
def portable_record(record, profile):
"""Preserve data mounts and provenance without first-install preparations."""
projection = normalize(record)
saved = record['deployment'].get('member_replay_projection')
if saved is not None and saved != projection:
raise ValueError(translate('The saved projection does not match the native evidence'))
result = copy.deepcopy(record)
deployment = projection['deployment']
native = projection['preserved_native']
for mount in deployment['mounts']:
mount.pop('existing_volume_id', None)
template_storage = record['deployment'].get('archive_volume', 'local:').split(':', 1)[0]
if template_storage.startswith('/'):
raise ValueError(translate('The OCI image storage was not kept'))
deployment.update(template_storage=template_storage, features=native.get('features', '').split(',')
if native.get('features') else [], stack_managed=True,
rootfs_adaptation_replay_required=False,
replay_profile=copy.deepcopy(record['deployment']['replay_profile']),
rootfs_replay=copy.deepcopy(record['deployment']['rootfs_replay']))
if 'cpuunits' in native:
deployment['resources']['cpu_units'] = int(native['cpuunits'])
# Unknown settings cannot be silently lost during the first migration.
if native.get('ostype') == 'unmanaged':
deployment['ostype'] = 'unmanaged'
result['deployment'] = deployment
template = result['template']
template.setdefault('schema_version', '0.5.0')
template.setdefault('kind', 'proxmenux.oci-template')
template.setdefault('status', 'generated-unvalidated')
template.setdefault('catalog_ui', {}).update(
architectures=[record['observed']['image']['architecture']], category='productivity')
template.setdefault('source', {}).setdefault('provider', 'official')
template['source'].setdefault('revision', record['observed']['image']['manifest_digest'])
template['container_contract']['volumes'] = [
{'container_path': m['container_path'], 'required': True}
for m in deployment['mounts']]
if deployment['resources'].get('cpu_allocation') == 'quota':
profile = copy.deepcopy(profile)
profile['cpu_allocation'] = 'quota'
template.setdefault('proxmox', {})['installer_profile'] = profile
if 'native_stack_intent' in result:
result['dedicated_stack_recipe'] = result.pop('native_stack_intent')
return result
def paperless_installer_profile(record):
return official_application_profile(record, 'install_paperless_stack.sh', {
'database': ['/usr/local/bin/paperless-postgres-lxc-start'],
'broker': ['tini', '--', 'docker-entrypoint.sh', 'valkey-server'],
})
def tandoor_installer_profile(record):
return official_application_profile(record, 'install_tandoor_stack.sh', {
'database': ['/usr/local/bin/tandoor-postgres-lxc-start'],
})
def official_application_profile(record, adapter, adapted_entrypoints):
projection = normalize(record)
recipe = record['deployment']['rootfs_replay']
if recipe['adapter'] != adapter:
raise ValueError(translate('Stack adapter not recognized by the translator'))
if projection.get('native_devices') or projection.get('preserved_raw_runtime'):
raise ValueError(translate('The stack contains devices or directives without a translation'))
runtime = projection['runtime']
entrypoint = runtime.get('entrypoint', '')
if not entrypoint or '\0' in entrypoint or '\n' in entrypoint:
raise ValueError(translate('A reproducible native startup is missing'))
arguments = shlex.split(entrypoint)
role = recipe['role']
if role in adapted_entrypoints:
expected = adapted_entrypoints[role]
else:
defaults = record['observed']['image']['defaults']
inherited = defaults.get('Entrypoint') or []
command = defaults.get('Cmd') or []
if not isinstance(inherited, list) or not isinstance(command, list):
raise ValueError(translate('The official startup cannot be reproduced'))
expected = inherited + command
if not expected or any(not isinstance(arg, str) for arg in expected):
raise ValueError(translate('The official startup of the application is missing'))
if arguments != expected:
raise ValueError(translate('The startup differs from the declared adapter'))
# The application follows the new image defaults, not the old entrypoint.
translated = {} if role == 'application' else {'compose_entrypoint': arguments, 'command': []}
for native, target in (('lxc.init.cwd', 'working_directory'),
('lxc.signal.halt', 'halt_signal')):
if native in runtime:
translated[target] = runtime[native]
return {'generated_files': copy.deepcopy(projection['generated_files']),
'runtime': translated}
def nextcloud_installer_profile(record):
"""Translate captured startup settings, without authorizing replacement.
Never include first-install volume preparations: existing database and
application disks must not be reseeded during image replacement.
"""
projection = normalize(record)
recipe = record['deployment']['rootfs_replay']
if recipe['adapter'] != 'install_nextcloud_stack.sh':
raise ValueError(translate('This translator only supports the Nextcloud stack'))
if projection.get('native_devices') or projection.get('preserved_raw_runtime'):
raise ValueError(translate('The stack contains devices or directives without a translation'))
runtime = projection['runtime']
entrypoint = runtime.get('entrypoint', '')
if not entrypoint or '\0' in entrypoint or '\n' in entrypoint:
raise ValueError(translate('A reproducible native startup is missing'))
try:
arguments = shlex.split(entrypoint)
except ValueError as exc:
raise ValueError(translate('Invalid native entrypoint')) from exc
role = recipe['role']
expected = {
'application': ['/usr/local/bin/nextcloud-lxc-start'],
'database': ['/usr/local/bin/nextcloud-postgres-lxc-start'],
'cache': ['docker-entrypoint.sh', 'redis-server'],
}[role]
if arguments != expected:
raise ValueError(translate('The startup differs from the declared Nextcloud adapter'))
translated = {'compose_entrypoint': arguments, 'command': []}
for native, target in (('lxc.init.cwd', 'working_directory'),
('lxc.signal.halt', 'halt_signal')):
if native in runtime:
translated[target] = runtime[native]
return {'generated_files': copy.deepcopy(projection['generated_files']),
'runtime': translated}
FILES = {
'install_immich_stack.sh': {
'database': (), 'valkey': (), 'machine-learning': (),
'server': ('/usr/local/bin/immich-lxc-start',),
},
'install_nextcloud_stack.sh': {
'database': ('/usr/local/bin/nextcloud-postgres-lxc-start',),
'cache': (), 'application': ('/usr/local/bin/nextcloud-lxc-start',),
},
'install_paperless_stack.sh': {
'database': ('/usr/local/bin/paperless-postgres-lxc-start',),
'broker': (), 'application': (),
},
'install_tandoor_stack.sh': {
'database': ('/usr/local/bin/tandoor-postgres-lxc-start',),
'application': (),
},
}
def capture(rootfs, adapter, role, mounts):
if adapter not in FILES or role not in FILES[adapter]:
raise ValueError(translate('Unrecognized stack adapter or role'))
root = Path(rootfs)
if root.is_symlink() or not root.is_dir():
raise ValueError(translate('Unsafe rootfs for the capture'))
files = []
for path in FILES[adapter][role]:
if any(path == m['container_path'] or path.startswith(m['container_path'].rstrip('/') + '/')
for m in mounts):
raise ValueError(translate('A rootfs adaptation is stored in persistent storage'))
destination = root
for part in Path(path).parts[1:]:
destination = destination / part
if destination.is_symlink():
raise ValueError(translate('Symbolic link in the path of an adaptation'))
info = destination.stat()
if (not stat.S_ISREG(info.st_mode) or info.st_mode & 0o022
or stat.S_IMODE(info.st_mode) != 0o755
or info.st_uid != 100000 or info.st_gid != 100000):
raise ValueError(translate('Adaptation file with unexpected permissions or owner'))
if info.st_size > 65536:
raise ValueError(translate('Adaptation file too large'))
content = destination.read_text()
if not content.startswith(('#!/bin/sh\n', '#!/bin/bash\n')) or '\0' in content:
raise ValueError(translate('Unrecognized adaptation format'))
files.append({'container_path': path, 'mode': '0755', 'owner': 'mapped-root',
'content': content, 'sha256': hashlib.sha256(content.encode()).hexdigest()})
return {'schema_version': 1, 'adapter': adapter, 'role': role, 'files': files}
def validate(recipe):
adapter, role = recipe.get('adapter'), recipe.get('role')
if recipe.get('schema_version') != 1 or adapter not in FILES or role not in FILES[adapter]:
raise ValueError(translate('Unrecognized adaptation recipe'))
files = recipe.get('files', [])
if [item.get('container_path') for item in files] != list(FILES[adapter][role]):
raise ValueError(translate('Incomplete file recipe or unknown paths'))
for item in files:
content = item.get('content', '')
if (item.get('mode') != '0755' or item.get('owner') != 'mapped-root'
or not content.startswith(('#!/bin/sh\n', '#!/bin/bash\n'))
or len(content.encode()) > 65536 or '\0' in content
or hashlib.sha256(content.encode()).hexdigest() != item.get('sha256')):
raise ValueError(translate('The adaptation content was modified'))
return files
def normalize(record):
"""Project a dedicated member into common desired state, without enabling it.
Raw native config remains authoritative. The projection is evidence for the
future replay adapter, not authorization to discard unsupported directives.
"""
deployment = record['deployment']
recipe = deployment['rootfs_replay']
files = validate(recipe)
if deployment.get('replay_profile') != {'adapter': recipe['adapter'], 'role': recipe['role']}:
raise ValueError(translate('Inconsistent adaptation profile and recipe'))
config = record['observed']['config']
config_hash = hashlib.sha256(config.encode()).hexdigest()
if record['observed'].get('config_sha256') != config_hash:
raise ValueError(translate('The configuration evidence does not match'))
values, environment, mount_lines = {}, [], []
names = set()
def add_environment(value):
name, equals, content = value.partition('=')
if not equals or not re.fullmatch(r'[A-Za-z_][A-Za-z0-9_]*', name) or name in names:
raise ValueError(translate('Ambiguous or invalid environment variable'))
names.add(name)
environment.append({'name': name, 'value': content})
for line in config.splitlines():
key, sep, value = line.partition(': ')
if not sep:
if line.strip():
raise ValueError(translate('Unrecognized native configuration'))
continue
if key == 'lxc.environment.runtime':
add_environment(value)
elif key == 'env':
for variable in value.split('\0'):
add_environment(variable)
elif re.fullmatch(r'mp[0-9]+', key):
mount_lines.append((key, value))
else:
values.setdefault(key, []).append(value)
def single(key, default=None):
matches = values.get(key, [])
if len(matches) > 1:
raise ValueError(f"{translate('Duplicated native directive:')} {key}")
if not matches:
if default is not None:
return default
raise ValueError(f"{translate('Missing native directive:')} {key}")
return matches[0]
def options(value):
result = {}
for part in value.split(','):
key, equals, content = part.partition('=')
if equals:
if key in result:
raise ValueError(translate('Duplicated native option'))
result[key] = content
return result
def size(value):
match = re.fullmatch(r'([0-9]+)([GMT])', value or '')
if not match:
raise ValueError(translate('The disk size cannot be reproduced'))
number, unit = int(match[1]), match[2]
if unit == 'M':
if number % 1024:
raise ValueError(translate('The size of existing disks is not rounded'))
number //= 1024
if unit == 'T':
number *= 1024
if number < 1:
raise ValueError(translate('Disk too small for the common profile'))
return number
if single('unprivileged') != '1':
raise ValueError(translate('The native unprivileged idmap is required'))
rootfs = single('rootfs')
source = rootfs.split(',', 1)[0]
if source.startswith('/') or ':' not in source:
raise ValueError(translate('The rootfs is not managed by Proxmox'))
net = options(single('net0'))
if not net.get('bridge') or not net.get('ip') or not net.get('hwaddr'):
raise ValueError(translate('Incomplete primary network'))
mounts, targets = [], set()
for key, value in mount_lines:
source = value.split(',', 1)[0]
opts = options(value)
target = opts.get('mp', '')
if (not target.startswith('/') or target == '/' or '..' in Path(target).parts
or str(Path(target)) != target or any(target == other or target.startswith(other + '/')
or other.startswith(target + '/') for other in targets)):
raise ValueError(translate('Invalid or duplicated mount path'))
targets.add(target)
mount = {'container_path': target, 'read_only': opts.get('ro', '0') == '1'}
if source.startswith('/'):
if opts.get('backup', '0') != '0':
raise ValueError(translate('A host bind mount cannot be included in vzdump'))
mount.update(type='host-bind', source=source, backup=False, create_if_missing=False)
else:
if ':' not in source or opts.get('backup') != '1':
raise ValueError(translate('A managed volume with backup enabled is required'))
mount.update(type='managed-volume', source=source.split(':', 1)[0], backup=True,
size_gb=size(opts.get('size')), existing_volume_id=source)
mounts.append(mount)
quota = recipe['adapter'] == 'install_immich_stack.sh' and 'cores' not in values
if quota:
limit = single('cpulimit')
if not re.fullmatch(r'[1-9][0-9]*', limit):
raise ValueError(translate('The Immich CPU quota cannot be reproduced'))
cores = int(limit)
else:
cores = int(single('cores'))
resources = {'cores': cores, 'memory_mb': int(single('memory')),
'swap_mb': int(single('swap', '0'))}
if quota:
resources['cpu_allocation'] = 'quota'
if resources['cores'] < 1 or resources['memory_mb'] < 1 or resources['swap_mb'] < 0:
raise ValueError(translate('Invalid resources'))
defaults = dict(item.split('=', 1) for item in record['observed'].get('image', {}).get('defaults', {}).get('Env', [])
if isinstance(item, str) and '=' in item)
overrides = [item for item in environment if defaults.get(item['name']) != item['value']]
plan = {'vmid': record['vmid'], 'hostname': single('hostname'),
'rootfs': {'storage': rootfs.split(':', 1)[0], 'size_gb': size(options(rootfs).get('size'))},
'resources': resources, 'security': {'unprivileged': True},
'network': {'bridge': net['bridge'], 'ipv4': net['ip'], 'mac_address': net['hwaddr'],
'firewall': net.get('firewall', '0') == '1'},
'onboot': single('onboot', '0') == '1', 'start_after_create': False,
'shutdown_timeout_seconds': 60, 'mounts': mounts, 'environment': overrides}
if net.get('gw'):
plan['network']['gateway'] = net['gw']
runtime = {key: single(key) for key in ('entrypoint', 'lxc.init.cwd', 'lxc.signal.halt') if key in values}
preserved = {key: single(key) for key in ('arch', 'ostype', 'cmode', 'console', 'tty', 'cpuunits',
'net0', 'net1', 'startup', 'hookscript', 'features', 'tags') if key in values}
devices = [{'key': key, 'value': single(key)} for key in values if re.fullmatch(r'dev[0-9]+', key)]
raw_runtime = [line for line in config.splitlines() if line.startswith('lxc.')
and line.partition(': ')[0] not in ('lxc.environment.runtime', 'lxc.init.cwd', 'lxc.signal.halt')]
return {'schema_version': 1, 'deployment': plan, 'runtime': runtime,
'preserved_native': preserved, 'generated_files': copy.deepcopy(files),
'native_devices': devices, 'preserved_raw_runtime': raw_runtime,
'observed_environment': environment,
'native_config_sha256': config_hash,
'activation_requires_native_compatibility_check': True}