Files
ProxMenux/oci/src/proxmenux_oci/custom.py
T
MacRimi 2ab83c0b88 fix(oci): clarify the summary of imported Compose stacks
Each registry line names its image, the variables of a stack are listed
without announcing questions that are not asked, and the name given to an
imported stack names its containers.
2026-10-28 22:57:15 +01:00

990 lines
53 KiB
Python

"""An image that is not in the catalog: its Compose file, or the image itself,
is translated into the same template the catalog applications use."""
from __future__ import annotations
import copy
import json
import os
import re
import shlex
import subprocess
import sys
import urllib.request
from pathlib import Path
from typing import Any
import yaml
from . import console
from .casaos import convert_casaos_compose, normalize_app_id
from .catalog import TIME_SETTINGS
from .cli import install_template
from .i18n import source_text
from .installer import ADVANCED_MODE, DEFAULT_MODE
from .converter import ConversionError, _image_contract
from .i18n import N_, translate
from .registry_auth import RegistryError, refuses_access, validate_image
from .ui import UserCancelled
def _valid_image_reference(reference: str) -> bool:
try:
validate_image(reference)
return True
except RegistryError:
return False
def _registry_lookup(reference: str) -> str:
"""Use the pull engine's repository/digest form only for registry transport.
Skopeo rejects Docker's valid tag@digest spelling. The written reference
remains the recipe identity; its tag is removed only from the query.
"""
if not _valid_image_reference(reference):
raise ConversionError(f"{translate('This is not a valid image reference:')} {reference}")
image = _image_contract(reference)
return image['repository'] + '@' + image['digest'] if image.get('digest') else reference
MAX_COMPOSE_BYTES = 256 * 1024
# An oci-compose: the file ProxMenux installs an application from. The catalog
# keeps one for each of its applications and every installation records its own.
MAX_OCI_COMPOSE_BYTES = 2 * 1024 * 1024
OCI_COMPOSE_KIND = 'proxmenux.oci-template'
# What read_definition() names an oci-compose with, in the place of the origin
# of a Compose file: it is installed as it is written and not translated.
OCI_COMPOSE = 'oci-compose'
# The lists of an oci-compose are completed item by item, each found by this field.
CONTRACT_LISTS = {'environment': 'name', 'volumes': 'container_path', 'ports': 'container_port'}
class _Dumper(yaml.SafeDumper):
"""Compose written the way it is read: lists indented under their key, so
the #optional markers of the documentation keep their meaning."""
def increase_indent(self, flow=False, indentless=False):
return super().increase_indent(flow, False)
def _dump(document: dict[str, Any], *, sort_keys: bool = True) -> str:
return yaml.dump(document, Dumper=_Dumper, default_flow_style=False, sort_keys=sort_keys)
def _services(compose: dict[str, Any]) -> dict[str, Any]:
services = compose.get('services')
if not isinstance(services, dict) or not services:
raise ConversionError(translate('The Compose file declares no service'))
return services
def _published_port(service: dict[str, Any]) -> str | None:
for item in service.get('ports') or []:
if isinstance(item, dict):
published = item.get('published') or item.get('target')
else:
parts = str(item).split('/', 1)[0].split(':')
published = parts[-2] if len(parts) > 1 else parts[-1]
if str(published or '').isdigit():
return str(published)
return None
# Directories of the host that no application receives this way. The two time
# settings are the usual exception and are harmless.
HOST_SYSTEM_PATHS = ('/', '/bin', '/boot', '/dev', '/etc', '/lib', '/lib64', '/proc',
'/root', '/run', '/sbin', '/sys', '/usr', '/var')
def _check_mounts(service: dict[str, Any]) -> None:
"""A Compose file that reaches into the host or into the Docker engine is
refused before anything else, with the reason."""
for item in service.get('volumes') or []:
if isinstance(item, dict):
source, target = str(item.get('source') or ''), str(item.get('target') or '')
else:
parts = str(item).split(':')
source, target = (parts[0], parts[1]) if len(parts) > 1 else ('', parts[0])
if 'docker.sock' in source or 'docker.sock' in target:
raise ConversionError(translate('It works through the Docker engine of the host, '
'and a native OCI container does not have one.'))
for path in (source, target):
clean = path.rstrip('/') or '/'
if clean in TIME_SETTINGS or not clean.startswith('/'):
continue
if clean in HOST_SYSTEM_PATHS:
raise ConversionError(
f"{translate('It asks for a system directory of the host:')} {clean}. "
f"{translate('ProxMenux does not give a container the system of its host.')}")
def _keep_reference(template: dict[str, Any], reference: str) -> None:
"""The catalog normalizes every image to its latest tag; an image given by
hand keeps the tag or the digest that was written."""
image = _image_contract(reference)
if image.get('digest') or ':' in reference.split('@', 1)[0].rsplit('/', 1)[-1]:
image['pull_policy'] = 'resolve-written-reference-to-architecture-digest-at-install'
template['container_contract']['image'] = image
def template_from_compose(text: str, title: str | None = None, *, interpolated: bool = False, image_entrypoints: dict[str, list[str]] | None = None, input_metadata: dict[str, Any] | None = None) -> dict[str, Any]:
"""Translate a Compose application through the native single or stack installer."""
try:
compose = yaml.safe_load(text)
except yaml.YAMLError as error:
raise ConversionError(f"{translate('The Compose file is not valid YAML:')} {error}") from error
if not isinstance(compose, dict):
raise ConversionError(translate('The Compose file does not contain a Compose document'))
services = _services(compose)
if len(services) > 1:
return _template_from_stack(compose, title, interpolated=interpolated, image_entrypoints=image_entrypoints, input_metadata=input_metadata)
name = next(iter(services))
service = services[name] or {}
if not service.get('image'):
raise ConversionError(f"{translate('The service declares no image:')} {name}")
if service.get('build'):
raise ConversionError(translate('The service builds its own image; only a published image can be installed'))
_check_mounts(service)
identifier = normalize_app_id(str(compose.get('name') or title or name))
metadata: dict[str, Any] = {'main': name, 'title': {'en_US': title or name}}
port = _published_port(service)
if port:
metadata.update(port_map=port, scheme='http', index='/')
if compose.get('x-casaos'):
document = text
else:
document = text.rstrip('\n') + '\n' + _dump({'x-casaos': metadata})
template = convert_casaos_compose(document, 'local', '',
f'local/{identifier}/docker-compose.yml', '')
_keep_reference(template, str(service['image']))
# The container takes its time settings from Proxmox, so the time files of
# the host are not attached to it.
contract = template['container_contract']
contract['volumes'] = [volume for volume in contract.get('volumes', [])
if volume['container_path'] not in TIME_SETTINGS]
profile = template.setdefault('proxmox', {}).setdefault('installer_profile', {})
preparations = []
for volume in contract.get('volumes', []):
# Docker marks paths as read-only in its own command; the container
# here gets them read and write, and the user decides where they go.
volume['read_only'] = False
# A fresh ext4 volume carries lost+found, which stops the images that
# take ownership of their own directories.
preparations.append({'container_path': volume['container_path'],
'remove_lost_found': True,
'owner_strategy': 'mapped-application-user',
'only_when_mount_type': 'managed-volume'})
if preparations:
profile['volume_preparations'] = preparations
files = [volume['container_path'] for volume in template['container_contract'].get('volumes', [])
if Path(volume['container_path']).suffix]
if files:
raise ConversionError(
f"{translate('These container mount paths have an extension-like suffix:')} {', '.join(files)}. "
f"{translate('This import rejects these paths without checking whether they are files or directories.')}")
return template
def _template_from_stack(compose: dict[str, Any], title: str | None, *, interpolated: bool = False, image_entrypoints: dict[str, list[str]] | None = None, input_metadata: dict[str, Any] | None = None) -> dict[str, Any]:
"""Reuse the catalog stack compiler, keeping explicitly selected image versions."""
from .stack import apply_stack_support, normalized_mounts
services = _services(compose)
singles = {}
for name, service in services.items():
if not isinstance(service, dict):
raise ConversionError(f"{translate('The service declares no image:')} {name}")
singles[name] = template_from_compose(_dump({'services': {name: service}}), title)
metadata = compose.get('x-casaos', {})
if metadata is None:
metadata = {}
if not isinstance(metadata, dict):
raise ConversionError('x-casaos.main no identifica un servicio valido')
main = metadata.get('main') or next(
(name for name, service in services.items() if _published_port(service)), next(iter(services)))
if not isinstance(main, str) or main not in services:
raise ConversionError('x-casaos.main no identifica un servicio valido')
document = copy.deepcopy(compose)
document['x-casaos'] = {**metadata, 'main': main,
'title': {'en_US': title or main}}
port = _published_port(services[main])
if port:
document['x-casaos'].update(port_map=port, scheme='http', index='/')
identifier = normalize_app_id(str(compose.get('name') or title or main))
template = convert_casaos_compose(_dump(document), 'local', '',
f'local/{identifier}/docker-compose.yml', '')
template['proxmox']['custom_compose_stack'] = True
template['proxmox']['custom_compose_interpolated'] = interpolated
template['proxmox']['stack_image_entrypoints'] = copy.deepcopy(image_entrypoints or {})
if (input_metadata or {}).get('selinux_labels'):
template['proxmox']['custom_compose_selinux_labels'] = True
_keep_reference(template, str(services[main]['image']))
# Catalog imports use latest. A pasted Compose must keep every written tag/digest.
for service in template['compose_stack']['services']:
reference = singles[service['name']]['container_contract']['image']['reference']
service['image'] = reference
service['compose']['image'] = reference
if 'environment' in services[service['name']]:
service['compose']['environment'] = copy.deepcopy(services[service['name']]['environment'])
service['compose']['volumes'] = [
mount for mount in normalized_mounts(service['compose'].get('volumes', []))
if mount['target'] not in TIME_SETTINGS]
template['container_contract']['original_compose'] = _dump({
**template['compose_stack']['top_level'],
'services': {service['name']: service['compose'] for service in template['compose_stack']['services']}})
for service in template['container_contract']['related_services']:
service['image'] = singles[service['name']]['container_contract']['image']['reference']
preparations = list({entry['container_path']: entry for single in singles.values()
for entry in single['proxmox'].get('installer_profile', {}).get('volume_preparations', [])}.values())
template['proxmox'].setdefault('installer_profile', {})['volume_preparations'] = preparations
apply_stack_support(template)
# Keep compiler diagnostics visible at the custom-image boundary.
if template['proxmox'].get('generic_stack_review'):
template['compatibility']['automatic_install_candidate'] = False
template['compatibility']['untranslated_blockers'] = template['proxmox']['generic_stack_review']
return template
def enrich_image_defaults(ui, document: dict[str, Any], *, image_entrypoints: dict[str, list[str]] | None = None) -> dict[str, Any]:
"""Read implicit ports/health checks from OCI metadata, never an app-name table."""
from .stack import kind
document = copy.deepcopy(document)
inspected = {}
services = _services(document)
for name, service in services.items():
if not isinstance(service, dict) or not service.get('image'):
continue # The converter supplies the normal validation diagnostic.
health = service.get('healthcheck')
inherit_health = isinstance(health, dict) and not health.get('disable') and 'test' not in health
application = kind(str(service['image'])) == 'application'
internal = application and not service.get('ports') and not service.get('expose')
command = service.get('command')
arguments = shlex.split(command) if isinstance(command, str) else command or []
option_command = bool(arguments and isinstance(arguments[0], str) and arguments[0].startswith('-'))
if not internal and not inherit_health and not (application and health is None) and not option_command:
continue
reference = str(service['image'])
if not _valid_image_reference(reference):
raise ConversionError(f"{translate('This is not a valid image reference:')} {reference}")
if reference not in inspected:
command = ['skopeo', 'inspect', '--config', f'docker://{_registry_lookup(reference)}']
result = subprocess.run(command, capture_output=True, text=True, check=False, timeout=120)
if result.returncode and _registry_refused(reference):
from .registry_accounts import offer_sign_in
if offer_sign_in(ui, reference, translate('Image that is not in the catalog')):
result = subprocess.run(command, capture_output=True, text=True, check=False, timeout=120)
if result.returncode:
raise ConversionError(f"{translate('The image could not be read from its registry:')} {reference}")
try:
inspected[reference] = (json.loads(result.stdout).get('config') or {})
except (ValueError, AttributeError):
raise ConversionError(f"{translate('The image could not be read from its registry:')} {reference}") from None
defaults = inspected[reference]
if image_entrypoints is not None:
entrypoint = defaults.get('Entrypoint') or []
if isinstance(entrypoint, list) and all(isinstance(arg, str) for arg in entrypoint):
image_entrypoints[reference] = entrypoint
if internal and service.get('entrypoint') is None and service.get('command') is None:
ports = [port for port in (defaults.get('ExposedPorts') or {})
if str(port).endswith('/tcp') or '/' not in str(port)]
if ports:
service['expose'] = sorted(ports)
# Compose healthcheck overrides inherit the image's test unless disabled.
if (health is None or inherit_health) and (defaults.get('Healthcheck') or {}).get('Test'):
source = defaults['Healthcheck']
effective = {'test': source['Test']}
for old, new in [('Interval', 'interval'), ('Timeout', 'timeout'), ('StartPeriod', 'start_period')]:
if source.get(old):
effective[new] = str(source[old])+'ns'
if source.get('Retries'):
effective['retries'] = source['Retries']
effective.update(health or {})
service['healthcheck'] = effective
elif inherit_health:
service.pop('healthcheck', None)
return document
def compose_from_image(reference: str, title: str | None = None) -> str:
"""A Compose file written from what the image declares: its ports, its
persistent paths and its variables."""
if not _valid_image_reference(reference):
raise ConversionError(f"{translate('This is not a valid image reference:')} {reference}")
result = subprocess.run(['skopeo', 'inspect', '--config', f'docker://{_registry_lookup(reference)}'],
capture_output=True, text=True, check=False, timeout=120)
if result.returncode != 0:
raise ConversionError(f"{translate('The image could not be read from its registry:')} "
f"{(result.stderr or '').strip().splitlines()[-1] if result.stderr else reference}")
config = (json.loads(result.stdout) or {}).get('config') or {}
name = normalize_app_id(title or reference.rsplit('/', 1)[-1].split(':', 1)[0].split('@', 1)[0])
service: dict[str, Any] = {'image': reference}
ports = [port.split('/', 1)[0] for port in (config.get('ExposedPorts') or {})
if port.endswith('/tcp') or '/' not in port]
if ports:
service['ports'] = [f'{port}:{port}' for port in ports]
volumes = sorted(config.get('Volumes') or {})
if volumes:
service['volumes'] = [f'./{Path(path).name or "data"}:{path}' for path in volumes]
environment = [item for item in (config.get('Env') or [])
if '=' in item and item.split('=', 1)[0] not in {'PATH', 'HOME', 'TERM'}]
if environment:
service['environment'] = environment
return _dump({'name': name, 'services': {name: service}})
DOCKER_RUN_VALUE_FLAGS = {
'--name': 'container_name', '--hostname': 'hostname', '-h': 'hostname',
'--restart': 'restart', '--user': 'user', '-u': 'user',
'--workdir': 'working_dir', '-w': 'working_dir', '--entrypoint': 'entrypoint',
'--shm-size': 'shm_size', '--network': 'network_mode', '--net': 'network_mode',
'--ipc': 'ipc', '--runtime': 'runtime', '--memory': 'mem_limit', '-m': 'mem_limit',
'--stop-timeout': 'stop_grace_period',
}
DOCKER_RUN_LIST_FLAGS = {
'-p': 'ports', '--publish': 'ports', '-v': 'volumes', '--volume': 'volumes',
'-e': 'environment', '--env': 'environment', '--device': 'devices',
'--cap-add': 'cap_add', '--sysctl': 'sysctls', '--group-add': 'group_add',
'--add-host': 'extra_hosts', '--label': 'labels', '--security-opt': 'security_opt',
}
DOCKER_RUN_IGNORED = {'-d', '--detach', '--rm', '-i', '--interactive', '-t', '--tty',
'-it', '-ti', '--init', '--pull', '--quiet', '-q'}
def compose_from_docker_run(command: str, title: str | None = None) -> str:
"""The Compose file of a `docker run` command, which is how many images
are documented."""
# Documentation writes optional lines as `#optional`, a shell comment that
# produces nothing when the command runs; here it is removed as well.
text = re.sub(r'`\s*#\s*optional\s*`', ' \x00optional\x00 ', command, flags=re.I)
text = re.sub(r'`[^`]*`', ' ', text).replace('\\\n', ' ').replace('\\', ' ')
# `-u $(id -u)` runs the container as the user who types the command; the
# LXC takes the user of the image instead, and its volumes are owned by it.
text, host_user = re.subn(r'(?:-u|--user)\s+\$\([^)]*\)(?::\$\([^)]*\))?', ' ', text)
remaining = re.search(r'\$\([^)]*\)', text)
if remaining:
raise ConversionError(f"{translate('The command works out a value by running another command:')} "
f"{remaining.group(0)}. {translate('Write the value it produces instead.')}")
tokens = [token for token in shlex.split(text) if not token.startswith('#')]
optional: set[str] = set()
for position, token in enumerate(tokens):
if token == '\x00optional\x00' and position:
optional.add(tokens[position - 1])
tokens = [token for token in tokens if token != '\x00optional\x00']
while tokens and tokens[0] in {'sudo', 'docker', 'podman', 'container', 'run'}:
tokens.pop(0)
service: dict[str, Any] = {}
unsupported: list[str] = []
image = None
index = 0
while index < len(tokens):
token = tokens[index]
if not token.startswith('-'):
image = token
arguments = tokens[index + 1:]
if arguments:
service['command'] = arguments
break
flag, _, inline = token.partition('=')
value = inline if inline else None
if flag in DOCKER_RUN_IGNORED or token in DOCKER_RUN_IGNORED:
index += 1
continue
if flag in DOCKER_RUN_VALUE_FLAGS or flag in DOCKER_RUN_LIST_FLAGS:
if value is None:
index += 1
value = tokens[index] if index < len(tokens) else ''
if flag in DOCKER_RUN_VALUE_FLAGS:
service[DOCKER_RUN_VALUE_FLAGS[flag]] = value
else:
service.setdefault(DOCKER_RUN_LIST_FLAGS[flag], []).append(value)
elif flag == '--privileged':
service['privileged'] = True
elif flag == '--gpus':
if value is None and index + 1 < len(tokens) and not tokens[index + 1].startswith('-'):
index += 1
service['deploy'] = {'resources': {'reservations': {'devices': [
{'driver': 'nvidia', 'count': 'all', 'capabilities': ['gpu']}]}}}
elif flag in {'--env-file'}:
raise ConversionError(translate('The command reads its variables from a file; '
'write them in the command or use a Compose file'))
else:
unsupported.append(flag)
index += 1
if unsupported:
raise ConversionError(f"{translate('The command uses options that cannot be translated:')} "
f"{', '.join(sorted(set(unsupported)))}")
if not image:
raise ConversionError(translate('The command does not name an image'))
if not _valid_image_reference(image):
raise ConversionError(f"{translate('This is not a valid image reference:')} {image}")
service['image'] = image
name = normalize_app_id(title or service.get('container_name')
or image.rsplit('/', 1)[-1].split(':', 1)[0].split('@', 1)[0])
document = _dump({'name': name, 'services': {name: service}})
if host_user:
note = translate('The command runs the container as the user of the host; the container '
'uses the user of its image instead.')
document = f'#note: {note}\n' + document
if not optional:
return document
lines = []
for line in document.splitlines():
value = line.strip().lstrip('- ').strip().strip('\'"')
lines.append(f'{line} #optional' if value and value in optional else line)
return '\n'.join(lines) + '\n'
def _read_url(url: str, limit: int = MAX_COMPOSE_BYTES) -> str:
if not url.startswith(('http://', 'https://')):
raise ConversionError(translate('The address must start with http:// or https://'))
with urllib.request.urlopen(url, timeout=60) as response: # noqa: S310 - the user gives the address
return response.read(limit + 1).decode('utf-8', errors='replace')
def _read_file(path: str, limit: int = MAX_COMPOSE_BYTES) -> str:
file = Path(path).expanduser()
if not file.is_file():
raise ConversionError(f"{translate('The file does not exist:')} {file}")
if file.stat().st_size > limit:
raise ConversionError(translate('The file is too large to be a Compose file') if limit == MAX_COMPOSE_BYTES
else translate('The file is too large to be an oci-compose'))
return file.read_text(encoding='utf-8', errors='replace')
def _catalog():
from .catalog import Catalog
from .cli import PROJECT_ROOT
return Catalog(PROJECT_ROOT)
def _errors(template: dict[str, Any]) -> list[str]:
"""What an oci-compose lacks or gives in another form than the installer reads."""
catalog = _catalog()
errors = catalog.schema_errors(template)
return catalog.shape_errors(template) if errors is None else errors
def _lay(target: dict[str, Any], written: dict[str, Any], contract: bool = False) -> None:
"""Lays what the author wrote on what ProxMenux writes for the same image."""
for key, value in written.items():
if isinstance(value, dict) and isinstance(target.get(key), dict):
_lay(target[key], value, contract or key == 'container_contract')
elif contract and key in CONTRACT_LISTS and isinstance(value, list) and isinstance(target.get(key), list):
field = CONTRACT_LISTS[key]
known = {item[field]: item for item in target[key]}
target[key] = []
for item in value:
complete = json.loads(json.dumps(known.get(item[field], {})))
_lay(complete, item)
target[key].append(complete)
else:
target[key] = value
def _completed(document: dict[str, Any]) -> dict[str, Any]:
"""An oci-compose written by hand says what its author decides: the image,
its ports, variables and volumes, and how it is installed. The rest is what
ProxMenux writes for any image, and is taken from the translation of that
same image with those ports, variables and volumes."""
contract = document['container_contract']
reference = contract['image']['reference']
title = (source_text((document.get('catalog_ui') or {}).get('title')) or contract.get('container_name')
or reference.rsplit('/', 1)[-1].split(':', 1)[0].split('@', 1)[0])
name = normalize_app_id(contract.get('service_name') or title)
service: dict[str, Any] = {'image': reference}
ports = [f"{port.get('published_example') or port['container_port']}:{port['container_port']}"
+ ('/udp' if port.get('protocol') == 'udp' else '') for port in contract.get('ports') or []]
if ports:
service['ports'] = ports
volumes = [f"./{Path(volume['container_path']).name or 'data'}:{volume['container_path']}"
for volume in contract.get('volumes') or []]
if volumes:
service['volumes'] = volumes
environment = {item['name']: str(item.get('example') or '') for item in contract.get('environment') or []}
if environment:
service['environment'] = environment
template = template_from_compose(_dump({'name': name, 'services': {name: service}}), title)
for item in template['container_contract'].get('environment', []):
# A value the author left empty is asked for or generated as the file says.
if not environment.get(item['name']):
item['example'] = ''
_lay(template, document)
ui = template['catalog_ui']
if 'category_label' not in (document.get('catalog_ui') or {}):
ui['category_label'] = _catalog().categories()['labels'].get(ui.get('category'), ui.get('category_label'))
return template
def template_from_oci_compose(text: str) -> dict[str, Any]:
"""The template of an oci-compose: one written by hand, a file of the
catalog or the record of an installed application. Of a record only the
template is taken, never the answers of that installation."""
if len(text.encode('utf-8', errors='replace')) > MAX_OCI_COMPOSE_BYTES:
raise ConversionError(translate('The file is too large to be an oci-compose'))
try:
document = json.loads(text)
except ValueError as error:
raise ConversionError(f"{translate('The oci-compose is not valid JSON:')} {error}") from error
if isinstance(document, dict) and isinstance(document.get('template'), dict):
document = document['template']
if not isinstance(document, dict) or document.get('kind') != OCI_COMPOSE_KIND:
raise ConversionError(translate('The file is not a ProxMenux oci-compose.'))
contract = document.get('container_contract')
image = contract.get('image') if isinstance(contract, dict) else None
reference = image.get('reference') if isinstance(image, dict) else None
if not isinstance(reference, str) or not reference.strip():
raise ConversionError(translate('The oci-compose is incomplete: it does not say which image to install.'))
if not _valid_image_reference(reference):
raise ConversionError(f"{translate('This is not a valid image reference:')} {reference}")
faulty = translate('The oci-compose has errors:')
def refuse(errors: list[str]) -> None:
more = [f' ... (+{len(errors) - 8})'] if len(errors) > 8 else []
raise ConversionError('\n'.join([faulty, *(f' {error}' for error in errors[:8]), *more]))
template = json.loads(json.dumps(document))
offered = None
try:
if _errors({'first_run': {'endpoints': [], 'credentials': []}, **template}):
# Written by hand: what it says has to be in its form before it is completed.
misshapen = [error for error in _catalog().shape_errors(template) if not error.endswith(': required')]
misshapen += [f'container_contract/{name}/{position}/{field}: required'
for name, field in CONTRACT_LISTS.items() if isinstance(contract.get(name), list)
for position, item in enumerate(contract[name])
if isinstance(item, dict) and field not in item]
if misshapen:
refuse(misshapen)
# The paths the author gives as a host directory stay offered as one.
offered = frozenset(volume['container_path'] for volume in contract.get('volumes') or []
if 'host-bind' in (volume.get('installation_choice') or [])
or volume.get('default') == 'host-bind')
template = _completed(template)
template.setdefault('first_run', {'endpoints': [], 'credentials': []})
template = _catalog().finish(template, offered)
except (KeyError, TypeError, AttributeError, IndexError, ValueError) as error:
raise ConversionError(f'{faulty} {type(error).__name__} {error}') from error
errors = _errors(template)
if errors:
refuse(errors)
return template
def describe_oci_compose(template: dict[str, Any]) -> str:
"""What an oci-compose adds to what a Compose file can say: the containers
it is made of and the files it writes in them."""
lines = []
services = [service.get('name') for service in (template.get('compose_stack') or {}).get('services') or []
if isinstance(service, dict) and service.get('name')]
if len(services) > 1:
lines += ['', translate('Containers of the application:'), f" {', '.join(services)}"]
profile = (template.get('proxmox') or {}).get('installer_profile') or {}
written = [item.get('container_path') for item in profile.get('generated_files') or [] if item.get('container_path')]
if written:
lines += ['', translate('Files it writes in the container:')] + [f' {path}' for path in written]
lines += ['', translate('This file decides what is mounted and with which privileges. '
'Install it only if it comes from a source you trust.')]
# It follows the description of the image, after an empty line.
return '\n' + '\n'.join(lines)
def _read_pasted(ui, title: str | None = None, limit: int = MAX_COMPOSE_BYTES, missing: str | None = None) -> str:
"""The definition is pasted in the terminal: dialog cannot take it. On a
terminal one Ctrl+D ends it, also at the end of a line that has text."""
console.show_logo()
console.msg_title(title or translate('Compose file of the application'))
console.msg_info2(translate('Paste it here and press Ctrl+D.'))
print()
if sys.stdin.isatty():
data = b''
while len(data) <= limit:
chunk = os.read(sys.stdin.fileno(), 65536)
data += chunk
# The terminal hands over whole lines; a chunk without its newline
# is the line Ctrl+D sent before the end of the line.
if not chunk.endswith(b'\n'):
break
text = data.decode('utf-8', errors='replace')
else:
text = sys.stdin.read(limit + 1)
if not text.strip():
raise UserCancelled(missing or (translate('No docker run command was given') if title is not None
else translate('No Compose file was given')))
return text
def read_definition(ui) -> tuple[str, str]:
"""The Compose file of the application and where it came from, or an
oci-compose, named by OCI_COMPOSE."""
from .cli import MENU_SIZE
ui._compose_source_dir = None
source = ui.choose(translate('How is the image described?'), [
('paste', translate('Paste its Compose file in the terminal')),
('file', translate('Read its Compose file from a file of this host')),
('url', translate('Download its Compose file from an address')),
('run', translate('Paste its docker run command in the terminal')),
('image', translate('Only the image reference, with no Compose file')),
('oci-compose-paste', translate('Paste an oci-compose in the terminal')),
('oci-compose', translate('Read an oci-compose from a file of this host')),
('oci-compose-url', translate('Download an oci-compose from an address')),
], 'paste', title=translate('Image that is not in the catalog'), size=MENU_SIZE)
if source is None:
raise UserCancelled(translate('Image selection was cancelled'))
if source == 'oci-compose-paste':
return _read_pasted(ui, translate('oci-compose of the application'), MAX_OCI_COMPOSE_BYTES,
translate('No oci-compose was given')), OCI_COMPOSE
if source == 'oci-compose':
return _read_file(ui.ask(translate('Path of the oci-compose file'), '/root/oci-compose.json'),
MAX_OCI_COMPOSE_BYTES), OCI_COMPOSE
if source == 'oci-compose-url':
return _read_url(ui.ask(translate('Address of the oci-compose file'), ''), MAX_OCI_COMPOSE_BYTES), OCI_COMPOSE
if source == 'paste':
return _read_pasted(ui), translate('pasted Compose file')
if source == 'file':
path = ui.ask(translate('Path of the Compose file'), '/root/docker-compose.yml')
ui._compose_source_dir = Path(path).expanduser().resolve().parent
return _read_file(path), translate('Compose file of this host')
if source == 'url':
return _read_url(ui.ask(translate('Address of the Compose file'), '')), translate('downloaded Compose file')
if source == 'run':
return compose_from_docker_run(_read_pasted(ui, translate('docker run command of the application'))), \
translate('docker run command')
reference = ui.ask(translate('Image reference (for example ghcr.io/user/application:latest)'), '')
try:
return compose_from_image(reference), translate('image itself')
except ConversionError as error:
from .registry_accounts import offer_sign_in
if not (refuses_access(error) and offer_sign_in(ui, reference)):
raise
return compose_from_image(reference), translate('image itself')
BLOCKER_TEXTS = {
'multi-service-compose': N_('it describes more than one image'),
'native-multi-lxc-orchestrator-not-yet-implemented': N_('it describes more than one image'),
'top-level-configs': N_('it uses Compose configs, which have no equivalent here'),
'devices-format': N_('the devices it asks for are not written in a way that can be read'),
'healthcheck-format': N_('its health check is not written in a way that can be read'),
'stop-grace-period-format': N_('its stop timeout is not written in a way that can be read'),
'shm-size-format': N_('its shared memory size is not written in a way that can be read'),
'ulimits-format-or-resource': N_('the resource limits it asks for cannot be applied'),
'mem-limit-format-or-below-proxmox-minimum': N_('the memory limit it asks for cannot be applied'),
'mem-limit-deploy-consistency-review': N_('it asks for two different memory limits'),
}
def blocker_text(code: str) -> str:
"""The reason a definition cannot be installed, in plain words."""
code = re.sub(r'^service:[^:]+:', '', code)
if code in BLOCKER_TEXTS:
return translate(BLOCKER_TEXTS[code])
if code.startswith('compose-key:'):
return f"{translate('it uses the Compose option')} {code.split(':', 1)[1]}"
if code.startswith('device-mapping:'):
return f"{translate('a device it asks for cannot be translated:')} {code.split(':', 1)[1]}"
if code.endswith(':missing-image'):
return translate('one of its services declares no image')
if code.endswith(':invalid-definition'):
return translate('one of its services is not written as a service')
return code
def ignored_settings(text: str) -> list[str]:
"""Settings of the definition that only mean something in Docker and are
not applied here; the user should know they were read and left aside."""
try:
compose = yaml.safe_load(text)
except yaml.YAMLError:
return []
if not isinstance(compose, dict):
return []
notes = []
for service in (compose.get('services') or {}).values():
if not isinstance(service, dict):
continue
deploy = service.get('deploy') or {}
swarm = sorted(set(deploy) - {'resources'}) if isinstance(deploy, dict) else []
if swarm:
notes.append(f"{translate('Only a Docker Swarm uses these settings, so they are not applied:')} "
f"deploy.{', deploy.'.join(swarm)}")
if service.get('labels'):
notes.append(translate('Its labels are not applied: they are read by other Docker tools.'))
for service in (compose.get('services') or {}).values():
if isinstance(service, dict) and any(
str(item).split(':')[0].rstrip('/') in TIME_SETTINGS for item in service.get('volumes') or []
if not isinstance(item, dict)):
notes.append(translate('The container takes its time zone from Proxmox, so the time files '
'of the host are not attached to it.'))
break
if len(compose.get('services') or {}) == 1 and (compose.get('networks') or compose.get('volumes')):
notes.append(translate('The container gets its own address and its own volumes, so the networks '
'and volumes declared in the file are not used.'))
return notes
def registry_report(reference: str) -> tuple[bool, str]:
"""Whether the image really exists in its registry, and for which
architectures. A name written by hand is easy to get wrong."""
result = subprocess.run(['skopeo', 'inspect', '--raw', f'docker://{_registry_lookup(reference)}'],
capture_output=True, text=True, check=False, timeout=120)
if result.returncode != 0:
return False, f"{translate('Could not inspect the image in its registry:')} {reference}"
try:
document = json.loads(result.stdout)
except ValueError:
return True, translate('The image is in its registry.')
manifests = document.get('manifests')
if not manifests:
# The image publishes a single manifest: its architecture is in the image itself.
detail = subprocess.run(['skopeo', 'inspect', f'docker://{_registry_lookup(reference)}'],
capture_output=True, text=True, check=False, timeout=120)
try:
architecture = json.loads(detail.stdout).get('Architecture') if detail.returncode == 0 else None
except ValueError:
architecture = None
if not architecture:
return True, translate('The image is in its registry, for one architecture.')
return True, (f"{translate('The image is in its registry:')} {architecture} "
f"({translate('it publishes no other architecture')})")
architectures = sorted({item.get('platform', {}).get('architecture', '')
for item in manifests} - {'', 'unknown'})
return True, f"{translate('The image is in its registry:')} {', '.join(architectures)}"
def _registry_refused(reference: str) -> bool:
"""Whether the registry answered that the image needs an account."""
try:
result = subprocess.run(['skopeo', 'inspect', '--raw', f'docker://{_registry_lookup(reference)}'],
capture_output=True, text=True, check=False, timeout=120)
except (OSError, subprocess.TimeoutExpired, ConversionError):
return False
return result.returncode != 0 and refuses_access(result.stderr)
def describe(template: dict[str, Any]) -> str:
"""What the translation understood, in the words of the installer, with
everything that changes how the container is created."""
contract = template['container_contract']
proxmox = template.get('proxmox', {}) or {}
profile = proxmox.get('installer_profile', {}) or {}
security = proxmox.get('security_profile', {}) or {}
lines = [f"{translate('Image') + ':':<14} {contract['image']['reference']}"]
if proxmox.get('custom_compose_selinux_labels'):
lines += [translate('SELinux volume labels are not applied on this Proxmox host.')]
services = (template.get('compose_stack') or {}).get('services') or []
if len(services) > 1:
lines += ['', translate('Containers of the application:')]
lines += [f" {service['name']}: {service['image']}" for service in services]
endpoints = template.get('first_run', {}).get('endpoints') or []
if endpoints:
# The address is read from the published port; the image does not say
# whether it answers over http or https.
lines.append(f"{translate('Web access') + ':':<14} " + ', '.join(
f"{item.get('scheme', 'http')}://<IP>:{item.get('port')}{item.get('path') or '/'}" for item in endpoints)
+ f" ({translate('https if the image serves TLS')})")
# The container has its own address, so the port Docker publishes on the
# host is not used: the application answers on its own port.
ports = []
republished = False
for port in contract.get('ports') or []:
ports.append(str(port['container_port']) + ('' if port.get('required', True)
else f" ({translate('optional')})"))
republished = republished or (port.get('published_example')
and int(port['published_example']) != int(port['container_port']))
if ports:
lines.append(f"{translate('Ports') + ':':<14} {', '.join(ports)}")
if republished:
own = translate('the container has its own address, so the port Docker published '
'on the host is not needed')
lines.append(f"{'':<14} {own}")
if (profile.get('network') or {}).get('compose_mode') == 'host':
# Docker shares the network of the host; in Proxmox the container has
# its own address, which is what the installation gives it.
lines.append(f"{translate('Network') + ':':<14} "
f"{translate('it asks for the network of the host; the container gets its own address instead')}")
volumes = contract.get('volumes') or []
if volumes:
lines += ['', translate('Persistent data:')]
lines += [f" {volume['container_path']}"
+ ('' if volume.get('default') != 'skip' else f" ({translate('optional')})")
for volume in volumes]
environment = contract.get('environment') or []
if environment:
# A stack takes its values from the Compose file; none is asked here.
lines += ['', translate('Variables the installation asks for:') if len(services) <= 1 else
f"{translate('Variables')} ({template['compose_stack']['main_service']}):"]
lines += [f" {item['name']}"
+ (f" = {item['example']}" if item.get('example') and not item['sensitive'] and len(services) <= 1 else '')
+ ('' if item.get('required', True) else f" ({translate('optional')})")
for item in environment]
devices = profile.get('device_requests') or []
if devices:
lines += ['', translate('Devices of the host it asks for:')]
lines += [f" {translate(str(item.get('enable_prompt') or item.get('purpose') or item.get('id')))}"
for item in devices]
warnings = []
if security.get('requires_privileged_lxc'):
warnings.append(translate('This profile requires a privileged LXC, which reduces isolation from the host.'))
elif security.get('source_requests_privileged_lxc') or security.get('optional_privileged_lxc'):
warnings.append(translate('Its Compose file asks for privileged mode; the container is created '
'unprivileged and that mode is only offered as an option.'))
if security.get('requires_relaxed_confinement') or security.get('source_requests_relaxed_confinement'):
warnings.append(translate('A relaxed AppArmor or seccomp profile is requested; this may be optional.'))
if security.get('requires_host_pid_namespace'):
warnings.append(translate('It asks to see the processes of the host.'))
if warnings:
lines += ['', translate('Worth knowing before installing it:')] + [f' {text}' for text in warnings]
blockers = template.get('compatibility', {}).get('untranslated_blockers') or []
if blockers:
lines += ['', translate('What cannot be translated:')] + [f' {blocker_text(blocker)}' for blocker in blockers]
return '\n'.join(lines)
# A value that looks like a secret is asked during the installation instead of
# being taken from the definition, where it is usually the documented example.
SECRET_NAME = re.compile(r'(?:^|_)(pass|passwd|password|pwd?|secret|token|apikey|api_key|key)$', re.I)
def _ask_secrets(template: dict[str, Any]) -> list[str]:
asked = []
for item in template['container_contract'].get('environment', []):
if SECRET_NAME.search(item['name']) and not item['sensitive']:
item.update(sensitive=True, example='', required=True)
asked.append(item['name'])
return asked
def _name(ui, template: dict[str, Any]) -> str:
stack = template.get('compose_stack') if len((template.get('compose_stack') or {}).get('services') or []) > 1 else None
default = normalize_app_id((stack or {}).get('project_name') or source_text(template['catalog_ui'].get('title'))
or template['container_contract'].get('container_name') or 'oci-app')
name = normalize_app_id(ui.ask(translate('Name for this application'), default))
if not name:
raise UserCancelled(translate('No name was given'))
template['catalog_ui']['title'] = {'en_US': name}
template['container_contract']['container_name'] = name
if stack:
# The containers of the stack are named after it.
stack['project_name'] = name
return name
def _explore(ui) -> None:
"""Reads a Compose file, a docker run command, an image reference or an
oci-compose, reports what ProxMenux would install from it and installs it."""
text, origin = read_definition(ui)
written = origin == OCI_COMPOSE
if written:
notes = []
template = template_from_oci_compose(text)
else:
notes = [line.partition(':')[2].strip() for line in text.splitlines() if line.startswith('#note:')]
notes += ignored_settings(text)
try:
document = yaml.safe_load(text)
except yaml.YAMLError as error:
raise ConversionError(f"{translate('The Compose file is not valid YAML:')} {error}") from error
multiple = isinstance(document, dict) and len(document.get('services') or {}) > 1
if multiple:
from .compose_input import prepare
input_metadata = {}
document = prepare(ui, document, getattr(ui, '_compose_source_dir', None), metadata=input_metadata)
text = _dump(document, sort_keys=False)
template = template_from_compose(text, interpolated=True, input_metadata=input_metadata) if multiple else template_from_compose(text)
title = translate('Image that is not in the catalog')
summary = describe(template) + (describe_oci_compose(template) if written else '')
stack_services = (template.get('compose_stack') or {}).get('services') or []
references = list(dict.fromkeys(service['image'] for service in stack_services)) if len(stack_services) > 1 else [template['container_contract']['image']['reference']]
available = True
reports = []
for reference in references:
accessible, report = registry_report(reference)
if not accessible and _registry_refused(reference):
from .registry_accounts import offer_sign_in
if offer_sign_in(ui, reference, title):
accessible, report = registry_report(reference)
available = available and accessible
if accessible and len(references) > 1:
report = f'{reference}: {report}'
reports.append(report)
summary += '\n\n' + report
if notes:
summary += '\n\n' + '\n'.join(notes)
if not available:
advice = translate('Check the image reference and registry access. If the registry is unavailable, '
'try again later. For a private image, sign in from Registry accounts in the OCI menu.')
ui.message(f'{summary}\n\n{advice}', title)
return
if not written and multiple:
image_entrypoints = {}
document = enrich_image_defaults(ui, document, image_entrypoints=image_entrypoints)
template = template_from_compose(_dump(document, sort_keys=False), interpolated=True, image_entrypoints=image_entrypoints, input_metadata=input_metadata)
summary = describe(template) + ''.join('\n\n'+report for report in reports)
if notes:
summary += '\n\n'+'\n'.join(notes)
if not template.get('compatibility', {}).get('automatic_install_candidate'):
ui.message(f"{translate('This image cannot be installed as it is described:')}\n\n{summary}", title)
return
# An oci-compose already says which of its values are secret and which it generates.
secrets = [] if written or len(stack_services) > 1 else _ask_secrets(template)
if secrets:
summary += ('\n\n' + translate('These values are asked during the installation:') + ' '
+ ', '.join(secrets))
heading = (translate('This is what the oci-compose installs:') if written
else f"{translate('This is what ProxMenux understood from the')} {origin}:")
if not ui.review(f'{heading}\n\n{summary}', title, question=translate('Install this image?'), default=False):
return
mode = ui.choose(translate('How is it installed?'),
[(DEFAULT_MODE, translate('Default: only what the application needs')),
(ADVANCED_MODE, translate('Advanced: every setting of the container'))],
DEFAULT_MODE, title=title)
if mode is None:
return
# The record of the instance keeps the whole template, which is what an
# update, a recreation or a removal read later.
if written:
# An oci-compose keeps the name it was written with.
name = normalize_app_id(source_text(template['catalog_ui'].get('title')) or str(template.get('id') or 'oci-app'))
else:
name = _name(ui, template)
install_template(ui, template, name, mode)
def conversion_message(error: ConversionError) -> str:
"""Render legacy converter diagnostics at the custom-image UI boundary.
Converter exceptions are also consumed by catalog tools. Keep their
original text, and any diagnostic data, outside this localized UI.
"""
text = str(error)
exact = {
'environment must be a list or a mapping': N_('Compose environment must be a list or a mapping.'),
'volumes must be a list': N_('Compose volumes must be a list.'),
'ports must be a list': N_('Compose ports must be a list.'),
'The CasaOS Compose file has no services': N_('The Compose file has no services.'),
'The CasaOS document is not a Compose mapping': N_('The Compose document must be a mapping.'),
'The Compose file has no x-casaos metadata': N_('The Compose file has no x-casaos metadata.'),
'x-casaos.main no identifica un servicio valido': N_('x-casaos.main must name an existing service.'),
'The main CasaOS service declares no image': N_('The main Compose service has no image.'),
}
prefixes = {
'Ruta de volumen no valida:': N_('Invalid Compose volume path:'),
'Puerto no valido:': N_('Invalid Compose port:'),
'Cannot normalise the CasaOS identifier:': N_('Invalid application identifier:'),
'Docker Compose CasaOS no valido:': N_('The Compose file is not valid YAML:'),
}
if text in exact:
translated = translate(exact[text])
return text if translated == exact[text] else translated
for prefix, source in prefixes.items():
if text.startswith(prefix):
translated = translate(source)
return text if translated == source else translated + text[len(prefix):]
return text
def explore(ui) -> None:
try:
_explore(ui)
except ConversionError as error:
raise ConversionError(conversion_message(error)) from error