feat(oci): support custom multi-service Compose applications

This commit is contained in:
Jonatan
2026-10-28 06:28:18 +01:00
parent cdb1bd0cbf
commit 692c7f7256
14 changed files with 424 additions and 56 deletions
+2 -2
View File
@@ -209,7 +209,7 @@ The native path checks the saved configuration and installation identity, keeps
## From a Compose file or a docker run command to an oci-compose
The converter reads a Compose file with one service. A `docker run` command and an image reference alone are first written as a Compose file and follow the same path, so the three produce the same kind of oci-compose.
The converter reads Compose files with one or several services. Multi-service files use the same native stack compiler as catalog applications: each service runs in its own LXC, with dependency ordering and private networking. The first service that publishes a port is selected as the main service, unless `x-casaos.main` specifies another one. Every image is checked for registry access before installation, and written tags and digests are preserved. Each web application uses its own published container port for its health check and receives its own LAN address. Repeated identical volume sources share one host directory, whose location is selected once; separate configuration volumes remain independent. Unsupported stack features (including external networks and application services without a supported health check) are reported before installation. A `docker run` command and an image reference alone are first written as a Compose file and follow the same path, so the three produce the same kind of oci-compose.
Starting from this definition:
@@ -251,7 +251,7 @@ each key becomes a field of the oci-compose:
| `stop_grace_period` | `proxmox.defaults.shutdown_timeout_seconds` | The time a shutdown waits |
| `labels`, `logging` | Kept in `container_contract.original_compose` only | Not applied: they are read by Docker tools |
What has no translation stops the installation and is named: `pid: host`, a Compose file with several services given to this option, `configs`, external secrets, a `build` section without a published image, and shell substitutions such as `$(command)`.
What has no translation stops the installation and is named: `pid: host`, `configs`, external secrets, a `build` section without a published image, and shell substitutions such as `$(command)`.
Besides those fields, the converter writes the blocks that describe the translation itself: where the definition came from, the keys it understood, the adaptations it applied and the state of each validation. They are listed in [Blocks ProxMenux writes](#blocks-proxmenux-writes).
+8 -1
View File
@@ -421,6 +421,12 @@ def attach_mounts(service, temporary):
if not source.is_absolute() or ',' in str(source) or '\n' in str(source):
raise RuntimeError(translate('Invalid shared path'))
if not source.exists():
shared_owner = mount.get('host_owner')
if shared_owner is not None:
if (not isinstance(shared_owner, list) or len(shared_owner) != 2
or any(type(value) is not int or not 0 <= value <= 2**32-2 for value in shared_owner)):
raise RuntimeError(translate('Invalid shared path'))
owner = (*shared_owner, owner[2])
source.mkdir(parents=True)
os.chown(source,*owner[:2])
source.chmod(owner[2])
@@ -883,7 +889,8 @@ def main():
refresh_notes(services)
# The credentials of the main service come from its own installation.
result.update(vmid=primary_id,ip=lan,stack_vmids={s['name']:s['vmid'] for s in services},
urls=[{'label':'Web UI','url':public_url}],
urls=(lan_access_urls(services, subnet) if sum(bool(s['main'] or s.get('frontend')) for s in services) > 1
else [{'label':'Web UI','url':public_url}]),
credentials=result.get('credentials') or [])
result.update(completion_notes=[note.replace('{main_vmid}',str(primary_id))
for note in deployment.get('completion_notes', [])], log=LOG)
+82 -26
View File
@@ -2,6 +2,7 @@
is translated into the same template the catalog applications use."""
from __future__ import annotations
import copy
import json
import os
import re
@@ -20,7 +21,7 @@ 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
from .converter import ConversionError, _image_contract
from .i18n import N_, translate
from .registry_auth import RegistryError, refuses_access, validate_image
from .ui import UserCancelled
@@ -107,22 +108,14 @@ def _check_mounts(service: dict[str, Any]) -> None:
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."""
repository, _, digest = reference.partition('@')
tag = ''
if ':' in repository.rsplit('/', 1)[-1]:
repository, _, tag = repository.rpartition(':')
image = template['container_contract']['image']
image['reference'] = reference if (tag or digest) else f'{repository}:latest'
image['repository'] = repository
image['tag'] = tag or ('' if digest else 'latest')
image['digest'] = f'@{digest}' if digest else None
if tag or digest:
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) -> dict[str, Any]:
"""The template of a Compose file with a single service. The metadata the
importer expects is taken from the service itself."""
"""Translate a Compose application through the native single or stack installer."""
try:
compose = yaml.safe_load(text)
except yaml.YAMLError as error:
@@ -131,9 +124,7 @@ def template_from_compose(text: str, title: str | None = None) -> dict[str, Any]
raise ConversionError(translate('The Compose file does not contain a Compose document'))
services = _services(compose)
if len(services) > 1:
raise ConversionError(
f"{translate('The Compose file describes several services:')} {', '.join(services)}. "
f"{translate('Only one service at a time can be installed this way.')}")
return _template_from_stack(compose, title)
name = next(iter(services))
service = services[name] or {}
if not service.get('image'):
@@ -181,6 +172,63 @@ def template_from_compose(text: str, title: str | None = None) -> dict[str, Any]
return template
def _template_from_stack(compose: dict[str, Any], title: str | None) -> dict[str, Any]:
"""Reuse the catalog stack compiler, keeping explicitly selected image versions."""
from .stack import apply_stack_support
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
_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 service['compose'].get('volumes', [])
if (mount.get('target') if isinstance(mount, dict)
else str(mount).split(':')[-1]) 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 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."""
@@ -589,7 +637,7 @@ def ignored_settings(text: str) -> list[str]:
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 compose.get('networks') or compose.get('volumes'):
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
@@ -642,6 +690,10 @@ def describe(template: dict[str, Any]) -> str:
profile = proxmox.get('installer_profile', {}) or {}
security = proxmox.get('security_profile', {}) or {}
lines = [f"{translate('Image') + ':':<14} {contract['image']['reference']}"]
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
@@ -679,7 +731,7 @@ def describe(template: dict[str, Any]) -> str:
if environment:
lines += ['', translate('Variables the installation asks for:')]
lines += [f" {item['name']}"
+ (f" = {item['example']}" if item.get('example') and not item['sensitive'] else '')
+ (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 []
@@ -744,13 +796,17 @@ def _explore(ui) -> None:
template = template_from_compose(text)
title = translate('Image that is not in the catalog')
summary = describe(template) + (describe_oci_compose(template) if written else '')
reference = template['container_contract']['image']['reference']
available, report = registry_report(reference)
if not available and _registry_refused(reference):
from .registry_accounts import offer_sign_in
if offer_sign_in(ui, reference, title):
available, report = registry_report(reference)
summary += '\n\n' + report
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
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
summary += '\n\n' + report
if notes:
summary += '\n\n' + '\n'.join(notes)
if not available:
@@ -762,7 +818,7 @@ def _explore(ui) -> None:
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 else _ask_secrets(template)
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))
+83 -10
View File
@@ -5,13 +5,14 @@ import copy
import base64
import re
import secrets
import hashlib
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 .converter import _image_contract, _split_short_mount
from .i18n import translate
@@ -143,7 +144,10 @@ def service_template(parent, service, main=False):
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']
image = _image_contract(service['image'])
if image.get('digest') or ':' in service['image'].split('@', 1)[0].rsplit('/', 1)[-1]:
image['pull_policy'] = 'resolve-written-reference-to-architecture-digest-at-install'
single['container_contract']['image'] = image
single['container_contract']['environment'] = [
{'name': k, 'example': v, 'required': True, 'sensitive': True, 'prompt_user': False}
for k, v in c['environment'].items()
@@ -166,13 +170,41 @@ def service_template(parent, service, main=False):
return single
def service_endpoint(service):
"""A custom web service is checked on its own container port, not a host mapping."""
for port in service['compose'].get('ports') or []:
if isinstance(port, dict):
protocol = port.get('protocol', 'tcp')
target = port.get('target')
else:
raw, _, protocol = str(port).partition('/')
protocol = protocol or 'tcp'
target = raw.rsplit(':', 1)[-1]
if protocol == 'tcp' and str(target).isdigit() and 0 < int(target) < 65536:
return {'scheme': 'http', 'port': int(target), 'path': '/'}
return None
def shared_sources(services):
"""Source identities mounted more than once, before storage is chosen."""
users = {}
for service in services:
for mount in normalized_mounts(service['compose'].get('volumes', [])):
source = mount.get('source')
if source:
users.setdefault(source, []).append(service['name'])
return {source for source, names in users.items() if len(names) > 1}
def assess(template):
"""Reject untranslated semantics before any host resources are allocated."""
errors = []
custom = template.get('proxmox', {}).get('custom_compose_stack', False)
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):
if any(k not in {'name', 'networks', 'volumes', 'version'}
and not (custom and str(k).startswith('x-')) 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()):
@@ -185,17 +217,18 @@ def assess(template):
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':
if (s['name'] != template['compose_stack']['main_service'] and kind(s['image']) == 'application'
and not (custom and service_endpoint(s))):
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:
if source is not None and source in seen_sources and not custom:
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'):
if set(m) - {'type','source','target','read_only'} or (m.get('read_only') and not custom):
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'))
@@ -209,6 +242,15 @@ def assess(template):
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 custom:
for source in shared_sources(services):
owners = set()
for service in services:
if any(m.get('source') == source for m in normalized_mounts(service['compose'].get('volumes', []))):
env = environment(service['compose'].get('environment'))
owners.add((env.get('PUID', '1000'), env.get('PGID', '1000')))
if len(owners) > 1:
errors.append(f"{source}: {translate('Volumes shared between services are not yet supported')}")
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', {}))
@@ -300,6 +342,9 @@ def build_stack(template, ui, mode='advanced'):
raise StackError('; '.join(problems))
services = copy.deepcopy(ordered_services(template))
defaults = template['proxmox']['defaults']
custom = template.get('proxmox', {}).get('custom_compose_stack', False)
shared = shared_sources(services) if custom else set()
shared_paths = {}
# A default installation takes the name, the VMID and the settings from the
# recipe; it still asks the storage, the address and how the stack starts.
quiet = DefaultsUI() if mode == DEFAULT_MODE else ui
@@ -332,8 +377,31 @@ def build_stack(template, ui, mode='advanced'):
# they go is asked in a default installation too; the databases and
# caches beside it keep the values of the recipe.
asked = mode != DEFAULT_MODE or s['name'] == template['compose_stack']['main_service']
source_mounts = {m['target']: m for m in normalized_mounts(s['compose'].get('volumes', []))}
for m in plan['mounts']:
label = f"{s['name']}: {m['container_path']}"
source_mount = source_mounts.get(m['container_path'], {})
source = source_mount.get('source')
if source in shared:
if source not in shared_paths:
slug = re.sub(r'[^A-Za-z0-9_.-]+', '-', source).strip('.-') or 'data'
identity = hashlib.sha256(source.encode()).hexdigest()[:8]
default_path = source if source.startswith('/') else '/mnt/oci-shared/'+name+'/shared/'+slug+'-'+identity
shared_paths[source] = ui.ask(f"{translate('Host path for')} {source}", default_path)
chosen = shared_paths[source]
if not chosen.startswith('/') or any(c in chosen for c in ',\n\r') or '..' in chosen.split('/'):
raise StackError(translate('Invalid volume target'))
if chosen.rstrip('/') in {'', '/etc', '/var', '/usr', '/root', '/dev', '/proc', '/sys', '/run', '/boot', '/bin', '/sbin', '/lib', '/lib64'}:
raise StackError(translate('Invalid volume target'))
env = draft_envs[s['name']]
uid, gid = env.get('PUID', '1000'), env.get('PGID', '1000')
if not str(uid).isdigit() or not str(gid).isdigit() or int(uid) > 65535 or int(gid) > 65535:
raise StackError(translate('Invalid resources'))
offset = 100000 if plan.get('security', {}).get('unprivileged', True) else 0
m.update(type='host-bind', source=shared_paths[source], backup=False, size_gb=None,
read_only=bool(source_mount.get('read_only')),
host_owner=[int(uid)+offset, int(gid)+offset])
continue
mount_mode = (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)'))],
@@ -358,7 +426,9 @@ def build_stack(template, ui, mode='advanced'):
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, [''])
frontend_names = [s['name'] for s in services if s['name'] == template['compose_stack']['main_service'] or (custom and service_endpoint(s))]
address_labels = frontend_names if custom else ['']
addresses, gateway = access.ask_addresses(ui, bridge, address_labels)
timezone = quiet.ask(translate('Timezone'), host.timezone())
envs = resolve_environments(services, timezone, generators)
for group in template.get('proxmox', {}).get('stack_optional_environment', []):
@@ -418,13 +488,16 @@ def build_stack(template, ui, mode='advanced'):
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]
endpoint = service_endpoint(s) if custom else template['first_run']['endpoints'][0]
if endpoint is None:
raise StackError(f"{s['name']}: {translate('Main endpoint not yet defined')}")
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 s['name'] in frontend_names:
plans[-1]['frontend'] = True
plans[-1]['frontend_ipv4'] = addresses[s['name']] if custom else addresses['']
if mode != DEFAULT_MODE:
from .extra_devices import ask_stack_extra_devices
ask_stack_extra_devices(ui, plans)
+111
View File
@@ -0,0 +1,111 @@
"""Pasted Compose files enter the same checked stack flow as catalog recipes."""
import sys
import unittest
from pathlib import Path
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'src'))
from proxmenux_oci import custom
from proxmenux_oci.converter import ConversionError
from proxmenux_oci.stack import ordered_services, resolve_environments, service_template
COMPOSE = '''services:
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: supplied-password
volumes: [dbdata:/var/lib/postgresql/data]
app:
image: adminer:4
ports: ['8080:8080']
depends_on: [db]
volumes:
dbdata:
'''
class CustomComposeStacks(unittest.TestCase):
def test_stack_uses_existing_installer_and_preserves_versions_and_environment(self):
template = custom.template_from_compose(COMPOSE)
self.assertTrue(template['compatibility']['automatic_install_candidate'])
self.assertEqual(template['proxmox']['installer_profile']['stack_driver'], 'generic-multi-lxc-stack')
self.assertEqual(template['compose_stack']['main_service'], 'app')
services = ordered_services(template)
self.assertEqual([s['name'] for s in services], ['db', 'app'])
self.assertEqual([s['image'] for s in services], ['postgres:16', 'adminer:4'])
self.assertEqual(resolve_environments(services, 'UTC')['db']['POSTGRES_PASSWORD'], 'supplied-password')
self.assertEqual(service_template(template, services[0])['container_contract']['image']['reference'], 'postgres:16')
self.assertNotIn('supplied-password', custom.describe(template))
def test_stack_summary_hides_credentials_embedded_in_a_url(self):
document = COMPOSE.replace(
" ports:",
" environment: {DATABASE_URL: 'postgres://user:embedded-secret@db/app'}\n ports:")
template = custom.template_from_compose(document)
summary = custom.describe(template)
self.assertIn('DATABASE_URL', summary)
self.assertNotIn('embedded-secret', summary)
self.assertNotIn('postgres://user:', summary)
app = next(service for service in ordered_services(template) if service['name'] == 'app')
self.assertIn('embedded-secret', app['compose']['environment']['DATABASE_URL'])
def test_invalid_stack_metadata_has_a_conversion_error(self):
for metadata in ('x-casaos: not-a-mapping', 'x-casaos: [app]',
'x-casaos: {main: missing-service}'):
with self.subTest(metadata=metadata), self.assertRaises(ConversionError):
custom.template_from_compose(COMPOSE + metadata + '\n')
def test_written_digest_and_tag_survive_in_full_image_contracts(self):
digest = 'sha256:' + 'a' * 64
reference = 'ghcr.io/example/adminer:4@' + digest
template = custom.template_from_compose(COMPOSE.replace('adminer:4', reference))
main_image = template['container_contract']['image']
for image in (main_image, service_template(template, next(
service for service in ordered_services(template) if service['name'] == 'app'),
main=True)['container_contract']['image']):
self.assertEqual(image['reference'], reference)
self.assertEqual(image['registry'], 'ghcr.io')
self.assertEqual(image['repository'], 'ghcr.io/example/adminer')
self.assertEqual(image['tag'], '4')
self.assertEqual(image['digest'], digest)
self.assertEqual(image['pull_policy'],
'resolve-written-reference-to-architecture-digest-at-install')
dependency = service_template(template, ordered_services(template)[0])['container_contract']['image']
self.assertEqual(dependency['reference'], 'postgres:16')
self.assertEqual(dependency['repository'], 'postgres')
self.assertEqual(dependency['tag'], '16')
self.assertIsNone(dependency['digest'])
def test_reject_unsafe_dependency_mount_or_build(self):
for value in (' volumes: [/var/run/docker.sock:/var/run/docker.sock]',
' build: .'):
with self.subTest(value=value), self.assertRaises(ConversionError):
custom.template_from_compose(COMPOSE.replace(' volumes: [dbdata:/var/lib/postgresql/data]', value))
def test_shared_storage_is_supported_by_custom_stack(self):
template = custom.template_from_compose(COMPOSE.replace(" ports:", " volumes: [dbdata:/shared]\n ports:"))
self.assertTrue(template['compatibility']['automatic_install_candidate'])
self.assertEqual(template['compatibility']['untranslated_blockers'], [])
def test_every_image_checked_before_install(self):
class UI:
def message(self, text, title):
self.text = text
ui = UI()
with patch.object(custom, 'read_definition', return_value=(COMPOSE, 'Compose')), \
patch.object(custom, 'registry_report', side_effect=[(True, 'db ok'), (False, 'app missing')]) as report, \
patch.object(custom, '_registry_refused', return_value=False), \
patch.object(custom, 'install_template') as install:
custom.explore(ui)
self.assertEqual([c.args[0] for c in report.call_args_list], ['postgres:16', 'adminer:4'])
install.assert_not_called()
self.assertIn('app missing', ui.text)
def test_single_image_still_uses_single_container_flow(self):
template = custom.template_from_compose('services: {app: {image: nginx:1.27}}')
self.assertNotEqual(template['proxmox'].get('installer_profile', {}).get('stack_driver'), 'generic-multi-lxc-stack')
self.assertEqual(template['container_contract']['image']['reference'], 'nginx:1.27')
if __name__ == '__main__':
unittest.main()
+121
View File
@@ -0,0 +1,121 @@
"""Custom stacks keep each app's endpoint and actual shared storage identity."""
from pathlib import Path
import sys
import unittest
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'src'))
from proxmenux_oci.casaos import convert_casaos_compose
from proxmenux_oci.stack import apply_stack_support, assess, build_stack, DefaultsUI, service_template
from proxmenux_oci.installer import DEFAULT_MODE, ADVANCED_MODE
COMPOSE = '''name: arr-test
services:
sonarr:
image: lscr.io/linuxserver/sonarr:latest
ports: ['8989:8989']
environment: {PUID: '1000', PGID: '1000'}
volumes: [sonarr-config:/config, shared-data:/data]
radarr:
image: lscr.io/linuxserver/radarr:latest
ports: ['7878:7878']
environment: {PUID: '1000', PGID: '1000'}
volumes: [radarr-config:/config, shared-data:/data]
volumes:
sonarr-config:
radarr-config:
shared-data:
x-casaos:
main: sonarr
port_map: '8989'
scheme: http
index: /
title: {en_US: Arr Test}
'''
def template(custom=True, text=COMPOSE):
result = convert_casaos_compose(text, 'local', '', 'local/arr-test/docker-compose.yml', '')
result['proxmox']['custom_compose_stack'] = custom
apply_stack_support(result)
return result
class UI(DefaultsUI):
def __init__(self):
self.asks = []
def ask(self, text, default=None, **kwargs):
self.asks.append(text)
return default or ''
class CustomMultiAppStackTests(unittest.TestCase):
def test_custom_opt_in_supports_two_web_apps_but_catalog_remains_guarded(self):
self.assertEqual(assess(template()), [])
self.assertTrue(assess(template(custom=False)))
def test_app_without_own_port_is_rejected(self):
result = template(text=COMPOSE.replace(" ports: ['7878:7878']\n", ''))
self.assertTrue(any('health and persistence' in e for e in assess(result)))
def test_service_image_contract_preserves_tag_and_digest(self):
parent = template()
service = parent['compose_stack']['services'][0]
for reference, repository, tag, digest in (
('ghcr.io/example/app:2.4', 'ghcr.io/example/app', '2.4', None),
('ghcr.io/example/app:2.4@sha256:'+'a'*64, 'ghcr.io/example/app', '2.4', 'sha256:'+'a'*64),
):
with self.subTest(reference=reference):
service['image'] = reference
service['compose']['image'] = reference
image = service_template(parent, service)['container_contract']['image']
self.assertEqual((image['reference'], image['repository'], image['tag'], image['digest']),
(reference, repository, tag, digest))
self.assertEqual(image['registry'], 'ghcr.io')
def test_compose_extension_metadata_is_accepted_only_for_custom(self):
text = 'x-common-environment: &common {PUID: \'1000\'}\n' + COMPOSE
self.assertEqual(assess(template(text=text)), [])
self.assertTrue(assess(template(custom=False, text=text)))
def test_shared_owners_must_match(self):
result = template(text=COMPOSE.replace("environment: {PUID: '1000', PGID: '1000'}", "environment: {PUID: '1001', PGID: '1000'}", 1))
self.assertTrue(any('shared' in e for e in assess(result)))
def test_advanced_addresses_are_kept_per_service(self):
ui = UI()
supplied = {'sonarr': '192.0.2.21/24', 'radarr': '192.0.2.22/24'}
with patch('proxmenux_oci.installer.ask_storage', return_value='local-zfs'), \
patch('proxmenux_oci.installer.ask_bridge', return_value='vmbr0'), \
patch('proxmenux_oci.host.timezone', return_value='UTC'), \
patch('proxmenux_oci.custom_mounts.ask_stack_custom_mounts'), \
patch('proxmenux_oci.extra_devices.ask_stack_extra_devices'), \
patch('proxmenux_oci.network.ask_addresses', return_value=(supplied, '192.0.2.1')):
plan = build_stack(template(), ui, ADVANCED_MODE)
self.assertEqual({s['name']:s['frontend_ipv4'] for s in plan['services']}, supplied)
self.assertEqual(plan['network']['frontend_gateway'], '192.0.2.1')
def test_build_assigns_distinct_endpoints_and_shared_bind_source(self):
ui = UI()
with patch('proxmenux_oci.installer.ask_storage', return_value='local-zfs'), \
patch('proxmenux_oci.installer.ask_bridge', return_value='vmbr0'), \
patch('proxmenux_oci.host.timezone', return_value='UTC'), \
patch('proxmenux_oci.network.ask_addresses', side_effect=lambda ui, bridge, labels: ({name:'dhcp' for name in labels}, None)):
plan = build_stack(template(), ui, DEFAULT_MODE)
members = {s['name']:s for s in plan['services']}
self.assertEqual(members['sonarr']['healthcheck']['endpoint']['port'], 8989)
self.assertEqual(members['radarr']['healthcheck']['endpoint']['port'], 7878)
for member in members.values():
self.assertTrue(member['frontend'])
self.assertEqual(member['frontend_ipv4'], 'dhcp')
binds = [next(m for m in s['deployment']['mounts'] if m['container_path']=='/data') for s in members.values()]
self.assertEqual(binds[0]['source'], binds[1]['source'])
self.assertEqual(binds[0]['host_owner'], [101000, 101000])
self.assertTrue(all(m['type']=='host-bind' and m['backup'] is False for m in binds))
self.assertEqual(len([text for text in ui.asks if text.endswith('shared-data')]), 1)
configs = [next(m for m in s['deployment']['mounts'] if m['container_path']=='/config') for s in members.values()]
self.assertTrue(all(m['type']=='managed-volume' for m in configs))
if __name__ == '__main__':
unittest.main()