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
+3
View File
@@ -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.", "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", "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.", "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 mount points in the container": "Mehrdeutige Befestigungspunkte im Behälter",
"Ambiguous or invalid environment variable": "Mehrdeutige oder ungültige Umgebungsvariable", "Ambiguous or invalid environment variable": "Mehrdeutige oder ungültige Umgebungsvariable",
"Amount of RAM in MiB (default: 4096)": "RAM-Größe in MiB (Standard: 4096)", "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 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 detection": "Automatische Erkennung",
"Automatic private network allocation requires a /24 subnet": "Automatische private Netzwerkzuweisung requires a /24 Subnetz", "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 video library manager for TV Shows": "Automatischer Videobibliotheksmanager für TV-Shows",
"Automatic/Unattended": "Automatisch/unbeaufsichtigt", "Automatic/Unattended": "Automatisch/unbeaufsichtigt",
"Automation & Scheduling": "Automatisierung und Zeitplanung", "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 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 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 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 restore error log": "Fehlerprotokoll für die lokale Wiederherstellung",
"Local storage for PostgreSQL": "Lokale Speicherung für PostgreSQL", "Local storage for PostgreSQL": "Lokale Speicherung für PostgreSQL",
"Locale generated": "Gebietsschema generiert", "Locale generated": "Gebietsschema generiert",
+3
View File
@@ -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.", "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", "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.", "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 mount points in the container": "Puntos de montaje ambiguos en el contenedor",
"Ambiguous or invalid environment variable": "Variable de entorno ambiguo o inválido", "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)", "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 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 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 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 video library manager for TV Shows": "Gestor automático de videoteca para series de TV",
"Automatic/Unattended": "Automático/desatendido", "Automatic/Unattended": "Automático/desatendido",
"Automation & Scheduling": "Automation & Scheduling", "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 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 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 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 restore error log": "Log de errores de restauración local",
"Local storage for PostgreSQL": "Almacenamiento local para PostgreSQL", "Local storage for PostgreSQL": "Almacenamiento local para PostgreSQL",
"Locale generated": "Configuración regional generada", "Locale generated": "Configuración regional generada",
+3
View File
@@ -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.", "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", "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.", "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 mount points in the container": "Points de montage ambigus dans le conteneur",
"Ambiguous or invalid environment variable": "Variable d'environnement ambulante ou invalide", "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)", "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 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 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 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 video library manager for TV Shows": "Gestionnaire automatique de bibliothèque vidéo pour TV Shows",
"Automatic/Unattended": "Automatique/sans surveillance", "Automatic/Unattended": "Automatique/sans surveillance",
"Automation & Scheduling": "Automatisation et planification", "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 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 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 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 restore error log": "Journal des erreurs de restauration locale",
"Local storage for PostgreSQL": "Stockage local pour PostgreSQL", "Local storage for PostgreSQL": "Stockage local pour PostgreSQL",
"Locale generated": "Paramètres régionaux générés", "Locale generated": "Paramètres régionaux générés",
+3
View File
@@ -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.", "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 è", "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.", "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 mount points in the container": "Punti di montaggio ambigui nel container",
"Ambiguous or invalid environment variable": "Variabile d'ambiente ambigua o non valida", "Ambiguous or invalid environment variable": "Variabile d'ambiente ambigua o non valida",
"Amount of RAM in MiB (default: 4096)": "RAM in MiB (predefinita: 4096)", "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 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 detection": "Rilevamento automatico",
"Automatic private network allocation requires a /24 subnet": "L’assegnazione automatica della rete privata richiede una sottorete /24", "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 video library manager for TV Shows": "Gestore automatico di librerie video per serie TV",
"Automatic/Unattended": "Automatico/non presidiato", "Automatic/Unattended": "Automatico/non presidiato",
"Automation & Scheduling": "Automazione e pianificazione", "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 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 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 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 restore error log": "Registro errori ripristino locale",
"Local storage for PostgreSQL": "Storage locale per PostgreSQL", "Local storage for PostgreSQL": "Storage locale per PostgreSQL",
"Locale generated": "Configurazione locale generata", "Locale generated": "Configurazione locale generata",
+3
View File
@@ -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.", "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 é", "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.", "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 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", "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)", "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 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 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 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 video library manager for TV Shows": "Gerenciador automático de bibliotecas de vídeo para programas de TV",
"Automatic/Unattended": "Automático/Autônomo", "Automatic/Unattended": "Automático/Autônomo",
"Automation & Scheduling": "Automação e agendamento", "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 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 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 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 restore error log": "Log de erros de restauração local",
"Local storage for PostgreSQL": "Armazenamento local para PostgreSQL", "Local storage for PostgreSQL": "Armazenamento local para PostgreSQL",
"Locale generated": "Local gerado", "Locale generated": "Local gerado",
+3
View File
@@ -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.", "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", "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.", "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 mount points in the container": "Nejednoznačné body pripojenia v kontajneri",
"Ambiguous or invalid environment variable": "Nejednoznačná alebo neplatná premenná prostredia", "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)", "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 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 detection": "Automatická detekcia",
"Automatic private network allocation requires a /24 subnet": "Automatické pridelenie súkromnej siete vyžaduje podsieť /24", "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 video library manager for TV Shows": "Automatický správca knižnice televíznych seriálov",
"Automatic/Unattended": "Automaticky/bez zásahu", "Automatic/Unattended": "Automaticky/bez zásahu",
"Automation & Scheduling": "Automatizácia a plánovanie", "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 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 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 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 restore error log": "Záznam chyby lokálnej obnovy",
"Local storage for PostgreSQL": "Lokálne úložisko pre PostgreSQL", "Local storage for PostgreSQL": "Lokálne úložisko pre PostgreSQL",
"Locale generated": "Locale bolo vytvorené", "Locale generated": "Locale bolo vytvorené",
+3
View File
@@ -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.", "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", "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.", "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 mount points in the container": "tvetydiga monteringspunkter i behållaren",
"Ambiguous or invalid environment variable": "tvetydiga eller ogiltiga miljövariabel", "Ambiguous or invalid environment variable": "tvetydiga eller ogiltiga miljövariabel",
"Amount of RAM in MiB (default: 4096)": "Mängd RAM i MiB (standard: 4096)", "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 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 detection": "Automatisk upptäckt",
"Automatic private network allocation requires a /24 subnet": "Automatisk privat nätverksallokering requires a /24 subnet", "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 video library manager for TV Shows": "Automatisk videobibliotekschef för TV-program",
"Automatic/Unattended": "Automatisk/Obevakad", "Automatic/Unattended": "Automatisk/Obevakad",
"Automation & Scheduling": "Automatisering och schemaläggning", "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 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 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 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 restore error log": "Lokal återställningsfellogg",
"Local storage for PostgreSQL": "Lokal lagring för PostgreSQL", "Local storage for PostgreSQL": "Lokal lagring för PostgreSQL",
"Locale generated": "Språk genererad", "Locale generated": "Språk genererad",
+13
View File
@@ -229,6 +229,19 @@ network. Internal services can use declared or inherited image health checks,
exposed TCP ports, or a clearly labelled process-running check. A running process exposed TCP ports, or a clearly labelled process-running check. A running process
alone does not satisfy `service_healthy`. 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; Repeated identical volume sources share one host directory, selected once;
separate configuration volumes stay independent. Host directories are not part separate configuration volumes stay independent. Host directories are not part
of a normal Proxmox container backup. Newly created private data volumes use the of a normal Proxmox container backup. Newly created private data volumes use the
+54 -1
View File
@@ -254,6 +254,58 @@ def assign_health_address(service):
check['host'] = service['ip'] 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): def bind_completed_jobs(services):
"""All members and private aliases exist before a job can be authorized.""" """All members and private aliases exist before a job can be authorized."""
from oci_compose_jobs import job_spec_from_record from oci_compose_jobs import job_spec_from_record
@@ -881,6 +933,7 @@ def main():
services = copy.deepcopy(deployment['services']) services = copy.deepcopy(deployment['services'])
independent = bool(deployment.get('suite_arr')) independent = bool(deployment.get('suite_arr'))
primary = None if independent else next(s for s in services if s['main']) primary = None if independent else next(s for s in services if s['main'])
prepare_public_environment(services)
created = [] created = []
bridge_created = False bridge_created = False
lifecycle = None lifecycle = None
@@ -932,7 +985,7 @@ def main():
results = {} results = {}
for order,s in enumerate(services,1): for order,s in enumerate(services,1):
plan = s['deployment'] 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']: for e in plan['environment']:
e['value'] = e['value'].replace('@STACK_LAN_IP@',s['ip']) e['value'] = e['value'].replace('@STACK_LAN_IP@',s['ip'])
plan['vmid'] = s['vmid'] plan['vmid'] = s['vmid']
+51 -2
View File
@@ -14,7 +14,7 @@ import yaml
from .casaos import convert_casaos_compose, canonical_image_repository from .casaos import convert_casaos_compose, canonical_image_repository
from .converter import ConversionError 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 from .i18n import translate
@@ -240,6 +240,46 @@ def service_endpoint(service, include_expose=False):
return None 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): def _check_duration(value, default):
if value is None: if value is None:
return default return default
@@ -350,6 +390,8 @@ def assess(template):
healthy_required.update(name for name, condition in dependencies.items() healthy_required.update(name for name, condition in dependencies.items()
if isinstance(condition, dict) and condition.get('condition') == 'service_healthy') if isinstance(condition, dict) and condition.get('condition') == 'service_healthy')
jobs = completed_services(template) if custom else set() 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: if template['compose_stack']['main_service'] in jobs or jobs & healthy_required:
raise StackError(translate('unsupported dependency condition') + ': service_completed_successfully') raise StackError(translate('unsupported dependency condition') + ': service_completed_successfully')
for service in services: 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'): if not value or any(c in value for c in '\r\n'):
raise StackError(translate('External credential is empty or spans multiple lines')) raise StackError(translate('External credential is empty or spans multiple lines'))
envs[service_name][field['name']] = value 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 = [] plans = []
for index, s in enumerate(services): for index, s in enumerate(services):
main = s['name'] == template['compose_stack']['main_service'] main = s['name'] == template['compose_stack']['main_service']
env = envs[s['name']] env = envs[s['name']]
# Public application URLs cannot retain localhost in a remote deployment. # Public application URLs cannot retain localhost in a remote deployment.
for key,value in list(env.items()): 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) url = urlsplit(value)
endpoint = template['first_run']['endpoints'][0] endpoint = template['first_run']['endpoints'][0]
env[key] = urlunsplit((url.scheme, '@STACK_LAN_IP@:'+str(endpoint['port']), url.path, url.query, url.fragment)) 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, 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'])])), 'aliases':list(dict.fromkeys([s['name'],s['compose'].get('container_name',s['name'])])),
'template':single,'deployment':plan,'healthcheck':health}) '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: if s['name'] in frontend_names and s['name'] not in jobs:
plans[-1]['frontend'] = True plans[-1]['frontend'] = True
plans[-1]['frontend_ipv4'] = addresses[s['name']] if custom else addresses[''] plans[-1]['frontend_ipv4'] = addresses[s['name']] if custom else addresses['']
+133
View File
@@ -0,0 +1,133 @@
"""Only public loopback origins bind by exact Compose port and static target."""
import copy
from pathlib import Path
import sys
import unittest
from unittest.mock import patch
ROOT=Path(__file__).resolve().parents[1]
sys.path.insert(0,str(ROOT/'src'));sys.path.insert(0,str(ROOT/'remote'))
from proxmenux_oci.custom import template_from_compose
from proxmenux_oci import stack
from proxmenux_oci.stack import assess,public_environment_bindings,StackError,build_stack,DefaultsUI
from proxmenux_oci.installer import DEFAULT_MODE
import install_generic_stack as runtime
SOURCE='''services:
web:
image: nginx:alpine
ports: ['9001:8080']
environment: {PUBLIC_URI: 'http://localhost:9001/app?x=1#view'}
depends_on: [backend, mail]
backend:
image: example/backend:latest
environment: {PUBLIC_URI: 'http://localhost:9001/app?x=1#view', EXPLICIT_URL: 'https://example.com:9443/keep'}
mail:
image: axllent/mailpit:v1
ports: ['8025:8025']
environment: {PUBLIC_URI: 'http://localhost:8025/mail'}
'''
def fixture(dynamic=False,jobs=False):
result=[]
for name,vmid,main,address in [('backend',4801,False,None),('mail',4802,False,'172.30.249.11/24'),('web',4800,True,'dhcp' if dynamic else '172.30.249.10/24')]:
target='mail' if name=='mail' else 'web';port=8025 if name=='mail' else 8080
value='http://@STACK_LAN_IP@:'+str(port)+'/app?x=1#view'
result.append({'name':name,'vmid':vmid,'main':main,'frontend':name!='backend','frontend_ipv4':address,
'healthcheck':{'type':'running'},'deployment':{'environment':[{'name':'PUBLIC_URI','value':value}]},
'public_environment_bindings':{'PUBLIC_URI':{'target_service':target,'container_port':port,'value':value}}})
if jobs:result.append({'name':'migration','vmid':4803,'main':False,'healthcheck':{'type':'completed'},'deployment':{'environment':[]}})
return result
def build(template,dynamic=False):
with patch('proxmenux_oci.installer.ask_storage',return_value='local-zfs'), \
patch('proxmenux_oci.installer.ask_bridge',return_value='vmbr0'), \
patch('proxmenux_oci.host.timezone',return_value='UTC'), \
patch('proxmenux_oci.network.ask_addresses',side_effect=lambda ui,bridge,labels: ({name:('dhcp' if dynamic else '172.30.249.11/24' if name=='mail' else '172.30.249.10/24') for name in labels},None)):
return build_stack(template,DefaultsUI(),DEFAULT_MODE)
class PublicEnvironmentTests(unittest.TestCase):
def source(self,text=SOURCE):
template=template_from_compose(text)
return template,template['compose_stack']['services']
def test_origin_port_maps_shared_url_to_web_and_mail_to_itself(self):
template,services=self.source();bindings=public_environment_bindings(services,'web')
self.assertEqual(assess(template),[])
self.assertEqual(bindings['backend']['PUBLIC_URI']['target_service'],'web')
self.assertEqual(bindings['backend']['PUBLIC_URI']['container_port'],8080)
self.assertEqual(bindings['backend']['PUBLIC_URI']['value'],'http://@STACK_LAN_IP@:8080/app?x=1#view')
self.assertEqual(bindings['mail']['PUBLIC_URI']['target_service'],'mail')
self.assertNotIn('EXPLICIT_URL',bindings['backend'])
def test_compiler_carries_targets_and_resolves_static_before_first_start(self):
template,_=self.source();plan=build(template)
services={service['name']:service for service in plan['services']}
self.assertEqual(services['backend']['public_environment_bindings']['PUBLIC_URI']['target_service'],'web')
with patch.object(runtime,'run') as run:runtime.prepare_public_environment(plan['services'])
values={item['name']:item['value'] for item in services['backend']['deployment']['environment']}
self.assertEqual(values['PUBLIC_URI'],'http://172.30.249.10:8080/app?x=1#view')
self.assertEqual(values['EXPLICIT_URL'],'https://example.com:9443/keep')
run.assert_not_called()
def test_final_environment_override_is_not_replaced_by_original_compose_binding(self):
template,_=self.source();original=stack.resolve_environments
def final_override(*args,**kwargs):
values=original(*args,**kwargs)
values['backend']['PUBLIC_URI']='https://operator.example.com/custom'
return values
with patch.object(stack,'resolve_environments',side_effect=final_override):plan=build(template)
backend=next(s for s in plan['services'] if s['name']=='backend')
self.assertNotIn('PUBLIC_URI',backend.get('public_environment_bindings',{}))
runtime.prepare_public_environment(plan['services'])
self.assertEqual(next(e['value'] for e in backend['deployment']['environment'] if e['name']=='PUBLIC_URI'),'https://operator.example.com/custom')
def test_internal_health_metrics_and_admin_urls_are_never_automatic_public_fields(self):
extra="OTEL_EXPORTER_OTLP_ENDPOINT: 'http://localhost:4318', HEALTHCHECK_URL: 'http://127.0.0.1:9090', LOCAL_ADMIN_API: 'http://localhost:9999', TEXT: 'not:a[url', "
template,services=self.source(SOURCE.replace('EXPLICIT_URL:',extra+'EXPLICIT_URL:'))
self.assertEqual(assess(template),[])
bindings=public_environment_bindings(services,'web')['backend']
for key in ['OTEL_EXPORTER_OTLP_ENDPOINT','HEALTHCHECK_URL','LOCAL_ADMIN_API','TEXT']:self.assertNotIn(key,bindings)
plan=build(template);backend=next(s for s in plan['services'] if s['name']=='backend');runtime.prepare_public_environment(plan['services'])
values={e['name']:e['value'] for e in backend['deployment']['environment']}
self.assertEqual(values['OTEL_EXPORTER_OTLP_ENDPOINT'],'http://localhost:4318')
self.assertEqual(values['HEALTHCHECK_URL'],'http://127.0.0.1:9090')
self.assertEqual(values['LOCAL_ADMIN_API'],'http://localhost:9999')
def test_ambiguous_host_port_is_blocked(self):
template,services=self.source(SOURCE.replace("['8025:8025']","['9001:8025']"))
with self.assertRaisesRegex(StackError,'Ambiguous'):public_environment_bindings(services,'web')
self.assertTrue(any('Ambiguous' in error for error in assess(template)))
def test_unmatched_public_origin_does_not_guess_even_single_frontend(self):
_,services=self.source(SOURCE.replace('localhost:9001','localhost:9999'))
with self.assertRaisesRegex(StackError,'no matching'):public_environment_bindings(services,'web')
only_web=[service for service in services if service['name']!='mail']
with self.assertRaisesRegex(StackError,'no matching'):public_environment_bindings(only_web,'web')
def test_missing_origin_port_does_not_fall_back_to_the_main(self):
_,services=self.source(SOURCE.replace('localhost:9001','localhost'))
with self.assertRaisesRegex(StackError,'no matching'):public_environment_bindings(services,'web')
def test_static_bindings_with_migration_do_not_run_any_commands(self):
with patch.object(runtime,'run') as run:runtime.prepare_public_environment(fixture(jobs=True))
run.assert_not_called()
def test_every_automatic_dhcp_binding_is_refused_before_any_command(self):
for jobs in (False,True):
with self.subTest(jobs=jobs),patch.object(runtime,'run') as run,self.assertRaisesRegex(RuntimeError,'Automatic public URLs'):
runtime.prepare_public_environment(fixture(dynamic=True,jobs=jobs))
run.assert_not_called()
template,_=self.source()
with self.assertRaisesRegex(StackError,'Automatic public URLs'):build(template,dynamic=True)
def test_explicit_public_urls_allow_dhcp_and_are_unchanged(self):
template,_=self.source(SOURCE.replace('localhost','operator.example.com'))
plan=build(template,dynamic=True)
with patch.object(runtime,'run') as run:runtime.prepare_public_environment(plan['services'])
self.assertTrue(all(not s.get('public_environment_bindings') for s in plan['services']))
backend=next(s for s in plan['services'] if s['name']=='backend')
self.assertEqual(next(e['value'] for e in backend['deployment']['environment'] if e['name']=='PUBLIC_URI'),'http://operator.example.com:9001/app?x=1#view')
run.assert_not_called()
if __name__=='__main__':unittest.main()
@@ -0,0 +1,130 @@
"""Public origins remain fixed desired state across native stack lifecycle APIs."""
import copy
import json
from pathlib import Path
import sys
import tempfile
import unittest
import uuid
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'src'))
sys.path.insert(0, str(ROOT / 'remote'))
from proxmenux_oci.custom import template_from_compose
from proxmenux_oci.stack import build_stack, DefaultsUI
from proxmenux_oci.installer import DEFAULT_MODE
import install_generic_stack as runtime
import oci_instances as instances
import oci_instance_transaction as transactions
import oci_stack_plan as plans
import oci_stack_native as native
import oci_restore_recovery as recovery
SOURCE = '''services:
web:
image: nginx:alpine
ports: ['9001:8080']
environment: {PUBLIC_ORIGIN: 'http://localhost:9001/entry'}
depends_on: [backend, secondary]
backend:
image: example/worker:latest
environment: {PUBLIC_ORIGIN: 'http://localhost:9001/entry'}
secondary:
image: nginx:alpine
ports: ['8025:80']
environment: {PUBLIC_ORIGIN: 'http://localhost:8025/mail'}
'''
DIGEST = 'sha256:' + 'a' * 64
class StaticPublicURLLifecycle(unittest.TestCase):
def publish(self, root):
template = template_from_compose(SOURCE)
with patch('proxmenux_oci.installer.ask_storage', return_value='local-zfs'), \
patch('proxmenux_oci.installer.ask_bridge', return_value='vmbr909'), \
patch('proxmenux_oci.host.timezone', return_value='UTC'), \
patch('proxmenux_oci.network.ask_addresses', side_effect=lambda ui, bridge, labels:
({name: ('172.30.249.91/24' if name == 'secondary' else '172.30.249.90/24')
for name in labels}, None)):
deployment = build_stack(template, DefaultsUI(), DEFAULT_MODE)
services = deployment['services']
original = copy.deepcopy(services)
runtime.prepare_public_environment(services)
configs, observed = {}, {}
for index, service in enumerate(services):
vmid = 4900 + service['offset']
service['vmid'] = vmid
service['deployment']['vmid'] = vmid
service['ip'] = f'10.77.5.{30 + service["offset"]}'
runtime.assign_health_address(service)
identity = str(uuid.uuid4())
config = f'description: proxmenux-instance={identity}\nrootfs: local-zfs:subvol-{vmid}-disk-0,size=8G\n'
configs[vmid] = config.encode()
observed[vmid] = {'config': config, 'archive_path': '/fixture/image.tar',
'resolved_registry_digest': DIGEST,
'image': {'architecture': 'amd64', 'os': 'linux', 'defaults': {}}}
# The installer must publish final runtime plans, not stale input snapshots.
instances.write(instances.location(root, vmid), {
'schema_version': 1, 'vmid': vmid, 'installation_id': identity,
'status': 'assembling', 'template': copy.deepcopy(service['template']),
'deployment': copy.deepcopy(original[index]['deployment']),
'observed': copy.deepcopy(observed[vmid])})
contract = {'schema': 1, 'stack': 'public-url-fixture', 'dependencies': [
{'vmid': s['vmid'], 'label': s['name'], 'healthcheck': copy.deepcopy(s['healthcheck'])}
for s in services if not s['main']]}
with patch.object(instances, 'command', side_effect=lambda *args: configs[int(args[-1])]), \
patch.object(instances, 'observe', side_effect=lambda vmid, *args: copy.deepcopy(observed[vmid])):
instances.publish_stack(root, 4900, template, deployment, services)
return contract, {s['name']: s['vmid'] for s in services}
def assert_origins(self, records, names):
for name, vmid in names.items():
values = {item['name']: item['value'] for item in records[vmid]['deployment']['environment']}
self.assertEqual(values['PUBLIC_ORIGIN'], 'http://172.30.249.91:80/mail'
if name == 'secondary' else 'http://172.30.249.90:8080/entry')
self.assertNotIn('@STACK_LAN_IP@', json.dumps(records[vmid]['deployment']))
self.assertNotIn('localhost', values['PUBLIC_ORIGIN'])
def test_published_static_origins_survive_native_update_contract_and_private_probes(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
contract, names = self.publish(root)
records = {vmid: instances.read(root, vmid) for vmid in names.values()}
self.assert_origins(records, names)
plan = plans.build(records[4900], records,
{v: r['installation_id'] for v, r in records.items()}, 'update')
adapter = native.NativeAdapter(root, root / 'journal.json', plan)
candidates = {r['vmid']: transactions.candidate_contract(r, 'update') for r in plan['members']}
self.assert_origins(candidates, names)
expected = [{'vmid': s['vmid'], 'label': s['name'], 'healthcheck': s['healthcheck']}
for s in adapter.services.values() if s['vmid'] != 4900]
self.assertEqual(expected, contract['dependencies'])
secondary = next(s for s in adapter.services.values() if s['name'] == 'secondary')
self.assertTrue(secondary['healthcheck']['url'].startswith('http://10.77.5.'))
self.assertTrue(recovery.valid_contract(contract, set(names.values()) - {4900}))
def test_restore_refresh_adopts_storage_without_reinferring_url_or_lan_healthchecks(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
contract, names = self.publish(root)
for vmid in names.values():
record = instances.read(root, vmid)
record['deployment']['rootfs']['storage'] = 'restored-zfs'
record['recovered_at'] = instances.now()
instances.write(instances.location(root, vmid), record)
recovery.refresh_service_plans(root, list(names.values()))
records = {v: instances.read(root, v) for v in names.values()}
self.assert_origins(records, names)
primary = records[4900]
for service in primary['stack']['deployment']['services']:
self.assertEqual(service['deployment'], records[service['vmid']]['deployment'])
self.assertEqual(service['deployment']['rootfs']['storage'], 'restored-zfs')
expected = [{'vmid': s['vmid'], 'label': s['name'], 'healthcheck': s['healthcheck']}
for s in primary['stack']['deployment']['services'] if s['vmid'] != 4900]
self.assertEqual(expected, contract['dependencies'])
self.assertTrue(recovery.valid_contract(contract, set(names.values()) - {4900}))
if __name__ == '__main__':
unittest.main()