fix(oci): keep update results when one application fails

- Check that the Monitor answers on its socket, not only that it exists
- Report the applications whose check failed instead of failing the whole request
- Show those applications as not checked, without their older result
- Tell the user in the menu when a check fails
This commit is contained in:
MacRimi
2026-10-19 17:23:58 +02:00
parent e138f4c800
commit 818ab1308a
12 changed files with 113 additions and 20 deletions
+4 -4
View File
@@ -74,19 +74,19 @@ def check_applications(module, rows, report):
module.check_app(vmid, app['id'], force=True, notify=False) module.check_app(vmid, app['id'], force=True, notify=False)
pool = ThreadPoolExecutor(max_workers=_CHECK_WORKERS, thread_name_prefix='oci-check') pool = ThreadPoolExecutor(max_workers=_CHECK_WORKERS, thread_name_prefix='oci-check')
failed = False failed = []
try: try:
futures = [pool.submit(check_one, row) for row in rows] futures = {pool.submit(check_one, row): row['vmid'] for row in rows}
for completed, future in enumerate(as_completed(futures), 1): for completed, future in enumerate(as_completed(futures), 1):
try: try:
future.result() future.result()
except Exception: except Exception:
failed = True # One failure never stops the other applications. failed.append(futures[future]) # One failure never stops the other applications.
report({'completed': completed, 'total': len(rows)}) report({'completed': completed, 'total': len(rows)})
finally: finally:
stopped.set() stopped.set()
pool.shutdown(wait=True, cancel_futures=True) pool.shutdown(wait=True, cancel_futures=True)
report({'error': 'check_failed'} if failed else {'done': True}) report({'done': True, 'failed': sorted(failed)})
class _Handler(socketserver.StreamRequestHandler): class _Handler(socketserver.StreamRequestHandler):
+2
View File
@@ -5940,6 +5940,7 @@
"Snippets — hook scripts / config": "Snippets – Hook-Skripte/Konfiguration", "Snippets — hook scripts / config": "Snippets – Hook-Skripte/Konfiguration",
"SoC-integrated GPU: tight coupling with other SoC components": "SoC-integrierte GPU: enge Kopplung mit anderen SoC-Komponenten", "SoC-integrated GPU: tight coupling with other SoC components": "SoC-integrierte GPU: enge Kopplung mit anderen SoC-Komponenten",
"Some DKMS removals reported errors; final verification will determine the result.": "Bei einigen DKMS-Entfernungen wurden Fehler gemeldet. Über das Ergebnis entscheidet die abschließende Prüfung.", "Some DKMS removals reported errors; final verification will determine the result.": "Bei einigen DKMS-Entfernungen wurden Fehler gemeldet. Über das Ergebnis entscheidet die abschließende Prüfung.",
"Some applications could not be checked ({count}). Try again later.": "Einige Anwendungen konnten nicht geprüft werden ({count}). Versuchen Sie es später erneut.",
"Some changes require a reboot to take effect. Do you want to restart now?": "Einige Änderungen erfordern einen Neustart, damit sie wirksam werden. Möchten Sie jetzt neu starten?", "Some changes require a reboot to take effect. Do you want to restart now?": "Einige Änderungen erfordern einen Neustart, damit sie wirksam werden. Möchten Sie jetzt neu starten?",
"Some essential Proxmox packages may not have been installed": "Einige wichtige Proxmox-Pakete wurden möglicherweise nicht installiert", "Some essential Proxmox packages may not have been installed": "Einige wichtige Proxmox-Pakete wurden möglicherweise nicht installiert",
"Some log2ram files may still exist. Manual cleanup may be required.": "Möglicherweise sind noch einige Log2RAM-Dateien vorhanden. Möglicherweise ist eine manuelle Bereinigung erforderlich.", "Some log2ram files may still exist. Manual cleanup may be required.": "Möglicherweise sind noch einige Log2RAM-Dateien vorhanden. Möglicherweise ist eine manuelle Bereinigung erforderlich.",
@@ -6794,6 +6795,7 @@
"The tmpfs mounts of the container differ from the saved record": "Die tmpfs Halterungen des Containers unterscheiden sich vom gespeicherten Datensatz", "The tmpfs mounts of the container differ from the saved record": "Die tmpfs Halterungen des Containers unterscheiden sich vom gespeicherten Datensatz",
"The tmpfs path or size is outside the supported profile": "Der Pfad oder die Größe von tmpfs befindet sich außerhalb des unterstützten Profils", "The tmpfs path or size is outside the supported profile": "Der Pfad oder die Größe von tmpfs befindet sich außerhalb des unterstützten Profils",
"The translated recipe changed during the preparation": "Das übersetzte Rezept änderte sich während der Vorbereitung", "The translated recipe changed during the preparation": "Das übersetzte Rezept änderte sich während der Vorbereitung",
"The update check could not be completed. Try again later.": "Die Update-Prüfung konnte nicht abgeschlossen werden. Versuchen Sie es später erneut.",
"The value contains an unsupported character": "Der Wert enthält ein nicht unterstütztes Zeichen", "The value contains an unsupported character": "Der Wert enthält ein nicht unterstütztes Zeichen",
"The value must be a number:": "Der Wert muss eine Zahl sein:", "The value must be a number:": "Der Wert muss eine Zahl sein:",
"The values do not match. Enter them again.": "Die Werte stimmen nicht überein. Betreten Sie sie wieder.", "The values do not match. Enter them again.": "Die Werte stimmen nicht überein. Betreten Sie sie wieder.",
+2
View File
@@ -5925,6 +5925,7 @@
"Snippets — hook scripts / config": "Snippets — hook scripts / config", "Snippets — hook scripts / config": "Snippets — hook scripts / config",
"SoC-integrated GPU: tight coupling with other SoC components": "GPU integrada en SoC: estrecho acoplamiento con otros componentes de SoC", "SoC-integrated GPU: tight coupling with other SoC components": "GPU integrada en SoC: estrecho acoplamiento con otros componentes de SoC",
"Some DKMS removals reported errors; final verification will determine the result.": "Algunas eliminaciones de DKMS han devuelto errores; la verificación final determinará el resultado.", "Some DKMS removals reported errors; final verification will determine the result.": "Algunas eliminaciones de DKMS han devuelto errores; la verificación final determinará el resultado.",
"Some applications could not be checked ({count}). Try again later.": "No se han podido comprobar algunas aplicaciones ({count}). Vuelve a intentarlo más tarde.",
"Some changes require a reboot to take effect. Do you want to restart now?": "Algunos cambios requieren un reinicio para que surtan efecto. ¿Quieres reiniciar ahora?", "Some changes require a reboot to take effect. Do you want to restart now?": "Algunos cambios requieren un reinicio para que surtan efecto. ¿Quieres reiniciar ahora?",
"Some essential Proxmox packages may not have been installed": "Es posible que algunos paquetes esenciales de Proxmox no se hayan instalado", "Some essential Proxmox packages may not have been installed": "Es posible que algunos paquetes esenciales de Proxmox no se hayan instalado",
"Some log2ram files may still exist. Manual cleanup may be required.": "Es posible que aún existan algunos archivos log2ram. Es posible que se requiera una limpieza manual.", "Some log2ram files may still exist. Manual cleanup may be required.": "Es posible que aún existan algunos archivos log2ram. Es posible que se requiera una limpieza manual.",
@@ -6770,6 +6771,7 @@
"The tmpfs mounts of the container differ from the saved record": "Los montajes tmpfs del contenedor difieren del registro guardado", "The tmpfs mounts of the container differ from the saved record": "Los montajes tmpfs del contenedor difieren del registro guardado",
"The tmpfs path or size is outside the supported profile": "El camino o tamaño de tmpfs está fuera del perfil soportado", "The tmpfs path or size is outside the supported profile": "El camino o tamaño de tmpfs está fuera del perfil soportado",
"The translated recipe changed during the preparation": "La receta traducida cambió durante la preparación", "The translated recipe changed during the preparation": "La receta traducida cambió durante la preparación",
"The update check could not be completed. Try again later.": "La búsqueda de actualizaciones no ha podido terminar. Vuelve a intentarlo más tarde.",
"The value contains an unsupported character": "El valor contiene un carácter no admitido", "The value contains an unsupported character": "El valor contiene un carácter no admitido",
"The value must be a number:": "El valor debe ser un número:", "The value must be a number:": "El valor debe ser un número:",
"The values do not match. Enter them again.": "Los valores no coinciden. Vuelve a introducirlos.", "The values do not match. Enter them again.": "Los valores no coinciden. Vuelve a introducirlos.",
+2
View File
@@ -5940,6 +5940,7 @@
"Snippets — hook scripts / config": "Extraits - scripts de hook / configuration", "Snippets — hook scripts / config": "Extraits - scripts de hook / configuration",
"SoC-integrated GPU: tight coupling with other SoC components": "GPU intégré au SoC : couplage étroit avec d'autres composants du SoC", "SoC-integrated GPU: tight coupling with other SoC components": "GPU intégré au SoC : couplage étroit avec d'autres composants du SoC",
"Some DKMS removals reported errors; final verification will determine the result.": "Certaines suppressions de DKMS ont signalé des erreurs ;la vérification finale déterminera le résultat.", "Some DKMS removals reported errors; final verification will determine the result.": "Certaines suppressions de DKMS ont signalé des erreurs ;la vérification finale déterminera le résultat.",
"Some applications could not be checked ({count}). Try again later.": "Certaines applications n'ont pas pu être vérifiées ({count}). Réessayez plus tard.",
"Some changes require a reboot to take effect. Do you want to restart now?": "Certaines modifications nécessitent un redémarrage pour prendre effet. Voulez-vous redémarrer maintenant ?", "Some changes require a reboot to take effect. Do you want to restart now?": "Certaines modifications nécessitent un redémarrage pour prendre effet. Voulez-vous redémarrer maintenant ?",
"Some essential Proxmox packages may not have been installed": "Certains packages Proxmox essentiels n'ont peut-être pas été installés", "Some essential Proxmox packages may not have been installed": "Certains packages Proxmox essentiels n'ont peut-être pas été installés",
"Some log2ram files may still exist. Manual cleanup may be required.": "Certains fichiers log2ram peuvent encore exister. Un nettoyage manuel peut être nécessaire.", "Some log2ram files may still exist. Manual cleanup may be required.": "Certains fichiers log2ram peuvent encore exister. Un nettoyage manuel peut être nécessaire.",
@@ -6793,6 +6794,7 @@
"The tmpfs mounts of the container differ from the saved record": "Les supports tmpfs du conteneur diffèrent de l'enregistrement enregistré", "The tmpfs mounts of the container differ from the saved record": "Les supports tmpfs du conteneur diffèrent de l'enregistrement enregistré",
"The tmpfs path or size is outside the supported profile": "Le chemin ou la taille de tmpfs est en dehors du profil pris en charge", "The tmpfs path or size is outside the supported profile": "Le chemin ou la taille de tmpfs est en dehors du profil pris en charge",
"The translated recipe changed during the preparation": "La recette traduite a changé pendant la préparation", "The translated recipe changed during the preparation": "La recette traduite a changé pendant la préparation",
"The update check could not be completed. Try again later.": "La recherche de mises à jour n'a pas pu se terminer. Réessayez plus tard.",
"The value contains an unsupported character": "La valeur contient un caractère non pris en charge", "The value contains an unsupported character": "La valeur contient un caractère non pris en charge",
"The value must be a number:": "La valeur doit être un nombre :", "The value must be a number:": "La valeur doit être un nombre :",
"The values do not match. Enter them again.": "Les valeurs ne correspondent pas. Entrez encore.", "The values do not match. Enter them again.": "Les valeurs ne correspondent pas. Entrez encore.",
+2
View File
@@ -5940,6 +5940,7 @@
"Snippets — hook scripts / config": "Snippet: script di hook/config", "Snippets — hook scripts / config": "Snippet: script di hook/config",
"SoC-integrated GPU: tight coupling with other SoC components": "GPU integrata nel SoC: stretto accoppiamento con altri componenti SoC", "SoC-integrated GPU: tight coupling with other SoC components": "GPU integrata nel SoC: stretto accoppiamento con altri componenti SoC",
"Some DKMS removals reported errors; final verification will determine the result.": "Alcune rimozioni DKMS hanno riportato errori; la verifica finale determinerà il risultato.", "Some DKMS removals reported errors; final verification will determine the result.": "Alcune rimozioni DKMS hanno riportato errori; la verifica finale determinerà il risultato.",
"Some applications could not be checked ({count}). Try again later.": "Non è stato possibile controllare alcune applicazioni ({count}). Riprova più tardi.",
"Some changes require a reboot to take effect. Do you want to restart now?": "Alcune modifiche richiedono un riavvio per avere effetto. Vuoi riavviare adesso?", "Some changes require a reboot to take effect. Do you want to restart now?": "Alcune modifiche richiedono un riavvio per avere effetto. Vuoi riavviare adesso?",
"Some essential Proxmox packages may not have been installed": "Alcuni pacchetti Proxmox essenziali potrebbero non essere stati installati", "Some essential Proxmox packages may not have been installed": "Alcuni pacchetti Proxmox essenziali potrebbero non essere stati installati",
"Some log2ram files may still exist. Manual cleanup may be required.": "Alcuni file log2ram potrebbero ancora esistere. Potrebbe essere necessaria la pulizia manuale.", "Some log2ram files may still exist. Manual cleanup may be required.": "Alcuni file log2ram potrebbero ancora esistere. Potrebbe essere necessaria la pulizia manuale.",
@@ -6794,6 +6795,7 @@
"The tmpfs mounts of the container differ from the saved record": "I mount tmpfs del container differiscono dal record salvato", "The tmpfs mounts of the container differ from the saved record": "I mount tmpfs del container differiscono dal record salvato",
"The tmpfs path or size is outside the supported profile": "Il percorso o la dimensione di tmpfs non rientra nel profilo supportato", "The tmpfs path or size is outside the supported profile": "Il percorso o la dimensione di tmpfs non rientra nel profilo supportato",
"The translated recipe changed during the preparation": "La ricetta tradotta è cambiata durante la preparazione", "The translated recipe changed during the preparation": "La ricetta tradotta è cambiata durante la preparazione",
"The update check could not be completed. Try again later.": "La ricerca degli aggiornamenti non è stata completata. Riprova più tardi.",
"The value contains an unsupported character": "Il valore contiene un carattere non supportato", "The value contains an unsupported character": "Il valore contiene un carattere non supportato",
"The value must be a number:": "Il valore deve essere un numero:", "The value must be a number:": "Il valore deve essere un numero:",
"The values do not match. Enter them again.": "I valori non corrispondono. Inserirli di nuovo.", "The values do not match. Enter them again.": "I valori non corrispondono. Inserirli di nuovo.",
+2
View File
@@ -5940,6 +5940,7 @@
"Snippets — hook scripts / config": "Snippets – scripts/configuração de gancho", "Snippets — hook scripts / config": "Snippets – scripts/configuração de gancho",
"SoC-integrated GPU: tight coupling with other SoC components": "GPU integrada ao SoC: forte acoplamento com outros componentes do SoC", "SoC-integrated GPU: tight coupling with other SoC components": "GPU integrada ao SoC: forte acoplamento com outros componentes do SoC",
"Some DKMS removals reported errors; final verification will determine the result.": "Algumas remoções de DKMS relataram erros;a verificação final determinará o resultado.", "Some DKMS removals reported errors; final verification will determine the result.": "Algumas remoções de DKMS relataram erros;a verificação final determinará o resultado.",
"Some applications could not be checked ({count}). Try again later.": "Não foi possível verificar algumas aplicações ({count}). Tente novamente mais tarde.",
"Some changes require a reboot to take effect. Do you want to restart now?": "Algumas alterações exigem uma reinicialização para entrar em vigor. Quer reiniciar agora?", "Some changes require a reboot to take effect. Do you want to restart now?": "Algumas alterações exigem uma reinicialização para entrar em vigor. Quer reiniciar agora?",
"Some essential Proxmox packages may not have been installed": "Alguns pacotes essenciais do Proxmox podem não ter sido instalados", "Some essential Proxmox packages may not have been installed": "Alguns pacotes essenciais do Proxmox podem não ter sido instalados",
"Some log2ram files may still exist. Manual cleanup may be required.": "Alguns arquivos log2ram ainda podem existir. A limpeza manual pode ser necessária.", "Some log2ram files may still exist. Manual cleanup may be required.": "Alguns arquivos log2ram ainda podem existir. A limpeza manual pode ser necessária.",
@@ -6794,6 +6795,7 @@
"The tmpfs mounts of the container differ from the saved record": "As montagens tmpfs do recipiente diferem do registo gravado", "The tmpfs mounts of the container differ from the saved record": "As montagens tmpfs do recipiente diferem do registo gravado",
"The tmpfs path or size is outside the supported profile": "O caminho ou tamanho do tmpfs está fora do perfil suportado", "The tmpfs path or size is outside the supported profile": "O caminho ou tamanho do tmpfs está fora do perfil suportado",
"The translated recipe changed during the preparation": "A receita traduzida mudou durante a preparação", "The translated recipe changed during the preparation": "A receita traduzida mudou durante a preparação",
"The update check could not be completed. Try again later.": "A pesquisa de atualizações não pôde ser concluída. Tente novamente mais tarde.",
"The value contains an unsupported character": "O valor contém um caracter não suportado", "The value contains an unsupported character": "O valor contém um caracter não suportado",
"The value must be a number:": "O valor deve ser um número:", "The value must be a number:": "O valor deve ser um número:",
"The values do not match. Enter them again.": "Os valores não correspondem. Entra outra vez.", "The values do not match. Enter them again.": "Os valores não correspondem. Entra outra vez.",
+2
View File
@@ -5942,6 +5942,7 @@
"Snippets — hook scripts / config": "Snippets — hook skripty / konfigurácia", "Snippets — hook scripts / config": "Snippets — hook skripty / konfigurácia",
"SoC-integrated GPU: tight coupling with other SoC components": "GPU integrovaná v SoC: úzke prepojenie s ďalšími súčasťami SoC", "SoC-integrated GPU: tight coupling with other SoC components": "GPU integrovaná v SoC: úzke prepojenie s ďalšími súčasťami SoC",
"Some DKMS removals reported errors; final verification will determine the result.": "Niektoré odstránenia DKMS hlásili chyby;konečné overenie určí výsledok.", "Some DKMS removals reported errors; final verification will determine the result.": "Niektoré odstránenia DKMS hlásili chyby;konečné overenie určí výsledok.",
"Some applications could not be checked ({count}). Try again later.": "Niektoré aplikácie sa nepodarilo skontrolovať ({count}). Skúste to znova neskôr.",
"Some changes require a reboot to take effect. Do you want to restart now?": "Niektoré zmeny sa prejavia až po reštarte. Chcete reštartovať teraz?", "Some changes require a reboot to take effect. Do you want to restart now?": "Niektoré zmeny sa prejavia až po reštarte. Chcete reštartovať teraz?",
"Some essential Proxmox packages may not have been installed": "Niektoré základné Proxmox balíky sa možno nenainštalovali", "Some essential Proxmox packages may not have been installed": "Niektoré základné Proxmox balíky sa možno nenainštalovali",
"Some log2ram files may still exist. Manual cleanup may be required.": "Niektoré súbory log2ram môžu stále existovať. Možno bude potrebné ručné čistenie.", "Some log2ram files may still exist. Manual cleanup may be required.": "Niektoré súbory log2ram môžu stále existovať. Možno bude potrebné ručné čistenie.",
@@ -6795,6 +6796,7 @@
"The tmpfs mounts of the container differ from the saved record": "Tmpfs držiaky kontajnera sa líšia od uloženého záznamu", "The tmpfs mounts of the container differ from the saved record": "Tmpfs držiaky kontajnera sa líšia od uloženého záznamu",
"The tmpfs path or size is outside the supported profile": "Tmpfs cesta alebo veľkosť je mimo podporovaný profil", "The tmpfs path or size is outside the supported profile": "Tmpfs cesta alebo veľkosť je mimo podporovaný profil",
"The translated recipe changed during the preparation": "Preložený recept sa počas prípravy zmenil", "The translated recipe changed during the preparation": "Preložený recept sa počas prípravy zmenil",
"The update check could not be completed. Try again later.": "Kontrolu aktualizácií sa nepodarilo dokončiť. Skúste to znova neskôr.",
"The value contains an unsupported character": "Hodnota obsahuje nepodporovaný znak", "The value contains an unsupported character": "Hodnota obsahuje nepodporovaný znak",
"The value must be a number:": "Hodnota musí byť číslo:", "The value must be a number:": "Hodnota musí byť číslo:",
"The values do not match. Enter them again.": "Hodnoty sa nezhodujú. Zadajte ich znova.", "The values do not match. Enter them again.": "Hodnoty sa nezhodujú. Zadajte ich znova.",
+2
View File
@@ -5940,6 +5940,7 @@
"Snippets — hook scripts / config": "Snippets — krokskript / config", "Snippets — hook scripts / config": "Snippets — krokskript / config",
"SoC-integrated GPU: tight coupling with other SoC components": "SoC-integrerad GPU: tät koppling med andra SoC-komponenter", "SoC-integrated GPU: tight coupling with other SoC components": "SoC-integrerad GPU: tät koppling med andra SoC-komponenter",
"Some DKMS removals reported errors; final verification will determine the result.": "Vissa DKMS-borttagningar rapporterade fel;slutlig verifiering kommer att avgöra resultatet.", "Some DKMS removals reported errors; final verification will determine the result.": "Vissa DKMS-borttagningar rapporterade fel;slutlig verifiering kommer att avgöra resultatet.",
"Some applications could not be checked ({count}). Try again later.": "Vissa program kunde inte kontrolleras ({count}). Försök igen senare.",
"Some changes require a reboot to take effect. Do you want to restart now?": "Vissa ändringar kräver en omstart för att träda i kraft. Vill du starta om nu?", "Some changes require a reboot to take effect. Do you want to restart now?": "Vissa ändringar kräver en omstart för att träda i kraft. Vill du starta om nu?",
"Some essential Proxmox packages may not have been installed": "Vissa viktiga Proxmox-paket kanske inte har installerats", "Some essential Proxmox packages may not have been installed": "Vissa viktiga Proxmox-paket kanske inte har installerats",
"Some log2ram files may still exist. Manual cleanup may be required.": "Vissa log2ram-filer kan fortfarande existera. Manuell rengöring kan krävas.", "Some log2ram files may still exist. Manual cleanup may be required.": "Vissa log2ram-filer kan fortfarande existera. Manuell rengöring kan krävas.",
@@ -6794,6 +6795,7 @@
"The tmpfs mounts of the container differ from the saved record": "Tmpfs montage av behållaren skiljer sig från den sparade rekordet", "The tmpfs mounts of the container differ from the saved record": "Tmpfs montage av behållaren skiljer sig från den sparade rekordet",
"The tmpfs path or size is outside the supported profile": "Tmpfs-banan eller storleken ligger utanför den stödda profilen", "The tmpfs path or size is outside the supported profile": "Tmpfs-banan eller storleken ligger utanför den stödda profilen",
"The translated recipe changed during the preparation": "Det översatta receptet ändrades under förberedelsen", "The translated recipe changed during the preparation": "Det översatta receptet ändrades under förberedelsen",
"The update check could not be completed. Try again later.": "Uppdateringskontrollen kunde inte slutföras. Försök igen senare.",
"The value contains an unsupported character": "Värdet innehåller en ostödd karaktär", "The value contains an unsupported character": "Värdet innehåller en ostödd karaktär",
"The value must be a number:": "Värdet måste vara ett tal:", "The value must be a number:": "Värdet måste vara ett tal:",
"The values do not match. Enter them again.": "Värdena matchar inte. Ange dem igen.", "The values do not match. Enter them again.": "Värdena matchar inte. Ange dem igen.",
+27 -8
View File
@@ -95,13 +95,17 @@ def snapshot(root=instances.ROOT, configs=Path('/etc/pve/lxc')):
return applications return applications
def cached(root=instances.ROOT, configs=Path('/etc/pve/lxc'), now=None, apps=APPS): def cached(root=instances.ROOT, configs=Path('/etc/pve/lxc'), now=None, apps=APPS, failed=()):
"""Read the Monitor's sidecars; obey its primary-container stack policy.""" """Read the Monitor's sidecars; obey its primary-container stack policy.
An application whose last check failed keeps no older result."""
inventory = snapshot(root, configs) inventory = snapshot(root, configs)
result = {'checked': False, 'available': [], 'unknown': 0, 'applications': len(inventory)} result = {'checked': False, 'available': [], 'unknown': 0, 'applications': len(inventory)}
instant = time.time() if now is None else now instant = time.time() if now is None else now
for app in inventory: for app in inventory:
try: try:
if app['vmid'] in failed:
result['checked'] = True
raise ValueError('Check failed')
if not app['complete'] or not all(member['ready'] for member in app['members']): if not app['complete'] or not all(member['ready'] for member in app['members']):
raise ValueError('Incomplete installation') raise ValueError('Incomplete installation')
main = next(member for member in app['members'] if member['vmid'] == app['vmid']) main = next(member for member in app['members'] if member['vmid'] == app['vmid'])
@@ -134,9 +138,15 @@ def cached(root=instances.ROOT, configs=Path('/etc/pve/lxc'), now=None, apps=APP
def monitor_available(path=SOCKET): def monitor_available(path=SOCKET):
try: try:
parent, endpoint = path.parent.lstat(), path.lstat() parent, endpoint = path.parent.lstat(), path.lstat()
return (stat.S_ISDIR(parent.st_mode) and parent.st_uid == 0 if not (stat.S_ISDIR(parent.st_mode) and parent.st_uid == 0
and not parent.st_mode & 0o077 and stat.S_ISSOCK(endpoint.st_mode) and not parent.st_mode & 0o077 and stat.S_ISSOCK(endpoint.st_mode)
and endpoint.st_uid == 0 and not endpoint.st_mode & 0o077) and endpoint.st_uid == 0 and not endpoint.st_mode & 0o077):
return False
# A Monitor that stopped without cleaning up leaves the socket behind.
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as probe:
probe.settimeout(0.5)
probe.connect(str(path))
return True
except OSError: except OSError:
return False return False
@@ -167,7 +177,10 @@ def check(root=instances.ROOT, configs=Path('/etc/pve/lxc'), progress=None, path
raise RuntimeError('Monitor check incomplete') raise RuntimeError('Monitor check incomplete')
event = json.loads(line) event = json.loads(line)
if event.get('done') is True and completed == len(rows): if event.get('done') is True and completed == len(rows):
return failed = event.get('failed', [])
if not isinstance(failed, list) or any(type(vmid) is not int for vmid in failed):
raise RuntimeError('Monitor check incomplete')
return failed
done, count = event.get('completed'), event.get('total') done, count = event.get('completed'), event.get('total')
if (type(done) is not int or type(count) is not int if (type(done) is not int or type(count) is not int
or count != len(rows) or not completed <= done <= count or done < 0): or count != len(rows) or not completed <= done <= count or done < 0):
@@ -177,14 +190,18 @@ def check(root=instances.ROOT, configs=Path('/etc/pve/lxc'), progress=None, path
progress(skipped + completed, total) progress(skipped + completed, total)
def emit_progress(completed, total): def emit(event):
message = (json.dumps({'completed': completed, 'total': total}) + '\n').encode() message = (json.dumps(event) + '\n').encode()
try: try:
os.write(sys.stdout.fileno(), message) os.write(sys.stdout.fileno(), message)
except (BlockingIOError, BrokenPipeError): except (BlockingIOError, BrokenPipeError):
pass pass
def emit_progress(completed, total):
emit({'completed': completed, 'total': total})
def main(): def main():
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--root', type=Path, default=instances.ROOT) parser.add_argument('--root', type=Path, default=instances.ROOT)
@@ -194,7 +211,9 @@ def main():
try: try:
if args.progress: if args.progress:
os.set_blocking(sys.stdout.fileno(), False) os.set_blocking(sys.stdout.fileno(), False)
check(args.root, args.configs, progress=emit_progress if args.progress else None) failed = check(args.root, args.configs, progress=emit_progress if args.progress else None)
if args.progress and failed:
emit({'failed': failed})
return 0 return 0
except (OSError, ValueError, KeyError, TypeError, RuntimeError): except (OSError, ValueError, KeyError, TypeError, RuntimeError):
return 1 return 1
+14 -2
View File
@@ -20,6 +20,7 @@ class UpdateCheck:
self.backend = importlib.import_module('oci_update_check') self.backend = importlib.import_module('oci_update_check')
self.process = None self.process = None
self.failed = False self.failed = False
self.failed_applications = set()
self.completed = self.total = 0 self.completed = self.total = 0
self._progress_buffer = b'' self._progress_buffer = b''
self._shown_progress = (0, 0) self._shown_progress = (0, 0)
@@ -52,6 +53,9 @@ class UpdateCheck:
line, self._progress_buffer = self._progress_buffer.split(b'\n', 1) line, self._progress_buffer = self._progress_buffer.split(b'\n', 1)
try: try:
data = json.loads(line) data = json.loads(line)
if isinstance(data.get('failed'), list):
self.failed_applications = {vmid for vmid in data['failed'] if type(vmid) is int}
continue
completed, total = data['completed'], data['total'] completed, total = data['completed'], data['total']
if type(completed) is int and type(total) is int and 0 <= completed <= total: if type(completed) is int and type(total) is int and 0 <= completed <= total:
self.completed, self.total = completed, total self.completed, self.total = completed, total
@@ -77,7 +81,7 @@ class UpdateCheck:
self._close_output() self._close_output()
self.process = None self.process = None
try: try:
state = self.backend.cached() state = self.backend.cached(failed=self.failed_applications)
except (OSError, ValueError, KeyError, TypeError): except (OSError, ValueError, KeyError, TypeError):
state = {'checked': False, 'available': [], 'unknown': 0, 'applications': 0} state = {'checked': False, 'available': [], 'unknown': 0, 'applications': 0}
if self.failed: if self.failed:
@@ -93,6 +97,7 @@ class UpdateCheck:
raise ValueError(translate('The ProxMenux Monitor update checker is not available. Start or update ProxMenux Monitor and try again.')) raise ValueError(translate('The ProxMenux Monitor update checker is not available. Start or update ProxMenux Monitor and try again.'))
state = self.status() state = self.status()
self.failed = False self.failed = False
self.failed_applications = set()
self.completed, self.total = 0, state['applications'] self.completed, self.total = 0, state['applications']
self._progress_buffer = b'' self._progress_buffer = b''
self.process = subprocess.Popen( self.process = subprocess.Popen(
@@ -142,7 +147,14 @@ class UpdateCheck:
return f"{translate('Updates available')} ({count})" if count else translate('Check for updates') return f"{translate('Updates available')} ({count})" if count else translate('Check for updates')
def notice(self, state): def notice(self, state):
return '' # Keep the menu heading clear after a check. if self.active:
return ''
if self.failed:
return translate('The update check could not be completed. Try again later.')
if self.failed_applications:
return translate('Some applications could not be checked ({count}). Try again later.').format(
count=len(self.failed_applications))
return ''
def select_updates(project, ui, available): def select_updates(project, ui, available):
+30 -5
View File
@@ -177,7 +177,8 @@ class UpdateCheckTests(unittest.TestCase):
def test_readonly_monitor_availability_rejects_non_private_endpoints(self): def test_readonly_monitor_availability_rejects_non_private_endpoints(self):
path = Mock(); path.parent.lstat.return_value = SimpleNamespace(st_mode=stat.S_IFDIR | 0o700, st_uid=0) path = Mock(); path.parent.lstat.return_value = SimpleNamespace(st_mode=stat.S_IFDIR | 0o700, st_uid=0)
path.lstat.return_value = SimpleNamespace(st_mode=stat.S_IFSOCK | 0o600, st_uid=0) path.lstat.return_value = SimpleNamespace(st_mode=stat.S_IFSOCK | 0o600, st_uid=0)
self.assertTrue(check.monitor_available(path)) with patch.object(check.socket, 'socket'):
self.assertTrue(check.monitor_available(path))
path.lstat.return_value.st_mode |= 0o066 path.lstat.return_value.st_mode |= 0o066
self.assertFalse(check.monitor_available(path)) self.assertFalse(check.monitor_available(path))
path.lstat.return_value.st_mode = stat.S_IFREG | 0o600 path.lstat.return_value.st_mode = stat.S_IFREG | 0o600
@@ -186,6 +187,30 @@ class UpdateCheckTests(unittest.TestCase):
path.parent.lstat.return_value.st_uid = 1000 path.parent.lstat.return_value.st_uid = 1000
self.assertFalse(check.monitor_available(path)) self.assertFalse(check.monitor_available(path))
def test_socket_left_by_a_stopped_monitor_is_not_available(self):
folder = Path(tempfile.mkdtemp(dir='/tmp')); self.addCleanup(lambda: __import__('shutil').rmtree(folder))
folder.chmod(0o700); path = folder / 'check.sock'
listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM); listener.bind(str(path)); path.chmod(0o600)
listener.listen(1)
owner = SimpleNamespace(st_uid=0)
real_lstat = Path.lstat
def lstat(self):
value = real_lstat(self)
return SimpleNamespace(st_mode=value.st_mode, st_uid=owner.st_uid)
with patch.object(Path, 'lstat', lstat):
self.assertTrue(check.monitor_available(path))
listener.close() # The socket file stays, nothing listens.
self.assertTrue(path.exists())
self.assertFalse(check.monitor_available(path))
def test_failed_application_keeps_no_older_result(self):
self.record(); self.sidecar(update_available=True)
self.assertEqual([r['vmid'] for r in self.status()['available']], [110])
state = self.status(failed={110})
self.assertEqual(state['available'], [])
self.assertEqual(state['unknown'], 1)
self.assertTrue(state['checked'])
def test_full_or_closed_progress_pipe_does_not_abort_client(self): def test_full_or_closed_progress_pipe_does_not_abort_client(self):
for error in (BlockingIOError(), BrokenPipeError()): for error in (BlockingIOError(), BrokenPipeError()):
with patch.object(check.os, 'write', side_effect=error), patch.object(check.sys, 'stdout'): with patch.object(check.os, 'write', side_effect=error), patch.object(check.sys, 'stdout'):
@@ -205,7 +230,7 @@ class BridgeTests(unittest.TestCase):
module = self.module(); events = [] module = self.module(); events = []
bridge.check_applications(module, [{'vmid': 110, 'installation_id': 'id-110'}], events.append) bridge.check_applications(module, [{'vmid': 110, 'installation_id': 'id-110'}], events.append)
module.check_app.assert_called_once_with(110, 'native-app', force=True, notify=False) module.check_app.assert_called_once_with(110, 'native-app', force=True, notify=False)
self.assertEqual(events, [{'completed': 0, 'total': 1}, {'completed': 1, 'total': 1}, {'done': True}]) self.assertEqual(events, [{'completed': 0, 'total': 1}, {'completed': 1, 'total': 1}, {'done': True, 'failed': []}])
def test_replaced_guest_pending_operation_and_secondary_are_not_queried(self): def test_replaced_guest_pending_operation_and_secondary_are_not_queried(self):
for changes, secondary in (({'installation_id': 'replacement'}, False), for changes, secondary in (({'installation_id': 'replacement'}, False),
@@ -236,10 +261,10 @@ class BridgeTests(unittest.TestCase):
bridge.check_applications(module, [{'vmid': 110, 'installation_id': 'id-110'}, bridge.check_applications(module, [{'vmid': 110, 'installation_id': 'id-110'},
{'vmid': 111, 'installation_id': 'id-111'}], report) {'vmid': 111, 'installation_id': 'id-111'}], report)
self.assertEqual(events, [{'completed': 0, 'total': 2}, {'completed': 1, 'total': 2}, self.assertEqual(events, [{'completed': 0, 'total': 2}, {'completed': 1, 'total': 2},
{'completed': 2, 'total': 2}, {'done': True}]) {'completed': 2, 'total': 2}, {'done': True, 'failed': []}])
self.assertEqual(module.check_app.call_count, 2) self.assertEqual(module.check_app.call_count, 2)
def test_unexpected_failure_does_not_skip_other_apps_or_report_success(self): def test_unexpected_failure_does_not_skip_other_apps_and_names_the_failed_app(self):
module = self.module(); events = [] module = self.module(); events = []
module._read_oci_record.side_effect = lambda vmid: { module._read_oci_record.side_effect = lambda vmid: {
'vmid': vmid, 'installation_id': f'id-{vmid}', 'status': 'installed'} 'vmid': vmid, 'installation_id': f'id-{vmid}', 'status': 'installed'}
@@ -248,7 +273,7 @@ class BridgeTests(unittest.TestCase):
{'vmid': 111, 'installation_id': 'id-111'}], events.append) {'vmid': 111, 'installation_id': 'id-111'}], events.append)
self.assertEqual(module.check_app.call_count, 2) self.assertEqual(module.check_app.call_count, 2)
self.assertEqual(events[-2], {'completed': 2, 'total': 2}) self.assertEqual(events[-2], {'completed': 2, 'total': 2})
self.assertEqual(events[-1], {'error': 'check_failed'}) self.assertEqual(events[-1], {'done': True, 'failed': [110]})
def test_requests_cannot_specify_commands_images_paths_or_duplicate_guests(self): def test_requests_cannot_specify_commands_images_paths_or_duplicate_guests(self):
valid = {'vmid': 110, 'installation_id': 'id'} valid = {'vmid': 110, 'installation_id': 'id'}
+24 -1
View File
@@ -134,7 +134,7 @@ class StatusTests(unittest.TestCase):
state = checker.status() state = checker.status()
self.assertEqual(checker.label(state), 'Check for updates') self.assertEqual(checker.label(state), 'Check for updates')
self.assertEqual(state['available'], []) self.assertEqual(state['available'], [])
self.assertEqual(checker.notice(state), '') self.assertEqual(checker.notice(state), 'The update check could not be completed. Try again later.')
def test_finished_worker_keeps_the_final_redraw_pending_until_status_is_consumed(self): def test_finished_worker_keeps_the_final_redraw_pending_until_status_is_consumed(self):
checker = self.checker(); checker.completed = checker.total = 2 checker = self.checker(); checker.completed = checker.total = 2
@@ -163,6 +163,29 @@ class StatusTests(unittest.TestCase):
self.assertEqual(args.kwargs['stdout'], subprocess.PIPE) self.assertEqual(args.kwargs['stdout'], subprocess.PIPE)
blocking.assert_called_once_with(checker.process.stdout.fileno(), False) blocking.assert_called_once_with(checker.process.stdout.fileno(), False)
def test_failed_applications_are_reported_and_cleared_by_a_new_check(self):
checker = self.checker(); read, write = os.pipe()
os.write(write, b'{"completed": 2, "total": 2}\n{"failed": [110]}\n'); os.close(write)
os.set_blocking(read, False)
checker.process = Mock(stdout=Mock(fileno=Mock(return_value=read)))
checker.process.poll.return_value = checker.process.wait.return_value = 0
state = {'checked': True, 'available': [], 'unknown': 1, 'applications': 2}
with patch.object(checker.backend, 'cached', return_value=state) as cached:
final = checker.status()
os.close(read)
self.assertEqual(cached.call_args.kwargs['failed'], {110})
self.assertEqual(checker.notice(final),
'Some applications could not be checked (1). Try again later.')
with patch.object(subprocess, 'Popen'), patch.object(updates.os, 'set_blocking'), \
patch.object(checker.backend, 'monitor_available', return_value=True), \
patch.object(checker.backend, 'cached', return_value=state):
checker.start()
self.assertEqual(checker.failed_applications, set())
def test_a_failed_check_is_reported_in_the_menu(self):
checker = self.checker(); checker.failed = True
self.assertEqual(checker.notice({}), 'The update check could not be completed. Try again later.')
def test_missing_monitor_never_starts_a_fallback_checker(self): def test_missing_monitor_never_starts_a_fallback_checker(self):
checker = self.checker() checker = self.checker()
with patch.object(checker.backend, 'monitor_available', return_value=False), \ with patch.object(checker.backend, 'monitor_available', return_value=False), \