From f7266e7b44fd3eb936acee05715766b85031fbcc Mon Sep 17 00:00:00 2001 From: MacRimi Date: Sat, 26 Sep 2026 01:22:37 +0200 Subject: [PATCH] Generalize OCI device setup and remove unused catalog hashes --- AppImage/components/script-terminal-modal.tsx | 4 +- AppImage/components/virtual-machines.tsx | 6 +- lang/es.json | 29 +- oci/catalog/apps/navidrome.json | 2 +- oci/catalog/index.json | 365 ------------------ oci/remote/install_oci.sh | 28 +- oci/remote/oci_description.py | 8 +- oci/remote/oci_instance_reconcile.py | 4 + oci/remote/oci_instance_transaction.py | 88 ++++- oci/remote/oci_instances.py | 18 + oci/remote/oci_nvidia_refresh.py | 3 +- oci/remote/oci_stack_native.py | 12 +- oci/remote/oci_update_current.py | 3 +- oci/src/proxmenux_oci/arr_suite.py | 2 + oci/src/proxmenux_oci/catalog.py | 33 +- oci/src/proxmenux_oci/cli.py | 20 +- oci/src/proxmenux_oci/extra_devices.py | 104 +++++ oci/src/proxmenux_oci/installer.py | 14 +- oci/src/proxmenux_oci/management.py | 19 +- oci/src/proxmenux_oci/recreation.py | 71 +--- oci/src/proxmenux_oci/stack.py | 2 + oci/src/proxmenux_oci/ui.py | 99 ++++- oci/tests/test_instance_reconcile.py | 58 +++ oci/tests/test_oci_description.py | 11 + oci/tests/test_recreation.py | 69 ++++ oci/tests/test_wizard.py | 104 +++++ 26 files changed, 689 insertions(+), 487 deletions(-) create mode 100644 oci/src/proxmenux_oci/extra_devices.py create mode 100644 oci/tests/test_recreation.py create mode 100644 oci/tests/test_wizard.py diff --git a/AppImage/components/script-terminal-modal.tsx b/AppImage/components/script-terminal-modal.tsx index 605ed58a..b4a7f308 100644 --- a/AppImage/components/script-terminal-modal.tsx +++ b/AppImage/components/script-terminal-modal.tsx @@ -649,7 +649,7 @@ const initMessage = { return } - if (value === "cancel" || value === "") { + if (value === "cancel") { setCurrentInteraction(null) setInteractionInput("") handleCloseModal() @@ -755,7 +755,7 @@ const initMessage = { return ( <> - + { if (!open) onClose() }}> { // user tapped the terminal's Close button and got kicked // all the way back to the guest list. if (open) return - if (applyOpen || terminalOpen) return + if (applyOpen || terminalOpen || ociAction) return setSelectedVM(null) setVMDetails(null) setCurrentView("main") @@ -3624,10 +3624,10 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { // remaining vectors (mobile tap slop reaching the parent's // scrim, ESC not consumed by the child) at the DOM level. onInteractOutside={(e) => { - if (applyOpen || terminalOpen) e.preventDefault() + if (applyOpen || terminalOpen || ociAction) e.preventDefault() }} onEscapeKeyDown={(e) => { - if (applyOpen || terminalOpen) e.preventDefault() + if (applyOpen || terminalOpen || ociAction) e.preventDefault() }} > {currentView === "main" ? ( diff --git a/lang/es.json b/lang/es.json index 0f2d6fa1..0df2d213 100644 --- a/lang/es.json +++ b/lang/es.json @@ -760,7 +760,7 @@ "Checking the device permissions for the application user...": "Verificación de los permisos del dispositivo para el usuario de la aplicación...", "Checking the image compatibility:": "Verificación de la compatibilidad de la imagen:", "Checking the image in the registry...": "Revisando la imagen en el registro...", - "Checking the interrupted operation...": "Revisando la interrumpida operación...", + "Checking the interrupted operation...": "Comprobando la operación interrumpida...", "Checking the interrupted stack operation...": "Revisando la operación de pila interrumpida...", "Checking the new image without starting it:": "Comprobando la nueva imagen sin comenzarla:", "Checking the remote...": "Revisando el control remoto...", @@ -1081,7 +1081,7 @@ "Container started successfully": "El contenedor se inició correctamente", "Container started successfully.": "El contenedor se inició correctamente.", "Container started.": "Contenedor iniciado.", - "Container stopped": "Container stopped", + "Container stopped": "Contenedor detenido", "Container stopped.": "El contenedor se detuvo.", "Container successfully converted to privileged.": "Contenedor convertido exitosamente a privilegiado.", "Container template— LXC templates": "Plantilla de contenedor: plantillas LXC", @@ -1352,7 +1352,7 @@ "Creating the backup": "Crear el backup", "Creating the container...": "Creando el contenedor...", "Creating the initial administrator...": "Crear el administrador inicial...", - "Creating the temporary data container": "Creación del contenedor de datos temporales", + "Creating the temporary data container": "Creando el contenedor temporal para los datos", "Creative & Design": "Creatividad y diseño", "Credentials are correct": "Las credenciales son correctas", "Credentials cleared. jwt_secret and API tokens preserved.": "Credenciales borradas. Se conservan los tokens jwt_secret y API.", @@ -3479,8 +3479,8 @@ "Mount Name": "Nombre de montaje", "Mount Name:": "Nombre de montaje:", "Mount Options": "Opciones de montaje", - "Mount Path": "Camino del monte", - "Mount Path:": "Camino de montaje:", + "Mount Path": "Ruta de montaje", + "Mount Path:": "Ruta de montaje:", "Mount Point": "Punto de montaje", "Mount Point ID": "ID del punto de montaje", "Mount Point Removal Summary:": "Resumen de eliminación del punto de montaje:", @@ -3501,7 +3501,7 @@ "Mount options:": "Opciones de montaje:", "Mount path must be an absolute path starting with /": "La ruta de montaje debe ser una ruta absoluta que comience con /", "Mount path:": "Ruta de montaje:", - "Mount paths must not overlap": "Los caminos del monte no deben sobreponerse", + "Mount paths must not overlap": "Las rutas de montaje no deben solaparse", "Mount point created": "Punto de montaje creado", "Mount point created.": "Punto de montaje creado.", "Mount point is visible but NOT writable from inside the container": "El punto de montaje es visible pero NO se puede escribir desde el interior del contenedor.", @@ -3512,7 +3512,7 @@ "Mount point:": "Punto de montaje:", "Mount points added:": "Puntos de montaje añadidos:", "Mount read-only": "Montaje solo lectura", - "Mount shares on HOST first": "Montar acciones en HOST primero", + "Mount shares on HOST first": "Montar primero los recursos compartidos en el host", "Mount specific dataset": "Montar conjunto de datos específico", "Mount status:": "Estado de montaje:", "Mount this device and use it as the backup destination?": "¿Montar este dispositivo y usarlo como destino de respaldo?", @@ -3527,14 +3527,14 @@ "Mounting CIFS share...": "Montando recurso compartido CIFS...", "Mounting NFS share...": "Montando recurso compartido NFS...", "Mounting container filesystem": "Montaje del sistema de archivos del contenedor", - "Mounting disk...": "Disco de montaje...", + "Mounting disk...": "Montando el disco...", "Mounting existing": "Montaje existente", "Mounting existing filesystem ({filesystem})...": "Montando el sistema de archivos existente ({filesystem})...", "Mounting here will hide existing files until unmounted.": "Montar aquí ocultará los archivos existentes hasta que se desmonten.", "Move that copy offsite (USB, password manager, another host). Delete it from this path when done.": "mueva esa copia fuera del sitio (USB, administrador de contraseñas, otro host).Elimínelo de esta ruta cuando haya terminado.", "Move to target VM (remove from source VM config)": "Mover a la VM de destino (eliminar de la configuración de la VM de origen)", - "Moving the data volumes aside": "Mover los volúmenes de datos a un lado", - "Moving the data volumes aside...": "Apartando los volúmenes de datos...", + "Moving the data volumes aside": "Separando temporalmente los volúmenes de datos", + "Moving the data volumes aside...": "Separando temporalmente los volúmenes de datos...", "Multi-container application (experimental)": "Aplicación multicontenedor (experimental)", "Multi-line variables are not supported": "No se admiten variables multilíneas", "Multiple networks or external networks are not yet supported": "Aún no se admiten múltiples redes o redes externas", @@ -3655,7 +3655,7 @@ "NVIDIA patch not applied.": "Parche de NVIDIA no aplicado.", "NVIDIA per-BDF VFIO binding configured": "Enlace NVIDIA por BDF VFIO configurado", "NVIDIA permissions or device nodes differ from the official inventory": "Los permisos o nodos de dispositivo NVIDIA difieren del inventario oficial", - "NVIDIA refresh validated; the container is stopped and its settings are kept": "NVIDIA refrescante validado; el contenedor se detiene y sus ajustes se mantienen", + "NVIDIA refresh validated; the container is stopped and its settings are kept": "Actualización de NVIDIA validada; el contenedor está detenido y se conserva su configuración", "NVIDIA runtime libraries or components are missing": "Faltan bibliotecas o componentes de tiempo de ejecución de NVIDIA", "NVIDIA selection not supported by this profile": "Selección NVIDIA no compatible con este perfil", "NVIDIA services stopped and disabled.": "Los servicios de NVIDIA se detuvieron y deshabilitaron.", @@ -4660,7 +4660,7 @@ "Recover the keyfile using your recovery passphrase?": "¿Recuperar el archivo clave usando su frase de contraseña de recuperación?", "Recover the previous installation": "Recuperar la instalación anterior", "Recoverable:": "Recuperable:", - "Recovering the previous installation": "Recuperar la instalación anterior", + "Recovering the previous installation": "Recuperando la instalación anterior", "Recovery blob upload failed — main backup is OK, but keyfile recovery from PBS will not be available for this backup.": "Error en la carga del blob de recuperación: la copia de seguridad principal está bien, pero la recuperación del archivo clave de PBS no estará disponible para esta copia de seguridad.", "Recovery blob:": "blob de recuperación:", "Recovery completed. The container had not been modified yet.": "Recuperación completada. El contenedor aún no había sido modificado.", @@ -5843,6 +5843,7 @@ "The archive destination directory is INSIDE one of the paths you are about to back up. Writing the archive there would copy the backup into itself — producing a corrupted archive, or growing without limit until the disk fills up.": "El directorio de destino del archivo está DENTRO de una de las rutas de las que está a punto de realizar una copia de seguridad. Escribir el archivo allí copiaría la copia de seguridad en sí mismo, lo que produciría un archivo corrupto o crecería sin límite hasta que el disco se llenara.", "The backup could not be identified; the image is not replaced": "El backup no se pudo identificar; la imagen no es reemplazada", "The backup metadata was compared against this host. The following items will be SKIPPED to keep the boot safe:": "los metadatos de la copia de seguridad se compararon con este host. Se SALTARÁN los siguientes elementos para mantener el arranque seguro:", + "The backup did not pass its integrity check; creating it again...": "El backup no superó la comprobación de integridad; creándolo de nuevo...", "The backup of a member could not be identified": "No se pudo identificar el backup de un miembro", "The backup storage does not exist:": "El almacenamiento de backups no existe:", "The backup was altered; recovery blocked": "El backup fue alterado; la recuperación bloqueada", @@ -6046,7 +6047,7 @@ "The official startup of the application is missing": "Falta el inicio oficial de la aplicación", "The operation already finished; it is not restored automatically": "La operación ya terminada; no se restaura automáticamente", "The operation could not be completed": "La operación no pudo completarse", - "The operation stopped halfway. Choose \"Recover\" for this container in the OCI management menu to restore the previous installation.": "La operación se detuvo a mitad de camino. Elija \"Recover\" para este contenedor en el menú de gestión OCI para restaurar la instalación anterior.", + "The operation stopped halfway. Choose \"Recover\" for this container in the OCI management menu to restore the previous installation.": "La operación se detuvo a mitad de camino. Elija \"Recuperar la instalación anterior\" para este contenedor en el menú de gestión OCI para restaurar la instalación anterior.", "The operation was stopped because a shared directory changed its identity:": "La operación se detuvo porque un directorio compartido cambió su identidad:", "The original MOTD backup is unavailable; no changes were made": "The original MOTD backup is unavailable;no se hicieron cambios", "The original MOTD configuration has been restored": "La configuración MOTD original ha sido restaurada", @@ -6094,7 +6095,7 @@ "The record no longer belongs to this operation": "El registro ya no pertenece a esta operación", "The record of a member was replaced; the assembly is not resumed": "El registro de un miembro fue reemplazado; la asamblea no se reanuda", "The record or diagnosis could not be completed; no update was run.": "El registro o el diagnóstico no se pudo completar; no se realizó ninguna actualización.", - "The recovery did not complete. Review the log and choose \"Recover\" again for this container in the OCI management menu.": "La recuperación no terminó. Revise el registro y elija \"Recover\" de nuevo para este contenedor en el menú de gestión OCI.", + "The recovery did not complete. Review the log and choose \"Recover\" again for this container in the OCI management menu.": "La recuperación no terminó. Revise el registro y elija \"Recuperar la instalación anterior\" de nuevo para este contenedor en el menú de gestión OCI.", "The remote does not exist; create and authorize it first in the WebUI:": "El remoto no existe; crear y autorizar primero en el WebUI:", "The remote installer must run as root on Proxmox VE": "El instalador remoto debe funcionar como root en Proxmox VE", "The remote must already be created and authorized in the Rclone web UI. This operation restarts the CT and publishes two FUSE views on the host.": "El remoto ya debe estar creado y autorizado en la interfaz web de Rclone. Esta operación reinicia el CT y publica dos puntos de vista FUSE sobre el host.", diff --git a/oci/catalog/apps/navidrome.json b/oci/catalog/apps/navidrome.json index 64997b1d..74ccc9bd 100644 --- a/oci/catalog/apps/navidrome.json +++ b/oci/catalog/apps/navidrome.json @@ -82,7 +82,7 @@ { "name": "ND_BASEURL", "example": "", - "required": true, + "required": false, "sensitive": false, "source": "docker-compose" }, diff --git a/oci/catalog/index.json b/oci/catalog/index.json index 84eda18d..b043513e 100644 --- a/oci/catalog/index.json +++ b/oci/catalog/index.json @@ -29,7 +29,6 @@ "category_label": "Authentication & Security", "template": "apps/2fauth.json", "template_status": "laboratory-validated", - "content_hash": "31670274bb80ae4fd49723d781b12df06cd14eb82c707f71cee836f3cb62fb96", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -66,7 +65,6 @@ "category_label": "Finance & Budgeting", "template": "apps/actualbudget.json", "template_status": "generated-unvalidated", - "content_hash": "7d1715f645a9171dbf1978ab4c6184185b024dee6c9126417005086ed7d1b07c", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -103,7 +101,6 @@ "category_label": "Adblock & DNS", "template": "apps/adguard-home.json", "template_status": "generated-unvalidated", - "content_hash": "7437656a5d50e8fbd642bfc2973a4942334114c4d6a6b4e7b424396f147d76c1", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -136,7 +133,6 @@ "category_label": "Adblock & DNS", "template": "apps/adguardhome-sync.json", "template_status": "laboratory-validated", - "content_hash": "e7e8cff6b839004bca3259a9bd6d5471cca6462c59111835407447b8e5c9b807", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -173,7 +169,6 @@ "category_label": "Databases", "template": "apps/adminer.json", "template_status": "generated-unvalidated", - "content_hash": "1ec8eae75e1a952b5cc2b8f3516cb8cc5eec2226d0ddad566b7e244e10cc38d4", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -206,7 +201,6 @@ "category_label": "Media & Streaming", "template": "apps/airsonic-advanced.json", "template_status": "generated-unvalidated", - "content_hash": "2ac8e1b0163d81c7c542119501b0cbd950b558f712409db48e1d52d2da95e158", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -243,7 +237,6 @@ "category_label": "Finance & Budgeting", "template": "apps/albyhub.json", "template_status": "laboratory-validated", - "content_hash": "b90d4c2f6787337a534fbbe2f3232a0bcdf3eb0511bec192a19aff6920371799", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -280,7 +273,6 @@ "category_label": "Productivity & Workflows", "template": "apps/alist.json", "template_status": "laboratory-validated", - "content_hash": "6e35ed8af6576b616fcecb1daec77dc5a94858f0b29eaae5ec3c63e6cc341148", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -317,7 +309,6 @@ "category_label": "Productivity & Workflows", "template": "apps/alist-sync.json", "template_status": "generated-unvalidated", - "content_hash": "63630a665e638c0b978abd7a64185e54c9c2ef603110525bbcff1734d6445ba4", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -350,7 +341,6 @@ "category_label": "Communication & Community", "template": "apps/altus.json", "template_status": "generated-unvalidated", - "content_hash": "2cd8310e6f13ff9ec6e2cb6fe853ae36f51a622fb197a5b5f53079d04a053dac", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -392,7 +382,6 @@ "requires_relaxed_confinement": false, "optional_relaxed_confinement": false, "requires_security_confirmation": false, - "content_hash": "bcef3879a8ce1483332eed56175200e8822600aaa6dfcb73a6e1b32209981229", "template_id": "image-amule" }, { @@ -419,7 +408,6 @@ "category_label": "AI / Coding & Dev-Tools", "template": "apps/anaconda3.json", "template_status": "generated-unvalidated", - "content_hash": "95b0cd1c6adc5cabef717c7d948b4878e6ff0f392cd46305e06c99e71bf644cd", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -455,7 +443,6 @@ "category_label": "AI / Coding & Dev-Tools", "template": "apps/anythingllm.json", "template_status": "generated-unvalidated", - "content_hash": "545fd1cfbf25c18795fe8062ba3e6b3a16ab859c2f1d6dc8c3be77da6b069908", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -488,7 +475,6 @@ "category_label": "Messaging & Notifications", "template": "apps/apprise-api.json", "template_status": "generated-unvalidated", - "content_hash": "48ea1b2eea8523bcb7ab2c03e5e9129ba46effa855528c171414270c2f3b4366", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -524,7 +510,6 @@ "category_label": "Documents & Notes", "template": "apps/archivebox.json", "template_status": "generated-review-required", - "content_hash": "29e6c737e7fb7c17083032349b168c457d941ee144022c675b9294e6ece3426a", "automatic_install_candidate": false, "untranslated_blockers": [ "multi-service-compose", @@ -563,7 +548,6 @@ "category_label": "Creative & Design", "template": "apps/ardour.json", "template_status": "generated-unvalidated", - "content_hash": "736ed876a5b5766151c3b7e0634cdd7db3b15c628f8787fe953a146b602b0c73", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -595,7 +579,6 @@ "category_label": "Creative & Design", "template": "apps/audacity.json", "template_status": "generated-unvalidated", - "content_hash": "78fd3449e7fd71b6441c4eb3936b2151c7f9a56139ca0d598e737fdeb0af3b4c", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -632,7 +615,6 @@ "category_label": "Media & Streaming", "template": "apps/audiobookshelf.json", "template_status": "generated-unvalidated", - "content_hash": "a4541cd70188b88a63920500676ca49bd2778a0f51098751c22f20610a6f208f", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -669,7 +651,6 @@ "category_label": "*Arr Suite", "template": "apps/autobrr.json", "template_status": "generated-unvalidated", - "content_hash": "65257f5fb4779a0bb71324151b77a36ca47f4111500a1b8aad8a6b831f3fc8f5", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -701,7 +682,6 @@ "category_label": "Gaming & Leisure", "template": "apps/azahar.json", "template_status": "generated-unvalidated", - "content_hash": "6d5a5c7f43ac8f35a586dbedeb6312eeaf5c028ac407b43c141c8fbf517820ff", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -734,7 +714,6 @@ "category_label": "Productivity & Workflows", "template": "apps/babybuddy.json", "template_status": "generated-unvalidated", - "content_hash": "a79a6cd2ec035041d411b2921cfe72189e452e738df1d957308f27b8f7fb2b6b", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -766,7 +745,6 @@ "category_label": "Creative & Design", "template": "apps/bambustudio.json", "template_status": "generated-unvalidated", - "content_hash": "b8cc49805839eec5d60652cecb7f3b672b76e74d4b9c5851b7c891d4c707cd8b", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -799,7 +777,6 @@ "category_label": "*Arr Suite", "template": "apps/bazarr.json", "template_status": "generated-unvalidated", - "content_hash": "d897ec3641bf34903e19ef702073af43c2ae19a637b22a5a26b08682e9580a60", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -836,7 +813,6 @@ "category_label": "Productivity & Workflows", "template": "apps/beaverhabittracker.json", "template_status": "generated-unvalidated", - "content_hash": "73ee3d95e26d072616ffc33c02db8d52024546effbf4776ceadddf8e00ce66ed", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -869,7 +845,6 @@ "category_label": "Media & Streaming", "template": "apps/beets.json", "template_status": "generated-unvalidated", - "content_hash": "beaa3825b76040e57e7f81fa3ff15561a3dcbeda4b13c705a5b3a5506d6c4193", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -906,7 +881,6 @@ "category_label": "Documents & Notes", "template": "apps/bentopdf.json", "template_status": "generated-unvalidated", - "content_hash": "eaa6ac9f7c1c92e623d7d60a96bc0c6e2237941b7d0381825d0763d4c2609bec", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -939,7 +913,6 @@ "category_label": "Finance & Budgeting", "template": "apps/bitcoin-knots.json", "template_status": "generated-unvalidated", - "content_hash": "1df82e85d15c1bb90aba6121087b85d578cd9d500427de928b663d2ddbad4d7d", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -971,7 +944,6 @@ "category_label": "Gaming & Leisure", "template": "apps/blade-of-agony.json", "template_status": "generated-unvalidated", - "content_hash": "fb8c8dc4bfdf72ac202ee75e90f7102e9f2418b6a370cd6321af273b6bdc16e2", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -1004,7 +976,6 @@ "category_label": "Creative & Design", "template": "apps/blender.json", "template_status": "generated-unvalidated", - "content_hash": "8ec78837752083a858e6efba3daa6facc48ece115769b2659f93ca31bd5f49b5", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -1041,7 +1012,6 @@ "category_label": "Productivity & Workflows", "template": "apps/blinko.json", "template_status": "generated-unvalidated", - "content_hash": "37aae02b1b7f699d27338aec1b622c7c234ffe4e2ce0f1c731f9f39eb6801e55", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -1074,7 +1044,6 @@ "category_label": "Miscellaneous", "template": "apps/boinc.json", "template_status": "generated-unvalidated", - "content_hash": "3cf3a1f1d92ba4953c138fa9856fa0d344504bdb8434990c0ba56c0cbd092db9", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -1107,7 +1076,6 @@ "category_label": "Documents & Notes", "template": "apps/bookstack.json", "template_status": "generated-unvalidated", - "content_hash": "7f23afaba8b32b5baa95396a405ce650d5ede362555e1bd3e0756ce18efafda5", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -1140,7 +1108,6 @@ "category_label": "Browsers & Web Desktops", "template": "apps/brave.json", "template_status": "generated-unvalidated", - "content_hash": "9c11de8124e0962c9be80a78d927ca635d246ea5c1250172ea7065cdb00ea1b4", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -1173,7 +1140,6 @@ "category_label": "Finance & Budgeting", "template": "apps/budge.json", "template_status": "generated-unvalidated", - "content_hash": "b770ea0c4b5f658c4e9d98ee6eb4334a232c72608bfab0fd015cfe6a17b0f791", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -1206,7 +1172,6 @@ "category_label": "Media & Streaming", "template": "apps/calibre.json", "template_status": "generated-unvalidated", - "content_hash": "85b7d4d3798e697e5e9ac1541d9b2ccc27c01a3c9b1d4a742a6a0ef8038bd072", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -1239,7 +1204,6 @@ "category_label": "Media & Streaming", "template": "apps/calibre-web.json", "template_status": "generated-unvalidated", - "content_hash": "4f258d503a2df1e227ccac6d9630de608b06b055236a1698090a6918ba250e2b", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -1272,7 +1236,6 @@ "category_label": "Documents & Notes", "template": "apps/calligra.json", "template_status": "generated-unvalidated", - "content_hash": "afda96f6a3d65573e1ede68f13fb7ece6321b9637e2027ddc66b4b79b761ec39", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -1305,7 +1268,6 @@ "category_label": "Monitoring & Analytics", "template": "apps/changedetection.io.json", "template_status": "generated-unvalidated", - "content_hash": "12b84b068f8bcecfa0370ed3cdb06108b6c06326ce84a716d6b1066443c49670", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -1342,7 +1304,6 @@ "category_label": "AI / Coding & Dev-Tools", "template": "apps/chatbot-ui.json", "template_status": "generated-unvalidated", - "content_hash": "113fbb9125527ac334c1d027733167c594a8b6dc2721e605a0d6705ff9326c61", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -1379,7 +1340,6 @@ "category_label": "AI / Coding & Dev-Tools", "template": "apps/chatgpt-next-web.json", "template_status": "generated-unvalidated", - "content_hash": "2017c467fa03002df0e23c36e2141939877c30f44b5d309d419c8ee2834d3912", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -1412,7 +1372,6 @@ "category_label": "Browsers & Web Desktops", "template": "apps/chrome.json", "template_status": "generated-unvalidated", - "content_hash": "64a099762a3c52dc163c02761973977edee09538c9fe34a4bbe55e5b93ad7bb5", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -1445,7 +1404,6 @@ "category_label": "Browsers & Web Desktops", "template": "apps/chromium.json", "template_status": "generated-unvalidated", - "content_hash": "4626a1a3afafb2eea46a2223ff6facfc8a372c6fff546d0882f17071497f9d4b", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -1482,7 +1440,6 @@ "category_label": "Databases", "template": "apps/cloudbeaver.json", "template_status": "generated-unvalidated", - "content_hash": "073c51f6bbb540cb92a54860e131531e89078865964dcf942ebecf2c9fa6a832", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -1519,7 +1476,6 @@ "category_label": "Webservers & Proxies", "template": "apps/cloudflared.json", "template_status": "generated-unvalidated", - "content_hash": "baf32b04617df02dca4979a2017ef0134d543bfd29579de8b9d57f163343cc42", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -1556,7 +1512,6 @@ "category_label": "Productivity & Workflows", "template": "apps/clumoove.json", "template_status": "generated-review-required", - "content_hash": "ffce6d14549c58c150c7282eae67f0ffb35929c80045def74a0d7aa2f6309cbe", "automatic_install_candidate": false, "untranslated_blockers": [ "multi-service-compose", @@ -1595,7 +1550,6 @@ "category_label": "AI / Coding & Dev-Tools", "template": "apps/code-server.json", "template_status": "generated-unvalidated", - "content_hash": "b50f59e5d639cd53d9213e332de23935795fdbc30300d9d8630ba00bec320822", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -1627,7 +1581,6 @@ "curated_path": "catalog/curated/codeproject-ai.json", "template": "apps/codeproject-ai.json", "template_status": "laboratory-validated", - "content_hash": "a520a1c0df3901f280458d944ded774252cd856dec736bb2011a908e4c6c9016", "hidden": true, "automatic_install_candidate": true, "untranslated_blockers": [], @@ -1664,7 +1617,6 @@ "category_label": "Documents & Notes", "template": "apps/convertx.json", "template_status": "generated-unvalidated", - "content_hash": "86d0231ff2c198ceeb0345011d8d91b28bd843db3fe58ac99ff99098ff631e6e", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -1697,7 +1649,6 @@ "category_label": "Media & Streaming", "template": "apps/cops.json", "template_status": "generated-unvalidated", - "content_hash": "81c6691f9a3a0d3dcad26dcc598ad86ce59ee45b613862a5886956a947d26629", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -1733,7 +1684,6 @@ "category_label": "Files & Downloads", "template": "apps/copyparty.json", "template_status": "laboratory-validated", - "content_hash": "7b8f71f67904ac34329246019299164b8b7ee5c5557e01d3280d23fe80615233", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -1770,7 +1720,6 @@ "category_label": "Gaming & Leisure", "template": "apps/crafty.json", "template_status": "laboratory-validated", - "content_hash": "b586bf376d6ceca65a6a3e2c9402fa667abccfa4ec91283ffcfae78404df05c3", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -1802,7 +1751,6 @@ "category_label": "Creative & Design", "template": "apps/cura.json", "template_status": "generated-unvalidated", - "content_hash": "b967c24a5655dd9b9d95bf04d06e226efc3a03cf28fc54dac551176f30a2f3a7", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -1835,7 +1783,6 @@ "category_label": "Creative & Design", "template": "apps/darktable.json", "template_status": "generated-unvalidated", - "content_hash": "b9e748556222df78760e3505bf08b9e8d277fbf31a55141077ac5641b5e96928", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -1872,7 +1819,6 @@ "category_label": "Communication & Community", "template": "apps/databag.json", "template_status": "generated-unvalidated", - "content_hash": "dc06124241f39e9af4417d246a6fb8b4a777613050a84cee9fd7dcf2be524320", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -1905,7 +1851,6 @@ "category_label": "Files & Downloads", "template": "apps/davos.json", "template_status": "generated-unvalidated", - "content_hash": "e3a6c26b81f0d93c411bb1b0046fbff0fc33d453a000efe486e6cd9b32905658", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -1938,7 +1883,6 @@ "category_label": "Adblock & DNS", "template": "apps/ddclient.json", "template_status": "laboratory-validated", - "content_hash": "91512aa83c7fe69d3b7ab3be11aac61cb3e41391a57d7beae44d240dceb103d3", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -1975,7 +1919,6 @@ "category_label": "Adblock & DNS", "template": "apps/ddns-go.json", "template_status": "generated-unvalidated", - "content_hash": "6ef6f75472f0e7a23f4b3dea4d98bff610c20004bdedd6dabadf5ab35af6b1f7", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -2012,7 +1955,6 @@ "category_label": "Adblock & DNS", "template": "apps/ddns-updater.json", "template_status": "generated-unvalidated", - "content_hash": "012d83f253220a95a83eb5251ed3f72153192a83a3109becea0d32b7c741bebe", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -2048,7 +1990,6 @@ "category_label": "AI / Coding & Dev-Tools", "template": "apps/deepseek-ocr-nvidia.json", "template_status": "generated-review-required", - "content_hash": "7d18da1b0651ef9d6be7e2ec5f3932b3bfa7e1ef4169507284fffc2b1dcd993c", "automatic_install_candidate": false, "untranslated_blockers": [ "multi-service-compose", @@ -2085,7 +2026,6 @@ "category_label": "Files & Downloads", "template": "apps/deluge.json", "template_status": "generated-unvalidated", - "content_hash": "106809a4fd1a5c48d919c8512bd3af7329126ab2f3fc49b7efc42192d57c4c97", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -2121,7 +2061,6 @@ "category_label": "AI / Coding & Dev-Tools", "template": "apps/dify.json", "template_status": "generated-review-required", - "content_hash": "b36ab46f437fe147c8bce574afcba73634569a7377a88f2464e48784857a957f", "automatic_install_candidate": false, "untranslated_blockers": [ "multi-service-compose", @@ -2163,7 +2102,6 @@ "category_label": "Creative & Design", "template": "apps/digikam.json", "template_status": "generated-unvalidated", - "content_hash": "363dae41fce55565c023cf71b710511c779fa5c5e8d981836858508c633c14b0", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -2196,7 +2134,6 @@ "category_label": "Miscellaneous", "template": "apps/diskover.json", "template_status": "generated-review-required", - "content_hash": "e4b1dd687f063a69d38b221b62459c3a11a609aa28bbbdc0e3ce23a7888e6f6a", "automatic_install_candidate": false, "untranslated_blockers": [ "multi-service-compose", @@ -2233,7 +2170,6 @@ "curated_path": "catalog/curated/docker-volume-backup.json", "template": "apps/docker-volume-backup.json", "template_status": "generated-review-required", - "content_hash": "aff9dd3a2a65b1ce7d987019c44bc95881ddf2a778362e808409ec424eebbd22", "hidden": true, "automatic_install_candidate": false, "untranslated_blockers": [ @@ -2272,7 +2208,6 @@ "category_label": "Documents & Notes", "template": "apps/docmost.json", "template_status": "generated-unvalidated", - "content_hash": "e590f26dd7df021490e58a84e6375ef79166b45f1439887ed4d23c58fa7110d4", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -2304,7 +2239,6 @@ "category_label": "Gaming & Leisure", "template": "apps/dogwalk.json", "template_status": "generated-unvalidated", - "content_hash": "987a713a13a56cb9b0b25d264697a37a266727bb192c15c67653e737042b7b4a", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -2337,7 +2271,6 @@ "category_label": "Documents & Notes", "template": "apps/dokuwiki.json", "template_status": "generated-unvalidated", - "content_hash": "6736c22b1b7c79d63952c63c192c73c760c317133b4b6e52dd84bdae585cbd4c", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -2369,7 +2302,6 @@ "category_label": "Gaming & Leisure", "template": "apps/dolphin.json", "template_status": "generated-unvalidated", - "content_hash": "02b815f93359d2912ae83bb5fd592d4ec254511383285789c7d4dc73a1e1d877", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -2402,7 +2334,6 @@ "category_label": "*Arr Suite", "template": "apps/doplarr.json", "template_status": "generated-unvalidated", - "content_hash": "fab02855609ab1483299490ddbe8cce934dfec2197bcfbf9810bb2bd38344aec", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -2435,7 +2366,6 @@ "category_label": "*Arr Suite", "template": "apps/doplarr_rs.json", "template_status": "generated-unvalidated", - "content_hash": "49b97eb2dffe7bcbf3a4aac206d70047829e610d13305cd70be2cf61d82ca44f", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -2467,7 +2397,6 @@ "category_label": "Gaming & Leisure", "template": "apps/dosbox-staging.json", "template_status": "generated-unvalidated", - "content_hash": "b8d2edd63d44ab1341f855849bf6b0c86c171b7e84b21975ed0a114c43797172", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -2500,7 +2429,6 @@ "category_label": "Tools", "template": "apps/doublecommander.json", "template_status": "generated-unvalidated", - "content_hash": "01fc0e09ef0b2409f14b1903423ee782b1caf286f52a9119bed34f408f251ea6", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -2537,7 +2465,6 @@ "category_label": "Media & Streaming", "template": "apps/downtify.json", "template_status": "generated-unvalidated", - "content_hash": "e19bcae00f2562cd506380effdfda90faf327476c7b30e9eedba52dcf8b595dc", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -2574,7 +2501,6 @@ "category_label": "AI / Coding & Dev-Tools", "template": "apps/dsh-harness.json", "template_status": "generated-unvalidated", - "content_hash": "fea31b738bde87dd3cbf9d49b963e8b9f090ac5ae68e9c7529fe5122a0a5a50d", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -2607,7 +2533,6 @@ "category_label": "Adblock & DNS", "template": "apps/duckdns.json", "template_status": "generated-unvalidated", - "content_hash": "4eefc97b4a4f876fb55563b28e2f61850ec84117b1548aa38263a05c5785ef7d", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -2640,7 +2565,6 @@ "category_label": "Gaming & Leisure", "template": "apps/duckstation.json", "template_status": "generated-unvalidated", - "content_hash": "f80996b8b0a9fcb901a750f03c33eb3e891896b812378e18712b8c05b14fdd88", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -2673,7 +2597,6 @@ "category_label": "Backup & Recovery", "template": "apps/duplicati.json", "template_status": "laboratory-validated", - "content_hash": "13fa5a981587604e9bb8442b67947ca7169a9ce67a1a5ca040ef48b8cb1abc12", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -2705,7 +2628,6 @@ "category_label": "Gaming & Leisure", "template": "apps/eden.json", "template_status": "generated-unvalidated", - "content_hash": "514de91483fab4b1a0f63137a4b6901d4d0c7bd7666874a255ea322fe1f2afac", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -2738,7 +2660,6 @@ "category_label": "Media & Streaming", "template": "apps/emby.json", "template_status": "generated-unvalidated", - "content_hash": "8d581f363c65f0e4ccadb3512cab7c0e241fb77583671f26d04804e5b7a174af", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -2774,7 +2695,6 @@ "curated_path": "catalog/curated/emby-official.json", "template": "apps/emby-official.json", "template_status": "generated-unvalidated", - "content_hash": "90c70b79980c766f6eabb20a29b738feb321c096bb8dc36e0b8eb2a4c1934ab3", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -2811,7 +2731,6 @@ "category_label": "Media & Streaming", "template": "apps/embystat.json", "template_status": "generated-unvalidated", - "content_hash": "22fa2fa636cdb34f54d3ce645ad5bcabe1f5e5dcd413785573b9cbe0cb9d5a6f", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -2848,7 +2767,6 @@ "category_label": "Gaming & Leisure", "template": "apps/emulatorjs.json", "template_status": "generated-unvalidated", - "content_hash": "267a6a6afae93ed0ba99322abe4732811427e4e49d961b016b23987d562cb173", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -2885,7 +2803,6 @@ "category_label": "IoT & Smart Home", "template": "apps/esphome.json", "template_status": "generated-unvalidated", - "content_hash": "b270ae8c95e66626531ba3820c59f6fca861c94ff2b496fc837ae29ceba83da1", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -2922,7 +2839,6 @@ "category_label": "Documents & Notes", "template": "apps/etherpad.json", "template_status": "laboratory-validated", - "content_hash": "eb9bca30dae0a541947d47a157fe60f7cd9dbef5338a90fb7de2e546e7418489", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -2958,7 +2874,6 @@ "category_label": "Documents & Notes", "template": "apps/excalidraw.json", "template_status": "generated-unvalidated", - "content_hash": "721d8393158e1fe316b7b64774efa9fa934ff08eda07363cfda9a77d2307ee82", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -2991,7 +2906,6 @@ "category_label": "AI / Coding & Dev-Tools", "template": "apps/faster-whisper.json", "template_status": "generated-unvalidated", - "content_hash": "6101b77660f6e1714e300703a1f7730ac698be028aa6e5982f262f1c8fac9e1a", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -3024,7 +2938,6 @@ "category_label": "Communication & Community", "template": "apps/ferdium.json", "template_status": "generated-unvalidated", - "content_hash": "88327550e74883d21cb62ccd94ea6bdaeb952ab3a158f1f984a14e25e0c56afb", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -3057,7 +2970,6 @@ "curated_path": "catalog/curated/filebrowser-quantum.json", "template": "apps/filebrowser-quantum.json", "template_status": "laboratory-validated", - "content_hash": "3eda37d759f056df9c2767a1037398259086ab2b8b442a9ca886bd534fc28819", "hidden": false, "automatic_install_candidate": true, "untranslated_blockers": [], @@ -3094,7 +3006,6 @@ "category_label": "Productivity & Workflows", "template": "apps/filedrop.json", "template_status": "generated-unvalidated", - "content_hash": "4b1e1a2caa97dc333688dafa6a119d94f092d3238ec069a430e3d11bf56d20f3", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -3129,7 +3040,6 @@ "curated_path": "catalog/curated/fileflows.json", "template": "apps/fileflows.json", "template_status": "generated-unvalidated", - "content_hash": "7c50616174ce22f228a86d57629b56311339cec9db84be17bd9f45b40263768a", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -3162,7 +3072,6 @@ "category_label": "Files & Downloads", "template": "apps/filezilla.json", "template_status": "generated-unvalidated", - "content_hash": "5751f36bb751fe2a8a712ca37189576dca7162d65148391d6815eb1181f5300d", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -3199,7 +3108,6 @@ "category_label": "Finance & Budgeting", "template": "apps/firefly.json", "template_status": "generated-unvalidated", - "content_hash": "0f2d454734f3abea20a0e1b2e8d616f6c815c6302f1cf00321d94bd19f302178", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -3232,7 +3140,6 @@ "category_label": "Browsers & Web Desktops", "template": "apps/firefox.json", "template_status": "generated-unvalidated", - "content_hash": "1e626c8a42bb9ca6a5d471e5b091a63ab3d4f7a3c80f7d9b20c92864f578196d", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -3269,7 +3176,6 @@ "category_label": "*Arr Suite", "template": "apps/flaresolverr.json", "template_status": "laboratory-validated", - "content_hash": "fd17defd88e466d6e2681673041418e0fad75ec443db7bcd01551336e9452a2e", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -3302,7 +3208,6 @@ "category_label": "*Arr Suite", "template": "apps/flexget.json", "template_status": "laboratory-validated", - "content_hash": "79cb8eb32708faf01774ccdd3beced9579f95aab0c47c91c7c800ae0993e2e45", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -3338,7 +3243,6 @@ "category_label": "AI / Coding & Dev-Tools", "template": "apps/flowise.json", "template_status": "generated-unvalidated", - "content_hash": "1139477888afa4cf584ac9b300f5bcdc67e70f2f1308cec33dcf1c5a0087eff1", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -3370,7 +3274,6 @@ "category_label": "Gaming & Leisure", "template": "apps/flycast.json", "template_status": "generated-unvalidated", - "content_hash": "52c339b1b7c8cefc8571fc22ab3c4b3f58b40dbf6f028bd4f3d7b1360e7e56c1", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -3403,7 +3306,6 @@ "category_label": "Miscellaneous", "template": "apps/foldingathome.json", "template_status": "generated-unvalidated", - "content_hash": "4254b1c8c1616a6c3bb85a2e638dcea3156c1b37c273e11a9b76615bd406a174", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -3436,7 +3338,6 @@ "category_label": "Creative & Design", "template": "apps/freecad.json", "template_status": "generated-unvalidated", - "content_hash": "afdc74c18ce6064a650669040d8398487703843fe8390f2ac831cc62b8e7ac46", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -3469,7 +3370,6 @@ "category_label": "Media & Streaming", "template": "apps/freshrss.json", "template_status": "generated-unvalidated", - "content_hash": "3a7d21c46f0a9e5d04e4441fda4ac1e77ba400a5822de725bbb78a86fcf4e680", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -3506,7 +3406,6 @@ "category_label": "Media & Streaming", "template": "apps/freshrss-official.json", "template_status": "generated-unvalidated", - "content_hash": "fbda38d47d0da0b3f126fe0392c29217a9993d2888176a903dace8e1a3ac23d5", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -3541,7 +3440,6 @@ "curated_path": "catalog/curated/frigate.json", "template": "apps/frigate.json", "template_status": "laboratory-validated", - "content_hash": "536b81d3a4a515f3b2f27fa35f0fcbc58bb88359374641493e644aa02061d81a", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -3578,7 +3476,6 @@ "category_label": "Webservers & Proxies", "template": "apps/gateway-go.json", "template_status": "generated-unvalidated", - "content_hash": "0ea76602729dea636b6325cedcdc6d3ec4d5dc78dce053340fb1f91a91767b58", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -3611,7 +3508,6 @@ "category_label": "Creative & Design", "template": "apps/gimp.json", "template_status": "generated-unvalidated", - "content_hash": "457bbc6a87847e3366d82c45f9cccdffade435f5f9684a8ffd471f40dd3c750e", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -3648,7 +3544,6 @@ "category_label": "AI / Coding & Dev-Tools", "template": "apps/gitea.json", "template_status": "generated-unvalidated", - "content_hash": "898dcb083d50e327908f79c5925286bfc65c37d6181f703e054ea0cb8ebe2fbd", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -3681,7 +3576,6 @@ "category_label": "AI / Coding & Dev-Tools", "template": "apps/github-desktop.json", "template_status": "generated-unvalidated", - "content_hash": "340f5476ceacc540de5985e42869bd06c0f0fc8cca0a87c70110f1172e45a03e", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -3714,7 +3608,6 @@ "category_label": "AI / Coding & Dev-Tools", "template": "apps/gitqlient.json", "template_status": "generated-unvalidated", - "content_hash": "43abbf314e5c5e4df6bb06f22a861191d5ff02da81c4b30bc2aebc807e18d734", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -3751,7 +3644,6 @@ "category_label": "Monitoring & Analytics", "template": "apps/glances.json", "template_status": "generated-unvalidated", - "content_hash": "3acd3e93cf8c7e4bde21759120191eccab5babcade9dcd04a320cef2e2bc3c95", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": true, @@ -3788,7 +3680,6 @@ "category_label": "Media & Streaming", "template": "apps/gopeed.json", "template_status": "generated-unvalidated", - "content_hash": "0f6d14d6fb6be73ca132a1d5d4816217c17247f672f934fd67b5b13027a3875a", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -3825,7 +3716,6 @@ "category_label": "Monitoring & Analytics", "template": "apps/grafana.json", "template_status": "laboratory-validated", - "content_hash": "233860093c838ee21ec2cdca7a203deb5b8de17f0be1e796672fffcf11e8d4c3", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -3858,7 +3748,6 @@ "category_label": "Documents & Notes", "template": "apps/grav.json", "template_status": "generated-unvalidated", - "content_hash": "3d2c3c1febc7ce0b446c8d5044a5d70d6211bb1da766aeba7faa903dc64dbb22", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -3891,7 +3780,6 @@ "category_label": "Productivity & Workflows", "template": "apps/grocy.json", "template_status": "generated-unvalidated", - "content_hash": "2aa466029f5147c1ec588c7addd54b57a6d1c9a47add459d669a1c7a91e5ef51", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -3924,7 +3812,6 @@ "category_label": "Gaming & Leisure", "template": "apps/gzdoom.json", "template_status": "generated-unvalidated", - "content_hash": "b8d8ab7e5cbb9c178b1d5e83f6a3bb077675af88e51e6b5e3ae75aa7d003b500", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -3957,7 +3844,6 @@ "category_label": "IoT & Smart Home", "template": "apps/habridge.json", "template_status": "generated-unvalidated", - "content_hash": "a822928bdd3bd3fdc19dbcd7d16ee178965e6fdd8288b1047e9b59d4cadfbb5c", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -3989,7 +3875,6 @@ "category_label": "Media & Streaming", "template": "apps/handbrake.json", "template_status": "generated-unvalidated", - "content_hash": "dd4eda75149e2fda09a2387579f4f59b1e3ffa23be520ce4e76358009dc7b2b2", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -4025,7 +3910,6 @@ "category_label": "Media & Streaming", "template": "apps/handbrake-jlesage.json", "template_status": "generated-unvalidated", - "content_hash": "8737b8977451d4b9e9f9c2a9839f8d92e9977befd3b308cb6ba272e6abce1b41", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -4058,7 +3942,6 @@ "curated_path": "catalog/curated/haos-one.json", "template": "apps/haos-one.json", "template_status": "generated-unvalidated", - "content_hash": "7e817118efb64ed24d1bf6d7a2088b15d24fb4333c12b2d12e2e0b48a581e09f", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -4091,7 +3974,6 @@ "category_label": "Monitoring & Analytics", "template": "apps/healthchecks.json", "template_status": "generated-unvalidated", - "content_hash": "32dd421e75b32d98dac09ce394d38726862fdf3b9439e8d2e5108841d0c4fd1f", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -4124,7 +4006,6 @@ "category_label": "Documents & Notes", "template": "apps/hedgedoc.json", "template_status": "generated-unvalidated", - "content_hash": "1cbc155638760ea9d871454c1d79f9f4ca7b2a646b4d8ad96b4f91cc7a422be2", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -4157,7 +4038,6 @@ "category_label": "Productivity & Workflows", "template": "apps/heimdall.json", "template_status": "generated-unvalidated", - "content_hash": "460fcddde0f8011c7c127c9d1bad5f847cee67093cdabb3266d26d7586a331f3", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -4190,7 +4070,6 @@ "category_label": "Browsers & Web Desktops", "template": "apps/helium.json", "template_status": "generated-unvalidated", - "content_hash": "2c213623ffd8246b3c0805747ac126d76b9aae81a96c06dfc5affeb967432dc2", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -4227,7 +4106,6 @@ "category_label": "AI / Coding & Dev-Tools", "template": "apps/hermes.json", "template_status": "generated-unvalidated", - "content_hash": "21e7836e7ab9cfc90e043e71f312b6051864357550ebdfd9fd084d7149dfd254", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -4260,7 +4138,6 @@ "category_label": "AI / Coding & Dev-Tools", "template": "apps/hishtory-server.json", "template_status": "generated-unvalidated", - "content_hash": "45aa45c3ed882f849ad8a36db5426eeac9dc5193c956d07cd987b8cc30f89dd6", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -4297,7 +4174,6 @@ "category_label": "Media & Streaming", "template": "apps/holoplay.json", "template_status": "generated-unvalidated", - "content_hash": "c8c13100b65ff4ca33b41edfb415aca273d236f92a17356ca31c4efe61f500d1", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -4330,7 +4206,6 @@ "category_label": "IoT & Smart Home", "template": "apps/homeassistant.json", "template_status": "generated-unvalidated", - "content_hash": "7fc947ae7aaeb257754d85aeee005bd3ce0f2d8ace5e92290cd29f01df9446a4", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -4367,7 +4242,6 @@ "category_label": "IoT & Smart Home", "template": "apps/homeassistant-official.json", "template_status": "generated-unvalidated", - "content_hash": "c0d8168fddf1dde10c064f7d43464133b166c9863488b869ef8dd65d3e35e822", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -4404,7 +4278,6 @@ "category_label": "IoT & Smart Home", "template": "apps/homebridge.json", "template_status": "generated-unvalidated", - "content_hash": "d9b674d72bdebe38949ff5012eb7f87705e33b00929f12a3a5c0e48a62ff904e", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -4437,7 +4310,6 @@ "category_label": "Media & Streaming", "template": "apps/htpcmanager.json", "template_status": "generated-unvalidated", - "content_hash": "be7d35d539b80274bdee5287b40e098c88e0488ac5c59705ffe58c60ae088be9", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -4474,7 +4346,6 @@ "category_label": "Webservers & Proxies", "template": "apps/hugo.json", "template_status": "generated-unvalidated", - "content_hash": "cc1e4196d8e67b7ad39f45c5e1df6632eed1346803536d65e15444337ab8651f", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -4509,7 +4380,6 @@ "curated_path": "catalog/curated/immich.json", "template": "apps/immich.json", "template_status": "laboratory-validated", - "content_hash": "4cab5178521f8495588b8432717832857c911ed0012fd6bd48efbb7ab80b80a6", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -4545,7 +4415,6 @@ "category_label": "AI / Coding & Dev-Tools", "template": "apps/index-tts.json", "template_status": "generated-unvalidated", - "content_hash": "daa4048d982c733b3d19ea1acf30ec794f5d6aae20b4d54e208483f35860abc5", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -4578,7 +4447,6 @@ "category_label": "Creative & Design", "template": "apps/inkscape.json", "template_status": "generated-unvalidated", - "content_hash": "e16bb57dd452200d6d57bfe39a6bb850b1d58095afca5ac035892aa76e45361d", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -4610,7 +4478,6 @@ "category_label": "AI / Coding & Dev-Tools", "template": "apps/intellij-idea.json", "template_status": "generated-unvalidated", - "content_hash": "8b0718256a4a838336d405b486487387a929f7e95856462ba5ee1ef18ba4effc", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -4643,7 +4510,6 @@ "category_label": "*Arr Suite", "template": "apps/jackett.json", "template_status": "generated-unvalidated", - "content_hash": "1c1fe77e445cbd2d1db67bc397ec88e796cf359309f71d6e8a239bf4853c104d", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -4677,7 +4543,6 @@ "curated_path": "catalog/curated/jdownloader.json", "template": "apps/jdownloader.json", "template_status": "laboratory-validated", - "content_hash": "49466605a5831af3ed81ef72d52bd6a0cd21fd805932b4e1eed81b768be641ad", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -4710,7 +4575,6 @@ "category_label": "Media & Streaming", "template": "apps/jellyfin.json", "template_status": "generated-unvalidated", - "content_hash": "2dd8c7de4ed18fbf753f39fe92ee4d9913cf25d0b8134d1e851aa7999f8c8f03", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -4743,7 +4607,6 @@ "curated_path": "catalog/curated/jellyfin-official.json", "template": "apps/jellyfin-official.json", "template_status": "generated-unvalidated", - "content_hash": "4c6c9892cb1570bfa2f67205956b7a9d2be82ac9a51406c07b11196d24305e1c", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -4780,7 +4643,6 @@ "category_label": "Media & Streaming", "template": "apps/jellyseerr.json", "template_status": "generated-unvalidated", - "content_hash": "0d28c61a767b4859af16be46bd6f75f4c6839a57b24b0be6d98c07f807def569", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -4817,7 +4679,6 @@ "category_label": "AI / Coding & Dev-Tools", "template": "apps/jenkins.json", "template_status": "laboratory-validated", - "content_hash": "65ad6556818ae2a78fa96d064094d79b8a0affa14f75ee774f9de023cc9d2e37", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -4849,7 +4710,6 @@ "category_label": "Documents & Notes", "template": "apps/joplin.json", "template_status": "generated-unvalidated", - "content_hash": "edc8231ac1a6b45570600949ae3db3b41b8c2dea35b8be1b70fb1d6bb09930bc", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -4882,7 +4742,6 @@ "category_label": "Authentication & Security", "template": "apps/kali-linux.json", "template_status": "generated-unvalidated", - "content_hash": "16ad4a1d5e51021cc3e81ec206712ae888a5bdcf4cd409665c971d03962d65ae", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -4919,7 +4778,6 @@ "category_label": "Documents & Notes", "template": "apps/karakeep.json", "template_status": "generated-review-required", - "content_hash": "abb0876d91ad1e5ca5580512e6db2284e8e2417fd5a393e95c50dc73f81c694d", "automatic_install_candidate": false, "untranslated_blockers": [ "multi-service-compose", @@ -4955,7 +4813,6 @@ "category_label": "Remote Access & VPN", "template": "apps/kasm.json", "template_status": "generated-unvalidated", - "content_hash": "0433a0ebb85b6ba3c9c714c6b99e560dc07b5e18d6bd44af32893e43017bc12a", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -4988,7 +4845,6 @@ "category_label": "Media & Streaming", "template": "apps/kavita.json", "template_status": "generated-unvalidated", - "content_hash": "8c9699f694d326fa5d01320fe37801a3c02769d21bd2011d3c4c868a2eb1bd9e", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -5025,7 +4881,6 @@ "category_label": "Media & Streaming", "template": "apps/kavita-jvmilazz0.json", "template_status": "generated-unvalidated", - "content_hash": "a6509341fdc1c2ea1e960e48f1bb0054e3a686a715284a713084b4fb5be8fd2e", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -5058,7 +4913,6 @@ "category_label": "Creative & Design", "template": "apps/kdenlive.json", "template_status": "generated-unvalidated", - "content_hash": "da38a79d680b6dfb2a2f096575e0ad31fdf403764b6fdc91f803ed6afde2274f", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -5091,7 +4945,6 @@ "category_label": "Authentication & Security", "template": "apps/keepassxc.json", "template_status": "generated-unvalidated", - "content_hash": "8a45a46593c2532263617b77b94cc23772aebff155c07abeafbfe368540408be", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -5124,7 +4977,6 @@ "category_label": "Creative & Design", "template": "apps/kicad.json", "template_status": "generated-unvalidated", - "content_hash": "7fb41022747d47c6b2e3f02448f4a986c46e2eff9ca9e74bf790743698c77a50", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -5157,7 +5009,6 @@ "category_label": "Business & ERP", "template": "apps/kimai.json", "template_status": "generated-unvalidated", - "content_hash": "1bf2e64488d7179e488a059b39de4e71a4621c8366212dcd244c96833c63656c", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -5190,7 +5041,6 @@ "category_label": "Media & Streaming", "template": "apps/kometa.json", "template_status": "generated-unvalidated", - "content_hash": "ce63980a05cbd8677668ace1ed71051001b61a99501d453463aafff598f1bcc7", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -5227,7 +5077,6 @@ "category_label": "Media & Streaming", "template": "apps/komga.json", "template_status": "generated-unvalidated", - "content_hash": "9303aecca778bd962327f90df48fe0b52bb8be170c34912f0d0bbf8ac5320fe9", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -5259,7 +5108,6 @@ "category_label": "Creative & Design", "template": "apps/krita.json", "template_status": "generated-unvalidated", - "content_hash": "e746fb2c27b281cc05061a32bbc18e741c7a33b15a65bb7efe4f7bbec76c7f60", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -5296,7 +5144,6 @@ "category_label": "AI / Coding & Dev-Tools", "template": "apps/label-studio.json", "template_status": "generated-unvalidated", - "content_hash": "e7c4fd3ae8c0b249ec5ee738fb9ab3283c5abe61a621746bb890238e1cff4e53", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -5332,7 +5179,6 @@ "category_label": "AI / Coding & Dev-Tools", "template": "apps/langflow.json", "template_status": "generated-unvalidated", - "content_hash": "03601b6544245bc7a3a02d254326d6261fe1b57ac8cd5284b25c1193b24e0dab", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -5365,7 +5211,6 @@ "category_label": "Media & Streaming", "template": "apps/lazylibrarian.json", "template_status": "generated-unvalidated", - "content_hash": "d4c450baa35b964f4f7759fd5206b29999550f06a8d35e2d128e5ae27ead9777", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -5398,7 +5243,6 @@ "category_label": "Authentication & Security", "template": "apps/ldap-auth.json", "template_status": "generated-unvalidated", - "content_hash": "ec1d5723bce0784ead049f63fe0e3d1ca13c6d30156df54fea10e20f39369afa", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -5434,7 +5278,6 @@ "category_label": "AI / Coding & Dev-Tools", "template": "apps/librechat.json", "template_status": "generated-review-required", - "content_hash": "bd6cc98712081bcf10059d0a38a5ce98b6fea70439d11375559887407e3f95ce", "automatic_install_candidate": false, "untranslated_blockers": [ "multi-service-compose", @@ -5476,7 +5319,6 @@ "category_label": "AI / Coding & Dev-Tools", "template": "apps/libredb-studio.json", "template_status": "generated-unvalidated", - "content_hash": "6485b09351e49ed6eaecf510644a8c4d2e5044edc4cb974a892ca3184dff31c0", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -5509,7 +5351,6 @@ "category_label": "Documents & Notes", "template": "apps/libreoffice.json", "template_status": "generated-unvalidated", - "content_hash": "fcc02118053fb469c9aeb695b26a58447cc3d9ef7c815919a8f4714c2e519cdb", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -5542,7 +5383,6 @@ "category_label": "Monitoring & Analytics", "template": "apps/librespeed.json", "template_status": "generated-unvalidated", - "content_hash": "cdfd71305229d535495af5189e4c4b5e0321c008f4ef794734c19b8bd17da2a2", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -5575,7 +5415,6 @@ "category_label": "Browsers & Web Desktops", "template": "apps/librewolf.json", "template_status": "generated-unvalidated", - "content_hash": "912397d7d438a946bdb1ead5b6685665f1f9e3b8438d612ecf09cbfe80b00ab4", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -5608,7 +5447,6 @@ "category_label": "*Arr Suite", "template": "apps/lidarr.json", "template_status": "generated-unvalidated", - "content_hash": "62422a5b8a054cfebef1de1d19ca9c5cfa8a85a8063596234f145dfb34807a00", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -5641,7 +5479,6 @@ "category_label": "Communication & Community", "template": "apps/limnoria.json", "template_status": "generated-unvalidated", - "content_hash": "a210b002667bf30202710e7393b3f253f7f9e797d188ef1ef3a2a3b88ad5a9f1", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -5678,7 +5515,6 @@ "category_label": "Documents & Notes", "template": "apps/linkwarden.json", "template_status": "laboratory-validated", - "content_hash": "abf2884e8ba3534a27a4a7dcb9d86e056c1329ac533d258d44caa62edb0b8347", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -5714,7 +5550,6 @@ "category_label": "AI / Coding & Dev-Tools", "template": "apps/llama-factory-nvidia.json", "template_status": "generated-unvalidated", - "content_hash": "27eedb0ae6dc7b54b199903c5ea65a799be7985bffb74117610838239a680626", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -5749,7 +5584,6 @@ "curated_path": "catalog/curated/llamacpp.json", "template": "apps/llamacpp.json", "template_status": "generated-unvalidated", - "content_hash": "ca744a983663b3ebed1d33b8b1e9b0e52e34ef57827f0f41e19c6c9e8a2275c3", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -5781,7 +5615,6 @@ "category_label": "AI / Coding & Dev-Tools", "template": "apps/lm-studio.json", "template_status": "generated-unvalidated", - "content_hash": "44997beed91523470d9493853cb8c38c231cbceff15c45d92e9a0b83cdd5f52f", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -5818,7 +5651,6 @@ "category_label": "Documents & Notes", "template": "apps/logseq.json", "template_status": "generated-unvalidated", - "content_hash": "687143e4e10ca33c3ca39a5fe64f1ea46ccee60aa3ce977eb4c478c1e22db058", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -5851,7 +5683,6 @@ "category_label": "Media & Streaming", "template": "apps/lollypop.json", "template_status": "generated-unvalidated", - "content_hash": "4ddf7603f638ae064a68c9683877247a6499a221008b431454384414b429ebfb", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -5884,7 +5715,6 @@ "category_label": "Gaming & Leisure", "template": "apps/luanti.json", "template_status": "generated-unvalidated", - "content_hash": "e2e3a7ec526e5ebc37496068997d1f3b42a4f316f09c449a22223f475a116a9b", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -5921,7 +5751,6 @@ "category_label": "Webservers & Proxies", "template": "apps/lucky.json", "template_status": "generated-unvalidated", - "content_hash": "f2b9ed8d035576997d9e78403eebfce848f319c25c7775d47643e92bf1e08fdd", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -5954,7 +5783,6 @@ "category_label": "Media & Streaming", "template": "apps/lychee.json", "template_status": "generated-unvalidated", - "content_hash": "8074e339789781dee4e5099df9132a52ca7e257376f6d17f6a50951fddc43b4e", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -5991,7 +5819,6 @@ "category_label": "Media & Streaming", "template": "apps/lyrionmusicserver.json", "template_status": "generated-unvalidated", - "content_hash": "7261549cf4937936afed3e4ae63e533aabddb7c975d0bdad2b5c9668fa9bcbf4", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -6026,7 +5853,6 @@ "curated_path": "catalog/curated/makemkv.json", "template": "apps/makemkv.json", "template_status": "generated-unvalidated", - "content_hash": "5e22f5d17e107f9c5cbaca06010e5204ef23754d2125a93ceb09c28e59377056", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -6059,7 +5885,6 @@ "category_label": "Gaming & Leisure", "template": "apps/mame.json", "template_status": "generated-unvalidated", - "content_hash": "8673f83e98d515c83ce7678e132c32c08bb181d7df462427015a0d3248a23525", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -6092,7 +5917,6 @@ "category_label": "Gaming & Leisure", "template": "apps/manyfold.json", "template_status": "generated-unvalidated", - "content_hash": "106687d51c574d2e4c51ad923629be3665eda59e6edcb75aa00e8bdf4ce277f0", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -6125,7 +5949,6 @@ "category_label": "Databases", "template": "apps/mariadb.json", "template_status": "generated-unvalidated", - "content_hash": "2105289fbccefd7c42c511d04ec4dcda17e923027a6a192b8db0256cfdb1e22f", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -6158,7 +5981,6 @@ "category_label": "Communication & Community", "template": "apps/mastodon.json", "template_status": "generated-unvalidated", - "content_hash": "66ced75d362288b843b6f2e1baefbc01580f0f7d28775b2c35b53feb876dd581", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -6195,7 +6017,6 @@ "category_label": "Finance & Budgeting", "template": "apps/maybe.json", "template_status": "generated-review-required", - "content_hash": "7713d5ab86b589c46068685965198baf89a469906bbf1b86bc8ae5b292298b3a", "automatic_install_candidate": false, "untranslated_blockers": [ "multi-service-compose", @@ -6234,7 +6055,6 @@ "category_label": "Media & Streaming", "template": "apps/mediaelch.json", "template_status": "generated-unvalidated", - "content_hash": "76fb38efb00bd0484f6a3cf2f636780605e8ca11e9dd8dbe7cb72b313b20be79", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -6267,7 +6087,6 @@ "category_label": "Media & Streaming", "template": "apps/medusa.json", "template_status": "generated-unvalidated", - "content_hash": "1ed6164183310cc30163962101df86050e4f9f76d8207627d98b06d377d8946c", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -6304,7 +6123,6 @@ "category_label": "Media & Streaming", "template": "apps/medusa-official.json", "template_status": "generated-unvalidated", - "content_hash": "5a8cd1fcf43cd8f7ff95925300f38e55f7d01fd229fb26f87ec2c914e5378822", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -6337,7 +6155,6 @@ "category_label": "Gaming & Leisure", "template": "apps/melonds.json", "template_status": "generated-unvalidated", - "content_hash": "2b550d653a1de88d15c7d171e42ca2b335cfc1a6ad1cec05e183a4cfd8ddeec7", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -6374,7 +6191,6 @@ "category_label": "Documents & Notes", "template": "apps/memos.json", "template_status": "laboratory-validated", - "content_hash": "94ac9cc627c820d0912956e1c5c6a08ecf4242d240b322f2c5f2b3a48d9bb37a", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -6410,7 +6226,6 @@ "category_label": "Gaming & Leisure", "template": "apps/mineos-node.json", "template_status": "laboratory-validated", - "content_hash": "ff052e25508b01568438d176fc961b57579b98115a5abc494734c5cb94cc820b", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -6443,7 +6258,6 @@ "category_label": "Media & Streaming", "template": "apps/minisatip.json", "template_status": "generated-unvalidated", - "content_hash": "5528556b3663f9dc0c8c9a64c2dae24188afea840f236b43823d72a7ef7d221f", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -6478,7 +6292,6 @@ "curated_path": "catalog/curated/mkvtoolnix.json", "template": "apps/mkvtoolnix.json", "template_status": "generated-unvalidated", - "content_hash": "c17222631fb1734d0b5b0395e25e53b0f5034332746933cf04db7597c3722976", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -6515,7 +6328,6 @@ "category_label": "Databases", "template": "apps/mongodb.json", "template_status": "generated-unvalidated", - "content_hash": "2f0c3b4678bc496e865291fa850c77880b488e74a8799cead539d285299690ad", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -6552,7 +6364,6 @@ "category_label": "Databases", "template": "apps/mongodb4.json", "template_status": "generated-unvalidated", - "content_hash": "ded611e14715be7ecf655239a1b4e4464eba18c8096bd9c9671011708072278b", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -6585,7 +6396,6 @@ "category_label": "Productivity & Workflows", "template": "apps/monica.json", "template_status": "generated-unvalidated", - "content_hash": "12779fdf8dc6617f198257445fadc783ccdf90bb64b518c32db3d88dedc4ea94", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -6622,7 +6432,6 @@ "category_label": "Productivity & Workflows", "template": "apps/monica-official.json", "template_status": "generated-unvalidated", - "content_hash": "8d9b5e07a261ef5245cd9aa8108fb39a5101507fefdec5aea78b881f4f54ccfa", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -6658,7 +6467,6 @@ "category_label": "NVR & Cameras", "template": "apps/motioneye.json", "template_status": "laboratory-validated", - "content_hash": "2c4932fde23765f83296739cf3e0196fc77f2dd89b6ea02588cd0448291df83c", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -6690,7 +6498,6 @@ "category_label": "Browsers & Web Desktops", "template": "apps/msedge.json", "template_status": "generated-unvalidated", - "content_hash": "246930a1ee01fb550fb469d05eeba09a1da217925ae876e9ed0a1ff17350b9ea", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -6723,7 +6530,6 @@ "category_label": "Media & Streaming", "template": "apps/mstream.json", "template_status": "generated-unvalidated", - "content_hash": "21fbe98c8b13ed061b21be07261e5048db60c3a86234b840692adeeb0f631249", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -6755,7 +6561,6 @@ "category_label": "Browsers & Web Desktops", "template": "apps/mullvad-browser.json", "template_status": "generated-unvalidated", - "content_hash": "6a1075b946faa8b3217f48d596d415a240000937a2baa565f3e14d83905c44cd", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -6788,7 +6593,6 @@ "category_label": "*Arr Suite", "template": "apps/mylar3.json", "template_status": "generated-unvalidated", - "content_hash": "78809472914b50e026e8375fcabd83b1358389e5b0d2d99eb9929bcf6ab8e95f", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -6825,7 +6629,6 @@ "category_label": "Monitoring & Analytics", "template": "apps/myspeed.json", "template_status": "generated-unvalidated", - "content_hash": "cbece9b22e1568f7e8116e1a113187f22463b9b1849530148b45a8131cc73e02", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -6857,7 +6660,6 @@ "category_label": "Databases", "template": "apps/mysql-workbench.json", "template_status": "generated-unvalidated", - "content_hash": "61e25e997378b8f98038de75f639e3f0072cd1007a6bd8b113dcd65e240a34f5", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -6894,7 +6696,6 @@ "category_label": "Automation & Scheduling", "template": "apps/n8n.json", "template_status": "generated-unvalidated", - "content_hash": "c91fc5bac65d36602aa8cd29fd9b56e0f2ba9274e3576a3c28dff6e99e2ba282", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -6931,7 +6732,6 @@ "category_label": "Media & Streaming", "template": "apps/navidrome.json", "template_status": "generated-unvalidated", - "content_hash": "1fc0500c68a1fd4e75eca110968728ea9459e21139259dad8841d33a9801c95c", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -6968,7 +6768,6 @@ "category_label": "Remote Access & VPN", "template": "apps/netbird.json", "template_status": "generated-unvalidated", - "content_hash": "b5dc0075e3d7dbcd523e556eec67ed1233f7f2bc5e486a969004a1e4c334a37f", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -7001,7 +6800,6 @@ "category_label": "Network & Firewall", "template": "apps/netbox.json", "template_status": "generated-unvalidated", - "content_hash": "71ea6310914895e2232ac0b7f9f14a40474e014bdf35953647c8eae57b9927d8", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -7038,7 +6836,6 @@ "category_label": "Host Management", "template": "apps/netdata.json", "template_status": "generated-unvalidated", - "content_hash": "ca96f2cd8a2ed2c252c928dd29cf6065799a5ec024f5ce92797a880e508cb1ce", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": true, @@ -7075,7 +6872,6 @@ "category_label": "Monitoring & Analytics", "template": "apps/netronome.json", "template_status": "generated-unvalidated", - "content_hash": "cc14c3acc44972385b678f72b5430de930b6be9b805616d031496677c4103c98", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -7108,7 +6904,6 @@ "category_label": "Productivity & Workflows", "template": "apps/nextcloud.json", "template_status": "laboratory-validated", - "content_hash": "3c6ab555a89e5881bafac6a24c53682080c6e8ddbe2177a49872db46f3fed42d", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -7145,7 +6940,6 @@ "category_label": "Productivity & Workflows", "template": "apps/nextcloud-official.json", "template_status": "generated-unvalidated", - "content_hash": "5e352ee3d1f34671c31797279eeb1d04272cd4678bd8360713eb9726420a8859", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -7177,7 +6971,6 @@ "curated_path": "catalog/curated/nextcloud-stack.json", "template": "apps/nextcloud-stack.json", "template_status": "generated-unvalidated", - "content_hash": "67e00f5a913724022e4262ef62ed61d65c18a462dff103d862fd72c9dd9ce955", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -7210,7 +7003,6 @@ "category_label": "Webservers & Proxies", "template": "apps/nginx.json", "template_status": "generated-unvalidated", - "content_hash": "9a03ac3c92a200386d640e96174d7dae2ff2c17f6a121445c5b15b4388e6e3df", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -7247,7 +7039,6 @@ "category_label": "Webservers & Proxies", "template": "apps/nginxproxymanager.json", "template_status": "generated-unvalidated", - "content_hash": "b77833e78c20f9610927f929cc961d65c092f94261f789fcd413980060c65d66", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -7280,7 +7071,6 @@ "category_label": "Communication & Community", "template": "apps/ngircd.json", "template_status": "generated-unvalidated", - "content_hash": "cc1a8c9d07caed3be713c73e24fae3d272d306788a954f7bc2027ff6db46ea6d", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -7317,7 +7107,6 @@ "category_label": "IoT & Smart Home", "template": "apps/node-red.json", "template_status": "generated-unvalidated", - "content_hash": "128efc3f542c4daa1aa4655e20b7652964b364b4b5cecdffe81f3aa3637d0b98", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -7354,7 +7143,6 @@ "category_label": "Media & Streaming", "template": "apps/nzbfast.json", "template_status": "generated-unvalidated", - "content_hash": "2524d33ba6db616fcf5d7d737c09b110b519764e429cad2f77be6d22c6c6fde3", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -7387,7 +7175,6 @@ "category_label": "Files & Downloads", "template": "apps/nzbget.json", "template_status": "generated-unvalidated", - "content_hash": "c1f0c1f10c4fd651436b305ea36755901d4d53c0e309de9cf6c0e27ef958fc58", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -7420,7 +7207,6 @@ "category_label": "*Arr Suite", "template": "apps/nzbhydra2.json", "template_status": "generated-unvalidated", - "content_hash": "9904215820e2919283e2d348b9fbd0e1ff0c7d3ce3188a3a74be4077ea57b0d2", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -7453,7 +7239,6 @@ "category_label": "Documents & Notes", "template": "apps/obsidian.json", "template_status": "generated-unvalidated", - "content_hash": "67c69f6c9c0591f35736d94a6f5289d8fecbca454f1644bb6b5b4bba883c8663", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -7490,7 +7275,6 @@ "category_label": "AI / Coding & Dev-Tools", "template": "apps/ollama.json", "template_status": "generated-unvalidated", - "content_hash": "be933d7111c9ba289dbb51adb329a40a574d04de6d1bb0d8234c031c4952e0b0", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -7523,7 +7307,6 @@ "category_label": "Media & Streaming", "template": "apps/ombi.json", "template_status": "generated-unvalidated", - "content_hash": "9f9572f62a233b6608a5bb2832a91b5b023ce06b9548aa72be526ce5298a0890", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -7555,7 +7338,6 @@ "category_label": "Documents & Notes", "template": "apps/onlyoffice.json", "template_status": "generated-unvalidated", - "content_hash": "617e57253991a4dbffe910e7e51851d187ccba621aec52bbed3e6c1f912538ec", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -7587,7 +7369,6 @@ "curated_path": "catalog/curated/open-webui.json", "template": "apps/open-webui.json", "template_status": "generated-unvalidated", - "content_hash": "5578979958bdfabcd228df3ec767f11dae6b519a67f8ddbeb7f71b9417bac20f", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -7619,7 +7400,6 @@ "curated_path": "catalog/curated/open-webui-cuda.json", "template": "apps/open-webui-cuda.json", "template_status": "generated-unvalidated", - "content_hash": "bdd21c3576bbd6e99959eaa54ed2d4b14ca596ad99eac33dc63330540e9f5dc3", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -7653,7 +7433,6 @@ "curated_path": "catalog/curated/open-webui-ollama.json", "template": "apps/open-webui-ollama.json", "template_status": "generated-unvalidated", - "content_hash": "2b3a28b5b3ae2ca98fbf6a36751d2b1dd3ad2bda839704ead89405ab6900a4ac", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -7690,7 +7469,6 @@ "category_label": "AI / Coding & Dev-Tools", "template": "apps/openclaw.json", "template_status": "generated-unvalidated", - "content_hash": "8ed49fe47c989e6eb37aa8dd89cad262da7b31be070508dda249dfc9a0380196", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -7727,7 +7505,6 @@ "category_label": "IoT & Smart Home", "template": "apps/openhab.json", "template_status": "generated-unvalidated", - "content_hash": "70e86f17e7ceb691394c8021e61af8a671bb3f96a28f73a183c2fa89fc61cdc9", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -7763,7 +7540,6 @@ "category_label": "AI / Coding & Dev-Tools", "template": "apps/openhands.json", "template_status": "generated-unvalidated", - "content_hash": "d12053234bd92b4e4a218072a912d75486828264c587bc8c2b34ee2f92604384", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -7800,7 +7576,6 @@ "category_label": "Productivity & Workflows", "template": "apps/openlist.json", "template_status": "laboratory-validated", - "content_hash": "b3203fceb0965da9393f9fe3fd380a377df69a5080245da2ecf946763e1472cf", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -7832,7 +7607,6 @@ "category_label": "Creative & Design", "template": "apps/openshot.json", "template_status": "generated-unvalidated", - "content_hash": "ed06e4e88482a7367eadac7ca014629d0144bc64e82b0a40774505945a85bfe9", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -7869,7 +7643,6 @@ "category_label": "Monitoring & Analytics", "template": "apps/openspeedtest.json", "template_status": "generated-unvalidated", - "content_hash": "08fb7d4b29331c86f918962b5f4eb0cfd25f7800a832a312cfed6b869796e38a", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -7902,7 +7675,6 @@ "category_label": "Remote Access & VPN", "template": "apps/openssh-server.json", "template_status": "laboratory-validated", - "content_hash": "c3b7c81dd77bc5739a1a6881cd3c94750774596e3fb53f3e549456c643bc194d", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -7935,7 +7707,6 @@ "category_label": "AI / Coding & Dev-Tools", "template": "apps/openvscode-server.json", "template_status": "generated-unvalidated", - "content_hash": "8b2e77ba8ba0482319da163c58cb606d486d2caf43c29d01412f827fbd05d550", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -7967,7 +7738,6 @@ "category_label": "Browsers & Web Desktops", "template": "apps/opera.json", "template_status": "generated-unvalidated", - "content_hash": "a61303b8c14b0ae83335582fcb62a7f920cd88b2a167a61fe82f423fb5916824", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -8004,7 +7774,6 @@ "category_label": "Media & Streaming", "template": "apps/opodsync.json", "template_status": "generated-unvalidated", - "content_hash": "ddf2fa1a7a5be83f6c7fc9d0e51a83aea64414b5a95da75ab5b80ed6061750f2", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -8037,7 +7806,6 @@ "category_label": "Creative & Design", "template": "apps/orcaslicer.json", "template_status": "generated-unvalidated", - "content_hash": "7168aad5ee382925e6d4138fd2780f9c4872a602bfcd6d56be53be637d14299a", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -8070,7 +7838,6 @@ "category_label": "Media & Streaming", "template": "apps/oscam.json", "template_status": "generated-unvalidated", - "content_hash": "8372c7d49f28c4a25472eee29955ba229ad9e54cebd4bd273b19f2ca9281e111", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -8107,7 +7874,6 @@ "category_label": "Media & Streaming", "template": "apps/overseerr.json", "template_status": "generated-unvalidated", - "content_hash": "be689b1022556d2c7f1d125d770bdff31e45044829926a7bfe72df82a9edc75c", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -8140,7 +7906,6 @@ "category_label": "Files & Downloads", "template": "apps/pairdrop.json", "template_status": "generated-unvalidated", - "content_hash": "8ec4f8f374304aa4178cfb27949e5e203b00682057392a0f8a42bf6ccb92628b", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -8175,7 +7940,6 @@ "curated_path": "catalog/curated/paperless-ngx.json", "template": "apps/paperless-ngx.json", "template_status": "laboratory-validated", - "content_hash": "62d8ee64c5818c4c0c564ea9a8cc6515ff98ed252361a5bf2d6a747a770e9e56", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -8207,7 +7971,6 @@ "category_label": "Gaming & Leisure", "template": "apps/pcsx2.json", "template_status": "generated-unvalidated", - "content_hash": "f39a306e91011977bf55b7eb29f80307950eea0fcc56b3713a6a45cd1ce2d732", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -8244,7 +8007,6 @@ "category_label": "Productivity & Workflows", "template": "apps/pdfding.json", "template_status": "generated-unvalidated", - "content_hash": "fbb93793032b8b6cfcd7ed783296544fe6e255c643a53af8390d1b5855fe2f5b", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -8281,7 +8043,6 @@ "category_label": "Monitoring & Analytics", "template": "apps/peanut.json", "template_status": "generated-unvalidated", - "content_hash": "386b180d3aed2b59bec0b7ce9bf0437469fbd40cb09d59e5b938116fd83fa39b", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -8313,7 +8074,6 @@ "category_label": "AI / Coding & Dev-Tools", "template": "apps/pelorus.json", "template_status": "generated-unvalidated", - "content_hash": "ff9ffa5ec20c0bcb4c4edd6eb15555197d63d57e813b3c097078f0a3e05fbfe7", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -8350,7 +8110,6 @@ "category_label": "Media & Streaming", "template": "apps/petio.json", "template_status": "generated-unvalidated", - "content_hash": "81f3550079fe26a5635452491fe264f192d1ca2e9e9864eb19abd2531247f848", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -8387,7 +8146,6 @@ "category_label": "Media & Streaming", "template": "apps/photoprism.json", "template_status": "generated-unvalidated", - "content_hash": "cd0cef614f9f3850e52f2b4f78d43af4c13a0c3ca8566adde4a4c3440d2dbabd", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -8420,7 +8178,6 @@ "category_label": "Databases", "template": "apps/phpmyadmin.json", "template_status": "laboratory-validated", - "content_hash": "48030a8e4362e982a8c2137381e0f419132c53122032fb70c42fe9f63fcc00b4", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -8453,7 +8210,6 @@ "category_label": "Communication & Community", "template": "apps/pidgin.json", "template_status": "generated-unvalidated", - "content_hash": "d8de7834c0ffd9dd7134404c079ffdd689d940368810bc575e764504b3a39d95", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -8490,7 +8246,6 @@ "category_label": "Adblock & DNS", "template": "apps/pihole.json", "template_status": "generated-unvalidated", - "content_hash": "c13691710f10692c311cd9f013e2373a99b08fc6fae1d2c44686f89f0767d4c4", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -8526,7 +8281,6 @@ "category_label": "Media & Streaming", "template": "apps/pinchflat.json", "template_status": "generated-unvalidated", - "content_hash": "47460dc255e205dceaeda44ccd34e4083fa47430db0ba733c0c06d1c0eb5bbeb", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -8563,7 +8317,6 @@ "category_label": "Productivity & Workflows", "template": "apps/pingvin-share.json", "template_status": "generated-unvalidated", - "content_hash": "9a375311e43bfd178b3e59bfec107c9160e4041fe54a2dbbe7972eecc9c1d677", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -8596,7 +8349,6 @@ "category_label": "AI / Coding & Dev-Tools", "template": "apps/piper.json", "template_status": "generated-unvalidated", - "content_hash": "61e7e0b4a6bb0235f018a5a28fa40813092967d5f402efd94bdba47e18c01e41", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -8629,7 +8381,6 @@ "category_label": "Media & Streaming", "template": "apps/piwigo.json", "template_status": "generated-unvalidated", - "content_hash": "c643c6b64528ff7f9927cf22ef8bd580f67a8cb2f3c1c38bb184e247d590433d", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -8662,7 +8413,6 @@ "category_label": "Productivity & Workflows", "template": "apps/planka.json", "template_status": "generated-unvalidated", - "content_hash": "c576006cd38cbc3beed40722fefad48ad1cca188da250f181ab67969dbec4dcd", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -8699,7 +8449,6 @@ "category_label": "Webservers & Proxies", "template": "apps/playit-agent.json", "template_status": "generated-unvalidated", - "content_hash": "4d71b2b20908f9c5566f3fe7243d8c856a83f6a8f6a1ed6fd2415407b34ec821", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -8732,7 +8481,6 @@ "category_label": "Media & Streaming", "template": "apps/plex.json", "template_status": "generated-unvalidated", - "content_hash": "3cc0184a9a584aa85af461c1178f634867cc30206b3fb961e6e9a32eb985511e", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -8765,7 +8513,6 @@ "curated_path": "catalog/curated/plex-official.json", "template": "apps/plex-official.json", "template_status": "generated-unvalidated", - "content_hash": "46158e92b2092dae2cb6bb1003e05e57d6c26a0386c784d1b7e3175d2de3d46f", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -8801,7 +8548,6 @@ "category_label": "Databases", "template": "apps/pocketbase.json", "template_status": "generated-unvalidated", - "content_hash": "341dfae83886182322dbffc7a129ff8d87931be5f25d9572199c71b2046037d9", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -8838,7 +8584,6 @@ "category_label": "Media & Streaming", "template": "apps/podfetch.json", "template_status": "generated-unvalidated", - "content_hash": "ed235eb8c120384aa6fbaaf5741dc5512d9f4645996cb389af1a5d77669a31e1", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -8875,7 +8620,6 @@ "category_label": "Containers & Docker", "template": "apps/portainer.json", "template_status": "generated-unvalidated", - "content_hash": "4cb9afba4933fe8704ccfc60bcaacadf14de4ddd66a4dfcf24e60ee8bcb9f2e6", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -8912,7 +8656,6 @@ "category_label": "Databases", "template": "apps/postgresql.json", "template_status": "generated-unvalidated", - "content_hash": "3a6329dff87774e7042dde96f2c5e658e0710adbc13e9100ee70682f82927fc4", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -8944,7 +8687,6 @@ "category_label": "Gaming & Leisure", "template": "apps/ppsspp.json", "template_status": "generated-unvalidated", - "content_hash": "227432244de320131318616f1f4395ba37fa6e3e766b5f05ddba66a10a31eca8", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -8977,7 +8719,6 @@ "category_label": "Documents & Notes", "template": "apps/projectsend.json", "template_status": "generated-unvalidated", - "content_hash": "ce567a95aab3b391085a9613456ab8dac506aa1d14a127c55d7b7452ca153151", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -9010,7 +8751,6 @@ "category_label": "*Arr Suite", "template": "apps/prowlarr.json", "template_status": "generated-unvalidated", - "content_hash": "3ebe43e7d698bef3e0ccd21e2ae3cda172e6d467059a3e26a1bce565659aa3d3", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -9047,7 +8787,6 @@ "category_label": "Productivity & Workflows", "template": "apps/psitransfer.json", "template_status": "generated-unvalidated", - "content_hash": "4748db83a75ddbc121a3db927a6edf226b87bf55e7cb270f95283bcc53a22255", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -9080,7 +8819,6 @@ "category_label": "Files & Downloads", "template": "apps/pwndrop.json", "template_status": "generated-unvalidated", - "content_hash": "afec8b0e1bd578f48d19b933f282cb72332d16fdc686c56a28bad592f899e29a", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -9112,7 +8850,6 @@ "category_label": "AI / Coding & Dev-Tools", "template": "apps/pycharm.json", "template_status": "generated-unvalidated", - "content_hash": "cee5e3c89d3d1163905746bc5bc1b88d66217be8df400dbfb424b6e7b7a09832", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -9145,7 +8882,6 @@ "category_label": "Files & Downloads", "template": "apps/pydio-cells.json", "template_status": "generated-unvalidated", - "content_hash": "e1a803957605720af4dabadb265f5455d75ed37e2b3b0c254c34844fae14dbb4", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -9178,7 +8914,6 @@ "category_label": "Files & Downloads", "template": "apps/pyload-ng.json", "template_status": "generated-unvalidated", - "content_hash": "49914b770ad2514abff6e8308954e7c31b8aa76fbba583c10858741fbaf72aa9", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -9211,7 +8946,6 @@ "category_label": "Files & Downloads", "template": "apps/qbittorrent.json", "template_status": "laboratory-validated", - "content_hash": "684e5958ac36beffc1fa2503e38b9728b33efb28eeb8cc051883fe2cddbb8048", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -9248,7 +8982,6 @@ "category_label": "Files & Downloads", "template": "apps/qbittorrent-hotio.json", "template_status": "generated-unvalidated", - "content_hash": "6125765539ae698bb3b12c2be47b55d023df7367526e277ce5b37034fefd89cf", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -9281,7 +9014,6 @@ "category_label": "Tools", "template": "apps/qdirstat.json", "template_status": "generated-unvalidated", - "content_hash": "46b5bab0e4c9df729f8f0ba94d68ae7014287556ea24b575599964877c9a849f", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -9318,7 +9050,6 @@ "category_label": "Files & Downloads", "template": "apps/qui.json", "template_status": "generated-unvalidated", - "content_hash": "25f46b4a176800da9ffc91509a53d3784c3080e660ae8bcf7275b91ca4641c6f", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -9351,7 +9082,6 @@ "category_label": "*Arr Suite", "template": "apps/radarr.json", "template_status": "generated-unvalidated", - "content_hash": "7386a2462e5e1c1a9432428dfbc2a92105d470ca0712eeff8f50e08be5bcb286", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -9387,7 +9117,6 @@ "category_label": "AI / Coding & Dev-Tools", "template": "apps/ragflow.json", "template_status": "generated-review-required", - "content_hash": "9251e28c7942933f47665c6e60efa40a2cbfefeb91c942c7cddbfc33ec9825fb", "automatic_install_candidate": false, "untranslated_blockers": [ "multi-service-compose", @@ -9426,7 +9155,6 @@ "category_label": "Documents & Notes", "template": "apps/raneto.json", "template_status": "generated-unvalidated", - "content_hash": "1aba7fd8cb7a3b7a48453a6b3b2451feeb0ce06df30881f553f0e53737063d39", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -9459,7 +9187,6 @@ "category_label": "Creative & Design", "template": "apps/rawtherapee.json", "template_status": "generated-unvalidated", - "content_hash": "dea13f85c5b83b7a2415dc74f9238c9b4fcbda5bb7467bbe2ede999279d57fd6", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -9492,7 +9219,6 @@ "curated_path": "catalog/curated/rclone.json", "template": "apps/rclone.json", "template_status": "laboratory-validated", - "content_hash": "458f9b53ad3ec232afc64183c1324083daf19d159bf62801d7a0aa888d52eef3", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": true, @@ -9529,7 +9255,6 @@ "category_label": "Files & Downloads", "template": "apps/rdtclient.json", "template_status": "laboratory-validated", - "content_hash": "06e85d3f0feba79ab088f6f5b1099a4c76b77060ddca1417b571943713431652", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -9566,7 +9291,6 @@ "category_label": "Media & Streaming", "template": "apps/readarr.json", "template_status": "generated-unvalidated", - "content_hash": "1f8539a356dd43d031fd381533a5b5044990b77cdde85200580c0921c226e1a6", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -9599,7 +9323,6 @@ "category_label": "Remote Access & VPN", "template": "apps/remmina.json", "template_status": "generated-unvalidated", - "content_hash": "fd2a7694158b46d8e4736753c0b917a6c2d363940473d9ceca338b8172766191", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -9632,7 +9355,6 @@ "category_label": "Files & Downloads", "template": "apps/resilio-sync.json", "template_status": "generated-unvalidated", - "content_hash": "d14178b19b3d372d6c8eb0f049468b04236f5d09ed6145dc0e558c021fcf2731", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -9665,7 +9387,6 @@ "category_label": "Gaming & Leisure", "template": "apps/retroarch.json", "template_status": "generated-unvalidated", - "content_hash": "e0c34aa3121f19cb4b9c09acd78d3c0c88689ef1cd6c632b05cccc042b643a46", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -9702,7 +9423,6 @@ "category_label": "Gaming & Leisure", "template": "apps/retroarch-inglebard.json", "template_status": "generated-unvalidated", - "content_hash": "209fd5aa52427c27557a53cc14fc3384673418a74bf3db42f8a4c203cf2421a6", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -9739,7 +9459,6 @@ "category_label": "Gaming & Leisure", "template": "apps/romm.json", "template_status": "generated-unvalidated", - "content_hash": "45cf2bcf7bad0abb0e38bc074591c9dc3a3c38b7bf59fbf5f1e96b4e64c316f8", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -9773,7 +9492,6 @@ "curated_path": "catalog/curated/roonserver.json", "template": "apps/roonserver.json", "template_status": "generated-unvalidated", - "content_hash": "7a03adeac910fb9cd8f21acfb3a62274a41bf77844fb71be788a3fcbe337154b", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -9806,7 +9524,6 @@ "category_label": "Gaming & Leisure", "template": "apps/rpcs3.json", "template_status": "generated-unvalidated", - "content_hash": "6ddb459ac6d44627cdfd1f125c9d24ec751661792c6a724c2678d1e2985b5570", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -9839,7 +9556,6 @@ "category_label": "Backup & Recovery", "template": "apps/rsnapshot.json", "template_status": "generated-unvalidated", - "content_hash": "5bfeb5cce3e9da1e47be019fc4ddff9842e33fd0c118436ebc6c470010797fb3", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -9872,7 +9588,6 @@ "category_label": "Remote Access & VPN", "template": "apps/rustdesk.json", "template_status": "generated-unvalidated", - "content_hash": "09d90aa1f7c7e02bdc3a632cb19258b25f5ff04e7254ff965f12492b94dd8e44", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -9905,7 +9620,6 @@ "category_label": "Files & Downloads", "template": "apps/sabnzbd.json", "template_status": "generated-unvalidated", - "content_hash": "7eaab95a30677aedcdcbc8860dca443a256b4ebe28958a67758565f79c77dc18", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -9937,7 +9651,6 @@ "category_label": "Gaming & Leisure", "template": "apps/scummvm.json", "template_status": "generated-unvalidated", - "content_hash": "b00474648ab3e217e8e305a357aa2873601ac23c629bc912b52e8e96d386b865", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -9970,7 +9683,6 @@ "category_label": "Browsers & Web Desktops", "template": "apps/sealskin.json", "template_status": "generated-unvalidated", - "content_hash": "ab76d53eea89231efa385f759751c1c72300d5955af35ee90e745a72140cd40b", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -10003,7 +9715,6 @@ "curated_path": "catalog/curated/seerr.json", "template": "apps/seerr.json", "template_status": "generated-unvalidated", - "content_hash": "7b0a15445e8a76bbe533df9dbb8aa6ae56ccaff3153b2c16b1c128b0a6d187f7", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -10035,7 +9746,6 @@ "category_label": "Gaming & Leisure", "template": "apps/shadps4.json", "template_status": "generated-unvalidated", - "content_hash": "267bb1e36ed9cd607f8c27432e74f7801d52da1b18b08f675842269e1c121654", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -10067,7 +9777,6 @@ "category_label": "Creative & Design", "template": "apps/shotcut.json", "template_status": "generated-unvalidated", - "content_hash": "e97c11387cb6fcb482c2c5116cb49a5a4ca6be585193de71b0783911b5ed8dc1", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -10104,7 +9813,6 @@ "category_label": "Media & Streaming", "template": "apps/sickchill.json", "template_status": "generated-unvalidated", - "content_hash": "629b42d7319685755295900f33c2a5a5e79f00b2cf2400fbdbcc5364f7903952", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -10137,7 +9845,6 @@ "category_label": "*Arr Suite", "template": "apps/sickgear.json", "template_status": "generated-unvalidated", - "content_hash": "09946081c57eaddf709c81a6119f7051d63920cb3b2cdb379bd57dfcf17cd4a1", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -10169,7 +9876,6 @@ "category_label": "Communication & Community", "template": "apps/signal.json", "template_status": "generated-unvalidated", - "content_hash": "4e8fd61dbd630a87807b297be546c72795caf6703ffa579f3c490b53f7a23f92", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -10206,7 +9912,6 @@ "category_label": "Productivity & Workflows", "template": "apps/siyuan-note.json", "template_status": "generated-unvalidated", - "content_hash": "6c38e5bd772d7f9b3945a503d39e65f3218a41d089ac79bf340da3758c734c6a", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -10239,7 +9944,6 @@ "category_label": "Monitoring & Analytics", "template": "apps/smokeping.json", "template_status": "generated-unvalidated", - "content_hash": "69336f4b056f9013342f83aee1941a4f4024b5d7e1cf6a82662588a0928a56d7", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -10276,7 +9980,6 @@ "category_label": "Productivity & Workflows", "template": "apps/snapdrop.json", "template_status": "generated-unvalidated", - "content_hash": "381b982092be587efad50abcadae4ae08b3bb2b009ab251ae1fc127c5fec873d", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -10313,7 +10016,6 @@ "category_label": "Media & Streaming", "template": "apps/snapotter.json", "template_status": "laboratory-validated", - "content_hash": "56b8d4ec7d332055b24ec9a17d9cb7f19b2af4e8d6224debb718291b28c96237", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -10346,7 +10048,6 @@ "category_label": "*Arr Suite", "template": "apps/sonarr.json", "template_status": "generated-unvalidated", - "content_hash": "eb5263221be311022817d6381a7cf376d0b589c52b2cca37188058c4c96e3292", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -10379,7 +10080,6 @@ "category_label": "Monitoring & Analytics", "template": "apps/speedtest-tracker.json", "template_status": "generated-unvalidated", - "content_hash": "bd2d1338d7115081530645b900f5a954755b58d06c85b6b89204da88a9af1644", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -10412,7 +10112,6 @@ "category_label": "Media & Streaming", "template": "apps/spotube.json", "template_status": "generated-unvalidated", - "content_hash": "3b64a7e47b91c6459677c454f52b3f60812a14afd07feb6df2da24a9b63d30d1", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -10445,7 +10144,6 @@ "category_label": "Databases", "template": "apps/sqlitebrowser.json", "template_status": "generated-unvalidated", - "content_hash": "2185c873858ce1952734289caedfdba2cee9682b6bb173b8bca9346a93741ae3", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -10481,7 +10179,6 @@ "category_label": "AI / Coding & Dev-Tools", "template": "apps/stable-diffusion-webui-nvidia.json", "template_status": "generated-unvalidated", - "content_hash": "52b935fdb7a25adc834933de6cfcb25b5da1701de3f02f90a7d5b08d843b32a9", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -10513,7 +10210,6 @@ "category_label": "Gaming & Leisure", "template": "apps/steam.json", "template_status": "generated-unvalidated", - "content_hash": "78be734cc7f82cc949c19d9f231c3a6d1db6034e53aaf980c95175725d04ad3e", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -10548,7 +10244,6 @@ "curated_path": "catalog/curated/stremio.json", "template": "apps/stremio.json", "template_status": "generated-unvalidated", - "content_hash": "b2a5d41e58b5ebc2738588b20a4e2c3d10ecf109bdeb79a3aa10c11061dcff0a", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -10581,7 +10276,6 @@ "curated_path": "catalog/curated/suite-arr.json", "template": "apps/suite-arr.json", "template_status": "generated-unvalidated", - "content_hash": "eb33e0f47e1f84a258bf667988ef1a5e178e6c50f75df27f1cd3c92d526629de", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -10618,7 +10312,6 @@ "category_label": "Finance & Budgeting", "template": "apps/sure.json", "template_status": "generated-review-required", - "content_hash": "888b4584227859512cb9f43f5b8c7cc7cdafc727bc5151c87acec763d7a4c01b", "automatic_install_candidate": false, "untranslated_blockers": [ "multi-service-compose", @@ -10658,7 +10351,6 @@ "category_label": "Webservers & Proxies", "template": "apps/swag.json", "template_status": "generated-unvalidated", - "content_hash": "c25276bc6f6b54d95fb3dac8ac952ee2345b6ad095eb9f83261145f12b1cd9e8", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -10694,7 +10386,6 @@ "category_label": "Media & Streaming", "template": "apps/swingmusic.json", "template_status": "generated-unvalidated", - "content_hash": "9dc5d638a714a54688c32f26a87504493d6652e72b582a1a78d7c6c434ad8527", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -10727,7 +10418,6 @@ "category_label": "Media & Streaming", "template": "apps/synclounge.json", "template_status": "generated-unvalidated", - "content_hash": "63c40039616331f757dfef88c626e84bc5258bf0e4fe1e851a44f2d126588c6a", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -10760,7 +10450,6 @@ "category_label": "Files & Downloads", "template": "apps/syncthing.json", "template_status": "generated-unvalidated", - "content_hash": "c939418e7fac97f3a8043da94bf0fa0044446263215638a16da27be7ce49324c", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -10793,7 +10482,6 @@ "category_label": "Monitoring & Analytics", "template": "apps/syslog-ng.json", "template_status": "generated-unvalidated", - "content_hash": "59bb91b4371a26d4b98dcafa7b581665f7f761f96cd7a8b608c1f84a6bdc17d4", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -10830,7 +10518,6 @@ "category_label": "Remote Access & VPN", "template": "apps/tailscale.json", "template_status": "generated-unvalidated", - "content_hash": "4ceee28e6d136b6e60deb806c69e43e286497b4842c5e21edb539cf2b6da0fcc", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -10865,7 +10552,6 @@ "curated_path": "catalog/curated/tandoor.json", "template": "apps/tandoor.json", "template_status": "generated-unvalidated", - "content_hash": "4c3f02f82c51eb1c7303953eb63cc2f76786daac0d2ea7d082de53c4db8b603b", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -10902,7 +10588,6 @@ "category_label": "AI / Coding & Dev-Tools", "template": "apps/taskingai.json", "template_status": "generated-review-required", - "content_hash": "56578d0ced633edb72bfbf42ca68193036b9b38d3f73182c0d3f777c43611fe2", "automatic_install_candidate": false, "untranslated_blockers": [ "multi-service-compose", @@ -10946,7 +10631,6 @@ "curated_path": "catalog/curated/tasmoadmin.json", "template": "apps/tasmoadmin.json", "template_status": "generated-unvalidated", - "content_hash": "8b403ef48c0be77c8cb57f004fde5c5c11842859c3ee6b40505c61e379e3c737", "hidden": false, "automatic_install_candidate": true, "untranslated_blockers": [], @@ -10980,7 +10664,6 @@ "category_label": "Media & Streaming", "template": "apps/tautulli.json", "template_status": "generated-unvalidated", - "content_hash": "c0eadde7ca2891584aef7fc01360ede3a8b74e157eb9b3120e89dc2b9818a089", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -11013,7 +10696,6 @@ "curated_path": "catalog/curated/tdarr.json", "template": "apps/tdarr.json", "template_status": "generated-unvalidated", - "content_hash": "9cd3c713326e31f4ce749ac1dc45b2265b3a28389140d7d57a5de95f491712bd", "hidden": false, "automatic_install_candidate": true, "untranslated_blockers": [], @@ -11051,7 +10733,6 @@ "category_label": "Databases", "template": "apps/teable.json", "template_status": "generated-unvalidated", - "content_hash": "a9a80af10e1f23adb086d7e42dc0479771437427f1eba19da365c6a87720cb8b", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -11084,7 +10765,6 @@ "category_label": "Communication & Community", "template": "apps/telegram.json", "template_status": "generated-unvalidated", - "content_hash": "971f8fd3dc847c583541f3d47f90b0211d3fa007e377590a7109b7ccb52dee6b", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -11117,7 +10797,6 @@ "category_label": "Communication & Community", "template": "apps/thelounge.json", "template_status": "laboratory-validated", - "content_hash": "d5d7aeabb2fa462b13b9c25f2aa2c0607b0f5a6da5f36485d9ecf2bbc420292a", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -11154,7 +10833,6 @@ "category_label": "Media & Streaming", "template": "apps/threadfin.json", "template_status": "generated-unvalidated", - "content_hash": "9617930786a3f035fde36f00806181daa74552590651c7851a8165337dd82c15", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -11187,7 +10865,6 @@ "category_label": "Communication & Community", "template": "apps/thunderbird.json", "template_status": "generated-unvalidated", - "content_hash": "e6e96a980490a5f72de2ed02de7fe049c4cb96b0f52823c325475386ef60c80e", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -11220,7 +10897,6 @@ "category_label": "Files & Downloads", "template": "apps/transmission.json", "template_status": "generated-unvalidated", - "content_hash": "240c2db38d35e939a7a415d05e6ee4122f78102605e9830f300f9631d1768c96", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -11257,7 +10933,6 @@ "category_label": "Documents & Notes", "template": "apps/trilium.json", "template_status": "laboratory-validated", - "content_hash": "da0994316a92ddb2ff74c12ce51440b1d3fc061fe7b22f6e3bc615a57960b7bf", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -11293,7 +10968,6 @@ "category_label": "AI / Coding & Dev-Tools", "template": "apps/turbodiffusion-nvidia.json", "template_status": "generated-unvalidated", - "content_hash": "92708cc935adf96cf0b6cf990f172e5a3d10f7d56b272b99a8d0cbaae87c1e8d", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -11326,7 +11000,6 @@ "category_label": "Media & Streaming", "template": "apps/tvheadend.json", "template_status": "generated-unvalidated", - "content_hash": "5eba949f7de4e82e979919726c2760cedfc0938327154ca145f82c63dc6981e6", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -11363,7 +11036,6 @@ "category_label": "Remote Access & VPN", "template": "apps/twingate-connector.json", "template_status": "generated-unvalidated", - "content_hash": "fdbfc2b3ac8ce28f14ee1de2bd1a5bb0b76c7bdcb5b900ab75260f9e8a78b8ae", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -11396,7 +11068,6 @@ "category_label": "Media & Streaming", "template": "apps/ubooquity.json", "template_status": "generated-unvalidated", - "content_hash": "00be4ce78bdf56e484c8cb9f6bc3fa1b8d7ae403b1c81f84fd0ab865264a7da6", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -11429,7 +11100,6 @@ "category_label": "Browsers & Web Desktops", "template": "apps/ungoogled-chromium.json", "template_status": "generated-unvalidated", - "content_hash": "05e77f04e355ddb56edf94b8b5df047aca192b5ac908913411505ebc24127af2", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -11466,7 +11136,6 @@ "category_label": "Network & Firewall", "template": "apps/unifi-controller.json", "template_status": "generated-unvalidated", - "content_hash": "a0b504b1e69443e58b231aa03b01e2bc3f7654cff1988bf37020088a8b971567", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -11499,7 +11168,6 @@ "category_label": "Network & Firewall", "template": "apps/unifi-network-application.json", "template_status": "generated-unvalidated", - "content_hash": "b30497c2ccc6d4341d7093c2b4f24684042aa39673e43d34556409a605afcbf4", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -11532,7 +11200,6 @@ "curated_path": "catalog/curated/unpackerr.json", "template": "apps/unpackerr.json", "template_status": "generated-unvalidated", - "content_hash": "cf30efc8362f9b033807d908fd29b33151993c8ec483cbb70606700612c300f5", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -11569,7 +11236,6 @@ "category_label": "Monitoring & Analytics", "template": "apps/uptimekuma.json", "template_status": "generated-unvalidated", - "content_hash": "b4b7d85780c731d7d68a596cdee4be771da54fc5bfbc27813c95dc5410976019", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -11606,7 +11272,6 @@ "category_label": "Remote Access & VPN", "template": "apps/v2raya.json", "template_status": "generated-unvalidated", - "content_hash": "100ab96c062ab93155191ab6099e72098459058ff4f1701f256f1139ddcb55ae", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -11643,7 +11308,6 @@ "category_label": "Authentication & Security", "template": "apps/vaultwarden.json", "template_status": "generated-unvalidated", - "content_hash": "aca898dc33a75b19b6fa5485b28953720920a561d69d50b63a7c15ffce5e7543", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -11680,7 +11344,6 @@ "category_label": "Host Management", "template": "apps/virt-manager.json", "template_status": "generated-unvalidated", - "content_hash": "d4c674fc1093a8dc0afb9c346d3e3900a9636240f7fbb1b07a123e5a406986fb", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -11713,7 +11376,6 @@ "category_label": "Browsers & Web Desktops", "template": "apps/vivaldi.json", "template_status": "generated-unvalidated", - "content_hash": "891cf938d5bce12c3b457524bbbd9a99bb361e06868e9996fe13ebacd23a3d3e", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -11746,7 +11408,6 @@ "category_label": "Media & Streaming", "template": "apps/vlc.json", "template_status": "generated-unvalidated", - "content_hash": "e02527d18fd43cafb94d8180a632047be108ae2f6da18bd92cb3ed60805ad1b1", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -11782,7 +11443,6 @@ "category_label": "Communication & Community", "template": "apps/vocechat.json", "template_status": "generated-unvalidated", - "content_hash": "76d7fd941f9a3673cc5f3840b63ee3433d326259810bab4160f286b1047c9c1e", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -11815,7 +11475,6 @@ "category_label": "AI / Coding & Dev-Tools", "template": "apps/vscode.json", "template_status": "generated-unvalidated", - "content_hash": "b0fa624781b3cdaa32b5cf7d70ec33f37f198190e7025c7f7adee8c54838c9a0", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -11848,7 +11507,6 @@ "category_label": "AI / Coding & Dev-Tools", "template": "apps/vscodium.json", "template_status": "generated-unvalidated", - "content_hash": "42e061febe0602119c45eddf245ec9df056c32786b877cc1931ab6666767c8f4", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -11881,7 +11539,6 @@ "category_label": "AI / Coding & Dev-Tools", "template": "apps/vscodium-web.json", "template_status": "generated-unvalidated", - "content_hash": "fcd69129dfef13b4297e02c9bda504d82d22afd25978a07abd848b5d80b22c17", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -11918,7 +11575,6 @@ "category_label": "Documents & Notes", "template": "apps/wallabag.json", "template_status": "generated-unvalidated", - "content_hash": "51e1a782e78fa244f6628b4b5b51a7e5fd5a7f29fcb646bc3177fabbbaa5d233", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -11951,7 +11607,6 @@ "category_label": "Communication & Community", "template": "apps/webcord.json", "template_status": "generated-unvalidated", - "content_hash": "ba0930e8043ae3737ec4ea55692111eb35d55e080b2ff24446e191fbae6f91cf", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -11988,7 +11643,6 @@ "category_label": "Productivity & Workflows", "template": "apps/webdav.json", "template_status": "generated-unvalidated", - "content_hash": "bb9969e0330a0f6ed75e15cc9386ae27a7e168d17d9ea32b67e53003430de851", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -12021,7 +11675,6 @@ "category_label": "Media & Streaming", "template": "apps/webgrabplus.json", "template_status": "generated-unvalidated", - "content_hash": "d95c683641c930199d082b5a4cd0a7f8319fb22c0d7d0094e5f37f7e4709ce70", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -12053,7 +11706,6 @@ "category_label": "Browsers & Web Desktops", "template": "apps/webstation.json", "template_status": "generated-unvalidated", - "content_hash": "98b5ee5bd4e9cfde9a16c956050eb92dc69bc338a2025a274f6247aed82964ae", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -12086,7 +11738,6 @@ "category_label": "Browsers & Web Desktops", "template": "apps/webtop.json", "template_status": "generated-unvalidated", - "content_hash": "aad91f6c615364e5409d09ea3a66cbeba37df33e78431e1466cda87853487253", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -12119,7 +11770,6 @@ "category_label": "Communication & Community", "template": "apps/weixin.json", "template_status": "generated-unvalidated", - "content_hash": "478228945042bca9aebc49d1e8124aac5b71c1f321b7fffe8ad49715b887f857", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -12156,7 +11806,6 @@ "category_label": "AI / Coding & Dev-Tools", "template": "apps/weknora.json", "template_status": "generated-review-required", - "content_hash": "5794e4e038acef62800013adbb93160b04da8e113928ed2ebda7be6518477f9c", "automatic_install_candidate": false, "untranslated_blockers": [ "multi-service-compose", @@ -12202,7 +11851,6 @@ "category_label": "Remote Access & VPN", "template": "apps/wg-easy.json", "template_status": "laboratory-validated", - "content_hash": "1dcc5a50a84c407cb5b9cc5903a7bce1656b1582f2db12494572b5221c37d25a", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -12235,7 +11883,6 @@ "category_label": "Documents & Notes", "template": "apps/wikijs.json", "template_status": "generated-unvalidated", - "content_hash": "a342be87c339c4585e68f30b0d4346b08e9837f023e6510441022fb974381571", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -12267,7 +11914,6 @@ "category_label": "Browsers & Web Desktops", "template": "apps/winegui.json", "template_status": "generated-unvalidated", - "content_hash": "c0c8f281a8251cdf5828cf9f535216c7426ccbcac51c5babcf694b7321478f5a", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -12300,7 +11946,6 @@ "category_label": "Remote Access & VPN", "template": "apps/wireguard.json", "template_status": "laboratory-validated", - "content_hash": "2280c57351bfd17287bf450cea823e6fff3dd6cf99d30dde13b2cd4fcde9845d", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -12333,7 +11978,6 @@ "category_label": "Network & Firewall", "template": "apps/wireshark.json", "template_status": "generated-unvalidated", - "content_hash": "a4301805cc1694ec90fd9ae0bb316e41e93bc736f791d045ea545a775fd15197", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -12365,7 +12009,6 @@ "category_label": "Documents & Notes", "template": "apps/wps-office.json", "template_status": "generated-unvalidated", - "content_hash": "b6bec2aab5f09d42185c54420022a7eb6f1ef59f21a23c0475f2550f53b576b5", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -12398,7 +12041,6 @@ "category_label": "Files & Downloads", "template": "apps/xbackbone.json", "template_status": "generated-unvalidated", - "content_hash": "6255b5f8ca24964edd110dda06e8d597673649f56fd113bfbb6203cfb12e3c52", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -12431,7 +12073,6 @@ "category_label": "Gaming & Leisure", "template": "apps/xemu.json", "template_status": "generated-unvalidated", - "content_hash": "23d26871dccef7cfd4dd1033de8a2e41aad54a5eaf89491a4a0afd0499144ee2", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -12463,7 +12104,6 @@ "category_label": "AI / Coding & Dev-Tools", "template": "apps/yaak.json", "template_status": "generated-unvalidated", - "content_hash": "ed19a7d4fcbcee297a93cca4c257e6bc5f535e19364b8aaa36297913cad18799", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -12496,7 +12136,6 @@ "category_label": "Media & Streaming", "template": "apps/your_spotify.json", "template_status": "generated-unvalidated", - "content_hash": "513c6c87abca5fe6af78b28e5cb1b838c223a130baa070694b205c61177b15e0", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -12529,7 +12168,6 @@ "category_label": "Browsers & Web Desktops", "template": "apps/zen.json", "template_status": "generated-unvalidated", - "content_hash": "72bfa0ed4079c6b8111b293068eb6ffd017a65b80d4528333a2cb0ae0872ec63", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -12562,7 +12200,6 @@ "category_label": "Communication & Community", "template": "apps/znc.json", "template_status": "generated-unvalidated", - "content_hash": "292b65cc31485609de0925f22036da98829f698e98479f8bc7f4477633909808", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -12594,7 +12231,6 @@ "category_label": "Documents & Notes", "template": "apps/zotero.json", "template_status": "generated-unvalidated", - "content_hash": "d37149cffd35aaed02da6f93edf483865ba4df6937bc1ad9fa6ee24361e27d96", "automatic_install_candidate": true, "untranslated_blockers": [], "requires_privileged_lxc": false, @@ -12631,7 +12267,6 @@ "category_label": "Network & Firewall", "template": "apps/ztnet.json", "template_status": "generated-review-required", - "content_hash": "4b2870e8eef8ae83f029495f4ab13755ec7aab1bad589cecc8d4bc02c4d2d891", "automatic_install_candidate": false, "untranslated_blockers": [ "multi-service-compose", diff --git a/oci/remote/install_oci.sh b/oci/remote/install_oci.sh index 580ac247..02086f64 100755 --- a/oci/remote/install_oci.sh +++ b/oci/remote/install_oci.sh @@ -20,6 +20,28 @@ die() { exit 1 } +container_is_running() { + local status attempt + for (( attempt=1; attempt<=3; attempt++ )); do + if status=$(pct status "$VMID" 2>/dev/null); then + case "$status" in + 'status: running') return 0 ;; + 'status: stopped') return 1 ;; + esac + fi + # A failed pct probe must not be mistaken for a stopped container. + oci_log "pct status could not confirm CT $VMID (attempt $attempt); checking lxc-info" + if status=$(lxc-info -n "$VMID" -sH 2>/dev/null); then + case "$status" in + RUNNING) return 0 ;; + STOPPED) return 1 ;; + esac + fi + (( attempt < 3 )) && sleep 1 + done + return 1 +} + mount_ct_rootfs() { oci_quiet pct mount "$VMID" || die "$(translate "Could not mount the container filesystem:") CT $VMID" } @@ -107,7 +129,7 @@ check_native_device_permissions() { msg_info "$(translate "Checking the device permissions for the application user...")" oci_log "Checking device access as abc (this is not a codec test)." for (( attempt=0; attempt<60; attempt++ )); do - pct status "$VMID" | grep -q 'status: running' \ + container_is_running \ || die "$(translate "The container stopped before the GPU permissions were verified:") CT $VMID" if pct exec "$VMID" -- s6-setuidgid abc sh -c \ 'for path do test -r "$path" && test -w "$path" || exit 1; done' \ @@ -1792,7 +1814,7 @@ if [[ $START_AFTER == 1 && $HAS_STARTUP_HEALTHCHECK == 1 ]]; then oci_log "Waiting for the service: $HC_URL" msg_info "$(translate "Waiting for the application to respond...")" while (( HC_ELAPSED < HC_TIMEOUT )); do - if [[ $(pct status "$VMID" 2>/dev/null || true) != "status: running" ]]; then + if ! container_is_running; then oci_log "The container stopped during its first start. Last console messages:" [[ -s $RUNTIME_CONSOLE_LOG ]] && tr -d '\r' <"$RUNTIME_CONSOLE_LOG" | tail -n 100 >>"${OCI_LOG:-/dev/stderr}" die "$(translate "The container stopped before the application responded:") CT $VMID" @@ -1838,7 +1860,7 @@ if [[ $START_AFTER == 1 && $HAS_RUNNING_CHECK == 1 ]]; then msg_info "$(translate "Checking that the container keeps running...")" RC_START=$(date +%s) while (( $(date +%s) - RC_START < RC_STABILITY )); do - if [[ $(pct status "$VMID" 2>/dev/null || true) != "status: running" ]]; then + if ! container_is_running; then oci_log "The container stopped after starting. Last console messages:" [[ -s $RUNTIME_CONSOLE_LOG ]] && tr -d '\r' <"$RUNTIME_CONSOLE_LOG" | tail -n 100 >>"${OCI_LOG:-/dev/stderr}" die "$(translate "The container stopped after starting:") CT $VMID" diff --git a/oci/remote/oci_description.py b/oci/remote/oci_description.py index 00251708..87bf6922 100644 --- a/oci/remote/oci_description.py +++ b/oci/remote/oci_description.py @@ -60,11 +60,7 @@ def render(template, digest, instance_id, ip=''): source = template.get('source') or {} image_url = (source.get('image_repository_url') or image_page(reference) or ui.get('repository') or source.get('repository')) - resources = [('Image', image_url), ('App', ui.get('website')), - ('App docs', ui.get('documentation'))] - repository = ui.get('repository') or source.get('repository') - if safe_url(repository) and repository != image_url: - resources.append(('Repository', repository)) + resources = [('Image', image_url), ('App', ui.get('website'))] resources = [link(label, url) for label, url in resources] resources = [item for item in resources if item] try: @@ -88,7 +84,7 @@ def render(template, digest, instance_id, ip=''): if not isinstance(path, str) or not path.startswith('/'): path = '/' url = f'{scheme}://{address}:{port}{path}' - item = f'{link(str(endpoint.get("label") or "Web UI"), url)}: {link(url, url)}' + item = f'🌐 {link(str(endpoint.get("label") or "Web UI"), url)}: {link(url, url)}' if item: access.append(item) badges = ( diff --git a/oci/remote/oci_instance_reconcile.py b/oci/remote/oci_instance_reconcile.py index d5efb8ef..b6567bb3 100644 --- a/oci/remote/oci_instance_reconcile.py +++ b/oci/remote/oci_instance_reconcile.py @@ -97,6 +97,10 @@ def propose(record, config): before = parse_config(record['observed']['config'].encode()) current = parse_config(config) changed = sorted(key for key in before.keys() | current.keys() if before.get(key) != current.get(key)) + # Notes do not describe a mount, device or runtime setting. The OCI marker + # was checked above, so a presentation-only change needs no adoption. + if 'description' in changed: + changed.remove('description') new_keys = [key for key in changed if key not in before and re.fullmatch(r'(mp|dev)[0-9]+', key)] unsupported = [key for key in changed if key not in new_keys and key not in transaction.ADOPTABLE] if unsupported: diff --git a/oci/remote/oci_instance_transaction.py b/oci/remote/oci_instance_transaction.py index fbeace18..e3aa6b3d 100644 --- a/oci/remote/oci_instance_transaction.py +++ b/oci/remote/oci_instance_transaction.py @@ -24,6 +24,7 @@ import stat import sys import time import uuid +from urllib.parse import unquote import oci_instances as instances from oci_installation_state import image_from_archive, parse_config, private_directory, sha @@ -204,6 +205,23 @@ def recovery_hint(after_recovery=False): msg_warn(translate('The operation stopped halfway. Choose "Recover" for this container in the OCI management menu to restore the previous installation.')) +def recover_untouched(root, journal): + """Close an operation that failed before the container was changed: + start the container again and restore its record, so nothing is left to + recover by hand. Returns whether it did.""" + try: + state = json.loads(Path(journal).read_text()) + if state.get('coordinated') or state.get('phase') in TERMINAL: + return False + if run('pct', 'config', str(state['vmid'])).decode() != state['before_config']: + return False + recover(root, Path(journal)) + return True + except (OSError, ValueError, KeyError, RuntimeError, subprocess.SubprocessError) as error: + log(f'automatic recovery skipped: {error}') + return False + + def fit(text): """A step line that is rewritten in place must not wrap.""" width = max(shutil.get_terminal_size((80, 24)).columns - 8, 30) @@ -211,7 +229,11 @@ def fit(text): def run(*args): - log('$ ' + shlex.join(str(arg) for arg in args)) + private_description = args[:2] == ('pct', 'set') and '--description' in args + shown = [str(arg) for arg in args] + if private_description: + shown[shown.index('--description') + 1] = '[notes redacted]' + log('$ ' + shlex.join(shown)) # OCI extraction enters an unprivileged user namespace; PVE's newly created # traversal directories must not inherit a caller's restrictive umask. pve_creation = (args[:2] in (('pct', 'create'), ('pct', 'restore')) @@ -224,12 +246,37 @@ def run(*args): raise if result.returncode: log(f' exit {result.returncode}') - log_output(None if tuple(args[:2]) in DATA_COMMANDS else result.stdout, result.stderr) + log_output(None if private_description or tuple(args[:2]) in DATA_COMMANDS else result.stdout, + None if private_description else result.stderr) if result.returncode: raise RuntimeError(f"{args[0]} {translate('failed with exit code')} {result.returncode}") return result.stdout +def verified_backup(vmid, directory, compression, unidentified, show=False): + """A stop-mode vzdump of vmid in directory that passes its integrity + check. An archive that fails the check is written once more before the + operation gives up.""" + suffix = 'zst' if compression == 'zstd' else 'gz' + for attempt in (1, 2): + run('vzdump', str(vmid), '--mode', 'stop', '--compress', compression, + '--dumpdir', str(directory), '--tmpdir', '/var/tmp') + backups = list(directory.glob(f'vzdump-lxc-*.tar.{suffix}')) + if len(backups) != 1: + raise ValueError(unidentified) + try: + run('zstd' if compression == 'zstd' else 'gzip', '-t', str(backups[0])) + return backups[0] + except RuntimeError: + if attempt == 2: + raise + log('backup attempt 1/2 failed its integrity check') + for damaged in directory.glob('vzdump-lxc-*'): + damaged.unlink() + if show: + msg_warn(translate('The backup did not pass its integrity check; creating it again...')) + + def filehash(path): value = hashlib.sha256() with Path(path).open('rb') as source: @@ -322,6 +369,10 @@ def external_changes(record, config, adopt=True): return {} before, now = parse_config(record['observed']['config'].encode()), parse_config(config) changed = sorted(key for key in before.keys() | now.keys() if before.get(key) != now.get(key)) + if ('description' in changed + and instances.identity(record['observed']['config'].encode()) == record['installation_id'] + and instances.identity(config) == record['installation_id']): + changed.remove('description') cores_key = 'cpulimit' if 'cpulimit' in before and 'cores' not in before else 'cores' values = {} for key in changed if adopt else (): @@ -682,6 +733,22 @@ def restore_firewall(vmid, state): Path(f'/etc/pve/firewall/{vmid}.fw').write_text(saved) +def original_description(state): + """Return the user's original Notes, including text added outside ProxMenux.""" + description = state.get('original_description') + if description is None: + # Journals created before this safeguard only have pct's escaped config. + description = unquote(parse_config(state['before_config'].encode()).get('description', '')) + if not isinstance(description, str) or instances.identity( + json.dumps({'description': description}).encode()) != state['record']['installation_id']: + raise ValueError(translate('The container identity changed; nothing was adopted')) + return description + + +def restore_description(vmid, state): + run('pct', 'set', str(vmid), '--description', original_description(state)) + + def release_stage(state): """After a commit the holder CT only keeps its own rootfs: every parked volume went back to the application. Anything still attached keeps it.""" @@ -791,6 +858,7 @@ def apply(root, vmid, archive, operation, proposal=None, registry_digest=None, i candidate = candidate_contract(record, operation, proposal) before = run('pct', 'config', str(vmid)) cfg, actual, mac = preflight(record, candidate, before, coordinated) + description = original_description({'record': record, 'before_config': before.decode()}) original_sources, desired_sources = freeze_host_sources(record, candidate, acknowledge_external_data) original_gpu = gpu_devices.planned(record['deployment']) desired_gpu = gpu_devices.planned(candidate['deployment']) @@ -823,6 +891,7 @@ def apply(root, vmid, archive, operation, proposal=None, registry_digest=None, i for p, m in actual.items() if p in required and not m['volume'].startswith('/')]) state = {'schema_version': 1, 'id': directory.name, 'vmid': vmid, 'operation': operation, 'record': record, 'candidate_contract': candidate, 'before_config': before.decode(), + 'original_description': description, 'archive': str(archive), 'archive_sha256': filehash(archive), 'registry_digest': registry_digest or image['manifest_digest'], 'runtime_template': candidate['template'], 'runtime_deployment': runtime_deployment, @@ -867,14 +936,9 @@ def apply(root, vmid, archive, operation, proposal=None, registry_digest=None, i else: backup_dir = directory / 'backup' private_directory(backup_dir) - run('vzdump', str(vmid), '--mode', 'stop', '--compress', backup_compression, - '--dumpdir', str(backup_dir), '--tmpdir', '/var/tmp') - suffix = 'zst' if backup_compression == 'zstd' else 'gz' - backups = list(backup_dir.glob(f'vzdump-lxc-*.tar.{suffix}')) - if len(backups) != 1: - raise ValueError(translate('The backup could not be identified; the image is not replaced')) - run('zstd' if backup_compression == 'zstd' else 'gzip', '-t', str(backups[0])) - state.update(backup=str(backups[0]), backup_sha256=filehash(backups[0])) + backup = verified_backup(vmid, backup_dir, backup_compression, + translate('The backup could not be identified; the image is not replaced'), show) + state.update(backup=str(backup), backup_sha256=filehash(backup)) checkpoint(journal, state, 'backup-ready') if show: msg_ok(translate('Backup created')) @@ -920,6 +984,7 @@ def apply(root, vmid, archive, operation, proposal=None, registry_digest=None, i if show: progress = translate('Installing the new image:') if update else translate('Recreating the container:') install_candidate(root, journal, state, progress) + restore_description(vmid, state) restore_firewall(vmid, state) if coordinated: for key, value in state['preserved_stack_config'].items(): @@ -1239,7 +1304,8 @@ def main(): except (OSError, ValueError, KeyError, RuntimeError, subprocess.SubprocessError) as error: with contextlib.redirect_stdout(output): report_error(error, f'oci-{args.action}-{args.vmid}') - if pending_journal(): + journal = pending_journal() + if journal and (args.action == 'recover' or not recover_untouched(args.root, journal)): recovery_hint(after_recovery=args.action == 'recover') return 1 diff --git a/oci/remote/oci_instances.py b/oci/remote/oci_instances.py index bfc733e4..9a895b84 100644 --- a/oci/remote/oci_instances.py +++ b/oci/remote/oci_instances.py @@ -223,6 +223,24 @@ def identity(config): return match.group(1) if match else None +def same_config_except_notes(record, config): + """Accept a presentation-only note edit, never a changed OCI identity or LXC setting.""" + previous = record['observed']['config'].encode() + expected = record['installation_id'] + if identity(previous) != expected or identity(config) != expected: + return False + + def without_notes(value): + lines = value.splitlines(keepends=True) + notes = [line for line in lines if line.startswith(b'description: ')] + if len(notes) != 1: + return None + return b''.join(line for line in lines if not line.startswith(b'description: ')) + + original = without_notes(previous) + return original is not None and original == without_notes(config) + + def save_assembly(root, primary_id, template, deployment, members): path = location(root, primary_id).parent / 'stack-assembly.json' if path.exists() or path.is_symlink(): diff --git a/oci/remote/oci_nvidia_refresh.py b/oci/remote/oci_nvidia_refresh.py index b9f6afa0..3c689ea4 100644 --- a/oci/remote/oci_nvidia_refresh.py +++ b/oci/remote/oci_nvidia_refresh.py @@ -76,8 +76,7 @@ def refresh(root, vmid, apply=False): if not nv.enabled(record['deployment']): raise ValueError(translate('The instance does not use NVIDIA')) config = instances.command('pct', 'config', str(vmid)) - if (instances.identity(config) != record['installation_id'] - or instances.sha(config) != record['observed']['config_sha256']): + if not instances.same_config_except_notes(record, config): raise ValueError(translate('The container identity or configuration changed')) previous = record['observed']['gpu_devices'][nv.KEY] plan = nv.refresh_plan(config, previous) diff --git a/oci/remote/oci_stack_native.py b/oci/remote/oci_stack_native.py index 9afbc4bc..ce77cc38 100644 --- a/oci/remote/oci_stack_native.py +++ b/oci/remote/oci_stack_native.py @@ -191,7 +191,7 @@ class NativeAdapter: config = instances.command('pct', 'config', str(vmid)) if instances.identity(config) != record['installation_id']: raise ValueError(translate('The identity of a member was replaced')) - if (not self.journal.exists() or not self.state().get('replacement_intent')) and sha(config) != record['observed']['config_sha256']: + if (not self.journal.exists() or not self.state().get('replacement_intent')) and not instances.same_config_except_notes(record, config): raise ValueError(translate('A member configuration changed during the preparation')) else: if Path('/etc/pve/lxc/%s.conf' % vmid).exists(): @@ -321,13 +321,9 @@ class NativeAdapter: self.validate(self.plan) directory = self.journal.parent / ('backup-%s' % vmid) private_directory(directory) - member_tx.run('vzdump', str(vmid), '--mode', 'stop', '--compress', 'zstd', - '--dumpdir', str(directory), '--tmpdir', '/var/tmp') - backups = list(directory.glob('vzdump-lxc-*.tar.zst')) - if len(backups) != 1: - raise ValueError(translate('The backup of a member could not be identified')) - member_tx.run('zstd', '-t', str(backups[0])) - return {'archive': str(backups[0]), 'sha256': member_tx.filehash(backups[0])} + archive = member_tx.verified_backup(vmid, directory, 'zstd', + translate('The backup of a member could not be identified')) + return {'archive': str(archive), 'sha256': member_tx.filehash(archive)} def verify_backups(self, backups): for backup in backups.values(): diff --git a/oci/remote/oci_update_current.py b/oci/remote/oci_update_current.py index 5fa9aa61..dd6b5b50 100644 --- a/oci/remote/oci_update_current.py +++ b/oci/remote/oci_update_current.py @@ -192,7 +192,8 @@ def main(): return 1 except (OSError, ValueError, KeyError, RuntimeError, subprocess.SubprocessError) as error: transaction.report_error(error, f'update-{args.vmid}') - if transaction.pending_journal(): + journal = transaction.pending_journal() + if journal and not transaction.recover_untouched(instances.ROOT, journal): transaction.recovery_hint() return 1 diff --git a/oci/src/proxmenux_oci/arr_suite.py b/oci/src/proxmenux_oci/arr_suite.py index e545346f..8938dd87 100644 --- a/oci/src/proxmenux_oci/arr_suite.py +++ b/oci/src/proxmenux_oci/arr_suite.py @@ -115,6 +115,8 @@ def build_suite(template, ui, mode='advanced'): if mode != DEFAULT_MODE: from .custom_mounts import ask_stack_custom_mounts ask_stack_custom_mounts(ui, services, storage) + from .extra_devices import ask_stack_extra_devices + ask_stack_extra_devices(ui, services) return {'deployment_kind':'generic-multi-lxc-stack','suite_arr':True,'lifecycle_mode':'independent','stack_name':name, 'base_vmid':int(base) if base else None,'services':services,'shared_media':shared,'media_player':player, 'completion_notes':[ diff --git a/oci/src/proxmenux_oci/catalog.py b/oci/src/proxmenux_oci/catalog.py index fb6b48d9..4b025ad2 100644 --- a/oci/src/proxmenux_oci/catalog.py +++ b/oci/src/proxmenux_oci/catalog.py @@ -1,7 +1,6 @@ from __future__ import annotations import json -import hashlib from concurrent.futures import ThreadPoolExecutor, as_completed from datetime import datetime, timezone from pathlib import Path @@ -176,7 +175,6 @@ class Catalog: ), "template": f"apps/{repo.app_id}.json" if repo.app_id in existing else None, "template_status": existing.get(repo.app_id), - "content_hash": self._template_hash(repo.app_id) if repo.app_id in existing else None, } for repo, summary in discovered ] @@ -291,7 +289,6 @@ class Catalog: "category_label": metadata.get("category_label"), "template": f"apps/{catalog_id}.json" if catalog_id in existing else None, "template_status": existing.get(catalog_id), - "content_hash": self._template_hash(catalog_id) if catalog_id in existing else None, } ) @@ -515,6 +512,24 @@ class Catalog: except (OSError, json.JSONDecodeError, KeyError, TypeError): return + def _preserve_optional_environment(self, app_id: str, template: dict[str, Any]) -> None: + existing_path = self.apps_dir / f"{app_id}.json" + if not existing_path.is_file(): + return + try: + existing = json.loads(existing_path.read_text(encoding="utf-8")) + if (existing["container_contract"]["image"]["repository"] != + template["container_contract"]["image"]["repository"]): + return + optional = {item["name"]: item for item in existing["container_contract"]["environment"] + if item.get("required") is False} + for item in template["container_contract"]["environment"]: + previous = optional.get(item["name"]) + if previous and previous.get("example") == item.get("example"): + item["required"] = False + except (OSError, json.JSONDecodeError, KeyError, TypeError): + return + def generate(self, app_id: str) -> tuple[Path, dict[str, Any]]: item = self.find_item(app_id) provider = item.get("provider", "linuxserver.io") @@ -541,6 +556,7 @@ class Catalog: raise ConversionError(f"Proveedor no soportado: {provider}") catalog_id = item["id"] self._apply_overlay(catalog_id, template) + self._preserve_optional_environment(catalog_id, template) self._preserve_registry_state(catalog_id, template) self.validate(template) self.apps_dir.mkdir(parents=True, exist_ok=True) @@ -593,6 +609,7 @@ class Catalog: else: raise ConversionError(f"Proveedor no soportado: {item_provider}") self._apply_overlay(item["id"], template) + self._preserve_optional_environment(item["id"], template) self._preserve_registry_state(item["id"], template) self.validate(template) return item["id"], template @@ -661,7 +678,6 @@ class Catalog: item["architectures"] = supported_architectures( template["catalog_ui"]["architectures"] ) - item["content_hash"] = self._template_hash(app_id) self._write_json(self.index_path, index) generated.sort() failed.sort(key=lambda item: item["id"]) @@ -759,7 +775,6 @@ class Catalog: "curated_path": str(path.relative_to(self.root)), "template": f"apps/{app_id}.json" if app_id in existing else None, "template_status": existing.get(app_id), - "content_hash": self._template_hash(app_id) if app_id in existing else None, } ) return result @@ -768,7 +783,11 @@ class Catalog: path = self.overlays_dir / f"{app_id}.json" if path.exists(): overlay = json.loads(path.read_text(encoding="utf-8")) + environment_overrides = overlay.pop("environment_overrides", {}) self._deep_merge(template, overlay) + for item in template.get("container_contract", {}).get("environment", []): + if item.get("name") in environment_overrides: + item.update(environment_overrides[item["name"]]) from .stack import apply_stack_support apply_stack_support(template) from .gpu import apply_gpu_contract @@ -809,7 +828,6 @@ class Catalog: "untranslated_blockers": template["compatibility"][ "untranslated_blockers" ], - "content_hash": self._template_hash(app_id), } ) if item.get("template_family") == "curated-profile": @@ -826,6 +844,3 @@ class Catalog: temporary = path.with_suffix(path.suffix + ".tmp") temporary.write_text(json.dumps(payload, ensure_ascii=True, indent=2) + "\n", encoding="utf-8") temporary.replace(path) - - def _template_hash(self, app_id: str) -> str: - return hashlib.sha256((self.apps_dir / f"{app_id}.json").read_bytes()).hexdigest() diff --git a/oci/src/proxmenux_oci/cli.py b/oci/src/proxmenux_oci/cli.py index e0d79065..15ffaaf2 100644 --- a/oci/src/proxmenux_oci/cli.py +++ b/oci/src/proxmenux_oci/cli.py @@ -366,10 +366,24 @@ def _install(catalog: Catalog, ui, item: dict[str, Any], mode: str) -> None: def install_template(ui, template: dict[str, Any], identifier: str, mode: str) -> dict[str, Any] | None: """Configures and installs one template, from the catalog or written from a definition the user gave.""" - deployment = build_deployment(template, ui, mode) - if not ui.review(_deployment_summary_text(template, deployment), translate("Installation summary"), - question=translate("Install with this configuration?")): + from .ui import BacktrackUI, RestartWizard + wizard = BacktrackUI(ui) + try: + while True: + candidate = copy.deepcopy(template) + try: + deployment = build_deployment(candidate, wizard, mode) + approved = wizard.review(_deployment_summary_text(candidate, deployment), + translate("Installation summary"), + question=translate("Install with this configuration?")) + break + except RestartWizard: + wizard.restart() + finally: + wizard.close() + if not approved: return None + template = candidate console.show_logo() console.msg_title(f"{source_text(template['catalog_ui']['title']) or identifier} · {APP_TITLE}") try: diff --git a/oci/src/proxmenux_oci/extra_devices.py b/oci/src/proxmenux_oci/extra_devices.py new file mode 100644 index 00000000..2afbae40 --- /dev/null +++ b/oci/src/proxmenux_oci/extra_devices.py @@ -0,0 +1,104 @@ +"""Explicit native LXC devices beyond an application's curated profile.""" +import copy +import re +from pathlib import Path + +from .i18n import translate +from .ui import UserCancelled + + +GPU_NODE = re.compile(r'/dev/dri/(?:renderD|card)[0-9]+|/dev/kfd') +USB_NODE = re.compile(r'/dev/(?:ttyUSB[0-9]+|ttyACM[0-9]+|bus/usb/[0-9]{3}/[0-9]{3})') +CORAL_NODE = re.compile(r'/dev/apex_[0-9]+') + + +def ask_extra_devices(ui, devices, unprivileged, allow_coral=False): + """Keep manual attachments separate from image-owned GPU profiles.""" + result = list(devices) + while ui.confirm(translate('Add another GPU or USB device manually?'), False): + options = [ + ('gpu', translate('Intel/AMD DRM node (device only)')), + ('nvidia', translate('NVIDIA runtime (device and host driver libraries)')), + ('usb', translate('USB or serial device node')), + ] + if allow_coral: + options.append(('coral', translate('Coral PCIe/M.2 device node'))) + kind = ui.choose(translate('Device to attach'), options) + if kind is None: + raise UserCancelled(translate('Device configuration cancelled')) + if kind == 'nvidia': + if any(item.get('kind') == 'nvidia-runtime' for item in result): + raise ValueError(translate('NVIDIA is already attached')) + if not ui.confirm(translate('Passing NVIDIA does not enable acceleration inside the application. ' + 'The host needs NVIDIA Container Toolkit and a compatible image. Continue?'), False): + continue + result.append({'id': 'manual-nvidia', 'kind': 'nvidia-runtime', + 'device_selection': 'all-requested-by-compose', + 'runtime_mode': 'dynamic' if unprivileged else 'static'}) + continue + if kind == 'gpu': + candidates = sorted(str(path) for path in Path('/dev/dri').glob('renderD*')) + default = candidates[0] if candidates else '/dev/dri/renderD128' + path = ui.ask(translate('Host DRM node (e.g. /dev/dri/renderD128)'), default) + valid = GPU_NODE.fullmatch(path) + elif kind == 'usb': + path = ui.ask(translate('Host USB node (e.g. /dev/ttyACM0 or /dev/bus/usb/003/004)'), + '/dev/ttyACM0') + valid = USB_NODE.fullmatch(path) + else: + path = ui.ask(translate('Host Coral node (e.g. /dev/apex_0)'), '/dev/apex_0') + valid = CORAL_NODE.fullmatch(path) + if not valid: + raise ValueError(translate('Choose a specific supported GPU or USB node')) + if any(item.get('host_path') == path for item in result): + raise ValueError(translate('This device is already attached')) + if kind == 'usb' and '/bus/usb/' in path: + ui.info(translate('USB bus numbers can change after reconnecting or rebooting.')) + result.append({'id': 'manual-' + path.removeprefix('/dev/').replace('/', '-'), + 'kind': 'character-device', 'host_path': path, 'container_path': path, + 'mode': '0660', 'deny_write': False, + 'gid_strategy': 'host-device-gid'}) + return result + + +def device_permissions(image, devices, existing=None): + if existing: + return existing + repository = image.split('@', 1)[0].rsplit(':', 1)[0] + if repository.startswith(('lscr.io/linuxserver/', 'linuxserver/', 'docker.io/linuxserver/')) and any( + item.get('kind') == 'character-device' for item in devices): + return {'strategy': 'linuxserver-native-init', 'service_user': 'abc', + 'environment': 'ATTACHED_DEVICES_PERMS', + 'paths': 'all-resolved-selected-character-devices'} + return None + + +def ask_stack_extra_devices(ui, services): + """Ask once per device, then select the stack members that need it.""" + devices = ask_extra_devices(ui, [], True) + if not devices: + return + options = [(service['name'], service['name']) for service in services] + for device in devices: + selected = ui.checklist( + f"{translate('Containers that will receive this device')}: " + f"{device.get('host_path', 'NVIDIA')}", options, + [service['name'] for service in services if service.get('main')] or [options[-1][0]]) + if not selected or set(selected) - {name for name, _ in options}: + raise ValueError(translate('Select at least one stack container')) + for service in services: + if service['name'] not in selected: + continue + plan = service['deployment'] + existing = plan.setdefault('devices', []) + if any(item.get('host_path') == device.get('host_path') if device.get('host_path') + else item.get('kind') == 'nvidia-runtime' for item in existing): + raise ValueError(translate('This device is already attached')) + member_device = copy.deepcopy(device) + if member_device['kind'] == 'nvidia-runtime': + member_device['runtime_mode'] = ( + 'dynamic' if plan.get('security', {}).get('unprivileged', True) else 'static') + existing.append(member_device) + image = service['template']['container_contract']['image']['reference'] + plan['device_permissions'] = device_permissions( + image, existing, plan.get('device_permissions')) diff --git a/oci/src/proxmenux_oci/installer.py b/oci/src/proxmenux_oci/installer.py index 8a4cbea7..1aea122e 100644 --- a/oci/src/proxmenux_oci/installer.py +++ b/oci/src/proxmenux_oci/installer.py @@ -20,6 +20,7 @@ from . import network as access from .i18n import translate from .ui import DialogUI, TerminalUI, UserCancelled from .custom_mounts import ask_custom_mounts +from .extra_devices import device_permissions class InstallError(RuntimeError): @@ -379,6 +380,15 @@ def build_deployment( devices, selected_hardware_profile, post_start_configurations, environment = configure_acceleration( installer_profile, environment, unprivileged, ui, mode) + if advanced: + from .extra_devices import ask_extra_devices + reference = template['container_contract']['image']['reference'] + repository = reference.split('@', 1)[0].rsplit(':', 1)[0] + devices = ask_extra_devices( + ui, devices, unprivileged, + allow_coral=repository in ('ghcr.io/blakeblackshear/frigate', + 'codeproject/ai-server', 'docker.io/codeproject/ai-server')) + from .gpu import apply_profile_image apply_profile_image(template, selected_hardware_profile) @@ -455,7 +465,9 @@ def build_deployment( "tmpfs_mounts": tmpfs_mounts, "devices": devices, "hardware_profile": selected_hardware_profile, - "device_permissions": installer_profile.get("device_permissions") if devices else None, + "device_permissions": (device_permissions(template['container_contract']['image']['reference'], + devices, installer_profile.get('device_permissions')) + if devices else None), "post_start_configurations": post_start_configurations, "extra_hosts": installer_profile.get("extra_hosts", []), } diff --git a/oci/src/proxmenux_oci/management.py b/oci/src/proxmenux_oci/management.py index 735e6cd8..c0656694 100644 --- a/oci/src/proxmenux_oci/management.py +++ b/oci/src/proxmenux_oci/management.py @@ -216,10 +216,21 @@ def manage_instance(project, ui, row, action=None, lifecycle_args=()): if action == 'recreate': from .recreation import edit_recreation from .cli import _deployment_summary_text - proposal = edit_recreation(record, ui) - if not ui.review(_deployment_summary_text(proposal['candidate']['template'], - proposal['candidate']['deployment']), translate('Recreate OCI'), - question=translate('Recreate with these options?'), default=True): + from .ui import BacktrackUI, RestartWizard + wizard = BacktrackUI(ui) + try: + while True: + try: + proposal = edit_recreation(record, wizard) + approved = wizard.review(_deployment_summary_text(proposal['candidate']['template'], + proposal['candidate']['deployment']), translate('Recreate OCI'), + question=translate('Recreate with these options?'), default=True) + break + except RestartWizard: + wizard.restart() + finally: + wizard.close() + if not approved: return False elif not ui.review(translate('The current image of the saved channel will be checked and downloaded. Resources, paths and GPU are kept. The CT is stopped during the replacement and a native backup is created first.'), translate('Update OCI'), question=translate('Update now?'), default=True): diff --git a/oci/src/proxmenux_oci/recreation.py b/oci/src/proxmenux_oci/recreation.py index 61a5a337..880ef2a3 100644 --- a/oci/src/proxmenux_oci/recreation.py +++ b/oci/src/proxmenux_oci/recreation.py @@ -104,31 +104,6 @@ def edit_environment(deployment, ui): environment.append(item) -def edit_peripherals(deployment, ui, allow_coral=False): - devices = deployment.setdefault('devices', []) - label = 'Coral/USB' if allow_coral else 'USB' - example = '/dev/apex_0, ' if allow_coral else '' - while ui.confirm(f"{translate('Add or change a device')} ({label})?", False): - path = ui.ask(f"{translate('Host device node')} ({translate('e.g.')} {example}/dev/ttyACM0, /dev/bus/usb/003/004)") - if not re.fullmatch(r'/dev/(apex_[0-9]+|ttyUSB[0-9]+|ttyACM[0-9]+|bus/usb/[0-9]{3}/[0-9]{3})', path): - raise ValueError(translate('Select a specific Coral or USB node, not the whole /dev')) - if path.startswith('/dev/apex_') and not allow_coral: - raise ValueError(translate('Coral is only offered for Frigate and CodeProject.AI')) - if '/bus/usb/' in path: - ui.info(translate('The USB number can change after reconnecting or rebooting. This profile does not remap it automatically or handle Coral USB re-enumeration. Do not share a dongle already used by another service.')) - old = next((d for d in devices if d.get('host_path') == path), None) - mode = ui.ask(translate('Node octal permissions (e.g. 0660)'), (old or {}).get('mode', '0660')) - if not re.fullmatch(r'0?[0-7]{3}', mode): - raise ValueError(translate('Invalid octal permissions')) - item = dict(old or {}, id=(old or {}).get('id', 'peripheral-' + path.removeprefix('/dev/').replace('/', '-')), - kind='character-device', host_path=path, container_path=path, - mode=mode, gid_strategy='host-device-gid', deny_write=False) - if old: - devices[devices.index(old)] = item - else: - devices.append(item) - - def edit_recreation(record, ui): candidate = copy.deepcopy(record) refresh_template(candidate, ui) @@ -136,32 +111,21 @@ def edit_recreation(record, ui): resources = deployment['resources'] resources['cores'] = positive_integer(ui, translate('Cores'), resources['cores']) resources['memory_mb'] = positive_integer(ui, translate('RAM in MiB'), resources['memory_mb'], 128) - while ui.confirm(translate('Add a custom data path?'), False): - target = absolute_path(ui.ask(translate('Path inside the container'), '/data/custom')) - existing = [m['container_path'].rstrip('/') for m in deployment['mounts']] - if any(target == p or target.startswith(p + '/') or p.startswith(target + '/') for p in existing): - raise ValueError(translate('The path overlaps an existing mount')) - mode = ui.choose(translate('Persistence for the new path'), - [('managed-volume', translate('Container volume (included in backups)')), - ('host-bind', translate('Host directory (not included in Proxmox backups)'))], - 'managed-volume') - if mode is None: - raise UserCancelled(translate('Custom path cancelled')) - mount = {'type': mode, 'container_path': target, 'read_only': False} - if mode == 'managed-volume': - mount.update(source=ui.ask(translate('Proxmox storage for the volume'), deployment['rootfs']['storage']), - size_gb=positive_integer(ui, translate('Volume size in GB'), 4), backup=True) - else: - mount.update(source=absolute_path(ui.ask(translate('Host directory'), - '/mnt/oci-shared/custom')), size_gb=None, backup=False, - create_if_missing=True) - deployment['mounts'].append(mount) + from .custom_mounts import ask_custom_mounts + deployment['mounts'] = ask_custom_mounts( + ui, deployment['mounts'], deployment['rootfs']['storage']) if ui.confirm(translate('Change the access network?'), False): edit_network(deployment, ui) edit_acceleration(candidate, ui) + from .extra_devices import ask_extra_devices reference = candidate.get('template', {}).get('container_contract', {}).get('image', {}).get('reference', '') repository = reference.split('@')[0].rsplit(':', 1)[0] - edit_peripherals(deployment, ui, repository in ('ghcr.io/blakeblackshear/frigate', 'codeproject/ai-server', 'docker.io/codeproject/ai-server')) + deployment['devices'] = ask_extra_devices( + ui, deployment.get('devices', []), deployment.get('security', {}).get('unprivileged', True), + allow_coral=repository in ('ghcr.io/blakeblackshear/frigate', 'codeproject/ai-server', 'docker.io/codeproject/ai-server')) + from .extra_devices import device_permissions + deployment['device_permissions'] = device_permissions( + reference, deployment['devices'], deployment.get('device_permissions')) edit_environment(deployment, ui) proposal = {'operation': 'recreate', 'candidate': candidate} if record.get('observed', {}).get('config_sha256'): @@ -172,14 +136,19 @@ def edit_recreation(record, ui): def refresh_template(candidate, ui): from .catalog import Catalog old = candidate.get('template', {}) - name = old.get('id', '').removeprefix('image-') - if not re.fullmatch(r'[a-z0-9][a-z0-9-]+', name): + template_id = old.get('id', '') + if not re.fullmatch(r'[a-z0-9][a-z0-9-]+', template_id): return root = Path(__file__).resolve().parents[2] - path = root / 'catalog' / 'apps' / (name + '.json') - if not path.is_file() or not old.get('container_contract', {}).get('image'): + if not old.get('container_contract', {}).get('image'): return - latest = Catalog(root).load_template(name, generate_if_missing=False) + catalog = Catalog(root) + matching = [item for item in catalog.load_index()['applications'] + if item.get('template_id') == template_id] + if len(matching) != 1: + return + name = matching[0]['id'] + latest = catalog.compose(name) if latest == old: return if not ui.confirm(translate('Apply the options from the current catalog template? Your data and configuration are kept.'), True): diff --git a/oci/src/proxmenux_oci/stack.py b/oci/src/proxmenux_oci/stack.py index 080cb4e7..8854e853 100644 --- a/oci/src/proxmenux_oci/stack.py +++ b/oci/src/proxmenux_oci/stack.py @@ -355,6 +355,8 @@ def build_stack(template, ui, mode='advanced'): if mode != DEFAULT_MODE: from .custom_mounts import ask_stack_custom_mounts ask_stack_custom_mounts(ui, plans, volumes) + from .extra_devices import ask_stack_extra_devices + ask_stack_extra_devices(ui, plans) return {'deployment_kind':'generic-multi-lxc-stack','stack_name':name,'base_vmid':int(vmid) if vmid else None, 'completion_notes':template.get('proxmox',{}).get('stack_completion_notes',[]), 'rootfs_storage':root,'template_storage':cache,'onboot':onboot,'start_after_create':True, diff --git a/oci/src/proxmenux_oci/ui.py b/oci/src/proxmenux_oci/ui.py index 2774d478..10d71aef 100644 --- a/oci/src/proxmenux_oci/ui.py +++ b/oci/src/proxmenux_oci/ui.py @@ -16,17 +16,96 @@ class UserCancelled(RuntimeError): pass +class BackRequested(RuntimeError): + pass + + +class RestartWizard(RuntimeError): + pass + + +class BacktrackUI: + """Replay prior answers when returning to a previous wizard question.""" + + def __init__(self, base): + self.base = base + self.answers = [] + self.cursor = 0 + self.previous_back_enabled = getattr(base, 'back_enabled', False) + base.back_enabled = True + + def __getattr__(self, name): + return getattr(self.base, name) + + def close(self): + self.base.back_enabled = self.previous_back_enabled + + def restart(self): + self.cursor = 0 + + def _call(self, name, *args, **kwargs): + if self.cursor < len(self.answers): + saved_name, value = self.answers[self.cursor] + if saved_name != name: + self.answers = self.answers[:self.cursor] + else: + self.cursor += 1 + return value + try: + value = getattr(self.base, name)(*args, **kwargs) + except BackRequested: + if self.cursor: + self.answers = self.answers[:self.cursor - 1] + self.cursor = 0 + raise RestartWizard() + raise UserCancelled(translate('Wizard cancelled')) + self.answers.append((name, value)) + self.cursor += 1 + return value + + def ask(self, *args, **kwargs): + return self._call('ask', *args, **kwargs) + + def password(self, *args, **kwargs): + return self._call('password', *args, **kwargs) + + def confirm(self, *args, **kwargs): + return self._call('confirm', *args, **kwargs) + + def choose(self, *args, **kwargs): + return self._call('choose', *args, **kwargs) + + def checklist(self, *args, **kwargs): + return self._call('checklist', *args, **kwargs) + + def detail_menu(self, *args, **kwargs): + return self._call('detail_menu', *args, **kwargs) + + def review(self, *args, **kwargs): + try: + return self.base.review(*args, **kwargs) + except BackRequested: + if self.cursor: + self.answers = self.answers[:self.cursor - 1] + self.cursor = 0 + raise RestartWizard() + raise UserCancelled(translate('Wizard cancelled')) + + APP_TITLE = "OCI manager Apps (beta)" @dataclass class TerminalUI: title: str = APP_TITLE + back_enabled: bool = False def ask(self, text: str, default: str | None = None, required: bool = True) -> str: suffix = f" [{default}]" if default not in (None, "") else "" while True: value = input(f"{text}{suffix}: ").strip() + if self.back_enabled and value == ':back': + raise BackRequested() if value: return value if default is not None: @@ -38,18 +117,25 @@ class TerminalUI: def password(self, text: str, required: bool = True) -> str: while True: value = getpass.getpass(f"{text}: ") + if self.back_enabled and value == ':back': + raise BackRequested() if not value: if not required: return "" print(translate("This value is required.")) continue - if value == getpass.getpass(f"{translate('Repeat to confirm')}: "): + repeated = getpass.getpass(f"{translate('Repeat to confirm')}: ") + if self.back_enabled and repeated == ':back': + raise BackRequested() + if value == repeated: return value print(translate("The values do not match. Enter them again.")) def confirm(self, text: str, default: bool = False) -> bool: suffix = " [Y/n]" if default else " [y/N]" value = input(f"{text}{suffix}: ").strip().casefold() + if self.back_enabled and value == ':back': + raise BackRequested() if not value: return default return value in {"y", "yes", "s", "si"} @@ -93,6 +179,7 @@ class DialogUI: title: str = APP_TITLE backtitle: str = "ProxMenux" + back_enabled: bool = False @staticmethod def available() -> bool: @@ -103,10 +190,16 @@ class DialogUI: if environment.get("TERM", "").casefold() in {"", "dumb", "unknown"}: environment["TERM"] = "xterm-256color" # dialog draws on the terminal and writes the selection to stderr. - return subprocess.run( - ["dialog", "--no-collapse", "--backtitle", self.backtitle, "--title", title or self.title, *widget], + back_widget = ['--extra-button', '--extra-label', 'Volver'] if self.back_enabled and any( + flag in widget for flag in ('--inputbox', '--passwordbox', '--yesno', '--menu', '--checklist')) else [] + result = subprocess.run( + ["dialog", "--no-collapse", "--backtitle", self.backtitle, "--title", title or self.title, + *back_widget, *widget], stdout=None, stderr=subprocess.PIPE, text=True, check=False, env=environment, ) + if result.returncode == 3 and back_widget: + raise BackRequested() + return result @staticmethod def _size(text: str, min_height: int, width: int, extra: int = 6) -> tuple[str, str]: diff --git a/oci/tests/test_instance_reconcile.py b/oci/tests/test_instance_reconcile.py index 27caf9ca..b0fb25a1 100644 --- a/oci/tests/test_instance_reconcile.py +++ b/oci/tests/test_instance_reconcile.py @@ -5,6 +5,7 @@ import sys import tempfile import unittest from unittest.mock import patch +from subprocess import CompletedProcess sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'remote')) @@ -35,6 +36,63 @@ class MountTests(unittest.TestCase): class ProposalTests(unittest.TestCase): + def test_note_only_comparison_keeps_other_lxc_settings_strict(self): + marker = '11111111-1111-1111-1111-111111111111' + before = f'description: Old proxmenux-instance={marker}\ncores: 2\n'.encode() + record = {'installation_id': marker, 'observed': {'config': before.decode()}} + updated = f'description: New proxmenux-instance={marker}\ncores: 2\n'.encode() + self.assertTrue(reconcile.instances.same_config_except_notes(record, updated)) + self.assertFalse(reconcile.instances.same_config_except_notes(record, updated.replace(b'cores: 2', b'cores: 4'))) + self.assertFalse(reconcile.instances.same_config_except_notes(record, b'description: Other instance\ncores: 2\n')) + + def test_replacement_restores_user_notes_verbatim(self): + marker = '11111111-1111-1111-1111-111111111111' + notes = f'

Nota personal: no borrar

' + state = {'record': {'installation_id': marker}, 'original_description': notes} + with patch.object(reconcile.transaction, 'run') as run: + reconcile.transaction.restore_description(200, state) + run.assert_called_once_with('pct', 'set', '200', '--description', notes) + state.pop('original_description') + state['before_config'] = f'description: {notes.replace("%", "%25").replace("<", "%3C").replace(">", "%3E")}\n' + self.assertEqual(reconcile.transaction.original_description(state), notes) + + def test_restoring_notes_does_not_log_their_contents(self): + notes = '

Nota privada del usuario

' + with (patch.object(reconcile.transaction, 'log') as log, + patch.object(reconcile.transaction.subprocess, 'run', + return_value=CompletedProcess([], 0, b'', b'')) as command): + reconcile.transaction.run('pct', 'set', '200', '--description', notes) + command.assert_called_once() + self.assertNotIn(notes, str(log.call_args)) + + def test_notes_only_change_does_not_require_adoption_or_block_update(self): + marker = '11111111-1111-1111-1111-111111111111' + config = f'description: Old notes proxmenux-instance={marker}\n'.encode() + updated = f'description: New notes proxmenux-instance={marker}\n'.encode() + record = { + 'vmid': 200, 'status': 'installed', 'installation_id': marker, + 'deployment': {'mounts': [], 'devices': []}, + 'observed': {'config': config.decode(), 'config_sha256': sha(config)}, + } + self.assertIsNone(reconcile.propose(record, updated)) + self.assertEqual(reconcile.transaction.external_changes(record, updated), {}) + + def test_notes_cannot_hide_identity_or_network_changes(self): + marker = '11111111-1111-1111-1111-111111111111' + config = f'description: Old notes proxmenux-instance={marker}\n'.encode() + record = { + 'vmid': 200, 'status': 'installed', 'installation_id': marker, + 'deployment': {'mounts': [], 'devices': []}, + 'observed': {'config': config.decode(), 'config_sha256': sha(config)}, + } + with self.assertRaises(ValueError): + reconcile.propose(record, b'description: Different instance\n') + changed = f'description: New notes proxmenux-instance={marker}\nnet0: name=eth0,bridge=vmbr1\n'.encode() + with self.assertRaises(ValueError): + reconcile.propose(record, changed) + with self.assertRaises(ValueError): + reconcile.transaction.external_changes(record, changed) + def test_new_volume_requires_confirmation_before_contract_changes(self): config = b'description: proxmenux-instance=11111111-1111-1111-1111-111111111111\n' added = config + b'mp2: local-lvm:vm-200-disk-3,mp=/media,size=8G,backup=1\n' diff --git a/oci/tests/test_oci_description.py b/oci/tests/test_oci_description.py index a4d242a0..7051857e 100644 --- a/oci/tests/test_oci_description.py +++ b/oci/tests/test_oci_description.py @@ -29,10 +29,13 @@ class DescriptionTests(unittest.TestCase): self.assertIn('Image: adguard/adguardhome:latest ', notes) self.assertIn('>Image · ', notes) self.assertIn('>App', notes) + self.assertNotIn('>App docs', notes) + self.assertNotIn('>Repository', notes) self.assertIn('href="http://192.168.0.42:3000/" target="_blank" rel="noopener noreferrer">Setup (first run)', notes) self.assertIn('>http://192.168.0.42:3000/', notes) self.assertIn('href="http://192.168.0.42:80/" target="_blank" rel="noopener noreferrer">Web UI (after setup)', notes) self.assertIn('>http://192.168.0.42:80/', notes) + self.assertEqual(notes.count('🌐 '), 2) def test_missing_ip_does_not_publish_placeholder_links(self): template = json.loads((CATALOG / 'adguard-home.json').read_text()) @@ -40,6 +43,14 @@ class DescriptionTests(unittest.TestCase): self.assertNotIn('http://:3000', notes) self.assertIn('proxmenux-instance=' + INSTANCE, notes) + def test_chromium_notes_keep_only_image_and_app_links(self): + template = json.loads((CATALOG / 'chromium.json').read_text()) + notes = render(template, '', INSTANCE, '192.168.0.37') + self.assertIn('>Image', notes) + self.assertIn('>App', notes) + self.assertNotIn('>App docs', notes) + self.assertNotIn('>Repository', notes) + def test_untrusted_title_is_escaped(self): template = {'id': 'example', 'catalog_ui': {'title': {'en_US': ''}}, 'container_contract': {'image': {'reference': 'example:latest'}}} diff --git a/oci/tests/test_recreation.py b/oci/tests/test_recreation.py new file mode 100644 index 00000000..e1cbbd62 --- /dev/null +++ b/oci/tests/test_recreation.py @@ -0,0 +1,69 @@ +"""Regression tests for catalog options added after an OCI installation.""" +import copy +from pathlib import Path +import sys +import unittest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / 'src')) + +from proxmenux_oci.catalog import Catalog +from proxmenux_oci.recreation import refresh_template + + +class ConfirmingUI: + def confirm(self, _message, _default=False): + return True + + def info(self, _message): + pass + + def ask(self, _message, default=''): + return default + + +class RecreationCatalogTests(unittest.TestCase): + def test_catalog_index_has_no_unused_template_hashes(self): + applications = Catalog(ROOT).load_index()['applications'] + self.assertTrue(applications) + self.assertTrue(all('content_hash' not in item for item in applications)) + + def test_internal_template_id_resolves_current_catalog_template(self): + catalog = Catalog(ROOT) + previous = catalog.load_template('chromium', generate_if_missing=False) + self.assertEqual(previous['id'], 'linuxserver-chromium') + previous = copy.deepcopy(previous) + previous['catalog_ui']['title']['en_US'] = 'Old Chromium' + candidate = {'template': previous, 'deployment': { + 'rootfs': {'storage': 'local-lvm'}, + 'mounts': [{'container_path': '/config'}], + 'environment': [{'name': item['name'], 'value': item.get('example') or 'value', + 'sensitive': item['sensitive']} + for item in previous['container_contract']['environment'] + if item['required']], + 'security': {}, + }} + + refresh_template(candidate, ConfirmingUI()) + + self.assertEqual(candidate['template']['catalog_ui']['title']['en_US'], + catalog.compose('chromium')['catalog_ui']['title']['en_US']) + + def test_navidrome_baseurl_is_optional(self): + catalog = Catalog(ROOT) + template = catalog.compose('navidrome') + base_url = next(item for item in template['container_contract']['environment'] + if item['name'] == 'ND_BASEURL') + self.assertFalse(base_url['required']) + self.assertEqual(base_url['example'], '') + + regenerated = copy.deepcopy(template) + target = next(item for item in regenerated['container_contract']['environment'] + if item['name'] == 'ND_BASEURL') + target['required'] = True + catalog._preserve_optional_environment('navidrome', regenerated) + self.assertFalse(target['required']) + + +if __name__ == '__main__': + unittest.main() diff --git a/oci/tests/test_wizard.py b/oci/tests/test_wizard.py new file mode 100644 index 00000000..cc63109e --- /dev/null +++ b/oci/tests/test_wizard.py @@ -0,0 +1,104 @@ +"""Shared wizard navigation and device choices do not depend on app overlays.""" +from pathlib import Path +import sys +import unittest +from unittest.mock import patch +from subprocess import CompletedProcess + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / 'src')) + +from proxmenux_oci.extra_devices import (ask_extra_devices, ask_stack_extra_devices, + device_permissions) +from proxmenux_oci.ui import BackRequested, BacktrackUI, DialogUI, RestartWizard + + +class SequenceUI: + def __init__(self, answers): + self.answers = iter(answers) + self.back_enabled = False + + def ask(self, *_args, **_kwargs): + result = next(self.answers) + if result == 'back': + raise BackRequested() + return result + + def confirm(self, *_args, **_kwargs): + return next(self.answers) + + def choose(self, *_args, **_kwargs): + return next(self.answers) + + def checklist(self, *_args, **_kwargs): + return next(self.answers) + + def info(self, _text): + pass + + +class WizardTests(unittest.TestCase): + def test_back_returns_to_previous_answer_without_reasking_first(self): + base = SequenceUI(['first', 'second', 'back', 'corrected', 'third']) + wizard = BacktrackUI(base) + try: + self.assertEqual(wizard.ask('first'), 'first') + self.assertEqual(wizard.ask('second'), 'second') + with self.assertRaises(RestartWizard): + wizard.ask('third') + wizard.restart() + self.assertEqual(wizard.ask('first'), 'first') + self.assertEqual(wizard.ask('second'), 'corrected') + self.assertEqual(wizard.ask('third'), 'third') + finally: + wizard.close() + self.assertFalse(base.back_enabled) + + def test_back_from_review_reopens_last_question(self): + class ReviewUI(SequenceUI): + def review(self, *_args, **_kwargs): + raise BackRequested() + + wizard = BacktrackUI(ReviewUI(['value', 'replacement'])) + try: + self.assertEqual(wizard.ask('value'), 'value') + with self.assertRaises(RestartWizard): + wizard.review('summary') + wizard.restart() + self.assertEqual(wizard.ask('value'), 'replacement') + finally: + wizard.close() + + def test_manual_usb_is_available_without_profile(self): + ui = SequenceUI([True, 'usb', '/dev/ttyACM0', False]) + devices = ask_extra_devices(ui, [], True) + self.assertEqual(devices[0]['host_path'], '/dev/ttyACM0') + self.assertEqual(devices[0]['container_path'], '/dev/ttyACM0') + + def test_linuxserver_device_permissions_are_generic(self): + permissions = device_permissions('lscr.io/linuxserver/chromium:latest', + [{'kind': 'character-device'}]) + self.assertEqual(permissions['strategy'], 'linuxserver-native-init') + + def test_stack_device_goes_only_to_selected_member(self): + ui = SequenceUI([True, 'usb', '/dev/ttyACM0', False, ['server']]) + services = [ + {'name': name, 'main': name == 'server', + 'template': {'container_contract': {'image': {'reference': 'example/app:latest'}}}, + 'deployment': {'devices': [], 'security': {'unprivileged': True}}} + for name in ('database', 'server') + ] + ask_stack_extra_devices(ui, services) + self.assertEqual(services[0]['deployment']['devices'], []) + self.assertEqual(services[1]['deployment']['devices'][0]['host_path'], '/dev/ttyACM0') + + def test_dialog_back_button_is_only_on_wizard_inputs(self): + ui = DialogUI(back_enabled=True) + with patch('proxmenux_oci.ui.subprocess.run', return_value=CompletedProcess([], 3, '', '')) as run: + with self.assertRaises(BackRequested): + ui._run(['--inputbox', 'Name', '10', '50', '']) + self.assertIn('--extra-button', run.call_args.args[0]) + + +if __name__ == '__main__': + unittest.main()