mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-01 10:22:08 +00:00
Merge pull request #497 from jcastro/perf/oci-native-modify
Apply supported OCI settings in place with recreation fallback
This commit is contained in:
@@ -28,6 +28,15 @@ jobs:
|
||||
python-version: '3.13'
|
||||
- name: Install OCI test dependencies
|
||||
run: python3 -m pip install -r oci/requirements.txt
|
||||
- name: Run OCI image reuse and resource edit regressions
|
||||
env:
|
||||
PYTHONDONTWRITEBYTECODE: '1'
|
||||
run: |
|
||||
python3 -m unittest discover -s oci/tests -p test_modify_image_reuse.py -v
|
||||
python3 -m unittest discover -s oci/tests -p test_native_modify.py -v
|
||||
python3 -m unittest discover -s oci/tests -p test_modify_review.py -v
|
||||
python3 -m unittest discover -s oci/tests -p test_modify_review_transaction.py -v
|
||||
python3 -m unittest discover -s oci/tests -p test_stage_release.py -v
|
||||
- name: Run carried record, recovery and configuration cache regressions
|
||||
env:
|
||||
PYTHONDONTWRITEBYTECODE: '1'
|
||||
|
||||
@@ -446,10 +446,12 @@
|
||||
"Apply the reviewed package upgrade now? Services may restart and packages listed for removal will be removed.": "Die geprüfte Paketaktualisierung jetzt anwenden? Dienste können neu starten und zur Entfernung aufgeführte Pakete werden entfernt.",
|
||||
"Apply these changes?": "Diese Änderungen anwenden?",
|
||||
"Applying AMD-specific fixes...": "Anwenden von AMD-spezifischen Korrekturen...",
|
||||
"Applying CPU and RAM changes without rebuilding the container...": "CPU- und RAM-Änderungen werden ohne Neuerstellung des Containers angewendet...",
|
||||
"Applying Changes": "Anwenden von Änderungen",
|
||||
"Applying Controller/NVMe passthrough to VM": "Anwenden von Controller/NVMe-Passthrough auf die VM",
|
||||
"Applying balanced memory optimization settings...": "Ausgewogene Einstellungen zur Speicheroptimierung werden angewendet...",
|
||||
"Applying changes safely...": "Änderungen sicher anwenden...",
|
||||
"Applying container settings without rebuilding the container...": "Containereinstellungen werden ohne Neuerstellung des Containers angewendet...",
|
||||
"Applying custom restore": "Benutzerdefinierte Wiederherstellung anwenden",
|
||||
"Applying default VM configuration": "Anwenden der Standard-VM-Konfiguration",
|
||||
"Applying host permissions for unprivileged LXC bind-mounts...": "Anwenden von Hostberechtigungen für nicht privilegierte LXC-Bind-Mounts ...",
|
||||
@@ -3225,6 +3227,8 @@
|
||||
"Invalid ALLOWED_HOSTS value": "Wert für ungültig ALLOWED HOSTs",
|
||||
"Invalid Home Assistant OS check timeout": "Invalid Home Assistant OS Check Timeout",
|
||||
"Invalid ID": "Ungültige ID",
|
||||
"Invalid IPv4 address": "Ungültige IPv4-Adresse",
|
||||
"Invalid IPv4 gateway": "Ungültiges IPv4-Gateway",
|
||||
"Invalid Intel render path": "Ungültiger Intel-Renderpfad",
|
||||
"Invalid Jellyfin path:": "Ungültige Jellyfin-Strecke:",
|
||||
"Invalid MAC address:": "Ungültige MAC-Adresse:",
|
||||
@@ -3257,6 +3261,7 @@
|
||||
"Invalid administrator email": "E-Mail des ungültigen Administrators",
|
||||
"Invalid administrator user name": "Ungültiger Administrator Benutzername",
|
||||
"Invalid base VMID": "Ungültige Basis VMID",
|
||||
"Invalid bridge": "Ungültige Bridge",
|
||||
"Invalid check package:": "Ungültiges Kontrollpaket:",
|
||||
"Invalid configuration path:": "Ungültiger Konfigurationspfad:",
|
||||
"Invalid consume/export volumes": "Ungültige Verbrauchs-/Ausfuhrmengen",
|
||||
@@ -3320,6 +3325,7 @@
|
||||
"Invalid remote name": "Name der ungültigen Fernbedienung",
|
||||
"Invalid remote path": "Ungültiger Fernpfad",
|
||||
"Invalid repair version:": "Ungültige Reparaturversion:",
|
||||
"Invalid resource value": "Ungültiger Ressourcenwert",
|
||||
"Invalid resources": "Ungültige Mittel",
|
||||
"Invalid restored volume path": "Ungültiger wiederhergestellter Volumenpfad",
|
||||
"Invalid running state": "Invaliditätsstatus",
|
||||
@@ -3338,6 +3344,7 @@
|
||||
"Invalid stack members": "Ungültige Stapelmitglieder",
|
||||
"Invalid stack name": "Name des ungültigen Stacks",
|
||||
"Invalid stack operation": "Ungültiger Stapel operation",
|
||||
"Invalid storage": "Ungültiger Speicher",
|
||||
"Invalid storage ID. Use only letters, numbers, hyphens and underscores.": "Ungültige Speicher-ID. Verwenden Sie nur Buchstaben, Zahlen, Bindestriche und Unterstriche.",
|
||||
"Invalid storage name": "Ungültiger Speichername",
|
||||
"Invalid suite application": "Anwendung ungültiger Suiten",
|
||||
@@ -4906,6 +4913,7 @@
|
||||
"Proxmox VE configuration completed.": "Proxmox VE-Konfiguration abgeschlossen.",
|
||||
"Proxmox VE safe update completed": "Sicheres Proxmox VE-Update abgeschlossen",
|
||||
"Proxmox auth logger service created and started": "Proxmox-Auth-Logger-Dienst erstellt und gestartet",
|
||||
"Proxmox could not apply the container settings": "Proxmox konnte die Containereinstellungen nicht anwenden",
|
||||
"Proxmox enterprise repo might be missing or inaccessible. Trying to switch to no-subscription...": "Das Proxmox-Enterprise-Repository fehlt möglicherweise oder ist nicht zugänglich. Ich versuche, auf kein Abonnement umzustellen...",
|
||||
"Proxmox filter configured": "Proxmox-Filter konfiguriert",
|
||||
"Proxmox iSCSI Storage Status:": "Proxmox iSCSI-Speicherstatus:",
|
||||
@@ -6348,6 +6356,7 @@
|
||||
"The build could not be checked beforehand; continuing without that check.": "Der Build konnte vorher nicht überprüft werden;ohne diese Prüfung weitermachen.",
|
||||
"The cached image does not match the current digest": "Das zwischengespeicherte Bild entspricht nicht dem aktuellen Digest",
|
||||
"The cached image is damaged; it will be downloaded again.": "Das zwischengespeicherte Bild ist beschädigt; es wird erneut heruntergeladen.",
|
||||
"The changes need the application installer": "Die Änderungen erfordern das Installationsprogramm der Anwendung",
|
||||
"The character device does not exist:": "Das Zeichengerät existiert nicht:",
|
||||
"The command asks for a password that is not echoed. After creating the users, the web interface asks for a user name and a password.": "Der Befehl fragt nach einem Passwort, das nicht wiederholt wird. Nach dem Erstellen der Benutzer fragt die Weboberfläche nach einem Benutzernamen und einem Passwort.",
|
||||
"The command does not name an image": "Der Befehl benennt kein Bild",
|
||||
@@ -6467,6 +6476,7 @@
|
||||
"The host bind source does not exist:": "Die Host-Bind-Quelle existiert nicht:",
|
||||
"The host bind source is not a regular file or directory:": "Die Host-Bind-Quelle ist keine reguläre Datei oder ein Verzeichnis:",
|
||||
"The host directory changed before it was mounted": "Das Host-Verzeichnis wurde geändert, bevor es gemountet wurde",
|
||||
"The host directory does not exist": "Das Verzeichnis auf dem Host ist nicht vorhanden",
|
||||
"The host directory may not be accessible from an unprivileged container.": "Auf das Hostverzeichnis kann von einem unprivilegierten Container aus möglicherweise nicht zugegriffen werden.",
|
||||
"The host has no IPv4 address on the selected bridge": "Der Host hat keine IPv4-Adresse auf der ausgewählten Brücke",
|
||||
"The host monitor does not see the real host memory": "Der Host-Monitor sieht den echten Host-Speicher nicht",
|
||||
@@ -6658,6 +6668,7 @@
|
||||
"The repair must preserve the image dependencies:": "Die Reparatur muss die Bildabhängigkeiten bewahren:",
|
||||
"The requested VMID block is already in use": "Der angeforderte VMID-Block wird bereits verwendet",
|
||||
"The requested machine learning GPU profile is not working; it is not replaced by CPU": "Das angeforderte Machine Learning-GPU-Profil funktioniert nicht; es wird nicht durch CPU ersetzt",
|
||||
"The resource edit could not be fully rolled back. Open Manage again to review and import the current Proxmox settings.": "Die Ressourcenänderung konnte nicht vollständig rückgängig gemacht werden. Öffne die Verwaltung erneut, um die aktuellen Proxmox-Einstellungen zu prüfen und zu übernehmen.",
|
||||
"The restored service did not pass its health check": "Der wiederhergestellte Service bestand seinen Gesundheitscheck nicht",
|
||||
"The restored service stopped; the recovery is not confirmed": "Der wiederhergestellte Dienst wurde gestoppt; die Wiederherstellung wird nicht bestätigt",
|
||||
"The reviewed Tandoor stack does not require a privileged LXC.": "Der überprüfte Tandoor-Stack requi ist kein privilegierter LXC.",
|
||||
@@ -6703,6 +6714,7 @@
|
||||
"The selected VM": "Die ausgewählte VM",
|
||||
"The selected VM is running.": "Die ausgewählte VM läuft.",
|
||||
"The selected base folder does not exist and could not be created:": "Der ausgewählte Basisordner existiert nicht und konnte nicht erstellt werden:",
|
||||
"The selected bridge does not exist": "Die ausgewählte Bridge ist nicht vorhanden",
|
||||
"The selected bridge has no IPv4 subnet for the host-monitor firewall": "Die ausgewählte Bridge verfügt über kein IPv4-Subnetz für die Host-Monitor-Firewall",
|
||||
"The selected configuration needs to start the LXC during the installation": "Die ausgewählte Konfiguration muss den LXC während der Installation starten",
|
||||
"The selected container is unprivileged. A privileged container is required for direct device passthrough.": "Der ausgewählte Container ist nicht privilegiert. Für den direkten Geräte-Passthrough ist ein privilegierter Container erforderlich.",
|
||||
@@ -6725,6 +6737,7 @@
|
||||
"The service builds its own image; only a published image can be installed": "Der Dienst erstellt ein eigenes Image; nur ein veröffentlichtes Image kann installiert werden",
|
||||
"The service declares no image:": "Der Dienst erklärt kein Bild:",
|
||||
"The setting has no final value:": "Die Einstellung hat keinen Endwert:",
|
||||
"The settings could not be fully rolled back. Review the container and its saved modification before continuing.": "Die Einstellungen konnten nicht vollständig zurückgesetzt werden. Prüfe den Container und die gespeicherte Änderung, bevor du fortfährst.",
|
||||
"The share already exists in smb.conf:": "Die Freigabe existiert bereits in smb.conf:",
|
||||
"The shared destination is not a directory": "Das gemeinsame Ziel ist kein Verzeichnis",
|
||||
"The shared directory points to a protected host path": "Das Shared Directory zeigt auf einen geschützten Hostpfad",
|
||||
@@ -6799,6 +6812,7 @@
|
||||
"These interface configurations will be removed": "Diese Schnittstellenkonfigurationen werden entfernt",
|
||||
"These manual changes cannot be adopted safely:": "Diese manuellen Änderungen können nicht sicher übernommen werden:",
|
||||
"These paths will not be restored live and will be extracted for manual recovery.": "Diese Pfade werden nicht live wiederhergestellt und zur manuellen Wiederherstellung extrahiert.",
|
||||
"These settings require an application-specific adaptation. The image was not downloaded and the container was not recreated.": "Diese Einstellungen erfordern eine anwendungsspezifische Anpassung. Das Image wurde nicht heruntergeladen und der Container nicht neu erstellt.",
|
||||
"These values are asked during the installation:": "Diese Werte werden während der Installation abgefragt:",
|
||||
"They are described in the image documentation:": "Sie sind in der Dokumentation des Images beschrieben:",
|
||||
"They will be added to oci-compose before continuing. Unsupported or changed resources are not imported.": "Sie werden zu oci-compose hinzugefügt, bevor Sie fortfahren. Nicht unterstützte oder geänderte Ressourcen werden nicht importiert.",
|
||||
@@ -7137,6 +7151,7 @@
|
||||
"Unsupported mount type": "Typ der nicht unterstützten Halterung",
|
||||
"Unsupported mount type:": "Typ der nicht unterstützten Halterung:",
|
||||
"Unsupported native device type:": "Nicht unterstützter nativer Gerätetyp:",
|
||||
"Unsupported network settings": "Nicht unterstützte Netzwerkeinstellungen",
|
||||
"Unsupported operation": "Nicht unterstützte operation",
|
||||
"Unsupported or unknown Proxmox version; no repository changed.": "Nicht unterstützte oder unbekannte Proxmox-Version; es wurde kein Repository geändert.",
|
||||
"Unsupported output format:": "Nicht unterstütztes Ausgabeformat:",
|
||||
|
||||
@@ -443,10 +443,12 @@
|
||||
"Apply the reviewed package upgrade now? Services may restart and packages listed for removal will be removed.": "¿Aplicar ahora la actualización de paquetes revisada? Los servicios podrían reiniciarse y se eliminarán los paquetes indicados para su eliminación.",
|
||||
"Apply these changes?": "¿Aplicar estos cambios?",
|
||||
"Applying AMD-specific fixes...": "Aplicando correcciones específicas de AMD...",
|
||||
"Applying CPU and RAM changes without rebuilding the container...": "Aplicando los cambios de CPU y RAM sin recrear el contenedor...",
|
||||
"Applying Changes": "Aplicando cambios",
|
||||
"Applying Controller/NVMe passthrough to VM": "Aplicando el passthrough de controlador/NVMe a la VM",
|
||||
"Applying balanced memory optimization settings...": "Aplicando configuraciones equilibradas de optimización de memoria...",
|
||||
"Applying changes safely...": "Aplicando cambios de forma segura...",
|
||||
"Applying container settings without rebuilding the container...": "Aplicando los ajustes sin recrear el contenedor...",
|
||||
"Applying custom restore": "Aplicar restauración personalizada",
|
||||
"Applying default VM configuration": "Aplicar la configuración predeterminada de VM",
|
||||
"Applying host permissions for unprivileged LXC bind-mounts...": "Aplicando permisos de host para montajes de enlace LXC sin privilegios...",
|
||||
@@ -3222,6 +3224,8 @@
|
||||
"Invalid ALLOWED_HOSTS value": "Valor inválido ALLOWED_HOSTS",
|
||||
"Invalid Home Assistant OS check timeout": "Inválido Home Assistant OS check timeout",
|
||||
"Invalid ID": "ID no válida",
|
||||
"Invalid IPv4 address": "Dirección IPv4 no válida",
|
||||
"Invalid IPv4 gateway": "Puerta de enlace IPv4 no válida",
|
||||
"Invalid Intel render path": "Ruta de renderización Intel no válida",
|
||||
"Invalid Jellyfin path:": "Camino inválido Jellyfin:",
|
||||
"Invalid MAC address:": "Dirección MAC inválida:",
|
||||
@@ -3254,6 +3258,7 @@
|
||||
"Invalid administrator email": "Correo electrónico del administrador inválido",
|
||||
"Invalid administrator user name": "Nombre del usuario del administrador inválido",
|
||||
"Invalid base VMID": "Base inválida VMID",
|
||||
"Invalid bridge": "Puente no válido",
|
||||
"Invalid check package:": "Paquete de comprobación inválido:",
|
||||
"Invalid configuration path:": "Vía de configuración inválida:",
|
||||
"Invalid consume/export volumes": "Volumen de consumo/exportación inválidos",
|
||||
@@ -3317,6 +3322,7 @@
|
||||
"Invalid remote name": "Nombre remoto inválido",
|
||||
"Invalid remote path": "Camino remoto inválido",
|
||||
"Invalid repair version:": "Versión de reparación inválida:",
|
||||
"Invalid resource value": "Valor de recursos no válido",
|
||||
"Invalid resources": "Recursos inválidos",
|
||||
"Invalid restored volume path": "Camino de volumen restaurado inválido",
|
||||
"Invalid running state": "Estado de funcionamiento inválido",
|
||||
@@ -3335,6 +3341,7 @@
|
||||
"Invalid stack members": "Miembros de pila no válidos",
|
||||
"Invalid stack name": "Nombre de pila no válido",
|
||||
"Invalid stack operation": "Operación de pila no válida",
|
||||
"Invalid storage": "Almacenamiento no válido",
|
||||
"Invalid storage ID. Use only letters, numbers, hyphens and underscores.": "ID de almacenamiento no válido. Utilice únicamente letras, números, guiones y guiones bajos.",
|
||||
"Invalid storage name": "Nombre de almacenamiento no válido",
|
||||
"Invalid suite application": "Aplicación de suite inválida",
|
||||
@@ -4897,6 +4904,7 @@
|
||||
"Proxmox VE configuration completed.": "Configuración de Proxmox VE completada.",
|
||||
"Proxmox VE safe update completed": "Actualización segura de Proxmox VE completada",
|
||||
"Proxmox auth logger service created and started": "Servicio de registro de autenticación Proxmox creado e iniciado",
|
||||
"Proxmox could not apply the container settings": "Proxmox no ha podido aplicar los ajustes del contenedor",
|
||||
"Proxmox enterprise repo might be missing or inaccessible. Trying to switch to no-subscription...": "Es posible que falte el repositorio empresarial de Proxmox o que no se pueda acceder a él. Intentando cambiar a sin suscripción...",
|
||||
"Proxmox filter configured": "Filtro Proxmox configurado",
|
||||
"Proxmox iSCSI Storage Status:": "Estado de almacenamiento Proxmox iSCSI:",
|
||||
@@ -6331,6 +6339,7 @@
|
||||
"The build could not be checked beforehand; continuing without that check.": "La compilación no se pudo comprobar de antemano; se continúa sin esa comprobación.",
|
||||
"The cached image does not match the current digest": "La imagen en caché no coincide con el digest actual",
|
||||
"The cached image is damaged; it will be downloaded again.": "La imagen en caché está dañada; se descargará de nuevo.",
|
||||
"The changes need the application installer": "Los cambios requieren el instalador de la aplicación",
|
||||
"The character device does not exist:": "El dispositivo de carácter no existe:",
|
||||
"The command asks for a password that is not echoed. After creating the users, the web interface asks for a user name and a password.": "El comando pide una contraseña que no se muestra al escribirla. Tras crear los usuarios, la interfaz web pide un nombre de usuario y una contraseña.",
|
||||
"The command does not name an image": "El comando no nombra ninguna imagen",
|
||||
@@ -6449,6 +6458,7 @@
|
||||
"The host bind source does not exist:": "La fuente de host bind no existe:",
|
||||
"The host bind source is not a regular file or directory:": "La fuente host bind no es un archivo o directorio regular:",
|
||||
"The host directory changed before it was mounted": "El directorio host cambió antes de montarlo",
|
||||
"The host directory does not exist": "El directorio del host no existe",
|
||||
"The host directory may not be accessible from an unprivileged container.": "Es posible que no se pueda acceder al directorio del host desde un contenedor sin privilegios.",
|
||||
"The host has no IPv4 address on the selected bridge": "El host no tiene dirección IPv4 en el puente seleccionado",
|
||||
"The host monitor does not see the real host memory": "El monitor host no ve la memoria real del host",
|
||||
@@ -6634,6 +6644,7 @@
|
||||
"The repair must preserve the image dependencies:": "La reparación debe preservar las dependencias de la imagen:",
|
||||
"The requested VMID block is already in use": "El bloque VMID solicitado ya está en uso",
|
||||
"The requested machine learning GPU profile is not working; it is not replaced by CPU": "El perfil de GPU solicitado para Machine learning no funciona; no se sustituye por CPU",
|
||||
"The resource edit could not be fully rolled back. Open Manage again to review and import the current Proxmox settings.": "El cambio de recursos no se ha podido revertir por completo. Vuelve al menú de gestión para revisar e incorporar la configuración actual de Proxmox.",
|
||||
"The restored service did not pass its health check": "El servicio restaurado no superó su comprobación de estado",
|
||||
"The restored service stopped; the recovery is not confirmed": "El servicio restaurado se detuvo; la recuperación no se confirma",
|
||||
"The reviewed Tandoor stack does not require a privileged LXC.": "La pila de Tandoor revisada no necesita un LXC privilegiado.",
|
||||
@@ -6679,6 +6690,7 @@
|
||||
"The selected VM": "La máquina virtual seleccionada",
|
||||
"The selected VM is running.": "La VM seleccionada se está ejecutando.",
|
||||
"The selected base folder does not exist and could not be created:": "La carpeta base seleccionada no existe y no se pudo crear:",
|
||||
"The selected bridge does not exist": "El puente seleccionado no existe",
|
||||
"The selected bridge has no IPv4 subnet for the host-monitor firewall": "El bridge seleccionado no tiene subred IPv4 para el cortafuegos del monitor del host",
|
||||
"The selected configuration needs to start the LXC during the installation": "La configuración seleccionada debe iniciar el LXC durante la instalación",
|
||||
"The selected container is unprivileged. A privileged container is required for direct device passthrough.": "El contenedor seleccionado no tiene privilegios. Se requiere un contenedor privilegiado para el passthrough directo de dispositivos.",
|
||||
@@ -6701,6 +6713,7 @@
|
||||
"The service builds its own image; only a published image can be installed": "El servicio construye su propia imagen; solo se puede instalar una imagen publicada",
|
||||
"The service declares no image:": "El servicio no declara imagen:",
|
||||
"The setting has no final value:": "El ajuste no tiene valor final:",
|
||||
"The settings could not be fully rolled back. Review the container and its saved modification before continuing.": "No se han podido revertir todos los ajustes. Revisa el contenedor y la modificación guardada antes de continuar.",
|
||||
"The share already exists in smb.conf:": "El recurso compartido ya existe en smb.conf:",
|
||||
"The shared destination is not a directory": "El destino compartido no es un directorio",
|
||||
"The shared directory points to a protected host path": "El directorio compartido apunta a una ruta de host protegida",
|
||||
@@ -6774,6 +6787,7 @@
|
||||
"These interface configurations will be removed": "Estas configuraciones de interfaz serán eliminadas",
|
||||
"These manual changes cannot be adopted safely:": "Estos cambios manuales no se pueden incorporar con seguridad:",
|
||||
"These paths will not be restored live and will be extracted for manual recovery.": "Estas rutas no se restaurarán en vivo y se extraerán para su recuperación manual.",
|
||||
"These settings require an application-specific adaptation. The image was not downloaded and the container was not recreated.": "Estos ajustes requieren una adaptación específica de la aplicación. No se ha descargado la imagen ni se ha recreado el contenedor.",
|
||||
"These values are asked during the installation:": "Estos valores se preguntan durante la instalación:",
|
||||
"They are described in the image documentation:": "Están descritos en la documentación de la imagen:",
|
||||
"They will be added to oci-compose before continuing. Unsupported or changed resources are not imported.": "Se añadirán al oci-compose antes de continuar. No se importarán recursos incompatibles o modificados.",
|
||||
@@ -7112,6 +7126,7 @@
|
||||
"Unsupported mount type": "Tipo de montaje no compatible",
|
||||
"Unsupported mount type:": "Tipo de montaje no compatible:",
|
||||
"Unsupported native device type:": "Tipo de dispositivo nativo sin soporte:",
|
||||
"Unsupported network settings": "Ajustes de red no compatibles",
|
||||
"Unsupported operation": "Operación sin soporte",
|
||||
"Unsupported or unknown Proxmox version; no repository changed.": "Versión de Proxmox no compatible o desconocida; no se ha modificado ningún repositorio.",
|
||||
"Unsupported output format:": "Formato de salida no admitido:",
|
||||
|
||||
@@ -446,10 +446,12 @@
|
||||
"Apply the reviewed package upgrade now? Services may restart and packages listed for removal will be removed.": "Appliquer maintenant la mise à niveau des paquets examinée ? Des services peuvent redémarrer et les paquets indiqués pour suppression seront supprimés.",
|
||||
"Apply these changes?": "Appliquer ces modifications ?",
|
||||
"Applying AMD-specific fixes...": "Application de correctifs spécifiques à AMD...",
|
||||
"Applying CPU and RAM changes without rebuilding the container...": "Application des modifications de CPU et de RAM sans recréer le conteneur...",
|
||||
"Applying Changes": "Application des modifications",
|
||||
"Applying Controller/NVMe passthrough to VM": "Application du relais Controller/NVMe à la VM",
|
||||
"Applying balanced memory optimization settings...": "Application des paramètres d'optimisation de la mémoire équilibrée...",
|
||||
"Applying changes safely...": "Appliquer les modifications en toute sécurité...",
|
||||
"Applying container settings without rebuilding the container...": "Application des paramètres sans recréer le conteneur...",
|
||||
"Applying custom restore": "Application d'une restauration personnalisée",
|
||||
"Applying default VM configuration": "Application de la configuration de VM par défaut",
|
||||
"Applying host permissions for unprivileged LXC bind-mounts...": "Application des autorisations d'hôte pour les montages de liaison LXC non privilégiés...",
|
||||
@@ -3225,6 +3227,8 @@
|
||||
"Invalid ALLOWED_HOSTS value": "Valeur HÔTES INDÉSIRABLES",
|
||||
"Invalid Home Assistant OS check timeout": "Non valide Home Assistant OS check timeout",
|
||||
"Invalid ID": "Pièce d'identité invalide",
|
||||
"Invalid IPv4 address": "Adresse IPv4 non valide",
|
||||
"Invalid IPv4 gateway": "Passerelle IPv4 non valide",
|
||||
"Invalid Intel render path": "Chemin de rendu Intel incorrect",
|
||||
"Invalid Jellyfin path:": "Chemin Jellyfin non valide:",
|
||||
"Invalid MAC address:": "Adresse MAC non valide:",
|
||||
@@ -3257,6 +3261,7 @@
|
||||
"Invalid administrator email": "Courriel administrateur non valide",
|
||||
"Invalid administrator user name": "Nom d'utilisateur non valide",
|
||||
"Invalid base VMID": "VMID de base non valide",
|
||||
"Invalid bridge": "Pont réseau non valide",
|
||||
"Invalid check package:": "Dossier de contrôle non valide:",
|
||||
"Invalid configuration path:": "Chemin de configuration non valide & #160;:",
|
||||
"Invalid consume/export volumes": "Volumes de consommation/d'exportation non valides",
|
||||
@@ -3320,6 +3325,7 @@
|
||||
"Invalid remote name": "Nom distant non valide",
|
||||
"Invalid remote path": "Voie distante non valide",
|
||||
"Invalid repair version:": "Version de réparation non valide:",
|
||||
"Invalid resource value": "Valeur de ressource non valide",
|
||||
"Invalid resources": "Ressources non valides",
|
||||
"Invalid restored volume path": "Voie de volume restaurée non valide",
|
||||
"Invalid running state": "État de fonctionnement non valide",
|
||||
@@ -3338,6 +3344,7 @@
|
||||
"Invalid stack members": "Membres de pile non valides",
|
||||
"Invalid stack name": "Nom de la pile non valide",
|
||||
"Invalid stack operation": "Pile non valide operation",
|
||||
"Invalid storage": "Stockage non valide",
|
||||
"Invalid storage ID. Use only letters, numbers, hyphens and underscores.": "ID de stockage invalide. Utilisez uniquement des lettres, des chiffres, des traits d'union et des traits de soulignement.",
|
||||
"Invalid storage name": "Nom de stockage invalide",
|
||||
"Invalid suite application": "Demande de suite non valide",
|
||||
@@ -4906,6 +4913,7 @@
|
||||
"Proxmox VE configuration completed.": "Configuration de Proxmox VE terminée.",
|
||||
"Proxmox VE safe update completed": "Mise à jour sécurisée de Proxmox VE terminée",
|
||||
"Proxmox auth logger service created and started": "Service d'enregistrement d'authentification Proxmox créé et démarré",
|
||||
"Proxmox could not apply the container settings": "Proxmox n’a pas pu appliquer les paramètres du conteneur",
|
||||
"Proxmox enterprise repo might be missing or inaccessible. Trying to switch to no-subscription...": "Le référentiel d'entreprise Proxmox peut être manquant ou inaccessible. J'essaie de passer au sans abonnement...",
|
||||
"Proxmox filter configured": "Filtre Proxmox configuré",
|
||||
"Proxmox iSCSI Storage Status:": "État du stockage Proxmox iSCSI :",
|
||||
@@ -6347,6 +6355,7 @@
|
||||
"The build could not be checked beforehand; continuing without that check.": "La build n'a pas pu être vérifiée au préalable ;continuer sans cette vérification.",
|
||||
"The cached image does not match the current digest": "L'image mise en cache ne correspond pas au digest actuel",
|
||||
"The cached image is damaged; it will be downloaded again.": "L'image mise en cache est endommagée ; elle sera à nouveau téléchargée.",
|
||||
"The changes need the application installer": "Ces modifications nécessitent le programme d’installation de l’application",
|
||||
"The character device does not exist:": "Le dispositif de caractère n'existe pas :",
|
||||
"The command asks for a password that is not echoed. After creating the users, the web interface asks for a user name and a password.": "La commande demande un mot de passe qui n'est pas repris. Après la création des utilisateurs, l'interface web demande un nom d'utilisateur et un mot de passe.",
|
||||
"The command does not name an image": "La commande ne nomme pas une image",
|
||||
@@ -6466,6 +6475,7 @@
|
||||
"The host bind source does not exist:": "La source de liaison hôte n'existe pas:",
|
||||
"The host bind source is not a regular file or directory:": "La source de liaison hôte n'est pas un fichier ou un répertoire régulier :",
|
||||
"The host directory changed before it was mounted": "Le répertoire hôte a changé avant son montage",
|
||||
"The host directory does not exist": "Le répertoire de l’hôte n’existe pas",
|
||||
"The host directory may not be accessible from an unprivileged container.": "Le répertoire hôte peut ne pas être accessible à partir d'un conteneur non privilégié.",
|
||||
"The host has no IPv4 address on the selected bridge": "L'hôte n'a pas d'adresse IPv4 sur le pont sélectionné",
|
||||
"The host monitor does not see the real host memory": "Le moniteur hôte ne voit pas la mémoire de l'hôte réel",
|
||||
@@ -6657,6 +6667,7 @@
|
||||
"The repair must preserve the image dependencies:": "La réparation doit préserver les dépendances de l'image:",
|
||||
"The requested VMID block is already in use": "Le bloc VMID demandé est déjà utilisé",
|
||||
"The requested machine learning GPU profile is not working; it is not replaced by CPU": "Le profil GPU d'apprentissage automatique demandé ne fonctionne pas; il n'est pas remplacé par CPU",
|
||||
"The resource edit could not be fully rolled back. Open Manage again to review and import the current Proxmox settings.": "Impossible d’annuler entièrement la modification des ressources. Rouvrez le menu de gestion pour vérifier et importer les réglages actuels de Proxmox.",
|
||||
"The restored service did not pass its health check": "Le service restauré n'a pas réussi son bilan de santé",
|
||||
"The restored service stopped; the recovery is not confirmed": "Le service restauré s'est arrêté; la récupération n'est pas confirmée",
|
||||
"The reviewed Tandoor stack does not require a privileged LXC.": "La pile Tandoor revue ne require un LXC privilégié.",
|
||||
@@ -6702,6 +6713,7 @@
|
||||
"The selected VM": "La VM sélectionnée",
|
||||
"The selected VM is running.": "La VM sélectionnée est en cours d'exécution.",
|
||||
"The selected base folder does not exist and could not be created:": "Le dossier de base sélectionné n'existe pas et n'a pas pu être créé :",
|
||||
"The selected bridge does not exist": "Le pont réseau sélectionné n’existe pas",
|
||||
"The selected bridge has no IPv4 subnet for the host-monitor firewall": "Le pont sélectionné n'a pas de sous-réseau IPv4 pour le pare-feu du moniteur hôte",
|
||||
"The selected configuration needs to start the LXC during the installation": "La configuration sélectionnée doit démarrer le LXC pendant l'installation",
|
||||
"The selected container is unprivileged. A privileged container is required for direct device passthrough.": "Le conteneur sélectionné n'est pas privilégié. Un conteneur privilégié est requis pour le relais direct du périphérique.",
|
||||
@@ -6724,6 +6736,7 @@
|
||||
"The service builds its own image; only a published image can be installed": "Le service construit sa propre image; seule une image publiée peut être installée",
|
||||
"The service declares no image:": "Le service ne déclare aucune image:",
|
||||
"The setting has no final value:": "Le réglage n'a pas de valeur finale:",
|
||||
"The settings could not be fully rolled back. Review the container and its saved modification before continuing.": "Les paramètres n’ont pas pu être entièrement rétablis. Vérifiez le conteneur et la modification enregistrée avant de continuer.",
|
||||
"The share already exists in smb.conf:": "Le partage existe déjà dans smb.conf :",
|
||||
"The shared destination is not a directory": "La destination partagée n'est pas un répertoire",
|
||||
"The shared directory points to a protected host path": "Le répertoire partagé indique un chemin d'hôte protégé",
|
||||
@@ -6798,6 +6811,7 @@
|
||||
"These interface configurations will be removed": "Ces configurations d'interface seront supprimées",
|
||||
"These manual changes cannot be adopted safely:": "Ces modifications manuelles ne peuvent pas être adoptées en toute sécurité :",
|
||||
"These paths will not be restored live and will be extracted for manual recovery.": "Ces chemins ne seront pas restaurés en direct et seront extraits pour une récupération manuelle.",
|
||||
"These settings require an application-specific adaptation. The image was not downloaded and the container was not recreated.": "Ces paramètres nécessitent une adaptation propre à l’application. L’image n’a pas été téléchargée et le conteneur n’a pas été recréé.",
|
||||
"These values are asked during the installation:": "Ces valeurs sont demandées lors de l'installation:",
|
||||
"They are described in the image documentation:": "Ils sont décrits dans la documentation de l'image :",
|
||||
"They will be added to oci-compose before continuing. Unsupported or changed resources are not imported.": "Ils seront ajoutés à oci-compose avant de continuer. Les ressources non prises en charge ou modifiées ne sont pas importées.",
|
||||
@@ -7136,6 +7150,7 @@
|
||||
"Unsupported mount type": "Type de montage non supporté",
|
||||
"Unsupported mount type:": "Type de montage non pris en charge:",
|
||||
"Unsupported native device type:": "Type de périphérique natif non pris en charge:",
|
||||
"Unsupported network settings": "Paramètres réseau non pris en charge",
|
||||
"Unsupported operation": "operation non prise en charge",
|
||||
"Unsupported or unknown Proxmox version; no repository changed.": "Version Proxmox non prise en charge ou inconnue ; aucun référentiel n'a été modifié.",
|
||||
"Unsupported output format:": "Format de sortie non pris en charge :",
|
||||
|
||||
@@ -446,10 +446,12 @@
|
||||
"Apply the reviewed package upgrade now? Services may restart and packages listed for removal will be removed.": "Applicare ora l'aggiornamento dei pacchetti esaminato? I servizi potrebbero riavviarsi e i pacchetti indicati per la rimozione verranno rimossi.",
|
||||
"Apply these changes?": "Applicare queste modifiche?",
|
||||
"Applying AMD-specific fixes...": "Applicazione delle correzioni specifiche AMD...",
|
||||
"Applying CPU and RAM changes without rebuilding the container...": "Applicazione delle modifiche a CPU e RAM senza ricreare il container...",
|
||||
"Applying Changes": "Applicazione delle modifiche",
|
||||
"Applying Controller/NVMe passthrough to VM": "Applicazione del passthrough Controller/NVMe alla VM",
|
||||
"Applying balanced memory optimization settings...": "Applicazione delle impostazioni bilanciate di ottimizzazione della memoria...",
|
||||
"Applying changes safely...": "Applicazione sicura delle modifiche...",
|
||||
"Applying container settings without rebuilding the container...": "Applicazione delle impostazioni senza ricreare il container...",
|
||||
"Applying custom restore": "Applicazione del ripristino personalizzato",
|
||||
"Applying default VM configuration": "Applicazione della configurazione predefinita della VM",
|
||||
"Applying host permissions for unprivileged LXC bind-mounts...": "Applicazione delle autorizzazioni sull’host per i bind mount dei container LXC non privilegiati...",
|
||||
@@ -3225,6 +3227,8 @@
|
||||
"Invalid ALLOWED_HOSTS value": "Valore ALLOWED_HOSTS non valido",
|
||||
"Invalid Home Assistant OS check timeout": "Timeout controllo Home Assistant OS non valido",
|
||||
"Invalid ID": "ID non valido",
|
||||
"Invalid IPv4 address": "Indirizzo IPv4 non valido",
|
||||
"Invalid IPv4 gateway": "Gateway IPv4 non valido",
|
||||
"Invalid Intel render path": "Percorso di rendering Intel non valido",
|
||||
"Invalid Jellyfin path:": "Percorso Jellyfin non valido:",
|
||||
"Invalid MAC address:": "Indirizzo MAC non valido:",
|
||||
@@ -3257,6 +3261,7 @@
|
||||
"Invalid administrator email": "Email amministratore non valida",
|
||||
"Invalid administrator user name": "Nome utente amministratore non valido",
|
||||
"Invalid base VMID": "VMID di base non valido",
|
||||
"Invalid bridge": "Bridge non valido",
|
||||
"Invalid check package:": "Pacchetto controllo non valido:",
|
||||
"Invalid configuration path:": "Percorso configurazione non valido:",
|
||||
"Invalid consume/export volumes": "Volumi di acquisizione/esportazione non validi",
|
||||
@@ -3320,6 +3325,7 @@
|
||||
"Invalid remote name": "Nome del remote non valido",
|
||||
"Invalid remote path": "Percorso remoto non valido",
|
||||
"Invalid repair version:": "Versione di riparazione non valida:",
|
||||
"Invalid resource value": "Valore della risorsa non valido",
|
||||
"Invalid resources": "Risorse non valide",
|
||||
"Invalid restored volume path": "Percorso volume ripristinato non valido",
|
||||
"Invalid running state": "Stato di esecuzione non valido",
|
||||
@@ -3338,6 +3344,7 @@
|
||||
"Invalid stack members": "Membri stack non validi",
|
||||
"Invalid stack name": "Nome stack non valido",
|
||||
"Invalid stack operation": "Operazione stack non valida",
|
||||
"Invalid storage": "Archiviazione non valida",
|
||||
"Invalid storage ID. Use only letters, numbers, hyphens and underscores.": "ID storage non valido. Usa solo lettere, numeri, trattini e trattini bassi.",
|
||||
"Invalid storage name": "Nome storage non valido",
|
||||
"Invalid suite application": "Applicazione suite non valida",
|
||||
@@ -4906,6 +4913,7 @@
|
||||
"Proxmox VE configuration completed.": "Configurazione Proxmox VE completata.",
|
||||
"Proxmox VE safe update completed": "Aggiornamento sicuro di Proxmox VE completato",
|
||||
"Proxmox auth logger service created and started": "Servizio log autenticazione Proxmox creato e avviato",
|
||||
"Proxmox could not apply the container settings": "Proxmox non è riuscito ad applicare le impostazioni del container",
|
||||
"Proxmox enterprise repo might be missing or inaccessible. Trying to switch to no-subscription...": "Il repository enterprise Proxmox potrebbe essere mancante o inaccessibile. Tentativo di passare al repository no-subscription...",
|
||||
"Proxmox filter configured": "Filtro Proxmox configurato",
|
||||
"Proxmox iSCSI Storage Status:": "Stato storage iSCSI Proxmox:",
|
||||
@@ -6348,6 +6356,7 @@
|
||||
"The build could not be checked beforehand; continuing without that check.": "Non è stato possibile verificare la build in anticipo; si prosegue senza tale verifica.",
|
||||
"The cached image does not match the current digest": "L'immagine nella cache non corrisponde al digest corrente",
|
||||
"The cached image is damaged; it will be downloaded again.": "L'immagine nella cache è danneggiata; verrà scaricata nuovamente.",
|
||||
"The changes need the application installer": "Le modifiche richiedono il programma di installazione dell’applicazione",
|
||||
"The character device does not exist:": "Il dispositivo a caratteri non esiste:",
|
||||
"The command asks for a password that is not echoed. After creating the users, the web interface asks for a user name and a password.": "Il comando richiede una password non visualizzata durante la digitazione. Dopo la creazione degli utenti, l’interfaccia web richiede nome utente e password.",
|
||||
"The command does not name an image": "Il comando non specifica un’immagine",
|
||||
@@ -6467,6 +6476,7 @@
|
||||
"The host bind source does not exist:": "La sorgente del bind mount sull’host non esiste:",
|
||||
"The host bind source is not a regular file or directory:": "La sorgente del bind mount sull'host non è un file regolare né una directory:",
|
||||
"The host directory changed before it was mounted": "La directory dell'host è cambiata prima del montaggio",
|
||||
"The host directory does not exist": "La directory dell’host non esiste",
|
||||
"The host directory may not be accessible from an unprivileged container.": "La directory dell’host potrebbe non essere accessibile da un container non privilegiato.",
|
||||
"The host has no IPv4 address on the selected bridge": "L'host non ha un indirizzo IPv4 sul bridge selezionato",
|
||||
"The host monitor does not see the real host memory": "Il monitoraggio dell'host non rileva la memoria reale dell'host",
|
||||
@@ -6658,6 +6668,7 @@
|
||||
"The repair must preserve the image dependencies:": "La riparazione deve conservare le dipendenze dell'immagine:",
|
||||
"The requested VMID block is already in use": "Il blocco di VMID richiesto è già in uso",
|
||||
"The requested machine learning GPU profile is not working; it is not replaced by CPU": "Il profilo GPU richiesto per il machine learning non funziona; non viene sostituito dalla CPU",
|
||||
"The resource edit could not be fully rolled back. Open Manage again to review and import the current Proxmox settings.": "Non è stato possibile annullare completamente la modifica delle risorse. Riapri il menu di gestione per verificare e importare le impostazioni attuali di Proxmox.",
|
||||
"The restored service did not pass its health check": "Il servizio ripristinato non ha superato il controllo dello stato",
|
||||
"The restored service stopped; the recovery is not confirmed": "Il servizio ripristinato si è arrestato; il recupero non è confermato",
|
||||
"The reviewed Tandoor stack does not require a privileged LXC.": "Lo stack Tandoor esaminato non richiede un container LXC privilegiato.",
|
||||
@@ -6703,6 +6714,7 @@
|
||||
"The selected VM": "La VM selezionata",
|
||||
"The selected VM is running.": "La VM selezionata è in esecuzione.",
|
||||
"The selected base folder does not exist and could not be created:": "La cartella di base selezionata non esiste e non è stato possibile crearla:",
|
||||
"The selected bridge does not exist": "Il bridge selezionato non esiste",
|
||||
"The selected bridge has no IPv4 subnet for the host-monitor firewall": "Il bridge selezionato non ha una sottorete IPv4 per il firewall del monitoraggio dell’host",
|
||||
"The selected configuration needs to start the LXC during the installation": "La configurazione selezionata richiede l'avvio del container LXC durante l'installazione",
|
||||
"The selected container is unprivileged. A privileged container is required for direct device passthrough.": "Il container selezionato non è privilegiato. È richiesto un container privilegiato per il passthrough diretto del dispositivo.",
|
||||
@@ -6725,6 +6737,7 @@
|
||||
"The service builds its own image; only a published image can be installed": "Il servizio crea la propria immagine; si possono installare solo immagini pubblicate",
|
||||
"The service declares no image:": "Il servizio non dichiara alcuna immagine:",
|
||||
"The setting has no final value:": "L'impostazione non ha un valore finale:",
|
||||
"The settings could not be fully rolled back. Review the container and its saved modification before continuing.": "Non è stato possibile ripristinare tutte le impostazioni. Controlla il container e la modifica salvata prima di continuare.",
|
||||
"The share already exists in smb.conf:": "La condivisione esiste già in smb.conf:",
|
||||
"The shared destination is not a directory": "La destinazione condivisa non è una directory",
|
||||
"The shared directory points to a protected host path": "La directory condivisa punta a un percorso protetto dell'host",
|
||||
@@ -6799,6 +6812,7 @@
|
||||
"These interface configurations will be removed": "Queste configurazioni dell'interfaccia verranno rimosse",
|
||||
"These manual changes cannot be adopted safely:": "Queste modifiche manuali non possono essere acquisite in modo sicuro:",
|
||||
"These paths will not be restored live and will be extracted for manual recovery.": "Questi percorsi non verranno ripristinati sul sistema in esecuzione e verranno estratti per il ripristino manuale.",
|
||||
"These settings require an application-specific adaptation. The image was not downloaded and the container was not recreated.": "Queste impostazioni richiedono un adattamento specifico per l’applicazione. L’immagine non è stata scaricata e il container non è stato ricreato.",
|
||||
"These values are asked during the installation:": "Questi valori verranno richiesti durante l’installazione:",
|
||||
"They are described in the image documentation:": "Sono descritti nella documentazione dell'immagine:",
|
||||
"They will be added to oci-compose before continuing. Unsupported or changed resources are not imported.": "Se confermi e la verifica finale riesce, queste risorse verranno registrate in oci-compose prima di proseguire. Le risorse non supportate o modificate non vengono acquisite.",
|
||||
@@ -7137,6 +7151,7 @@
|
||||
"Unsupported mount type": "Tipo mount non supportato",
|
||||
"Unsupported mount type:": "Tipo mount non supportato:",
|
||||
"Unsupported native device type:": "Tipo dispositivo nativo non supportato:",
|
||||
"Unsupported network settings": "Impostazioni di rete non supportate",
|
||||
"Unsupported operation": "Operazione non supportata",
|
||||
"Unsupported or unknown Proxmox version; no repository changed.": "Versione Proxmox non supportata o sconosciuta; nessun repository è stato modificato.",
|
||||
"Unsupported output format:": "Formato output non supportato:",
|
||||
|
||||
@@ -446,10 +446,12 @@
|
||||
"Apply the reviewed package upgrade now? Services may restart and packages listed for removal will be removed.": "Aplicar agora a atualização de pacotes revista? Os serviços podem reiniciar e os pacotes indicados para remoção serão removidos.",
|
||||
"Apply these changes?": "Aplicar essas alterações?",
|
||||
"Applying AMD-specific fixes...": "Aplicando correções específicas da AMD...",
|
||||
"Applying CPU and RAM changes without rebuilding the container...": "A aplicar as alterações de CPU e RAM sem recriar o contentor...",
|
||||
"Applying Changes": "Aplicando alterações",
|
||||
"Applying Controller/NVMe passthrough to VM": "Aplicando passagem de controlador/NVMe à VM",
|
||||
"Applying balanced memory optimization settings...": "Aplicando configurações de otimização de memória balanceadas...",
|
||||
"Applying changes safely...": "Aplicando alterações com segurança...",
|
||||
"Applying container settings without rebuilding the container...": "A aplicar as definições sem recriar o contentor...",
|
||||
"Applying custom restore": "Aplicando restauração personalizada",
|
||||
"Applying default VM configuration": "Aplicando configuração de VM padrão",
|
||||
"Applying host permissions for unprivileged LXC bind-mounts...": "Aplicando permissões de host para montagens de ligação LXC sem privilégios...",
|
||||
@@ -3225,6 +3227,8 @@
|
||||
"Invalid ALLOWED_HOSTS value": "Valor Allowed hosts inválido",
|
||||
"Invalid Home Assistant OS check timeout": "Tempo de verificação do sistema operacional Home Assistant inválido",
|
||||
"Invalid ID": "ID inválido",
|
||||
"Invalid IPv4 address": "Endereço IPv4 inválido",
|
||||
"Invalid IPv4 gateway": "Gateway IPv4 inválido",
|
||||
"Invalid Intel render path": "Caminho de renderização Intel inválido",
|
||||
"Invalid Jellyfin path:": "Caminho Jellyfin inválido:",
|
||||
"Invalid MAC address:": "Endereço MAC inválido:",
|
||||
@@ -3257,6 +3261,7 @@
|
||||
"Invalid administrator email": "E- mail de administrador inválido",
|
||||
"Invalid administrator user name": "Nome de usuário do administrador inválido",
|
||||
"Invalid base VMID": "VMID base inválida",
|
||||
"Invalid bridge": "Bridge inválida",
|
||||
"Invalid check package:": "Pacote de verificação inválido:",
|
||||
"Invalid configuration path:": "Caminho de configuração inválido:",
|
||||
"Invalid consume/export volumes": "Volumes de consumo/exportação inválidos",
|
||||
@@ -3320,6 +3325,7 @@
|
||||
"Invalid remote name": "Nome remoto inválido",
|
||||
"Invalid remote path": "Caminho remoto inválido",
|
||||
"Invalid repair version:": "Versão de reparação inválida:",
|
||||
"Invalid resource value": "Valor de recurso inválido",
|
||||
"Invalid resources": "Recursos inválidos",
|
||||
"Invalid restored volume path": "Caminho de volume restaurado inválido",
|
||||
"Invalid running state": "Estado de execução inválido",
|
||||
@@ -3338,6 +3344,7 @@
|
||||
"Invalid stack members": "Membros inválidos da pilha",
|
||||
"Invalid stack name": "Nome da pilha inválido",
|
||||
"Invalid stack operation": "Pilha inválida operation",
|
||||
"Invalid storage": "Armazenamento inválido",
|
||||
"Invalid storage ID. Use only letters, numbers, hyphens and underscores.": "ID de armazenamento inválido. Use apenas letras, números, hífens e sublinhados.",
|
||||
"Invalid storage name": "Nome de armazenamento inválido",
|
||||
"Invalid suite application": "Aplicativo de suite inválido",
|
||||
@@ -4906,6 +4913,7 @@
|
||||
"Proxmox VE configuration completed.": "Configuração do Proxmox VE concluída.",
|
||||
"Proxmox VE safe update completed": "Atualização segura do Proxmox VE concluída",
|
||||
"Proxmox auth logger service created and started": "Serviço de registrador de autenticação Proxmox criado e iniciado",
|
||||
"Proxmox could not apply the container settings": "O Proxmox não conseguiu aplicar as definições do contentor",
|
||||
"Proxmox enterprise repo might be missing or inaccessible. Trying to switch to no-subscription...": "O repositório corporativo Proxmox pode estar ausente ou inacessível. Tentando mudar para sem assinatura...",
|
||||
"Proxmox filter configured": "Filtro Proxmox configurado",
|
||||
"Proxmox iSCSI Storage Status:": "Status de armazenamento iSCSI do Proxmox:",
|
||||
@@ -6348,6 +6356,7 @@
|
||||
"The build could not be checked beforehand; continuing without that check.": "A compilação não pôde ser verificada antecipadamente;continuando sem essa verificação.",
|
||||
"The cached image does not match the current digest": "A imagem em cache não corresponde à digest atual",
|
||||
"The cached image is damaged; it will be downloaded again.": "A imagem em cache está danificada; será transferida novamente.",
|
||||
"The changes need the application installer": "As alterações requerem o instalador da aplicação",
|
||||
"The character device does not exist:": "O dispositivo de caracteres não existe:",
|
||||
"The command asks for a password that is not echoed. After creating the users, the web interface asks for a user name and a password.": "O comando pede uma senha que não seja ecoada. Depois de criar os usuários, a interface web pede um nome de usuário e uma senha.",
|
||||
"The command does not name an image": "O comando não nomeia uma imagem",
|
||||
@@ -6467,6 +6476,7 @@
|
||||
"The host bind source does not exist:": "O código de ligação da máquina não existe:",
|
||||
"The host bind source is not a regular file or directory:": "O código de ligação da máquina não é um ficheiro ou directório regular:",
|
||||
"The host directory changed before it was mounted": "A pasta da máquina mudou antes de ser montada",
|
||||
"The host directory does not exist": "O diretório do anfitrião não existe",
|
||||
"The host directory may not be accessible from an unprivileged container.": "O diretório host pode não estar acessível a partir de um contêiner sem privilégios.",
|
||||
"The host has no IPv4 address on the selected bridge": "A máquina não tem endereço IPv4 na ponte seleccionada",
|
||||
"The host monitor does not see the real host memory": "O monitor da máquina não vê a memória verdadeira da máquina",
|
||||
@@ -6658,6 +6668,7 @@
|
||||
"The repair must preserve the image dependencies:": "O reparo deve preservar as dependências da imagem:",
|
||||
"The requested VMID block is already in use": "O bloco VMID solicitado já está em uso",
|
||||
"The requested machine learning GPU profile is not working; it is not replaced by CPU": "O perfil de aprendizado de máquina solicitado GPU não está funcionando; não é substituído por CPU",
|
||||
"The resource edit could not be fully rolled back. Open Manage again to review and import the current Proxmox settings.": "Não foi possível reverter totalmente a alteração dos recursos. Volte a abrir o menu de gestão para rever e importar as definições atuais do Proxmox.",
|
||||
"The restored service did not pass its health check": "O serviço restaurado não passou no seu exame de saúde",
|
||||
"The restored service stopped; the recovery is not confirmed": "O serviço restaurado parou; a recuperação não é confirmada",
|
||||
"The reviewed Tandoor stack does not require a privileged LXC.": "A pilha Tandoor revisada não require um LXC privilegiado.",
|
||||
@@ -6703,6 +6714,7 @@
|
||||
"The selected VM": "A VM selecionada",
|
||||
"The selected VM is running.": "A VM selecionada está em execução.",
|
||||
"The selected base folder does not exist and could not be created:": "A pasta base selecionada não existe e não pôde ser criada:",
|
||||
"The selected bridge does not exist": "A bridge selecionada não existe",
|
||||
"The selected bridge has no IPv4 subnet for the host-monitor firewall": "A ponte selecionada não possui sub-rede IPv4 para o firewall do monitor host",
|
||||
"The selected configuration needs to start the LXC during the installation": "A configuração selecionada precisa iniciar o LXC durante a instalação",
|
||||
"The selected container is unprivileged. A privileged container is required for direct device passthrough.": "O contêiner selecionado não tem privilégios. Um contêiner privilegiado é necessário para passagem direta do dispositivo.",
|
||||
@@ -6725,6 +6737,7 @@
|
||||
"The service builds its own image; only a published image can be installed": "O serviço constrói sua própria imagem; somente uma imagem publicada pode ser instalada",
|
||||
"The service declares no image:": "O serviço não declara imagem:",
|
||||
"The setting has no final value:": "A configuração não tem valor final:",
|
||||
"The settings could not be fully rolled back. Review the container and its saved modification before continuing.": "Não foi possível repor todas as definições. Reveja o contentor e a alteração guardada antes de continuar.",
|
||||
"The share already exists in smb.conf:": "O compartilhamento já existe em smb.conf:",
|
||||
"The shared destination is not a directory": "O destino compartilhado não é um diretório",
|
||||
"The shared directory points to a protected host path": "A pasta partilhada aponta para uma localização protegida da máquina",
|
||||
@@ -6799,6 +6812,7 @@
|
||||
"These interface configurations will be removed": "Essas configurações de interface serão removidas",
|
||||
"These manual changes cannot be adopted safely:": "Estas alterações manuais não podem ser adotadas com segurança:",
|
||||
"These paths will not be restored live and will be extracted for manual recovery.": "Esses caminhos não serão restaurados ao vivo e serão extraídos para recuperação manual.",
|
||||
"These settings require an application-specific adaptation. The image was not downloaded and the container was not recreated.": "Estas definições requerem uma adaptação específica da aplicação. A imagem não foi transferida e o contentor não foi recriado.",
|
||||
"These values are asked during the installation:": "Estes valores são questionados durante a instalação:",
|
||||
"They are described in the image documentation:": "Estão descritos na documentação da imagem:",
|
||||
"They will be added to oci-compose before continuing. Unsupported or changed resources are not imported.": "Eles serão adicionados ao oci-compose antes de continuar. Recursos não suportados ou alterados não são importados.",
|
||||
@@ -7137,6 +7151,7 @@
|
||||
"Unsupported mount type": "Tipo de montagem não suportado",
|
||||
"Unsupported mount type:": "Tipo de montagem não suportado:",
|
||||
"Unsupported native device type:": "Tipo de dispositivo nativo não suportado:",
|
||||
"Unsupported network settings": "Definições de rede não suportadas",
|
||||
"Unsupported operation": "operation não suportado",
|
||||
"Unsupported or unknown Proxmox version; no repository changed.": "Versão Proxmox não suportada ou desconhecida; nenhum repositório foi alterado.",
|
||||
"Unsupported output format:": "Formato de saída não suportado:",
|
||||
|
||||
@@ -446,10 +446,12 @@
|
||||
"Apply the reviewed package upgrade now? Services may restart and packages listed for removal will be removed.": "Použiť teraz skontrolovanú aktualizáciu balíkov? Služby sa môžu reštartovať a balíky označené na odstránenie budú odstránené.",
|
||||
"Apply these changes?": "Použiť tieto zmeny?",
|
||||
"Applying AMD-specific fixes...": "Používam opravy špecifické pre AMD...",
|
||||
"Applying CPU and RAM changes without rebuilding the container...": "Používajú sa zmeny CPU a RAM bez opätovného vytvorenia kontajnera...",
|
||||
"Applying Changes": "Používam zmeny",
|
||||
"Applying Controller/NVMe passthrough to VM": "Používam priame priradenie Controller/NVMe do VM",
|
||||
"Applying balanced memory optimization settings...": "Používam vyvážené nastavenia optimalizácie pamäte...",
|
||||
"Applying changes safely...": "Bezpečne používam zmeny...",
|
||||
"Applying container settings without rebuilding the container...": "Aplikujú sa nastavenia bez opätovného vytvorenia kontajnera...",
|
||||
"Applying custom restore": "Používam vlastnú obnovu",
|
||||
"Applying default VM configuration": "Používam predvolené nastavenie VM",
|
||||
"Applying host permissions for unprivileged LXC bind-mounts...": "Nastavujem oprávnenia hosta pre LXC bind mounty v neprivilegovaných kontajneroch...",
|
||||
@@ -3225,6 +3227,8 @@
|
||||
"Invalid ALLOWED_HOSTS value": "Neplatná hodnota UDELENÁ HOSTS",
|
||||
"Invalid Home Assistant OS check timeout": "Neplatný čas odhlásenia Home Assistant OS",
|
||||
"Invalid ID": "Neplatné ID",
|
||||
"Invalid IPv4 address": "Neplatná adresa IPv4",
|
||||
"Invalid IPv4 gateway": "Neplatná brána IPv4",
|
||||
"Invalid Intel render path": "Neplatná cesta k zobrazeniu informácií",
|
||||
"Invalid Jellyfin path:": "Neplatná cesta Jellyfin:",
|
||||
"Invalid MAC address:": "Neplatná adresa MAC:",
|
||||
@@ -3257,6 +3261,7 @@
|
||||
"Invalid administrator email": "Neplatný administrátorský mail",
|
||||
"Invalid administrator user name": "Neplatné meno správcu",
|
||||
"Invalid base VMID": "Neplatná základňa VMID",
|
||||
"Invalid bridge": "Neplatný sieťový most",
|
||||
"Invalid check package:": "Neplatný kontrolný balík:",
|
||||
"Invalid configuration path:": "Neplatná konfiguračná dráha:",
|
||||
"Invalid consume/export volumes": "Neplatné objemy spotreby/vývozu",
|
||||
@@ -3320,6 +3325,7 @@
|
||||
"Invalid remote name": "Neplatné vzdialené meno",
|
||||
"Invalid remote path": "Neplatná vzdialená cesta",
|
||||
"Invalid repair version:": "Neplatná verzia opravy:",
|
||||
"Invalid resource value": "Neplatná hodnota prostriedkov",
|
||||
"Invalid resources": "Neplatné zdroje",
|
||||
"Invalid restored volume path": "Neplatná cesta obnoveného objemu",
|
||||
"Invalid running state": "Neplatný jazdný stav",
|
||||
@@ -3338,6 +3344,7 @@
|
||||
"Invalid stack members": "Neplatní členovia stohu",
|
||||
"Invalid stack name": "Neplatný názov stohu",
|
||||
"Invalid stack operation": "Neplatný zásobník operation",
|
||||
"Invalid storage": "Neplatné úložisko",
|
||||
"Invalid storage ID. Use only letters, numbers, hyphens and underscores.": "Neplatné ID úložiska. Použite iba písmená, čísla, pomlčky a podčiarkovníky.",
|
||||
"Invalid storage name": "Neplatný názov úložiska",
|
||||
"Invalid suite application": "Neplatná aplikácia",
|
||||
@@ -4908,6 +4915,7 @@
|
||||
"Proxmox VE configuration completed.": "Nastavenie Proxmox VE je dokončené.",
|
||||
"Proxmox VE safe update completed": "Bezpečná aktualizácia Proxmox VE je dokončená",
|
||||
"Proxmox auth logger service created and started": "Služba na logovanie Proxmox prihlásení bola vytvorená a spustená",
|
||||
"Proxmox could not apply the container settings": "Proxmox nedokázal použiť nastavenia kontajnera",
|
||||
"Proxmox enterprise repo might be missing or inaccessible. Trying to switch to no-subscription...": "Proxmox enterprise repozitár môže chýbať alebo byť nedostupný. Skúšam prepnúť na no-subscription...",
|
||||
"Proxmox filter configured": "Proxmox filter nastavený",
|
||||
"Proxmox iSCSI Storage Status:": "Stav iSCSI úložiska v Proxmoxe:",
|
||||
@@ -6349,6 +6357,7 @@
|
||||
"The build could not be checked beforehand; continuing without that check.": "Zostavenie nebolo možné vopred skontrolovať;pokračovanie bez tejto kontroly.",
|
||||
"The cached image does not match the current digest": "Comment",
|
||||
"The cached image is damaged; it will be downloaded again.": "Comment",
|
||||
"The changes need the application installer": "Zmeny vyžadujú inštalačný program aplikácie",
|
||||
"The character device does not exist:": "Znakové zariadenie neexistuje:",
|
||||
"The command asks for a password that is not echoed. After creating the users, the web interface asks for a user name and a password.": "Príkaz vyžaduje heslo, ktoré nie je ozvene. Po vytvorení užívateľov, webové rozhranie žiada o užívateľské meno a heslo.",
|
||||
"The command does not name an image": "Príkaz nepomenuje obrázok",
|
||||
@@ -6468,6 +6477,7 @@
|
||||
"The host bind source does not exist:": "Zdroj hostiteľskej väzby neexistuje:",
|
||||
"The host bind source is not a regular file or directory:": "Zdroj odkazu hostiteľa nie je bežný súbor alebo adresár:",
|
||||
"The host directory changed before it was mounted": "Adresár hostiteľa sa zmenil predtým, ako bol pripojený",
|
||||
"The host directory does not exist": "Adresár na hostiteľovi neexistuje",
|
||||
"The host directory may not be accessible from an unprivileged container.": "Priečinok na hostovi nemusí byť dostupný z neprivilegovaného kontajnera.",
|
||||
"The host has no IPv4 address on the selected bridge": "Hostiteľ nemá IPv4 adresu na vybranom moste",
|
||||
"The host monitor does not see the real host memory": "Monitor hostiteľa nevidí skutočnú pamäť hostiteľa",
|
||||
@@ -6659,6 +6669,7 @@
|
||||
"The repair must preserve the image dependencies:": "Oprava musí zachovať závislosť obrazu:",
|
||||
"The requested VMID block is already in use": "Požadovaný blok VMID sa už používa",
|
||||
"The requested machine learning GPU profile is not working; it is not replaced by CPU": "Požadovaný profil strojového učenia sa GPU nefunguje; nie je nahradený CPU",
|
||||
"The resource edit could not be fully rolled back. Open Manage again to review and import the current Proxmox settings.": "Zmenu prostriedkov sa nepodarilo úplne vrátiť späť. Znova otvorte správu aplikácií, skontrolujte aktuálne nastavenia systému Proxmox a prevezmite ich.",
|
||||
"The restored service did not pass its health check": "Obnovená služba neprešla kontrolou stavu",
|
||||
"The restored service stopped; the recovery is not confirmed": "Obnovená služba sa zastavila; obnova sa nepotvrdila",
|
||||
"The reviewed Tandoor stack does not require a privileged LXC.": "Preskúmaný Tandoor stack require privilegovaný LXC.",
|
||||
@@ -6704,6 +6715,7 @@
|
||||
"The selected VM": "Vybraná VM",
|
||||
"The selected VM is running.": "Vybraná VM beží.",
|
||||
"The selected base folder does not exist and could not be created:": "Vybraný základný priečinok neexistuje a nepodarilo sa ho vytvoriť:",
|
||||
"The selected bridge does not exist": "Vybraný sieťový most neexistuje",
|
||||
"The selected bridge has no IPv4 subnet for the host-monitor firewall": "Vybraný bridge nemá IPv4 podsieť pre firewall monitora hostiteľa",
|
||||
"The selected configuration needs to start the LXC during the installation": "Vybraná konfigurácia musí spustiť LXC počas inštalácie",
|
||||
"The selected container is unprivileged. A privileged container is required for direct device passthrough.": "Vybraný kontajner je neprivilegovaný. Na priame priradenie zariadenia je potrebný privilegovaný kontajner.",
|
||||
@@ -6726,6 +6738,7 @@
|
||||
"The service builds its own image; only a published image can be installed": "Služba buduje svoj vlastný obraz; môže byť nainštalovaný iba zverejnený obrázok",
|
||||
"The service declares no image:": "Služba neprehlasuje žiadny obrázok:",
|
||||
"The setting has no final value:": "Nastavenie nemá žiadnu konečnú hodnotu:",
|
||||
"The settings could not be fully rolled back. Review the container and its saved modification before continuing.": "Nepodarilo sa vrátiť všetky nastavenia. Pred pokračovaním skontrolujte kontajner a uloženú zmenu.",
|
||||
"The share already exists in smb.conf:": "Zdieľanie už existuje v smb.conf:",
|
||||
"The shared destination is not a directory": "Zdieľaná destinácia nie je adresár",
|
||||
"The shared directory points to a protected host path": "Zdieľaný adresár ukazuje na chránenú cestu hostiteľa",
|
||||
@@ -6800,6 +6813,7 @@
|
||||
"These interface configurations will be removed": "Tieto nastavenia rozhraní sa odstránia",
|
||||
"These manual changes cannot be adopted safely:": "Tieto manuálne zmeny nie je možné bezpečne prijať:",
|
||||
"These paths will not be restored live and will be extracted for manual recovery.": "Tieto cesty sa neobnovia za behu a rozbalia sa na ručnú obnovu.",
|
||||
"These settings require an application-specific adaptation. The image was not downloaded and the container was not recreated.": "Tieto nastavenia vyžadujú úpravu špecifickú pre aplikáciu. Obraz sa nestiahol a kontajner sa nevytvoril znova.",
|
||||
"These values are asked during the installation:": "Tieto hodnoty sa požadujú počas inštalácie:",
|
||||
"They are described in the image documentation:": "Ich popis nájdete v dokumentácii image:",
|
||||
"They will be added to oci-compose before continuing. Unsupported or changed resources are not imported.": "Pred pokračovaním budú pridané do oci-compose. Nepodporované alebo zmenené zdroje sa neimportujú.",
|
||||
@@ -7138,6 +7152,7 @@
|
||||
"Unsupported mount type": "Nepodporovaný typ pripojenia",
|
||||
"Unsupported mount type:": "Nepodporovaný typ pripojenia:",
|
||||
"Unsupported native device type:": "Nepodporovaný natívne typ zariadenia:",
|
||||
"Unsupported network settings": "Nepodporované sieťové nastavenia",
|
||||
"Unsupported operation": "Nepodporovaná operation",
|
||||
"Unsupported or unknown Proxmox version; no repository changed.": "Nepodporovaná alebo neznáma verzia Proxmox; žiadne úložisko sa nezmenilo.",
|
||||
"Unsupported output format:": "Nepodporovaný výstupný formát:",
|
||||
|
||||
@@ -446,10 +446,12 @@
|
||||
"Apply the reviewed package upgrade now? Services may restart and packages listed for removal will be removed.": "Tillämpa den granskade paketuppgraderingen nu? Tjänster kan startas om och paket som anges för borttagning kommer att tas bort.",
|
||||
"Apply these changes?": "Tillämpa dessa ändringar?",
|
||||
"Applying AMD-specific fixes...": "Tillämpar AMD-specifika korrigeringar...",
|
||||
"Applying CPU and RAM changes without rebuilding the container...": "Tillämpar ändringarna av CPU och RAM utan att återskapa containern...",
|
||||
"Applying Changes": "Tillämpa ändringar",
|
||||
"Applying Controller/NVMe passthrough to VM": "Tillämpar styrenhets-/NVMe-passthrough på VM",
|
||||
"Applying balanced memory optimization settings...": "Tillämpar inställningar för balanserad minnesoptimering...",
|
||||
"Applying changes safely...": "Tillämpar ändringar på ett säkert sätt...",
|
||||
"Applying container settings without rebuilding the container...": "Tillämpar inställningarna utan att återskapa containern...",
|
||||
"Applying custom restore": "Använder anpassad återställning",
|
||||
"Applying default VM configuration": "Tillämpar standard VM-konfiguration",
|
||||
"Applying host permissions for unprivileged LXC bind-mounts...": "Tillämpar värdbehörigheter för oprivilegierade LXC bind-mounts...",
|
||||
@@ -3225,6 +3227,8 @@
|
||||
"Invalid ALLOWED_HOSTS value": "Invalid ALLOWED_HOSTS värde",
|
||||
"Invalid Home Assistant OS check timeout": "Ogiltig Home Assistant OS check timeout",
|
||||
"Invalid ID": "Ogiltigt ID",
|
||||
"Invalid IPv4 address": "Ogiltig IPv4-adress",
|
||||
"Invalid IPv4 gateway": "Ogiltig IPv4-gateway",
|
||||
"Invalid Intel render path": "Invalid Intel render bana",
|
||||
"Invalid Jellyfin path:": "Invalid Jellyfin väg:",
|
||||
"Invalid MAC address:": "Ogiltig MAC-adress:",
|
||||
@@ -3257,6 +3261,7 @@
|
||||
"Invalid administrator email": "Invalid administratör e-post",
|
||||
"Invalid administrator user name": "Invalid administratör användarnamn",
|
||||
"Invalid base VMID": "Ogiltig bas VMID",
|
||||
"Invalid bridge": "Ogiltig brygga",
|
||||
"Invalid check package:": "Ogiltigt checkpaket:",
|
||||
"Invalid configuration path:": "Ogiltig konfigurationsväg:",
|
||||
"Invalid consume/export volumes": "Ogiltiga konsum/exportvolymer",
|
||||
@@ -3320,6 +3325,7 @@
|
||||
"Invalid remote name": "Ogiltigt fjärrnamn",
|
||||
"Invalid remote path": "Invalid fjärrväg",
|
||||
"Invalid repair version:": "Ogiltig reparationsversion:",
|
||||
"Invalid resource value": "Ogiltigt resursvärde",
|
||||
"Invalid resources": "Ogiltiga resurser",
|
||||
"Invalid restored volume path": "Invalid återställd volymväg",
|
||||
"Invalid running state": "Ogiltig körstatus",
|
||||
@@ -3338,6 +3344,7 @@
|
||||
"Invalid stack members": "Ogiltiga stackmedlemmar",
|
||||
"Invalid stack name": "Invalid stack namn",
|
||||
"Invalid stack operation": "Ogiltig stack operation",
|
||||
"Invalid storage": "Ogiltig lagring",
|
||||
"Invalid storage ID. Use only letters, numbers, hyphens and underscores.": "Ogiltigt lagrings-ID. Använd endast bokstäver, siffror, bindestreck och understreck.",
|
||||
"Invalid storage name": "Ogiltigt lagringsnamn",
|
||||
"Invalid suite application": "Invalid suite applikation",
|
||||
@@ -4906,6 +4913,7 @@
|
||||
"Proxmox VE configuration completed.": "Proxmox VE-konfigurationen slutförd.",
|
||||
"Proxmox VE safe update completed": "Proxmox VE säker uppdatering slutförd",
|
||||
"Proxmox auth logger service created and started": "Proxmox authlogger-tjänst skapad och startade",
|
||||
"Proxmox could not apply the container settings": "Proxmox kunde inte tillämpa containerns inställningar",
|
||||
"Proxmox enterprise repo might be missing or inaccessible. Trying to switch to no-subscription...": "Proxmox företagsrepo kan saknas eller är otillgänglig. Försöker byta till ingen prenumeration...",
|
||||
"Proxmox filter configured": "Proxmox-filter konfigurerat",
|
||||
"Proxmox iSCSI Storage Status:": "Proxmox iSCSI-lagringsstatus:",
|
||||
@@ -6348,6 +6356,7 @@
|
||||
"The build could not be checked beforehand; continuing without that check.": "Bygget kunde inte kontrolleras i förväg;fortsätter utan den kontrollen.",
|
||||
"The cached image does not match the current digest": "Den cachade bilden matchar inte den nuvarande smältningen",
|
||||
"The cached image is damaged; it will be downloaded again.": "Den cachade bilden är skadad; den kommer att laddas ner igen.",
|
||||
"The changes need the application installer": "Ändringarna kräver programmets installationsverktyg",
|
||||
"The character device does not exist:": "Karaktärenheten existerar inte:",
|
||||
"The command asks for a password that is not echoed. After creating the users, the web interface asks for a user name and a password.": "Kommandot begär ett lösenord som inte echoed. Efter att ha skapat användarna ber webbgränssnittet om ett användarnamn och ett lösenord.",
|
||||
"The command does not name an image": "Kommandot heter inte en bild",
|
||||
@@ -6467,6 +6476,7 @@
|
||||
"The host bind source does not exist:": "Värdbindningskällan finns inte:",
|
||||
"The host bind source is not a regular file or directory:": "Värdbindningskällan är inte en vanlig fil eller katalog:",
|
||||
"The host directory changed before it was mounted": "Värdkatalogen ändrades innan den monterades",
|
||||
"The host directory does not exist": "Katalogen på värden finns inte",
|
||||
"The host directory may not be accessible from an unprivileged container.": "Värdkatalogen kanske inte är tillgänglig från en oprivilegierad behållare.",
|
||||
"The host has no IPv4 address on the selected bridge": "Värden har ingen IPv4-adress på den valda bron",
|
||||
"The host monitor does not see the real host memory": "Värdskärmen ser inte det verkliga värdminnet",
|
||||
@@ -6658,6 +6668,7 @@
|
||||
"The repair must preserve the image dependencies:": "Reparationen måste bevara bildberoenden:",
|
||||
"The requested VMID block is already in use": "Det begärda VMID-blocket används redan",
|
||||
"The requested machine learning GPU profile is not working; it is not replaced by CPU": "Den begärda maskininlärningen GPU-profilen fungerar inte; den ersätts inte av CPU.",
|
||||
"The resource edit could not be fully rolled back. Open Manage again to review and import the current Proxmox settings.": "Det gick inte att helt återställa resursändringen. Öppna hanteringsmenyn igen för att granska och importera de aktuella Proxmox-inställningarna.",
|
||||
"The restored service did not pass its health check": "Den återställda tjänsten passerade inte hälsokontrollen",
|
||||
"The restored service stopped; the recovery is not confirmed": "Den återställda tjänsten stannade; återhämtningen bekräftas inte",
|
||||
"The reviewed Tandoor stack does not require a privileged LXC.": "Den granskade Tandoor stack inte require en privilegierad LXC.",
|
||||
@@ -6703,6 +6714,7 @@
|
||||
"The selected VM": "Den valda virtuella datorn",
|
||||
"The selected VM is running.": "Den valda virtuella datorn körs.",
|
||||
"The selected base folder does not exist and could not be created:": "Den valda basmappen finns inte och kunde inte skapas:",
|
||||
"The selected bridge does not exist": "Den valda bryggan finns inte",
|
||||
"The selected bridge has no IPv4 subnet for the host-monitor firewall": "Den valda bryggan har inget IPv4-undernät för värd-monitorns brandvägg",
|
||||
"The selected configuration needs to start the LXC during the installation": "Den valda konfigurationen måste starta LXC under installationen",
|
||||
"The selected container is unprivileged. A privileged container is required for direct device passthrough.": "Den valda behållaren är oprivilegierad. En privilegierad behållare krävs för direkt enhetsgenomföring.",
|
||||
@@ -6725,6 +6737,7 @@
|
||||
"The service builds its own image; only a published image can be installed": "Tjänsten bygger sin egen bild; endast en publicerad bild kan installeras.",
|
||||
"The service declares no image:": "Tjänsten förklarar ingen bild:",
|
||||
"The setting has no final value:": "Inställningen har inget slutvärde:",
|
||||
"The settings could not be fully rolled back. Review the container and its saved modification before continuing.": "Det gick inte att återställa alla inställningar. Kontrollera containern och den sparade ändringen innan du fortsätter.",
|
||||
"The share already exists in smb.conf:": "Delningen finns redan i smb.conf:",
|
||||
"The shared destination is not a directory": "Den delade destinationen är inte en katalog",
|
||||
"The shared directory points to a protected host path": "Den delade katalogen pekar på en skyddad värdväg",
|
||||
@@ -6799,6 +6812,7 @@
|
||||
"These interface configurations will be removed": "Dessa gränssnittskonfigurationer kommer att tas bort",
|
||||
"These manual changes cannot be adopted safely:": "Dessa manuella ändringar kan inte användas på ett säkert sätt:",
|
||||
"These paths will not be restored live and will be extracted for manual recovery.": "Dessa sökvägar kommer inte att återställas live och kommer att extraheras för manuell återställning.",
|
||||
"These settings require an application-specific adaptation. The image was not downloaded and the container was not recreated.": "Dessa inställningar kräver en anpassning för just det här programmet. Avbildningen hämtades inte och containern återskapades inte.",
|
||||
"These values are asked during the installation:": "Dessa värden tillfrågas under installationen:",
|
||||
"They are described in the image documentation:": "De beskrivs i bilddokumentationen:",
|
||||
"They will be added to oci-compose before continuing. Unsupported or changed resources are not imported.": "De kommer att läggas till oci-compose innan du fortsätter. Resurser som inte stöds eller ändras importeras inte.",
|
||||
@@ -7137,6 +7151,7 @@
|
||||
"Unsupported mount type": "Unsupported Mount Typ",
|
||||
"Unsupported mount type:": "Unsupported Mount Typ:",
|
||||
"Unsupported native device type:": "Inbyggd enhetstyp som inte stöds:",
|
||||
"Unsupported network settings": "Nätverksinställningarna stöds inte",
|
||||
"Unsupported operation": "Unsupported operation",
|
||||
"Unsupported or unknown Proxmox version; no repository changed.": "Ostödd eller okänd version av Proxmox; inget arkiv har ändrats.",
|
||||
"Unsupported output format:": "Utdataformat som inte stöds:",
|
||||
|
||||
+5
-1
@@ -142,7 +142,7 @@ An application made of several images is one entry of the catalog and one instal
|
||||
| Operation | What changes | What is kept |
|
||||
|---|---|---|
|
||||
| **Update** | The rootfs is replaced by the image the saved tag publishes today | Record, container disks, host directories, network and devices |
|
||||
| **Modify** | An editor opens with the current resources, network, paths and GPU; the container is rebuilt with the changes | The data of container disks and host directories |
|
||||
| **Modify** | Edit and confirm the configuration; supported native settings apply directly, while other changes use the existing recreation flow | Native edits keep the installed image and rootfs; both paths preserve persistent data |
|
||||
| **Recreate** (multi-container) | Every container is rebuilt from the image it was installed with | Data, network and devices |
|
||||
| **Watchdog** | Whether the application is started again when it stops on its own | Everything; only the choice is saved |
|
||||
| **Remove** | The LXC, or every container of the application, its disks and its record | Host directories, with their content |
|
||||
@@ -159,6 +159,10 @@ Settings changed in Proxmox VE after the installation, such as memory, cores or
|
||||
|
||||
A rolling tag is not an unattended update: the digest changes only through an explicit update.
|
||||
|
||||
For standalone applications, CPU and RAM changes apply live. Supported network settings, environment overrides, directory mounts, additional managed volumes and native devices use a controlled stop/start when needed. Modify does not query the registry or download an image for these native edits, even if the cached archive has already been cleaned. Removing a managed mount detaches its disk without deleting its data; subsequent image updates park that unused disk and return it to the container. An optional retained backup does not force recreation.
|
||||
|
||||
The native path checks the saved configuration and installation identity, keeps a private before/after journal, and restores the original settings if applying them or checking the restarted service fails. It refuses to overwrite concurrent edits. Changes that require installer adaptations, such as NVIDIA runtime changes, GPU permission/environment setup, file mounts, completion notes or a refreshed catalog template, use the existing validated recreation flow. The route is chosen before applying any settings, so one proposal is not partially applied by both paths. The recreation flow reuses a verified cached archive when it matches the resolved image; it downloads an image when a suitable archive is unavailable. Image changes and coordinated stack operations retain their separate update flow.
|
||||
|
||||
## From a Compose file or a docker run command to an oci-compose
|
||||
|
||||
The converter reads a Compose file with one service. 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.
|
||||
|
||||
@@ -320,6 +320,36 @@ def mounts(config):
|
||||
return result
|
||||
|
||||
|
||||
def unused_volumes(config):
|
||||
return {key: value for key, value in parse_config(config).items()
|
||||
if re.fullmatch(r'unused[0-9]+', key)}
|
||||
|
||||
|
||||
def move_retained_unused(state, to_stage):
|
||||
"""Unused disks are not in vzdump. Park and return them separately, using
|
||||
PVE's volume reassignment API, never --delete (which frees an unused disk).
|
||||
The journal's keys and both ownership markers also make retries safe."""
|
||||
keys = set(state.get('retained_unused', {}))
|
||||
if not keys:
|
||||
return
|
||||
vmid, stage = state['vmid'], state['stage']
|
||||
guest_marker = state['record']['installation_id']
|
||||
holder_marker = 'proxmenux-transaction=' + state['id']
|
||||
source, target = (vmid, stage) if to_stage else (stage, vmid)
|
||||
source_marker, target_marker = ((guest_marker, holder_marker) if to_stage
|
||||
else (holder_marker, guest_marker))
|
||||
for key in sorted(keys):
|
||||
present = unused_volumes(owned(source, source_marker))
|
||||
destination = unused_volumes(owned(target, target_marker))
|
||||
if key not in present:
|
||||
if key in destination:
|
||||
continue # Already reassigned before an interruption.
|
||||
raise ValueError(translate('There are extra disks or bind mounts outside the journal; the rootfs is not replaced'))
|
||||
if key in destination or (to_stage and present.keys() - keys):
|
||||
raise ValueError(translate('There are extra disks or bind mounts outside the journal; the rootfs is not replaced'))
|
||||
run('pct', 'move-volume', str(source), key, '--target-vmid', str(target), '--target-volume', key)
|
||||
|
||||
|
||||
def is_file_bind(mount, files=()):
|
||||
return mount['type'] == 'host-bind' and (mount['source'] in files or Path(mount['source']).is_file())
|
||||
|
||||
@@ -473,7 +503,7 @@ def external_changes(record, config, adopt=True):
|
||||
return values
|
||||
|
||||
|
||||
def preflight(record, candidate, config, coordinated=None):
|
||||
def preflight(record, candidate, config, coordinated=None, in_place=False, baseline=True):
|
||||
deployment = record['deployment']
|
||||
desired = candidate['deployment']
|
||||
if coordinated and (record.get('native_stack_intent') or deployment.get('rootfs_adaptation_replay_required')):
|
||||
@@ -482,7 +512,7 @@ def preflight(record, candidate, config, coordinated=None):
|
||||
raise ValueError(translate('Stack members are updated together with their stack'))
|
||||
if instances.identity(config) != record['installation_id']:
|
||||
raise ValueError(translate('The container identity changed; the container is not replaced'))
|
||||
for key, value in external_changes(record, config, adopt=not coordinated).items():
|
||||
for key, value in (external_changes(record, config, adopt=not coordinated) if baseline else {}).items():
|
||||
section, name = ADOPTABLE[key]
|
||||
recorded = deployment.get(section, {}) if section else deployment
|
||||
target = desired.setdefault(section, {}) if section else desired
|
||||
@@ -511,7 +541,17 @@ def preflight(record, candidate, config, coordinated=None):
|
||||
runtime_keys.add('lxc.mount.entry')
|
||||
runtime_settings.check(config, deployment, record['vmid'])
|
||||
coordinated_keys = {'hookscript', *KEPT_AS_IS} if coordinated else set(KEPT_AS_IS)
|
||||
if '[' in config.decode() or keys - BASIC - runtime_keys - coordinated_keys - {k for k in keys if re.fullmatch(r'(mp|dev)[0-9]+', k)}:
|
||||
preserved_keys = {'nameserver', 'searchdomain', 'timezone', 'protection'} if in_place else set()
|
||||
detached = unused_volumes(config)
|
||||
if baseline and detached != unused_volumes(record['observed'].get('config', '').encode()):
|
||||
raise ValueError(translate('The container disks do not match the saved record'))
|
||||
used_volumes = {value.split(',', 1)[0] for key, value in cfg.items()
|
||||
if key == 'rootfs' or re.fullmatch(r'mp[0-9]+', key)}
|
||||
if (len(set(detached.values())) != len(detached)
|
||||
or any(not re.fullmatch(r'[A-Za-z0-9_.-]+:[^\s,]+', value) or value in used_volumes
|
||||
for value in detached.values())):
|
||||
raise ValueError(translate('The container disks do not match the saved record'))
|
||||
if '[' in config.decode() or keys - BASIC - runtime_keys - coordinated_keys - preserved_keys - {k for k in keys if re.fullmatch(r'(mp|dev|unused)[0-9]+', k)}:
|
||||
raise ValueError(translate('The container has advanced Proxmox settings outside the supported profile'))
|
||||
unprivileged = deployment.get('security', {}).get('unprivileged', True) is not False
|
||||
if (desired.get('security', {}).get('unprivileged', True) is not False) != unprivileged \
|
||||
@@ -552,7 +592,8 @@ def preflight(record, candidate, config, coordinated=None):
|
||||
raise ValueError(translate('Changing the rootfs or its storage requires a separate migration'))
|
||||
actual = mounts(config)
|
||||
gpu_devices.check(config, deployment)
|
||||
gpu_devices.verify_baseline(record['observed'].get('gpu_devices', {}), deployment)
|
||||
if baseline:
|
||||
gpu_devices.verify_baseline(record['observed'].get('gpu_devices', {}), deployment)
|
||||
recorded_files = {m['source']: bool(m.get('read_only')) for m in deployment.get('mounts', []) if is_file_bind(m, files)}
|
||||
if files != recorded_files:
|
||||
raise ValueError(translate('A container mount has a source, backup or permission different from the saved record'))
|
||||
@@ -567,7 +608,9 @@ def preflight(record, candidate, config, coordinated=None):
|
||||
or mount.get('ro', '0') != ('1' if old[target].get('read_only') else '0')):
|
||||
raise ValueError(translate('A container mount has a source, backup or permission different from the saved record'))
|
||||
for target in old.keys() & new.keys():
|
||||
if any(old[target].get(k) != new[target].get(k) for k in ('type', 'source', 'size_gb')):
|
||||
directory_bind = old[target]['type'] == new[target]['type'] == 'host-bind'
|
||||
fixed = ('type', 'size_gb') if directory_bind else ('type', 'source', 'size_gb')
|
||||
if any(old[target].get(k) != new[target].get(k) for k in fixed):
|
||||
raise ValueError(translate('Changing the storage or size of a disk requires a migration; empty disks are not created'))
|
||||
protected = ('/bin', '/sbin', '/etc', '/usr', '/lib', '/lib64', '/proc', '/sys', '/dev', '/run')
|
||||
for target in new.keys() - old.keys():
|
||||
@@ -1060,6 +1103,7 @@ def apply(root, vmid, archive, operation, proposal=None, registry_digest=None, i
|
||||
for p, m in actual.items() if p in required and not m['volume'].startswith('/')])
|
||||
state = {'schema_version': 1, 'id': directory.name, 'vmid': vmid, 'operation': operation,
|
||||
'record': record, 'candidate_contract': candidate, 'before_config': before.decode(),
|
||||
'retained_unused': unused_volumes(before),
|
||||
'original_description': description,
|
||||
'archive': str(archive), 'archive_sha256': filehash(archive),
|
||||
'registry_digest': registry_digest or image['manifest_digest'],
|
||||
@@ -1137,6 +1181,7 @@ def apply(root, vmid, archive, operation, proposal=None, registry_digest=None, i
|
||||
else:
|
||||
owned(stage, 'proxmenux-transaction=' + state['id'])
|
||||
run('pct', 'move-volume', str(vmid), mount['key'], '--target-vmid', str(stage), '--target-volume', mount['key'])
|
||||
move_retained_unused(state, to_stage=True)
|
||||
checkpoint(journal, state, 'data-parked')
|
||||
if show:
|
||||
msg_ok(translate('Data volumes protected'))
|
||||
@@ -1146,8 +1191,8 @@ def apply(root, vmid, archive, operation, proposal=None, registry_digest=None, i
|
||||
msg_info(translate('Installing the new image...') if update else translate('Recreating the container...'))
|
||||
current = owned(vmid, record['installation_id'])
|
||||
expected = b''.join(line for line in before.splitlines(keepends=True)
|
||||
if not re.match(rb'mp[0-9]+: ', line))
|
||||
if mounts(current) or current != expected:
|
||||
if not re.match(rb'(mp|unused)[0-9]+: ', line))
|
||||
if mounts(current) or unused_volumes(current) or current != expected:
|
||||
raise ValueError(translate('A concurrent change was detected; the container is not removed'))
|
||||
host_mounts.verify_sources(original_sources)
|
||||
gpu_devices.verify(original_gpu)
|
||||
@@ -1162,6 +1207,7 @@ def apply(root, vmid, archive, operation, proposal=None, registry_digest=None, i
|
||||
install_candidate(root, journal, state, progress)
|
||||
restore_description(vmid, state)
|
||||
restore_firewall(vmid, state)
|
||||
move_retained_unused(state, to_stage=False)
|
||||
if coordinated:
|
||||
for key, value in state['preserved_stack_config'].items():
|
||||
if key == 'tags' and not value:
|
||||
@@ -1299,6 +1345,9 @@ def complete_recovery(root, journal, state):
|
||||
|
||||
def recover(root, journal):
|
||||
state = json.loads(journal.read_text())
|
||||
if state.get('kind') == 'native-modify':
|
||||
import oci_native_modify
|
||||
return oci_native_modify.recover(root, journal)
|
||||
vmid = state['vmid']
|
||||
show = not state.get('coordinated')
|
||||
if show:
|
||||
@@ -1361,8 +1410,9 @@ def recover(root, journal):
|
||||
stop(vmid)
|
||||
current = owned(vmid, record['installation_id'])
|
||||
gpu_devices.check_recovery_entries(current, state)
|
||||
if any(re.fullmatch(r'unused[0-9]+', k) for k in parse_config(current)):
|
||||
if unused_volumes(current).keys() - state.get('retained_unused', {}).keys():
|
||||
raise ValueError(translate('There are extra disks or bind mounts outside the journal; the rootfs is not replaced'))
|
||||
move_retained_unused(state, to_stage=True)
|
||||
known_binds = {(m['source'], m['container_path'])
|
||||
for plan in (state['record']['deployment'], state['candidate_contract']['deployment'])
|
||||
for m in plan.get('mounts', []) if m['type'] == 'host-bind'}
|
||||
@@ -1390,6 +1440,7 @@ def recover(root, journal):
|
||||
host_mounts.verify_sources(state.get('original_host_sources', {}))
|
||||
run('pct', 'restore', str(vmid), backup, '--force', '1', '--storage',
|
||||
state['record']['deployment']['rootfs']['storage'])
|
||||
move_retained_unused(state, to_stage=False)
|
||||
state['restore_config_sha256'] = sha(owned(vmid, state['record']['installation_id']))
|
||||
checkpoint(journal, state, 'checking-recovery')
|
||||
if show:
|
||||
|
||||
@@ -0,0 +1,426 @@
|
||||
"""Edit an installed application's LXC configuration, retaining its rootfs/data.
|
||||
|
||||
Runtime changes use a controlled stop/start, not pct destroy/create. Values,
|
||||
including secrets and NUL-separated environment, reach PVE over stdin only.
|
||||
"""
|
||||
import copy
|
||||
import hashlib
|
||||
import ipaddress
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import uuid
|
||||
|
||||
import oci_instances as instances
|
||||
import oci_instance_transaction as transaction
|
||||
import oci_resource_modify as resources
|
||||
import oci_gpu_devices as devices
|
||||
import oci_host_mounts as host_mounts
|
||||
from oci_installation_state import parse_config, sha
|
||||
from oci_ui import translate, msg_info, msg_ok, msg_warn
|
||||
|
||||
PERL_UPDATE = r'''
|
||||
use strict; use warnings; use JSON::PP;
|
||||
use PVE::RPCEnvironment; use PVE::INotify; use PVE::API2::LXC::Config;
|
||||
PVE::RPCEnvironment->setup_default_cli_env();
|
||||
my $p = decode_json(do { local $/; <STDIN> });
|
||||
$p->{node} = PVE::INotify::nodename();
|
||||
PVE::API2::LXC::Config->update_vm($p);
|
||||
'''
|
||||
|
||||
|
||||
def environment(config):
|
||||
result = {}
|
||||
entries = []
|
||||
for line in config.decode().splitlines():
|
||||
if line.startswith('lxc.environment.runtime: '):
|
||||
entries.append(line.split(': ', 1)[1])
|
||||
elif line.startswith('env: '):
|
||||
# pct config exposes PVE's public env property, whereas older
|
||||
# observations and native files contain repeated runtime lines.
|
||||
entries.extend(line.split(': ', 1)[1].split('\0'))
|
||||
for entry in entries:
|
||||
name, sep, value = entry.partition('=')
|
||||
if not sep or name in result:
|
||||
raise ValueError(translate('Duplicate variable in the contract; review it before editing'))
|
||||
result[name] = value
|
||||
return result
|
||||
|
||||
|
||||
def environment_values(items):
|
||||
result = {}
|
||||
for item in items:
|
||||
name, value = item['name'], str(item['value'])
|
||||
if (not re.fullmatch(r'[A-Za-z_][A-Za-z0-9_]*', name) or name in result
|
||||
or any(ord(c) < 32 or ord(c) == 127 for c in value)):
|
||||
raise ValueError(translate('The value contains an unsupported character'))
|
||||
result[name] = value
|
||||
return result
|
||||
|
||||
|
||||
def changes(record, candidate, config):
|
||||
"""Only unchanged recipes and native settings; adapters keep their installer."""
|
||||
old = transaction.with_default_healthcheck(copy.deepcopy(record))
|
||||
candidate = transaction.with_default_healthcheck(copy.deepcopy(candidate))
|
||||
if (old['template'] != candidate['template'] or old['deployment'].get('host_monitor')
|
||||
or any(k in record for k in ('stack', 'stack_member', 'native_stack_intent'))
|
||||
or old['deployment'].get('stack_managed')):
|
||||
return None
|
||||
before, after = old['deployment'], candidate['deployment']
|
||||
changed = {k for k in before.keys() | after.keys() if before.get(k) != after.get(k)}
|
||||
allowed = {'resources', 'network', 'environment', 'devices', 'mounts', 'hardware_profile'}
|
||||
if not changed or changed - allowed:
|
||||
return None
|
||||
resource_keys = {k for k in before.get('resources', {}).keys() | after.get('resources', {}).keys()
|
||||
if before.get('resources', {}).get(k) != after.get('resources', {}).get(k)}
|
||||
if resource_keys - {'cores', 'memory_mb', 'swap_mb', 'cpu_units'}:
|
||||
return None
|
||||
if 'devices' in changed:
|
||||
if any(d.get('kind', 'character-device') not in ('character-device', 'block-device')
|
||||
or d.get('append_host_device_gid_to_environment')
|
||||
for plan in (before, after) for d in plan.get('devices', [])):
|
||||
return None
|
||||
if before.get('device_permissions') or after.get('device_permissions'):
|
||||
return None
|
||||
if 'mounts' in changed:
|
||||
if any(transaction.is_file_bind(m) for plan in (before, after) for m in plan.get('mounts', [])):
|
||||
return None
|
||||
if transaction.nested_mounts.without(config) != config:
|
||||
# The installer also regenerates the recursive bind entries and
|
||||
# hooks associated with these mounts.
|
||||
return None
|
||||
old_mounts = {m['container_path']: m for m in before.get('mounts', [])}
|
||||
for mount in after.get('mounts', []):
|
||||
original = old_mounts.get(mount['container_path'])
|
||||
# Permission grants and installer-specific mount attributes need
|
||||
# their normal installer, including when only their source changes.
|
||||
if mount.get('grant_access'):
|
||||
return None
|
||||
if original:
|
||||
fields = {k for k in original.keys() | mount.keys() if original.get(k) != mount.get(k)}
|
||||
native_fields = {'read_only', 'backup', 'create_if_missing'}
|
||||
if original['type'] == mount['type'] == 'host-bind':
|
||||
native_fields.add('source')
|
||||
if fields - native_fields:
|
||||
return None
|
||||
return changed
|
||||
|
||||
|
||||
def plan(record, candidate, config):
|
||||
changed = changes(record, candidate, config)
|
||||
if not changed:
|
||||
return None
|
||||
transaction.preflight(record, candidate, config, in_place=True)
|
||||
old, new = record['deployment'], candidate['deployment']
|
||||
cfg = parse_config(config)
|
||||
values, delete = {}, []
|
||||
if 'resources' in changed:
|
||||
for key, native, minimum in (('cores', 'cpulimit' if 'cpulimit' in cfg and 'cores' not in cfg else 'cores', 1),
|
||||
('memory_mb', 'memory', 128), ('swap_mb', 'swap', 0), ('cpu_units', 'cpuunits', 1)):
|
||||
value = new['resources'].get(key)
|
||||
if old['resources'].get(key) == value:
|
||||
continue
|
||||
if value is None and key == 'cpu_units':
|
||||
if native in cfg: delete.append(native)
|
||||
elif isinstance(value, bool) or not isinstance(value, int) or value < minimum:
|
||||
raise ValueError(translate('Invalid resource value'))
|
||||
else:
|
||||
values[native] = str(value)
|
||||
if 'network' in changed:
|
||||
network = new['network']
|
||||
if set(network) - {'bridge', 'ipv4', 'gateway', 'firewall', 'host_managed', 'mac_address'}:
|
||||
raise ValueError(translate('Unsupported network settings'))
|
||||
if not re.fullmatch(r'[A-Za-z0-9_.:-]+', network['bridge']):
|
||||
raise ValueError(translate('Invalid bridge'))
|
||||
if not Path('/sys/class/net', network['bridge'], 'bridge').is_dir():
|
||||
raise ValueError(translate('The selected bridge does not exist'))
|
||||
address = network['ipv4']
|
||||
if address not in ('dhcp', 'manual'):
|
||||
if ipaddress.ip_interface(address).version != 4:
|
||||
raise ValueError(translate('Invalid IPv4 address'))
|
||||
gateway = network.get('gateway')
|
||||
if gateway and ipaddress.ip_address(gateway).version != 4:
|
||||
raise ValueError(translate('Invalid IPv4 gateway'))
|
||||
fields = dict(p.split('=', 1) for p in cfg['net0'].split(',') if '=' in p)
|
||||
fields.update(bridge=network['bridge'], ip=address)
|
||||
if gateway: fields['gw'] = gateway
|
||||
else: fields.pop('gw', None)
|
||||
for key, native in (('firewall', 'firewall'), ('host_managed', 'host-managed')):
|
||||
if old['network'].get(key) != network.get(key):
|
||||
fields[native] = '1' if network.get(key) else '0'
|
||||
if old['network'].get('mac_address') != network.get('mac_address'):
|
||||
raise ValueError(translate('The MAC address of the container cannot be kept'))
|
||||
values['net0'] = ','.join(k+'='+v for k, v in fields.items())
|
||||
if 'environment' in changed:
|
||||
env = environment(config)
|
||||
previous = environment_values(old.get('environment', []))
|
||||
wanted = environment_values(new.get('environment', []))
|
||||
defaults = dict(item.split('=', 1) for item in record['observed']['image'].get('defaults', {}).get('Env', []) if '=' in item)
|
||||
for name in previous.keys() - wanted.keys():
|
||||
if name in defaults: env[name] = defaults[name]
|
||||
else: env.pop(name, None)
|
||||
env.update(wanted)
|
||||
if env: values['env'] = '\0'.join(k+'='+v for k, v in env.items())
|
||||
else: delete.append('env')
|
||||
if 'devices' in changed:
|
||||
inventory = devices.planned(new)
|
||||
existing = {v['path']: k for k, v in ((k, dict(p.split('=', 1) for p in v.split(',')))
|
||||
for k, v in cfg.items() if re.fullmatch(r'dev[0-9]+', k))}
|
||||
used = set(existing.values())
|
||||
for path, key in existing.items():
|
||||
if path not in inventory: delete.append(key)
|
||||
for device in new.get('devices', []):
|
||||
path = device['host_path']
|
||||
key = existing.get(path)
|
||||
if not key:
|
||||
key = next('dev'+str(n) for n in range(256) if 'dev'+str(n) not in used)
|
||||
used.add(key)
|
||||
info = inventory[path]
|
||||
mode = info['mode'] if device.get('mode') == 'preserve-host' else int(device.get('mode', '0660'), 8)
|
||||
values[key] = (f"path={path},mode={mode:04o},uid={int(device.get('uid', 0))},"
|
||||
f"gid={info['gid'] if device['gid_strategy'] == 'host-device-gid' else 0},"
|
||||
f"deny-write={1 if device.get('deny_write') else 0}")
|
||||
if 'mounts' in changed:
|
||||
actual = transaction.mounts(config)
|
||||
used = {m['key'] for m in actual.values()}
|
||||
wanted = {m['container_path']: m for m in new.get('mounts', [])}
|
||||
for target, mount in actual.items():
|
||||
if target not in wanted: delete.append(mount['key'])
|
||||
for target, mount in wanted.items():
|
||||
if target in actual:
|
||||
original = next(m for m in old['mounts'] if m['container_path'] == target)
|
||||
if mount == original: continue
|
||||
fields = dict(p.split('=', 1) for p in actual[target]['value'].split(',')[1:] if '=' in p)
|
||||
source = actual[target]['volume']
|
||||
if mount['type'] == 'host-bind':
|
||||
source = host_mounts.valid_path(mount['source'])
|
||||
if not Path(source).is_dir() and not mount.get('create_if_missing'):
|
||||
raise ValueError(translate('The host directory does not exist'))
|
||||
fields.update(mp=target, backup='1' if mount['backup'] else '0',
|
||||
ro='1' if mount.get('read_only') else '0')
|
||||
values[actual[target]['key']] = source+','+','.join(k+'='+v for k, v in fields.items())
|
||||
continue
|
||||
key = next('mp'+str(n) for n in range(256) if 'mp'+str(n) not in used)
|
||||
used.add(key)
|
||||
source = mount['source']
|
||||
if mount['type'] == 'managed-volume':
|
||||
if not re.fullmatch(r'[A-Za-z0-9_-]+', source):
|
||||
raise ValueError(translate('Invalid storage'))
|
||||
source += ':'+str(mount['size_gb'])
|
||||
else:
|
||||
source = host_mounts.valid_path(source)
|
||||
if not Path(source).is_dir():
|
||||
if not mount.get('create_if_missing'):
|
||||
raise ValueError(translate('The host directory does not exist'))
|
||||
values[key] = (f"{source},mp={target},backup={1 if mount['backup'] else 0},"
|
||||
f"ro={1 if mount.get('read_only') else 0}")
|
||||
return {'values': values, 'delete': delete, 'restart': bool(changed - {'resources'}),
|
||||
'changed': sorted(changed)}
|
||||
|
||||
|
||||
def verify_values(config, operation):
|
||||
"""PVE may canonicalize property strings; compare their actual fields."""
|
||||
cfg = parse_config(config)
|
||||
for key, value in operation['values'].items():
|
||||
if key == 'env':
|
||||
if environment(config) != dict(item.split('=', 1) for item in value.split('\0')):
|
||||
raise ValueError(translate('Proxmox could not apply the container settings'))
|
||||
elif re.fullmatch(r'(mp|dev|net)[0-9]+', key):
|
||||
if re.fullmatch(r'mp[0-9]+', key):
|
||||
source = value.split(',', 1)[0]
|
||||
actual_source = cfg.get(key, '').split(',', 1)[0]
|
||||
allocating = re.fullmatch(r'[A-Za-z0-9_-]+:[0-9]+', source)
|
||||
if (not allocating and actual_source != source) or (allocating and not actual_source.startswith(source.split(':')[0]+':')):
|
||||
raise ValueError(translate('Proxmox could not apply the container settings'))
|
||||
desired = dict(p.split('=', 1) for p in value.split(',') if '=' in p)
|
||||
actual = dict(p.split('=', 1) for p in cfg.get(key, '').split(',') if '=' in p)
|
||||
defaults = {'uid': '0', 'gid': '0', 'deny-write': '0', 'ro': '0', 'backup': '0', 'mode': '0660', 'firewall': '0'}
|
||||
def matches(k, v):
|
||||
observed = actual.get(k, defaults.get(k))
|
||||
if k == 'mode': return observed is not None and int(observed, 8) == int(v, 8)
|
||||
return observed == v
|
||||
if any(not matches(k, v) for k, v in desired.items()):
|
||||
raise ValueError(translate('Proxmox could not apply the container settings'))
|
||||
elif cfg.get(key) != value:
|
||||
raise ValueError(translate('Proxmox could not apply the container settings'))
|
||||
if any(key in cfg or (key == 'env' and environment(config)) for key in operation['delete']):
|
||||
raise ValueError(translate('Proxmox could not apply the container settings'))
|
||||
|
||||
|
||||
def set_config(vmid, installation_id, expected, values, delete=()):
|
||||
raw = (resources.CONFIG_DIR / f'{vmid}.conf').read_bytes()
|
||||
if instances.command('pct', 'config', str(vmid)) != expected or instances.identity(expected) != installation_id:
|
||||
raise ValueError(translate('The instance changed while it was being edited; open Modify again'))
|
||||
payload = dict(values, vmid=vmid, digest=hashlib.sha1(raw).hexdigest())
|
||||
if delete: payload['delete'] = ','.join(delete)
|
||||
transaction.log('Applying native settings: '+', '.join(sorted(set(values) | set(delete))))
|
||||
process = subprocess.run(['perl', '-e', PERL_UPDATE], input=json.dumps(payload).encode(),
|
||||
capture_output=True, timeout=120)
|
||||
# API errors may include secret values. Only keep the exit status.
|
||||
if process.returncode:
|
||||
raise RuntimeError(translate('Proxmox could not apply the container settings'))
|
||||
|
||||
|
||||
def untouched(before, after, keys):
|
||||
keys = set(keys)
|
||||
if 'env' in keys: keys.add('lxc.environment.runtime')
|
||||
def keep(config):
|
||||
return b''.join(line for line in config.splitlines(keepends=True)
|
||||
if line.split(b':', 1)[0].decode() not in keys
|
||||
and not re.match(rb'unused[0-9]+:', line))
|
||||
return keep(before) == keep(after)
|
||||
|
||||
|
||||
def restore_settings(root, record, before, now, operation):
|
||||
vmid = record['vmid']
|
||||
keys = set(operation['values']) | set(operation['delete'])
|
||||
if operation['restart']: transaction.stop(vmid)
|
||||
original = parse_config(before)
|
||||
restore = {k: original[k] for k in keys if k in original and k != 'env'}
|
||||
remove = [k for k in keys if k not in original and k != 'env' and k in parse_config(now)]
|
||||
if 'env' in keys:
|
||||
env = environment(before)
|
||||
if env: restore['env'] = '\0'.join(k+'='+v for k, v in env.items())
|
||||
elif environment(now): remove.append('env')
|
||||
if restore or remove:
|
||||
set_config(vmid, record['installation_id'], now, restore, remove)
|
||||
restored_config = instances.command('pct', 'config', str(vmid))
|
||||
verify_values(restored_config, {'values': restore, 'delete': remove})
|
||||
if not untouched(before, restored_config, keys):
|
||||
raise ValueError(translate('The instance changed while it was being edited; open Modify again'))
|
||||
restored = copy.deepcopy(record)
|
||||
obs = record['observed']
|
||||
restored['observed'] = instances.observe(vmid, record['installation_id'],
|
||||
obs['archive_path'], obs['resolved_registry_digest'], obs['image'])
|
||||
if restored['observed']['config_sha256'] != sha(restored_config):
|
||||
raise ValueError(translate('The instance changed while it was being edited; open Modify again'))
|
||||
return restored
|
||||
|
||||
|
||||
def recover(root, journal):
|
||||
"""Recover through the normal Manage interrupted-operation menu."""
|
||||
state = json.loads(journal.read_text())
|
||||
record, operation = state['record'], state['operation_plan']
|
||||
vmid = record['vmid']
|
||||
current = instances.read(root, vmid)
|
||||
if (state['kind'] != 'native-modify' or state['phase'] in ('committed', 'rolled-back')
|
||||
or current.get('pending_transaction') != str(journal)
|
||||
or current.get('transaction_id') != state['id']
|
||||
or current['installation_id'] != record['installation_id']):
|
||||
raise ValueError(translate('The record does not belong to this operation'))
|
||||
before = state['config'].encode()
|
||||
now = instances.command('pct', 'config', str(vmid))
|
||||
keys = set(operation['values']) | set(operation['delete'])
|
||||
if instances.identity(now) != record['installation_id'] or not untouched(before, now, keys):
|
||||
raise ValueError(translate('The instance changed while it was being edited; open Modify again'))
|
||||
if state.get('after_config'):
|
||||
if now not in (before, state['after_config'].encode()):
|
||||
raise ValueError(translate('The instance changed while it was being edited; open Modify again'))
|
||||
elif now != before:
|
||||
# A crash during the PVE API call is recoverable only when the full
|
||||
# planned edit is present. Refuse unknown partial/concurrent changes.
|
||||
verify_values(now, operation)
|
||||
host_mounts.verify_sources(state.get('original_host_sources', {}))
|
||||
tx_record = restore_settings(root, record, before, now, operation) if now != before else copy.deepcopy(record)
|
||||
if state['was_running']:
|
||||
if transaction.run('pct', 'status', str(vmid)).strip() != b'status: running':
|
||||
transaction.run('pct', 'start', str(vmid))
|
||||
transaction.healthcheck(vmid, record['template'])
|
||||
instances.write(instances.location(root, vmid), tx_record)
|
||||
state['phase'] = 'rolled-back'
|
||||
instances.write(journal, state)
|
||||
msg_ok(translate('Previous installation restored'))
|
||||
|
||||
|
||||
def apply(root, record, candidate, config, acknowledge_external_data=False):
|
||||
operation = plan(record, candidate, config)
|
||||
if not operation:
|
||||
raise ValueError(translate('The changes need the application installer'))
|
||||
original_sources, desired_sources = transaction.freeze_host_sources(
|
||||
record, candidate, acknowledge_external_data)
|
||||
vmid = record['vmid']
|
||||
folder = instances.location(root, vmid).parent / 'modifications' / uuid.uuid4().hex
|
||||
running = transaction.run('pct', 'status', str(vmid)).strip() == b'status: running'
|
||||
journal = {'kind': 'native-modify', 'id': folder.name, 'vmid': vmid, 'operation': 'recreate',
|
||||
'record': record, 'config': config.decode(), 'operation_plan': operation,
|
||||
'phase': 'prepared', 'was_running': running, 'original_host_sources': original_sources}
|
||||
instances.write(folder / 'before.json', journal)
|
||||
transaction.open_log(folder)
|
||||
transaction._run['journal'] = folder / 'before.json'
|
||||
pending = copy.deepcopy(record)
|
||||
pending.update(status='updating', transaction_id=folder.name, pending_transaction=str(folder / 'before.json'))
|
||||
instances.write(instances.location(root, vmid), pending)
|
||||
updated_config = None
|
||||
changed = False
|
||||
try:
|
||||
if operation['restart'] and running:
|
||||
transaction.stop(vmid)
|
||||
# Do not accept a concurrent edit merely because the service stopped.
|
||||
if instances.command('pct', 'config', str(vmid)) != config:
|
||||
raise ValueError(translate('The instance changed while it was being edited; open Modify again'))
|
||||
host_mounts.verify_sources(desired_sources)
|
||||
for mount in candidate['deployment'].get('mounts', []):
|
||||
if mount['type'] == 'host-bind' and mount.get('create_if_missing'):
|
||||
Path(mount['source']).mkdir(parents=True, exist_ok=True)
|
||||
host_mounts.verify_sources(original_sources)
|
||||
# Capture newly-created directories before installing their mounts.
|
||||
pinned = {p: host_mounts.validate_source(p) for p in desired_sources}
|
||||
for path, previous in desired_sources.items():
|
||||
if previous['exists'] and not host_mounts.same_source(previous, pinned[path]):
|
||||
raise ValueError(translate('A shared source does not match its recorded identity'))
|
||||
host_mounts.verify_sources(pinned)
|
||||
msg_info(translate('Applying container settings without rebuilding the container...'))
|
||||
if operation['values'] or operation['delete']:
|
||||
changed = True # API updates may partially succeed before an error.
|
||||
set_config(vmid, record['installation_id'], config, operation['values'], operation['delete'])
|
||||
updated_config = instances.command('pct', 'config', str(vmid))
|
||||
verify_values(updated_config, operation)
|
||||
keys = set(operation['values']) | set(operation['delete'])
|
||||
if not untouched(config, updated_config, keys):
|
||||
raise ValueError(translate('The instance changed while it was being edited; open Modify again'))
|
||||
transaction.preflight(candidate, candidate, updated_config, in_place=True, baseline=False)
|
||||
journal.update(phase='configured', after_config=updated_config.decode())
|
||||
instances.write(folder / 'before.json', journal)
|
||||
if operation['restart'] and running:
|
||||
transaction.run('pct', 'start', str(vmid))
|
||||
transaction.healthcheck(vmid, candidate['template'])
|
||||
observed = record['observed']
|
||||
fresh = instances.observe(vmid, record['installation_id'], observed['archive_path'],
|
||||
observed['resolved_registry_digest'], observed['image'])
|
||||
host_mounts.verify_observation(pinned, fresh)
|
||||
if fresh['config_sha256'] != sha(updated_config):
|
||||
raise ValueError(translate('The instance changed while it was being edited; open Modify again'))
|
||||
result = copy.deepcopy(record)
|
||||
result.update(deployment=copy.deepcopy(candidate['deployment']), observed=fresh)
|
||||
instances.write(instances.location(root, vmid), result)
|
||||
journal['phase'] = 'committed'
|
||||
instances.write(folder / 'before.json', journal)
|
||||
except BaseException:
|
||||
try:
|
||||
now = instances.command('pct', 'config', str(vmid))
|
||||
keys = set(operation['values']) | set(operation['delete'])
|
||||
if changed:
|
||||
if not untouched(config, now, keys) or (updated_config is not None and now != updated_config):
|
||||
raise ValueError('Concurrent edit; rollback refused')
|
||||
restored = restore_settings(root, record, config, now, operation)
|
||||
else:
|
||||
restored = copy.deepcopy(record)
|
||||
if operation['restart'] and running:
|
||||
if transaction.run('pct', 'status', str(vmid)).strip() != b'status: running':
|
||||
transaction.run('pct', 'start', str(vmid))
|
||||
transaction.healthcheck(vmid, record['template'])
|
||||
instances.write(instances.location(root, vmid), restored)
|
||||
journal['phase'] = 'rolled-back'
|
||||
instances.write(folder / 'before.json', journal)
|
||||
except Exception as error:
|
||||
transaction.log('native rollback failed: '+type(error).__name__)
|
||||
msg_warn(translate('The settings could not be fully rolled back. Review the container and its saved modification before continuing.'))
|
||||
raise
|
||||
try:
|
||||
import oci_carried_record
|
||||
oci_carried_record.carry(root, vmid, mount_stopped=False)
|
||||
except (OSError, ValueError, RuntimeError):
|
||||
transaction.log('carried record will be refreshed on the next management entry')
|
||||
msg_ok(translate('Changes applied. Data kept.'))
|
||||
return folder
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Apply CPU/RAM-only proposals to the existing guest, without an image archive."""
|
||||
import copy
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
import uuid
|
||||
|
||||
import oci_instances as instances
|
||||
import oci_instance_transaction as transaction
|
||||
from oci_installation_state import parse_config, sha
|
||||
from oci_ui import translate, msg_info, msg_ok, msg_warn
|
||||
|
||||
CONFIG_DIR = Path('/etc/pve/lxc')
|
||||
|
||||
|
||||
def changes(record, candidate, config):
|
||||
"""Return a narrow native edit, or None when the normal installer is needed."""
|
||||
if (any(key in record for key in ('stack', 'stack_member', 'native_stack_intent'))
|
||||
or record['deployment'].get('stack_managed')
|
||||
or instances.identity(config) != record['installation_id']):
|
||||
return None
|
||||
previous = transaction.with_default_healthcheck(copy.deepcopy(record))
|
||||
candidate = transaction.with_default_healthcheck(copy.deepcopy(candidate))
|
||||
old, new = previous['deployment'], candidate['deployment']
|
||||
if previous['template'] != candidate['template'] or old.get('host_monitor'):
|
||||
return None
|
||||
if {k: v for k, v in old.items() if k != 'resources'} != {
|
||||
k: v for k, v in new.items() if k != 'resources'}:
|
||||
return None
|
||||
original, desired = old.get('resources', {}), new.get('resources', {})
|
||||
changed = {key for key in original.keys() | desired.keys() if original.get(key) != desired.get(key)}
|
||||
if not changed or changed - {'cores', 'memory_mb'}:
|
||||
return None
|
||||
cfg = parse_config(config)
|
||||
cpu = 'cpulimit' if 'cpulimit' in cfg and 'cores' not in cfg else 'cores'
|
||||
parameters = {}
|
||||
for key in sorted(changed):
|
||||
value = desired.get(key)
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value < (128 if key == 'memory_mb' else 1):
|
||||
return None
|
||||
native = 'memory' if key == 'memory_mb' else cpu
|
||||
if native not in cfg:
|
||||
return None
|
||||
parameters[native] = str(value)
|
||||
return parameters
|
||||
|
||||
|
||||
def guarded_set(vmid, installation_id, expected, parameters):
|
||||
# pct uses this SHA1 under its own configuration lock. Sample the native
|
||||
# file before checking pct's view, so a concurrent edit cannot be accepted
|
||||
# accidentally by taking a fresh digest after validation.
|
||||
native = (CONFIG_DIR / f'{vmid}.conf').read_bytes()
|
||||
current = instances.command('pct', 'config', str(vmid))
|
||||
if current != expected or instances.identity(current) != installation_id:
|
||||
raise ValueError(translate('The instance changed while it was being edited; open Modify again'))
|
||||
command = ['pct', 'set', str(vmid), '--digest', hashlib.sha1(native).hexdigest()]
|
||||
for key, value in sorted(parameters.items()):
|
||||
command += ['--' + key, value]
|
||||
transaction.run(*command)
|
||||
|
||||
|
||||
def expected_config(before, after, parameters):
|
||||
def unrelated(config):
|
||||
return b''.join(line for line in config.splitlines(keepends=True)
|
||||
if line.split(b':', 1)[0].decode() not in parameters)
|
||||
new = parse_config(after)
|
||||
return unrelated(before) == unrelated(after) and all(new.get(key) == value for key, value in parameters.items())
|
||||
|
||||
|
||||
def apply(root, record, candidate, config, parameters):
|
||||
"""The caller holds the instance lock and has checked image identity/preflight."""
|
||||
if changes(record, candidate, config) != parameters:
|
||||
raise ValueError(translate('The instance changed while it was being edited; open Modify again'))
|
||||
if transaction.external_changes(record, config):
|
||||
raise ValueError(translate('The instance changed while it was being edited; open Modify again'))
|
||||
vmid = record['vmid']
|
||||
directory = instances.location(root, vmid).parent / 'modifications' / uuid.uuid4().hex
|
||||
instances.write(directory / 'before.json', {'record': record, 'config': config.decode()})
|
||||
transaction.open_log(directory)
|
||||
msg_info(translate('Applying CPU and RAM changes without rebuilding the container...'))
|
||||
applied = False
|
||||
after = None
|
||||
try:
|
||||
guarded_set(vmid, record['installation_id'], config, parameters)
|
||||
applied = True
|
||||
after = instances.command('pct', 'config', str(vmid))
|
||||
if not expected_config(config, after, parameters):
|
||||
raise ValueError(translate('The instance changed while it was being edited; open Modify again'))
|
||||
observed = record['observed']
|
||||
fresh = instances.observe(vmid, record['installation_id'], observed['archive_path'],
|
||||
observed['resolved_registry_digest'], observed['image'])
|
||||
if fresh['config_sha256'] != sha(after):
|
||||
raise ValueError(translate('The instance changed while it was being edited; open Modify again'))
|
||||
updated = copy.deepcopy(record)
|
||||
updated.update(deployment=copy.deepcopy(candidate['deployment']), observed=fresh)
|
||||
instances.write(instances.location(root, vmid), updated)
|
||||
except Exception:
|
||||
if applied:
|
||||
try:
|
||||
current = instances.command('pct', 'config', str(vmid))
|
||||
if not expected_config(config, current, parameters):
|
||||
raise ValueError('Concurrent configuration change; resource rollback refused')
|
||||
previous = parse_config(config)
|
||||
guarded_set(vmid, record['installation_id'], current,
|
||||
{key: previous[key] for key in parameters})
|
||||
instances.write(instances.location(root, vmid), record)
|
||||
except Exception as error:
|
||||
transaction.log(f'resource rollback: {type(error).__name__}')
|
||||
msg_warn(translate('The resource edit could not be fully rolled back. Open Manage again to review and import the current Proxmox settings.'))
|
||||
raise
|
||||
# Updating the carried record is best effort here, as it is after other
|
||||
# management operations. The normal menu also refreshes it on return.
|
||||
try:
|
||||
import oci_carried_record
|
||||
oci_carried_record.carry(root, vmid, mount_stopped=False)
|
||||
except (OSError, ValueError, RuntimeError):
|
||||
transaction.log('carried record will be refreshed on the next management entry')
|
||||
msg_ok(translate('Changes applied. Data kept.'))
|
||||
return directory
|
||||
@@ -8,6 +8,7 @@ import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
|
||||
import oci_instances as instances
|
||||
@@ -56,7 +57,7 @@ def run_quiet(args, error, capture=False):
|
||||
process.wait()
|
||||
|
||||
|
||||
def resolve_archive(desired, config, current=None, check=None, required_digest=None):
|
||||
def resolve_archive(desired, config, current=None, check=None, required_digest=None, local_archives=()):
|
||||
# Shared by individual and coordinated operations; no guest mutation here.
|
||||
# When the registry still serves the current digest nothing is downloaded.
|
||||
reference = desired['template']['container_contract']['image']['reference']
|
||||
@@ -83,6 +84,27 @@ def resolve_archive(desired, config, current=None, check=None, required_digest=N
|
||||
return None, digest
|
||||
if check:
|
||||
check()
|
||||
verifier = Path(__file__).with_name('verify_oci_archive.py')
|
||||
|
||||
def intact(path):
|
||||
try:
|
||||
run_quiet([sys.executable, str(verifier), str(path)],
|
||||
translate('The image did not pass the integrity check'))
|
||||
return same_image(candidate, image_from_archive(str(path)))
|
||||
except (RuntimeError, OSError, ValueError, KeyError, tarfile.TarError):
|
||||
return False
|
||||
|
||||
# Initial installations and updates use different archive names. The
|
||||
# instance record knows the actual archive; validate it before looking
|
||||
# only for the deterministic update-cache name. Never delete a rejected
|
||||
# recorded path: another instance may still refer to it.
|
||||
for saved in local_archives:
|
||||
path = Path(saved) if saved else None
|
||||
if path and not path.is_symlink() and path.is_file():
|
||||
msg_info(translate('Verifying the image integrity...'))
|
||||
if intact(path):
|
||||
msg_ok(translate('Using the verified image from the cache'))
|
||||
return path, digest
|
||||
storage = desired['deployment']['template_storage']
|
||||
if not re.fullmatch(r'[A-Za-z0-9_-]+', storage):
|
||||
raise ValueError(translate('Invalid template storage'))
|
||||
@@ -91,16 +113,6 @@ def resolve_archive(desired, config, current=None, check=None, required_digest=N
|
||||
archive.parent.mkdir(parents=True, exist_ok=True)
|
||||
if archive.is_symlink():
|
||||
raise ValueError(translate('Unsafe OCI archive path'))
|
||||
verifier = Path(__file__).with_name('verify_oci_archive.py')
|
||||
|
||||
def intact(path):
|
||||
try:
|
||||
run_quiet([sys.executable, str(verifier), str(path)],
|
||||
translate('The image did not pass the integrity check'))
|
||||
except RuntimeError:
|
||||
return False
|
||||
return same_image(candidate, image_from_archive(str(path)))
|
||||
|
||||
if archive.exists():
|
||||
msg_info(translate('Verifying the image integrity...'))
|
||||
if intact(archive):
|
||||
@@ -168,6 +180,16 @@ def application_name(record, vmid):
|
||||
return f'CT {vmid}'
|
||||
|
||||
|
||||
def backup_native_settings(vmid, storage):
|
||||
"""An optional retained backup does not require rebuilding the guest."""
|
||||
if storage:
|
||||
import oci_keep_backup
|
||||
oci_keep_backup.validate(storage)
|
||||
transaction.run('vzdump', str(vmid), '--storage', storage, '--mode', 'snapshot',
|
||||
'--compress', 'zstd', '--notes-template',
|
||||
'ProxMenux OCI: before modifying container settings')
|
||||
|
||||
|
||||
def update(vmid, acknowledge_external_data=False, proposal=None, keep_backup=None):
|
||||
operation = 'recreate' if proposal is not None else 'update'
|
||||
msg_info(translate('Checking the container before the update...') if operation == 'update'
|
||||
@@ -179,11 +201,42 @@ def update(vmid, acknowledge_external_data=False, proposal=None, keep_backup=Non
|
||||
config = instances.command('pct', 'config', str(vmid))
|
||||
desired = transaction.candidate_contract(record, operation, proposal)
|
||||
changes = transaction.external_changes(record, config)
|
||||
resource_changes = None
|
||||
if operation == 'recreate' and not changes:
|
||||
import oci_resource_modify
|
||||
resource_changes = oci_resource_modify.changes(record, desired, config)
|
||||
# CPU/RAM edits retain the installed image and all unrelated native
|
||||
# settings. They do not implicitly update the application or depend
|
||||
# on the registry and the narrower recreation profile.
|
||||
if resource_changes:
|
||||
msg_ok(translate('Container checked'))
|
||||
import oci_operation_notice
|
||||
with oci_operation_notice.operation([vmid], 'modify', application_name(record, vmid)):
|
||||
backup_native_settings(vmid, keep_backup)
|
||||
return oci_resource_modify.apply(instances.ROOT, record, desired, config, resource_changes)
|
||||
if operation == 'recreate' and not changes:
|
||||
import oci_native_modify
|
||||
if oci_native_modify.changes(record, desired, config):
|
||||
import oci_operation_notice
|
||||
with oci_operation_notice.operation([vmid], 'modify', application_name(record, vmid)):
|
||||
# Validate the native plan before an optional full backup.
|
||||
oci_native_modify.plan(record, desired, config)
|
||||
backup_native_settings(vmid, keep_backup)
|
||||
return oci_native_modify.apply(instances.ROOT, record, desired, config,
|
||||
acknowledge_external_data=acknowledge_external_data)
|
||||
# An unsupported native edit still uses the existing installer. Decide
|
||||
# the route before changing anything; actual validation/API failures
|
||||
# must not silently switch to a destructive fallback.
|
||||
transaction.preflight(record, desired, config)
|
||||
msg_ok(translate('Container checked'))
|
||||
current = record['observed']['image'] if operation == 'update' else None
|
||||
archive, digest = resolve_archive(desired, config, current, lambda: work_backup.locate(
|
||||
instances.location(instances.ROOT, vmid).parent / 'transactions' / 'check', [vmid], record, vmid))
|
||||
check = lambda: work_backup.locate(
|
||||
instances.location(instances.ROOT, vmid).parent / 'transactions' / 'check', [vmid], record, vmid)
|
||||
if operation == 'recreate':
|
||||
archive, digest = resolve_archive(desired, config, current, check,
|
||||
local_archives=[record['observed'].get('archive_path')])
|
||||
else:
|
||||
archive, digest = resolve_archive(desired, config, current, check)
|
||||
if archive is None:
|
||||
msg_ok(translate('The image is already up to date; nothing was changed.'))
|
||||
return
|
||||
|
||||
@@ -1588,7 +1588,7 @@ def _run_remote_install(
|
||||
shutil.copy2(project_root / 'remote' / 'haos_healthcheck.py', temporary / 'haos_healthcheck.py')
|
||||
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')
|
||||
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_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_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_ui.sh', 'oci_ui.py', 'oci_description.py', 'oci_console.py', 'oci_native_stack.py', 'oci_native_stack.sh', 'oci_instance_transaction.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')
|
||||
@@ -1625,7 +1625,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_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_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_ui.sh', 'oci_ui.py', 'oci_description.py', 'oci_console.py', 'oci_native_stack.py', 'oci_native_stack.sh', 'oci_instance_transaction.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')
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
"""Unchanged OCI images permit native resource edits or verified local reuse."""
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import stat
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT/'remote'))
|
||||
import oci_carried_record
|
||||
import oci_instances as instances
|
||||
import oci_resource_modify as resources
|
||||
import oci_update_current as update
|
||||
|
||||
DIGEST = 'sha256:'+'a'*64
|
||||
NEW = 'sha256:'+'b'*64
|
||||
IDENTITY = '11111111-1111-4111-8111-111111111111'
|
||||
CONFIG = (f'arch: amd64\ncores: 2\ndescription: proxmenux-instance={IDENTITY}\n'
|
||||
'memory: 512\nrootfs: local-zfs:subvol-115-disk-0,size=8G\n'
|
||||
'lxc.environment.runtime: A=one\nlxc.environment.runtime: B=two\n').encode()
|
||||
|
||||
|
||||
def record():
|
||||
return {'schema_version': 1, 'vmid': 115, 'installation_id': IDENTITY, 'status': 'installed',
|
||||
'template': {'id': 'example-app', 'catalog_ui': {'title': 'Example'},
|
||||
'container_contract': {'image': {'reference': 'example/app:latest'}},
|
||||
'proxmox': {'installer_profile': {'startup_healthcheck': {'type': 'running'}}}},
|
||||
'deployment': {'vmid': 115, 'hostname': 'example', 'template_storage': 'local',
|
||||
'resources': {'cores': 2, 'memory_mb': 512, 'swap_mb': 512},
|
||||
'network': {'bridge': 'vmbr0', 'ipv4': 'dhcp'}, 'mounts': [], 'devices': [],
|
||||
'environment': [{'name': 'PASSWORD', 'value': 'private-test-value', 'sensitive': True}]},
|
||||
'observed': {'config': CONFIG.decode(), 'config_sha256': resources.sha(CONFIG),
|
||||
'image': {'manifest_digest': DIGEST, 'architecture': 'amd64', 'os': 'linux'},
|
||||
'resolved_registry_digest': DIGEST, 'archive_path': '/missing/deleted-image.tar'}}
|
||||
|
||||
|
||||
class ResourceModifyTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.root = Path(self.temp.name)/'instances'
|
||||
self.configs = Path(self.temp.name)/'configs'
|
||||
self.configs.mkdir()
|
||||
self.file = self.configs/'115.conf'
|
||||
self.file.write_bytes(CONFIG)
|
||||
self.saved = record()
|
||||
self.candidate = copy.deepcopy(self.saved)
|
||||
self.candidate['deployment']['resources'].update(cores=3, memory_mb=768)
|
||||
self.params = {'cores': '3', 'memory': '768'}
|
||||
instances.write(instances.location(self.root, 115), self.saved)
|
||||
self.calls = []
|
||||
self.native_failure = None
|
||||
def run(*args):
|
||||
self.calls.append(args)
|
||||
self.assertEqual(args[:3], ('pct', 'set', '115'))
|
||||
expected = args[args.index('--digest')+1]
|
||||
self.assertEqual(expected, hashlib.sha1(self.file.read_bytes()).hexdigest())
|
||||
if self.native_failure:
|
||||
raise self.native_failure
|
||||
text = self.file.read_text()
|
||||
for index in range(5, len(args), 2):
|
||||
text = re.sub(r'^'+args[index][2:]+r': .*$', args[index][2:]+': '+args[index+1], text, flags=re.M)
|
||||
self.file.write_text(text)
|
||||
return b''
|
||||
def observe(*args):
|
||||
result = copy.deepcopy(self.saved['observed'])
|
||||
result.update(config=self.file.read_text(), config_sha256=resources.sha(self.file.read_bytes()))
|
||||
return result
|
||||
patches = [patch.object(resources, 'CONFIG_DIR', self.configs),
|
||||
patch.object(instances, 'command', side_effect=lambda *args: self.file.read_bytes()),
|
||||
patch.object(resources.transaction, 'run', side_effect=run),
|
||||
patch.object(resources.transaction, 'open_log'), patch.object(resources.transaction, 'log'),
|
||||
patch.object(instances, 'observe', side_effect=observe),
|
||||
patch.object(oci_carried_record, 'carry', return_value='carried')]
|
||||
for p in patches:
|
||||
p.start()
|
||||
self.addCleanup(p.stop)
|
||||
|
||||
def apply(self):
|
||||
return resources.apply(self.root, self.saved, self.candidate, CONFIG, self.params)
|
||||
|
||||
def test_resource_only_change_needs_no_archive_and_preserves_every_other_field(self):
|
||||
directory = self.apply()
|
||||
after = instances.read(self.root, 115)
|
||||
self.assertEqual(after['deployment']['resources'], {'cores': 3, 'memory_mb': 768, 'swap_mb': 512})
|
||||
for key in ('network', 'mounts', 'devices', 'environment'):
|
||||
self.assertEqual(after['deployment'][key], self.saved['deployment'][key])
|
||||
self.assertEqual(after['observed']['image'], self.saved['observed']['image'])
|
||||
self.assertEqual(after['observed']['archive_path'], '/missing/deleted-image.tar')
|
||||
self.assertEqual(after['observed']['config_sha256'], resources.sha(self.file.read_bytes()))
|
||||
self.assertNotIn('pending_transaction', after)
|
||||
self.assertEqual(len(self.calls), 1)
|
||||
self.assertEqual(stat.S_IMODE((directory/'before.json').stat().st_mode), 0o600)
|
||||
self.assertEqual(json.loads((directory/'before.json').read_text())['record'], self.saved)
|
||||
self.assertNotIn('private-test-value', str(self.calls))
|
||||
|
||||
def test_network_environment_template_and_extra_device_edits_use_the_normal_installer(self):
|
||||
for mutate in (
|
||||
lambda c: c['deployment']['network'].update(ipv4='192.0.2.2/24'),
|
||||
lambda c: c['deployment']['environment'][0].update(value='changed'),
|
||||
lambda c: c['template']['container_contract']['image'].update(reference='example/app:other'),
|
||||
lambda c: c['deployment']['devices'].append({'host_path': '/dev/ttyACM0'}),
|
||||
lambda c: c['deployment']['resources'].update(swap_mb=1024),
|
||||
):
|
||||
candidate = copy.deepcopy(self.candidate)
|
||||
mutate(candidate)
|
||||
self.assertIsNone(resources.changes(self.saved, candidate, CONFIG))
|
||||
|
||||
def test_invalid_values_and_unchanged_proposal_do_not_use_the_native_path(self):
|
||||
for value in (True, 0, -1, 64, 128.5, '768'):
|
||||
candidate = copy.deepcopy(self.saved)
|
||||
candidate['deployment']['resources']['memory_mb'] = value
|
||||
self.assertIsNone(resources.changes(self.saved, candidate, CONFIG))
|
||||
self.assertIsNone(resources.changes(self.saved, copy.deepcopy(self.saved), CONFIG))
|
||||
|
||||
def test_cpu_limit_profile_stays_a_cpu_limit(self):
|
||||
config = CONFIG.replace(b'cores: 2', b'cpulimit: 2')
|
||||
self.assertEqual(resources.changes(self.saved, self.candidate, config), {'cpulimit': '3', 'memory': '768'})
|
||||
|
||||
def test_native_failure_leaves_saved_record_and_config_unchanged(self):
|
||||
self.native_failure = RuntimeError('native API rejected edit')
|
||||
with self.assertRaisesRegex(RuntimeError, 'rejected'):
|
||||
self.apply()
|
||||
self.assertEqual(self.file.read_bytes(), CONFIG)
|
||||
self.assertEqual(instances.read(self.root, 115), self.saved)
|
||||
|
||||
def test_observation_failure_rolls_back_resource_values(self):
|
||||
with patch.object(instances, 'observe', side_effect=RuntimeError('observation failed')):
|
||||
with self.assertRaisesRegex(RuntimeError, 'observation failed'):
|
||||
self.apply()
|
||||
self.assertEqual(self.file.read_bytes(), CONFIG)
|
||||
self.assertEqual(instances.read(self.root, 115), self.saved)
|
||||
self.assertEqual(len(self.calls), 2)
|
||||
|
||||
def test_record_write_failure_rolls_back_resources(self):
|
||||
real_write = instances.write
|
||||
failed = False
|
||||
def write(path, value):
|
||||
nonlocal failed
|
||||
if Path(path) == instances.location(self.root, 115) and not failed:
|
||||
failed = True
|
||||
raise OSError('record could not be saved')
|
||||
real_write(path, value)
|
||||
with patch.object(instances, 'write', side_effect=write):
|
||||
with self.assertRaisesRegex(OSError, 'could not be saved'):
|
||||
self.apply()
|
||||
self.assertEqual(self.file.read_bytes(), CONFIG)
|
||||
self.assertEqual(instances.read(self.root, 115), self.saved)
|
||||
|
||||
def test_configuration_drift_is_rejected_before_native_set(self):
|
||||
self.file.write_bytes(CONFIG.replace(b'memory: 512', b'memory: 1024'))
|
||||
with self.assertRaisesRegex(ValueError, 'changed'):
|
||||
self.apply()
|
||||
self.assertEqual(self.calls, [])
|
||||
|
||||
def test_repeated_environment_entries_are_all_preserved_by_the_guard(self):
|
||||
changed = CONFIG.replace(b'cores: 2', b'cores: 3').replace(b'memory: 512', b'memory: 768')
|
||||
self.assertTrue(resources.expected_config(CONFIG, changed, self.params))
|
||||
self.assertFalse(resources.expected_config(CONFIG, changed.replace(b'A=one', b'A=foreign'), self.params))
|
||||
|
||||
def test_cpu_change_preserves_homepage_dns_timezone_and_cpu_priority(self):
|
||||
extra = b'nameserver: 10.0.1.1\ntimezone: Europe/Madrid\ncpuunits: 100\n'
|
||||
config = CONFIG + extra
|
||||
self.file.write_bytes(config)
|
||||
self.saved['observed'].update(config=config.decode(), config_sha256=resources.sha(config))
|
||||
self.saved['deployment']['resources']['cpu_units'] = 100
|
||||
self.candidate = copy.deepcopy(self.saved)
|
||||
self.candidate['deployment']['resources']['cores'] = 3
|
||||
instances.write(instances.location(self.root,115),self.saved)
|
||||
resources.apply(self.root,self.saved,self.candidate,config,{'cores':'3'})
|
||||
self.assertEqual(self.file.read_bytes(), config.replace(b'cores: 2',b'cores: 3'))
|
||||
self.assertEqual(instances.read(self.root,115)['deployment']['resources']['cpu_units'],100)
|
||||
|
||||
def test_a_concurrent_unrelated_change_is_not_overwritten_during_rollback(self):
|
||||
def observe(*args):
|
||||
self.file.write_bytes(self.file.read_bytes().replace(b'A=one', b'A=foreign'))
|
||||
raise RuntimeError('concurrent edit')
|
||||
with patch.object(instances, 'observe', side_effect=observe), patch.object(resources, 'msg_warn') as warn:
|
||||
with self.assertRaisesRegex(RuntimeError, 'concurrent edit'):
|
||||
self.apply()
|
||||
self.assertIn(b'A=foreign', self.file.read_bytes())
|
||||
self.assertEqual(len(self.calls), 1)
|
||||
warn.assert_called_once()
|
||||
self.assertEqual(instances.read(self.root, 115), self.saved)
|
||||
|
||||
def test_a_carried_record_error_does_not_undo_the_successful_edit(self):
|
||||
with patch.object(oci_carried_record, 'carry', side_effect=RuntimeError('copy unavailable')):
|
||||
self.apply()
|
||||
self.assertEqual(instances.read(self.root, 115)['deployment']['resources']['memory_mb'], 768)
|
||||
self.assertEqual(len(self.calls), 1)
|
||||
|
||||
|
||||
class CachedArchiveTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.path = Path(self.temp.name)/'original-installation.tar'
|
||||
self.path.write_bytes(b'fixture verified by stub')
|
||||
self.desired = record()
|
||||
self.image = {'manifest_digest': DIGEST}
|
||||
self.candidate = dict(self.image, registry_digest=DIGEST)
|
||||
|
||||
def resolve(self, paths, image=None):
|
||||
def run(args, error, capture=False):
|
||||
self.assertNotEqual(args[0], 'skopeo', 'No layers should be downloaded')
|
||||
return json.dumps(self.candidate) if '-c' in args else b''
|
||||
with patch.object(update, 'run_quiet', side_effect=run), \
|
||||
patch.object(update, 'image_from_archive', return_value=image or self.image), \
|
||||
patch.object(instances, 'command') as command:
|
||||
result = update.resolve_archive(self.desired, b'arch: amd64\n', local_archives=paths)
|
||||
command.assert_not_called()
|
||||
return result
|
||||
|
||||
def test_original_installation_archive_is_reused_without_update_cache_lookup(self):
|
||||
self.assertEqual(self.resolve([self.path]), (self.path, DIGEST))
|
||||
|
||||
def test_invalid_original_cache_is_kept_and_a_second_matching_archive_is_reused(self):
|
||||
other = self.path.with_name('other.tar')
|
||||
other.write_bytes(b'second fixture')
|
||||
def image(path):
|
||||
return {'manifest_digest': NEW if str(path) == str(self.path) else DIGEST}
|
||||
with patch.object(update, 'run_quiet', side_effect=lambda args, *a, **kw: json.dumps(self.candidate) if '-c' in args else b''), \
|
||||
patch.object(update, 'image_from_archive', side_effect=image), \
|
||||
patch.object(instances, 'command') as command:
|
||||
result = update.resolve_archive(self.desired, b'arch: amd64\n', local_archives=[self.path, other])
|
||||
self.assertEqual(result, (other, DIGEST))
|
||||
self.assertTrue(self.path.exists())
|
||||
command.assert_not_called()
|
||||
|
||||
def test_a_new_digest_downloads_once_instead_of_using_an_old_archive(self):
|
||||
candidate = {'manifest_digest': NEW, 'registry_digest': NEW}
|
||||
cached = self.path.with_name('update.tar')
|
||||
copies = []
|
||||
def run(args, *a, **kwargs):
|
||||
if '-c' in args:
|
||||
return json.dumps(candidate)
|
||||
if args[0] == 'skopeo':
|
||||
copies.append(args)
|
||||
Path(args[-1].removeprefix('oci-archive:')).write_bytes(b'new verified fixture')
|
||||
return b''
|
||||
def image(path):
|
||||
return {'manifest_digest': DIGEST if str(path) == str(self.path) else NEW}
|
||||
with patch.object(update, 'run_quiet', side_effect=run), \
|
||||
patch.object(update, 'image_from_archive', side_effect=image), \
|
||||
patch.object(instances, 'command', return_value=str(cached).encode()), \
|
||||
patch.object(update.work_backup, 'available', return_value=1024**4), \
|
||||
patch.object(update.transaction, 'log'):
|
||||
result = update.resolve_archive(self.desired, b'arch: amd64\n', local_archives=[self.path])
|
||||
self.assertEqual(result, (cached, NEW))
|
||||
self.assertEqual(len(copies), 1)
|
||||
self.assertIn('docker://example/app@'+NEW, copies[0])
|
||||
self.assertEqual(self.path.read_bytes(), b'fixture verified by stub')
|
||||
|
||||
def test_a_broken_or_symlink_original_cache_does_not_hide_a_valid_update_cache(self):
|
||||
cached = self.path.with_name('update.tar')
|
||||
cached.write_bytes(b'valid update fixture')
|
||||
link = self.path.with_name('link.tar')
|
||||
link.symlink_to(self.path)
|
||||
verified = []
|
||||
def run(args, *a, **kwargs):
|
||||
if '-c' in args:
|
||||
return json.dumps(self.candidate)
|
||||
self.assertNotEqual(args[0], 'skopeo')
|
||||
verified.append(Path(args[-1]))
|
||||
if Path(args[-1]) == self.path:
|
||||
raise RuntimeError('corrupt archive')
|
||||
return b''
|
||||
with patch.object(update, 'run_quiet', side_effect=run), \
|
||||
patch.object(update, 'image_from_archive', return_value=self.image), \
|
||||
patch.object(instances, 'command', return_value=str(cached).encode()):
|
||||
result = update.resolve_archive(self.desired, b'arch: amd64\n', local_archives=[link, self.path])
|
||||
self.assertEqual(result, (cached, DIGEST))
|
||||
self.assertNotIn(link, verified)
|
||||
self.assertTrue(link.is_symlink())
|
||||
self.assertTrue(self.path.exists())
|
||||
|
||||
|
||||
class UpdateRoutingTests(unittest.TestCase):
|
||||
def run_update(self, new_image=False, resource_edit=True, reject_recreation=False):
|
||||
saved = record()
|
||||
candidate = copy.deepcopy(saved)
|
||||
candidate['deployment']['resources']['memory_mb'] = 768
|
||||
if not resource_edit:
|
||||
candidate['deployment']['environment'][0]['value'] = 'changed'
|
||||
candidate['template']['container_contract']['image']['reference'] = 'example/app:other'
|
||||
proposal = {'operation': 'recreate', 'candidate': candidate}
|
||||
import contextlib, oci_operation_notice
|
||||
with patch.object(instances, 'locked', return_value=contextlib.nullcontext()), \
|
||||
patch.object(instances, 'read', return_value=saved), \
|
||||
patch.object(instances, 'command', return_value=CONFIG), \
|
||||
patch.object(update.transaction, 'candidate_contract', return_value=candidate), \
|
||||
patch.object(update.transaction, 'external_changes', return_value={}), \
|
||||
patch.object(update.transaction, 'preflight', side_effect=ValueError('advanced Proxmox settings') if reject_recreation else None), \
|
||||
patch.object(update, 'resolve_archive', return_value=(Path('/new.tar') if new_image or not resource_edit else None, NEW if new_image else DIGEST)) as resolve, \
|
||||
patch.object(resources, 'apply') as apply_resources, \
|
||||
patch.object(update.transaction, 'apply') as apply_image, \
|
||||
patch.object(oci_operation_notice, 'operation', return_value=contextlib.nullcontext()):
|
||||
update.update(115, proposal=proposal)
|
||||
return resolve, apply_resources, apply_image
|
||||
|
||||
def test_unchanged_image_routes_cpu_ram_edits_to_the_existing_guest(self):
|
||||
resolve, native, image = self.run_update()
|
||||
resolve.assert_not_called()
|
||||
native.assert_called_once()
|
||||
image.assert_not_called()
|
||||
|
||||
def test_a_new_image_for_other_configuration_changes_uses_verified_recreation(self):
|
||||
_resolve, native, image = self.run_update(new_image=True,resource_edit=False)
|
||||
native.assert_not_called()
|
||||
image.assert_called_once()
|
||||
|
||||
def test_cpu_edits_do_not_implicitly_upgrade_the_image(self):
|
||||
resolve,native,image=self.run_update(new_image=True)
|
||||
resolve.assert_not_called()
|
||||
native.assert_called_once()
|
||||
image.assert_not_called()
|
||||
|
||||
def test_homepage_resource_edit_does_not_run_the_recreation_preflight(self):
|
||||
resolve,native,image=self.run_update(reject_recreation=True)
|
||||
resolve.assert_not_called()
|
||||
native.assert_called_once()
|
||||
image.assert_not_called()
|
||||
|
||||
def test_other_modifications_offer_the_recorded_archive_to_the_resolver(self):
|
||||
resolve, native, image = self.run_update(resource_edit=False)
|
||||
self.assertIsNone(resolve.call_args.args[2])
|
||||
self.assertEqual(resolve.call_args.kwargs['local_archives'], ['/missing/deleted-image.tar'])
|
||||
native.assert_not_called()
|
||||
image.assert_called_once()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,219 @@
|
||||
"""Maintainer review: fallback, complete mount edits and retained unused disks."""
|
||||
import contextlib
|
||||
import copy
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]/'remote'))
|
||||
import oci_instances as instances
|
||||
import oci_instance_transaction as transaction
|
||||
import oci_native_modify as native
|
||||
import oci_update_current as update
|
||||
import oci_operation_notice
|
||||
from test_modify_image_reuse import record, CONFIG
|
||||
|
||||
|
||||
def installation():
|
||||
result=record()
|
||||
result['deployment'].update(rootfs={'storage':'local-zfs','size_gb':8}, onboot=True,
|
||||
security={'unprivileged':True})
|
||||
result['observed']['image']['defaults']={}
|
||||
config=CONFIG+b'unprivileged: 1\nonboot: 1\nnet0: name=eth0,bridge=vmbr0,ip=dhcp,hwaddr=AA:BB:CC:DD:EE:FF\n'
|
||||
result['observed'].update(config=config.decode(), config_sha256=transaction.sha(config))
|
||||
return result,config
|
||||
|
||||
|
||||
class FallbackTests(unittest.TestCase):
|
||||
def test_all_installer_adaptations_fall_back_before_any_native_write(self):
|
||||
saved,config=installation()
|
||||
def nvidia(c):c['deployment']['devices']=[{'kind':'nvidia-runtime','host_path':'/dev/nvidia0'}]
|
||||
def groups(c):c['deployment']['devices']=[{'host_path':'/dev/dri/renderD128','append_host_device_gid_to_environment':'GROUPS'}]
|
||||
def permissions(c):
|
||||
c['deployment']['devices']=[{'host_path':'/dev/dri/renderD128'}]
|
||||
c['deployment']['device_permissions']={'uid':1000}
|
||||
mutations=[nvidia,groups,permissions,
|
||||
lambda c:c['deployment'].update(completion_notes=['Installer note']),
|
||||
lambda c:c['deployment'].update(post_start_configurations=[{'id':'adapter'}]),
|
||||
lambda c:c['template']['catalog_ui'].update(title='Refreshed catalog template'),
|
||||
lambda c:c['deployment'].update(mounts=[{'type':'host-bind','source':'/host/file','container_path':'/config/file','backup':False,'size_gb':None}])]
|
||||
for mutation in mutations:
|
||||
with self.subTest(mutation=mutation),tempfile.TemporaryDirectory() as folder:
|
||||
candidate=copy.deepcopy(saved);mutation(candidate)
|
||||
root=Path(folder);instances.write(instances.location(root,115),saved)
|
||||
with patch.object(instances,'ROOT',root),patch.object(instances,'locked',return_value=contextlib.nullcontext()), \
|
||||
patch.object(instances,'command',return_value=config), \
|
||||
patch.object(transaction,'external_changes',return_value={}),patch.object(transaction,'preflight'), \
|
||||
patch.object(transaction,'is_file_bind',side_effect=lambda m:m.get('source')=='/host/file'), \
|
||||
patch.object(native,'apply',side_effect=AssertionError('partial native apply')), \
|
||||
patch.object(update,'resolve_archive',return_value=(Path('/cached.tar'),'sha256:resolved')) as resolve, \
|
||||
patch.object(transaction,'apply') as recreate, \
|
||||
patch.object(oci_operation_notice,'operation',return_value=contextlib.nullcontext()):
|
||||
update.update(115,proposal={'operation':'recreate','candidate':candidate})
|
||||
recreate.assert_called_once();resolve.assert_called_once()
|
||||
self.assertEqual(resolve.call_args.kwargs['local_archives'],[saved['observed']['archive_path']])
|
||||
|
||||
def test_native_validation_failure_never_falls_back_to_recreation(self):
|
||||
saved,config=installation();candidate=copy.deepcopy(saved)
|
||||
candidate['deployment']['environment']=[{'name':'INVALID NAME','value':'value'}]
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
root=Path(folder);instances.write(instances.location(root,115),saved)
|
||||
with patch.object(instances,'ROOT',root),patch.object(instances,'locked',return_value=contextlib.nullcontext()), \
|
||||
patch.object(instances,'command',return_value=config), \
|
||||
patch.object(transaction,'preflight'),patch.object(transaction,'external_changes',return_value={}), \
|
||||
patch.object(transaction,'apply') as recreate,patch.object(update,'resolve_archive') as resolve, \
|
||||
patch.object(oci_operation_notice,'operation',return_value=contextlib.nullcontext()):
|
||||
with self.assertRaises(ValueError):update.update(115,proposal={'operation':'recreate','candidate':candidate})
|
||||
recreate.assert_not_called();resolve.assert_not_called()
|
||||
|
||||
def test_native_api_failure_never_falls_back_after_starting_the_edit(self):
|
||||
saved,config=installation();candidate=copy.deepcopy(saved)
|
||||
candidate['deployment']['environment']=[{'name':'VALID','value':'value'}]
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
root=Path(folder);instances.write(instances.location(root,115),saved)
|
||||
with patch.object(instances,'ROOT',root),patch.object(instances,'locked',return_value=contextlib.nullcontext()), \
|
||||
patch.object(instances,'command',return_value=config), \
|
||||
patch.object(transaction,'preflight'),patch.object(transaction,'external_changes',return_value={}), \
|
||||
patch.object(transaction,'apply') as recreate,patch.object(update,'resolve_archive') as resolve, \
|
||||
patch.object(native,'apply',side_effect=RuntimeError('native API failed')), \
|
||||
patch.object(oci_operation_notice,'operation',return_value=contextlib.nullcontext()):
|
||||
with self.assertRaisesRegex(RuntimeError,'native API failed'):
|
||||
update.update(115,proposal={'operation':'recreate','candidate':candidate})
|
||||
recreate.assert_not_called();resolve.assert_not_called()
|
||||
|
||||
|
||||
class MountTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.record,self.config=installation()
|
||||
self.mount={'type':'host-bind','source':'/mnt/old','container_path':'/media',
|
||||
'size_gb':None,'backup':False,'read_only':False,'create_if_missing':False}
|
||||
self.record['deployment']['mounts']=[self.mount]
|
||||
self.config+=b'mp3: /mnt/old,mp=/media,backup=0,ro=0,replicate=0\n'
|
||||
self.record['observed'].update(config=self.config.decode(),config_sha256=transaction.sha(self.config))
|
||||
self.candidate=copy.deepcopy(self.record)
|
||||
|
||||
def test_existing_bind_source_and_flags_are_applied_with_other_options_preserved(self):
|
||||
self.candidate['deployment']['mounts'][0].update(source='/mnt/new',read_only=True)
|
||||
with patch.object(Path,'is_dir',return_value=True):
|
||||
plan=native.plan(self.record,self.candidate,self.config)
|
||||
self.assertEqual(plan['values'],{'mp3':'/mnt/new,mp=/media,backup=0,ro=1,replicate=0'})
|
||||
self.assertTrue(plan['restart']);self.assertEqual(plan['delete'],[])
|
||||
|
||||
def test_missing_replacement_directory_is_rejected_unless_creation_was_requested(self):
|
||||
self.candidate['deployment']['mounts'][0]['source']='/mnt/new'
|
||||
with patch.object(Path,'is_dir',return_value=False):
|
||||
with self.assertRaisesRegex(ValueError,'directory does not exist'):
|
||||
native.plan(self.record,self.candidate,self.config)
|
||||
self.candidate['deployment']['mounts'][0]['create_if_missing']=True
|
||||
self.assertIn('/mnt/new,',native.plan(self.record,self.candidate,self.config)['values']['mp3'])
|
||||
|
||||
def test_verification_detects_a_source_that_was_not_applied(self):
|
||||
operation={'values':{'mp3':'/mnt/new,mp=/media,backup=0,ro=0'},'delete':[]}
|
||||
with self.assertRaisesRegex(ValueError,'could not apply'):native.verify_values(self.config,operation)
|
||||
native.verify_values(self.config.replace(b'/mnt/old',b'/mnt/new'),operation)
|
||||
|
||||
def test_unknown_mount_options_and_permission_grants_use_installer(self):
|
||||
for fields in ({'grant_access':True},{'mount_options':['noexec']},{'type':'managed-volume','source':'local-zfs','size_gb':2,'backup':True}):
|
||||
candidate=copy.deepcopy(self.candidate);candidate['deployment']['mounts'][0].update(fields)
|
||||
self.assertIsNone(native.changes(self.record,candidate,self.config))
|
||||
|
||||
def test_recursive_bind_adaptations_use_installer(self):
|
||||
self.candidate['deployment']['mounts'][0]['read_only']=True
|
||||
derived=b'lxc.mount.entry: /mnt/old media none rbind,rslave,create=dir 0 0\n'
|
||||
self.assertIsNone(native.changes(self.record,self.candidate,self.config+derived))
|
||||
|
||||
def test_storage_and_disk_size_changes_keep_existing_migration_guard(self):
|
||||
self.record['deployment']['mounts'][0].update(type='managed-volume',source='local-zfs',size_gb=1,backup=True)
|
||||
self.config=self.config.replace(b'/mnt/old,mp=/media,backup=0',b'local-zfs:subvol-115-disk-1,mp=/media,backup=1')
|
||||
self.record['observed']['config']=self.config.decode()
|
||||
for fields in ({'size_gb':2},{'source':'other-storage'}):
|
||||
candidate=copy.deepcopy(self.record);candidate['deployment']['mounts'][0].update(fields)
|
||||
self.assertIsNone(native.changes(self.record,candidate,self.config))
|
||||
with self.assertRaisesRegex(ValueError,'migration'):transaction.preflight(self.record,candidate,self.config)
|
||||
|
||||
|
||||
class DetachedPreflightTests(unittest.TestCase):
|
||||
def test_removing_a_mount_then_updating_accepts_the_recorded_unused_disk(self):
|
||||
saved,config=installation()
|
||||
saved['deployment']['mounts']=[{'type':'managed-volume','source':'local-zfs','size_gb':1,
|
||||
'container_path':'/extra','backup':True,'read_only':False}]
|
||||
config+=b'mp4: local-zfs:subvol-115-disk-1,mp=/extra,backup=1,size=1G\n'
|
||||
saved['observed']['config']=config.decode()
|
||||
candidate=copy.deepcopy(saved);candidate['deployment']['mounts']=[]
|
||||
self.assertEqual(native.plan(saved,candidate,config)['delete'],['mp4'])
|
||||
detached=config.replace(b'mp4: local-zfs:subvol-115-disk-1,mp=/extra,backup=1,size=1G\n',b'unused0: local-zfs:subvol-115-disk-1\n')
|
||||
candidate['observed'].update(config=detached.decode(),config_sha256=transaction.sha(detached))
|
||||
cfg,_,_=transaction.preflight(candidate,copy.deepcopy(candidate),detached)
|
||||
self.assertIn('unused0',cfg)
|
||||
|
||||
def test_unrecorded_detached_disks_are_not_adopted_silently(self):
|
||||
saved,config=installation()
|
||||
with self.assertRaisesRegex(ValueError,'disks do not match|discard those changes'):
|
||||
transaction.preflight(saved,copy.deepcopy(saved),config+b'unused0: local-zfs:subvol-115-disk-1\n')
|
||||
|
||||
def test_invalid_duplicate_or_mounted_unused_disks_are_rejected(self):
|
||||
for extra in (b'unused0: /etc/passwd\n',b'unused0: local-zfs:subvol-115-disk-0\n',
|
||||
b'unused0: local-zfs:subvol-115-disk-1\nunused1: local-zfs:subvol-115-disk-1\n'):
|
||||
saved,config=installation();config+=extra;saved['observed']['config']=config.decode()
|
||||
with self.assertRaisesRegex(ValueError,'disks do not match'):transaction.preflight(saved,copy.deepcopy(saved),config)
|
||||
|
||||
|
||||
class DetachedTransferTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
saved,config=installation()
|
||||
self.configs={115:config+b'unused0: local-zfs:subvol-115-disk-1\nunused2: local-zfs:subvol-115-disk-2\n',
|
||||
900:b'description: proxmenux-transaction=test-tx\nrootfs: local-zfs:subvol-900-disk-0\n'}
|
||||
self.state={'vmid':115,'stage':900,'id':'test-tx','record':saved,
|
||||
'retained_unused':transaction.unused_volumes(self.configs[115])}
|
||||
self.calls=[]
|
||||
def owned(vmid,marker):
|
||||
self.assertIn(marker.encode(),self.configs[vmid]);return self.configs[vmid]
|
||||
def run(*args):
|
||||
self.calls.append(args);self.assertEqual(args[:2],('pct','move-volume'))
|
||||
source,key,target=int(args[2]),args[3],int(args[5]);self.assertEqual(key,args[7])
|
||||
line=next(line for line in self.configs[source].splitlines(keepends=True) if line.startswith((key+': ').encode()))
|
||||
self.configs[source]=self.configs[source].replace(line,b'')
|
||||
self.configs[target]+=line.replace(('subvol-'+str(source)+'-').encode(),('subvol-'+str(target)+'-').encode())
|
||||
for p in (patch.object(transaction,'owned',side_effect=owned),patch.object(transaction,'run',side_effect=run)):
|
||||
p.start();self.addCleanup(p.stop)
|
||||
|
||||
def test_unused_disks_are_parked_and_returned_without_freeing_any_volume(self):
|
||||
transaction.move_retained_unused(self.state,True)
|
||||
self.assertEqual(transaction.unused_volumes(self.configs[115]),{})
|
||||
self.assertEqual(set(transaction.unused_volumes(self.configs[900])),{'unused0','unused2'})
|
||||
transaction.move_retained_unused(self.state,False)
|
||||
self.assertEqual(transaction.unused_volumes(self.configs[115]),self.state['retained_unused'])
|
||||
self.assertEqual(transaction.unused_volumes(self.configs[900]),{})
|
||||
self.assertEqual(len(self.calls),4)
|
||||
|
||||
def test_an_interrupted_transfer_can_be_retried_in_both_directions(self):
|
||||
original=transaction.run.side_effect
|
||||
def interrupt(*args):
|
||||
original(*args);raise RuntimeError('interrupted after reassignment')
|
||||
with patch.object(transaction,'run',side_effect=interrupt):
|
||||
with self.assertRaises(RuntimeError):transaction.move_retained_unused(self.state,True)
|
||||
transaction.move_retained_unused(self.state,True)
|
||||
with patch.object(transaction,'run',side_effect=interrupt):
|
||||
with self.assertRaises(RuntimeError):transaction.move_retained_unused(self.state,False)
|
||||
transaction.move_retained_unused(self.state,False)
|
||||
self.assertEqual(transaction.unused_volumes(self.configs[115]),self.state['retained_unused'])
|
||||
|
||||
def test_unrelated_unused_disks_are_not_moved(self):
|
||||
self.configs[115]+=b'unused3: local-zfs:subvol-115-disk-3\n'
|
||||
with self.assertRaises(ValueError):transaction.move_retained_unused(self.state,True)
|
||||
self.assertEqual(self.calls,[])
|
||||
|
||||
def test_a_missing_disk_or_target_collision_blocks_the_transfer(self):
|
||||
del self.state['retained_unused']['unused2']
|
||||
self.configs[115]=self.configs[115].replace(b'unused2: local-zfs:subvol-115-disk-2\n',b'')
|
||||
self.configs[900]+=b'unused0: local-zfs:subvol-900-disk-9\n'
|
||||
with self.assertRaises(ValueError):transaction.move_retained_unused(self.state,True)
|
||||
self.configs[115]=self.configs[115].replace(b'unused0: local-zfs:subvol-115-disk-1\n',b'')
|
||||
self.configs[900]=self.configs[900].replace(b'unused0: local-zfs:subvol-900-disk-9\n',b'')
|
||||
with self.assertRaises(ValueError):transaction.move_retained_unused(self.state,False)
|
||||
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Exercise the actual update and recovery orchestration with a simulated PVE API.
|
||||
|
||||
Unused volumes must leave the old CT before destroy/restore and return before
|
||||
validation/commit, including an interruption during volume reassignment.
|
||||
"""
|
||||
import copy
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import socket
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'remote'))
|
||||
import oci_instances as instances
|
||||
import oci_instance_transaction as transaction
|
||||
from test_modify_review import installation
|
||||
from test_modify_image_reuse import NEW
|
||||
|
||||
|
||||
class UpdateRetainedDiskTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
temp=tempfile.TemporaryDirectory();self.addCleanup(temp.cleanup)
|
||||
self.root=Path(temp.name)/'instances'
|
||||
self.archive=Path(temp.name)/'image.tar';self.archive.write_bytes(b'verified fixture')
|
||||
self.backup=Path(temp.name)/'backup.tar.zst';self.backup.write_bytes(b'verified backup')
|
||||
self.saved,config=installation()
|
||||
self.base_config=config
|
||||
config+=b'unused0: local-zfs:subvol-115-disk-1\nunused2: local-zfs:subvol-115-disk-2\n'
|
||||
self.saved['observed'].update(config=config.decode(),config_sha256=transaction.sha(config))
|
||||
self.saved['observed']['archive_path']=str(self.archive)
|
||||
self.image=copy.deepcopy(self.saved['observed']['image']);self.image['manifest_digest']=NEW
|
||||
self.configs={115:config};self.commands=[];self.fail_install=False;self.interrupt_return=False
|
||||
instances.write(instances.location(self.root,115),self.saved)
|
||||
def config_line(vmid,key,value):
|
||||
before=self.configs[vmid]
|
||||
result=b''.join(line for line in before.splitlines(keepends=True) if not line.startswith((key+': ').encode()))
|
||||
self.configs[vmid]=result+(key+': '+value+'\n').encode()
|
||||
def run(*args):
|
||||
self.commands.append(args)
|
||||
if args[0]=='zstd':
|
||||
self.assertEqual(args[1:],('-t',str(self.backup)))
|
||||
return b''
|
||||
if args[:2]==('pvesh','get'):
|
||||
if args[2]=='/cluster/nextid':return b'900'
|
||||
return json.dumps([{'vmid':vmid,'type':'lxc','node':socket.gethostname().split('.')[0]}
|
||||
for vmid in self.configs]).encode() if args[2]=='/cluster/resources' else b'[]'
|
||||
self.assertEqual(args[0],'pct',args)
|
||||
action,vmid=args[1],int(args[2])
|
||||
if action=='config':return self.configs[vmid]
|
||||
if action=='status':return b'status: running'
|
||||
if action in ('start','shutdown','stop'):return b''
|
||||
if action=='create':
|
||||
marker=args[args.index('--description')+1]
|
||||
self.configs[vmid]=f'description: {marker}\nrootfs: local-zfs:subvol-{vmid}-disk-0\n'.encode()
|
||||
elif action=='destroy':
|
||||
self.assertFalse(transaction.unused_volumes(self.configs[vmid]),'would destroy retained data')
|
||||
self.assertFalse(transaction.mounts(self.configs[vmid]),'would destroy attached data')
|
||||
del self.configs[vmid]
|
||||
elif action=='set':
|
||||
for index in range(3,len(args),2):config_line(vmid,args[index][2:],args[index+1])
|
||||
elif action=='move-volume':
|
||||
key,target,target_key=args[3],int(args[5]),args[7]
|
||||
volume=transaction.unused_volumes(self.configs[vmid])[key]
|
||||
line=(key+': '+volume+'\n').encode()
|
||||
self.configs[vmid]=self.configs[vmid].replace(line,b'')
|
||||
volume=volume.replace('subvol-'+str(vmid)+'-','subvol-'+str(target)+'-')
|
||||
config_line(target,target_key,volume)
|
||||
if self.interrupt_return and target==115:
|
||||
self.interrupt_return=False
|
||||
raise RuntimeError('interrupted return after PVE reassignment')
|
||||
elif action=='restore':
|
||||
# vzdump does not contain detached disks; restore skips unusedN.
|
||||
self.assertFalse(transaction.unused_volumes(self.configs.get(vmid,b'')))
|
||||
self.configs[vmid]=self.base_config
|
||||
else:self.fail('Unexpected PVE command: '+str(args))
|
||||
return b''
|
||||
def install(root,journal,state,progress=None):
|
||||
if self.fail_install:raise RuntimeError('installer failed after old rootfs was removed')
|
||||
self.configs[state['vmid']]=self.base_config
|
||||
def observe(vmid,identity,archive,digest,image=None):
|
||||
observed=copy.deepcopy(self.saved['observed'])
|
||||
observed.update(config=self.configs[vmid].decode(),config_sha256=transaction.sha(self.configs[vmid]),
|
||||
image=copy.deepcopy(image or self.image),resolved_registry_digest=digest)
|
||||
return observed
|
||||
original_exists=Path.exists
|
||||
def exists(path):
|
||||
match=re.fullmatch(r'/etc/pve/lxc/([0-9]+)\.conf',str(path))
|
||||
return int(match[1]) in self.configs if match else original_exists(path)
|
||||
patches=[patch.object(transaction,'run',side_effect=run),patch.object(transaction,'open_log'),
|
||||
patch.object(transaction,'log'),patch.object(transaction,'stop'),
|
||||
patch.object(transaction,'check_archive'),patch.object(transaction,'image_from_archive',return_value=self.image),
|
||||
patch.object(transaction,'verified_backup',return_value=self.backup),
|
||||
patch.object(transaction,'install_candidate',side_effect=install),
|
||||
patch.object(transaction,'healthcheck',return_value='running'),
|
||||
patch.object(transaction,'candidate_host_sources',return_value={}),
|
||||
patch.object(transaction,'prune_backups'),patch.object(transaction.image_cache,'prune',return_value=[]),
|
||||
patch.object(transaction,'cleanup_restored_format_dirs'),patch.object(transaction.runtime_settings,'restore'),
|
||||
patch.object(transaction.work_backup,'locate',side_effect=lambda directory,*a:directory),
|
||||
patch.object(instances,'observe',side_effect=observe),patch.object(Path,'exists',exists)]
|
||||
for p in patches:p.start();self.addCleanup(p.stop)
|
||||
self.addCleanup(transaction._run.update,journal=None)
|
||||
|
||||
def apply(self,**kwargs):return transaction.apply(self.root,115,self.archive,'update',registry_digest=NEW,**kwargs)
|
||||
|
||||
def journal(self):return next(instances.location(self.root,115).parent.glob('transactions/*/transaction.json'))
|
||||
|
||||
def assert_retained(self):
|
||||
self.assertEqual(transaction.unused_volumes(self.configs[115]),{
|
||||
'unused0':'local-zfs:subvol-115-disk-1','unused2':'local-zfs:subvol-115-disk-2'})
|
||||
self.assertNotIn(900,self.configs)
|
||||
self.assertFalse(any(args[:2]==('pct','set') and '--delete' in args for args in self.commands))
|
||||
|
||||
def test_update_keeps_detached_disks_and_the_next_update_preflight_accepts_them(self):
|
||||
self.apply();self.assert_retained()
|
||||
saved=instances.read(self.root,115)
|
||||
self.assertEqual(saved['status'],'installed')
|
||||
self.assertEqual(saved['observed']['image']['manifest_digest'],NEW)
|
||||
transaction.preflight(saved,copy.deepcopy(saved),self.configs[115])
|
||||
self.assertEqual(json.loads(self.journal().read_text())['phase'],'committed')
|
||||
|
||||
def test_failed_install_restores_both_backup_and_detached_disks(self):
|
||||
self.fail_install=True
|
||||
with self.assertRaisesRegex(RuntimeError,'installer failed'):self.apply()
|
||||
transaction.recover(self.root,self.journal())
|
||||
self.assert_retained()
|
||||
self.assertEqual(instances.read(self.root,115)['observed']['image'],self.saved['observed']['image'])
|
||||
self.assertEqual(json.loads(self.journal().read_text())['phase'],'rolled-back')
|
||||
|
||||
def test_recovery_after_parking_data_returns_the_original_unused_disks(self):
|
||||
with self.assertRaisesRegex(RuntimeError,'Lab interruption'):self.apply(interrupt_after='data-parked')
|
||||
transaction.recover(self.root,self.journal());self.assert_retained()
|
||||
|
||||
def test_recovery_handles_an_interruption_while_returning_unused_disks(self):
|
||||
self.interrupt_return=True
|
||||
with self.assertRaisesRegex(RuntimeError,'interrupted return'):self.apply()
|
||||
transaction.recover(self.root,self.journal());self.assert_retained()
|
||||
|
||||
def test_failed_runtime_check_recovers_disks_already_returned_to_the_candidate(self):
|
||||
with patch.object(transaction.gpu_devices,'validate_runtime',side_effect=[RuntimeError('invalid runtime'), None]):
|
||||
with self.assertRaisesRegex(RuntimeError,'invalid runtime'):self.apply()
|
||||
transaction.recover(self.root,self.journal())
|
||||
self.assert_retained()
|
||||
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
@@ -0,0 +1,262 @@
|
||||
"""Native runtime edits keep rootfs, defaults and secrets; failures roll back."""
|
||||
import contextlib
|
||||
import copy
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch, Mock
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]/'remote'))
|
||||
import oci_native_modify as native
|
||||
import oci_instances as instances
|
||||
import oci_update_current as update
|
||||
import oci_operation_notice
|
||||
from test_modify_image_reuse import record, CONFIG
|
||||
|
||||
|
||||
class NativePlanTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.record = record()
|
||||
self.record['observed']['image']['defaults'] = {'Env': ['A=image-default']}
|
||||
self.record['deployment']['environment'] = [{'name': 'A', 'value': 'one'}]
|
||||
self.config = CONFIG+b'net0: name=eth0,bridge=vmbr0,ip=dhcp,hwaddr=AA:BB:CC:DD:EE:FF,tag=20,ip6=auto\n'
|
||||
self.candidate = copy.deepcopy(self.record)
|
||||
p = patch.object(native.transaction, 'preflight')
|
||||
self.preflight = p.start(); self.addCleanup(p.stop)
|
||||
|
||||
def test_environment_preserves_image_defaults_and_other_runtime_values(self):
|
||||
self.candidate['deployment']['environment'] = [{'name': 'SECRET', 'value': "a' $x \" ; = value", 'sensitive': True}]
|
||||
result = native.plan(self.record, self.candidate, self.config)
|
||||
env = dict(x.split('=', 1) for x in result['values']['env'].split('\0'))
|
||||
self.assertEqual(env, {'A': 'image-default', 'B': 'two', 'SECRET': "a' $x \" ; = value"})
|
||||
self.assertTrue(result['restart'])
|
||||
|
||||
def test_remove_environment_without_default_restores_image_environment(self):
|
||||
self.record['observed']['image']['defaults'] = {}
|
||||
self.candidate['deployment']['environment'] = []
|
||||
self.assertEqual(native.plan(self.record, self.candidate, self.config)['values']['env'], 'B=two')
|
||||
|
||||
def test_public_pct_environment_and_native_lines_have_the_same_values(self):
|
||||
raw=b'lxc.environment.runtime: A=one\nlxc.environment.runtime: B=two\n'
|
||||
public=b'env: A=one\0B=two\n'
|
||||
self.assertEqual(native.environment(raw),native.environment(public))
|
||||
self.assertEqual(native.environment(public),{'A':'one','B':'two'})
|
||||
|
||||
def test_duplicate_invalid_and_control_character_values_are_refused(self):
|
||||
for items in ([{'name':'X','value':'a'},{'name':'X','value':'b'}],
|
||||
[{'name':'X','value':'a\nb'}],[{'name':'BAD NAME','value':'a'}]):
|
||||
self.candidate['deployment']['environment'] = items
|
||||
with self.assertRaises(ValueError): native.plan(self.record, self.candidate, self.config)
|
||||
|
||||
def test_network_preserves_mac_vlan_ipv6_and_interface_identity(self):
|
||||
self.candidate['deployment']['network'].update(ipv4='192.0.2.2/24', gateway='192.0.2.1')
|
||||
with patch.object(Path, 'is_dir', return_value=True):
|
||||
result = native.plan(self.record, self.candidate, self.config)
|
||||
fields = dict(x.split('=',1) for x in result['values']['net0'].split(','))
|
||||
self.assertEqual(fields['hwaddr'], 'AA:BB:CC:DD:EE:FF')
|
||||
self.assertEqual(fields['tag'], '20'); self.assertEqual(fields['ip6'], 'auto')
|
||||
self.assertEqual(fields['ip'], '192.0.2.2/24'); self.assertEqual(fields['gw'], '192.0.2.1')
|
||||
|
||||
def test_invalid_network_is_rejected_before_stopping(self):
|
||||
for value in ('nonsense', '2001:db8::1/64'):
|
||||
self.candidate['deployment']['network']['ipv4'] = value
|
||||
with patch.object(Path, 'is_dir', return_value=True), self.assertRaises(ValueError):
|
||||
native.plan(self.record,self.candidate,self.config)
|
||||
|
||||
def test_new_managed_disk_uses_native_allocation_and_existing_disk_is_untouched(self):
|
||||
self.candidate['deployment']['mounts'] = [{'type':'managed-volume','container_path':'/extra',
|
||||
'source':'local-zfs','size_gb':1,'backup':True,'read_only':False}]
|
||||
result = native.plan(self.record,self.candidate,self.config)
|
||||
self.assertEqual(result['values'], {'mp0':'local-zfs:1,mp=/extra,backup=1,ro=0'})
|
||||
self.assertNotIn('rootfs', result['values'])
|
||||
|
||||
def test_removing_mount_detaches_it_without_freeing_its_volume(self):
|
||||
mount={'type':'managed-volume','container_path':'/extra','source':'local-zfs','size_gb':1,'backup':True}
|
||||
self.record['deployment']['mounts']=[mount]
|
||||
result=native.plan(self.record,self.candidate,self.config+b'mp4: local-zfs:subvol-115-disk-1,mp=/extra,backup=1,size=1G\n')
|
||||
self.assertEqual(result['delete'],['mp4'])
|
||||
|
||||
def test_image_or_installer_adapter_changes_do_not_take_native_path(self):
|
||||
for key in ('image', 'adapter'):
|
||||
candidate=copy.deepcopy(self.record)
|
||||
if key=='image':candidate['template']['container_contract']['image']['reference']='new/image'
|
||||
else:candidate['deployment']['post_start_configurations']=[{'id':'rootfs-adapter'}]
|
||||
self.assertIsNone(native.changes(self.record,candidate,self.config))
|
||||
|
||||
def test_secrets_use_stdin_never_arguments_or_logs(self):
|
||||
secret="super-secret with 'quotes' and $shell"
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root=Path(directory);(root/'115.conf').write_bytes(self.config)
|
||||
with patch.object(native.resources,'CONFIG_DIR',root), \
|
||||
patch.object(instances,'command',return_value=self.config), \
|
||||
patch.object(native.transaction,'log') as log, \
|
||||
patch.object(native.subprocess,'run',return_value=Mock(returncode=0)) as run:
|
||||
native.set_config(115,self.record['installation_id'],self.config,{'env':'SECRET='+secret+'\0B=two'})
|
||||
self.assertNotIn(secret,str(run.call_args.args))
|
||||
self.assertNotIn(secret,str(log.call_args_list))
|
||||
self.assertIn(secret,json.loads(run.call_args.kwargs['input'])['env'])
|
||||
|
||||
def test_native_api_failure_does_not_echo_secret_stderr(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root=Path(directory);(root/'115.conf').write_bytes(self.config)
|
||||
with patch.object(native.resources,'CONFIG_DIR',root),patch.object(instances,'command',return_value=self.config), \
|
||||
patch.object(native.subprocess,'run',return_value=Mock(returncode=1,stderr=b'secret')):
|
||||
with self.assertRaisesRegex(RuntimeError,'Proxmox could not apply') as error:
|
||||
native.set_config(115,self.record['installation_id'],self.config,{'env':'SECRET=secret'})
|
||||
self.assertNotIn('secret',str(error.exception))
|
||||
|
||||
def test_verifies_all_environment_entries_and_native_values(self):
|
||||
native.verify_values(CONFIG,{'values':{'env':'A=one\0B=two','cores':'2'},'delete':[]})
|
||||
for values in ({'env':'A=wrong\0B=two'},{'cores':'3'}):
|
||||
with self.assertRaises(ValueError):native.verify_values(CONFIG,{'values':values,'delete':[]})
|
||||
with self.assertRaises(ValueError):native.verify_values(CONFIG,{'values':{},'delete':['env']})
|
||||
|
||||
def test_native_property_canonicalization_keeps_modes_and_default_flags(self):
|
||||
cfg=CONFIG+b'dev0: path=/dev/fuse,mode=660\n'
|
||||
native.verify_values(cfg,{'values':{'dev0':'path=/dev/fuse,mode=0660,uid=0,gid=0,deny-write=0'},'delete':[]})
|
||||
|
||||
|
||||
class NativeApplyTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp=tempfile.TemporaryDirectory();self.addCleanup(self.temp.cleanup)
|
||||
self.root=Path(self.temp.name)
|
||||
self.record=record();self.candidate=copy.deepcopy(self.record)
|
||||
self.candidate['deployment']['environment']=[{'name':'A','value':'changed'}]
|
||||
self.config=CONFIG
|
||||
self.state={'config':CONFIG,'running':True}
|
||||
self.commands=[]
|
||||
def run(*args):
|
||||
self.commands.append(args)
|
||||
if args[:2]==('pct','status'):return b'status: running' if self.state['running'] else b'status: stopped'
|
||||
if args[:2]==('pct','start'):self.state['running']=True
|
||||
return b''
|
||||
def stop(vmid):self.state['running']=False
|
||||
def setter(vmid,identity,expected,values,delete=()):
|
||||
self.assertEqual(expected,self.state['config'])
|
||||
text=self.state['config'].decode()
|
||||
if 'env' in values or 'env' in delete:
|
||||
public='env: ' in text
|
||||
text=''.join(x for x in text.splitlines(keepends=True) if not x.startswith(('lxc.environment.runtime: ','env: ')))
|
||||
if public and values.get('env'):text+='env: '+values['env']+'\n'
|
||||
else:
|
||||
for item in values.get('env','').split('\0'):
|
||||
if item:text+='lxc.environment.runtime: '+item+'\n'
|
||||
self.state['config']=text.encode()
|
||||
def observe(*args):
|
||||
observed=copy.deepcopy(self.record['observed'])
|
||||
observed.update(config=self.state['config'].decode(),config_sha256=native.sha(self.state['config']))
|
||||
return observed
|
||||
patches=[patch.object(native,'plan',return_value={'values':{'env':'A=changed\0B=two'},'delete':[],'restart':True,'changed':['environment']}),
|
||||
patch.object(native,'set_config',side_effect=setter),patch.object(instances,'command',side_effect=lambda *a:self.state['config']),
|
||||
patch.object(native.transaction,'run',side_effect=run),patch.object(native.transaction,'stop',side_effect=stop),
|
||||
patch.object(native.transaction,'healthcheck'),patch.object(native.transaction,'preflight'),
|
||||
patch.object(native.transaction,'freeze_host_sources',return_value=({},{})),
|
||||
patch.object(native.transaction,'open_log'),patch.object(native.transaction,'log'),
|
||||
patch.object(instances,'observe',side_effect=observe),patch('oci_carried_record.carry')]
|
||||
for p in patches:p.start();self.addCleanup(p.stop)
|
||||
instances.write(instances.location(self.root,115),self.record)
|
||||
|
||||
def apply(self):return native.apply(self.root,self.record,self.candidate,self.config)
|
||||
|
||||
def test_runtime_edit_restarts_existing_guest_and_keeps_rootfs_image_and_data(self):
|
||||
folder=self.apply()
|
||||
saved=instances.read(self.root,115)
|
||||
self.assertEqual(saved['observed']['image'],self.record['observed']['image'])
|
||||
self.assertIn(b'rootfs: local-zfs:subvol-115-disk-0',self.state['config'])
|
||||
self.assertEqual(saved['deployment'],self.candidate['deployment'])
|
||||
self.assertTrue(self.state['running'])
|
||||
self.assertEqual(json.loads((folder/'before.json').read_text())['phase'],'committed')
|
||||
self.assertFalse(any(c[:2] in (('pct','create'),('pct','destroy')) for c in self.commands))
|
||||
|
||||
def test_failed_healthcheck_rolls_back_and_starts_original_service(self):
|
||||
with patch.object(native.transaction,'healthcheck',side_effect=[RuntimeError('not healthy'),None]):
|
||||
with self.assertRaisesRegex(RuntimeError,'not healthy'):self.apply()
|
||||
self.assertEqual(self.state['config'],CONFIG)
|
||||
self.assertTrue(self.state['running'])
|
||||
self.assertEqual(instances.read(self.root,115)['deployment'],self.record['deployment'])
|
||||
|
||||
def test_health_failure_restores_public_nul_separated_environment(self):
|
||||
self.config=CONFIG.replace(b'lxc.environment.runtime: A=one\nlxc.environment.runtime: B=two\n',b'env: A=one\0B=two\n')
|
||||
self.state['config']=self.config
|
||||
self.record['observed'].update(config=self.config.decode(),config_sha256=native.sha(self.config))
|
||||
instances.write(instances.location(self.root,115),self.record)
|
||||
with patch.object(native.transaction,'healthcheck',side_effect=[RuntimeError('not healthy'),None]):
|
||||
with self.assertRaises(RuntimeError):self.apply()
|
||||
self.assertEqual(native.environment(self.state['config']),{'A':'one','B':'two'})
|
||||
self.assertTrue(self.state['running'])
|
||||
|
||||
def test_stopped_guest_stays_stopped(self):
|
||||
self.state['running']=False
|
||||
self.apply()
|
||||
self.assertFalse(self.state['running'])
|
||||
self.assertNotIn(('pct','start','115'),self.commands)
|
||||
|
||||
def test_interrupted_edit_is_recovered_through_transaction_dispatch(self):
|
||||
folder=self.apply()
|
||||
path=folder/'before.json';state=json.loads(path.read_text());state['phase']='configured'
|
||||
instances.write(path,state)
|
||||
pending=copy.deepcopy(self.record)
|
||||
pending.update(status='updating',transaction_id=folder.name,pending_transaction=str(path))
|
||||
instances.write(instances.location(self.root,115),pending)
|
||||
native.transaction.recover(self.root,path)
|
||||
self.assertEqual(self.state['config'],CONFIG)
|
||||
saved=instances.read(self.root,115)
|
||||
self.assertEqual(saved['status'],'installed');self.assertNotIn('pending_transaction',saved)
|
||||
self.assertEqual(json.loads(path.read_text())['phase'],'rolled-back')
|
||||
|
||||
def test_recovery_refuses_a_concurrent_environment_edit(self):
|
||||
folder=self.apply();path=folder/'before.json';state=json.loads(path.read_text());state['phase']='configured'
|
||||
instances.write(path,state)
|
||||
pending=copy.deepcopy(self.record)
|
||||
pending.update(status='updating',transaction_id=folder.name,pending_transaction=str(path))
|
||||
instances.write(instances.location(self.root,115),pending)
|
||||
self.state['config']=self.state['config'].replace(b'A=changed',b'A=concurrent')
|
||||
with self.assertRaisesRegex(ValueError,'changed'):native.transaction.recover(self.root,path)
|
||||
self.assertIn(b'A=concurrent',self.state['config'])
|
||||
|
||||
def test_concurrent_change_before_write_is_not_overwritten(self):
|
||||
def stop(vmid):
|
||||
self.state['running']=False
|
||||
self.state['config']+=b'timezone: host\n'
|
||||
with patch.object(native.transaction,'stop',side_effect=stop):
|
||||
with self.assertRaisesRegex(ValueError,'changed'):self.apply()
|
||||
self.assertIn(b'timezone: host',self.state['config'])
|
||||
self.assertTrue(self.state['running'])
|
||||
|
||||
def test_router_does_not_resolve_download_or_recreate_for_environment_edit(self):
|
||||
with patch.object(instances,'ROOT',self.root),patch.object(instances,'locked',return_value=contextlib.nullcontext()), \
|
||||
patch.object(native.transaction,'external_changes',return_value={}), \
|
||||
patch.object(update,'resolve_archive',side_effect=AssertionError('registry path')), \
|
||||
patch.object(native.transaction,'apply',side_effect=AssertionError('recreation')), \
|
||||
patch.object(oci_operation_notice,'operation',return_value=contextlib.nullcontext()):
|
||||
update.update(115,proposal={'operation':'recreate','candidate':self.candidate})
|
||||
self.assertEqual(instances.read(self.root,115)['deployment']['environment'],self.candidate['deployment']['environment'])
|
||||
|
||||
def test_unsupported_same_image_adaptation_uses_the_existing_recreation_flow(self):
|
||||
self.candidate['deployment']['post_start_configurations']=[{'id':'unsupported-adapter'}]
|
||||
with patch.object(instances,'ROOT',self.root),patch.object(instances,'locked',return_value=contextlib.nullcontext()), \
|
||||
patch.object(native.transaction,'external_changes',return_value={}), \
|
||||
patch.object(update,'resolve_archive',return_value=(Path('/verified.tar'),'sha256:test')) as resolve, \
|
||||
patch.object(native.transaction,'apply') as recreate, \
|
||||
patch.object(native,'apply',side_effect=AssertionError('partial native edit')), \
|
||||
patch.object(oci_operation_notice,'operation',return_value=contextlib.nullcontext()):
|
||||
update.update(115,proposal={'operation':'recreate','candidate':self.candidate})
|
||||
resolve.assert_called_once()
|
||||
recreate.assert_called_once()
|
||||
self.assertEqual(recreate.call_args.args[3],'recreate')
|
||||
|
||||
def test_optional_backup_does_not_force_recreation(self):
|
||||
with patch.object(instances,'ROOT',self.root),patch.object(instances,'locked',return_value=contextlib.nullcontext()), \
|
||||
patch.object(native.transaction,'external_changes',return_value={}), \
|
||||
patch.object(update,'resolve_archive',side_effect=AssertionError('registry path')), \
|
||||
patch.object(update,'backup_native_settings') as backup, \
|
||||
patch.object(oci_operation_notice,'operation',return_value=contextlib.nullcontext()):
|
||||
update.update(115,proposal={'operation':'recreate','candidate':self.candidate},keep_backup='test-storage')
|
||||
backup.assert_called_once_with(115,'test-storage')
|
||||
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
Reference in New Issue
Block a user