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:25:01 +02:00
70 changed files with 3407 additions and 302 deletions
+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 && (
+4 -1
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",
+3
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",
+7 -4
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",
@@ -3569,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": {
+4 -1
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é",
+4 -1
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",
+16 -13
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",
@@ -3570,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": {
@@ -4022,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.",
@@ -4550,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",
@@ -4915,7 +4918,7 @@
},
"noIssues": "Nenhum problema detectado",
"updateNow": "Atualizar agora",
"dismissedItems": "Itens dispensados ​​({count})",
"dismissedItems": "Itens dispensados ({count})",
"permanent": "Permanente",
"dismissed": "Dispensado",
"wasStatus": "era {status}",
+3
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",
+4 -1
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",
+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:
@@ -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()