mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-02 19:02:31 +00:00
fix(oci): bind stack public URLs to their frontend before startup
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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['']
|
||||
|
||||
@@ -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