feat(oci): support custom Compose workers and migration jobs

This commit is contained in:
Jonatan
2026-10-28 08:41:41 +01:00
parent b383fb9311
commit 3a0a7eaf14
44 changed files with 3267 additions and 110 deletions
@@ -29,6 +29,9 @@ TRANSACTION = ROOT / 'oci/remote/oci_stack_transaction.py'
def extracted(path, name, scope):
if name == 'remove':
# Operational cache cleanup is exercised by the engine tests; keep wording tests inert.
scope.setdefault('compose_jobs', SimpleNamespace(forget_completed=lambda *args: None))
node = next(n for n in ast.parse(path.read_text()).body
if isinstance(n, ast.FunctionDef) and n.name == name)
exec(compile(ast.Module(body=[node], type_ignores=[]), str(path), 'exec'), scope)
@@ -35,6 +35,9 @@ KEYS = (PREVIEW, CONFIRM, RESULT, BRIDGE, BRIDGE_FAILURE, SUCCESS, FIREWALL, POR
def extract(path, name, scope):
if name == 'remove':
# Operational cache cleanup is exercised by the engine tests; keep wording tests inert.
scope.setdefault('compose_jobs', SimpleNamespace(forget_completed=lambda *args: None))
node = next(n for n in ast.parse(path.read_text()).body if isinstance(n, ast.FunctionDef) and n.name == name)
exec(compile(ast.Module(body=[node], type_ignores=[]), str(path), 'exec'), scope)
return scope[name]
@@ -1,9 +1,13 @@
"""The watchdog of an OCI application is reachable from the Monitor and worded in every language."""
import ast
import copy
import json
from pathlib import Path
import re
from unittest import TestCase
from unittest.mock import Mock
from types import SimpleNamespace
from typing import Any
ROOT = Path(__file__).resolve().parents[3]
LOCALES = ('en', 'es', 'de', 'fr', 'it', 'pt', 'sk', 'sv')
@@ -65,9 +69,45 @@ class OciWatchdogInstaller(TestCase):
self.assertLess(install.index('deployment = build_deployment(candidate, wizard, mode)'), asked)
self.assertLess(asked, install.index('approved = wizard.review('))
self.assertLess(install.index('watchdog = bool(deployment.pop("watchdog", False))'), install.index('run_remote_install('))
self.assertIn('set_watchdog(PROJECT_ROOT, sorted(vmids), True)', install)
self._assert_jobs_are_excluded_from_installed_watchdog()
self.assertIn('row(translate("Watchdog"), _yes_no(plan["watchdog"]))', self.CLI)
def _assert_jobs_are_excluded_from_installed_watchdog(self):
node = next(item for item in ast.parse(self.CLI).body
if isinstance(item, ast.FunctionDef) and item.name == 'install_template')
class InertImports(ast.NodeTransformer):
def visit_ImportFrom(self, item):
return None
node = InertImports().visit(node)
deployment = {'services': [
{'name': 'migration', 'deployment': {'execution_mode': 'one-shot'}},
{'name': 'database', 'deployment': {}},
{'name': 'worker', 'deployment': {}}]}
result = {'vmid': 4800, 'stack_vmids': {'web': 4800, 'migration': 4801,
'database': 4802, 'worker': 4803}}
set_watchdog = Mock(return_value=True)
class UI:
def confirm(self, *args, **kwargs): return True
def review(self, *args, **kwargs): return True
def close(self): pass
def install(project, template, plan, target):
self.assertNotIn('watchdog', plan)
return result
scope = {'Any': Any, 'copy': copy, 'BacktrackUI': lambda ui: ui,
'RestartWizard': RuntimeError, 'InstallError': ValueError,
'build_deployment': lambda *args: copy.deepcopy(deployment),
'watchdog_default': lambda *args: False,
'translate': lambda value: value, '_deployment_summary_text': lambda *args: '',
'PROJECT_ROOT': Path('/inert'), 'source_text': lambda value: 'test', 'APP_TITLE': 'test',
'console': SimpleNamespace(**{name: lambda *args: None for name in
('show_logo', 'msg_title', 'msg_error', 'msg_ok', 'msg_warn', 'wait_for_enter')}),
'run_remote_install': install, 'carry_records': lambda *args, **kwargs: None,
'images': SimpleNamespace(offer_removal=lambda *args: (False, None)),
'_print_installation_summary': lambda *args: None, 'set_watchdog': set_watchdog}
exec(compile(ast.fix_missing_locations(ast.Module(body=[node], type_ignores=[])), 'cli.py', 'exec'), scope)
scope['install_template'](UI(), {'catalog_ui': {'title': {'en_US': 'test'}}}, 'test', 'advanced')
set_watchdog.assert_called_once_with(Path('/inert'), [4800, 4802, 4803], True)
def test_both_management_menus_offer_it(self):
self.assertEqual(self.MENU.count("('watchdog', "), 2)
self.assertEqual(self.MENU.count("if action == 'watchdog':"), 2)
+4 -1
View File
@@ -85,7 +85,10 @@ class OciWordingTests(TestCase):
template = function(CUSTOM, 'template_from_compose',
_services=lambda doc: doc['services'], _template_from_stack=stack)
self.assertIs(template(json.dumps(document)), converted)
stack.assert_called_once_with(document, None)
stack.assert_called_once()
self.assertEqual(stack.call_args.args, (document, None))
self.assertFalse(stack.call_args.kwargs['interpolated'])
self.assertIsNone(stack.call_args.kwargs.get('image_entrypoints'))
def test_empty_paste_is_specific_and_nonempty_unchanged(self):
for title, expected in ((None, 'No Compose file was given'),
+11
View File
@@ -3582,6 +3582,7 @@
"Invalid device path:": "Ungültiger Gerätepfad:",
"Invalid device paths for": "Ungültige Gerätepfade für",
"Invalid device permissions": "Ungültige Geräteberechtigungen",
"Invalid environment variable name:": "Ungültiger Name der Umgebungsvariable:",
"Invalid group name. Use letters, digits, underscore or hyphen, and start with a letter or underscore.": "Ungültiger Gruppenname. Verwenden Sie Buchstaben, Ziffern, Unterstrich oder Bindestrich und beginnen Sie mit einem Buchstaben oder Unterstrich.",
"Invalid health check": "Ungültige Gesundheitskontrolle",
"Invalid healthcheck path": "Ungültiger Gesundheitscheckpfad",
@@ -4073,6 +4074,7 @@
"Migrate away any guests that must keep running": "Migrieren Sie alle Gäste weg, die weiter ausgeführt werden müssen",
"Migrated": "Migriert",
"Migrated legacy ProxMenux NVIDIA blacklist state — module will reload after reboot": "Migrierter Legacy-ProxMenux-NVIDIA-Blacklist-Status – Modul wird nach dem Neustart neu geladen",
"Migration jobs cannot safely restore database or unclassified backend data from host directories": "Migrationsaufgaben können Datenbanken oder nicht klassifizierte Hintergrunddienste aus Host-Verzeichnissen nicht sicher wiederherstellen",
"MineOS web interface": "MineOS Web-Schnittstelle",
"Minimal self-hosted bookmark manager.": "Minimalistischer, selbst gehosteter Lesezeichenmanager.",
"Minimalist self-hosted RSS reader.": "Minimalistischer, selbst gehosteter RSS-Reader.",
@@ -4200,6 +4202,7 @@
"Mumble SuperUser administrator": "Mumble-SuperUser-Administrator",
"Music Collection and Streaming Server": "Musiksammlung und Streaming Server",
"Music software that transforms your listening experience": "Musiksoftware, die Ihr Hörerlebnis verändert",
"Must finish successfully (exit code 0)": "Muss erfolgreich beendet werden (Exit-Code 0)",
"MyIP": "MyIP",
"MySQL Workbench is a unified visual tool for database architects, developers, and DBAs. MySQL Workbench provides data modeling, SQL development, and comprehensive administration tools for server configuration, user administration, backup, and much more.": "MySQL Workbench ist ein einheitliches visuelles Tool für Datenbankarchitekten, Entwickler und DBAs. MySQL Workbench bietet Datenmodellierung, SQL-Entwicklung und umfassende Verwaltungstools für Serverkonfiguration, Benutzerverwaltung, Backup und vieles mehr.",
"MySQL root account": "MySQL-Rootkonto",
@@ -5074,6 +5077,7 @@
"Path must be absolute (start with /)": "Der Pfad muss absolut sein (mit / beginnen)",
"Path must be absolute (start with /).": "Der Pfad muss absolut sein (beginnen Sie mit /).",
"Path not found": "Pfad nicht gefunden",
"Path of environment file": "Pfad zur Umgebungsdatei",
"Path of the Compose file": "Pfad der Compose-Datei",
"Path of the oci-compose file": "Pfad der oci-compose-Datei",
"Path removed:": "Pfad entfernt:",
@@ -5278,10 +5282,12 @@
"Proceeding with Log2RAM setup on non-SSD disk as requested by user.": "Fahren Sie mit der Log2RAM-Einrichtung auf einer Nicht-SSD-Festplatte fort, wie vom Benutzer gewünscht.",
"Process may take several minutes depending on container size": "Der Vorgang kann je nach Behältergröße mehrere Minuten dauern",
"Process may take several minutes for large containers": "Bei großen Behältern kann der Vorgang mehrere Minuten dauern",
"Process running only (no health check declared)": "Nur laufender Prozess (keine Zustandsprüfung definiert)",
"Processes using NVIDIA:": "Prozesse mit NVIDIA:",
"Productivity & Workflows": "Produktivität und Arbeitsabläufe",
"Profile": "Profil",
"Profile:": "Profil:",
"Project environment file (.env), or leave empty to enter values": "Umgebungsdatei des Projekts (.env), oder leer lassen, um Werte einzugeben",
"Project tracking with issues, wikis and time tracking.": "Projektverwaltung mit Tickets, Wikis und Zeiterfassung.",
"Projectsend is a self-hosted application that lets you upload files and assign them to specific clients that you create yourself. Secure, private and easy. No more depending on external services or e-mail to send those files.": "Projectsend ist eine selbst gehostete Anwendung, mit der Sie Dateien hochladen und bestimmten Clients zuweisen können, die Sie selbst erstellen. Sicher, privat und einfach. Nicht mehr abhängig von externen Diensten oder E-Mail, um diese Dateien zu senden.",
"Proposed Changes": "Vorgeschlagene Änderungen",
@@ -5882,6 +5888,8 @@
"SECURITY WARNING:": "SICHERHEITSHINWEIS:",
"SELECT CUSTOM IMAGE": "BENUTZERDEFINIERTES IMAGE AUSWÄHLEN",
"SELECT LOADER": "LOADER AUSWÄHLEN",
"SELinux volume labels are not applied on this Proxmox host.": "SELinux-Volume-Labels werden auf diesem Proxmox-Host nicht angewendet.",
"SELinux volume labels cannot be ignored while SELinux is enforcing.": "SELinux-Volume-Labels können nicht ignoriert werden, solange SELinux im Enforcing-Modus läuft.",
"SETTINGS": "EINSTELLUNGEN",
"SFTPGo": "SFTPGo",
"SMART Action": "SMART-Aktion",
@@ -6576,6 +6584,7 @@
"Starting the main container and its dependencies...": "Starten des Hauptcontainers und seiner Abhängigkeiten...",
"Starting the service:": "Starten des Dienstes:",
"Starting unprivileged container...": "Unprivilegierter Container wird gestartet...",
"Startup check": "Startprüfung",
"Startup: coordinated by the stack startup hook": "Startup: Koordiniert durch den Stack Startup Hook",
"Startup: independent, without hookscript": "Startup: unabhängig, ohne Hookscript",
"Static IP": "Statisches IP",
@@ -6793,6 +6802,7 @@
"The Compose file has no services.": "Die Compose-Datei hat keine Dienste.",
"The Compose file has no x-casaos metadata.": "Die Compose-Datei hat keine x-casaos-Metadaten.",
"The Compose file is not valid YAML:": "Die Compose-Datei ist nicht gültig YAML:",
"The Compose file is too large": "Die Compose-Datei ist zu groß",
"The Compose offers an optional AppArmor or seccomp relaxation; it will stay disabled unless the user selects it. Continue only if you trust the image and accept this risk.": "Das Compose bietet eine optionale AppArmor- oder seccomp-Entspannung; es bleibt deaktiviert, es sei denn, der Benutzer wählt es aus. Fahren Sie nur fort, wenn Sie dem Image vertrauen und dieses Risiko akzeptieren.",
"The Compose value must be text or a list:": "Der Compose-Wert muss Text oder eine Liste sein:",
"The DRM node is not an Intel or AMD GPU; NVIDIA requires its library profile": "Der DRM-Knoten ist keine Intel- oder AMD-GPU; NVIDIA requires sein Bibliotheksprofil",
@@ -7017,6 +7027,7 @@
"The dynamic NVIDIA hook is missing or duplicated": "Der dynamische NVIDIA-Hook fehlt oder dupliziert",
"The dynamic NVIDIA profile requires an unprivileged LXC": "Das dynamische NVIDIA Profil requires und unprivilegierte LXC",
"The dynamic profile does not support static driver mounts": "Das dynamische Profil unterstützt keine statischen Treiberhalterungen",
"The file could not be read:": "Die Datei konnte nicht gelesen werden:",
"The file does not exist, is empty or is not readable.": "Die Datei existiert nicht, ist leer oder nicht lesbar.",
"The file does not exist:": "Die Datei existiert nicht:",
"The file is not a ProxMenux oci-compose.": "Die Datei ist kein oci-compose von ProxMenux.",
+11
View File
@@ -3579,6 +3579,7 @@
"Invalid device path:": "Ruta de dispositivo no válida:",
"Invalid device paths for": "Rutas de dispositivo no válidas para",
"Invalid device permissions": "Permisos del dispositivo no válidos",
"Invalid environment variable name:": "Nombre de variable de entorno no válido:",
"Invalid group name. Use letters, digits, underscore or hyphen, and start with a letter or underscore.": "Nombre de grupo no válido. Utiliza letras, dígitos, guion bajo o guion, y comienza con una letra o un guion bajo.",
"Invalid health check": "Control de salud inválido",
"Invalid healthcheck path": "Camino de control de salud inválido",
@@ -4069,6 +4070,7 @@
"Migrate away any guests that must keep running": "Migre las VM y CT que deban seguir en ejecución",
"Migrated": "Migrados",
"Migrated legacy ProxMenux NVIDIA blacklist state — module will reload after reboot": "Estado de lista negra de NVIDIA ProxMenux heredado migrado: el módulo se recargará después del reinicio",
"Migration jobs cannot safely restore database or unclassified backend data from host directories": "Las tareas de migración no pueden restaurar de forma segura los datos de bases de datos o de servicios internos sin clasificar desde directorios del host",
"MineOS web interface": "Interfaz web de MineOS",
"Minimal self-hosted bookmark manager.": "Gestor de marcadores minimalista autoalojado.",
"Minimalist self-hosted RSS reader.": "Lector RSS minimalista autoalojado.",
@@ -4196,6 +4198,7 @@
"Mumble SuperUser administrator": "Administrador SuperUser de Mumble",
"Music Collection and Streaming Server": "Servidor de colección y streaming de música",
"Music software that transforms your listening experience": "Software de música que transforma tu experiencia de escucha",
"Must finish successfully (exit code 0)": "Debe finalizar correctamente (código de salida 0)",
"MyIP": "MyIP",
"MySQL Workbench is a unified visual tool for database architects, developers, and DBAs. MySQL Workbench provides data modeling, SQL development, and comprehensive administration tools for server configuration, user administration, backup, and much more.": "MySQL Workbench es una herramienta visual unificada para arquitectos de bases de datos, desarrolladores y DBA. Ofrece modelado de datos, desarrollo SQL y completas herramientas de administración para la configuración del servidor, la gestión de usuarios, el backup y mucho más.",
"MySQL root account": "Cuenta root de MySQL",
@@ -5066,6 +5069,7 @@
"Path must be absolute (start with /)": "La ruta debe ser absoluta (comenzar con /)",
"Path must be absolute (start with /).": "La ruta debe ser absoluta (comenzar con /).",
"Path not found": "Ruta no encontrada",
"Path of environment file": "Ruta del archivo de entorno",
"Path of the Compose file": "Ruta del archivo Compose",
"Path of the oci-compose file": "Ruta del archivo oci-compose",
"Path removed:": "Ruta eliminada:",
@@ -5269,10 +5273,12 @@
"Proceeding with Log2RAM setup on non-SSD disk as requested by user.": "Continuando con la configuración de Log2RAM en un disco que no es SSD según lo solicitado por el usuario.",
"Process may take several minutes depending on container size": "El proceso puede tardar varios minutos dependiendo del tamaño del contenedor",
"Process may take several minutes for large containers": "El proceso puede tardar varios minutos para contenedores grandes",
"Process running only (no health check declared)": "Solo se comprueba que el proceso esté en ejecución (sin comprobación de salud declarada)",
"Processes using NVIDIA:": "Procesos usando NVIDIA:",
"Productivity & Workflows": "Productivity & Workflows",
"Profile": "Perfil",
"Profile:": "Perfil:",
"Project environment file (.env), or leave empty to enter values": "Archivo de entorno del proyecto (.env), o déjalo vacío para introducir los valores",
"Project tracking with issues, wikis and time tracking.": "Gestión de proyectos con incidencias, wikis y control del tiempo.",
"Projectsend is a self-hosted application that lets you upload files and assign them to specific clients that you create yourself. Secure, private and easy. No more depending on external services or e-mail to send those files.": "Projectsend es una aplicación autoalojada que te permite subir archivos y asignarlos a los clientes que tú mismo creas. Segura, privada y sencilla. Sin depender ya de servicios externos ni del correo para enviar esos archivos.",
"Proposed Changes": "Cambios propuestos",
@@ -5866,6 +5872,8 @@
"SECURITY WARNING:": "ADVERTENCIA DE SEGURIDAD:",
"SELECT CUSTOM IMAGE": "SELECCIONAR IMAGEN PERSONALIZADA",
"SELECT LOADER": "SELECCIONAR CARGADOR",
"SELinux volume labels are not applied on this Proxmox host.": "Las etiquetas de volumen de SELinux no se aplican en este host Proxmox.",
"SELinux volume labels cannot be ignored while SELinux is enforcing.": "No se pueden omitir las etiquetas de volumen de SELinux mientras SELinux esté en modo obligatorio.",
"SETTINGS": "CONFIGURACIÓN",
"SFTPGo": "SFTPGo",
"SMART Action": "Acción SMART",
@@ -6561,6 +6569,7 @@
"Starting the main container and its dependencies...": "Iniciando el contenedor principal y sus dependencias...",
"Starting the service:": "Iniciando el servicio:",
"Starting unprivileged container...": "Iniciando el contenedor sin privilegios...",
"Startup check": "Comprobación de arranque",
"Startup: coordinated by the stack startup hook": "Arranque: coordinado por el hook de arranque de la pila",
"Startup: independent, without hookscript": "Inicio: independiente, sin hookscript",
"Static IP": "IP estática",
@@ -6778,6 +6787,7 @@
"The Compose file has no services.": "El archivo Compose no tiene servicios.",
"The Compose file has no x-casaos metadata.": "El archivo Compose no tiene metadatos x-casaos.",
"The Compose file is not valid YAML:": "El archivo Compose no es YAML válido:",
"The Compose file is too large": "El archivo Compose es demasiado grande",
"The Compose offers an optional AppArmor or seccomp relaxation; it will stay disabled unless the user selects it. Continue only if you trust the image and accept this risk.": "El Compose ofrece una relajación opcional de AppArmor o seccomp; seguirá desactivada salvo que el usuario la seleccione. Continúa solo si confías en la imagen y aceptas este riesgo.",
"The Compose value must be text or a list:": "El valor Compose debe ser texto o lista:",
"The DRM node is not an Intel or AMD GPU; NVIDIA requires its library profile": "El nodo DRM no es una GPU Intel o AMD; NVIDIA requiere su perfil de biblioteca",
@@ -6999,6 +7009,7 @@
"The dynamic NVIDIA hook is missing or duplicated": "Falta el hook dinámico NVIDIA o está duplicado",
"The dynamic NVIDIA profile requires an unprivileged LXC": "El perfil dinámico NVIDIA requiere un LXC no privilegiado",
"The dynamic profile does not support static driver mounts": "El perfil dinámico no admite montajes de controlador estático",
"The file could not be read:": "No se pudo leer el archivo:",
"The file does not exist, is empty or is not readable.": "El archivo no existe, está vacío o no es legible.",
"The file does not exist:": "El archivo no existe:",
"The file is not a ProxMenux oci-compose.": "El archivo no es un oci-compose de ProxMenux.",
+11
View File
@@ -3582,6 +3582,7 @@
"Invalid device path:": "Chemin du périphérique non valide & #160;:",
"Invalid device paths for": "Chemins de périphérique non valides pour",
"Invalid device permissions": "autorisations d'appareil non valides",
"Invalid environment variable name:": "Nom de variable d’environnement non valide :",
"Invalid group name. Use letters, digits, underscore or hyphen, and start with a letter or underscore.": "Nom de groupe invalide. Utilisez des lettres, des chiffres, un trait de soulignement ou un trait d'union et commencez par une lettre ou un trait de soulignement.",
"Invalid health check": "Contrôle de santé non valide",
"Invalid healthcheck path": "Voie de contrôle de santé non valide",
@@ -4073,6 +4074,7 @@
"Migrate away any guests that must keep running": "Migrez tous les invités qui doivent continuer à fonctionner",
"Migrated": "Migré",
"Migrated legacy ProxMenux NVIDIA blacklist state — module will reload after reboot": "État de la liste noire ProxMenux NVIDIA héritée migrée : le module se rechargera après le redémarrage",
"Migration jobs cannot safely restore database or unclassified backend data from host directories": "Les tâches de migration ne peuvent pas restaurer de manière sûre les données des bases de données ou des services internes non classés depuis des répertoires de l’hôte",
"MineOS web interface": "Interface web MineOS",
"Minimal self-hosted bookmark manager.": "Gestionnaire de marque-pages minimaliste auto-hébergé.",
"Minimalist self-hosted RSS reader.": "Lecteur RSS minimaliste auto-hébergé.",
@@ -4200,6 +4202,7 @@
"Mumble SuperUser administrator": "Administrateur SuperUser de Mumble",
"Music Collection and Streaming Server": "Collection de musique et serveur de streaming",
"Music software that transforms your listening experience": "Logiciel de musique qui transforme votre expérience d'écoute",
"Must finish successfully (exit code 0)": "Doit se terminer correctement (code de sortie 0)",
"MyIP": "MyIP",
"MySQL Workbench is a unified visual tool for database architects, developers, and DBAs. MySQL Workbench provides data modeling, SQL development, and comprehensive administration tools for server configuration, user administration, backup, and much more.": "MySQL Workbench est un outil visuel unifié pour les architectes de bases de données, les développeurs et les DBA. MySQL Workbench fournit des outils de modélisation de données, de développement SQL et d'administration complète pour la configuration du serveur, l'administration des utilisateurs, la sauvegarde et bien plus encore.",
"MySQL root account": "Compte root MySQL",
@@ -5074,6 +5077,7 @@
"Path must be absolute (start with /)": "Le chemin doit être absolu (commencer par /)",
"Path must be absolute (start with /).": "Le chemin doit être absolu (commencer par /).",
"Path not found": "Chemin introuvable",
"Path of environment file": "Chemin du fichier d’environnement",
"Path of the Compose file": "Chemin du fichier Composer",
"Path of the oci-compose file": "Chemin du fichier oci-compose",
"Path removed:": "Chemin supprimé :",
@@ -5278,10 +5282,12 @@
"Proceeding with Log2RAM setup on non-SSD disk as requested by user.": "Poursuite de la configuration de Log2RAM sur un disque non SSD comme demandé par l'utilisateur.",
"Process may take several minutes depending on container size": "Le processus peut prendre plusieurs minutes selon la taille du conteneur",
"Process may take several minutes for large containers": "Le processus peut prendre plusieurs minutes pour les grands conteneurs",
"Process running only (no health check declared)": "Processus en cours uniquement (aucun contrôle de santé déclaré)",
"Processes using NVIDIA:": "Processus utilisant NVIDIA :",
"Productivity & Workflows": "Productivité et flux de travail",
"Profile": "Profil",
"Profile:": "Profil :",
"Project environment file (.env), or leave empty to enter values": "Fichier d’environnement du projet (.env), ou laisser vide pour saisir les valeurs",
"Project tracking with issues, wikis and time tracking.": "Suivi de projets avec tickets, wikis et suivi du temps.",
"Projectsend is a self-hosted application that lets you upload files and assign them to specific clients that you create yourself. Secure, private and easy. No more depending on external services or e-mail to send those files.": "Projectsend est une application auto-portée qui vous permet de télécharger des fichiers et de les assigner à des clients spécifiques que vous créez vous-même. Sécurisée, privée et facile. Pas plus selon les services externes ou e-mail pour envoyer ces fichiers.",
"Proposed Changes": "Modifications proposées",
@@ -5882,6 +5888,8 @@
"SECURITY WARNING:": "AVERTISSEMENT DE SÉCURITÉ :",
"SELECT CUSTOM IMAGE": "SÉLECTIONNER UNE IMAGE PERSONNALISÉE",
"SELECT LOADER": "SÉLECTIONNER LE CHARGEUR",
"SELinux volume labels are not applied on this Proxmox host.": "Les étiquettes de volume SELinux ne sont pas appliquées sur cet hôte Proxmox.",
"SELinux volume labels cannot be ignored while SELinux is enforcing.": "Les étiquettes de volume SELinux ne peuvent pas être ignorées lorsque SELinux est en mode strict.",
"SETTINGS": "PARAMÈTRES",
"SFTPGo": "SFTPGo",
"SMART Action": "Action SMART",
@@ -6576,6 +6584,7 @@
"Starting the main container and its dependencies...": "Commencer le conteneur principal et ses dépendances...",
"Starting the service:": "Début du service:",
"Starting unprivileged container...": "Démarrage du conteneur sans privilèges...",
"Startup check": "Vérification du démarrage",
"Startup: coordinated by the stack startup hook": "Démarrage : coordonné par le crochet de démarrage de la pile",
"Startup: independent, without hookscript": "Démarrage : indépendant, sans hookscript",
"Static IP": "IP statique",
@@ -6792,6 +6801,7 @@
"The Compose file has no services.": "Le fichier Compose n'a aucun service.",
"The Compose file has no x-casaos metadata.": "Le fichier Compose n'a pas de métadonnées x-casaos.",
"The Compose file is not valid YAML:": "Le fichier Compose n'est pas valide YAML:",
"The Compose file is too large": "Le fichier Compose est trop volumineux",
"The Compose offers an optional AppArmor or seccomp relaxation; it will stay disabled unless the user selects it. Continue only if you trust the image and accept this risk.": "Le Compose offre une relaxation en option AppArmor ou seccomp ; il restera désactivé à moins que l'utilisateur le sélectionne. Continuez seulement si vous faites confiance à l'image et acceptez ce risque.",
"The Compose value must be text or a list:": "La valeur Compose doit être un texte ou une liste :",
"The DRM node is not an Intel or AMD GPU; NVIDIA requires its library profile": "Le noeud DRM n'est pas un GPU Intel ou AMD ; NVIDIA requires son profil de bibliothèque",
@@ -7016,6 +7026,7 @@
"The dynamic NVIDIA hook is missing or duplicated": "Le crochet dynamique NVIDIA est manquant ou dupliqué",
"The dynamic NVIDIA profile requires an unprivileged LXC": "Le profil dynamique NVIDIA requires un LXC non privilégié",
"The dynamic profile does not support static driver mounts": "Le profil dynamique ne supporte pas les supports statiques du pilote",
"The file could not be read:": "Impossible de lire le fichier :",
"The file does not exist, is empty or is not readable.": "Le fichier n'existe pas, est vide ou n'est pas lisible.",
"The file does not exist:": "Le fichier n'existe pas :",
"The file is not a ProxMenux oci-compose.": "Le fichier n'est pas un oci-compose ProxMenux.",
+11
View File
@@ -3582,6 +3582,7 @@
"Invalid device path:": "Percorso dispositivo non valido:",
"Invalid device paths for": "Percorsi dei dispositivi non validi per",
"Invalid device permissions": "Permessi dispositivo non validi",
"Invalid environment variable name:": "Nome della variabile di ambiente non valido:",
"Invalid group name. Use letters, digits, underscore or hyphen, and start with a letter or underscore.": "Nome del gruppo non valido. Utilizza lettere, cifre, trattino basso o trattino e inizia con una lettera o un trattino basso.",
"Invalid health check": "Controllo stato non valido",
"Invalid healthcheck path": "Percorso controllo stato non valido",
@@ -4073,6 +4074,7 @@
"Migrate away any guests that must keep running": "Migra altrove tutti i guest che devono restare in esecuzione",
"Migrated": "Migrato",
"Migrated legacy ProxMenux NVIDIA blacklist state — module will reload after reboot": "Stato della lista nera NVIDIA ProxMenux legacy migrato: il modulo verrà ricaricato dopo il riavvio",
"Migration jobs cannot safely restore database or unclassified backend data from host directories": "Le attività di migrazione non possono ripristinare in modo sicuro i dati dei database o dei servizi interni non classificati dalle directory dell’host",
"MineOS web interface": "Interfaccia web MineOS",
"Minimal self-hosted bookmark manager.": "Gestore minimale di segnalibri, ospitato sul proprio server.",
"Minimalist self-hosted RSS reader.": "Lettore RSS minimale, ospitato sul proprio server.",
@@ -4200,6 +4202,7 @@
"Mumble SuperUser administrator": "Amministratore SuperUser Mumble",
"Music Collection and Streaming Server": "Server per la raccolta e lo streaming di musica",
"Music software that transforms your listening experience": "Software musicale che trasforma la tua esperienza d'ascolto",
"Must finish successfully (exit code 0)": "Deve terminare correttamente (codice di uscita 0)",
"MyIP": "MyIP",
"MySQL Workbench is a unified visual tool for database architects, developers, and DBAs. MySQL Workbench provides data modeling, SQL development, and comprehensive administration tools for server configuration, user administration, backup, and much more.": "MySQL Workbench è uno strumento visivo unificato per architetti di database, sviluppatori e DBA. Offre funzionalità di modellazione dei dati, sviluppo SQL e strumenti completi per la configurazione del server, l’amministrazione degli utenti, i backup e molto altro.",
"MySQL root account": "Account root di MySQL",
@@ -5074,6 +5077,7 @@
"Path must be absolute (start with /)": "Il percorso deve essere assoluto (inizia con /)",
"Path must be absolute (start with /).": "Il percorso deve essere assoluto (inizia con /).",
"Path not found": "Percorso non trovato",
"Path of environment file": "Percorso del file di ambiente",
"Path of the Compose file": "Percorso del file Compose",
"Path of the oci-compose file": "Percorso del file oci-compose",
"Path removed:": "Percorso rimosso:",
@@ -5278,10 +5282,12 @@
"Proceeding with Log2RAM setup on non-SSD disk as requested by user.": "Si procede con la configurazione di Log2RAM su disco non SSD come richiesto dall'utente.",
"Process may take several minutes depending on container size": "Il processo potrebbe richiedere diversi minuti a seconda delle dimensioni del container",
"Process may take several minutes for large containers": "Il processo potrebbe richiedere diversi minuti per i container di grandi dimensioni",
"Process running only (no health check declared)": "Solo processo in esecuzione (nessun controllo di integrità dichiarato)",
"Processes using NVIDIA:": "Processi che usano NVIDIA:",
"Productivity & Workflows": "Produttività e flussi di lavoro",
"Profile": "Profilo",
"Profile:": "Profilo:",
"Project environment file (.env), or leave empty to enter values": "File di ambiente del progetto (.env), oppure lascia vuoto per inserire i valori",
"Project tracking with issues, wikis and time tracking.": "Gestione progetti con issue, wiki e monitoraggio del tempo.",
"Projectsend is a self-hosted application that lets you upload files and assign them to specific clients that you create yourself. Secure, private and easy. No more depending on external services or e-mail to send those files.": "Projectsend è un'applicazione ospitabile sul proprio server che permette di caricare file e assegnarli a clienti specifici creati da te. Sicura, privata e semplice: non serve più affidarsi a servizi esterni o all'e-mail per inviare i file.",
"Proposed Changes": "Modifiche proposte",
@@ -5882,6 +5888,8 @@
"SECURITY WARNING:": "AVVISO DI SICUREZZA:",
"SELECT CUSTOM IMAGE": "SELEZIONA IMMAGINE PERSONALIZZATA",
"SELECT LOADER": "SELEZIONA LOADER",
"SELinux volume labels are not applied on this Proxmox host.": "Le etichette dei volumi SELinux non vengono applicate su questo host Proxmox.",
"SELinux volume labels cannot be ignored while SELinux is enforcing.": "Le etichette dei volumi SELinux non possono essere ignorate quando SELinux è in modalità enforcing.",
"SETTINGS": "IMPOSTAZIONI",
"SFTPGo": "SFTPGo",
"SMART Action": "Azione SMART",
@@ -6576,6 +6584,7 @@
"Starting the main container and its dependencies...": "Avvio del container principale e delle sue dipendenze...",
"Starting the service:": "Avvio del servizio:",
"Starting unprivileged container...": "Avvio del container non privilegiato...",
"Startup check": "Verifica di avvio",
"Startup: coordinated by the stack startup hook": "Avvio: coordinato dall'hook di avvio dello stack",
"Startup: independent, without hookscript": "Avvio indipendente, senza hookscript",
"Static IP": "IP statico",
@@ -6793,6 +6802,7 @@
"The Compose file has no services.": "Il file Compose non ha servizi.",
"The Compose file has no x-casaos metadata.": "Il file Compose non ha metadati x-casaos.",
"The Compose file is not valid YAML:": "Il file Compose non è un documento YAML valido:",
"The Compose file is too large": "Il file Compose è troppo grande",
"The Compose offers an optional AppArmor or seccomp relaxation; it will stay disabled unless the user selects it. Continue only if you trust the image and accept this risk.": "Compose consente di allentare le restrizioni AppArmor o seccomp; l’opzione resta disattivata salvo selezione dell’utente. Continuare solo se si ritiene affidabile l’immagine e si accetta il rischio.",
"The Compose value must be text or a list:": "Il valore Compose deve essere testo o un elenco:",
"The DRM node is not an Intel or AMD GPU; NVIDIA requires its library profile": "Il nodo DRM non è una GPU Intel o AMD; NVIDIA richiede il proprio profilo di librerie",
@@ -7017,6 +7027,7 @@
"The dynamic NVIDIA hook is missing or duplicated": "L'hook NVIDIA dinamico manca o è duplicato",
"The dynamic NVIDIA profile requires an unprivileged LXC": "Il profilo NVIDIA dinamico richiede un LXC non privilegiato",
"The dynamic profile does not support static driver mounts": "Il profilo dinamico non supporta i mount statici dei driver",
"The file could not be read:": "Impossibile leggere il file:",
"The file does not exist, is empty or is not readable.": "Il file non esiste, è vuoto o non è leggibile.",
"The file does not exist:": "Il file non esiste:",
"The file is not a ProxMenux oci-compose.": "Il file non è un oci-compose di ProxMenux.",
+11
View File
@@ -3582,6 +3582,7 @@
"Invalid device path:": "Caminho inválido do dispositivo:",
"Invalid device paths for": "Caminhos de dispositivos inválidos para",
"Invalid device permissions": "permissões de dispositivo inválidas",
"Invalid environment variable name:": "Nome de variável de ambiente inválido:",
"Invalid group name. Use letters, digits, underscore or hyphen, and start with a letter or underscore.": "Nome de grupo inválido. Use letras, dígitos, sublinhado ou hífen e comece com uma letra ou sublinhado.",
"Invalid health check": "Verificação de saúde inválida",
"Invalid healthcheck path": "Caminho de verificação de saúde inválido",
@@ -4073,6 +4074,7 @@
"Migrate away any guests that must keep running": "Migre todos os convidados que precisam continuar em execução",
"Migrated": "Migrado",
"Migrated legacy ProxMenux NVIDIA blacklist state — module will reload after reboot": "Estado de lista negra legado ProxMenux NVIDIA migrado – o módulo será recarregado após a reinicialização",
"Migration jobs cannot safely restore database or unclassified backend data from host directories": "As tarefas de migração não podem restaurar com segurança os dados de bases de dados ou de serviços internos não classificados a partir de diretórios do host",
"MineOS web interface": "Interface Web MineOS",
"Minimal self-hosted bookmark manager.": "Gestor de marcadores minimalista autoalojado.",
"Minimalist self-hosted RSS reader.": "Leitor RSS minimalista autoalojado.",
@@ -4200,6 +4202,7 @@
"Mumble SuperUser administrator": "Administrador SuperUser do Mumble",
"Music Collection and Streaming Server": "Servidor de Coleção e Streaming de Música",
"Music software that transforms your listening experience": "Software de música que transforma sua experiência de audição",
"Must finish successfully (exit code 0)": "Tem de terminar com sucesso (código de saída 0)",
"MyIP": "MyIP",
"MySQL Workbench is a unified visual tool for database architects, developers, and DBAs. MySQL Workbench provides data modeling, SQL development, and comprehensive administration tools for server configuration, user administration, backup, and much more.": "MySQL Workbench é uma ferramenta visual unificada para arquitetos de banco de dados, desenvolvedores e DBAs. O MySQL Workbench fornece modelagem de dados, desenvolvimento SQL e ferramentas de administração abrangentes para configuração de servidor, administração de usuários, backup e muito mais.",
"MySQL root account": "Conta root do MySQL",
@@ -5074,6 +5077,7 @@
"Path must be absolute (start with /)": "O caminho deve ser absoluto (comece com /)",
"Path must be absolute (start with /).": "O caminho deve ser absoluto (começar com /).",
"Path not found": "Caminho não encontrado",
"Path of environment file": "Caminho do ficheiro de ambiente",
"Path of the Compose file": "Localização do ficheiro Compor",
"Path of the oci-compose file": "Caminho do arquivo oci-compose",
"Path removed:": "Caminho removido:",
@@ -5278,10 +5282,12 @@
"Proceeding with Log2RAM setup on non-SSD disk as requested by user.": "Prosseguindo com a configuração do Log2RAM em disco não SSD conforme solicitado pelo usuário.",
"Process may take several minutes depending on container size": "O processo pode levar vários minutos dependendo do tamanho do recipiente",
"Process may take several minutes for large containers": "O processo pode levar vários minutos para contêineres grandes",
"Process running only (no health check declared)": "Apenas processo em execução (sem verificação de saúde declarada)",
"Processes using NVIDIA:": "Processos usando NVIDIA:",
"Productivity & Workflows": "Produtividade e fluxos de trabalho",
"Profile": "Perfil",
"Profile:": "Perfil:",
"Project environment file (.env), or leave empty to enter values": "Ficheiro de ambiente do projeto (.env), ou deixe vazio para introduzir os valores",
"Project tracking with issues, wikis and time tracking.": "Gerenciamento de projetos com issues, wikis e controle de tempo.",
"Projectsend is a self-hosted application that lets you upload files and assign them to specific clients that you create yourself. Secure, private and easy. No more depending on external services or e-mail to send those files.": "Projectsend é uma aplicação auto-hospedada que permite fazer upload de arquivos e atribuí-los a clientes específicos que você mesmo cria. Seguro, privado e fácil. Não mais dependendo de serviços externos ou e-mail para enviar esses arquivos.",
"Proposed Changes": "Mudanças propostas",
@@ -5882,6 +5888,8 @@
"SECURITY WARNING:": "AVISO DE SEGURANÇA:",
"SELECT CUSTOM IMAGE": "SELECIONAR IMAGEM PERSONALIZADA",
"SELECT LOADER": "SELECIONAR CARREGADOR",
"SELinux volume labels are not applied on this Proxmox host.": "As etiquetas de volume SELinux não são aplicadas neste host Proxmox.",
"SELinux volume labels cannot be ignored while SELinux is enforcing.": "As etiquetas de volume SELinux não podem ser ignoradas enquanto o SELinux estiver em modo enforcing.",
"SETTINGS": "DEFINIÇÕES",
"SFTPGo": "SFTPGo",
"SMART Action": "Ação SMART",
@@ -6576,6 +6584,7 @@
"Starting the main container and its dependencies...": "A iniciar o contentor principal e as suas dependências...",
"Starting the service:": "Iniciando o serviço:",
"Starting unprivileged container...": "Iniciando contêiner sem privilégios...",
"Startup check": "Verificação de arranque",
"Startup: coordinated by the stack startup hook": "Inicialização: coordenada pelo gancho de arranque da pilha",
"Startup: independent, without hookscript": "Inicialização: independente, sem hookscript",
"Static IP": "IP estático",
@@ -6793,6 +6802,7 @@
"The Compose file has no services.": "O ficheiro Compose não tem serviços.",
"The Compose file has no x-casaos metadata.": "O ficheiro Compose não tem metadados x-casaos.",
"The Compose file is not valid YAML:": "O arquivo Compose não é válido YAML:",
"The Compose file is too large": "O ficheiro Compose é demasiado grande",
"The Compose offers an optional AppArmor or seccomp relaxation; it will stay disabled unless the user selects it. Continue only if you trust the image and accept this risk.": "O Compose oferece um relaxamento opcional AppArmor ou seccomp; ele ficará desativado a menos que o usuário o selecione. Continue apenas se você confiar na imagem e aceitar este risco.",
"The Compose value must be text or a list:": "O valor Compose deve ser texto ou uma lista:",
"The DRM node is not an Intel or AMD GPU; NVIDIA requires its library profile": "O nó DRM não é uma GPU Intel ou AMD; NVIDIA requires seu perfil de biblioteca",
@@ -7017,6 +7027,7 @@
"The dynamic NVIDIA hook is missing or duplicated": "O gancho NVIDIA dinâmico está faltando ou duplicado",
"The dynamic NVIDIA profile requires an unprivileged LXC": "O perfil NVIDIA dinâmico requires um LXC sem privilégios",
"The dynamic profile does not support static driver mounts": "O perfil dinâmico não suporta montagens estáticas do driver",
"The file could not be read:": "Não foi possível ler o ficheiro:",
"The file does not exist, is empty or is not readable.": "O arquivo não existe, está vazio ou não é legível.",
"The file does not exist:": "O arquivo não existe:",
"The file is not a ProxMenux oci-compose.": "O arquivo não é um oci-compose do ProxMenux.",
+11
View File
@@ -3582,6 +3582,7 @@
"Invalid device path:": "Neplatná cesta zariadenia:",
"Invalid device paths for": "Neplatné cesty zariadenia pre",
"Invalid device permissions": "Neplatné povolenia zariadenia",
"Invalid environment variable name:": "Neplatný názov premennej prostredia:",
"Invalid group name. Use letters, digits, underscore or hyphen, and start with a letter or underscore.": "Neplatný názov skupiny. Použite písmená, čísla, podčiarkovník alebo pomlčku a začnite písmenom alebo podčiarkovníkom.",
"Invalid health check": "Neplatná kontrola zdravotného stavu",
"Invalid healthcheck path": "Neplatná cesta kontroly zdravotného stavu",
@@ -4073,6 +4074,7 @@
"Migrate away any guests that must keep running": "Presuňte mimo uzla všetkých hostí, ktorí musia zostať bežať",
"Migrated": "Migrované",
"Migrated legacy ProxMenux NVIDIA blacklist state — module will reload after reboot": "Starý stav ProxMenux NVIDIA blacklistu bol migrovaný - modul sa znovu načíta po reštarte",
"Migration jobs cannot safely restore database or unclassified backend data from host directories": "Migračné úlohy nedokážu bezpečne obnoviť údaje databáz alebo nezaradených interných služieb z adresárov hostiteľa",
"MineOS web interface": "Webové rozhranie MineOS",
"Minimal self-hosted bookmark manager.": "Minimalistický správca záložiek na vlastnom serveri.",
"Minimalist self-hosted RSS reader.": "Minimalistická RSS čítačka na vlastnom serveri.",
@@ -4200,6 +4202,7 @@
"Mumble SuperUser administrator": "Správca SuperUser v Mumble",
"Music Collection and Streaming Server": "Správca hudobnej zbierky a streamovací server",
"Music software that transforms your listening experience": "Hudobný softvér, ktorý mení vaše počúvanie",
"Must finish successfully (exit code 0)": "Musí sa úspešne dokončiť (návratový kód 0)",
"MyIP": "MyIP",
"MySQL Workbench is a unified visual tool for database architects, developers, and DBAs. MySQL Workbench provides data modeling, SQL development, and comprehensive administration tools for server configuration, user administration, backup, and much more.": "MySQL Workbench je jednotný vizuálny nástroj pre databázových architektov, vývojárov a DBA. MySQL Workbench poskytuje dátové modelovanie, vývoj SQL a komplexné nástroje správy pre konfiguráciu serverov, správu užívateľov, zálohovanie a oveľa viac.",
"MySQL root account": "Účet root v MySQL",
@@ -5076,6 +5079,7 @@
"Path must be absolute (start with /)": "Cesta musí byť absolútna (začínať znakom /)",
"Path must be absolute (start with /).": "Cesta musí byť absolútna (začínať znakom /).",
"Path not found": "Cesta sa nenašla",
"Path of environment file": "Cesta k súboru prostredia",
"Path of the Compose file": "Cesta k súboru Compose",
"Path of the oci-compose file": "Cesta k súboru oci-compose",
"Path removed:": "Cesta bola odstránená:",
@@ -5280,10 +5284,12 @@
"Proceeding with Log2RAM setup on non-SSD disk as requested by user.": "Pokračujem v nastavení Log2RAM na disku, ktorý nie je SSD, podľa voľby používateľa.",
"Process may take several minutes depending on container size": "Proces môže trvať niekoľko minút podľa veľkosti kontajnera",
"Process may take several minutes for large containers": "Pri veľkých kontajneroch môže proces trvať niekoľko minút",
"Process running only (no health check declared)": "Iba bežiaci proces (bez definovanej kontroly stavu)",
"Processes using NVIDIA:": "Procesy používajúce NVIDIA:",
"Productivity & Workflows": "Produktivita a pracovné postupy",
"Profile": "Profil",
"Profile:": "Profil:",
"Project environment file (.env), or leave empty to enter values": "Súbor prostredia projektu (.env), alebo nechajte prázdne a zadajte hodnoty",
"Project tracking with issues, wikis and time tracking.": "Sledovanie projektov, tiketov, wiki a evidencie času.",
"Projectsend is a self-hosted application that lets you upload files and assign them to specific clients that you create yourself. Secure, private and easy. No more depending on external services or e-mail to send those files.": "Projectsend je aplikácia self-hosted, ktorá vám umožní nahrávať súbory a priradiť ich konkrétnym klientom, ktoré vytvoríte sami. Bezpečné, súkromné a jednoduché. Už nie je závislá od externých služieb alebo e-mailu, ktorý by tieto súbory posielal.",
"Proposed Changes": "Navrhované zmeny",
@@ -5884,6 +5890,8 @@
"SECURITY WARNING:": "BEZPEČNOSTNÉ UPOZORNENIE:",
"SELECT CUSTOM IMAGE": "VÝBER VLASTNÉHO OBRAZU",
"SELECT LOADER": "VÝBER ZAVÁDZAČA",
"SELinux volume labels are not applied on this Proxmox host.": "Označenia zväzkov SELinux sa na tomto hostiteľovi Proxmox neuplatňujú.",
"SELinux volume labels cannot be ignored while SELinux is enforcing.": "Označenia zväzkov SELinux nemožno ignorovať, keď je SELinux v režime vynucovania.",
"SETTINGS": "NASTAVENIA",
"SFTPGo": "SFTPGo",
"SMART Action": "SMART akcia",
@@ -6578,6 +6586,7 @@
"Starting the main container and its dependencies...": "Spustenie hlavného kontajnera a jeho závislosti...",
"Starting the service:": "Spustenie služby:",
"Starting unprivileged container...": "Spúšťam neprivilegovaný kontajner...",
"Startup check": "Kontrola spustenia",
"Startup: coordinated by the stack startup hook": "Spúšťanie: koordinované spúšťacím skriptom zostavy",
"Startup: independent, without hookscript": "Spúšťanie: samostatné, bez hookscript",
"Static IP": "Statické IP",
@@ -6794,6 +6803,7 @@
"The Compose file has no services.": "Súbor Compose neobsahuje žiadne služby.",
"The Compose file has no x-casaos metadata.": "Súbor Compose neobsahuje metadáta x-casaos.",
"The Compose file is not valid YAML:": "Súbor Compose nie je platný YAML:",
"The Compose file is too large": "Súbor Compose je príliš veľký",
"The Compose offers an optional AppArmor or seccomp relaxation; it will stay disabled unless the user selects it. Continue only if you trust the image and accept this risk.": "Súbor Compose ponúka voliteľné uvoľnenie obmedzení AppArmor alebo seccomp, ktoré zostane vypnuté, kým ho používateľ nevyberie. Pokračujte iba vtedy, ak obrazu dôverujete a riziko prijímate.",
"The Compose value must be text or a list:": "Hodnota Compose musí byť text alebo zoznam:",
"The DRM node is not an Intel or AMD GPU; NVIDIA requires its library profile": "Uzol DRM nie je GPU Intel ani AMD; NVIDIA vyžaduje profil so svojimi knižnicami",
@@ -7018,6 +7028,7 @@
"The dynamic NVIDIA hook is missing or duplicated": "Dynamický NVIDIA hák chýba alebo sa zdvojí",
"The dynamic NVIDIA profile requires an unprivileged LXC": "Dynamický profil NVIDIA vyžaduje neprivilegovaný kontajner LXC",
"The dynamic profile does not support static driver mounts": "Dynamický profil nepodporuje statické pripojenia ovládača",
"The file could not be read:": "Súbor sa nepodarilo prečítať:",
"The file does not exist, is empty or is not readable.": "Súbor neexistuje, je prázdny alebo sa nedá čítať.",
"The file does not exist:": "Súbor neexistuje:",
"The file is not a ProxMenux oci-compose.": "Súbor nie je ProxMenux oci-compose.",
+11
View File
@@ -3582,6 +3582,7 @@
"Invalid device path:": "Invalid enhetsväg:",
"Invalid device paths for": "Invalid enhet vägar för",
"Invalid device permissions": "Ogiltiga enhetsbehörigheter",
"Invalid environment variable name:": "Ogiltigt namn på miljövariabel:",
"Invalid group name. Use letters, digits, underscore or hyphen, and start with a letter or underscore.": "Ogiltigt gruppnamn. Använd bokstäver, siffror, understreck eller bindestreck och börja med en bokstav eller understreck.",
"Invalid health check": "Ogiltig hälsokontroll",
"Invalid healthcheck path": "Ogiltig hälsokontrollväg",
@@ -4073,6 +4074,7 @@
"Migrate away any guests that must keep running": "Migrera bort alla gäster som måste fortsätta springa",
"Migrated": "Migrerade",
"Migrated legacy ProxMenux NVIDIA blacklist state — module will reload after reboot": "Migrerat äldre ProxMenux NVIDIA-svartlistatillstånd — modulen laddas om efter omstart",
"Migration jobs cannot safely restore database or unclassified backend data from host directories": "Migreringsjobb kan inte säkert återställa data för databaser eller oklassificerade interna tjänster från kataloger på värden",
"MineOS web interface": "MineOS webbgränssnitt",
"Minimal self-hosted bookmark manager.": "Minimalistisk, självhostad bokmärkeshanterare.",
"Minimalist self-hosted RSS reader.": "Minimalistisk, självhostad RSS-läsare.",
@@ -4200,6 +4202,7 @@
"Mumble SuperUser administrator": "Mumble SuperUser-administratör",
"Music Collection and Streaming Server": "Music Collection och Streaming Server",
"Music software that transforms your listening experience": "Musikprogramvara som omvandlar din lyssnarupplevelse",
"Must finish successfully (exit code 0)": "Måste slutföras utan fel (avslutningskod 0)",
"MyIP": "MyIP",
"MySQL Workbench is a unified visual tool for database architects, developers, and DBAs. MySQL Workbench provides data modeling, SQL development, and comprehensive administration tools for server configuration, user administration, backup, and much more.": "MySQL Workbench är ett enhetligt visuellt verktyg för databasarkitekter, utvecklare och DBA. MySQL Workbench tillhandahåller datamodellering, SQL-utveckling och omfattande administrationsverktyg för serverkonfiguration, användaradministration, backup och mycket mer.",
"MySQL root account": "Rootkonto för MySQL",
@@ -5074,6 +5077,7 @@
"Path must be absolute (start with /)": "Sökväg måste vara absolut (börja med /)",
"Path must be absolute (start with /).": "Sökväg måste vara absolut (börja med /).",
"Path not found": "Sökväg hittades inte",
"Path of environment file": "Sökväg till miljöfilen",
"Path of the Compose file": "Path of the Compose filen",
"Path of the oci-compose file": "Sökväg till oci-compose-filen",
"Path removed:": "Sökväg borttagen:",
@@ -5278,10 +5282,12 @@
"Proceeding with Log2RAM setup on non-SSD disk as requested by user.": "Fortsätter med konfigurationen av Log2RAM på icke-SSD-disk enligt användarens begäran.",
"Process may take several minutes depending on container size": "Processen kan ta flera minuter beroende på behållarens storlek",
"Process may take several minutes for large containers": "Processen kan ta flera minuter för stora behållare",
"Process running only (no health check declared)": "Endast körande process (ingen hälsokontroll angiven)",
"Processes using NVIDIA:": "Processer som använder NVIDIA:",
"Productivity & Workflows": "Produktivitet och arbetsflöden",
"Profile": "Profil",
"Profile:": "Profil:",
"Project environment file (.env), or leave empty to enter values": "Projektets miljöfil (.env), eller lämna tomt för att ange värden",
"Project tracking with issues, wikis and time tracking.": "Projekthantering med ärenden, wikis och tidrapportering.",
"Projectsend is a self-hosted application that lets you upload files and assign them to specific clients that you create yourself. Secure, private and easy. No more depending on external services or e-mail to send those files.": "Projectsend är en självhäftad applikation som låter dig ladda upp filer och tilldela dem till specifika kunder som du skapar själv. Säker, privat och lätt. Inte mer beroende på externa tjänster eller e-post för att skicka dessa filer.",
"Proposed Changes": "Föreslagna ändringar",
@@ -5882,6 +5888,8 @@
"SECURITY WARNING:": "SÄKERHETSVARNING:",
"SELECT CUSTOM IMAGE": "VÄLJ ANPASSAD AVBILD",
"SELECT LOADER": "VÄLJ LOADER",
"SELinux volume labels are not applied on this Proxmox host.": "SELinux-etiketter för volymer tillämpas inte på den här Proxmox-värden.",
"SELinux volume labels cannot be ignored while SELinux is enforcing.": "SELinux-etiketter för volymer kan inte ignoreras när SELinux är i tvingande läge.",
"SETTINGS": "INSTÄLLNINGAR",
"SFTPGo": "SFTPGo",
"SMART Action": "SMART-åtgärd",
@@ -6576,6 +6584,7 @@
"Starting the main container and its dependencies...": "Starta huvudbehållaren och dess beroende...",
"Starting the service:": "Starta tjänsten:",
"Starting unprivileged container...": "Startar oprivilegierad behållare...",
"Startup check": "Startkontroll",
"Startup: coordinated by the stack startup hook": "Startup: koordinerad av stack start hook",
"Startup: independent, without hookscript": "Startup: oberoende, utan hookscript",
"Static IP": "Statisk IP",
@@ -6793,6 +6802,7 @@
"The Compose file has no services.": "Compose-filen har inga tjänster.",
"The Compose file has no x-casaos metadata.": "Compose-filen saknar x-casaos-metadata.",
"The Compose file is not valid YAML:": "Compose-filen är inte giltig YAML:",
"The Compose file is too large": "Compose-filen är för stor",
"The Compose offers an optional AppArmor or seccomp relaxation; it will stay disabled unless the user selects it. Continue only if you trust the image and accept this risk.": "Komponera erbjuder en valfri AppArmor eller seccomp avslappning; det kommer att vara inaktiverat om inte användaren väljer det. Fortsätt bara om du litar på bilden och accepterar denna risk.",
"The Compose value must be text or a list:": "Komponeringsvärdet måste vara text eller en lista:",
"The DRM node is not an Intel or AMD GPU; NVIDIA requires its library profile": "DRM-noden är inte en Intel eller AMD GPU; NVIDIA requires sin biblioteksprofil",
@@ -7017,6 +7027,7 @@
"The dynamic NVIDIA hook is missing or duplicated": "Den dynamiska NVIDIA-kroken saknas eller dupliceras",
"The dynamic NVIDIA profile requires an unprivileged LXC": "Den dynamiska NVIDIA-profilen requires en oprivilegierad LXC",
"The dynamic profile does not support static driver mounts": "Den dynamiska profilen stöder inte statiska drivrutiner",
"The file could not be read:": "Filen kunde inte läsas:",
"The file does not exist, is empty or is not readable.": "Filen finns inte, är tom eller är inte läsbar.",
"The file does not exist:": "Filen finns inte:",
"The file is not a ProxMenux oci-compose.": "Filen är inte en ProxMenux oci-compose.",
+49 -1
View File
@@ -209,7 +209,55 @@ The native path checks the saved configuration and installation identity, keeps
## From a Compose file or a docker run command to an oci-compose
The converter reads Compose files with one or several services. Multi-service files use the same native stack compiler as catalog applications: each service runs in its own LXC, with dependency ordering and private networking. The first service that publishes a port is selected as the main service, unless `x-casaos.main` specifies another one. Every image is checked for registry access before installation, and written tags and digests are preserved. Each web application uses its own published container port for its health check and receives its own LAN address. Repeated identical volume sources share one host directory, whose location is selected once; separate configuration volumes remain independent. Unsupported stack features (including external networks and application services without a supported health check) are reported before installation. A `docker run` command and an image reference alone are first written as a Compose file and follow the same path, so the three produce the same kind of oci-compose.
The converter reads Compose files with one or several services. Multi-service
files use the native stack compiler: each service runs in its own LXC, with
dependency ordering and a private network. The first service that publishes a
port is selected as the main service, unless `x-casaos.main` selects another one.
Every image is checked for registry access before installation; written tags,
digests and image entrypoints are preserved.
The file importer resolves an explicitly selected project `.env` and service
`env_file` files, then applies Compose interpolation and service overrides. It
does not silently import the host process environment. Missing required values
are requested before installation, and summaries hide secret values. An inline
Compose definition can also supply an environment file or enter values through
the assistant.
Published web services receive individual LAN addresses. The main web check uses
the same LAN URL shown to the user; private backend aliases stay on the internal
network. Internal services can use declared or inherited image health checks,
exposed TCP ports, or a clearly labelled process-running check. A running process
alone does not satisfy `service_healthy`.
Repeated identical volume sources share one host directory, selected once;
separate configuration volumes stay independent. Host directories are not part
of a normal Proxmox container backup. Newly created private data volumes use the
imported image user unless an explicit volume owner or PUID/PGID overrides it.
SELinux `z`/`Z` labels are reported as not applied on a non-enforcing host; an
enforcing host is refused rather than silently losing their protection.
An explicit Compose `stop_signal` overrides the image's stop signal through
`lxc.signal.halt`. Without it, the image signal stays unchanged, including when
Compose replaces its entrypoint. A worker sharing a web image may therefore
need its own declared stop signal. Native updates abort safely if a service
does not stop; they do not silently force termination to replace its disks.
Some programs ignore termination signals when running as PID 1. Setting a signal
does not add an init process or guarantee graceful shutdown; an explicit
`SIGKILL` requests forced termination, not a graceful stop.
A dependency declared with `service_completed_successfully` runs as a one-shot
job. Its original command must finish with a verified exit code of zero before
the dependent service starts. Such jobs are excluded from onboot and watchdog
restarts. Update and restore invalidate the completion proof when the image,
runtime configuration or recovery identity changes. A migration update is
refused when database or unclassified backend data lives in host directories
that the transaction cannot safely restore.
The bridges are local to the installation node; they are not a cluster SDN.
Unsupported network and runtime semantics are reported before installation.
Support for several services does not imply complete Docker Compose compatibility.
A `docker run` command or image reference is converted to Compose first and
follows the same importer.
Starting from this definition:
+161 -44
View File
@@ -96,7 +96,7 @@ def report_error(text, tail=True):
def persist_instances(deployment, services, primary_id=None):
template = json.loads(Path(sys.argv[1]).read_text()) if primary_id is not None else {}
with oci_instances.locked(oci_instances.ROOT):
with oci_instances.locked(oci_instances.ROOT, timeout=60):
if primary_id is not None:
oci_instances.save_assembly(oci_instances.ROOT, primary_id, template, deployment, services)
oci_instances.resume_assembly(oci_instances.ROOT, primary_id)
@@ -244,6 +244,31 @@ def relay_child_output(stream):
return result
def assign_health_address(service):
"""Bind a generated dependency probe to its allocated private address."""
check = service['healthcheck']
if check['type'] == 'http':
endpoint = check['endpoint']
check['url'] = f"{endpoint['scheme']}://{service['ip']}:{endpoint['port']}{endpoint['path']}"
elif check['type'] == 'tcp':
check['host'] = service['ip']
def bind_completed_jobs(services):
"""All members and private aliases exist before a job can be authorized."""
from oci_compose_jobs import job_spec_from_record
for service in services:
check = service['healthcheck']
if check.get('type') != 'completed':
continue
vmid = int(service['vmid'])
run('pct', 'set', vmid, '--onboot', '0')
service['deployment']['onboot'] = False
record = oci_instances.read(oci_instances.ROOT, vmid)
check['job'] = job_spec_from_record(record, run('pct', 'config', vmid),
int(check.get('timeout_seconds', 300)))
def lan_access_urls(services, subnet, strict=True):
urls = []
for service in services:
@@ -271,15 +296,43 @@ def lan_access_urls(services, subnet, strict=True):
return urls
def _last_int_env(service, names):
"""Last matching PUID/PGID-style value from deployment.environment,
or 0 if absent/unparsable — mirrors install_oci.sh's
`[.environment[]? | select(.name == ...) | .value] | last // "0"`
plus its numeric-guard `[[ $X =~ ^[0-9]+$ ]] || X=0`."""
def wait_main_web(service, subnet):
"""Check the advertised frontend, preserving private IPs for backend aliases.
A service may legitimately refuse HTTP requests whose Host is its private
stack address. The endpoint shown to the user is the readiness contract.
"""
deadline = time.monotonic() + min(120, int(service['healthcheck']['timeout_seconds']))
url = None
while time.monotonic() < deadline:
try:
found = lan_access_urls([service], subnet)
if found:
url = found[0]['url']
break
except (RuntimeError, OSError, subprocess.SubprocessError):
pass
time.sleep(1)
if url is None:
raise RuntimeError(translate('No LAN address was obtained'))
service['healthcheck']['url'] = url
wait_web(service, url)
return url
def _last_int_env(service, names, default=0):
"""Use the last explicit environment override, falling back only if absent.
Invalid explicit values retain the installer's existing zero fallback.
"""
value = None
found = False
for entry in service['deployment'].get('environment', []):
if entry.get('name') in names:
found = True
value = entry.get('value')
if not found:
return default
try:
parsed = int(value)
except (TypeError, ValueError):
@@ -287,28 +340,78 @@ def _last_int_env(service, names):
return parsed if parsed >= 0 else 0
def resolve_volume_owner(service, owner_strategy):
"""(uid, gid) for a given volume_preparations owner_strategy,
mirroring install_oci.sh's HOST_ROOT_UID/HOST_ROOT_GID (mapped-root)
and HOST_BIND_UID/HOST_BIND_GID (mapped-application-user) — these
are deliberately DIFFERENT values, not the same computation:
def runtime_volume_owner(service, root=None):
"""Container IDs from the OCI import, with image USER as a fallback.
- mapped-root: 100000:100000 for an unprivileged container, 0:0
otherwise. Never depends on PUID/PGID or volume_owner (matches
install_oci.sh's HOST_ROOT_UID/HOST_ROOT_GID, which are pinned
constants that do not read CONTAINER_PUID/CONTAINER_PGID at all).
- mapped-application-user: the container's own PUID/PGID (last of
PUID/USER_ID/UID and PGID/GROUP_ID/GID in deployment.environment,
overridden by the template's volume_owner.uid/gid when present —
matches install_oci.sh's IMAGE_VOLUME_UID/GID override of
CONTAINER_PUID/CONTAINER_PGID before the offset is applied),
offset by 100000 for an unprivileged container (matches
HOST_BIND_UID/HOST_BIND_GID). volume_owner only ever overrides
this application-user value, never mapped-root's.
Proxmox has already resolved a named USER and Compose user override into
lxc.init.uid/gid. No ownership is changed here. The mounted image account
files are only needed on versions that omit one of those numeric fields.
"""
vmid = service.get('vmid')
config = {}
if type(vmid) is int and vmid >= 100:
from oci_installation_state import parse_config
config = parse_config(run('pct', 'config', vmid).encode())
resolved = {}
for name, key in (('uid', 'lxc.init.uid'), ('gid', 'lxc.init.gid')):
value = config.get(key)
if value is not None:
if not re.fullmatch(r'[0-9]+', value):
raise RuntimeError(translate('The imported OCI user or group is not numeric'))
resolved[name] = int(value)
if len(resolved) == 2:
return resolved['uid'], resolved['gid']
image = (service.get('observed') or {}).get('image') or {}
if not image and type(vmid) is int and vmid >= 100:
try:
record = oci_instances.read(oci_instances.ROOT, vmid)
image = (record.get('observed') or {}).get('image') or {}
except (OSError, ValueError, KeyError):
pass
declared = str((image.get('defaults') or {}).get('User') or '')
user, separator, group = declared.partition(':')
accounts, groups = {}, {}
if root is not None:
root = Path(root)
for filename, target in (('passwd', accounts), ('group', groups)):
path = root / 'etc' / filename
if path.resolve().is_relative_to(root.resolve()) and path.is_file() and not path.is_symlink():
for line in path.read_text(errors='replace').splitlines():
fields = line.split(':')
if len(fields) >= (4 if filename == 'passwd' else 3) and fields[2].isdigit():
if filename == 'passwd' and fields[3].isdigit():
target[fields[0]] = (int(fields[2]), int(fields[3]))
elif filename == 'group':
target[fields[0]] = int(fields[2])
if not user:
uid, gid = 0, 0
elif user.isdigit():
uid = int(user)
gid = next((value[1] for value in accounts.values() if value[0] == uid), 0)
elif user in accounts:
uid, gid = accounts[user]
elif 'uid' in resolved:
uid = resolved['uid']
gid = next((value[1] for value in accounts.values() if value[0] == uid), 0)
else:
raise RuntimeError(translate('The imported OCI user or group is not numeric'))
if separator and group:
if group.isdigit():
gid = int(group)
elif group in groups:
gid = groups[group]
elif 'gid' not in resolved:
raise RuntimeError(translate('The imported OCI user or group is not numeric'))
return resolved.get('uid', uid), resolved.get('gid', gid)
Any value that cannot be parsed as a non-negative integer is
treated as 0, matching install_oci.sh's `[[ ... =~ ^[0-9]+$ ]] ||
...=0` guard.
def resolve_volume_owner(service, owner_strategy, imported_owner=None):
"""Resolve fresh volume ownership without changing existing host directories.
Mapped root remains fixed. Application ownership starts with the actual
OCI import user, then honors explicit PUID/PGID aliases and the recipe's
declared volume_owner override. All values are container IDs until the
standard unprivileged offset is applied exactly once.
"""
unprivileged = bool(service['deployment']['security']['unprivileged'])
if owner_strategy == 'mapped-root':
@@ -316,8 +419,9 @@ def resolve_volume_owner(service, owner_strategy):
if owner_strategy != 'mapped-application-user':
raise RuntimeError(f"{translate('Unsupported owner strategy:')} {owner_strategy}")
puid = _last_int_env(service, ('PUID', 'USER_ID', 'UID'))
pgid = _last_int_env(service, ('PGID', 'GROUP_ID', 'GID'))
imported_uid, imported_gid = (runtime_volume_owner(service) if imported_owner is None else imported_owner)
puid = _last_int_env(service, ('PUID', 'USER_ID', 'UID'), imported_uid)
pgid = _last_int_env(service, ('PGID', 'GROUP_ID', 'GID'), imported_gid)
volume_owner = service.get('template', {}).get('proxmox', {}) \
.get('installer_profile', {}).get('volume_owner') or {}
@@ -338,7 +442,7 @@ def resolve_volume_owner(service, owner_strategy):
return (puid + offset, pgid + offset)
def apply_volume_preparation(service, mount, target):
def apply_volume_preparation(service, mount, target, imported_owner=None):
"""Declarative volume_preparations for one already-attached (but
still empty/freshly-mounted) mount, mirroring install_oci.sh's
apply_installer_profile() semantics (only_when_mount_type /
@@ -381,7 +485,7 @@ def apply_volume_preparation(service, mount, target):
if lost.is_dir() and not lost.is_symlink():
lost.rmdir()
owner_strategy = match.get('owner_strategy')
owner = resolve_volume_owner(service, owner_strategy)
owner = resolve_volume_owner(service, owner_strategy, imported_owner)
log(LOG, f"Volume prepared before the first start: {mount['container_path']}")
return True, owner
@@ -390,6 +494,7 @@ def attach_mounts(service, temporary):
"""Populate new managed volumes from the image, preserving its ownership."""
vmid = service['vmid']
root = Path(f'/var/lib/lxc/{vmid}/rootfs')
imported_owner = None
for index, mount in enumerate(service['deployment']['mounts']):
target = root / mount['container_path'].lstrip('/')
seed = temporary / f'seed-{vmid}-{index}'
@@ -398,6 +503,8 @@ def attach_mounts(service, temporary):
try:
if not target.resolve().is_relative_to(root.resolve()) or target.is_symlink():
raise RuntimeError(translate('Unsafe volume path'))
if imported_owner is None:
imported_owner = runtime_volume_owner(service, root)
if target.is_dir():
stat = target.stat()
owner = (stat.st_uid,stat.st_gid,stat.st_mode & 0o777)
@@ -437,7 +544,7 @@ def attach_mounts(service, temporary):
if mount['type']=='managed-volume':
run('pct','mount',vmid)
try:
applied, declared_owner = apply_volume_preparation(service, mount, target)
applied, declared_owner = apply_volume_preparation(service, mount, target, imported_owner)
if not applied:
lost=target/'lost+found'
if lost.is_dir() and not lost.is_symlink():
@@ -472,6 +579,13 @@ def wait_web(primary, url):
try:
with opener.open(url,timeout=4) as response:
if response.status<400:
check = primary['healthcheck']
if check.get('type') == 'exec':
remaining = max(1, deadline-time.monotonic())
process = subprocess.run(['pct', 'exec', str(primary['vmid']), '--', *check['argv']],
capture_output=True, timeout=min(30, remaining), check=False)
if process.returncode != 0:
raise RuntimeError('Health check has not succeeded yet')
return
except Exception:
pass
@@ -744,6 +858,12 @@ def finish_independent_suite(services, subnet):
return result
def check_selinux_labels(template, enforcing_path=Path('/sys/fs/selinux/enforce')):
if (template.get('proxmox', {}).get('custom_compose_selinux_labels')
and enforcing_path.is_file() and enforcing_path.read_text().strip() == '1'):
raise RuntimeError(translate('SELinux volume labels cannot be ignored while SELinux is enforcing.'))
def main():
deployment = json.loads(Path(sys.argv[2]).read_text())
if deployment.get('deployment_kind') != 'generic-multi-lxc-stack':
@@ -756,6 +876,7 @@ def main():
return
if os.geteuid()!=0:
raise RuntimeError(translate('The installer must run as root on Proxmox VE'))
check_selinux_labels(json.loads(Path(sys.argv[1]).read_text()))
init_log(deployment.get('stack_name') or 'stack')
services = copy.deepcopy(deployment['services'])
independent = bool(deployment.get('suite_arr'))
@@ -785,7 +906,7 @@ def main():
s['vmid'] = base+s['offset']
s['ip'] = str(subnet.network_address+30+s['offset'])
for alias in s['aliases']:
if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_.-]*',alias):
if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_.-]{0,252}',alias):
raise RuntimeError(translate('Invalid service alias'))
aliases.append({'hostname':alias,'address':s['ip']})
if not independent:
@@ -837,17 +958,18 @@ def main():
if s['main'] or s.get('frontend'):
address = access_address(s.get('frontend_ipv4') or 'dhcp', deployment['network'].get('frontend_gateway'))
run('pct','set',s['vmid'],'--net1',f"name=eth1,bridge={deployment['network']['frontend_bridge']},{address},host-managed=1,firewall=1,type=veth")
if s['healthcheck']['type']=='http':
endpoint = s['healthcheck']['endpoint']
s['healthcheck']['url'] = f"{endpoint['scheme']}://{s['ip']}:{endpoint['port']}{endpoint['path']}"
assign_health_address(s)
# Existing CT/network configurations now reserve every allocated ID and subnet.
# Never retain the allocation lock while startup/hooks wait for another registry operation.
fcntl.flock(lock,fcntl.LOCK_UN)
if independent:
fcntl.flock(lock,fcntl.LOCK_UN)
result = finish_independent_suite(services,subnet)
persist_instances(deployment, services)
refresh_notes(services)
result.update(completion_notes=list(deployment.get('completion_notes',[])), log=LOG)
print('PROXMENUX_RESULT='+base64.b64encode(json.dumps(result).encode()).decode(),flush=True)
return
bind_completed_jobs(services)
hook_spec = Path(tmp)/'lifecycle.json'
write_json(hook_spec,{'schema':1,'stack':deployment['stack_name'],'dependencies':[
{'vmid':s['vmid'],'label':s['name'],'healthcheck':s['healthcheck']} for s in services if not s['main'] and not s.get('deferred_setup')]})
@@ -855,20 +977,15 @@ def main():
msg_info(translate('Installing the stack startup hook...'))
run('bash',HERE/'stack_dependency_hook.sh','--install',primary_id,hook_spec,capture=False)
msg_ok(translate('Stack startup hook installed'))
fcntl.flock(lock,fcntl.LOCK_UN)
msg_info(translate('Starting the main container and its dependencies...') if len(services) > 1
else translate('Starting the container...'))
run('pct','start',primary_id,capture=False,timeout=600)
msg_ok(f"{translate('Container started')}: CT {primary_id} ({primary['name']})")
endpoint = primary['healthcheck']['endpoint']
url = f"{endpoint['scheme']}://{primary['ip']}:{endpoint['port']}{endpoint['path']}"
msg_info(translate('Waiting for the application to respond...'))
wait_web(primary,url)
addresses=run('lxc-info','-n',primary_id,'-iH').splitlines()
lan = next((a for a in addresses if re.fullmatch(r'\d+\.\d+\.\d+\.\d+',a) and ipaddress.ip_address(a) not in subnet),None)
if not lan:
raise RuntimeError(translate('No LAN address was obtained'))
public_url = f"{endpoint['scheme']}://{lan}:{endpoint['port']}{endpoint['path']}"
public_url = wait_main_web(primary, subnet)
url = public_url
lan = urllib.parse.urlsplit(public_url).hostname
msg_ok(f"{translate('Application responding:')} {public_url}")
if primary['public_environment']:
msg_info(translate('Applying the LAN address to the application URLs...'))
+6 -1
View File
@@ -994,6 +994,11 @@ prepare_nvidia_driver_links() {
source "$SCRIPT_DIR/oci_nvidia_setup.sh"
oci_valid_extra_hostname() {
# Compose network aliases may contain underscores; each entry stays one hosts-file token.
[[ ${#1} -le 253 && $1 =~ ^[A-Za-z0-9][A-Za-z0-9_.-]*$ ]]
}
apply_extra_hosts() {
local count rootfs hosts_file temporary address hostname bridge_address failed=0
count=$(jq '.extra_hosts? // [] | length' "$DEPLOYMENT_FILE")
@@ -1021,7 +1026,7 @@ apply_extra_hosts() {
|| { oci_log "Could not resolve host-gateway on $BRIDGE"; failed=1; break; }
address=$bridge_address
fi
[[ $hostname =~ ^[A-Za-z0-9][A-Za-z0-9.-]*$ ]] \
oci_valid_extra_hostname "$hostname" \
|| { oci_log "Invalid extra hostname: $hostname"; failed=1; break; }
[[ $address =~ ^[0-9A-Fa-f:.]+$ ]] \
|| { oci_log "Invalid extra host address: $address"; failed=1; break; }
+479
View File
@@ -0,0 +1,479 @@
#!/usr/bin/env python3
"""Verify native OCI one-shot completion using authenticated raw LXC events.
No image wrapper, shell injection or STOPPED-state inference is used. The
supported monitor ABI was verified against upstream LXC v7.0.0 monitor.h,
monitor.c and tools/lxc_monitor.c. The textual CLI loses signal information.
This standalone helper is copied beside the persistent dependency hook.
"""
from __future__ import annotations
import argparse
import contextlib
import datetime
import fcntl
import hashlib
import json
import os
from pathlib import Path
import re
import select
import socket
import stat
import struct
import subprocess
import tempfile
import time
import urllib.parse
import uuid
STATE_ROOT = Path('/var/lib/proxmenux/oci/jobs')
INSTANCE_ROOT = Path('/usr/local/share/proxmenux/oci/instances')
PCT = '/usr/sbin/pct'
MONITOR = '/usr/bin/lxc-monitor'
LXC_PATH = '/var/lib/lxc'
FRAME = struct.Struct('@i256si')
SUPPORTED_VERSIONS = frozenset({'7.0.0'})
DIGEST = re.compile(r'sha256:[0-9a-f]{64}\Z')
class JobError(RuntimeError):
pass
def _vmid(value):
if type(value) is not int or not 100 <= value <= 999999999:
raise JobError('Invalid OCI job VMID')
return value
def _root():
if os.geteuid() != 0:
raise JobError('OCI job execution requires root')
def _trusted_read(path, *, owner=0):
"""Open a root-owned regular file without following its final symlink."""
fd = os.open(path, os.O_RDONLY | os.O_CLOEXEC | os.O_NOFOLLOW)
try:
info = os.fstat(fd)
if not stat.S_ISREG(info.st_mode) or info.st_uid != owner or info.st_mode & 0o022:
raise JobError('Untrusted OCI job metadata file')
if info.st_size > 8 * 1024 * 1024:
raise JobError('OCI job metadata file is too large')
with os.fdopen(fd, 'r', encoding='utf-8') as source:
fd = -1
return source.read()
finally:
if fd >= 0:
os.close(fd)
def _private_directory(path):
path.mkdir(parents=True, mode=0o700, exist_ok=True)
info = path.lstat()
if (not stat.S_ISDIR(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o077):
raise JobError('OCI job state directory is not private')
def config_fingerprint(text):
"""Presentation and resource changes do not re-run a completed migration."""
selected = []
for line in text.split('\n'):
if line.startswith('['):
break # Pending/snapshot sections are not the active definition.
key, separator, value = line.partition(': ')
if not separator:
continue
if (key in {'entrypoint', 'env', 'rootfs', 'unprivileged', 'features', 'ostype'}
or re.fullmatch(r'(?:mp|dev)[0-9]+', key)
or key.startswith(('lxc.init.', 'lxc.environment.', 'lxc.mount.',
'lxc.cgroup', 'lxc.apparmor', 'lxc.seccomp'))):
if key == 'rootfs' or re.fullmatch(r'mp[0-9]+', key):
value = ','.join(part for part in value.split(',') if not part.startswith('size='))
selected.append((key, value))
if not any(key == 'rootfs' for key, _ in selected):
raise JobError('OCI job has no root filesystem configuration')
selected.sort(key=lambda item: item[0])
return hashlib.sha256(json.dumps(selected, separators=(',', ':'), ensure_ascii=True).encode()).hexdigest()
def _config_text(vmid):
# Validate the actual PVE file's ownership; use the same API rendering
# whose exact hash the coordinated transaction authorizes.
_trusted_read(Path('/etc/pve/lxc') / f'{vmid}.conf')
result = subprocess.run([PCT, 'config', str(vmid)], capture_output=True, check=True, timeout=15)
return result.stdout.decode('utf-8')
def _record_digest(record, config_text, *, executing=False):
pending = record.get('pending_transaction')
if not pending:
return record.get('observed', {}).get('resolved_registry_digest')
vmid = _vmid(record.get('vmid'))
if record.get('status') != 'updating' or not record.get('pending_stack_transaction'):
raise JobError('OCI job has no authorized coordinated update context')
journal = Path(pending)
try:
relative = journal.relative_to(INSTANCE_ROOT / str(vmid) / 'transactions')
except ValueError as error:
raise JobError('OCI job transaction is outside its private instance state') from error
if (len(relative.parts) != 2 or relative.parts[1] != 'transaction.json'
or not re.fullmatch(r'[0-9a-f]{32}', relative.parts[0])):
raise JobError('Invalid OCI job transaction path')
child = json.loads(_trusted_read(journal))
coordinated = child.get('coordinated') or {}
if (child.get('schema_version') != 1 or child.get('id') != record.get('transaction_id')
or child.get('id') != relative.parts[0] or child.get('vmid') != vmid
or child.get('phase') not in {'candidate-installed', 'health-passed', 'committing'}
or child.get('record', {}).get('installation_id') != record.get('installation_id')
or coordinated.get('journal') != record.get('pending_stack_transaction')
or child.get('staged_config_sha256') != hashlib.sha256(config_text.encode()).hexdigest()):
raise JobError('OCI job staged image is not bound to this actual container configuration')
stack_journal = Path(record['pending_stack_transaction'])
try:
stack_path = stack_journal.relative_to(INSTANCE_ROOT)
except ValueError as error:
raise JobError('OCI job stack transaction is outside private instance state') from error
if (len(stack_path.parts) != 4 or not stack_path.parts[0].isdigit()
or stack_path.parts[1] != 'stack-transactions'
or not re.fullmatch(r'[0-9a-f]{32}', stack_path.parts[2])
or stack_path.parts[3] != 'transaction.json'):
raise JobError('Invalid OCI job stack transaction path')
stack = json.loads(_trusted_read(stack_journal))
phases = {'starting', 'checking', 'publishing', 'recovering'}
if not executing:
phases.add('replacing')
members = stack.get('plan', {}).get('members') or []
if (stack.get('schema_version') != 1 or stack.get('id') != coordinated.get('id')
or stack.get('phase') not in phases
or not any(member.get('vmid') == vmid
and member.get('installation_id') == record.get('installation_id')
for member in members)):
raise JobError('OCI job is not authorized by the active stack transaction')
return child.get('registry_digest')
def job_spec_from_record(record, config_text, timeout_seconds=300):
"""Bind a first installation, staged update or restored member for its hook."""
if isinstance(config_text, bytes):
config_text = config_text.decode('utf-8')
if not isinstance(config_text, str) or not isinstance(record, dict):
raise JobError('Invalid OCI job binding input')
if type(timeout_seconds) is not int or not 30 <= timeout_seconds <= 3600:
raise JobError('OCI job timeout must be between 30 and 3600 seconds')
_vmid(record.get('vmid'))
try:
installation_id = str(uuid.UUID(record['installation_id']))
except (KeyError, ValueError, TypeError, AttributeError) as error:
raise JobError('OCI job requires an installation identity') from error
digest = _record_digest(record, config_text)
if not isinstance(digest, str) or not DIGEST.fullmatch(digest):
raise JobError('OCI job requires a verified image digest')
return {'installation_id': installation_id, 'image_digest': digest,
'expected_config_fingerprint': config_fingerprint(config_text),
'timeout_seconds': timeout_seconds}
def current_identity(vmid, spec):
"""Bind the request to both a trusted instance record and the actual CT."""
_vmid(vmid)
if not isinstance(spec, dict):
raise JobError('Invalid OCI job specification')
try:
installation_id = str(uuid.UUID(spec['installation_id']))
except (KeyError, ValueError, TypeError, AttributeError) as error:
raise JobError('OCI job requires an installation identity') from error
digest = spec.get('image_digest')
if not isinstance(digest, str) or not DIGEST.fullmatch(digest):
raise JobError('OCI job requires a verified image digest')
record = json.loads(_trusted_read(INSTANCE_ROOT / str(vmid) / 'oci-compose.json'))
if record.get('vmid') != vmid or record.get('installation_id') != installation_id:
raise JobError('OCI job installation identity changed')
config = _config_text(vmid)
if _record_digest(record, config, executing=True) != digest:
raise JobError('OCI job image digest changed')
description = next((line.partition(': ')[2] for line in config.split('\n')
if line.startswith('description: ')), '')
markers = re.findall(r'proxmenux-instance=([0-9a-fA-F-]{36})', urllib.parse.unquote(description))
if installation_id not in markers:
raise JobError('The CT does not carry the OCI job installation identity')
fingerprint = config_fingerprint(config)
if spec.get('expected_config_fingerprint', fingerprint) != fingerprint:
raise JobError('OCI job runtime configuration changed')
recovery = record.get('recovered_at', '')
if not isinstance(recovery, str):
raise JobError('Invalid OCI job recovery generation')
return {'vmid': vmid, 'installation_id': installation_id, 'image_digest': digest,
'config_fingerprint': fingerprint, 'recovery_generation': recovery}
def _status(vmid):
result = subprocess.run([PCT, 'status', str(vmid)], capture_output=True, text=True, timeout=15)
value = result.stdout.strip()
if result.returncode or value not in {'status: stopped', 'status: running'}:
raise JobError('Cannot establish the OCI job container state')
return value.removeprefix('status: ')
def _socket_name(lxcpath=LXC_PATH):
value = 14695981039346656037
for byte in ('lxc/'+lxcpath+'/monitor-sock').encode():
value = ((value ^ byte) * 1099511628211) & ((1 << 64)-1)
return f'lxc/{value:016x}/{lxcpath}'
@contextlib.contextmanager
def _subscription():
version = subprocess.run([MONITOR, '--version'], capture_output=True, text=True,
check=True, timeout=10).stdout.strip()
if version not in SUPPORTED_VERSIONS or FRAME.size != 264:
raise JobError('Unsupported native LXC monitor ABI; refusing to guess job completion')
# This CLI only bootstraps monitord when no other container has started it.
# Its lossy textual exit status is NEVER consumed.
primer = subprocess.Popen([MONITOR, '-n', '^$'], stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
close_fds=True)
receiver = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
try:
receiver.settimeout(1)
deadline = time.monotonic()+5
name = _socket_name()
while True:
try:
receiver.connect(b'\0'+name.encode())
break
except (ConnectionRefusedError, FileNotFoundError):
if time.monotonic() >= deadline:
raise JobError('Native LXC monitor subscription could not be established')
time.sleep(.05)
peer = struct.unpack('3i', receiver.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED,
struct.calcsize('3i')))
pid, uid, gid = peer
executable = str(Path(f'/proc/{pid}/exe').resolve())
if uid != 0 or gid != 0 or executable not in {
'/usr/libexec/lxc/lxc-monitord', '/usr/lib/lxc/lxc-monitord'}:
raise JobError('Native LXC monitor peer is not the trusted root daemon')
receiver.setblocking(False)
yield receiver, {'version': version, 'peer_uid': uid, 'peer_gid': gid,
'peer_executable': executable, 'protocol': 'lxc-msg-native264'}
finally:
receiver.close()
primer.terminate()
try:
primer.wait(timeout=3)
except subprocess.TimeoutExpired:
primer.kill()
primer.wait(timeout=3)
def _frames(buffer):
messages = []
while len(buffer) >= FRAME.size:
chunk, buffer = buffer[:FRAME.size], buffer[FRAME.size:]
kind, name, value = FRAME.unpack(chunk)
if kind not in {0, 1, 2}:
raise JobError('Unknown native LXC monitor event')
try:
name = name.split(b'\0', 1)[0].decode('ascii')
except UnicodeDecodeError as error:
raise JobError('Malformed native LXC monitor event') from error
messages.append((kind, name, value))
return messages, buffer
def _run_attempt(vmid, deadline):
with _subscription() as (receiver, proof):
# Drain traffic before our start; events from unrelated guests do not
# become evidence for this job. There is no historical event replay.
pending = b''
while select.select([receiver], [], [], 0)[0]:
data = receiver.recv(8192)
if not data:
raise JobError('Native LXC monitor closed before job start')
messages, pending = _frames(pending+data)
if any(name == str(vmid) for _, name, _ in messages):
raise JobError('OCI job changed state before its start attempt')
started = False
# Fast successful PID1 jobs can make pct start return 255 because
# Proxmox can no longer query the PID. Only raw exit evidence decides.
process = subprocess.Popen([PCT, 'start', str(vmid)], stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
close_fds=True)
try:
while time.monotonic() < deadline:
if not select.select([receiver], [], [], min(1, max(.01, deadline-time.monotonic())))[0]:
continue
data = receiver.recv(8192)
if not data:
raise JobError('Native LXC monitor closed without a job exit event')
messages, pending = _frames(pending+data)
for kind, name, value in messages:
if name != str(vmid):
continue
if kind == 0 and value in {1, 2}:
started = True
if kind != 2:
continue
if not started or not 0 <= value <= 65535:
raise JobError('No fresh, valid native OCI job exit proof')
if not os.WIFEXITED(value):
reason = f'signal {os.WTERMSIG(value)}' if os.WIFSIGNALED(value) else 'unknown wait status'
raise JobError(f'OCI job CT {vmid} did not complete normally ({reason})')
exitcode = os.WEXITSTATUS(value)
if exitcode != 0:
raise JobError(f'OCI job CT {vmid} exited with code {exitcode}')
while _status(vmid) != 'stopped':
if time.monotonic() >= deadline:
raise JobError('OCI job reported exit zero but did not stop')
time.sleep(.05)
process.wait(timeout=max(.1, min(5, deadline-time.monotonic())))
return {**proof, 'raw_waitstatus': value, 'exit_code': exitcode,
'fresh_start_observed': True, 'pct_start_returncode': process.returncode}
raise JobError('OCI job timed out without verified native exit zero')
finally:
if process.poll() is None:
process.terminate()
try:
process.wait(timeout=3)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=3)
def _cache_matches(cache, identity):
return (isinstance(cache, dict) and cache.get('schema') == 1
and cache.get('status') == 'completed' and cache.get('identity') == identity
and isinstance(cache.get('proof'), dict)
and type(cache['proof'].get('raw_waitstatus')) is int
and cache['proof']['raw_waitstatus'] == 0
and cache['proof'].get('fresh_start_observed') is True)
def _read_cache(path):
try:
return json.loads(_trusted_read(path))
except FileNotFoundError:
return None
def _sync_directory(path):
descriptor = os.open(path, os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC)
try:
os.fsync(descriptor)
finally:
os.close(descriptor)
def _write_cache(path, value):
descriptor, name = tempfile.mkstemp(prefix='.'+path.name+'.', dir=path.parent)
try:
with os.fdopen(descriptor, 'w', encoding='utf-8') as output:
json.dump(value, output, sort_keys=True)
output.write('\n')
output.flush()
os.fsync(output.fileno())
os.replace(name, path)
_sync_directory(path.parent)
finally:
with contextlib.suppress(FileNotFoundError):
os.unlink(name)
@contextlib.contextmanager
def _job_lock(vmid, deadline):
_private_directory(STATE_ROOT)
fd = os.open(STATE_ROOT/f'{vmid}.lock', os.O_CREAT|os.O_RDWR|os.O_NOFOLLOW|os.O_CLOEXEC, 0o600)
try:
info = os.fstat(fd)
if not stat.S_ISREG(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o077:
raise JobError('Untrusted OCI job lock file')
while True:
try:
fcntl.flock(fd, fcntl.LOCK_EX|fcntl.LOCK_NB)
break
except BlockingIOError:
if time.monotonic() >= deadline:
raise JobError('Timed out waiting for another OCI job attempt')
time.sleep(.05)
yield
finally:
os.close(fd)
def ensure_completed(vmid, job_spec, force=False):
_root()
_vmid(vmid)
if type(force) is not bool:
raise JobError('Invalid OCI job force flag')
timeout = job_spec.get('timeout_seconds', 300) if isinstance(job_spec, dict) else None
if type(timeout) is not int or not 30 <= timeout <= 3600:
raise JobError('OCI job timeout must be between 30 and 3600 seconds')
deadline = time.monotonic()+timeout
with _job_lock(vmid, deadline):
identity = current_identity(vmid, job_spec)
if _status(vmid) != 'stopped':
raise JobError('OCI job is already running without this attempt\'s exit subscription')
path = STATE_ROOT/f'{vmid}.json'
cache = _read_cache(path)
if not force and _cache_matches(cache, identity):
return {'status': 'completed', 'cached': True, **identity, 'proof': cache['proof']}
# A forced failed retry must never leave an earlier success reusable.
if path.exists():
path.unlink()
_sync_directory(path.parent)
proof = _run_attempt(vmid, deadline)
if current_identity(vmid, job_spec) != identity:
raise JobError('OCI job identity or configuration changed during execution')
if _status(vmid) != 'stopped':
raise JobError('OCI job restarted before completion could be recorded')
value = {'schema': 1, 'status': 'completed', 'identity': identity, 'proof': proof,
'attempt_id': str(uuid.uuid4()),
'completed_at': datetime.datetime.now(datetime.timezone.utc).isoformat()}
_write_cache(path, value)
return {'status': 'completed', 'cached': False, **identity, 'proof': proof}
def forget_completed(vmid, installation_id):
"""Remove only this installation's completion, never a reused VMID's state."""
_vmid(vmid)
try:
installation_id = str(uuid.UUID(installation_id))
except (ValueError, TypeError, AttributeError):
return False # Older non-job records may use a legacy identity format.
if not STATE_ROOT.exists():
return False
_root()
with _job_lock(vmid, time.monotonic()+30):
path = STATE_ROOT/f'{vmid}.json'
cache = _read_cache(path)
if (not isinstance(cache, dict) or not isinstance(cache.get('identity'), dict)
or cache['identity'].get('installation_id') != installation_id):
return False
path.unlink()
_sync_directory(path.parent)
# The stable lock inode stays: unlinking it could split concurrent
# waiters across two locks when this numeric VMID is reused.
return True
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('operation', choices=['ensure'])
parser.add_argument('vmid', type=int)
parser.add_argument('--spec-json', required=True)
parser.add_argument('--force', action='store_true')
args = parser.parse_args()
try:
if len(args.spec_json) > 65536:
raise JobError('OCI job specification is too large')
result = ensure_completed(args.vmid, json.loads(args.spec_json), force=args.force)
except (JobError, OSError, ValueError, KeyError, subprocess.SubprocessError) as error:
print(str(error), file=__import__('sys').stderr)
return 1
print(json.dumps(result, sort_keys=True))
return 0
if __name__ == '__main__':
raise SystemExit(main())
+18 -3
View File
@@ -15,6 +15,7 @@ import re
import subprocess
import sys
import tempfile
import time
import uuid
from oci_installation_state import command, image_from_archive, private_directory, sha
@@ -42,8 +43,22 @@ def location(root, vmid):
return path / 'oci-compose.json'
def _acquire_lock(fd, timeout):
"""Wait only for acquisition; a failure inside the operation is never replayed."""
deadline = time.monotonic() + max(0, float(timeout))
while True:
try:
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
return
except BlockingIOError:
remaining = deadline - time.monotonic()
if remaining <= 0:
raise
time.sleep(min(0.2, remaining))
@contextmanager
def locked(root):
def locked(root, timeout=0):
private_directory(root)
path = root / '.lock'
if path.is_symlink():
@@ -53,12 +68,12 @@ def locked(root):
fd = int(inherited)
if os.fstat(fd).st_ino != path.stat().st_ino or os.fstat(fd).st_dev != path.stat().st_dev:
raise ValueError(translate('Wrong inherited registry lock'))
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
_acquire_lock(fd, timeout)
yield
else:
with path.open('a') as lock:
os.chmod(path, 0o600)
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
_acquire_lock(lock, timeout)
yield
+3
View File
@@ -19,6 +19,7 @@ import sys
import oci_image_cache as image_cache
import oci_work_backup as work_backup
import oci_instances as instances
import oci_compose_jobs as compose_jobs
from oci_installation_state import parse_config
import oci_console
import oci_runtime_settings as runtime_settings
@@ -365,6 +366,8 @@ def remove(root, vmid):
if lifecycle.exists() and not lifecycle.is_symlink():
lifecycle.unlink()
for member in members:
record = instances.read(root, member)
compose_jobs.forget_completed(member, record['installation_id'])
directory = instances.location(root, member).parent
work_backup.forget(root, member)
if directory.is_dir() and not directory.is_symlink():
+78
View File
@@ -32,6 +32,8 @@ import tempfile
import oci_carried_record as carried
import oci_console
import oci_instances as instances
import oci_compose_jobs as compose_jobs
import oci_stack_plan
import oci_instance_transaction as transaction
import oci_nested_mounts
import oci_nvidia_refresh
@@ -660,6 +662,19 @@ def valid_contract(contract, dependents):
return False
except ValueError:
return False
elif check.get('type') == 'tcp':
try:
if (not isinstance(check.get('host'), str)
or ipaddress.IPv4Address(check['host']) not in PRIVATE
or type(check.get('port')) is not int or not 0 < check['port'] < 65536):
return False
except ValueError:
return False
elif check.get('type') == 'completed':
try:
oci_stack_plan.validate_job_check(check)
except (ValueError, TypeError, KeyError):
return False
elif check.get('type') != 'running':
return False
return True
@@ -903,6 +918,7 @@ def register(root, plan):
# leaves it; this also proves the stack can still be updated.
oci_stack_modify.register(root, vmid)
refresh_service_plans(root, [vmid for vmid, stack in written if stack])
refresh_completed_jobs(root, plan)
except BaseException:
for vmid, _ in written:
path = instances.location(root, vmid)
@@ -945,6 +961,65 @@ def refresh_service_plans(root, vmids):
instances.write(instances.location(root, vmid), record)
def refresh_completed_jobs(root, plan):
"""Rebind restored job proofs before a start, including a deferred start."""
primaries = set()
for entry in plan['restored']:
record = instances.read(root, entry['vmid'])
primary = record.get('stack_member', {}).get('primary_vmid')
if record.get('stack'):
primary = record['vmid']
if primary is not None:
primaries.add(primary)
for primary_id in primaries:
primary = instances.read(root, primary_id)
services = primary.get('stack', {}).get('deployment', {}).get('services', [])
jobs = [service for service in services if service.get('healthcheck', {}).get('type') == 'completed']
if not jobs:
continue
for service in jobs:
vmid = service['vmid']
record = instances.read(root, vmid)
if (record['deployment'].get('onboot') or record['deployment'].get('watchdog')
or record['deployment'].get('execution_mode') != 'one-shot'):
raise ValueError(translate('Invalid service check arguments'))
config = checked('pct', 'config', str(vmid))
# Any restored member may include database data from before the cached proof.
record['recovered_at'] = instances.now()
instances.write(instances.location(root, vmid), record)
timeout = service['healthcheck'].get('timeout_seconds', 300)
service['healthcheck'] = {'type': 'completed', 'timeout_seconds': timeout,
'job': compose_jobs.job_spec_from_record(record, config, timeout)}
dependencies = [{'vmid': service['vmid'], 'label': service['name'],
'healthcheck': service['healthcheck']} for service in services
if service['vmid'] != primary_id and not service.get('deferred_setup')]
contract = {'schema': 1, 'stack': primary['stack']['deployment'].get('stack_name', ''),
'dependencies': dependencies}
with tempfile.NamedTemporaryFile('w', suffix='.json') as file:
json.dump(contract, file)
file.flush()
checked('bash', str(ENGINE / 'stack_dependency_hook.sh'), '--install', str(primary_id), file.name)
for entry in plan['restored']:
if entry['vmid'] == primary_id:
entry['contract'] = contract
primary['stack']['deployment']['services'] = services
members = primary['stack'].get('members', [])
for index, member in enumerate(members):
if any(service['vmid'] == member.get('vmid') for service in jobs):
current = copy.deepcopy(instances.read(root, member['vmid']))
current.pop('stack', None)
members[index] = current
instances.write(instances.location(root, primary_id), primary)
def completed_job(root, vmid):
record = instances.read(root, vmid)
primary = record.get('stack_member', {}).get('primary_vmid', vmid)
main = record if primary == vmid else instances.read(root, primary)
return any(service.get('vmid') == vmid and service.get('healthcheck', {}).get('type') == 'completed'
for service in main.get('stack', {}).get('deployment', {}).get('services', []))
def start_order(entry):
order = re.search(r'^startup: .*?order=([0-9]+)', entry['config'], re.MULTILINE)
return int(order[1]) if order else 9999
@@ -990,6 +1065,9 @@ def recover(root, plan, start=False, host_firewall=()):
msg_warn(f"{translate('Could not add the confirmed host firewall rule')}: {text}")
if start and not plan['hold']:
for entry in sorted(plan['restored'], key=start_order):
if completed_job(root, entry['vmid']):
# The main hook executes jobs in graph order with authenticated exit proofs.
continue
if 'running' in run('pct', 'status', str(entry['vmid'])).stdout:
continue
msg_info(f"{translate('Starting the container...')} CT {entry['vmid']}")
+140 -6
View File
@@ -3,6 +3,7 @@
import argparse
import copy
import json
import ipaddress
import os
from pathlib import Path
import socket
@@ -12,6 +13,7 @@ import time
import uuid
import oci_image_cache as image_cache
import oci_compose_jobs as compose_jobs
import oci_instances as instances
import oci_instance_transaction as member_tx
import oci_stack_plan
@@ -23,6 +25,17 @@ from oci_update_current import resolve_archive
from oci_ui import translate, msg_info, msg_ok, msg_warn, msg_error
def tcp_target(check):
"""Saved dependency probes only connect to numeric addresses and real TCP ports."""
host, port = check.get('host'), check.get('port')
if not isinstance(host, str) or type(port) is not int or not 0 < port < 65536:
raise ValueError(translate('Invalid service check arguments'))
try:
return str(ipaddress.IPv4Address(host)), port
except ValueError as error:
raise ValueError(translate('Invalid service check arguments')) from error
def validate_database_transition(previous, candidate):
def major(image):
values = image.get('defaults', {}).get('Env') or []
@@ -206,8 +219,29 @@ class NativeAdapter:
if pending and pending != str(self.journal):
raise ValueError(translate('Another stack operation is pending'))
def _migration_backups(self):
if not any(self.is_job(vmid) for vmid in self.services):
return
database_kinds = {'postgres', 'mariadb', 'linuxserver/mariadb', 'mongo',
'getmeili/meilisearch', 'redis', 'valkey/valkey'}
markers = ('PG_MAJOR=', 'MYSQL_MAJOR=', 'MYSQL_VERSION=', 'MARIADB_MAJOR=',
'MARIADB_VERSION=', 'MONGO_VERSION=', 'MONGODB_VERSION=')
for vmid, record in self.records.items():
service = self.services[vmid]
if self.is_job(vmid):
continue
environment = record.get('observed', {}).get('image', {}).get('defaults', {}).get('Env') or []
database = service.get('kind') in database_kinds or any(
isinstance(value, str) and value.startswith(markers) for value in environment)
unknown_backend = vmid != self.plan['primary_vmid'] and not service.get('frontend')
external = any(mount.get('type') == 'host-bind' and not mount.get('read_only')
for mount in record['deployment'].get('mounts', []))
if external and (database or unknown_backend):
raise ValueError(translate('Migration jobs cannot safely restore database or unclassified backend data from host directories'))
def preflight(self):
self.validate(self.plan)
self._migration_backups()
ha = json.loads(instances.command('pvesh', 'get', '/cluster/ha/resources', '--output-format', 'json'))
if not isinstance(ha, list) or any(r.get('sid') == 'ct:%s' % vmid for r in ha for vmid in self.records):
raise ValueError(translate('High availability resources are not supported for stacks'))
@@ -220,7 +254,7 @@ class NativeAdapter:
member_tx.preflight(record, record, config, coordinated=True)
member_tx.freeze_host_sources(record, record, self.acknowledge)
check = self.services[vmid].get('healthcheck', {})
if check.get('type') not in ('exec', 'http', 'running'):
if check.get('type') not in ('exec', 'http', 'running', 'tcp', 'completed'):
raise ValueError(translate('A member has no reproducible service check'))
if check['type'] == 'exec' and not check.get('argv'):
raise ValueError(translate('Empty exec service check'))
@@ -231,6 +265,16 @@ class NativeAdapter:
raise ValueError(translate('HTTP service check without a saved URL'))
if check['type'] == 'http' and not check['url'].startswith(('http://', 'https://')):
raise ValueError(translate('Invalid service check URL'))
if check['type'] == 'tcp':
tcp_target(check)
if check['type'] == 'completed':
oci_stack_plan.validate_job_check(check, record)
expected = compose_jobs.job_spec_from_record(record, config.decode(), check['timeout_seconds'])
if (check['job'] != expected or record['deployment'].get('onboot')
or record['deployment'].get('watchdog')
or record['deployment'].get('execution_mode') != 'one-shot'
or parse_config(config).get('onboot', '0') != '0' or self.is_running(vmid)):
raise ValueError(translate('Invalid service check arguments'))
if not 0 < int(check.get('timeout_seconds', 120)) <= 3600:
raise ValueError(translate('Invalid service check timeout'))
primary_id = self.plan['primary_vmid']
@@ -382,12 +426,83 @@ class NativeAdapter:
progress=f"{translate('Recreating') if operation == 'recreate' else translate('Updating')} "
f"{self.describe(vmid)}:")
def is_job(self, vmid):
return self.services[vmid].get('healthcheck', {}).get('type') == 'completed'
def _job_contract(self, rollback=False):
"""Rebind jobs to staged/restored images and journal the hook transition."""
if not any(self.is_job(vmid) for vmid in self.services):
return
primary = self.plan['primary_vmid']
path = Path('/etc/pve/priv/proxmenux-stack-%s.json' % primary)
info = path.lstat()
if not stat.S_ISREG(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o077:
raise ValueError(translate('Unsafe dependency hook contract'))
current = json.loads(path.read_text())
operation = self.state()['id']
marker = self.journal.with_name('completed-job-contract.json')
if marker.is_symlink():
raise ValueError(translate('Unsafe dependency hook contract'))
state = {'schema': 1, 'operation_id': operation}
if marker.exists():
info = marker.lstat()
if not stat.S_ISREG(info.st_mode) or info.st_uid != os.geteuid() or info.st_mode & 0o077:
raise ValueError(translate('Unsafe dependency hook contract'))
state = json.loads(marker.read_text())
if state.get('schema') != 1 or state.get('operation_id') != operation:
raise ValueError(translate('The dependency hook and the stack recipe differ'))
original = next(member for member in self.plan['members'] if member['vmid'] == primary)
original_services = original['stack']['deployment']['services']
expected = state.get('job_hook_contract')
intent = state.get('job_hook_contract_intent')
if expected is None:
expected = {'schema': 1, 'stack': original['stack']['deployment'].get('stack_name', '')}
expected['dependencies'] = [{'vmid': s['vmid'], 'label': s['name'], 'healthcheck': s['healthcheck']}
for s in original_services if s['vmid'] != primary and not s.get('deferred_setup')]
accepted = [expected]
if intent is not None:
accepted.extend([intent['before'], intent['after']])
if current not in accepted:
raise ValueError(translate('The dependency hook and the stack recipe differ'))
if rollback:
self.services = {s['vmid']: copy.deepcopy(s) for s in original_services}
for vmid, service in self.services.items():
if not self.is_job(vmid):
continue
record = instances.read(self.root, vmid)
config = member_tx.owned(vmid, record['installation_id']).decode()
timeout = service['healthcheck'].get('timeout_seconds', 300)
service['healthcheck'] = {'type': 'completed', 'timeout_seconds': timeout,
'job': compose_jobs.job_spec_from_record(record, config, timeout)}
rebuilt = copy.deepcopy(current)
rebuilt['dependencies'] = [{'vmid': s['vmid'], 'label': s['name'], 'healthcheck': s['healthcheck']}
for s in self.services.values() if s['vmid'] != primary and not s.get('deferred_setup')]
if current != rebuilt or intent is not None:
state['job_hook_contract_intent'] = {'before': current, 'after': rebuilt}
instances.write(marker, state)
instances.write(path, rebuilt)
state['job_hook_contract'] = rebuilt
state.pop('job_hook_contract_intent', None)
instances.write(marker, state)
def _ensure_job(self, vmid, force=False):
self._job_contract()
return compose_jobs.ensure_completed(vmid, self.services[vmid]['healthcheck']['job'], force=force)
def start(self, vmid):
self.validate(self.plan)
if self.is_job(vmid):
self._ensure_job(vmid)
return
if vmid == self.plan['primary_vmid']:
self._job_contract()
if not self.is_running(vmid):
member_tx.run('pct', 'start', str(vmid))
def healthcheck(self, vmid):
if self.is_job(vmid):
self._ensure_job(vmid)
return
check = self.services[vmid]['healthcheck']
timeout = int(check.get('timeout_seconds', 120))
if not 0 < timeout <= 3600:
@@ -401,6 +516,9 @@ class NativeAdapter:
member_tx.run('pct', 'exec', str(vmid), '--', *check['argv'])
elif check['type'] == 'http':
member_tx.run('curl', '-fsS', '--noproxy', '*', '--max-time', '5', check['url'])
elif check['type'] == 'tcp':
with socket.create_connection(tcp_target(check), timeout=5):
pass
member_tx.gpu_devices.validate_runtime(vmid, self.records[vmid]['deployment'])
deployment = self.records[vmid]['deployment']
if deployment.get('replay_profile') == {'adapter': 'install_immich_stack.sh', 'role': 'machine-learning'}:
@@ -419,7 +537,7 @@ class NativeAdapter:
'assert (any(d.startswith("GPU") for d in ort.capi._pybind_state.get_available_openvino_device_ids()) '
'if p=="openvino" else ctypes.CDLL("libcuda.so.1").cuInit(0)==0)', acceleration)
return
except RuntimeError:
except (RuntimeError, OSError):
time.sleep(2)
raise ValueError(f"{translate('A member did not pass its service check:')} {self.describe(vmid)}")
@@ -441,12 +559,22 @@ class NativeAdapter:
instances.write(journal, child)
def restore_running_state(self, running, order):
recovering = self.state().get('phase') == 'recovering'
has_jobs = any(self.is_job(vmid) for vmid in order)
for vmid in order:
if self.is_job(vmid):
if recovering and running.get(str(self.plan['primary_vmid'])):
self._ensure_job(vmid, force=True)
continue
if running[str(vmid)]:
self.start(vmid)
for vmid in order:
if running[str(vmid)]:
self.healthcheck(vmid)
if has_jobs:
# A rollback migration may require the restored database to be ready.
self.healthcheck(vmid)
if not has_jobs:
for vmid in order:
if running[str(vmid)]:
self.healthcheck(vmid)
# Starting the primary can start dependencies through its native hook.
for vmid in reversed(order):
if not running[str(vmid)]:
@@ -465,7 +593,7 @@ class NativeAdapter:
# Keep child journals available even if publication is interrupted.
links = self.journal.parent / ('member-%s.json' % vmid)
instances.write(links, {'journal': str(journal)})
member_tx.run('pct', 'set', str(vmid), '--onboot', '1' if original['deployment']['onboot'] else '0')
member_tx.run('pct', 'set', str(vmid), '--onboot', '1' if original['deployment']['onboot'] and not self.is_job(vmid) else '0')
after = member_tx.owned(vmid, original['installation_id'])
if ([line for line in before.splitlines() if not line.startswith(b'onboot: ')]
!= [line for line in after.splitlines() if not line.startswith(b'onboot: ')]):
@@ -476,6 +604,7 @@ class NativeAdapter:
primary_id = self.plan['primary_vmid']
primary = instances.read(self.root, primary_id)
primary['stack']['members'] = []
primary['stack']['deployment']['services'] = copy.deepcopy(list(self.services.values()))
for vmid in self.plan['start_order']:
record = instances.read(self.root, vmid)
record.pop('stack', None)
@@ -555,8 +684,12 @@ class NativeAdapter:
if any(self.plan.get(flag) for flag in ('nextcloud_replay', 'paperless_replay', 'tandoor_replay', 'immich_replay')):
record['deployment']['native_config'] = record['observed']['config']
record['deployment']['member_replay_projection'] = replay.normalize(record)
if self.is_job(vmid) and self.state().get('replacement_intent'):
# Restored database contents invalidate an earlier completion proof too.
record['recovered_at'] = instances.now()
record['pending_stack_transaction'] = str(self.journal)
instances.write(instances.location(self.root, vmid), record)
self._job_contract(rollback=True)
primary_id = plan['primary_vmid']
primary = instances.read(self.root, primary_id)
snapshots = []
@@ -566,6 +699,7 @@ class NativeAdapter:
snapshot.pop('pending_stack_transaction', None)
snapshots.append(snapshot)
primary['stack']['members'] = snapshots
primary['stack']['deployment']['services'] = copy.deepcopy(list(self.services.values()))
instances.write(instances.location(self.root, primary_id), primary)
def finalize(self, state):
+35
View File
@@ -1,9 +1,37 @@
"""Read-only validation of a coordinated stack operation, before host changes."""
import copy
import re
import uuid
from oci_ui import translate
def validate_job_check(check, record=None):
"""Validate a completion proof request without treating STOPPED as success."""
failure = translate('Invalid service check arguments')
if not isinstance(check, dict) or check.get('type') != 'completed':
raise ValueError(failure)
job = check.get('job')
if not isinstance(job, dict):
raise ValueError(failure)
try:
identity = str(uuid.UUID(job['installation_id']))
except (KeyError, ValueError, TypeError, AttributeError) as error:
raise ValueError(failure) from error
timeout = job.get('timeout_seconds')
if (type(timeout) is not int or not 30 <= timeout <= 3600
or type(check.get('timeout_seconds')) is not int or check['timeout_seconds'] != timeout
or not isinstance(job.get('image_digest'), str)
or not re.fullmatch(r'sha256:[0-9a-f]{64}', job['image_digest'])
or not isinstance(job.get('expected_config_fingerprint'), str)
or not re.fullmatch(r'[0-9a-f]{64}', job['expected_config_fingerprint'])):
raise ValueError(failure)
if record is not None and (record.get('installation_id') != identity
or record.get('observed', {}).get('resolved_registry_digest') != job['image_digest']):
raise ValueError(failure)
return job
def build(primary, records, inventory, operation):
"""Inventory maps VMIDs to installation UUIDs, including unrelated guests.
@@ -48,6 +76,13 @@ def build(primary, records, inventory, operation):
missing.append(vmid)
if current.get('deployment', {}).get('rootfs_adaptation_replay_required'):
blockers.append({'vmid': vmid, 'reason': 'dedicated-adapter-replay-required'})
service = next(service for service in services if service['vmid'] == vmid)
if service.get('healthcheck', {}).get('type') == 'completed':
validate_job_check(service['healthcheck'], current)
if (vmid == primary['vmid'] or current.get('deployment', {}).get('onboot')
or current.get('deployment', {}).get('watchdog')
or current.get('deployment', {}).get('execution_mode') != 'one-shot'):
raise ValueError(translate('Invalid service check arguments'))
members.append(copy.deepcopy(current))
if missing and operation == 'update':
raise ValueError(translate('Stack members are missing; recreate them after verifying their volumes'))
+4 -2
View File
@@ -124,7 +124,8 @@ def watched(root):
try:
vmid = int(path.parent.name)
record = instances.read(root, vmid)
if record.get('status') != 'installed' or record.get('deployment', {}).get('watchdog') is not True:
if (record.get('status') != 'installed' or record.get('deployment', {}).get('watchdog') is not True
or record.get('deployment', {}).get('execution_mode') == 'one-shot'):
continue
if record.get('pending_stack_transaction') or record.get('pending_transaction'):
continue
@@ -326,7 +327,8 @@ def set_watchdog(root, vmid, enabled):
vmids = application(root, vmid)
for member in vmids:
record = instances.read(root, member)
record.setdefault('deployment', {})['watchdog'] = bool(enabled)
deployment = record.setdefault('deployment', {})
deployment['watchdog'] = bool(enabled) and deployment.get('execution_mode') != 'one-shot'
instances.write(instances.location(root, member), record)
oci_carried_record.carry(root, member, mount_stopped=False)
if enabled:
+62 -2
View File
@@ -4,6 +4,7 @@ set -Eeuo pipefail
PATH=/usr/sbin:/usr/bin:/sbin:/bin
HOOK_NAME=proxmenux-stack-dependencies.sh
COMPOSE_JOB_HELPER=/var/lib/vz/snippets/proxmenux-compose-jobs.py
# Longest wait for a dependency that is being backed up, and for any other lock.
BACKUP_LOCK_WAIT=${PROXMENUX_STACK_BACKUP_LOCK_WAIT:-14400}
OTHER_LOCK_WAIT=${PROXMENUX_STACK_OTHER_LOCK_WAIT:-300}
@@ -12,6 +13,8 @@ RESTART_GRACE=${PROXMENUX_STACK_RESTART_GRACE:-20}
POLL=${PROXMENUX_STACK_POLL:-5}
# Earlier published versions of this script, replaced on update.
PREVIOUS_RELEASES=(
4bc477bcc35e229d1c964c9372838f0973cbe6f1e3e41f0fb486e9b724247310
243b0dca81b1b155ca169a4d0e11cdb8ca55974c65ede6a4a9be8f4d536a141e
c28b69acd7cc8e66d3aec3e8b2ec86bda0e4f91193ec78549f1965f2c859c0d1
02c50ef17f2411ef22f4d382c51b34a7a0974a446d4a155ecb63fb1e805a7c59
)
@@ -58,11 +61,40 @@ install_hook() {
(all(.dependencies[];
(.vmid | type == "number") and
(.label | type == "string") and
(.healthcheck.type | IN("exec", "http", "running")) and
(.healthcheck.timeout_seconds | type == "number")
(.healthcheck.type | IN("exec", "http", "running", "tcp", "completed")) and
(.healthcheck.timeout_seconds | type == "number") and
(if .healthcheck.type == "tcp" then
(.healthcheck.host | type == "string") and
(.healthcheck.host | test("^([0-9]{1,3}\\.){3}[0-9]{1,3}$")) and
(.healthcheck.host | split(".") | all(.[]; tonumber < 256)) and
(.healthcheck.port | type == "number") and
(.healthcheck.port > 0 and .healthcheck.port < 65536 and .healthcheck.port == (.healthcheck.port | floor))
elif .healthcheck.type == "completed" then
(.healthcheck.job | type == "object") and
(.healthcheck.job.installation_id | type == "string") and
(.healthcheck.job.installation_id | test("^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")) and
(.healthcheck.job.image_digest | type == "string") and
(.healthcheck.job.image_digest | test("^sha256:[0-9a-f]{64}$")) and
(.healthcheck.job.expected_config_fingerprint | type == "string") and
(.healthcheck.job.expected_config_fingerprint | test("^[0-9a-f]{64}$")) and
(.healthcheck.job.timeout_seconds == .healthcheck.timeout_seconds) and
(.healthcheck.timeout_seconds >= 30 and .healthcheck.timeout_seconds <= 3600 and .healthcheck.timeout_seconds == (.healthcheck.timeout_seconds | floor))
else true end)
))
' "$source_config" >/dev/null || die "Invalid dependency contract"
if jq -e 'any(.dependencies[]; .healthcheck.type == "completed")' "$source_config" >/dev/null; then
local helper_source helper_temporary
helper_source="$(dirname -- "$0")/oci_compose_jobs.py"
[[ -f $helper_source && ! -L $helper_source ]] || die "Native OCI job helper is missing"
install -d -m 0755 "$(dirname -- "$COMPOSE_JOB_HELPER")"
helper_temporary=$(mktemp "${COMPOSE_JOB_HELPER}.XXXXXX")
if ! install -m 0755 "$helper_source" "$helper_temporary" || ! mv -f -- "$helper_temporary" "$COMPOSE_JOB_HELPER"; then
rm -f -- "$helper_temporary"
die "Could not install the persistent native OCI job helper"
fi
fi
storage=$(find_snippet_storage)
hook_volume="${storage}:snippets/${HOOK_NAME}"
hook_path=$(pvesm path "$hook_volume")
@@ -144,6 +176,20 @@ dependency_is_healthy() {
url=$(jq -r '.url' <<<"$healthcheck")
curl -fsS --max-time 3 "$url" >/dev/null 2>&1
;;
tcp)
local host port
host=$(jq -r '.host' <<<"$healthcheck")
port=$(jq -r '.port' <<<"$healthcheck")
python3 - "$host" "$port" <<'PYTCP' >/dev/null 2>&1
import ipaddress, socket, sys
host = str(ipaddress.IPv4Address(sys.argv[1]))
port = int(sys.argv[2])
if not 0 < port < 65536:
raise ValueError('Invalid TCP port')
with socket.create_connection((host, port), timeout=3):
pass
PYTCP
;;
*) return 1 ;;
esac
}
@@ -167,6 +213,20 @@ start_dependencies() {
pct config "$id" >/dev/null 2>&1 \
|| die "Dependency $label (CT $id) does not exist"
if [[ $(jq -r '.type' <<<"$healthcheck") == completed ]]; then
[[ -f $COMPOSE_JOB_HELPER && ! -L $COMPOSE_JOB_HELPER ]] || die "Native OCI job helper is missing"
local helper_owner helper_mode
helper_owner=$(stat -c '%u' "$COMPOSE_JOB_HELPER")
helper_mode=$(stat -c '%a' "$COMPOSE_JOB_HELPER")
[[ $helper_owner == 0 && $helper_mode =~ ^[0-7]{3,4}$ ]] && (( (8#$helper_mode & 8#022) == 0 )) \
|| die "Native OCI job helper is not owned safely by root"
printf 'Waiting for completed job %s (CT %s)...\n' "$label" "$id"
python3 -I "$COMPOSE_JOB_HELPER" ensure "$id" --spec-json "$(jq -c '.job' <<<"$healthcheck")" >/dev/null 9>&- \
|| die "Job $label (CT $id) did not complete successfully"
printf 'Dependency %s (CT %s): ready.\n' "$label" "$id"
continue
fi
is_running "$id" || wait_for_release "$id" "$label"
if ! is_running "$id"; then
printf 'Starting dependency %s (CT %s)...\n' "$label" "$id"
+1 -1
View File
@@ -906,7 +906,7 @@ def convert_casaos_compose(
"environment": "import-image-env-then-apply-compose-overrides",
"user": "import-from-oci-image",
"working_dir": "import-from-oci-image",
"stop_signal": "import-from-oci-image",
"stop_signal": "apply-compose-override" if service.get("stop_signal") is not None else "import-from-oci-image",
},
**(
{"installer_profile": _compose_installer_profile(service, markers["devices"], markers["security_opt"])}
+14 -2
View File
@@ -265,9 +265,18 @@ def _deployment_summary_text(template: dict[str, Any], deployment: dict[str, Any
child = service["deployment"]
lines.append(f" {service['name']}: {child['resources']['cores']} CPU, "
f"{child['resources']['memory_mb']} MB RAM")
check = service.get('healthcheck', {}).get('type')
if check:
label = {'running': translate('Process running only (no health check declared)'),
'completed': translate('Must finish successfully (exit code 0)'),
'exec': translate('Command'), 'http': 'HTTP', 'tcp': 'TCP'}.get(check, check)
lines.append(f" {translate('Startup check')}: {label}")
for mount in child["mounts"]:
target = (f"{mount['size_gb']} GB {on} {mount['source']}"
if mount["type"] == "managed-volume" else mount["source"])
if mount["type"] == "managed-volume" else
f"{translate('Host directory (not included in Proxmox backups)')}: {mount['source']}")
if mount['type'] == 'managed-volume' and mount.get('backup'):
target += f" ({translate('in backups')})"
lines.append(f" {mount['container_path']} → {target}")
for key, label in (("media", N_("Library")), ("application", N_("Application data")), ("transfer", N_("Consume/export"))):
storage = plan.get(key)
@@ -457,7 +466,10 @@ def install_template(ui, template: dict[str, Any], identifier: str, mode: str) -
_print_installation_summary(result, removed)
if watchdog:
from .management import set_watchdog
if set_watchdog(PROJECT_ROOT, sorted(vmids), True):
job_names = {service['name'] for service in deployment.get('services', [])
if service.get('deployment', {}).get('execution_mode') == 'one-shot'}
job_vmids = {int(vmid) for name, vmid in (result.get('stack_vmids') or {}).items() if name in job_names}
if set_watchdog(PROJECT_ROOT, sorted(vmids-job_vmids), True):
console.msg_ok(translate("Watchdog enabled: the application is restarted when it crashes."))
else:
console.msg_warn(translate("The watchdog could not be enabled. Turn it on from Manage installed OCI applications."))
+258
View File
@@ -0,0 +1,258 @@
"""Resolve explicitly supplied Compose inputs without inheriting the host environment.
No command substitutions, shell evaluation or image-specific rewrites are performed.
"""
from __future__ import annotations
import json
from pathlib import Path
import re
import yaml
from .converter import ConversionError, _split_short_mount
from .i18n import translate
NAME = re.compile(r'[A-Za-z_][A-Za-z0-9_]*')
SECRET = re.compile(r'pass|secret|token|key|credential|url', re.I)
LIMIT = 256 * 1024
class Interpolator:
def __init__(self, values=None, missing=None):
self.values = dict(values or {})
self.missing = missing
def expand(self, value, depth=0):
if depth > 20:
raise ConversionError(translate('Unresolved variable:') + ' nesting')
result, i = [], 0
while i < len(value):
if value[i] != '$':
result.append(value[i]); i += 1; continue
if value[i:i+2] == '$$':
result.append('$'); i += 2; continue
if value[i:i+2] == '${':
end, nested = i+2, 1
while end < len(value) and nested:
if value[end:end+2] == '${':
nested += 1; end += 2; continue
if value[end] == '}':
nested -= 1
end += 1
if nested:
raise ConversionError(translate('Unresolved variable:') + ' ${...}')
expression = value[i+2:end-1]
match = re.fullmatch(r'([A-Za-z_][A-Za-z0-9_]*)(?:(:?[-+?])(.*))?', expression, re.S)
if not match:
raise ConversionError(translate('Unresolved variable:') + ' ${...}')
name, op, argument = match.groups()
i = end
else:
match = NAME.match(value, i+1)
if not match:
result.append('$'); i += 1; continue
name, op, argument = match[0], None, None
i = match.end()
present = name in self.values
nonempty = present and self.values[name] != ''
use = nonempty if op and op.startswith(':') else present
if op and op.endswith('-'):
replacement = self.values[name] if use else self.expand(argument, depth+1)
elif op and op.endswith('+'):
replacement = self.expand(argument, depth+1) if use else ''
elif op and op.endswith('?'):
if not use:
raise ConversionError(translate('Unresolved variable:') + ' ' + name)
replacement = self.values[name]
elif present:
replacement = self.values[name]
elif self.missing is not None:
replacement = self.missing(name)
self.values[name] = replacement
else:
raise ConversionError(translate('Unresolved variable:') + ' ' + name)
result.append(str(replacement))
return ''.join(result)
def walk(self, value):
if isinstance(value, str):
return self.expand(value)
if isinstance(value, dict):
return {key: self.walk(item) for key, item in value.items()}
if isinstance(value, list):
return [self.walk(item) for item in value]
return value
def dotenv(text, interpolator):
"""The common KEY=value dotenv grammar; quoted values are never executed."""
result = {}
local = Interpolator(interpolator.values, interpolator.missing)
for line in text.splitlines():
raw = line.strip()
if not raw or raw.startswith('#'):
continue
if raw.startswith('export '):
raw = raw[7:].lstrip()
key, separator, value = raw.partition('=')
key = key.strip()
if not NAME.fullmatch(key):
raise ConversionError(translate('Invalid environment variable name:') + ' ' + key)
if not separator:
result[key] = None
continue
value = value.strip()
if value.startswith("'"):
end, quoted = 1, []
while end < len(value):
if value[end:end+2] == "\\'":
quoted.append("'"); end += 2; continue
if value[end] == "'":
break
quoted.append(value[end]); end += 1
if end >= len(value) or (value[end+1:].strip() and not value[end+1:].lstrip().startswith('#')):
raise ConversionError(translate('Invalid environment variable name:') + ' ' + key)
value = ''.join(quoted)
else:
if value.startswith('"'):
quoted = value
try:
value, end = json.JSONDecoder().raw_decode(value)
if not isinstance(value, str):
raise ValueError()
except (ValueError, TypeError):
raise ConversionError(translate('Invalid environment variable name:') + ' ' + key) from None
suffix = quoted[end:].strip()
if suffix and not suffix.startswith('#'):
raise ConversionError(translate('Invalid environment variable name:') + ' ' + key)
else:
value = re.split(r'\s+#', value, maxsplit=1)[0].rstrip()
value = local.expand(value)
result[key] = value
local.values[key] = value
return result
def resolve_document(document, project=None, env_files=None, missing=None):
"""Pure preparation of a full document; preserve every service and image."""
resolver = Interpolator(project, missing)
result = resolver.walk(document)
for service in result.get('services', {}).values():
if not isinstance(service, dict):
raise ConversionError(translate('The Compose file does not contain a Compose document'))
merged = {}
files = service.pop('env_file', [])
if isinstance(files, str):
files = [files]
if not isinstance(files, list):
raise ConversionError(translate('Unresolved variable:') + ' env_file')
for item in files:
if isinstance(item, dict):
if set(item) - {'path', 'required'}:
raise ConversionError(translate('Unresolved variable:') + ' env_file')
filename, required = item.get('path'), item.get('required', True)
else:
filename, required = item, True
if not isinstance(filename, str) or not filename:
raise ConversionError(translate('Unresolved variable:') + ' env_file')
if filename not in (env_files or {}):
if not required:
continue
raise ConversionError(translate('Unresolved variable:') + ' ' + filename)
merged.update(dotenv(env_files[filename], resolver))
environment = service.get('environment', {})
if isinstance(environment, list):
if any(not isinstance(item, str) for item in environment):
raise ConversionError('environment must be a list or a mapping')
pairs = [item.partition('=') for item in environment]
environment = {key: value if separator else resolver.values.get(key)
for key, separator, value in pairs}
if not isinstance(environment, dict):
raise ConversionError('environment must be a list or a mapping')
for key, value in environment.items():
merged[key] = resolver.values.get(str(key)) if value is None else value
service['environment'] = {key: value for key, value in merged.items() if value is not None}
return result
def prepare(ui, document, directory=None, *, metadata=None):
"""Read companion files explicitly, then ask once for each missing value."""
directory = Path(directory) if directory is not None else None
cache = {}
entered = {}
def read(path):
try:
data = Path(path).expanduser().read_bytes()
except OSError as error:
raise ConversionError(translate('The file could not be read:') + ' ' + str(path)) from error
if len(data) > LIMIT:
raise ConversionError(translate('The Compose file is too large'))
try:
return data.decode('utf-8-sig')
except UnicodeError:
raise ConversionError(translate('The Compose file is not valid YAML:') + ' UTF-8') from None
def missing(name):
if name not in entered:
prompt = translate('Value for') + ' ' + name
entered[name] = (ui.password(prompt, required=True) if SECRET.search(name)
else ui.ask(prompt, '', required=True))
return entered[name]
resolver = Interpolator(missing=missing)
default = directory / '.env' if directory else None
if default is not None and default.is_file():
cache['.env'] = read(default)
resolver.values.update({k: v for k, v in dotenv(cache['.env'], resolver).items() if v is not None})
elif re.search(r'\$(?:\{|[A-Za-z_])', yaml.safe_dump(document)):
path = ui.ask(translate('Project environment file (.env), or leave empty to enter values'), '', required=False)
if path:
cache['.env'] = read(path)
resolver.values.update({k: v for k, v in dotenv(cache['.env'], resolver).items() if v is not None})
for service in document.get('services', {}).values():
files = service.get('env_file', []) if isinstance(service, dict) else []
if isinstance(files, str):
files = [files]
if not isinstance(files, list):
raise ConversionError(translate('Unresolved variable:') + ' env_file')
for item in files:
raw = item.get('path', '') if isinstance(item, dict) else item
if not isinstance(raw, str) or not raw:
raise ConversionError(translate('Unresolved variable:') + ' env_file')
filename = resolver.expand(raw)
if filename in cache:
continue
candidate = directory / filename if directory and not Path(filename).is_absolute() else None
if candidate is not None and candidate.resolve().is_relative_to(directory.resolve()) and candidate.is_file():
cache[filename] = read(candidate)
continue
required = item.get('required', True) if isinstance(item, dict) else True
if type(required) is not bool:
raise ConversionError(translate('Unresolved variable:') + ' env_file.required')
if not required:
continue
path = ui.ask(translate('Path of environment file') + ': ' + filename,
str(candidate or filename), required=required)
if path:
cache[filename] = read(path)
result = resolve_document(document, resolver.values, cache, missing)
for service in result.get('services', {}).values():
mounts = []
for mount in service.get('volumes') or []:
if isinstance(mount, str):
source, target, mode = _split_short_mount(mount)
flags = (mode or '').split(',')
if any(flag in {'z', 'Z'} for flag in flags):
if any(flag not in {'', 'ro', 'rw', 'z', 'Z'} for flag in flags) or {'ro','rw'} <= set(flags) or {'z','Z'} <= set(flags):
raise ConversionError(translate('Unsupported volume options:') + ' ' + str(mode))
mount = {'target':target, 'read_only':'ro' in flags}
if source is not None:
mount['source'] = source
if metadata is not None:
metadata['selinux_labels'] = True
mounts.append(mount)
if 'volumes' in service:
service['volumes'] = mounts
return result
+21 -2
View File
@@ -40,6 +40,7 @@ SUPPORTED_SERVICE_KEYS = {
"shm_size",
"stdin_open",
"stop_grace_period",
"stop_signal",
"sysctls",
"tty",
"ulimits",
@@ -59,6 +60,18 @@ LINUX_CAPABILITIES = {
"SYS_TTY_CONFIG", "SYSLOG", "WAKE_ALARM",
}
# Linux signal names accepted by LXC. Keep independent of the review host OS.
_STOP_SIGNALS = set("HUP INT QUIT ILL TRAP ABRT IOT BUS FPE KILL USR1 SEGV USR2 PIPE ALRM TERM STKFLT CHLD CLD CONT STOP TSTP TTIN TTOU URG XCPU XFSZ VTALRM PROF WINCH IO POLL PWR SYS UNUSED RTMIN RTMAX".split())
def _stop_signal(value: Any) -> str | None:
if not isinstance(value, str):
return None
name = value.strip().upper()
if name.startswith("SIG"):
name = name[3:]
return "SIG" + name if name in _STOP_SIGNALS else None
SENSITIVE_NAME = re.compile(
r"(?:PASS|PASSWORD|TOKEN|SECRET|API_?KEY|PRIVATE_KEY|CREDENTIAL|(?:^|_)KEY(?:$|_))",
re.I,
@@ -463,7 +476,7 @@ def _extra_hosts(value: Any) -> list[dict[str, str]] | None:
for raw_host, raw_address in entries:
host = str(raw_host).strip()
address = str(raw_address).strip()
if not re.fullmatch(r"[A-Za-z0-9](?:[A-Za-z0-9.-]{0,251}[A-Za-z0-9])?", host):
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,252}", host):
return None
if address != "host-gateway" and not re.fullmatch(r"[0-9A-Fa-f:.]+", address):
return None
@@ -889,6 +902,8 @@ def _compose_option_blockers(
blockers.append("mac-address-format")
_, device_blockers = _device_installer_profile(service, optional_devices)
blockers.extend(device_blockers)
if "stop_signal" in service and _stop_signal(service["stop_signal"]) is None:
blockers.append("stop-signal-format")
runtime = service.get("runtime")
if runtime is not None and str(runtime).casefold() != "nvidia":
blockers.append(f"runtime-value:{runtime}")
@@ -949,6 +964,10 @@ def _compose_installer_profile(
profile.setdefault("runtime", {})["command"] = service["command"]
if "entrypoint" in service and service["entrypoint"] is not None:
profile.setdefault("runtime", {})["compose_entrypoint"] = service["entrypoint"]
if service.get("stop_signal") is not None:
halt_signal = _stop_signal(service["stop_signal"])
if halt_signal is not None:
profile.setdefault("runtime", {})["halt_signal"] = halt_signal
if service.get("working_dir") is not None:
profile.setdefault("runtime", {})["working_directory"] = str(service["working_dir"])
if service.get("user") is not None:
@@ -1580,7 +1599,7 @@ def convert_readme(
"environment": "import-image-env-then-apply-compose-overrides",
"user": "import-from-oci-image",
"working_dir": "import-from-oci-image",
"stop_signal": "import-from-oci-image",
"stop_signal": "apply-compose-override" if service.get("stop_signal") is not None else "import-from-oci-image",
},
**(
{"installer_profile": _compose_installer_profile(service, markers["devices"], markers["security_opt"])}
+96 -8
View File
@@ -54,8 +54,8 @@ class _Dumper(yaml.SafeDumper):
return super().increase_indent(flow, False)
def _dump(document: dict[str, Any]) -> str:
return yaml.dump(document, Dumper=_Dumper, default_flow_style=False, sort_keys=True)
def _dump(document: dict[str, Any], *, sort_keys: bool = True) -> str:
return yaml.dump(document, Dumper=_Dumper, default_flow_style=False, sort_keys=sort_keys)
def _services(compose: dict[str, Any]) -> dict[str, Any]:
@@ -114,7 +114,7 @@ def _keep_reference(template: dict[str, Any], reference: str) -> None:
template['container_contract']['image'] = image
def template_from_compose(text: str, title: str | None = None) -> dict[str, Any]:
def template_from_compose(text: str, title: str | None = None, *, interpolated: bool = False, image_entrypoints: dict[str, list[str]] | None = None, input_metadata: dict[str, Any] | None = None) -> dict[str, Any]:
"""Translate a Compose application through the native single or stack installer."""
try:
compose = yaml.safe_load(text)
@@ -124,7 +124,7 @@ def template_from_compose(text: str, title: str | None = None) -> dict[str, Any]
raise ConversionError(translate('The Compose file does not contain a Compose document'))
services = _services(compose)
if len(services) > 1:
return _template_from_stack(compose, title)
return _template_from_stack(compose, title, interpolated=interpolated, image_entrypoints=image_entrypoints, input_metadata=input_metadata)
name = next(iter(services))
service = services[name] or {}
if not service.get('image'):
@@ -172,7 +172,7 @@ def template_from_compose(text: str, title: str | None = None) -> dict[str, Any]
return template
def _template_from_stack(compose: dict[str, Any], title: str | None) -> dict[str, Any]:
def _template_from_stack(compose: dict[str, Any], title: str | None, *, interpolated: bool = False, image_entrypoints: dict[str, list[str]] | None = None, input_metadata: dict[str, Any] | None = None) -> dict[str, Any]:
"""Reuse the catalog stack compiler, keeping explicitly selected image versions."""
from .stack import apply_stack_support, normalized_mounts
@@ -201,6 +201,10 @@ def _template_from_stack(compose: dict[str, Any], title: str | None) -> dict[str
template = convert_casaos_compose(_dump(document), 'local', '',
f'local/{identifier}/docker-compose.yml', '')
template['proxmox']['custom_compose_stack'] = True
template['proxmox']['custom_compose_interpolated'] = interpolated
template['proxmox']['stack_image_entrypoints'] = copy.deepcopy(image_entrypoints or {})
if (input_metadata or {}).get('selinux_labels'):
template['proxmox']['custom_compose_selinux_labels'] = True
_keep_reference(template, str(services[main]['image']))
# Catalog imports use latest. A pasted Compose must keep every written tag/digest.
for service in template['compose_stack']['services']:
@@ -228,6 +232,66 @@ def _template_from_stack(compose: dict[str, Any], title: str | None) -> dict[str
return template
def enrich_image_defaults(ui, document: dict[str, Any], *, image_entrypoints: dict[str, list[str]] | None = None) -> dict[str, Any]:
"""Read implicit ports/health checks from OCI metadata, never an app-name table."""
from .stack import kind
document = copy.deepcopy(document)
inspected = {}
services = _services(document)
for name, service in services.items():
if not isinstance(service, dict) or not service.get('image'):
continue # The converter supplies the normal validation diagnostic.
health = service.get('healthcheck')
inherit_health = isinstance(health, dict) and not health.get('disable') and 'test' not in health
application = kind(str(service['image'])) == 'application'
internal = application and not service.get('ports') and not service.get('expose')
command = service.get('command')
arguments = shlex.split(command) if isinstance(command, str) else command or []
option_command = bool(arguments and isinstance(arguments[0], str) and arguments[0].startswith('-'))
if not internal and not inherit_health and not (application and health is None) and not option_command:
continue
reference = str(service['image'])
if not _valid_image_reference(reference):
raise ConversionError(f"{translate('This is not a valid image reference:')} {reference}")
if reference not in inspected:
command = ['skopeo', 'inspect', '--config', f'docker://{reference}']
result = subprocess.run(command, capture_output=True, text=True, check=False, timeout=120)
if result.returncode and _registry_refused(reference):
from .registry_accounts import offer_sign_in
if offer_sign_in(ui, reference, translate('Image that is not in the catalog')):
result = subprocess.run(command, capture_output=True, text=True, check=False, timeout=120)
if result.returncode:
raise ConversionError(f"{translate('The image could not be read from its registry:')} {reference}")
try:
inspected[reference] = (json.loads(result.stdout).get('config') or {})
except (ValueError, AttributeError):
raise ConversionError(f"{translate('The image could not be read from its registry:')} {reference}") from None
defaults = inspected[reference]
if image_entrypoints is not None:
entrypoint = defaults.get('Entrypoint') or []
if isinstance(entrypoint, list) and all(isinstance(arg, str) for arg in entrypoint):
image_entrypoints[reference] = entrypoint
if internal and service.get('entrypoint') is None and service.get('command') is None:
ports = [port for port in (defaults.get('ExposedPorts') or {})
if str(port).endswith('/tcp') or '/' not in str(port)]
if ports:
service['expose'] = sorted(ports)
# Compose healthcheck overrides inherit the image's test unless disabled.
if (health is None or inherit_health) and (defaults.get('Healthcheck') or {}).get('Test'):
source = defaults['Healthcheck']
effective = {'test': source['Test']}
for old, new in [('Interval', 'interval'), ('Timeout', 'timeout'), ('StartPeriod', 'start_period')]:
if source.get(old):
effective[new] = str(source[old])+'ns'
if source.get('Retries'):
effective['retries'] = source['Retries']
effective.update(health or {})
service['healthcheck'] = effective
elif inherit_health:
service.pop('healthcheck', None)
return document
def compose_from_image(reference: str, title: str | None = None) -> str:
"""A Compose file written from what the image declares: its ports, its
persistent paths and its variables."""
@@ -539,6 +603,7 @@ def read_definition(ui) -> tuple[str, str]:
"""The Compose file of the application and where it came from, or an
oci-compose, named by OCI_COMPOSE."""
from .cli import MENU_SIZE
ui._compose_source_dir = None
source = ui.choose(translate('How is the image described?'), [
('paste', translate('Paste its Compose file in the terminal')),
('file', translate('Read its Compose file from a file of this host')),
@@ -562,8 +627,9 @@ def read_definition(ui) -> tuple[str, str]:
if source == 'paste':
return _read_pasted(ui), translate('pasted Compose file')
if source == 'file':
return _read_file(ui.ask(translate('Path of the Compose file'), '/root/docker-compose.yml')), \
translate('Compose file of this host')
path = ui.ask(translate('Path of the Compose file'), '/root/docker-compose.yml')
ui._compose_source_dir = Path(path).expanduser().resolve().parent
return _read_file(path), translate('Compose file of this host')
if source == 'url':
return _read_url(ui.ask(translate('Address of the Compose file'), '')), translate('downloaded Compose file')
if source == 'run':
@@ -689,6 +755,9 @@ def describe(template: dict[str, Any]) -> str:
profile = proxmox.get('installer_profile', {}) or {}
security = proxmox.get('security_profile', {}) or {}
lines = [f"{translate('Image') + ':':<14} {contract['image']['reference']}"]
if proxmox.get('custom_compose_selinux_labels'):
lines += [translate('SELinux volume labels are not applied on this Proxmox host.')]
services = (template.get('compose_stack') or {}).get('services') or []
if len(services) > 1:
lines += ['', translate('Containers of the application:')]
@@ -792,12 +861,23 @@ def _explore(ui) -> None:
else:
notes = [line.partition(':')[2].strip() for line in text.splitlines() if line.startswith('#note:')]
notes += ignored_settings(text)
template = template_from_compose(text)
try:
document = yaml.safe_load(text)
except yaml.YAMLError as error:
raise ConversionError(f"{translate('The Compose file is not valid YAML:')} {error}") from error
multiple = isinstance(document, dict) and len(document.get('services') or {}) > 1
if multiple:
from .compose_input import prepare
input_metadata = {}
document = prepare(ui, document, getattr(ui, '_compose_source_dir', None), metadata=input_metadata)
text = _dump(document, sort_keys=False)
template = template_from_compose(text, interpolated=True, input_metadata=input_metadata) if multiple else template_from_compose(text)
title = translate('Image that is not in the catalog')
summary = describe(template) + (describe_oci_compose(template) if written else '')
stack_services = (template.get('compose_stack') or {}).get('services') or []
references = list(dict.fromkeys(service['image'] for service in stack_services)) if len(stack_services) > 1 else [template['container_contract']['image']['reference']]
available = True
reports = []
for reference in references:
accessible, report = registry_report(reference)
if not accessible and _registry_refused(reference):
@@ -805,6 +885,7 @@ def _explore(ui) -> None:
if offer_sign_in(ui, reference, title):
accessible, report = registry_report(reference)
available = available and accessible
reports.append(report)
summary += '\n\n' + report
if notes:
summary += '\n\n' + '\n'.join(notes)
@@ -813,6 +894,13 @@ def _explore(ui) -> None:
'try again later. For a private image, sign in from Registry accounts in the OCI menu.')
ui.message(f'{summary}\n\n{advice}', title)
return
if not written and multiple:
image_entrypoints = {}
document = enrich_image_defaults(ui, document, image_entrypoints=image_entrypoints)
template = template_from_compose(_dump(document, sort_keys=False), interpolated=True, image_entrypoints=image_entrypoints, input_metadata=input_metadata)
summary = describe(template) + ''.join('\n\n'+report for report in reports)
if notes:
summary += '\n\n'+'\n'.join(notes)
if not template.get('compatibility', {}).get('automatic_install_candidate'):
ui.message(f"{translate('This image cannot be installed as it is described:')}\n\n{summary}", title)
return
+2 -2
View File
@@ -1598,7 +1598,7 @@ def _run_remote_install(
shutil.copy2(project_root / 'remote' / 'oci_installation_state.py', temporary / 'oci_installation_state.py')
shutil.copy2(project_root / 'remote' / 'oci_instances.py', temporary / 'oci_instances.py')
shutil.copy2(project_root / 'remote' / 'oci_image_mountpoints.py', temporary / 'oci_image_mountpoints.py')
for helper in ('oci_ui.sh', 'oci_ui.py', 'oci_description.py', 'oci_console.py', 'oci_native_stack.py', 'oci_native_stack.sh', 'oci_instance_transaction.py', 'oci_managed_volumes.py', 'oci_host_mounts.py', 'oci_nested_mounts.py', 'oci_runtime_settings.py', 'oci_gpu_devices.py', 'oci_accelerators.py', 'oci_nvidia_runtime.py', 'oci_nvidia_refresh.py', 'oci_nvidia_dynamic.py', 'oci_update_current.py', 'oci_resource_modify.py', 'oci_native_modify.py', 'oci_stack_replay.py', 'oci_stack_plan.py', 'oci_stack_transaction.py', 'oci_stack_native.py', 'oci_image_cache.py', 'nvidia_lxc_mount_lab.sh', 'oci_nvidia_setup.sh', 'oci_immich_ml.sh', 'oci_rocm_check.py'):
for helper in ('oci_compose_jobs.py', 'oci_ui.sh', 'oci_ui.py', 'oci_description.py', 'oci_console.py', 'oci_native_stack.py', 'oci_native_stack.sh', 'oci_instance_transaction.py', 'oci_managed_volumes.py', 'oci_host_mounts.py', 'oci_nested_mounts.py', 'oci_runtime_settings.py', 'oci_gpu_devices.py', 'oci_accelerators.py', 'oci_nvidia_runtime.py', 'oci_nvidia_refresh.py', 'oci_nvidia_dynamic.py', 'oci_update_current.py', 'oci_resource_modify.py', 'oci_native_modify.py', 'oci_stack_replay.py', 'oci_stack_plan.py', 'oci_stack_transaction.py', 'oci_stack_native.py', 'oci_image_cache.py', 'nvidia_lxc_mount_lab.sh', 'oci_nvidia_setup.sh', 'oci_immich_ml.sh', 'oci_rocm_check.py'):
shutil.copy2(project_root / 'remote' / helper, temporary / helper)
if deployment_kind == 'generic-multi-lxc-stack':
shutil.copy2(project_root / 'remote' / 'install_generic_stack.py', temporary / 'install_generic_stack.py')
@@ -1635,7 +1635,7 @@ def _run_remote_install(
archive.add(project_root / 'remote' / 'haos_healthcheck.py', arcname='haos_healthcheck.py')
archive.add(project_root / 'remote' / 'oci_installation_state.py', arcname='oci_installation_state.py')
archive.add(project_root / 'remote' / 'oci_instances.py', arcname='oci_instances.py')
for helper in ('oci_ui.sh', 'oci_ui.py', 'oci_description.py', 'oci_console.py', 'oci_native_stack.py', 'oci_native_stack.sh', 'oci_instance_transaction.py', 'oci_managed_volumes.py', 'oci_image_mountpoints.py', 'oci_host_mounts.py', 'oci_nested_mounts.py', 'oci_runtime_settings.py', 'oci_gpu_devices.py', 'oci_accelerators.py', 'oci_nvidia_runtime.py', 'oci_nvidia_refresh.py', 'oci_nvidia_dynamic.py', 'oci_update_current.py', 'oci_resource_modify.py', 'oci_native_modify.py', 'oci_stack_replay.py', 'oci_stack_plan.py', 'oci_stack_transaction.py', 'oci_stack_native.py', 'oci_image_cache.py', 'nvidia_lxc_mount_lab.sh', 'oci_nvidia_setup.sh', 'oci_immich_ml.sh', 'oci_rocm_check.py'):
for helper in ('oci_compose_jobs.py', 'oci_ui.sh', 'oci_ui.py', 'oci_description.py', 'oci_console.py', 'oci_native_stack.py', 'oci_native_stack.sh', 'oci_instance_transaction.py', 'oci_managed_volumes.py', 'oci_image_mountpoints.py', 'oci_host_mounts.py', 'oci_nested_mounts.py', 'oci_runtime_settings.py', 'oci_gpu_devices.py', 'oci_accelerators.py', 'oci_nvidia_runtime.py', 'oci_nvidia_refresh.py', 'oci_nvidia_dynamic.py', 'oci_update_current.py', 'oci_resource_modify.py', 'oci_native_modify.py', 'oci_stack_replay.py', 'oci_stack_plan.py', 'oci_stack_transaction.py', 'oci_stack_native.py', 'oci_image_cache.py', 'nvidia_lxc_mount_lab.sh', 'oci_nvidia_setup.sh', 'oci_immich_ml.sh', 'oci_rocm_check.py'):
archive.add(project_root / 'remote' / helper, arcname=helper)
if deployment_kind == 'generic-multi-lxc-stack':
archive.add(project_root / 'remote' / 'install_generic_stack.py', arcname='install_generic_stack.py')
+217 -32
View File
@@ -6,6 +6,8 @@ import base64
import re
import secrets
import hashlib
import math
import shlex
from urllib.parse import urlsplit, urlunsplit, quote
import yaml
@@ -52,6 +54,12 @@ def environment(value):
def kind(image):
repo = canonical_image_repository(image)
for prefix in ('docker.io/', 'index.docker.io/', 'registry-1.docker.io/'):
if repo.startswith(prefix):
repo = repo[len(prefix):]
break
if repo.startswith('library/'):
repo = repo[len('library/'):]
return repo if repo in {'postgres', 'redis', 'valkey/valkey', 'mariadb', 'linuxserver/mariadb', 'mongo', 'getmeili/meilisearch'} else 'application'
@@ -78,6 +86,47 @@ def authenticated_redis(service):
and bool(environment(c.get('environment')).get('REDISCLI_AUTH')))
def custom_dependency_command(service, image_entrypoint=None):
"""Known server commands may add flags; another executable is not equivalent."""
if service['compose'].get('entrypoint'):
return False
command = service['compose'].get('command')
if not command:
return True
try:
argv = shlex.split(command) if isinstance(command, str) else command
except ValueError:
return False
if (not isinstance(argv, list) or not argv or any(not isinstance(arg, str) for arg in argv)):
return False
allowed = {'postgres': {'postgres'}, 'redis': {'redis-server'},
'valkey/valkey': {'valkey-server'}, 'mariadb': {'mariadbd', 'mysqld'},
'linuxserver/mariadb': {'mariadbd', 'mysqld'}, 'mongo': {'mongod'},
'getmeili/meilisearch': {'meilisearch'}}
# Password-protected Redis still needs its own validated auth check.
if kind(service['image']) in {'redis', 'valkey/valkey'} and any(
arg.split('=', 1)[0] in {'--requirepass', '--masterauth', '--aclfile'} for arg in argv):
return authenticated_redis(service)
if argv[0].startswith('-'):
# Docker passes options through the actual image entrypoint. Do not
# guess or prepend a database executable on behalf of the image.
return (isinstance(image_entrypoint, list) and bool(image_entrypoint)
and all(isinstance(arg, str) and arg for arg in image_entrypoint))
return argv[0] in allowed.get(kind(service['image']), set())
def completed_services(template):
"""Jobs are declared by Compose's successful-completion dependency condition."""
jobs = set()
for service in template.get('compose_stack', {}).get('services', []):
dependencies = service['compose'].get('depends_on') or {}
if isinstance(dependencies, dict):
for name, requirement in dependencies.items():
if isinstance(requirement, dict) and requirement.get('condition') == 'service_completed_successfully':
jobs.add(name)
return jobs
def ordered_services(template):
stack = template['compose_stack']
services = {s['name']: copy.deepcopy(s) for s in stack['services']}
@@ -105,7 +154,10 @@ def ordered_services(template):
for dep in raw:
if isinstance(raw, dict):
condition = raw[dep].get('condition', 'service_started')
if condition not in {'service_started', 'service_healthy'}:
allowed = {'service_started', 'service_healthy'}
if template.get('proxmox', {}).get('custom_compose_stack'):
allowed.add('service_completed_successfully')
if condition not in allowed:
raise StackError(f"{name}: {translate('unsupported dependency condition')} {condition}")
if raw[dep].get('required', True) is False:
raise StackError(f"{name}: {translate('optional dependencies are not yet supported')}")
@@ -170,9 +222,12 @@ def service_template(parent, service, main=False):
return single
def service_endpoint(service):
"""A custom web service is checked on its own container port, not a host mapping."""
for port in service['compose'].get('ports') or []:
def service_endpoint(service, include_expose=False):
"""Return a TCP port; only a published port implies a web frontend here."""
ports = list(service['compose'].get('ports') or [])
if include_expose:
ports += list(service['compose'].get('expose') or [])
for port in ports:
if isinstance(port, dict):
protocol = port.get('protocol', 'tcp')
target = port.get('target')
@@ -185,6 +240,80 @@ def service_endpoint(service):
return None
def _check_duration(value, default):
if value is None:
return default
text = str(value).strip().lower()
parts = list(re.finditer(r'(\d+(?:\.\d+)?)(ns|us|ms|h|m|s)', text))
if not parts or ''.join(item.group() for item in parts) != text:
raise StackError(translate('Invalid service check timeout'))
factors = {'ns': 1e-9, 'us': 1e-6, 'ms': 1e-3, 's': 1, 'm': 60, 'h': 3600}
duration = sum(float(item.group(1)) * factors[item.group(2)] for item in parts)
if not math.isfinite(duration) or duration < 0 or duration > 3600:
raise StackError(translate('Invalid service check timeout'))
return duration
def compose_healthcheck(service):
"""An explicit worker readiness command executed inside its own LXC."""
raw = service['compose'].get('healthcheck')
if raw is None:
return None
if not isinstance(raw, dict):
raise StackError(translate('Invalid service check arguments'))
if raw.get('disable') is True:
return None
test = raw.get('test')
if test == ['NONE']:
return None
if isinstance(test, str):
test = ['CMD-SHELL', test]
if (not isinstance(test, list) or len(test) < 2
or any(not isinstance(arg, str) or any(ch in arg for ch in '\0\n\r') for arg in test)):
raise StackError(translate('Invalid service check arguments'))
if test[0] == 'CMD':
argv = test[1:]
elif test[0] == 'CMD-SHELL' and len(test) == 2 and test[1].strip():
# pct exec does not promise the image PID1 environment. Reject checks
# that would silently evaluate an unset runtime variable.
reference = re.search(r'\$(?:[A-Za-z_][A-Za-z0-9_]*|\{[^}]+\})', test[1])
if reference:
raise StackError(f"healthcheck: {translate('needs stack review')}: {reference.group(0)}")
argv = ['sh', '-c', test[1]]
else:
raise StackError(translate('Invalid service check arguments'))
if not argv or not argv[0].strip():
raise StackError(translate('Empty exec service check'))
interval = _check_duration(raw.get('interval'), 30)
request = _check_duration(raw.get('timeout'), 5)
start_period = _check_duration(raw.get('start_period'), 0)
retries = raw.get('retries', 3)
if (type(retries) is not int or retries < 1 or retries > 1000
or interval <= 0 or request <= 0):
raise StackError(translate('Invalid service check timeout'))
total = max(30, math.ceil(start_period + (interval + request) * retries))
if total > 3600:
raise StackError(translate('Invalid service check timeout'))
return {'type': 'exec', 'argv': argv, 'timeout_seconds': total}
def readiness_mode(service):
"""A declared healthy check is distinct from port liveness or running only."""
if compose_healthcheck(service) or kind(service['image']) != 'application':
return 'exec'
if service_endpoint(service):
return 'http'
if service_endpoint(service, include_expose=True):
return 'tcp'
return 'running'
def _entrypoint_for(template, service):
entries = template.get('proxmox', {}).get('stack_image_entrypoints', {})
reference = service['image']
return entries.get(reference) or (entries.get(reference[:-7]) if reference.endswith(':latest') else None)
def shared_sources(services):
"""Source identities mounted more than once, before storage is chosen."""
users = {}
@@ -202,6 +331,7 @@ def assess(template):
custom = template.get('proxmox', {}).get('custom_compose_stack', False)
try:
services = ordered_services(template)
literal = template.get('proxmox', {}).get('custom_compose_interpolated', False)
top = template['compose_stack'].get('top_level', {})
if any(k not in {'name', 'networks', 'volumes', 'version'}
and not (custom and str(k).startswith('x-')) for k in top):
@@ -211,16 +341,42 @@ def assess(template):
raise StackError(translate('Multiple networks or external networks are not yet supported'))
if any(v for v in top.get('volumes', {}).values()):
raise StackError(translate('Top-level volume options are not yet supported'))
healthy_required = set()
# Short-form dependencies mean service_started; collect long-form
# healthy requirements even when other services use the short form.
for service in services:
dependencies = service['compose'].get('depends_on') or {}
if isinstance(dependencies, dict):
healthy_required.update(name for name, condition in dependencies.items()
if isinstance(condition, dict) and condition.get('condition') == 'service_healthy')
jobs = completed_services(template) if custom else set()
if template['compose_stack']['main_service'] in jobs or jobs & healthy_required:
raise StackError(translate('unsupported dependency condition') + ': service_completed_successfully')
for service in services:
if service['name'] in jobs and service['compose'].get('restart') not in (None, 'no'):
raise StackError(translate('unsupported dependency condition') + ': restart')
seen_sources = set()
for s in services:
c = s['compose']
for key in ('profiles','build','configs','secrets','env_file','pid','privileged','devices','runtime','cap_add','security_opt','network_mode','extra_hosts'):
if c.get(key):
errors.append(f"{s['name']}: {key} {translate('needs stack review')}")
if (s['name'] != template['compose_stack']['main_service'] and kind(s['image']) == 'application'
and not (custom and service_endpoint(s))):
errors.append(f"{s['name']}: {translate('health and persistence profile not yet defined')}")
if kind(s['image']) != 'application' and (c.get('command') or c.get('entrypoint')) and not authenticated_redis(s):
try:
explicit_check = compose_healthcheck(s) if custom else None
except StackError as error:
errors.append(f"{s['name']}: {error}")
explicit_check = None
if (custom and s['name'] == template['compose_stack']['main_service']
and explicit_check and service_endpoint(s) is None):
errors.append(f"{s['name']}: {translate('Main endpoint not yet defined')}")
if s['name'] != template['compose_stack']['main_service'] and kind(s['image']) == 'application':
if not custom:
errors.append(f"{s['name']}: {translate('health and persistence profile not yet defined')}")
elif s['name'] in healthy_required and explicit_check is None:
errors.append(f"{s['name']}: service_healthy {translate('needs stack review')}: "
f"{translate('health and persistence profile not yet defined')}")
if (kind(s['image']) != 'application' and (c.get('command') or c.get('entrypoint'))
and not authenticated_redis(s) and not (custom and custom_dependency_command(s, _entrypoint_for(template, s)))):
errors.append(f"{s['name']}: {translate('custom dependency commands are not yet supported')}")
for m in normalized_mounts(c.get('volumes', [])):
source = m.get('source')
@@ -236,7 +392,7 @@ def assess(template):
for k,v in env.items():
for a,b in VARIABLE.findall(v):
variable = a or b
if variable not in {'TZ','PUID','PGID'} and not variable.startswith('GENERATED_'):
if not literal and variable not in {'TZ','PUID','PGID'} and not variable.startswith('GENERATED_'):
errors.append(f'{s["name"]}: {translate("unsupported variable")} {variable}')
if variable.startswith('GENERATED_') and re.search(r'API_KEY|CLIENT_SECRET|CREDENTIALS_ENABLED', variable):
errors.append(f'{s["name"]}: {translate("unsupported external credential or boolean")} {variable}')
@@ -253,19 +409,21 @@ def assess(template):
errors.append(f"{source}: {translate('Volumes shared between services are not yet supported')}")
if not template['first_run'].get('endpoints'):
errors.append(translate('Main endpoint not yet defined'))
resolve_environments(services, 'UTC', template.get('proxmox', {}).get('stack_generators', {}))
resolve_environments(services, 'UTC', template.get('proxmox', {}).get('stack_generators', {}), literal=literal)
except (ValueError, KeyError, TypeError, ConversionError) as e:
errors.append(str(e))
return sorted(set(errors))
def resolve_environments(services, timezone, generators=None):
def resolve_environments(services, timezone, generators=None, literal=False):
tokens = {'TZ': timezone, 'PUID': '1000', 'PGID': '1000'}
resolved = {}
for s in services:
env = environment(s['compose'].get('environment'))
def replace(match):
name = match.group(1) or match.group(2)
if literal:
return match.group(0)
if name.startswith('GENERATED_'):
if name not in tokens:
generator = (generators or {}).get(name)
@@ -279,7 +437,10 @@ def resolve_environments(services, timezone, generators=None):
raise StackError(f"{translate('Unresolved variable:')} {name}")
return tokens[name]
resolved[s['name']] = {k: VARIABLE.sub(replace, v) for k,v in env.items()}
# Bind URL credentials using the destination service, never a global text replacement.
if literal:
return resolved
# Catalog-generated credentials are bound only for legacy catalog definitions.
# A fully interpolated custom Compose keeps its explicit connection strings.
databases = {}
for s in services:
if kind(s['image']) == 'postgres':
@@ -343,6 +504,7 @@ def build_stack(template, ui, mode='advanced'):
services = copy.deepcopy(ordered_services(template))
defaults = template['proxmox']['defaults']
custom = template.get('proxmox', {}).get('custom_compose_stack', False)
jobs = completed_services(template) if custom else set()
shared = shared_sources(services) if custom else set()
shared_paths = {}
# A default installation takes the name, the VMID and the settings from the
@@ -360,19 +522,23 @@ def build_stack(template, ui, mode='advanced'):
root = ask_storage(ui, translate('Storage for rootfs'), 'rootdir', defaults['rootfs_storage'])
cache = ask_storage(ui, translate('Storage for the OCI image cache'), 'vztmpl', defaults['template_storage'], mode)
generators = template.get('proxmox', {}).get('stack_generators', {})
literal = template.get('proxmox', {}).get('custom_compose_interpolated', False)
# The data paths of every member are settled before the network and the settings.
volumes = root
draft_envs = resolve_environments(copy.deepcopy(services), host.timezone(), generators)
draft_envs = resolve_environments(copy.deepcopy(services), host.timezone(), generators, literal=literal)
drafts = []
selected_resources = {}
for s in services:
draft = copy.deepcopy(s)
draft['compose']['environment'] = draft_envs[s['name']]
_, plan = _service_plan(template, draft)
if mode != DEFAULT_MODE and s['name'] == template['compose_stack']['main_service']:
resources = {'cores': int(ui.ask(translate('CPU cores'), str(plan['resources']['cores']))),
'memory_mb': int(ui.ask(translate('Memory in MB'), str(plan['resources']['memory_mb'])))}
if mode != DEFAULT_MODE and (custom or s['name'] == template['compose_stack']['main_service']):
prefix = s['name']+': ' if custom and s['name'] != template['compose_stack']['main_service'] else ''
resources = {'cores': int(ui.ask(prefix+translate('CPU cores'), str(plan['resources']['cores']))),
'memory_mb': int(ui.ask(prefix+translate('Memory in MB'), str(plan['resources']['memory_mb'])))}
if resources['cores'] < 1 or resources['memory_mb'] < 256:
raise StackError(translate('Invalid resources'))
selected_resources[s['name']] = resources
# The paths of the application itself hold the user's data, so where
# they go is asked in a default installation too; the databases and
# caches beside it keep the values of the recipe.
@@ -394,13 +560,16 @@ def build_stack(template, ui, mode='advanced'):
if chosen.rstrip('/') in {'', '/etc', '/var', '/usr', '/root', '/dev', '/proc', '/sys', '/run', '/boot', '/bin', '/sbin', '/lib', '/lib64'}:
raise StackError(translate('Invalid volume target'))
env = draft_envs[s['name']]
uid, gid = env.get('PUID', '1000'), env.get('PGID', '1000')
if not str(uid).isdigit() or not str(gid).isdigit() or int(uid) > 65535 or int(gid) > 65535:
raise StackError(translate('Invalid resources'))
offset = 100000 if plan.get('security', {}).get('unprivileged', True) else 0
m.update(type='host-bind', source=shared_paths[source], backup=False, size_gb=None,
read_only=bool(source_mount.get('read_only')),
host_owner=[int(uid)+offset, int(gid)+offset])
read_only=bool(source_mount.get('read_only')))
# Without an explicit PUID/PGID, use the imported image user
# when creating the directory, just like private volumes.
if 'PUID' in env or 'PGID' in env:
uid, gid = env.get('PUID', '1000'), env.get('PGID', '1000')
if not str(uid).isdigit() or not str(gid).isdigit() or int(uid) > 65535 or int(gid) > 65535:
raise StackError(translate('Invalid resources'))
offset = 100000 if plan.get('security', {}).get('unprivileged', True) else 0
m['host_owner'] = [int(uid)+offset, int(gid)+offset]
continue
mount_mode = (ui.choose(f"{translate('Where to store')} {label}",
[('managed-volume', translate('Container volume (included in backups)')),
@@ -430,7 +599,7 @@ def build_stack(template, ui, mode='advanced'):
address_labels = frontend_names if custom else ['']
addresses, gateway = access.ask_addresses(ui, bridge, address_labels)
timezone = quiet.ask(translate('Timezone'), host.timezone())
envs = resolve_environments(services, timezone, generators)
envs = resolve_environments(services, timezone, generators, literal=literal)
for group in template.get('proxmox', {}).get('stack_optional_environment', []):
service_name = group['service']
if service_name not in envs:
@@ -459,16 +628,27 @@ def build_stack(template, ui, mode='advanced'):
single, plan = _service_plan(template, s)
k = kind(s['image'])
# Values are already resolved; do not reinterpret user credentials as Compose variables.
plan['environment'] = [{'name':key,'value':value,'sensitive':True} for key,value in env.items() if value != '']
plan['environment'] = [{'name':key,'value':value,'sensitive':True} for key,value in env.items() if value != '' or literal]
plan.update(hostname=_hostname_default(name+'-'+s['name']), template_storage=cache,
start_after_create=False)
plan['rootfs']['storage'] = root
if 'memory_default_mb' not in single['proxmox'].get('installer_profile', {}).get('resources', {}):
plan['resources']['memory_mb'] = max(1024 if k in {'postgres','mariadb','linuxserver/mariadb','mongo','getmeili/meilisearch'} else 512, plan['resources']['memory_mb'])
plan['mounts'] = drafts[index]['deployment']['mounts']
if main and mode != DEFAULT_MODE:
plan['resources'].update(resources)
if k == 'postgres':
if s['name'] in selected_resources:
plan['resources'].update(selected_resources[s['name']])
explicit_check = compose_healthcheck(s) if custom else None
if s['name'] in jobs:
health = {'type': 'completed', 'timeout_seconds': 300}
plan['execution_mode'] = 'one-shot'
elif explicit_check:
health = explicit_check
if main:
endpoint = service_endpoint(s)
if endpoint is None:
raise StackError(f"{s['name']}: {translate('Main endpoint not yet defined')}")
health['endpoint'] = endpoint
elif k == 'postgres':
health = {'type':'exec','timeout_seconds':180,'argv':['pg_isready','-h','127.0.0.1','-U',env.get('POSTGRES_USER','postgres'),'-d',env.get('POSTGRES_DB',env.get('POSTGRES_USER','postgres'))]}
elif k == 'mariadb':
health = {'type':'exec','timeout_seconds':240,'argv':['healthcheck.sh','--connect','--innodb_initialized']}
@@ -488,14 +668,19 @@ def build_stack(template, ui, mode='advanced'):
check = "REDISCLI_AUTH=${REDISCLI_AUTH:-$(tr '\\000' '\\n' < /proc/1/environ | sed -n 's/^REDISCLI_AUTH=//p')}; export REDISCLI_AUTH; " + check
health = {'type':'exec','timeout_seconds':90,'argv':['sh','-c',check]}
else:
endpoint = service_endpoint(s) if custom else template['first_run']['endpoints'][0]
endpoint = service_endpoint(s, include_expose=not main) if custom else template['first_run']['endpoints'][0]
if endpoint is None:
raise StackError(f"{s['name']}: {translate('Main endpoint not yet defined')}")
health = {'type':'http','timeout_seconds':360,'endpoint':endpoint}
if main or not custom:
raise StackError(f"{s['name']}: {translate('Main endpoint not yet defined')}")
health = {'type': 'running', 'timeout_seconds': 30}
elif custom and not main and service_endpoint(s) is None:
health = {'type': 'tcp', 'timeout_seconds': 180, 'port': endpoint['port']}
else:
health = {'type':'http','timeout_seconds':360,'endpoint':endpoint}
plans.append({'name':s['name'],'main':main,'kind':k,'offset':0 if main else len(plans)+1,
'aliases':list(dict.fromkeys([s['name'],s['compose'].get('container_name',s['name'])])),
'template':single,'deployment':plan,'healthcheck':health})
if s['name'] in frontend_names:
if s['name'] in frontend_names and s['name'] not in jobs:
plans[-1]['frontend'] = True
plans[-1]['frontend_ipv4'] = addresses[s['name']] if custom else addresses['']
if mode != DEFAULT_MODE:
@@ -503,7 +688,7 @@ def build_stack(template, ui, mode='advanced'):
ask_stack_extra_devices(ui, plans)
onboot = ui.confirm(translate('Start the stack with Proxmox'), False)
for plan in plans:
plan['deployment']['onboot'] = onboot
plan['deployment']['onboot'] = onboot and plan['name'] not in jobs
return {'deployment_kind':'generic-multi-lxc-stack','stack_name':name,'base_vmid':int(vmid) if vmid else None,
'completion_notes':template.get('proxmox',{}).get('stack_completion_notes',[]),
'rootfs_storage':root,'template_storage':cache,'onboot':onboot,'start_after_create':True,
+278
View File
@@ -0,0 +1,278 @@
"""Completion proofs survive native staging, rollback and renumbered restore."""
import copy
import json
from pathlib import Path
import stat
import sys
import tempfile
from types import SimpleNamespace
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'remote'))
import oci_compose_jobs as jobs
import oci_instances as instances
import oci_stack_native as native
import oci_stack_plan as plans
import oci_restore_recovery as recovery
MAIN = '11111111-1111-4111-8111-111111111111'
JOB = '22222222-2222-4222-8222-222222222222'
DB = '33333333-3333-4333-8333-333333333333'
OLD = 'sha256:' + 'a' * 64
NEW = 'sha256:' + 'b' * 64
CONFIG = 'rootfs: local-zfs:subvol-4901-disk-0,size=8G\nunprivileged: 1\nentrypoint: migrate\n'
SPEC = {'installation_id': JOB, 'image_digest': OLD,
'expected_config_fingerprint': jobs.config_fingerprint(CONFIG), 'timeout_seconds': 300}
CHECK = {'type': 'completed', 'timeout_seconds': 300, 'job': SPEC}
def fixture():
services = [{'vmid': 4902, 'name': 'database', 'kind': 'postgres', 'healthcheck': {'type': 'running', 'timeout_seconds': 120}},
{'vmid': 4901, 'name': 'migration', 'healthcheck': copy.deepcopy(CHECK)},
{'vmid': 4900, 'name': 'web', 'frontend': True, 'healthcheck': {'type': 'running', 'timeout_seconds': 120}}]
records = []
for vmid, identity in ((4900, MAIN), (4901, JOB), (4902, DB)):
records.append({'schema_version': 1, 'vmid': vmid, 'installation_id': identity, 'status': 'installed',
'deployment': {'onboot': False, 'mounts': []},
'observed': {'resolved_registry_digest': OLD, 'image': {'defaults': {}}},
'stack_member': {'stack_id': MAIN, 'primary_vmid': 4900}})
records[1]['deployment']['execution_mode'] = 'one-shot'
records[0]['stack'] = {'id': MAIN, 'deployment': {'stack_name': 'test', 'services': services},
'members': copy.deepcopy(records)}
plan = {'operation': 'update', 'primary_vmid': 4900, 'members': records,
'start_order': [4902, 4901, 4900], 'stop_order': [4900, 4901, 4902]}
return plan
class NativeCompletedJobs(unittest.TestCase):
def adapter(self):
return native.NativeAdapter(Path('/unused'), Path('/unused/journal'), fixture())
def test_stopped_job_is_not_accepted_without_an_authenticated_success(self):
adapter = self.adapter()
with patch.object(adapter, '_job_contract'), \
patch.object(jobs, 'ensure_completed', side_effect=jobs.JobError('exit 7')), \
patch.object(adapter, 'is_running', return_value=False), \
patch.object(native.member_tx, 'run') as run:
with self.assertRaisesRegex(jobs.JobError, 'exit 7'):
adapter.healthcheck(4901)
run.assert_not_called()
def test_job_start_calls_completion_runner_not_pct_start(self):
adapter = self.adapter()
with patch.object(adapter, 'validate'), patch.object(adapter, '_job_contract'), \
patch.object(jobs, 'ensure_completed', return_value={'status': 'completed'}) as complete, \
patch.object(native.member_tx, 'run') as run:
adapter.start(4901)
complete.assert_called_once_with(4901, SPEC, force=False)
run.assert_not_called()
def test_database_host_bind_cannot_be_acknowledged_as_a_migration_backup(self):
adapter = self.adapter()
adapter.acknowledge = True
adapter.records[4902]['deployment']['mounts'] = [{'type': 'host-bind', 'source': '/database'}]
with self.assertRaisesRegex(ValueError, 'Migration jobs'):
adapter._migration_backups()
def test_unknown_internal_backend_host_bind_is_not_assumed_safe(self):
adapter = self.adapter()
adapter.services[4902]['kind'] = 'application'
adapter.records[4902]['deployment']['mounts'] = [{'type': 'host-bind', 'source': '/unknown'}]
with self.assertRaisesRegex(ValueError, 'Migration jobs'):
adapter._migration_backups()
def test_image_database_fingerprint_detects_an_unknown_tagged_repository(self):
adapter = self.adapter()
adapter.services[4902].update(kind='application', frontend=True)
adapter.records[4902]['observed']['image']['defaults']['Env'] = ['PG_MAJOR=16']
adapter.records[4902]['deployment']['mounts'] = [{'type': 'host-bind', 'source': '/database'}]
with self.assertRaisesRegex(ValueError, 'Migration jobs'):
adapter._migration_backups()
def test_primary_app_shared_files_and_readonly_backend_are_not_database_write_risks(self):
adapter = self.adapter()
adapter.records[4900]['deployment']['mounts'] = [{'type': 'host-bind', 'source': '/app'}]
adapter.records[4902]['deployment']['mounts'] = [{'type': 'host-bind', 'source': '/db', 'read_only': True}]
adapter._migration_backups()
def test_rollback_checks_database_before_rerunning_old_job_then_starts_main(self):
adapter = self.adapter()
events = []
with patch.object(adapter, 'state', return_value={'phase': 'recovering', 'replacement_intent': True}), \
patch.object(adapter, 'start', side_effect=lambda vmid: events.append(('start', vmid))), \
patch.object(adapter, 'healthcheck', side_effect=lambda vmid: events.append(('healthy', vmid))), \
patch.object(adapter, '_ensure_job', side_effect=lambda vmid, force=False: events.append(('job', vmid, force))), \
patch.object(adapter, 'stop', side_effect=lambda vmid: events.append(('stop', vmid))):
adapter.restore_running_state({'4900': True, '4901': False, '4902': True}, [4902, 4901, 4900])
self.assertLess(events.index(('healthy', 4902)), events.index(('job', 4901, True)))
self.assertLess(events.index(('job', 4901, True)), events.index(('start', 4900)))
self.assertNotIn(('start', 4901), events)
def job_contract(self, tamper=False, rollback=False):
adapter = self.adapter()
original = {'schema': 1, 'stack': 'test', 'dependencies': [
{'vmid': s['vmid'], 'label': s['name'], 'healthcheck': copy.deepcopy(s['healthcheck'])}
for s in adapter.services.values() if s['vmid'] != 4900]}
contract = copy.deepcopy(original)
if tamper:
contract['dependencies'][0]['label'] = 'externally changed'
state = {'id': 'operation', 'phase': 'recovering' if rollback else 'starting'}
writes = []
path = SimpleNamespace(lstat=lambda: SimpleNamespace(st_mode=stat.S_IFREG | 0o600, st_uid=0),
read_text=lambda: json.dumps(contract))
staged = copy.deepcopy(adapter.records[4901])
staged['observed']['resolved_registry_digest'] = OLD if rollback else NEW
updated = {**SPEC, 'image_digest': OLD if rollback else NEW,
'expected_config_fingerprint': 'c' * 64}
def write(target, value):
writes.append(copy.deepcopy(value))
if target is path:
contract.clear(); contract.update(copy.deepcopy(value))
else:
state.clear(); state.update(copy.deepcopy(value))
with patch.object(native, 'Path', return_value=path), \
patch.object(adapter, 'state', side_effect=lambda: copy.deepcopy(state)), \
patch.object(instances, 'write', side_effect=write), \
patch.object(instances, 'read', return_value=staged), \
patch.object(native.member_tx, 'owned', return_value=CONFIG.encode()), \
patch.object(jobs, 'job_spec_from_record', return_value=updated) as bind:
if tamper:
with self.assertRaisesRegex(ValueError, 'hook and the stack recipe differ'):
adapter._job_contract()
bind.assert_not_called()
self.assertEqual(writes, [])
return
adapter._job_contract(rollback=rollback)
self.assertEqual(bind.call_args.args[0]['observed']['resolved_registry_digest'], updated['image_digest'])
self.assertEqual(adapter.services[4901]['healthcheck']['job'], updated)
self.assertEqual(contract['dependencies'][1]['healthcheck']['job'], updated)
self.assertIn('job_hook_contract_intent', writes[0])
self.assertNotIn('job_hook_contract_intent', state)
def test_new_image_binds_new_job_digest_before_main_hook(self):
self.job_contract()
def test_rollback_rebinds_old_image_with_actual_restored_config(self):
self.job_contract(rollback=True)
def test_concurrent_hook_edit_is_not_overwritten(self):
self.job_contract(tamper=True)
def test_job_hook_intent_survives_orchestrator_rewriting_its_main_journal(self):
with tempfile.TemporaryDirectory() as directory:
adapter = self.adapter()
adapter.journal = Path(directory) / 'transaction.json'
adapter.journal.write_text(json.dumps({'id': 'operation', 'phase': 'starting'}))
contract = {'schema': 1, 'stack': 'test', 'dependencies': [
{'vmid': s['vmid'], 'label': s['name'], 'healthcheck': copy.deepcopy(s['healthcheck'])}
for s in adapter.services.values() if s['vmid'] != 4900]}
hook = SimpleNamespace(lstat=lambda: SimpleNamespace(st_mode=stat.S_IFREG | 0o600, st_uid=0),
read_text=lambda: json.dumps(contract))
staged = copy.deepcopy(adapter.records[4901])
staged['observed']['resolved_registry_digest'] = NEW
updated = {**SPEC, 'image_digest': NEW, 'expected_config_fingerprint': 'c' * 64}
actual_write = instances.write
def write(path, value):
if path is hook:
contract.clear(); contract.update(copy.deepcopy(value))
else:
actual_write(path, value)
with patch.object(native, 'Path', return_value=hook), \
patch.object(instances, 'write', side_effect=write), \
patch.object(instances, 'read', return_value=staged), \
patch.object(native.member_tx, 'owned', return_value=CONFIG.encode()), \
patch.object(jobs, 'job_spec_from_record', return_value=updated):
adapter._job_contract()
# The orchestration engine checkpoints its own original state object.
adapter.journal.write_text(json.dumps({'id': 'operation', 'phase': 'checking'}))
adapter._job_contract()
marker = json.loads((Path(directory) / 'completed-job-contract.json').read_text())
self.assertEqual(marker['operation_id'], 'operation')
self.assertEqual(marker['job_hook_contract']['dependencies'][1]['healthcheck']['job'], updated)
self.assertNotIn('job_hook_contract_intent', marker)
class JobPlanValidation(unittest.TestCase):
def test_plan_preserves_database_job_main_order(self):
fixture_plan = fixture()
primary = fixture_plan['members'][0]
records = {r['vmid']: r for r in fixture_plan['members']}
plan = plans.build(primary, records, {r['vmid']: r['installation_id'] for r in records.values()}, 'update')
self.assertEqual(plan['start_order'], [4902, 4901, 4900])
def test_job_cannot_be_onboot_or_bound_to_another_image(self):
for mutation in ('onboot', 'watchdog', 'mode', 'digest'):
with self.subTest(mutation=mutation):
value = fixture()
records = {r['vmid']: r for r in value['members']}
if mutation in ('onboot', 'watchdog'):
records[4901]['deployment'][mutation] = True
elif mutation == 'mode':
records[4901]['deployment'].pop('execution_mode')
else:
value['members'][0]['stack']['deployment']['services'][1]['healthcheck']['job']['image_digest'] = NEW
with self.assertRaises(ValueError):
plans.build(value['members'][0], records, {r['vmid']: r['installation_id'] for r in records.values()}, 'update')
class RestoreCompletedJobs(unittest.TestCase):
def test_only_bound_completed_contracts_are_accepted(self):
contract = {'schema': 1, 'dependencies': [{'vmid': 4901, 'label': 'migration', 'healthcheck': copy.deepcopy(CHECK)}]}
self.assertTrue(recovery.valid_contract(contract, {4901}))
for field, value in (('image_digest', 'latest'), ('installation_id', 'arbitrary'),
('expected_config_fingerprint', ''), ('timeout_seconds', True)):
broken = copy.deepcopy(contract)
broken['dependencies'][0]['healthcheck']['job'][field] = value
self.assertFalse(recovery.valid_contract(broken, {4901}), field)
def test_restore_never_starts_a_job_as_an_ordinary_daemon(self):
entries = [{'vmid': vmid, 'config': 'startup: order=' + str(order), 'contract': None}
for order, vmid in enumerate((4902, 4901, 4900), 1)]
plan = {'bridges': {}, 'renumbered': {}, 'restored': entries, 'hold': False, 'notes': []}
calls = []
def run(*args):
calls.append(args)
return SimpleNamespace(stdout='status: stopped', stderr='', returncode=0)
with patch.object(recovery, 'restore_host_files'), patch.object(recovery, 'clean_format_directories'), \
patch.object(recovery, 'register', return_value=[4902, 4901, 4900]), \
patch.object(recovery.carried, 'carry'), \
patch.object(recovery, 'completed_job', side_effect=lambda root, vmid: vmid == 4901), \
patch.object(recovery, 'run', side_effect=run):
recovery.recover(Path('/unused'), plan, start=True)
self.assertEqual([call for call in calls if call[:2] == ('pct', 'start')],
[('pct', 'start', '4902'), ('pct', 'start', '4900')])
def test_renumbered_restore_rebinds_current_config_and_invalidates_deferred_cache(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
plan = fixture()
for record in plan['members']:
instances.write(instances.location(root, record['vmid']), record)
entry = {'vmid': 4900, 'contract': {'schema': 1}}
restore_plan = {'restored': [entry]}
calls = []
def checked(*args):
if args[:2] == ('pct', 'config'):
return CONFIG.replace('subvol-4901', 'subvol-NEW-4901')
calls.append((args, json.loads(Path(args[-1]).read_text())))
return ''
with patch.object(recovery, 'checked', side_effect=checked):
recovery.refresh_completed_jobs(root, restore_plan)
restored = instances.read(root, 4901)
main = instances.read(root, 4900)
check = main['stack']['deployment']['services'][1]['healthcheck']
self.assertTrue(restored['recovered_at'])
self.assertEqual(check['job']['installation_id'], JOB)
self.assertEqual(check['job']['image_digest'], OLD)
self.assertNotEqual(check['job']['expected_config_fingerprint'], SPEC['expected_config_fingerprint'])
self.assertEqual(calls[0][0][-2], '4900')
self.assertEqual([d['vmid'] for d in calls[0][1]['dependencies']], [4902, 4901])
self.assertTrue(recovery.completed_job(root, 4901))
self.assertFalse(recovery.completed_job(root, 4902))
if __name__ == '__main__':
unittest.main()
+41
View File
@@ -0,0 +1,41 @@
"""Compose aliases remain single, safe /etc/hosts tokens, including underscores."""
from pathlib import Path
import re
import subprocess
import sys
import unittest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'src'))
from proxmenux_oci.converter import _extra_hosts
class ComposeHostAliasTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
source = (ROOT / 'remote/install_oci.sh').read_text()
cls.shell = re.search(r'^oci_valid_extra_hostname\(\) \{\n.*?^\}', source, re.M | re.S)[0]
def shell_accepts(self, value):
return subprocess.run(['bash', '-c', self.shell + '\noci_valid_extra_hostname "$1"', 'alias-test', value],
capture_output=True).returncode == 0
def test_compose_container_aliases_are_accepted_by_import_and_installer(self):
for value in ('affine_postgres', 'affine_migration_job', 'db-1', 'db.internal', 'a', 'x' * 253):
with self.subTest(value=value):
self.assertTrue(self.shell_accepts(value))
self.assertEqual(_extra_hosts({value: '10.77.8.2'}),
[{'hostname': value, 'address': '10.77.8.2'}])
def test_multiline_whitespace_and_shell_syntax_are_refused(self):
for value in ('', 'db name', 'db\tname', 'db\n127.0.0.1 injected', '#comment',
'-db', '_db', '$(id)', 'db;id', 'db/../host', 'x' * 254):
with self.subTest(value=value):
self.assertFalse(self.shell_accepts(value))
self.assertIsNone(_extra_hosts({value: '10.77.8.2'}))
def test_apply_extra_hosts_uses_validated_token_before_writing(self):
source = (ROOT / 'remote/install_oci.sh').read_text()
apply = source.split('apply_extra_hosts() {', 1)[1].split('\n}', 1)[0]
self.assertLess(apply.index('oci_valid_extra_hostname "$hostname"'),
apply.index("printf '%s %s\\n'"))
+132
View File
@@ -0,0 +1,132 @@
"""Compose inputs are explicit, deterministic, and never taken from the host."""
import os
from pathlib import Path
import sys
import tempfile
import unittest
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'src'))
from proxmenux_oci.compose_input import Interpolator, dotenv, prepare, resolve_document
from proxmenux_oci.converter import ConversionError
from proxmenux_oci.custom import template_from_compose, _dump
class ComposeInputs(unittest.TestCase):
def test_default_required_alternative_nested_and_escaped_dollar(self):
expand = Interpolator({'EMPTY': '', 'SET': 'value'}).expand
for source, expected in [
('${MISSING:-fallback}', 'fallback'), ('${EMPTY:-fallback}', 'fallback'),
('${EMPTY-fallback}', ''), ('${SET:+yes}', 'yes'), ('${MISSING+yes}', ''),
('${EMPTY+yes}', 'yes'), ('${MISSING:-${SET}}', 'value'),
('$$HOME $SET ${SET}', '$HOME value value'), ('$5', '$5')]:
with self.subTest(source=source):
self.assertEqual(expand(source), expected)
for source in ['${MISSING?needed}', '${EMPTY:?needed}', '${NO}', '${BAD', '${X:=bad}']:
with self.subTest(source=source), self.assertRaises(ConversionError):
expand(source)
def test_never_reads_host_environment(self):
with patch.dict(os.environ, {'COMPOSE_SECRET': 'must-not-inherit'}):
with self.assertRaises(ConversionError):
Interpolator().expand('${COMPOSE_SECRET}')
def test_dotenv_quotes_comments_interpolation_and_no_shell_execution(self):
parsed = dotenv('''A=hello
B="${A} world" # note
C='literal$NO'
D=unquoted # comment
E=$(touch /tmp/should-not-exist)
F=
G
''', Interpolator())
self.assertEqual(parsed['B'], 'hello world')
self.assertEqual(parsed['C'], 'literal$NO')
self.assertEqual(parsed['D'], 'unquoted')
self.assertEqual(parsed['E'], '$(touch /tmp/should-not-exist)')
self.assertEqual(parsed['F'], '')
self.assertIsNone(parsed['G'])
for text in ['NOT A KEY=value', 'A="x" junk', "A='unterminated"]:
with self.subTest(text=text), self.assertRaises(ConversionError):
dotenv(text, Interpolator())
def test_file_merge_order_and_environment_override(self):
doc = {'services': {'app': {'image': 'example:${VERSION:-stable}',
'env_file': ['first.env', {'path':'optional.env', 'required':False}, 'second.env'],
'environment': {'A':'explicit', 'B':None}}}}
result = resolve_document(doc, {'B':'inherited-explicitly'},
{'first.env':'A=one\nB=first\nC=first', 'second.env':'C=second'})
self.assertEqual(result['services']['app']['image'], 'example:stable')
self.assertEqual(result['services']['app']['environment'],
{'A':'explicit', 'B':'inherited-explicitly', 'C':'second'})
self.assertNotIn('env_file', result['services']['app'])
self.assertIn('env_file', doc['services']['app'])
def test_unset_bare_variables_are_removed_and_optional_file_is_not_prompted(self):
class UI:
def ask(self, *args, **kwargs): raise AssertionError('Optional input must not be required')
document={'services': {'app': {'image':'example:1', 'env_file':[{'path':'missing.env','required':False}],
'environment':['UNSET', 'EMPTY=']}}}
with tempfile.TemporaryDirectory() as directory:
result=prepare(UI(),document,directory)
self.assertEqual(result['services']['app']['environment'],{'EMPTY':''})
def test_single_quoted_escape_and_dollar_value_are_literal(self):
self.assertEqual(dotenv("PASSWORD='it\\'s$literal'", Interpolator())['PASSWORD'],"it's$literal")
def test_selinux_mount_labels_are_normalized_with_explicit_host_guard_metadata(self):
class UI:
def ask(self,*args,**kwargs): raise AssertionError('No input required')
doc={'services':{'app':{'image':'example:1','volumes':['shared:/data:ro,z']},
'worker':{'image':'example:1','volumes':['shared:/data:Z']}}}
metadata={}
result=prepare(UI(),doc,metadata=metadata)
self.assertTrue(metadata['selinux_labels'])
self.assertEqual(result['services']['app']['volumes'],[{'source':'shared','target':'/data','read_only':True}])
self.assertEqual(result['services']['worker']['volumes'][0]['read_only'],False)
template=template_from_compose(_dump({'services':{'app':{'image':'adminer:4','ports':['8080:8080']},
'db':{'image':'postgres:16','environment':{'POSTGRES_PASSWORD':'test-only'}}}}),
interpolated=True,input_metadata=metadata)
self.assertTrue(template['proxmox']['custom_compose_selinux_labels'])
def test_literal_secret_is_not_expanded_again_by_stack_compiler(self):
document = {'services': {'db': {'image':'postgres:16',
'environment':{'POSTGRES_PASSWORD':'${PASSWORD}'}},
'app':{'image':'adminer:4','ports':['8080:8080'],
'environment':{'DATABASE_URL':'postgres://user:${PASSWORD}@db/test'}}}}
resolved = resolve_document(document, {'PASSWORD':'literal$PASSWORD${ALSO_LITERAL}'})
template = template_from_compose(_dump(resolved), interpolated=True)
self.assertTrue(template['compatibility']['automatic_install_candidate'], template['compatibility'])
db = next(s for s in template['compose_stack']['services'] if s['name']=='db')
self.assertEqual(db['compose']['environment']['POSTGRES_PASSWORD'], 'literal$PASSWORD${ALSO_LITERAL}')
def test_companion_dotenv_and_env_file_read_from_explicit_source_directory(self):
class UI:
def ask(self, *args, **kwargs):
raise AssertionError('No extra input needed for supplied companion files')
password = ask
with tempfile.TemporaryDirectory() as directory:
Path(directory, '.env').write_text('VERSION=16\nPASSWORD=test-only\n')
Path(directory, 'db.env').write_text('POSTGRES_PASSWORD=${PASSWORD}\n')
doc = {'services':{'db':{'image':'postgres:${VERSION}', 'env_file':'db.env'},
'app':{'image':'adminer:4','ports':['8080:8080']}}}
result = prepare(UI(), doc, directory)
self.assertEqual(result['services']['db']['image'], 'postgres:16')
self.assertEqual(result['services']['db']['environment']['POSTGRES_PASSWORD'], 'test-only')
def test_missing_values_are_asked_once_and_secrets_use_password_input(self):
class UI:
def __init__(self): self.prompts=[]
def ask(self, prompt, default='', **kwargs):
return ''
def password(self, prompt, **kwargs):
self.prompts.append(prompt); return 'synthetic-secret'
ui=UI()
doc={'services':{'one':{'image':'example:1','environment':{'PASSWORD':'${PASSWORD}'}},
'two':{'image':'example:2','environment':{'PASSWORD':'${PASSWORD}'}}}}
result=prepare(ui,doc)
self.assertEqual(len(ui.prompts),1)
self.assertEqual(result['services']['one']['environment'],result['services']['two']['environment'])
if __name__ == '__main__': unittest.main()
+92
View File
@@ -0,0 +1,92 @@
"""One-shot dependencies use the persistent verified helper, never bare pct start."""
import io
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tarfile
import tempfile
import unittest
from unittest.mock import patch
ROOT=Path(__file__).resolve().parents[1]
sys.path.insert(0,str(ROOT/'remote'))
sys.path.insert(0,str(ROOT/'src'))
import install_generic_stack as stack
import oci_compose_jobs as jobs
from proxmenux_oci import installer
IID='55bb1839-f087-4b03-a9b8-75bec7537c9c'
DIGEST='sha256:'+'a'*64
CONFIG=f'description: proxmenux-instance={IID}\nrootfs: local-zfs:subvol-4791-disk-0,size=8G\nentrypoint: sh -c true\n'
RECORD={'vmid':4791,'installation_id':IID,'observed':{'resolved_registry_digest':DIGEST}}
class JobInstallIntegrationTests(unittest.TestCase):
def test_installer_binds_completed_job_after_actual_runtime_configuration(self):
service={'vmid':4791,'deployment':{'onboot':True},'healthcheck':{'type':'completed','timeout_seconds':300}}
def run(*args):
if args==('pct','config',4791):return CONFIG
self.assertEqual(args,('pct','set',4791,'--onboot','0'));return ''
with patch.object(stack,'run',side_effect=run),patch.object(stack.oci_instances,'read',return_value=RECORD):
stack.bind_completed_jobs([service])
self.assertFalse(service['deployment']['onboot'])
self.assertEqual(service['healthcheck']['job'],jobs.job_spec_from_record(RECORD,CONFIG))
def test_local_transport_really_copies_standalone_job_helper(self):
with tempfile.TemporaryDirectory() as directory:
template=Path(directory)/'template.json';template.write_text('{}')
def inspect(command,*args):
staged=Path(command[1]).parent/'oci_compose_jobs.py'
self.assertEqual(staged.read_bytes(),(ROOT/'remote/oci_compose_jobs.py').read_bytes())
return None
with patch.object(installer,'_stream_process',side_effect=inspect):
installer._run_remote_install(ROOT,template,{'deployment_kind':'generic-multi-lxc-stack'},'local',True)
def test_ssh_transport_really_archives_standalone_job_helper(self):
with tempfile.TemporaryDirectory() as directory:
template=Path(directory)/'template.json';template.write_text('{}')
def inspect(command,data):
self.assertEqual(command[0],'ssh')
with tarfile.open(fileobj=io.BytesIO(data),mode='r:gz') as archive:
self.assertEqual(archive.extractfile('oci_compose_jobs.py').read(),(ROOT/'remote/oci_compose_jobs.py').read_bytes())
return None
with patch.object(installer,'_stream_process',side_effect=inspect):
installer._run_remote_install(ROOT,template,{'deployment_kind':'generic-multi-lxc-stack'},'test-node',True)
@unittest.skipUnless(shutil.which('jq'),'jq required')
def test_hook_contract_rejects_incomplete_or_unsafe_job_spec(self):
text=(ROOT/'remote/stack_dependency_hook.sh').read_text()
start=text.index(" jq -e '")+len(" jq -e '");end=text.index("\n ' \"$source_config\"",start)
expression=text[start:end]
spec=jobs.job_spec_from_record(RECORD,CONFIG)
contract={'schema':1,'dependencies':[{'vmid':4791,'label':'migration','healthcheck':{'type':'completed','timeout_seconds':300,'job':spec}}]}
good=subprocess.run(['jq','-e',expression],input=json.dumps(contract),text=True,capture_output=True)
self.assertEqual(good.returncode,0,good.stderr)
contract['dependencies'][0]['healthcheck']['job']['image_digest']='latest'
bad=subprocess.run(['jq','-e',expression],input=json.dumps(contract),text=True,capture_output=True)
self.assertNotEqual(bad.returncode,0)
@unittest.skipUnless(shutil.which('jq') and shutil.which('flock'),'jq/flock required')
def test_hook_calls_completed_helper_without_starting_job_directly(self):
with tempfile.TemporaryDirectory() as directory:
root=Path(directory);priv=root/'priv';priv.mkdir();locks=root/'locks';locks.mkdir();binary=root/'bin';binary.mkdir()
helper=root/'jobs.py';helper.write_text("import json,os,sys\nfrom pathlib import Path\nPath(os.environ['JOB_CALLS']).write_text(json.dumps(sys.argv[1:]))\n")
pct=binary/'pct';pct.write_text('#!/bin/sh\n[ "$1" = config ] && exit 0\necho "UNEXPECTED_PCT_START" >&2\nexit 99\n');pct.chmod(0o755)
stat=binary/'stat';stat.write_text('#!/bin/sh\n[ "$2" = %u ] && { echo 0; exit 0; }\n[ "$2" = %a ] && { echo 755; exit 0; }\nexit 1\n');stat.chmod(0o755)
spec=jobs.job_spec_from_record(RECORD,CONFIG)
(priv/'proxmenux-stack-4790.json').write_text(json.dumps({'schema':1,'dependencies':[{'vmid':4791,'label':'migration','healthcheck':{'type':'completed','timeout_seconds':300,'job':spec}}]}))
text=(ROOT/'remote/stack_dependency_hook.sh').read_text()
functions=text[text.index('die() {'):text.index('\nif [[ ${1:-} == "--install"')]
functions=functions.replace('/etc/pve/priv',str(priv)).replace('/run/lock',str(locks))
command='COMPOSE_JOB_HELPER='+str(helper)+'\n'+functions+'\nstart_dependencies 4790\n'
calls=root/'calls.json';env={**os.environ,'PATH':str(binary)+':'+os.environ['PATH'],'JOB_CALLS':str(calls)}
result=subprocess.run(['bash','-c',command],env=env,text=True,capture_output=True)
self.assertEqual(result.returncode,0,result.stderr)
self.assertEqual(json.loads(calls.read_text())[:2],['ensure','4791'])
self.assertNotIn('UNEXPECTED_PCT_START',result.stderr)
if __name__=='__main__':unittest.main()
+219
View File
@@ -0,0 +1,219 @@
"""A completed OCI job needs real exit-zero proof, not stopped/CLI output."""
import contextlib
import copy
import json
import os
from pathlib import Path
import socket
import sys
import tempfile
import time
import unittest
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1]/'remote'))
import oci_compose_jobs as jobs
IID='55bb1839-f087-4b03-a9b8-75bec7537c9c'
DIGEST='sha256:'+'a'*64
IDENTITY={'vmid':4790,'installation_id':IID,'image_digest':DIGEST,'config_fingerprint':'f'*64}
PROOF={'raw_waitstatus':0,'exit_code':0,'fresh_start_observed':True,'protocol':'lxc-msg-native264'}
CONFIG=f'''description: OCI %3C!-- proxmenux-instance={IID} --%3E
rootfs: local-zfs:subvol-4790-disk-0,size=8G
entrypoint: sh -c 'exit 0'
env: TOKEN=literal-secret-value
unprivileged: 1
lxc.init.uid: 1000
lxc.init.gid: 1000
mp0: /mnt/test/shared,mp=/data,backup=0,size=8G
cores: 1
memory: 256
tags: temporary
'''
def pack(kind,name,value):return jobs.FRAME.pack(kind,str(name).encode(),value)
class FakeProcess:
def __init__(self,rc=255):self.returncode=rc
def poll(self):return self.returncode
def wait(self,timeout=None):return self.returncode
def terminate(self):self.returncode=-15
def kill(self):self.returncode=-9
class NativeExitTests(unittest.TestCase):
def attempt(self, events):
receiver,sender=socket.socketpair()
with receiver,sender:
receiver.setblocking(False)
@contextlib.contextmanager
def subscription():yield receiver,{'protocol':'test-native-frames'}
def start(*args,**kwargs):
sender.sendall(b''.join(pack(*event) for event in events))
return FakeProcess()
with patch.object(jobs,'_subscription',subscription), \
patch.object(jobs.subprocess,'Popen',side_effect=start), \
patch.object(jobs,'_status',return_value='stopped'):
return jobs._run_attempt(4790,time.monotonic()+.2)
def test_exit_zero_is_proven_even_when_pct_start_loses_the_fast_pid(self):
proof=self.attempt([(0,4790,1),(0,4790,2),(2,4790,0)])
self.assertEqual(proof['raw_waitstatus'],0)
self.assertEqual(proof['pct_start_returncode'],255)
def test_nonzero_and_sigkill_never_become_completed(self):
for raw in (7<<8,9):
with self.subTest(raw=raw),self.assertRaises(jobs.JobError):
self.attempt([(0,4790,1),(0,4790,2),(2,4790,raw)])
def test_stopped_or_missing_exit_is_not_success(self):
with self.assertRaisesRegex(jobs.JobError,'timed out'):
self.attempt([(0,4790,1),(0,4790,2),(0,4790,0)])
def test_exit_without_a_fresh_start_event_is_rejected(self):
with self.assertRaisesRegex(jobs.JobError,'fresh'):
self.attempt([(2,4790,0)])
def test_unrelated_guest_exit_zero_does_not_complete_this_job(self):
with self.assertRaisesRegex(jobs.JobError,'timed out'):
self.attempt([(0,4790,1),(2,123,0),(0,4790,0)])
def test_partial_frames_are_buffered_and_unknown_abi_is_rejected(self):
data=pack(2,4790,1792)
self.assertEqual(jobs._frames(data[:8]),([],data[:8]))
self.assertEqual(jobs._frames(data),([(2,'4790',1792)],b''))
with self.assertRaises(jobs.JobError):jobs._frames(pack(100,4790,0))
class IdentityAndTrustTests(unittest.TestCase):
def test_command_environment_user_and_mount_changes_invalidate_fingerprint(self):
first=jobs.config_fingerprint(CONFIG)
for before,after in [('exit 0','exit 7'),('literal-secret-value','changed'),
('lxc.init.uid: 1000','lxc.init.uid: 1001'),('/mnt/test/shared','/mnt/test/other')]:
with self.subTest(before=before):
self.assertNotEqual(jobs.config_fingerprint(CONFIG.replace(before,after)),first)
altered=CONFIG.replace('cores: 1','cores: 4').replace('memory: 256','memory: 4096').replace('tags: temporary','tags: renamed').replace('size=8G','size=16G')
self.assertEqual(jobs.config_fingerprint(altered),first)
self.assertNotIn('literal-secret-value',first)
def test_instance_uuid_digest_and_container_marker_are_bound(self):
record={'vmid':4790,'installation_id':IID,'observed':{'resolved_registry_digest':DIGEST}}
spec={'installation_id':IID,'image_digest':DIGEST}
def read(path):return json.dumps(record) if str(path).endswith('.json') else CONFIG
with patch.object(jobs,'_trusted_read',side_effect=read), patch.object(jobs,'_config_text',return_value=CONFIG):
result=jobs.current_identity(4790,spec)
self.assertEqual(result['installation_id'],IID)
with self.assertRaises(jobs.JobError):jobs.current_identity(4790,{**spec,'image_digest':'sha256:'+'b'*64})
with self.assertRaises(jobs.JobError):jobs.current_identity(4790,{**spec,'installation_id':str(__import__('uuid').uuid4())})
with self.assertRaises(jobs.JobError):jobs.current_identity(4790,{**spec,'expected_config_fingerprint':'b'*64})
with patch.object(jobs,'_trusted_read',return_value=json.dumps(record)), patch.object(jobs,'_config_text',return_value=CONFIG.replace(IID,'another-instance')):
with self.assertRaises(jobs.JobError):jobs.current_identity(4790,spec)
def test_staged_digest_requires_matching_child_and_stack_transactions(self):
txid='a'*32
stackid='b'*32
journal=jobs.INSTANCE_ROOT/'4790'/'transactions'/txid/'transaction.json'
parent=jobs.INSTANCE_ROOT/'4792'/'stack-transactions'/stackid/'transaction.json'
new_digest='sha256:'+'b'*64
record={'vmid':4790,'installation_id':IID,'status':'updating',
'observed':{'resolved_registry_digest':DIGEST},'transaction_id':txid,
'pending_transaction':str(journal),'pending_stack_transaction':str(parent)}
child={'schema_version':1,'id':txid,'vmid':4790,'phase':'candidate-installed',
'record':{'installation_id':IID},'registry_digest':new_digest,
'staged_config_sha256':__import__('hashlib').sha256(CONFIG.encode()).hexdigest(),
'coordinated':{'journal':str(parent),'id':'active-stack-operation'}}
stack={'schema_version':1,'id':'active-stack-operation','phase':'starting',
'plan':{'members':[{'vmid':4790,'installation_id':IID}]}}
def read(path):
return json.dumps(child if path==journal else stack if path==parent else record)
with patch.object(jobs,'_trusted_read',side_effect=read),patch.object(jobs,'_config_text',return_value=CONFIG):
spec=jobs.job_spec_from_record(record,CONFIG)
self.assertEqual(spec['image_digest'],new_digest)
self.assertEqual(jobs.current_identity(4790,spec)['image_digest'],new_digest)
child['staged_config_sha256']='0'*64
with self.assertRaises(jobs.JobError):jobs.current_identity(4790,spec)
child['staged_config_sha256']=__import__('hashlib').sha256(CONFIG.encode()).hexdigest()
stack['id']='another-operation'
with self.assertRaises(jobs.JobError):jobs.current_identity(4790,spec)
stack['id']='active-stack-operation';stack['phase']='replacing'
self.assertEqual(jobs.job_spec_from_record(record,CONFIG)['image_digest'],new_digest)
with self.assertRaises(jobs.JobError):jobs.current_identity(4790,spec)
def test_restore_generation_changes_identity_even_with_identical_config(self):
record={'vmid':4790,'installation_id':IID,'observed':{'resolved_registry_digest':DIGEST}}
spec={'installation_id':IID,'image_digest':DIGEST}
with patch.object(jobs,'_trusted_read',side_effect=lambda path:json.dumps(record)),patch.object(jobs,'_config_text',return_value=CONFIG):
before=jobs.current_identity(4790,spec)
record['recovered_at']='2026-10-28T12:00:00Z'
after=jobs.current_identity(4790,spec)
self.assertEqual(before['config_fingerprint'],after['config_fingerprint'])
self.assertNotEqual(before,after)
def test_metadata_final_symlinks_and_world_writes_are_rejected(self):
with tempfile.TemporaryDirectory() as directory:
source=Path(directory)/'record';source.write_text('{}');source.chmod(0o600)
link=Path(directory)/'link';link.symlink_to(source)
self.assertEqual(jobs._trusted_read(source,owner=os.getuid()),'{}')
with self.assertRaises(OSError):jobs._trusted_read(link,owner=os.getuid())
source.chmod(0o666)
with self.assertRaises(jobs.JobError):jobs._trusted_read(source,owner=os.getuid())
def test_socket_name_is_the_source_verified_lxc_hash(self):
self.assertEqual(jobs._socket_name(),'lxc/ad055575fe28ddd5//var/lib/lxc')
class CompletionCacheTests(unittest.TestCase):
def setUp(self):
directory=tempfile.TemporaryDirectory();self.addCleanup(directory.cleanup)
self.root=Path(directory.name)
for name,replacement in [('STATE_ROOT',self.root),('_root',lambda:None),
('_job_lock',lambda *args:contextlib.nullcontext()),
('_status',lambda *args:'stopped')]:
item=patch.object(jobs,name,replacement);item.start();self.addCleanup(item.stop)
item=patch.object(jobs,'current_identity',return_value=IDENTITY);item.start();self.addCleanup(item.stop)
item=patch.object(jobs,'_read_cache',side_effect=lambda path:json.loads(path.read_text()) if path.exists() else None);item.start();self.addCleanup(item.stop)
self.spec={'installation_id':IID,'image_digest':DIGEST,'timeout_seconds':30}
def test_only_matching_proven_cache_skips_job_start(self):
with patch.object(jobs,'_run_attempt',return_value=PROOF) as attempt:
self.assertFalse(jobs.ensure_completed(4790,self.spec)['cached'])
self.assertTrue(jobs.ensure_completed(4790,self.spec)['cached'])
attempt.assert_called_once()
cache=json.loads((self.root/'4790.json').read_text())
self.assertEqual(cache['identity'],IDENTITY)
self.assertEqual(cache['proof']['raw_waitstatus'],0)
def test_changed_image_identity_or_config_never_reuses_old_completion(self):
cache={'schema':1,'status':'completed','identity':{**IDENTITY,'image_digest':'sha256:'+'b'*64},'proof':PROOF}
(self.root/'4790.json').write_text(json.dumps(cache))
with patch.object(jobs,'_run_attempt',return_value=PROOF) as attempt:
self.assertFalse(jobs.ensure_completed(4790,self.spec)['cached'])
attempt.assert_called_once()
def test_force_failure_invalidates_earlier_success(self):
with patch.object(jobs,'_run_attempt',return_value=PROOF):jobs.ensure_completed(4790,self.spec)
with patch.object(jobs,'_run_attempt',side_effect=jobs.JobError('signal 9')):
with self.assertRaises(jobs.JobError):jobs.ensure_completed(4790,self.spec,force=True)
self.assertFalse((self.root/'4790.json').exists())
def test_remove_forgets_only_matching_installation_uuid(self):
with patch.object(jobs,'_run_attempt',return_value=PROOF):jobs.ensure_completed(4790,self.spec)
other='45e85d70-4e28-416f-8ae8-325ae9153272'
self.assertFalse(jobs.forget_completed(4790,other))
self.assertTrue((self.root/'4790.json').exists())
self.assertTrue(jobs.forget_completed(4790,IID))
self.assertFalse((self.root/'4790.json').exists())
self.assertFalse(jobs.forget_completed(4790,IID))
def test_running_job_and_bad_timeout_are_refused(self):
with patch.object(jobs,'_status',return_value='running'),patch.object(jobs,'_run_attempt') as attempt:
with self.assertRaisesRegex(jobs.JobError,'already running'):jobs.ensure_completed(4790,self.spec)
attempt.assert_not_called()
for timeout in (True,0,29,3601,'300'):
with self.subTest(timeout=timeout),self.assertRaises(jobs.JobError):
jobs.ensure_completed(4790,{**self.spec,'timeout_seconds':timeout})
if __name__=='__main__':unittest.main()
+57
View File
@@ -0,0 +1,57 @@
"""Explicit Compose stop signals override OCI image metadata without guessing."""
from pathlib import Path
import sys
import unittest
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'src'))
from proxmenux_oci.custom import template_from_compose
from proxmenux_oci.stack import assess, service_template
from proxmenux_oci.converter import _stop_signal
SOURCE = '''services:
web:
image: nginx:alpine
ports: ['8080:80']
depends_on: [worker]
worker:
image: example/worker:latest
entrypoint: /worker.sh
'''
class ComposeStopSignalTests(unittest.TestCase):
def test_explicit_worker_signal_reaches_native_runtime(self):
template = template_from_compose(SOURCE + ' stop_signal: SIGTERM\n')
self.assertEqual(assess(template), [])
worker = next(s for s in template['compose_stack']['services'] if s['name'] == 'worker')
native = service_template(template, worker)
self.assertEqual(native['proxmox']['installer_profile']['runtime']['halt_signal'], 'SIGTERM')
self.assertEqual(native['proxmox']['installer_profile']['runtime']['compose_entrypoint'], '/worker.sh')
def test_main_signal_is_applied_too(self):
document = SOURCE.replace(" ports: ['8080:80']", " ports: ['8080:80']\n stop_signal: SIGQUIT")
template = template_from_compose(document)
web = next(s for s in template['compose_stack']['services'] if s['name'] == 'web')
native = service_template(template, web)
self.assertEqual(native['proxmox']['installer_profile']['runtime']['halt_signal'], 'SIGQUIT')
self.assertEqual(native['proxmox']['image_metadata_policy']['stop_signal'], 'apply-compose-override')
def test_absent_signal_does_not_guess_from_entrypoint_override(self):
template = template_from_compose(SOURCE)
worker = next(s for s in template['compose_stack']['services'] if s['name'] == 'worker')
self.assertNotIn('halt_signal', service_template(template, worker)['proxmox']['installer_profile']['runtime'])
def test_invalid_signal_is_a_review_blocker(self):
for value in ('SIGTYPO', 'SIGTERM;touch /tmp/x', '', 99, None):
with self.subTest(value=value):
document = SOURCE + ' stop_signal: ' + (repr(value) if isinstance(value, str) else str(value).lower()) + '\n'
template = template_from_compose(document)
self.assertTrue(any('stop-signal-format' in e for e in assess(template)))
def test_named_linux_signals_normalize_independent_of_host_os(self):
self.assertEqual(_stop_signal('term'), 'SIGTERM')
self.assertEqual(_stop_signal('SIGWINCH'), 'SIGWINCH')
self.assertEqual(_stop_signal('SIGPWR'), 'SIGPWR')
self.assertIsNone(_stop_signal('SIGWINCH\nSIGTERM'))
if __name__ == '__main__':
unittest.main()
+71
View File
@@ -0,0 +1,71 @@
"""Successful-completion conditions represent jobs, not daemon liveness."""
import copy
from pathlib import Path
import sys
import unittest
from unittest.mock import patch
sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'src'))
from proxmenux_oci.custom import template_from_compose
from proxmenux_oci.stack import assess,build_stack,DefaultsUI,ordered_services
from proxmenux_oci.installer import DEFAULT_MODE
COMPOSE='''services:
app:
image: example.org/application:3
ports: ['8080:8080']
depends_on:
migration: {condition: service_completed_successfully}
volumes: [storage:/data]
migration:
image: example.org/application:3
command: [sh, -c, run-migrations]
depends_on: [db]
volumes: [storage:/data]
db:
image: postgres:16
environment: {POSTGRES_PASSWORD: synthetic-only}
volumes:
storage:
'''
class UI(DefaultsUI):
def confirm(self,text,default=False):return True
class JobPlanning(unittest.TestCase):
def test_job_order_completion_check_and_no_autostart(self):
template=template_from_compose(COMPOSE,interpolated=True)
self.assertEqual(assess(template),[])
self.assertEqual([s['name'] for s in ordered_services(template)],['db','migration','app'])
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',return_value=({'app':'dhcp'},None)):
plan=build_stack(template,UI(),DEFAULT_MODE)
services={s['name']:s for s in plan['services']}
self.assertEqual(services['migration']['healthcheck']['type'],'completed')
self.assertEqual(services['migration']['deployment']['execution_mode'],'one-shot')
self.assertFalse(services['migration']['deployment']['onboot'])
self.assertTrue(services['app']['deployment']['onboot'])
self.assertNotIn('frontend',services['migration'])
shared=[m['source'] for name in ['app','migration'] for m in services[name]['deployment']['mounts'] if m['container_path']=='/data']
self.assertEqual(len(set(shared)),1)
def test_a_job_cannot_be_treated_as_a_continuously_healthy_daemon(self):
template=template_from_compose(COMPOSE,interpolated=True)
template['compose_stack']['services'][0]['compose'].setdefault('depends_on',{})
main=next(s for s in template['compose_stack']['services'] if s['name']=='app')
main['compose']['depends_on']['migration']['condition']='service_healthy'
# Without a health check, a migration-like worker cannot satisfy healthy.
self.assertTrue(assess(template))
def test_auto_restart_of_successful_jobs_is_not_silently_enabled(self):
template=template_from_compose(COMPOSE,interpolated=True)
job=next(s for s in template['compose_stack']['services'] if s['name']=='migration')
job['compose']['restart']='always'
self.assertTrue(any('restart' in error for error in assess(template)))
if __name__=='__main__':unittest.main()
+86
View File
@@ -0,0 +1,86 @@
"""Internal service metadata is inferred from OCI, not app-specific recipes."""
import json
from pathlib import Path
import subprocess
import sys
import unittest
from types import SimpleNamespace
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 enrich_image_defaults
import install_generic_stack as generic
class ImageDefaults(unittest.TestCase):
def test_internal_expose_and_health_from_oci_are_preserved_without_publishing_ports(self):
doc={'services':{'helper':{'image':'example.org/team/helper:3','healthcheck':{'disable':False,'retries':5}}}}
config={'config':{'ExposedPorts':{'7000/tcp':{},'7001/udp':{}},'Healthcheck':{
'Test':['CMD','/usr/bin/check-ready'],'Interval':3000000000,'Timeout':1000000000}}}
process=SimpleNamespace(returncode=0,stdout=json.dumps(config))
with patch('proxmenux_oci.custom.subprocess.run',return_value=process):
result=enrich_image_defaults(None,doc)['services']['helper']
self.assertEqual(result['expose'],['7000/tcp'])
self.assertNotIn('ports',result)
self.assertEqual(result['healthcheck']['test'],['CMD','/usr/bin/check-ready'])
self.assertEqual(result['healthcheck']['retries'],5)
self.assertEqual(result['healthcheck']['interval'],'3000000000ns')
self.assertNotIn('expose',doc['services']['helper'])
def test_null_image_health_does_not_crash_and_no_port_is_invented(self):
doc={'services':{'helper':{'image':'example.org/team/helper:3','healthcheck':{'disable':False}}}}
with patch('proxmenux_oci.custom.subprocess.run',return_value=SimpleNamespace(returncode=0,stdout='{"config":{"Healthcheck":null}}')):
result=enrich_image_defaults(None,doc)['services']['helper']
self.assertNotIn('healthcheck',result)
self.assertNotIn('expose',result)
def test_explicit_disabled_healthcheck_is_not_reenabled(self):
doc={'services':{'web':{'image':'example.org/web:1','ports':['8080:8080'],'healthcheck':{'disable':True}}}}
with patch('proxmenux_oci.custom.subprocess.run') as inspect:
self.assertEqual(enrich_image_defaults(None,doc),doc)
inspect.assert_not_called()
def test_worker_entrypoint_or_command_does_not_inherit_a_false_web_listener(self):
for override in [{'entrypoint':'/cron.sh'}, {'command':['worker','run']}, {'command':[]}]:
with self.subTest(override=override):
doc={'services':{'worker':{'image':'example.org/web-and-worker:1',**override}}}
config={'config':{'ExposedPorts':{'80/tcp':{}},'Healthcheck':None}}
with patch('proxmenux_oci.custom.subprocess.run',return_value=SimpleNamespace(returncode=0,stdout=json.dumps(config))):
result=enrich_image_defaults(None,doc)['services']['worker']
self.assertNotIn('expose',result)
def test_option_command_uses_actual_image_entrypoint_metadata(self):
doc={'services':{'db':{'image':'mariadb:11','command':['--transaction-isolation=READ-COMMITTED']}}}
config={'config':{'Entrypoint':['docker-entrypoint.sh'],'Cmd':['mariadbd']}}
entrypoints={}
with patch('proxmenux_oci.custom.subprocess.run',return_value=SimpleNamespace(returncode=0,stdout=json.dumps(config))):
result=enrich_image_defaults(None,doc,image_entrypoints=entrypoints)
self.assertEqual(entrypoints,{'mariadb:11':['docker-entrypoint.sh']})
self.assertEqual(result,doc)
def test_enforcing_selinux_stops_before_install_when_labels_need_translation(self):
import tempfile
with tempfile.TemporaryDirectory() as directory:
path=Path(directory)/'enforce'; path.write_text('1')
with self.assertRaisesRegex(RuntimeError,'SELinux'):
generic.check_selinux_labels({'proxmox':{'custom_compose_selinux_labels':True}},path)
path.write_text('0')
generic.check_selinux_labels({'proxmox':{'custom_compose_selinux_labels':True}},path)
path.write_text('1')
generic.check_selinux_labels({'proxmox':{}},path)
def test_main_http_success_does_not_override_failed_declared_exec_health(self):
service={'vmid':4800,'name':'app','healthcheck':{'type':'exec','argv':['check-ready'],'timeout_seconds':30}}
opener=SimpleNamespace(open=lambda *args,**kwargs: __import__('contextlib').nullcontext(SimpleNamespace(status=200)))
with patch.object(generic,'run',return_value='status: running'), \
patch.object(generic.urllib.request,'build_opener',return_value=opener), \
patch.object(generic.subprocess,'run',side_effect=[SimpleNamespace(returncode=1),SimpleNamespace(returncode=0)]) as execute, \
patch.object(generic.time,'sleep'):
generic.wait_web(service,'http://192.0.2.1:8080/')
self.assertEqual(execute.call_count,2)
self.assertEqual(execute.call_args.args[0],['pct','exec','4800','--','check-ready'])
if __name__=='__main__':unittest.main()
+34 -2
View File
@@ -54,9 +54,9 @@ class CustomMultiAppStackTests(unittest.TestCase):
self.assertEqual(assess(template()), [])
self.assertTrue(assess(template(custom=False)))
def test_app_without_own_port_is_rejected(self):
def test_custom_background_app_without_port_is_only_checked_running(self):
result = template(text=COMPOSE.replace(" ports: ['7878:7878']\n", ''))
self.assertTrue(any('health and persistence' in e for e in assess(result)))
self.assertEqual(assess(result), [])
def test_service_image_contract_preserves_tag_and_digest(self):
parent = template()
@@ -95,6 +95,27 @@ class CustomMultiAppStackTests(unittest.TestCase):
self.assertEqual({s['name']:s['frontend_ipv4'] for s in plan['services']}, supplied)
self.assertEqual(plan['network']['frontend_gateway'], '192.0.2.1')
def test_advanced_custom_resources_are_selected_per_service(self):
class ResourceUI(UI):
def ask(self,text,default=None,**kwargs):
if text=='radarr: Memory in MB': return '768'
if text=='radarr: CPU cores': return '3'
if text=='Memory in MB': return '2048'
if text=='CPU cores': return '2'
return super().ask(text,default,**kwargs)
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.custom_mounts.ask_stack_custom_mounts'), \
patch('proxmenux_oci.extra_devices.ask_stack_extra_devices'), \
patch('proxmenux_oci.network.ask_addresses',return_value=({'sonarr':'dhcp','radarr':'dhcp'},None)):
plan=build_stack(template(),ResourceUI(),ADVANCED_MODE)
resources={service['name']:service['deployment']['resources'] for service in plan['services']}
self.assertEqual(resources['radarr']['memory_mb'],768)
self.assertEqual(resources['radarr']['cores'],3)
self.assertEqual(resources['sonarr']['memory_mb'],2048)
self.assertEqual(resources['sonarr']['cores'],2)
def test_build_assigns_distinct_endpoints_and_shared_bind_source(self):
ui = UI()
with patch('proxmenux_oci.installer.ask_storage', return_value='local-zfs'), \
@@ -116,6 +137,17 @@ class CustomMultiAppStackTests(unittest.TestCase):
configs = [next(m for m in s['deployment']['mounts'] if m['container_path']=='/config') for s in members.values()]
self.assertTrue(all(m['type']=='managed-volume' for m in configs))
def test_shared_directory_without_puid_uses_imported_image_owner(self):
text = COMPOSE.replace(" environment: {PUID: '1000', PGID: '1000'}\n", '')
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' for name in labels}, None)):
plan = build_stack(template(text=text), UI(), DEFAULT_MODE)
for service in plan['services']:
shared = next(m for m in service['deployment']['mounts'] if m['container_path'] == '/data')
self.assertNotIn('host_owner', shared)
if __name__ == '__main__':
unittest.main()
+206
View File
@@ -0,0 +1,206 @@
"""Custom internal helpers need explicit readiness and never gain a LAN by expose."""
from pathlib import Path
import sys
import unittest
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'src'))
from proxmenux_oci.custom import template_from_compose
from proxmenux_oci.stack import (assess, build_stack, compose_healthcheck,
DefaultsUI, kind, resolve_environments, StackError)
from proxmenux_oci.installer import DEFAULT_MODE
COMPOSE = '''name: worker-example
services:
web:
image: nginx:1.27
ports: ['8080:80']
depends_on: [helper]
helper:
image: example/helper:1
expose: ['3000']
'''
def build(template):
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' for name in labels}, None)):
return build_stack(template, DefaultsUI(), DEFAULT_MODE)
class WorkerStackTests(unittest.TestCase):
def test_exposed_helper_gets_internal_tcp_check_without_frontend(self):
template = template_from_compose(COMPOSE)
self.assertEqual(assess(template), [])
services = {service['name']: service for service in build(template)['services']}
self.assertEqual(services['helper']['healthcheck']['port'], 3000)
self.assertEqual(services['helper']['healthcheck']['type'], 'tcp')
self.assertNotIn('frontend', services['helper'])
self.assertTrue(services['web']['frontend'])
self.assertEqual(services['web']['healthcheck']['endpoint']['port'], 80)
def test_service_healthy_does_not_invent_an_http_or_tcp_health_contract(self):
document = COMPOSE.replace('depends_on: [helper]', 'depends_on: {helper: {condition: service_healthy}}')
template = template_from_compose(document)
self.assertFalse(template['compatibility']['automatic_install_candidate'])
self.assertTrue(any('health and persistence' in error for error in assess(template)))
checked = document + " healthcheck: {test: ['CMD', 'test', '-f', '/ready']}\n"
self.assertEqual(assess(template_from_compose(checked)), [])
def test_worker_exec_check_is_preserved_without_exposing_a_port(self):
document = COMPOSE.replace(" expose: ['3000']", " healthcheck:\n test: ['CMD', 'test', '-f', '/ready']\n interval: 10s\n timeout: 2s\n start_period: 20s\n retries: 3")
template = template_from_compose(document)
self.assertEqual(assess(template), [])
services = {service['name']: service for service in build(template)['services']}
check = services['helper']['healthcheck']
self.assertEqual(check['type'], 'exec')
self.assertEqual(check['argv'], ['test', '-f', '/ready'])
self.assertEqual(check['timeout_seconds'], 56)
self.assertNotIn('frontend', services['helper'])
def test_explicit_check_takes_precedence_over_inferred_internal_http(self):
document = COMPOSE + " healthcheck: {test: ['CMD-SHELL', 'test -f /ready && test -f /worker-ready']}\n"
template = template_from_compose(document)
self.assertEqual(assess(template), [])
helper = next(service for service in build(template)['services'] if service['name'] == 'helper')
self.assertEqual(helper['healthcheck']['type'], 'exec')
self.assertEqual(helper['healthcheck']['argv'], ['sh', '-c', 'test -f /ready && test -f /worker-ready'])
def test_main_exec_check_retains_the_published_web_endpoint(self):
document = COMPOSE.replace(" ports: ['8080:80']", " ports: ['8080:80']\n healthcheck: {test: ['CMD', 'test', '-f', '/ready']}")
template = template_from_compose(document)
self.assertEqual(assess(template), [])
web = next(service for service in build(template)['services'] if service['name'] == 'web')
self.assertEqual(web['healthcheck']['type'], 'exec')
self.assertEqual(web['healthcheck']['argv'], ['test', '-f', '/ready'])
self.assertEqual(web['healthcheck']['endpoint'], {'scheme': 'http', 'port': 80, 'path': '/'})
self.assertTrue(web['frontend'])
def test_main_dependency_is_still_rejected_before_allocation(self):
document = COMPOSE.replace(' depends_on: [helper]\n', '').replace(" expose: ['3000']", " expose: ['3000']\n depends_on: [web]")
template = template_from_compose(document)
self.assertFalse(template['compatibility']['automatic_install_candidate'])
self.assertTrue(any('main service' in error for error in assess(template)))
def test_disabled_check_without_endpoint_is_explicitly_running_only(self):
document = COMPOSE.replace(" expose: ['3000']", ' healthcheck: {disable: true}')
template = template_from_compose(document)
self.assertEqual(assess(template), [])
helper = next(service for service in build(template)['services'] if service['name'] == 'helper')
self.assertEqual(helper['healthcheck']['type'], 'running')
def test_invalid_healthcheck_or_runtime_environment_requirement_needs_review(self):
for check in ({'test': ['CMD-SHELL', 'test -n "$TOKEN"']},
{'test': ['CMD', '']}, {'test': ['CMD', 'a\nb']},
{'test': ['CMD', 'true'], 'interval': '0s'},
{'test': ['CMD', 'true'], 'retries': -1},
{'test': ['CMD', 'true'], 'start_period': '2h'},
{'test': ['CMD', 'true'], 'timeout': 'nonsense'}):
with self.subTest(check=check), self.assertRaises(StackError):
compose_healthcheck({'compose': {'healthcheck': check}})
def test_dockerhub_aliases_keep_the_existing_database_health_profiles(self):
for image, expected in [('docker.io/library/postgres:16', 'postgres'),
('docker.io/library/redis:7', 'redis'),
('docker.io/valkey/valkey:8', 'valkey/valkey'),
('index.docker.io/library/mariadb:11', 'mariadb')]:
with self.subTest(image=image):
self.assertEqual(kind(image), expected)
def test_custom_server_flags_preserve_native_health_check(self):
document = COMPOSE.replace('example/helper:1', 'docker.io/library/redis:7').replace(
" expose: ['3000']", " command: ['redis-server', '--appendonly', 'yes', '--maxmemory-policy', 'noeviction']")
template = template_from_compose(document)
self.assertEqual(assess(template), [])
helper = next(service for service in build(template)['services'] if service['name'] == 'helper')
self.assertEqual(helper['kind'], 'redis')
self.assertEqual(helper['healthcheck']['type'], 'exec')
self.assertIn('redis-cli', str(helper['healthcheck']['argv']))
def test_a_different_binary_or_unhandled_auth_does_not_use_default_database_readiness(self):
for command in ("['sh', '-c', 'redis-server']", "['redis-server', '--requirepass', 'secret']"):
document = COMPOSE.replace('example/helper:1', 'redis:7').replace(
" expose: ['3000']", ' command: ' + command)
template = template_from_compose(document)
self.assertTrue(any('custom dependency commands' in error for error in assess(template)))
def test_plain_worker_uses_running_only_when_dependency_requires_started(self):
template = template_from_compose(COMPOSE.replace(" expose: ['3000']", " command: ['worker', '--watch']"))
self.assertEqual(assess(template), [])
helper = next(service for service in build(template)['services'] if service['name'] == 'helper')
self.assertEqual(helper['healthcheck'], {'type': 'running', 'timeout_seconds': 30})
self.assertNotIn('frontend', helper)
self.assertEqual(helper['template']['proxmox']['installer_profile']['runtime']['command'], ['worker', '--watch'])
def test_running_only_worker_cannot_satisfy_service_healthy(self):
document = COMPOSE.replace('depends_on: [helper]', 'depends_on: {helper: {condition: service_healthy}}').replace(" expose: ['3000']", " command: ['worker', '--watch']")
template = template_from_compose(document)
self.assertFalse(template['compatibility']['automatic_install_candidate'])
self.assertTrue(any('helper: service_healthy' in error for error in assess(template)))
def test_exposed_tcp_port_alone_is_not_a_declared_service_healthy_check(self):
template = template_from_compose(COMPOSE.replace('depends_on: [helper]', 'depends_on: {helper: {condition: service_healthy}}'))
self.assertTrue(any('helper: service_healthy' in error for error in assess(template)))
def test_explicit_worker_check_can_satisfy_service_healthy(self):
document = COMPOSE.replace('depends_on: [helper]', 'depends_on: {helper: {condition: service_healthy}}').replace(" expose: ['3000']", " healthcheck: {test: ['CMD', 'test', '-f', '/ready']}")
template = template_from_compose(document)
self.assertEqual(assess(template), [])
helper = next(service for service in build(template)['services'] if service['name'] == 'helper')
self.assertEqual(helper['healthcheck']['type'], 'exec')
def test_option_only_database_command_requires_verified_entrypoint_and_keeps_argv(self):
command = ['--transaction-isolation=READ-COMMITTED', '--binlog-format=ROW']
document = COMPOSE.replace('example/helper:1', 'mariadb:11').replace(" expose: ['3000']", " command: ['--transaction-isolation=READ-COMMITTED', '--binlog-format=ROW']")
template = template_from_compose(document)
self.assertTrue(any('custom dependency commands' in error for error in assess(template)))
template = template_from_compose(document, image_entrypoints={'mariadb:11': ['docker-entrypoint.sh']})
self.assertTrue(template['compatibility']['automatic_install_candidate'])
self.assertEqual(assess(template), [])
helper = next(service for service in build(template)['services'] if service['name'] == 'helper')
self.assertEqual(helper['template']['proxmox']['installer_profile']['runtime']['command'], command)
self.assertEqual(helper['healthcheck']['argv'], ['healthcheck.sh', '--connect', '--innodb_initialized'])
def test_literal_environment_preserves_all_explicit_dollar_values(self):
services = [{'name': 'helper', 'image': 'example/helper:1',
'compose': {'environment': {'PASSWORD': 'cash$VALUE${OTHER}', 'TOKEN': '${GENERATED_LOCAL_TOKEN}'}}}]
result = resolve_environments(services, 'UTC', literal=True)['helper']
self.assertEqual(result['PASSWORD'], 'cash$VALUE${OTHER}')
self.assertEqual(result['TOKEN'], '${GENERATED_LOCAL_TOKEN}')
with self.assertRaises(StackError):
resolve_environments(services, 'UTC')
def test_literal_explicit_empty_environment_clears_inherited_image_value(self):
document = COMPOSE.replace(" expose: ['3000']", " expose: ['3000']\n environment: {CLEAR_IMAGE_DEFAULT: ''}")
template = template_from_compose(document, interpolated=True)
helper = next(service for service in build(template)['services'] if service['name'] == 'helper')
self.assertEqual({item['name']: item['value'] for item in helper['deployment']['environment']},
{'CLEAR_IMAGE_DEFAULT': ''})
template['proxmox']['custom_compose_interpolated'] = False
helper = next(service for service in build(template)['services'] if service['name'] == 'helper')
self.assertEqual(helper['deployment']['environment'], [])
def test_literal_database_url_is_not_rebound_or_forced_to_have_a_password(self):
services = [
{'name': 'db', 'image': 'postgres:16', 'compose': {'environment': {'POSTGRES_USER': 'bootstrap', 'POSTGRES_HOST_AUTH_METHOD': 'trust'}}},
{'name': 'app', 'image': 'example/app:1', 'compose': {'environment': {'DATABASE_URL': 'postgresql://explicit-user@db/app'}}},
]
result = resolve_environments(services, 'UTC', literal=True)
self.assertEqual(result['app']['DATABASE_URL'], 'postgresql://explicit-user@db/app')
services[0]['compose']['environment']['POSTGRES_PASSWORD'] = 'bootstrap-password'
services[1]['compose']['environment']['DATABASE_URL'] = 'postgresql://other-user:explicit-password@db/app'
result = resolve_environments(services, 'UTC', literal=True)
self.assertEqual(result['app']['DATABASE_URL'], 'postgresql://other-user:explicit-password@db/app')
def test_fully_interpolated_custom_stack_does_not_reparse_literal_environment(self):
template = template_from_compose(COMPOSE.replace(" expose: ['3000']", " expose: ['3000']\n environment: {PASSWORD: 'cash$VALUE'}"))
template['proxmox']['custom_compose_interpolated'] = True
self.assertEqual(assess(template), [])
helper = next(service for service in build(template)['services'] if service['name'] == 'helper')
self.assertEqual(helper['deployment']['environment'][0]['value'], 'cash$VALUE')
if __name__ == '__main__':
unittest.main()
+101
View File
@@ -0,0 +1,101 @@
"""Real cross-process contention at the late stack publication boundary."""
import contextlib
from pathlib import Path
import subprocess
import sys
import tempfile
import threading
import time
import unittest
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'remote'))
import oci_instances as instances
HOLDER = '''import fcntl,sys,time
with open(sys.argv[1], 'a') as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
print('locked', flush=True)
sys.stdin.readline()
'''
@contextlib.contextmanager
def competing_owner(root):
process = subprocess.Popen([sys.executable, '-u', '-c', HOLDER, str(root / '.lock')],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True)
try:
if process.stdout.readline().strip() != 'locked':
raise RuntimeError('Lock fixture did not acquire the lock')
yield process
finally:
if process.poll() is None:
process.terminate()
process.wait(timeout=5)
process.stdout.close()
process.stdin.close()
class InstanceLockWaitTests(unittest.TestCase):
def test_waiting_publication_succeeds_after_another_process_releases(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
with competing_owner(root) as process:
def release():
process.stdin.write('release\n')
process.stdin.flush()
timer = threading.Timer(0.35, release)
start = time.monotonic()
timer.start()
try:
with instances.locked(root, timeout=2):
elapsed = time.monotonic() - start
self.assertGreaterEqual(elapsed, 0.2)
self.assertLess(elapsed, 2)
finally:
timer.cancel()
timer.join()
def test_default_remains_immediate_when_another_operation_owns_the_registry(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
with competing_owner(root):
start = time.monotonic()
with self.assertRaises(BlockingIOError), instances.locked(root):
self.fail('Contended default lock was acquired')
self.assertLess(time.monotonic() - start, 0.5)
def test_wait_has_a_finite_timeout(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
with competing_owner(root):
start = time.monotonic()
with self.assertRaises(BlockingIOError), instances.locked(root, timeout=0.1):
self.fail('Contended timed lock was acquired')
elapsed = time.monotonic() - start
self.assertGreaterEqual(elapsed, 0.08)
self.assertLess(elapsed, 0.6)
def test_error_inside_operation_is_propagated_without_repeating_it(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
entered = []
with self.assertRaises(BlockingIOError):
with instances.locked(root, timeout=2):
entered.append(True)
raise BlockingIOError('unrelated operation failure')
self.assertEqual(entered, [True])
with instances.locked(root):
pass
def test_permission_failure_is_not_retried(self):
with tempfile.TemporaryDirectory() as directory:
with patch.object(instances.fcntl, 'flock', side_effect=PermissionError('denied')), \
patch.object(instances.time, 'sleep') as sleep:
with self.assertRaises(PermissionError), instances.locked(Path(directory), timeout=2):
pass
sleep.assert_not_called()
if __name__ == '__main__':
unittest.main()
+44
View File
@@ -0,0 +1,44 @@
"""Main readiness probes the same address the installation advertises."""
import copy
import ipaddress
from pathlib import Path
import sys
import unittest
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'remote'))
import install_generic_stack as installer
SERVICE = {'vmid':4860, 'main':True, 'name':'app', 'ip':'10.77.6.30',
'healthcheck':{'type':'http','timeout_seconds':360,
'endpoint':{'scheme':'http','port':80,'path':'/'}}}
class MainFrontendReadiness(unittest.TestCase):
def test_probe_and_persistent_health_url_use_advertised_lan_not_backend_ip(self):
service=copy.deepcopy(SERVICE)
with patch.object(installer,'run',return_value='10.77.6.30\n172.30.249.70\n'), \
patch.object(installer,'wait_web') as web:
url=installer.wait_main_web(service,ipaddress.ip_network('10.77.6.0/24'))
self.assertEqual(url,'http://172.30.249.70:80/')
self.assertEqual(service['healthcheck']['url'],url)
self.assertEqual(service['ip'],'10.77.6.30')
web.assert_called_once_with(service,url)
def test_waits_for_dhcp_frontend_without_probing_private_host(self):
service=copy.deepcopy(SERVICE)
with patch.object(installer,'lan_access_urls',side_effect=[RuntimeError('not yet'),[{'url':'http://192.0.2.7:80/'}]]), \
patch.object(installer.time,'sleep') as sleep, patch.object(installer,'wait_web') as web:
url=installer.wait_main_web(service,ipaddress.ip_network('10.77.6.0/24'))
self.assertEqual(url,'http://192.0.2.7:80/')
sleep.assert_called_once_with(1)
web.assert_called_once_with(service,url)
def test_no_frontend_address_never_falls_back_to_private_probe(self):
service=copy.deepcopy(SERVICE)
with patch.object(installer.time,'monotonic',side_effect=[0,1,121]), \
patch.object(installer.time,'sleep'), \
patch.object(installer,'lan_access_urls',side_effect=RuntimeError('not yet')), \
patch.object(installer,'wait_web') as web, self.assertRaisesRegex(RuntimeError,'No LAN address'):
installer.wait_main_web(service,ipaddress.ip_network('10.77.6.0/24'))
web.assert_not_called()
if __name__=='__main__':unittest.main()
@@ -149,6 +149,63 @@ class ResolveVolumeOwnerTests(unittest.TestCase):
igs.resolve_volume_owner(service, 'some-future-strategy')
class ImportedRuntimeOwnerTests(unittest.TestCase):
def test_numeric_imported_user_controls_fresh_app_volume_without_puid(self):
service = make_service()
service['vmid'] = 4820
with patch.object(igs, 'run', return_value='lxc.init.uid: 1000\nlxc.init.gid: 1000\n'):
owner = igs.runtime_volume_owner(service)
self.assertEqual(owner, (1000, 1000))
self.assertEqual(igs.resolve_volume_owner(service, 'mapped-application-user', owner), (101000, 101000))
def test_explicit_zero_puid_and_volume_owner_override_the_imported_user(self):
service = make_service(environment=[{'name': 'PUID', 'value': '0'}, {'name': 'PGID', 'value': '2500'}])
self.assertEqual(igs.resolve_volume_owner(service, 'mapped-application-user', (1000, 1000)), (100000, 102500))
service['template']['proxmox']['installer_profile']['volume_owner'] = {'uid': 1234, 'gid': 2345}
self.assertEqual(igs.resolve_volume_owner(service, 'mapped-application-user', (1000, 1000)), (101234, 102345))
with patch.object(igs, 'runtime_volume_owner', side_effect=AssertionError('mapped root must not inspect USER')):
self.assertEqual(igs.resolve_volume_owner(service, 'mapped-root'), (100000, 100000))
def test_imported_root_is_not_replaced_by_a_guessed_app_user(self):
service = make_service()
service['vmid'] = 4820
service['observed'] = {'image': {'defaults': {'User': 'node'}}}
with patch.object(igs, 'run', return_value='lxc.init.uid: 0\nlxc.init.gid: 0\n'):
self.assertEqual(igs.runtime_volume_owner(service), (0, 0))
def test_named_image_user_and_group_resolve_from_mounted_rootfs(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
(root / 'etc').mkdir()
(root / 'etc/passwd').write_text('root:x:0:0:root:/root:/bin/sh\nnode:x:1000:1000:Node:/home/node:/bin/sh\n')
(root / 'etc/group').write_text('root:x:0:\nnode:x:1000:\nshared:x:2500:\n')
service = make_service()
service['observed'] = {'image': {'defaults': {'User': 'node:shared'}}}
self.assertEqual(igs.runtime_volume_owner(service, root), (1000, 2500))
service['observed']['image']['defaults']['User'] = '1000'
self.assertEqual(igs.runtime_volume_owner(service, root), (1000, 1000))
def test_actual_compose_user_override_wins_over_image_user(self):
service = make_service(unprivileged=False)
service['vmid'] = 4820
service['observed'] = {'image': {'defaults': {'User': 'node'}}}
with patch.object(igs, 'run', return_value='lxc.init.uid: 2000\nlxc.init.gid: 3000\n'):
owner = igs.runtime_volume_owner(service)
self.assertEqual(igs.resolve_volume_owner(service, 'mapped-application-user', owner), (2000, 3000))
def test_named_user_cannot_be_resolved_through_a_host_file_symlink(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp) / 'rootfs'
(root / 'etc').mkdir(parents=True)
outside = Path(tmp) / 'host-passwd'
outside.write_text('node:x:1000:1000:Node:/home/node:/bin/sh\n')
(root / 'etc/passwd').symlink_to(outside)
service = make_service()
service['observed'] = {'image': {'defaults': {'User': 'node'}}}
with self.assertRaises(RuntimeError):
igs.runtime_volume_owner(service, root)
class ApplyVolumePreparationTests(unittest.TestCase):
def _target(self, tmp, with_lost_found=True):
target = Path(tmp) / 'target'
+64
View File
@@ -0,0 +1,64 @@
"""Internal TCP readiness survives initial install, native lifecycle and restore."""
import copy
from pathlib import Path
import socket
import subprocess
import sys
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT/'remote'))
import install_generic_stack as installer
import oci_stack_native as native
import oci_restore_recovery as recovery
class TCPReadinessTests(unittest.TestCase):
def test_initial_runtime_probe_uses_allocated_private_address(self):
service={'ip':'10.77.20.31','healthcheck':{'type':'tcp','port':9998,'timeout_seconds':120}}
installer.assign_health_address(service)
self.assertEqual(service['healthcheck']['host'],'10.77.20.31')
self.assertNotIn('url',service['healthcheck'])
self.assertNotIn('frontend',service)
def test_native_health_probes_real_socket_without_http(self):
with socket.socket() as server:
server.bind(('127.0.0.1',0));server.listen()
port=server.getsockname()[1]
member={'vmid':4800,'deployment':{},'stack':{'deployment':{'services':[
{'vmid':4800,'name':'helper','healthcheck':{'type':'tcp','host':'127.0.0.1','port':port,'timeout_seconds':5}}]}}}
adapter=native.NativeAdapter(Path('/registry'),Path('/journal'),{'members':[member],'primary_vmid':4800})
with patch.object(adapter,'is_running',return_value=True), \
patch.object(native.member_tx.gpu_devices,'validate_runtime'), \
patch.object(native.member_tx,'run') as run:
adapter.healthcheck(4800)
run.assert_not_called()
def test_native_tcp_target_rejects_names_and_invalid_ports(self):
for host,port in [('example.com',3000),('127.0.0.1',True),('127.0.0.1',0),('127.0.0.1',65536),('127.0.0.1','3000')]:
with self.subTest(host=host,port=port),self.assertRaises(ValueError):
native.tcp_target({'host':host,'port':port})
def test_restore_retains_only_private_tcp_probes_with_valid_ports(self):
contract={'schema':1,'dependencies':[{'vmid':4801,'label':'helper',
'healthcheck':{'type':'tcp','host':'10.77.20.31','port':9998,'timeout_seconds':120}}]}
self.assertTrue(recovery.valid_contract(contract,{4801}))
for changes in ({'host':'192.168.1.1'},{'host':'localhost'},{'port':False},{'port':65536}):
invalid=copy.deepcopy(contract);invalid['dependencies'][0]['healthcheck'].update(changes)
self.assertFalse(recovery.valid_contract(invalid,{4801}))
def test_hook_tcp_probe_checks_connection_not_http_status(self):
text=(ROOT/'remote/stack_dependency_hook.sh').read_text()
functions=text[text.index('dependency_is_healthy() {'):text.index('\nstart_dependencies()')]
with socket.socket() as server:
server.bind(('127.0.0.1',0));server.listen()
port=server.getsockname()[1]
command=functions+'\ndependency_is_healthy 4801 \'{"type":"tcp","host":"127.0.0.1","port":'+str(port)+'}\''
result=subprocess.run(['bash','-c',command],text=True,capture_output=True)
self.assertEqual(result.returncode,0,result.stderr)
failed=subprocess.run(['bash','-c',command],text=True,capture_output=True)
self.assertNotEqual(failed.returncode,0)
if __name__=='__main__':unittest.main()
+13
View File
@@ -351,6 +351,19 @@ class Switch(unittest.TestCase):
return {int(path.parent.name): json.loads(path.read_text())["deployment"].get("watchdog")
for path in root.glob("*/oci-compose.json")}
def test_completed_jobs_never_receive_or_use_watchdog(self):
with tempfile.TemporaryDirectory() as folder:
root=self.registry(folder)
path=root/'102'/'oci-compose.json'
record=json.loads(path.read_text())
record['deployment'].update(execution_mode='one-shot',watchdog=True)
path.write_text(json.dumps(record))
self.assertNotIn(102,watchdog.watched(root))
with patch.object(watchdog,'ensure_service'),patch('oci_carried_record.carry'):
watchdog.set_watchdog(root,101,True)
self.assertEqual(self.flags(root)[101],True)
self.assertEqual(self.flags(root)[102],False)
def test_every_container_of_the_application_takes_the_choice_and_keeps_the_rest_of_its_record(self):
from unittest.mock import patch
import oci_carried_record