fix(oci): services show their address instead of a web link, and applications are not asked for their own

This commit is contained in:
MacRimi
2026-10-11 15:57:05 +02:00
parent 873a3cd8e2
commit 69b76116fd
27 changed files with 407 additions and 18 deletions
+16
View File
@@ -269,6 +269,9 @@ interface OciInstance {
port: number | null
path: string | null
website: string | null
// The ports of an application with no web address: a service that is
// reached on them and has no page to open.
service_ports?: { port: number; protocol: string }[] | null
}
interface Suggestions {
@@ -2904,6 +2907,19 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }:
</div>
)}
{/* A service has no page to open: its address is shown as
text, on the ports of its container. */}
{!app.ports?.length && app.installed_via === "oci_image" && !!ctIp && ctIp !== "DHCP"
&& !!suggestions?.oci_instance?.service_ports?.length && (
<div className="mb-3 flex flex-col gap-1">
{suggestions.oci_instance.service_ports.map((p) => (
<span key={`${p.port}/${p.protocol}`} className="font-mono text-sm text-foreground">
{ctIp.split("/")[0]}:{p.port}{p.protocol === "udp" ? "/udp" : ""}
</span>
))}
</div>
)}
{/* Footer with per-card actions — only rendered in the
global edit mode (toggled from the "Edit" button next
to Add another application). View mode keeps cards
+21 -2
View File
@@ -5509,7 +5509,9 @@ def ensure_oci_registration(vmid) -> bool:
# A database or a cache is reached by the application, not by the user.
endpoints = []
elif not endpoints:
port = meta.get("endpoint_port") or next(iter(meta.get("ports") or []), None)
# A recipe that names no web address has none: its ports are the
# service itself, a database or an SSH server, and not a page to open.
port = meta.get("endpoint_port")
endpoints = [{"port": port, "scheme": meta.get("endpoint_scheme"), "path": meta.get("endpoint_path"),
"description": "", "logo_url": ""}] if isinstance(port, int) else []
ports = []
@@ -5638,6 +5640,19 @@ def _oci_instance_meta(vmid) -> Optional[dict]:
continue
if 1 <= port <= 65535 and port not in ports:
ports.append(port)
# With no web address, the application is a service reached on the ports
# of its container; they are shown as an address and never as a link. The
# database or the cache of a stack is reached by its application only.
service_ports = []
if not endpoint.get("port") and not _oci_secondary_member(record):
for entry in contract.get("ports") or []:
try:
port = int((entry or {}).get("container_port"))
except (TypeError, ValueError):
continue
if 1 <= port <= 65535:
service_ports.append({"port": port,
"protocol": "udp" if (entry or {}).get("protocol") == "udp" else "tcp"})
template_id = str(template.get("id") or "").strip()
# The application is reached on its first endpoint and carries its own
# name and logo. Any other endpoint is a service of its own, such as
@@ -5708,6 +5723,7 @@ def _oci_instance_meta(vmid) -> Optional[dict]:
"endpoint_path": str(endpoint.get("path") or "").strip() or None,
"ports": ports,
"endpoints": endpoints,
"service_ports": service_ports,
# The exact image this container was created from. Its digest is what
# an update is decided on; the version label is only for reading.
"installed_digest": str(observed_image.get("manifest_digest") or "").strip() or None,
@@ -6244,7 +6260,9 @@ def get_suggestions(vmid, force: bool = False) -> dict:
ports = [oci_meta["endpoint_port"]] + [p for p in (oci_meta["ports"] or ports)
if p != oci_meta["endpoint_port"]]
elif oci_meta["ports"]:
default_ports = list(oci_meta["ports"])
# A service has no page to open: none of its ports is proposed
# as a web link.
default_ports = []
web_hint = oci_meta["endpoint_path"] or web_hint
if oci_meta["template_id"] == "image-adguard-home":
# The first-run endpoint disappears once setup switches to :80.
@@ -6289,6 +6307,7 @@ def get_suggestions(vmid, force: bool = False) -> dict:
"port": oci_meta["endpoint_port"],
"path": oci_meta["endpoint_path"],
"website": oci_meta["website"],
"service_ports": oci_meta["service_ports"],
} if oci_meta else None,
# Categoría preset for the primary detection — same lookup as
# get_catalog_entry so the Register button pre-selects it.
@@ -105,6 +105,30 @@ class OciEndpointTests(CatalogFixture):
self.assertNotIn('logo_url', ports[0])
self.assertEqual(ports[1]['logo_url'], ICON)
def test_a_service_with_no_web_address_is_registered_without_a_link(self):
# A database or an SSH server: its port is not a page to open.
meta = self.install([])
self.assertEqual(meta['service_ports'], [{'port': 8971, 'protocol': 'tcp'}])
with patch.object(lxc_apps, '_read_sidecar', return_value=None), \
patch.object(lxc_apps, '_oci_dismissed', return_value={}), \
patch.object(lxc_apps, 'add_app', return_value=(True, {})) as add:
self.assertTrue(lxc_apps.ensure_oci_registration(190))
self.assertEqual(add.call_args.args[1]['ports'], [])
def test_a_service_is_shown_by_its_address_and_not_proposed_as_a_link(self):
self.install([])
with patch.object(lxc_apps, '_probe_listening_ports', return_value=[]), \
patch.object(lxc_apps, '_helper_slug_meta', return_value=None), \
patch.object(lxc_apps, '_fetch_tracking_hints', return_value={}), \
patch.object(lxc_apps, '_oci_image_versions', return_value={'error': 'offline'}), \
patch.object(lxc_apps, '_read_sidecar', return_value=None):
suggestion = lxc_apps.get_suggestions(190)
self.assertEqual(suggestion['default_ports'], [])
self.assertEqual(suggestion['oci_instance']['service_ports'], [{'port': 8971, 'protocol': 'tcp'}])
def test_an_application_with_a_web_address_has_no_service_ports(self):
self.assertEqual(self.install(ENDPOINTS)['service_ports'], [])
class OciGuestVersionTests(CatalogFixture):
def test_version_read_from_the_file_the_catalog_names(self):
+3 -1
View File
@@ -284,7 +284,7 @@
"Additional audio function(s) to be added": "Zusätzliche Audiofunktion(en) sollen hinzugefügt werden",
"Additional media/GPU GIDs, comma-separated": "Zusätzliche Medien/GPU-GIDs, kommagetrennt",
"Additional paths for": "Zusätzliche Pfade für",
"Address BookStack will be opened at (for example http://192.168.1.50:6875)": "Adresse, unter der BookStack geöffnet wird (zum Beispiel http://192.168.1.50:6875)",
"Address BookStack will be opened at (for example http://192.168.1.50)": "Adresse, unter der BookStack geöffnet wird (zum Beispiel http://192.168.1.50)",
"Address Healthchecks will be opened at (for example http://192.168.1.50:8000)": "Adresse, unter der Healthchecks geöffnet wird (zum Beispiel http://192.168.1.50:8000)",
"Address Speedtest Tracker will be opened at (for example http://192.168.1.50)": "Adresse, unter der Speedtest Tracker geöffnet wird (zum Beispiel http://192.168.1.50)",
"Address of Redis or Valkey (for example redis://192.168.1.20:6379/1)": "Adresse von Redis oder Valkey (zum Beispiel redis://192.168.1.20:6379/1)",
@@ -3593,6 +3593,7 @@
"Manyfold is an open source, self-hosted web application for managing a collection of 3D models, particularly focused on 3D printing.": "Manyfold ist eine selbst gehostete Open-Source-Webanwendung zur Verwaltung einer Sammlung von 3D-Modellen, die sich insbesondere auf den 3D-Druck konzentriert.",
"Mapped GID on host": "Zugeordnete GID auf dem Host",
"Mapped UID on host": "Zugeordnete UID auf dem Host",
"MariaDB has no web interface: applications and clients connect to port 3306 of this container.": "MariaDB hat keine Weboberfläche: Anwendungen und Clients verbinden sich mit Port 3306 dieses Containers.",
"Mariadb is one of the most popular database servers. Made by the original developers of MySQL.": "Mariadb ist einer der beliebtesten Datenbankserver. Hergestellt von den ursprünglichen Entwicklern von MySQL.",
"Mark the VMs/CTs to include in this job:": "Markieren Sie die VMs/CTs, die in diesen Job einbezogen werden sollen:",
"Mastodon is a free, open-source social network server based on ActivityPub where users can follow friends and discover new ones..": "Mastodon ist ein kostenloser Open-Source-Sozialnetzwerkserver auf Basis von ActivityPub, auf dem Benutzer Freunden folgen und neue entdecken können.",
@@ -6514,6 +6515,7 @@
"The restored service did not pass its health check": "Der wiederhergestellte Service bestand seinen Gesundheitscheck nicht",
"The restored service stopped; the recovery is not confirmed": "Der wiederhergestellte Dienst wurde gestoppt; die Wiederherstellung wird nicht bestätigt",
"The reviewed Tandoor stack does not require a privileged LXC.": "Der überprüfte Tandoor-Stack requi ist kein privilegierter LXC.",
"The root password is asked for when connecting from another machine. Inside the container, the mariadb command opens as root without a password.": "Das root-Passwort wird bei Verbindungen von einem anderen Rechner abgefragt. Im Container öffnet der Befehl mariadb als root ohne Passwort.",
"The rootfs capture only belongs to the running installation": "Der Rootfs Capture gehört nur zur laufenden Installation",
"The rootfs is not managed by Proxmox": "Die Rootfs werden nicht von Proxmox verwaltet",
"The rootfs is not mounted": "Die Rootfs sind nicht montiert",
+3 -1
View File
@@ -282,7 +282,7 @@
"Additional audio function(s) to be added": "Funciones de audio adicionales que se agregarán",
"Additional media/GPU GIDs, comma-separated": "GID adicionales de medios/GPU, separados por comas",
"Additional paths for": "Rutas adicionales para",
"Address BookStack will be opened at (for example http://192.168.1.50:6875)": "Dirección en la que se abrirá BookStack (por ejemplo http://192.168.1.50:6875)",
"Address BookStack will be opened at (for example http://192.168.1.50)": "Dirección en la que se abrirá BookStack (por ejemplo http://192.168.1.50)",
"Address Healthchecks will be opened at (for example http://192.168.1.50:8000)": "Dirección en la que se abrirá Healthchecks (por ejemplo http://192.168.1.50:8000)",
"Address Speedtest Tracker will be opened at (for example http://192.168.1.50)": "Dirección en la que se abrirá Speedtest Tracker (por ejemplo http://192.168.1.50)",
"Address of Redis or Valkey (for example redis://192.168.1.20:6379/1)": "Dirección de Redis o Valkey (por ejemplo redis://192.168.1.20:6379/1)",
@@ -3589,6 +3589,7 @@
"Manyfold is an open source, self-hosted web application for managing a collection of 3D models, particularly focused on 3D printing.": "Manyfold es una aplicación web autoalojada y de código abierto para gestionar una colección de modelos 3D, orientada sobre todo a la impresión 3D.",
"Mapped GID on host": "GID asignado en el host",
"Mapped UID on host": "UID asignado en el host",
"MariaDB has no web interface: applications and clients connect to port 3306 of this container.": "MariaDB no tiene interfaz web: las aplicaciones y los clientes se conectan al puerto 3306 de este contenedor.",
"Mariadb is one of the most popular database servers. Made by the original developers of MySQL.": "Mariadb es uno de los servidores de bases de datos más populares, creado por los desarrolladores originales de MySQL.",
"Mark the VMs/CTs to include in this job:": "Marque las VM/CT que incluirá este trabajo:",
"Mastodon is a free, open-source social network server based on ActivityPub where users can follow friends and discover new ones..": "Mastodon es un servidor de red social gratuito y de código abierto basado en ActivityPub, donde los usuarios pueden seguir a sus amigos y descubrir otros nuevos..",
@@ -6490,6 +6491,7 @@
"The restored service did not pass its health check": "El servicio restaurado no superó su comprobación de estado",
"The restored service stopped; the recovery is not confirmed": "El servicio restaurado se detuvo; la recuperación no se confirma",
"The reviewed Tandoor stack does not require a privileged LXC.": "La pila de Tandoor revisada no necesita un LXC privilegiado.",
"The root password is asked for when connecting from another machine. Inside the container, the mariadb command opens as root without a password.": "La contraseña de root se pide al conectarse desde otra máquina. Dentro del contenedor, el comando mariadb abre como root sin contraseña.",
"The rootfs capture only belongs to the running installation": "La captura del rootfs solo pertenece a la instalación en ejecución",
"The rootfs is not managed by Proxmox": "Los rootfs no son gestionados por Proxmox",
"The rootfs is not mounted": "Los rootfs no se montan",
+3 -1
View File
@@ -284,7 +284,7 @@
"Additional audio function(s) to be added": "Fonction(s) audio supplémentaire(s) à ajouter",
"Additional media/GPU GIDs, comma-separated": "Autres médias/GPU GID, séparés par des virgules",
"Additional paths for": "Voies supplémentaires pour",
"Address BookStack will be opened at (for example http://192.168.1.50:6875)": "Adresse à laquelle BookStack sera ouvert (par exemple http://192.168.1.50:6875)",
"Address BookStack will be opened at (for example http://192.168.1.50)": "Adresse à laquelle BookStack sera ouvert (par exemple http://192.168.1.50)",
"Address Healthchecks will be opened at (for example http://192.168.1.50:8000)": "Adresse à laquelle Healthchecks sera ouvert (par exemple http://192.168.1.50:8000)",
"Address Speedtest Tracker will be opened at (for example http://192.168.1.50)": "Adresse à laquelle Speedtest Tracker sera ouvert (par exemple http://192.168.1.50)",
"Address of Redis or Valkey (for example redis://192.168.1.20:6379/1)": "Adresse de Redis ou Valkey (par exemple redis://192.168.1.20:6379/1)",
@@ -3593,6 +3593,7 @@
"Manyfold is an open source, self-hosted web application for managing a collection of 3D models, particularly focused on 3D printing.": "Manyfold est une application web ouverte, auto-organisée pour gérer une collection de modèles 3D, particulièrement axée sur l'impression 3D.",
"Mapped GID on host": "GID mappé sur l'hôte",
"Mapped UID on host": "UID mappé sur l'hôte",
"MariaDB has no web interface: applications and clients connect to port 3306 of this container.": "MariaDB n'a pas d'interface web : les applications et les clients se connectent au port 3306 de ce conteneur.",
"Mariadb is one of the most popular database servers. Made by the original developers of MySQL.": "Mariadb est l'un des serveurs de base de données les plus populaires. Réalisé par les développeurs originaux de MySQL.",
"Mark the VMs/CTs to include in this job:": "Marquez les machines virtuelles/CT à inclure dans cette tâche :",
"Mastodon is a free, open-source social network server based on ActivityPub where users can follow friends and discover new ones..": "Mastodon est un serveur de réseau social libre basé sur ActivitéPub où les utilisateurs peuvent suivre leurs amis et en découvrir de nouveaux.",
@@ -6513,6 +6514,7 @@
"The restored service did not pass its health check": "Le service restauré n'a pas réussi son bilan de santé",
"The restored service stopped; the recovery is not confirmed": "Le service restauré s'est arrêté; la récupération n'est pas confirmée",
"The reviewed Tandoor stack does not require a privileged LXC.": "La pile Tandoor revue ne require un LXC privilégié.",
"The root password is asked for when connecting from another machine. Inside the container, the mariadb command opens as root without a password.": "Le mot de passe root est demandé lors d'une connexion depuis une autre machine. Dans le conteneur, la commande mariadb s'ouvre en tant que root sans mot de passe.",
"The rootfs capture only belongs to the running installation": "La capture rootfs appartient uniquement à l'installation en cours d'exécution",
"The rootfs is not managed by Proxmox": "Les roofs ne sont pas gérés par Proxmox",
"The rootfs is not mounted": "Les roofs ne sont pas montés",
+3 -1
View File
@@ -284,7 +284,7 @@
"Additional audio function(s) to be added": "Ulteriori funzioni audio da aggiungere",
"Additional media/GPU GIDs, comma-separated": "GID aggiuntivi per dispositivi multimediali/GPU, separati da virgole",
"Additional paths for": "Percorsi aggiuntivi per",
"Address BookStack will be opened at (for example http://192.168.1.50:6875)": "Indirizzo a cui verrà aperto BookStack (ad esempio http://192.168.1.50:6875)",
"Address BookStack will be opened at (for example http://192.168.1.50)": "Indirizzo a cui verrà aperto BookStack (ad esempio http://192.168.1.50)",
"Address Healthchecks will be opened at (for example http://192.168.1.50:8000)": "Indirizzo a cui verrà aperto Healthchecks (ad esempio http://192.168.1.50:8000)",
"Address Speedtest Tracker will be opened at (for example http://192.168.1.50)": "Indirizzo a cui verrà aperto Speedtest Tracker (ad esempio http://192.168.1.50)",
"Address of Redis or Valkey (for example redis://192.168.1.20:6379/1)": "Indirizzo di Redis o Valkey (ad esempio redis://192.168.1.20:6379/1)",
@@ -3593,6 +3593,7 @@
"Manyfold is an open source, self-hosted web application for managing a collection of 3D models, particularly focused on 3D printing.": "Manyfold è un'applicazione web open source e self-hosted per gestire collezioni di modelli 3D, in particolare per la stampa 3D.",
"Mapped GID on host": "GID mappato sull'host",
"Mapped UID on host": "UID mappato sull'host",
"MariaDB has no web interface: applications and clients connect to port 3306 of this container.": "MariaDB non ha un'interfaccia web: le applicazioni e i client si connettono alla porta 3306 di questo container.",
"Mariadb is one of the most popular database servers. Made by the original developers of MySQL.": "MariaDB è uno dei server di database più diffusi, creato dagli sviluppatori originali di MySQL.",
"Mark the VMs/CTs to include in this job:": "Spunta le VM/CT da includere in questo job:",
"Mastodon is a free, open-source social network server based on ActivityPub where users can follow friends and discover new ones..": "Mastodon è un server di social network gratuito e open source basato su ActivityPub, dove puoi seguire i tuoi amici e conoscere nuove persone.",
@@ -6514,6 +6515,7 @@
"The restored service did not pass its health check": "Il servizio ripristinato non ha superato il controllo dello stato",
"The restored service stopped; the recovery is not confirmed": "Il servizio ripristinato si è arrestato; il recupero non è confermato",
"The reviewed Tandoor stack does not require a privileged LXC.": "Lo stack Tandoor esaminato non richiede un container LXC privilegiato.",
"The root password is asked for when connecting from another machine. Inside the container, the mariadb command opens as root without a password.": "La password di root viene richiesta quando ci si connette da un'altra macchina. All'interno del container, il comando mariadb si apre come root senza password.",
"The rootfs capture only belongs to the running installation": "L'acquisizione della rootfs appartiene solo all'installazione in esecuzione",
"The rootfs is not managed by Proxmox": "Il rootfs non è gestito da Proxmox",
"The rootfs is not mounted": "Il rootfs non è montato",
+3 -1
View File
@@ -284,7 +284,7 @@
"Additional audio function(s) to be added": "Funções de áudio adicionais a serem adicionadas",
"Additional media/GPU GIDs, comma-separated": "GIDs adicionais/GPU, separados por vírgulas",
"Additional paths for": "Caminhos adicionais para",
"Address BookStack will be opened at (for example http://192.168.1.50:6875)": "Endereço em que o BookStack será aberto (por exemplo http://192.168.1.50:6875)",
"Address BookStack will be opened at (for example http://192.168.1.50)": "Endereço em que o BookStack será aberto (por exemplo http://192.168.1.50)",
"Address Healthchecks will be opened at (for example http://192.168.1.50:8000)": "Endereço em que o Healthchecks será aberto (por exemplo http://192.168.1.50:8000)",
"Address Speedtest Tracker will be opened at (for example http://192.168.1.50)": "Endereço em que o Speedtest Tracker será aberto (por exemplo http://192.168.1.50)",
"Address of Redis or Valkey (for example redis://192.168.1.20:6379/1)": "Endereço do Redis ou Valkey (por exemplo redis://192.168.1.20:6379/1)",
@@ -3593,6 +3593,7 @@
"Manyfold is an open source, self-hosted web application for managing a collection of 3D models, particularly focused on 3D printing.": "Manyfold é um aplicativo web de código aberto e auto-hospedado para gerenciar uma coleção de modelos 3D, particularmente focado na impressão 3D.",
"Mapped GID on host": "GID mapeado no host",
"Mapped UID on host": "UID mapeado no host",
"MariaDB has no web interface: applications and clients connect to port 3306 of this container.": "O MariaDB não tem interface web: os aplicativos e os clientes se conectam à porta 3306 deste contêiner.",
"Mariadb is one of the most popular database servers. Made by the original developers of MySQL.": "Mariadb é um dos servidores de banco de dados mais populares. Feito pelos desenvolvedores originais do MySQL.",
"Mark the VMs/CTs to include in this job:": "Marque as VMs/CTs a serem incluídas neste trabalho:",
"Mastodon is a free, open-source social network server based on ActivityPub where users can follow friends and discover new ones..": "Mastodon é um servidor de rede social livre e de código aberto baseado no ActivityPub onde os usuários podem seguir amigos e descobrir novos..",
@@ -6514,6 +6515,7 @@
"The restored service did not pass its health check": "O serviço restaurado não passou no seu exame de saúde",
"The restored service stopped; the recovery is not confirmed": "O serviço restaurado parou; a recuperação não é confirmada",
"The reviewed Tandoor stack does not require a privileged LXC.": "A pilha Tandoor revisada não require um LXC privilegiado.",
"The root password is asked for when connecting from another machine. Inside the container, the mariadb command opens as root without a password.": "A senha de root é solicitada ao conectar a partir de outra máquina. Dentro do contêiner, o comando mariadb abre como root sem senha.",
"The rootfs capture only belongs to the running installation": "A captura do rootfs só pertence à instalação em execução",
"The rootfs is not managed by Proxmox": "O rootfs não é gerido pelo Proxmox",
"The rootfs is not mounted": "O rootfs não está montado",
+3 -1
View File
@@ -284,7 +284,7 @@
"Additional audio function(s) to be added": "Pridajú sa ďalšie audio funkcie",
"Additional media/GPU GIDs, comma-separated": "Dodatočné médiá/GPU GID, čiarovo oddelené",
"Additional paths for": "Ďalšie cesty pre",
"Address BookStack will be opened at (for example http://192.168.1.50:6875)": "Adresa, na ktorej sa bude BookStack otvárať (napríklad http://192.168.1.50:6875)",
"Address BookStack will be opened at (for example http://192.168.1.50)": "Adresa, na ktorej sa bude BookStack otvárať (napríklad http://192.168.1.50)",
"Address Healthchecks will be opened at (for example http://192.168.1.50:8000)": "Adresa, na ktorej sa bude Healthchecks otvárať (napríklad http://192.168.1.50:8000)",
"Address Speedtest Tracker will be opened at (for example http://192.168.1.50)": "Adresa, na ktorej sa bude Speedtest Tracker otvárať (napríklad http://192.168.1.50)",
"Address of Redis or Valkey (for example redis://192.168.1.20:6379/1)": "Adresa Redis alebo Valkey (napríklad redis://192.168.1.20:6379/1)",
@@ -3593,6 +3593,7 @@
"Manyfold is an open source, self-hosted web application for managing a collection of 3D models, particularly focused on 3D printing.": "Manyfold je open source, self-hosted webová aplikácia pre správu kolekcie 3D modelov, najmä zamerané na 3D tlač.",
"Mapped GID on host": "GID namapované na serveri",
"Mapped UID on host": "UID namapované na serveri",
"MariaDB has no web interface: applications and clients connect to port 3306 of this container.": "MariaDB nemá webové rozhranie: aplikácie a klienti sa pripájajú na port 3306 tohto kontajnera.",
"Mariadb is one of the most popular database servers. Made by the original developers of MySQL.": "Mariadb je jedným z najpopulárnejších databázových serverov. Vyrobený pôvodnými vývojármi MySQL.",
"Mark the VMs/CTs to include in this job:": "Označte VM/CT, ktoré chcete zahrnúť do tejto úlohy:",
"Mastodon is a free, open-source social network server based on ActivityPub where users can follow friends and discover new ones..": "Mastodon je bezplatný, open-source server sociálnej siete založený na ActivityPub, kde používatelia môžu sledovať priateľov a objavovať nové..",
@@ -6513,6 +6514,7 @@
"The restored service did not pass its health check": "Obnovená služba neprešla kontrolou stavu",
"The restored service stopped; the recovery is not confirmed": "Obnovená služba sa zastavila; obnova sa nepotvrdila",
"The reviewed Tandoor stack does not require a privileged LXC.": "Preskúmaný Tandoor stack require privilegovaný LXC.",
"The root password is asked for when connecting from another machine. Inside the container, the mariadb command opens as root without a password.": "Heslo používateľa root sa vyžaduje pri pripojení z iného počítača. Vnútri kontajnera sa príkaz mariadb otvorí ako root bez hesla.",
"The rootfs capture only belongs to the running installation": "Rootfs capture patrí len do bežiacej inštalácie",
"The rootfs is not managed by Proxmox": "Rootfs nie je riadený Proxmox",
"The rootfs is not mounted": "Korene nie sú namontované",
+3 -1
View File
@@ -284,7 +284,7 @@
"Additional audio function(s) to be added": "Ytterligare ljudfunktion(er) ska läggas till",
"Additional media/GPU GIDs, comma-separated": "Ytterligare media/GPU GID, comma-separerade",
"Additional paths for": "Ytterligare vägar för",
"Address BookStack will be opened at (for example http://192.168.1.50:6875)": "Adress som BookStack öppnas på (till exempel http://192.168.1.50:6875)",
"Address BookStack will be opened at (for example http://192.168.1.50)": "Adress som BookStack öppnas på (till exempel http://192.168.1.50)",
"Address Healthchecks will be opened at (for example http://192.168.1.50:8000)": "Adress som Healthchecks öppnas på (till exempel http://192.168.1.50:8000)",
"Address Speedtest Tracker will be opened at (for example http://192.168.1.50)": "Adress som Speedtest Tracker öppnas på (till exempel http://192.168.1.50)",
"Address of Redis or Valkey (for example redis://192.168.1.20:6379/1)": "Redis eller Valkeys adress (till exempel redis://192.168.1.20:6379/1)",
@@ -3593,6 +3593,7 @@
"Manyfold is an open source, self-hosted web application for managing a collection of 3D models, particularly focused on 3D printing.": "Manyfold är en öppen källkod, självvärd webbapplikation för att hantera en samling av 3D-modeller, särskilt fokuserad på 3D-utskrift.",
"Mapped GID on host": "Mappad GID på värd",
"Mapped UID on host": "Mappat UID på värd",
"MariaDB has no web interface: applications and clients connect to port 3306 of this container.": "MariaDB har inget webbgränssnitt: applikationer och klienter ansluter till port 3306 på den här behållaren.",
"Mariadb is one of the most popular database servers. Made by the original developers of MySQL.": "Mariadb är en av de mest populära databasservrarna. Tillverkad av de ursprungliga utvecklarna av MySQL.",
"Mark the VMs/CTs to include in this job:": "Markera de virtuella datorerna/CT:erna som ska inkluderas i det här jobbet:",
"Mastodon is a free, open-source social network server based on ActivityPub where users can follow friends and discover new ones..": "Mastodon är en gratis, öppen källkod sociala nätverksserver baserad på ActivityPub där användare kan följa vänner och upptäcka nya.",
@@ -6514,6 +6515,7 @@
"The restored service did not pass its health check": "Den återställda tjänsten passerade inte hälsokontrollen",
"The restored service stopped; the recovery is not confirmed": "Den återställda tjänsten stannade; återhämtningen bekräftas inte",
"The reviewed Tandoor stack does not require a privileged LXC.": "Den granskade Tandoor stack inte require en privilegierad LXC.",
"The root password is asked for when connecting from another machine. Inside the container, the mariadb command opens as root without a password.": "Root-lösenordet efterfrågas vid anslutning från en annan dator. Inne i behållaren öppnas kommandot mariadb som root utan lösenord.",
"The rootfs capture only belongs to the running installation": "Rotfarna fångar bara tillhör den löpande installationen",
"The rootfs is not managed by Proxmox": "Rötterna hanteras inte av Proxmox",
"The rootfs is not mounted": "Rötterna är inte monterade",
+2 -1
View File
@@ -117,7 +117,8 @@
},
"environment_overrides": {
"APP_URL": {
"prompt": "Address BookStack will be opened at (for example http://192.168.1.50:6875)"
"prompt": "Address BookStack will be opened at (for example http://192.168.1.50)",
"container_address": "http://@CONTAINER_ADDRESS@"
},
"DB_HOST": {
"prompt": "Address of the MariaDB server"
+8
View File
@@ -1,4 +1,12 @@
{
"catalog_ui": {
"launch": {
"port": null
}
},
"first_run": {
"endpoints": []
},
"proxmox": {
"installer_profile": {
"volume_preparations": [
+2 -1
View File
@@ -16,7 +16,8 @@
"required": false
},
"SITE_ROOT": {
"prompt": "Address Healthchecks will be opened at (for example http://192.168.1.50:8000)"
"prompt": "Address Healthchecks will be opened at (for example http://192.168.1.50:8000)",
"container_address": "http://@CONTAINER_ADDRESS@:8000"
},
"SITE_NAME": {
"example": "Healthchecks"
+12
View File
@@ -1,6 +1,18 @@
{
"catalog_ui": {
"launch": {
"port": null
}
},
"first_run": {
"endpoints": []
},
"proxmox": {
"installer_profile": {
"completion_notes": [
"MariaDB has no web interface: applications and clients connect to port 3306 of this container.",
"The root password is asked for when connecting from another machine. Inside the container, the mariadb command opens as root without a password."
],
"volume_preparations": [
{
"container_path": "/config",
+2 -1
View File
@@ -215,7 +215,8 @@
"prompt": "Email address of the admin account"
},
"ALLOWED_HOST": {
"prompt": "Name or address NetBox will be opened at (for example netbox.example.com)"
"prompt": "Name or address NetBox will be opened at (for example netbox.example.com)",
"container_address": "@CONTAINER_ADDRESS@"
},
"DB_HOST": {
"prompt": "Address of the PostgreSQL server"
+8
View File
@@ -1,4 +1,12 @@
{
"catalog_ui": {
"launch": {
"port": null
}
},
"first_run": {
"endpoints": []
},
"proxmox": {
"installer_profile": {
"volume_preparations": [
+8
View File
@@ -1,4 +1,12 @@
{
"catalog_ui": {
"launch": {
"port": null
}
},
"first_run": {
"endpoints": []
},
"proxmox": {
"installer_profile": {
"volume_preparations": [
+2 -1
View File
@@ -142,7 +142,8 @@
},
"environment_overrides": {
"APP_URL": {
"prompt": "Address Speedtest Tracker will be opened at (for example http://192.168.1.50)"
"prompt": "Address Speedtest Tracker will be opened at (for example http://192.168.1.50)",
"container_address": "http://@CONTAINER_ADDRESS@"
},
"SPEEDTEST_SCHEDULE": {
"example": "0 */6 * * *",
+8
View File
@@ -1,4 +1,12 @@
{
"catalog_ui": {
"launch": {
"port": null
}
},
"first_run": {
"endpoints": []
},
"proxmox": {
"installer_profile": {
"volume_preparations": [
+64 -2
View File
@@ -2060,6 +2060,60 @@ CONSOLE_STATE=$(python3 "${SCRIPT_DIR}/oci_console.py" configure "$VMID") \
|| die "$(translate "The console of the container could not be configured")"
RUNTIME_CONSOLE_LOG=$(jq -r '.log' <<<"$CONSOLE_STATE")
# A variable that holds the application's own address takes the one of the
# container: the fixed one, or the one DHCP gives it. That one is asked for
# with the application not started yet, so that its first start already has it.
OWN_ADDRESS_MARK='@CONTAINER_ADDRESS@'
OWN_ADDRESS=""
apply_own_address() {
local names name value original inert="" candidate attempt
names=$(jq -r --arg mark "$OWN_ADDRESS_MARK" \
'.environment[]? | select(.value | contains($mark)) | .name' "$DEPLOYMENT_FILE")
[[ -n $names ]] || return 0
msg_info "$(translate "Waiting for the network address...")"
if [[ -n ${HOST_MONITOR:-} ]]; then
OWN_ADDRESS=$HOST_MONITOR_IP
elif [[ $IPV4 != dhcp ]]; then
OWN_ADDRESS=${IPV4%%/*}
else
mount_ct_rootfs
for candidate in /bin/sleep /usr/bin/sleep; do
if [[ -e /var/lib/lxc/${VMID}/rootfs${candidate} || -L /var/lib/lxc/${VMID}/rootfs${candidate} ]]; then
inert=$candidate
break
fi
done
oci_quiet pct unmount "$VMID" || true
if [[ -z $inert ]]; then
oci_log "The image has no sleep command to wait for the DHCP address with"
die "$(translate "No LAN address was obtained")"
fi
original=$(pct config "$VMID" | sed -n 's/^entrypoint: //p')
oci_quiet pct set "$VMID" --entrypoint "$inert 120" \
|| die "$(translate "Could not set the container entrypoint")"
oci_quiet pct start "$VMID" || die "$(translate "The container could not be started:") CT $VMID"
for attempt in $(seq 1 60); do
OWN_ADDRESS=$(own_ipv4 2>/dev/null) \
|| OWN_ADDRESS=$(lxc-info -n "$VMID" -iH 2>/dev/null | grep -m1 -E '^[0-9]+\.' || true)
[[ -n $OWN_ADDRESS ]] && break
sleep 0.5
done
oci_quiet pct stop "$VMID" || die "$(translate "The container could not be started:") CT $VMID"
if [[ -n $original ]]; then
oci_quiet pct set "$VMID" --entrypoint "$original" || die "$(translate "Could not set the container entrypoint")"
else
oci_quiet pct set "$VMID" --delete entrypoint || die "$(translate "Could not set the container entrypoint")"
fi
fi
[[ $OWN_ADDRESS =~ ^[0-9]+(\.[0-9]+){3}$ ]] || die "$(translate "No LAN address was obtained")"
while IFS= read -r name; do
value=$(jq -r --arg name "$name" '[.environment[] | select(.name == $name) | .value] | last' "$DEPLOYMENT_FILE")
set_runtime_environment "$name" "${value//$OWN_ADDRESS_MARK/$OWN_ADDRESS}"
done <<<"$names"
msg_ok "$(translate "IP address:") $OWN_ADDRESS"
}
apply_own_address
oci_log "Configuration created for CT $VMID"
PASSWORD_STATE=""
if [[ $START_AFTER == 1 ]]; then
@@ -2279,6 +2333,13 @@ URLS=$(jq -c --arg ip "$IP" '
}]
else [] end
' "$TEMPLATE_FILE")
# An application with no web address is reached on the ports of its container.
SERVICE_ADDRESSES=$(jq -c --arg ip "$IP" '
if $ip == "" or ((.first_run.endpoints? // []) | length) > 0 or .catalog_ui.launch.port != null then []
else [.container_contract.ports[]?
| $ip + ":" + (.container_port | tostring) + (if .protocol == "udp" then "/udp" else "" end)]
end
' "$TEMPLATE_FILE")
if [[ -n $HAOS_URLS ]]; then
URLS=$HAOS_URLS
elif [[ $HAOS_HEALTHCHECK != 0 ]]; then
@@ -2293,7 +2354,7 @@ elif [[ $START_AFTER == 1 ]]; then
check_native_device_permissions
fi
if [[ -z ${PROXMENUX_OCI_TRANSACTION:-} ]] && ! oci_quiet python3 "${SCRIPT_DIR}/oci_instances.py" complete "$VMID" \
--archive "$ARCHIVE_PATH" --digest "$DIGEST"; then
--archive "$ARCHIVE_PATH" --digest "$DIGEST" ${OWN_ADDRESS:+--address "$OWN_ADDRESS"}; then
msg_warn "$(translate "Container installed, but without a verifiable record for future updates.")"
fi
# The template's notes, then those of this installation's choices, such as the
@@ -2309,9 +2370,10 @@ RESULT=$(jq -cn \
--arg image_created "$CREATED" \
--arg ip "$IP" \
--argjson urls "$URLS" \
--argjson service_addresses "$SERVICE_ADDRESSES" \
--argjson credentials "$CREDENTIALS" \
--argjson completion_notes "$COMPLETION_NOTES" \
--argjson vmid "$VMID" \
--arg log "${OCI_LOG:-}" \
'{app_id:$app_id,vmid:$vmid,image:$image,digest:$digest,architecture:$architecture,image_version:$image_version,image_created:$image_created,ip:$ip,urls:$urls,credentials:$credentials,completion_notes:$completion_notes,log:$log}')
'{app_id:$app_id,vmid:$vmid,image:$image,digest:$digest,architecture:$architecture,image_version:$image_version,image_created:$image_created,ip:$ip,urls:$urls,service_addresses:$service_addresses,credentials:$credentials,completion_notes:$completion_notes,log:$log}')
printf 'PROXMENUX_RESULT=%s\n' "$(printf '%s' "$RESULT" | base64 | tr -d '\n')"
+7
View File
@@ -87,6 +87,13 @@ def render(template, digest, instance_id, ip=''):
item = f'&#127760; {link(str(endpoint.get("label") or "Web UI"), url)}: {link(url, url)}'
if item:
access.append(item)
if not access:
# With no web address, the application is reached on its ports.
for item in (template.get('container_contract') or {}).get('ports') or []:
port = item.get('container_port')
if isinstance(port, int) and 1 <= port <= 65535:
protocol = '/udp' if item.get('protocol') == 'udp' else ''
access.append(f'&#128268; <code>{address}:{port}{protocol}</code>')
badges = (
('Docs', DOCS, 'https://img.shields.io/badge/%F0%9F%93%9A_Docs-blue'),
('Code', CODE, 'https://img.shields.io/badge/%F0%9F%92%BB_Code-green'),
+10 -2
View File
@@ -24,6 +24,8 @@ from oci_ui import translate, msg_error, msg_ok
ROOT = Path('/usr/local/share/proxmenux/oci/instances')
MARKER = 'proxmenux-instance='
# Stands for the address of the container until the installation knows it.
ADDRESS_MARK = '@CONTAINER_ADDRESS@'
ACTIVE = {'installing', 'assembling', 'updating', 'recovering'}
@@ -160,8 +162,13 @@ def observe(vmid, installation_id, archive, digest, image=None):
return observed
def finish(root, vmid, archive, digest):
def finish(root, vmid, archive, digest, address=None):
value = read(root, vmid)
if address:
# The address the installation gave the application is recorded as the
# value it is, the way one typed by the user would be.
for item in value['deployment'].get('environment', []):
item['value'] = item['value'].replace(ADDRESS_MARK, address)
observed = observe(vmid, value['installation_id'], archive, digest)
value.update(status='assembling' if value['deployment'].get('stack_managed') else 'installed',
completed_at=now(), observed=observed)
@@ -356,6 +363,7 @@ def main():
if action == 'complete':
p.add_argument('--archive', required=True)
p.add_argument('--digest', required=True)
p.add_argument('--address')
sub.add_parser('reconcile')
resume = sub.add_parser('resume-stack-recording')
resume.add_argument('vmid', type=int)
@@ -372,7 +380,7 @@ def main():
json.loads(Path(args.deployment).read_text()))
print(value['installation_id'])
elif args.action == 'complete':
finish(args.root, args.vmid, args.archive, args.digest)
finish(args.root, args.vmid, args.archive, args.digest, args.address)
elif args.action == 'failed':
value = read(args.root, args.vmid)
value.update(status='failed', failed_at=now())
+1
View File
@@ -133,6 +133,7 @@
"example": {"type": ["string", "null"]},
"prompt": {"type": "string", "minLength": 1},
"prompt_user": {"type": "boolean"},
"container_address": {"type": "string", "pattern": "@CONTAINER_ADDRESS@"},
"required": {"type": "boolean"},
"sensitive": {"type": "boolean"},
"source": {"type": "string"}
+6 -1
View File
@@ -17,7 +17,9 @@ from .converter import ConversionError
from .github_source import SourceError
from .i18n import N_, source_text, translate
from .installer import (
ADDRESS_SHOWN,
ADVANCED_MODE,
CONTAINER_ADDRESS,
DEFAULT_MODE,
InstallError,
build_deployment,
@@ -345,7 +347,8 @@ def _deployment_summary_text(template: dict[str, Any], deployment: dict[str, Any
translate("unprivileged LXC") if security.get("unprivileged") else translate("privileged LXC"))
environment = plan.get("environment", [])
if environment:
row(translate("Variables"), ", ".join(f"{item['name']}={item['value']}" for item in environment))
row(translate("Variables"), ", ".join(
f"{item['name']}={item['value'].replace(CONTAINER_ADDRESS, ADDRESS_SHOWN)}" for item in environment))
if plan.get("suite_arr"):
lines += ["", *[translate(note) for note in plan.get("completion_notes", [])]]
lines.append("")
@@ -367,6 +370,8 @@ def _print_installation_summary(result: dict[str, Any], images_removed: str | No
console.msg_ok(f"{translate(STACK_LABELS.get(name, name))}: CT {vmid}")
for item in result.get("urls") or []:
console.msg_ok(f"{translate(item['label'])}: {item['url']}")
for address in result.get("service_addresses") or []:
console.msg_ok(f"{translate('Service:')} {address}")
for item in result.get("credentials") or []:
console.msg_info2(translate(item["label"]))
username = str(item["username"])
+23
View File
@@ -31,6 +31,20 @@ DEFAULT_MODE = "default"
ADVANCED_MODE = "advanced"
# Stands for the address of the container in a variable that holds the
# application's own address, until the installation knows it.
CONTAINER_ADDRESS = "@CONTAINER_ADDRESS@"
ADDRESS_SHOWN = "<IP>"
def own_address_value(pattern: str, ipv4: str) -> str:
"""The application's own address for a container with this network: the
fixed address when it has one, its mark when DHCP is still to give it."""
if ipv4 in ("dhcp", "host"):
return pattern
return pattern.replace(CONTAINER_ADDRESS, ipv4.split("/", 1)[0])
def _example_default(name: str, example: str | None, timezone: str | None = None) -> str | None:
defaults = {"PUID": "1000", "PGID": "1000", "TZ": timezone or host.timezone()}
if name in defaults:
@@ -584,6 +598,15 @@ def build_deployment(
label = f"{translate('Value for')} {name}"
default = _example_default(name, item["example"], timezone)
generated = installer_profile.get("generated_sensitive_environment", {}).get(name)
if item.get("container_address"):
# The address of the container is known or will be: nothing to ask.
# The advanced installation shows it, for a name or a proxy in front.
value = own_address_value(item["container_address"], ipv4)
if advanced:
shown = ui.ask(label, value.replace(CONTAINER_ADDRESS, ADDRESS_SHOWN), required=True)
value = shown.replace(ADDRESS_SHOWN, CONTAINER_ADDRESS)
environment.append({"name": name, "value": value, "sensitive": False})
continue
if item.get("prompt_user", True) is False:
if default in (None, "") and item["required"]:
raise InstallError(f"{name}: {translate('no declarative value')}")
+84
View File
@@ -0,0 +1,84 @@
"""An application that has to be told its own address is not asked for it: the
container has one, fixed or given by DHCP, and the installation uses that."""
import json
from pathlib import Path
import sys
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
sys.path.insert(0, str(ROOT / "remote"))
from proxmenux_oci.catalog import Catalog
from proxmenux_oci.installer import CONTAINER_ADDRESS, own_address_value
import oci_instances
# Application, its variable and the value it takes at 192.168.1.50.
OWN_ADDRESS = {
"bookstack": ("APP_URL", "http://192.168.1.50"),
"healthchecks": ("SITE_ROOT", "http://192.168.1.50:8000"),
"speedtest-tracker": ("APP_URL", "http://192.168.1.50"),
"netbox": ("ALLOWED_HOST", "192.168.1.50"),
}
class OwnAddressTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
catalog = Catalog(ROOT)
cls.variables = {}
for app, (name, _) in OWN_ADDRESS.items():
environment = catalog.compose(app)["container_contract"]["environment"]
cls.variables[app] = next(item for item in environment if item["name"] == name)
def test_a_fixed_address_is_used_as_it_is(self):
for app, (_, expected) in OWN_ADDRESS.items():
with self.subTest(app=app):
pattern = self.variables[app]["container_address"]
self.assertEqual(own_address_value(pattern, "192.168.1.50/24"), expected)
def test_dhcp_leaves_the_mark_for_the_installation(self):
for app in OWN_ADDRESS:
with self.subTest(app=app):
pattern = self.variables[app]["container_address"]
self.assertIn(CONTAINER_ADDRESS, own_address_value(pattern, "dhcp"))
def test_the_address_is_that_of_the_port_the_application_listens_on(self):
catalog = Catalog(ROOT)
for app, (_, expected) in OWN_ADDRESS.items():
with self.subTest(app=app):
endpoint = catalog.compose(app)["first_run"]["endpoints"][0]
if not expected.startswith("http"):
continue # A bare address names the machine, not a port.
port = "" if endpoint["port"] == 80 else f":{endpoint['port']}"
self.assertEqual(expected, f"{endpoint['scheme']}://192.168.1.50{port}")
def test_the_installer_resolves_the_mark_before_the_first_start(self):
script = (ROOT / "remote/install_oci.sh").read_text()
self.assertIn(f"OWN_ADDRESS_MARK='{CONTAINER_ADDRESS}'", script)
self.assertLess(script.index("\napply_own_address\n"), script.index('msg_info "$(translate "Starting the container...")"'))
self.assertEqual(oci_instances.ADDRESS_MARK, CONTAINER_ADDRESS)
def test_the_record_keeps_the_address_and_not_the_mark(self):
with tempfile.TemporaryDirectory() as folder:
root = Path(folder)
record = {"schema_version": 1, "vmid": 900, "installation_id": "00000000-0000-4000-8000-000000000000", "status": "installing",
"deployment": {"environment": [{"name": "APP_URL", "value": f"http://{CONTAINER_ADDRESS}"},
{"name": "TZ", "value": "Europe/Madrid"}]}}
path = oci_instances.location(root, 900)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(record))
observe, oci_instances.observe = oci_instances.observe, lambda *args, **kwargs: {"config": ""}
try:
oci_instances.finish(root, 900, "archive", "digest", "192.168.1.50")
finally:
oci_instances.observe = observe
saved = json.loads(path.read_text())["deployment"]["environment"]
self.assertEqual(saved[0]["value"], "http://192.168.1.50")
self.assertEqual(saved[1]["value"], "Europe/Madrid")
if __name__ == "__main__":
unittest.main()
+78
View File
@@ -0,0 +1,78 @@
"""A port that does not speak HTTP is not announced as a web address: the link
would open nothing, and an update would wait on it for an answer that never
comes and undo itself."""
from pathlib import Path
import sys
import unittest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
sys.path.insert(0, str(ROOT / "remote"))
from proxmenux_oci.catalog import Catalog
import oci_description
import oci_instance_transaction
# Application and the port its recipe was generated with as web address.
SERVICES = {
"mariadb": 3306,
"ngircd": 6667,
"openssh-server": 2222,
"syslog-ng": 6601,
"faster-whisper": 10300,
"piper": 10200,
}
class ServicePortTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
catalog = Catalog(ROOT)
cls.templates = {app: catalog.compose(app) for app in SERVICES}
def test_no_web_address_is_announced(self):
for app, template in self.templates.items():
with self.subTest(app=app):
self.assertEqual(template["first_run"]["endpoints"], [])
self.assertIsNone(template["catalog_ui"]["launch"]["port"])
def test_the_port_is_still_the_one_of_the_container(self):
for app, port in SERVICES.items():
with self.subTest(app=app):
ports = [item["container_port"] for item in self.templates[app]["container_contract"]["ports"]]
self.assertIn(port, ports)
def test_an_update_checks_that_the_container_keeps_running(self):
for app, template in self.templates.items():
with self.subTest(app=app):
self.assertEqual(oci_instance_transaction.effective_healthcheck(template)["type"], "running")
def test_the_notes_give_the_address_as_text(self):
notes = oci_description.render(self.templates["mariadb"], "sha256:" + "0" * 64, "instance", "192.168.1.50")
self.assertIn("<code>192.168.1.50:3306</code>", notes)
self.assertNotIn("Web UI", notes)
self.assertNotIn("http://192.168.1.50", notes)
notes = oci_description.render(self.templates["syslog-ng"], "sha256:" + "0" * 64, "instance", "192.168.1.50")
self.assertIn("<code>192.168.1.50:5514/udp</code>", notes)
def test_a_web_application_keeps_its_link_in_the_notes(self):
template = Catalog(ROOT).compose("bookstack")
notes = oci_description.render(template, "sha256:" + "0" * 64, "instance", "192.168.1.50")
self.assertIn("http://192.168.1.50:80/", notes)
self.assertNotIn("<code>192.168.1.50:", notes)
def test_the_summary_gives_the_address_of_a_service_only(self):
script = (ROOT / "remote/install_oci.sh").read_text()
self.assertIn("service_addresses:$service_addresses", script)
cli = (ROOT / "src/proxmenux_oci/cli.py").read_text()
self.assertIn("translate('Service:')", cli)
def test_mariadb_says_how_to_connect(self):
notes = self.templates["mariadb"]["proxmox"]["installer_profile"]["completion_notes"]
self.assertTrue(any("port 3306" in note for note in notes))
self.assertTrue(any("without a password" in note for note in notes))
if __name__ == "__main__":
unittest.main()