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
+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()