mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-29 18:16:43 +00:00
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>
572 lines
28 KiB
Python
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}
|