mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-05 04:12:10 +00:00
Merge pull request #582 from jcastro/feature/oci-catalog-refresh
Refresh the OCI application catalog automatically with a local cache
This commit is contained in:
@@ -12,7 +12,7 @@ from unittest.mock import Mock, patch
|
|||||||
|
|
||||||
ROOT=Path(__file__).resolve().parents[3]
|
ROOT=Path(__file__).resolve().parents[3]
|
||||||
sys.path[:0]=[str(ROOT/'oci/src'),str(ROOT/'oci/remote')]
|
sys.path[:0]=[str(ROOT/'oci/src'),str(ROOT/'oci/remote')]
|
||||||
from proxmenux_oci import cli, custom, custom_mounts, extra_devices, i18n, installer, ui as native_ui, updates
|
from proxmenux_oci import catalog_refresh, cli, custom, custom_mounts, extra_devices, i18n, installer, ui as native_ui, updates
|
||||||
from proxmenux_oci.catalog import Catalog
|
from proxmenux_oci.catalog import Catalog
|
||||||
|
|
||||||
|
|
||||||
@@ -203,7 +203,13 @@ class InputAndCancellationTests(StorageFixture):
|
|||||||
def test_real_menu_error_handler_has_slovak_heading_and_preserves_detail(self):
|
def test_real_menu_error_handler_has_slovak_heading_and_preserves_detail(self):
|
||||||
ui=Mock();ui.choose.side_effect=['custom',None]
|
ui=Mock();ui.choose.side_effect=['custom',None]
|
||||||
with patch.object(cli,'interactive_ui',return_value=ui),patch.object(custom,'explore',side_effect=ValueError('fiction-detail')), \
|
with patch.object(cli,'interactive_ui',return_value=ui),patch.object(custom,'explore',side_effect=ValueError('fiction-detail')), \
|
||||||
patch.object(updates,'UpdateCheck') as checker:
|
patch.object(updates,'UpdateCheck') as checker, \
|
||||||
|
patch.object(catalog_refresh,'CatalogRefresh') as catalog_checker:
|
||||||
|
# Keep this wording/error-handler test offline and free of writes.
|
||||||
|
# The real background worker has its own catalog lifecycle tests.
|
||||||
|
catalog_check=catalog_checker.return_value.__enter__.return_value
|
||||||
|
catalog_check.active=False;catalog_check.consume.return_value=False
|
||||||
|
catalog_check.describe.return_value=''
|
||||||
check=checker.return_value.__enter__.return_value
|
check=checker.return_value.__enter__.return_value
|
||||||
check.active=False;check.notice.return_value='';check.status.return_value={'available':[]}
|
check.active=False;check.notice.return_value='';check.status.return_value={'available':[]}
|
||||||
self.assertEqual(cli.interactive(self.catalog),0)
|
self.assertEqual(cli.interactive(self.catalog),0)
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ Thumbs.db
|
|||||||
|
|
||||||
# Cache
|
# Cache
|
||||||
.cache
|
.cache
|
||||||
|
/oci/.catalog-cache/
|
||||||
/web/.cache
|
/web/.cache
|
||||||
|
|
||||||
# IDE/editor files
|
# IDE/editor files
|
||||||
|
|||||||
@@ -649,6 +649,7 @@
|
|||||||
"Available PVE Version": "Verfügbare PVE-Version",
|
"Available PVE Version": "Verfügbare PVE-Version",
|
||||||
"Available Shares:": "Verfügbare Aktien:",
|
"Available Shares:": "Verfügbare Aktien:",
|
||||||
"Available action": "Verfügbare Aktion",
|
"Available action": "Verfügbare Aktion",
|
||||||
|
"Available applications: {count}": "Verfügbare Anwendungen: {count}",
|
||||||
"Available archives:": "Verfügbare Archive:",
|
"Available archives:": "Verfügbare Archive:",
|
||||||
"Available disks detected.": "Verfügbare Festplatten erkannt.",
|
"Available disks detected.": "Verfügbare Festplatten erkannt.",
|
||||||
"Available host backups:": "Verfügbare Host-Backups:",
|
"Available host backups:": "Verfügbare Host-Backups:",
|
||||||
@@ -880,6 +881,7 @@
|
|||||||
"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.",
|
"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",
|
"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",
|
"Catalog options": "Katalogoptionen",
|
||||||
|
"Catalog refresh unavailable; using the local catalog.": "Der Katalog konnte nicht aktualisiert werden; der lokale Katalog wird verwendet.",
|
||||||
"Category": "Kategorie",
|
"Category": "Kategorie",
|
||||||
"Caution: Maximum mode generates more heat.": "Achtung: Im Maximalmodus entsteht mehr Wärme.",
|
"Caution: Maximum mode generates more heat.": "Achtung: Im Maximalmodus entsteht mehr Wärme.",
|
||||||
"Central server for file and image backups.": "Zentraler Server für Datei- und Bildsicherungen.",
|
"Central server for file and image backups.": "Zentraler Server für Datei- und Bildsicherungen.",
|
||||||
@@ -4452,6 +4454,7 @@
|
|||||||
"New Search": "Neue Suche",
|
"New Search": "Neue Suche",
|
||||||
"New VM:": "Neue VM:",
|
"New VM:": "Neue VM:",
|
||||||
"New Virtual Machine": "Neue virtuelle Maschine",
|
"New Virtual Machine": "Neue virtuelle Maschine",
|
||||||
|
"New applications since your last visit: {count}": "Neue Anwendungen seit Ihrem letzten Besuch: {count}",
|
||||||
"New backup job": "Neuer Sicherungsauftrag",
|
"New backup job": "Neuer Sicherungsauftrag",
|
||||||
"New backups on this host will be unencrypted until a new keyfile is set up.": "Neue Backups auf diesem Host werden unverschlüsselt sein, bis eine neue Schlüsseldatei eingerichtet wird.",
|
"New backups on this host will be unencrypted until a new keyfile is set up.": "Neue Backups auf diesem Host werden unverschlüsselt sein, bis eine neue Schlüsseldatei eingerichtet wird.",
|
||||||
"New image compatible:": "Neues Bild kompatibel:",
|
"New image compatible:": "Neues Bild kompatibel:",
|
||||||
@@ -5587,6 +5590,7 @@
|
|||||||
"Recursive and authoritative DNS server.": "Rekursiver und autoritativer DNS-Server.",
|
"Recursive and authoritative DNS server.": "Rekursiver und autoritativer DNS-Server.",
|
||||||
"Redmine": "Redmine",
|
"Redmine": "Redmine",
|
||||||
"Refresh APT index and verify repositories:": "APT-Index aktualisieren und Repositorys überprüfen:",
|
"Refresh APT index and verify repositories:": "APT-Index aktualisieren und Repositorys überprüfen:",
|
||||||
|
"Refresh catalog": "Katalog aktualisieren",
|
||||||
"Refresh the configured APT repositories and review the PBS package upgrade? Repository files will be preserved.": "Die konfigurierten APT-Repositories aktualisieren und die PBS-Paketaktualisierung prüfen? Repository-Dateien bleiben erhalten.",
|
"Refresh the configured APT repositories and review the PBS package upgrade? Repository files will be preserved.": "Die konfigurierten APT-Repositories aktualisieren und die PBS-Paketaktualisierung prüfen? Repository-Dateien bleiben erhalten.",
|
||||||
"Refresh the index from the image sources": "Den Index aus den Image-Quellen aktualisieren",
|
"Refresh the index from the image sources": "Den Index aus den Image-Quellen aktualisieren",
|
||||||
"Refresh token of the connector, from the Twingate admin console": "Aktualisierungstoken des Connectors aus der Twingate-Verwaltungskonsole",
|
"Refresh token of the connector, from the Twingate admin console": "Aktualisierungstoken des Connectors aus der Twingate-Verwaltungskonsole",
|
||||||
@@ -5594,6 +5598,7 @@
|
|||||||
"Refresh your browser to see changes (server restart may be required)": "Aktualisieren Sie Ihren Browser, um Änderungen zu sehen (möglicherweise ist ein Neustart des Servers erforderlich).",
|
"Refresh your browser to see changes (server restart may be required)": "Aktualisieren Sie Ihren Browser, um Änderungen zu sehen (möglicherweise ist ein Neustart des Servers erforderlich).",
|
||||||
"Refreshing apt cache...": "Apt-Cache wird aktualisiert...",
|
"Refreshing apt cache...": "Apt-Cache wird aktualisiert...",
|
||||||
"Refreshing the NVIDIA runtime...": "Erfrischung der NVIDIA Runtime...",
|
"Refreshing the NVIDIA runtime...": "Erfrischung der NVIDIA Runtime...",
|
||||||
|
"Refreshing the application catalog...": "Anwendungskatalog wird aktualisiert...",
|
||||||
"Refusing an unexpected rootfs path:": "Ablehnung eines unerwarteten Rootfs-Pfades:",
|
"Refusing an unexpected rootfs path:": "Ablehnung eines unerwarteten Rootfs-Pfades:",
|
||||||
"Regenerate the catalog templates": "Die Katalogvorlagen neu erstellen",
|
"Regenerate the catalog templates": "Die Katalogvorlagen neu erstellen",
|
||||||
"Regenerate the template of one application": "Die Vorlage einer Anwendung neu erstellen",
|
"Regenerate the template of one application": "Die Vorlage einer Anwendung neu erstellen",
|
||||||
@@ -7889,6 +7894,7 @@
|
|||||||
"Update the whole stack?": "Aktualisieren Sie den gesamten Stack?",
|
"Update the whole stack?": "Aktualisieren Sie den gesamten Stack?",
|
||||||
"Update, modify or recreate one installed OCI instance": "Eine installierte OCI-Instanz aktualisieren, ändern oder neu erstellen",
|
"Update, modify or recreate one installed OCI instance": "Eine installierte OCI-Instanz aktualisieren, ändern oder neu erstellen",
|
||||||
"Updated": "Aktualisiert",
|
"Updated": "Aktualisiert",
|
||||||
|
"Updated recipes since your last visit: {count}": "Aktualisierte Rezepte seit Ihrem letzten Besuch: {count}",
|
||||||
"Updated sharedfiles group to GID: 101000": "Sharedfiles-Gruppe auf GID aktualisiert: 101000",
|
"Updated sharedfiles group to GID: 101000": "Sharedfiles-Gruppe auf GID aktualisiert: 101000",
|
||||||
"Updated stack checked": "Aktualisierter Stapel geprüft",
|
"Updated stack checked": "Aktualisierter Stapel geprüft",
|
||||||
"Updated:": "Aktualisiert:",
|
"Updated:": "Aktualisiert:",
|
||||||
|
|||||||
@@ -647,6 +647,7 @@
|
|||||||
"Available PVE Version": "Versión PVE disponible",
|
"Available PVE Version": "Versión PVE disponible",
|
||||||
"Available Shares:": "Recursos compartidos disponibles:",
|
"Available Shares:": "Recursos compartidos disponibles:",
|
||||||
"Available action": "Acción disponible",
|
"Available action": "Acción disponible",
|
||||||
|
"Available applications: {count}": "Aplicaciones disponibles: {count}",
|
||||||
"Available archives:": "Archivos disponibles:",
|
"Available archives:": "Archivos disponibles:",
|
||||||
"Available disks detected.": "Discos disponibles detectados.",
|
"Available disks detected.": "Discos disponibles detectados.",
|
||||||
"Available host backups:": "Backups de host disponibles:",
|
"Available host backups:": "Backups de host disponibles:",
|
||||||
@@ -879,6 +880,7 @@
|
|||||||
"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.",
|
"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",
|
"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",
|
"Catalog options": "Opciones del catálogo",
|
||||||
|
"Catalog refresh unavailable; using the local catalog.": "No se pudo actualizar el catálogo; se está usando la copia local.",
|
||||||
"Category": "Categoría",
|
"Category": "Categoría",
|
||||||
"Caution: Maximum mode generates more heat.": "Precaución: el modo máximo genera más calor.",
|
"Caution: Maximum mode generates more heat.": "Precaución: el modo máximo genera más calor.",
|
||||||
"Central server for file and image backups.": "Servidor central para copias de seguridad de archivos e imágenes.",
|
"Central server for file and image backups.": "Servidor central para copias de seguridad de archivos e imágenes.",
|
||||||
@@ -4447,6 +4449,7 @@
|
|||||||
"New Search": "Nueva búsqueda",
|
"New Search": "Nueva búsqueda",
|
||||||
"New VM:": "Nueva máquina virtual:",
|
"New VM:": "Nueva máquina virtual:",
|
||||||
"New Virtual Machine": "Nueva máquina virtual",
|
"New Virtual Machine": "Nueva máquina virtual",
|
||||||
|
"New applications since your last visit: {count}": "Aplicaciones nuevas desde tu última visita: {count}",
|
||||||
"New backup job": "Nuevo trabajo de backup",
|
"New backup job": "Nuevo trabajo de backup",
|
||||||
"New backups on this host will be unencrypted until a new keyfile is set up.": "Los nuevos backups de este host no se cifrarán hasta que se configure un nuevo archivo de claves.",
|
"New backups on this host will be unencrypted until a new keyfile is set up.": "Los nuevos backups de este host no se cifrarán hasta que se configure un nuevo archivo de claves.",
|
||||||
"New image compatible:": "Nueva imagen compatible:",
|
"New image compatible:": "Nueva imagen compatible:",
|
||||||
@@ -5570,6 +5573,7 @@
|
|||||||
"Recursive and authoritative DNS server.": "Servidor DNS recursivo y autoritativo.",
|
"Recursive and authoritative DNS server.": "Servidor DNS recursivo y autoritativo.",
|
||||||
"Redmine": "Redmine",
|
"Redmine": "Redmine",
|
||||||
"Refresh APT index and verify repositories:": "Actualizar el índice APT y verificar los repositorios:",
|
"Refresh APT index and verify repositories:": "Actualizar el índice APT y verificar los repositorios:",
|
||||||
|
"Refresh catalog": "Actualizar catálogo",
|
||||||
"Refresh the configured APT repositories and review the PBS package upgrade? Repository files will be preserved.": "¿Actualizar los índices de los repositorios de APT configurados y revisar la actualización de paquetes de PBS? Se conservarán los archivos de los repositorios.",
|
"Refresh the configured APT repositories and review the PBS package upgrade? Repository files will be preserved.": "¿Actualizar los índices de los repositorios de APT configurados y revisar la actualización de paquetes de PBS? Se conservarán los archivos de los repositorios.",
|
||||||
"Refresh the index from the image sources": "Actualizar el índice desde las fuentes de imágenes",
|
"Refresh the index from the image sources": "Actualizar el índice desde las fuentes de imágenes",
|
||||||
"Refresh token of the connector, from the Twingate admin console": "Token de renovación del conector, de la consola de administración de Twingate",
|
"Refresh token of the connector, from the Twingate admin console": "Token de renovación del conector, de la consola de administración de Twingate",
|
||||||
@@ -5577,6 +5581,7 @@
|
|||||||
"Refresh your browser to see changes (server restart may be required)": "Actualiza tu navegador para ver los cambios (es posible que sea necesario reiniciar el servidor)",
|
"Refresh your browser to see changes (server restart may be required)": "Actualiza tu navegador para ver los cambios (es posible que sea necesario reiniciar el servidor)",
|
||||||
"Refreshing apt cache...": "Actualizando caché de apt...",
|
"Refreshing apt cache...": "Actualizando caché de apt...",
|
||||||
"Refreshing the NVIDIA runtime...": "Refrescando el tiempo de ejecución de NVIDIA...",
|
"Refreshing the NVIDIA runtime...": "Refrescando el tiempo de ejecución de NVIDIA...",
|
||||||
|
"Refreshing the application catalog...": "Actualizando el catálogo de aplicaciones...",
|
||||||
"Refusing an unexpected rootfs path:": "Refusing an inesperado rootfs path:",
|
"Refusing an unexpected rootfs path:": "Refusing an inesperado rootfs path:",
|
||||||
"Regenerate the catalog templates": "Regenerar las plantillas del catálogo",
|
"Regenerate the catalog templates": "Regenerar las plantillas del catálogo",
|
||||||
"Regenerate the template of one application": "Regenerar la plantilla de una aplicación",
|
"Regenerate the template of one application": "Regenerar la plantilla de una aplicación",
|
||||||
@@ -7860,6 +7865,7 @@
|
|||||||
"Update the whole stack?": "¿Actualizar toda la pila?",
|
"Update the whole stack?": "¿Actualizar toda la pila?",
|
||||||
"Update, modify or recreate one installed OCI instance": "Actualizar, modificar o recrear una instancia OCI instalada",
|
"Update, modify or recreate one installed OCI instance": "Actualizar, modificar o recrear una instancia OCI instalada",
|
||||||
"Updated": "Actualizado",
|
"Updated": "Actualizado",
|
||||||
|
"Updated recipes since your last visit: {count}": "Recetas actualizadas desde tu última visita: {count}",
|
||||||
"Updated sharedfiles group to GID: 101000": "Grupo de archivos compartidos actualizado a GID: 101000",
|
"Updated sharedfiles group to GID: 101000": "Grupo de archivos compartidos actualizado a GID: 101000",
|
||||||
"Updated stack checked": "Pila actualizada comprobada",
|
"Updated stack checked": "Pila actualizada comprobada",
|
||||||
"Updated:": "Actualizado:",
|
"Updated:": "Actualizado:",
|
||||||
|
|||||||
@@ -649,6 +649,7 @@
|
|||||||
"Available PVE Version": "Version PVE disponible",
|
"Available PVE Version": "Version PVE disponible",
|
||||||
"Available Shares:": "Actions disponibles :",
|
"Available Shares:": "Actions disponibles :",
|
||||||
"Available action": "Action disponible",
|
"Available action": "Action disponible",
|
||||||
|
"Available applications: {count}": "Applications disponibles : {count}",
|
||||||
"Available archives:": "Archives disponibles :",
|
"Available archives:": "Archives disponibles :",
|
||||||
"Available disks detected.": "Disques disponibles détectés.",
|
"Available disks detected.": "Disques disponibles détectés.",
|
||||||
"Available host backups:": "Sauvegardes d'hôte disponibles :",
|
"Available host backups:": "Sauvegardes d'hôte disponibles :",
|
||||||
@@ -880,6 +881,7 @@
|
|||||||
"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.",
|
"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",
|
"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",
|
"Catalog options": "Options du catalogue",
|
||||||
|
"Catalog refresh unavailable; using the local catalog.": "Impossible de mettre à jour le catalogue ; le catalogue local est utilisé.",
|
||||||
"Category": "Catégorie",
|
"Category": "Catégorie",
|
||||||
"Caution: Maximum mode generates more heat.": "Attention : le mode maximum génère plus de chaleur.",
|
"Caution: Maximum mode generates more heat.": "Attention : le mode maximum génère plus de chaleur.",
|
||||||
"Central server for file and image backups.": "Serveur central pour les sauvegardes de fichiers et d’images.",
|
"Central server for file and image backups.": "Serveur central pour les sauvegardes de fichiers et d’images.",
|
||||||
@@ -4452,6 +4454,7 @@
|
|||||||
"New Search": "Nouvelle recherche",
|
"New Search": "Nouvelle recherche",
|
||||||
"New VM:": "Nouvelle VM :",
|
"New VM:": "Nouvelle VM :",
|
||||||
"New Virtual Machine": "Nouvelle machine virtuelle",
|
"New Virtual Machine": "Nouvelle machine virtuelle",
|
||||||
|
"New applications since your last visit: {count}": "Nouvelles applications depuis votre dernière visite : {count}",
|
||||||
"New backup job": "Nouveau travail de sauvegarde",
|
"New backup job": "Nouveau travail de sauvegarde",
|
||||||
"New backups on this host will be unencrypted until a new keyfile is set up.": "Les nouvelles sauvegardes sur cet hôte ne seront pas chiffrées jusqu'à ce qu'un nouveau fichier de clés soit configuré.",
|
"New backups on this host will be unencrypted until a new keyfile is set up.": "Les nouvelles sauvegardes sur cet hôte ne seront pas chiffrées jusqu'à ce qu'un nouveau fichier de clés soit configuré.",
|
||||||
"New image compatible:": "Nouvelle image compatible & #160;:",
|
"New image compatible:": "Nouvelle image compatible & #160;:",
|
||||||
@@ -5587,6 +5590,7 @@
|
|||||||
"Recursive and authoritative DNS server.": "Serveur DNS récursif et faisant autorité.",
|
"Recursive and authoritative DNS server.": "Serveur DNS récursif et faisant autorité.",
|
||||||
"Redmine": "Redmine",
|
"Redmine": "Redmine",
|
||||||
"Refresh APT index and verify repositories:": "Actualisez l'index APT et vérifiez les référentiels :",
|
"Refresh APT index and verify repositories:": "Actualisez l'index APT et vérifiez les référentiels :",
|
||||||
|
"Refresh catalog": "Actualiser le catalogue",
|
||||||
"Refresh the configured APT repositories and review the PBS package upgrade? Repository files will be preserved.": "Actualiser les dépôts APT configurés et examiner la mise à niveau des paquets PBS ? Les fichiers des dépôts seront conservés.",
|
"Refresh the configured APT repositories and review the PBS package upgrade? Repository files will be preserved.": "Actualiser les dépôts APT configurés et examiner la mise à niveau des paquets PBS ? Les fichiers des dépôts seront conservés.",
|
||||||
"Refresh the index from the image sources": "Actualiser l'index à partir des sources d'images",
|
"Refresh the index from the image sources": "Actualiser l'index à partir des sources d'images",
|
||||||
"Refresh token of the connector, from the Twingate admin console": "Jeton d'actualisation du connecteur, depuis la console d'administration Twingate",
|
"Refresh token of the connector, from the Twingate admin console": "Jeton d'actualisation du connecteur, depuis la console d'administration Twingate",
|
||||||
@@ -5594,6 +5598,7 @@
|
|||||||
"Refresh your browser to see changes (server restart may be required)": "Actualisez votre navigateur pour voir les modifications (un redémarrage du serveur peut être nécessaire)",
|
"Refresh your browser to see changes (server restart may be required)": "Actualisez votre navigateur pour voir les modifications (un redémarrage du serveur peut être nécessaire)",
|
||||||
"Refreshing apt cache...": "Actualisation du cache apt...",
|
"Refreshing apt cache...": "Actualisation du cache apt...",
|
||||||
"Refreshing the NVIDIA runtime...": "Rafraîchir l'exécution NVIDIA...",
|
"Refreshing the NVIDIA runtime...": "Rafraîchir l'exécution NVIDIA...",
|
||||||
|
"Refreshing the application catalog...": "Mise à jour du catalogue des applications...",
|
||||||
"Refusing an unexpected rootfs path:": "Refuser un chemin inattendu de roofs :",
|
"Refusing an unexpected rootfs path:": "Refuser un chemin inattendu de roofs :",
|
||||||
"Regenerate the catalog templates": "Régénérer les modèles du catalogue",
|
"Regenerate the catalog templates": "Régénérer les modèles du catalogue",
|
||||||
"Regenerate the template of one application": "Régénérer le modèle d'une application",
|
"Regenerate the template of one application": "Régénérer le modèle d'une application",
|
||||||
@@ -7888,6 +7893,7 @@
|
|||||||
"Update the whole stack?": "Mettre à jour toute la pile ?",
|
"Update the whole stack?": "Mettre à jour toute la pile ?",
|
||||||
"Update, modify or recreate one installed OCI instance": "Mettre à jour, modifier ou recréer une instance OCI installée",
|
"Update, modify or recreate one installed OCI instance": "Mettre à jour, modifier ou recréer une instance OCI installée",
|
||||||
"Updated": "Mis à jour",
|
"Updated": "Mis à jour",
|
||||||
|
"Updated recipes since your last visit: {count}": "Recettes mises à jour depuis votre dernière visite : {count}",
|
||||||
"Updated sharedfiles group to GID: 101000": "Groupe de fichiers partagés mis à jour vers GID : 101000",
|
"Updated sharedfiles group to GID: 101000": "Groupe de fichiers partagés mis à jour vers GID : 101000",
|
||||||
"Updated stack checked": "Correction de la pile",
|
"Updated stack checked": "Correction de la pile",
|
||||||
"Updated:": "Mise à jour :",
|
"Updated:": "Mise à jour :",
|
||||||
|
|||||||
@@ -649,6 +649,7 @@
|
|||||||
"Available PVE Version": "Versione PVE disponibile",
|
"Available PVE Version": "Versione PVE disponibile",
|
||||||
"Available Shares:": "Condivisioni disponibili:",
|
"Available Shares:": "Condivisioni disponibili:",
|
||||||
"Available action": "Azione disponibile",
|
"Available action": "Azione disponibile",
|
||||||
|
"Available applications: {count}": "Applicazioni disponibili: {count}",
|
||||||
"Available archives:": "Archivi disponibili:",
|
"Available archives:": "Archivi disponibili:",
|
||||||
"Available disks detected.": "Rilevati dischi disponibili.",
|
"Available disks detected.": "Rilevati dischi disponibili.",
|
||||||
"Available host backups:": "Backup host disponibili:",
|
"Available host backups:": "Backup host disponibili:",
|
||||||
@@ -880,6 +881,7 @@
|
|||||||
"Cannot write the image archive to {storage}. Check storage access and mount status.": "Impossibile scrivere l'archivio dell'immagine su {storage}. Controlla l'accesso allo storage e lo stato di montaggio.",
|
"Cannot write the image archive to {storage}. Check storage access and mount status.": "Impossibile scrivere l'archivio dell'immagine su {storage}. Controlla l'accesso allo storage 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",
|
"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",
|
"Catalog options": "Opzioni del catalogo",
|
||||||
|
"Catalog refresh unavailable; using the local catalog.": "Impossibile aggiornare il catalogo; viene usata la copia locale.",
|
||||||
"Category": "Categoria",
|
"Category": "Categoria",
|
||||||
"Caution: Maximum mode generates more heat.": "Attenzione: la modalità massima genera più calore.",
|
"Caution: Maximum mode generates more heat.": "Attenzione: la modalità massima genera più calore.",
|
||||||
"Central server for file and image backups.": "Server centrale per il backup di file e immagini.",
|
"Central server for file and image backups.": "Server centrale per il backup di file e immagini.",
|
||||||
@@ -4452,6 +4454,7 @@
|
|||||||
"New Search": "Nuova ricerca",
|
"New Search": "Nuova ricerca",
|
||||||
"New VM:": "Nuova VM:",
|
"New VM:": "Nuova VM:",
|
||||||
"New Virtual Machine": "Nuova VM",
|
"New Virtual Machine": "Nuova VM",
|
||||||
|
"New applications since your last visit: {count}": "Nuove applicazioni dalla tua ultima visita: {count}",
|
||||||
"New backup job": "Nuova attività di backup",
|
"New backup job": "Nuova attività di backup",
|
||||||
"New backups on this host will be unencrypted until a new keyfile is set up.": "I nuovi backup su questo host non saranno crittografati finché non verrà impostato un nuovo file di chiavi.",
|
"New backups on this host will be unencrypted until a new keyfile is set up.": "I nuovi backup su questo host non saranno crittografati finché non verrà impostato un nuovo file di chiavi.",
|
||||||
"New image compatible:": "Nuova immagine compatibile:",
|
"New image compatible:": "Nuova immagine compatibile:",
|
||||||
@@ -5587,6 +5590,7 @@
|
|||||||
"Recursive and authoritative DNS server.": "Server DNS ricorsivo e autorevole.",
|
"Recursive and authoritative DNS server.": "Server DNS ricorsivo e autorevole.",
|
||||||
"Redmine": "Redmine",
|
"Redmine": "Redmine",
|
||||||
"Refresh APT index and verify repositories:": "Aggiorna l'indice APT e verifica i repository:",
|
"Refresh APT index and verify repositories:": "Aggiorna l'indice APT e verifica i repository:",
|
||||||
|
"Refresh catalog": "Aggiorna catalogo",
|
||||||
"Refresh the configured APT repositories and review the PBS package upgrade? Repository files will be preserved.": "Aggiornare gli indici dei repository APT configurati ed esaminare l'aggiornamento dei pacchetti PBS? I file dei repository verranno conservati.",
|
"Refresh the configured APT repositories and review the PBS package upgrade? Repository files will be preserved.": "Aggiornare gli indici dei repository APT configurati ed esaminare l'aggiornamento dei pacchetti PBS? I file dei repository verranno conservati.",
|
||||||
"Refresh the index from the image sources": "Aggiorna l'indice dalle sorgenti delle immagini",
|
"Refresh the index from the image sources": "Aggiorna l'indice dalle sorgenti delle immagini",
|
||||||
"Refresh token of the connector, from the Twingate admin console": "Token di refresh del connettore, dalla console di amministrazione Twingate",
|
"Refresh token of the connector, from the Twingate admin console": "Token di refresh del connettore, dalla console di amministrazione Twingate",
|
||||||
@@ -5594,6 +5598,7 @@
|
|||||||
"Refresh your browser to see changes (server restart may be required)": "Aggiorna il browser per vedere le modifiche (potrebbe essere necessario il riavvio del server)",
|
"Refresh your browser to see changes (server restart may be required)": "Aggiorna il browser per vedere le modifiche (potrebbe essere necessario il riavvio del server)",
|
||||||
"Refreshing apt cache...": "Aggiornamento cache apt...",
|
"Refreshing apt cache...": "Aggiornamento cache apt...",
|
||||||
"Refreshing the NVIDIA runtime...": "Aggiornamento del runtime NVIDIA...",
|
"Refreshing the NVIDIA runtime...": "Aggiornamento del runtime NVIDIA...",
|
||||||
|
"Refreshing the application catalog...": "Aggiornamento del catalogo delle applicazioni...",
|
||||||
"Refusing an unexpected rootfs path:": "Rifiutato un percorso rootfs inatteso:",
|
"Refusing an unexpected rootfs path:": "Rifiutato un percorso rootfs inatteso:",
|
||||||
"Regenerate the catalog templates": "Rigenera i modelli del catalogo",
|
"Regenerate the catalog templates": "Rigenera i modelli del catalogo",
|
||||||
"Regenerate the template of one application": "Rigenera il modello di un'applicazione",
|
"Regenerate the template of one application": "Rigenera il modello di un'applicazione",
|
||||||
@@ -7889,6 +7894,7 @@
|
|||||||
"Update the whole stack?": "Aggiornare l’intero stack?",
|
"Update the whole stack?": "Aggiornare l’intero stack?",
|
||||||
"Update, modify or recreate one installed OCI instance": "Aggiorna, modifica o ricrea un'istanza OCI installata",
|
"Update, modify or recreate one installed OCI instance": "Aggiorna, modifica o ricrea un'istanza OCI installata",
|
||||||
"Updated": "Aggiornato",
|
"Updated": "Aggiornato",
|
||||||
|
"Updated recipes since your last visit: {count}": "Ricette aggiornate dalla tua ultima visita: {count}",
|
||||||
"Updated sharedfiles group to GID: 101000": "Gruppo sharedfiles aggiornato a GID: 101000",
|
"Updated sharedfiles group to GID: 101000": "Gruppo sharedfiles aggiornato a GID: 101000",
|
||||||
"Updated stack checked": "Stack aggiornato verificato",
|
"Updated stack checked": "Stack aggiornato verificato",
|
||||||
"Updated:": "Aggiornato:",
|
"Updated:": "Aggiornato:",
|
||||||
|
|||||||
@@ -649,6 +649,7 @@
|
|||||||
"Available PVE Version": "Versão PVE disponível",
|
"Available PVE Version": "Versão PVE disponível",
|
||||||
"Available Shares:": "Ações disponíveis:",
|
"Available Shares:": "Ações disponíveis:",
|
||||||
"Available action": "Ação disponível",
|
"Available action": "Ação disponível",
|
||||||
|
"Available applications: {count}": "Aplicações disponíveis: {count}",
|
||||||
"Available archives:": "Arquivos disponíveis:",
|
"Available archives:": "Arquivos disponíveis:",
|
||||||
"Available disks detected.": "Discos disponíveis detectados.",
|
"Available disks detected.": "Discos disponíveis detectados.",
|
||||||
"Available host backups:": "Backups de host disponíveis:",
|
"Available host backups:": "Backups de host disponíveis:",
|
||||||
@@ -880,6 +881,7 @@
|
|||||||
"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.",
|
"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",
|
"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",
|
"Catalog options": "Opções do catálogo",
|
||||||
|
"Catalog refresh unavailable; using the local catalog.": "Não foi possível atualizar o catálogo; está a ser usada a cópia local.",
|
||||||
"Category": "Categoria",
|
"Category": "Categoria",
|
||||||
"Caution: Maximum mode generates more heat.": "Cuidado: O modo máximo gera mais calor.",
|
"Caution: Maximum mode generates more heat.": "Cuidado: O modo máximo gera mais calor.",
|
||||||
"Central server for file and image backups.": "Servidor central para backups de arquivos e imagens.",
|
"Central server for file and image backups.": "Servidor central para backups de arquivos e imagens.",
|
||||||
@@ -4452,6 +4454,7 @@
|
|||||||
"New Search": "Nova pesquisa",
|
"New Search": "Nova pesquisa",
|
||||||
"New VM:": "Nova VM:",
|
"New VM:": "Nova VM:",
|
||||||
"New Virtual Machine": "Nova máquina virtual",
|
"New Virtual Machine": "Nova máquina virtual",
|
||||||
|
"New applications since your last visit: {count}": "Novas aplicações desde a sua última visita: {count}",
|
||||||
"New backup job": "Nova tarefa de backup",
|
"New backup job": "Nova tarefa de backup",
|
||||||
"New backups on this host will be unencrypted until a new keyfile is set up.": "Novos backups neste host serão descriptografados até que um novo arquivo-chave seja configurado.",
|
"New backups on this host will be unencrypted until a new keyfile is set up.": "Novos backups neste host serão descriptografados até que um novo arquivo-chave seja configurado.",
|
||||||
"New image compatible:": "Nova imagem compatível:",
|
"New image compatible:": "Nova imagem compatível:",
|
||||||
@@ -5587,6 +5590,7 @@
|
|||||||
"Recursive and authoritative DNS server.": "Servidor DNS recursivo e autoritativo.",
|
"Recursive and authoritative DNS server.": "Servidor DNS recursivo e autoritativo.",
|
||||||
"Redmine": "Redmine",
|
"Redmine": "Redmine",
|
||||||
"Refresh APT index and verify repositories:": "Atualize o índice APT e verifique os repositórios:",
|
"Refresh APT index and verify repositories:": "Atualize o índice APT e verifique os repositórios:",
|
||||||
|
"Refresh catalog": "Atualizar catálogo",
|
||||||
"Refresh the configured APT repositories and review the PBS package upgrade? Repository files will be preserved.": "Atualizar os índices dos repositórios APT configurados e rever a atualização de pacotes PBS? Os ficheiros dos repositórios serão preservados.",
|
"Refresh the configured APT repositories and review the PBS package upgrade? Repository files will be preserved.": "Atualizar os índices dos repositórios APT configurados e rever a atualização de pacotes PBS? Os ficheiros dos repositórios serão preservados.",
|
||||||
"Refresh the index from the image sources": "Atualizar o índice a partir das origens de imagens",
|
"Refresh the index from the image sources": "Atualizar o índice a partir das origens de imagens",
|
||||||
"Refresh token of the connector, from the Twingate admin console": "Token de atualização do conector, no console administrativo Twingate",
|
"Refresh token of the connector, from the Twingate admin console": "Token de atualização do conector, no console administrativo Twingate",
|
||||||
@@ -5594,6 +5598,7 @@
|
|||||||
"Refresh your browser to see changes (server restart may be required)": "Atualize seu navegador para ver as alterações (pode ser necessário reiniciar o servidor)",
|
"Refresh your browser to see changes (server restart may be required)": "Atualize seu navegador para ver as alterações (pode ser necessário reiniciar o servidor)",
|
||||||
"Refreshing apt cache...": "Atualizando o cache do apt...",
|
"Refreshing apt cache...": "Atualizando o cache do apt...",
|
||||||
"Refreshing the NVIDIA runtime...": "Atualizando o tempo de execução NVIDIA...",
|
"Refreshing the NVIDIA runtime...": "Atualizando o tempo de execução NVIDIA...",
|
||||||
|
"Refreshing the application catalog...": "A atualizar o catálogo de aplicações...",
|
||||||
"Refusing an unexpected rootfs path:": "Recusando um caminho inesperado do rootfs:",
|
"Refusing an unexpected rootfs path:": "Recusando um caminho inesperado do rootfs:",
|
||||||
"Regenerate the catalog templates": "Regenerar os modelos do catálogo",
|
"Regenerate the catalog templates": "Regenerar os modelos do catálogo",
|
||||||
"Regenerate the template of one application": "Regenerar o modelo de uma aplicação",
|
"Regenerate the template of one application": "Regenerar o modelo de uma aplicação",
|
||||||
@@ -7889,6 +7894,7 @@
|
|||||||
"Update the whole stack?": "Atualizar toda a pilha?",
|
"Update the whole stack?": "Atualizar toda a pilha?",
|
||||||
"Update, modify or recreate one installed OCI instance": "Atualizar, modificar ou recriar uma instância OCI instalada",
|
"Update, modify or recreate one installed OCI instance": "Atualizar, modificar ou recriar uma instância OCI instalada",
|
||||||
"Updated": "Atualizado",
|
"Updated": "Atualizado",
|
||||||
|
"Updated recipes since your last visit: {count}": "Receitas atualizadas desde a sua última visita: {count}",
|
||||||
"Updated sharedfiles group to GID: 101000": "Grupo de arquivos compartilhados atualizado para GID: 101000",
|
"Updated sharedfiles group to GID: 101000": "Grupo de arquivos compartilhados atualizado para GID: 101000",
|
||||||
"Updated stack checked": "Stack atualizado verificado",
|
"Updated stack checked": "Stack atualizado verificado",
|
||||||
"Updated:": "Actualizado:",
|
"Updated:": "Actualizado:",
|
||||||
|
|||||||
@@ -649,6 +649,7 @@
|
|||||||
"Available PVE Version": "Dostupná verzia PVE",
|
"Available PVE Version": "Dostupná verzia PVE",
|
||||||
"Available Shares:": "Dostupné zdieľania:",
|
"Available Shares:": "Dostupné zdieľania:",
|
||||||
"Available action": "Dostupná akcia",
|
"Available action": "Dostupná akcia",
|
||||||
|
"Available applications: {count}": "Dostupné aplikácie: {count}",
|
||||||
"Available archives:": "Dostupné archívy:",
|
"Available archives:": "Dostupné archívy:",
|
||||||
"Available disks detected.": "Dostupné disky boli nájdené.",
|
"Available disks detected.": "Dostupné disky boli nájdené.",
|
||||||
"Available host backups:": "Dostupné zálohy hosta:",
|
"Available host backups:": "Dostupné zálohy hosta:",
|
||||||
@@ -880,6 +881,7 @@
|
|||||||
"Cannot write the image archive to {storage}. Check storage access and mount status.": "Archív obrazu sa nedá zapísať do úložiska {storage}. Skontrolujte prístup k úložisku a stav jeho pripojenia.",
|
"Cannot write the image archive to {storage}. Check storage access and mount status.": "Archív obrazu sa nedá zapísať do úložiska {storage}. Skontrolujte prístup k úložisku a stav jeho pripojenia.",
|
||||||
"Capabilities cannot be kept and all dropped at the same time": "Oprávnenia capabilities nemožno súčasne ponechať aj všetky odobrať",
|
"Capabilities cannot be kept and all dropped at the same time": "Oprávnenia capabilities nemožno súčasne ponechať aj všetky odobrať",
|
||||||
"Catalog options": "Možnosti katalógu",
|
"Catalog options": "Možnosti katalógu",
|
||||||
|
"Catalog refresh unavailable; using the local catalog.": "Katalóg sa nepodarilo aktualizovať; používa sa miestna kópia.",
|
||||||
"Category": "Kategória",
|
"Category": "Kategória",
|
||||||
"Caution: Maximum mode generates more heat.": "Pozor: maximálny režim vytvára viac tepla.",
|
"Caution: Maximum mode generates more heat.": "Pozor: maximálny režim vytvára viac tepla.",
|
||||||
"Central server for file and image backups.": "Centrálny server na zálohovanie súborov a obrazov.",
|
"Central server for file and image backups.": "Centrálny server na zálohovanie súborov a obrazov.",
|
||||||
@@ -4452,6 +4454,7 @@
|
|||||||
"New Search": "Nové hľadanie",
|
"New Search": "Nové hľadanie",
|
||||||
"New VM:": "Nová VM:",
|
"New VM:": "Nová VM:",
|
||||||
"New Virtual Machine": "Nová virtuálna mašina",
|
"New Virtual Machine": "Nová virtuálna mašina",
|
||||||
|
"New applications since your last visit: {count}": "Nové aplikácie od vašej poslednej návštevy: {count}",
|
||||||
"New backup job": "Nová úloha zálohy",
|
"New backup job": "Nová úloha zálohy",
|
||||||
"New backups on this host will be unencrypted until a new keyfile is set up.": "Nové zálohy na tomto hostovi budú nešifrované, kým nenastavíte nový keyfile.",
|
"New backups on this host will be unencrypted until a new keyfile is set up.": "Nové zálohy na tomto hostovi budú nešifrované, kým nenastavíte nový keyfile.",
|
||||||
"New image compatible:": "Nový obraz je kompatibilný:",
|
"New image compatible:": "Nový obraz je kompatibilný:",
|
||||||
@@ -5589,6 +5592,7 @@
|
|||||||
"Recursive and authoritative DNS server.": "Rekurzívny a autoritatívny DNS server.",
|
"Recursive and authoritative DNS server.": "Rekurzívny a autoritatívny DNS server.",
|
||||||
"Redmine": "Redmine",
|
"Redmine": "Redmine",
|
||||||
"Refresh APT index and verify repositories:": "Obnovte APT index a overte repozitáre:",
|
"Refresh APT index and verify repositories:": "Obnovte APT index a overte repozitáre:",
|
||||||
|
"Refresh catalog": "Aktualizovať katalóg",
|
||||||
"Refresh the configured APT repositories and review the PBS package upgrade? Repository files will be preserved.": "Obnoviť indexy nakonfigurovaných repozitárov APT a skontrolovať aktualizáciu balíkov PBS? Súbory repozitárov sa zachovajú.",
|
"Refresh the configured APT repositories and review the PBS package upgrade? Repository files will be preserved.": "Obnoviť indexy nakonfigurovaných repozitárov APT a skontrolovať aktualizáciu balíkov PBS? Súbory repozitárov sa zachovajú.",
|
||||||
"Refresh the index from the image sources": "Obnoviť index zo zdrojov obrazov",
|
"Refresh the index from the image sources": "Obnoviť index zo zdrojov obrazov",
|
||||||
"Refresh token of the connector, from the Twingate admin console": "Obnovovací token konektora z administrátorskej konzoly Twingate",
|
"Refresh token of the connector, from the Twingate admin console": "Obnovovací token konektora z administrátorskej konzoly Twingate",
|
||||||
@@ -5596,6 +5600,7 @@
|
|||||||
"Refresh your browser to see changes (server restart may be required)": "Obnovte stránku v prehliadači, aby ste videli zmeny (môže byť potrebný reštart servera)",
|
"Refresh your browser to see changes (server restart may be required)": "Obnovte stránku v prehliadači, aby ste videli zmeny (môže byť potrebný reštart servera)",
|
||||||
"Refreshing apt cache...": "Obnovujem apt cache...",
|
"Refreshing apt cache...": "Obnovujem apt cache...",
|
||||||
"Refreshing the NVIDIA runtime...": "Obnovenie prevádzky NVIDIA...",
|
"Refreshing the NVIDIA runtime...": "Obnovenie prevádzky NVIDIA...",
|
||||||
|
"Refreshing the application catalog...": "Aktualizuje sa katalóg aplikácií...",
|
||||||
"Refusing an unexpected rootfs path:": "Odmietnutá neočakávaná cesta koreňového súborového systému:",
|
"Refusing an unexpected rootfs path:": "Odmietnutá neočakávaná cesta koreňového súborového systému:",
|
||||||
"Regenerate the catalog templates": "Znova vygenerovať šablóny katalógu",
|
"Regenerate the catalog templates": "Znova vygenerovať šablóny katalógu",
|
||||||
"Regenerate the template of one application": "Znova vygenerovať šablónu jednej aplikácie",
|
"Regenerate the template of one application": "Znova vygenerovať šablónu jednej aplikácie",
|
||||||
@@ -7889,6 +7894,7 @@
|
|||||||
"Update the whole stack?": "Aktualizovať celú aplikáciu?",
|
"Update the whole stack?": "Aktualizovať celú aplikáciu?",
|
||||||
"Update, modify or recreate one installed OCI instance": "Aktualizovať, upraviť alebo znova vytvoriť jednu nainštalovanú inštanciu OCI",
|
"Update, modify or recreate one installed OCI instance": "Aktualizovať, upraviť alebo znova vytvoriť jednu nainštalovanú inštanciu OCI",
|
||||||
"Updated": "Aktualizované",
|
"Updated": "Aktualizované",
|
||||||
|
"Updated recipes since your last visit: {count}": "Aktualizované recepty od vašej poslednej návštevy: {count}",
|
||||||
"Updated sharedfiles group to GID: 101000": "Skupina sharedfiles bola aktualizovaná na GID: 101000",
|
"Updated sharedfiles group to GID: 101000": "Skupina sharedfiles bola aktualizovaná na GID: 101000",
|
||||||
"Updated stack checked": "Aktualizovaná aplikácia je skontrolovaná",
|
"Updated stack checked": "Aktualizovaná aplikácia je skontrolovaná",
|
||||||
"Updated:": "Aktualizované:",
|
"Updated:": "Aktualizované:",
|
||||||
|
|||||||
@@ -607,6 +607,7 @@
|
|||||||
"Audit socket disabled or not required": "Granskningsuttaget inaktiverat eller inte nödvändigt",
|
"Audit socket disabled or not required": "Granskningsuttaget inaktiverat eller inte nödvändigt",
|
||||||
"Auth key is required.": "Auth-nyckel krävs.",
|
"Auth key is required.": "Auth-nyckel krävs.",
|
||||||
"Auth:": "Auth:",
|
"Auth:": "Auth:",
|
||||||
|
"Authelia Web UI": "Authelias webbgränssnitt",
|
||||||
"Authelia administrator": "Authelia administratör",
|
"Authelia administrator": "Authelia administratör",
|
||||||
"Authelia session cookie domain for this portal (for example example.com)": "Authelia sessionscookiedomän för denna portal (till exempel example.com)",
|
"Authelia session cookie domain for this portal (for example example.com)": "Authelia sessionscookiedomän för denna portal (till exempel example.com)",
|
||||||
"Authentication": "Autentisering",
|
"Authentication": "Autentisering",
|
||||||
@@ -648,6 +649,7 @@
|
|||||||
"Available PVE Version": "Tillgänglig PVE-version",
|
"Available PVE Version": "Tillgänglig PVE-version",
|
||||||
"Available Shares:": "Tillgängliga andelar:",
|
"Available Shares:": "Tillgängliga andelar:",
|
||||||
"Available action": "Tillgänglig åtgärd",
|
"Available action": "Tillgänglig åtgärd",
|
||||||
|
"Available applications: {count}": "Tillgängliga appar: {count}",
|
||||||
"Available archives:": "Tillgängliga arkiv:",
|
"Available archives:": "Tillgängliga arkiv:",
|
||||||
"Available disks detected.": "Tillgängliga diskar har upptäckts.",
|
"Available disks detected.": "Tillgängliga diskar har upptäckts.",
|
||||||
"Available host backups:": "Tillgängliga värdsäkerhetskopior:",
|
"Available host backups:": "Tillgängliga värdsäkerhetskopior:",
|
||||||
@@ -879,6 +881,7 @@
|
|||||||
"Cannot write the image archive to {storage}. Check storage access and mount status.": "Kan inte skriva bildarkivet till {storage}. Kontrollera lagringsåtkomst och monteringsstatus.",
|
"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",
|
"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",
|
"Catalog options": "Katalogalternativ",
|
||||||
|
"Catalog refresh unavailable; using the local catalog.": "Katalogen kunde inte uppdateras; den lokala katalogen används.",
|
||||||
"Category": "Kategori",
|
"Category": "Kategori",
|
||||||
"Caution: Maximum mode generates more heat.": "Varning: Maximalt läge genererar mer värme.",
|
"Caution: Maximum mode generates more heat.": "Varning: Maximalt läge genererar mer värme.",
|
||||||
"Central server for file and image backups.": "Central server för säkerhetskopiering av filer och bilder.",
|
"Central server for file and image backups.": "Central server för säkerhetskopiering av filer och bilder.",
|
||||||
@@ -4450,6 +4453,7 @@
|
|||||||
"New Search": "Ny sökning",
|
"New Search": "Ny sökning",
|
||||||
"New VM:": "Ny VM:",
|
"New VM:": "Ny VM:",
|
||||||
"New Virtual Machine": "Ny virtuell maskin",
|
"New Virtual Machine": "Ny virtuell maskin",
|
||||||
|
"New applications since your last visit: {count}": "Nya appar sedan ditt senaste besök: {count}",
|
||||||
"New backup job": "Nytt säkerhetskopia jobb",
|
"New backup job": "Nytt säkerhetskopia jobb",
|
||||||
"New backups on this host will be unencrypted until a new keyfile is set up.": "Nya säkerhetskopior på denna värd kommer att vara okrypterade tills en ny nyckelfil ställs in.",
|
"New backups on this host will be unencrypted until a new keyfile is set up.": "Nya säkerhetskopior på denna värd kommer att vara okrypterade tills en ny nyckelfil ställs in.",
|
||||||
"New image compatible:": "Ny bildkompatibel:",
|
"New image compatible:": "Ny bildkompatibel:",
|
||||||
@@ -5585,6 +5589,7 @@
|
|||||||
"Recursive and authoritative DNS server.": "Rekursiv och auktoritativ DNS-server.",
|
"Recursive and authoritative DNS server.": "Rekursiv och auktoritativ DNS-server.",
|
||||||
"Redmine": "Redmine",
|
"Redmine": "Redmine",
|
||||||
"Refresh APT index and verify repositories:": "Uppdatera APT-index och verifiera förråd:",
|
"Refresh APT index and verify repositories:": "Uppdatera APT-index och verifiera förråd:",
|
||||||
|
"Refresh catalog": "Uppdatera katalogen",
|
||||||
"Refresh the configured APT repositories and review the PBS package upgrade? Repository files will be preserved.": "Uppdatera de konfigurerade APT-paketförråden och granska PBS-paketuppgraderingen? Paketförrådens filer bevaras.",
|
"Refresh the configured APT repositories and review the PBS package upgrade? Repository files will be preserved.": "Uppdatera de konfigurerade APT-paketförråden och granska PBS-paketuppgraderingen? Paketförrådens filer bevaras.",
|
||||||
"Refresh the index from the image sources": "Uppdatera indexet från avbildskällorna",
|
"Refresh the index from the image sources": "Uppdatera indexet från avbildskällorna",
|
||||||
"Refresh token of the connector, from the Twingate admin console": "Uppdateringstoken för kontakten, från Twingate-administratörskonsolen",
|
"Refresh token of the connector, from the Twingate admin console": "Uppdateringstoken för kontakten, från Twingate-administratörskonsolen",
|
||||||
@@ -5592,6 +5597,7 @@
|
|||||||
"Refresh your browser to see changes (server restart may be required)": "Uppdatera din webbläsare för att se ändringar (serverns omstart kan krävas)",
|
"Refresh your browser to see changes (server restart may be required)": "Uppdatera din webbläsare för att se ändringar (serverns omstart kan krävas)",
|
||||||
"Refreshing apt cache...": "Uppdaterar apt cache...",
|
"Refreshing apt cache...": "Uppdaterar apt cache...",
|
||||||
"Refreshing the NVIDIA runtime...": "Uppfriskande NVIDIA runtime...",
|
"Refreshing the NVIDIA runtime...": "Uppfriskande NVIDIA runtime...",
|
||||||
|
"Refreshing the application catalog...": "Uppdaterar appkatalogen...",
|
||||||
"Refusing an unexpected rootfs path:": "Vägrar en oväntad rootfs väg:",
|
"Refusing an unexpected rootfs path:": "Vägrar en oväntad rootfs väg:",
|
||||||
"Regenerate the catalog templates": "Generera om katalogmallarna",
|
"Regenerate the catalog templates": "Generera om katalogmallarna",
|
||||||
"Regenerate the template of one application": "Generera om mallen för en applikation",
|
"Regenerate the template of one application": "Generera om mallen för en applikation",
|
||||||
@@ -7887,6 +7893,7 @@
|
|||||||
"Update the whole stack?": "Uppdatera hela stacken?",
|
"Update the whole stack?": "Uppdatera hela stacken?",
|
||||||
"Update, modify or recreate one installed OCI instance": "Uppdatera, ändra eller återskapa en installerad OCI-instans",
|
"Update, modify or recreate one installed OCI instance": "Uppdatera, ändra eller återskapa en installerad OCI-instans",
|
||||||
"Updated": "Uppdaterad",
|
"Updated": "Uppdaterad",
|
||||||
|
"Updated recipes since your last visit: {count}": "Uppdaterade recept sedan ditt senaste besök: {count}",
|
||||||
"Updated sharedfiles group to GID: 101000": "Uppdaterad sharedfiles-grupp till GID: 101000",
|
"Updated sharedfiles group to GID: 101000": "Uppdaterad sharedfiles-grupp till GID: 101000",
|
||||||
"Updated stack checked": "Uppdaterad stack kontrollerad",
|
"Updated stack checked": "Uppdaterad stack kontrollerad",
|
||||||
"Updated:": "Uppdaterad:",
|
"Updated:": "Uppdaterad:",
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# Automatic catalog refresh
|
||||||
|
|
||||||
|
Opening OCI Manager displays its local catalog immediately and starts a separate
|
||||||
|
worker. The menu shows the available total, additions since the last displayed
|
||||||
|
catalog, and changed recipes. Returning from a search or installation does not
|
||||||
|
start another download. A running wizard keeps the catalog it started with.
|
||||||
|
The menu also offers **Refresh catalog** to check again manually or retry after
|
||||||
|
a connection failure. Selecting it during a refresh does not start a second worker.
|
||||||
|
Manual refresh bypasses the conditional check and verifies the complete manifest,
|
||||||
|
allowing it to repair damaged cached files even when the upstream revision has not
|
||||||
|
changed. A repaired snapshot gets a separate immutable directory so existing
|
||||||
|
readers retain their original snapshot until they return to the menu. Each
|
||||||
|
Catalog reader holds an OS file-lock lease. Every worker run, including unchanged
|
||||||
|
checks and failed downloads, prunes snapshots that are neither a channel's active
|
||||||
|
snapshot nor leased by an open reader. Reader exit or a crash releases its lease
|
||||||
|
automatically. Snapshot selection, publication and pruning share a short local
|
||||||
|
lock; opening a menu never waits for the worker's network lock.
|
||||||
|
|
||||||
|
The worker checks the latest commit affecting `oci/catalog` in the official
|
||||||
|
MacRimi/ProxMenux repository. GitHub's ETag avoids repeated transfers. When the
|
||||||
|
revision changes, its immutable Git tree supplies the file manifest. Only changed
|
||||||
|
JSON files are downloaded from that commit; Git blob hashes and sizes are checked.
|
||||||
|
The complete snapshot, including overlays, categories, volume policy and
|
||||||
|
verification records, is validated before publishing its pointer atomically.
|
||||||
|
Runtime Python or shell files are never downloaded by catalog refresh.
|
||||||
|
|
||||||
|
State and snapshots live in `/usr/local/share/proxmenux/oci/catalog-cache`, outside
|
||||||
|
the engine replaced by the normal ProxMenux updater. The bundled catalog remains
|
||||||
|
the fallback. A network failure, incomplete manifest, invalid recipe, different
|
||||||
|
template schema or unsupported catalog engine API leaves the previous catalog
|
||||||
|
active. The private `CHANNEL-result.json` records the diagnostic.
|
||||||
|
The revision, snapshot and ETag are committed together in one atomic pointer;
|
||||||
|
failure to publish it cannot acknowledge an update that was not applied.
|
||||||
|
|
||||||
|
The channel follows `install_branch`, an active beta program or `beta_version.txt`;
|
||||||
|
otherwise it is `main`. A host can explicitly select `main` or `develop` with
|
||||||
|
`/usr/local/share/proxmenux/oci/catalog-source.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"channel": "develop", "local_apps": ["my-local-preview"]}
|
||||||
|
```
|
||||||
|
|
||||||
|
`local_apps` optionally keeps explicitly named bundled preview recipes available
|
||||||
|
when they are absent upstream. These are local overrides, not automatic retention
|
||||||
|
of withdrawn upstream applications. An upstream recipe with the same ID wins.
|
||||||
|
|
||||||
|
The first visit uses the bundled/current catalog as its baseline. Additions are
|
||||||
|
compared by application ID, not net growth, and only visible installable entries
|
||||||
|
are counted, using the same rule as the existing menu. A downloaded catalog is
|
||||||
|
acknowledged only after the menu displays it. Exiting during a download therefore
|
||||||
|
preserves its news for the next visit. The new-app count remains visible throughout
|
||||||
|
the current visit and becomes zero on the next visit after acknowledgement.
|
||||||
|
|
||||||
|
Downloaded recipes are also checked against the top-level `installer_profile`
|
||||||
|
keys used by the engine's bundled apps, curated recipes and overlays. A new app
|
||||||
|
using an unknown key is silently excluded; an existing app retains its previous
|
||||||
|
compatible, fully composed recipe and its matching settings. Hidden entries do
|
||||||
|
not contribute to the available/new counters, and retaining an old recipe does
|
||||||
|
not count it as an update. When a ProxMenux update adds support for that key, the
|
||||||
|
next background check re-evaluates the catalog even at the same remote revision.
|
||||||
|
This is a conservative key-presence gate, not validation of new values or nested
|
||||||
|
options of an already known key. Changes in meaning still require `engine_api`.
|
||||||
|
|
||||||
|
Catalog maintainers must increase `catalog/compatibility.json`'s `engine_api` when
|
||||||
|
recipes require incompatible runtime capabilities. The consumer currently
|
||||||
|
supports API 1. Changes to the template schema also require a ProxMenux engine
|
||||||
|
update. Catalog delivery alone neither changes installed CTs nor applies a new
|
||||||
|
recipe to an existing deployment; the established management actions remain in
|
||||||
|
charge of those changes.
|
||||||
@@ -1117,3 +1117,9 @@ have not been tested. No custom supervisor or image entrypoint is introduced.
|
|||||||
DeepSeek OCR remains deferred at the user's request: registry inspection on
|
DeepSeek OCR remains deferred at the user's request: registry inspection on
|
||||||
2026-09-14 found only `v2.2.0` for both IceWhaleTech images and no `latest` tag.
|
2026-09-14 found only `v2.2.0` for both IceWhaleTech images and no `latest` tag.
|
||||||
No fixed-version exception or deployment has been introduced.
|
No fixed-version exception or deployment has been introduced.
|
||||||
|
|
||||||
|
## Automatic catalog refresh
|
||||||
|
|
||||||
|
OCI Manager opens with its local catalog and checks for new applications and recipes
|
||||||
|
in the background. **Refresh catalog** repeats the check manually. See
|
||||||
|
[Automatic catalog refresh](CATALOG-REFRESH.md) for caching, channels and compatibility.
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"engine_api": 1,
|
||||||
|
"description": "Increment engine_api when catalog recipes require runtime capabilities unavailable in earlier catalog-refresh clients."
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import weakref
|
||||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -56,9 +57,15 @@ def is_multi_container(template: dict[str, Any]) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
class Catalog:
|
class Catalog:
|
||||||
def __init__(self, root: Path, source: GitHubSource | None = None) -> None:
|
def __init__(self, root: Path, source: GitHubSource | None = None, *,
|
||||||
|
catalog_dir: Path | None = None) -> None:
|
||||||
self.root = root
|
self.root = root
|
||||||
self.catalog_dir = root / "catalog"
|
from .catalog_refresh import pin_catalog
|
||||||
|
self.catalog_dir, lease = pin_catalog(root, catalog_dir)
|
||||||
|
if lease is not None:
|
||||||
|
self._snapshot_lease = weakref.finalize(self, lease.close)
|
||||||
|
self._include_local = catalog_dir is None and self.catalog_dir != root / "catalog"
|
||||||
|
self.local_app_ids: set[str] = set()
|
||||||
self.apps_dir = self.catalog_dir / "apps"
|
self.apps_dir = self.catalog_dir / "apps"
|
||||||
self.curated_dir = self.catalog_dir / "curated"
|
self.curated_dir = self.catalog_dir / "curated"
|
||||||
self.overlays_dir = self.catalog_dir / "overlays"
|
self.overlays_dir = self.catalog_dir / "overlays"
|
||||||
@@ -345,6 +352,16 @@ class Catalog:
|
|||||||
payload = json.loads(self.index_path.read_text(encoding="utf-8"))
|
payload = json.loads(self.index_path.read_text(encoding="utf-8"))
|
||||||
self._enrich_index_from_templates(payload)
|
self._enrich_index_from_templates(payload)
|
||||||
self._apply_verification_to_index(payload)
|
self._apply_verification_to_index(payload)
|
||||||
|
if self._include_local:
|
||||||
|
from .catalog_refresh import source_settings
|
||||||
|
requested = source_settings(self.root).get("local_apps", [])
|
||||||
|
if isinstance(requested, list) and requested:
|
||||||
|
bundled = Catalog(self.root, catalog_dir=self.root / "catalog").load_index()
|
||||||
|
present = {item["id"] for item in payload["applications"]}
|
||||||
|
local = [item for item in bundled["applications"]
|
||||||
|
if item["id"] in requested and item["id"] not in present]
|
||||||
|
self.local_app_ids = {item["id"] for item in local}
|
||||||
|
payload["applications"].extend(local)
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
def categories(self) -> dict[str, Any]:
|
def categories(self) -> dict[str, Any]:
|
||||||
@@ -588,7 +605,7 @@ class Catalog:
|
|||||||
item["id"],
|
item["id"],
|
||||||
)
|
)
|
||||||
elif item.get("template_family") == "curated-profile":
|
elif item.get("template_family") == "curated-profile":
|
||||||
template = json.loads((self.root / item["curated_path"]).read_text(encoding="utf-8"))
|
template = json.loads((self._curated_path(item)).read_text(encoding="utf-8"))
|
||||||
else:
|
else:
|
||||||
raise ConversionError(f"Proveedor no soportado: {provider}")
|
raise ConversionError(f"Proveedor no soportado: {provider}")
|
||||||
catalog_id = item["id"]
|
catalog_id = item["id"]
|
||||||
@@ -641,7 +658,7 @@ class Catalog:
|
|||||||
)
|
)
|
||||||
elif item.get("template_family") == "curated-profile":
|
elif item.get("template_family") == "curated-profile":
|
||||||
template = json.loads(
|
template = json.loads(
|
||||||
(self.root / item["curated_path"]).read_text(encoding="utf-8")
|
(self._curated_path(item)).read_text(encoding="utf-8")
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
raise ConversionError(f"Proveedor no soportado: {item_provider}")
|
raise ConversionError(f"Proveedor no soportado: {item_provider}")
|
||||||
@@ -785,18 +802,35 @@ class Catalog:
|
|||||||
if errors:
|
if errors:
|
||||||
raise ConversionError(f"La plantilla generada no cumple el esquema: {'; '.join(errors)}")
|
raise ConversionError(f"La plantilla generada no cumple el esquema: {'; '.join(errors)}")
|
||||||
|
|
||||||
|
def _curated_path(self, item: dict[str, Any]) -> Path:
|
||||||
|
path = Path(item["curated_path"])
|
||||||
|
if path.parts and path.parts[0] == "catalog":
|
||||||
|
return self.catalog_dir / path.relative_to("catalog")
|
||||||
|
# Older local profiles may use an engine-relative path. Downloaded
|
||||||
|
# catalogs are checked separately and must use catalog/curated paths.
|
||||||
|
return self.root / path
|
||||||
|
|
||||||
def compose(self, app_id: str) -> dict[str, Any]:
|
def compose(self, app_id: str) -> dict[str, Any]:
|
||||||
"""The installable template, built only from the files shipped with
|
"""The installable template, built only from the files shipped with
|
||||||
ProxMenux: the curated profile or the pre-generated template, with its
|
ProxMenux: the curated profile or the pre-generated template, with its
|
||||||
overlay applied. Nothing is downloaded and nothing is written."""
|
overlay applied. Nothing is downloaded and nothing is written."""
|
||||||
item = self.find_item(app_id)
|
return self._compose_item(self.find_item(app_id))
|
||||||
|
|
||||||
|
def _compose_item(self, item: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Compose an already loaded item when validating a complete snapshot."""
|
||||||
|
app_id = item["id"]
|
||||||
|
if app_id in self.local_app_ids:
|
||||||
|
return Catalog(self.root, catalog_dir=self.root / "catalog").compose(app_id)
|
||||||
if item.get("template_family") == "curated-profile":
|
if item.get("template_family") == "curated-profile":
|
||||||
path = self.root / item["curated_path"]
|
path = self._curated_path(item)
|
||||||
else:
|
else:
|
||||||
path = self.apps_dir / f"{item['id']}.json"
|
path = self.apps_dir / f"{item['id']}.json"
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
raise ConversionError(f"No template is available for '{app_id}'")
|
raise ConversionError(f"No template is available for '{app_id}'")
|
||||||
template = json.loads(path.read_text(encoding="utf-8"))
|
template = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
if item.get("_retained_recipe"):
|
||||||
|
self.validate(template, required=False)
|
||||||
|
return template
|
||||||
self._apply_overlay(item["id"], template)
|
self._apply_overlay(item["id"], template)
|
||||||
self._apply_category(item["id"], template["catalog_ui"])
|
self._apply_category(item["id"], template["catalog_ui"])
|
||||||
self._apply_volume_policy(item["id"], template["container_contract"],
|
self._apply_volume_policy(item["id"], template["container_contract"],
|
||||||
|
|||||||
@@ -0,0 +1,535 @@
|
|||||||
|
"""Refresh the reviewed ProxMenux catalog without blocking the OCI menu.
|
||||||
|
|
||||||
|
GitHub's catalog commit and tree form the manifest. Files are fetched from
|
||||||
|
that immutable commit and checked against their Git blob IDs. Runtime code is
|
||||||
|
never downloaded. An installed engine always retains its bundled fallback.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
import fcntl
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path, PurePosixPath
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from .i18n import translate
|
||||||
|
|
||||||
|
REPOSITORY = "MacRimi/ProxMenux"
|
||||||
|
CATALOG_API = 1
|
||||||
|
MAX_FILE = 4 * 1024 * 1024
|
||||||
|
MAX_TOTAL = 40 * 1024 * 1024
|
||||||
|
SHA = re.compile(r"[0-9a-f]{40}")
|
||||||
|
ROOT_FILES = {"index.json", "categories.json", "volume-policy.json",
|
||||||
|
"exclusions.json", "verification.json", "compatibility.json"}
|
||||||
|
|
||||||
|
|
||||||
|
def source_settings(root):
|
||||||
|
root = Path(root)
|
||||||
|
base = (root.parent.parent if root.name == "engine" else
|
||||||
|
root.parent if root.name == "oci" else root)
|
||||||
|
return read_json(base / "oci/catalog-source.json")
|
||||||
|
|
||||||
|
|
||||||
|
def channel_for(root):
|
||||||
|
"""Keep stable and beta catalogs separate; allow a local channel choice."""
|
||||||
|
root = Path(root)
|
||||||
|
base = (root.parent.parent if root.name == "engine" else
|
||||||
|
root.parent if root.name == "oci" else root)
|
||||||
|
for name in ("oci/catalog-source.json", "config.json"):
|
||||||
|
try:
|
||||||
|
value = read_json(base / name)
|
||||||
|
channel = value.get("channel") or value.get("install_branch")
|
||||||
|
if channel in ("main", "develop"):
|
||||||
|
return channel
|
||||||
|
beta = value.get("beta_program")
|
||||||
|
if isinstance(beta, dict) and beta.get("status") == "active":
|
||||||
|
return "develop"
|
||||||
|
except (OSError, ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
return "develop" if (base / "beta_version.txt").exists() else "main"
|
||||||
|
|
||||||
|
|
||||||
|
def cache_root(root):
|
||||||
|
root = Path(root)
|
||||||
|
return root.parent / "catalog-cache" if root.name == "engine" else root / ".catalog-cache"
|
||||||
|
|
||||||
|
|
||||||
|
def read_json(path, default=None):
|
||||||
|
try:
|
||||||
|
value = json.loads(Path(path).read_text())
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return value
|
||||||
|
except (OSError, ValueError):
|
||||||
|
pass
|
||||||
|
return {} if default is None else default
|
||||||
|
|
||||||
|
|
||||||
|
def write_json(path, value):
|
||||||
|
path = Path(path)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
fd, name = tempfile.mkstemp(prefix=".write-", dir=path.parent)
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, "w") as stream:
|
||||||
|
json.dump(value, stream, ensure_ascii=False)
|
||||||
|
stream.write("\n")
|
||||||
|
stream.flush()
|
||||||
|
os.fsync(stream.fileno())
|
||||||
|
os.replace(name, path)
|
||||||
|
finally:
|
||||||
|
Path(name).unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def schema_hash(root):
|
||||||
|
return hashlib.sha256((Path(root) / "schemas/oci-template.schema.json").read_bytes()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def bundle_hash(root):
|
||||||
|
# Cheap menu-time engine-update detection; no scan of the bundled recipes.
|
||||||
|
return hashlib.sha256((Path(root) / "catalog/index.json").read_bytes()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def snapshot_lock(root):
|
||||||
|
cache = cache_root(root)
|
||||||
|
cache.mkdir(parents=True, exist_ok=True)
|
||||||
|
with (cache / "snapshots.lock").open("a") as lock:
|
||||||
|
fcntl.flock(lock, fcntl.LOCK_EX)
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
def pin_catalog(root, directory=None):
|
||||||
|
"""Select and lease atomically against pruning; no network/worker wait."""
|
||||||
|
explicit = directory
|
||||||
|
directory = Path(directory) if directory is not None else current_catalog(root)
|
||||||
|
if (directory.parent != cache_root(root) / "snapshots"
|
||||||
|
or not re.fullmatch(r"[0-9a-f]{40}(?:-[0-9a-f]{12})?", directory.name)):
|
||||||
|
return directory, None
|
||||||
|
with snapshot_lock(root):
|
||||||
|
directory = Path(explicit) if explicit is not None else current_catalog(root)
|
||||||
|
snapshots = cache_root(root) / "snapshots"
|
||||||
|
if directory.parent != snapshots or not directory.is_dir():
|
||||||
|
return directory, None
|
||||||
|
leases = cache_root(root) / "leases"
|
||||||
|
leases.mkdir(exist_ok=True)
|
||||||
|
lease = (leases / directory.name).open("a")
|
||||||
|
fcntl.flock(lease, fcntl.LOCK_SH)
|
||||||
|
return directory, lease
|
||||||
|
|
||||||
|
|
||||||
|
def prune_snapshots(root):
|
||||||
|
"""Retain channel pointers and live readers; locks survive PID reuse/crashes."""
|
||||||
|
cache = cache_root(root)
|
||||||
|
with snapshot_lock(root):
|
||||||
|
keep = {read_json(p).get("snapshot", read_json(p).get("revision"))
|
||||||
|
for p in cache.glob("*-current.json")}
|
||||||
|
leases = cache / "leases"
|
||||||
|
leases.mkdir(exist_ok=True)
|
||||||
|
for directory in (cache / "snapshots").glob("*"):
|
||||||
|
if (not SHA.fullmatch(directory.name.split("-")[0])
|
||||||
|
or directory.name in keep or directory.is_symlink() or not directory.is_dir()):
|
||||||
|
continue
|
||||||
|
lease_path = leases / directory.name
|
||||||
|
with lease_path.open("a") as lease:
|
||||||
|
try:
|
||||||
|
fcntl.flock(lease, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
|
except BlockingIOError:
|
||||||
|
continue
|
||||||
|
shutil.rmtree(directory)
|
||||||
|
lease_path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def supported_profile_keys(root):
|
||||||
|
keys = set()
|
||||||
|
for folder in ("apps", "curated", "overlays"):
|
||||||
|
for path in (Path(root) / "catalog" / folder).glob("*.json"):
|
||||||
|
value = json.loads(path.read_text())
|
||||||
|
keys.update(value.get("proxmox", {}).get("installer_profile", {}))
|
||||||
|
return keys
|
||||||
|
|
||||||
|
|
||||||
|
def filter_compatible_recipes(root, directory, previous):
|
||||||
|
"""Silently defer new engine capabilities, retaining compatible old apps."""
|
||||||
|
from .catalog import Catalog
|
||||||
|
supported = supported_profile_keys(root)
|
||||||
|
candidate = Catalog(Path(root), catalog_dir=directory)
|
||||||
|
old = Catalog(Path(root), catalog_dir=previous)
|
||||||
|
old_items = {item["id"]: item for item in old.load_index()["applications"]}
|
||||||
|
effective_items = {item["id"]: item for item in candidate.load_index()["applications"]}
|
||||||
|
index = json.loads((directory / "index.json").read_text())
|
||||||
|
accepted = []
|
||||||
|
old_fingerprints = None
|
||||||
|
for item in index["applications"]:
|
||||||
|
effective = effective_items[item["id"]]
|
||||||
|
if effective.get("hidden") or not effective.get("automatic_install_candidate"):
|
||||||
|
accepted.append(item)
|
||||||
|
continue
|
||||||
|
template = candidate._compose_item(item)
|
||||||
|
profile = template.get("proxmox", {}).get("installer_profile", {})
|
||||||
|
if set(profile) <= supported:
|
||||||
|
accepted.append(item)
|
||||||
|
continue
|
||||||
|
fallback = old_items.get(item["id"])
|
||||||
|
if fallback is None:
|
||||||
|
continue
|
||||||
|
template = old._compose_item(fallback)
|
||||||
|
if not set(template.get("proxmox", {}).get("installer_profile", {})) <= supported:
|
||||||
|
continue
|
||||||
|
# Materialize the previously composed contract: its old overlay/volume
|
||||||
|
# policy must not be combined with a newly downloaded incompatible one.
|
||||||
|
app_id = item["id"]
|
||||||
|
for folder in ("apps", "curated"):
|
||||||
|
path = directory / folder / f"{app_id}.json"
|
||||||
|
path.parent.mkdir(exist_ok=True)
|
||||||
|
write_json(path, template)
|
||||||
|
(directory / "overlays" / f"{app_id}.json").unlink(missing_ok=True)
|
||||||
|
if old_fingerprints is None:
|
||||||
|
old_fingerprints = visible_recipes(old)
|
||||||
|
fallback = dict(fallback, _retained_recipe_hash=old_fingerprints.get(app_id), template_family="curated-profile",
|
||||||
|
curated_path=f"catalog/curated/{app_id}.json",
|
||||||
|
_retained_recipe=True)
|
||||||
|
accepted.append(fallback)
|
||||||
|
index["applications"] = accepted
|
||||||
|
write_json(directory / "index.json", index)
|
||||||
|
|
||||||
|
|
||||||
|
def current_catalog(root, channel=None):
|
||||||
|
root = Path(root)
|
||||||
|
channel = channel or channel_for(root)
|
||||||
|
value = read_json(cache_root(root) / f"{channel}-current.json")
|
||||||
|
revision = value.get("revision", "")
|
||||||
|
if (isinstance(revision, str) and SHA.fullmatch(revision)
|
||||||
|
and value.get("schema_hash") == schema_hash(root)
|
||||||
|
and value.get("engine_api") == CATALOG_API
|
||||||
|
and value.get("bundle_hash") == bundle_hash(root)):
|
||||||
|
snapshot = value.get("snapshot", revision)
|
||||||
|
if not isinstance(snapshot, str) or not re.fullmatch(revision + r"(?:-[0-9a-f]{12})?", snapshot):
|
||||||
|
return root / "catalog"
|
||||||
|
path = cache_root(root) / "snapshots" / snapshot
|
||||||
|
index = read_json(path / "index.json")
|
||||||
|
if (index.get("kind") == "proxmenux.oci-catalog-index"
|
||||||
|
and isinstance(index.get("applications"), list)):
|
||||||
|
return path
|
||||||
|
return root / "catalog"
|
||||||
|
|
||||||
|
|
||||||
|
def git_blob(data):
|
||||||
|
return hashlib.sha1(b"blob " + str(len(data)).encode() + b"\0" + data).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def request(url, etag=None, limit=MAX_FILE):
|
||||||
|
headers = {"User-Agent": "ProxMenux-OCI-Catalog", "Accept": "application/vnd.github+json"}
|
||||||
|
if etag:
|
||||||
|
headers["If-None-Match"] = etag
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(urllib.request.Request(url, headers=headers), timeout=8) as response:
|
||||||
|
body = response.read(limit + 1)
|
||||||
|
if len(body) > limit:
|
||||||
|
raise ValueError("Catalog response exceeds the size limit")
|
||||||
|
return body, response.headers.get("ETag")
|
||||||
|
except urllib.error.HTTPError as error:
|
||||||
|
error.close()
|
||||||
|
if error.code == 304:
|
||||||
|
return None, etag
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def catalog_path(name):
|
||||||
|
path = PurePosixPath(name)
|
||||||
|
if (str(path) != name or path.is_absolute() or ".." in path.parts
|
||||||
|
or path.suffix != ".json"):
|
||||||
|
raise ValueError("Invalid catalog file path")
|
||||||
|
if name not in ROOT_FILES and not (len(path.parts) == 2 and path.parts[0] in
|
||||||
|
("apps", "curated", "overlays")):
|
||||||
|
raise ValueError("Unsupported catalog file path")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def validate_snapshot(root, directory, *, allow_empty=False):
|
||||||
|
from .catalog import Catalog
|
||||||
|
from jsonschema import Draft202012Validator
|
||||||
|
compatibility = (json.loads((directory / "compatibility.json").read_text())
|
||||||
|
if (directory / "compatibility.json").exists() else {"engine_api": 1})
|
||||||
|
if compatibility.get("engine_api") != CATALOG_API:
|
||||||
|
raise ValueError("This catalog requires a newer OCI engine")
|
||||||
|
validator = Draft202012Validator(json.loads((Path(root) / "schemas/oci-template.schema.json").read_text()))
|
||||||
|
for folder in ("apps", "curated"):
|
||||||
|
for file in (directory / folder).glob("*.json"):
|
||||||
|
validator.validate(json.loads(file.read_text()))
|
||||||
|
index = json.loads((directory / "index.json").read_text())
|
||||||
|
if index.get("kind") != "proxmenux.oci-catalog-index":
|
||||||
|
raise ValueError("Invalid catalog index")
|
||||||
|
ids = set()
|
||||||
|
for item in index.get("applications", []):
|
||||||
|
app_id = item.get("id", "")
|
||||||
|
if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9._-]*", app_id) or app_id in ids:
|
||||||
|
raise ValueError("Invalid or duplicate catalog application")
|
||||||
|
ids.add(app_id)
|
||||||
|
if item.get("template_family") == "curated-profile":
|
||||||
|
curated = item.get("curated_path", "")
|
||||||
|
if not curated.startswith("catalog/"):
|
||||||
|
raise ValueError("Invalid curated recipe path")
|
||||||
|
catalog_path(curated.removeprefix("catalog/"))
|
||||||
|
if not ids and not allow_empty:
|
||||||
|
raise ValueError("The remote catalog is empty")
|
||||||
|
catalog = Catalog(Path(root), catalog_dir=directory)
|
||||||
|
for item in catalog.load_index()["applications"]:
|
||||||
|
if not item.get("hidden") and item.get("automatic_install_candidate"):
|
||||||
|
validator.validate(catalog._compose_item(item))
|
||||||
|
|
||||||
|
|
||||||
|
def synchronize(root, channel, fetch=request, force=False):
|
||||||
|
"""Download changed JSON files into a private snapshot, then publish once."""
|
||||||
|
root = Path(root)
|
||||||
|
cache = cache_root(root)
|
||||||
|
cache.mkdir(parents=True, exist_ok=True)
|
||||||
|
pointer = cache / f"{channel}-current.json"
|
||||||
|
previous = read_json(pointer)
|
||||||
|
active = current_catalog(root, channel)
|
||||||
|
capabilities = hashlib.sha256(json.dumps(sorted(supported_profile_keys(root))).encode()).hexdigest()
|
||||||
|
force = force or previous.get("profile_capabilities") != capabilities
|
||||||
|
etag = previous.get("etag") if active != root / "catalog" and not force else None
|
||||||
|
url = f"https://api.github.com/repos/{REPOSITORY}/commits?sha={channel}&path=oci/catalog&per_page=1"
|
||||||
|
body, etag = fetch(url, etag)
|
||||||
|
if body is None:
|
||||||
|
return False
|
||||||
|
commits = json.loads(body)
|
||||||
|
revision = commits[0]["sha"]
|
||||||
|
if not isinstance(revision, str) or not SHA.fullmatch(revision):
|
||||||
|
raise ValueError("Invalid catalog revision")
|
||||||
|
if previous.get("revision") == revision and active != root / "catalog" and not force:
|
||||||
|
write_json(pointer, {**previous, "etag": etag})
|
||||||
|
return False
|
||||||
|
body, _ = fetch(f"https://api.github.com/repos/{REPOSITORY}/git/trees/{revision}?recursive=1")
|
||||||
|
tree = json.loads(body)
|
||||||
|
if tree.get("truncated"):
|
||||||
|
raise ValueError("GitHub returned an incomplete catalog manifest")
|
||||||
|
entries = {}
|
||||||
|
schema = None
|
||||||
|
for entry in tree["tree"]:
|
||||||
|
name = entry["path"]
|
||||||
|
if name == "oci/schemas/oci-template.schema.json":
|
||||||
|
schema = entry["sha"]
|
||||||
|
if not name.startswith("oci/catalog/") or entry.get("type") != "blob":
|
||||||
|
continue
|
||||||
|
relative = name.removeprefix("oci/catalog/")
|
||||||
|
if relative.startswith("generation-report"):
|
||||||
|
continue
|
||||||
|
catalog_path(relative)
|
||||||
|
if entry.get("mode") != "100644" or not SHA.fullmatch(entry.get("sha", "")):
|
||||||
|
raise ValueError("Invalid catalog manifest entry")
|
||||||
|
if type(entry.get("size")) is not int or not 0 <= entry["size"] <= MAX_FILE:
|
||||||
|
raise ValueError("Invalid catalog file size")
|
||||||
|
if relative in entries:
|
||||||
|
raise ValueError("Duplicate catalog manifest path")
|
||||||
|
entries[relative] = entry
|
||||||
|
if schema != git_blob((root / "schemas/oci-template.schema.json").read_bytes()):
|
||||||
|
raise ValueError("Catalog schema changed; update ProxMenux first")
|
||||||
|
if not entries or len(entries) > 3000 or sum(e["size"] for e in entries.values()) > MAX_TOTAL:
|
||||||
|
raise ValueError("Catalog exceeds the size limit")
|
||||||
|
if not {"index.json", "categories.json", "volume-policy.json", "verification.json"} <= entries.keys():
|
||||||
|
raise ValueError("Catalog manifest is incomplete")
|
||||||
|
snapshots = cache / "snapshots"
|
||||||
|
snapshots.mkdir(exist_ok=True)
|
||||||
|
with tempfile.TemporaryDirectory(prefix=".download-", dir=snapshots) as name:
|
||||||
|
staging = Path(name)
|
||||||
|
deadline = time.monotonic() + 90
|
||||||
|
|
||||||
|
def download(pair):
|
||||||
|
if time.monotonic() > deadline:
|
||||||
|
raise TimeoutError("Catalog download exceeded its time limit")
|
||||||
|
relative, entry = pair
|
||||||
|
old = active / relative
|
||||||
|
data = old.read_bytes() if old.is_file() else b""
|
||||||
|
if git_blob(data) != entry["sha"]:
|
||||||
|
data, _ = fetch(f"https://raw.githubusercontent.com/{REPOSITORY}/{revision}/oci/catalog/{relative}")
|
||||||
|
if data is None or len(data) != entry["size"] or git_blob(data) != entry["sha"]:
|
||||||
|
raise ValueError("Catalog file failed its integrity check")
|
||||||
|
json.loads(data)
|
||||||
|
target = staging / relative
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
target.write_bytes(data)
|
||||||
|
|
||||||
|
pool = ThreadPoolExecutor(max_workers=4)
|
||||||
|
try:
|
||||||
|
list(pool.map(download, entries.items()))
|
||||||
|
finally:
|
||||||
|
pool.shutdown(wait=True, cancel_futures=True)
|
||||||
|
validate_snapshot(root, staging)
|
||||||
|
filter_compatible_recipes(root, staging, active)
|
||||||
|
validate_snapshot(root, staging, allow_empty=True)
|
||||||
|
destination = (active if previous.get("revision") == revision and active != root / "catalog"
|
||||||
|
else snapshots / revision)
|
||||||
|
if destination.exists():
|
||||||
|
staged_files = {str(p.relative_to(staging)) for p in staging.rglob("*.json")}
|
||||||
|
intact = all((destination / relative).is_file()
|
||||||
|
and not (destination / relative).is_symlink()
|
||||||
|
and (destination / relative).read_bytes() == (staging / relative).read_bytes()
|
||||||
|
for relative in staged_files)
|
||||||
|
existing = {str(p.relative_to(destination)) for p in destination.rglob("*.json")}
|
||||||
|
if not intact or existing != staged_files:
|
||||||
|
# Repair without removing a snapshot another wizard may still hold.
|
||||||
|
destination = snapshots / (revision + "-" + uuid.uuid4().hex[:12])
|
||||||
|
with snapshot_lock(root):
|
||||||
|
if not destination.exists():
|
||||||
|
os.replace(staging, destination)
|
||||||
|
write_json(pointer, {"revision": revision, "snapshot": destination.name, "etag": etag,
|
||||||
|
"schema_hash": schema_hash(root), "engine_api": CATALOG_API,
|
||||||
|
"bundle_hash": bundle_hash(root), "profile_capabilities": capabilities})
|
||||||
|
return destination != active
|
||||||
|
|
||||||
|
|
||||||
|
def worker(root, channel, force=False):
|
||||||
|
cache = cache_root(root)
|
||||||
|
cache.mkdir(parents=True, exist_ok=True)
|
||||||
|
with (cache / "refresh.lock").open("a") as lock:
|
||||||
|
fcntl.flock(lock, fcntl.LOCK_EX)
|
||||||
|
try:
|
||||||
|
for partial in (cache / "snapshots").glob(".download-*"):
|
||||||
|
if partial.is_dir() and not partial.is_symlink():
|
||||||
|
shutil.rmtree(partial)
|
||||||
|
changed = synchronize(root, channel, force=force)
|
||||||
|
write_json(cache / f"{channel}-result.json", {"ok": True, "changed": changed})
|
||||||
|
except Exception as error:
|
||||||
|
write_json(cache / f"{channel}-result.json", {"ok": False, "error": str(error)[:400]})
|
||||||
|
finally:
|
||||||
|
prune_snapshots(root)
|
||||||
|
|
||||||
|
|
||||||
|
def visible_recipes(catalog):
|
||||||
|
"""Same availability rule as the menu; count new IDs, not net growth."""
|
||||||
|
result = {}
|
||||||
|
policies = {}
|
||||||
|
for item in catalog.load_index()["applications"]:
|
||||||
|
if item.get("hidden") or not item.get("automatic_install_candidate"):
|
||||||
|
continue
|
||||||
|
if item.get("_retained_recipe") and item.get("_retained_recipe_hash"):
|
||||||
|
result[item["id"]] = item["_retained_recipe_hash"]
|
||||||
|
continue
|
||||||
|
directory = catalog.root / "catalog" if item["id"] in catalog.local_app_ids else catalog.catalog_dir
|
||||||
|
if item.get("template_family") == "curated-profile":
|
||||||
|
path = directory / item["curated_path"].removeprefix("catalog/")
|
||||||
|
else:
|
||||||
|
path = directory / "apps" / f"{item['id']}.json"
|
||||||
|
overlay = directory / "overlays" / f"{item['id']}.json"
|
||||||
|
recipe = json.loads(path.read_text()) if path.exists() else {}
|
||||||
|
# Formatting and regenerated source timestamps are not recipe changes.
|
||||||
|
recipe.pop("source", None)
|
||||||
|
recipe.pop("validation", None)
|
||||||
|
correction = json.loads(overlay.read_text()) if overlay.exists() else {}
|
||||||
|
if directory not in policies:
|
||||||
|
policies[directory] = read_json(directory / "volume-policy.json")
|
||||||
|
policy = policies[directory]
|
||||||
|
paths = {volume.get("container_path") for volume in recipe.get("container_contract", {}).get("volumes", [])}
|
||||||
|
relevant = {"shared_paths": sorted(paths & set(policy.get("shared_paths", []))),
|
||||||
|
"application": policy.get("applications", {}).get(item["id"], []),
|
||||||
|
"sizes": policy.get("sizes", {}).get(item["id"], {})}
|
||||||
|
data = json.dumps([recipe, correction, relevant, item.get("category"), item.get("category_label")],
|
||||||
|
sort_keys=True, separators=(",", ":")).encode()
|
||||||
|
result[item["id"]] = hashlib.sha256(data).hexdigest()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class CatalogRefresh:
|
||||||
|
def __init__(self, root, catalog):
|
||||||
|
self.root = Path(root)
|
||||||
|
self.channel = channel_for(root)
|
||||||
|
self.seen_path = cache_root(root) / f"{self.channel}-seen.json"
|
||||||
|
self.recipes = visible_recipes(catalog)
|
||||||
|
self.displayed_directory = catalog.catalog_dir
|
||||||
|
baseline = read_json(self.seen_path).get("recipes")
|
||||||
|
self.baseline = baseline if isinstance(baseline, dict) else self.recipes.copy()
|
||||||
|
self.process = None
|
||||||
|
self.failed = False
|
||||||
|
self._finished = False
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
self.start()
|
||||||
|
return self
|
||||||
|
|
||||||
|
def start(self, force=False):
|
||||||
|
if self.active:
|
||||||
|
return False
|
||||||
|
self._finished = False
|
||||||
|
self.failed = False
|
||||||
|
environment = dict(os.environ, PYTHONPATH=str(self.root / "src"))
|
||||||
|
try:
|
||||||
|
command = [sys.executable, "-m", __name__, str(self.root), self.channel]
|
||||||
|
if force:
|
||||||
|
command.append("--force")
|
||||||
|
self.process = subprocess.Popen(command,
|
||||||
|
env=environment, stdin=subprocess.DEVNULL,
|
||||||
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||||
|
start_new_session=True)
|
||||||
|
except OSError:
|
||||||
|
self.failed = True
|
||||||
|
self._finished = True
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def __exit__(self, *args):
|
||||||
|
# The worker can finish downloading after the user leaves. Its result
|
||||||
|
# is not marked seen until a later menu actually displays that catalog.
|
||||||
|
if self.process and self.process.poll() is not None:
|
||||||
|
self.process.wait()
|
||||||
|
elif self.process:
|
||||||
|
threading.Thread(target=self.process.wait, daemon=True).start()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def active(self):
|
||||||
|
return self.process is not None and not self._finished
|
||||||
|
|
||||||
|
def changed(self):
|
||||||
|
return self.active and self.process.poll() is not None
|
||||||
|
|
||||||
|
def consume(self):
|
||||||
|
if not self.changed():
|
||||||
|
return False
|
||||||
|
self._finished = True
|
||||||
|
result = read_json(cache_root(self.root) / f"{self.channel}-result.json")
|
||||||
|
self.failed = self.process.returncode != 0 or not result.get("ok")
|
||||||
|
directory = current_catalog(self.root, self.channel)
|
||||||
|
changed = directory != self.displayed_directory
|
||||||
|
if changed:
|
||||||
|
self.displayed_directory = directory
|
||||||
|
return changed
|
||||||
|
|
||||||
|
def describe(self):
|
||||||
|
text = translate("Available applications: {count}").format(count=len(self.recipes))
|
||||||
|
added = len(self.recipes.keys() - self.baseline.keys())
|
||||||
|
updated = sum(self.recipes[key] != self.baseline[key] for key in self.recipes.keys() & self.baseline.keys())
|
||||||
|
text += "\n" + translate("New applications since your last visit: {count}").format(count=added)
|
||||||
|
if updated:
|
||||||
|
text += "\n" + translate("Updated recipes since your last visit: {count}").format(count=updated)
|
||||||
|
if self.active:
|
||||||
|
text += "\n" + translate("Refreshing the application catalog...")
|
||||||
|
elif self.failed:
|
||||||
|
text += "\n" + translate("Catalog refresh unavailable; using the local catalog.")
|
||||||
|
return text
|
||||||
|
|
||||||
|
def mark_seen(self):
|
||||||
|
try:
|
||||||
|
write_json(self.seen_path, {"recipes": self.recipes})
|
||||||
|
except OSError:
|
||||||
|
self.failed = True
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("root", type=Path)
|
||||||
|
parser.add_argument("channel", choices=("main", "develop"))
|
||||||
|
parser.add_argument("--force", action="store_true")
|
||||||
|
args = parser.parse_args()
|
||||||
|
worker(args.root, args.channel, args.force)
|
||||||
@@ -557,13 +557,7 @@ def _search(catalog: Catalog, ui, applications: list[dict[str, Any]]) -> None:
|
|||||||
keep_order=True)
|
keep_order=True)
|
||||||
|
|
||||||
|
|
||||||
def interactive(catalog: Catalog) -> int:
|
def _catalog_options(catalog):
|
||||||
from .ui import MenuRefresh
|
|
||||||
from .updates import UpdateCheck, select_updates
|
|
||||||
ui = interactive_ui()
|
|
||||||
if not catalog.index_path.exists():
|
|
||||||
ui.message(translate("The OCI catalog is not installed. Update ProxMenux and try again."))
|
|
||||||
return 1
|
|
||||||
applications = _installable(catalog.load_index()["applications"])
|
applications = _installable(catalog.load_index()["applications"])
|
||||||
categories: dict[str, list[dict[str, Any]]] = {}
|
categories: dict[str, list[dict[str, Any]]] = {}
|
||||||
labels: dict[str, str] = {}
|
labels: dict[str, str] = {}
|
||||||
@@ -577,17 +571,34 @@ def interactive(catalog: Catalog) -> int:
|
|||||||
("search", translate("Search applications")),
|
("search", translate("Search applications")),
|
||||||
("all", f"{translate('All applications'):<35} ({len(applications):>3})"),
|
("all", f"{translate('All applications'):<35} ({len(applications):>3})"),
|
||||||
("manage", translate("Manage installed OCI applications")),
|
("manage", translate("Manage installed OCI applications")),
|
||||||
|
("catalog-refresh", translate("Refresh catalog")),
|
||||||
("registries", translate("Registry accounts")),
|
("registries", translate("Registry accounts")),
|
||||||
("custom", translate("Install an image that is not in the catalog")),
|
("custom", translate("Install an image that is not in the catalog")),
|
||||||
("", ""),
|
("", ""),
|
||||||
] + [(number, f"{translate(labels[key]):<35} ({len(categories[key]):>3})")
|
] + [(number, f"{translate(labels[key]):<35} ({len(categories[key]):>3})")
|
||||||
for number, key in category_by_index.items()]
|
for number, key in category_by_index.items()]
|
||||||
|
return applications, categories, labels, category_by_index, options
|
||||||
|
|
||||||
|
|
||||||
|
def interactive(catalog: Catalog) -> int:
|
||||||
|
from .ui import MenuRefresh
|
||||||
|
from .catalog_refresh import CatalogRefresh, visible_recipes
|
||||||
|
from .updates import UpdateCheck, select_updates
|
||||||
|
ui = interactive_ui()
|
||||||
|
if not catalog.index_path.exists():
|
||||||
|
ui.message(translate("The OCI catalog is not installed. Update ProxMenux and try again."))
|
||||||
|
return 1
|
||||||
|
applications, categories, labels, category_by_index, options = _catalog_options(catalog)
|
||||||
selection = "search"
|
selection = "search"
|
||||||
with UpdateCheck(PROJECT_ROOT) as updates:
|
with CatalogRefresh(PROJECT_ROOT, catalog) as catalog_refresh, UpdateCheck(PROJECT_ROOT) as updates:
|
||||||
while True:
|
while True:
|
||||||
|
if catalog_refresh.consume():
|
||||||
|
catalog = Catalog(PROJECT_ROOT)
|
||||||
|
applications, categories, labels, category_by_index, options = _catalog_options(catalog)
|
||||||
|
catalog_refresh.recipes = visible_recipes(catalog)
|
||||||
state = updates.status()
|
state = updates.status()
|
||||||
entries = ReadableTags(options[:3] + [('updates', updates.label(state))] + options[3:])
|
entries = ReadableTags(options[:3] + [('updates', updates.label(state))] + options[3:])
|
||||||
text = translate("Select a category or search for applications:")
|
text = catalog_refresh.describe() + "\n\n" + translate("Select a category or search for applications:")
|
||||||
if notice := updates.notice(state):
|
if notice := updates.notice(state):
|
||||||
text += '\n\n' + notice
|
text += '\n\n' + notice
|
||||||
if updates.active and not getattr(ui, 'live_refresh', False):
|
if updates.active and not getattr(ui, 'live_refresh', False):
|
||||||
@@ -595,10 +606,13 @@ def interactive(catalog: Catalog) -> int:
|
|||||||
updates.wait()
|
updates.wait()
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
kwargs = {'refresh': updates.changed} if updates.active else {}
|
kwargs = ({'refresh': lambda: updates.changed() or catalog_refresh.changed()}
|
||||||
|
if updates.active or catalog_refresh.active else {})
|
||||||
selected = ui.choose(text, entries, selection, size=MENU_SIZE, **kwargs)
|
selected = ui.choose(text, entries, selection, size=MENU_SIZE, **kwargs)
|
||||||
except MenuRefresh:
|
except MenuRefresh:
|
||||||
continue
|
continue
|
||||||
|
finally:
|
||||||
|
catalog_refresh.mark_seen()
|
||||||
if selected is None:
|
if selected is None:
|
||||||
return 0
|
return 0
|
||||||
selection = selected
|
selection = selected
|
||||||
@@ -611,6 +625,8 @@ def interactive(catalog: Catalog) -> int:
|
|||||||
elif selection == "manage":
|
elif selection == "manage":
|
||||||
from .management import interactive_management
|
from .management import interactive_management
|
||||||
interactive_management(PROJECT_ROOT, ui)
|
interactive_management(PROJECT_ROOT, ui)
|
||||||
|
elif selection == "catalog-refresh":
|
||||||
|
catalog_refresh.start(force=True)
|
||||||
elif selection == "updates":
|
elif selection == "updates":
|
||||||
if not updates.active:
|
if not updates.active:
|
||||||
if state['available']:
|
if state['available']:
|
||||||
|
|||||||
@@ -0,0 +1,743 @@
|
|||||||
|
"""Catalog delivery is atomic, offline-safe, and never acknowledges unseen apps."""
|
||||||
|
import json
|
||||||
|
import gc
|
||||||
|
import errno
|
||||||
|
import http.server
|
||||||
|
import itertools
|
||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(ROOT / "src"))
|
||||||
|
from proxmenux_oci.catalog import Catalog
|
||||||
|
from proxmenux_oci import catalog_refresh as refresh
|
||||||
|
|
||||||
|
|
||||||
|
class CatalogRefreshTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.temp.cleanup)
|
||||||
|
self.root = Path(self.temp.name) / "oci" / "engine"
|
||||||
|
self.root.mkdir(parents=True)
|
||||||
|
shutil.copytree(ROOT / "schemas", self.root / "schemas")
|
||||||
|
index = json.loads((ROOT / "catalog/index.json").read_text())
|
||||||
|
self.item = next(x for x in index["applications"]
|
||||||
|
if x.get("template_family") == "curated-profile"
|
||||||
|
and x.get("automatic_install_candidate") and not x.get("hidden"))
|
||||||
|
self.files = {}
|
||||||
|
for name in ("categories.json", "volume-policy.json", "exclusions.json", "verification.json"):
|
||||||
|
self.files[name] = (ROOT / "catalog" / name).read_bytes()
|
||||||
|
for name in (f'apps/{self.item["id"]}.json', self.item['curated_path'].removeprefix("catalog/")):
|
||||||
|
self.files[name] = (ROOT / "catalog" / name).read_bytes()
|
||||||
|
self.files["index.json"] = json.dumps({"kind": "proxmenux.oci-catalog-index",
|
||||||
|
"applications": [self.item]}).encode()
|
||||||
|
for name, data in self.files.items():
|
||||||
|
file = self.root / "catalog" / name
|
||||||
|
file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
file.write_bytes(data)
|
||||||
|
self.revision = "a" * 40
|
||||||
|
self.requests = []
|
||||||
|
|
||||||
|
def fetch(self, url, etag=None):
|
||||||
|
self.requests.append((url, etag))
|
||||||
|
if "/commits?" in url:
|
||||||
|
return json.dumps([{"sha": self.revision}]).encode(), '"catalog-etag"'
|
||||||
|
if "/git/trees/" in url:
|
||||||
|
entries = [{"path": "oci/catalog/" + name, "type": "blob", "mode": "100644",
|
||||||
|
"sha": refresh.git_blob(data), "size": len(data)}
|
||||||
|
for name, data in self.files.items()]
|
||||||
|
schema = (self.root / "schemas/oci-template.schema.json").read_bytes()
|
||||||
|
entries.append({"path": "oci/schemas/oci-template.schema.json", "type": "blob",
|
||||||
|
"sha": refresh.git_blob(schema)})
|
||||||
|
return json.dumps({"tree": entries, "truncated": False}).encode(), None
|
||||||
|
name = url.split("/oci/catalog/", 1)[1]
|
||||||
|
return self.files[name], None
|
||||||
|
|
||||||
|
def sync(self):
|
||||||
|
return refresh.synchronize(self.root, "main", self.fetch)
|
||||||
|
|
||||||
|
def add_app(self):
|
||||||
|
item = dict(self.item, id="sample-added", template_id="image-sample-added",
|
||||||
|
curated_path="catalog/curated/sample-added.json", template="apps/sample-added.json")
|
||||||
|
recipe = json.loads(self.files[self.item["curated_path"].removeprefix("catalog/")])
|
||||||
|
recipe["id"] = item["template_id"]
|
||||||
|
for name in ("apps/sample-added.json", "curated/sample-added.json"):
|
||||||
|
self.files[name] = json.dumps(recipe).encode()
|
||||||
|
index = json.loads(self.files["index.json"])
|
||||||
|
index["applications"].append(item)
|
||||||
|
self.files["index.json"] = json.dumps(index).encode()
|
||||||
|
|
||||||
|
def future_profile(self, app_id):
|
||||||
|
index = json.loads(self.files["index.json"])
|
||||||
|
item = next(x for x in index["applications"] if x["id"] == app_id)
|
||||||
|
path = item["curated_path"].removeprefix("catalog/")
|
||||||
|
recipe = json.loads(self.files[path])
|
||||||
|
recipe.setdefault("proxmox", {}).setdefault("installer_profile", {})["future_engine_key"] = True
|
||||||
|
self.files[path] = json.dumps(recipe).encode()
|
||||||
|
|
||||||
|
def test_new_unknown_profile_key_is_silent_and_not_counted(self):
|
||||||
|
self.sync()
|
||||||
|
baseline = Catalog(self.root)
|
||||||
|
status = refresh.CatalogRefresh(self.root, baseline)
|
||||||
|
self.revision = "b" * 40
|
||||||
|
self.add_app()
|
||||||
|
self.future_profile("sample-added")
|
||||||
|
self.assertTrue(self.sync())
|
||||||
|
catalog = Catalog(self.root)
|
||||||
|
self.assertNotIn("sample-added", refresh.visible_recipes(catalog))
|
||||||
|
self.assertEqual(set(refresh.visible_recipes(catalog)), set(status.recipes))
|
||||||
|
status.recipes = refresh.visible_recipes(catalog)
|
||||||
|
self.assertIn("New applications since your last visit: 0", status.describe())
|
||||||
|
|
||||||
|
def test_compatibility_gate_uses_effective_menu_visibility(self):
|
||||||
|
self.add_app()
|
||||||
|
self.future_profile("sample-added")
|
||||||
|
index = json.loads(self.files["index.json"])
|
||||||
|
added = next(x for x in index["applications"] if x["id"] == "sample-added")
|
||||||
|
added.update(hidden=True, automatic_install_candidate=False)
|
||||||
|
self.files["index.json"] = json.dumps(index).encode()
|
||||||
|
# The app template makes this stale index entry visible/installable;
|
||||||
|
# use the same rule as Catalog.load_index(), not the raw index flags.
|
||||||
|
self.sync()
|
||||||
|
self.assertNotIn("sample-added", refresh.visible_recipes(Catalog(self.root)))
|
||||||
|
|
||||||
|
def test_incompatible_existing_app_retains_complete_previous_contract(self):
|
||||||
|
self.sync()
|
||||||
|
old = Catalog(self.root).compose(self.item["id"])
|
||||||
|
old_visible = refresh.visible_recipes(Catalog(self.root))
|
||||||
|
self.revision = "b" * 40
|
||||||
|
self.future_profile(self.item["id"])
|
||||||
|
# A new policy must not mutate the retained contract.
|
||||||
|
policy = json.loads(self.files["volume-policy.json"])
|
||||||
|
policy["shared_paths"] = [v["container_path"] for v in old["container_contract"].get("volumes", [])]
|
||||||
|
self.files["volume-policy.json"] = json.dumps(policy).encode()
|
||||||
|
self.sync()
|
||||||
|
self.assertEqual(Catalog(self.root).compose(self.item["id"]), old)
|
||||||
|
self.assertEqual(refresh.visible_recipes(Catalog(self.root)), old_visible)
|
||||||
|
# A later incompatible revision still retains the same original contract.
|
||||||
|
self.revision = "c" * 40
|
||||||
|
self.sync()
|
||||||
|
self.assertEqual(Catalog(self.root).compose(self.item["id"]), old)
|
||||||
|
|
||||||
|
def test_engine_capability_update_rechecks_same_revision_without_etag(self):
|
||||||
|
self.add_app()
|
||||||
|
self.future_profile("sample-added")
|
||||||
|
self.sync()
|
||||||
|
self.assertNotIn("sample-added", refresh.visible_recipes(Catalog(self.root)))
|
||||||
|
bundled = self.root / "catalog" / self.item["curated_path"].removeprefix("catalog/")
|
||||||
|
value = json.loads(bundled.read_text())
|
||||||
|
value.setdefault("proxmox", {}).setdefault("installer_profile", {})["future_engine_key"] = True
|
||||||
|
bundled.write_text(json.dumps(value))
|
||||||
|
self.requests.clear()
|
||||||
|
self.assertTrue(self.sync())
|
||||||
|
self.assertIsNone(self.requests[0][1])
|
||||||
|
self.assertIn("sample-added", refresh.visible_recipes(Catalog(self.root)))
|
||||||
|
|
||||||
|
def test_known_profile_key_accepts_new_values(self):
|
||||||
|
self.add_app()
|
||||||
|
path = "curated/sample-added.json"
|
||||||
|
value = json.loads(self.files[path])
|
||||||
|
self.assertIn("completion_notes", refresh.supported_profile_keys(self.root))
|
||||||
|
value["proxmox"]["installer_profile"]["completion_notes"].append("A new supported setting")
|
||||||
|
self.files[path] = json.dumps(value).encode()
|
||||||
|
self.sync()
|
||||||
|
self.assertIn("sample-added", refresh.visible_recipes(Catalog(self.root)))
|
||||||
|
|
||||||
|
def test_all_incompatible_apps_can_produce_empty_visible_catalog(self):
|
||||||
|
self.future_profile(self.item["id"])
|
||||||
|
# No compatible old recipe exists on this host.
|
||||||
|
path = self.root / "catalog" / self.item["curated_path"].removeprefix("catalog/")
|
||||||
|
value = json.loads(path.read_text())
|
||||||
|
value["proxmox"]["installer_profile"].pop("future_engine_key", None)
|
||||||
|
# Use a new ID instead of retaining the bundled application.
|
||||||
|
self.add_app()
|
||||||
|
self.future_profile("sample-added")
|
||||||
|
index = json.loads(self.files["index.json"])
|
||||||
|
index["applications"] = [x for x in index["applications"] if x["id"] == "sample-added"]
|
||||||
|
self.files["index.json"] = json.dumps(index).encode()
|
||||||
|
self.sync()
|
||||||
|
self.assertNotEqual(refresh.current_catalog(self.root), self.root / "catalog")
|
||||||
|
self.assertEqual(refresh.visible_recipes(Catalog(self.root)), {})
|
||||||
|
|
||||||
|
def test_pruning_preserves_active_and_leased_snapshots_then_reclaims_reader(self):
|
||||||
|
self.sync()
|
||||||
|
old = Catalog(self.root)
|
||||||
|
old_directory = old.catalog_dir
|
||||||
|
self.revision = "b" * 40
|
||||||
|
self.add_app()
|
||||||
|
self.sync()
|
||||||
|
refresh.prune_snapshots(self.root)
|
||||||
|
self.assertTrue(old_directory.exists())
|
||||||
|
self.assertEqual(old.compose(self.item["id"])["id"], self.item["template_id"])
|
||||||
|
del old
|
||||||
|
gc.collect()
|
||||||
|
refresh.prune_snapshots(self.root)
|
||||||
|
self.assertFalse(old_directory.exists())
|
||||||
|
self.assertTrue(refresh.current_catalog(self.root).exists())
|
||||||
|
|
||||||
|
def test_pruning_preserves_both_channels_and_unlinks_orphan_lease(self):
|
||||||
|
self.sync()
|
||||||
|
main = refresh.current_catalog(self.root, "main")
|
||||||
|
self.revision = "b" * 40
|
||||||
|
refresh.synchronize(self.root, "develop", self.fetch)
|
||||||
|
orphan = refresh.cache_root(self.root) / "snapshots" / ("c" * 40)
|
||||||
|
orphan.mkdir()
|
||||||
|
refresh.prune_snapshots(self.root)
|
||||||
|
self.assertTrue(main.exists())
|
||||||
|
self.assertTrue(refresh.current_catalog(self.root, "develop").exists())
|
||||||
|
self.assertFalse(orphan.exists())
|
||||||
|
self.assertFalse((refresh.cache_root(self.root) / "leases" / orphan.name).exists())
|
||||||
|
|
||||||
|
def test_worker_prunes_even_when_no_change_or_network_failure(self):
|
||||||
|
self.sync()
|
||||||
|
for error in (None, OSError("offline")):
|
||||||
|
orphan = refresh.cache_root(self.root) / "snapshots" / ("c" * 40)
|
||||||
|
orphan.mkdir()
|
||||||
|
with patch.object(refresh, "synchronize", side_effect=error, return_value=False):
|
||||||
|
refresh.worker(self.root, "main")
|
||||||
|
self.assertFalse(orphan.exists())
|
||||||
|
|
||||||
|
def test_reader_process_exit_releases_lease_without_pid_cleanup(self):
|
||||||
|
import subprocess
|
||||||
|
self.sync()
|
||||||
|
directory = refresh.current_catalog(self.root)
|
||||||
|
code = ("import fcntl,sys,time; "
|
||||||
|
"f=open(sys.argv[1], 'a'); fcntl.flock(f,fcntl.LOCK_SH); "
|
||||||
|
"print('ready',flush=True); time.sleep(30)")
|
||||||
|
leases = refresh.cache_root(self.root) / "leases"
|
||||||
|
leases.mkdir(exist_ok=True)
|
||||||
|
child = subprocess.Popen([sys.executable, "-c", code, str(leases / directory.name)],
|
||||||
|
stdout=subprocess.PIPE, text=True)
|
||||||
|
self.addCleanup(child.stdout.close)
|
||||||
|
self.addCleanup(lambda: child.poll() is None and child.kill())
|
||||||
|
self.assertEqual(child.stdout.readline().strip(), "ready")
|
||||||
|
self.revision = "b" * 40
|
||||||
|
self.sync()
|
||||||
|
refresh.prune_snapshots(self.root)
|
||||||
|
self.assertTrue(directory.exists())
|
||||||
|
child.kill()
|
||||||
|
child.wait(timeout=3)
|
||||||
|
refresh.prune_snapshots(self.root)
|
||||||
|
self.assertFalse(directory.exists())
|
||||||
|
|
||||||
|
def test_menu_pin_does_not_wait_for_network_worker_lock(self):
|
||||||
|
self.sync()
|
||||||
|
lock = (refresh.cache_root(self.root) / "refresh.lock").open("a")
|
||||||
|
self.addCleanup(lock.close)
|
||||||
|
refresh.fcntl.flock(lock, refresh.fcntl.LOCK_EX)
|
||||||
|
catalog = Catalog(self.root)
|
||||||
|
self.assertEqual(catalog.compose(self.item["id"])["id"], self.item["template_id"])
|
||||||
|
|
||||||
|
def test_complete_catalog_is_selected_atomically_without_redownloading_unchanged_files(self):
|
||||||
|
self.assertTrue(self.sync())
|
||||||
|
directory = refresh.current_catalog(self.root, "main")
|
||||||
|
self.assertEqual(directory.name, self.revision)
|
||||||
|
self.assertEqual(Catalog(self.root).compose(self.item["id"])["id"], self.item["template_id"])
|
||||||
|
self.assertFalse(any("raw.githubusercontent" in url for url, _ in self.requests))
|
||||||
|
self.assertTrue((self.root / "catalog/index.json").exists())
|
||||||
|
|
||||||
|
def test_changed_file_uses_immutable_commit_and_updates_recipe(self):
|
||||||
|
name = self.item["curated_path"].removeprefix("catalog/")
|
||||||
|
recipe = json.loads(self.files[name]); recipe["catalog_ui"]["tips"].append("New setup tip")
|
||||||
|
self.files[name] = json.dumps(recipe).encode()
|
||||||
|
self.sync()
|
||||||
|
self.assertTrue(any(f"/{self.revision}/oci/catalog/{name}" in u for u, _ in self.requests))
|
||||||
|
self.assertIn("New setup tip", Catalog(self.root).compose(self.item["id"])["catalog_ui"]["tips"])
|
||||||
|
|
||||||
|
def test_unchanged_revision_does_not_fetch_tree_or_recipes(self):
|
||||||
|
self.sync(); self.requests.clear()
|
||||||
|
self.assertFalse(self.sync())
|
||||||
|
self.assertEqual(len(self.requests), 1)
|
||||||
|
self.assertEqual(self.requests[0][1], '"catalog-etag"')
|
||||||
|
|
||||||
|
def test_not_modified_uses_cached_snapshot(self):
|
||||||
|
self.sync()
|
||||||
|
fetch = Mock(return_value=(None, '"catalog-etag"'))
|
||||||
|
self.assertFalse(refresh.synchronize(self.root, "main", fetch))
|
||||||
|
self.assertEqual(fetch.call_count, 1)
|
||||||
|
|
||||||
|
def test_formatting_and_unrelated_categories_do_not_count_as_recipe_updates(self):
|
||||||
|
before = refresh.visible_recipes(Catalog(self.root))
|
||||||
|
for name in self.files:
|
||||||
|
path = self.root / 'catalog' / name
|
||||||
|
value = json.loads(path.read_text())
|
||||||
|
if name == 'categories.json':
|
||||||
|
value['applications']['unrelated-app'] = 'misc'
|
||||||
|
path.write_text(json.dumps(value, indent=4) + '\n')
|
||||||
|
self.assertEqual(refresh.visible_recipes(Catalog(self.root)), before)
|
||||||
|
|
||||||
|
def test_failure_keeps_previous_catalog_and_does_not_commit_remote_etag(self):
|
||||||
|
self.sync()
|
||||||
|
old = refresh.current_catalog(self.root)
|
||||||
|
self.revision = "b" * 40
|
||||||
|
self.files[self.item["curated_path"].removeprefix("catalog/")] = b'{}'
|
||||||
|
with self.assertRaises(Exception):
|
||||||
|
self.sync()
|
||||||
|
self.assertEqual(refresh.current_catalog(self.root), old)
|
||||||
|
self.assertFalse((refresh.cache_root(self.root) / "snapshots" / self.revision).exists())
|
||||||
|
|
||||||
|
def test_missing_recipe_cannot_publish_index_alone(self):
|
||||||
|
self.files.pop(self.item["curated_path"].removeprefix("catalog/"))
|
||||||
|
with self.assertRaises(Exception):
|
||||||
|
self.sync()
|
||||||
|
self.assertEqual(refresh.current_catalog(self.root), self.root / "catalog")
|
||||||
|
|
||||||
|
def test_hash_mismatch_is_rejected(self):
|
||||||
|
real = self.fetch
|
||||||
|
def corrupt(url, etag=None):
|
||||||
|
body, tag = real(url, etag)
|
||||||
|
if "/git/trees/" in url:
|
||||||
|
tree = json.loads(body)
|
||||||
|
tree["tree"][0]["sha"] = "f" * 40
|
||||||
|
body = json.dumps(tree).encode()
|
||||||
|
return body, tag
|
||||||
|
with self.assertRaisesRegex(ValueError, "integrity"):
|
||||||
|
refresh.synchronize(self.root, "main", corrupt)
|
||||||
|
self.assertEqual(refresh.current_catalog(self.root), self.root / "catalog")
|
||||||
|
|
||||||
|
def test_schema_mismatch_requires_engine_update(self):
|
||||||
|
real = self.fetch
|
||||||
|
def incompatible(url, etag=None):
|
||||||
|
body, tag = real(url, etag)
|
||||||
|
if "/git/trees/" in url:
|
||||||
|
tree = json.loads(body); tree["tree"][-1]["sha"] = "f" * 40
|
||||||
|
body = json.dumps(tree).encode()
|
||||||
|
return body, tag
|
||||||
|
with self.assertRaisesRegex(ValueError, "schema changed"):
|
||||||
|
refresh.synchronize(self.root, "main", incompatible)
|
||||||
|
|
||||||
|
def test_new_engine_api_requires_engine_update(self):
|
||||||
|
self.files["compatibility.json"] = b'{"engine_api": 999}'
|
||||||
|
with self.assertRaisesRegex(ValueError, "newer OCI engine"):
|
||||||
|
self.sync()
|
||||||
|
|
||||||
|
def test_paths_and_symlink_entries_are_rejected(self):
|
||||||
|
for name in ("../index.json", "/index.json", "apps/../../secret.json", "remote/install.sh"):
|
||||||
|
with self.subTest(name=name), self.assertRaises(ValueError):
|
||||||
|
refresh.catalog_path(name)
|
||||||
|
|
||||||
|
def test_offline_worker_keeps_local_catalog(self):
|
||||||
|
with patch.object(refresh, "synchronize", side_effect=OSError("Network unavailable")):
|
||||||
|
refresh.worker(self.root, "main")
|
||||||
|
self.assertFalse(refresh.read_json(refresh.cache_root(self.root) / "main-result.json")["ok"])
|
||||||
|
self.assertEqual(refresh.current_catalog(self.root), self.root / "catalog")
|
||||||
|
|
||||||
|
def test_first_visit_has_zero_new_apps_and_second_visit_clears_acknowledged_news(self):
|
||||||
|
status = refresh.CatalogRefresh(self.root, Catalog(self.root))
|
||||||
|
self.assertIn("New applications since your last visit: 0", status.describe())
|
||||||
|
status.recipes["new-app"] = "new-recipe-hash"
|
||||||
|
self.assertIn("New applications since your last visit: 1", status.describe())
|
||||||
|
status.mark_seen()
|
||||||
|
same = refresh.CatalogRefresh(self.root, Catalog(self.root))
|
||||||
|
same.recipes = status.recipes.copy()
|
||||||
|
self.assertIn("New applications since your last visit: 0", same.describe())
|
||||||
|
|
||||||
|
def test_new_app_count_is_not_net_growth_and_modified_recipes_are_separate(self):
|
||||||
|
status = refresh.CatalogRefresh(self.root, Catalog(self.root))
|
||||||
|
original = self.item["id"]
|
||||||
|
status.baseline = {original: "old-recipe", "removed-app": "hash"}
|
||||||
|
status.recipes["new-app"] = "hash"
|
||||||
|
self.assertIn("Available applications: 2", status.describe())
|
||||||
|
self.assertIn("New applications since your last visit: 1", status.describe())
|
||||||
|
self.assertIn("Updated recipes since your last visit: 1", status.describe())
|
||||||
|
|
||||||
|
def test_exit_before_download_finishes_does_not_acknowledge_remote_apps(self):
|
||||||
|
status = refresh.CatalogRefresh(self.root, Catalog(self.root))
|
||||||
|
status.mark_seen()
|
||||||
|
status.process = Mock(); status.process.poll.return_value = None
|
||||||
|
with patch.object(refresh.threading, "Thread"):
|
||||||
|
status.__exit__(None, None, None)
|
||||||
|
status.process.terminate.assert_not_called()
|
||||||
|
self.add_app()
|
||||||
|
self.sync()
|
||||||
|
next_visit = refresh.CatalogRefresh(self.root, Catalog(self.root))
|
||||||
|
self.assertIn("New applications since your last visit: 1", next_visit.describe())
|
||||||
|
|
||||||
|
def test_main_menu_redraws_totals_and_news_after_background_refresh(self):
|
||||||
|
from proxmenux_oci import cli, updates
|
||||||
|
from proxmenux_oci.ui import MenuRefresh
|
||||||
|
status = refresh.CatalogRefresh(self.root, Catalog(self.root))
|
||||||
|
status.process = Mock(returncode=0)
|
||||||
|
status.process.poll.return_value = None
|
||||||
|
screens = []
|
||||||
|
owner = self
|
||||||
|
class UI:
|
||||||
|
live_refresh = True
|
||||||
|
def choose(self, text, entries, default, **kwargs):
|
||||||
|
screens.append((text, dict(entries)))
|
||||||
|
if len(screens) == 1:
|
||||||
|
owner.add_app()
|
||||||
|
owner.sync()
|
||||||
|
refresh.write_json(refresh.cache_root(owner.root) / 'main-result.json',
|
||||||
|
{"ok": True, "changed": True})
|
||||||
|
status.process.poll.return_value = 0
|
||||||
|
self_refreshed = kwargs['refresh']()
|
||||||
|
owner.assertTrue(self_refreshed)
|
||||||
|
raise MenuRefresh()
|
||||||
|
return None
|
||||||
|
checker = Mock(active=False)
|
||||||
|
checker.status.return_value = {"available": []}
|
||||||
|
checker.notice.return_value = ""
|
||||||
|
checker.label.return_value = "Check for updates"
|
||||||
|
checker.changed.return_value = False
|
||||||
|
checker_context = Mock()
|
||||||
|
checker_context.__enter__ = Mock(return_value=checker)
|
||||||
|
checker_context.__exit__ = Mock(return_value=False)
|
||||||
|
original_class = refresh.CatalogRefresh
|
||||||
|
with patch.object(original_class, '__enter__', return_value=status), \
|
||||||
|
patch.object(refresh, 'CatalogRefresh', return_value=status), \
|
||||||
|
patch.object(cli, 'PROJECT_ROOT', self.root), \
|
||||||
|
patch.object(cli, 'interactive_ui', return_value=UI()), \
|
||||||
|
patch.object(updates, 'UpdateCheck', return_value=checker_context):
|
||||||
|
self.assertEqual(cli.interactive(Catalog(self.root)), 0)
|
||||||
|
self.assertIn('Available applications: 1', screens[0][0])
|
||||||
|
self.assertIn('Available applications: 2', screens[1][0])
|
||||||
|
self.assertIn('New applications since your last visit: 1', screens[1][0])
|
||||||
|
self.assertIn('( 2)', screens[1][1]['all'])
|
||||||
|
next_visit = original_class(self.root, Catalog(self.root))
|
||||||
|
self.assertIn('New applications since your last visit: 0', next_visit.describe())
|
||||||
|
|
||||||
|
def test_explicit_local_apps_survive_refresh_without_preventing_upstream_removal(self):
|
||||||
|
# This app is a local preview, not a member of the upstream manifest.
|
||||||
|
self.add_app()
|
||||||
|
for name, data in self.files.items():
|
||||||
|
file = self.root / 'catalog' / name
|
||||||
|
file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
file.write_bytes(data)
|
||||||
|
index = json.loads(self.files['index.json'])
|
||||||
|
index['applications'] = [self.item]
|
||||||
|
self.files['index.json'] = json.dumps(index).encode()
|
||||||
|
for name in ('apps/sample-added.json', 'curated/sample-added.json'):
|
||||||
|
self.files.pop(name)
|
||||||
|
with patch.object(refresh, 'source_settings', return_value={'local_apps': ['sample-added']}):
|
||||||
|
self.sync()
|
||||||
|
catalog = Catalog(self.root)
|
||||||
|
self.assertIn('sample-added', refresh.visible_recipes(catalog))
|
||||||
|
self.assertEqual(catalog.compose('sample-added')['id'], 'image-sample-added')
|
||||||
|
self.assertNotIn('sample-added', refresh.visible_recipes(Catalog(self.root)))
|
||||||
|
|
||||||
|
def test_channel_switch_does_not_use_other_channel_snapshot(self):
|
||||||
|
self.sync()
|
||||||
|
self.assertEqual(refresh.current_catalog(self.root, "develop"), self.root / "catalog")
|
||||||
|
|
||||||
|
def test_engine_schema_change_invalidates_cached_catalog(self):
|
||||||
|
self.sync()
|
||||||
|
schema = self.root / "schemas/oci-template.schema.json"
|
||||||
|
schema.write_text(schema.read_text() + "\n")
|
||||||
|
self.assertEqual(refresh.current_catalog(self.root), self.root / "catalog")
|
||||||
|
|
||||||
|
def test_corrupted_cached_index_falls_back_to_bundled_catalog(self):
|
||||||
|
self.sync()
|
||||||
|
snapshot = refresh.current_catalog(self.root)
|
||||||
|
(snapshot / 'index.json').write_text('{broken json')
|
||||||
|
self.assertEqual(refresh.current_catalog(self.root), self.root / 'catalog')
|
||||||
|
|
||||||
|
def test_refresh_process_is_started_without_waiting(self):
|
||||||
|
status = refresh.CatalogRefresh(self.root, Catalog(self.root))
|
||||||
|
with patch.object(refresh.subprocess, "Popen") as spawn:
|
||||||
|
spawn.return_value.poll.return_value = None
|
||||||
|
with patch.object(refresh.threading, "Thread") as thread:
|
||||||
|
with status:
|
||||||
|
self.assertTrue(status.active)
|
||||||
|
self.assertIn("Refreshing", status.describe())
|
||||||
|
thread.return_value.start.assert_called_once()
|
||||||
|
spawn.return_value.wait.assert_not_called()
|
||||||
|
|
||||||
|
def test_manual_refresh_does_not_duplicate_worker_and_can_retry_failure(self):
|
||||||
|
status = refresh.CatalogRefresh(self.root, Catalog(self.root))
|
||||||
|
with patch.object(refresh.subprocess, 'Popen') as spawn:
|
||||||
|
spawn.return_value.poll.return_value = None
|
||||||
|
self.assertTrue(status.start())
|
||||||
|
self.assertFalse(status.start())
|
||||||
|
self.assertEqual(spawn.call_count, 1)
|
||||||
|
spawn.return_value.poll.return_value = 0
|
||||||
|
spawn.return_value.returncode = 0
|
||||||
|
refresh.write_json(refresh.cache_root(self.root) / 'main-result.json', {'ok': False})
|
||||||
|
self.assertFalse(status.consume())
|
||||||
|
self.assertTrue(status.failed)
|
||||||
|
self.assertTrue(status.start())
|
||||||
|
self.assertFalse(status.failed)
|
||||||
|
self.assertEqual(spawn.call_count, 2)
|
||||||
|
|
||||||
|
def test_manual_refresh_is_offered_in_the_main_menu(self):
|
||||||
|
from proxmenux_oci.cli import _catalog_options
|
||||||
|
options = dict(_catalog_options(Catalog(self.root))[-1])
|
||||||
|
self.assertEqual(options['catalog-refresh'], 'Refresh catalog')
|
||||||
|
|
||||||
|
def test_sequence_new_app_failed_update_retry_and_withdrawal(self):
|
||||||
|
self.sync()
|
||||||
|
old = Catalog(self.root)
|
||||||
|
status = refresh.CatalogRefresh(self.root, old)
|
||||||
|
status.mark_seen()
|
||||||
|
self.revision = 'b' * 40
|
||||||
|
self.add_app()
|
||||||
|
self.assertTrue(self.sync())
|
||||||
|
self.assertIn('sample-added', refresh.visible_recipes(Catalog(self.root)))
|
||||||
|
self.assertNotIn('sample-added', refresh.visible_recipes(old)) # Wizard remains pinned.
|
||||||
|
next_visit = refresh.CatalogRefresh(self.root, Catalog(self.root))
|
||||||
|
self.assertIn('New applications since your last visit: 1', next_visit.describe())
|
||||||
|
next_visit.mark_seen()
|
||||||
|
self.revision = 'c' * 40
|
||||||
|
recipe_path = self.item['curated_path'].removeprefix('catalog/')
|
||||||
|
good = self.files[recipe_path]
|
||||||
|
self.files[recipe_path] = b'{invalid-json'
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
self.sync()
|
||||||
|
self.assertEqual(refresh.current_catalog(self.root).name, 'b' * 40)
|
||||||
|
self.files[recipe_path] = good
|
||||||
|
self.assertTrue(self.sync())
|
||||||
|
index = json.loads(self.files['index.json'])
|
||||||
|
index['applications'] = [self.item]
|
||||||
|
self.files['index.json'] = json.dumps(index).encode()
|
||||||
|
for name in ('apps/sample-added.json', 'curated/sample-added.json'):
|
||||||
|
self.files.pop(name)
|
||||||
|
self.revision = 'd' * 40
|
||||||
|
self.assertTrue(self.sync())
|
||||||
|
current = Catalog(self.root)
|
||||||
|
self.assertNotIn('sample-added', refresh.visible_recipes(current))
|
||||||
|
self.assertFalse((current.catalog_dir / 'curated/sample-added.json').exists())
|
||||||
|
self.assertIn('New applications since your last visit: 0',
|
||||||
|
refresh.CatalogRefresh(self.root, current).describe())
|
||||||
|
|
||||||
|
def test_http_errors_and_timeouts_keep_snapshot_then_retry_succeeds(self):
|
||||||
|
self.sync()
|
||||||
|
old = refresh.current_catalog(self.root)
|
||||||
|
errors = [TimeoutError('timeout'), urllib.error.URLError('DNS unavailable')]
|
||||||
|
errors += [urllib.error.HTTPError('https://api.github.com', code, 'test', {}, None)
|
||||||
|
for code in (403, 404, 429, 500, 503)]
|
||||||
|
for error in errors:
|
||||||
|
if isinstance(error, urllib.error.HTTPError): self.addCleanup(error.close)
|
||||||
|
self.revision = 'b' * 40
|
||||||
|
for error in errors:
|
||||||
|
with self.subTest(error=str(error)), self.assertRaises(Exception):
|
||||||
|
refresh.synchronize(self.root, 'main', Mock(side_effect=error))
|
||||||
|
self.assertEqual(refresh.current_catalog(self.root), old)
|
||||||
|
self.assertTrue(self.sync())
|
||||||
|
|
||||||
|
def test_failed_file_transfer_does_not_publish_partial_download(self):
|
||||||
|
self.sync(); old = refresh.current_catalog(self.root)
|
||||||
|
self.revision = 'b' * 40; self.add_app()
|
||||||
|
def interrupted(url, etag=None):
|
||||||
|
if 'raw.githubusercontent' in url:
|
||||||
|
raise TimeoutError('Connection lost during download')
|
||||||
|
return self.fetch(url, etag)
|
||||||
|
with self.assertRaises(TimeoutError):
|
||||||
|
refresh.synchronize(self.root, 'main', interrupted)
|
||||||
|
self.assertEqual(refresh.current_catalog(self.root), old)
|
||||||
|
self.assertFalse(list((refresh.cache_root(self.root) / 'snapshots').glob('.download-*')))
|
||||||
|
self.assertTrue(self.sync())
|
||||||
|
|
||||||
|
def test_disk_full_and_pointer_permission_failure_preserve_previous_revision_and_etag(self):
|
||||||
|
for error in (OSError(errno.ENOSPC, 'Disk full'), PermissionError('Read only')):
|
||||||
|
with self.subTest(error=error):
|
||||||
|
self.sync()
|
||||||
|
pointer = refresh.cache_root(self.root) / 'main-current.json'
|
||||||
|
before = pointer.read_bytes()
|
||||||
|
old = refresh.current_catalog(self.root)
|
||||||
|
self.revision = 'b' * 40
|
||||||
|
real = refresh.write_json
|
||||||
|
def fail(path, value):
|
||||||
|
if Path(path) == pointer:
|
||||||
|
raise error
|
||||||
|
return real(path, value)
|
||||||
|
with patch.object(refresh, 'write_json', side_effect=fail), self.assertRaises(OSError):
|
||||||
|
self.sync()
|
||||||
|
self.assertEqual(pointer.read_bytes(), before)
|
||||||
|
self.assertEqual(refresh.current_catalog(self.root), old)
|
||||||
|
self.assertTrue(self.sync())
|
||||||
|
self.revision = 'c' * 40
|
||||||
|
|
||||||
|
def test_manual_force_repairs_corrupted_recipe_at_same_revision(self):
|
||||||
|
self.sync()
|
||||||
|
old = refresh.current_catalog(self.root)
|
||||||
|
recipe = old / self.item['curated_path'].removeprefix('catalog/')
|
||||||
|
recipe.write_text('{}')
|
||||||
|
self.requests.clear()
|
||||||
|
self.assertTrue(refresh.synchronize(self.root, 'main', self.fetch, force=True))
|
||||||
|
self.assertIsNone(self.requests[0][1])
|
||||||
|
self.assertNotEqual(refresh.current_catalog(self.root), old)
|
||||||
|
self.assertEqual(Catalog(self.root).compose(self.item['id'])['id'], self.item['template_id'])
|
||||||
|
self.assertFalse(refresh.synchronize(self.root, 'main', self.fetch, force=True))
|
||||||
|
|
||||||
|
def test_corrupt_cached_index_can_be_repaired_by_automatic_refresh(self):
|
||||||
|
self.sync()
|
||||||
|
old = refresh.current_catalog(self.root)
|
||||||
|
(old / 'index.json').write_text('{broken')
|
||||||
|
self.assertTrue(self.sync())
|
||||||
|
self.assertNotEqual(refresh.current_catalog(self.root), self.root / 'catalog')
|
||||||
|
self.assertEqual(Catalog(self.root).compose(self.item['id'])['id'], self.item['template_id'])
|
||||||
|
|
||||||
|
def test_second_session_result_cannot_hide_successful_update(self):
|
||||||
|
status = refresh.CatalogRefresh(self.root, Catalog(self.root))
|
||||||
|
self.sync()
|
||||||
|
status.process = Mock(returncode=0)
|
||||||
|
status.process.poll.return_value = 0
|
||||||
|
# Another session checked the newly installed revision and found no change.
|
||||||
|
refresh.write_json(refresh.cache_root(self.root) / 'main-result.json',
|
||||||
|
{'ok': True, 'changed': False})
|
||||||
|
self.assertTrue(status.consume())
|
||||||
|
|
||||||
|
def test_crashed_worker_does_not_trust_stale_success(self):
|
||||||
|
status = refresh.CatalogRefresh(self.root, Catalog(self.root))
|
||||||
|
status.process = Mock(returncode=1); status.process.poll.return_value = 1
|
||||||
|
refresh.write_json(refresh.cache_root(self.root) / 'main-result.json',
|
||||||
|
{'ok': True, 'changed': True})
|
||||||
|
self.assertFalse(status.consume())
|
||||||
|
self.assertTrue(status.failed)
|
||||||
|
|
||||||
|
def test_force_manual_refresh_passes_flag_to_worker(self):
|
||||||
|
status = refresh.CatalogRefresh(self.root, Catalog(self.root))
|
||||||
|
with patch.object(refresh.subprocess, 'Popen') as spawn:
|
||||||
|
self.assertTrue(status.start(force=True))
|
||||||
|
self.assertIn('--force', spawn.call_args.args[0])
|
||||||
|
|
||||||
|
def test_duplicate_manifest_path_symlink_and_truncated_tree_are_rejected(self):
|
||||||
|
for change in ('duplicate', 'symlink', 'truncated'):
|
||||||
|
def bad_tree(url, etag=None):
|
||||||
|
body, tag = self.fetch(url, etag)
|
||||||
|
if '/git/trees/' in url:
|
||||||
|
tree = json.loads(body)
|
||||||
|
if change == 'duplicate': tree['tree'].append(tree['tree'][0])
|
||||||
|
elif change == 'symlink': tree['tree'][0]['mode'] = '120000'
|
||||||
|
else: tree['truncated'] = True
|
||||||
|
body = json.dumps(tree).encode()
|
||||||
|
return body, tag
|
||||||
|
with self.subTest(change=change), self.assertRaises(ValueError):
|
||||||
|
refresh.synchronize(self.root, 'main', bad_tree)
|
||||||
|
self.assertEqual(refresh.current_catalog(self.root), self.root / 'catalog')
|
||||||
|
|
||||||
|
def test_bad_channel_config_falls_back_without_crashing_menu(self):
|
||||||
|
config = self.root.parent / 'catalog-source.json'
|
||||||
|
for value in ([], None, 'invalid', {'channel': 'other'}, {'beta_program': 'invalid'}):
|
||||||
|
config.write_text(json.dumps(value))
|
||||||
|
self.assertEqual(refresh.channel_for(self.root), 'main')
|
||||||
|
|
||||||
|
def test_full_disk_during_file_write_leaves_no_partial_catalog(self):
|
||||||
|
self.sync(); old = refresh.current_catalog(self.root)
|
||||||
|
self.revision = 'b' * 40; self.add_app()
|
||||||
|
real = Path.write_bytes
|
||||||
|
def full(path, data):
|
||||||
|
if any(part.startswith('.download-') for part in path.parts):
|
||||||
|
raise OSError(errno.ENOSPC, 'Disk full')
|
||||||
|
return real(path, data)
|
||||||
|
with patch.object(Path, 'write_bytes', new=full), self.assertRaises(OSError):
|
||||||
|
self.sync()
|
||||||
|
self.assertEqual(refresh.current_catalog(self.root), old)
|
||||||
|
self.assertFalse(list((refresh.cache_root(self.root) / 'snapshots').glob('.download-*')))
|
||||||
|
|
||||||
|
def test_orphaned_download_is_removed_before_retry(self):
|
||||||
|
partial = refresh.cache_root(self.root) / 'snapshots/.download-crashed'
|
||||||
|
partial.mkdir(parents=True); (partial/'partial.json').write_text('{}')
|
||||||
|
synchronize = refresh.synchronize
|
||||||
|
with patch.object(refresh, 'synchronize', side_effect=lambda *a, **kw:
|
||||||
|
synchronize(self.root, 'main', self.fetch)):
|
||||||
|
refresh.worker(self.root, 'main')
|
||||||
|
self.assertFalse(partial.exists())
|
||||||
|
self.assertTrue(refresh.read_json(refresh.cache_root(self.root)/'main-result.json')['ok'])
|
||||||
|
|
||||||
|
def test_request_rejects_oversized_response_and_handles_304(self):
|
||||||
|
context = Mock()
|
||||||
|
context.__enter__ = Mock(return_value=Mock(read=Mock(return_value=b'oversized')))
|
||||||
|
context.__exit__ = Mock(return_value=False)
|
||||||
|
with patch.object(refresh.urllib.request, 'urlopen', return_value=context), self.assertRaises(ValueError):
|
||||||
|
refresh.request('https://api.github.com/test', limit=2)
|
||||||
|
error = urllib.error.HTTPError('https://api.github.com', 304, 'unchanged', {}, None)
|
||||||
|
with patch.object(refresh.urllib.request, 'urlopen', side_effect=error):
|
||||||
|
self.assertEqual(refresh.request('https://api.github.com/test', etag='old'), (None, 'old'))
|
||||||
|
|
||||||
|
def test_unwritable_seen_state_does_not_crash_menu(self):
|
||||||
|
status = refresh.CatalogRefresh(self.root, Catalog(self.root))
|
||||||
|
with patch.object(refresh, 'write_json', side_effect=PermissionError('Read only')):
|
||||||
|
status.mark_seen()
|
||||||
|
self.assertTrue(status.failed)
|
||||||
|
|
||||||
|
def test_concurrent_workers_serialize_the_catalog_transaction(self):
|
||||||
|
barrier = threading.Barrier(2)
|
||||||
|
guard = threading.Lock()
|
||||||
|
active = maximum = 0
|
||||||
|
errors = []
|
||||||
|
def operation(*args, **kwargs):
|
||||||
|
nonlocal active, maximum
|
||||||
|
with guard:
|
||||||
|
active += 1; maximum = max(maximum, active)
|
||||||
|
time.sleep(0.03)
|
||||||
|
with guard: active -= 1
|
||||||
|
return False
|
||||||
|
def run():
|
||||||
|
try:
|
||||||
|
barrier.wait(timeout=2)
|
||||||
|
refresh.worker(self.root, 'main')
|
||||||
|
except Exception as error:
|
||||||
|
errors.append(error)
|
||||||
|
with patch.object(refresh, 'synchronize', side_effect=operation):
|
||||||
|
threads = [threading.Thread(target=run) for _ in range(2)]
|
||||||
|
for thread in threads: thread.start()
|
||||||
|
for thread in threads: thread.join(timeout=3)
|
||||||
|
self.assertFalse(errors)
|
||||||
|
self.assertTrue(all(not thread.is_alive() for thread in threads))
|
||||||
|
self.assertEqual(maximum, 1)
|
||||||
|
|
||||||
|
def test_download_deadline_preserves_previous_catalog(self):
|
||||||
|
self.sync(); old = refresh.current_catalog(self.root)
|
||||||
|
self.revision = 'b' * 40; self.add_app()
|
||||||
|
with patch.object(refresh.time, 'monotonic', side_effect=itertools.chain([0], itertools.repeat(100))), \
|
||||||
|
self.assertRaises(TimeoutError):
|
||||||
|
self.sync()
|
||||||
|
self.assertEqual(refresh.current_catalog(self.root), old)
|
||||||
|
|
||||||
|
def test_http_end_to_end_new_release_outage_retry_manual_refresh_and_repair(self):
|
||||||
|
owner = self
|
||||||
|
unavailable = False
|
||||||
|
class Handler(http.server.BaseHTTPRequestHandler):
|
||||||
|
def log_message(self, *args): pass
|
||||||
|
def do_GET(self):
|
||||||
|
if unavailable:
|
||||||
|
self.send_error(503); return
|
||||||
|
etag = '"' + owner.revision + '"'
|
||||||
|
if self.headers.get('If-None-Match') == etag:
|
||||||
|
self.send_response(304); self.end_headers(); return
|
||||||
|
host = ('https://api.github.com' if self.path.startswith('/repos/')
|
||||||
|
else 'https://raw.githubusercontent.com')
|
||||||
|
body, _ = owner.fetch(host + self.path)
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header('Content-Length', str(len(body)))
|
||||||
|
self.send_header('ETag', etag)
|
||||||
|
self.end_headers(); self.wfile.write(body)
|
||||||
|
server = http.server.ThreadingHTTPServer(('127.0.0.1', 0), Handler)
|
||||||
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
def fetch(url, etag=None):
|
||||||
|
original = urllib.parse.urlsplit(url)
|
||||||
|
path = original.path + ('?' + original.query if original.query else '')
|
||||||
|
return refresh.request(f'http://127.0.0.1:{server.server_port}' + path, etag)
|
||||||
|
try:
|
||||||
|
self.assertTrue(refresh.synchronize(self.root, 'main', fetch))
|
||||||
|
self.assertFalse(refresh.synchronize(self.root, 'main', fetch))
|
||||||
|
first = refresh.current_catalog(self.root)
|
||||||
|
unavailable = True
|
||||||
|
self.revision = 'b' * 40; self.add_app()
|
||||||
|
with self.assertRaises(urllib.error.HTTPError):
|
||||||
|
refresh.synchronize(self.root, 'main', fetch)
|
||||||
|
self.assertEqual(refresh.current_catalog(self.root), first)
|
||||||
|
unavailable = False
|
||||||
|
self.assertTrue(refresh.synchronize(self.root, 'main', fetch))
|
||||||
|
current = Catalog(self.root)
|
||||||
|
self.assertEqual(len(refresh.visible_recipes(current)), 2)
|
||||||
|
self.assertFalse(refresh.synchronize(self.root, 'main', fetch, force=True))
|
||||||
|
(current.catalog_dir / 'curated/sample-added.json').write_text('{}')
|
||||||
|
self.assertTrue(refresh.synchronize(self.root, 'main', fetch, force=True))
|
||||||
|
self.assertEqual(Catalog(self.root).compose('sample-added')['id'], 'image-sample-added')
|
||||||
|
finally:
|
||||||
|
server.shutdown(); server.server_close(); thread.join(timeout=2)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -126,8 +126,13 @@ class RegistryMenuTests(unittest.TestCase):
|
|||||||
catalog = SimpleNamespace(index_path=SimpleNamespace(exists=lambda: True),
|
catalog = SimpleNamespace(index_path=SimpleNamespace(exists=lambda: True),
|
||||||
load_index=lambda: {'applications': []})
|
load_index=lambda: {'applications': []})
|
||||||
ui = UI(['registries', None])
|
ui = UI(['registries', None])
|
||||||
with patch.object(cli, 'interactive_ui', return_value=ui), \
|
with patch('proxmenux_oci.catalog_refresh.CatalogRefresh') as refresh_type, \
|
||||||
|
patch.object(cli, 'interactive_ui', return_value=ui), \
|
||||||
patch.object(accounts, 'open_registry_accounts') as open_accounts:
|
patch.object(accounts, 'open_registry_accounts') as open_accounts:
|
||||||
|
refresher = refresh_type.return_value.__enter__.return_value
|
||||||
|
refresher.active = False
|
||||||
|
refresher.consume.return_value = False
|
||||||
|
refresher.describe.return_value = ''
|
||||||
self.assertEqual(cli.interactive(catalog), 0)
|
self.assertEqual(cli.interactive(catalog), 0)
|
||||||
open_accounts.assert_called_once_with(ui)
|
open_accounts.assert_called_once_with(ui)
|
||||||
|
|
||||||
|
|||||||
@@ -280,7 +280,14 @@ class MainMenuTests(unittest.TestCase):
|
|||||||
checker.running = False; checker.done = True
|
checker.running = False; checker.done = True
|
||||||
raise MenuRefresh()
|
raise MenuRefresh()
|
||||||
return None
|
return None
|
||||||
with patch.object(cli, 'interactive_ui', return_value=UI()), \
|
catalog_refresh = Mock(active=False)
|
||||||
|
catalog_refresh.consume.return_value = False
|
||||||
|
catalog_refresh.describe.return_value = 'Catalog: 0 applications'
|
||||||
|
catalog_context = Mock()
|
||||||
|
catalog_context.__enter__ = Mock(return_value=catalog_refresh)
|
||||||
|
catalog_context.__exit__ = Mock(return_value=False)
|
||||||
|
with patch('proxmenux_oci.catalog_refresh.CatalogRefresh', return_value=catalog_context), \
|
||||||
|
patch.object(cli, 'interactive_ui', return_value=UI()), \
|
||||||
patch.object(updates, 'UpdateCheck', return_value=checker):
|
patch.object(updates, 'UpdateCheck', return_value=checker):
|
||||||
self.assertEqual(cli.interactive(cli.Catalog(ROOT)), 0)
|
self.assertEqual(cli.interactive(cli.Catalog(ROOT)), 0)
|
||||||
self.assertEqual(seen, ['Check for updates', 'Checking for updates (0/2)',
|
self.assertEqual(seen, ['Check for updates', 'Checking for updates (0/2)',
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ initialize_cache
|
|||||||
ensure_oci_dependencies() {
|
ensure_oci_dependencies() {
|
||||||
local -a missing=()
|
local -a missing=()
|
||||||
python3 -c 'import yaml' >/dev/null 2>&1 || missing+=("python3-yaml")
|
python3 -c 'import yaml' >/dev/null 2>&1 || missing+=("python3-yaml")
|
||||||
|
python3 -c 'import jsonschema' >/dev/null 2>&1 || missing+=("python3-jsonschema")
|
||||||
command -v skopeo >/dev/null 2>&1 || missing+=("skopeo")
|
command -v skopeo >/dev/null 2>&1 || missing+=("skopeo")
|
||||||
[[ ${#missing[@]} -eq 0 ]] && return 0
|
[[ ${#missing[@]} -eq 0 ]] && return 0
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user