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:
MacRimi
2026-11-03 19:46:54 +01:00
committed by GitHub
19 changed files with 1490 additions and 20 deletions
+8 -2
View File
@@ -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)
+1
View File
@@ -54,6 +54,7 @@ Thumbs.db
# Cache # Cache
.cache .cache
/oci/.catalog-cache/
/web/.cache /web/.cache
# IDE/editor files # IDE/editor files
+6
View File
@@ -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:",
+6
View File
@@ -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:",
+6
View File
@@ -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 :",
+6
View File
@@ -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:",
+6
View File
@@ -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:",
+6
View File
@@ -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é:",
+7
View File
@@ -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:",
+69
View File
@@ -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.
+6
View File
@@ -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.
+4
View File
@@ -0,0 +1,4 @@
{
"engine_api": 1,
"description": "Increment engine_api when catalog recipes require runtime capabilities unavailable in earlier catalog-refresh clients."
}
+40 -6
View File
@@ -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"],
+535
View File
@@ -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)
+26 -10
View File
@@ -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']:
+743
View File
@@ -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()
+6 -1
View File
@@ -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)
+8 -1
View File
@@ -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)',
+1
View File
@@ -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