diff --git a/.github/scripts/build_translation_cache.py b/.github/scripts/build_translation_cache.py index 95825a29..b5797010 100644 --- a/.github/scripts/build_translation_cache.py +++ b/.github/scripts/build_translation_cache.py @@ -38,6 +38,18 @@ DEFAULT_CONTEXT = "Context: Technical message for Proxmox and IT. Translate:" # provider, then restore the exact source spelling afterwards. Keep longer # terms first so ``gasket`` cannot consume part of ``gasket-dkms``. PROTECTED_TECHNICAL_TERMS = ( + # Product names, kept whole. Left to a translator, "Proxmox Backup Server" + # became "Proxmox Sicherungsserver" and "Serveur de sauvegarde Proxmox", + # and when a provider rightly handed it back unchanged the string was + # retried and then dropped as untranslated. + "Proxmox Backup Server", + "Proxmox Datacenter Manager", + "Proxmox Mail Gateway", + "Proxmox VE", + "ProxMenux Monitor", + "ProxMenux Optimizations", + "Secure Gateway", + "Tailscale", "google/gasket-driver", "feranick/gasket-driver", "libedgetpu1-std", diff --git a/.github/scripts/tests/test_oci_selection_setup_wording.py b/.github/scripts/tests/test_oci_selection_setup_wording.py index 9d35d7d5..7baa2ad5 100644 --- a/.github/scripts/tests/test_oci_selection_setup_wording.py +++ b/.github/scripts/tests/test_oci_selection_setup_wording.py @@ -42,13 +42,13 @@ class SelectionSetupWording(TestCase): shutil=SimpleNamespace(which=lambda _: '/fake/pct'), saved_inventory=lambda _: [], _clean_orphans=lambda project: None, recover_automatically=lambda project: None, offer_recovery=lambda project, ui: None, - carry_records=lambda project, **options: {}, + carry_records=lambda project, **options: {}, _records_unread=lambda project: False, translate=lambda s: s) fn(Path('/fixture'), ui) ui.message.assert_called_once_with(EMPTY, 'OCI management') ui.choose.assert_not_called() - def _menu(self, inventories, selections): + def _menu(self, inventories, selections, unread=True): rows = iter(inventories) ui = SimpleNamespace(message=Mock(), choose=Mock(side_effect=selections)) refresh = Mock(return_value={}) @@ -57,7 +57,8 @@ class SelectionSetupWording(TestCase): shutil=SimpleNamespace(which=lambda _: '/fake/pct'), saved_inventory=lambda _: next(rows), _clean_orphans=lambda project: None, recover_automatically=lambda project: None, offer_recovery=lambda project, ui: None, - carry_records=refresh, manage_instance=manage, menu_size=lambda: 10, + carry_records=refresh, _records_unread=lambda project: unread, + manage_instance=manage, menu_size=lambda: 10, STATUS_LABELS={}, translate=lambda s: s) fn(Path('/fixture'), ui) return ui, refresh, manage @@ -67,7 +68,14 @@ class SelectionSetupWording(TestCase): ui, refresh, manage = self._menu([[row], [row]], ['101', None]) self.assertEqual(ui.choose.call_count, 2) manage.assert_called_once() - refresh.assert_called_once_with(Path('/fixture'), mount_stopped=False, shown=True) + refresh.assert_called_once_with(Path('/fixture'), mount_stopped=False, shown=True, + heading='Manage installed OCI applications') + + def test_the_list_opens_without_the_sync_notice_once_every_configuration_is_cached(self): + row = {'vmid': 101, 'title': 'Jellyfin', 'hostname': 'jellyfin', 'status': 'installed', 'image': 'jellyfin/jellyfin'} + _, refresh, _ = self._menu([[row]], [None], unread=False) + refresh.assert_called_once_with(Path('/fixture'), mount_stopped=False, shown=False, + heading='Manage installed OCI applications') def test_removing_the_last_application_returns_without_a_message(self): row = {'vmid': 101, 'title': 'Jellyfin', 'hostname': 'jellyfin', 'status': 'installed', 'image': 'jellyfin/jellyfin'} diff --git a/.github/workflows/test-oci-registries.yml b/.github/workflows/test-oci-registries.yml index 2d2bf69e..a04ea91a 100644 --- a/.github/workflows/test-oci-registries.yml +++ b/.github/workflows/test-oci-registries.yml @@ -28,6 +28,19 @@ jobs: python-version: '3.13' - name: Install OCI test dependencies run: python3 -m pip install -r oci/requirements.txt + - name: Run carried record, recovery and configuration cache regressions + env: + PYTHONDONTWRITEBYTECODE: '1' + run: | + python3 -m unittest discover -s oci/tests -p 'test_carried*.py' -v + python3 -m unittest discover -s oci/tests -p test_restore_recovery.py -v + - name: Run read-only volume preparation regressions + env: + PYTHONDONTWRITEBYTECODE: '1' + run: | + test -x /bin/bash + test -x /usr/bin/jq + python3 -m unittest discover -s oci/tests -p test_readonly_volume_preparation.py -v - name: Run offline registry account tests env: PYTHONDONTWRITEBYTECODE: '1' @@ -41,3 +54,20 @@ jobs: python3 -m unittest discover -s oci/tests -p test_dialog_escape.py -v python3 -m unittest discover -s oci/tests -p test_work_backup.py -v python3 -m unittest discover -s oci/tests -p test_recreation.py -v + - name: Run OCI modification menu regressions + env: + PYTHONDONTWRITEBYTECODE: '1' + run: | + python3 -m unittest discover -s oci/tests -p test_modify_menu.py -v + python3 -m unittest discover -s oci/tests -p test_manage_sync_notice.py -v + python3 -m unittest discover -s oci/tests -p test_recreation.py -v + python3 -m unittest discover -s oci/tests -p test_shared_directories.py -v + python3 -m unittest discover -s oci/tests -p 'test_wizard*.py' -v + - name: Run external OCI settings regressions + env: + PYTHONDONTWRITEBYTECODE: '1' + run: | + python3 -m unittest discover -s oci/tests -p 'test_external_tags.py' -v + python3 -m unittest discover -s oci/tests -p 'test_instance_reconcile.py' -v + python3 -m unittest discover -s oci/tests -p 'test_guessed_endpoint_healthcheck.py' -v + python3 -m unittest discover -s oci/tests -p 'test_runtime_home_*.py' -v diff --git a/lang/de.json b/lang/de.json index 1bc1956d..f86abc03 100644 --- a/lang/de.json +++ b/lang/de.json @@ -753,6 +753,7 @@ "Cannot verify the reused disk:": "Kann die wiederverwendete Festplatte nicht überprüfen:", "Cannot write the image archive to {storage}. Check storage access and mount status.": "Das Bildarchiv kann nicht in {storage} geschrieben werden. Überprüfen Sie den Speicherzugriff und den Bereitstellungsstatus.", "Capabilities cannot be kept and all dropped at the same time": "Fähigkeiten können nicht gehalten werden und alle gleichzeitig fallen gelassen werden", + "Catalog options": "Katalogoptionen", "Category": "Kategorie", "Caution: Maximum mode generates more heat.": "Achtung: Im Maximalmodus entsteht mehr Wärme.", "Ceph check skipped by user flag (--ignore-ceph-check)": "Ceph-Prüfung durch Benutzerflag übersprungen (--ignore-ceph-check)", @@ -887,6 +888,7 @@ "Choose a schedule (systemd calendar-event syntax):": "Wählen Sie einen Zeitplan (systemd-Kalender-Ereignis-Syntax):", "Choose a script to execute:": "Wählen Sie ein auszuführendes Skript aus:", "Choose a server:": "Wählen Sie einen Server:", + "Choose a setting. Changes are applied only after the final review.": "Wähle eine Einstellung. Änderungen werden erst nach der abschließenden Prüfung angewendet.", "Choose a share to mount:": "Wählen Sie eine Freigabe zum Mounten aus:", "Choose a specific supported GPU or USB node": "Wählen Sie einen bestimmten unterstützten GPU- oder USB-Knoten aus", "Choose action for this controller/NVMe:": "Wählen Sie eine Aktion für diesen Controller/NVMe:", @@ -1772,6 +1774,8 @@ "Disabling and stopping rpcbind service and socket...": "Rpcbind-Dienst und Socket deaktivieren und stoppen ...", "Disabling non-free firmware warnings...": "Warnungen zu nicht kostenloser Firmware werden deaktiviert...", "Disabling portmapper/rpcbind for security...": "Portmapper/rpcbind wird aus Sicherheitsgründen deaktiviert...", + "Discard changes": "Änderungen verwerfen", + "Discard unapplied changes?": "Noch nicht angewendete Änderungen verwerfen?", "Discover targets on portal:": "Entdecken Sie Ziele auf dem Portal:", "Discovering iSCSI targets...": "iSCSI-Ziele werden ermittelt...", "Discovery successful": "Entdeckung erfolgreich", @@ -4270,6 +4274,7 @@ "No password provided.": "Kein Passwort angegeben.", "No paths available": "Keine Pfade verfügbar", "No paths selected. Select at least one path.": "Keine Pfade ausgewählt. Wählen Sie mindestens einen Pfad aus.", + "No pending changes": "Keine ausstehenden Änderungen", "No pending paths to schedule for reboot.": "Keine ausstehenden Pfade zum Planen eines Neustarts.", "No permanent NFS mounts configured.": "Keine permanenten NFS-Mounts konfiguriert.", "No permanent Samba mounts configured.": "Keine permanenten Samba-Mounts konfiguriert.", @@ -4411,6 +4416,7 @@ "OVH RTM removed (Puppet artefacts may need manual cleanup)": "OVH RTM entfernt (Marionettenartefakte müssen möglicherweise manuell bereinigt werden)", "OVH server detected": "OVH-Server erkannt", "OVH server detection and RTM installation process completed": "OVH-Servererkennung und RTM-Installationsprozess abgeschlossen", + "Object detector": "Objekterkennung", "Object detector for Frigate": "Objektdetektor für Frigate", "Observer is not responding on port 4357": "Beobachter reagiert nicht auf Port 4357", "Obsidian is a note-taking app that lets you create, link, and organize your notes on your device, with hundreds of plugins and themes to customize your workflow. You can also publish your notes online, access them offline, and sync them securely with end-to-end encryption.": "Obsidian ist eine Notiz-App, mit der Sie Ihre Notizen auf Ihrem Gerät erstellen, verknüpfen und organisieren können, mit Hunderten von Plugins und Themes, um Ihren Workflow anzupassen. Sie können Ihre Notizen auch online veröffentlichen, offline darauf zugreifen und sie sicher mit einer End-to-End-Verschlüsselung synchronisieren.", @@ -5342,6 +5348,7 @@ "Reverting ZFS ARC tuning...": "ZFS ARC-Tuning wird zurückgesetzt...", "Reverting pigz wrapper...": "Pigz-Wrapper wird zurückgesetzt...", "Reverting vzdump speed tuning...": "vzdump-Geschwindigkeitsoptimierung wird zurückgesetzt...", + "Review and apply": "Prüfen und anwenden", "Review external OCI changes": "Überprüfen Sie externe OCI-Änderungen", "Review package upgrade": "Paketaktualisierung prüfen", "Review passthrough config files": "Überprüfen Sie die Passthrough-Konfigurationsdateien", @@ -5801,6 +5808,7 @@ "ShareCoveX panel": "ShareCoveX-Panel", "Shared Directory Ready:": "Bereit für das freigegebene Verzeichnis:", "Shared Group": "Geteilte Gruppe", + "Shared directories": "Freigegebene Verzeichnisse", "Shared directory created:": "Gemeinsames Verzeichnis erstellt:", "Shared directory for consume and export": "Gemeinsames Verzeichnis für Konsum und Export", "Shared directory for copy/sync operations": "Gemeinsames Verzeichnis für Copy/Sync operations", @@ -7013,6 +7021,7 @@ "Unable to identify the root filesystem. Aborting.": "Das Root-Dateisystem konnte nicht identifiziert werden. Abbruch.", "Unable to resolve system ZFS pool disks. Aborting.": "System-Pool-Festplatten ZFS können nicht aufgelöst werden. Abbruch.", "Unable to resolve system disk topology. Aborting.": "Die Topologie der Systemfestplatte kann nicht aufgelöst werden. Abbruch.", + "Unapplied changes": "Noch nicht angewendete Änderungen", "Understand the security implications of privileged containers": "Verstehen Sie die Sicherheitsauswirkungen privilegierter Container", "Unexpected Proxmox inventory; recovery blocked": "Unerwartetes Proxmox-Inventar; Erholung blockiert", "Unexpected formatting directory in the new Valkey volume": "Unerwartetes Formatierungsverzeichnis im neuen Valkey-Volume", diff --git a/lang/es.json b/lang/es.json index 0ddda5c8..60edefc6 100644 --- a/lang/es.json +++ b/lang/es.json @@ -752,6 +752,7 @@ "Cannot verify the reused disk:": "No se puede verificar el disco reutilizado:", "Cannot write the image archive to {storage}. Check storage access and mount status.": "No se puede escribir el archivo de la imagen en {storage}. Comprueba el acceso al almacenamiento y que esté montado.", "Capabilities cannot be kept and all dropped at the same time": "No se pueden mantener las capacidades y todas se disminuyen al mismo tiempo", + "Catalog options": "Opciones del catálogo", "Category": "Categoría", "Caution: Maximum mode generates more heat.": "Precaución: el modo máximo genera más calor.", "Ceph check skipped by user flag (--ignore-ceph-check)": "Comprobación de Ceph omitida por el indicador del usuario (--ignore-ceph-check)", @@ -886,6 +887,7 @@ "Choose a schedule (systemd calendar-event syntax):": "Elija una programación (sintaxis de eventos de calendario de systemd):", "Choose a script to execute:": "Elige un script para ejecutar:", "Choose a server:": "Elige un servidor:", + "Choose a setting. Changes are applied only after the final review.": "Elige qué configurar. Los cambios solo se aplican tras la revisión final.", "Choose a share to mount:": "Elige un recurso compartido para montar:", "Choose a specific supported GPU or USB node": "Elija un nodo concreto de GPU o USB compatible", "Choose action for this controller/NVMe:": "Elige la acción para este controlador/NVMe:", @@ -1770,6 +1772,8 @@ "Disabling and stopping rpcbind service and socket...": "Deshabilitar y detener el servicio y el socket rpcbind...", "Disabling non-free firmware warnings...": "Deshabilitando las advertencias de firmware no libre...", "Disabling portmapper/rpcbind for security...": "Deshabilitando portmapper/rpcbind por seguridad...", + "Discard changes": "Descartar cambios", + "Discard unapplied changes?": "¿Descartar los cambios sin aplicar?", "Discover targets on portal:": "Detectar destinos en el portal:", "Discovering iSCSI targets...": "Descubriendo destinos iSCSI...", "Discovery successful": "Detección correcta", @@ -4265,6 +4269,7 @@ "No password provided.": "No se proporcionó contraseña.", "No paths available": "No hay rutas disponibles", "No paths selected. Select at least one path.": "No hay rutas seleccionadas. Seleccione al menos una ruta.", + "No pending changes": "No hay cambios pendientes", "No pending paths to schedule for reboot.": "No hay rutas pendientes para programar el reinicio.", "No permanent NFS mounts configured.": "No se han configurado montajes NFS permanentes.", "No permanent Samba mounts configured.": "No hay montajes Samba permanentes configurados.", @@ -4403,6 +4408,7 @@ "OVH RTM removed (Puppet artefacts may need manual cleanup)": "OVH RTM eliminado (los artefactos de las marionetas pueden necesitar una limpieza manual)", "OVH server detected": "Servidor OVH detectado", "OVH server detection and RTM installation process completed": "Finalizado el proceso de detección del servidor OVH e instalación de RTM", + "Object detector": "Detector de objetos", "Object detector for Frigate": "Detector de objetos para Frigate", "Observer is not responding on port 4357": "El observador no responde en el puerto 4357", "Obsidian is a note-taking app that lets you create, link, and organize your notes on your device, with hundreds of plugins and themes to customize your workflow. You can also publish your notes online, access them offline, and sync them securely with end-to-end encryption.": "Obsidian es una aplicación de notas que te permite crear, enlazar y organizar tus notas en tu dispositivo, con cientos de plugins y temas para personalizar tu flujo de trabajo. También puedes publicar tus notas en línea, acceder a ellas sin conexión y sincronizarlas de forma segura con cifrado de extremo a extremo.", @@ -5326,6 +5332,7 @@ "Reverting ZFS ARC tuning...": "Revirtiendo el ajuste ZFS ARC...", "Reverting pigz wrapper...": "Revertir la envoltura de pigz...", "Reverting vzdump speed tuning...": "Revertir el ajuste de velocidad de vzdump...", + "Review and apply": "Revisar y aplicar", "Review external OCI changes": "Revisar cambios externos de OCI", "Review package upgrade": "Revisar la actualización de paquetes", "Review passthrough config files": "Revisar los archivos de configuración de passthrough", @@ -5785,6 +5792,7 @@ "ShareCoveX panel": "Panel de ShareCoveX", "Shared Directory Ready:": "Directorio compartido listo:", "Shared Group": "Grupo compartido", + "Shared directories": "Directorios compartidos", "Shared directory created:": "Directorio compartido creado:", "Shared directory for consume and export": "Directorio compartido para consumo y exportación", "Shared directory for copy/sync operations": "Directorio compartido para operaciones de copia/sincronización", @@ -6988,6 +6996,7 @@ "Unable to identify the root filesystem. Aborting.": "No se pudo identificar el sistema de archivos raíz. Se cancela.", "Unable to resolve system ZFS pool disks. Aborting.": "No se pudieron resolver los discos del pool ZFS del sistema. Se cancela.", "Unable to resolve system disk topology. Aborting.": "No se pudo resolver la topología de discos del sistema. Se cancela.", + "Unapplied changes": "Cambios sin aplicar", "Understand the security implications of privileged containers": "Comprender las implicaciones de seguridad de los contenedores privilegiados", "Unexpected Proxmox inventory; recovery blocked": "Inventario Proxmox inesperado; recuperación bloqueada", "Unexpected formatting directory in the new Valkey volume": "Directorio de formato inesperado en el nuevo volumen Valkey", diff --git a/lang/fr.json b/lang/fr.json index 5fa19db0..9dc13952 100644 --- a/lang/fr.json +++ b/lang/fr.json @@ -753,6 +753,7 @@ "Cannot verify the reused disk:": "Impossible de vérifier le disque réutilisé :", "Cannot write the image archive to {storage}. Check storage access and mount status.": "Impossible d'écrire l'archive d'images sur {storage}. Vérifiez l'accès au stockage et l'état du montage.", "Capabilities cannot be kept and all dropped at the same time": "Les capacités ne peuvent être conservées et toutes abandonnées en même temps", + "Catalog options": "Options du catalogue", "Category": "Catégorie", "Caution: Maximum mode generates more heat.": "Attention : le mode maximum génère plus de chaleur.", "Ceph check skipped by user flag (--ignore-ceph-check)": "Vérification Ceph ignorée par l'indicateur utilisateur (--ignore-ceph-check)", @@ -887,6 +888,7 @@ "Choose a schedule (systemd calendar-event syntax):": "Choisissez un calendrier (syntaxe d'événement de calendrier systemd) :", "Choose a script to execute:": "Choisissez un script à exécuter :", "Choose a server:": "Choisissez un serveur :", + "Choose a setting. Changes are applied only after the final review.": "Choisissez un réglage. Les modifications ne sont appliquées qu’après la vérification finale.", "Choose a share to mount:": "Choisissez un partage à monter :", "Choose a specific supported GPU or USB node": "Choisissez un GPU ou un nœud USB pris en charge spécifique", "Choose action for this controller/NVMe:": "Choisissez une action pour ce contrôleur/NVMe :", @@ -1772,6 +1774,8 @@ "Disabling and stopping rpcbind service and socket...": "Désactivation et arrêt du service et du socket rpcbind...", "Disabling non-free firmware warnings...": "Désactivation des avertissements du firmware non libre...", "Disabling portmapper/rpcbind for security...": "Désactivation de portmapper/rpcbind pour la sécurité...", + "Discard changes": "Abandonner les modifications", + "Discard unapplied changes?": "Abandonner les modifications non appliquées ?", "Discover targets on portal:": "Découvrez les cibles sur le portail :", "Discovering iSCSI targets...": "Découverte des cibles iSCSI...", "Discovery successful": "Découverte réussie", @@ -4270,6 +4274,7 @@ "No password provided.": "Aucun mot de passe fourni.", "No paths available": "Aucun chemin disponible", "No paths selected. Select at least one path.": "Aucun chemin sélectionné. Sélectionnez au moins un chemin.", + "No pending changes": "Aucune modification en attente", "No pending paths to schedule for reboot.": "Aucun chemin en attente pour planifier le redémarrage.", "No permanent NFS mounts configured.": "Aucun montage NFS permanent configuré.", "No permanent Samba mounts configured.": "Aucun montage Samba permanent configuré.", @@ -4411,6 +4416,7 @@ "OVH RTM removed (Puppet artefacts may need manual cleanup)": "OVH RTM supprimé (les artefacts de marionnettes peuvent nécessiter un nettoyage manuel)", "OVH server detected": "Serveur OVH détecté", "OVH server detection and RTM installation process completed": "Processus de détection du serveur OVH et d'installation de RTM terminé", + "Object detector": "Détecteur d’objets", "Object detector for Frigate": "Détecteur d'objets pour Frigate", "Observer is not responding on port 4357": "L'observateur ne répond pas au port 4357", "Obsidian is a note-taking app that lets you create, link, and organize your notes on your device, with hundreds of plugins and themes to customize your workflow. You can also publish your notes online, access them offline, and sync them securely with end-to-end encryption.": "Obsidian est une application de prise de notes qui vous permet de créer, de lier et d'organiser vos notes sur votre appareil, avec des centaines de plugins et de thèmes pour personnaliser votre workflow. Vous pouvez également publier vos notes en ligne, les accéder hors ligne et les synchroniser en toute sécurité avec le chiffrement de bout en bout.", @@ -5342,6 +5348,7 @@ "Reverting ZFS ARC tuning...": "Annulation du réglage ZFS ARC...", "Reverting pigz wrapper...": "Rétablissement du wrapper pigz...", "Reverting vzdump speed tuning...": "Annulation du réglage de la vitesse de vzdump...", + "Review and apply": "Vérifier et appliquer", "Review external OCI changes": "Examiner les modifications externes de OCI", "Review package upgrade": "Examiner la mise à niveau des paquets", "Review passthrough config files": "Examiner les fichiers de configuration relais", @@ -5801,6 +5808,7 @@ "ShareCoveX panel": "Panneau ShareCoveX", "Shared Directory Ready:": "Répertoire partagé prêt :", "Shared Group": "Groupe partagé", + "Shared directories": "Dossiers partagés", "Shared directory created:": "Répertoire partagé créé & #160;:", "Shared directory for consume and export": "Répertoire partagé pour la consommation et l'exportation", "Shared directory for copy/sync operations": "Répertoire partagé pour copier/sync operations", @@ -7012,6 +7020,7 @@ "Unable to identify the root filesystem. Aborting.": "Impossible d'identifier le système de fichiers racine. Avorter.", "Unable to resolve system ZFS pool disks. Aborting.": "Impossible de résoudre les disques du pool système ZFS.Avorter.", "Unable to resolve system disk topology. Aborting.": "Impossible de résoudre la topologie du disque système. Avorter.", + "Unapplied changes": "Modifications non appliquées", "Understand the security implications of privileged containers": "Comprendre les implications de sécurité des conteneurs privilégiés", "Unexpected Proxmox inventory; recovery blocked": "stocks imprévus Proxmox; récupération bloquée", "Unexpected formatting directory in the new Valkey volume": "Répertoire de formatage inattendu dans le nouveau volume Valkey", diff --git a/lang/it.json b/lang/it.json index db7b48a6..8933e853 100644 --- a/lang/it.json +++ b/lang/it.json @@ -753,6 +753,7 @@ "Cannot verify the reused disk:": "Impossibile verificare il disco riutilizzato:", "Cannot write the image archive to {storage}. Check storage access and mount status.": "Impossibile scrivere l'archivio immagini su {storage}. Controlla l'accesso allo spazio di archiviazione e lo stato di montaggio.", "Capabilities cannot be kept and all dropped at the same time": "Non è possibile conservare alcune capability e rimuoverle tutte contemporaneamente", + "Catalog options": "Opzioni del catalogo", "Category": "Categoria", "Caution: Maximum mode generates more heat.": "Attenzione: la modalità massima genera più calore.", "Ceph check skipped by user flag (--ignore-ceph-check)": "Controllo Ceph saltato dal flag utente (--ignore-ceph-check)", @@ -887,6 +888,7 @@ "Choose a schedule (systemd calendar-event syntax):": "Scegli una pianificazione (sintassi calendar-event di systemd):", "Choose a script to execute:": "Scegli uno script da eseguire:", "Choose a server:": "Scegli un server:", + "Choose a setting. Changes are applied only after the final review.": "Scegli cosa configurare. Le modifiche vengono applicate solo dopo la verifica finale.", "Choose a share to mount:": "Scegli una condivisione da montare:", "Choose a specific supported GPU or USB node": "Scegli uno specifico nodo GPU o USB supportato", "Choose action for this controller/NVMe:": "Scegli l'azione per questo controller/NVMe:", @@ -1772,6 +1774,8 @@ "Disabling and stopping rpcbind service and socket...": "Disabilitazione e arresto del servizio e del socket rpcbind...", "Disabling non-free firmware warnings...": "Disabilitazione degli avvisi sul firmware non libero...", "Disabling portmapper/rpcbind for security...": "Disabilitazione di portmapper/rpcbind per sicurezza...", + "Discard changes": "Scarta le modifiche", + "Discard unapplied changes?": "Scartare le modifiche non applicate?", "Discover targets on portal:": "Scopri target sul portale:", "Discovering iSCSI targets...": "Individuazione dei target iSCSI...", "Discovery successful": "Scoperta riuscita", @@ -4270,6 +4274,7 @@ "No password provided.": "Nessuna password fornita.", "No paths available": "Nessun percorso disponibile", "No paths selected. Select at least one path.": "Nessun percorso selezionato. Seleziona almeno un percorso.", + "No pending changes": "Nessuna modifica in sospeso", "No pending paths to schedule for reboot.": "Nessun percorso in sospeso da pianificare per il riavvio.", "No permanent NFS mounts configured.": "Nessun montaggio NFS permanente configurato.", "No permanent Samba mounts configured.": "Nessun montaggio Samba permanente configurato.", @@ -4411,6 +4416,7 @@ "OVH RTM removed (Puppet artefacts may need manual cleanup)": "OVH RTM rimosso (gli artefatti di Puppet potrebbero richiedere una pulizia manuale)", "OVH server detected": "Rilevato server OVH", "OVH server detection and RTM installation process completed": "Processo di rilevamento dei server OVH e installazione RTM completato", + "Object detector": "Rilevatore di oggetti", "Object detector for Frigate": "Rilevatore di oggetti per Frigate", "Observer is not responding on port 4357": "Observer non risponde sulla porta 4357", "Obsidian is a note-taking app that lets you create, link, and organize your notes on your device, with hundreds of plugins and themes to customize your workflow. You can also publish your notes online, access them offline, and sync them securely with end-to-end encryption.": "Obsidian è un’app per creare, collegare e organizzare le note sul tuo dispositivo, con centinaia di plugin e temi per personalizzare il flusso di lavoro. Puoi pubblicare le note online, consultarle offline e sincronizzarle in modo sicuro con crittografia end-to-end.", @@ -5342,6 +5348,7 @@ "Reverting ZFS ARC tuning...": "Rimozione delle ottimizzazioni di ZFS ARC…", "Reverting pigz wrapper...": "Rimozione del wrapper pigz e ripristino della configurazione precedente...", "Reverting vzdump speed tuning...": "Rimozione ottimizzazioni velocità vzdump…", + "Review and apply": "Verifica e applica", "Review external OCI changes": "Esamina le modifiche OCI esterne", "Review package upgrade": "Esamina l'aggiornamento dei pacchetti", "Review passthrough config files": "Esamina i file di configurazione passthrough", @@ -5801,6 +5808,7 @@ "ShareCoveX panel": "Pannello ShareCoveX", "Shared Directory Ready:": "Directory condivisa pronta:", "Shared Group": "Gruppo condiviso", + "Shared directories": "Cartelle condivise", "Shared directory created:": "Directory condivisa creata:", "Shared directory for consume and export": "Directory condivisa per consume ed export", "Shared directory for copy/sync operations": "Directory condivisa per copia e sincronizzazione", @@ -7013,6 +7021,7 @@ "Unable to identify the root filesystem. Aborting.": "Impossibile identificare il filesystem root. Interruzione.", "Unable to resolve system ZFS pool disks. Aborting.": "Impossibile individuare i dischi del pool ZFS di sistema. Interruzione.", "Unable to resolve system disk topology. Aborting.": "Impossibile determinare la topologia del disco di sistema. Interruzione.", + "Unapplied changes": "Modifiche non applicate", "Understand the security implications of privileged containers": "Comprendere le implicazioni sulla sicurezza dei container privilegiati", "Unexpected Proxmox inventory; recovery blocked": "Inventario Proxmox inatteso; ripristino bloccato", "Unexpected formatting directory in the new Valkey volume": "Directory di formattazione imprevista nel nuovo volume Valkey", diff --git a/lang/pt.json b/lang/pt.json index b7d5011d..aa3b8e49 100644 --- a/lang/pt.json +++ b/lang/pt.json @@ -753,6 +753,7 @@ "Cannot verify the reused disk:": "Não foi possível verificar o disco reutilizado:", "Cannot write the image archive to {storage}. Check storage access and mount status.": "Não é possível gravar o arquivo de imagem em {storage}. Verifique o acesso ao armazenamento e o status de montagem.", "Capabilities cannot be kept and all dropped at the same time": "Capacidades não podem ser mantidas e todas retiradas ao mesmo tempo", + "Catalog options": "Opções do catálogo", "Category": "Categoria", "Caution: Maximum mode generates more heat.": "Cuidado: O modo máximo gera mais calor.", "Ceph check skipped by user flag (--ignore-ceph-check)": "Verificação do Ceph ignorada pelo sinalizador do usuário (--ignore-ceph-check)", @@ -887,6 +888,7 @@ "Choose a schedule (systemd calendar-event syntax):": "Escolha uma programação (sintaxe de evento de calendário do systemd):", "Choose a script to execute:": "Escolha um script para executar:", "Choose a server:": "Escolha um servidor:", + "Choose a setting. Changes are applied only after the final review.": "Escolha o que configurar. As alterações só são aplicadas após a revisão final.", "Choose a share to mount:": "Escolha um compartilhamento para montar:", "Choose a specific supported GPU or USB node": "Escolha um GPU ou nó USB específico compatível", "Choose action for this controller/NVMe:": "Escolha a ação para este controlador/NVMe:", @@ -1772,6 +1774,8 @@ "Disabling and stopping rpcbind service and socket...": "Desativando e parando o serviço e soquete rpcbind...", "Disabling non-free firmware warnings...": "Desativando avisos de firmware não-livre...", "Disabling portmapper/rpcbind for security...": "Desativando portmapper/rpcbind por segurança...", + "Discard changes": "Descartar alterações", + "Discard unapplied changes?": "Descartar as alterações por aplicar?", "Discover targets on portal:": "Descubra alvos no portal:", "Discovering iSCSI targets...": "Descobrindo alvos iSCSI...", "Discovery successful": "Descoberta bem-sucedida", @@ -4270,6 +4274,7 @@ "No password provided.": "Nenhuma senha fornecida.", "No paths available": "Nenhum caminho disponível", "No paths selected. Select at least one path.": "Nenhum caminho selecionado. Selecione pelo menos um caminho.", + "No pending changes": "Sem alterações pendentes", "No pending paths to schedule for reboot.": "Não há caminhos pendentes para agendar a reinicialização.", "No permanent NFS mounts configured.": "Nenhuma montagem NFS permanente configurada.", "No permanent Samba mounts configured.": "Nenhuma montagem permanente do Samba configurada.", @@ -4411,6 +4416,7 @@ "OVH RTM removed (Puppet artefacts may need manual cleanup)": "OVH RTM removido (artefatos de marionetes podem precisar de limpeza manual)", "OVH server detected": "Servidor OVH detectado", "OVH server detection and RTM installation process completed": "Processo de detecção do servidor OVH e instalação RTM concluído", + "Object detector": "Detetor de objetos", "Object detector for Frigate": "Detector de objetos para Frigate", "Observer is not responding on port 4357": "Observador não está respondendo na porta 4357", "Obsidian is a note-taking app that lets you create, link, and organize your notes on your device, with hundreds of plugins and themes to customize your workflow. You can also publish your notes online, access them offline, and sync them securely with end-to-end encryption.": "Obsidian é um aplicativo de anotações que permite criar, vincular e organizar suas notas em seu dispositivo, com centenas de plugins e temas para personalizar seu fluxo de trabalho. Você também pode publicar suas notas online, acessá-las offline e sincronizá-las com segurança com criptografia de ponta a ponta.", @@ -5342,6 +5348,7 @@ "Reverting ZFS ARC tuning...": "Revertendo ajuste do ZFS ARC...", "Reverting pigz wrapper...": "Revertendo o wrapper pigz...", "Reverting vzdump speed tuning...": "Revertendo o ajuste de velocidade do vzdump...", + "Review and apply": "Rever e aplicar", "Review external OCI changes": "revisar alterações externas do OCI", "Review package upgrade": "Rever a atualização de pacotes", "Review passthrough config files": "Revise os arquivos de configuração de passagem", @@ -5801,6 +5808,7 @@ "ShareCoveX panel": "Painel ShareCoveX", "Shared Directory Ready:": "Diretório compartilhado pronto:", "Shared Group": "Grupo Compartilhado", + "Shared directories": "Diretórios partilhados", "Shared directory created:": "Directório partilhado criado:", "Shared directory for consume and export": "Diretório compartilhado para consumir e exportar", "Shared directory for copy/sync operations": "Diretório compartilhado para copiar/sincronizar operations", @@ -7013,6 +7021,7 @@ "Unable to identify the root filesystem. Aborting.": "Não foi possível identificar o sistema de arquivos raiz. Abortando.", "Unable to resolve system ZFS pool disks. Aborting.": "Não foi possível resolver os discos do pool ZFS do sistema. Abortando.", "Unable to resolve system disk topology. Aborting.": "Não é possível resolver a topologia do disco do sistema. Abortando.", + "Unapplied changes": "Alterações por aplicar", "Understand the security implications of privileged containers": "Entenda as implicações de segurança de contêineres privilegiados", "Unexpected Proxmox inventory; recovery blocked": "Inesperado inventário Proxmox; recuperação bloqueada", "Unexpected formatting directory in the new Valkey volume": "Directório de formatação inesperado no novo volume Valkey", diff --git a/lang/sk.json b/lang/sk.json index ccb23c92..d1e0c14f 100644 --- a/lang/sk.json +++ b/lang/sk.json @@ -753,6 +753,7 @@ "Cannot verify the reused disk:": "Nie je možné overiť znovu použitý disk:", "Cannot write the image archive to {storage}. Check storage access and mount status.": "Nie je možné zapísať archív obrázkov do {storage}. Skontrolujte prístup k úložisku a stav pripojenia.", "Capabilities cannot be kept and all dropped at the same time": "Nie je možné udržať si schopnosti a zároveň ich znížiť.", + "Catalog options": "Možnosti katalógu", "Category": "Kategória", "Caution: Maximum mode generates more heat.": "Pozor: maximálny režim vytvára viac tepla.", "Ceph check skipped by user flag (--ignore-ceph-check)": "Kontrola Ceph preskočená používateľskou voľbou (--ignore-ceph-check)", @@ -887,6 +888,7 @@ "Choose a schedule (systemd calendar-event syntax):": "Vyberte plán (syntax systémového kalendára a udalosti):", "Choose a script to execute:": "Vyberte skript, ktorý sa má spustiť:", "Choose a server:": "Vyberte server:", + "Choose a setting. Changes are applied only after the final review.": "Vyberte nastavenie. Zmeny sa použijú až po záverečnej kontrole.", "Choose a share to mount:": "Vyberte zdieľanie na pripojenie:", "Choose a specific supported GPU or USB node": "Vyberte konkrétny podporovaný uzol GPU alebo USB", "Choose action for this controller/NVMe:": "Vyberte akciu pre tento Controller/NVMe:", @@ -1772,6 +1774,8 @@ "Disabling and stopping rpcbind service and socket...": "Zakázanie a zastavenie služby rpcbind a soketu...", "Disabling non-free firmware warnings...": "Vypínam upozornenia na non-free firmware...", "Disabling portmapper/rpcbind for security...": "Z bezpečnostných dôvodov vypínam portmapper/rpcbind...", + "Discard changes": "Zahodiť zmeny", + "Discard unapplied changes?": "Zahodiť nepoužité zmeny?", "Discover targets on portal:": "Vyhľadať targety na portáli:", "Discovering iSCSI targets...": "Vyhľadávam iSCSI targety...", "Discovery successful": "Vyhľadanie bolo úspešné", @@ -4270,6 +4274,7 @@ "No password provided.": "Nebolo zadané heslo.", "No paths available": "Nie sú dostupné žiadne cesty", "No paths selected. Select at least one path.": "Nie sú vybrané žiadne cesty. Vyberte aspoň jednu cestu.", + "No pending changes": "Žiadne čakajúce zmeny", "No pending paths to schedule for reboot.": "Nie sú žiadne čakajúce cesty, ktoré by sa mali naplánovať na reštart.", "No permanent NFS mounts configured.": "Nie sú nastavené žiadne trvalé NFS pripojenia.", "No permanent Samba mounts configured.": "Nie sú nastavené žiadne trvalé Samba pripojenia.", @@ -4413,6 +4418,7 @@ "OVH RTM removed (Puppet artefacts may need manual cleanup)": "OVH RTM bol odstránený (Puppet súbory môže byť potrebné vyčistiť ručne)", "OVH server detected": "Zistený OVH server", "OVH server detection and RTM installation process completed": "Kontrola OVH servera a inštalácia RTM sú dokončené", + "Object detector": "Detektor objektov", "Object detector for Frigate": "Detektor objektov pre Frigate", "Observer is not responding on port 4357": "Pozorovateľ nereaguje na prístav 4357", "Obsidian is a note-taking app that lets you create, link, and organize your notes on your device, with hundreds of plugins and themes to customize your workflow. You can also publish your notes online, access them offline, and sync them securely with end-to-end encryption.": "Obsidian je note-prijímajúce aplikácie, ktoré vám umožní vytvoriť, odkaz, a usporiadať poznámky na vašom zariadení, so stovkami pluginov a tém prispôsobiť svoje pracovné postupy. Môžete tiež zverejniť svoje poznámky on-line, prístup je offline, a synchronizovať ich bezpečne s end-to-end šifrovanie.", @@ -5344,6 +5350,7 @@ "Reverting ZFS ARC tuning...": "Vraciam späť ladenie ZFS ARC...", "Reverting pigz wrapper...": "Vraciam späť wrapper pigz...", "Reverting vzdump speed tuning...": "Vraciam späť ladenie rýchlosti vzdump...", + "Review and apply": "Skontrolovať a použiť", "Review external OCI changes": "Skontrolujte externé zmeny OCI", "Review package upgrade": "Skontrolovať aktualizáciu balíkov", "Review passthrough config files": "Skontrolovať konfiguračné súbory passthrough", @@ -5803,6 +5810,7 @@ "ShareCoveX panel": "Panel ShareCoveX", "Shared Directory Ready:": "Zdieľaný priečinok je pripravený:", "Shared Group": "Zdieľaná skupina", + "Shared directories": "Zdieľané priečinky", "Shared directory created:": "Vytvorený zdieľaný adresár:", "Shared directory for consume and export": "Zdieľaný adresár pre spotrebu a export", "Shared directory for copy/sync operations": "Zdieľaný adresár pre kopírovanie/sync operations", @@ -7014,6 +7022,7 @@ "Unable to identify the root filesystem. Aborting.": "Nepodarilo sa identifikovať koreňový súborový systém. Prerušuje sa.", "Unable to resolve system ZFS pool disks. Aborting.": "Nepodarilo sa vyriešiť systémové disky ZFS fondu. Prerušuje sa.", "Unable to resolve system disk topology. Aborting.": "Nepodarilo sa vyriešiť topológiu systémového disku. Prerušuje sa.", + "Unapplied changes": "Nepoužité zmeny", "Understand the security implications of privileged containers": "Uistite sa, že rozumiete bezpečnostným dôsledkom privilegovaných kontajnerov", "Unexpected Proxmox inventory; recovery blocked": "Neočakávaný inventár Proxmox; zablokovaná obnova", "Unexpected formatting directory in the new Valkey volume": "Neočakávaný formátovanie adresára v novom hlasitosti Valkey", diff --git a/lang/sv.json b/lang/sv.json index 57a2c152..2fdf2b62 100644 --- a/lang/sv.json +++ b/lang/sv.json @@ -753,6 +753,7 @@ "Cannot verify the reused disk:": "Kan inte verifiera den återanvända disken:", "Cannot write the image archive to {storage}. Check storage access and mount status.": "Kan inte skriva bildarkivet till {storage}. Kontrollera lagringsåtkomst och monteringsstatus.", "Capabilities cannot be kept and all dropped at the same time": "Förmågor kan inte hållas och alla tappas samtidigt", + "Catalog options": "Katalogalternativ", "Category": "Kategori", "Caution: Maximum mode generates more heat.": "Varning: Maximalt läge genererar mer värme.", "Ceph check skipped by user flag (--ignore-ceph-check)": "Ceph-kontroll hoppas över av användarflaggan (--ignore-ceph-check)", @@ -887,6 +888,7 @@ "Choose a schedule (systemd calendar-event syntax):": "Välj ett schema (syntax för systemd kalenderhändelser):", "Choose a script to execute:": "Välj ett skript att köra:", "Choose a server:": "Välj en server:", + "Choose a setting. Changes are applied only after the final review.": "Välj en inställning. Ändringarna tillämpas först efter den slutliga granskningen.", "Choose a share to mount:": "Välj en resurs att montera:", "Choose a specific supported GPU or USB node": "Välj en specifik GPU- eller USB-nod som stöds", "Choose action for this controller/NVMe:": "Välj åtgärd för denna kontroller/NVMe:", @@ -1772,6 +1774,8 @@ "Disabling and stopping rpcbind service and socket...": "Inaktivera och stoppa rpcbind-tjänsten och -socket...", "Disabling non-free firmware warnings...": "Inaktiverar icke-fri firmware-varningar...", "Disabling portmapper/rpcbind for security...": "Inaktiverar portmapper/rpcbind för säkerhet...", + "Discard changes": "Kasta ändringar", + "Discard unapplied changes?": "Kasta ändringar som inte har tillämpats?", "Discover targets on portal:": "Upptäck mål på portalen:", "Discovering iSCSI targets...": "Upptäcker iSCSI-mål...", "Discovery successful": "Upptäckten lyckades", @@ -4270,6 +4274,7 @@ "No password provided.": "Inget lösenord angett.", "No paths available": "Inga vägar tillgängliga", "No paths selected. Select at least one path.": "Inga sökvägar har valts. Välj minst en väg.", + "No pending changes": "Inga väntande ändringar", "No pending paths to schedule for reboot.": "Inga väntande sökvägar att schemalägga för omstart.", "No permanent NFS mounts configured.": "Inga permanenta NFS-monteringar har konfigurerats.", "No permanent Samba mounts configured.": "Inga permanenta Samba-fästen har konfigurerats.", @@ -4411,6 +4416,7 @@ "OVH RTM removed (Puppet artefacts may need manual cleanup)": "OVH RTM har tagits bort (Puppet artefakter kan behöva manuell rengöring)", "OVH server detected": "OVH-server upptäckt", "OVH server detection and RTM installation process completed": "OVH-serverdetektering och RTM-installation har slutförts", + "Object detector": "Objektdetektor", "Object detector for Frigate": "Objektdetektor för Frigate", "Observer is not responding on port 4357": "Observer svarar inte på port 4357", "Obsidian is a note-taking app that lets you create, link, and organize your notes on your device, with hundreds of plugins and themes to customize your workflow. You can also publish your notes online, access them offline, and sync them securely with end-to-end encryption.": "Obsidian är en noterande app som låter dig skapa, länka och organisera dina anteckningar på din enhet, med hundratals plugins och teman för att anpassa ditt arbetsflöde. Du kan också publicera dina anteckningar online, komma åt dem offline och synkronisera dem säkert med end-to-end-kryptering.", @@ -4883,8 +4889,10 @@ "Proxmology logo applied": "Proxmology-logotyp applicerad", "Proxmox 9 system update allready": "Proxmox 9 systemuppdatering redan", "Proxmox APT repositories configured": "Proxmox APT-förråd konfigurerade", + "Proxmox Backup Server": "Proxmox Backup Server", "Proxmox Backup Server (PBS) destinations": "Destinationer för Proxmox Backup Server (PBS)", "Proxmox Backup Server APT repositories configured": "Proxmox Backup Server APT-förråd konfigurerade", + "Proxmox Backup Server Update": "Proxmox Backup Server Uppdatering", "Proxmox Backup Server update completed.": "Proxmox Backup Server-uppdatering slutförd.", "Proxmox CIFS Storage Status:": "Proxmox CIFS-lagringsstatus:", "Proxmox NFS Storage Status:": "Proxmox NFS-lagringsstatus:", @@ -5340,6 +5348,7 @@ "Reverting ZFS ARC tuning...": "Återställer ZFS ARC-inställning...", "Reverting pigz wrapper...": "Återställer pigz-omslag...", "Reverting vzdump speed tuning...": "Återställer vzdump-hastighetsinställning...", + "Review and apply": "Granska och tillämpa", "Review external OCI changes": "Granska externa OCI ändringar", "Review package upgrade": "Granska paketuppgraderingen", "Review passthrough config files": "Granska passthrough-konfigurationsfiler", @@ -5799,6 +5808,7 @@ "ShareCoveX panel": "ShareCoveX panel", "Shared Directory Ready:": "Redo för delad katalog:", "Shared Group": "Delad grupp", + "Shared directories": "Delade kataloger", "Shared directory created:": "Delad katalog skapad:", "Shared directory for consume and export": "Delad katalog för konsumering och export", "Shared directory for copy/sync operations": "Delad katalog för kopia/synkronisera operations", @@ -7011,6 +7021,7 @@ "Unable to identify the root filesystem. Aborting.": "Det går inte att identifiera rotfilsystemet. Avbryter.", "Unable to resolve system ZFS pool disks. Aborting.": "Det går inte att lösa system ZFS pooldiskar. Avbryter.", "Unable to resolve system disk topology. Aborting.": "Det gick inte att lösa systemdisktopologin. Avbryter.", + "Unapplied changes": "Ändringar som inte har tillämpats", "Understand the security implications of privileged containers": "Förstå säkerhetskonsekvenserna av privilegierade containrar", "Unexpected Proxmox inventory; recovery blocked": "Oväntad Proxmox inventering; återhämtning blockerad", "Unexpected formatting directory in the new Valkey volume": "Oväntad formateringskatalog i den nya Valkey-volymen", diff --git a/oci/remote/install_oci.sh b/oci/remote/install_oci.sh index 7d1d8e85..b3b100e6 100755 --- a/oci/remote/install_oci.sh +++ b/oci/remote/install_oci.sh @@ -359,13 +359,18 @@ cleanup_canonical_inputs() { # A derived check accepts any HTTP answer below 500: the application is up, # even when its first page is a redirect or asks for a login. healthcheck_probe() { + local code status=0 if [[ ${HC_ANY_STATUS:-false} == true ]]; then - local code - code=$(curl "${CURL_ARGS[@]}" "$HC_URL" 2>/dev/null) || return 1 - [[ $code =~ ^[1-4][0-9][0-9]$ ]] + code=$(curl "${CURL_ARGS[@]}" "$HC_URL" 2>/dev/null) || status=$? + (( status == 0 )) && [[ $code =~ ^[1-4][0-9][0-9]$ ]] && return 0 else - curl "${CURL_ARGS[@]}" "$HC_URL" 2>/dev/null + curl "${CURL_ARGS[@]}" "$HC_URL" >/dev/null 2>&1 || status=$? + (( status == 0 )) && return 0 fi + # A port taken from the compose file may serve a database or SSH: an answer + # in another protocol (HTTP/0.9, weird or empty reply, reset) means it is up. + [[ ${HC_ACCEPT_NON_HTTP:-false} == true ]] \ + && (( status == 1 || status == 8 || status == 52 || status == 56 )) } cleanup_failed_install() { @@ -1025,15 +1030,18 @@ apply_extra_hosts() { } # Settings an update gives back to the rebuilt container before it starts: -# the LAN leg of a suite or stack member and its start order. +# the LAN leg of a suite or stack member, its start order and current tags. apply_kept_settings() { local key value while IFS=$'\t' read -r key value; do [[ -n $key ]] || continue - [[ $key == net1 || $key == startup ]] \ + [[ $key == net1 || $key == startup || $key == tags ]] \ || die "$(translate "Unsupported kept setting:") $key" - oci_quiet pct set "$VMID" "--$key" "$value" \ - || die "$(translate "Could not restore the container setting:") $key" + if [[ $key == tags && -z $value ]]; then + oci_quiet pct set "$VMID" --delete tags + else + oci_quiet pct set "$VMID" "--$key" "$value" + fi || die "$(translate "Could not restore the container setting:") $key" done < <(jq -r '.kept_proxmox_settings // {} | to_entries[] | [.key, .value] | @tsv' "$DEPLOYMENT_FILE") } @@ -1091,7 +1099,7 @@ ensure_runtime_home() { passwd_file="${rootfs}/etc/passwd" mount_ct_rootfs if ! home=$(python3 "$OCI_RUNTIME_RESOLVER" --home "$passwd_file" "$uid" 2>>"${OCI_LOG:-/dev/stderr}"); then - oci_quiet pct unmount "$VMID" || true + oci_quiet pct unmount "$VMID" || die "$(translate "Could not unmount the container filesystem")" return 0 fi oci_quiet pct unmount "$VMID" || die "$(translate "Could not unmount the container filesystem")" @@ -1230,6 +1238,17 @@ apply_installer_profile() { fi [[ -d ${rootfs}${target} ]] \ || { oci_log "The volume to prepare does not exist: $target"; failed=1; break; } + # A reused volume may intentionally be mounted read-only (for example, + # /run/secrets). Do not remove files or change ownership/mode on it. + # Use the most specific containing mount: an RW child can override RO. + if [[ $(jq -r --arg target "$target" ' + [.mounts[]? | select(.container_path as $path | + $target == $path or ($target | startswith($path + "/")))] + | sort_by(.container_path | length) | last | .read_only // false' \ + "$DEPLOYMENT_FILE") == true ]]; then + oci_log "Skipping preparation of read-only volume: $target" + continue + fi if [[ $remove_lost_found == true ]]; then rm -rf "${rootfs}${target}/lost+found" || { failed=1; break; } fi @@ -2253,6 +2272,7 @@ if [[ $START_AFTER == 1 && $HAS_STARTUP_HEALTHCHECK == 1 ]]; then [[ -n $IP ]] || die "$(translate "The healthcheck cannot run without an IP address")" HC_URL="${HC_SCHEME}://${IP}:${HC_PORT}${HC_PATH}" HC_ANY_STATUS=$(jq -r '.accept_any_status // false' <<<"$STARTUP_HEALTHCHECK") + HC_ACCEPT_NON_HTTP=$(jq -r '.accept_non_http // false' <<<"$STARTUP_HEALTHCHECK") if [[ $HC_ANY_STATUS == true ]]; then CURL_ARGS=(-sS --noproxy '*' -o /dev/null -w '%{http_code}' --max-time "$HC_REQUEST_TIMEOUT") else diff --git a/oci/remote/oci_carried_record.py b/oci/remote/oci_carried_record.py index 725640dd..14375ef5 100644 --- a/oci/remote/oci_carried_record.py +++ b/oci/remote/oci_carried_record.py @@ -38,6 +38,8 @@ DIRECTORY = '.proxmenux' NAME = 'oci-record.json' KIND = 'proxmenux.oci-carried-record' STAMP = 'carried.sha256' +CONFIG_CACHE = 'carried-config.json' +LOCAL_CONFIGS = Path('/etc/pve/lxc') LIMIT = 16 * 1024 * 1024 STACK_CONTRACT = '/etc/pve/priv/proxmenux-stack-{}.json' NVIDIA_HOOK = re.compile(r'/usr/local/lib/proxmenux/oci/nvidia-mount-([a-f0-9]{64})\.sh') @@ -279,6 +281,47 @@ def read_stamp(path): return value if isinstance(value, dict) else {} +def configuration(root, vmid, verify=False): + """Reuse pct's formatted output only while its local source is unchanged. + + Opening Manage checks every carried copy. Starting a fresh pct process + for each unchanged guest dominates that check. The private host cache is + keyed by the complete native configuration, including notes, pending + changes and snapshot sections. Lifecycle verification always calls pct. + An unavailable source, invalid cache or concurrent edit falls back to pct. + """ + source = LOCAL_CONFIGS / f'{int(vmid)}.conf' + cache = instances.location(root, vmid).parent / CONFIG_CACHE + + def fingerprint(): + try: + return sha(source.read_bytes()) + except OSError: + return None + + before = fingerprint() + if before is not None and not verify: + try: + if not cache.is_symlink() and cache.stat().st_size <= LIMIT: + value = json.loads(cache.read_text()) + if (isinstance(value, dict) and value.get('schema_version') == 1 + and value.get('source_sha256') == before + and isinstance(value.get('config'), str) and value['config'] + and fingerprint() == before): + return value['config'] + except (OSError, ValueError): + pass + result = _run('pct', 'config', str(vmid)) + if result.returncode != 0: + return None + # Do not associate output from one revision with another revision's hash. + if before is not None and fingerprint() == before: + with contextlib.suppress(OSError, ValueError): + instances.write(cache, {'schema_version': 1, 'source_sha256': before, + 'config': result.stdout}) + return result.stdout + + def superseded(record, existing, stamp): """Whether the container carries a copy this host did not write, with a record that differs from the one of this host. That is a container that @@ -308,10 +351,10 @@ def carry(root, vmid, mount_stopped=True, verify=False, adopted=False): if record.get('status') != 'installed' or record.get('pending_transaction') \ or record.get('pending_stack_transaction'): return 'skipped' - result = _run('pct', 'config', str(vmid)) - if result.returncode != 0 or instances.identity(result.stdout.encode()) != record['installation_id']: + config = configuration(root, vmid, verify) + if config is None or instances.identity(config.encode()) != record['installation_id']: return 'skipped' - value = bundle(record, result.stdout) + value = bundle(record, config) expected = digest(value) record_path = instances.location(root, vmid) path = record_path.parent / STAMP @@ -340,7 +383,7 @@ def carry(root, vmid, mount_stopped=True, verify=False, adopted=False): else: value['saved_at'] = datetime.datetime.now(datetime.timezone.utc).isoformat() value['generation'] = uuid.uuid4().hex - write_copy(rootfs, value, mapped_root(result.stdout)) + write_copy(rootfs, value, mapped_root(config)) stamp = {'generation': value['generation']} outcome = 'carried' if outcome == 'stale': diff --git a/oci/remote/oci_instance_transaction.py b/oci/remote/oci_instance_transaction.py index 59579967..b5cef528 100644 --- a/oci/remote/oci_instance_transaction.py +++ b/oci/remote/oci_instance_transaction.py @@ -352,6 +352,22 @@ def profile_keys(deployment): return keys +GUESSED_ENDPOINT = 'compose-first-tcp-port-fallback' +# curl exit codes for a port that accepted the connection and answered in a +# protocol other than HTTP: HTTP/0.9 refused, weird reply, empty reply, reset. +NON_HTTP_ANSWER = (1, 8, 52, 56) + + +def probe_http(args): + """Run one curl probe and return its output and exit code.""" + log('$ ' + shlex.join(str(arg) for arg in args)) + try: + result = subprocess.run(args, capture_output=True, timeout=60, close_fds=True) + except subprocess.TimeoutExpired: + return b'', -1 + return result.stdout, result.returncode + + def effective_healthcheck(template): """The template's own health check or, when it has none, one derived from its web address: any HTTP answer below 500 means the application is up.""" @@ -367,9 +383,14 @@ def effective_healthcheck(template): path = str(endpoint.get('path') or '/') if (endpoint.get('scheme') in ('http', 'https') and str(endpoint.get('port', '')).isdigit() and path.startswith('/') and not any(c.isspace() for c in path)): - return {'scheme': endpoint['scheme'], 'port': int(endpoint['port']), 'path': path, - 'timeout_seconds': 300, 'request_timeout_seconds': 10, 'stability_seconds': 4, - 'verify_tls': False, 'accept_any_status': True} + check = {'scheme': endpoint['scheme'], 'port': int(endpoint['port']), 'path': path, + 'timeout_seconds': 300, 'request_timeout_seconds': 10, 'stability_seconds': 4, + 'verify_tls': False, 'accept_any_status': True} + # A port taken from the compose file because the recipe names no + # web address may serve a database or SSH: any answer counts. + if endpoint.get('source') == GUESSED_ENDPOINT: + check['accept_non_http'] = True + return check # Without a web address, the new image must at least keep the container running. return {'type': 'running', 'timeout_seconds': 90, 'stability_seconds': 20} @@ -415,10 +436,9 @@ def candidate_contract(record, operation, proposal=None): return with_default_healthcheck(result) -# Resource settings edited in Proxmox that the new container keeps. # Proxmox settings the rebuilt container gets back exactly as they are: the -# LAN leg ProxMenux adds to a suite or stack member, and the start order. -KEPT_AS_IS = ('net1', 'startup') +# LAN leg ProxMenux adds to a suite or stack member, the start order and tags. +KEPT_AS_IS = ('net1', 'startup', 'tags') ADOPTABLE = {'memory': ('resources', 'memory_mb'), 'swap': ('resources', 'swap_mb'), 'cores': ('resources', 'cores'), 'cpulimit': ('resources', 'cores'), 'cpuunits': ('resources', 'cpu_units'), 'onboot': (None, 'onboot')} @@ -734,11 +754,10 @@ def healthcheck(vmid, template): args += ['-w', '%{http_code}'] if not check.get('verify_tls', False): args.append('-k') - try: - answer = run(*args, f'{scheme}://{ip}:{port}{path}') - ok = not any_status or re.fullmatch(rb'[1-4][0-9][0-9]', answer.strip()) is not None - except RuntimeError: - pass + answer, status = probe_http([*args, f'{scheme}://{ip}:{port}{path}']) + ok = status == 0 and (not any_status or re.fullmatch(rb'[1-4][0-9][0-9]', answer.strip()) is not None) + if not ok and check.get('accept_non_http') and status in NON_HTTP_ANSWER: + ok = True if ok: stable_since = stable_since or time.monotonic() if time.monotonic() - stable_since >= stability: @@ -1051,6 +1070,9 @@ def apply(root, vmid, archive, operation, proposal=None, registry_digest=None, i 'original_gpu_devices': original_gpu, 'desired_gpu_devices': desired_gpu, 'was_running': run('pct', 'status', str(vmid)).strip() == b'status: running'} state['kept_config'] = {key: cfg[key] for key in KEPT_AS_IS if key in cfg} + # The installer adds default tags. Preserve an explicit absence too, so + # deleting all tags in Proxmox does not bring them back on the next update. + state['kept_config']['tags'] = cfg.get('tags', '') if not coordinated and state['kept_config']: # Applied by the installer before the new container starts, so the # application has its LAN leg from the first second. @@ -1058,9 +1080,10 @@ def apply(root, vmid, archive, operation, proposal=None, registry_digest=None, i if coordinated: state['coordinated'] = coordinated state['preserved_stack_config'] = {key: cfg[key] for key in ('net1', 'startup', 'hookscript') if key in cfg} + state['preserved_stack_config']['tags'] = cfg.get('tags', '') if operation == 'update': state['preserved_stack_config'].update({key: cfg[key] for key in - ('net0', 'features', 'cmode', 'console', 'tty', 'cpuunits', 'tags') if key in cfg}) + ('net0', 'features', 'cmode', 'console', 'tty', 'cpuunits') if key in cfg}) checkpoint(journal, state, 'prepared') pending = copy.deepcopy(record) pending.update(status='updating', pending_transaction=str(journal), transaction_id=state['id']) @@ -1141,7 +1164,10 @@ def apply(root, vmid, archive, operation, proposal=None, registry_digest=None, i restore_firewall(vmid, state) if coordinated: for key, value in state['preserved_stack_config'].items(): - run('pct', 'set', str(vmid), '--' + key, value) + if key == 'tags' and not value: + run('pct', 'set', str(vmid), '--delete', 'tags') + else: + run('pct', 'set', str(vmid), '--' + key, value) check_runtime_mounts(run('pct', 'config', str(vmid)), candidate['deployment']) gpu_devices.check(run('pct', 'config', str(vmid)), candidate['deployment']) state['staged_config_sha256'] = sha(owned(vmid, record['installation_id'])) diff --git a/oci/remote/oci_instances.py b/oci/remote/oci_instances.py index f039b956..0f4d19ce 100644 --- a/oci/remote/oci_instances.py +++ b/oci/remote/oci_instances.py @@ -230,8 +230,9 @@ def identity(config): return match.group(1) if match else None -def same_config_except_notes(record, config): - """Accept a presentation-only note edit, never a changed OCI identity or LXC setting.""" +def same_config_except_notes(record, config, ignore_tags=False): + """Accept a presentation-only note edit, never a changed OCI identity or LXC setting. + With `ignore_tags`, Proxmox tags may differ too: the operation keeps them as they are.""" previous = record['observed']['config'].encode() expected = record['installation_id'] if identity(previous) != expected or identity(config) != expected: @@ -242,7 +243,8 @@ def same_config_except_notes(record, config): notes = [line for line in lines if line.startswith(b'description: ')] if len(notes) != 1: return None - return b''.join(line for line in lines if not line.startswith(b'description: ')) + dropped = (b'description: ', b'tags: ') if ignore_tags else (b'description: ',) + return b''.join(line for line in lines if not line.startswith(dropped)) original = without_notes(previous) return original is not None and original == without_notes(config) diff --git a/oci/remote/oci_runtime.py b/oci/remote/oci_runtime.py index ee3b9d56..3d842fd3 100644 --- a/oci/remote/oci_runtime.py +++ b/oci/remote/oci_runtime.py @@ -4,7 +4,9 @@ from __future__ import annotations import json +import os import shlex +import stat import sys import tarfile from pathlib import Path @@ -90,14 +92,39 @@ def effective_entrypoint( def home_directory(passwd_path: Path, uid: int) -> str | None: - """Return the safe home directory recorded for a container UID.""" - for line in passwd_path.read_text(encoding="utf-8").splitlines(): + """Read a regular image passwd file; only an absent UID defaults to /.""" + if uid < 0: + return None + # The caller supplies /etc/passwd. Pin etc so an image + # symlink or a rename during lookup cannot redirect the host-side read. + directory = os.open(passwd_path.parent, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + try: + if not stat.S_ISREG(os.stat(passwd_path.name, dir_fd=directory, follow_symlinks=False).st_mode): + return None + descriptor = os.open( + passwd_path.name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=directory, + ) + try: + # Recheck the opened object, not its name; a FIFO replacement + # must neither block open() nor reach a read operation. + if not stat.S_ISREG(os.fstat(descriptor).st_mode): + return None + with os.fdopen(descriptor, encoding="utf-8", closefd=False) as stream: + content = stream.read() + finally: + os.close(descriptor) + finally: + os.close(directory) + for line in content.splitlines(): fields = line.split(":") - if len(fields) < 7 or fields[2] != str(uid): + if len(fields) < 3 or fields[2] != str(uid): continue + if len(fields) != 7: + return None home = fields[5] - return home if home.startswith("/") else None - return None + safe = home.startswith("/") and not any(ord(char) < 32 or ord(char) == 127 for char in home) + return home if safe else None + return "/" def main() -> int: diff --git a/oci/remote/oci_stack_native.py b/oci/remote/oci_stack_native.py index b679ee74..2b564205 100644 --- a/oci/remote/oci_stack_native.py +++ b/oci/remote/oci_stack_native.py @@ -192,7 +192,7 @@ class NativeAdapter: config = instances.command('pct', 'config', str(vmid)) if instances.identity(config) != record['installation_id']: raise ValueError(translate('The identity of a member was replaced')) - if (not self.journal.exists() or not self.state().get('replacement_intent')) and not instances.same_config_except_notes(record, config): + if (not self.journal.exists() or not self.state().get('replacement_intent')) and not instances.same_config_except_notes(record, config, ignore_tags=True): raise ValueError(translate('A member configuration changed during the preparation')) else: if Path('/etc/pve/lxc/%s.conf' % vmid).exists(): diff --git a/oci/src/proxmenux_oci/cli.py b/oci/src/proxmenux_oci/cli.py index 9266f283..14dda6e2 100644 --- a/oci/src/proxmenux_oci/cli.py +++ b/oci/src/proxmenux_oci/cli.py @@ -578,8 +578,6 @@ def interactive(catalog: Catalog) -> int: f"{translate('All applications')} ({len(applications)})") elif selection == "manage": from .management import interactive_management - console.show_logo() - console.msg_title(translate("Manage installed OCI applications")) interactive_management(PROJECT_ROOT, ui) elif selection == "registries": from .registry_accounts import open_registry_accounts diff --git a/oci/src/proxmenux_oci/management.py b/oci/src/proxmenux_oci/management.py index db75d597..27ee09ad 100644 --- a/oci/src/proxmenux_oci/management.py +++ b/oci/src/proxmenux_oci/management.py @@ -151,7 +151,7 @@ def _run_lifecycle(command, title): return completed.returncode == 0 -def carry_records(project, mount_stopped=True, vmids=None, verify=False, shown=False): +def carry_records(project, mount_stopped=True, vmids=None, verify=False, shown=False, heading=None): """Leave inside each container the copy of its record that a restore on another host needs. It is refreshed after every operation. Returns what was found for each container. Every installed application is looked at, @@ -159,6 +159,9 @@ def carry_records(project, mount_stopped=True, vmids=None, verify=False, shown=F from . import console sys.path.insert(0, str(project / 'remote')) if shown: + if heading: + console.show_logo() + console.msg_title(heading) console.msg_info(translate('Updating the copy of the record...')) try: import oci_carried_record @@ -253,6 +256,17 @@ def interactive_management(project, ui): ui.message(translate('OCI management could not be completed. Check the backend status; no additional cleanup has been authorized.'), translate('OCI management')) +def _records_unread(project): + """Whether an installed application has no cached configuration yet; only + then does the first sync read every container and take a while.""" + sys.path.insert(0, str(project / 'remote')) + import oci_carried_record as carried + return any(not (carried.instances.location(carried.instances.ROOT, row['vmid']).parent + / carried.CONFIG_CACHE).exists() + for row in saved_inventory(project) + if row.get('status') == 'installed' and not row.get('pending')) + + def _clean_orphans(project): """Remove, silently, what is left of containers that exist on no node of the cluster.""" sys.path.insert(0, str(project / 'remote')) @@ -272,7 +286,8 @@ def _interactive_management(project, ui): _clean_orphans(project) recover_automatically(project) offer_recovery(project, ui) - carry_records(project, mount_stopped=False, shown=True) + carry_records(project, mount_stopped=False, shown=_records_unread(project), + heading=translate('Manage installed OCI applications')) # After each operation the list is shown again. Every operation leaves # the copies of the records up to date, so they are not all refreshed again. first = True @@ -440,25 +455,8 @@ def manage_instance(project, ui, row, action=None, lifecycle_args=()): proposal = None if action == 'modify': from .recreation import edit_recreation - from .cli import _deployment_summary_text - from .ui import BacktrackUI, RestartWizard - wizard = BacktrackUI(ui) - try: - while True: - try: - proposal = edit_recreation(record, wizard) - approved = wizard.review(_deployment_summary_text(proposal['candidate']['template'], - proposal['candidate']['deployment']), translate('Modify OCI'), - question=translate('Apply these changes?'), default=True) - break - except RestartWizard: - wizard.restart() - except ValueError as error: - if not wizard.retry_last(error): - raise - finally: - wizard.close() - if not approved: + proposal = edit_recreation(record, ui) + if proposal is None: return False elif not ui.review(translate('If the image channel has a new version, the CT is stopped, backed up and verified, and replaced with the new image to update the container. If the update fails after the container was changed, the backup is restored. An update that stops halfway is recovered from the OCI management menu.'), translate('Update OCI'), question=translate('Update now?'), default=True): diff --git a/oci/src/proxmenux_oci/recreation.py b/oci/src/proxmenux_oci/recreation.py index d8ddf4b9..e1f3d897 100644 --- a/oci/src/proxmenux_oci/recreation.py +++ b/oci/src/proxmenux_oci/recreation.py @@ -4,7 +4,7 @@ import re from pathlib import Path, PurePosixPath from .i18n import translate -from .ui import UserCancelled +from .ui import BacktrackUI, RestartWizard, UserCancelled def positive_integer(ui, prompt, value, minimum=1): @@ -30,13 +30,13 @@ def edit_network(deployment, ui): network.update(bridge=bridge, ipv4=ipv4, gateway=gateway) -def edit_acceleration(candidate, ui): +def edit_acceleration(candidate, ui, confirm=True): from .installer import configure_acceleration deployment = candidate['deployment'] template = candidate.get('template', {}) installer = template.get('proxmox', {}).get('installer_profile', {}) hardware = installer.get('hardware_acceleration') - if not hardware or not ui.confirm(translate('Change GPU acceleration?'), False): + if not hardware or (confirm and not ui.confirm(translate('Change GPU acceleration?'), False)): return profiles = hardware.get('profiles', []) reference = template.get('container_contract', {}).get('image', {}).get('reference', '') @@ -104,44 +104,143 @@ def edit_environment(deployment, ui): environment.append(item) -def edit_recreation(record, ui): - candidate = copy.deepcopy(record) - refresh_template(candidate, ui) +def modification_options(candidate): + """Only offer supported settings; never put environment values in the menu.""" deployment = candidate['deployment'] resources = deployment['resources'] - resources['cores'] = positive_integer(ui, translate('Cores'), resources['cores']) - resources['memory_mb'] = positive_integer(ui, translate('RAM in MiB'), resources['memory_mb'], 128) + profile = candidate.get('template', {}).get('proxmox', {}).get('installer_profile', {}) + network = deployment.get('network', {}) + address = network.get('ipv4', 'dhcp') + + def row(label, value): + return f"{label:<26} {value}" + + options = [ + ('cores', row(translate('CPU cores'), resources['cores'])), + ('memory', row(translate('RAM in MiB'), resources['memory_mb'])), + ('network', row(translate('Network'), + f"{network.get('bridge', '-')} · {'DHCP' if address == 'dhcp' else address}")), + # Only the paths added by hand: the application's own volumes are not extra. + ('paths', row(translate('Extra paths'), sum(1 for m in deployment.get('mounts', []) if m.get('custom')))), + ] + if profile.get('shared_directories'): + options.append(('shares', translate('Shared directories'))) + if profile.get('hardware_acceleration'): + hardware = profile['hardware_acceleration'] + selected = next((p for p in hardware.get('profiles', []) + if p['id'] == deployment.get('hardware_profile')), {}) + options.append(('acceleration', row(translate('Acceleration'), + translate(selected.get('label') or deployment.get('hardware_profile') or 'CPU')))) + if profile.get('object_detector'): + options.append(('detector', translate('Object detector'))) + options += [ + ('devices', row(translate('Devices'), len(deployment.get('devices', [])))), + ('environment', row(translate('Variables'), len(deployment.get('environment', [])))), + ('catalog', translate('Catalog options')), + ('apply', translate('Review and apply')), + ('discard', translate('Discard changes')), + ] + return options + + +def edit_setting(candidate, ui, setting): + deployment = candidate['deployment'] template = candidate.get('template', {}) - if template.get('proxmox', {}).get('installer_profile', {}).get('shared_directories'): + profile = template.get('proxmox', {}).get('installer_profile', {}) + if setting == 'cores': + resources = deployment['resources'] + resources['cores'] = positive_integer(ui, translate('Cores'), resources['cores']) + elif setting == 'memory': + resources = deployment['resources'] + resources['memory_mb'] = positive_integer(ui, translate('RAM in MiB'), resources['memory_mb'], 128) + elif setting == 'network': + edit_network(deployment, ui) + elif setting == 'shares': from .installer import ask_shared_directories deployment['mounts'] += ask_shared_directories( ui, template, deployment['mounts'], deployment['rootfs']['storage'], True, optional=True, unprivileged=deployment.get('security', {}).get('unprivileged', True)) - from .custom_mounts import ask_custom_mounts - deployment['mounts'] = ask_custom_mounts( - ui, deployment['mounts'], deployment['rootfs']['storage']) - if ui.confirm(translate('Change the access network?'), False): - edit_network(deployment, ui) - edit_acceleration(candidate, ui) - from .installer import configure_detector - installer_profile = candidate.get('template', {}).get('proxmox', {}).get('installer_profile', {}) - deployment['devices'], notes = configure_detector(installer_profile, deployment.get('devices', []), ui) - if notes: - deployment['completion_notes'] = notes - from .extra_devices import ask_extra_devices - reference = candidate.get('template', {}).get('container_contract', {}).get('image', {}).get('reference', '') - repository = reference.split('@')[0].rsplit(':', 1)[0] - deployment['devices'] = ask_extra_devices( - ui, deployment.get('devices', []), deployment.get('security', {}).get('unprivileged', True), - allow_coral=repository in ('ghcr.io/blakeblackshear/frigate', 'codeproject/ai-server', 'docker.io/codeproject/ai-server')) - from .extra_devices import device_permissions - deployment['device_permissions'] = device_permissions( - reference, deployment['devices'], deployment.get('device_permissions')) - edit_environment(deployment, ui) - proposal = {'operation': 'recreate', 'candidate': candidate} - if record.get('observed', {}).get('config_sha256'): - proposal['base_config_sha256'] = record['observed']['config_sha256'] - return proposal + elif setting == 'paths': + from .custom_mounts import ask_custom_mounts + deployment['mounts'] = ask_custom_mounts(ui, deployment['mounts'], deployment['rootfs']['storage']) + elif setting == 'acceleration': + edit_acceleration(candidate, ui, confirm=False) + elif setting == 'detector': + from .installer import configure_detector + deployment['devices'], notes = configure_detector(profile, deployment.get('devices', []), ui) + if notes: + deployment['completion_notes'] = notes + elif setting == 'devices': + from .extra_devices import ask_extra_devices + reference = template.get('container_contract', {}).get('image', {}).get('reference', '') + repository = reference.split('@')[0].rsplit(':', 1)[0] + deployment['devices'] = ask_extra_devices( + ui, deployment.get('devices', []), deployment.get('security', {}).get('unprivileged', True), + allow_coral=repository in ('ghcr.io/blakeblackshear/frigate', 'codeproject/ai-server', 'docker.io/codeproject/ai-server')) + elif setting == 'environment': + edit_environment(deployment, ui) + elif setting == 'catalog': + refresh_template(candidate, ui) + else: + raise ValueError(setting) + if setting in ('acceleration', 'detector', 'devices'): + from .extra_devices import device_permissions + reference = candidate.get('template', {}).get('container_contract', {}).get('image', {}).get('reference', '') + permissions = device_permissions(reference, deployment.get('devices', []), deployment.get('device_permissions')) + if permissions is not None or 'device_permissions' in deployment: + deployment['device_permissions'] = permissions + + +def edit_section(candidate, ui, setting): + """A cancelled or invalid section cannot leave a half-edited draft.""" + wizard = BacktrackUI(ui) + try: + while True: + edited = copy.deepcopy(candidate) + try: + edit_setting(edited, wizard, setting) + return edited + except RestartWizard: + wizard.restart() + except UserCancelled: + return candidate + except ValueError as error: + if not wizard.retry_last(error): + ui.message(str(error), translate('Modify OCI')) + return candidate + finally: + wizard.close() + + +def edit_recreation(record, ui): + """Keep a draft across independently editable settings; apply it once.""" + from .cli import _deployment_summary_text + candidate = copy.deepcopy(record) + selected = 'cores' + while True: + pending = candidate != record + status = translate('Unapplied changes') if pending else translate('No pending changes') + text = (f"CT {record.get('vmid', '-')}: {candidate['deployment'].get('hostname', '')}\n" + f"{status}\n\n" + + translate('Choose a setting. Changes are applied only after the final review.')) + selected = ui.choose(text, modification_options(candidate), selected, + title=translate('Modify OCI'), show_tags=False) + if selected is None or selected == 'discard': + if not pending or ui.confirm(translate('Discard unapplied changes?'), False): + return None + selected = 'apply' + elif selected == 'apply': + if not pending: + ui.message(translate('Nothing was changed.'), translate('Modify OCI')) + return None + if ui.review(_deployment_summary_text(candidate['template'], candidate['deployment']), + translate('Modify OCI'), question=translate('Apply these changes?'), default=True): + proposal = {'operation': 'recreate', 'candidate': candidate} + if record.get('observed', {}).get('config_sha256'): + proposal['base_config_sha256'] = record['observed']['config_sha256'] + return proposal + else: + candidate = edit_section(candidate, ui, selected) def refresh_template(candidate, ui): diff --git a/oci/src/proxmenux_oci/registry_accounts.py b/oci/src/proxmenux_oci/registry_accounts.py index f500520c..2ad9791d 100644 --- a/oci/src/proxmenux_oci/registry_accounts.py +++ b/oci/src/proxmenux_oci/registry_accounts.py @@ -166,7 +166,7 @@ def registry_accounts_menu(ui, engine=ENGINE_ROOT, auth=None): return try: options = [('add', translate('Sign in to a registry'))] - options += [(f'account-{index}', f"{row['registry']:<28} {row['account'] or row['method']} [{translate('saved')}]") + options += [(f'account-{index}', f"{row['registry']:<28} [{translate('saved')}]") for index, row in enumerate(rows)] options += [('storage', translate('Credential storage and access'))] count = translate('{count} saved account.') if len(rows) == 1 else translate('{count} saved accounts.') diff --git a/oci/tests/test_carried_config_cache.py b/oci/tests/test_carried_config_cache.py new file mode 100644 index 00000000..026a3598 --- /dev/null +++ b/oci/tests/test_carried_config_cache.py @@ -0,0 +1,134 @@ +"""A fast Manage entry still detects config edits and verifies lifecycle work.""" +import contextlib +import copy +import json +import os +from pathlib import Path +import sys +import tempfile +from types import SimpleNamespace +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / 'remote')) +import oci_carried_record as carried + + +class CarriedConfigCacheTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.root = Path(self.tmp.name) / 'instances' + self.source = Path(self.tmp.name) / 'lxc' + self.source.mkdir() + self.config = self.source / '120.conf' + self.config.write_text('# proxmenux-instance=example\nunprivileged: 1\n') + (self.root / '120').mkdir(parents=True) + self.cache = self.root / '120' / carried.CONFIG_CACHE + self.patch = patch.object(carried, 'LOCAL_CONFIGS', self.source) + self.patch.start() + self.addCleanup(self.patch.stop) + + def response(self, text='description: proxmenux-instance=example\nunprivileged: 1\n', status=0): + return SimpleNamespace(returncode=status, stdout=text) + + def test_warm_reads_avoid_pct_and_cache_is_private(self): + with patch.object(carried, '_run', return_value=self.response()) as run: + first = carried.configuration(self.root, 120) + self.assertEqual(carried.configuration(self.root, 120), first) + run.assert_called_once_with('pct', 'config', '120') + self.assertEqual(self.cache.stat().st_mode & 0o777, 0o600) + + def test_native_edits_invalidate_even_when_size_and_mtime_are_restored(self): + with patch.object(carried, '_run', side_effect=[self.response(), self.response('new')]) as run: + carried.configuration(self.root, 120) + info = self.config.stat() + self.config.write_text(self.config.read_text().replace('example', 'changed')) + os.utime(self.config, ns=(info.st_atime_ns, info.st_mtime_ns)) + self.assertEqual(carried.configuration(self.root, 120), 'new') + self.assertEqual(run.call_count, 2) + + def test_verify_forces_a_fresh_pct_read_with_a_warm_cache(self): + with patch.object(carried, '_run', side_effect=[self.response(), self.response('verified')]) as run: + carried.configuration(self.root, 120) + self.assertEqual(carried.configuration(self.root, 120, verify=True), 'verified') + self.assertEqual(run.call_count, 2) + + def test_missing_native_source_never_uses_or_populates_a_cache(self): + self.config.unlink() + with patch.object(carried, '_run', return_value=self.response()) as run: + carried.configuration(self.root, 120) + carried.configuration(self.root, 120) + self.assertEqual(run.call_count, 2) + self.assertFalse(self.cache.exists()) + + def test_concurrent_edit_does_not_cache_output_for_the_wrong_revision(self): + def command(*args): + self.config.write_text('changed while pct was reading\n') + return self.response() + with patch.object(carried, '_run', side_effect=command): + carried.configuration(self.root, 120) + self.assertFalse(self.cache.exists()) + + def test_edit_during_cache_lookup_falls_back_to_pct(self): + with patch.object(carried, '_run', return_value=self.response()): + carried.configuration(self.root, 120) + read_text = Path.read_text + def edit_during_read(path, *args, **kwargs): + text = read_text(path, *args, **kwargs) + if path == self.cache: + self.config.write_text('changed while reading the cache\n') + return text + with patch.object(Path, 'read_text', edit_during_read), \ + patch.object(carried, '_run', return_value=self.response('fresh')) as run: + self.assertEqual(carried.configuration(self.root, 120), 'fresh') + run.assert_called_once_with('pct', 'config', '120') + + def test_corrupt_cache_and_pct_failure_are_not_accepted(self): + for text in ('not json', '[]', '{"schema_version": 999}'): + with self.subTest(cache=text): + self.cache.write_text(text) + with patch.object(carried, '_run', return_value=self.response(status=1)) as run: + self.assertIsNone(carried.configuration(self.root, 120)) + run.assert_called_once() + self.assertEqual(self.cache.read_text(), text) + + def test_symlink_cache_does_not_read_or_replace_its_target(self): + target = Path(self.tmp.name) / 'outside' + target.write_text('private original') + self.cache.symlink_to(target) + with patch.object(carried, '_run', return_value=self.response()) as run: + self.assertEqual(carried.configuration(self.root, 120), self.response().stdout) + run.assert_called_once() + self.assertTrue(self.cache.is_symlink()) + self.assertEqual(target.read_text(), 'private original') + + def test_cached_config_still_detects_a_restored_container_record(self): + installation = '488ed3cb-1145-477a-b90f-c46777d69fb2' + record = {'schema_version': 1, 'vmid': 120, 'installation_id': installation, + 'status': 'installed', 'deployment': {'hostname': 'current'}} + carried.instances.write(carried.instances.location(self.root, 120), record) + native = f'description: proxmenux-instance={installation}\n' + with patch.object(carried, '_run', return_value=self.response(native)): + carried.configuration(self.root, 120) + rootfs = Path(self.tmp.name) / 'rootfs' + rootfs.mkdir() + previous = copy.deepcopy(record) + previous['deployment']['hostname'] = 'restored-snapshot' + carried.write_copy(rootfs, {'kind': carried.KIND, 'schema_version': 1, + 'record': previous, 'generation': 'restored-generation'}, + (os.getuid(), os.getgid())) + stamp = self.root / '120' / carried.STAMP + stamp.write_text(json.dumps({'generation': 'host-generation'})) + with patch.object(carried, '_run', side_effect=AssertionError('cached config expected')), \ + patch.object(carried, 'read_cluster', return_value=None), \ + patch.object(carried, 'running_pid', return_value=123), \ + patch.object(carried, 'container_root', return_value=contextlib.nullcontext(rootfs)): + self.assertEqual(carried.carry(self.root, 120, mount_stopped=False), 'stale') + self.assertFalse(carried.instances.location(self.root, 120).exists()) + self.assertTrue((self.root / '120' / f'retired-{installation}.json').exists()) + + +if __name__ == '__main__': + unittest.main() diff --git a/oci/tests/test_external_tags.py b/oci/tests/test_external_tags.py new file mode 100644 index 00000000..0f09c5e3 --- /dev/null +++ b/oci/tests/test_external_tags.py @@ -0,0 +1,203 @@ +"""Proxmox tags can change without preventing OCI management or being lost.""" +import copy +import io +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import sys +import tempfile +import unittest +from contextlib import redirect_stdout +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / 'remote')) + +import oci_instance_reconcile as reconcile +import oci_instance_transaction as transaction +from oci_installation_state import sha + +MARKER = '11111111-1111-4111-8111-111111111111' +BASE = (f'description: proxmenux-instance={MARKER}\n' + 'rootfs: local-lvm:vm-200-disk-0,size=8G\n' + 'unprivileged: 1\ncores: 2\nmemory: 512\n' + 'net0: name=eth0,bridge=vmbr0,hwaddr=02:00:00:00:00:01,ip=dhcp\n' + 'net1: name=eth1,bridge=vmbr1,ip=dhcp\nstartup: order=20\n').encode() + + +def config(tags): + return BASE + (f'tags: {tags}\n'.encode() if tags is not None else b'') + + +def record(tags='oci;proxmenux'): + before = config(tags) + return { + 'schema_version': 1, 'vmid': 200, 'status': 'installed', + 'installation_id': MARKER, + 'template': {'id': 'test-app', 'container_contract': {'volumes': []}}, + 'deployment': { + 'vmid': 200, 'rootfs': {'storage': 'local-lvm', 'size_gb': 8}, + 'network': {}, 'resources': {'cores': 2, 'memory_mb': 512}, + 'mounts': [], 'devices': [], + }, + 'observed': { + 'config': before.decode(), 'config_sha256': sha(before), + 'image': {'architecture': 'amd64', 'manifest_digest': 'sha256:old', + 'defaults': {'Volumes': {}}}, + }, + } + + +class ExternalTagsTests(unittest.TestCase): + def test_adding_editing_reordering_and_removing_tags_need_no_adoption(self): + for old, new in ((None, 'homelab'), ('oci;proxmenux', 'homelab;oci;proxmenux'), + ('oci;proxmenux', 'proxmenux;oci'), ('oci;proxmenux', None), + ('oci;proxmenux', '')): + with self.subTest(old=old, new=new): + saved, current = record(old), config(new) + self.assertIsNone(reconcile.propose(saved, current)) + self.assertEqual(transaction.external_changes(saved, current), {}) + + def test_tags_can_change_together_with_cpu_and_memory(self): + current = config('homelab').replace(b'cores: 2', b'cores: 4').replace(b'memory: 512', b'memory: 2048') + self.assertIsNone(reconcile.propose(record(), current)) + self.assertEqual(transaction.external_changes(record(), current), {'cores': 4, 'memory': 2048}) + + def test_tag_changes_do_not_bypass_identity_or_network_checks(self): + for current in (config('homelab').replace(MARKER.encode(), b'22222222-2222-4222-8222-222222222222'), + config('homelab').replace(b'bridge=vmbr0', b'bridge=vmbr2')): + with self.subTest(config=current): + with self.assertRaises(ValueError): + reconcile.propose(record(), current) + with self.assertRaises(ValueError): + transaction.external_changes(record(), current) + + def test_new_volume_still_requires_adoption_when_tags_also_change(self): + saved = record() + current = config('homelab') + b'mp0: local-lvm:vm-200-disk-1,mp=/data,size=8G,backup=1\n' + with tempfile.TemporaryDirectory() as folder: + root = Path(folder) + path = root / '200/oci-compose.json' + path.parent.mkdir() + path.write_text(json.dumps(saved)) + with patch.object(reconcile.instances, 'ROOT', root): + proposal = reconcile.propose(saved, current) + self.assertEqual(proposal['candidate']['deployment']['mounts'][0]['container_path'], '/data') + self.assertEqual(len(proposal['details']), 1) + self.assertEqual(json.loads(path.read_text()), saved) + + +class Prepared(Exception): + """Stop a mocked transaction before it can stop or modify any container.""" + + +class TagReplayTests(unittest.TestCase): + def prepare(self, folder, tags, operation, coordinated=None): + current, saved = config(tags), record() + archive = folder / 'image.tar' + archive.write_bytes(b'fixture') + states = [] + + def checkpoint(_path, state, phase): + self.assertEqual(phase, 'prepared') + states.append(copy.deepcopy(state)) + raise Prepared() + + def command(*args): + if args[:2] == ('pct', 'config'): + return current + if args[:3] == ('pvesh', 'get', '/cluster/ha/resources'): + return b'[]' + if args[:2] == ('pct', 'status'): + return b'status: running' + self.fail(f'Unexpected host command: {args}') + + proposal = {'operation': 'recreate', 'candidate': copy.deepcopy(saved)} if operation == 'recreate' else None + image = {'architecture': 'amd64', 'os': 'linux', 'manifest_digest': 'sha256:new', 'defaults': {}} + with (patch.object(transaction.instances, 'read', return_value=saved), + patch.object(transaction, 'run', side_effect=command), + patch.object(transaction, 'check_archive'), + patch.object(transaction, 'image_from_archive', return_value=image), + patch.object(transaction.work_backup, 'locate', side_effect=lambda directory, *_: directory), + patch.object(transaction, 'open_log'), + patch.object(transaction, 'checkpoint', side_effect=checkpoint), + redirect_stdout(io.StringIO()), self.assertRaises(Prepared)): + transaction.apply(folder, 200, archive, operation, proposal=proposal, coordinated=coordinated) + return states[0] + + @unittest.skipUnless(shutil.which('bash') and shutil.which('jq'), 'bash and jq are needed') + def test_update_and_modify_replay_current_tags_including_no_tags(self): + installer = (ROOT / 'remote/install_oci.sh').read_text() + function = re.search(r'^apply_kept_settings\(\) \{\n.*?^\}\n', installer, re.M | re.S) + self.assertIsNotNone(function) + for operation in ('update', 'recreate'): + for tags in ('homelab;oci;proxmenux', 'oci', None, ''): + with self.subTest(operation=operation, tags=tags), tempfile.TemporaryDirectory() as name: + folder = Path(name) + state = self.prepare(folder, tags, operation) + kept = state['runtime_deployment']['kept_proxmox_settings'] + deployment = folder / 'deployment.json' + deployment.write_text(json.dumps({'kept_proxmox_settings': kept})) + calls = folder / 'calls' + script = ''' +pct() { printf '%s\\0' "$@" >> "$CALLS"; printf '\\n' >> "$CALLS"; } +oci_quiet() { "$@"; } +translate() { printf '%s' "$1"; } +die() { printf '%s\\n' "$*" >&2; exit 1; } +''' + done = subprocess.run(['bash', '-euc', script + function.group(0) + '\napply_kept_settings'], + env=dict(os.environ, VMID='200', DEPLOYMENT_FILE=str(deployment), CALLS=str(calls)), + capture_output=True, text=True) + self.assertEqual(done.returncode, 0, done.stderr) + commands = [line.split(b'\0')[:-1] for line in calls.read_bytes().splitlines()] + expected = [b'set', b'200', b'--tags', tags.encode()] if tags else [b'set', b'200', b'--delete', b'tags'] + self.assertIn(expected, commands) + self.assertIn([b'set', b'200', b'--net1', b'name=eth1,bridge=vmbr1,ip=dhcp'], commands) + self.assertIn([b'set', b'200', b'--startup', b'order=20'], commands) + + def test_stack_operations_also_preserve_tags_and_their_absence(self): + for operation in ('update', 'recreate'): + for tags in ('homelab;oci', None, ''): + with self.subTest(operation=operation, tags=tags), tempfile.TemporaryDirectory() as name: + state = self.prepare(Path(name), tags, operation, coordinated={'stack': 'test'}) + self.assertEqual(state['preserved_stack_config']['tags'], tags or '') + + @unittest.skipUnless(shutil.which('bash') and shutil.which('jq'), 'bash and jq are needed') + def test_native_stack_check_accepts_tag_edits_and_nothing_else(self): + saved = record() + instances = transaction.instances + for new in ('homelab;oci;proxmenux', 'proxmenux;oci', '', None): + with self.subTest(new=new): + self.assertTrue(instances.same_config_except_notes(saved, config(new), ignore_tags=True)) + self.assertFalse(instances.same_config_except_notes(saved, config(new))) + changed = config('homelab').replace(b'cores: 2', b'cores: 4') + self.assertFalse(instances.same_config_except_notes(saved, changed, ignore_tags=True)) + other = config('homelab').replace(MARKER.encode(), b'22222222-2222-4222-8222-222222222222') + self.assertFalse(instances.same_config_except_notes(saved, other, ignore_tags=True)) + source = (ROOT / 'remote/oci_stack_native.py').read_text() + self.assertIn('same_config_except_notes(record, config, ignore_tags=True)', source) + + def test_installer_still_refuses_unsupported_kept_settings(self): + installer = (ROOT / 'remote/install_oci.sh').read_text() + function = re.search(r'^apply_kept_settings\(\) \{\n.*?^\}\n', installer, re.M | re.S).group(0) + with tempfile.TemporaryDirectory() as name: + deployment = Path(name) / 'deployment.json' + deployment.write_text(json.dumps({'kept_proxmox_settings': {'unprivileged': '0'}})) + script = ''' +oci_quiet() { echo 'must not run' >&2; exit 99; } +translate() { printf '%s' "$1"; } +die() { printf '%s\\n' "$*" >&2; exit 1; } +''' + done = subprocess.run(['bash', '-euc', script + function + '\napply_kept_settings'], + env=dict(os.environ, VMID='200', DEPLOYMENT_FILE=str(deployment)), + capture_output=True, text=True) + self.assertEqual(done.returncode, 1) + self.assertIn('Unsupported kept setting: unprivileged', done.stderr) + self.assertNotIn('must not run', done.stderr) + + +if __name__ == '__main__': + unittest.main() diff --git a/oci/tests/test_guessed_endpoint_healthcheck.py b/oci/tests/test_guessed_endpoint_healthcheck.py new file mode 100644 index 00000000..8ce9003f --- /dev/null +++ b/oci/tests/test_guessed_endpoint_healthcheck.py @@ -0,0 +1,87 @@ +"""A web address guessed from the compose file may belong to a service that does +not speak HTTP. Updates, Modify and recovery must accept its answer instead of +waiting for an HTTP reply that never comes.""" + +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / 'remote')) + +import oci_instance_transaction as transaction + +INSTALLER = ROOT / 'remote/install_oci.sh' +GUESSED = {'first_run': {'endpoints': [{'label': 'Web UI', 'scheme': 'http', 'port': 3306, 'path': '/', + 'source': 'compose-first-tcp-port-fallback'}]}} +DECLARED = {'first_run': {'endpoints': [{'label': 'Web UI', 'scheme': 'http', 'port': 8080, 'path': '/'}]}} + + +def probe_function(): + source = INSTALLER.read_text() + start = source.index('healthcheck_probe() {') + end = source.index('\n}\n', start) + 3 + return source[start:end] + + +class GuessedEndpointCheckTests(unittest.TestCase): + def test_only_a_guessed_endpoint_accepts_other_protocols(self): + self.assertTrue(transaction.effective_healthcheck(GUESSED)['accept_non_http']) + self.assertNotIn('accept_non_http', transaction.effective_healthcheck(DECLARED)) + + def run_probe(self, exit_code, code='000', accept_non_http='true', any_status='true'): + with tempfile.TemporaryDirectory() as directory: + fake = Path(directory) / 'curl' + fake.write_text(f'#!/bin/sh\nprintf %s {code}\nexit {exit_code}\n') + fake.chmod(0o755) + script = (probe_function() + '\nCURL_ARGS=()\nHC_URL=http://192.0.2.1:3306/\n' + f'HC_ANY_STATUS={any_status}\nHC_ACCEPT_NON_HTTP={accept_non_http}\nhealthcheck_probe\n') + env = {**os.environ, 'PATH': f'{directory}:{os.environ["PATH"]}'} + return subprocess.run(['bash', '-c', script], env=env, capture_output=True).returncode + + def test_installer_probe_accepts_a_non_http_answer_only_when_guessed(self): + for status in transaction.NON_HTTP_ANSWER: + with self.subTest(status=status): + self.assertEqual(self.run_probe(status), 0) + self.assertNotEqual(self.run_probe(status, accept_non_http='false'), 0) + # A closed port, a timeout or a server error still fail. + for status, code in ((7, '000'), (28, '000'), (0, '503')): + with self.subTest(status=status, code=code): + self.assertNotEqual(self.run_probe(status, code), 0) + self.assertEqual(self.run_probe(0, '200'), 0) + self.assertEqual(self.run_probe(0, any_status='false', accept_non_http='false'), 0) + + def test_recovery_check_accepts_a_database_answer(self): + check = transaction.effective_healthcheck(GUESSED) + check.update(timeout_seconds=6, stability_seconds=0) + template = {'proxmox': {'installer_profile': {'startup_healthcheck': check}}} + + def run(*args): + if args[:2] == ('pct', 'status'): + return b'status: running\n' + if args[:2] == ('pct', 'config'): + return b'' + if args[0] == 'lxc-info': + return b'192.0.2.10\n' + raise AssertionError(args) + + with patch.object(transaction, 'run', side_effect=run), \ + patch.object(transaction, 'probe_http', return_value=(b'000', 1)), \ + patch.object(transaction, 'log'): + self.assertEqual(transaction.healthcheck(113, template), 'http://192.0.2.10:3306/') + declared = transaction.effective_healthcheck(DECLARED) + declared.update(timeout_seconds=4, stability_seconds=0) + template = {'proxmox': {'installer_profile': {'startup_healthcheck': declared}}} + with patch.object(transaction, 'run', side_effect=run), \ + patch.object(transaction, 'probe_http', return_value=(b'000', 1)), \ + patch.object(transaction, 'log'), patch.object(transaction.time, 'sleep'): + with self.assertRaises(ValueError): + transaction.healthcheck(113, template) + + +if __name__ == '__main__': + unittest.main() diff --git a/oci/tests/test_manage_sync_notice.py b/oci/tests/test_manage_sync_notice.py new file mode 100644 index 00000000..ede909b5 --- /dev/null +++ b/oci/tests/test_manage_sync_notice.py @@ -0,0 +1,34 @@ +"""Opening Manage shows the sync notice only while some configuration is still unread.""" +from pathlib import Path +import sys +import tempfile +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / 'src')) +sys.path.insert(0, str(ROOT / 'remote')) + +from proxmenux_oci import management +import oci_carried_record as carried + + +class ManageSyncNoticeTests(unittest.TestCase): + def test_notice_only_while_an_installed_application_has_no_cache(self): + rows = [{'vmid': 101, 'status': 'installed', 'pending': False}, + {'vmid': 102, 'status': 'installed', 'pending': True}, + {'vmid': 103, 'status': 'failed', 'pending': False}] + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + with patch.object(carried.instances, 'ROOT', root), \ + patch.object(management, 'saved_inventory', return_value=rows): + self.assertTrue(management._records_unread(ROOT)) + cache = carried.instances.location(root, 101).parent / carried.CONFIG_CACHE + cache.parent.mkdir(parents=True) + cache.write_text('{}') + # The pending and failed applications are never cached by the sync. + self.assertFalse(management._records_unread(ROOT)) + + +if __name__ == '__main__': + unittest.main() diff --git a/oci/tests/test_modify_menu.py b/oci/tests/test_modify_menu.py new file mode 100644 index 00000000..601c588e --- /dev/null +++ b/oci/tests/test_modify_menu.py @@ -0,0 +1,208 @@ +"""Selective OCI edits stay in a draft until reviewed, then use one transaction.""" +import copy +import json +from pathlib import Path +import stat +import sys +import unittest +from unittest.mock import Mock, patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / 'src')) +sys.path.insert(0, str(ROOT / 'remote')) + +from proxmenux_oci import management, recreation +from proxmenux_oci.ui import BackRequested, UserCancelled + + +def record(): + return {'vmid': 115, 'installation_id': 'test-installation', 'status': 'installed', + 'observed': {'config_sha256': 'native-config-fingerprint'}, + 'template': {'id': 'example-app', 'catalog_ui': {'title': 'Example'}, + 'container_contract': {'image': {'reference': 'example/app:latest'}}, + 'proxmox': {'installer_profile': {}}}, + 'deployment': {'vmid': 115, 'hostname': 'example', 'resources': {'cores': 2, 'memory_mb': 512}, + 'network': {'bridge': 'vmbr0', 'ipv4': 'dhcp', 'gateway': None}, + 'rootfs': {'storage': 'local-zfs', 'size_gb': 8}, 'mounts': [ + {'container_path': '/data', 'source': 'local-zfs', + 'type': 'managed-volume', 'size_gb': 8, 'backup': True}], + 'devices': [], 'security': {'unprivileged': True}, + 'environment': [{'name': 'PASSWORD', 'value': 'private-test-secret', + 'sensitive': True}]}} + + +class MenuUI: + def __init__(self, choices, answers=(), reviews=(True,), confirms=()): + self.choices, self.answers = iter(choices), iter(answers) + self.reviews, self.confirms = iter(reviews), iter(confirms) + self.events = [] + self.back_enabled = False + + def choose(self, text, options, default=None, **kwargs): + self.events.append(('menu', text, dict(options))) + value = next(self.choices) + if value is not None: + assert value in dict(options), value + return value + + def ask(self, text, default=None, **kwargs): + self.events.append(('ask', text, default)) + value = next(self.answers) + if isinstance(value, Exception): + raise value + return value + + def confirm(self, text, default=False): + self.events.append(('confirm', text)) + return next(self.confirms) + + def review(self, text, *args, **kwargs): + self.events.append(('review', text)) + return next(self.reviews) + + def message(self, text, *args): + self.events.append(('message', text)) + + +@patch('proxmenux_oci.i18n.language', return_value='en') +class ModifyMenuTests(unittest.TestCase): + def test_ram_only_does_not_ask_about_cpu_network_paths_devices_or_catalog(self, _language): + original = record() + saved = copy.deepcopy(original) + ui = MenuUI(['memory', 'apply'], ['768']) + with patch.object(recreation, 'refresh_template') as refresh: + result = recreation.edit_recreation(original, ui) + self.assertEqual(result['candidate']['deployment']['resources'], {'cores': 2, 'memory_mb': 768}) + self.assertEqual([e[1] for e in ui.events if e[0] == 'ask'], ['RAM in MiB']) + self.assertEqual(result['base_config_sha256'], 'native-config-fingerprint') + self.assertEqual(original, saved) + refresh.assert_not_called() + self.assertFalse(ui.back_enabled) + + def test_several_sections_and_revisits_keep_the_current_draft(self, _language): + ui = MenuUI(['memory', 'cores', 'memory', 'apply'], ['768', '3', '1024']) + result = recreation.edit_recreation(record(), ui) + self.assertEqual(result['candidate']['deployment']['resources'], {'cores': 3, 'memory_mb': 1024}) + self.assertEqual([e[2] for e in ui.events if e[0] == 'ask'], ['512', '2', '768']) + self.assertEqual(sum(e[0] == 'review' for e in ui.events), 1) + + def test_declining_review_returns_to_menu_without_losing_edits(self, _language): + ui = MenuUI(['cores', 'apply', 'memory', 'apply'], ['3', '768'], reviews=[False, True]) + result = recreation.edit_recreation(record(), ui) + self.assertEqual(result['candidate']['deployment']['resources'], {'cores': 3, 'memory_mb': 768}) + self.assertEqual(sum(e[0] == 'review' for e in ui.events), 2) + + def test_unchanged_apply_does_not_propose_recreating_the_guest(self, _language): + ui = MenuUI(['memory', 'apply'], ['512']) + self.assertIsNone(recreation.edit_recreation(record(), ui)) + self.assertFalse(any(e[0] == 'review' for e in ui.events)) + self.assertIn(('message', 'Nothing was changed.'), ui.events) + + def test_discard_leaves_the_saved_record_untouched(self, _language): + original = record() + saved = copy.deepcopy(original) + ui = MenuUI(['memory', 'discard'], ['768'], confirms=[True]) + self.assertIsNone(recreation.edit_recreation(original, ui)) + self.assertEqual(original, saved) + self.assertFalse(any(e[0] == 'review' for e in ui.events)) + + def test_declining_discard_preserves_the_draft(self, _language): + ui = MenuUI(['memory', None, 'apply'], ['768'], confirms=[False]) + result = recreation.edit_recreation(record(), ui) + self.assertEqual(result['candidate']['deployment']['resources']['memory_mb'], 768) + + def test_cancel_without_changes_asks_nothing_else(self, _language): + ui = MenuUI([None]) + self.assertIsNone(recreation.edit_recreation(record(), ui)) + self.assertEqual(len(ui.events), 1) + + def test_invalid_ram_is_retried_without_restarting_other_sections(self, _language): + ui = MenuUI(['cores', 'memory', 'apply'], ['3', 'invalid', '64', '768']) + result = recreation.edit_recreation(record(), ui) + self.assertEqual(result['candidate']['deployment']['resources'], {'cores': 3, 'memory_mb': 768}) + self.assertEqual(sum(e[0] == 'message' for e in ui.events), 2) + self.assertEqual(sum(e[:2] == ('ask', 'Cores') for e in ui.events), 1) + + def test_back_from_first_field_returns_to_menu_and_keeps_other_edits(self, _language): + ui = MenuUI(['cores', 'memory', 'apply'], ['3', BackRequested()]) + result = recreation.edit_recreation(record(), ui) + self.assertEqual(result['candidate']['deployment']['resources'], {'cores': 3, 'memory_mb': 512}) + self.assertFalse(ui.back_enabled) + + def test_cancelling_a_partly_edited_section_rolls_back_only_that_section(self, _language): + real_edit = recreation.edit_setting + def edit(candidate, ui, setting): + if setting == 'environment': + candidate['deployment']['environment'][0]['value'] = 'partial-value' + raise UserCancelled() + real_edit(candidate, ui, setting) + ui = MenuUI(['cores', 'environment', 'apply'], ['3']) + with patch.object(recreation, 'edit_setting', side_effect=edit): + result = recreation.edit_recreation(record(), ui) + self.assertEqual(result['candidate']['deployment']['resources']['cores'], 3) + self.assertEqual(result['candidate']['deployment']['environment'], record()['deployment']['environment']) + + def test_menu_and_review_do_not_expose_a_saved_secret(self, _language): + ui = MenuUI(['cores', 'apply'], ['3']) + recreation.edit_recreation(record(), ui) + self.assertNotIn('private-test-secret', json.dumps(ui.events)) + + def test_extra_paths_counts_only_paths_added_by_hand(self, _language): + candidate = record() + self.assertTrue(dict(recreation.modification_options(candidate))['paths'].rstrip().endswith(' 0')) + candidate['deployment']['mounts'].append({'container_path': '/media-extra', 'type': 'host-bind', + 'source': '/mnt/media', 'custom': True}) + self.assertTrue(dict(recreation.modification_options(candidate))['paths'].rstrip().endswith(' 1')) + + def test_optional_categories_follow_the_application_profile(self, _language): + candidate = record() + options = dict(recreation.modification_options(candidate)) + self.assertTrue({'cores', 'memory', 'network', 'paths', 'devices', 'environment', 'catalog', 'apply'} <= options.keys()) + self.assertFalse({'shares', 'acceleration', 'detector'} & options.keys()) + candidate['template']['proxmox']['installer_profile'] = { + 'shared_directories': {'base_path': '/shares'}, 'object_detector': {'devices': []}, + 'hardware_acceleration': {'profiles': [{'id': 'intel', 'label': 'Intel GPU'}]}} + candidate['deployment']['hardware_profile'] = 'intel' + options = dict(recreation.modification_options(candidate)) + self.assertTrue({'shares', 'acceleration', 'detector'} <= options.keys()) + self.assertIn('Intel GPU', options['acceleration']) + + +class ModifyDispatchTests(unittest.TestCase): + def call_manager(self, proposal, lifecycle): + import oci_instances + import oci_instance_reconcile + saved = record() + row = {'vmid': 115, 'reason': 'matched', 'stack': False, 'pending': False, 'status': 'installed'} + with patch.object(management, 'carry_records', return_value={}), \ + patch.object(management, 'check_selected', return_value=row), \ + patch.object(management, '_work_backup_ready', return_value=True), \ + patch.object(oci_instances, 'read', return_value=saved), \ + patch.object(oci_instances, 'command', return_value='hostname: example'), \ + patch.object(oci_instance_reconcile, 'propose', return_value=None), \ + patch.object(recreation, 'edit_recreation', return_value=proposal), \ + patch.object(management, '_run_lifecycle', side_effect=lifecycle): + return management.manage_instance(ROOT, Mock(), row, action='modify') + + def test_cancel_does_not_start_a_lifecycle_transaction(self): + lifecycle = Mock() + self.assertFalse(self.call_manager(None, lifecycle)) + lifecycle.assert_not_called() + + def test_apply_sends_one_private_proposal_to_the_existing_backend(self): + proposal = {'operation': 'recreate', 'candidate': record(), 'base_config_sha256': 'fingerprint'} + seen = [] + def lifecycle(command, title): + path = Path(command[command.index('--proposal') + 1]) + seen.append(json.loads(path.read_text())) + self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o600) + self.assertNotIn('private-test-secret', ' '.join(command)) + self.assertIn('oci_update_current.py', command[1]) + self.assertIn('--acknowledge-external-data', command) + return True + self.assertTrue(self.call_manager(proposal, lifecycle)) + self.assertEqual(seen, [proposal]) + + +if __name__ == '__main__': + unittest.main() diff --git a/oci/tests/test_readonly_volume_preparation.py b/oci/tests/test_readonly_volume_preparation.py new file mode 100644 index 00000000..37f67011 --- /dev/null +++ b/oci/tests/test_readonly_volume_preparation.py @@ -0,0 +1,187 @@ +"""Run the installer's volume preparation loop against filesystem fixtures. + +Write doubles reject every mutation of the protected directory, including +lost+found removal. Writable paths still run real rm/chmod; chown is recorded +without requiring root. These tests never invoke Proxmox or touch a container. +""" + +import json +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +INSTALLER = ROOT / "remote/install_oci.sh" + + +def preparation_loop(): + source = INSTALLER.read_text() + start = source.index(" if (( failed == 0 )) && [[ $(jq -r '.stack_managed") + end = source.index("\n if (( failed == 0 && tls_count == 1 )); then", start) + return source[start:end] + + +def preparation(target): + return { + "container_path": target, + "remove_lost_found": True, + "owner_strategy": "mapped-application-user", + "mode": "0750", + } + + +class ReadOnlyVolumePreparationTests(unittest.TestCase): + def run_case(self, mounts, *, target="/run/secrets", read_only=True, + preparations=None): + preparations = preparations if preparations is not None else [preparation(target)] + with tempfile.TemporaryDirectory() as directory: + fixture = Path(directory) + rootfs = fixture / "rootfs" + for item in preparations: + path = rootfs / item["container_path"].lstrip("/") + path.mkdir(parents=True, exist_ok=True) + (path / "lost+found").mkdir(exist_ok=True) + protected = rootfs / target.lstrip("/") + protected.mkdir(parents=True, exist_ok=True) + sentinel = protected / "sentinel" + sentinel.write_bytes(b"existing secret\n") + sentinel.chmod(0o600) + protected.chmod(0o700) + before = sentinel.stat() + template = {"proxmox": {"installer_profile": { + "volume_preparations": preparations, + }}} + (fixture / "template.json").write_text(json.dumps(template)) + (fixture / "deployment.json").write_text(json.dumps({"mounts": mounts})) + script = r'''set -Eeuo pipefail +rootfs="$FIXTURE/rootfs" +TEMPLATE_FILE="$FIXTURE/template.json" +DEPLOYMENT_FILE="$FIXTURE/deployment.json" +HOST_BIND_UID=110001; HOST_BIND_GID=110001 +HOST_ROOT_UID=100000; HOST_ROOT_GID=100000 +failed=0; volumes_prepared=0 +oci_log() { printf 'log %s\n' "$*"; } +write_op() { + local operation=$1 + shift + local path=${!#} + printf 'WRITE %s %s\n' "$operation" "$path" + if [[ -n $PROTECTED && ( $path == "$PROTECTED" || $path == "$PROTECTED/"* ) ]]; then + printf 'Read-only file system\n' >&2 + return 1 + fi + case "$operation" in + rm|chmod) command "$operation" "$@" ;; + chown) return 0 ;; + esac +} +chown() { write_op chown "$@"; } +chmod() { write_op chmod "$@"; } +rm() { write_op rm "$@"; } +''' + preparation_loop() + r''' +printf 'failed=%s prepared=%s\n' "$failed" "$volumes_prepared" +test "$failed" = 0 +''' + result = subprocess.run( + ["bash", "-c", script], capture_output=True, text=True, + env=dict(os.environ, FIXTURE=str(fixture), + PROTECTED=str(protected) if read_only else ""), + timeout=15, + ) + self.assertEqual(sentinel.read_bytes(), b"existing secret\n") + after = sentinel.stat() + self.assertEqual( + (before.st_ino, before.st_uid, before.st_gid, before.st_mode, before.st_mtime_ns), + (after.st_ino, after.st_uid, after.st_gid, after.st_mode, after.st_mtime_ns), + ) + if read_only: + self.assertEqual(protected.stat().st_mode & 0o777, 0o700) + self.assertTrue((protected / "lost+found").is_dir()) + elif "prepared=1" in result.stdout: + self.assertEqual(protected.stat().st_mode & 0o777, 0o750) + self.assertFalse((protected / "lost+found").exists()) + return result + + def assert_skipped(self, result): + self.assertEqual(result.returncode, 0, result.stderr + result.stdout) + self.assertNotIn("WRITE ", result.stdout) + self.assertIn("Skipping preparation of read-only volume", result.stdout) + self.assertIn("prepared=0", result.stdout) + + def assert_prepared(self, result): + self.assertEqual(result.returncode, 0, result.stderr + result.stdout) + for operation in ("rm", "chown", "chmod"): + self.assertIn(f"WRITE {operation} ", result.stdout) + self.assertIn("prepared=1", result.stdout) + + def test_read_only_managed_and_bind_volumes_receive_no_writes(self): + for mount_type in ("managed-volume", "host-bind"): + with self.subTest(mount_type=mount_type): + self.assert_skipped(self.run_case([ + {"container_path": "/run/secrets", "type": mount_type, "read_only": True}, + ])) + + def test_read_only_parent_protects_nested_preparation(self): + self.assert_skipped(self.run_case([ + {"container_path": "/run", "read_only": True}, + ])) + + def test_writable_child_overrides_read_only_parent_regardless_of_order(self): + mounts = [ + {"container_path": "/run", "read_only": True}, + {"container_path": "/run/secrets", "read_only": False}, + ] + for ordered in (mounts, mounts[::-1]): + with self.subTest(mounts=ordered): + self.assert_prepared(self.run_case(ordered, read_only=False)) + + def test_child_without_read_only_flag_overrides_read_only_parent(self): + self.assert_prepared(self.run_case([ + {"container_path": "/run", "read_only": True}, + {"container_path": "/run/secrets"}, + ], read_only=False)) + + def test_read_only_child_overrides_writable_parent_regardless_of_order(self): + mounts = [ + {"container_path": "/run/secrets", "read_only": True}, + {"container_path": "/run", "read_only": False}, + ] + for ordered in (mounts, mounts[::-1]): + with self.subTest(mounts=ordered): + self.assert_skipped(self.run_case(ordered)) + + def test_writable_volume_is_still_prepared(self): + self.assert_prepared(self.run_case([ + {"container_path": "/data", "read_only": False}, + ], target="/data", read_only=False)) + + def test_missing_read_only_flag_defaults_to_writable(self): + self.assert_prepared(self.run_case([ + {"container_path": "/data"}, + ], target="/data", read_only=False)) + + def test_unrelated_mount_with_similar_prefix_does_not_skip_preparation(self): + self.assert_prepared(self.run_case([ + {"container_path": "/run/secret", "read_only": True}, + ], read_only=False)) + + def test_directory_outside_mounts_is_still_prepared(self): + self.assert_prepared(self.run_case([], read_only=False)) + + def test_skipping_read_only_volume_does_not_skip_following_writable_volume(self): + result = self.run_case([ + {"container_path": "/run/secrets", "read_only": True}, + {"container_path": "/data", "read_only": False}, + ], preparations=[preparation("/run/secrets"), preparation("/data")]) + self.assert_prepared(result) + self.assertIn("Skipping preparation of read-only volume: /run/secrets", result.stdout) + for line in result.stdout.splitlines(): + if line.startswith("WRITE "): + self.assertTrue(line.endswith("/data") or line.endswith("/data/lost+found"), line) + + +if __name__ == "__main__": + unittest.main() diff --git a/oci/tests/test_runtime_home_contract.py b/oci/tests/test_runtime_home_contract.py index c14e8e26..25e9ca9e 100644 --- a/oci/tests/test_runtime_home_contract.py +++ b/oci/tests/test_runtime_home_contract.py @@ -31,14 +31,14 @@ class RuntimeHomeResolutionTests(unittest.TestCase): ) self.assertEqual(oci_runtime.home_directory(passwd, 1001), "/srv/app") - def test_unknown_or_unsafe_home_is_not_invented(self): + def test_unsafe_record_is_rejected_but_unknown_uid_defaults_to_root(self): passwd = self.passwd( "root:x:0:0:root:relative-home:/bin/sh\n" "app:x:1001:1001:App user::/sbin/nologin\n" ) self.assertIsNone(oci_runtime.home_directory(passwd, 0)) self.assertIsNone(oci_runtime.home_directory(passwd, 1001)) - self.assertIsNone(oci_runtime.home_directory(passwd, 1234)) + self.assertEqual(oci_runtime.home_directory(passwd, 1234), "/") class RuntimeHomeInstallerContractTests(unittest.TestCase): diff --git a/oci/tests/test_runtime_home_hardening.py b/oci/tests/test_runtime_home_hardening.py new file mode 100644 index 00000000..6418709d --- /dev/null +++ b/oci/tests/test_runtime_home_hardening.py @@ -0,0 +1,260 @@ +"""Exercise HOME resolution without reading image symlinks or changing guests.""" + +from contextlib import redirect_stderr, redirect_stdout +import io +import os +from pathlib import Path +import re +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "remote")) +import oci_runtime + + +class HomeHardeningTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + (self.root / "etc").mkdir() + self.passwd = self.root / "etc/passwd" + self.passwd.write_text("root:x:0:0:root:/root:/bin/sh\n", encoding="utf-8") + + def cli(self, uid="1234"): + output, error = io.StringIO(), io.StringIO() + with patch.object(sys, "argv", ["oci_runtime.py", "--home", str(self.passwd), uid]), \ + redirect_stdout(output), redirect_stderr(error): + status = oci_runtime.main() + return status, output.getvalue(), error.getvalue() + + def assert_rejected_without_read(self): + with patch.object(os, "fdopen", side_effect=AssertionError("unsafe file read")): + status, output, _ = self.cli() + self.assertEqual(status, 1) + self.assertEqual(output, "") + + def test_unknown_uid_uses_root_only_after_reading_a_regular_database(self): + self.assertEqual(self.cli(), (0, "/\n", "")) + + def test_empty_regular_database_uses_root(self): + self.passwd.write_text("") + self.assertEqual(self.cli(), (0, "/\n", "")) + + def test_final_symlink_is_rejected_without_reading_its_target(self): + target = self.root / "outside" + target.write_text("app:x:1234:1234::/host-secret:/bin/sh\n") + self.passwd.unlink() + self.passwd.symlink_to(target) + self.assert_rejected_without_read() + self.assertEqual(target.read_text(), "app:x:1234:1234::/host-secret:/bin/sh\n") + + def test_symlinked_etc_is_rejected_without_reading_its_target(self): + directory = self.root / "etc" + directory.rename(self.root / "outside-etc") + directory.symlink_to(self.root / "outside-etc", target_is_directory=True) + self.assert_rejected_without_read() + self.assertEqual(self.cli("0")[:2], (1, "")) + + def test_pinned_etc_does_not_follow_a_later_directory_replacement(self): + original_open = os.open + outside = self.root / "outside" + outside.mkdir() + (outside / "passwd").write_text("root:x:0:0::/outside:/bin/sh\n") + replaced = [] + def replace(path, flags, *args, **kwargs): + if str(path) == "passwd": + (self.root / "etc").rename(self.root / "original-etc") + (self.root / "etc").symlink_to(outside, target_is_directory=True) + replaced.append(True) + return original_open(path, flags, *args, **kwargs) + with patch.object(os, "open", side_effect=replace): + self.assertEqual(self.cli("0"), (0, "/root\n", "")) + self.assertEqual(replaced, [True]) + + def test_directory_is_rejected_without_reading(self): + self.passwd.unlink() + self.passwd.mkdir() + self.assert_rejected_without_read() + + def test_fifo_is_rejected_without_reading_or_waiting_for_a_writer(self): + self.passwd.unlink() + os.mkfifo(self.passwd) + done = subprocess.run( + [sys.executable, "-B", str(ROOT / "remote/oci_runtime.py"), "--home", str(self.passwd), "0"], + capture_output=True, text=True, timeout=3, + ) + self.assertEqual(done.returncode, 1) + self.assertEqual(done.stdout, "") + self.assert_rejected_without_read() + + def test_missing_database_does_not_masquerade_as_unknown_uid(self): + self.passwd.unlink() + self.assert_rejected_without_read() + + def test_permission_failure_does_not_masquerade_as_unknown_uid(self): + with patch.object(os, "open", side_effect=PermissionError("denied")): + self.assert_rejected_without_read() + + def test_invalid_encoding_does_not_produce_a_fallback(self): + self.passwd.write_bytes(b"\xff\xfe") + status, output, _ = self.cli() + self.assertEqual((status, output), (1, "")) + + def test_partial_matching_record_does_not_produce_a_fallback(self): + self.passwd.write_text("app:x:1234:1234\n") + self.assertEqual(self.cli()[:2], (1, "")) + + def test_read_error_closes_both_descriptors_without_a_fallback(self): + descriptors = [] + original_open = os.open + def opened(*args, **kwargs): + fd = original_open(*args, **kwargs) + descriptors.append(fd) + return fd + with patch.object(os, "open", side_effect=opened), \ + patch.object(os, "fdopen", side_effect=OSError("read failed")): + self.assertEqual(self.cli()[:2], (1, "")) + self.assertEqual(len(descriptors), 2) + for fd in descriptors: + with self.assertRaises(OSError): + os.fstat(fd) + + def test_matching_unsafe_home_is_not_replaced_with_root(self): + for value in ("", "relative", "/bad\tvalue", "/bad\x00value", "/bad\x7fvalue"): + with self.subTest(value=repr(value)): + self.passwd.write_text(f"app:x:1234:1234::{value}:/bin/sh\n") + self.assertEqual(self.cli()[:2], (1, "")) + + def test_invalid_uid_does_not_open_the_database(self): + for uid in ("-1", "nobody"): + with self.subTest(uid=uid), patch.object(os, "open") as opened: + self.assertEqual(self.cli(uid)[:2], (1, "")) + opened.assert_not_called() + + def test_symlink_replacement_between_check_and_open_is_not_read(self): + original_open = os.open + outside = self.root / "outside" + outside.write_text("app:x:1234:1234::/outside:/bin/sh\n") + def replace(path, flags, *args, **kwargs): + if str(path) == "passwd": + self.passwd.unlink() + self.passwd.symlink_to(outside) + return original_open(path, flags, *args, **kwargs) + with patch.object(os, "open", side_effect=replace): + self.assert_rejected_without_read() + + def test_fifo_replacement_between_check_and_open_is_not_read(self): + original_open = os.open + def replace(path, flags, *args, **kwargs): + if str(path) == "passwd": + self.passwd.unlink() + os.mkfifo(self.passwd) + return original_open(path, flags, *args, **kwargs) + with patch.object(os, "open", side_effect=replace): + self.assert_rejected_without_read() + + +class HomeInstallerExecutionTests(unittest.TestCase): + """Run the real shell HOME function and Python CLI with fake mount boundaries.""" + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + (self.root / "etc").mkdir() + self.passwd = self.root / "etc/passwd" + self.passwd.write_text("root:x:0:0:root:/root:/bin/sh\napp:x:1001:1001::/srv/app:/bin/sh\n") + self.conf = self.root / "guest.conf" + self.trace = self.root / "trace" + self.source = (ROOT / "remote/install_oci.sh").read_text() + + def run_home(self, config="unrelated: keep\n", unmount_status=0, mount_status=0): + self.conf.write_text(config) + self.trace.write_text("") + functions = [] + for name in ("ensure_runtime_home", "set_runtime_environment"): + found = re.search(rf"^{name}\(\) \{{\n.*?^\}}\n", self.source, re.M | re.S) + self.assertIsNotNone(found, name) + functions.append(found.group(0)) + script = r''' +set -euo pipefail +CONF=$1 +FIXTURE_ROOT=$2 +TRACE=$3 +OCI_RUNTIME_RESOLVER=$4 +PYTHON=$5 +UNMOUNT_STATUS=$6 +MOUNT_STATUS=$7 +OCI_LOG="$FIXTURE_ROOT/resolver.log" +VMID=123456789 +translate() { printf '%s' "$1"; } +die() { printf '%s\n' "$*" >&2; exit 1; } +mount_ct_rootfs() { + printf 'mount\n' >> "$TRACE" + [ "$MOUNT_STATUS" = 0 ] || die 'mount failed' + rootfs=$FIXTURE_ROOT + passwd_file="$rootfs/etc/passwd" +} +oci_quiet() { printf '%s\n' "$*" >> "$TRACE"; return "$UNMOUNT_STATUS"; } +python3() { printf 'resolve\n' >> "$TRACE"; command "$PYTHON" -B "$@"; } +''' + done = subprocess.run( + ["bash", "-c", script + "\n".join(functions) + "\nensure_runtime_home\nprintf 'continued\\n'\n", + "bash", str(self.conf), str(self.root), str(self.trace), str(ROOT / "remote/oci_runtime.py"), + sys.executable, str(unmount_status), str(mount_status)], + capture_output=True, text=True, timeout=5, + ) + return done, self.conf.read_text(), self.trace.read_text().splitlines() + + def test_explicit_home_including_empty_never_mounts_or_resolves(self): + for home in ("/custom", ""): + config = f"unrelated: keep\nlxc.environment.runtime: HOME={home}\n" + done, after, trace = self.run_home(config) + self.assertEqual((done.returncode, after, trace), (0, config, [])) + + def test_default_and_effective_uid_write_the_resolved_home_after_unmount(self): + for uid, expected in ((None, "/root"), (1001, "/srv/app"), (9999, "/")): + config = "unrelated: keep\n" + (f"lxc.init.uid: {uid}\n" if uid is not None else "") + done, after, trace = self.run_home(config) + self.assertEqual(done.returncode, 0, done.stderr) + self.assertEqual(after, config + f"lxc.environment.runtime: HOME={expected}\n") + self.assertEqual(trace, ["mount", "resolve", "pct unmount 123456789"]) + + def test_invalid_uid_leaves_config_unchanged_without_mount(self): + config = "lxc.init.uid: not-a-uid\n" + done, after, trace = self.run_home(config) + self.assertEqual((done.returncode, after, trace), (0, config, [])) + + def test_rejected_input_unmounts_and_preserves_config(self): + self.passwd.unlink() + self.passwd.symlink_to(self.root / "missing") + config = "unrelated: keep\n" + done, after, trace = self.run_home(config) + self.assertEqual((done.returncode, after), (0, config)) + self.assertEqual(trace, ["mount", "resolve", "pct unmount 123456789"]) + + def test_failed_unmount_stops_before_config_write_for_success_and_failure(self): + for valid in (True, False): + if not valid: + self.passwd.unlink() + config = "unrelated: keep\n" + done, after, trace = self.run_home(config, unmount_status=1) + self.assertNotEqual(done.returncode, 0) + self.assertEqual(after, config) + self.assertNotIn("continued", done.stdout) + self.assertEqual(trace, ["mount", "resolve", "pct unmount 123456789"]) + + def test_failed_mount_never_reads_or_writes(self): + config = "unrelated: keep\n" + done, after, trace = self.run_home(config, mount_status=1) + self.assertNotEqual(done.returncode, 0) + self.assertEqual(after, config) + self.assertEqual(trace, ["mount"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/oci/tests/test_shared_directories.py b/oci/tests/test_shared_directories.py index d8204835..55545374 100644 --- a/oci/tests/test_shared_directories.py +++ b/oci/tests/test_shared_directories.py @@ -43,15 +43,18 @@ class ScriptedUI: def password(self, text, required=True): return self._answer(text, "") - def choose(self, text, options, default=None): + def choose(self, text, options, default=None, **_kwargs): return self._answer(text, default) def confirm(self, text, default=False): return self._answer(text, default) - def message(self, text): + def message(self, text, *_args): pass + def review(self, *_args, **_kwargs): + return True + @patch("proxmenux_oci.i18n.language", return_value="en") @patch("proxmenux_oci.installer.host.storages", side_effect=storages) @@ -223,14 +226,15 @@ class SharedDirectoriesTests(unittest.TestCase): patch("proxmenux_oci.recreation.edit_acceleration"), \ patch("proxmenux_oci.recreation.edit_environment"): ui = ScriptedUI({"Add a directory to share": [True], NAME: ["photos"], + "Choose a setting. Changes are applied only after the final review.": ["shares", "apply"], "Where to store /shares/photos": ["host-bind"], "Host path for /shares/photos": ["/tank/photos"]}) proposal = edit_recreation(record, ui) mounts = [m["container_path"] for m in proposal["candidate"]["deployment"]["mounts"]] self.assertEqual(mounts, ["/config", "/shares/media", "/shares/photos"]) - untouched = edit_recreation(record, ScriptedUI()) - self.assertEqual([m["container_path"] for m in untouched["candidate"]["deployment"]["mounts"]], - ["/config", "/shares/media"]) + untouched = edit_recreation(record, ScriptedUI({ + "Choose a setting. Changes are applied only after the final review.": ["discard"]})) + self.assertIsNone(untouched) self.assertEqual([m["container_path"] for m in plan["mounts"]], ["/config", "/shares/media"])