Files
ProxMenux/oci/src/proxmenux_oci/stack.py
T

440 lines
25 KiB
Python

"""Compile supported Compose stacks into the existing single-LXC installer contract."""
from __future__ import annotations
import copy
import base64
import re
import secrets
from urllib.parse import urlsplit, urlunsplit, quote
import yaml
from .casaos import convert_casaos_compose, canonical_image_repository
from .converter import ConversionError
from .converter import _split_short_mount
from .i18n import translate
class StackError(ValueError):
pass
VARIABLE = re.compile(r'\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)')
def normalized_mounts(mounts):
result = []
for mount in mounts:
if isinstance(mount, str):
source, target, mode = _split_short_mount(mount)
if mode not in (None, 'rw', 'ro'):
raise StackError(f"{translate('Unsupported volume options:')} {mode}")
mount = {'source': source, 'target': target, 'read_only': mode == 'ro'}
if not isinstance(mount, dict):
raise StackError(translate('Unrecognized volume definition'))
target = mount.get('target', '')
if not target.startswith('/') or '..' in target.split('/') or target == '/':
raise StackError(translate('Invalid volume target'))
result.append(copy.deepcopy(mount))
return result
def environment(value):
if isinstance(value, list):
if any('=' not in item for item in value):
raise StackError(translate('Environment entry without an explicit value'))
value = dict(item.split('=', 1) for item in value)
if any(v is None for v in (value or {}).values()):
raise StackError(translate('Environment entry without an explicit value'))
return {k: str(v) for k, v in (value or {}).items()}
def kind(image):
repo = canonical_image_repository(image)
return repo if repo in {'postgres', 'redis', 'valkey/valkey', 'mariadb', 'linuxserver/mariadb', 'mongo', 'getmeili/meilisearch'} else 'application'
DEPENDENCY_VOLUMES = {'mariadb':['/var/lib/mysql'], 'linuxserver/mariadb':['/config'], 'mongo':['/data/db','/data/configdb'],
'getmeili/meilisearch':['/meili_data']}
def normalized_dependency_mount_path(container_path, image):
"""PostgreSQL 18+ 'postgres:latest' stores versioned PGDATA directly
under /var/lib/postgresql instead of /var/lib/postgresql/data (the path
declared in the source Compose file). Both the volume rename below and
volume_preparations matching in service_template() must agree on this
one normalized path, or a preparation keyed to either form gets
silently dropped or ends up orphaned against the renamed mount."""
if container_path == '/var/lib/postgresql/data' and str(image).endswith(':latest'):
return '/var/lib/postgresql'
return container_path
def authenticated_redis(service):
c = service['compose']
return (kind(service['image']) == 'redis' and not c.get('entrypoint')
and c.get('command') == ['redis-server','--requirepass','${REDISCLI_AUTH}']
and bool(environment(c.get('environment')).get('REDISCLI_AUTH')))
def ordered_services(template):
stack = template['compose_stack']
services = {s['name']: copy.deepcopy(s) for s in stack['services']}
for name, values in template.get('proxmox', {}).get('stack_environment_overrides', {}).items():
if name not in services:
raise StackError(f"{translate('Environment override for an unknown service:')} {name}")
env = environment(services[name]['compose'].get('environment'))
env.update(values)
services[name]['compose']['environment'] = env
for name, command in template.get('proxmox', {}).get('stack_command_overrides', {}).items():
if name not in services:
raise StackError(f"{translate('Command override for an unknown service:')} {name}")
services[name]['compose']['command'] = copy.deepcopy(command)
done, visiting, ordered = set(), set(), []
def visit(name):
if name not in services:
raise StackError(f"{translate('Unknown dependency:')} {name}")
if name in visiting:
raise StackError(f"{translate('Circular dependency:')} {name}")
if name in done:
return
visiting.add(name)
raw = services[name]['compose'].get('depends_on', [])
for dep in raw:
if isinstance(raw, dict):
condition = raw[dep].get('condition', 'service_started')
if condition not in {'service_started', 'service_healthy'}:
raise StackError(f"{name}: {translate('unsupported dependency condition')} {condition}")
if raw[dep].get('required', True) is False:
raise StackError(f"{name}: {translate('optional dependencies are not yet supported')}")
visit(dep)
visiting.remove(name)
done.add(name)
ordered.append(services[name])
for name in services:
visit(name)
main = stack['main_service']
if any(main in s['compose'].get('depends_on', []) for s in services.values()):
raise StackError(translate('Services that depend on the main service are not yet supported'))
return [s for s in ordered if s['name'] != main] + [services[main]]
def service_template(parent, service, main=False):
c = copy.deepcopy(service['compose'])
mounts = normalized_mounts(c.get('volumes', []))
explicit_targets = {m['target'] for m in mounts}
for target in DEPENDENCY_VOLUMES.get(kind(service['image']), []):
if not any(target == t or target.startswith(t.rstrip('/')+'/') for t in explicit_targets):
mounts.append({'type':'volume','target':target})
targets = {m['target'] for m in mounts}
c['volumes'] = mounts
for key in ('depends_on', 'networks', 'healthcheck', 'expose'):
c.pop(key, None)
c['environment'] = environment(c.get('environment'))
if authenticated_redis(service):
c['command'] = ['redis-server','--requirepass',c['environment']['REDISCLI_AUTH']]
single = convert_casaos_compose(
yaml.safe_dump({'name': service['name'], 'services': {service['name']: c},
'x-casaos': {'main': service['name'], 'title': {'en_US': service['name']}}}),
parent['source']['revision'], parent['source']['repository'],
service['name'], '', parent['catalog_ui']['category'],
parent['catalog_ui'].get('category_label'), service['name'],
)
# Preserve selected image and resolved stack values; the importer normalizes secrets.
single['container_contract']['image']['reference'] = service['image']
single['container_contract']['environment'] = [
{'name': k, 'example': v, 'required': True, 'sensitive': True, 'prompt_user': False}
for k, v in c['environment'].items()
]
# Only the main service reports the credentials of the application.
single['first_run'] = {'endpoints': [],
'credentials': copy.deepcopy(parent.get('first_run', {}).get('credentials', [])) if main else []}
single['proxmox'].setdefault('installer_profile', {}).pop('startup_healthcheck', None)
parent_preparations = parent.get('proxmox', {}).get('installer_profile', {}).get('volume_preparations', [])
normalized_targets = {normalized_dependency_mount_path(t, service['image']) for t in targets}
own_preparations = []
for p in parent_preparations:
normalized_path = normalized_dependency_mount_path(p.get('container_path'), service['image'])
if normalized_path in normalized_targets:
entry = copy.deepcopy(p)
entry['container_path'] = normalized_path
own_preparations.append(entry)
if own_preparations:
single['proxmox'].setdefault('installer_profile', {})['volume_preparations'] = own_preparations
return single
def assess(template):
"""Reject untranslated semantics before any host resources are allocated."""
errors = []
try:
services = ordered_services(template)
top = template['compose_stack'].get('top_level', {})
if any(k not in {'name', 'networks', 'volumes', 'version'} for k in top):
raise StackError(translate('Top-level configs, secrets and other global options are not yet supported'))
networks = top.get('networks', {})
if len(networks) > 1 or any(v and any(not ((k == 'driver' and x == 'bridge') or (k == 'name' and isinstance(x,str))) for k,x in v.items()) for v in networks.values()):
raise StackError(translate('Multiple networks or external networks are not yet supported'))
if any(v for v in top.get('volumes', {}).values()):
raise StackError(translate('Top-level volume options are not yet supported'))
seen_sources = set()
for s in services:
c = s['compose']
for key in ('profiles','build','configs','secrets','env_file','pid','privileged','devices','runtime','cap_add','security_opt','network_mode','extra_hosts'):
if c.get(key):
errors.append(f"{s['name']}: {key} {translate('needs stack review')}")
if s['name'] != template['compose_stack']['main_service'] and kind(s['image']) == 'application':
errors.append(f"{s['name']}: {translate('health and persistence profile not yet defined')}")
if kind(s['image']) != 'application' and (c.get('command') or c.get('entrypoint')) and not authenticated_redis(s):
errors.append(f"{s['name']}: {translate('custom dependency commands are not yet supported')}")
for m in normalized_mounts(c.get('volumes', [])):
source = m.get('source')
if source is not None and source in seen_sources:
errors.append(translate('Volumes shared between services are not yet supported'))
if source is not None:
seen_sources.add(source)
if set(m) - {'type','source','target','read_only'} or m.get('read_only'):
errors.append(translate('Volume options are not yet supported'))
if m['target'].startswith(('/etc/', '/var/run/', '/run/', '/dev/', '/proc/', '/sys/')):
errors.append(translate('System path mounts are not yet supported'))
env = environment(c.get('environment'))
for k,v in env.items():
for a,b in VARIABLE.findall(v):
variable = a or b
if variable not in {'TZ','PUID','PGID'} and not variable.startswith('GENERATED_'):
errors.append(f'{s["name"]}: {translate("unsupported variable")} {variable}')
if variable.startswith('GENERATED_') and re.search(r'API_KEY|CLIENT_SECRET|CREDENTIALS_ENABLED', variable):
errors.append(f'{s["name"]}: {translate("unsupported external credential or boolean")} {variable}')
single = service_template(template,s)
errors.extend(f'{s["name"]}: {b}' for b in single['compatibility']['untranslated_blockers'])
if not template['first_run'].get('endpoints'):
errors.append(translate('Main endpoint not yet defined'))
resolve_environments(services, 'UTC', template.get('proxmox', {}).get('stack_generators', {}))
except (ValueError, KeyError, TypeError, ConversionError) as e:
errors.append(str(e))
return sorted(set(errors))
def resolve_environments(services, timezone, generators=None):
tokens = {'TZ': timezone, 'PUID': '1000', 'PGID': '1000'}
resolved = {}
for s in services:
env = environment(s['compose'].get('environment'))
def replace(match):
name = match.group(1) or match.group(2)
if name.startswith('GENERATED_'):
if name not in tokens:
generator = (generators or {}).get(name)
if generator:
if generator != {'encoding':'base64','bytes':32,'prefix':'base64:'}:
raise StackError(f"{translate('Unsupported secret generator:')} {name}")
tokens[name] = 'base64:'+base64.b64encode(secrets.token_bytes(32)).decode()
else:
tokens[name] = secrets.token_hex(32)
if name not in tokens:
raise StackError(f"{translate('Unresolved variable:')} {name}")
return tokens[name]
resolved[s['name']] = {k: VARIABLE.sub(replace, v) for k,v in env.items()}
# Bind URL credentials using the destination service, never a global text replacement.
databases = {}
for s in services:
if kind(s['image']) == 'postgres':
env = resolved[s['name']]
for alias in (s['name'], s['compose'].get('container_name', s['name'])):
databases[alias] = env
for env in resolved.values():
for key, value in list(env.items()):
if value.startswith(('postgres://', 'postgresql://')):
url = urlsplit(value)
db = databases.get(url.hostname)
if db is None:
raise StackError(f"{translate('PostgreSQL URL without an associated service:')} {key}")
user = db.get('POSTGRES_USER', 'postgres')
password = db.get('POSTGRES_PASSWORD')
if not password:
raise StackError(translate('PostgreSQL requires a password'))
host = url.hostname + (f':{url.port}' if url.port else '')
env[key] = urlunsplit((url.scheme, quote(user, safe='')+':'+quote(password,safe='')+'@'+host, url.path, url.query, url.fragment))
return resolved
class DefaultsUI:
def ask(self, text, default=None, required=True):
return default if default is not None else ''
def choose(self, text, options, default=None):
return default
def confirm(self, text, default=False):
return default
def info(self, text):
pass
def message(self, text):
pass
def _service_plan(template, service):
"""The single-container template and plan of one stack member."""
from .installer import build_deployment, DEFAULT_MODE
single = service_template(template, service, service['name'] == template['compose_stack']['main_service'])
if kind(service['image']) == 'postgres':
# Official PostgreSQL 18+ stores versioned PGDATA under this parent.
for m in single['container_contract']['volumes']:
if m['container_path'] == '/var/lib/postgresql/data' and service['image'].endswith(':latest'):
m['container_path'] = normalized_dependency_mount_path(m['container_path'], service['image'])
for e in single['container_contract']['environment']:
e['required'] = bool(e['example'])
e['example'] = 'stack-resolved-value' if e['example'] else ''
return single, build_deployment(single, DefaultsUI(), DEFAULT_MODE)
def build_stack(template, ui, mode='advanced'):
from .installer import _hostname_default, DEFAULT_MODE
problems = assess(template)
if problems:
raise StackError('; '.join(problems))
services = copy.deepcopy(ordered_services(template))
defaults = template['proxmox']['defaults']
name = _hostname_default(ui.ask(translate('Stack name'), template['compose_stack']['project_name']))
vmid = ui.ask(translate('Base VMID (empty = next free block)'), '', required=False)
from . import host
from . import network as access
from .installer import _ask_size, ask_bridge, ask_storage
root = ask_storage(ui, translate('Storage for rootfs'), 'rootdir', defaults['rootfs_storage'], mode)
cache = ask_storage(ui, translate('Storage for the OCI image cache'), 'vztmpl', defaults['template_storage'], mode)
generators = template.get('proxmox', {}).get('stack_generators', {})
# The data paths of every member are settled before the network and the settings.
volumes = (ask_storage(ui, '', 'rootdir', defaults['volume_storage'], mode) if mode == DEFAULT_MODE else root)
draft_envs = resolve_environments(copy.deepcopy(services), host.timezone(), generators)
drafts = []
for s in services:
draft = copy.deepcopy(s)
draft['compose']['environment'] = draft_envs[s['name']]
_, plan = _service_plan(template, draft)
for m in plan['mounts']:
label = f"{s['name']}: {m['container_path']}"
mount_mode = ('managed-volume' if mode == DEFAULT_MODE else
ui.choose(f"{translate('Where to store')} {label}",
[('managed-volume', translate('Container volume (included in backups)')),
('host-bind', translate('Host directory (not included in Proxmox backups)'))],
'managed-volume'))
if mount_mode is None:
raise StackError(translate('Storage selection cancelled'))
m.update(type=mount_mode, source=volumes, backup=mount_mode=='managed-volume')
if mount_mode == 'host-bind':
m['source'] = ui.ask(f"{translate('Host path for')} {label}",
'/mnt/oci-shared/'+name+'/'+s['name']+'/'+m['container_path'].strip('/').replace('/','-'))
m['size_gb'] = None
elif mode != DEFAULT_MODE:
volumes = ask_storage(ui, f"{translate('Storage for')} {label}", 'rootdir', volumes)
m['source'] = volumes
m['size_gb'] = _ask_size(ui, label, max(8, m['size_gb'] or 8))
if m['size_gb'] is not None and m['size_gb'] < 1:
raise StackError(translate('Invalid volume size'))
drafts.append({'name': s['name'], 'main': s['name'] == template['compose_stack']['main_service'],
'deployment': {'mounts': plan['mounts']}})
if mode != DEFAULT_MODE:
from .custom_mounts import ask_stack_custom_mounts
ask_stack_custom_mounts(ui, drafts, volumes)
bridge = ask_bridge(ui, translate('Access bridge'), defaults['bridge'], mode)
addresses, gateway = access.ask_addresses(ui, bridge, [''])
timezone = ui.ask(translate('Timezone'), host.timezone())
envs = resolve_environments(services, timezone, generators)
for group in template.get('proxmox', {}).get('stack_optional_environment', []):
service_name = group['service']
if service_name not in envs:
raise StackError(f"{translate('Unknown credential service:')} {service_name}")
if not ui.confirm(f"{translate('Configure')} {group['label']} ({translate('optional')})", False):
continue
for field in group['fields']:
if field['name'] not in envs[service_name] or envs[service_name][field['name']] != '':
raise StackError(f"{translate('External field not reserved:')} {field['name']}")
value = (ui.password(field['label'], required=True) if field.get('sensitive',True)
else ui.ask(field['label'], required=True))
if not value or any(c in value for c in '\r\n'):
raise StackError(translate('External credential is empty or spans multiple lines'))
envs[service_name][field['name']] = value
plans = []
for index, s in enumerate(services):
main = s['name'] == template['compose_stack']['main_service']
env = envs[s['name']]
# Public application URLs cannot retain localhost in a remote deployment.
for key,value in list(env.items()):
if value.startswith(('http://localhost', 'http://127.0.0.1', 'http://0.0.0.0')):
url = urlsplit(value)
endpoint = template['first_run']['endpoints'][0]
env[key] = urlunsplit((url.scheme, '@STACK_LAN_IP@:'+str(endpoint['port']), url.path, url.query, url.fragment))
s['compose']['environment'] = env
single, plan = _service_plan(template, s)
k = kind(s['image'])
# Values are already resolved; do not reinterpret user credentials as Compose variables.
plan['environment'] = [{'name':key,'value':value,'sensitive':True} for key,value in env.items() if value != '']
plan.update(hostname=_hostname_default(name+'-'+s['name']), template_storage=cache,
start_after_create=False)
plan['rootfs']['storage'] = root
if 'memory_default_mb' not in single['proxmox'].get('installer_profile', {}).get('resources', {}):
plan['resources']['memory_mb'] = max(1024 if k in {'postgres','mariadb','linuxserver/mariadb','mongo','getmeili/meilisearch'} else 512, plan['resources']['memory_mb'])
plan['mounts'] = drafts[index]['deployment']['mounts']
if k == 'postgres':
health = {'type':'exec','timeout_seconds':180,'argv':['pg_isready','-h','127.0.0.1','-U',env.get('POSTGRES_USER','postgres'),'-d',env.get('POSTGRES_DB',env.get('POSTGRES_USER','postgres'))]}
elif k == 'mariadb':
health = {'type':'exec','timeout_seconds':240,'argv':['healthcheck.sh','--connect','--innodb_initialized']}
elif k == 'linuxserver/mariadb':
if not all(env.get(key) for key in ('MYSQL_PASSWORD','MYSQL_USER','MYSQL_DATABASE')):
raise StackError(translate('LinuxServer MariaDB requires a user, database and password'))
check = "MYSQL_PWD=${MYSQL_PASSWORD:-$(tr '\\000' '\\n' < /proc/1/environ | sed -n 's/^MYSQL_PASSWORD=//p')}; export MYSQL_PWD; [ \"$(mariadb --protocol=tcp -h127.0.0.1 -u\"$1\" -D\"$2\" --batch --skip-column-names -e 'SELECT 1')\" = 1 ]"
health = {'type':'exec','timeout_seconds':240,'argv':['sh','-c',check,'healthcheck',env['MYSQL_USER'],env['MYSQL_DATABASE']]}
elif k == 'mongo':
health = {'type':'exec','timeout_seconds':180,'argv':['mongosh','--quiet','--host','127.0.0.1','--eval','quit(db.adminCommand({ping:1}).ok === 1 ? 0 : 1)']}
elif k == 'getmeili/meilisearch':
health = {'type':'http','timeout_seconds':180,'endpoint':{'scheme':'http','port':7700,'path':'/health'}}
elif k in {'redis','valkey/valkey'}:
cli = 'redis-cli' if k=='redis' else 'valkey-cli'
check = '[ "$('+cli+' --raw ping)" = PONG ]'
if authenticated_redis(s):
check = "REDISCLI_AUTH=${REDISCLI_AUTH:-$(tr '\\000' '\\n' < /proc/1/environ | sed -n 's/^REDISCLI_AUTH=//p')}; export REDISCLI_AUTH; " + check
health = {'type':'exec','timeout_seconds':90,'argv':['sh','-c',check]}
else:
endpoint = template['first_run']['endpoints'][0]
health = {'type':'http','timeout_seconds':360,'endpoint':endpoint}
plans.append({'name':s['name'],'main':main,'kind':k,'offset':0 if main else len(plans)+1,
'aliases':list(dict.fromkeys([s['name'],s['compose'].get('container_name',s['name'])])),
'template':single,'deployment':plan,'healthcheck':health})
if main:
plans[-1]['frontend_ipv4'] = addresses['']
if mode != DEFAULT_MODE:
from .extra_devices import ask_stack_extra_devices
ask_stack_extra_devices(ui, plans)
onboot = ui.confirm(translate('Start the stack with Proxmox'), False)
for plan in plans:
plan['deployment']['onboot'] = onboot
return {'deployment_kind':'generic-multi-lxc-stack','stack_name':name,'base_vmid':int(vmid) if vmid else None,
'completion_notes':template.get('proxmox',{}).get('stack_completion_notes',[]),
'rootfs_storage':root,'template_storage':cache,'onboot':onboot,'start_after_create':True,
'network':{'frontend_bridge':bridge,'frontend_gateway':gateway,'private_allocation':'automatic','private_bridge':'vmbr10','private_subnet':'10.77.0.0/24','private_host_address':'10.77.0.1/24'},
'services':plans}
def apply_stack_support(template):
"""Only promote pending imported stacks that the compiler can represent."""
driver = template.get('proxmox',{}).get('installer_profile',{}).get('stack_driver')
if driver != 'generic-multi-lxc-stack' and 'native-multi-lxc-orchestrator-not-yet-implemented' not in template.get('compatibility',{}).get('untranslated_blockers',[]):
return
errors = assess(template)
if errors:
template['proxmox']['generic_stack_review'] = errors
if driver == 'generic-multi-lxc-stack':
template['compatibility']['automatic_install_candidate'] = False
template['compatibility']['untranslated_blockers'] = errors
template['status'] = 'generated-review-required'
return
template['proxmox'].pop('generic_stack_review',None)
preserved_preparations = template['proxmox'].get('installer_profile',{}).get('volume_preparations')
template['proxmox']['installer_profile'] = {'stack_driver':'generic-multi-lxc-stack'}
if preserved_preparations:
template['proxmox']['installer_profile']['volume_preparations'] = preserved_preparations
template['compatibility']['untranslated_blockers'] = []
template['compatibility']['automatic_install_candidate'] = True
template['status'] = 'generated-unvalidated'
template['lifecycle']['dependency_lifecycle'] = {'implementation':'proxmox-hookscript','trigger':'main-lxc-pre-start','waits_for_dependency_healthchecks':True,'stops_dependencies_with_main':False}