fix(oci): bind stack public URLs to their frontend before startup

This commit is contained in:
Jonatan
2026-10-28 10:56:36 +01:00
parent 3d1c542a05
commit a7786a6874
12 changed files with 402 additions and 3 deletions
+13
View File
@@ -229,6 +229,19 @@ network. Internal services can use declared or inherited image health checks,
exposed TCP ports, or a clearly labelled process-running check. A running process
alone does not satisfy `service_healthy`.
For custom stacks, loopback public URLs in variables such as `PUBLIC_URL` or
`PENPOT_PUBLIC_URI` are mapped to the service publishing their original Compose
host port, using that service's LAN address and container port. Explicit external
URLs and internal loopback endpoints remain unchanged. Automatic public URL
mapping requires a static frontend address; with DHCP, provide the application's
explicit public URL instead. This lets every dependent service receive its final
public URL before its first start, including migration jobs.
Only names containing a `PUBLIC` or `EXTERNAL` segment and ending in `URL`,
`URI` or `ORIGIN` qualify for this automatic mapping. Other application-specific
variables, such as `NEXTAUTH_URL` or `ROOT_URL`, must contain the intended URL in
the supplied Compose or environment file. Changing a frontend IP later also
requires updating its application URLs.
Repeated identical volume sources share one host directory, selected once;
separate configuration volumes stay independent. Host directories are not part
of a normal Proxmox container backup. Newly created private data volumes use the
+54 -1
View File
@@ -254,6 +254,58 @@ def assign_health_address(service):
check['host'] = service['ip']
def public_bindings(services):
"""Validate compiler-owned targets before any host/container mutation."""
names = {service['name']: service for service in services}
result = []
failure = translate('Invalid stack contract')
for service in services:
bindings = service.get('public_environment_bindings', {})
if not isinstance(bindings, dict):
raise RuntimeError(failure)
environment = service['deployment'].get('environment', [])
for key, binding in bindings.items():
if (not isinstance(key, str) or not re.fullmatch(r'[A-Za-z_][A-Za-z0-9_]*', key)
or not isinstance(binding, dict)
or set(binding) != {'target_service', 'container_port', 'value'}
or not isinstance(binding.get('target_service'), str)):
raise RuntimeError(failure)
target = names.get(binding['target_service'])
value = binding.get('value')
if (target is None or not (target.get('frontend') or target.get('main'))
or type(binding.get('container_port')) is not int
or not 0 < binding['container_port'] < 65536
or not isinstance(value, str) or value.count('@STACK_LAN_IP@') != 1
or any(char in value for char in '\r\n')
or sum(entry.get('name') == key for entry in environment) != 1):
raise RuntimeError(failure)
result.append((service, key, binding, target))
if any(target.get('frontend_ipv4') == 'dhcp' for _, _, _, target in result):
raise RuntimeError(translate('Automatic public URLs require static frontend addresses or explicit URLs'))
return result
def _set_public_value(service, key, value):
for entry in service['deployment']['environment']:
if entry['name'] == key:
entry['value'] = value
return
raise RuntimeError(translate('Invalid stack contract'))
def prepare_public_environment(services):
"""Known static destinations are final before any application's first start."""
for service, key, binding, target in public_bindings(services):
address = target.get('frontend_ipv4')
try:
address = ipaddress.ip_interface(address).ip
if address.version != 4:
raise ValueError()
except (ValueError, TypeError) as error:
raise RuntimeError(translate('Invalid stack contract')) from error
_set_public_value(service, key, binding['value'].replace('@STACK_LAN_IP@', str(address)))
def bind_completed_jobs(services):
"""All members and private aliases exist before a job can be authorized."""
from oci_compose_jobs import job_spec_from_record
@@ -881,6 +933,7 @@ def main():
services = copy.deepcopy(deployment['services'])
independent = bool(deployment.get('suite_arr'))
primary = None if independent else next(s for s in services if s['main'])
prepare_public_environment(services)
created = []
bridge_created = False
lifecycle = None
@@ -932,7 +985,7 @@ def main():
results = {}
for order,s in enumerate(services,1):
plan = s['deployment']
s['public_environment'] = {e['name']:e['value'] for e in plan['environment'] if '@STACK_LAN_IP@' in e['value']}
s['public_environment'] = {e['name']:e['value'] for e in plan['environment'] if '@STACK_LAN_IP@' in e['value'] and e['name'] not in s.get('public_environment_bindings', {})}
for e in plan['environment']:
e['value'] = e['value'].replace('@STACK_LAN_IP@',s['ip'])
plan['vmid'] = s['vmid']
+51 -2
View File
@@ -14,7 +14,7 @@ import yaml
from .casaos import convert_casaos_compose, canonical_image_repository
from .converter import ConversionError
from .converter import _image_contract, _split_short_mount
from .converter import _image_contract, _split_short_mount, _port_contract
from .i18n import translate
@@ -240,6 +240,46 @@ def service_endpoint(service, include_expose=False):
return None
def public_environment_bindings(services, main_name, environments=None):
"""Bind loopback URL origins to the service publishing their Compose host port."""
frontends = [service for service in services if service_endpoint(service)]
result = {}
for service in services:
bindings = {}
values = (environments or {}).get(service['name'], environment(service['compose'].get('environment')))
for key, value in values.items():
words = key.upper().split('_')
if not ({'PUBLIC', 'EXTERNAL'} & set(words[:-1]) and words[-1] in {'URL', 'URI', 'ORIGIN'}):
continue
if not re.match(r'^https?://', value, re.I):
continue
url = urlsplit(value)
if url.scheme not in {'http', 'https'} or url.hostname not in {'localhost', '127.0.0.1', '0.0.0.0', '::1'}:
continue
try:
origin_port = url.port or (443 if url.scheme == 'https' else 80)
except ValueError as error:
raise StackError(translate('Invalid service check URL')) from error
matches = set()
for target in frontends:
for port in _port_contract(target['compose'].get('ports'), set()):
if (port['protocol'] == 'tcp' and port.get('published_example') == origin_port
and not port.get('container_port_end') and not port.get('published_example_end')):
matches.add((target['name'], port['container_port']))
if len(matches) > 1:
raise StackError(f"{service['name']}: {key}: {translate('Ambiguous local public URL; use an explicit URL')}")
if not matches:
raise StackError(f"{service['name']}: {key}: {translate('Local public URL has no matching published port; use an explicit URL')}")
target_name, target_port = next(iter(matches))
credentials = url.netloc.rpartition('@')[0]+'@' if '@' in url.netloc else ''
rendered = urlunsplit((url.scheme, credentials+'@STACK_LAN_IP@:'+str(target_port),
url.path, url.query, url.fragment))
bindings[key] = {'target_service': target_name, 'container_port': target_port, 'value': rendered}
if bindings:
result[service['name']] = bindings
return result
def _check_duration(value, default):
if value is None:
return default
@@ -350,6 +390,8 @@ def assess(template):
healthy_required.update(name for name, condition in dependencies.items()
if isinstance(condition, dict) and condition.get('condition') == 'service_healthy')
jobs = completed_services(template) if custom else set()
if custom:
public_environment_bindings(services, template['compose_stack']['main_service'])
if template['compose_stack']['main_service'] in jobs or jobs & healthy_required:
raise StackError(translate('unsupported dependency condition') + ': service_completed_successfully')
for service in services:
@@ -614,13 +656,18 @@ def build_stack(template, ui, mode='advanced'):
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
public_bindings = public_environment_bindings(services, template['compose_stack']['main_service'], envs) if custom else {}
if any(addresses[binding['target_service']] == 'dhcp' for bindings in public_bindings.values() for binding in bindings.values()):
raise StackError(translate('Automatic public URLs require static frontend addresses or explicit URLs'))
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')):
if custom and key in public_bindings.get(s['name'], {}):
env[key] = public_bindings[s['name']][key]['value']
elif not custom and 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))
@@ -680,6 +727,8 @@ def build_stack(template, ui, mode='advanced'):
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 public_bindings.get(s['name']):
plans[-1]['public_environment_bindings'] = public_bindings[s['name']]
if s['name'] in frontend_names and s['name'] not in jobs:
plans[-1]['frontend'] = True
plans[-1]['frontend_ipv4'] = addresses[s['name']] if custom else addresses['']
+133
View File
@@ -0,0 +1,133 @@
"""Only public loopback origins bind by exact Compose port and static target."""
import copy
from pathlib import Path
import sys
import unittest
from unittest.mock import patch
ROOT=Path(__file__).resolve().parents[1]
sys.path.insert(0,str(ROOT/'src'));sys.path.insert(0,str(ROOT/'remote'))
from proxmenux_oci.custom import template_from_compose
from proxmenux_oci import stack
from proxmenux_oci.stack import assess,public_environment_bindings,StackError,build_stack,DefaultsUI
from proxmenux_oci.installer import DEFAULT_MODE
import install_generic_stack as runtime
SOURCE='''services:
web:
image: nginx:alpine
ports: ['9001:8080']
environment: {PUBLIC_URI: 'http://localhost:9001/app?x=1#view'}
depends_on: [backend, mail]
backend:
image: example/backend:latest
environment: {PUBLIC_URI: 'http://localhost:9001/app?x=1#view', EXPLICIT_URL: 'https://example.com:9443/keep'}
mail:
image: axllent/mailpit:v1
ports: ['8025:8025']
environment: {PUBLIC_URI: 'http://localhost:8025/mail'}
'''
def fixture(dynamic=False,jobs=False):
result=[]
for name,vmid,main,address in [('backend',4801,False,None),('mail',4802,False,'172.30.249.11/24'),('web',4800,True,'dhcp' if dynamic else '172.30.249.10/24')]:
target='mail' if name=='mail' else 'web';port=8025 if name=='mail' else 8080
value='http://@STACK_LAN_IP@:'+str(port)+'/app?x=1#view'
result.append({'name':name,'vmid':vmid,'main':main,'frontend':name!='backend','frontend_ipv4':address,
'healthcheck':{'type':'running'},'deployment':{'environment':[{'name':'PUBLIC_URI','value':value}]},
'public_environment_bindings':{'PUBLIC_URI':{'target_service':target,'container_port':port,'value':value}}})
if jobs:result.append({'name':'migration','vmid':4803,'main':False,'healthcheck':{'type':'completed'},'deployment':{'environment':[]}})
return result
def build(template,dynamic=False):
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' if dynamic else '172.30.249.11/24' if name=='mail' else '172.30.249.10/24') for name in labels},None)):
return build_stack(template,DefaultsUI(),DEFAULT_MODE)
class PublicEnvironmentTests(unittest.TestCase):
def source(self,text=SOURCE):
template=template_from_compose(text)
return template,template['compose_stack']['services']
def test_origin_port_maps_shared_url_to_web_and_mail_to_itself(self):
template,services=self.source();bindings=public_environment_bindings(services,'web')
self.assertEqual(assess(template),[])
self.assertEqual(bindings['backend']['PUBLIC_URI']['target_service'],'web')
self.assertEqual(bindings['backend']['PUBLIC_URI']['container_port'],8080)
self.assertEqual(bindings['backend']['PUBLIC_URI']['value'],'http://@STACK_LAN_IP@:8080/app?x=1#view')
self.assertEqual(bindings['mail']['PUBLIC_URI']['target_service'],'mail')
self.assertNotIn('EXPLICIT_URL',bindings['backend'])
def test_compiler_carries_targets_and_resolves_static_before_first_start(self):
template,_=self.source();plan=build(template)
services={service['name']:service for service in plan['services']}
self.assertEqual(services['backend']['public_environment_bindings']['PUBLIC_URI']['target_service'],'web')
with patch.object(runtime,'run') as run:runtime.prepare_public_environment(plan['services'])
values={item['name']:item['value'] for item in services['backend']['deployment']['environment']}
self.assertEqual(values['PUBLIC_URI'],'http://172.30.249.10:8080/app?x=1#view')
self.assertEqual(values['EXPLICIT_URL'],'https://example.com:9443/keep')
run.assert_not_called()
def test_final_environment_override_is_not_replaced_by_original_compose_binding(self):
template,_=self.source();original=stack.resolve_environments
def final_override(*args,**kwargs):
values=original(*args,**kwargs)
values['backend']['PUBLIC_URI']='https://operator.example.com/custom'
return values
with patch.object(stack,'resolve_environments',side_effect=final_override):plan=build(template)
backend=next(s for s in plan['services'] if s['name']=='backend')
self.assertNotIn('PUBLIC_URI',backend.get('public_environment_bindings',{}))
runtime.prepare_public_environment(plan['services'])
self.assertEqual(next(e['value'] for e in backend['deployment']['environment'] if e['name']=='PUBLIC_URI'),'https://operator.example.com/custom')
def test_internal_health_metrics_and_admin_urls_are_never_automatic_public_fields(self):
extra="OTEL_EXPORTER_OTLP_ENDPOINT: 'http://localhost:4318', HEALTHCHECK_URL: 'http://127.0.0.1:9090', LOCAL_ADMIN_API: 'http://localhost:9999', TEXT: 'not:a[url', "
template,services=self.source(SOURCE.replace('EXPLICIT_URL:',extra+'EXPLICIT_URL:'))
self.assertEqual(assess(template),[])
bindings=public_environment_bindings(services,'web')['backend']
for key in ['OTEL_EXPORTER_OTLP_ENDPOINT','HEALTHCHECK_URL','LOCAL_ADMIN_API','TEXT']:self.assertNotIn(key,bindings)
plan=build(template);backend=next(s for s in plan['services'] if s['name']=='backend');runtime.prepare_public_environment(plan['services'])
values={e['name']:e['value'] for e in backend['deployment']['environment']}
self.assertEqual(values['OTEL_EXPORTER_OTLP_ENDPOINT'],'http://localhost:4318')
self.assertEqual(values['HEALTHCHECK_URL'],'http://127.0.0.1:9090')
self.assertEqual(values['LOCAL_ADMIN_API'],'http://localhost:9999')
def test_ambiguous_host_port_is_blocked(self):
template,services=self.source(SOURCE.replace("['8025:8025']","['9001:8025']"))
with self.assertRaisesRegex(StackError,'Ambiguous'):public_environment_bindings(services,'web')
self.assertTrue(any('Ambiguous' in error for error in assess(template)))
def test_unmatched_public_origin_does_not_guess_even_single_frontend(self):
_,services=self.source(SOURCE.replace('localhost:9001','localhost:9999'))
with self.assertRaisesRegex(StackError,'no matching'):public_environment_bindings(services,'web')
only_web=[service for service in services if service['name']!='mail']
with self.assertRaisesRegex(StackError,'no matching'):public_environment_bindings(only_web,'web')
def test_missing_origin_port_does_not_fall_back_to_the_main(self):
_,services=self.source(SOURCE.replace('localhost:9001','localhost'))
with self.assertRaisesRegex(StackError,'no matching'):public_environment_bindings(services,'web')
def test_static_bindings_with_migration_do_not_run_any_commands(self):
with patch.object(runtime,'run') as run:runtime.prepare_public_environment(fixture(jobs=True))
run.assert_not_called()
def test_every_automatic_dhcp_binding_is_refused_before_any_command(self):
for jobs in (False,True):
with self.subTest(jobs=jobs),patch.object(runtime,'run') as run,self.assertRaisesRegex(RuntimeError,'Automatic public URLs'):
runtime.prepare_public_environment(fixture(dynamic=True,jobs=jobs))
run.assert_not_called()
template,_=self.source()
with self.assertRaisesRegex(StackError,'Automatic public URLs'):build(template,dynamic=True)
def test_explicit_public_urls_allow_dhcp_and_are_unchanged(self):
template,_=self.source(SOURCE.replace('localhost','operator.example.com'))
plan=build(template,dynamic=True)
with patch.object(runtime,'run') as run:runtime.prepare_public_environment(plan['services'])
self.assertTrue(all(not s.get('public_environment_bindings') for s in plan['services']))
backend=next(s for s in plan['services'] if s['name']=='backend')
self.assertEqual(next(e['value'] for e in backend['deployment']['environment'] if e['name']=='PUBLIC_URI'),'http://operator.example.com:9001/app?x=1#view')
run.assert_not_called()
if __name__=='__main__':unittest.main()
@@ -0,0 +1,130 @@
"""Public origins remain fixed desired state across native stack lifecycle APIs."""
import copy
import json
from pathlib import Path
import sys
import tempfile
import unittest
import uuid
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'src'))
sys.path.insert(0, str(ROOT / 'remote'))
from proxmenux_oci.custom import template_from_compose
from proxmenux_oci.stack import build_stack, DefaultsUI
from proxmenux_oci.installer import DEFAULT_MODE
import install_generic_stack as runtime
import oci_instances as instances
import oci_instance_transaction as transactions
import oci_stack_plan as plans
import oci_stack_native as native
import oci_restore_recovery as recovery
SOURCE = '''services:
web:
image: nginx:alpine
ports: ['9001:8080']
environment: {PUBLIC_ORIGIN: 'http://localhost:9001/entry'}
depends_on: [backend, secondary]
backend:
image: example/worker:latest
environment: {PUBLIC_ORIGIN: 'http://localhost:9001/entry'}
secondary:
image: nginx:alpine
ports: ['8025:80']
environment: {PUBLIC_ORIGIN: 'http://localhost:8025/mail'}
'''
DIGEST = 'sha256:' + 'a' * 64
class StaticPublicURLLifecycle(unittest.TestCase):
def publish(self, root):
template = template_from_compose(SOURCE)
with patch('proxmenux_oci.installer.ask_storage', return_value='local-zfs'), \
patch('proxmenux_oci.installer.ask_bridge', return_value='vmbr909'), \
patch('proxmenux_oci.host.timezone', return_value='UTC'), \
patch('proxmenux_oci.network.ask_addresses', side_effect=lambda ui, bridge, labels:
({name: ('172.30.249.91/24' if name == 'secondary' else '172.30.249.90/24')
for name in labels}, None)):
deployment = build_stack(template, DefaultsUI(), DEFAULT_MODE)
services = deployment['services']
original = copy.deepcopy(services)
runtime.prepare_public_environment(services)
configs, observed = {}, {}
for index, service in enumerate(services):
vmid = 4900 + service['offset']
service['vmid'] = vmid
service['deployment']['vmid'] = vmid
service['ip'] = f'10.77.5.{30 + service["offset"]}'
runtime.assign_health_address(service)
identity = str(uuid.uuid4())
config = f'description: proxmenux-instance={identity}\nrootfs: local-zfs:subvol-{vmid}-disk-0,size=8G\n'
configs[vmid] = config.encode()
observed[vmid] = {'config': config, 'archive_path': '/fixture/image.tar',
'resolved_registry_digest': DIGEST,
'image': {'architecture': 'amd64', 'os': 'linux', 'defaults': {}}}
# The installer must publish final runtime plans, not stale input snapshots.
instances.write(instances.location(root, vmid), {
'schema_version': 1, 'vmid': vmid, 'installation_id': identity,
'status': 'assembling', 'template': copy.deepcopy(service['template']),
'deployment': copy.deepcopy(original[index]['deployment']),
'observed': copy.deepcopy(observed[vmid])})
contract = {'schema': 1, 'stack': 'public-url-fixture', 'dependencies': [
{'vmid': s['vmid'], 'label': s['name'], 'healthcheck': copy.deepcopy(s['healthcheck'])}
for s in services if not s['main']]}
with patch.object(instances, 'command', side_effect=lambda *args: configs[int(args[-1])]), \
patch.object(instances, 'observe', side_effect=lambda vmid, *args: copy.deepcopy(observed[vmid])):
instances.publish_stack(root, 4900, template, deployment, services)
return contract, {s['name']: s['vmid'] for s in services}
def assert_origins(self, records, names):
for name, vmid in names.items():
values = {item['name']: item['value'] for item in records[vmid]['deployment']['environment']}
self.assertEqual(values['PUBLIC_ORIGIN'], 'http://172.30.249.91:80/mail'
if name == 'secondary' else 'http://172.30.249.90:8080/entry')
self.assertNotIn('@STACK_LAN_IP@', json.dumps(records[vmid]['deployment']))
self.assertNotIn('localhost', values['PUBLIC_ORIGIN'])
def test_published_static_origins_survive_native_update_contract_and_private_probes(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
contract, names = self.publish(root)
records = {vmid: instances.read(root, vmid) for vmid in names.values()}
self.assert_origins(records, names)
plan = plans.build(records[4900], records,
{v: r['installation_id'] for v, r in records.items()}, 'update')
adapter = native.NativeAdapter(root, root / 'journal.json', plan)
candidates = {r['vmid']: transactions.candidate_contract(r, 'update') for r in plan['members']}
self.assert_origins(candidates, names)
expected = [{'vmid': s['vmid'], 'label': s['name'], 'healthcheck': s['healthcheck']}
for s in adapter.services.values() if s['vmid'] != 4900]
self.assertEqual(expected, contract['dependencies'])
secondary = next(s for s in adapter.services.values() if s['name'] == 'secondary')
self.assertTrue(secondary['healthcheck']['url'].startswith('http://10.77.5.'))
self.assertTrue(recovery.valid_contract(contract, set(names.values()) - {4900}))
def test_restore_refresh_adopts_storage_without_reinferring_url_or_lan_healthchecks(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
contract, names = self.publish(root)
for vmid in names.values():
record = instances.read(root, vmid)
record['deployment']['rootfs']['storage'] = 'restored-zfs'
record['recovered_at'] = instances.now()
instances.write(instances.location(root, vmid), record)
recovery.refresh_service_plans(root, list(names.values()))
records = {v: instances.read(root, v) for v in names.values()}
self.assert_origins(records, names)
primary = records[4900]
for service in primary['stack']['deployment']['services']:
self.assertEqual(service['deployment'], records[service['vmid']]['deployment'])
self.assertEqual(service['deployment']['rootfs']['storage'], 'restored-zfs')
expected = [{'vmid': s['vmid'], 'label': s['name'], 'healthcheck': s['healthcheck']}
for s in primary['stack']['deployment']['services'] if s['vmid'] != 4900]
self.assertEqual(expected, contract['dependencies'])
self.assertTrue(recovery.valid_contract(contract, set(names.values()) - {4900}))
if __name__ == '__main__':
unittest.main()