mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-02 10:52:22 +00:00
fix(oci): bind stack public URLs to their frontend before startup
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user