mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-10-08 14:36:38 +00:00
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:
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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 */ }
|
||||
}}
|
||||
|
||||
@@ -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
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)'
|
||||
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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.",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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)
|
||||
@@ -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...'))
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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"}
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user