feat(oci): GPU selection per host and per image, one notification per update, and App tab for stack containers

- Immich asks what runs its video and its recognition in one menu, in both
  modes, and gives the GPU to the server and to Machine learning; AMD uses ROCm
- Frigate, Ollama, llama.cpp, Faster Whisper and Piper take the image built
  for the chosen GPU
- The acceleration menu offers only what the host can run
- An update or a recreation sends one notification with its result instead of
  the stop, backup and start of each container
- A private bridge with nothing connected is not reported as down
- Secondary containers of a stack appear in the App tab with their version and
  logo; Secure Gateway shows the same update state in both views
- A mistyped value in the wizard asks the same question again
This commit is contained in:
MacRimi
2026-10-02 21:45:38 +02:00
parent 20ee21c08f
commit 9b5cefb81a
55 changed files with 2294 additions and 113 deletions
+37 -3
View File
@@ -343,6 +343,10 @@ interface Props {
ctIp?: string | null
onChange?: () => void
managed?: ManagedAppInfo | null
// What the OCI record says about this container, known without probing
// it: whether ProxMenux installed it from an image, and whether it is a
// secondary container of a multi-container application.
oci?: { instance: boolean; memberOf?: { vmid: number; label: string } | null } | null
// Optional seed payload from the parent's cross-open ref cache. When
// supplied, the panel renders with real content on the very first
// frame and only revalidates silently in the background — no
@@ -443,7 +447,7 @@ function parseArgvInput(value: string): string[] {
return value.split(",").map((item) => item.trim()).filter(Boolean)
}
export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Props) {
export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }: Props) {
const t = useT()
const isLightTheme = useIsLightTheme()
// Seed from `initialData` first, then fall back to the shared cache
@@ -704,7 +708,8 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
// record names, so there is nothing to search for and nothing else to add.
// What can go stale is what the record and the registry say, and this
// reads both again.
const isOciInstall = !!suggestions?.oci_instance
const isOciInstall = !!suggestions?.oci_instance || !!oci?.instance
const ociMemberOf = oci?.memberOf ?? null
const isOciAdguard = suggestions?.oci_instance?.template_id === "image-adguard-home"
useEffect(() => {
if (!isOciAdguard) return
@@ -2582,7 +2587,36 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
many detections there are (0, 1, or many). Below the chips,
a single "Register a different app" button lets the user
add something the auto-detector doesn't know about. */}
{apps.length === 0 && (
{/* A database or a cache of a multi-container application has no
application of its own: it is reached from the main container. */}
{apps.length === 0 && ociMemberOf && (
<Card className="border border-border bg-card/50">
<CardContent className="p-6 space-y-3">
<div className="mx-auto p-2 rounded-full bg-blue-500/10 w-fit">
<Info className="h-5 w-5 text-blue-400" />
</div>
<h3 className="text-sm font-semibold text-foreground text-center">
{t("vmLxc.appEditor.stackMemberTitle")}
</h3>
<p className="text-xs text-muted-foreground max-w-md mx-auto leading-relaxed text-center">
{t("vmLxc.appEditor.stackMemberBody", { primary: ociMemberOf.label })}
</p>
<div className="pt-1 flex justify-center">
<Button
onClick={() => window.dispatchEvent(new CustomEvent("openLxcAppModal", {
detail: { vmid: ociMemberOf.vmid, tab: "app" },
}))}
className="bg-blue-500 hover:bg-blue-600 text-white"
>
<ChevronRight className="h-4 w-4 mr-1.5" />
{t("vmLxc.ociUpdates.openPrimary")}
</Button>
</div>
</CardContent>
</Card>
)}
{apps.length === 0 && !ociMemberOf && (
<Card className="border border-border bg-card/50">
<CardContent className="p-6 space-y-3">
<div className="mx-auto p-2 rounded-full bg-emerald-500/10 w-fit">
@@ -162,6 +162,19 @@ export function SecureGatewaySetup() {
loadInitialData()
}, [])
// The gateway was updated from the Updates tab of its container: read the
// update state and the status again so this card agrees with it.
useEffect(() => {
const refresh = (event: Event) => {
const detail = (event as CustomEvent).detail || {}
if (detail.appId !== "secure-gateway" || detail.source === "card") return
void loadUpdateInfo(true)
void loadStatus()
}
window.addEventListener("proxmenuxManagedAppUpdated", refresh)
return () => window.removeEventListener("proxmenuxManagedAppUpdated", refresh)
}, [])
const loadInitialData = async () => {
setLoading(true)
setLoadError(null)
@@ -288,6 +301,8 @@ export function SecureGatewaySetup() {
// Status may briefly show "stopped" if tailscale was restarted —
// refresh that too so the action buttons render the right state.
await loadStatus()
// The Updates tab of the gateway container reads it again.
window.dispatchEvent(new CustomEvent("proxmenuxManagedAppUpdated", { detail: { appId: "secure-gateway", source: "card" } }))
} else {
setUpdateError(res?.message || sg("errors.updateFailed"))
}
+24
View File
@@ -1102,6 +1102,14 @@ export function VirtualMachines() {
return () => window.removeEventListener("openLxcAppModal", handler as EventListener)
}, [vmData])
// An application ProxMenux manages (Secure Gateway) was updated or checked
// from its own card: read the guests again so its Updates tab agrees.
useEffect(() => {
const refresh = () => { mutate() }
window.addEventListener("proxmenuxManagedAppUpdated", refresh)
return () => window.removeEventListener("proxmenuxManagedAppUpdated", refresh)
}, [mutate])
// Same deep-link but for QEMU guests. VMs don't have the App tab,
// so we land on Status (which is what handleVMClick already
// defaults to — no override needed).
@@ -5057,6 +5065,20 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
ctIp={ctIp}
onChange={() => mutate()}
initialData={getLxcAppsCached(selectedVM.vmid) ?? null}
oci={ociInstance?.oci_instance ? {
instance: true,
memberOf: ociInstance.stack && ociInstance.primary_vmid !== selectedVM.vmid
? (() => {
const primaryVM = (vmData || []).find((v) => v.vmid === ociInstance.primary_vmid)
return {
vmid: ociInstance.primary_vmid,
label: primaryVM
? `${primaryVM.name} (CT ${ociInstance.primary_vmid})`
: `CT ${ociInstance.primary_vmid}`,
}
})()
: null,
} : null}
managed={
managedEntry
? {
@@ -5182,6 +5204,8 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
onClick={async () => {
try {
await fetchApi(`/api/oci/installed/${appId}/update`, { method: "POST" })
// The card of this application on the Security page reads it again.
window.dispatchEvent(new CustomEvent("proxmenuxManagedAppUpdated", { detail: { appId } }))
mutate()
} catch { /* opening the App tab surfaces the error */ }
}}
+24
View File
@@ -1996,6 +1996,10 @@
"secure_gateway_update_available": "Secure Gateway-Update verfügbar",
"nvidia_driver_update_available": "NVIDIA-Treiberupdate verfügbar",
"coral_driver_update_available": "Update des Coral TPU-Treibers verfügbar",
"oci_update_completed": "OCI-Anwendung aktualisiert",
"oci_update_failed": "Aktualisierung der OCI-Anwendung fehlgeschlagen",
"oci_recreate_completed": "OCI-Anwendung neu erstellt",
"oci_recreate_failed": "Neuerstellung der OCI-Anwendung fehlgeschlagen",
"app_update_available": "App-Update verfügbar",
"lxc_update_applied": "LXC Update angewendet",
"docker_stack_update_available": "Docker Updates verfügbar"
@@ -6290,6 +6294,26 @@
"body": "{details}",
"label": "LXC Update angewendet"
},
"oci_update_completed": {
"title": "{hostname}: {app_name} aktualisiert",
"body": "{app_name} wurde auf das neue Image aktualisiert, die Daten wurden beibehalten.\nContainer: {containers}",
"label": "OCI-Anwendung aktualisiert"
},
"oci_update_failed": {
"title": "{hostname}: Aktualisierung von {app_name} nicht abgeschlossen",
"body": "Die Aktualisierung von {app_name} wurde nicht abgeschlossen.\nGrund: {reason}\nContainer: {containers}\nÖffnen Sie „Installierte OCI-Anwendungen verwalten“, um den Zustand zu prüfen.",
"label": "Aktualisierung der OCI-Anwendung fehlgeschlagen"
},
"oci_recreate_completed": {
"title": "{hostname}: {app_name} neu erstellt",
"body": "{app_name} wurde mit den neuen Optionen neu erstellt, die Daten wurden beibehalten.\nContainer: {containers}",
"label": "OCI-Anwendung neu erstellt"
},
"oci_recreate_failed": {
"title": "{hostname}: Neuerstellung von {app_name} nicht abgeschlossen",
"body": "Die Neuerstellung von {app_name} wurde nicht abgeschlossen.\nGrund: {reason}\nContainer: {containers}\nÖffnen Sie „Installierte OCI-Anwendungen verwalten“, um den Zustand zu prüfen.",
"label": "Neuerstellung der OCI-Anwendung fehlgeschlagen"
},
"app_update_available": {
"title": "{hostname}: {app_name} Update verfügbar auf CT {vmid}",
"body": "{app_name} auf CT {vmid} ({ct_name}) hat eine neue Version:\n {installed} → {latest}",
File diff suppressed because one or more lines are too long
+26
View File
@@ -1601,6 +1601,8 @@
"hiddenDetectionsHelpPlural": "Tienes {count} detecciones ocultas. Restaure una para recuperarla o registre una aplicación personalizada manualmente.",
"registerCustom": "Registrar una aplicación personalizada",
"noAppsTitle": "No hay aplicaciones registradas",
"stackMemberTitle": "Forma parte de una aplicación multicontenedor",
"stackMemberBody": "Este contenedor da servicio a una aplicación cuyo contenedor principal es {primary}. La aplicación, sus enlaces web y su versión se muestran allí.",
"noAppsBody": "Registre las aplicaciones que se ejecutan en este contenedor. Obtendrá enlaces web y, opcionalmente, seguimiento de versiones disponibles y notificaciones de nuevos lanzamientos.",
"registerApplication": "Registrar aplicación",
"searchApplications": "Buscar aplicaciones",
@@ -1983,6 +1985,10 @@
"update_summary": "Actualizaciones de paquetes del host",
"pve_update": "Actualización de Proxmox VE disponible",
"update_complete": "Actualización del host completada",
"oci_update_completed": "Aplicación OCI actualizada",
"oci_update_failed": "Actualización de aplicación OCI fallida",
"oci_recreate_completed": "Aplicación OCI recreada",
"oci_recreate_failed": "Recreación de aplicación OCI fallida",
"app_update_available": "Actualización de app disponible",
"ai_model_migrated": "Modelo de IA actualizado automáticamente",
"proxmenux_update": "Actualización de ProxMenux disponible",
@@ -6290,6 +6296,26 @@
"body": "{details}",
"label": "Actualización LXC aplicada"
},
"oci_update_completed": {
"title": "{hostname}: {app_name} actualizado",
"body": "{app_name} se ha actualizado a su nueva imagen y sus datos se han conservado.\nContenedores: {containers}",
"label": "Aplicación OCI actualizada"
},
"oci_update_failed": {
"title": "{hostname}: la actualización de {app_name} no se completó",
"body": "La actualización de {app_name} no se completó.\nMotivo: {reason}\nContenedores: {containers}\nAbre Gestionar aplicaciones OCI instaladas para comprobar su estado.",
"label": "Actualización de aplicación OCI fallida"
},
"oci_recreate_completed": {
"title": "{hostname}: {app_name} recreado",
"body": "{app_name} se ha recreado con sus nuevas opciones y sus datos se han conservado.\nContenedores: {containers}",
"label": "Aplicación OCI recreada"
},
"oci_recreate_failed": {
"title": "{hostname}: la recreación de {app_name} no se completó",
"body": "La recreación de {app_name} no se completó.\nMotivo: {reason}\nContenedores: {containers}\nAbre Gestionar aplicaciones OCI instaladas para comprobar su estado.",
"label": "Recreación de aplicación OCI fallida"
},
"app_update_available": {
"title": "{hostname}: actualización de {app_name} disponible en CT {vmid}",
"body": "{app_name} en CT {vmid} ({ct_name}) tiene una nueva versión:\n {installed} → {latest}",
+24
View File
@@ -1996,6 +1996,10 @@
"secure_gateway_update_available": "Mise à jour de Secure Gateway disponible",
"nvidia_driver_update_available": "Mise à jour du pilote NVIDIA disponible",
"coral_driver_update_available": "Mise à jour du pilote Coral TPU disponible",
"oci_update_completed": "Application OCI mise à jour",
"oci_update_failed": "Échec de la mise à jour de l'application OCI",
"oci_recreate_completed": "Application OCI recréée",
"oci_recreate_failed": "Échec de la recréation de l'application OCI",
"app_update_available": "mise à jour de l'application disponible",
"lxc_update_applied": "Mise à jour LXC appliquée",
"docker_stack_update_available": "Docker mises à jour disponibles"
@@ -6290,6 +6294,26 @@
"body": "{details}",
"label": "Mise à jour LXC appliquée"
},
"oci_update_completed": {
"title": "{hostname} : {app_name} mis à jour",
"body": "{app_name} a été mis à jour vers sa nouvelle image et ses données ont été conservées.\nConteneurs : {containers}",
"label": "Application OCI mise à jour"
},
"oci_update_failed": {
"title": "{hostname} : la mise à jour de {app_name} n'a pas abouti",
"body": "La mise à jour de {app_name} n'a pas abouti.\nMotif : {reason}\nConteneurs : {containers}\nOuvrez Gérer les applications OCI installées pour vérifier son état.",
"label": "Échec de la mise à jour de l'application OCI"
},
"oci_recreate_completed": {
"title": "{hostname} : {app_name} recréé",
"body": "{app_name} a été recréé avec ses nouvelles options et ses données ont été conservées.\nConteneurs : {containers}",
"label": "Application OCI recréée"
},
"oci_recreate_failed": {
"title": "{hostname} : la recréation de {app_name} n'a pas abouti",
"body": "La recréation de {app_name} n'a pas abouti.\nMotif : {reason}\nConteneurs : {containers}\nOuvrez Gérer les applications OCI installées pour vérifier son état.",
"label": "Échec de la recréation de l'application OCI"
},
"app_update_available": {
"title": "{hostname} : mise à jour {app_name} disponible sur CT {vmid}",
"body": "{app_name} sur CT {vmid} ({ct_name}) a une nouvelle version :\n {installed} → {latest}",
+24
View File
@@ -1996,6 +1996,10 @@
"secure_gateway_update_available": "Aggiornamento Secure Gateway disponibile",
"nvidia_driver_update_available": "Aggiornamento del driver NVIDIA disponibile",
"coral_driver_update_available": "Disponibile l'aggiornamento del driver Coral TPU",
"oci_update_completed": "Applicazione OCI aggiornata",
"oci_update_failed": "Aggiornamento dell'applicazione OCI non riuscito",
"oci_recreate_completed": "Applicazione OCI ricreata",
"oci_recreate_failed": "Ricreazione dell'applicazione OCI non riuscita",
"app_update_available": "aggiornamento dell'app disponibile",
"lxc_update_applied": "Aggiornamento LXC applicato",
"docker_stack_update_available": "Aggiornamenti Docker disponibili"
@@ -6290,6 +6294,26 @@
"body": "{details}",
"label": "Aggiornamento LXC applicato"
},
"oci_update_completed": {
"title": "{hostname}: {app_name} aggiornato",
"body": "{app_name} è stato aggiornato alla nuova immagine e i dati sono stati conservati.\nContenitori: {containers}",
"label": "Applicazione OCI aggiornata"
},
"oci_update_failed": {
"title": "{hostname}: aggiornamento di {app_name} non completato",
"body": "L'aggiornamento di {app_name} non è stato completato.\nMotivo: {reason}\nContenitori: {containers}\nApri Gestisci le applicazioni OCI installate per verificarne lo stato.",
"label": "Aggiornamento dell'applicazione OCI non riuscito"
},
"oci_recreate_completed": {
"title": "{hostname}: {app_name} ricreato",
"body": "{app_name} è stato ricreato con le nuove opzioni e i dati sono stati conservati.\nContenitori: {containers}",
"label": "Applicazione OCI ricreata"
},
"oci_recreate_failed": {
"title": "{hostname}: ricreazione di {app_name} non completata",
"body": "La ricreazione di {app_name} non è stata completata.\nMotivo: {reason}\nContenitori: {containers}\nApri Gestisci le applicazioni OCI installate per verificarne lo stato.",
"label": "Ricreazione dell'applicazione OCI non riuscita"
},
"app_update_available": {
"title": "{hostname}: aggiornamento {app_name} disponibile su CT {vmid}",
"body": "{app_name} su CT {vmid} ({ct_name}) ha una nuova versione:\n {installed} → {latest}",
+24
View File
@@ -1996,6 +1996,10 @@
"secure_gateway_update_available": "Atualização do Secure Gateway disponível",
"nvidia_driver_update_available": "Atualização de driver NVIDIA disponível",
"coral_driver_update_available": "Atualização do driver Coral TPU disponível",
"oci_update_completed": "Aplicação OCI atualizada",
"oci_update_failed": "Falha na atualização da aplicação OCI",
"oci_recreate_completed": "Aplicação OCI recriada",
"oci_recreate_failed": "Falha na recriação da aplicação OCI",
"app_update_available": "atualização de aplicativo disponível",
"lxc_update_applied": "Atualização LXC aplicada",
"docker_stack_update_available": "Docker atualizações disponíveis"
@@ -6290,6 +6294,26 @@
"body": "{details}",
"label": "Atualização LXC aplicada"
},
"oci_update_completed": {
"title": "{hostname}: {app_name} atualizado",
"body": "{app_name} foi atualizado para a nova imagem e os dados foram mantidos.\nContêineres: {containers}",
"label": "Aplicação OCI atualizada"
},
"oci_update_failed": {
"title": "{hostname}: a atualização de {app_name} não foi concluída",
"body": "A atualização de {app_name} não foi concluída.\nMotivo: {reason}\nContêineres: {containers}\nAbra Gerir aplicações OCI instaladas para verificar o estado.",
"label": "Falha na atualização da aplicação OCI"
},
"oci_recreate_completed": {
"title": "{hostname}: {app_name} recriado",
"body": "{app_name} foi recriado com as novas opções e os dados foram mantidos.\nContêineres: {containers}",
"label": "Aplicação OCI recriada"
},
"oci_recreate_failed": {
"title": "{hostname}: a recriação de {app_name} não foi concluída",
"body": "A recriação de {app_name} não foi concluída.\nMotivo: {reason}\nContêineres: {containers}\nAbra Gerir aplicações OCI instaladas para verificar o estado.",
"label": "Falha na recriação da aplicação OCI"
},
"app_update_available": {
"title": "{hostname}: atualização {app_name} disponível no CT {vmid}",
"body": "{app_name} no CT {vmid} ({ct_name}) tem uma nova versão:\n {installed} → {latest}",
+24
View File
@@ -1997,6 +1997,10 @@
"secure_gateway_update_available": "K dispozícii je aktualizácia Secure Gateway",
"nvidia_driver_update_available": "Dostupná aktualizácia ovládača NVIDIA",
"coral_driver_update_available": "Dostupná aktualizácia ovládača Coral TPU",
"oci_update_completed": "OCI aplikácia aktualizovaná",
"oci_update_failed": "Aktualizácia OCI aplikácie zlyhala",
"oci_recreate_completed": "OCI aplikácia znovu vytvorená",
"oci_recreate_failed": "Opätovné vytvorenie OCI aplikácie zlyhalo",
"app_update_available": "K dispozícii je aktualizácia aplikácie"
},
"ui": {
@@ -6289,6 +6293,26 @@
"body": "{details}",
"label": "Aktualizácia LXC použitá"
},
"oci_update_completed": {
"title": "{hostname}: {app_name} aktualizovaná",
"body": "Aplikácia {app_name} bola aktualizovaná na nový obraz a jej dáta zostali zachované.\nKontajnery: {containers}",
"label": "OCI aplikácia aktualizovaná"
},
"oci_update_failed": {
"title": "{hostname}: aktualizácia {app_name} sa nedokončila",
"body": "Aktualizácia aplikácie {app_name} sa nedokončila.\nDôvod: {reason}\nKontajnery: {containers}\nOtvorte Spravovať nainštalované OCI aplikácie a skontrolujte jej stav.",
"label": "Aktualizácia OCI aplikácie zlyhala"
},
"oci_recreate_completed": {
"title": "{hostname}: {app_name} znovu vytvorená",
"body": "Aplikácia {app_name} bola znovu vytvorená s novými možnosťami a jej dáta zostali zachované.\nKontajnery: {containers}",
"label": "OCI aplikácia znovu vytvorená"
},
"oci_recreate_failed": {
"title": "{hostname}: opätovné vytvorenie {app_name} sa nedokončilo",
"body": "Opätovné vytvorenie aplikácie {app_name} sa nedokončilo.\nDôvod: {reason}\nKontajnery: {containers}\nOtvorte Spravovať nainštalované OCI aplikácie a skontrolujte jej stav.",
"label": "Opätovné vytvorenie OCI aplikácie zlyhalo"
},
"app_update_available": {
"title": "{hostname}: Pre {app_name} je na CT {vmid} dostupná aktualizácia",
"body": "Aplikácia {app_name} na CT {vmid} ({ct_name}) má novú verziu:\n {installed} → {latest}",
+24
View File
@@ -1996,6 +1996,10 @@
"secure_gateway_update_available": "Secure Gateway uppdatering tillgänglig",
"nvidia_driver_update_available": "NVIDIA drivrutinsuppdatering tillgänglig",
"coral_driver_update_available": "Coral TPU drivrutinsuppdatering tillgänglig",
"oci_update_completed": "OCI-applikation uppdaterad",
"oci_update_failed": "Uppdatering av OCI-applikation misslyckades",
"oci_recreate_completed": "OCI-applikation återskapad",
"oci_recreate_failed": "Återskapande av OCI-applikation misslyckades",
"app_update_available": "Appuppdatering tillgänglig",
"lxc_update_applied": "LXC uppdatering tillämpad",
"docker_stack_update_available": "Docker uppdateringar tillgängliga"
@@ -6290,6 +6294,26 @@
"body": "{details}",
"label": "LXC uppdatering tillämpad"
},
"oci_update_completed": {
"title": "{hostname}: {app_name} uppdaterad",
"body": "{app_name} uppdaterades till sin nya avbild och dess data behölls.\nContainrar: {containers}",
"label": "OCI-applikation uppdaterad"
},
"oci_update_failed": {
"title": "{hostname}: uppdateringen av {app_name} slutfördes inte",
"body": "Uppdateringen av {app_name} slutfördes inte.\nOrsak: {reason}\nContainrar: {containers}\nÖppna Hantera installerade OCI-applikationer för att kontrollera dess tillstånd.",
"label": "Uppdatering av OCI-applikation misslyckades"
},
"oci_recreate_completed": {
"title": "{hostname}: {app_name} återskapad",
"body": "{app_name} återskapades med sina nya alternativ och dess data behölls.\nContainrar: {containers}",
"label": "OCI-applikation återskapad"
},
"oci_recreate_failed": {
"title": "{hostname}: återskapandet av {app_name} slutfördes inte",
"body": "Återskapandet av {app_name} slutfördes inte.\nOrsak: {reason}\nContainrar: {containers}\nÖppna Hantera installerade OCI-applikationer för att kontrollera dess tillstånd.",
"label": "Återskapande av OCI-applikation misslyckades"
},
"app_update_available": {
"title": "{hostname}: {app_name} uppdatering tillgänglig på CT {vmid}",
"body": "{app_name} på CT {vmid} ({ct_name}) har en ny version:\n {installed} → {latest}",
+1
View File
@@ -138,6 +138,7 @@ cp "$SCRIPT_DIR/mount_monitor.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠
cp "$SCRIPT_DIR/lxc_mount_points.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ lxc_mount_points.py not found"
cp "$SCRIPT_DIR/oci_console_logs.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ oci_console_logs.py not found"
cp "$SCRIPT_DIR/oci_instance_info.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ oci_instance_info.py not found"
cp "$SCRIPT_DIR/oci_operations.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ oci_operations.py not found"
cp "$SCRIPT_DIR/disk_temperature_history.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ disk_temperature_history.py not found"
cp "$SCRIPT_DIR/smartctl_resolver.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ smartctl_resolver.py not found"
cp "$SCRIPT_DIR/disk_identity.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ disk_identity.py not found"
@@ -1625,6 +1625,54 @@ def internal_shutdown_event():
return jsonify({'error': 'internal_error', 'detail': str(e)}), 500
# ─── Internal OCI Event Endpoint ─────────────────────────────────
_OCI_EVENTS = {
'oci_update_completed': 'INFO', 'oci_update_failed': 'WARNING',
'oci_recreate_completed': 'INFO', 'oci_recreate_failed': 'WARNING',
}
@notification_bp.route('/api/internal/oci-event', methods=['POST'])
def internal_oci_event():
"""Called by the OCI engine when an update or a recreation ends, with its
result. Only accepts requests from this host."""
remote_addr = request.remote_addr or ''
try:
import ipaddress
addr = ipaddress.ip_address(remote_addr.split('%')[0])
if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped is not None:
addr = addr.ipv4_mapped
is_loopback = addr.is_loopback
except (ValueError, TypeError):
is_loopback = remote_addr in ('127.0.0.1', '::1', 'localhost')
if not is_loopback:
return jsonify({'error': 'forbidden', 'detail': 'localhost only'}), 403
try:
data = request.get_json(silent=True) or {}
event_type = str(data.get('event') or '')
if event_type not in _OCI_EVENTS:
return jsonify({'error': 'invalid_event_type'}), 400
vmid = str(data.get('vmid') or '')
notification_manager.emit_event(
event_type=event_type,
severity=_OCI_EVENTS[event_type],
data={
'hostname': str(data.get('hostname') or 'unknown'),
'app_name': str(data.get('app_name') or f'CT {vmid}')[:120],
'vmid': vmid,
'containers': str(data.get('containers') or '')[:400],
'reason': str(data.get('reason') or '')[:600],
},
source='proxmenux',
entity='ct',
entity_id=vmid,
)
return jsonify({'success': True, 'event_type': event_type}), 200
except Exception as e:
return jsonify({'error': 'internal_error', 'detail': str(e)}), 500
# ─── Internal Restore Event Endpoint ─────────────────────────────
@notification_bp.route('/api/internal/restore-event', methods=['POST'])
+13
View File
@@ -545,6 +545,15 @@ def update_auth_key(app_id: str):
}), 500
def _sync_managed_registry(app_id: str) -> None:
"""Keep the Updates tab of the container in step with this page."""
try:
import managed_installs
managed_installs.refresh_oci_app(app_id)
except Exception as e:
logger.warning(f"Could not refresh the managed registry for {app_id}: {e}")
@oci_bp.route("/installed/<app_id>/update-check", methods=["GET"])
@require_auth
def installed_update_check(app_id: str):
@@ -558,6 +567,8 @@ def installed_update_check(app_id: str):
try:
force = request.args.get("force", "").lower() in ("1", "true", "yes")
result = oci_manager.check_app_update_available(app_id, force=force)
if force:
_sync_managed_registry(app_id)
return jsonify({"success": True, **result})
except Exception as e:
logger.error(f"Failed to check app update for {app_id}: {e}")
@@ -572,6 +583,8 @@ def installed_update_apply(app_id: str):
would cause an unnecessary brief disconnect."""
try:
result = oci_manager.update_app(app_id)
if result.get("success"):
_sync_managed_registry(app_id)
status_code = 200 if result.get("success") else 500
return jsonify(result), status_code
except Exception as e:
+26
View File
@@ -3158,6 +3158,20 @@ class HealthMonitor:
print(f"[HealthMonitor] Disk/IO check failed: {e}")
return {'status': 'UNKNOWN', 'reason': f'Disk check unavailable: {str(e)}', 'checks': {}, 'dismissable': True}
@staticmethod
def _bridge_is_idle(interface: str, root: str = '/sys/class/net') -> bool:
"""Whether a bridge is administratively up with no port attached.
Such a bridge reports no carrier, which is its normal state and not a
failure. A bridge that is set down, or one that has ports and still no
carrier, is not idle."""
try:
with open(f'{root}/{interface}/flags', encoding='ascii') as handle:
administratively_up = bool(int(handle.read().strip(), 16) & 0x1)
return administratively_up and not os.listdir(f'{root}/{interface}/brif')
except (OSError, ValueError):
return False
def _check_network_optimized(self) -> Dict[str, Any]:
"""
Optimized network check - only alerts for interfaces that are actually in use.
@@ -3206,6 +3220,18 @@ class HealthMonitor:
# Check if it's a bridge interface (always important for VMs/LXCs)
if interface.startswith('vmbr'):
if self._bridge_is_idle(interface):
# A bridge with no port attached has no carrier: the
# private network of an application whose containers
# are stopped, during an update for example. Nothing
# is down; nothing is connected to it.
interface_details[interface] = {
'status': 'OK',
'reason': 'Bridge without attached ports',
'is_up': False,
}
health_persistence.resolve_error(interface, 'Bridge without attached ports')
continue
should_alert = True
alert_reason = 'Bridge interface DOWN (VMs/LXCs may be affected)'
+57 -10
View File
@@ -4332,7 +4332,10 @@ def check_app(
pass
if app.get("installed_via") == "oci_image":
result = _oci_image_versions(vmid, known=state)
# A secondary container of a stack shows the version it runs; the
# registry is not asked, because it is not updated on its own.
result = _oci_image_versions(
vmid, known=state, with_latest=not _oci_secondary_member(_read_oci_record(vmid)))
if result.get("busy"):
_recheck_after_oci_operation(vmid, app_id)
return sidecar
@@ -5458,30 +5461,50 @@ def _dismiss_oci_registration(vmid) -> None:
print(f"[ProxMenux] lxc_apps: could not save the OCI dismissal: {exc}")
def _oci_secondary_member(record) -> bool:
"""Whether the record belongs to a container of a stack other than its main one."""
if not isinstance(record, dict):
return False
member = record.get("stack_member") if isinstance(record.get("stack_member"), dict) else {}
return member.get("primary_vmid") not in (None, record.get("vmid"))
def ensure_oci_registration(vmid) -> bool:
"""Register the application ProxMenux installed from an OCI image the
first time the Monitor sees its container, with version tracking by the
image. The auxiliary members of a stack are not registered, nor is a
container that already has applications, nor one whose registration the
user removed."""
image. A secondary container of a stack, its database or its cache, is
registered with the version it runs and no tracking: the stack is updated
as a whole from its main container. A container that already has
applications is left alone, and so is one whose registration the user
removed."""
record = _read_oci_record(vmid)
if not record or record.get("status") != "installed":
return False
member = record.get("stack_member") if isinstance(record.get("stack_member"), dict) else {}
if member.get("primary_vmid") not in (None, record.get("vmid")):
return False
secondary = _oci_secondary_member(record)
if _oci_dismissed().get(str(int(vmid))) == record.get("installation_id"):
return False
with _cache_lock:
sidecar = _read_sidecar(vmid)
if sidecar and sidecar.get("apps"):
# An application registered before its logo could be resolved
# takes it now; nothing else of what is registered is touched.
missing = [app for app in sidecar["apps"]
if app.get("installed_via") == "oci_image" and not app.get("logo_url")]
logo = (_oci_instance_meta(vmid) or {}).get("logo") if missing else ""
if logo:
for app in missing:
app["logo_url"] = logo
_write_sidecar(vmid, sidecar)
return False
meta = _oci_instance_meta(vmid)
if not meta or not meta.get("name") or not _NAME_RE.match(str(meta["name"])):
return False
category = meta.get("category_label") or meta.get("category") or ""
endpoints = meta.get("endpoints") or []
if not endpoints:
if secondary:
# A database or a cache is reached by the application, not by the user.
endpoints = []
elif not endpoints:
port = meta.get("endpoint_port") or next(iter(meta.get("ports") or []), None)
endpoints = [{"port": port, "scheme": meta.get("endpoint_scheme"), "path": meta.get("endpoint_path"),
"description": "", "logo_url": ""}] if isinstance(port, int) else []
@@ -5543,6 +5566,15 @@ def _recheck_after_oci_operation(vmid, app_id: str) -> None:
threading.Thread(target=wait_and_check, name=f"oci-recheck-{vmid}", daemon=True).start()
_OCI_ICON_BASE = "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp"
# The services a stack runs beside its application, by the name of their image.
_OCI_SERVICE_ICONS = {
name: f"{_OCI_ICON_BASE}/{icon}.webp"
for name, icon in (("postgres", "postgresql"), ("valkey", "valkey"), ("redis", "redis"),
("mariadb", "mariadb"), ("mongo", "mongodb"), ("meilisearch", "meilisearch"))
}
def _oci_instance_meta(vmid) -> Optional[dict]:
"""What ProxMenux itself recorded when it installed this container.
@@ -5623,12 +5655,27 @@ def _oci_instance_meta(vmid) -> Optional[dict]:
endpoints.append(detail)
catalog_icons = _oci_catalog_icons()
logo = catalog_icons.get(template_id) or ""
repository = str(image.get("reference") or "").split("@", 1)[0]
basename = repository.rsplit("/", 1)[-1].rsplit(":", 1)[0].strip().lower()
# A container of a stack records its own template id, `image-immich-server`,
# which the catalog does not list: the application it belongs to does.
stack_id = str(stack_template.get("id") or "").strip()
secondary = _oci_secondary_member(record)
if not stack_id and secondary:
primary = _read_oci_record(member.get("primary_vmid")) or {}
stack_id = str(((primary.get("stack") or {}).get("template") or {}).get("id") or "").strip()
application = stack_id.removeprefix("image-").removesuffix("-stack")
if not logo and stack_id and (not secondary or (application and basename.startswith(application))):
# The main container, or one that carries the application in its own
# name, such as Immich's machine learning.
logo = catalog_icons.get(stack_id) or ""
if not logo:
# A stack or a one-off image has no catalog entry of its own, but the
# image it runs usually does: the Nextcloud stack wears Nextcloud's.
repository = str(image.get("reference") or "").split("@", 1)[0]
basename = repository.rsplit("/", 1)[-1].rsplit(":", 1)[0].strip().lower()
logo = catalog_icons.get(basename) or ""
if not logo:
# The database or the cache beside an application.
logo = _OCI_SERVICE_ICONS.get(basename, "")
if not logo:
logo = ui.get("icon") if isinstance(ui.get("icon"), str) else ""
website = ui.get("website") if isinstance(ui.get("website"), str) else ""
+21
View File
@@ -1716,6 +1716,27 @@ def _store_update_result(item: dict, result: dict) -> None:
item["update_check"][extra_key] = result[extra_key]
def refresh_oci_app(app_id: str) -> Optional[dict]:
"""Read one OCI-managed application again and store what it reports.
Its card on the Security page and the Updates tab of its container read
different stores; after an update or a forced check from either one, the
registry is brought to the same state the application reports now."""
with _lock:
reg = _read_registry()
for it in reg.get("items", []):
if (it.get("type") != "oci_app" or it.get("removed_at")
or it.get("_oci_app_id") != app_id):
continue
result = _check_oci_app(it)
_store_update_result(it, result)
if result.get("current"):
it["current_version"] = result["current"]
_write_registry(reg)
return it
return None
def check_for_updates(force: bool = False) -> list[dict]:
"""Run every type-specific checker over active items, persist
the updated state, return the list of items that have an update
+10
View File
@@ -1275,6 +1275,16 @@ class NotificationManager:
if self._is_backup_running():
return
# The stop, the backup and the start of a container the OCI manager is
# updating or recreating are steps of that operation, which reports
# its own result when it ends.
try:
import oci_operations
if oci_operations.quiet(event):
return
except Exception:
pass
# Check storage exclusions for storage-related events.
# If the storage is excluded from notifications, suppress the event entirely.
_STORAGE_EVENTS = {'storage_unavailable', 'storage_low_space', 'storage_warning', 'storage_error',
@@ -870,6 +870,44 @@ TEMPLATES = {
'group': 'vm_ct',
'default_enabled': True,
},
'oci_update_completed': {
'title': '{hostname}: {app_name} updated',
'body': '{app_name} was updated to its new image and its data was kept.\nContainers: {containers}',
'label': 'OCI application updated',
'group': 'vm_ct',
'default_enabled': True,
},
'oci_update_failed': {
'title': '{hostname}: {app_name} update did not complete',
'body': (
'The update of {app_name} did not complete.\n'
'Reason: {reason}\n'
'Containers: {containers}\n'
'Open Manage installed OCI applications to check its state.'
),
'label': 'OCI application update failed',
'group': 'vm_ct',
'default_enabled': True,
},
'oci_recreate_completed': {
'title': '{hostname}: {app_name} recreated',
'body': '{app_name} was recreated with its new options and its data was kept.\nContainers: {containers}',
'label': 'OCI application recreated',
'group': 'vm_ct',
'default_enabled': True,
},
'oci_recreate_failed': {
'title': '{hostname}: {app_name} recreation did not complete',
'body': (
'The recreation of {app_name} did not complete.\n'
'Reason: {reason}\n'
'Containers: {containers}\n'
'Open Manage installed OCI applications to check its state.'
),
'label': 'OCI application recreation failed',
'group': 'vm_ct',
'default_enabled': True,
},
'app_update_available': {
'title': '{hostname}: {app_name} update available on CT {vmid}',
'body': (
@@ -2314,6 +2352,10 @@ EVENT_EMOJI = {
'lxc_updates_available': '\U0001F4E6', # \uD83D\uDCE6 package \u2014 pending CT updates
'apt_listchanges': '\U0001F4E6', # package-maintainer NEWS via PVE mail
'lxc_update_applied': '\u2705', # \u2705 check \u2014 update applied
'oci_update_completed': '\u2705',
'oci_update_failed': '\u26A0\uFE0F',
'oci_recreate_completed': '\u2705',
'oci_recreate_failed': '\u26A0\uFE0F',
'app_update_available': '\U0001F195', # \ud83c\udd95 NEW \u2014 upstream app release
'docker_stack_update_available': '\U0001F433',
'vm_start': '\u25B6\uFE0F', # play button
+62
View File
@@ -0,0 +1,62 @@
"""Containers an OCI manager operation is working on.
An update or a recreation stops, backs up and starts its containers. The OCI
engine marks them while it works and reports the result itself, so their
stop, start and backup notices are part of the operation, not news.
"""
import json
import os
import re
import time
MARKERS = '/run/proxmenux/oci-operations'
# The last start of an operation is noticed a little after it returned.
GRACE_SECONDS = 180
# A mark left behind by an operation that died is not trusted for ever.
STALE_SECONDS = 6 * 3600
QUIET_EVENTS = frozenset({
'vm_start', 'vm_stop', 'vm_shutdown', 'vm_restart',
'ct_start', 'ct_stop', 'ct_shutdown', 'ct_restart',
'backup_start', 'backup_complete',
})
_OCI_BACKUP_PATH = '/proxmenux/oci/instances/'
def active(vmid, now=None, root=MARKERS) -> bool:
try:
with open(os.path.join(root, str(int(vmid))), encoding='utf-8') as handle:
mark = json.load(handle)
started = float(mark.get('started') or 0)
ended = mark.get('ended')
except (OSError, ValueError, TypeError):
return False
now = time.time() if now is None else now
if ended is None:
return now - started < STALE_SECONDS
return now - float(ended) < GRACE_SECONDS
def _vmids(event) -> set:
data = event.data or {}
found = set()
for value in (data.get('vmid'), getattr(event, 'entity_id', '')):
if str(value or '').isdigit():
found.add(int(value))
if event.event_type.startswith('backup_'):
text = ' '.join(str(data.get(key) or '') for key in ('reason', 'pve_message', 'vmname', 'guests'))
found.update(int(value) for value in re.findall(r'\((\d{3,})\)|vzdump-(?:lxc|qemu)-(\d+)-', text)
for value in value if value)
return found
def quiet(event, root=MARKERS) -> bool:
"""Whether the event is a step of a running OCI operation."""
if event.event_type not in QUIET_EVENTS or event.severity in ('CRITICAL', 'WARNING'):
return False
data = event.data or {}
if event.event_type.startswith('backup_') and any(
_OCI_BACKUP_PATH in str(data.get(key) or '') for key in ('reason', 'pve_message', 'filename')):
# The working copy of an update lives in the OCI registry of the host.
return True
vmids = _vmids(event)
return bool(vmids) and all(active(vmid, root=root) for vmid in vmids)
@@ -0,0 +1,39 @@
"""A bridge with no port attached has no carrier and is not a failure: the
private network of an application whose containers are stopped."""
import sys
import tempfile
from pathlib import Path
import unittest
SCRIPTS = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(SCRIPTS))
from health_monitor import HealthMonitor
class IdleBridgeTests(unittest.TestCase):
def bridge(self, flags, ports=()):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
folder = Path(tmp.name) / 'vmbr10'
(folder / 'brif').mkdir(parents=True)
(folder / 'flags').write_text(flags + '\n')
for port in ports:
(folder / 'brif' / port).mkdir()
return HealthMonitor._bridge_is_idle('vmbr10', tmp.name)
def test_an_up_bridge_with_no_port_is_idle(self):
self.assertTrue(self.bridge('0x1003'))
def test_a_bridge_with_ports_and_no_carrier_is_not_idle(self):
self.assertFalse(self.bridge('0x1003', ['enp3s0']))
self.assertFalse(self.bridge('0x1003', ['veth115i1']))
def test_a_bridge_set_down_is_not_idle(self):
self.assertFalse(self.bridge('0x1002'))
def test_an_interface_that_is_not_a_bridge_is_not_idle(self):
self.assertFalse(HealthMonitor._bridge_is_idle('vmbr10', '/nonexistent'))
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,75 @@
"""While the OCI manager updates or recreates an application, the stop, the
backup and the start of its containers are steps of that operation."""
import json
import sys
import tempfile
import time
from pathlib import Path
from types import SimpleNamespace
import unittest
SCRIPTS = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(SCRIPTS))
import oci_operations
def event(kind, vmid=None, severity='INFO', **data):
if vmid is not None:
data['vmid'] = str(vmid)
return SimpleNamespace(event_type=kind, severity=severity, data=data, entity_id=str(vmid or ''))
class OperationQuietTests(unittest.TestCase):
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.root = tmp.name
def mark(self, vmid, started, ended=None):
Path(self.root, str(vmid)).write_text(json.dumps({'started': started, 'ended': ended}))
def test_the_steps_of_a_running_operation_are_quiet(self):
self.mark(115, time.time())
for kind in ('ct_shutdown', 'ct_stop', 'ct_start', 'ct_restart', 'backup_start', 'backup_complete'):
self.assertTrue(oci_operations.quiet(event(kind, 115), self.root), kind)
def test_another_container_is_still_reported(self):
self.mark(115, time.time())
self.assertFalse(oci_operations.quiet(event('ct_stop', 200), self.root))
self.assertFalse(oci_operations.quiet(event('ct_stop'), self.root))
def test_a_problem_is_never_silenced(self):
self.mark(115, time.time())
self.assertFalse(oci_operations.quiet(event('ct_stop', 115, severity='WARNING'), self.root))
self.assertFalse(oci_operations.quiet(event('ct_fail', 115), self.root))
self.assertFalse(oci_operations.quiet(event('backup_fail', 115), self.root))
def test_the_last_start_is_still_quiet_just_after_the_operation(self):
now = time.time()
self.mark(115, now - 60, ended=now - 60)
self.assertTrue(oci_operations.quiet(event('ct_start', 115), self.root))
self.mark(115, now - 3600, ended=now - 3600)
self.assertFalse(oci_operations.quiet(event('ct_start', 115), self.root))
def test_a_mark_left_by_a_dead_operation_expires(self):
self.mark(115, time.time() - 7 * 3600)
self.assertFalse(oci_operations.quiet(event('ct_stop', 115), self.root))
def test_the_working_copy_of_an_update_is_recognised_by_its_path(self):
archive = ('/usr/local/share/proxmenux/oci/instances/115/stack-transactions/abc/backup-117/'
'vzdump-lxc-117-2026_10_02-20_45_41.tar.zst')
self.assertTrue(oci_operations.quiet(event('backup_complete', pve_message=archive), self.root))
self.assertFalse(oci_operations.quiet(
event('backup_complete', pve_message='/var/lib/vz/dump/vzdump-lxc-117-2026.tar.zst'), self.root))
def test_a_backup_of_several_guests_is_quiet_only_when_all_belong_to_the_operation(self):
self.mark(115, time.time())
self.mark(117, time.time())
both = event('backup_start', reason='VM/CT:\n CT immich-server (115)\n CT immich-db (117)')
mixed = event('backup_start', reason='VM/CT:\n CT immich-db (117)\n CT other (200)')
self.assertTrue(oci_operations.quiet(both, self.root))
self.assertFalse(oci_operations.quiet(mixed, self.root))
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,123 @@
"""A secondary container of an OCI stack is registered with the version it
runs and no version tracking; its application is updated with the stack."""
import json
import sys
import tempfile
from pathlib import Path
import unittest
from unittest.mock import patch
SCRIPTS = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(SCRIPTS))
import lxc_apps
DIGEST = 'sha256:' + 'ab' * 32
class StackMemberRegistrationTests(unittest.TestCase):
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.root = Path(tmp.name)
(self.root / 'catalog').mkdir()
(self.root / 'catalog/index.json').write_text(json.dumps({'applications': []}))
patches = [patch.object(lxc_apps, '_OCI_INSTANCE_ROOT', str(self.root / 'instances')),
patch.object(lxc_apps, '_OCI_CATALOG_INDEX', str(self.root / 'catalog/index.json')),
patch.object(lxc_apps, '_oci_catalog_cache', None),
patch.object(lxc_apps, '_APPS_DIR', str(self.root / 'apps')),
patch.object(lxc_apps, '_OCI_DISMISSED_FILE', str(self.root / 'apps/.oci-dismissed.json'))]
for item in patches:
item.start()
self.addCleanup(item.stop)
def record(self, vmid, primary, reference):
folder = self.root / f'instances/{vmid}'
folder.mkdir(parents=True)
(folder / 'oci-compose.json').write_text(json.dumps({
'vmid': vmid, 'status': 'installed', 'installation_id': f'install-{vmid}',
'stack_member': {'name': 'database', 'primary_vmid': primary},
'stack': {'template': {'id': 'stack-nextcloud', 'catalog_ui': {'title': 'Nextcloud'},
'first_run': {'endpoints': [{'port': 80, 'scheme': 'http', 'path': '/'}]}}},
'observed': {'image': {'manifest_digest': DIGEST, 'architecture': 'amd64'}},
'template': {'id': f'stack-nextcloud-{vmid}',
'container_contract': {'image': {'reference': reference},
'ports': [{'container_port': 5432}]}}}))
def test_the_database_of_a_stack_is_registered_without_a_web_port(self):
self.record(131, 129, 'docker.io/library/postgres:16-alpine')
with patch.object(lxc_apps, 'add_app', return_value=(True, {})) as add:
self.assertTrue(lxc_apps.ensure_oci_registration(131))
payload = add.call_args.args[1]
self.assertEqual(payload['name'], 'Postgres')
self.assertEqual(payload['installed_via'], 'oci_image')
self.assertEqual(payload['ports'], [])
def test_a_secondary_container_is_told_apart_from_the_main_one(self):
self.assertTrue(lxc_apps._oci_secondary_member({'vmid': 131, 'stack_member': {'primary_vmid': 129}}))
self.assertFalse(lxc_apps._oci_secondary_member({'vmid': 129, 'stack_member': {'primary_vmid': 129}}))
self.assertFalse(lxc_apps._oci_secondary_member({'vmid': 120}))
self.assertFalse(lxc_apps._oci_secondary_member(None))
def test_the_registry_is_not_asked_for_a_secondary_container(self):
self.record(131, 129, 'docker.io/library/postgres:16-alpine')
class Engine:
@staticmethod
def resolve_candidate(reference, architecture):
assert '@' in reference, 'only the installed digest is read'
return {'version': '16.15', 'created': '2026-09-01T00:00:00Z', 'manifest_digest': DIGEST}
with patch.object(lxc_apps, '_oci_state_module', return_value=Engine):
result = lxc_apps._oci_image_versions(
131, with_latest=not lxc_apps._oci_secondary_member(lxc_apps._read_oci_record(131)))
self.assertEqual(result['installed_version'], '16.15')
self.assertNotIn('update_available', result)
self.assertNotIn('latest_version', result)
if __name__ == '__main__':
unittest.main()
class StackLogoTests(StackMemberRegistrationTests):
ICON = 'https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/immich.webp'
def stack(self):
(self.root / 'catalog/index.json').write_text(json.dumps({'applications': [
{'id': 'immich', 'template_id': 'image-immich', 'template': 'apps/immich.json', 'icon': self.ICON}]}))
members = {115: ('server', 'ghcr.io/immich-app/immich-server:release'),
116: ('machine-learning', 'ghcr.io/immich-app/immich-machine-learning:release'),
117: ('database', 'ghcr.io/immich-app/postgres:14-vectorchord0.4.3'),
118: ('valkey', 'docker.io/valkey/valkey:9')}
for vmid, (role, reference) in members.items():
folder = self.root / f'instances/{vmid}'
folder.mkdir(parents=True)
record = {'vmid': vmid, 'status': 'installed', 'installation_id': f'install-{vmid}',
'stack_member': {'name': role, 'primary_vmid': 115},
'observed': {'image': {'manifest_digest': DIGEST, 'architecture': 'amd64'}},
'template': {'id': f'image-immich-{role}',
'container_contract': {'image': {'reference': reference}, 'ports': []}}}
if vmid == 115:
record['stack'] = {'template': {'id': 'image-immich', 'catalog_ui': {'title': 'Immich', 'icon': None}}}
(folder / 'oci-compose.json').write_text(json.dumps(record))
def test_the_main_container_and_machine_learning_wear_the_application_logo(self):
self.stack()
self.assertEqual(lxc_apps._oci_instance_meta(115)['logo'], self.ICON)
self.assertEqual(lxc_apps._oci_instance_meta(116)['logo'], self.ICON)
def test_the_database_and_the_cache_wear_their_own(self):
self.stack()
self.assertTrue(lxc_apps._oci_instance_meta(117)['logo'].endswith('/postgresql.webp'))
self.assertTrue(lxc_apps._oci_instance_meta(118)['logo'].endswith('/valkey.webp'))
def test_an_application_registered_without_logo_takes_it_later(self):
self.stack()
sidecar = {'vmid': 115, 'apps': [{'id': 'a', 'name': 'Immich', 'installed_via': 'oci_image', 'logo_url': ''},
{'id': 'b', 'name': 'Other', 'installed_via': '', 'logo_url': ''}]}
written = {}
with patch.object(lxc_apps, '_read_sidecar', return_value=sidecar), \
patch.object(lxc_apps, '_write_sidecar', side_effect=lambda vmid, data: written.update(data)):
self.assertFalse(lxc_apps.ensure_oci_registration(115))
self.assertEqual(written['apps'][0]['logo_url'], self.ICON)
self.assertEqual(written['apps'][1]['logo_url'], '')
+32 -6
View File
@@ -132,6 +132,8 @@
"ALL Utilities": "TODAS las utilidades",
"ALLOWED_HOSTS cannot contain line breaks": "ALLOWED_HOSTS no puede contener saltos de línea",
"AList initial login": "Primera sesión de AList",
"AMD (ROCm)": "AMD (ROCm)",
"AMD (VA-API and ROCm detection)": "AMD (VA-API y detección con ROCm)",
"AMD CPU detected": "CPU AMD detectada",
"AMD CPU fixes applied successfully": "Las correcciones de CPU AMD se aplicaron con éxito",
"AMD GPU Tools installation completed!": "¡Se completó la instalación de las herramientas AMD GPU!",
@@ -139,6 +141,7 @@
"AMD GPU(s) detected:": "GPU AMD detectadas:",
"AMD KFD device": "dispositivo AMD KFD",
"AMD VA-API + OpenCL (official mod)": "AMD VA-API + OpenCL (modal oficial)",
"AMD compute device": "Dispositivo de cómputo AMD",
"AMD fixes have been successfully reverted": "Las correcciones de AMD se han revertido con éxito",
"AMD mesa drivers installed.": "Controladores Mesa de AMD instalados.",
"AMD softdep configured": "AMD softdep configurado",
@@ -168,8 +171,11 @@
"Acceleration": "Aceleración",
"Acceleration configuration cancelled": "Configuración de aceleración cancelada",
"Acceleration for CodeProject.AI": "Aceleración para CodeProject. AI",
"Acceleration for Faster Whisper": "Aceleración para Faster Whisper",
"Acceleration for Immich smart recognition": "Aceleración para el reconocimiento inteligente Immich",
"Acceleration for Ollama": "Aceleración para Ollama",
"Acceleration for Piper": "Aceleración para Piper",
"Acceleration for llama.cpp": "Aceleración para llama.cpp",
"Accept routes from other nodes?": "¿Aceptar rutas de otros nodos?",
"Accept this host monitoring profile?": "¿Aceptar este perfil de monitorización del host?",
"Access Scope:": "Ámbito de acceso:",
@@ -776,7 +782,7 @@
"Checking remaining interfaces": "Comprobando las interfaces restantes",
"Checking that the container keeps running...": "Comprobando que el contenedor sigue funcionando...",
"Checking that this version builds against the running kernel...": "Comprobando que esta versión se compila con el kernel en ejecución...",
"Checking the GPU of the machine learning container...": "Comprobando la GPU del contenedor de aprendizaje automático...",
"Checking the GPU of the machine learning container...": "Comprobando la GPU del contenedor Machine learning...",
"Checking the container before recreating it...": "Revisando el contenedor antes de recrearlo...",
"Checking the container before the update...": "Revisando el contenedor antes de la actualización...",
"Checking the device permissions for the application user...": "Verificación de los permisos del dispositivo para el usuario de la aplicación...",
@@ -1997,6 +2003,7 @@
"Enter the password for Samba user:": "Ingrese la contraseña para el usuario de Samba:",
"Enter the recovery passphrase set when the keyfile was created:": "ingrese la frase de contraseña de recuperación establecida cuando se creó el archivo de claves:",
"Enter the size in whole GB, for example": "Introduzca el tamaño en GB enteros, por ejemplo",
"Enter the value again.": "Introduce el valor de nuevo.",
"Enter username for Samba server:": "Ingrese el nombre de usuario para el servidor Samba:",
"Enter username:": "Introduzca nombre de usuario:",
"Enterprise Proxmox Ceph repository disabled": "Repositorio Enterprise Proxmox Ceph deshabilitado",
@@ -2383,6 +2390,7 @@
"French": "Francés",
"Freshrss is a free, self-hostable aggregator for rss feeds.": "Freshrss es un agregador libre y auto-hostable para las fuentes de rss.",
"Frigate WebUI": "Frigate WebUI",
"Frigate uses the AMD GPU for detection once /config/config.yaml defines a detector with type: onnx.": "Frigate usa la GPU AMD para la detección cuando /config/config.yaml define un detector con type: onnx.",
"Frigate uses the Coral once /config/config.yaml defines a detector with type: edgetpu and device: pci.": "Frigate usa el Coral cuando /config/config.yaml define un detector con type: edgetpu y device: pci.",
"Frigate uses the Intel NPU once /config/config.yaml defines a detector with type: openvino and device: NPU.": "Frigate usa la NPU de Intel cuando /config/config.yaml define un detector con type: openvino y device: NPU.",
"Full SMART Report": "Informe SMART completo",
@@ -2423,7 +2431,7 @@
"GPU already present in target VM — existing hostpci entry reused": "GPU ya presente en la máquina virtual de destino: se reutiliza la entrada hostpci existente",
"GPU audio added": "Audio GPU agregado",
"GPU audio already present in target VM — existing hostpci entry reused": "El audio de la GPU ya está presente en la máquina virtual de destino: se reutiliza la entrada hostpci existente",
"GPU available for machine learning:": "GPU disponible para el aprendizaje automático:",
"GPU available for machine learning:": "GPU disponible para Machine learning:",
"GPU driver blacklisted": "Controlador de GPU en lista negra",
"GPU guard hook will block concurrent start when another VM is already using this GPU": "El gancho de protección de GPU bloqueará el inicio simultáneo cuando otra VM ya esté usando esta GPU",
"GPU host driver blacklisted in /etc/modprobe.d/blacklist.conf": "Controlador de host de GPU incluido en la lista negra en /etc/modprobe.d/blacklist.conf",
@@ -2541,6 +2549,7 @@
"Hardware acceleration for Emby": "aceleración de hardware para Emby",
"Hardware acceleration for FileFlows": "aceleración de hardware para FileFlows",
"Hardware acceleration for Frigate": "aceleración de hardware para Frigate",
"Hardware acceleration for Immich": "Aceleración por hardware para Immich",
"Hardware acceleration for Jellyfin": "aceleración de hardware para Jellyfin",
"Hardware acceleration for Plex": "aceleración de hardware para Plex",
"Hardware acceleration for Roon Server": "aceleración de hardware para Roon Server",
@@ -3025,6 +3034,7 @@
"Insufficient memory. Skipping LXC": "Memoria insuficiente. Saltarse LXC",
"Insufficient space:": "Espacio insuficiente:",
"Integrity check failed on": "La verificación de integridad falló",
"Intel (SYCL)": "Intel (SYCL)",
"Intel CPU detected": "CPU Intel detectada",
"Intel GPU Tools installation completed!": "¡Se completó la instalación de las herramientas Intel GPU!",
"Intel GPU(s) detected:": "GPU Intel detectadas:",
@@ -3124,8 +3134,8 @@
"Invalid input": "Entrada no válida",
"Invalid internal volume": "Volumen interno inválido",
"Invalid list:": "Lista inválida:",
"Invalid machine learning CPU allocation:": "Aprendizaje de máquina inválida CPU:",
"Invalid machine learning resources": "Recursos de aprendizaje automático inválidos",
"Invalid machine learning CPU allocation:": "Asignación de CPU de Machine learning no válida:",
"Invalid machine learning resources": "Recursos de Machine learning no válidos",
"Invalid main member or duplicated members": "Miembros principales inválidos o miembros duplicados",
"Invalid media path": "Camino de los medios inválidos",
"Invalid media volume": "Volumen de medios inválidos",
@@ -3441,7 +3451,7 @@
"MOTD configuration was already up to date": "la configuración de MOTD ya estaba actualizada",
"Machine Type": "Tipo de máquina",
"Machine learning": "Aprendizaje automático",
"Machine learning profile not implemented; it is not replaced by CPU:": "Perfil de aprendizaje automático no implementado; no es reemplazado por CPU:",
"Machine learning profile not implemented; it is not replaced by CPU:": "Perfil de Machine learning no implementado; no se sustituye por CPU:",
"Machine type: q35": "Tipo de máquina: q35",
"Machine: q35": "Máquina: q35",
"Main endpoint not yet defined": "Punto final principal aún no definido",
@@ -4097,6 +4107,8 @@
"No unprivileged containers available in Proxmox.": "No hay contenedores sin privilegios disponibles en Proxmox.",
"No updates available — run a scan first or wait for the Monitor to refresh.": "No hay actualizaciones disponibles: primero ejecute un análisis o espere a que se actualice el monitor.",
"No updates or failed to fetch templates": "No hay actualizaciones o no se pudieron recuperar las plantillas",
"No usable GPU was found on this host. Immich will be installed on the CPU.": "No se ha encontrado ninguna GPU utilizable en este host. Immich se instalará en la CPU.",
"No usable GPU was found on this host. The application will be installed without hardware acceleration.": "No se ha encontrado ninguna GPU utilizable en este host. La aplicación se instalará sin aceleración por hardware.",
"No user groups found.": "No se encontraron grupos de usuarios.",
"No user-created disk storage or fstab mount found.": "No se encontró ningún almacenamiento en disco creado por el usuario ni montaje fstab.",
"No username provided.": "No se proporcionó ningún nombre de usuario.",
@@ -4678,6 +4690,8 @@
"RAM in MiB": "RAM en MiB",
"REPAIR SUMMARY": "RESUMEN DE REPARACIÓN",
"REQUIREMENTS:": "REQUISITOS:",
"ROCm requires /dev/kfd on the host": "ROCm requiere /dev/kfd en el host",
"ROCm requires the render device of an AMD GPU": "ROCm requiere el dispositivo de render de una GPU AMD",
"ROM dump not available — configuring without romfile.": "Volcado de ROM no disponible: configuración sin archivo rom.",
"ROM file used": "archivo ROM utilizado",
"RPC Bind Service: RUNNING": "Servicio de enlace RPC: EN EJECUCIÓN",
@@ -4743,7 +4757,9 @@
"Recent Samba server": "Servidor Samba reciente",
"Recent logs:": "Registros recientes:",
"Recent test results:": "Resultados de pruebas recientes:",
"Recognition on AMD uses ROCm. Its image is several times larger than the others, so the first installation takes longer, and whether a GPU works with it depends on its model.": "El reconocimiento en AMD usa ROCm. Su imagen es varias veces mayor que las demás, por lo que la primera instalación tarda más, y que una GPU funcione con ella depende de su modelo.",
"Recognition profile not implemented": "Perfil de reconocimiento no implementado",
"Recognition runs on the CPU.": "El reconocimiento se ejecuta en la CPU.",
"Recommendation: reformat the disk to ext4 for a robust setup — see docs.": "Recomendación: vuelva a formatear el disco a ext4 para una configuración sólida; consulte los documentos.",
"Recommendation: start with Complete restore.": "Recomendación: comience con la restauración completa.",
"Recommendation: use 'Export to file' for these paths and apply manually during a maintenance window.": "Recomendación: use 'Exportar a archivo' para estas rutas y aplíquelo manualmente durante una ventana de mantenimiento.",
@@ -5868,6 +5884,7 @@
"That VM is currently stopped, so the GPU can be reassigned now.": "Esa VM está actualmente detenida, por lo que la GPU se puede reasignar ahora.",
"That doesn't look like an SSH private key. Pick the private key file (no .pub extension, parseable by ssh-keygen).": "Eso no parece una clave privada SSH.Elige el archivo de clave privada (sin extensión .pub, analizable mediante ssh-keygen).",
"The .conf files under /config/fail2ban are rewritten on every start. Keep customizations in the matching .local file, for example jail.local for jail.conf.": "Los archivos .conf bajo /config/fail2ban son reescritos en cada inicio. Mantenga las personalizaciones en el archivo .local coincidente, por ejemplo la cárcel.local para jail.conf.",
"The AMD driver does not offer its compute interface (/dev/kfd) on this host.": "El driver de AMD no ofrece su interfaz de cómputo (/dev/kfd) en este host.",
"The AppArmor/seccomp relaxation does not include the required consent": "La relajación AppArmor/seccomp no incluye el consentimiento necesario",
"The Bookmark Everything App": "La aplicación Todo Marcador",
"The Brave browser is a fast, private and secure web browser for PC, Mac and mobile.": "El navegador Brave es un navegador web rápido, privado y seguro para PC, Mac y móvil.",
@@ -6247,7 +6264,7 @@
"The removal could not be prepared:": "No se pudo preparar la eliminación:",
"The repair must preserve the image dependencies:": "La reparación debe preservar las dependencias de la imagen:",
"The requested VMID block is already in use": "El bloque VMID solicitado ya está en uso",
"The requested machine learning GPU profile is not working; it is not replaced by CPU": "El perfil GPU de aprendizaje automático solicitado no funciona; no es reemplazado por CPU",
"The requested machine learning GPU profile is not working; it is not replaced by CPU": "El perfil de GPU solicitado para Machine learning no funciona; no se sustituye por CPU",
"The restored service did not pass its health check": "El servicio restaurado no aprobó su cheque de salud",
"The restored service stopped; the recovery is not confirmed": "El servicio restaurado se detuvo; la recuperación no se confirma",
"The reviewed Tandoor stack does not require a privileged LXC.": "La pila de Tandoor revisada no requiere un LXC privilegiado.",
@@ -6267,6 +6284,7 @@
"The script will preconfigure the selected GPU now and finalize hardware binding after reboot.": "El script preconfigurará la GPU seleccionada ahora y finalizará el enlace del hardware después del reinicio.",
"The selected AMD GPU does not report FLR reset support": "La GPU AMD seleccionada no informa compatibilidad con el restablecimiento de FLR",
"The selected AMD GPU is currently in power state D3cold": "La GPU AMD seleccionada se encuentra actualmente en estado de energía D3cold",
"The selected AMD render device does not exist:": "El dispositivo de render AMD seleccionado no existe:",
"The selected CT does not match its OCI record. Its configuration will not be modified or deleted.": "El CT seleccionado no coincide con su registro OCI. Su configuración no se modificará ni se eliminará.",
"The selected GPU changed": "La GPU seleccionada cambió",
"The selected GPU configuration already exists in this container.": "La configuración de GPU seleccionada ya existe en este contenedor.",
@@ -6340,6 +6358,7 @@
"The staticfiles volume needs at least 1 GB": "El volumen de los ficheros estáticos necesita al menos 1 GB",
"The storage does not accept backups:": "El almacenamiento no admite backups:",
"The storage has been removed and the disk unmounted.": "Se eliminó el almacenamiento y se desmontó el disco.",
"The storage has less than 40 GB free for the ROCm image.": "El almacenamiento tiene menos de 40 GB libres para la imagen de ROCm.",
"The sysctl content was modified outside the saved record": "El contenido de sysctl fue modificado fuera del registro guardado",
"The sysctl include is a link:": "El include de sysctl es un enlace:",
"The sysctl include is not a safe host file": "El include de sysctl no es un archivo seguro del host",
@@ -6351,6 +6370,7 @@
"The tmpfs path or size is outside the supported profile": "El camino o tamaño de tmpfs está fuera del perfil soportado",
"The translated recipe changed during the preparation": "La receta traducida cambió durante la preparación",
"The value contains an unsupported character": "El valor contiene un carácter no admitido",
"The value must be a number:": "El valor debe ser un número:",
"The values do not match. Enter them again.": "Los valores no coinciden. Vuelve a introducirlos.",
"The variable contains control characters:": "La variable contiene caracteres de control:",
"The variable contains line breaks:": "La variable contiene roturas de línea:",
@@ -6933,6 +6953,7 @@
"Video": "Vídeo",
"Video acceleration and object detection are independent choices; the installer does not write camera or detector YAML.": "La aceleración de vídeo y la detección de objetos son opciones independientes; el instalador no escribe cámara o detector YAML.",
"Video transcoding acceleration": "Aceleración de transcodificación de vídeo",
"Video transcoding profile not implemented:": "Perfil de transcodificación de vídeo no implementado:",
"View CIFS Mounts (pvesm + fstab)": "Ver montajes CIFS (pvesm + fstab)",
"View Current Exports": "Ver exportaciones actuales",
"View Current Mounts": "Ver montajes actuales",
@@ -7498,6 +7519,8 @@
"read-only": "solo lectura",
"reboot-quick alias added": "alias de reinicio rápido agregado",
"reboot-quick alias is already configured": "el alias de reinicio rápido ya está configurado",
"recognition": "reconocimiento",
"recognition only": "solo reconocimiento",
"recommended": "recomendado",
"recovering": "recuperando",
"remapped users": "usuarios reasignados",
@@ -7589,6 +7612,9 @@
"vCPUs:": "vCPU:",
"vfio-pci IDs configured": "ID de vfio-pci configurados",
"vfio-pci IDs in /etc/modprobe.d/vfio.conf": "ID de vfio-pci en /etc/modprobe.d/vfio.conf",
"video": "vídeo",
"video + recognition": "vídeo + reconocimiento",
"video only": "solo vídeo",
"vzdump backup speed optimization completed": "Optimización de la velocidad de copia de seguridad de vzdump completada",
"wallabag builds its links from the address given during the installation. If it does not match the address of the container, edit lxc.environment.runtime: SYMFONY__ENV__DOMAIN_NAME in /etc/pve/lxc/<CTID>.conf with the container stopped, and start it again.": "wallabag construye sus enlaces desde la dirección dada durante la instalación. Si no coincide con la dirección del contenedor, edite lxc.environment. tiempo de ejecución: SYMFONY DOMAIN NAME en /etc/pve/lxc/ se hizo referencia aCTID ratio.conf con el contenedor parado, y comenzar de nuevo.",
"wallabag listens on port 80 of the container and stores its data in SQLite.": "wallabag escucha en el puerto 80 del contenedor y almacena sus datos en SQLite.",
+53 -1
View File
@@ -246,7 +246,59 @@
"owner_strategy": "mapped-root",
"only_when_mount_type": "managed-volume"
}
]
],
"hardware_acceleration": {
"prompt": "Acceleration for Faster Whisper",
"default": "cpu",
"profiles": [
{
"id": "cpu",
"label": "CPU",
"image": {
"reference": "lscr.io/linuxserver/faster-whisper:latest",
"registry": "lscr.io",
"repository": "lscr.io/linuxserver/faster-whisper",
"tag": "latest",
"digest": null,
"pull_policy": "resolve-selected-tag-to-architecture-digest-at-install"
},
"device_requests": []
},
{
"id": "nvidia",
"label": "NVIDIA (CUDA)",
"architectures": [
"amd64"
],
"image": {
"reference": "lscr.io/linuxserver/faster-whisper:gpu",
"registry": "lscr.io",
"repository": "lscr.io/linuxserver/faster-whisper",
"tag": "gpu",
"digest": null,
"pull_policy": "resolve-selected-tag-to-architecture-digest-at-install"
},
"device_requests": [
{
"id": "nvidia-runtime",
"kind": "nvidia-runtime",
"purpose": "nvidia-cuda",
"device_selection": "all-requested-by-compose"
}
],
"environment": [
{
"name": "NVIDIA_VISIBLE_DEVICES",
"value": "all"
},
{
"name": "NVIDIA_DRIVER_CAPABILITIES",
"value": "compute,utility"
}
]
}
]
}
}
},
"compatibility": {
+43
View File
@@ -884,6 +884,49 @@
}
]
},
{
"id": "rocm",
"label": "AMD (VA-API and ROCm detection)",
"architectures": [
"amd64"
],
"image": {
"reference": "ghcr.io/blakeblackshear/frigate:stable-rocm",
"registry": "ghcr.io",
"repository": "ghcr.io/blakeblackshear/frigate",
"tag": "stable-rocm",
"digest": null,
"pull_policy": "resolve-rolling-stable-tag-to-architecture-digest-at-install"
},
"device_requests": [
{
"id": "gpu-render",
"path_prompt": "GPU render device",
"host_path_default": "/dev/dri/renderD128",
"mode": "0660",
"deny_write": false,
"gid_strategy": "host-device-gid",
"kind": "character-device",
"container_path_strategy": "same-as-host",
"drm_vendor_ids": [
"0x1002"
]
},
{
"id": "amd-kfd",
"path_prompt": "AMD compute device",
"host_path_default": "/dev/kfd",
"mode": "0660",
"deny_write": false,
"gid_strategy": "host-device-gid",
"kind": "character-device",
"container_path_strategy": "same-as-host"
}
],
"completion_notes": [
"Frigate uses the AMD GPU for detection once /config/config.yaml defines a detector with type: onnx."
]
},
{
"id": "nvidia",
"label": "NVIDIA (NVDEC/CUDA)",
+124 -1
View File
@@ -254,7 +254,130 @@
"working_dir": "import-from-oci-image",
"stop_signal": "import-from-oci-image"
},
"installer_profile": {},
"installer_profile": {
"hardware_acceleration": {
"prompt": "Acceleration for llama.cpp",
"default": "cpu",
"profiles": [
{
"id": "cpu",
"label": "CPU",
"image": {
"reference": "ghcr.io/ggml-org/llama.cpp:server",
"registry": "ghcr.io",
"repository": "ghcr.io/ggml-org/llama.cpp",
"tag": "server",
"digest": null,
"pull_policy": "resolve-selected-tag-to-architecture-digest-at-install"
},
"device_requests": []
},
{
"id": "nvidia",
"label": "NVIDIA (CUDA)",
"architectures": [
"amd64"
],
"image": {
"reference": "ghcr.io/ggml-org/llama.cpp:server-cuda",
"registry": "ghcr.io",
"repository": "ghcr.io/ggml-org/llama.cpp",
"tag": "server-cuda",
"digest": null,
"pull_policy": "resolve-selected-tag-to-architecture-digest-at-install"
},
"device_requests": [
{
"id": "nvidia-runtime",
"kind": "nvidia-runtime",
"purpose": "nvidia-cuda",
"device_selection": "all-requested-by-compose"
}
],
"environment": [
{
"name": "NVIDIA_VISIBLE_DEVICES",
"value": "all"
},
{
"name": "NVIDIA_DRIVER_CAPABILITIES",
"value": "compute,utility"
}
]
},
{
"id": "rocm",
"label": "AMD (ROCm)",
"architectures": [
"amd64"
],
"image": {
"reference": "ghcr.io/ggml-org/llama.cpp:server-rocm",
"registry": "ghcr.io",
"repository": "ghcr.io/ggml-org/llama.cpp",
"tag": "server-rocm",
"digest": null,
"pull_policy": "resolve-selected-tag-to-architecture-digest-at-install"
},
"device_requests": [
{
"id": "gpu-render",
"path_prompt": "GPU render device",
"host_path_default": "/dev/dri/renderD128",
"mode": "0660",
"deny_write": false,
"gid_strategy": "host-device-gid",
"kind": "character-device",
"container_path_strategy": "same-as-host",
"drm_vendor_ids": [
"0x1002"
]
},
{
"id": "amd-kfd",
"path_prompt": "AMD compute device",
"host_path_default": "/dev/kfd",
"mode": "0660",
"deny_write": false,
"gid_strategy": "host-device-gid",
"kind": "character-device",
"container_path_strategy": "same-as-host"
}
]
},
{
"id": "intel",
"label": "Intel (SYCL)",
"architectures": [
"amd64"
],
"image": {
"reference": "ghcr.io/ggml-org/llama.cpp:server-intel",
"registry": "ghcr.io",
"repository": "ghcr.io/ggml-org/llama.cpp",
"tag": "server-intel",
"digest": null,
"pull_policy": "resolve-selected-tag-to-architecture-digest-at-install"
},
"device_requests": [
{
"id": "gpu-render",
"path_prompt": "GPU render device",
"host_path_default": "/dev/dri/renderD128",
"mode": "0660",
"deny_write": false,
"gid_strategy": "host-device-gid",
"kind": "character-device",
"container_path_strategy": "same-as-host",
"drm_vendor_ids": [
"0x8086"
]
}
]
}
]
}
},
"adaptations": [
{
"id": "imported-compose-source",
+59
View File
@@ -371,11 +371,30 @@
{
"id": "cpu",
"label": "CPU",
"image": {
"reference": "ollama/ollama:latest",
"registry": "docker.io",
"repository": "ollama/ollama",
"tag": "latest",
"digest": null,
"pull_policy": "resolve-selected-tag-to-architecture-digest-at-install"
},
"device_requests": []
},
{
"id": "nvidia",
"label": "NVIDIA (CUDA)",
"architectures": [
"amd64"
],
"image": {
"reference": "ollama/ollama:latest",
"registry": "docker.io",
"repository": "ollama/ollama",
"tag": "latest",
"digest": null,
"pull_policy": "resolve-selected-tag-to-architecture-digest-at-install"
},
"device_requests": [
{
"id": "nvidia-runtime",
@@ -390,6 +409,46 @@
"value": "all"
}
]
},
{
"id": "rocm",
"label": "AMD (ROCm)",
"architectures": [
"amd64"
],
"image": {
"reference": "ollama/ollama:rocm",
"registry": "docker.io",
"repository": "ollama/ollama",
"tag": "rocm",
"digest": null,
"pull_policy": "resolve-selected-tag-to-architecture-digest-at-install"
},
"device_requests": [
{
"id": "gpu-render",
"path_prompt": "GPU render device",
"host_path_default": "/dev/dri/renderD128",
"mode": "0660",
"deny_write": false,
"gid_strategy": "host-device-gid",
"kind": "character-device",
"container_path_strategy": "same-as-host",
"drm_vendor_ids": [
"0x1002"
]
},
{
"id": "amd-kfd",
"path_prompt": "AMD compute device",
"host_path_default": "/dev/kfd",
"mode": "0660",
"deny_write": false,
"gid_strategy": "host-device-gid",
"kind": "character-device",
"container_path_strategy": "same-as-host"
}
]
}
]
},
+53 -1
View File
@@ -260,7 +260,59 @@
"owner_strategy": "mapped-root",
"only_when_mount_type": "managed-volume"
}
]
],
"hardware_acceleration": {
"prompt": "Acceleration for Piper",
"default": "cpu",
"profiles": [
{
"id": "cpu",
"label": "CPU",
"image": {
"reference": "lscr.io/linuxserver/piper:latest",
"registry": "lscr.io",
"repository": "lscr.io/linuxserver/piper",
"tag": "latest",
"digest": null,
"pull_policy": "resolve-selected-tag-to-architecture-digest-at-install"
},
"device_requests": []
},
{
"id": "nvidia",
"label": "NVIDIA (CUDA)",
"architectures": [
"amd64"
],
"image": {
"reference": "lscr.io/linuxserver/piper:gpu",
"registry": "lscr.io",
"repository": "lscr.io/linuxserver/piper",
"tag": "gpu",
"digest": null,
"pull_policy": "resolve-selected-tag-to-architecture-digest-at-install"
},
"device_requests": [
{
"id": "nvidia-runtime",
"kind": "nvidia-runtime",
"purpose": "nvidia-cuda",
"device_selection": "all-requested-by-compose"
}
],
"environment": [
{
"name": "NVIDIA_VISIBLE_DEVICES",
"value": "all"
},
{
"name": "NVIDIA_DRIVER_CAPABILITIES",
"value": "compute,utility"
}
]
}
]
}
}
},
"compatibility": {
+43
View File
@@ -884,6 +884,49 @@
}
]
},
{
"id": "rocm",
"label": "AMD (VA-API and ROCm detection)",
"architectures": [
"amd64"
],
"image": {
"reference": "ghcr.io/blakeblackshear/frigate:stable-rocm",
"registry": "ghcr.io",
"repository": "ghcr.io/blakeblackshear/frigate",
"tag": "stable-rocm",
"digest": null,
"pull_policy": "resolve-rolling-stable-tag-to-architecture-digest-at-install"
},
"device_requests": [
{
"id": "gpu-render",
"path_prompt": "GPU render device",
"host_path_default": "/dev/dri/renderD128",
"mode": "0660",
"deny_write": false,
"gid_strategy": "host-device-gid",
"kind": "character-device",
"container_path_strategy": "same-as-host",
"drm_vendor_ids": [
"0x1002"
]
},
{
"id": "amd-kfd",
"path_prompt": "AMD compute device",
"host_path_default": "/dev/kfd",
"mode": "0660",
"deny_write": false,
"gid_strategy": "host-device-gid",
"kind": "character-device",
"container_path_strategy": "same-as-host"
}
],
"completion_notes": [
"Frigate uses the AMD GPU for detection once /config/config.yaml defines a detector with type: onnx."
]
},
{
"id": "nvidia",
"label": "NVIDIA (NVDEC/CUDA)",
+124 -1
View File
@@ -254,7 +254,130 @@
"working_dir": "import-from-oci-image",
"stop_signal": "import-from-oci-image"
},
"installer_profile": {},
"installer_profile": {
"hardware_acceleration": {
"prompt": "Acceleration for llama.cpp",
"default": "cpu",
"profiles": [
{
"id": "cpu",
"label": "CPU",
"image": {
"reference": "ghcr.io/ggml-org/llama.cpp:server",
"registry": "ghcr.io",
"repository": "ghcr.io/ggml-org/llama.cpp",
"tag": "server",
"digest": null,
"pull_policy": "resolve-selected-tag-to-architecture-digest-at-install"
},
"device_requests": []
},
{
"id": "nvidia",
"label": "NVIDIA (CUDA)",
"architectures": [
"amd64"
],
"image": {
"reference": "ghcr.io/ggml-org/llama.cpp:server-cuda",
"registry": "ghcr.io",
"repository": "ghcr.io/ggml-org/llama.cpp",
"tag": "server-cuda",
"digest": null,
"pull_policy": "resolve-selected-tag-to-architecture-digest-at-install"
},
"device_requests": [
{
"id": "nvidia-runtime",
"kind": "nvidia-runtime",
"purpose": "nvidia-cuda",
"device_selection": "all-requested-by-compose"
}
],
"environment": [
{
"name": "NVIDIA_VISIBLE_DEVICES",
"value": "all"
},
{
"name": "NVIDIA_DRIVER_CAPABILITIES",
"value": "compute,utility"
}
]
},
{
"id": "rocm",
"label": "AMD (ROCm)",
"architectures": [
"amd64"
],
"image": {
"reference": "ghcr.io/ggml-org/llama.cpp:server-rocm",
"registry": "ghcr.io",
"repository": "ghcr.io/ggml-org/llama.cpp",
"tag": "server-rocm",
"digest": null,
"pull_policy": "resolve-selected-tag-to-architecture-digest-at-install"
},
"device_requests": [
{
"id": "gpu-render",
"path_prompt": "GPU render device",
"host_path_default": "/dev/dri/renderD128",
"mode": "0660",
"deny_write": false,
"gid_strategy": "host-device-gid",
"kind": "character-device",
"container_path_strategy": "same-as-host",
"drm_vendor_ids": [
"0x1002"
]
},
{
"id": "amd-kfd",
"path_prompt": "AMD compute device",
"host_path_default": "/dev/kfd",
"mode": "0660",
"deny_write": false,
"gid_strategy": "host-device-gid",
"kind": "character-device",
"container_path_strategy": "same-as-host"
}
]
},
{
"id": "intel",
"label": "Intel (SYCL)",
"architectures": [
"amd64"
],
"image": {
"reference": "ghcr.io/ggml-org/llama.cpp:server-intel",
"registry": "ghcr.io",
"repository": "ghcr.io/ggml-org/llama.cpp",
"tag": "server-intel",
"digest": null,
"pull_policy": "resolve-selected-tag-to-architecture-digest-at-install"
},
"device_requests": [
{
"id": "gpu-render",
"path_prompt": "GPU render device",
"host_path_default": "/dev/dri/renderD128",
"mode": "0660",
"deny_write": false,
"gid_strategy": "host-device-gid",
"kind": "character-device",
"container_path_strategy": "same-as-host",
"drm_vendor_ids": [
"0x8086"
]
}
]
}
]
}
},
"adaptations": [
{
"id": "imported-compose-source",
+53 -1
View File
@@ -8,7 +8,59 @@
"owner_strategy": "mapped-root",
"only_when_mount_type": "managed-volume"
}
]
],
"hardware_acceleration": {
"prompt": "Acceleration for Faster Whisper",
"default": "cpu",
"profiles": [
{
"id": "cpu",
"label": "CPU",
"image": {
"reference": "lscr.io/linuxserver/faster-whisper:latest",
"registry": "lscr.io",
"repository": "lscr.io/linuxserver/faster-whisper",
"tag": "latest",
"digest": null,
"pull_policy": "resolve-selected-tag-to-architecture-digest-at-install"
},
"device_requests": []
},
{
"id": "nvidia",
"label": "NVIDIA (CUDA)",
"architectures": [
"amd64"
],
"image": {
"reference": "lscr.io/linuxserver/faster-whisper:gpu",
"registry": "lscr.io",
"repository": "lscr.io/linuxserver/faster-whisper",
"tag": "gpu",
"digest": null,
"pull_policy": "resolve-selected-tag-to-architecture-digest-at-install"
},
"device_requests": [
{
"id": "nvidia-runtime",
"kind": "nvidia-runtime",
"purpose": "nvidia-cuda",
"device_selection": "all-requested-by-compose"
}
],
"environment": [
{
"name": "NVIDIA_VISIBLE_DEVICES",
"value": "all"
},
{
"name": "NVIDIA_DRIVER_CAPABILITIES",
"value": "compute,utility"
}
]
}
]
}
}
}
}
+59
View File
@@ -12,11 +12,30 @@
{
"id": "cpu",
"label": "CPU",
"image": {
"reference": "ollama/ollama:latest",
"registry": "docker.io",
"repository": "ollama/ollama",
"tag": "latest",
"digest": null,
"pull_policy": "resolve-selected-tag-to-architecture-digest-at-install"
},
"device_requests": []
},
{
"id": "nvidia",
"label": "NVIDIA (CUDA)",
"architectures": [
"amd64"
],
"image": {
"reference": "ollama/ollama:latest",
"registry": "docker.io",
"repository": "ollama/ollama",
"tag": "latest",
"digest": null,
"pull_policy": "resolve-selected-tag-to-architecture-digest-at-install"
},
"device_requests": [
{
"id": "nvidia-runtime",
@@ -31,6 +50,46 @@
"value": "all"
}
]
},
{
"id": "rocm",
"label": "AMD (ROCm)",
"architectures": [
"amd64"
],
"image": {
"reference": "ollama/ollama:rocm",
"registry": "docker.io",
"repository": "ollama/ollama",
"tag": "rocm",
"digest": null,
"pull_policy": "resolve-selected-tag-to-architecture-digest-at-install"
},
"device_requests": [
{
"id": "gpu-render",
"path_prompt": "GPU render device",
"host_path_default": "/dev/dri/renderD128",
"mode": "0660",
"deny_write": false,
"gid_strategy": "host-device-gid",
"kind": "character-device",
"container_path_strategy": "same-as-host",
"drm_vendor_ids": [
"0x1002"
]
},
{
"id": "amd-kfd",
"path_prompt": "AMD compute device",
"host_path_default": "/dev/kfd",
"mode": "0660",
"deny_write": false,
"gid_strategy": "host-device-gid",
"kind": "character-device",
"container_path_strategy": "same-as-host"
}
]
}
]
},
+53 -1
View File
@@ -8,7 +8,59 @@
"owner_strategy": "mapped-root",
"only_when_mount_type": "managed-volume"
}
]
],
"hardware_acceleration": {
"prompt": "Acceleration for Piper",
"default": "cpu",
"profiles": [
{
"id": "cpu",
"label": "CPU",
"image": {
"reference": "lscr.io/linuxserver/piper:latest",
"registry": "lscr.io",
"repository": "lscr.io/linuxserver/piper",
"tag": "latest",
"digest": null,
"pull_policy": "resolve-selected-tag-to-architecture-digest-at-install"
},
"device_requests": []
},
{
"id": "nvidia",
"label": "NVIDIA (CUDA)",
"architectures": [
"amd64"
],
"image": {
"reference": "lscr.io/linuxserver/piper:gpu",
"registry": "lscr.io",
"repository": "lscr.io/linuxserver/piper",
"tag": "gpu",
"digest": null,
"pull_policy": "resolve-selected-tag-to-architecture-digest-at-install"
},
"device_requests": [
{
"id": "nvidia-runtime",
"kind": "nvidia-runtime",
"purpose": "nvidia-cuda",
"device_selection": "all-requested-by-compose"
}
],
"environment": [
{
"name": "NVIDIA_VISIBLE_DEVICES",
"value": "all"
},
{
"name": "NVIDIA_DRIVER_CAPABILITIES",
"value": "compute,utility"
}
]
}
]
}
}
}
}
+8 -4
View File
@@ -121,7 +121,7 @@ MEDIA_SIZE=$(jq -r '.media.size_gb // empty' "$DEPLOYMENT_FILE")
MEDIA_ROOT=$(jq -r '.media.host_path // empty' "$DEPLOYMENT_FILE")
TIMEZONE=$(jqr '.timezone')
APPLICATION_CORES=$(jqr '.resources.cores // 4')
APPLICATION_MEMORY=$(jqr '.resources.memory_mb // 3072')
APPLICATION_MEMORY=$(jqr '.resources.memory_mb // 4096')
APPLICATION_SWAP=$(jqr '.resources.swap_mb // 1024')
[[ $APPLICATION_CORES =~ ^[1-9][0-9]*$ && $APPLICATION_MEMORY =~ ^[1-9][0-9]*$ && $APPLICATION_SWAP =~ ^[0-9]+$ ]] \
|| die "$(translate "Invalid resources")"
@@ -149,6 +149,8 @@ ML_IP=${ML_ADDRESS%/*}
DB_IP=${DB_ADDRESS%/*}
VALKEY_IP=${VALKEY_ADDRESS%/*}
VIDEO_ACCELERATION=$(jqr '.video_transcoding.acceleration')
[[ $VIDEO_ACCELERATION == cpu || $VIDEO_ACCELERATION == vaapi || $VIDEO_ACCELERATION == nvenc ]] \
|| die "$(translate "Video transcoding profile not implemented:") $VIDEO_ACCELERATION"
RENDER_DEVICE=$(jq -r '.video_transcoding.render_device // empty' "$DEPLOYMENT_FILE")
VAAPI_DRIVER=$(jqr '.video_transcoding.driver')
MODEL_CACHE_SIZE=$(jqr '.machine_learning.model_cache_size_gb')
@@ -417,7 +419,7 @@ set_lxc_directive "$VALKEY_ID" lxc.signal.halt SIGTERM
msg_ok "$(translate "Container created:") CT $VALKEY_ID (Valkey)"
msg_info "$(translate "Creating the container...")"
oci_create_container "$ML_ID" "$ML_ARCHIVE" --rootfs "${ROOTFS_STORAGE}:12" \
oci_create_container "$ML_ID" "$ML_ARCHIVE" --rootfs "${ROOTFS_STORAGE}:${ML_ROOTFS_SIZE}" \
--mp0 "${ROOTFS_STORAGE}:${MODEL_CACHE_SIZE},mp=/cache,backup=1" \
--hostname "${STACK_NAME}-ml" "${ML_CPU_ARGS[@]}" --memory "$ML_MEMORY" --swap "$ML_SWAP" \
--net0 "name=eth0,bridge=${FRONTEND_BRIDGE},firewall=1,host-managed=1,${ML_FRONTEND_NET},type=veth" \
@@ -442,7 +444,7 @@ rm -rf "$ML_ROOT/cache/lost+found"
chown 100000:100000 "$ML_ROOT/cache"
chmod 0755 "$ML_ROOT/cache"
oci_quiet pct unmount "$ML_ID"
msg_ok "$(translate "Container created:") CT $ML_ID ($(translate "Machine learning"))"
msg_ok "$(translate "Container created:") CT $ML_ID (Machine learning)"
SERVER_DEVICE_ARGS=()
if [[ $VIDEO_ACCELERATION == vaapi ]]; then
@@ -463,6 +465,8 @@ oci_create_container "$SERVER_ID" "$SERVER_ARCHIVE" --rootfs "${ROOTFS_STORAGE}:
created_ids+=("$SERVER_ID")
oci_apply_extra_mounts "$SERVER_ID"
oci_apply_extra_devices "$SERVER_ID"
# NVENC needs the video capability on top of what recognition uses.
[[ $VIDEO_ACCELERATION != nvenc ]] || configure_immich_nvidia "$SERVER_ID" "compute,video,utility"
oci_quiet pct mount "$SERVER_ID"
SERVER_ROOT="/var/lib/lxc/${SERVER_ID}/rootfs"
@@ -555,7 +559,7 @@ if (( START_AFTER == 1 )); then
oci_quiet pct start "$VALKEY_ID"
wait_command Valkey 30 pct exec "$VALKEY_ID" -- valkey-cli -h "$VALKEY_IP" ping
msg_ok "$(translate "Service ready:") Valkey"
ML_LABEL=$(translate "Machine learning")
ML_LABEL="Machine learning"
msg_info "$(translate "Starting the service:") $ML_LABEL"
oci_quiet pct start "$ML_ID"
wait_command "$ML_LABEL" 60 curl -fsS "http://${ML_IP}:3003/ping"
+51 -24
View File
@@ -1,9 +1,22 @@
# Immich ML prerequisites and native GPU setup; no host driver installation.
validate_immich_ml_profile() {
ML_CPU_ARGS=(--cores 2)
ML_MEMORY=2048
ML_CPU_ARGS=(--cores 4)
ML_MEMORY=4096
ML_ROOTFS_SIZE=12
case "$ML_ACCELERATION" in
cpu) ;;
rocm)
ML_RENDER_DEVICE=$(jq -er '.machine_learning.render_device' "$DEPLOYMENT_FILE")
[[ $ML_RENDER_DEVICE =~ ^/dev/dri/renderD[0-9]+$ && -c $ML_RENDER_DEVICE ]] \
|| die "$(translate "The selected AMD render device does not exist:") $ML_RENDER_DEVICE"
[[ $(cat "/sys/class/drm/${ML_RENDER_DEVICE##*/}/device/vendor") == 0x1002 ]] \
|| die "$(translate "ROCm requires the render device of an AMD GPU")"
[[ -c /dev/kfd ]] || die "$(translate "ROCm requires /dev/kfd on the host")"
ML_MEMORY=8192
# The ROCm image carries the whole AMD runtime and is several times
# larger than the others.
ML_ROOTFS_SIZE=40
;;
openvino)
ML_RENDER_DEVICE=$(jq -er '.machine_learning.render_device' "$DEPLOYMENT_FILE")
[[ $ML_RENDER_DEVICE =~ ^/dev/dri/renderD[0-9]+$ && -c $ML_RENDER_DEVICE ]] \
@@ -57,34 +70,46 @@ validate_immich_ml_profile() {
esac
}
# Gives one container of the stack the NVIDIA GPU through the dynamic hook.
# Arguments: VMID CAPABILITIES
configure_immich_nvidia() {
# Isolate the shared standalone installer's runtime context from the stack.
(
VMID=$1
CONF="/etc/pve/lxc/${VMID}.conf"
UNPRIVILEGED_FLAG=1
DEVICE='{"kind":"nvidia-runtime","runtime_mode":"dynamic"}'
NVIDIA_GID_ENV=""
DEVICE_INDEX=0
while grep -q "^dev${DEVICE_INDEX}:" "$CONF"; do DEVICE_INDEX=$((DEVICE_INDEX + 1)); done
fragment=$(mktemp)
trap 'rm -f "$fragment"' EXIT
jq -nc --arg capabilities "$2" \
'{environment:[{name:"NVIDIA_DRIVER_CAPABILITIES",value:$capabilities}]}' >"$fragment"
DEPLOYMENT_FILE=$fragment
add_character_device() {
local path=$1 mode gid
[[ -c $path && $path == /dev/nvidia* ]] || die "$(translate "Invalid NVIDIA device:") $path"
mode="0$(stat -c %a "$path")"
gid=$(stat -c %g "$path")
oci_quiet pct set "$VMID" "--dev${DEVICE_INDEX}" "path=${path},mode=${mode},gid=${gid},deny-write=0"
DEVICE_INDEX=$((DEVICE_INDEX + 1))
}
configure_nvidia_runtime
)
}
configure_immich_ml_gpu() {
case "$ML_ACCELERATION" in
openvino)
oci_quiet pct set "$ML_ID" --dev0 "path=${ML_RENDER_DEVICE},gid=$(stat -c %g "$ML_RENDER_DEVICE"),mode=0660"
;;
rocm)
oci_quiet pct set "$ML_ID" --dev0 "path=${ML_RENDER_DEVICE},gid=$(stat -c %g "$ML_RENDER_DEVICE"),mode=0660"
oci_quiet pct set "$ML_ID" --dev1 "path=/dev/kfd,gid=$(stat -c %g /dev/kfd),mode=0660"
;;
cuda)
# Isolate the shared standalone installer's runtime context from the stack.
(
VMID=$ML_ID
CONF="/etc/pve/lxc/${ML_ID}.conf"
UNPRIVILEGED_FLAG=1
DEVICE='{"kind":"nvidia-runtime","runtime_mode":"dynamic"}'
NVIDIA_GID_ENV=""
DEVICE_INDEX=0
fragment=$(mktemp)
trap 'rm -f "$fragment"' EXIT
printf '%s\n' '{"environment":[{"name":"NVIDIA_DRIVER_CAPABILITIES","value":"compute,utility"}]}' >"$fragment"
DEPLOYMENT_FILE=$fragment
add_character_device() {
local path=$1 mode gid
[[ -c $path && $path == /dev/nvidia* ]] || die "$(translate "Invalid NVIDIA device:") $path"
mode="0$(stat -c %a "$path")"
gid=$(stat -c %g "$path")
oci_quiet pct set "$VMID" "--dev${DEVICE_INDEX}" "path=${path},mode=${mode},gid=${gid},deny-write=0"
DEVICE_INDEX=$((DEVICE_INDEX + 1))
}
configure_nvidia_runtime
)
configure_immich_nvidia "$ML_ID" "compute,utility"
;;
esac
}
@@ -101,6 +126,8 @@ if profile == "openvino":
assert "OpenVINOExecutionProvider" in ort.get_available_providers()
devices = ort.capi._pybind_state.get_available_openvino_device_ids()
assert any(device.startswith("GPU") for device in devices), devices
elif profile == "rocm":
assert "MIGraphXExecutionProvider" in ort.get_available_providers(), ort.get_available_providers()
else:
assert profile == "cuda"
assert "CUDAExecutionProvider" in ort.get_available_providers()
+3
View File
@@ -40,6 +40,9 @@ def begin(root, primary, template, deployment, members, adapter):
'mounts': []}
if Path(adapter).name == 'install_immich_stack.sh' and name == 'machine-learning':
plan['machine_learning'] = copy.deepcopy(deployment.get('machine_learning', {'acceleration': 'cpu'}))
if Path(adapter).name == 'install_immich_stack.sh' and name == 'server':
# An update must know that the server transcodes with NVIDIA.
plan['video_transcoding'] = copy.deepcopy(deployment.get('video_transcoding', {'acceleration': 'cpu'}))
if Path(adapter).name in oci_stack_replay.FILES:
plan['replay_profile'] = {'adapter': Path(adapter).name, 'role': name}
if not oci_stack_replay.FILES[Path(adapter).name][name]:
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env python3
"""What the Monitor is told about an update or a recreation.
While the operation runs, every container it touches is stopped, backed up
and started again. Those steps belong to the operation, so the containers are
marked for the Monitor to keep their stop, start and backup notices to itself,
and one notification with the result is sent when it ends.
"""
from __future__ import annotations
import contextlib
import json
from pathlib import Path
import socket
import ssl
import time
import urllib.request
MARKERS = Path('/run/proxmenux/oci-operations')
ENDPOINTS = ('http://127.0.0.1:8008/api/internal/oci-event', 'https://127.0.0.1:8008/api/internal/oci-event')
def _write(vmid, data):
try:
MARKERS.mkdir(parents=True, exist_ok=True)
path = MARKERS / str(int(vmid))
temporary = path.with_suffix('.tmp')
temporary.write_text(json.dumps(data))
temporary.replace(path)
except OSError:
pass
def begin(vmids):
started = time.time()
for vmid in vmids:
_write(vmid, {'started': started, 'ended': None})
def end(vmids):
# The notices of the last start can arrive after the operation returned;
# the Monitor keeps the mark for a short while after `ended`.
ended = time.time()
for vmid in vmids:
_write(vmid, {'started': ended, 'ended': ended})
def notify(event, data):
"""Best effort: the operation never depends on the Monitor answering."""
payload = json.dumps({'event': event, 'hostname': socket.gethostname(), **data}).encode()
context = ssl.create_default_context()
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
for url in ENDPOINTS:
request = urllib.request.Request(url, data=payload, headers={'Content-Type': 'application/json'})
try:
with urllib.request.urlopen(request, timeout=5, context=context if url.startswith('https') else None):
return True
except (OSError, ValueError):
continue
return False
@contextlib.contextmanager
def operation(vmids, kind, application, primary=None):
"""Mark the containers for the length of an update or a recreation and
report how it ended. `kind` is 'update' or 'recreate'."""
vmids = [int(vmid) for vmid in vmids]
data = {'app_name': str(application), 'vmid': int(primary if primary is not None else vmids[0]),
'containers': ', '.join(f'CT {vmid}' for vmid in vmids)}
begin(vmids)
try:
yield
except BaseException as error:
end(vmids)
notify(f'oci_{kind}_failed', {**data, 'reason': str(error) or type(error).__name__})
raise
end(vmids)
notify(f'oci_{kind}_completed', data)
+9
View File
@@ -208,6 +208,15 @@ def modify(root, vmid, changes):
backup = instances.location(root, vmid).parent / f"config-before-recreate-{time.strftime('%Y%m%d-%H%M%S')}.conf"
backup.write_text(run('pct', 'config', str(vmid)))
backup.chmod(0o600)
import oci_operation_notice
import oci_update_current
primary_id = (record.get('stack_member') or {}).get('primary_vmid', vmid)
name = oci_update_current.application_name(instances.read(root, primary_id), primary_id)
with oci_operation_notice.operation([vmid], 'recreate', name, primary_id):
_modify(root, vmid, changes)
def _modify(root, vmid, changes):
running = is_running(vmid)
if running:
msg_info(translate('Stopping the container...'))
+9 -1
View File
@@ -373,6 +373,10 @@ class NativeAdapter:
deployment = self.records[vmid]['deployment']
if deployment.get('replay_profile') == {'adapter': 'install_immich_stack.sh', 'role': 'machine-learning'}:
acceleration = deployment.get('machine_learning', {}).get('acceleration', 'cpu')
if acceleration == 'rocm':
member_tx.run('pct', 'exec', str(vmid), '--', 'python', '-c',
'import onnxruntime as ort; '
'assert "MIGraphXExecutionProvider" in ort.get_available_providers()')
if acceleration in ('openvino', 'cuda'):
member_tx.run('pct', 'exec', str(vmid), '--', 'python', '-c',
'import sys,ctypes,onnxruntime as ort; p=sys.argv[1]; '
@@ -674,7 +678,11 @@ def run(vmid, recover=False, acknowledge_external_data=False, keep_backup=None):
msg_ok(f"{translate('Backup created in')} {keep_backup}")
else:
adapter.keep_backup = keep_backup
result = stack_tx.execute(journal, adapter, plan)
import oci_operation_notice
import oci_update_current
with oci_operation_notice.operation([member['vmid'] for member in plan['members']], 'update',
oci_update_current.application_name(primary, primary_id), primary_id):
result = stack_tx.execute(journal, adapter, plan)
msg_ok(translate('Stack update completed. Data kept.'))
return result
+18 -5
View File
@@ -68,9 +68,13 @@ def immich_record(record):
if shlex.split(runtime.get('entrypoint', '')) != expected[role]:
raise ValueError(translate('The Immich startup was modified or cannot be reproduced'))
acceleration = record['deployment'].get('machine_learning', {}).get('acceleration', 'cpu')
if role == 'machine-learning' and acceleration not in ('cpu', 'openvino', 'cuda'):
if role == 'machine-learning' and acceleration not in ('cpu', 'openvino', 'cuda', 'rocm'):
raise ValueError(translate('Immich GPU profile not validated'))
cuda = role == 'machine-learning' and acceleration == 'cuda'
video = record['deployment'].get('video_transcoding', {}).get('acceleration', 'cpu')
# NVIDIA reaches Machine learning for recognition and the server for NVENC.
capabilities = ('compute,utility' if role == 'machine-learning' and acceleration == 'cuda'
else 'compute,video,utility' if role == 'server' and video == 'nvenc' else None)
cuda = capabilities is not None
devices = [{'kind': 'nvidia-runtime', 'runtime_mode': 'dynamic'}] if cuda else []
for item in projection['native_devices']:
fields = dict(p.split('=', 1) for p in item['value'].split(',') if '=' in p)
@@ -80,17 +84,24 @@ def immich_record(record):
if oci_gpu_devices.peripheral_path(path):
devices.append(peripheral_device(fields))
continue
rocm = role == 'machine-learning' and acceleration == 'rocm'
if rocm and path == '/dev/kfd':
# The compute interface ROCm needs beside the render node.
devices.append({'kind': 'character-device', 'host_path': path, 'container_path': path,
'gid_strategy': 'host-device-gid', 'mode': fields.get('mode', '0660')})
continue
if not path or not re.fullmatch(r'/dev/dri/renderD[0-9]+', path):
raise ValueError(translate('Immich device without a validated translation'))
vendors = (['0x1002'] if rocm else ['0x8086'] if role == 'machine-learning' else ['0x8086', '0x1002'])
devices.append({'kind': 'character-device', 'host_path': path, 'container_path': path,
'gid_strategy': 'host-device-gid', 'mode': fields.get('mode', '0660'),
'drm_vendor_ids': ['0x8086'] if role == 'machine-learning' else ['0x8086', '0x1002']})
'drm_vendor_ids': vendors})
if projection['preserved_raw_runtime'] and not cuda:
raise ValueError(translate('Immich runtime without a validated translation'))
if cuda:
import oci_accelerators
candidate = {'devices': devices, 'environment': [
{'name': 'NVIDIA_DRIVER_CAPABILITIES', 'value': 'compute,utility'}]}
{'name': 'NVIDIA_DRIVER_CAPABILITIES', 'value': capabilities}]}
oci_accelerators.check(record['observed']['config'].encode(), candidate)
translated = {'compose_entrypoint': expected[role], 'command': []}
for native, target in (('lxc.init.cwd', 'working_directory'), ('lxc.signal.halt', 'halt_signal')):
@@ -101,8 +112,10 @@ def immich_record(record):
if cuda:
result['deployment']['environment'] = [e for e in result['deployment']['environment']
if e['name'] != 'NVIDIA_DRIVER_CAPABILITIES']
result['deployment']['environment'].append({'name': 'NVIDIA_DRIVER_CAPABILITIES', 'value': 'compute,utility'})
result['deployment']['environment'].append({'name': 'NVIDIA_DRIVER_CAPABILITIES', 'value': capabilities})
result['deployment']['machine_learning'] = copy.deepcopy(record['deployment'].get('machine_learning', {}))
if 'video_transcoding' in record['deployment']:
result['deployment']['video_transcoding'] = copy.deepcopy(record['deployment']['video_transcoding'])
return result
+16 -3
View File
@@ -137,6 +137,17 @@ def kept_settings(changes, deployment):
return kept
def application_name(record, vmid):
"""The name the user knows the application by, for its notifications."""
for template in ((record.get('stack') or {}).get('template') or {}, record.get('template') or {}):
title = (template.get('catalog_ui') or {}).get('title')
if isinstance(title, dict):
title = title.get('en_US') or next(iter(title.values()), '')
if isinstance(title, str) and title.strip():
return title.strip()
return f'CT {vmid}'
def update(vmid, acknowledge_external_data=False, proposal=None, keep_backup=None):
operation = 'recreate' if proposal is not None else 'update'
msg_info(translate('Checking the container before the update...') if operation == 'update'
@@ -169,9 +180,11 @@ def update(vmid, acknowledge_external_data=False, proposal=None, keep_backup=Non
kept = kept_settings(changes, desired['deployment'])
if kept:
msg_info2(f"{translate('Keeping the settings changed in Proxmox:')} {', '.join(kept)}")
transaction.apply(instances.ROOT, vmid, archive, operation, proposal=proposal,
registry_digest=digest, acknowledge_external_data=acknowledge_external_data,
keep_backup=file_storage)
import oci_operation_notice
with oci_operation_notice.operation([vmid], operation, application_name(record, vmid)):
transaction.apply(instances.ROOT, vmid, archive, operation, proposal=proposal,
registry_digest=digest, acknowledge_external_data=acknowledge_external_data,
keep_backup=file_storage)
def main():
+7 -2
View File
@@ -35,7 +35,7 @@ PUBLISHERS = {"linuxserver.io": "LinuxServer", "official": N_("Official image")}
STACK_LABELS = {
"server": N_("Server"),
"application": N_("Application"),
"machine_learning": N_("Machine learning"),
"machine_learning": "Machine learning",
"database": "PostgreSQL",
"valkey": "Valkey",
"cache": "Redis",
@@ -129,7 +129,7 @@ def _service_kind(service: dict[str, Any]) -> str:
if service.get("is_main"):
return translate("Application")
if "machine-learning" in name or "machine-learning" in image:
return translate("Machine learning")
return "Machine learning"
for key, label in SERVICE_KINDS:
if key in image:
return label
@@ -403,6 +403,11 @@ def install_template(ui, template: dict[str, Any], identifier: str, mode: str) -
break
except RestartWizard:
wizard.restart()
except (ValueError, InstallError) as error:
# A mistyped value asks that question again instead of
# sending the user back to the start of the wizard.
if not wizard.retry_last(error):
raise
finally:
wizard.close()
if not approved:
+31
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import ipaddress
import json
import re
import shutil
import subprocess
from pathlib import Path
from typing import Any
@@ -136,6 +137,36 @@ def _sysfs(path: Path) -> str:
return ""
def gpus(root: Path = Path("/")) -> dict[str, Any]:
"""The GPUs an installation can use: the render nodes of each Intel and
AMD GPU, and whether NVIDIA is usable on the host."""
vendors = {"0x8086": "intel", "0x1002": "amd"}
found: dict[str, Any] = {"intel": [], "amd": [], "nvidia": False}
for node in sorted((root / "sys/class/drm").glob("renderD*")):
vendor = vendors.get(_sysfs(node / "device/vendor").lower())
if vendor:
found[vendor].append(f"/dev/dri/{node.name}")
# NVIDIA is usable when its driver answers and the Container Toolkit is installed.
if shutil.which("nvidia-smi") and shutil.which("nvidia-container-cli"):
try:
found["nvidia"] = subprocess.run(["nvidia-smi", "-L"], capture_output=True, text=True,
timeout=15, check=False).returncode == 0
except (OSError, subprocess.TimeoutExpired):
found["nvidia"] = False
return found
def rocm_blocker(storage: str | None, needed_gb: int = 40) -> str | None:
"""Why this host cannot run recognition on an AMD GPU with ROCm, or None.
ROCm needs the compute interface of the driver and room for its image."""
if not Path("/dev/kfd").is_char_device():
return "kfd"
row = next((item for item in storages("rootdir") if item.get("storage") == storage), None)
if row is not None and gib(row.get("avail")) < needed_gb:
return "space"
return None
def usb_devices(root: Path = Path("/"), lsusb: str | None = None) -> list[dict[str, str]]:
"""USB peripherals of this node an LXC can receive, named as the Monitor
names them: a serial adapter by its tty node, any other device by its bus
+127 -46
View File
@@ -532,6 +532,10 @@ def build_deployment(
devices, selected_hardware_profile, post_start_configurations, environment = configure_acceleration(
installer_profile, environment, unprivileged, ui, mode)
devices, completion_notes = configure_detector(installer_profile, devices, ui)
# What the selected acceleration profile leaves for the user to set.
completion_notes = [*next((profile.get("completion_notes", [])
for profile in installer_profile.get("hardware_acceleration", {}).get("profiles", [])
if profile["id"] == selected_hardware_profile), []), *completion_notes]
if advanced:
from .extra_devices import ask_extra_devices
@@ -788,6 +792,84 @@ def build_rclone_mount_deployment(
}
def _ask_immich_acceleration(ui, rootfs_storage: str | None = None) -> tuple[str, str | None, str, str, str | None]:
"""What runs Immich's video transcoding (the server) and its recognition
(the Machine learning container), asked in one menu in both modes. Each
usable GPU of the host can take both, or only one of them, and the CPU is
always there."""
software = ("cpu", None, "auto", "cpu", None)
real = essential_ui(ui)
found = host.gpus()
names = {"intel": "Intel", "amd": "AMD", "nvidia": "NVIDIA"}
vendors = [vendor for vendor in names if found[vendor]]
if not vendors:
real.message(translate("No usable GPU was found on this host. Immich will be installed on the CPU."))
return software
options = [("cpu", translate("No acceleration (CPU)"))]
for vendor in vendors:
options += [(vendor, f"{names[vendor]}: {translate('video + recognition')}"),
(f"{vendor}-video", f"{names[vendor]}: {translate('video only')}"),
(f"{vendor}-ml", f"{names[vendor]}: {translate('recognition only')}")]
if found["nvidia"]:
options += [(f"{vendor}+nvidia", f"{names[vendor]}: {translate('video')} · NVIDIA: {translate('recognition')}")
for vendor in ("intel", "amd") if found[vendor]]
# The GPU matters for Immich, so the first one is proposed whole.
selected = real.choose(translate("Hardware acceleration for Immich"), options, vendors[0])
if selected is None:
raise UserCancelled(translate("Immich configuration cancelled"))
if selected == "cpu":
return software
if "+" in selected:
video_vendor, ml_vendor = selected.split("+", 1)
else:
vendor, _, use = selected.partition("-")
video_vendor = vendor if use in ("", "video") else None
ml_vendor = vendor if use in ("", "ml") else None
video_acceleration, render_device, vaapi_driver = "cpu", None, "auto"
if video_vendor == "nvidia":
video_acceleration = "nvenc"
elif video_vendor:
nodes = found[video_vendor]
render_device = nodes[0]
if len(nodes) > 1:
render_device = ui.choose(translate("VA-API render device"), [(node, node) for node in nodes], nodes[0])
drivers = ([("auto", translate("Automatic detection")), ("iHD", "Intel iHD"), ("i965", "Intel i965")]
if video_vendor == "intel" else
[("auto", translate("Automatic detection")), ("radeonsi", "AMD radeonsi")])
vaapi_driver = ui.choose(translate("VA-API driver"), drivers, "auto")
if render_device is None or vaapi_driver is None:
raise UserCancelled(translate("Immich configuration cancelled"))
video_acceleration = "vaapi"
ml_acceleration, ml_render = "cpu", None
if ml_vendor == "nvidia":
ml_acceleration = "cuda"
elif ml_vendor == "intel":
ml_acceleration, ml_render = "openvino", render_device or found["intel"][0]
elif ml_vendor == "amd":
# ROCm is checked before it is promised; when the host cannot run it,
# recognition stays on the CPU.
blocker = host.rocm_blocker(rootfs_storage)
if blocker:
reason = (translate("The AMD driver does not offer its compute interface (/dev/kfd) on this host.")
if blocker == "kfd" else
translate("The storage has less than 40 GB free for the ROCm image."))
real.message(f"{reason}\n\n{translate('Recognition runs on the CPU.')}")
else:
ml_acceleration, ml_render = "rocm", render_device or found["amd"][0]
if ml_acceleration == "rocm":
real.message(translate("Recognition on AMD uses ROCm. Its image is several times larger than the others, "
"so the first installation takes longer, and whether a GPU works with it depends "
"on its model."))
if ml_acceleration != "cpu":
ui.message(translate("GPU recognition uses 8 GB of RAM and a limit of 4 CPU equivalents. These resources "
"were tested in the lab and are not a universal minimum. Compatibility depends on the "
"GPU, the models and the kernel. NVIDIA uses the GPUs of the Toolkit inventory; Intel "
"keeps the CPU topology."))
return video_acceleration, render_device, vaapi_driver, ml_acceleration, ml_render
def _build_immich_deployment(
template: dict[str, Any],
ui: TerminalUI | DialogUI,
@@ -798,7 +880,7 @@ def _build_immich_deployment(
stack_name = ui.ask(translate("Stack name"), defaults["stack_name"])
if not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,31}", stack_name):
raise InstallError(translate("The stack name only accepts lowercase letters, numbers and hyphens"))
resources = ask_application_resources(ui, 4, 3072, 1024)
resources = ask_application_resources(ui, 4, 4096, 1024)
chosen_storage = ask_default_storage(ui, defaults["rootfs_storage"])
rootfs_storage = ask_storage(ui, translate("Storage for rootfs"), "rootdir",
chosen_storage or defaults["rootfs_storage"])
@@ -833,49 +915,11 @@ def _build_immich_deployment(
extra_mounts = ask_application_extra_paths(ui, ["/data"], media_storage or rootfs_storage)
frontend_bridge = ask_bridge(ui, translate("Access bridge for Immich"), defaults["frontend_network"]["bridge"])
addresses, frontend_gateway = access.ask_addresses(
essential_ui(ui), frontend_bridge, [translate("Immich server"), translate("Immich machine learning")])
essential_ui(ui), frontend_bridge, [translate("Immich server"), "Immich Machine learning"])
server_ipv4, ml_ipv4 = addresses.values()
timezone = ui.ask(translate("Timezone"), host.timezone())
video_acceleration = ui.choose(
translate("Video transcoding acceleration"),
[("vaapi", "VA-API"), ("cpu", "CPU")],
defaults["video_transcoding"]["acceleration"],
)
if video_acceleration is None:
raise UserCancelled(translate("Immich configuration cancelled"))
render_device = None
vaapi_driver = "auto"
if video_acceleration == "vaapi":
render_device = ui.ask(
translate("VA-API render device"), defaults["video_transcoding"]["render_device"]
)
vaapi_driver = ui.choose(
translate("VA-API driver"),
[("auto", translate("Automatic detection")), ("radeonsi", "AMD radeonsi"), ("iHD", "Intel iHD"), ("i965", "Intel i965")],
defaults["video_transcoding"]["driver"],
)
if vaapi_driver is None:
raise UserCancelled(translate("Immich configuration cancelled"))
ml_acceleration = ui.choose(
translate("Acceleration for Immich smart recognition"),
[("cpu", "CPU"), ("openvino", "Intel GPU / OpenVINO"),
("cuda", translate("NVIDIA GPU / CUDA (Toolkit on the host)"))],
"cpu",
)
if ml_acceleration is None:
raise UserCancelled(translate("Immich configuration cancelled"))
if ml_acceleration not in ("cpu", "openvino", "cuda"):
raise InstallError(translate("Recognition profile not implemented"))
ml_render = None
if ml_acceleration == "openvino":
ml_render = ui.ask(translate("Intel render device for recognition"), "/dev/dri/renderD128")
if not re.fullmatch(r"/dev/dri/renderD[0-9]+", ml_render):
raise InstallError(translate("Invalid Intel render path"))
if ml_acceleration != "cpu":
ui.message(translate("GPU recognition uses 8 GB of RAM and a limit of 4 CPU equivalents. These resources "
"were tested in the lab and are not a universal minimum. Compatibility depends on the "
"GPU, the models and the kernel. NVIDIA uses the GPUs of the Toolkit inventory; Intel "
"keeps the CPU topology."))
(video_acceleration, render_device, vaapi_driver,
ml_acceleration, ml_render) = _ask_immich_acceleration(ui, rootfs_storage)
extra_devices = ask_application_extra_devices(ui, ("usb",))
return {
"deployment_kind": "immich-four-lxc-stack",
@@ -919,8 +963,8 @@ def _build_immich_deployment(
},
"machine_learning": {"acceleration": ml_acceleration, "render_device": ml_render,
"model_cache_size_gb": 8,
"resources": {"cores": 2 if ml_acceleration == "cpu" else 4,
"memory_mb": 2048 if ml_acceleration == "cpu" else 8192,
"resources": {"cores": 4,
"memory_mb": 4096 if ml_acceleration == "cpu" else 8192,
"swap_mb": 1024,
"cpu_allocation": "quota" if ml_acceleration == "openvino" else "cpuset"}},
}
@@ -1630,6 +1674,26 @@ def configure_detector(installer_profile, devices, ui, root=Path("/")):
return [*devices, device], list(chosen.get("completion_notes", []))
def _profile_usable(profile: dict[str, Any], found: dict[str, Any]) -> bool:
"""Whether the host has what an acceleration profile needs: the NVIDIA
runtime, a GPU of the vendor it is written for, or ROCm's compute device."""
vendors = {"0x8086": "intel", "0x1002": "amd"}
for request in profile.get("device_requests", []):
if request.get("kind") == "nvidia-runtime":
if not found["nvidia"]:
return False
continue
path = str(request.get("host_path_default") or "")
if path == "/dev/kfd":
if not Path(path).is_char_device():
return False
elif path.startswith("/dev/dri/"):
wanted = [vendors[item] for item in request.get("drm_vendor_ids", []) if item in vendors] or list(vendors.values())
if not any(found[vendor] for vendor in wanted):
return False
return True
def configure_acceleration(installer_profile, environment, unprivileged, ui, mode=ADVANCED_MODE):
advanced = mode != DEFAULT_MODE
devices: list[dict[str, Any]] = []
@@ -1640,14 +1704,25 @@ def configure_acceleration(installer_profile, environment, unprivileged, ui, mod
hardware = installer_profile.get("hardware_acceleration")
if hardware:
profiles = hardware.get("profiles", [])
options = [(item["id"], item["label"]) for item in profiles]
default_profile = hardware.get("default", profiles[0]["id"] if profiles else None)
# Only what this host can run is offered; the profile already in use
# stays in the list so a recreation never loses it.
found = host.gpus()
usable = [item for item in profiles
if item["id"] == default_profile or _profile_usable(item, found)]
options = [(item["id"], item["label"]) for item in usable]
asked = advanced or not installer_profile.get("selkies")
if asked and len(usable) < len(profiles) and len(usable) == 1:
# Nothing but the CPU is left: say why there is nothing to choose.
ui.message(translate("No usable GPU was found on this host. The application will be installed "
"without hardware acceleration."))
asked = False
selected_hardware_profile = (
ui.choose(
translate(hardware.get("prompt", "Hardware acceleration")),
[(tag, translate(label)) for tag, label in options],
default_profile,
) if advanced or not installer_profile.get("selkies") else default_profile
) if asked else default_profile
)
if selected_hardware_profile is None:
raise UserCancelled(translate("Acceleration configuration cancelled"))
@@ -1710,6 +1785,12 @@ def configure_acceleration(installer_profile, environment, unprivileged, ui, mod
]
devices.append(device)
else:
# The render node proposed is one of the GPU the profile is for;
# renderD128 is not always it on a host with two GPUs.
nodes = [node for vendor_id, vendor in (("0x8086", "intel"), ("0x1002", "amd"))
if vendor_id in item.get("drm_vendor_ids", []) for node in host.gpus()[vendor]]
if nodes and item["host_path_default"] not in nodes:
item = {**item, "host_path_default": nodes[0]}
if not advanced:
host_path = item["host_path_default"]
elif item.get("purpose") in ("serial", "user-selected-device"):
+3
View File
@@ -241,6 +241,9 @@ def manage_instance(project, ui, row, action=None, lifecycle_args=()):
break
except RestartWizard:
wizard.restart()
except ValueError as error:
if not wizard.retry_last(error):
raise
finally:
wizard.close()
if not approved:
+15
View File
@@ -43,6 +43,21 @@ class BacktrackUI:
def restart(self):
self.cursor = 0
def retry_last(self, error) -> bool:
"""After an answer the wizard could not accept: say why and ask that
question again, keeping every answer given before it. False when no
answer was given yet, so there is nothing to ask again."""
if not self.answers:
return False
text = str(error)
# What Python says about a number it could not read is not for the user.
if text.startswith(("invalid literal for int()", "could not convert string to float")):
text = f"{translate('The value must be a number:')} {text.rsplit(':', 1)[-1].strip()}"
self.base.message(f"{text}\n\n{translate('Enter the value again.')}")
self.answers.pop()
self.cursor = 0
return True
def _call(self, name, *args, **kwargs):
if self.cursor < len(self.answers):
saved_name, value = self.answers[self.cursor]
@@ -0,0 +1,79 @@
"""An application offers the acceleration profiles the host can run."""
from pathlib import Path
import sys
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
sys.path.insert(0, str(Path(__file__).resolve().parent))
from proxmenux_oci.catalog import Catalog
from proxmenux_oci.installer import DEFAULT_MODE, build_deployment
from test_advanced_flow_order import RecordingUI, storages
NODE = "/dev/dri/renderD128"
class OptionsUI(RecordingUI):
def __init__(self, answers=None):
super().__init__(answers)
self.options, self.messages = {}, []
def choose(self, text, options, default=None):
self.options[text] = [tag for tag, _ in options]
return super().choose(text, options, default)
def message(self, text, title=None):
self.messages.append(text)
@patch("proxmenux_oci.i18n.language", return_value="en")
@patch("proxmenux_oci.installer.host.storages", side_effect=storages)
@patch("proxmenux_oci.installer.host.bridges", return_value=[{"iface": "vmbr0", "cidr": "192.0.2.10/24"}])
@patch("proxmenux_oci.installer.host.timezone", return_value="Europe/Madrid")
class HostProfileTests(unittest.TestCase):
catalog = Catalog(ROOT)
def offered(self, app, gpus, kfd=False, answer=None):
template = self.catalog.compose(app)
prompt = template["proxmox"]["installer_profile"]["hardware_acceleration"]["prompt"]
ui = OptionsUI({prompt: answer} if answer else None)
with patch("proxmenux_oci.installer.host.gpus", return_value=gpus), \
patch("pathlib.Path.is_char_device", return_value=kfd):
plan = build_deployment(template, ui, DEFAULT_MODE)
return ui.options.get(prompt), ui, plan
def test_an_amd_host_is_not_offered_nvidia(self, *_):
amd = {"intel": [], "amd": [NODE], "nvidia": False}
self.assertEqual(self.offered("frigate", amd, kfd=True)[0], ["none", "vaapi", "rocm"])
self.assertEqual(self.offered("ollama", amd, kfd=True)[0], ["cpu", "rocm"])
self.assertEqual(self.offered("llamacpp", amd, kfd=True)[0], ["cpu", "rocm"])
def test_rocm_is_not_offered_without_its_compute_device(self, *_):
amd = {"intel": [], "amd": [NODE], "nvidia": False}
self.assertEqual(self.offered("frigate", amd, kfd=False)[0], ["none", "vaapi"])
def test_an_intel_and_nvidia_host_is_not_offered_amd(self, *_):
both = {"intel": [NODE], "amd": [], "nvidia": True}
self.assertEqual(self.offered("frigate", both)[0], ["none", "vaapi", "nvidia"])
self.assertEqual(self.offered("llamacpp", both)[0], ["cpu", "nvidia", "intel"])
def test_a_host_without_gpu_is_told_and_not_asked(self, *_):
nothing = {"intel": [], "amd": [], "nvidia": False}
for app in ("faster-whisper", "ollama", "frigate"):
options, ui, plan = self.offered(app, nothing)
self.assertIsNone(options, app)
self.assertTrue(any("No usable GPU" in message for message in ui.messages), app)
self.assertEqual(plan["devices"], [], app)
def test_the_render_node_proposed_belongs_to_the_gpu_of_the_profile(self, *_):
# The first render node of this host is the NVIDIA one; Intel's is the second.
gpus = {"intel": ["/dev/dri/renderD129"], "amd": [], "nvidia": True}
_, _, plan = self.offered("llamacpp", gpus, answer="intel")
self.assertEqual([device["host_path"] for device in plan["devices"]], ["/dev/dri/renderD129"])
if __name__ == "__main__":
unittest.main()
+59
View File
@@ -0,0 +1,59 @@
"""The AI applications whose image depends on the GPU take the image and the
devices of the profile that is chosen."""
from pathlib import Path
import json
import sys
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
sys.path.insert(0, str(Path(__file__).resolve().parent))
from proxmenux_oci.catalog import Catalog
from proxmenux_oci.installer import DEFAULT_MODE, build_deployment
from test_advanced_flow_order import RecordingUI, storages
RENDER, KFD = "/dev/dri/renderD128", "/dev/kfd"
EXPECTED = {
"ollama": {"cpu": (":latest", []), "nvidia": (":latest", ["nvidia-runtime"]), "rocm": (":rocm", [RENDER, KFD])},
"llamacpp": {"cpu": (":server", []), "nvidia": (":server-cuda", ["nvidia-runtime"]),
"rocm": (":server-rocm", [RENDER, KFD]), "intel": (":server-intel", [RENDER])},
"faster-whisper": {"cpu": (":latest", []), "nvidia": (":gpu", ["nvidia-runtime"])},
"piper": {"cpu": (":latest", []), "nvidia": (":gpu", ["nvidia-runtime"])},
}
SOURCES = {"ollama": "overlays", "llamacpp": "curated", "faster-whisper": "overlays", "piper": "overlays"}
# Every profile is offered: what the host has is checked in its own test.
@patch("proxmenux_oci.installer.host.gpus", return_value={"intel": ["/dev/dri/renderD128"], "amd": ["/dev/dri/renderD128"], "nvidia": True})
@patch("proxmenux_oci.installer._profile_usable", return_value=True)
@patch("proxmenux_oci.i18n.language", return_value="en")
@patch("proxmenux_oci.installer.host.storages", side_effect=storages)
@patch("proxmenux_oci.installer.host.bridges", return_value=[{"iface": "vmbr0", "cidr": "192.0.2.10/24"}])
@patch("proxmenux_oci.installer.host.timezone", return_value="Europe/Madrid")
class AiGpuProfileTests(unittest.TestCase):
catalog = Catalog(ROOT)
def test_each_profile_installs_its_image_with_its_devices(self, *_):
for app, profiles in EXPECTED.items():
hardware = self.catalog.compose(app)["proxmox"]["installer_profile"]["hardware_acceleration"]
self.assertEqual([profile["id"] for profile in hardware["profiles"]], list(profiles), app)
self.assertEqual(hardware["default"], "cpu", app)
for profile, (tag, devices) in profiles.items():
template = self.catalog.compose(app)
plan = build_deployment(template, RecordingUI({hardware["prompt"]: profile}), DEFAULT_MODE)
self.assertTrue(template["container_contract"]["image"]["reference"].endswith(tag), (app, profile))
self.assertEqual([device.get("host_path") or device["kind"] for device in plan["devices"]],
devices, (app, profile))
def test_the_shipped_copy_matches_its_source(self, *_):
read = lambda place, app: json.loads((ROOT / f"catalog/{place}/{app}.json").read_text())[
"proxmox"]["installer_profile"]["hardware_acceleration"]
for app, source in SOURCES.items():
self.assertEqual(read(source, app), read("apps", app), app)
if __name__ == "__main__":
unittest.main()
+3 -1
View File
@@ -35,6 +35,8 @@ def storages_used(plan):
return used
# The GPUs of the host the tests run on are not part of what they check.
@patch("proxmenux_oci.installer.host.gpus", return_value={"intel": ["/dev/dri/renderD128"], "amd": [], "nvidia": False})
@patch("proxmenux_oci.i18n.language", return_value="en")
@patch("proxmenux_oci.installer.host.storages", side_effect=storages)
@patch("proxmenux_oci.installer.host.bridges", return_value=[{"iface": "vmbr0", "cidr": "192.0.2.10/24"}])
@@ -61,7 +63,7 @@ class DefaultInstallEssentialsTests(unittest.TestCase):
"Nextcloud volume size in GB", ADDRESS, "Start the stack with Proxmox",
"Start when finished"],
"immich": [STORAGE, "Where to store the Immich library", "Library size in GB", ADDRESS,
"Start the stack with Proxmox", "Start when finished"],
"Hardware acceleration for Immich", "Start the stack with Proxmox", "Start when finished"],
"tandoor": [STORAGE, "Where to store the recipe images and files", "Files volume size in GB", ADDRESS,
"Start the stack with Proxmox"],
"paperless-ngx": [STORAGE, "Documents volume size in GB", "Where to store the consume and export folders",
+55
View File
@@ -0,0 +1,55 @@
"""Frigate's AMD profile takes the image built for ROCm and the two devices
it needs, and says what is left for the user to set."""
from pathlib import Path
import sys
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
sys.path.insert(0, str(Path(__file__).resolve().parent))
from proxmenux_oci.catalog import Catalog
from proxmenux_oci.installer import DEFAULT_MODE, build_deployment
from test_advanced_flow_order import RecordingUI, storages
PROMPT = "Hardware acceleration for Frigate"
# Every profile is offered: what the host has is checked in its own test.
@patch("proxmenux_oci.installer.host.gpus", return_value={"intel": ["/dev/dri/renderD128"], "amd": ["/dev/dri/renderD128"], "nvidia": True})
@patch("proxmenux_oci.installer._profile_usable", return_value=True)
@patch("proxmenux_oci.i18n.language", return_value="en")
@patch("proxmenux_oci.installer.host.storages", side_effect=storages)
@patch("proxmenux_oci.installer.host.bridges", return_value=[{"iface": "vmbr0", "cidr": "192.0.2.10/24"}])
@patch("proxmenux_oci.installer.host.timezone", return_value="Europe/Madrid")
class FrigateAmdProfileTests(unittest.TestCase):
def build(self, profile):
template = Catalog(ROOT).compose("frigate")
plan = build_deployment(template, RecordingUI({PROMPT: profile}), DEFAULT_MODE)
return template, plan
def test_the_amd_profile_uses_the_rocm_image_with_both_devices(self, *_):
template, plan = self.build("rocm")
self.assertEqual(template["container_contract"]["image"]["reference"],
"ghcr.io/blakeblackshear/frigate:stable-rocm")
self.assertEqual([device["host_path"] for device in plan["devices"]], ["/dev/dri/renderD128", "/dev/kfd"])
self.assertEqual(plan["devices"][0]["drm_vendor_ids"], ["0x1002"])
self.assertTrue(any("type: onnx" in note for note in plan["completion_notes"]))
def test_the_other_profiles_keep_their_image(self, *_):
for profile, tag in (("none", "stable"), ("vaapi", "stable"), ("nvidia", "stable-tensorrt")):
template, plan = self.build(profile)
self.assertTrue(template["container_contract"]["image"]["reference"].endswith(":" + tag), profile)
self.assertFalse(plan.get("completion_notes"), profile)
def test_the_shipped_copy_matches_the_curated_profile(self, *_):
import json
read = lambda name: json.loads((ROOT / f"catalog/{name}/frigate.json").read_text())[
"proxmox"]["installer_profile"]["hardware_acceleration"]
self.assertEqual(read("curated"), read("apps"))
if __name__ == "__main__":
unittest.main()
+129
View File
@@ -0,0 +1,129 @@
"""Immich asks in one menu, in both installation modes, what runs its video
transcoding and its recognition: the CPU, or each usable GPU of the host for
both or for only one of them."""
from pathlib import Path
import sys
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
sys.path.insert(0, str(Path(__file__).resolve().parent))
from proxmenux_oci.catalog import Catalog
from proxmenux_oci.installer import ADVANCED_MODE, DEFAULT_MODE, build_deployment
from test_advanced_flow_order import RecordingUI, addresses, storages
PROMPT = "Hardware acceleration for Immich"
NODE = "/dev/dri/renderD128"
INTEL = {"intel": [NODE], "amd": [], "nvidia": False}
AMD = {"intel": [], "amd": [NODE], "nvidia": False}
BOTH = {"intel": [NODE], "amd": [], "nvidia": True}
NOTHING = {"intel": [], "amd": [], "nvidia": False}
class OptionsUI(RecordingUI):
def __init__(self, answers=None):
super().__init__(answers)
self.options = {}
self.defaults = {}
self.messages = []
def choose(self, text, options, default=None):
self.options[text] = [tag for tag, _ in options]
self.defaults[text] = default
return super().choose(text, options, default)
def message(self, text, title=None):
self.messages.append(text)
@patch("proxmenux_oci.i18n.language", return_value="en")
@patch("proxmenux_oci.installer.host.storages", side_effect=storages)
@patch("proxmenux_oci.installer.host.bridges", return_value=[{"iface": "vmbr0", "cidr": "192.0.2.10/24"}])
@patch("proxmenux_oci.installer.host.timezone", return_value="Europe/Madrid")
@patch("proxmenux_oci.installer.host.rocm_blocker", return_value=None)
@patch("proxmenux_oci.installer.access.ask_addresses", side_effect=addresses)
class ImmichAccelerationTests(unittest.TestCase):
template = Catalog(ROOT).compose("immich")
def plan(self, gpus, mode, answer=None):
ui = OptionsUI({PROMPT: answer} if answer else None)
with patch("proxmenux_oci.installer.host.gpus", return_value=gpus):
plan = build_deployment(self.template, ui, mode)
return ui, (plan["video_transcoding"]["acceleration"], plan["machine_learning"]["acceleration"]), plan
def test_the_menu_is_asked_in_both_modes_with_what_the_host_has(self, *_):
for mode in (DEFAULT_MODE, ADVANCED_MODE):
ui, _, _ = self.plan(INTEL, mode)
self.assertEqual(ui.options[PROMPT], ["cpu", "intel", "intel-video", "intel-ml"], mode)
ui, _, _ = self.plan(BOTH, DEFAULT_MODE)
self.assertEqual(ui.options[PROMPT], ["cpu", "intel", "intel-video", "intel-ml",
"nvidia", "nvidia-video", "nvidia-ml", "intel+nvidia"])
def test_the_first_gpu_is_proposed_whole(self, *_):
ui, result, _ = self.plan(BOTH, DEFAULT_MODE)
self.assertEqual(ui.defaults[PROMPT], "intel")
self.assertEqual(result, ("vaapi", "openvino"))
def test_every_option_gives_the_gpu_to_what_it_names(self, *_):
expected = {"cpu": ("cpu", "cpu"), "intel": ("vaapi", "openvino"), "intel-video": ("vaapi", "cpu"),
"intel-ml": ("cpu", "openvino"), "nvidia": ("nvenc", "cuda"), "nvidia-video": ("nvenc", "cpu"),
"nvidia-ml": ("cpu", "cuda"), "intel+nvidia": ("vaapi", "cuda")}
for answer, result in expected.items():
self.assertEqual(self.plan(BOTH, DEFAULT_MODE, answer)[1], result, answer)
for answer, result in {"amd": ("vaapi", "rocm"), "amd-video": ("vaapi", "cpu"),
"amd-ml": ("cpu", "rocm")}.items():
self.assertEqual(self.plan(AMD, DEFAULT_MODE, answer)[1], result, answer)
def test_recognition_alone_still_gets_its_render_device(self, *_):
for gpus, answer in ((INTEL, "intel-ml"), (AMD, "amd-ml")):
_, _, plan = self.plan(gpus, DEFAULT_MODE, answer)
self.assertEqual(plan["machine_learning"]["render_device"], NODE, answer)
self.assertIsNone(plan["video_transcoding"]["render_device"], answer)
def test_a_host_without_usable_gpu_says_so_and_installs_on_the_cpu(self, *_):
for mode in (DEFAULT_MODE, ADVANCED_MODE):
ui, result, _ = self.plan(NOTHING, mode)
self.assertNotIn(PROMPT, ui.options, mode)
self.assertEqual(len(ui.messages), 1, mode)
self.assertIn("No usable GPU", ui.messages[0])
self.assertEqual(result, ("cpu", "cpu"), mode)
def test_an_amd_host_that_cannot_run_rocm_says_so_and_recognises_on_the_cpu(self, *_):
for blocker, text in (("kfd", "/dev/kfd"), ("space", "40 GB")):
with patch("proxmenux_oci.installer.host.rocm_blocker", return_value=blocker):
ui, result, _ = self.plan(AMD, DEFAULT_MODE, "amd")
self.assertEqual(result, ("vaapi", "cpu"), blocker)
self.assertTrue(any(text in message and "Recognition runs on the CPU." in message
for message in ui.messages), blocker)
def test_machine_learning_gets_four_cores_and_at_least_four_gigabytes(self, *_):
_, _, plan = self.plan(BOTH, DEFAULT_MODE, "cpu")
self.assertEqual(plan["machine_learning"]["resources"]["cores"], 4)
self.assertEqual(plan["machine_learning"]["resources"]["memory_mb"], 4096)
for gpus, answer in ((BOTH, "nvidia"), (INTEL, "intel"), (AMD, "amd")):
_, _, plan = self.plan(gpus, DEFAULT_MODE, answer)
self.assertEqual(plan["machine_learning"]["resources"]["memory_mb"], 8192, answer)
def test_the_installers_give_the_gpu_to_both_containers(self, *_):
script = (ROOT / "remote/install_immich_stack.sh").read_text()
helper = (ROOT / "remote/oci_immich_ml.sh").read_text()
self.assertIn('configure_immich_nvidia "$SERVER_ID" "compute,video,utility"', script)
self.assertIn('configure_immich_nvidia "$ML_ID" "compute,utility"', helper)
self.assertIn('--dev1 "path=/dev/kfd', helper)
self.assertIn("MIGraphXExecutionProvider", helper)
self.assertIn("ML_ROOTFS_SIZE=40", helper)
self.assertIn('--rootfs "${ROOTFS_STORAGE}:${ML_ROOTFS_SIZE}"', script)
self.assertIn("'rocm')", (ROOT / "remote/oci_stack_replay.py").read_text())
self.assertIn("MIGraphXExecutionProvider", (ROOT / "remote/oci_stack_native.py").read_text())
def test_the_name_of_the_machine_learning_container_is_not_translated(self, *_):
script = (ROOT / "remote/install_immich_stack.sh").read_text()
self.assertNotIn('translate "Machine learning"', script)
self.assertNotIn('translate("Machine learning")', (ROOT / "src/proxmenux_oci/cli.py").read_text())
if __name__ == "__main__":
unittest.main()
+62
View File
@@ -0,0 +1,62 @@
"""An update or a recreation marks its containers for the Monitor and reports
its result once, whether it works or fails."""
import json
from pathlib import Path
import sys
import tempfile
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "remote"))
import oci_operation_notice as notice
class OperationNoticeTests(unittest.TestCase):
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.markers = Path(tmp.name)
patcher = patch.object(notice, "MARKERS", self.markers)
patcher.start()
self.addCleanup(patcher.stop)
def mark(self, vmid):
return json.loads((self.markers / str(vmid)).read_text())
def test_the_containers_are_marked_while_it_runs_and_the_result_is_sent(self):
with patch.object(notice, "notify") as notify:
with notice.operation([115, 116], "update", "Immich", 115):
self.assertIsNone(self.mark(115)["ended"])
self.assertIsNone(self.mark(116)["ended"])
notify.assert_not_called()
self.assertIsNotNone(self.mark(115)["ended"])
notify.assert_called_once_with("oci_update_completed",
{"app_name": "Immich", "vmid": 115, "containers": "CT 115, CT 116"})
def test_a_failure_is_reported_with_its_reason_and_raised(self):
with patch.object(notice, "notify") as notify:
with self.assertRaises(RuntimeError):
with notice.operation([120], "recreate", "Jellyfin"):
raise RuntimeError("the new image did not answer")
event, data = notify.call_args.args
self.assertEqual(event, "oci_recreate_failed")
self.assertEqual(data["reason"], "the new image did not answer")
self.assertIsNotNone(self.mark(120)["ended"])
def test_a_monitor_that_does_not_answer_never_stops_the_operation(self):
with patch.object(notice.urllib.request, "urlopen", side_effect=OSError("refused")):
self.assertFalse(notice.notify("oci_update_completed", {"app_name": "x"}))
with notice.operation([120], "update", "Jellyfin"):
pass
def test_both_engines_report_through_it(self):
self.assertIn("oci_operation_notice.operation", (ROOT / "remote/oci_update_current.py").read_text())
self.assertIn("oci_operation_notice.operation", (ROOT / "remote/oci_stack_native.py").read_text())
self.assertIn("oci_operation_notice.operation", (ROOT / "remote/oci_stack_modify.py").read_text())
if __name__ == "__main__":
unittest.main()
+1 -1
View File
@@ -15,7 +15,7 @@ from proxmenux_oci.cli import _deployment_summary_text
from proxmenux_oci.installer import ADVANCED_MODE, DEFAULT_MODE, InstallError, build_deployment
from test_advanced_flow_order import RecordingUI, addresses, storages
SPECIAL = {"nextcloud-stack": (2, 2048), "paperless-ngx": (2, 2048), "tandoor": (2, 2048), "immich": (4, 3072)}
SPECIAL = {"nextcloud-stack": (2, 2048), "paperless-ngx": (2, 2048), "tandoor": (2, 2048), "immich": (4, 4096)}
REMOTE = {"nextcloud-stack": "nextcloud", "paperless-ngx": "paperless", "tandoor": "tandoor", "immich": "immich"}
+64
View File
@@ -0,0 +1,64 @@
"""A value the wizard cannot accept asks that question again, with every
earlier answer kept, instead of ending the wizard."""
from pathlib import Path
import sys
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
sys.path.insert(0, str(Path(__file__).resolve().parent))
from proxmenux_oci import cli
from proxmenux_oci.catalog import Catalog
from proxmenux_oci.installer import ADVANCED_MODE
from test_advanced_flow_order import RecordingUI, addresses, storages
class TypoUI(RecordingUI):
"""Types 8o for the cores the first time, and 8 the second."""
back_enabled = False
def __init__(self):
super().__init__()
self.messages = []
self.cores = iter(["8o", "8"])
def ask(self, text, default=None, required=True):
if text == "CPU cores":
self.asked.append(text)
return next(self.cores)
return super().ask(text, default, required)
def message(self, text, title=None):
self.messages.append(text)
def review(self, text, title=None, question=None, default=True):
return False
@patch("proxmenux_oci.i18n.language", return_value="en")
@patch("proxmenux_oci.installer.host.storages", side_effect=storages)
@patch("proxmenux_oci.installer.host.bridges", return_value=[{"iface": "vmbr0", "cidr": "192.0.2.10/24"}])
@patch("proxmenux_oci.installer.host.timezone", return_value="Europe/Madrid")
@patch("proxmenux_oci.installer.host.gpus", return_value={"intel": [], "amd": [], "nvidia": False})
@patch("proxmenux_oci.installer.access.ask_addresses", side_effect=addresses)
class WizardRetryTests(unittest.TestCase):
def test_a_mistyped_number_asks_the_same_question_again(self, *_):
ui = TypoUI()
cli.install_template(ui, Catalog(ROOT).compose("tandoor"), "tandoor", ADVANCED_MODE)
self.assertEqual(ui.asked.count("CPU cores"), 2)
# The answers given before the mistake are replayed, not asked again.
self.assertEqual(ui.asked.count("Stack name"), 1)
self.assertEqual(len(ui.messages), 1)
self.assertIn("The value must be a number: '8o'", ui.messages[0])
self.assertIn("Enter the value again.", ui.messages[0])
def test_an_error_before_any_answer_still_ends_the_wizard(self, *_):
from proxmenux_oci.ui import BacktrackUI
self.assertFalse(BacktrackUI(TypoUI()).retry_last(ValueError("x")))
if __name__ == "__main__":
unittest.main()