diff --git a/AppImage/components/lxc-app-panel.tsx b/AppImage/components/lxc-app-panel.tsx index 7a4ce118..0670d697 100644 --- a/AppImage/components/lxc-app-panel.tsx +++ b/AppImage/components/lxc-app-panel.tsx @@ -607,6 +607,9 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }: if (lower.includes("github rate limited")) { return t("vmLxc.appEditor.upstreamErrorGithubRateLimit") } + if (lower.startsWith("registry unreachable")) { + return t("vmLxc.appEditor.upstreamErrorRegistry") + } return msg } diff --git a/AppImage/messages/de/common.json b/AppImage/messages/de/common.json index f539e33b..3fa2aa14 100644 --- a/AppImage/messages/de/common.json +++ b/AppImage/messages/de/common.json @@ -1674,6 +1674,7 @@ "upstreamErrorTimeout": "Netzwerk-Timeout beim Kontaktieren des Upstreams", "upstreamErrorNetwork": "Netzwerkfehler: {detail}", "upstreamErrorGithubRateLimit": "Das GitHub-Anfragelimit wurde erreicht. Konfigurieren Sie unter Einstellungen → GitHub API ein optionales Token oder versuchen Sie es später erneut.", + "upstreamErrorRegistry": "Die Registry des Images war nicht erreichbar. In einigen Minuten wird erneut geprüft.", "upstreamErrorGeneric": "Upstream-Prüfung fehlgeschlagen: {detail}", "notificationsEnabled": "Upstream-Update-Benachrichtigungen EIN – zum Stummschalten klicken", "notificationsMuted": "Upstream-Update-Benachrichtigungen stummgeschaltet – zum Aktivieren klicken", diff --git a/AppImage/messages/en/common.json b/AppImage/messages/en/common.json index db0d363c..c2b0c293 100644 --- a/AppImage/messages/en/common.json +++ b/AppImage/messages/en/common.json @@ -1592,6 +1592,7 @@ "upstreamErrorTimeout": "Network timeout while contacting upstream", "upstreamErrorNetwork": "Network error: {detail}", "upstreamErrorGithubRateLimit": "GitHub's request limit has been reached. Configure an optional token in Settings → GitHub API, or try again later.", + "upstreamErrorRegistry": "The image registry could not be reached. It is checked again in a few minutes.", "upstreamErrorGeneric": "Upstream check failed: {detail}", "portDescriptionPlaceholder": "Description (e.g. Web UI, go2rtc, admin)", "portPortPlaceholder": "port", diff --git a/AppImage/messages/es/common.json b/AppImage/messages/es/common.json index 6de1ae8b..acab8c74 100644 --- a/AppImage/messages/es/common.json +++ b/AppImage/messages/es/common.json @@ -1574,6 +1574,7 @@ "upstreamErrorTimeout": "Tiempo de espera agotado al contactar con el origen", "upstreamErrorNetwork": "Error de red: {detail}", "upstreamErrorGithubRateLimit": "Se ha alcanzado el límite de solicitudes de GitHub. Configure un token opcional en Ajustes → API de GitHub o vuelva a intentarlo más tarde.", + "upstreamErrorRegistry": "No se ha podido consultar el registro de la imagen. Se vuelve a comprobar en unos minutos.", "upstreamErrorGeneric": "Fallo al comprobar el origen: {detail}", "updateAvailableBadge": "Actualización disponible", "portDescriptionPlaceholder": "Descripción (por ejemplo, interfaz de usuario web, go2rtc, administrador)", diff --git a/AppImage/messages/fr/common.json b/AppImage/messages/fr/common.json index 583e5e68..7fb33a6a 100644 --- a/AppImage/messages/fr/common.json +++ b/AppImage/messages/fr/common.json @@ -1674,6 +1674,7 @@ "upstreamErrorTimeout": "expiration du délai d'attente du réseau lors du contact en amont", "upstreamErrorNetwork": "Erreur réseau : {detail}", "upstreamErrorGithubRateLimit": "La limite de requêtes GitHub a été atteinte. Configurez un jeton facultatif dans Paramètres → API GitHub ou réessayez plus tard.", + "upstreamErrorRegistry": "Le registre de l'image n'a pas pu être consulté. Une nouvelle vérification a lieu dans quelques minutes.", "upstreamErrorGeneric": "Échec de la vérification en amont : {detail}", "notificationsEnabled": "Notifications de mise à jour en amont activées – cliquez pour désactiver le son", "notificationsMuted": "Notifications de mise à jour en amont MUTED – cliquez pour activer", diff --git a/AppImage/messages/it/common.json b/AppImage/messages/it/common.json index becf3e43..131aff6d 100644 --- a/AppImage/messages/it/common.json +++ b/AppImage/messages/it/common.json @@ -1674,6 +1674,7 @@ "upstreamErrorTimeout": "timeout della rete durante il contatto a monte", "upstreamErrorNetwork": "errore di rete: {detail}", "upstreamErrorGithubRateLimit": "È stato raggiunto il limite di richieste GitHub. Configura un token facoltativo in Impostazioni → API GitHub oppure riprova più tardi.", + "upstreamErrorRegistry": "Non è stato possibile consultare il registro dell'immagine. Viene controllato di nuovo tra qualche minuto.", "upstreamErrorGeneric": "controllo upstream non riuscito: {detail}", "notificationsEnabled": "notifiche di aggiornamento upstream attivate: fai clic per disattivare l'audio", "notificationsMuted": "notifiche di aggiornamento upstream MUTED: fare clic per abilitare", diff --git a/AppImage/messages/pt/common.json b/AppImage/messages/pt/common.json index a29d1c01..c06473e4 100644 --- a/AppImage/messages/pt/common.json +++ b/AppImage/messages/pt/common.json @@ -1674,6 +1674,7 @@ "upstreamErrorTimeout": "Tempo limite da rede ao entrar em contato com o upstream", "upstreamErrorNetwork": "Erro de rede: {detail}", "upstreamErrorGithubRateLimit": "O limite de pedidos do GitHub foi atingido. Configure um token opcional em Definições → API do GitHub ou tente novamente mais tarde.", + "upstreamErrorRegistry": "Não foi possível consultar o registro da imagem. É verificado novamente dentro de alguns minutos.", "upstreamErrorGeneric": "falha na verificação upstream: {detail}", "notificationsEnabled": "Notificações de atualização upstream ATIVADAS – clique para silenciar", "notificationsMuted": "notificações de atualização upstream silenciadas – clique para ativar", diff --git a/AppImage/messages/sk/common.json b/AppImage/messages/sk/common.json index 1fa5939d..c995c33f 100644 --- a/AppImage/messages/sk/common.json +++ b/AppImage/messages/sk/common.json @@ -1698,6 +1698,7 @@ "upstreamErrorTimeout": "Časový limit siete pri kontaktovaní upstream", "upstreamErrorNetwork": "Chyba siete: {detail}", "upstreamErrorGithubRateLimit": "Bol dosiahnutý limit požiadaviek GitHubu. V časti Nastavenia → GitHub API nakonfigurujte voliteľný token alebo to skúste znova neskôr.", + "upstreamErrorRegistry": "Register obrazu sa nepodarilo kontaktovať. O niekoľko minút sa skontroluje znova.", "upstreamErrorGeneric": "Kontrola proti prúdu zlyhala: {detail}", "notificationsEnabled": "Upozornenia na upstream aktualizácie sú ZAPNUTÉ – kliknutím ich stlmíte", "notificationsMuted": "Upstream upozornenia na aktualizácie MUTED – kliknutím aktivujete", diff --git a/AppImage/messages/sv/common.json b/AppImage/messages/sv/common.json index d3a81e62..ce491c34 100644 --- a/AppImage/messages/sv/common.json +++ b/AppImage/messages/sv/common.json @@ -1674,6 +1674,7 @@ "upstreamErrorTimeout": "Nätverkstimeout vid kontakt uppströms", "upstreamErrorNetwork": "Nätverksfel: {detail}", "upstreamErrorGithubRateLimit": "GitHubs förfrågningsgräns har nåtts. Konfigurera en valfri token under Inställningar → GitHub API eller försök igen senare.", + "upstreamErrorRegistry": "Avbildens register kunde inte nås. Det kontrolleras igen om några minuter.", "upstreamErrorGeneric": "Uppströmskontroll misslyckades: {detail}", "notificationsEnabled": "Uppströmsuppdateringsmeddelanden PÅ – klicka för att stänga av ljudet", "notificationsMuted": "Uppströmsuppdateringsmeddelanden AVSTÄLLD – klicka för att aktivera", diff --git a/AppImage/scripts/lxc_apps.py b/AppImage/scripts/lxc_apps.py index 802aee3e..78d0ae6e 100644 --- a/AppImage/scripts/lxc_apps.py +++ b/AppImage/scripts/lxc_apps.py @@ -4348,6 +4348,7 @@ def check_app( "checked_at": _now_iso(), "installed_digest": result.get("installed_digest"), "installed_registry_digest": result.get("installed_registry_digest"), + "registry_retry": bool(result.get("registry_retry")), "latest_digest": result.get("latest_digest"), "image_created": result.get("image_created"), "latest_image_created": result.get("latest_image_created"), @@ -4356,6 +4357,8 @@ def check_app( } sidecar["updated_at"] = _now_iso() _write_sidecar(vmid, sidecar) + if app["state"]["registry_retry"]: + _retry_oci_registry(vmid, app_id) # The payload names an image by its build date and digest when it # states no version, so it decides whether there is anything to send. if notify and app["state"]["update_available"]: @@ -5765,6 +5768,34 @@ def _oci_resolve(module, reference: str, architecture: str) -> dict: return module.resolve_candidate(reference, architecture) +# What the panel shows, translated, when the registry of an image did not answer. +_OCI_REGISTRY_UNREACHABLE = "registry unreachable" +_OCI_REGISTRY_RETRY_WAITS = (120, 600, 1800) +_oci_registry_retries: set = set() + + +def _retry_oci_registry(vmid, app_id: str) -> None: + """Ask the registry again a few times after it did not answer, so a + passing failure does not stay on screen until the next daily check.""" + key = (int(vmid), app_id) + if key in _oci_registry_retries: + return + _oci_registry_retries.add(key) + + def wait_and_check(): + try: + for wait in _OCI_REGISTRY_RETRY_WAITS: + time.sleep(wait) + sidecar = check_app(vmid, app_id, force=True) + app = _find_app(sidecar, app_id) if sidecar else None + if not app or not (app.get("state") or {}).get("registry_retry"): + return + finally: + _oci_registry_retries.discard(key) + + threading.Thread(target=wait_and_check, name=f"oci-registry-{vmid}", daemon=True).start() + + def _oci_image_versions(vmid, known: Optional[dict] = None, with_latest: bool = True) -> dict: """Installed and published version of a container ProxMenux installed. @@ -5809,8 +5840,8 @@ def _oci_image_versions(vmid, known: Optional[dict] = None, with_latest: bool = result["installed_version"] = installed.get("version") result["image_created"] = installed.get("created") result["installed_registry_digest"] = installed.get("manifest_digest") - except Exception as exc: - return {**result, "error": f"could not read the installed image: {exc}"} + except Exception: + return {**result, "error": _OCI_REGISTRY_UNREACHABLE, "registry_retry": True} if not result.get("installed_version"): # The container runs the installed digest, so what it states is the # version of that image; once read it is kept with the digest. @@ -5819,8 +5850,15 @@ def _oci_image_versions(vmid, known: Optional[dict] = None, with_latest: bool = return result try: latest = _oci_resolve(module, reference, architecture) - except Exception as exc: - return {**result, "error": f"could not read {reference} from its registry: {exc}"} + except Exception: + # A registry that does not answer says nothing new about the image: + # what the last check found for this same image still stands. + if known.get("installed_digest") == installed_digest and known.get("latest_digest"): + result.update(latest_digest=known.get("latest_digest"), latest_version=known.get("latest_version"), + latest_image_created=known.get("latest_image_created"), + update_available=known.get("update_available"), registry_retry=True) + return result + return {**result, "error": _OCI_REGISTRY_UNREACHABLE, "registry_retry": True} latest_digest = latest.get("manifest_digest") # The image decides. An application whose version did not move can still # have a new image — a rebuild on a patched base — and that is an update diff --git a/AppImage/scripts/tests/test_oci_docker_manifest_versions.py b/AppImage/scripts/tests/test_oci_docker_manifest_versions.py index f53e1b72..1c0c50ad 100644 --- a/AppImage/scripts/tests/test_oci_docker_manifest_versions.py +++ b/AppImage/scripts/tests/test_oci_docker_manifest_versions.py @@ -68,8 +68,66 @@ class DockerManifestVersionTests(unittest.TestCase): self.assertEqual(registry.asked[0], "jellyfin/jellyfin@" + INDEX) def test_a_record_without_the_registry_digest_is_read_by_its_own(self): - result = self.versions(Registry(), registry_digest=None) - self.assertIn("could not read the installed image", result["error"]) + registry = Registry() + result = self.versions(registry, registry_digest=None) + self.assertEqual(registry.asked[0], "jellyfin/jellyfin@" + ARCHIVE) + self.assertEqual(result["error"], "registry unreachable") + + +class Silent(Registry): + """A registry that stops answering for the tag.""" + + def resolve_candidate(self, reference, architecture): + if "@" not in reference: + self.asked.append(reference) + raise RuntimeError("skopeo failed with exit code 1") + return super().resolve_candidate(reference, architecture) + + +class RegistryUnreachableTests(unittest.TestCase): + versions = DockerManifestVersionTests.versions + + KNOWN = {"installed_digest": ARCHIVE, "installed_registry_digest": PLATFORM, "installed_version": "12.1", + "image_created": "2026-09-15T01:13:55Z", "latest_digest": NEWER, "latest_version": "12.2", + "latest_image_created": "2026-09-20T00:00:00Z", "update_available": True} + + def test_the_last_answer_stands_when_the_registry_does_not_answer(self): + result = self.versions(Silent(), known=self.KNOWN) + self.assertNotIn("error", result) + self.assertEqual((result["latest_digest"], result["latest_version"], result["update_available"]), + (NEWER, "12.2", True)) + self.assertTrue(result["registry_retry"]) + + def test_without_a_previous_answer_the_panel_is_told_in_a_way_it_can_translate(self): + result = self.versions(Silent()) + self.assertEqual(result["error"], "registry unreachable") + self.assertTrue(result["registry_retry"]) + self.assertEqual((result["installed_version"], result["installed_digest"]), ("12.1", ARCHIVE)) + + def test_an_answer_for_another_image_is_not_reused(self): + other = dict(self.KNOWN, installed_digest="sha256:" + "e5" * 32) + self.assertEqual(self.versions(Silent(), known=other)["error"], "registry unreachable") + + def test_the_registry_is_asked_again_until_it_answers(self): + states = [{"registry_retry": True}, {"registry_retry": False}] + checks, waits = [], [] + + def check(vmid, app_id, force=False): + checks.append((vmid, app_id, force)) + return {"apps": [{"id": app_id, "state": states[len(checks) - 1]}]} + + started = [] + with patch.object(lxc_apps, "check_app", side_effect=check), \ + patch.object(lxc_apps.time, "sleep", side_effect=waits.append), \ + patch.object(lxc_apps.threading, "Thread", side_effect=lambda target, **_: started.append(target) or self): + self.start = lambda: None + lxc_apps._retry_oci_registry(105, "jellyfin") + lxc_apps._retry_oci_registry(105, "jellyfin") + self.assertEqual(len(started), 1) + started[0]() + self.assertEqual(checks, [(105, "jellyfin", True)] * 2) + self.assertEqual(waits, list(lxc_apps._OCI_REGISTRY_RETRY_WAITS[:2])) + self.assertNotIn((105, "jellyfin"), lxc_apps._oci_registry_retries) if __name__ == "__main__":