diff --git a/lang/de.json b/lang/de.json index 0a115777..b4004687 100644 --- a/lang/de.json +++ b/lang/de.json @@ -462,6 +462,7 @@ "Also install the VirtIO network driver during setup to enable network access.": "Installieren Sie während des Setups auch den VirtIO-Netzwerktreiber, um den Netzwerkzugriff zu ermöglichen.", "Although VFIO can bind to this device, full passthrough to a VM is": "Obwohl sich VFIO an dieses Gerät binden kann, ist ein vollständiger Passthrough zu einer VM möglich", "Altus is an Electron-based WhatsApp client with themes and multiple account support.": "Altus ist ein Electron-basierter WhatsApp-Client mit Themes und Unterstützung für mehrere Konten.", + "Ambiguous local public URL; use an explicit URL": "Die lokale öffentliche URL ist mehrdeutig; gib eine eindeutige URL an", "Ambiguous mount points in the container": "Mehrdeutige Befestigungspunkte im Behälter", "Ambiguous or invalid environment variable": "Mehrdeutige oder ungültige Umgebungsvariable", "Amount of RAM in MiB (default: 4096)": "RAM-Größe in MiB (Standard: 4096)", @@ -625,6 +626,7 @@ "Automatic Video Library Manager for TV Shows. It watches for new episodes of your favorite shows, and when they are posted it does its magic.": "Automatic Video Library Manager für TV-Shows. Es schaut nach neuen episoden ihrer lieblingsshows, und wenn sie gepostet werden, macht es seine magie.", "Automatic detection": "Automatische Erkennung", "Automatic private network allocation requires a /24 subnet": "Automatische private Netzwerkzuweisung requires a /24 Subnetz", + "Automatic public URLs require static frontend addresses or explicit URLs": "Automatische öffentliche URLs erfordern statische Frontend-Adressen oder explizite URLs", "Automatic video library manager for TV Shows": "Automatischer Videobibliotheksmanager für TV-Shows", "Automatic/Unattended": "Automatisch/unbeaufsichtigt", "Automation & Scheduling": "Automatisierung und Zeitplanung", @@ -3913,6 +3915,7 @@ "Local directory (single-machine — only use if it is a SEPARATE disk)": "Lokales Verzeichnis (einzelne Maschine – nur verwenden, wenn es sich um eine SEPARATE Festplatte handelt)", "Local keyfile is missing but a recovery copy was found in PBS.": "Die lokale Schlüsseldatei fehlt, aber in PBS wurde eine Wiederherstellungskopie gefunden.", "Local network only (192.168.0.0/16)": "Nur lokales Netzwerk (192.168.0.0/16)", + "Local public URL has no matching published port; use an explicit URL": "Für die lokale öffentliche URL gibt es keinen passenden veröffentlichten Port; gib eine eindeutige URL an", "Local restore error log": "Fehlerprotokoll für die lokale Wiederherstellung", "Local storage for PostgreSQL": "Lokale Speicherung für PostgreSQL", "Locale generated": "Gebietsschema generiert", diff --git a/lang/es.json b/lang/es.json index 0a9891f6..ec097355 100644 --- a/lang/es.json +++ b/lang/es.json @@ -458,6 +458,7 @@ "Also install the VirtIO network driver during setup to enable network access.": "Instala también el controlador de red VirtIO durante la configuración para permitir el acceso a la red.", "Although VFIO can bind to this device, full passthrough to a VM is": "Aunque VFIO puede vincularse a este dispositivo, el passthrough completo a una VM", "Altus is an Electron-based WhatsApp client with themes and multiple account support.": "Altus es un cliente de WhatsApp basado en Electron, con temas y soporte para varias cuentas.", + "Ambiguous local public URL; use an explicit URL": "La URL pública local es ambigua; indica una URL explícita", "Ambiguous mount points in the container": "Puntos de montaje ambiguos en el contenedor", "Ambiguous or invalid environment variable": "Variable de entorno ambiguo o inválido", "Amount of RAM in MiB (default: 4096)": "Cantidad de RAM en MiB (predeterminado: 4096)", @@ -623,6 +624,7 @@ "Automatic Video Library Manager for TV Shows. It watches for new episodes of your favorite shows, and when they are posted it does its magic.": "Gestor automático de videoteca para series de TV. Vigila la aparición de nuevos episodios de tus series favoritas y, cuando se publican, hace su magia.", "Automatic detection": "Detección automática", "Automatic private network allocation requires a /24 subnet": "Asignación automática de red privada requiere una subred /24", + "Automatic public URLs require static frontend addresses or explicit URLs": "Las URL públicas automáticas requieren direcciones estáticas para los servicios web o URL explícitas", "Automatic video library manager for TV Shows": "Gestor automático de videoteca para series de TV", "Automatic/Unattended": "Automático/desatendido", "Automation & Scheduling": "Automation & Scheduling", @@ -3909,6 +3911,7 @@ "Local directory (single-machine — only use if it is a SEPARATE disk)": "Directorio local (una sola máquina; utilícelo solo si es un disco SEPARADO)", "Local keyfile is missing but a recovery copy was found in PBS.": "Falta el archivo de claves local, pero se encontró una copia de recuperación en PBS.", "Local network only (192.168.0.0/16)": "Solo red local (192.168.0.0/16)", + "Local public URL has no matching published port; use an explicit URL": "La URL pública local no coincide con ningún puerto publicado; indica una URL explícita", "Local restore error log": "Log de errores de restauración local", "Local storage for PostgreSQL": "Almacenamiento local para PostgreSQL", "Locale generated": "Configuración regional generada", diff --git a/lang/fr.json b/lang/fr.json index c4df7a33..99be0070 100644 --- a/lang/fr.json +++ b/lang/fr.json @@ -462,6 +462,7 @@ "Also install the VirtIO network driver during setup to enable network access.": "Installez également le pilote réseau VirtIO lors de l'installation pour activer l'accès au réseau.", "Although VFIO can bind to this device, full passthrough to a VM is": "Bien que VFIO puisse se lier à cet appareil, le relais complet vers une VM est", "Altus is an Electron-based WhatsApp client with themes and multiple account support.": "Altus est un client de WhatsApp basé à Electron avec des thèmes et un support de compte multiple.", + "Ambiguous local public URL; use an explicit URL": "L’URL publique locale est ambiguë ; indiquez une URL explicite", "Ambiguous mount points in the container": "Points de montage ambigus dans le conteneur", "Ambiguous or invalid environment variable": "Variable d'environnement ambulante ou invalide", "Amount of RAM in MiB (default: 4096)": "Quantité de RAM en MiB (par défaut : 4096)", @@ -625,6 +626,7 @@ "Automatic Video Library Manager for TV Shows. It watches for new episodes of your favorite shows, and when they are posted it does its magic.": "Gestionnaire automatique de bibliothèque vidéo pour les émissions de télévision. Il regarde de nouveaux épisodes de vos spectacles préférés, et quand ils sont affichés, il fait sa magie.", "Automatic detection": "Détection automatique", "Automatic private network allocation requires a /24 subnet": "Allocation de réseau privé automatique requires un sous-réseau /24", + "Automatic public URLs require static frontend addresses or explicit URLs": "Les URL publiques automatiques nécessitent des adresses statiques pour les interfaces web ou des URL explicites", "Automatic video library manager for TV Shows": "Gestionnaire automatique de bibliothèque vidéo pour TV Shows", "Automatic/Unattended": "Automatique/sans surveillance", "Automation & Scheduling": "Automatisation et planification", @@ -3913,6 +3915,7 @@ "Local directory (single-machine — only use if it is a SEPARATE disk)": "Répertoire local (machine unique — à utiliser uniquement s'il s'agit d'un disque SÉPARÉ)", "Local keyfile is missing but a recovery copy was found in PBS.": "Le fichier de clé local est manquant mais une copie de récupération a été trouvée dans PBS.", "Local network only (192.168.0.0/16)": "Réseau local uniquement (192.168.0.0/16)", + "Local public URL has no matching published port; use an explicit URL": "Aucun port publié ne correspond à l’URL publique locale ; indiquez une URL explicite", "Local restore error log": "Journal des erreurs de restauration locale", "Local storage for PostgreSQL": "Stockage local pour PostgreSQL", "Locale generated": "Paramètres régionaux générés", diff --git a/lang/it.json b/lang/it.json index b8c8e567..ed5be6d4 100644 --- a/lang/it.json +++ b/lang/it.json @@ -462,6 +462,7 @@ "Also install the VirtIO network driver during setup to enable network access.": "Installare anche il driver di rete VirtIO durante la configurazione per abilitare l'accesso alla rete.", "Although VFIO can bind to this device, full passthrough to a VM is": "Sebbene VFIO possa associarsi a questo dispositivo, il passthrough completo a una VM è", "Altus is an Electron-based WhatsApp client with themes and multiple account support.": "Altus è un client WhatsApp basato su Electron con temi e supporto di account multipli.", + "Ambiguous local public URL; use an explicit URL": "L’URL pubblico locale è ambiguo; specifica un URL esplicito", "Ambiguous mount points in the container": "Punti di montaggio ambigui nel container", "Ambiguous or invalid environment variable": "Variabile d'ambiente ambigua o non valida", "Amount of RAM in MiB (default: 4096)": "RAM in MiB (predefinita: 4096)", @@ -625,6 +626,7 @@ "Automatic Video Library Manager for TV Shows. It watches for new episodes of your favorite shows, and when they are posted it does its magic.": "Gestore automatico di videoteche per serie TV. Monitora l'uscita di nuovi episodi delle serie preferite e li gestisce quando sono disponibili.", "Automatic detection": "Rilevamento automatico", "Automatic private network allocation requires a /24 subnet": "L’assegnazione automatica della rete privata richiede una sottorete /24", + "Automatic public URLs require static frontend addresses or explicit URLs": "Gli URL pubblici automatici richiedono indirizzi statici per i frontend o URL espliciti", "Automatic video library manager for TV Shows": "Gestore automatico di librerie video per serie TV", "Automatic/Unattended": "Automatico/non presidiato", "Automation & Scheduling": "Automazione e pianificazione", @@ -3913,6 +3915,7 @@ "Local directory (single-machine — only use if it is a SEPARATE disk)": "Directory locale (macchina singola: usa solo se è un disco SEPARATO)", "Local keyfile is missing but a recovery copy was found in PBS.": "Manca il file di chiavi locale ma è stata trovata una copia di ripristino in PBS.", "Local network only (192.168.0.0/16)": "Solo rete locale (192.168.0.0/16)", + "Local public URL has no matching published port; use an explicit URL": "Nessuna porta pubblicata corrisponde all’URL pubblico locale; specifica un URL esplicito", "Local restore error log": "Registro errori ripristino locale", "Local storage for PostgreSQL": "Storage locale per PostgreSQL", "Locale generated": "Configurazione locale generata", diff --git a/lang/pt.json b/lang/pt.json index 7da77a68..e439ae50 100644 --- a/lang/pt.json +++ b/lang/pt.json @@ -462,6 +462,7 @@ "Also install the VirtIO network driver during setup to enable network access.": "Instale também o driver de rede VirtIO durante a configuração para habilitar o acesso à rede.", "Although VFIO can bind to this device, full passthrough to a VM is": "Embora o VFIO possa se vincular a este dispositivo, a passagem completa para uma VM é", "Altus is an Electron-based WhatsApp client with themes and multiple account support.": "Altus é um cliente WhatsApp baseado em Electron com temas e suporte a múltiplas contas.", + "Ambiguous local public URL; use an explicit URL": "O URL público local é ambíguo; indique um URL explícito", "Ambiguous mount points in the container": "Pontos de montagem ambíguos no recipiente", "Ambiguous or invalid environment variable": "Variável de ambiente ambígua ou inválida", "Amount of RAM in MiB (default: 4096)": "Quantidade de RAM em MiB (padrão: 4096)", @@ -625,6 +626,7 @@ "Automatic Video Library Manager for TV Shows. It watches for new episodes of your favorite shows, and when they are posted it does its magic.": "Gestor de Biblioteca de Vídeo Automático para Programas de TV. Ele assiste a novos episódios de seus programas favoritos, e quando eles são postados ele faz sua mágica.", "Automatic detection": "Detecção automática", "Automatic private network allocation requires a /24 subnet": "Alocação automática de rede privada requires a /24 subnet", + "Automatic public URLs require static frontend addresses or explicit URLs": "Os URL públicos automáticos exigem endereços estáticos para os serviços web ou URL explícitos", "Automatic video library manager for TV Shows": "Gerenciador automático de bibliotecas de vídeo para programas de TV", "Automatic/Unattended": "Automático/Autônomo", "Automation & Scheduling": "Automação e agendamento", @@ -3913,6 +3915,7 @@ "Local directory (single-machine — only use if it is a SEPARATE disk)": "Diretório local (máquina única — use apenas se for um disco SEPARADO)", "Local keyfile is missing but a recovery copy was found in PBS.": "O arquivo-chave local está faltando, mas uma cópia de recuperação foi encontrada no PBS.", "Local network only (192.168.0.0/16)": "Somente rede local (192.168.0.0/16)", + "Local public URL has no matching published port; use an explicit URL": "O URL público local não corresponde a nenhuma porta publicada; indique um URL explícito", "Local restore error log": "Log de erros de restauração local", "Local storage for PostgreSQL": "Armazenamento local para PostgreSQL", "Locale generated": "Local gerado", diff --git a/lang/sk.json b/lang/sk.json index 5f30bfae..58209936 100644 --- a/lang/sk.json +++ b/lang/sk.json @@ -462,6 +462,7 @@ "Also install the VirtIO network driver during setup to enable network access.": "Počas inštalácie nainštalujte aj sieťový ovládač VirtIO, aby fungoval prístup k sieti.", "Although VFIO can bind to this device, full passthrough to a VM is": "Aj keď sa VFIO vie na toto zariadenie naviazať, úplný passthrough do VM je", "Altus is an Electron-based WhatsApp client with themes and multiple account support.": "Altus je klient WhatsApp založený na Electrone s podporou tém a viacerých účtov.", + "Ambiguous local public URL; use an explicit URL": "Lokálna verejná URL je nejednoznačná; zadajte explicitnú URL", "Ambiguous mount points in the container": "Nejednoznačné body pripojenia v kontajneri", "Ambiguous or invalid environment variable": "Nejednoznačná alebo neplatná premenná prostredia", "Amount of RAM in MiB (default: 4096)": "Množstvo RAM v MiB (predvolené: 4096)", @@ -625,6 +626,7 @@ "Automatic Video Library Manager for TV Shows. It watches for new episodes of your favorite shows, and when they are posted it does its magic.": "Automatický správca knižnice televíznych seriálov. Sleduje nové epizódy obľúbených seriálov a spracuje ich, keď sú dostupné.", "Automatic detection": "Automatická detekcia", "Automatic private network allocation requires a /24 subnet": "Automatické pridelenie súkromnej siete vyžaduje podsieť /24", + "Automatic public URLs require static frontend addresses or explicit URLs": "Automatické verejné URL vyžadujú statické adresy frontendov alebo explicitné URL", "Automatic video library manager for TV Shows": "Automatický správca knižnice televíznych seriálov", "Automatic/Unattended": "Automaticky/bez zásahu", "Automation & Scheduling": "Automatizácia a plánovanie", @@ -3913,6 +3915,7 @@ "Local directory (single-machine — only use if it is a SEPARATE disk)": "Lokálny priečinok (pre jeden stroj - použite iba ak je na SAMOSTATNOM disku)", "Local keyfile is missing but a recovery copy was found in PBS.": "Lokálny keyfile chýba, ale v PBS sa našla obnovovacia kópia.", "Local network only (192.168.0.0/16)": "Iba lokálna sieť (192.168.0.0/16)", + "Local public URL has no matching published port; use an explicit URL": "Lokálnej verejnej URL nezodpovedá žiadny zverejnený port; zadajte explicitnú URL", "Local restore error log": "Záznam chyby lokálnej obnovy", "Local storage for PostgreSQL": "Lokálne úložisko pre PostgreSQL", "Locale generated": "Locale bolo vytvorené", diff --git a/lang/sv.json b/lang/sv.json index 052e6cc6..64a581b5 100644 --- a/lang/sv.json +++ b/lang/sv.json @@ -462,6 +462,7 @@ "Also install the VirtIO network driver during setup to enable network access.": "Installera även VirtIO-nätverksdrivrutinen under installationen för att möjliggöra nätverksåtkomst.", "Although VFIO can bind to this device, full passthrough to a VM is": "Även om VFIO kan binda till den här enheten, är full passthrough till en virtuell dator", "Altus is an Electron-based WhatsApp client with themes and multiple account support.": "Altus är en Electron-baserad WhatsApp-klient med teman och flera kontosupport.", + "Ambiguous local public URL; use an explicit URL": "Den lokala publika URL:en är tvetydig; ange en uttrycklig URL", "Ambiguous mount points in the container": "tvetydiga monteringspunkter i behållaren", "Ambiguous or invalid environment variable": "tvetydiga eller ogiltiga miljövariabel", "Amount of RAM in MiB (default: 4096)": "Mängd RAM i MiB (standard: 4096)", @@ -625,6 +626,7 @@ "Automatic Video Library Manager for TV Shows. It watches for new episodes of your favorite shows, and when they are posted it does its magic.": "Automatisk videobibliotekschef för TV-program. Den tittar på nya avsnitt av dina favoritprogram, och när de publiceras gör den sin magi.", "Automatic detection": "Automatisk upptäckt", "Automatic private network allocation requires a /24 subnet": "Automatisk privat nätverksallokering requires a /24 subnet", + "Automatic public URLs require static frontend addresses or explicit URLs": "Automatiska publika URL:er kräver statiska frontendadresser eller uttryckliga URL:er", "Automatic video library manager for TV Shows": "Automatisk videobibliotekschef för TV-program", "Automatic/Unattended": "Automatisk/Obevakad", "Automation & Scheduling": "Automatisering och schemaläggning", @@ -3913,6 +3915,7 @@ "Local directory (single-machine — only use if it is a SEPARATE disk)": "Lokal katalog (en dator - använd endast om det är en SEPARAT disk)", "Local keyfile is missing but a recovery copy was found in PBS.": "Lokal nyckelfil saknas men en återställningskopia hittades i PBS.", "Local network only (192.168.0.0/16)": "Endast lokalt nätverk (192.168.0.0/16)", + "Local public URL has no matching published port; use an explicit URL": "Den lokala publika URL:en matchar ingen publicerad port; ange en uttrycklig URL", "Local restore error log": "Lokal återställningsfellogg", "Local storage for PostgreSQL": "Lokal lagring för PostgreSQL", "Locale generated": "Språk genererad", diff --git a/oci/README.md b/oci/README.md index 6e512cb8..cb49bb14 100644 --- a/oci/README.md +++ b/oci/README.md @@ -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 diff --git a/oci/remote/install_generic_stack.py b/oci/remote/install_generic_stack.py index 2562b886..17538a10 100644 --- a/oci/remote/install_generic_stack.py +++ b/oci/remote/install_generic_stack.py @@ -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'] diff --git a/oci/src/proxmenux_oci/stack.py b/oci/src/proxmenux_oci/stack.py index 6d42c884..e465564b 100644 --- a/oci/src/proxmenux_oci/stack.py +++ b/oci/src/proxmenux_oci/stack.py @@ -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[''] diff --git a/oci/tests/test_stack_public_environment.py b/oci/tests/test_stack_public_environment.py new file mode 100644 index 00000000..33ade01e --- /dev/null +++ b/oci/tests/test_stack_public_environment.py @@ -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() diff --git a/oci/tests/test_stack_public_url_lifecycle.py b/oci/tests/test_stack_public_url_lifecycle.py new file mode 100644 index 00000000..2ced8cf1 --- /dev/null +++ b/oci/tests/test_stack_public_url_lifecycle.py @@ -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()