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

- Immich asks what runs its video and its recognition in one menu, in both
  modes, and gives the GPU to the server and to Machine learning; AMD uses ROCm
- Frigate, Ollama, llama.cpp, Faster Whisper and Piper take the image built
  for the chosen GPU
- The acceleration menu offers only what the host can run
- An update or a recreation sends one notification with its result instead of
  the stop, backup and start of each container
- A private bridge with nothing connected is not reported as down
- Secondary containers of a stack appear in the App tab with their version and
  logo; Secure Gateway shows the same update state in both views
- A mistyped value in the wizard asks the same question again
This commit is contained in:
MacRimi
2026-10-02 21:45:38 +02:00
parent 20ee21c08f
commit 9b5cefb81a
55 changed files with 2294 additions and 113 deletions
+37 -3
View File
@@ -343,6 +343,10 @@ interface Props {
ctIp?: string | null
onChange?: () => void
managed?: ManagedAppInfo | null
// What the OCI record says about this container, known without probing
// it: whether ProxMenux installed it from an image, and whether it is a
// secondary container of a multi-container application.
oci?: { instance: boolean; memberOf?: { vmid: number; label: string } | null } | null
// Optional seed payload from the parent's cross-open ref cache. When
// supplied, the panel renders with real content on the very first
// frame and only revalidates silently in the background — no
@@ -443,7 +447,7 @@ function parseArgvInput(value: string): string[] {
return value.split(",").map((item) => item.trim()).filter(Boolean)
}
export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Props) {
export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci }: Props) {
const t = useT()
const isLightTheme = useIsLightTheme()
// Seed from `initialData` first, then fall back to the shared cache
@@ -704,7 +708,8 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
// record names, so there is nothing to search for and nothing else to add.
// What can go stale is what the record and the registry say, and this
// reads both again.
const isOciInstall = !!suggestions?.oci_instance
const isOciInstall = !!suggestions?.oci_instance || !!oci?.instance
const ociMemberOf = oci?.memberOf ?? null
const isOciAdguard = suggestions?.oci_instance?.template_id === "image-adguard-home"
useEffect(() => {
if (!isOciAdguard) return
@@ -2582,7 +2587,36 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Prop
many detections there are (0, 1, or many). Below the chips,
a single "Register a different app" button lets the user
add something the auto-detector doesn't know about. */}
{apps.length === 0 && (
{/* A database or a cache of a multi-container application has no
application of its own: it is reached from the main container. */}
{apps.length === 0 && ociMemberOf && (
<Card className="border border-border bg-card/50">
<CardContent className="p-6 space-y-3">
<div className="mx-auto p-2 rounded-full bg-blue-500/10 w-fit">
<Info className="h-5 w-5 text-blue-400" />
</div>
<h3 className="text-sm font-semibold text-foreground text-center">
{t("vmLxc.appEditor.stackMemberTitle")}
</h3>
<p className="text-xs text-muted-foreground max-w-md mx-auto leading-relaxed text-center">
{t("vmLxc.appEditor.stackMemberBody", { primary: ociMemberOf.label })}
</p>
<div className="pt-1 flex justify-center">
<Button
onClick={() => window.dispatchEvent(new CustomEvent("openLxcAppModal", {
detail: { vmid: ociMemberOf.vmid, tab: "app" },
}))}
className="bg-blue-500 hover:bg-blue-600 text-white"
>
<ChevronRight className="h-4 w-4 mr-1.5" />
{t("vmLxc.ociUpdates.openPrimary")}
</Button>
</div>
</CardContent>
</Card>
)}
{apps.length === 0 && !ociMemberOf && (
<Card className="border border-border bg-card/50">
<CardContent className="p-6 space-y-3">
<div className="mx-auto p-2 rounded-full bg-emerald-500/10 w-fit">
@@ -162,6 +162,19 @@ export function SecureGatewaySetup() {
loadInitialData()
}, [])
// The gateway was updated from the Updates tab of its container: read the
// update state and the status again so this card agrees with it.
useEffect(() => {
const refresh = (event: Event) => {
const detail = (event as CustomEvent).detail || {}
if (detail.appId !== "secure-gateway" || detail.source === "card") return
void loadUpdateInfo(true)
void loadStatus()
}
window.addEventListener("proxmenuxManagedAppUpdated", refresh)
return () => window.removeEventListener("proxmenuxManagedAppUpdated", refresh)
}, [])
const loadInitialData = async () => {
setLoading(true)
setLoadError(null)
@@ -288,6 +301,8 @@ export function SecureGatewaySetup() {
// Status may briefly show "stopped" if tailscale was restarted —
// refresh that too so the action buttons render the right state.
await loadStatus()
// The Updates tab of the gateway container reads it again.
window.dispatchEvent(new CustomEvent("proxmenuxManagedAppUpdated", { detail: { appId: "secure-gateway", source: "card" } }))
} else {
setUpdateError(res?.message || sg("errors.updateFailed"))
}
+24
View File
@@ -1102,6 +1102,14 @@ export function VirtualMachines() {
return () => window.removeEventListener("openLxcAppModal", handler as EventListener)
}, [vmData])
// An application ProxMenux manages (Secure Gateway) was updated or checked
// from its own card: read the guests again so its Updates tab agrees.
useEffect(() => {
const refresh = () => { mutate() }
window.addEventListener("proxmenuxManagedAppUpdated", refresh)
return () => window.removeEventListener("proxmenuxManagedAppUpdated", refresh)
}, [mutate])
// Same deep-link but for QEMU guests. VMs don't have the App tab,
// so we land on Status (which is what handleVMClick already
// defaults to — no override needed).
@@ -5057,6 +5065,20 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
ctIp={ctIp}
onChange={() => mutate()}
initialData={getLxcAppsCached(selectedVM.vmid) ?? null}
oci={ociInstance?.oci_instance ? {
instance: true,
memberOf: ociInstance.stack && ociInstance.primary_vmid !== selectedVM.vmid
? (() => {
const primaryVM = (vmData || []).find((v) => v.vmid === ociInstance.primary_vmid)
return {
vmid: ociInstance.primary_vmid,
label: primaryVM
? `${primaryVM.name} (CT ${ociInstance.primary_vmid})`
: `CT ${ociInstance.primary_vmid}`,
}
})()
: null,
} : null}
managed={
managedEntry
? {
@@ -5182,6 +5204,8 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => {
onClick={async () => {
try {
await fetchApi(`/api/oci/installed/${appId}/update`, { method: "POST" })
// The card of this application on the Security page reads it again.
window.dispatchEvent(new CustomEvent("proxmenuxManagedAppUpdated", { detail: { appId } }))
mutate()
} catch { /* opening the App tab surfaces the error */ }
}}
+24
View File
@@ -1996,6 +1996,10 @@
"secure_gateway_update_available": "Secure Gateway-Update verfügbar",
"nvidia_driver_update_available": "NVIDIA-Treiberupdate verfügbar",
"coral_driver_update_available": "Update des Coral TPU-Treibers verfügbar",
"oci_update_completed": "OCI-Anwendung aktualisiert",
"oci_update_failed": "Aktualisierung der OCI-Anwendung fehlgeschlagen",
"oci_recreate_completed": "OCI-Anwendung neu erstellt",
"oci_recreate_failed": "Neuerstellung der OCI-Anwendung fehlgeschlagen",
"app_update_available": "App-Update verfügbar",
"lxc_update_applied": "LXC Update angewendet",
"docker_stack_update_available": "Docker Updates verfügbar"
@@ -6290,6 +6294,26 @@
"body": "{details}",
"label": "LXC Update angewendet"
},
"oci_update_completed": {
"title": "{hostname}: {app_name} aktualisiert",
"body": "{app_name} wurde auf das neue Image aktualisiert, die Daten wurden beibehalten.\nContainer: {containers}",
"label": "OCI-Anwendung aktualisiert"
},
"oci_update_failed": {
"title": "{hostname}: Aktualisierung von {app_name} nicht abgeschlossen",
"body": "Die Aktualisierung von {app_name} wurde nicht abgeschlossen.\nGrund: {reason}\nContainer: {containers}\nÖffnen Sie „Installierte OCI-Anwendungen verwalten“, um den Zustand zu prüfen.",
"label": "Aktualisierung der OCI-Anwendung fehlgeschlagen"
},
"oci_recreate_completed": {
"title": "{hostname}: {app_name} neu erstellt",
"body": "{app_name} wurde mit den neuen Optionen neu erstellt, die Daten wurden beibehalten.\nContainer: {containers}",
"label": "OCI-Anwendung neu erstellt"
},
"oci_recreate_failed": {
"title": "{hostname}: Neuerstellung von {app_name} nicht abgeschlossen",
"body": "Die Neuerstellung von {app_name} wurde nicht abgeschlossen.\nGrund: {reason}\nContainer: {containers}\nÖffnen Sie „Installierte OCI-Anwendungen verwalten“, um den Zustand zu prüfen.",
"label": "Neuerstellung der OCI-Anwendung fehlgeschlagen"
},
"app_update_available": {
"title": "{hostname}: {app_name} Update verfügbar auf CT {vmid}",
"body": "{app_name} auf CT {vmid} ({ct_name}) hat eine neue Version:\n {installed} → {latest}",
File diff suppressed because one or more lines are too long
+26
View File
@@ -1601,6 +1601,8 @@
"hiddenDetectionsHelpPlural": "Tienes {count} detecciones ocultas. Restaure una para recuperarla o registre una aplicación personalizada manualmente.",
"registerCustom": "Registrar una aplicación personalizada",
"noAppsTitle": "No hay aplicaciones registradas",
"stackMemberTitle": "Forma parte de una aplicación multicontenedor",
"stackMemberBody": "Este contenedor da servicio a una aplicación cuyo contenedor principal es {primary}. La aplicación, sus enlaces web y su versión se muestran allí.",
"noAppsBody": "Registre las aplicaciones que se ejecutan en este contenedor. Obtendrá enlaces web y, opcionalmente, seguimiento de versiones disponibles y notificaciones de nuevos lanzamientos.",
"registerApplication": "Registrar aplicación",
"searchApplications": "Buscar aplicaciones",
@@ -1983,6 +1985,10 @@
"update_summary": "Actualizaciones de paquetes del host",
"pve_update": "Actualización de Proxmox VE disponible",
"update_complete": "Actualización del host completada",
"oci_update_completed": "Aplicación OCI actualizada",
"oci_update_failed": "Actualización de aplicación OCI fallida",
"oci_recreate_completed": "Aplicación OCI recreada",
"oci_recreate_failed": "Recreación de aplicación OCI fallida",
"app_update_available": "Actualización de app disponible",
"ai_model_migrated": "Modelo de IA actualizado automáticamente",
"proxmenux_update": "Actualización de ProxMenux disponible",
@@ -6290,6 +6296,26 @@
"body": "{details}",
"label": "Actualización LXC aplicada"
},
"oci_update_completed": {
"title": "{hostname}: {app_name} actualizado",
"body": "{app_name} se ha actualizado a su nueva imagen y sus datos se han conservado.\nContenedores: {containers}",
"label": "Aplicación OCI actualizada"
},
"oci_update_failed": {
"title": "{hostname}: la actualización de {app_name} no se completó",
"body": "La actualización de {app_name} no se completó.\nMotivo: {reason}\nContenedores: {containers}\nAbre Gestionar aplicaciones OCI instaladas para comprobar su estado.",
"label": "Actualización de aplicación OCI fallida"
},
"oci_recreate_completed": {
"title": "{hostname}: {app_name} recreado",
"body": "{app_name} se ha recreado con sus nuevas opciones y sus datos se han conservado.\nContenedores: {containers}",
"label": "Aplicación OCI recreada"
},
"oci_recreate_failed": {
"title": "{hostname}: la recreación de {app_name} no se completó",
"body": "La recreación de {app_name} no se completó.\nMotivo: {reason}\nContenedores: {containers}\nAbre Gestionar aplicaciones OCI instaladas para comprobar su estado.",
"label": "Recreación de aplicación OCI fallida"
},
"app_update_available": {
"title": "{hostname}: actualización de {app_name} disponible en CT {vmid}",
"body": "{app_name} en CT {vmid} ({ct_name}) tiene una nueva versión:\n {installed} → {latest}",
+24
View File
@@ -1996,6 +1996,10 @@
"secure_gateway_update_available": "Mise à jour de Secure Gateway disponible",
"nvidia_driver_update_available": "Mise à jour du pilote NVIDIA disponible",
"coral_driver_update_available": "Mise à jour du pilote Coral TPU disponible",
"oci_update_completed": "Application OCI mise à jour",
"oci_update_failed": "Échec de la mise à jour de l'application OCI",
"oci_recreate_completed": "Application OCI recréée",
"oci_recreate_failed": "Échec de la recréation de l'application OCI",
"app_update_available": "mise à jour de l'application disponible",
"lxc_update_applied": "Mise à jour LXC appliquée",
"docker_stack_update_available": "Docker mises à jour disponibles"
@@ -6290,6 +6294,26 @@
"body": "{details}",
"label": "Mise à jour LXC appliquée"
},
"oci_update_completed": {
"title": "{hostname} : {app_name} mis à jour",
"body": "{app_name} a été mis à jour vers sa nouvelle image et ses données ont été conservées.\nConteneurs : {containers}",
"label": "Application OCI mise à jour"
},
"oci_update_failed": {
"title": "{hostname} : la mise à jour de {app_name} n'a pas abouti",
"body": "La mise à jour de {app_name} n'a pas abouti.\nMotif : {reason}\nConteneurs : {containers}\nOuvrez Gérer les applications OCI installées pour vérifier son état.",
"label": "Échec de la mise à jour de l'application OCI"
},
"oci_recreate_completed": {
"title": "{hostname} : {app_name} recréé",
"body": "{app_name} a été recréé avec ses nouvelles options et ses données ont été conservées.\nConteneurs : {containers}",
"label": "Application OCI recréée"
},
"oci_recreate_failed": {
"title": "{hostname} : la recréation de {app_name} n'a pas abouti",
"body": "La recréation de {app_name} n'a pas abouti.\nMotif : {reason}\nConteneurs : {containers}\nOuvrez Gérer les applications OCI installées pour vérifier son état.",
"label": "Échec de la recréation de l'application OCI"
},
"app_update_available": {
"title": "{hostname} : mise à jour {app_name} disponible sur CT {vmid}",
"body": "{app_name} sur CT {vmid} ({ct_name}) a une nouvelle version :\n {installed} → {latest}",
+24
View File
@@ -1996,6 +1996,10 @@
"secure_gateway_update_available": "Aggiornamento Secure Gateway disponibile",
"nvidia_driver_update_available": "Aggiornamento del driver NVIDIA disponibile",
"coral_driver_update_available": "Disponibile l'aggiornamento del driver Coral TPU",
"oci_update_completed": "Applicazione OCI aggiornata",
"oci_update_failed": "Aggiornamento dell'applicazione OCI non riuscito",
"oci_recreate_completed": "Applicazione OCI ricreata",
"oci_recreate_failed": "Ricreazione dell'applicazione OCI non riuscita",
"app_update_available": "aggiornamento dell'app disponibile",
"lxc_update_applied": "Aggiornamento LXC applicato",
"docker_stack_update_available": "Aggiornamenti Docker disponibili"
@@ -6290,6 +6294,26 @@
"body": "{details}",
"label": "Aggiornamento LXC applicato"
},
"oci_update_completed": {
"title": "{hostname}: {app_name} aggiornato",
"body": "{app_name} è stato aggiornato alla nuova immagine e i dati sono stati conservati.\nContenitori: {containers}",
"label": "Applicazione OCI aggiornata"
},
"oci_update_failed": {
"title": "{hostname}: aggiornamento di {app_name} non completato",
"body": "L'aggiornamento di {app_name} non è stato completato.\nMotivo: {reason}\nContenitori: {containers}\nApri Gestisci le applicazioni OCI installate per verificarne lo stato.",
"label": "Aggiornamento dell'applicazione OCI non riuscito"
},
"oci_recreate_completed": {
"title": "{hostname}: {app_name} ricreato",
"body": "{app_name} è stato ricreato con le nuove opzioni e i dati sono stati conservati.\nContenitori: {containers}",
"label": "Applicazione OCI ricreata"
},
"oci_recreate_failed": {
"title": "{hostname}: ricreazione di {app_name} non completata",
"body": "La ricreazione di {app_name} non è stata completata.\nMotivo: {reason}\nContenitori: {containers}\nApri Gestisci le applicazioni OCI installate per verificarne lo stato.",
"label": "Ricreazione dell'applicazione OCI non riuscita"
},
"app_update_available": {
"title": "{hostname}: aggiornamento {app_name} disponibile su CT {vmid}",
"body": "{app_name} su CT {vmid} ({ct_name}) ha una nuova versione:\n {installed} → {latest}",
+24
View File
@@ -1996,6 +1996,10 @@
"secure_gateway_update_available": "Atualização do Secure Gateway disponível",
"nvidia_driver_update_available": "Atualização de driver NVIDIA disponível",
"coral_driver_update_available": "Atualização do driver Coral TPU disponível",
"oci_update_completed": "Aplicação OCI atualizada",
"oci_update_failed": "Falha na atualização da aplicação OCI",
"oci_recreate_completed": "Aplicação OCI recriada",
"oci_recreate_failed": "Falha na recriação da aplicação OCI",
"app_update_available": "atualização de aplicativo disponível",
"lxc_update_applied": "Atualização LXC aplicada",
"docker_stack_update_available": "Docker atualizações disponíveis"
@@ -6290,6 +6294,26 @@
"body": "{details}",
"label": "Atualização LXC aplicada"
},
"oci_update_completed": {
"title": "{hostname}: {app_name} atualizado",
"body": "{app_name} foi atualizado para a nova imagem e os dados foram mantidos.\nContêineres: {containers}",
"label": "Aplicação OCI atualizada"
},
"oci_update_failed": {
"title": "{hostname}: a atualização de {app_name} não foi concluída",
"body": "A atualização de {app_name} não foi concluída.\nMotivo: {reason}\nContêineres: {containers}\nAbra Gerir aplicações OCI instaladas para verificar o estado.",
"label": "Falha na atualização da aplicação OCI"
},
"oci_recreate_completed": {
"title": "{hostname}: {app_name} recriado",
"body": "{app_name} foi recriado com as novas opções e os dados foram mantidos.\nContêineres: {containers}",
"label": "Aplicação OCI recriada"
},
"oci_recreate_failed": {
"title": "{hostname}: a recriação de {app_name} não foi concluída",
"body": "A recriação de {app_name} não foi concluída.\nMotivo: {reason}\nContêineres: {containers}\nAbra Gerir aplicações OCI instaladas para verificar o estado.",
"label": "Falha na recriação da aplicação OCI"
},
"app_update_available": {
"title": "{hostname}: atualização {app_name} disponível no CT {vmid}",
"body": "{app_name} no CT {vmid} ({ct_name}) tem uma nova versão:\n {installed} → {latest}",
+24
View File
@@ -1997,6 +1997,10 @@
"secure_gateway_update_available": "K dispozícii je aktualizácia Secure Gateway",
"nvidia_driver_update_available": "Dostupná aktualizácia ovládača NVIDIA",
"coral_driver_update_available": "Dostupná aktualizácia ovládača Coral TPU",
"oci_update_completed": "OCI aplikácia aktualizovaná",
"oci_update_failed": "Aktualizácia OCI aplikácie zlyhala",
"oci_recreate_completed": "OCI aplikácia znovu vytvorená",
"oci_recreate_failed": "Opätovné vytvorenie OCI aplikácie zlyhalo",
"app_update_available": "K dispozícii je aktualizácia aplikácie"
},
"ui": {
@@ -6289,6 +6293,26 @@
"body": "{details}",
"label": "Aktualizácia LXC použitá"
},
"oci_update_completed": {
"title": "{hostname}: {app_name} aktualizovaná",
"body": "Aplikácia {app_name} bola aktualizovaná na nový obraz a jej dáta zostali zachované.\nKontajnery: {containers}",
"label": "OCI aplikácia aktualizovaná"
},
"oci_update_failed": {
"title": "{hostname}: aktualizácia {app_name} sa nedokončila",
"body": "Aktualizácia aplikácie {app_name} sa nedokončila.\nDôvod: {reason}\nKontajnery: {containers}\nOtvorte Spravovať nainštalované OCI aplikácie a skontrolujte jej stav.",
"label": "Aktualizácia OCI aplikácie zlyhala"
},
"oci_recreate_completed": {
"title": "{hostname}: {app_name} znovu vytvorená",
"body": "Aplikácia {app_name} bola znovu vytvorená s novými možnosťami a jej dáta zostali zachované.\nKontajnery: {containers}",
"label": "OCI aplikácia znovu vytvorená"
},
"oci_recreate_failed": {
"title": "{hostname}: opätovné vytvorenie {app_name} sa nedokončilo",
"body": "Opätovné vytvorenie aplikácie {app_name} sa nedokončilo.\nDôvod: {reason}\nKontajnery: {containers}\nOtvorte Spravovať nainštalované OCI aplikácie a skontrolujte jej stav.",
"label": "Opätovné vytvorenie OCI aplikácie zlyhalo"
},
"app_update_available": {
"title": "{hostname}: Pre {app_name} je na CT {vmid} dostupná aktualizácia",
"body": "Aplikácia {app_name} na CT {vmid} ({ct_name}) má novú verziu:\n {installed} → {latest}",
+24
View File
@@ -1996,6 +1996,10 @@
"secure_gateway_update_available": "Secure Gateway uppdatering tillgänglig",
"nvidia_driver_update_available": "NVIDIA drivrutinsuppdatering tillgänglig",
"coral_driver_update_available": "Coral TPU drivrutinsuppdatering tillgänglig",
"oci_update_completed": "OCI-applikation uppdaterad",
"oci_update_failed": "Uppdatering av OCI-applikation misslyckades",
"oci_recreate_completed": "OCI-applikation återskapad",
"oci_recreate_failed": "Återskapande av OCI-applikation misslyckades",
"app_update_available": "Appuppdatering tillgänglig",
"lxc_update_applied": "LXC uppdatering tillämpad",
"docker_stack_update_available": "Docker uppdateringar tillgängliga"
@@ -6290,6 +6294,26 @@
"body": "{details}",
"label": "LXC uppdatering tillämpad"
},
"oci_update_completed": {
"title": "{hostname}: {app_name} uppdaterad",
"body": "{app_name} uppdaterades till sin nya avbild och dess data behölls.\nContainrar: {containers}",
"label": "OCI-applikation uppdaterad"
},
"oci_update_failed": {
"title": "{hostname}: uppdateringen av {app_name} slutfördes inte",
"body": "Uppdateringen av {app_name} slutfördes inte.\nOrsak: {reason}\nContainrar: {containers}\nÖppna Hantera installerade OCI-applikationer för att kontrollera dess tillstånd.",
"label": "Uppdatering av OCI-applikation misslyckades"
},
"oci_recreate_completed": {
"title": "{hostname}: {app_name} återskapad",
"body": "{app_name} återskapades med sina nya alternativ och dess data behölls.\nContainrar: {containers}",
"label": "OCI-applikation återskapad"
},
"oci_recreate_failed": {
"title": "{hostname}: återskapandet av {app_name} slutfördes inte",
"body": "Återskapandet av {app_name} slutfördes inte.\nOrsak: {reason}\nContainrar: {containers}\nÖppna Hantera installerade OCI-applikationer för att kontrollera dess tillstånd.",
"label": "Återskapande av OCI-applikation misslyckades"
},
"app_update_available": {
"title": "{hostname}: {app_name} uppdatering tillgänglig på CT {vmid}",
"body": "{app_name} på CT {vmid} ({ct_name}) har en ny version:\n {installed} → {latest}",
+1
View File
@@ -138,6 +138,7 @@ cp "$SCRIPT_DIR/mount_monitor.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠
cp "$SCRIPT_DIR/lxc_mount_points.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ lxc_mount_points.py not found"
cp "$SCRIPT_DIR/oci_console_logs.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ oci_console_logs.py not found"
cp "$SCRIPT_DIR/oci_instance_info.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ oci_instance_info.py not found"
cp "$SCRIPT_DIR/oci_operations.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ oci_operations.py not found"
cp "$SCRIPT_DIR/disk_temperature_history.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ disk_temperature_history.py not found"
cp "$SCRIPT_DIR/smartctl_resolver.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ smartctl_resolver.py not found"
cp "$SCRIPT_DIR/disk_identity.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ disk_identity.py not found"
@@ -1625,6 +1625,54 @@ def internal_shutdown_event():
return jsonify({'error': 'internal_error', 'detail': str(e)}), 500
# ─── Internal OCI Event Endpoint ─────────────────────────────────
_OCI_EVENTS = {
'oci_update_completed': 'INFO', 'oci_update_failed': 'WARNING',
'oci_recreate_completed': 'INFO', 'oci_recreate_failed': 'WARNING',
}
@notification_bp.route('/api/internal/oci-event', methods=['POST'])
def internal_oci_event():
"""Called by the OCI engine when an update or a recreation ends, with its
result. Only accepts requests from this host."""
remote_addr = request.remote_addr or ''
try:
import ipaddress
addr = ipaddress.ip_address(remote_addr.split('%')[0])
if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped is not None:
addr = addr.ipv4_mapped
is_loopback = addr.is_loopback
except (ValueError, TypeError):
is_loopback = remote_addr in ('127.0.0.1', '::1', 'localhost')
if not is_loopback:
return jsonify({'error': 'forbidden', 'detail': 'localhost only'}), 403
try:
data = request.get_json(silent=True) or {}
event_type = str(data.get('event') or '')
if event_type not in _OCI_EVENTS:
return jsonify({'error': 'invalid_event_type'}), 400
vmid = str(data.get('vmid') or '')
notification_manager.emit_event(
event_type=event_type,
severity=_OCI_EVENTS[event_type],
data={
'hostname': str(data.get('hostname') or 'unknown'),
'app_name': str(data.get('app_name') or f'CT {vmid}')[:120],
'vmid': vmid,
'containers': str(data.get('containers') or '')[:400],
'reason': str(data.get('reason') or '')[:600],
},
source='proxmenux',
entity='ct',
entity_id=vmid,
)
return jsonify({'success': True, 'event_type': event_type}), 200
except Exception as e:
return jsonify({'error': 'internal_error', 'detail': str(e)}), 500
# ─── Internal Restore Event Endpoint ─────────────────────────────
@notification_bp.route('/api/internal/restore-event', methods=['POST'])
+13
View File
@@ -545,6 +545,15 @@ def update_auth_key(app_id: str):
}), 500
def _sync_managed_registry(app_id: str) -> None:
"""Keep the Updates tab of the container in step with this page."""
try:
import managed_installs
managed_installs.refresh_oci_app(app_id)
except Exception as e:
logger.warning(f"Could not refresh the managed registry for {app_id}: {e}")
@oci_bp.route("/installed/<app_id>/update-check", methods=["GET"])
@require_auth
def installed_update_check(app_id: str):
@@ -558,6 +567,8 @@ def installed_update_check(app_id: str):
try:
force = request.args.get("force", "").lower() in ("1", "true", "yes")
result = oci_manager.check_app_update_available(app_id, force=force)
if force:
_sync_managed_registry(app_id)
return jsonify({"success": True, **result})
except Exception as e:
logger.error(f"Failed to check app update for {app_id}: {e}")
@@ -572,6 +583,8 @@ def installed_update_apply(app_id: str):
would cause an unnecessary brief disconnect."""
try:
result = oci_manager.update_app(app_id)
if result.get("success"):
_sync_managed_registry(app_id)
status_code = 200 if result.get("success") else 500
return jsonify(result), status_code
except Exception as e:
+26
View File
@@ -3158,6 +3158,20 @@ class HealthMonitor:
print(f"[HealthMonitor] Disk/IO check failed: {e}")
return {'status': 'UNKNOWN', 'reason': f'Disk check unavailable: {str(e)}', 'checks': {}, 'dismissable': True}
@staticmethod
def _bridge_is_idle(interface: str, root: str = '/sys/class/net') -> bool:
"""Whether a bridge is administratively up with no port attached.
Such a bridge reports no carrier, which is its normal state and not a
failure. A bridge that is set down, or one that has ports and still no
carrier, is not idle."""
try:
with open(f'{root}/{interface}/flags', encoding='ascii') as handle:
administratively_up = bool(int(handle.read().strip(), 16) & 0x1)
return administratively_up and not os.listdir(f'{root}/{interface}/brif')
except (OSError, ValueError):
return False
def _check_network_optimized(self) -> Dict[str, Any]:
"""
Optimized network check - only alerts for interfaces that are actually in use.
@@ -3206,6 +3220,18 @@ class HealthMonitor:
# Check if it's a bridge interface (always important for VMs/LXCs)
if interface.startswith('vmbr'):
if self._bridge_is_idle(interface):
# A bridge with no port attached has no carrier: the
# private network of an application whose containers
# are stopped, during an update for example. Nothing
# is down; nothing is connected to it.
interface_details[interface] = {
'status': 'OK',
'reason': 'Bridge without attached ports',
'is_up': False,
}
health_persistence.resolve_error(interface, 'Bridge without attached ports')
continue
should_alert = True
alert_reason = 'Bridge interface DOWN (VMs/LXCs may be affected)'
+57 -10
View File
@@ -4332,7 +4332,10 @@ def check_app(
pass
if app.get("installed_via") == "oci_image":
result = _oci_image_versions(vmid, known=state)
# A secondary container of a stack shows the version it runs; the
# registry is not asked, because it is not updated on its own.
result = _oci_image_versions(
vmid, known=state, with_latest=not _oci_secondary_member(_read_oci_record(vmid)))
if result.get("busy"):
_recheck_after_oci_operation(vmid, app_id)
return sidecar
@@ -5458,30 +5461,50 @@ def _dismiss_oci_registration(vmid) -> None:
print(f"[ProxMenux] lxc_apps: could not save the OCI dismissal: {exc}")
def _oci_secondary_member(record) -> bool:
"""Whether the record belongs to a container of a stack other than its main one."""
if not isinstance(record, dict):
return False
member = record.get("stack_member") if isinstance(record.get("stack_member"), dict) else {}
return member.get("primary_vmid") not in (None, record.get("vmid"))
def ensure_oci_registration(vmid) -> bool:
"""Register the application ProxMenux installed from an OCI image the
first time the Monitor sees its container, with version tracking by the
image. The auxiliary members of a stack are not registered, nor is a
container that already has applications, nor one whose registration the
user removed."""
image. A secondary container of a stack, its database or its cache, is
registered with the version it runs and no tracking: the stack is updated
as a whole from its main container. A container that already has
applications is left alone, and so is one whose registration the user
removed."""
record = _read_oci_record(vmid)
if not record or record.get("status") != "installed":
return False
member = record.get("stack_member") if isinstance(record.get("stack_member"), dict) else {}
if member.get("primary_vmid") not in (None, record.get("vmid")):
return False
secondary = _oci_secondary_member(record)
if _oci_dismissed().get(str(int(vmid))) == record.get("installation_id"):
return False
with _cache_lock:
sidecar = _read_sidecar(vmid)
if sidecar and sidecar.get("apps"):
# An application registered before its logo could be resolved
# takes it now; nothing else of what is registered is touched.
missing = [app for app in sidecar["apps"]
if app.get("installed_via") == "oci_image" and not app.get("logo_url")]
logo = (_oci_instance_meta(vmid) or {}).get("logo") if missing else ""
if logo:
for app in missing:
app["logo_url"] = logo
_write_sidecar(vmid, sidecar)
return False
meta = _oci_instance_meta(vmid)
if not meta or not meta.get("name") or not _NAME_RE.match(str(meta["name"])):
return False
category = meta.get("category_label") or meta.get("category") or ""
endpoints = meta.get("endpoints") or []
if not endpoints:
if secondary:
# A database or a cache is reached by the application, not by the user.
endpoints = []
elif not endpoints:
port = meta.get("endpoint_port") or next(iter(meta.get("ports") or []), None)
endpoints = [{"port": port, "scheme": meta.get("endpoint_scheme"), "path": meta.get("endpoint_path"),
"description": "", "logo_url": ""}] if isinstance(port, int) else []
@@ -5543,6 +5566,15 @@ def _recheck_after_oci_operation(vmid, app_id: str) -> None:
threading.Thread(target=wait_and_check, name=f"oci-recheck-{vmid}", daemon=True).start()
_OCI_ICON_BASE = "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp"
# The services a stack runs beside its application, by the name of their image.
_OCI_SERVICE_ICONS = {
name: f"{_OCI_ICON_BASE}/{icon}.webp"
for name, icon in (("postgres", "postgresql"), ("valkey", "valkey"), ("redis", "redis"),
("mariadb", "mariadb"), ("mongo", "mongodb"), ("meilisearch", "meilisearch"))
}
def _oci_instance_meta(vmid) -> Optional[dict]:
"""What ProxMenux itself recorded when it installed this container.
@@ -5623,12 +5655,27 @@ def _oci_instance_meta(vmid) -> Optional[dict]:
endpoints.append(detail)
catalog_icons = _oci_catalog_icons()
logo = catalog_icons.get(template_id) or ""
repository = str(image.get("reference") or "").split("@", 1)[0]
basename = repository.rsplit("/", 1)[-1].rsplit(":", 1)[0].strip().lower()
# A container of a stack records its own template id, `image-immich-server`,
# which the catalog does not list: the application it belongs to does.
stack_id = str(stack_template.get("id") or "").strip()
secondary = _oci_secondary_member(record)
if not stack_id and secondary:
primary = _read_oci_record(member.get("primary_vmid")) or {}
stack_id = str(((primary.get("stack") or {}).get("template") or {}).get("id") or "").strip()
application = stack_id.removeprefix("image-").removesuffix("-stack")
if not logo and stack_id and (not secondary or (application and basename.startswith(application))):
# The main container, or one that carries the application in its own
# name, such as Immich's machine learning.
logo = catalog_icons.get(stack_id) or ""
if not logo:
# A stack or a one-off image has no catalog entry of its own, but the
# image it runs usually does: the Nextcloud stack wears Nextcloud's.
repository = str(image.get("reference") or "").split("@", 1)[0]
basename = repository.rsplit("/", 1)[-1].rsplit(":", 1)[0].strip().lower()
logo = catalog_icons.get(basename) or ""
if not logo:
# The database or the cache beside an application.
logo = _OCI_SERVICE_ICONS.get(basename, "")
if not logo:
logo = ui.get("icon") if isinstance(ui.get("icon"), str) else ""
website = ui.get("website") if isinstance(ui.get("website"), str) else ""
+21
View File
@@ -1716,6 +1716,27 @@ def _store_update_result(item: dict, result: dict) -> None:
item["update_check"][extra_key] = result[extra_key]
def refresh_oci_app(app_id: str) -> Optional[dict]:
"""Read one OCI-managed application again and store what it reports.
Its card on the Security page and the Updates tab of its container read
different stores; after an update or a forced check from either one, the
registry is brought to the same state the application reports now."""
with _lock:
reg = _read_registry()
for it in reg.get("items", []):
if (it.get("type") != "oci_app" or it.get("removed_at")
or it.get("_oci_app_id") != app_id):
continue
result = _check_oci_app(it)
_store_update_result(it, result)
if result.get("current"):
it["current_version"] = result["current"]
_write_registry(reg)
return it
return None
def check_for_updates(force: bool = False) -> list[dict]:
"""Run every type-specific checker over active items, persist
the updated state, return the list of items that have an update
+10
View File
@@ -1275,6 +1275,16 @@ class NotificationManager:
if self._is_backup_running():
return
# The stop, the backup and the start of a container the OCI manager is
# updating or recreating are steps of that operation, which reports
# its own result when it ends.
try:
import oci_operations
if oci_operations.quiet(event):
return
except Exception:
pass
# Check storage exclusions for storage-related events.
# If the storage is excluded from notifications, suppress the event entirely.
_STORAGE_EVENTS = {'storage_unavailable', 'storage_low_space', 'storage_warning', 'storage_error',
@@ -870,6 +870,44 @@ TEMPLATES = {
'group': 'vm_ct',
'default_enabled': True,
},
'oci_update_completed': {
'title': '{hostname}: {app_name} updated',
'body': '{app_name} was updated to its new image and its data was kept.\nContainers: {containers}',
'label': 'OCI application updated',
'group': 'vm_ct',
'default_enabled': True,
},
'oci_update_failed': {
'title': '{hostname}: {app_name} update did not complete',
'body': (
'The update of {app_name} did not complete.\n'
'Reason: {reason}\n'
'Containers: {containers}\n'
'Open Manage installed OCI applications to check its state.'
),
'label': 'OCI application update failed',
'group': 'vm_ct',
'default_enabled': True,
},
'oci_recreate_completed': {
'title': '{hostname}: {app_name} recreated',
'body': '{app_name} was recreated with its new options and its data was kept.\nContainers: {containers}',
'label': 'OCI application recreated',
'group': 'vm_ct',
'default_enabled': True,
},
'oci_recreate_failed': {
'title': '{hostname}: {app_name} recreation did not complete',
'body': (
'The recreation of {app_name} did not complete.\n'
'Reason: {reason}\n'
'Containers: {containers}\n'
'Open Manage installed OCI applications to check its state.'
),
'label': 'OCI application recreation failed',
'group': 'vm_ct',
'default_enabled': True,
},
'app_update_available': {
'title': '{hostname}: {app_name} update available on CT {vmid}',
'body': (
@@ -2314,6 +2352,10 @@ EVENT_EMOJI = {
'lxc_updates_available': '\U0001F4E6', # \uD83D\uDCE6 package \u2014 pending CT updates
'apt_listchanges': '\U0001F4E6', # package-maintainer NEWS via PVE mail
'lxc_update_applied': '\u2705', # \u2705 check \u2014 update applied
'oci_update_completed': '\u2705',
'oci_update_failed': '\u26A0\uFE0F',
'oci_recreate_completed': '\u2705',
'oci_recreate_failed': '\u26A0\uFE0F',
'app_update_available': '\U0001F195', # \ud83c\udd95 NEW \u2014 upstream app release
'docker_stack_update_available': '\U0001F433',
'vm_start': '\u25B6\uFE0F', # play button
+62
View File
@@ -0,0 +1,62 @@
"""Containers an OCI manager operation is working on.
An update or a recreation stops, backs up and starts its containers. The OCI
engine marks them while it works and reports the result itself, so their
stop, start and backup notices are part of the operation, not news.
"""
import json
import os
import re
import time
MARKERS = '/run/proxmenux/oci-operations'
# The last start of an operation is noticed a little after it returned.
GRACE_SECONDS = 180
# A mark left behind by an operation that died is not trusted for ever.
STALE_SECONDS = 6 * 3600
QUIET_EVENTS = frozenset({
'vm_start', 'vm_stop', 'vm_shutdown', 'vm_restart',
'ct_start', 'ct_stop', 'ct_shutdown', 'ct_restart',
'backup_start', 'backup_complete',
})
_OCI_BACKUP_PATH = '/proxmenux/oci/instances/'
def active(vmid, now=None, root=MARKERS) -> bool:
try:
with open(os.path.join(root, str(int(vmid))), encoding='utf-8') as handle:
mark = json.load(handle)
started = float(mark.get('started') or 0)
ended = mark.get('ended')
except (OSError, ValueError, TypeError):
return False
now = time.time() if now is None else now
if ended is None:
return now - started < STALE_SECONDS
return now - float(ended) < GRACE_SECONDS
def _vmids(event) -> set:
data = event.data or {}
found = set()
for value in (data.get('vmid'), getattr(event, 'entity_id', '')):
if str(value or '').isdigit():
found.add(int(value))
if event.event_type.startswith('backup_'):
text = ' '.join(str(data.get(key) or '') for key in ('reason', 'pve_message', 'vmname', 'guests'))
found.update(int(value) for value in re.findall(r'\((\d{3,})\)|vzdump-(?:lxc|qemu)-(\d+)-', text)
for value in value if value)
return found
def quiet(event, root=MARKERS) -> bool:
"""Whether the event is a step of a running OCI operation."""
if event.event_type not in QUIET_EVENTS or event.severity in ('CRITICAL', 'WARNING'):
return False
data = event.data or {}
if event.event_type.startswith('backup_') and any(
_OCI_BACKUP_PATH in str(data.get(key) or '') for key in ('reason', 'pve_message', 'filename')):
# The working copy of an update lives in the OCI registry of the host.
return True
vmids = _vmids(event)
return bool(vmids) and all(active(vmid, root=root) for vmid in vmids)
@@ -0,0 +1,39 @@
"""A bridge with no port attached has no carrier and is not a failure: the
private network of an application whose containers are stopped."""
import sys
import tempfile
from pathlib import Path
import unittest
SCRIPTS = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(SCRIPTS))
from health_monitor import HealthMonitor
class IdleBridgeTests(unittest.TestCase):
def bridge(self, flags, ports=()):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
folder = Path(tmp.name) / 'vmbr10'
(folder / 'brif').mkdir(parents=True)
(folder / 'flags').write_text(flags + '\n')
for port in ports:
(folder / 'brif' / port).mkdir()
return HealthMonitor._bridge_is_idle('vmbr10', tmp.name)
def test_an_up_bridge_with_no_port_is_idle(self):
self.assertTrue(self.bridge('0x1003'))
def test_a_bridge_with_ports_and_no_carrier_is_not_idle(self):
self.assertFalse(self.bridge('0x1003', ['enp3s0']))
self.assertFalse(self.bridge('0x1003', ['veth115i1']))
def test_a_bridge_set_down_is_not_idle(self):
self.assertFalse(self.bridge('0x1002'))
def test_an_interface_that_is_not_a_bridge_is_not_idle(self):
self.assertFalse(HealthMonitor._bridge_is_idle('vmbr10', '/nonexistent'))
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,75 @@
"""While the OCI manager updates or recreates an application, the stop, the
backup and the start of its containers are steps of that operation."""
import json
import sys
import tempfile
import time
from pathlib import Path
from types import SimpleNamespace
import unittest
SCRIPTS = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(SCRIPTS))
import oci_operations
def event(kind, vmid=None, severity='INFO', **data):
if vmid is not None:
data['vmid'] = str(vmid)
return SimpleNamespace(event_type=kind, severity=severity, data=data, entity_id=str(vmid or ''))
class OperationQuietTests(unittest.TestCase):
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.root = tmp.name
def mark(self, vmid, started, ended=None):
Path(self.root, str(vmid)).write_text(json.dumps({'started': started, 'ended': ended}))
def test_the_steps_of_a_running_operation_are_quiet(self):
self.mark(115, time.time())
for kind in ('ct_shutdown', 'ct_stop', 'ct_start', 'ct_restart', 'backup_start', 'backup_complete'):
self.assertTrue(oci_operations.quiet(event(kind, 115), self.root), kind)
def test_another_container_is_still_reported(self):
self.mark(115, time.time())
self.assertFalse(oci_operations.quiet(event('ct_stop', 200), self.root))
self.assertFalse(oci_operations.quiet(event('ct_stop'), self.root))
def test_a_problem_is_never_silenced(self):
self.mark(115, time.time())
self.assertFalse(oci_operations.quiet(event('ct_stop', 115, severity='WARNING'), self.root))
self.assertFalse(oci_operations.quiet(event('ct_fail', 115), self.root))
self.assertFalse(oci_operations.quiet(event('backup_fail', 115), self.root))
def test_the_last_start_is_still_quiet_just_after_the_operation(self):
now = time.time()
self.mark(115, now - 60, ended=now - 60)
self.assertTrue(oci_operations.quiet(event('ct_start', 115), self.root))
self.mark(115, now - 3600, ended=now - 3600)
self.assertFalse(oci_operations.quiet(event('ct_start', 115), self.root))
def test_a_mark_left_by_a_dead_operation_expires(self):
self.mark(115, time.time() - 7 * 3600)
self.assertFalse(oci_operations.quiet(event('ct_stop', 115), self.root))
def test_the_working_copy_of_an_update_is_recognised_by_its_path(self):
archive = ('/usr/local/share/proxmenux/oci/instances/115/stack-transactions/abc/backup-117/'
'vzdump-lxc-117-2026_10_02-20_45_41.tar.zst')
self.assertTrue(oci_operations.quiet(event('backup_complete', pve_message=archive), self.root))
self.assertFalse(oci_operations.quiet(
event('backup_complete', pve_message='/var/lib/vz/dump/vzdump-lxc-117-2026.tar.zst'), self.root))
def test_a_backup_of_several_guests_is_quiet_only_when_all_belong_to_the_operation(self):
self.mark(115, time.time())
self.mark(117, time.time())
both = event('backup_start', reason='VM/CT:\n CT immich-server (115)\n CT immich-db (117)')
mixed = event('backup_start', reason='VM/CT:\n CT immich-db (117)\n CT other (200)')
self.assertTrue(oci_operations.quiet(both, self.root))
self.assertFalse(oci_operations.quiet(mixed, self.root))
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,123 @@
"""A secondary container of an OCI stack is registered with the version it
runs and no version tracking; its application is updated with the stack."""
import json
import sys
import tempfile
from pathlib import Path
import unittest
from unittest.mock import patch
SCRIPTS = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(SCRIPTS))
import lxc_apps
DIGEST = 'sha256:' + 'ab' * 32
class StackMemberRegistrationTests(unittest.TestCase):
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.root = Path(tmp.name)
(self.root / 'catalog').mkdir()
(self.root / 'catalog/index.json').write_text(json.dumps({'applications': []}))
patches = [patch.object(lxc_apps, '_OCI_INSTANCE_ROOT', str(self.root / 'instances')),
patch.object(lxc_apps, '_OCI_CATALOG_INDEX', str(self.root / 'catalog/index.json')),
patch.object(lxc_apps, '_oci_catalog_cache', None),
patch.object(lxc_apps, '_APPS_DIR', str(self.root / 'apps')),
patch.object(lxc_apps, '_OCI_DISMISSED_FILE', str(self.root / 'apps/.oci-dismissed.json'))]
for item in patches:
item.start()
self.addCleanup(item.stop)
def record(self, vmid, primary, reference):
folder = self.root / f'instances/{vmid}'
folder.mkdir(parents=True)
(folder / 'oci-compose.json').write_text(json.dumps({
'vmid': vmid, 'status': 'installed', 'installation_id': f'install-{vmid}',
'stack_member': {'name': 'database', 'primary_vmid': primary},
'stack': {'template': {'id': 'stack-nextcloud', 'catalog_ui': {'title': 'Nextcloud'},
'first_run': {'endpoints': [{'port': 80, 'scheme': 'http', 'path': '/'}]}}},
'observed': {'image': {'manifest_digest': DIGEST, 'architecture': 'amd64'}},
'template': {'id': f'stack-nextcloud-{vmid}',
'container_contract': {'image': {'reference': reference},
'ports': [{'container_port': 5432}]}}}))
def test_the_database_of_a_stack_is_registered_without_a_web_port(self):
self.record(131, 129, 'docker.io/library/postgres:16-alpine')
with patch.object(lxc_apps, 'add_app', return_value=(True, {})) as add:
self.assertTrue(lxc_apps.ensure_oci_registration(131))
payload = add.call_args.args[1]
self.assertEqual(payload['name'], 'Postgres')
self.assertEqual(payload['installed_via'], 'oci_image')
self.assertEqual(payload['ports'], [])
def test_a_secondary_container_is_told_apart_from_the_main_one(self):
self.assertTrue(lxc_apps._oci_secondary_member({'vmid': 131, 'stack_member': {'primary_vmid': 129}}))
self.assertFalse(lxc_apps._oci_secondary_member({'vmid': 129, 'stack_member': {'primary_vmid': 129}}))
self.assertFalse(lxc_apps._oci_secondary_member({'vmid': 120}))
self.assertFalse(lxc_apps._oci_secondary_member(None))
def test_the_registry_is_not_asked_for_a_secondary_container(self):
self.record(131, 129, 'docker.io/library/postgres:16-alpine')
class Engine:
@staticmethod
def resolve_candidate(reference, architecture):
assert '@' in reference, 'only the installed digest is read'
return {'version': '16.15', 'created': '2026-09-01T00:00:00Z', 'manifest_digest': DIGEST}
with patch.object(lxc_apps, '_oci_state_module', return_value=Engine):
result = lxc_apps._oci_image_versions(
131, with_latest=not lxc_apps._oci_secondary_member(lxc_apps._read_oci_record(131)))
self.assertEqual(result['installed_version'], '16.15')
self.assertNotIn('update_available', result)
self.assertNotIn('latest_version', result)
if __name__ == '__main__':
unittest.main()
class StackLogoTests(StackMemberRegistrationTests):
ICON = 'https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/immich.webp'
def stack(self):
(self.root / 'catalog/index.json').write_text(json.dumps({'applications': [
{'id': 'immich', 'template_id': 'image-immich', 'template': 'apps/immich.json', 'icon': self.ICON}]}))
members = {115: ('server', 'ghcr.io/immich-app/immich-server:release'),
116: ('machine-learning', 'ghcr.io/immich-app/immich-machine-learning:release'),
117: ('database', 'ghcr.io/immich-app/postgres:14-vectorchord0.4.3'),
118: ('valkey', 'docker.io/valkey/valkey:9')}
for vmid, (role, reference) in members.items():
folder = self.root / f'instances/{vmid}'
folder.mkdir(parents=True)
record = {'vmid': vmid, 'status': 'installed', 'installation_id': f'install-{vmid}',
'stack_member': {'name': role, 'primary_vmid': 115},
'observed': {'image': {'manifest_digest': DIGEST, 'architecture': 'amd64'}},
'template': {'id': f'image-immich-{role}',
'container_contract': {'image': {'reference': reference}, 'ports': []}}}
if vmid == 115:
record['stack'] = {'template': {'id': 'image-immich', 'catalog_ui': {'title': 'Immich', 'icon': None}}}
(folder / 'oci-compose.json').write_text(json.dumps(record))
def test_the_main_container_and_machine_learning_wear_the_application_logo(self):
self.stack()
self.assertEqual(lxc_apps._oci_instance_meta(115)['logo'], self.ICON)
self.assertEqual(lxc_apps._oci_instance_meta(116)['logo'], self.ICON)
def test_the_database_and_the_cache_wear_their_own(self):
self.stack()
self.assertTrue(lxc_apps._oci_instance_meta(117)['logo'].endswith('/postgresql.webp'))
self.assertTrue(lxc_apps._oci_instance_meta(118)['logo'].endswith('/valkey.webp'))
def test_an_application_registered_without_logo_takes_it_later(self):
self.stack()
sidecar = {'vmid': 115, 'apps': [{'id': 'a', 'name': 'Immich', 'installed_via': 'oci_image', 'logo_url': ''},
{'id': 'b', 'name': 'Other', 'installed_via': '', 'logo_url': ''}]}
written = {}
with patch.object(lxc_apps, '_read_sidecar', return_value=sidecar), \
patch.object(lxc_apps, '_write_sidecar', side_effect=lambda vmid, data: written.update(data)):
self.assertFalse(lxc_apps.ensure_oci_registration(115))
self.assertEqual(written['apps'][0]['logo_url'], self.ICON)
self.assertEqual(written['apps'][1]['logo_url'], '')