Merge remote-tracking branch 'origin/develop' into HEAD

# Conflicts:
#	.github/scripts/tests/test_command_descriptions.py
This commit is contained in:
MacRimi
2026-10-16 23:32:05 +02:00
76 changed files with 3714 additions and 309 deletions
@@ -0,0 +1,32 @@
"use client"
import { ExternalLink } from "lucide-react"
import { useT } from "../lib/i18n/provider"
// These are examples, never defaults: opening help must not replace a saved
// destination or put placeholder credentials into the notification settings.
export function AppriseServiceHelp() {
const t = useT()
return <details className="rounded-md border border-border bg-muted/20 p-2 text-xs">
<summary className="cursor-pointer font-medium">{t("settings.notifications.ui.appriseServiceHelp")}</summary>
<div className="grid gap-3 pt-3 sm:grid-cols-2">
<div className="space-y-1.5 min-w-0">
<h4 className="font-medium">Slack</h4>
<p className="text-muted-foreground">{t("settings.notifications.ui.appriseSlackSteps")}</p>
<code className="block break-all rounded bg-background p-2 text-[10px]">https://hooks.slack.com/services/TOKEN_A/TOKEN_B/TOKEN_C</code>
<a href="https://appriseit.com/services/slack/" target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-cyan-500 hover:underline">
{t("settings.notifications.ui.appriseSlackDocs")} <ExternalLink className="h-3 w-3" />
</a>
</div>
<div className="space-y-1.5 min-w-0">
<h4 className="font-medium">Matrix</h4>
<p className="text-muted-foreground">{t("settings.notifications.ui.appriseMatrixSteps")}</p>
<code className="block break-all rounded bg-background p-2 text-[10px]">matrixs://USER:PASSWORD@matrix.example.com/!ROOM_ID:matrix.example.com</code>
<a href="https://appriseit.com/services/matrix/" target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-cyan-500 hover:underline">
{t("settings.notifications.ui.appriseMatrixDocs")} <ExternalLink className="h-3 w-3" />
</a>
</div>
</div>
<p className="mt-3 text-muted-foreground">{t("settings.notifications.ui.appriseServiceFinish")}</p>
</details>
}
@@ -1,5 +1,6 @@
"use client"
import { AppriseServiceHelp } from "./apprise-service-help"
import { useState, useEffect, useCallback } from "react"
import { useTheme } from "next-themes"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
@@ -2061,6 +2062,7 @@ export function NotificationSettings() {
Enable toggle + Test button pattern as the other
channels. */}
<TabsContent value="apprise" className="space-y-3 pt-2">
<AppriseServiceHelp />
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Label className="text-xs font-medium">{t("settings.notifications.ui.enableApprise")}</Label>
+27 -3
View File
@@ -35,6 +35,23 @@ import DOMPurify from "dompurify"
import { marked } from "marked"
import { getCountFormKey, useI18n, useT } from "@/lib/i18n/provider"
// A schedule record predating the structured reason code still contains one
// of these exact diagnostic messages. Recognise only those known historical
// values so an already-recorded safe skip localizes after an upgrade, while
// unrelated freeform diagnostics remain verbatim for troubleshooting.
const LEGACY_STOPPED_SCHEDULE_REASONS = new Set([
"container is stopped; scheduled update was skipped",
"container was stopped before scheduled update could begin",
])
const scheduleReasonCodeFromPersisted = (reasonCode: unknown, reason: unknown): string | null => {
if (typeof reasonCode === "string" && reasonCode) return reasonCode
if (typeof reason === "string" && LEGACY_STOPPED_SCHEDULE_REASONS.has(reason.toLowerCase())) {
return "stopped_container"
}
return null
}
// Sent by /api/vms only for LXC rows, only when the user has enabled
// `lxc_updates_available` notifications. The Monitor populates this
// from managed_installs registry → frontend uses it to render the
@@ -2077,6 +2094,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
const [scheduleLastRunAt, setScheduleLastRunAt] = useState<string | null>(null)
const [scheduleLastRunStatus, setScheduleLastRunStatus] = useState<string | null>(null)
const [scheduleLastRunReason, setScheduleLastRunReason] = useState<string | null>(null)
const [scheduleLastRunReasonCode, setScheduleLastRunReasonCode] = useState<string | null>(null)
const [scheduleLastRunLog, setScheduleLastRunLog] = useState<string | null>(null)
const [scheduleLastRunRebootRequired, setScheduleLastRunRebootRequired] = useState(false)
const [scheduleLastRunRebootPackages, setScheduleLastRunRebootPackages] = useState<string[]>([])
@@ -2153,6 +2171,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
setScheduleLastRunAt(s.last_run_at || null)
setScheduleLastRunStatus(s.last_run_status || null)
setScheduleLastRunReason(s.last_run_reason || null)
setScheduleLastRunReasonCode(scheduleReasonCodeFromPersisted(s.last_run_reason_code, s.last_run_reason))
setScheduleLastRunLog(s.last_run_log || null)
setScheduleLastRunRebootRequired(s.last_run_reboot_required === true)
setScheduleLastRunRebootPackages(Array.isArray(s.last_run_reboot_packages)
@@ -2430,6 +2449,11 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
const renderScheduleRunDetails = (indented = false) => {
if (!scheduleLastRunAt || !selectedVM) return null
const spacing = indented ? "pl-4" : ""
const scheduleLastRunReasonText = scheduleLastRunReasonCode === "stopped_container"
? selectedVM.status === "stopped"
? t("vmLxc.scheduled.reasonStoppedContainer")
: t("vmLxc.scheduled.reasonStoppedContainerHistorical")
: scheduleLastRunReason
return (
<div className={`space-y-1.5 ${spacing}`}>
<div className="text-xs text-muted-foreground">
@@ -2443,14 +2467,14 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
: scheduleLastRunStatus === "deferred"
? t("vmLxc.scheduled.runDeferred")
: scheduleLastRunStatus === "skipped"
? t("vmLxc.scheduled.runSkipped")
? t("vmLxc.scheduled.runSkippedStatus")
: t("vmLxc.scheduled.runFailed")}
</span></>
)}
</div>
{scheduleLastRunReason && (
{scheduleLastRunReasonText && (
<div className="text-xs text-muted-foreground break-words">
{scheduleLastRunReason}
{scheduleLastRunReasonText}
</div>
)}
{scheduleLastRunRebootRequired && (
+11 -2
View File
@@ -1302,7 +1302,10 @@
"Donnerstag",
"Freitag",
"Samstag"
]
],
"runSkippedStatus": "Lauf übersprungen",
"reasonStoppedContainer": "Container ist gestoppt; das geplante Update wurde übersprungen.",
"reasonStoppedContainerHistorical": "Der Container wurde zum geplanten Aktualisierungszeitpunkt gestoppt."
},
"cronChip": {
"detected": "Host-Cron erkannt",
@@ -2108,7 +2111,13 @@
"inactiveFooter": "Aktivieren Sie Benachrichtigungen, um System-, Gesundheits- und Sicherheitswarnungen über die konfigurierten Kanäle zu erhalten.",
"notificationLanguage": "Benachrichtigungssprache",
"selectNotificationLanguage": "Benachrichtigungssprache auswählen",
"notificationLanguageHint": "Wird für deterministische Laufzeitbenachrichtigungen verwendet, auch wenn KI deaktiviert ist."
"notificationLanguageHint": "Wird für deterministische Laufzeitbenachrichtigungen verwendet, auch wenn KI deaktiviert ist.",
"appriseServiceHelp": "Slack und Matrix einrichten",
"appriseSlackSteps": "Erstellen Sie in Ihrer Slack-App einen eingehenden Webhook für den Zielkanal und fügen Sie dessen vollständige HTTPS-URL in das Feld Apprise-URL ein. Eine Umwandlung ist nicht erforderlich.",
"appriseSlackDocs": "Slack-URL-Formate und Einrichtung",
"appriseMatrixSteps": "Verwenden Sie ein Benachrichtigungskonto, das in den Raum schreiben darf. Ersetzen Sie den Beispielbenutzer, das Passwort, den Homeserver und die vollständige Raum-ID; kodieren Sie Sonderzeichen in Zugangsdaten für URLs. Das Schema matrixs verwendet HTTPS. Informationen zur Unterstützung von Tokens, SSO und verschlüsselten Räumen in Ihrer installierten Apprise-Version finden Sie in der Dokumentation.",
"appriseMatrixDocs": "Matrix-URL-Formate und Einrichtung",
"appriseServiceFinish": "Aktivieren Sie Apprise, geben Sie Ihre URL ein, wählen Sie Benachrichtigungskategorien oder einzelne Ereignisse aus, speichern Sie und verwenden Sie anschließend Test senden. Diese Beispiele ändern Ihre gespeicherte URL nicht. Dieser Apprise-Kanal hat ein Ziel; verwenden Sie die Apprise-API für mehrere Dienste gleichzeitig."
},
"ai": {
"title": "KI-Verbesserung",
+9
View File
@@ -1288,6 +1288,9 @@
"runFailed": "✗ failed",
"runDeferred": "held for safety",
"runSkipped": "nothing pending",
"runSkippedStatus": "run skipped",
"reasonStoppedContainer": "Container is stopped; scheduled update was skipped.",
"reasonStoppedContainerHistorical": "Container was stopped at the scheduled update time.",
"releaseDelaySummary": "{days}-day release hold",
"releaseDelayLabel": "Wait after a new app release",
"releaseDelayNone": "No waiting period",
@@ -2094,6 +2097,12 @@
"pushoverCredentialsHint": "Create an application in Pushover and copy its API token and your user or group key.",
"enableApprise": "Enable Apprise",
"urlFormats": "+URL formats",
"appriseServiceHelp": "Slack and Matrix setup",
"appriseSlackSteps": "Create an Incoming Webhook in your Slack app for the target channel, then paste its complete HTTPS URL into Apprise URL. No conversion is needed.",
"appriseSlackDocs": "Slack URL formats and setup",
"appriseMatrixSteps": "Use a notification account that can post to the room. Replace the example user, password, homeserver and full room ID; URL-encode special characters in credentials. The matrixs scheme uses HTTPS. See the documentation for token, SSO and encrypted-room support in your installed Apprise version.",
"appriseMatrixDocs": "Matrix URL formats and setup",
"appriseServiceFinish": "Enable Apprise, enter your URL, choose the notification categories or individual events, save, then use Send Test. These examples do not change your saved URL. This Apprise channel has one destination; use Apprise API for several services at once.",
"appriseUrl": "Apprise URL",
"showUrl": "Show URL",
"hideUrl": "Hide URL",
+14 -5
View File
@@ -607,7 +607,7 @@
"controllerBusyTime": "Minutos totales que el controlador pasó procesando comandos de E/S. Los valores altos indican una carga de trabajo pesada y sostenida.",
"errorLogEntries": "Número de entradas en el registro de información de errores. A menudo incluye artefactos benignos de autoevaluación; verifique con Errores de Medios.",
"warningTempTime": "Total de minutos transcurridos por encima del umbral de temperatura de advertencia. Provoca una limitación del rendimiento. El cero es ideal.",
"criticalTempTime": "Minutos totales pasados ​​por encima del umbral de temperatura crítica. La unidad puede apagarse para evitar daños. Siempre debe ser cero.",
"criticalTempTime": "Minutos totales pasados por encima del umbral de temperatura crítica. La unidad puede apagarse para evitar daños. Siempre debe ser cero.",
"warningCompositeTemperatureTime": "Minutos totales que la temperatura compuesta superó el umbral de advertencia.",
"criticalCompositeTemperatureTime": "Minutos totales la temperatura compuesta superó el umbral crítico. Debe ser cero.",
"thermalManagementT1TransCount": "Número de veces que la unidad entró en aceleración térmica ligera (T1). Indica problemas de enfriamiento.",
@@ -1302,7 +1302,10 @@
"jueves",
"viernes",
"sábado"
]
],
"runSkippedStatus": "ejecución omitida",
"reasonStoppedContainer": "El contenedor está parado; se ha omitido la actualización programada.",
"reasonStoppedContainerHistorical": "El contenedor estaba parado a la hora de la actualización programada."
},
"cronChip": {
"detected": "cron del host detectado",
@@ -2108,7 +2111,13 @@
"inactiveFooter": "Habilite notificaciones para recibir alertas del sistema, de salud y de seguridad a través de los canales configurados.",
"notificationLanguage": "Idioma de notificación",
"selectNotificationLanguage": "Seleccione el idioma de notificación",
"notificationLanguageHint": "Se utiliza para notificaciones deterministas en tiempo de ejecución incluso cuando la IA está deshabilitada."
"notificationLanguageHint": "Se utiliza para notificaciones deterministas en tiempo de ejecución incluso cuando la IA está deshabilitada.",
"appriseServiceHelp": "Configuración de Slack y Matrix",
"appriseSlackSteps": "Crea un webhook entrante en tu aplicación de Slack para el canal de destino y pega su URL HTTPS completa en URL de Apprise. No hace falta convertirla.",
"appriseSlackDocs": "Formatos de URL y configuración de Slack",
"appriseMatrixSteps": "Utiliza una cuenta de notificaciones que pueda publicar en la sala. Sustituye el usuario, la contraseña, el servidor de origen y el ID completo de la sala del ejemplo; codifica para URL los caracteres especiales de las credenciales. El esquema matrixs utiliza HTTPS. Consulta en la documentación la compatibilidad con tokens, SSO y salas cifradas de tu versión de Apprise.",
"appriseMatrixDocs": "Formatos de URL y configuración de Matrix",
"appriseServiceFinish": "Activa Apprise, introduce tu URL, elige las categorías de notificación o los eventos individuales, guarda y utiliza Enviar prueba. Estos ejemplos no cambian tu URL guardada. Este canal de Apprise tiene un único destino; utiliza la API de Apprise para varios servicios a la vez."
},
"ai": {
"title": "mejora de la IA",
@@ -3563,8 +3572,8 @@
"short": {
"hour": "Pasada 1 hora",
"day": "Pasadas 24h",
"week": "Pasados ​​los 7 días",
"month": "Pasados ​​los 30 días",
"week": "Pasados los 7 días",
"month": "Pasados los 30 días",
"year": "Último 1 año"
},
"last": {
+11 -2
View File
@@ -1302,7 +1302,10 @@
"Jeudi",
"Vendredi",
"Samedi"
]
],
"runSkippedStatus": "exécution ignorée",
"reasonStoppedContainer": "Le conteneur est arrêté ; la mise à jour programmée a été ignorée.",
"reasonStoppedContainerHistorical": "Le conteneur était arrêté à l'heure de la mise à jour programmée."
},
"cronChip": {
"detected": "cron hôte détecté",
@@ -2108,7 +2111,13 @@
"inactiveFooter": "Activez les notifications pour recevoir des alertes système, de santé et de sécurité via les canaux configurés.",
"notificationLanguage": "Langue de notification",
"selectNotificationLanguage": "Sélectionnez la langue de notification",
"notificationLanguageHint": "Utilisé pour les notifications d'exécution déterministes, même lorsque l'IA est désactivée."
"notificationLanguageHint": "Utilisé pour les notifications d'exécution déterministes, même lorsque l'IA est désactivée.",
"appriseServiceHelp": "Configuration de Slack et Matrix",
"appriseSlackSteps": "Créez un webhook entrant dans votre application Slack pour le canal cible, puis collez son URL HTTPS complète dans le champ URL Apprise. Aucune conversion n'est nécessaire.",
"appriseSlackDocs": "Formats d'URL et configuration de Slack",
"appriseMatrixSteps": "Utilisez un compte de notification autorisé à publier dans le salon. Remplacez l'utilisateur, le mot de passe, le serveur d'origine et l'identifiant complet du salon de l'exemple ; encodez les caractères spéciaux des identifiants pour les URL. Le schéma matrixs utilise HTTPS. Consultez la documentation pour la prise en charge des jetons, du SSO et des salons chiffrés dans votre version d'Apprise.",
"appriseMatrixDocs": "Formats d'URL et configuration de Matrix",
"appriseServiceFinish": "Activez Apprise, saisissez votre URL, choisissez les catégories de notification ou les événements individuels, enregistrez, puis utilisez Envoyer un test. Ces exemples ne modifient pas votre URL enregistrée. Ce canal Apprise possède une destination ; utilisez l'API Apprise pour plusieurs services à la fois."
},
"ai": {
"title": "Amélioration de l'IA",
+11 -2
View File
@@ -1302,7 +1302,10 @@
"Giovedì",
"Venerdì",
"Sabato"
]
],
"runSkippedStatus": "esecuzione saltata",
"reasonStoppedContainer": "Il contenitore è fermo; l'aggiornamento pianificato è stato saltato.",
"reasonStoppedContainerHistorical": "Il contenitore era fermo all'ora dell'aggiornamento pianificato."
},
"cronChip": {
"detected": "cron host rilevato",
@@ -2108,7 +2111,13 @@
"inactiveFooter": "Abilita le notifiche per ricevere avvisi di sistema, integrità e sicurezza attraverso i canali configurati.",
"notificationLanguage": "Lingua notifiche",
"selectNotificationLanguage": "Seleziona lingua notifiche",
"notificationLanguageHint": "Usata per le notifiche runtime deterministiche anche quando l’IA è disabilitata."
"notificationLanguageHint": "Usata per le notifiche runtime deterministiche anche quando l’IA è disabilitata.",
"appriseServiceHelp": "Configurazione di Slack e Matrix",
"appriseSlackSteps": "Crea un webhook in ingresso nell'app Slack per il canale di destinazione, quindi incolla il suo URL HTTPS completo nel campo URL di Apprise. Non è necessaria alcuna conversione.",
"appriseSlackDocs": "Formati URL e configurazione di Slack",
"appriseMatrixSteps": "Usa un account di notifica autorizzato a pubblicare nella stanza. Sostituisci utente, password, homeserver e ID completo della stanza dell'esempio; codifica per URL i caratteri speciali nelle credenziali. Lo schema matrixs usa HTTPS. Consulta la documentazione per il supporto di token, SSO e stanze cifrate nella versione di Apprise installata.",
"appriseMatrixDocs": "Formati URL e configurazione di Matrix",
"appriseServiceFinish": "Abilita Apprise, inserisci il tuo URL, scegli le categorie di notifica o i singoli eventi, salva e poi usa Invia test. Questi esempi non modificano l'URL salvato. Questo canale Apprise ha una sola destinazione; usa l'API Apprise per più servizi contemporaneamente."
},
"ai": {
"title": "Miglioramento con IA",
+23 -14
View File
@@ -353,7 +353,7 @@
"downloadJson": "Baixar JSON",
"delete": "Excluir",
"confirmDelete": "Excluir este registro de teste?",
"note": "Os resultados dos testes são armazenados localmente e usados ​​para gerar relatórios SMART detalhados."
"note": "Os resultados dos testes são armazenados localmente e usados para gerar relatórios SMART detalhados."
},
"smartReport": {
"title": "Relatório de saúde SMART",
@@ -595,8 +595,8 @@
"availableSpare": "Porcentagem de blocos NAND sobressalentes restantes para substituição de blocos defeituosos. Um alerta é acionado abaixo do limite.",
"availableSpareThreshold": "Mínimo definido pelo fabricante para peças sobressalentes disponíveis. Abaixo disso, a unidade sinaliza um aviso crítico.",
"percentageUsed": "Impulsione a estimativa da resistência consumida com base nos ciclos de gravação reais versus nominais. 100% significa que o TBW avaliado foi atingido; a unidade pode continuar funcionando além disso.",
"mediaErrors": "Erros de leitura/gravação irrecuperáveis ​​no flash NAND. Qualquer valor diferente de zero indica dano celular permanente. Contagem crescente significa substituição em breve.",
"mediaIntegrityErrors": "Erros irrecuperáveis ​​detectados pelo controlador. Diferente de zero significa risco de corrupção de dados.",
"mediaErrors": "Erros de leitura/gravação irrecuperáveis no flash NAND. Qualquer valor diferente de zero indica dano celular permanente. Contagem crescente significa substituição em breve.",
"mediaIntegrityErrors": "Erros irrecuperáveis detectados pelo controlador. Diferente de zero significa risco de corrupção de dados.",
"unsafeShutdowns": "Perdas de energia sem descarga ou desligamento adequado. Contagens muito altas correm o risco de corrupção de metadados e problemas de firmware.",
"nvmePowerCycles": "Ciclos totais de ligar/desligar. A ciclagem frequente aumenta o desgaste do conector e do capacitor.",
"nvmePowerOnHours": "Total de horas acumuladas em que o inversor esteve ligado desde a fabricação.",
@@ -624,7 +624,7 @@
"reallocatedSectorCount": "Setores defeituosos substituídos por setores sobressalentes do pool de reserva. Uma contagem crescente significa degradação da unidade.",
"retiredBlockCount": "Blocos NAND retirados devido a desgaste ou falha (SSD). Semelhante à contagem de setores realocados para HDDs.",
"reallocatedEventCount": "Número de operações de remapeamento realizadas. Cada evento significa que um setor defeituoso foi substituído.",
"currentPendingSector": "Setores instáveis ​​esperando para serem remapeados na próxima gravação. Eles podem ser resolvidos ou serem realocados permanentemente.",
"currentPendingSector": "Setores instáveis esperando para serem remapeados na próxima gravação. Eles podem ser resolvidos ou serem realocados permanentemente.",
"pendingSectors": "Setores esperando para serem remapeados. Eles podem ser resolvidos ou realocados.",
"offlineUncorrectable": "Setores que falharam durante a verificação offline e não puderam ser corrigidos. Indica possível perda de dados.",
"offlineUncorrectableSectorCount": "Setores incorrigíveis encontrados durante a verificação em segundo plano. Os dados sobre estes setores são perdidos.",
@@ -667,7 +667,7 @@
"endToEndError": "Corrupção de dados detectada entre o cache do controlador e a interface do host. Deve ser sempre zero.",
"endToEndErrorDetectionCount": "Número de erros de paridade no caminho de dados. Diferente de zero indica problemas no controlador.",
"wearLevelingCount": "Média de ciclos de apagamento por bloco NAND (SSD). VALOR mais baixo significa mais desgaste consumido.",
"wearRangeDelta": "Diferença entre blocos mais usados ​​e menos usados ​​(SSD). Valores altos indicam desgaste irregular.",
"wearRangeDelta": "Diferença entre blocos mais usados e menos usados (SSD). Valores altos indicam desgaste irregular.",
"mediaWearoutIndicator": "Estimativa de vida útil restante do SSD Intel. Começa em 100 e diminui para 0 conforme a resistência é consumida.",
"ssdLifeLeft": "Porcentagem estimada de vida útil restante do SSD com base no desgaste da NAND.",
"percentLifetimeRemain": "Porcentagem estimada de vida útil restante. 100 significa novo; 0 significa fim da vida útil nominal.",
@@ -744,7 +744,7 @@
"nvmeWarningTitle": "Aviso crítico NVMe ativo (0x{value})",
"nvmeWarningText": "O controlador NVMe gerou um alerta. Faça backup dos dados imediatamente e investigue mais a fundo.",
"nvmeMediaTitle": "Erros de mídia NVMe detectados ({count})",
"nvmeMediaText": "Erros irrecuperáveis ​​de flash NAND indicam danos físicos. Faça backup dos dados e planeje a substituição.",
"nvmeMediaText": "Erros irrecuperáveis de flash NAND indicam danos físicos. Faça backup dos dados e planeje a substituição.",
"unsafeTitle": "Contagem alta de desligamentos inseguros ({count})",
"unsafeText": "Perdas frequentes de energia aumentam o risco de corrupção do firmware. Garanta energia estável ou use um UPS.",
"seagateTitle": "Seagate Raw_Read_Error_Rate - Comportamento normal",
@@ -1302,7 +1302,10 @@
"Quinta-feira",
"Sexta-feira",
"Sábado"
]
],
"runSkippedStatus": "execução ignorada",
"reasonStoppedContainer": "O contêiner está parado; a atualização agendada foi ignorada.",
"reasonStoppedContainerHistorical": "O contêiner estava parado no horário da atualização agendada."
},
"cronChip": {
"detected": "cron do host detectado",
@@ -1768,7 +1771,7 @@
},
"healthMonitor": {
"title": "Monitor de Saúde",
"description": "Configure por quanto tempo os alertas dispensados ​​permanecem ocultos para cada categoria. As alterações se aplicam a alertas ignorados existentes e futuros.",
"description": "Configure por quanto tempo os alertas dispensados permanecem ocultos para cada categoria. As alterações se aplicam a alertas ignorados existentes e futuros.",
"category": "Categoria",
"suppressionDuration": "Duração da Supressão",
"hours": "Horas",
@@ -1778,7 +1781,7 @@
"footerNote": "Essas configurações se aplicam quando você descarta um aviso do Health Monitor. Alertas críticos de temperatura da CPU sempre aparecem para proteger seu hardware.",
"activeSuppressions": "Supressões Ativas",
"activeSuppressionsDescription": "Alertas que você ocultou do Health Monitor. As demissões permanentes só podem ser revertidas aqui. A edição requer o Health Monitor",
"noActiveSuppressions": "Sem supressões ativas. Os alertas dispensados ​​do Health Monitor aparecerão aqui.",
"noActiveSuppressions": "Sem supressões ativas. Os alertas dispensados do Health Monitor aparecerão aqui.",
"permanent": "Permanente",
"daysRemaining": "{count}d restantes",
"hoursRemaining": "{count}h restantes",
@@ -2108,7 +2111,13 @@
"inactiveFooter": "Habilite notificações para receber alertas de sistema, integridade e segurança por meio dos canais configurados.",
"notificationLanguage": "Idioma de notificação",
"selectNotificationLanguage": "Selecione o idioma de notificação",
"notificationLanguageHint": "Usado para notificações determinísticas de tempo de execução, mesmo quando a IA está desabilitada."
"notificationLanguageHint": "Usado para notificações determinísticas de tempo de execução, mesmo quando a IA está desabilitada.",
"appriseServiceHelp": "Configuração do Slack e do Matrix",
"appriseSlackSteps": "Crie um webhook de entrada na sua aplicação Slack para o canal de destino e cole o seu URL HTTPS completo no campo URL do Apprise. Não é necessária qualquer conversão.",
"appriseSlackDocs": "Formatos de URL e configuração do Slack",
"appriseMatrixSteps": "Utilize uma conta de notificações que possa publicar na sala. Substitua o utilizador, a palavra-passe, o homeserver e o ID completo da sala do exemplo; codifique os caracteres especiais das credenciais para URL. O esquema matrixs usa HTTPS. Consulte a documentação sobre o suporte de tokens, SSO e salas cifradas na versão do Apprise instalada.",
"appriseMatrixDocs": "Formatos de URL e configuração do Matrix",
"appriseServiceFinish": "Ative o Apprise, introduza o seu URL, escolha as categorias de notificação ou os eventos individuais, guarde e utilize Enviar teste. Estes exemplos não alteram o URL guardado. Este canal do Apprise tem um destino; utilize a API do Apprise para vários serviços em simultâneo."
},
"ai": {
"title": "Aprimoramento de IA",
@@ -3564,7 +3573,7 @@
"hour": "Passada 1h",
"day": "Passadas 24 horas",
"week": "Passado 7 dias",
"month": "Passados ​​30 dias",
"month": "Passados 30 dias",
"year": "Último 1 ano"
},
"last": {
@@ -4016,7 +4025,7 @@
},
"backends": {
"borgDescription": "Repositório Borg para backups de host desduplicados.",
"borgDescriptionTimerOnly": "Os destinos Borg são usados ​​pelos temporizadores do Monitor, não pelos trabalhos PVE vzdump.",
"borgDescriptionTimerOnly": "Os destinos Borg são usados pelos temporizadores do Monitor, não pelos trabalhos PVE vzdump.",
"borgShortDescription": "Repositório Borg desduplicado",
"local": "Local",
"localDescription": "Pasta neste host ou em uma unidade USB montada.",
@@ -4544,7 +4553,7 @@
"customPickDescriptionMiddle": "através do",
"destructiveTitle": "A restauração completa REMOVERÁ o seguinte (criado após o backup)",
"noPathsMatch": "Nenhum caminho corresponde ao filtro.",
"noRestorablePaths": "Este backup não contém caminhos restauráveis ​​— apenas a restauração completa é significativa aqui.",
"noRestorablePaths": "Este backup não contém caminhos restauráveis — apenas a restauração completa é significativa aqui.",
"olderThanTargetKernel": "é mais antigo que o kernel alvo",
"operatorConfigDescription": "As seguintes configurações criadas pelo operador serão mescladas no destino automaticamente, sem alterar os padrões vinculados ao kernel. É executado na restauração completa e personalizada.",
"operatorConfigTitle": "Configuração do operador reaplicada por meio de mesclagem independente de kernel",
@@ -4926,7 +4935,7 @@
},
"noIssues": "Nenhum problema detectado",
"updateNow": "Atualizar agora",
"dismissedItems": "Itens dispensados ​​({count})",
"dismissedItems": "Itens dispensados ({count})",
"permanent": "Permanente",
"dismissed": "Dispensado",
"wasStatus": "era {status}",
+10 -1
View File
@@ -1288,6 +1288,9 @@
"runFailed": "✗ zlyhalo",
"runDeferred": "odložené z bezpečnostných dôvodov",
"runSkipped": "nič nečaká",
"runSkippedStatus": "spustenie bolo preskočené",
"reasonStoppedContainer": "Kontajner je vypnutý; plánovaná aktualizácia sa preskočila.",
"reasonStoppedContainerHistorical": "Kontajner bol v čase plánovanej aktualizácie vypnutý.",
"releaseDelaySummary": "odklad {days} deň/dní",
"releaseDelayLabel": "Počkať po novej verzii aplikácie",
"releaseDelayNone": "Bez čakacej doby",
@@ -2107,7 +2110,13 @@
"inactiveFooter": "Zapnite notifikácie a dostávajte upozornenia o systéme, stave a bezpečnosti cez nastavené kanály.",
"notificationLanguage": "Jazyk notifikácií",
"selectNotificationLanguage": "Vyberte jazyk notifikácií",
"notificationLanguageHint": "Používa sa pre spoľahlivé runtime notifikácie aj vtedy, keď je AI vypnutá."
"notificationLanguageHint": "Používa sa pre spoľahlivé runtime notifikácie aj vtedy, keď je AI vypnutá.",
"appriseServiceHelp": "Nastavenie Slack a Matrix",
"appriseSlackSteps": "V aplikácii Slack vytvorte prichádzajúci webhook pre cieľový kanál a vložte jeho úplnú HTTPS URL do poľa URL Apprise. Nie je potrebný žiadny prevod.",
"appriseSlackDocs": "Formáty URL a nastavenie Slack",
"appriseMatrixSteps": "Použite účet pre oznámenia, ktorý môže odosielať správy do miestnosti. Nahraďte vzorového používateľa, heslo, domovský server a úplné ID miestnosti; špeciálne znaky v prihlasovacích údajoch zakódujte pre URL. Schéma matrixs používa HTTPS. Podporu tokenov, SSO a šifrovaných miestností v nainštalovanej verzii Apprise nájdete v dokumentácii.",
"appriseMatrixDocs": "Formáty URL a nastavenie Matrix",
"appriseServiceFinish": "Povoľte Apprise, zadajte svoju URL, vyberte kategórie oznámení alebo jednotlivé udalosti, uložte a potom použite Odoslať test. Tieto príklady nemenia uloženú URL. Tento kanál Apprise má jeden cieľ; pre viac služieb naraz použite API Apprise."
},
"ai": {
"title": "AI vylepšenie",
+11 -2
View File
@@ -1302,7 +1302,10 @@
"torsdag",
"fredag",
"lördag"
]
],
"runSkippedStatus": "körning överhoppad",
"reasonStoppedContainer": "Behållaren är stoppad; den schemalagda uppdateringen hoppades över.",
"reasonStoppedContainerHistorical": "Behållaren stoppades vid den schemalagda uppdateringstiden."
},
"cronChip": {
"detected": "host cron upptäckt",
@@ -2108,7 +2111,13 @@
"inactiveFooter": "Aktivera aviseringar för att ta emot system-, hälso- och säkerhetsvarningar via de konfigurerade kanalerna.",
"notificationLanguage": "Aviseringsspråk",
"selectNotificationLanguage": "Välj språk för meddelanden",
"notificationLanguageHint": "Används för deterministiska körtidsmeddelanden även när AI är inaktiverat."
"notificationLanguageHint": "Används för deterministiska körtidsmeddelanden även när AI är inaktiverat.",
"appriseServiceHelp": "Konfiguration av Slack och Matrix",
"appriseSlackSteps": "Skapa en inkommande webhook i din Slack-app för målkanalen och klistra sedan in dess fullständiga HTTPS-URL i fältet Apprise-URL. Ingen omvandling behövs.",
"appriseSlackDocs": "URL-format och konfiguration för Slack",
"appriseMatrixSteps": "Använd ett aviseringskonto som får skicka meddelanden till rummet. Ersätt exemplets användare, lösenord, hemserver och fullständiga rums-ID; URL-koda specialtecken i inloggningsuppgifterna. Schemat matrixs använder HTTPS. Se dokumentationen för stöd för token, SSO och krypterade rum i din installerade Apprise-version.",
"appriseMatrixDocs": "URL-format och konfiguration för Matrix",
"appriseServiceFinish": "Aktivera Apprise, ange din URL, välj aviseringskategorier eller enskilda händelser, spara och använd sedan Skicka test. Dessa exempel ändrar inte din sparade URL. Denna Apprise-kanal har ett mål; använd Apprise-API för flera tjänster samtidigt."
},
"ai": {
"title": "AI-förbättring",
+1 -1
View File
@@ -35,7 +35,7 @@ TOOL_METADATA = {
'system_utils': {'name': 'System Utilities', 'function': 'install_system_utils', 'version': '1.0'},
'network_optimization': {'name': 'Network Optimizations', 'function': 'apply_network_optimizations', 'version': '1.0'},
'bashrc_custom': {'name': 'Bashrc Customization', 'function': 'customize_bashrc', 'version': '1.0'},
'figurine': {'name': 'Figurine', 'function': 'configure_figurine', 'version': '1.0'},
'figurine': {'name': 'Figurine', 'function': 'configure_figurine', 'version': '1.2'},
'fastfetch': {'name': 'Fastfetch', 'function': 'configure_fastfetch', 'version': '1.0'},
'log2ram': {'name': 'Log2ram (SSD Protection)', 'function': 'configure_log2ram', 'version': '1.0'},
'zfs_autotrim': {'name': 'ZFS Autotrim', 'function': 'enable_zfs_autotrim', 'version': '1.0'},
+44 -1
View File
@@ -22245,6 +22245,7 @@ def api_proxmenux_self_update_status():
# so scheduled and manual applies cannot overlap.
_APPLY_UPDATES_SCRIPT = _LXC_APPLY_UPDATES_SCRIPT
_SCHEDULED_STOPPED_EXIT = 9
_DOCKER_ENGINE_INTEGRATED_COMMAND = (
'python3 /usr/local/share/proxmenux/monitor-app/usr/bin/'
'update_docker_engine.py --vmid "$VMID"'
@@ -22286,6 +22287,23 @@ def _append_lxc_update_log(path: str | None, text: str) -> None:
print(f'[ProxMenux] scheduler: could not append update log: {exc}', flush=True)
def _scheduled_runner_reported_stopped(result, log_path: str | None, marker: str) -> bool:
"""Return true only for the scheduler's own pre-start stopped marker."""
if result.returncode != _SCHEDULED_STOPPED_EXIT or not marker:
return False
if log_path:
try:
with open(log_path, 'rb') as stream:
stream.seek(0, os.SEEK_END)
stream.seek(max(0, stream.tell() - 4096))
output = stream.read().decode('utf-8', errors='replace')
except OSError:
return False
else:
output = (result.stdout or '') + (result.stderr or '')
return marker in output
def _prune_lxc_update_logs(vmid: int) -> None:
try:
candidates = sorted(
@@ -22679,8 +22697,10 @@ def _run_scheduled_update(vmid: int, sched: dict) -> dict:
requested_targets = _normalise_schedule_targets(sched)
targets = list(requested_targets)
before = _lxc_update_snapshot(vmid)
requested_target = sched.get("target") or "both"
deferred_targets: list[str] = []
reasons: list[str] = []
reason_code: str | None = None
reboot_required: bool | None = None
reboot_packages: list[str] = []
reboot_check_error: str | None = None
@@ -22732,6 +22752,7 @@ def _run_scheduled_update(vmid: int, sched: dict) -> dict:
'status': status,
'target': actual_target,
'reason': reason,
'reason_code': reason_code,
'run_id': run_id,
'requested_targets': requested_targets,
'executed_targets': list(executed),
@@ -22743,10 +22764,23 @@ def _run_scheduled_update(vmid: int, sched: dict) -> dict:
'finalization': finalization,
}
requested_target = sched.get("target") or "both"
if not originally_running:
reason_code = 'stopped_container'
reasons.append('container is stopped; scheduled update was skipped')
return finish('skipped', requested_target, [])
if requested_targets == ['oci_image']:
# A container installed by OCI manager Apps is updated by replacing
# its image, through the same flow as the OCI menu, unattended.
# OCI updates have their own runner rather than apply_updates.sh.
# Check once more immediately before handing control to it: a user
# may have intentionally stopped the CT after the scheduler's first
# observation, and the OCI transaction can start a replacement CT
# while validating its new image.
if _fast_guest_status(vmid, 'lxc') != 'running':
reason_code = 'stopped_container'
reasons.append('container was stopped before scheduled update could begin')
return finish('skipped', 'oci_image', [])
command = ['bash', '/usr/local/share/proxmenux/scripts/oci/oci_manager_apps.sh',
'manage', str(vmid), '--action', 'update', '--unattended']
storage = (sched.get('backup_storage') or '').strip()
@@ -22887,6 +22921,9 @@ def _run_scheduled_update(vmid: int, sched: dict) -> dict:
target = "both" if has_os_target and has_app_targets else ("os" if has_os_target else "app")
env = dict(os.environ)
env["VMID"] = str(vmid)
env["EXECUTION_MODE"] = "scheduled"
scheduled_stopped_marker = f"PROXMENUX_SCHEDULED_STOPPED:{run_id}"
env["SCHEDULED_STOPPED_MARKER"] = scheduled_stopped_marker
env["TARGET"] = target
env["BACKUP"] = "1" if sched.get("backup") else "0"
env["BACKUP_STORAGE"] = sched.get("backup_storage") or ""
@@ -22952,6 +22989,10 @@ def _run_scheduled_update(vmid: int, sched: dict) -> dict:
text=True,
timeout=60 * 60,
)
if _scheduled_runner_reported_stopped(r, log_path, scheduled_stopped_marker):
reason_code = 'stopped_container'
reasons.append('container was stopped before scheduled update could begin')
return finish('skipped', target, [])
reboot_required, reboot_packages, reboot_check_error = (
_inspect_lxc_reboot_requirement(
vmid,
@@ -23027,12 +23068,14 @@ def _scheduler_loop():
status = outcome.get('status') or 'failure'
actual_target = outcome.get('target') or 'both'
reason = outcome.get('reason')
reason_code = outcome.get('reason_code')
try:
lxc_apps.record_schedule_run(
_vmid,
status,
actual_target,
reason,
reason_code=reason_code,
log_name=outcome.get('log_name'),
reboot_required=outcome.get('reboot_required'),
reboot_packages=outcome.get('reboot_packages'),
+43 -12
View File
@@ -94,6 +94,7 @@ _VALID_UPSTREAM_TYPES = ("github", "http_json", "docker_hub")
# `*/N` step, and comma lists — that covers every preset the UI
# exposes and the freeform "custom" text field.
_VALID_SCHEDULE_TARGETS = ("os", "app", "both")
_SCHEDULE_REASON_CODES = frozenset({"stopped_container"})
_SCHEDULE_TARGET_ID_RE = re.compile(
r"^(?:os|apps|oci_image|app:[A-Za-z0-9_-]{1,64}|docker-engine|docker-(?:compose|container):[A-Za-z0-9][A-Za-z0-9_.-]{0,127}|docker-unit:[a-f0-9]{20})$"
)
@@ -2081,8 +2082,28 @@ class _DockerNoRedirect(urllib.request.HTTPRedirectHandler):
_docker_no_redirect_opener = urllib.request.build_opener(_DockerNoRedirect)
def _registry_bearer_token(challenge: dict) -> tuple[Optional[str], Optional[str]]:
"""Exchange a parsed ``WWW-Authenticate`` challenge for a pull token."""
_REGISTRY_AUTH_FILE = "/etc/pve/priv/proxmenux/registry-auth.json"
_DOCKER_HUB_API_HOSTS = {"docker.io", "index.docker.io", "registry-1.docker.io", "registry.hub.docker.com"}
def _registry_saved_login(api_host: str) -> Optional[str]:
"""The login saved in ProxMenux for a registry, as a Basic credential."""
try:
with open(_REGISTRY_AUTH_FILE, encoding="utf-8") as handle:
auths = (json.load(handle) or {}).get("auths") or {}
except (OSError, ValueError, AttributeError):
return None
host = (api_host or "").lower()
for key in ([host, "docker.io"] if host in _DOCKER_HUB_API_HOSTS else [host]):
entry = auths.get(key) if isinstance(auths, dict) else None
if isinstance(entry, dict) and isinstance(entry.get("auth"), str) and entry["auth"]:
return f"Basic {entry['auth']}"
return None
def _registry_bearer_token(challenge: dict, saved_login: Optional[str] = None) -> tuple[Optional[str], Optional[str]]:
"""Exchange a parsed ``WWW-Authenticate`` challenge for a pull token,
with the login saved for a private registry when there is one."""
query = {
key: challenge[key]
for key in ("service", "scope") if challenge.get(key)
@@ -2090,10 +2111,10 @@ def _registry_bearer_token(challenge: dict) -> tuple[Optional[str], Optional[str
token_url = challenge["realm"]
if query:
token_url += ("&" if "?" in token_url else "?") + urllib.parse.urlencode(query)
token_req = urllib.request.Request(
token_url,
headers={"User-Agent": "ProxMenux-Monitor", "Accept": "application/json"},
)
token_headers = {"User-Agent": "ProxMenux-Monitor", "Accept": "application/json"}
if saved_login:
token_headers["Authorization"] = saved_login
token_req = urllib.request.Request(token_url, headers=token_headers)
try:
with urllib.request.urlopen(token_req, timeout=_docker_registry_timeout()) as response:
body = response.read(65537)
@@ -2160,13 +2181,18 @@ def _registry_request(url: str, headers: dict, method: str = "HEAD",
if error:
return None, None, token, error
if status == 401:
challenge = _parse_bearer_challenge((response_headers or {}).get("WWW-Authenticate", ""))
if not challenge:
authenticate = (response_headers or {}).get("WWW-Authenticate", "")
saved_login = _registry_saved_login(urllib.parse.urlsplit(url).netloc)
challenge = _parse_bearer_challenge(authenticate)
if challenge:
token, token_error = _registry_bearer_token(challenge, saved_login)
if token_error:
return None, None, None, token_error
attempt_headers["Authorization"] = f"Bearer {token}"
elif saved_login and authenticate.lower().startswith("basic") and url.startswith("https://"):
attempt_headers["Authorization"] = saved_login
else:
return None, None, token, "registry authentication required"
token, token_error = _registry_bearer_token(challenge)
if token_error:
return None, None, None, token_error
attempt_headers["Authorization"] = f"Bearer {token}"
response_headers, body, status, location, error = _registry_open(
url, attempt_headers, method, max_bytes,
)
@@ -3918,6 +3944,7 @@ def record_schedule_run(
target: str,
reason: Optional[str] = None,
*,
reason_code: Optional[str] = None,
log_name: Optional[str] = None,
reboot_required: Optional[bool] = None,
reboot_packages: Optional[list[str]] = None,
@@ -3937,6 +3964,10 @@ def record_schedule_run(
sidecar["schedule"]["last_run_reason"] = str(reason)[:300]
else:
sidecar["schedule"].pop("last_run_reason", None)
if reason_code in _SCHEDULE_REASON_CODES:
sidecar["schedule"]["last_run_reason_code"] = reason_code
else:
sidecar["schedule"].pop("last_run_reason_code", None)
if log_name:
sidecar["schedule"]["last_run_log"] = os.path.basename(str(log_name))[:220]
else:
+1 -1
View File
@@ -1564,7 +1564,7 @@ class AppriseChannel(NotificationChannel):
return apprise_mod.NotifyType.FAILURE
if sev == 'WARNING':
return apprise_mod.NotifyType.WARNING
if sev == 'SUCCESS':
if sev in ('SUCCESS', 'OK'):
return apprise_mod.NotifyType.SUCCESS
return apprise_mod.NotifyType.INFO
@@ -33,6 +33,8 @@ class ScheduledUpdateRecordTests(unittest.TestCase):
self.vmid,
'success',
'both',
reason='container is stopped; scheduled update was skipped',
reason_code='stopped_container',
log_name='../9911-scheduled-' + ('a' * 32) + '.log',
reboot_required=True,
reboot_packages=['linux-image-amd64', 'libc6'],
@@ -43,11 +45,31 @@ class ScheduledUpdateRecordTests(unittest.TestCase):
'9911-scheduled-' + ('a' * 32) + '.log',
)
self.assertTrue(schedule['last_run_reboot_required'])
self.assertEqual(schedule['last_run_reason_code'], 'stopped_container')
self.assertEqual(
schedule['last_run_reboot_packages'],
['linux-image-amd64', 'libc6'],
)
def test_record_rejects_unknown_reason_code_and_clears_the_previous_one(self):
self.assertTrue(lxc_apps.record_schedule_run(
self.vmid,
'skipped',
'os',
reason='container is stopped; scheduled update was skipped',
reason_code='stopped_container',
))
self.assertTrue(lxc_apps.record_schedule_run(
self.vmid,
'failure',
'os',
reason='update runner exited with code 9',
reason_code='untrusted_value',
))
schedule = lxc_apps._read_sidecar(self.vmid)['schedule']
self.assertEqual(schedule['last_run_reason'], 'update runner exited with code 9')
self.assertNotIn('last_run_reason_code', schedule)
def test_lifecycle_clear_preserves_run_and_log(self):
lxc_apps.record_schedule_run(
self.vmid,
@@ -0,0 +1,101 @@
"""The update check of a private image uses the login saved in ProxMenux:
it is sent to the token service the registry names, or to the registry
itself when it asks for Basic authentication, and never anywhere else."""
import base64
import json
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
SCRIPTS_DIR = Path(__file__).resolve().parents[1]
if str(SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPTS_DIR))
import lxc_apps
CREDENTIAL = base64.b64encode(b"macrimi:ghp_secret").decode()
MANIFEST = "https://ghcr.io/v2/macrimi/app/manifests/latest"
class SavedLoginTests(unittest.TestCase):
def setUp(self):
self.folder = tempfile.TemporaryDirectory()
self.auth_file = Path(self.folder.name) / "registry-auth.json"
self.auth_file.write_text(json.dumps({"auths": {"ghcr.io": {"auth": CREDENTIAL},
"docker.io": {"auth": CREDENTIAL},
"registry.example.com:5000": {"auth": CREDENTIAL}}}))
patcher = mock.patch.object(lxc_apps, "_REGISTRY_AUTH_FILE", str(self.auth_file))
patcher.start()
self.addCleanup(patcher.stop)
self.addCleanup(self.folder.cleanup)
def test_the_login_is_found_by_registry_host(self):
self.assertEqual(lxc_apps._registry_saved_login("ghcr.io"), f"Basic {CREDENTIAL}")
self.assertEqual(lxc_apps._registry_saved_login("registry-1.docker.io"), f"Basic {CREDENTIAL}")
self.assertEqual(lxc_apps._registry_saved_login("registry.example.com:5000"), f"Basic {CREDENTIAL}")
self.assertIsNone(lxc_apps._registry_saved_login("quay.io"))
def test_without_the_file_nothing_is_sent(self):
self.auth_file.unlink()
self.assertIsNone(lxc_apps._registry_saved_login("ghcr.io"))
def test_a_bearer_challenge_asks_the_token_with_the_saved_login(self):
challenge = 'Bearer realm="https://ghcr.io/token",service="ghcr.io",scope="repository:macrimi/app:pull"'
answers = [({"WWW-Authenticate": challenge}, b"", 401, None, None),
({"Docker-Content-Digest": "sha256:abc"}, b"", 200, None, None)]
sent = []
def token_service(request, timeout):
sent.append(dict(request.header_items()))
response = mock.MagicMock()
response.__enter__.return_value.read.return_value = b'{"token": "pull-token"}'
return response
with mock.patch.object(lxc_apps, "_registry_open", side_effect=answers) as registry, \
mock.patch.object(lxc_apps.urllib.request, "urlopen", side_effect=token_service):
headers, _body, token, error = lxc_apps._registry_request(MANIFEST, {"Accept": "*/*"})
self.assertIsNone(error)
self.assertEqual(token, "pull-token")
self.assertEqual(sent[0]["Authorization"], f"Basic {CREDENTIAL}")
self.assertEqual(registry.call_args_list[1].args[1]["Authorization"], "Bearer pull-token")
def test_a_basic_challenge_is_answered_with_the_saved_login(self):
answers = [({"WWW-Authenticate": 'Basic realm="registry"'}, b"", 401, None, None),
({"Docker-Content-Digest": "sha256:abc"}, b"", 200, None, None)]
url = "https://registry.example.com:5000/v2/team/app/manifests/latest"
with mock.patch.object(lxc_apps, "_registry_open", side_effect=answers) as registry:
_headers, _body, _token, error = lxc_apps._registry_request(url, {"Accept": "*/*"})
self.assertIsNone(error)
self.assertEqual(registry.call_args_list[1].args[1]["Authorization"], f"Basic {CREDENTIAL}")
def test_a_basic_challenge_over_http_gets_no_login(self):
answers = [({"WWW-Authenticate": 'Basic realm="registry"'}, b"", 401, None, None)]
url = "http://registry.example.com:5000/v2/team/app/manifests/latest"
with mock.patch.object(lxc_apps, "_registry_open", side_effect=answers) as registry:
_headers, _body, _token, error = lxc_apps._registry_request(url, {"Accept": "*/*"})
self.assertEqual(error, "registry authentication required")
self.assertEqual(registry.call_count, 1)
def test_a_public_image_asks_its_token_without_a_login(self):
challenge = 'Bearer realm="https://auth.example.org/token",service="example",scope="repository:x:pull"'
answers = [({"WWW-Authenticate": challenge}, b"", 401, None, None),
({"Docker-Content-Digest": "sha256:abc"}, b"", 200, None, None)]
sent = []
def token_service(request, timeout):
sent.append(dict(request.header_items()))
response = mock.MagicMock()
response.__enter__.return_value.read.return_value = b'{"token": "t"}'
return response
with mock.patch.object(lxc_apps, "_registry_open", side_effect=answers), \
mock.patch.object(lxc_apps.urllib.request, "urlopen", side_effect=token_service):
lxc_apps._registry_request("https://quay.io/v2/x/manifests/latest", {})
self.assertNotIn("Authorization", sent[0])
if __name__ == "__main__":
unittest.main()