diff --git a/.github/scripts/tests/test_helper_addon_target.py b/.github/scripts/tests/test_helper_addon_target.py
new file mode 100644
index 00000000..64ba2a9c
--- /dev/null
+++ b/.github/scripts/tests/test_helper_addon_target.py
@@ -0,0 +1,108 @@
+"""An addon of the Helper-Scripts runs where its catalog says it belongs: on
+the Proxmox host, inside a container, or wherever the user chooses when it
+can be both. Until this, every addon ran on the host."""
+
+from pathlib import Path
+import subprocess
+import textwrap
+import unittest
+
+ROOT = Path(__file__).resolve().parents[3]
+MENU = ROOT / "scripts/menus/menu_Helper_Scripts.sh"
+PLAIN = ROOT / "scripts/menus/helper_plain_text.py"
+
+# Only the functions under test are loaded, not the menu or its catalog.
+PRELUDE = textwrap.dedent(f"""
+ set -uo pipefail
+ eval "$(sed -n '/^download_script() {{/,/^RETURN_TO_MAIN=false/p' '{MENU}' | sed '$d')"
+ translate() {{ printf '%s' "$1"; }}
+ clear() {{ :; }}
+ dialog() {{ :; }}
+ pmx_lxc_is_oci() {{ [[ "$1" == 100 ]]; }}
+ MENU_HEIGHT=22; MENU_ROWS=14
+""")
+
+
+def bash(script):
+ return subprocess.run(["bash", "-c", PRELUDE + textwrap.dedent(script)], capture_output=True, text=True)
+
+
+class TargetChoiceTests(unittest.TestCase):
+ def choice(self, kind, places):
+ return bash(f'helper_target_choice {kind} "{places}"').stdout.strip()
+
+ def test_an_addon_for_the_host_runs_on_the_host(self):
+ self.assertEqual(self.choice("addon", "pve"), "host")
+
+ def test_an_addon_for_containers_runs_in_one(self):
+ self.assertEqual(self.choice("addon", "lxc vm"), "lxc")
+ self.assertEqual(self.choice("addon", "lxc"), "lxc")
+
+ def test_an_addon_for_both_asks_where(self):
+ self.assertEqual(self.choice("addon", "pve vm lxc"), "ask")
+ self.assertEqual(self.choice("addon", ""), "ask")
+
+ def test_what_is_not_an_addon_runs_on_the_host(self):
+ # A script that creates a container or a VM belongs on the host,
+ # whatever the catalog lists for the application it installs.
+ self.assertEqual(self.choice("lxc", "lxc"), "host")
+ self.assertEqual(self.choice("vm", "vm"), "host")
+ self.assertEqual(self.choice("pve", "pve"), "host")
+
+
+class ContainerListTests(unittest.TestCase):
+ def test_oci_containers_are_left_out_and_a_lock_is_shown(self):
+ result = bash(r"""
+ pct() { printf 'VMID Status Lock Name\n100 running chromium\n109 running ordinary\n110 stopped stopped-one\n112 stopped backup locked-one\n'; }
+ helper_lxc_rows
+ """)
+ self.assertEqual(result.stdout,
+ "109\tordinary · running\n110\tstopped-one · stopped\n112\tlocked-one · stopped · backup\n")
+
+
+class RunTests(unittest.TestCase):
+ def test_the_script_runs_on_the_host_or_inside_the_container_chosen(self):
+ result = bash(r"""
+ curl() { printf 'printf on-host'; }
+ pct() { [[ "$1" == exec && "$2" == 109 && "$3" == --keep-env && "$4" == 0 && "$5" == -- ]] && printf 'in-lxc:%s' "${*: -1}"; }
+ HELPER_LXC_TARGET=''; download_script https://example.invalid/a; echo
+ HELPER_LXC_TARGET=109; download_script https://example.invalid/a; echo
+ """)
+ self.assertEqual(result.stdout, "on-host\nin-lxc:printf on-host\n")
+
+ def test_a_failed_or_empty_download_runs_nothing(self):
+ result = bash(r"""
+ bash() { echo RAN; }
+ pct() { echo RAN; }
+ HELPER_LXC_TARGET=109
+ curl() { return 22; }; download_script https://example.invalid/a && echo UNEXPECTED
+ curl() { printf ' \n'; }; download_script https://example.invalid/a && echo UNEXPECTED
+ echo done
+ """)
+ self.assertEqual(result.stdout, "done\n")
+
+ def test_cancelling_the_choice_leaves_no_target(self):
+ result = bash(r"""
+ dialog() { return 1; }
+ select_helper_target Test ask && echo UNEXPECTED
+ printf '[%s]\n' "$HELPER_LXC_TARGET"
+ """)
+ self.assertEqual(result.stdout, "[]\n")
+
+ def test_the_host_needs_no_question(self):
+ result = bash(r"""
+ dialog() { echo ASKED; return 1; }
+ select_helper_target Test host && printf 'ok[%s]\n' "$HELPER_LXC_TARGET"
+ """)
+ self.assertEqual(result.stdout, "ok[]\n")
+
+
+class PlainTextTests(unittest.TestCase):
+ def test_html_becomes_text_and_commands_are_kept(self):
+ result = subprocess.run(["python3", str(PLAIN)], capture_output=True, text=True,
+ input="
Run ls -la && pwd
")
+ self.assertEqual(result.stdout, "Run ls -la && pwd\n\n• one\n\n• two")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/.github/scripts/tests/test_menu_search.py b/.github/scripts/tests/test_menu_search.py
new file mode 100644
index 00000000..4555de1a
--- /dev/null
+++ b/.github/scripts/tests/test_menu_search.py
@@ -0,0 +1,155 @@
+"""The search of the main menu finds an option of ProxMenux, an OCI application
+or a Helper-Script by its name or by related words, and opens it where it
+lives: the scripts it launches are the ones the menus launch."""
+
+import importlib.util
+import json
+from pathlib import Path
+import re
+import unittest
+
+ROOT = Path(__file__).resolve().parents[3]
+SCRIPTS = ROOT / "scripts"
+
+
+def load():
+ spec = importlib.util.spec_from_file_location("search_menu", SCRIPTS / "menus/search_menu.py")
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ # The repository in the place of the installed copy.
+ module.BASE, module.SCRIPTS = ROOT, SCRIPTS
+ module.OCI_INDEX = ROOT / "oci/catalog/index.json"
+ module.HELPERS_CACHE = ROOT / "json/helpers_cache.json"
+ module._translations = {}
+ return module
+
+
+class IndexTests(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ cls.index = json.loads((SCRIPTS / "menus/search_index.json").read_text(encoding="utf-8"))
+ cls.spanish = json.loads((ROOT / "lang/es.json").read_text(encoding="utf-8"))
+
+ def test_every_entry_opens_a_script_that_exists(self):
+ missing = sorted({item["script"] for item in self.index if not (SCRIPTS / item["script"]).is_file()})
+ self.assertEqual(missing, [])
+
+ def test_every_title_is_a_text_the_menus_already_translate(self):
+ names = {"ZimaOS", "Synology DSM"}
+ unknown = [item["title"] for item in self.index
+ if item["title"] not in self.spanish and item["title"] not in names]
+ self.assertEqual(unknown, [])
+ self.assertEqual([item["menu"] for item in self.index if item["menu"] not in self.spanish], [])
+
+ def test_every_script_a_menu_launches_can_be_found(self):
+ indexed = {item["script"] for item in self.index}
+ launched = set()
+ for menu in sorted((SCRIPTS / "menus").glob("*.sh")):
+ if menu.name in ("main_menu.sh", "lxc_menu.sh"):
+ continue
+ launched |= set(re.findall(r'bash "\$LOCAL_SCRIPTS/([A-Za-z0-9_./-]+\.sh)"', menu.read_text()))
+ launched -= {"menus/main_menu.sh"}
+ # Opened from their own menu, which is what the index points at.
+ launched -= {"post_install/auto_post_install.sh", "utilities/proxmox_update.sh", "utilities/upgrade_pve8_to_pve9.sh"}
+ self.assertEqual(sorted(launched - indexed), [])
+
+ def test_the_main_menu_offers_the_search_and_opens_what_it_hands_back(self):
+ menu = (SCRIPTS / "menus/main_menu.sh").read_text()
+ self.assertIn('search "$(translate "Search options and applications")"', menu)
+ self.assertIn('search_menu.py" --handoff "$handoff"', menu)
+ self.assertIn('[[ "${target[0]:-}" == "$LOCAL_SCRIPTS"/* && -f "${target[0]}" ]]', menu)
+ self.assertIn('exec bash "${target[@]}"', menu)
+
+ def test_an_option_inside_a_menu_names_one_that_menu_has(self):
+ for item in self.index:
+ argument = item.get("argument")
+ if not argument:
+ continue
+ script = (SCRIPTS / item["script"]).read_text()
+ self.assertIn("PRESELECTED_OPTION", script, item["script"])
+ if not argument.isdigit():
+ self.assertIn(argument, script, f"{item['script']}: {argument}")
+
+
+class CatalogTests(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ cls.search = load()
+ cls.items = cls.search.catalog(refresh=False)
+
+ def found(self, query):
+ return self.search.search(self.items, query)
+
+ def test_the_three_sources_are_in_the_catalog(self):
+ self.assertEqual({item["source"] for item in self.items}, set(self.search.SOURCES))
+
+ def test_proxmenux_comes_first_then_oci_then_helper_scripts(self):
+ sources = [item["source"] for item in self.found("samba")]
+ self.assertEqual(sources, sorted(sources, key=self.search.SOURCES.index))
+ self.assertEqual(sources[0], "ProxMenux")
+
+ def test_a_name_is_found_by_the_start_of_any_of_its_words(self):
+ self.assertIn("Jellyfin", [item["title"] for item in self.found("jelly")])
+ self.assertTrue(any(item["source"] == "OCI Manager" for item in self.found("plex")))
+
+ def test_accents_and_case_do_not_matter(self):
+ self.assertEqual([item["title"] for item in self.found("NVIDIA")],
+ [item["title"] for item in self.found("nvidia")])
+
+ def test_related_words_find_an_option(self):
+ self.assertTrue(any("NFS" in item["title"] for item in self.found("nfs storage")))
+ self.assertTrue(any(item["action"][-1].endswith("fail2ban_installer.sh") for item in self.found("fail2ban")))
+
+ def test_a_match_by_category_comes_after_the_names(self):
+ matches = self.found("vpn")
+ self.assertTrue(any(item["title"] == "Tailscale" for item in matches))
+ by_category = False
+ for item in matches:
+ words = re.findall(r"[a-z0-9]+", self.search.fold(item["terms"]))
+ in_name = any(word.startswith("vpn") for word in words)
+ self.assertFalse(in_name and by_category)
+ by_category = by_category or not in_name
+
+ def test_an_application_is_not_found_by_what_its_description_mentions(self):
+ titles = [item["title"] for item in self.found("plex")]
+ self.assertNotIn("Jellyfin", titles)
+ self.assertTrue(all("plex" in title.casefold() for title in titles))
+
+ def test_nothing_matches_nothing(self):
+ self.assertEqual(self.found("zzqqxx"), [])
+ self.assertEqual(self.found(" "), [])
+
+ def test_a_sub_menu_is_handed_back_to_the_main_menu(self):
+ by_script = {item["action"][1]: item for item in self.items if item["source"] == "ProxMenux"}
+ self.assertTrue(by_script[str(SCRIPTS / "menus/network_menu.sh")]["handoff"])
+ self.assertFalse(by_script[str(SCRIPTS / "storage/format-disk.sh")]["handoff"])
+
+ def test_an_option_inside_a_menu_is_given_to_that_menu(self):
+ zfs = next(item for item in self.items if item["action"][1:] == [str(SCRIPTS / "help_info_menu.sh"), "7"])
+ self.assertFalse(zfs["handoff"])
+ language = next(item for item in self.items if item["action"][-1] == "change_language")
+ self.assertEqual(language["action"][1], str(SCRIPTS / "menus/config_menu.sh"))
+ self.assertTrue(language["handoff"])
+
+
+class OpenedInPlaceTests(unittest.TestCase):
+ def test_an_option_of_the_post_install_comes_selected(self):
+ script = (SCRIPTS / "post_install/customizable_post_install.sh").read_text()
+ self.assertIn('PRESELECTED_OPTION="${1:-}"', script)
+ self.assertIn('--default-item "${preselected_item:-1}"', script)
+
+ def test_a_utility_comes_selected(self):
+ script = (SCRIPTS / "utilities/system_utils.sh").read_text()
+ self.assertIn('PRESELECTED_UTILITY="${1:-}"', script)
+
+ def test_a_helper_script_is_opened_on_its_own(self):
+ script = (SCRIPTS / "menus/menu_Helper_Scripts.sh").read_text()
+ self.assertIn('run_script_by_slug "$1"', script)
+
+ def test_an_oci_application_opens_its_page(self):
+ cli = (ROOT / "oci/src/proxmenux_oci/cli.py").read_text()
+ self.assertIn('subparsers.add_parser("open"', cli)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/lang/es.json b/lang/es.json
index 49605a7a..d349a83c 100644
--- a/lang/es.json
+++ b/lang/es.json
@@ -191,9 +191,12 @@
"Access bridge for Tandoor": "Puente de acceso para Tandoor",
"Access could not be granted completely; the application may be unable to write to:": "No se pudo conceder el acceso por completo; puede que la aplicación no pueda escribir en:",
"Access granted to the application on:": "Acceso concedido a la aplicación en:",
+ "Access password of the web interface (optional)": "Contraseña de acceso a la interfaz web (opcional)",
"Access profile:": "Perfil de acceso:",
"Access token of the Jupyter Lab web interface": "Token de acceso de la interfaz web de Jupyter Lab",
+ "Access token of the connector, from the Twingate admin console": "Token de acceso del conector, de la consola de administración de Twingate",
"Account is not locked": "La cuenta no está bloqueada",
+ "Account token from the settings of https://app.foldingathome.org": "Token de la cuenta, de los ajustes de https://app.foldingathome.org",
"Action cancelled due to previous xshok-proxmox modifications.": "Acción cancelada debido a modificaciones anteriores de xshok-proxmox.",
"Action:": "Acción:",
"Activate Monitor": "Activar Monitor",
@@ -279,7 +282,16 @@
"Additional audio function(s) to be added": "Funciones de audio adicionales que se agregarán",
"Additional media/GPU GIDs, comma-separated": "GID adicionales de medios/GPU, separados por comas",
"Additional paths for": "Rutas adicionales para",
+ "Address BookStack will be opened at (for example http://192.168.1.50:6875)": "Dirección en la que se abrirá BookStack (por ejemplo http://192.168.1.50:6875)",
+ "Address Healthchecks will be opened at (for example http://192.168.1.50:8000)": "Dirección en la que se abrirá Healthchecks (por ejemplo http://192.168.1.50:8000)",
+ "Address Speedtest Tracker will be opened at (for example http://192.168.1.50)": "Dirección en la que se abrirá Speedtest Tracker (por ejemplo http://192.168.1.50)",
+ "Address of Redis or Valkey (for example redis://192.168.1.20:6379/1)": "Dirección de Redis o Valkey (por ejemplo redis://192.168.1.20:6379/1)",
"Address of the Compose file": "Dirección del archivo Compose",
+ "Address of the MariaDB server": "Dirección del servidor MariaDB",
+ "Address of the NetBird management server": "Dirección del servidor de gestión de NetBird",
+ "Address of the PostgreSQL server": "Dirección del servidor PostgreSQL",
+ "Address of the Redis or Valkey server": "Dirección del servidor Redis o Valkey",
+ "Address of the database server (mysql and pgsql only)": "Dirección del servidor de base de datos (solo mysql y pgsql)",
"Address to reach Pydio Cells (https://domain or https://IP:8080)": "Dirección para acceder a Pydio Cells (https://dominio o https://IP:8080)",
"Address used to reach wallabag (http://IP or https://wallabag.example.com)": "Dirección usada para acceder a wallabag (http://IP o https://wallabag.example.com)",
"Addresses to update: ipv4, ipv6 or both (uses an external service)": "Direcciones que se actualizan: ipv4, ipv6 o both (usa un servicio externo)",
@@ -994,6 +1006,7 @@
"Configuration has been stopped due to high reset risk.": "La configuración se ha detenido debido al alto riesgo de reinicio.",
"Configuration has been stopped to prevent an unusable VM state.": "La configuración se ha detenido para evitar un estado de VM inutilizable.",
"Configuration has been stopped to prevent leaving the VM in an unusable state.": "La configuración se ha detenido para evitar dejar la máquina virtual en un estado inutilizable.",
+ "Configuration in JSON on one line (optional)": "Configuración en JSON en una sola línea (opcional)",
"Configuration name:": "Nombre de configuración:",
"Configuration saved": "Configuración guardada",
"Configuration sections that will be REMOVED": "Secciones de configuración que serán ELIMINADAS",
@@ -1558,8 +1571,11 @@
"Data volumes protected": "Volúmenes de datos protegidos",
"Data wipe complete.": "Borrado de datos completo.",
"Data wiped from": "Datos borrados de",
+ "Database (a SQLite file, or postgresql://user:password@address:5432/name)": "Base de datos (un archivo SQLite, o postgresql://usuario:contraseña@dirección:5432/nombre)",
+ "Database file for sqlite, or database name for mysql and pgsql": "Archivo de base de datos para sqlite, o nombre de la base de datos para mysql y pgsql",
"Database management in a single PHP file": "Gestión de bases de datos en un solo archivo PHP",
"Database server proposed on the login page (empty = typed at each login)": "Servidor de base de datos propuesto en la página de inicio de sesión (vacío = se escribe en cada inicio de sesión)",
+ "Database type (sqlite, mysql or pgsql)": "Tipo de base de datos (sqlite, mysql o pgsql)",
"Databases": "Databases",
"Datastore name:": "Nombre del almacén de datos:",
"Davos is an FTP automation tool that periodically scans given host locations for new files. It can be configured for various purposes, including listening for specific files to appear in the host location, ready for it to download and then move, if required. It also supports completion notifications as well as downstream API calls, to further the workflow.": "Davos es una herramienta de automatización de FTP que examina periódicamente ubicaciones de un servidor en busca de archivos nuevos. Se puede configurar para distintos fines, como esperar a que aparezcan archivos concretos para descargarlos y, si hace falta, moverlos. También admite notificaciones de finalización y llamadas a API posteriores para continuar el flujo de trabajo.",
@@ -1888,6 +1904,8 @@
"Email address for certificate expiry notices (required by ZeroSSL)": "Dirección de correo para los avisos de caducidad del certificado (obligatoria con ZeroSSL)",
"Email address of the LibreDB Studio administrator": "Dirección de correo del administrador de LibreDB Studio",
"Email address of the NetBox admin account": "Dirección de correo de la cuenta de administrador de NetBox",
+ "Email address of the admin account": "Correo electrónico de la cuenta admin",
+ "Email address of the superuser account": "Correo electrónico de la cuenta de superusuario",
"Emby WebUI": "Emby WebUI",
"Emby organizes video, music, live TV, and photos from personal media libraries and streams them to smart TVs, streaming boxes and mobile devices. This container is packaged as a standalone emby Media Server.": "Emby organiza vídeo, música, TV en directo y fotos de bibliotecas multimedia personales y los transmite a televisores inteligentes, reproductores de streaming y dispositivos móviles. Este contenedor se distribuye como un Emby Media Server independiente.",
"Emergency Proxmox System Repair": "Reparación de emergencia del sistema Proxmox",
@@ -2162,6 +2180,7 @@
"External credential is empty or spans multiple lines": "La credencial externa está vacía o abarca varias líneas",
"External disk for backup": "Disco externo para backup",
"External field not reserved:": "Campo externo no reservado:",
+ "Extra parameters for minisatip (optional)": "Parámetros adicionales para minisatip (opcional)",
"Extra paths": "Rutas extra",
"Extra paths to keep (unmark one to remove it)": "Rutas extra que se mantienen (desmarca una para eliminarla)",
"Extracting NVIDIA installer on host...": "Extrayendo el instalador de NVIDIA en el host...",
@@ -2893,6 +2912,7 @@
"Importing disk": "Importando disco",
"Importing:": "Importador:",
"Improve log rotation and limit log size to save space and extend disk life": "Mejorar la rotación de logs y limitar su tamaño para ahorrar espacio y alargar la vida del disco",
+ "In an LXC": "En un LXC",
"In the loader interface, follow the instructions to select your Synology model": "En la interfaz del cargador, siga las instrucciones para seleccionar su modelo de Synology",
"In use": "En uso",
"In use by VM/LXC config": "En uso por la configuración de VM/LXC",
@@ -3462,6 +3482,7 @@
"Listing relevant CT users and their mapped UID/GID on host...": "Listado de usuarios CT relevantes y su UID/GID asignado en el host...",
"Load and verify the WireGuard module on the Proxmox host": "Cargar y verificar el módulo WireGuard en el host Proxmox",
"Loading modules...": "Cargando módulos...",
+ "Loading the Helper-Scripts catalog...": "Cargando el catálogo de Helper-Scripts...",
"Loading the host kernel module:": "Carga el módulo del kernel host:",
"Local Disk Manager - Proxmox Host": "Administrador de discos locales - Proxmox Host",
"Local Disk Storages": "Almacenamientos en disco local",
@@ -3533,6 +3554,7 @@
"Machine type: q35": "Tipo de máquina: q35",
"Machine: q35": "Máquina: q35",
"Main endpoint not yet defined": "Punto final principal aún no definido",
+ "Main menu": "Menú principal",
"Major version differs:": "La versión principal difiere:",
"Make sure IOMMU is properly enabled and the system has been rebooted after activation.": "Asegúrese de que IOMMU esté habilitado correctamente y que el sistema se haya reiniciado después de la activación.",
"Make sure there are no critical services running as they will be interrupted. Ensure your server can be safely rebooted.": "Asegúrese de que no haya servicios críticos en ejecución, ya que se interrumpirán. Asegúrese de que su servidor pueda reiniciarse de forma segura.",
@@ -3858,16 +3880,22 @@
"NZBGet web interface": "Interfaz web de NZBGet",
"Name": "Nombre",
"Name for this application": "Nombre de esta aplicación",
+ "Name for this machine in Folding@home (no spaces)": "Nombre de esta máquina en Folding@home (sin espacios)",
"Name for this target:": "Nombre para este destino:",
"Name of the PostgreSQL user created on the first start": "Nombre del usuario de PostgreSQL creado en el primer arranque",
+ "Name of the Twingate network (the part before .twingate.com)": "Nombre de la red de Twingate (la parte anterior a .twingate.com)",
"Name of the database created on the first start": "Nombre de la base de datos creada en el primer arranque",
+ "Name of the database on the MariaDB server": "Nombre de la base de datos en el servidor MariaDB",
"Name of the directory to share (lowercase letters, digits, - or _)": "Nombre del directorio a compartir (minúsculas, números, - o _)",
"Name of the internal worker node": "Nombre del nodo worker interno",
"Name of this wallabag instance, shown in the interface and in 2FA codes": "Nombre de esta instancia de wallabag, mostrado en la interfaz y en los códigos 2FA",
+ "Name or address NetBox will be opened at (for example netbox.example.com)": "Nombre o dirección en la que se abrirá NetBox (por ejemplo netbox.example.com)",
"Name or part of the description of the application": "Nombre o parte de la descripción de la aplicación",
+ "Name or part of the option or of the application": "Nombre o parte de la opción o de la aplicación",
"Name:": "Nombre:",
"Named accounts need private mode. Stop the container, set public: false in /config/config.js, start it again and create each user with: pct exec -- env THELOUNGE_HOME=/config s6-setuidgid abc thelounge add ": "Las cuentas con nombre necesitan el modo privado. Detén el contenedor, pon public: false en /config/config.js, vuelve a arrancarlo y crea cada usuario con: pct exec -- env THELOUNGE_HOME=/config s6-setuidgid abc thelounge add ",
"Needed:": "Necesario:",
+ "Needs": "Necesita",
"Neither /etc/kernel/cmdline nor /etc/default/grub found.": "No se encontraron /etc/kernel/cmdline ni /etc/default/grub.",
"Nesting feature enabled": "Función de anidamiento habilitada",
"NetBIOS Service: RUNNING": "Servicio NetBIOS: EN EJECUCIÓN",
@@ -4142,6 +4170,7 @@
"No language selected.": "No se seleccionó ningún idioma.",
"No local disk storage configured.": "No hay almacenamiento en disco local configurado.",
"No main sources.list present (skipped)": "No hay sources.list principal (omitido)",
+ "No matches for:": "No hay coincidencias para:",
"No matching PVE vzdump jobs were found for the PBS backend.": "No se encontraron trabajos PVE vzdump coincidentes para el backend de PBS.",
"No matching PVE vzdump jobs were found for the local archive backend.": "No se encontraron trabajos PVE vzdump coincidentes para el backend del archivo local.",
"No mount point was specified.": "No se especificó ningún punto de montaje.",
@@ -4172,6 +4201,7 @@
"No recent Samba servers found.": "No se encontraron servidores Samba recientes.",
"No registered OCI containers are available for selection on this host.": "No hay contenedores OCI registrados disponibles para seleccionar en este host.",
"No restored OCI application is waiting to be recovered.": "No hay ninguna aplicación OCI restaurada pendiente de recuperar.",
+ "No results": "Sin resultados",
"No routing information found.": "No se encontró información de enrutamiento.",
"No scheduled backup jobs configured.": "No se han configurado trabajos de backup programados.",
"No scheduled backup jobs found.": "No se encontraron trabajos de backup programados.",
@@ -4263,6 +4293,7 @@
"Number of CPU cores (default: 2)": "Número de núcleos de CPU (predeterminado: 2)",
"Nzbget is a usenet downloader, written in C++ and designed with performance in mind to achieve maximum download speed by using very little system resources.": "Nzbget es un descargador de Usenet escrito en C++ y diseñado pensando en el rendimiento, para lograr la máxima velocidad de descarga con muy pocos recursos del sistema.",
"Nzbhydra2 is a meta search application for NZB indexers, the spiritual successor to NZBmegasearcH, and an evolution of the original application NZBHydra.": "Nzbhydra2 es una aplicación de metabúsqueda para indexadores NZB, sucesora espiritual de NZBmegasearcH y evolución de la aplicación original NZBHydra.",
+ "OCI application": "Aplicación OCI",
"OCI container, configuration kept. Remove its GPU from OCI manager Apps: CT": "Contenedor OCI, configuración conservada. Elimina su GPU desde OCI manager Apps: CT",
"OCI containers keep their configuration: they are stopped and their Start on boot is disabled. Their GPU is removed from OCI manager Apps.": "Los contenedores OCI conservan su configuración: se detienen y se desactiva su inicio automático. Su GPU se elimina desde OCI manager Apps.",
"OCI containers require Proxmox VE 9.1 or later.": "Los contenedores OCI requieren Proxmox VE 9.1 o posterior.",
@@ -4294,6 +4325,7 @@
"Observer is not responding on port 4357": "El observador no responde en el puerto 4357",
"Obsidian is a note-taking app that lets you create, link, and organize your notes on your device, with hundreds of plugins and themes to customize your workflow. You can also publish your notes online, access them offline, and sync them securely with end-to-end encryption.": "Obsidian es una aplicación de notas que te permite crear, enlazar y organizar tus notas en tu dispositivo, con cientos de plugins y temas para personalizar tu flujo de trabajo. También puedes publicar tus notas en línea, acceder a ellas sin conexión y sincronizarlas de forma segura con cifrado de extremo a extremo.",
"Offer as exit node?": "¿Oferta como nodo de salida?",
+ "Official Duplicati image: encrypted, incremental backups to local, network and cloud storage.": "Imagen oficial de Duplicati: backups cifrados e incrementales en almacenamiento local, de red y en la nube.",
"Official Emby Media Server image with optional VA-API or NVIDIA acceleration.": "Imagen oficial de Emby Media Server con aceleración VA-API o NVIDIA opcional.",
"Official Jellyfin image with optional VA-API or NVIDIA acceleration.": "Imagen oficial de Jellyfin con aceleración VA-API o NVIDIA opcional.",
"Official Linux Distributions": "Distribuciones oficiales de Linux",
@@ -4306,6 +4338,7 @@
"On a privileged CT the mount options carry the only permissions.": "En un CT privilegiado, las opciones de montaje tienen los únicos permisos.",
"On some systems, when starting the VM the host may slow down for several minutes until it stabilizes, or freeze completely.": "En algunos sistemas, al iniciar la máquina virtual, el host puede ralentizarse durante varios minutos hasta que se estabilice o se congele por completo.",
"On the Borg server, append the following line to:": "en el servidor Borg, agregue la siguiente línea a:",
+ "On the host": "En el host",
"Once finished, re-run 'PVE 8 to 9 check' to verify that all issues are resolved \n before executing the PVE 8 → PVE 9 upgrade.": "Una vez terminado, vuelva a ejecutar la 'verificación PVE 8 a 9' para verificar que todos los problemas estén resueltos. \n antes de ejecutar la actualización PVE 8 → PVE 9.",
"Once finished, re-run the script 'PVE 8 to 9 check' to verify that all issues are resolved \n before rebooting.": "Una vez terminado, vuelva a ejecutar el script 'PVE 8 to 9 check' para verificar que todos los problemas estén resueltos. \n antes de reiniciar.",
"Once finished, re-run the script 'PVE 8 to 9 check' to verify that all issues.": "Una vez terminado, vuelva a ejecutar el script 'PVE 8 to 9 check' para verificar que todos los problemas.",
@@ -4485,6 +4518,7 @@
"Password of the Etherpad admin user": "Contraseña del usuario administrador de Etherpad",
"Password of the FlexGet web interface": "Contraseña de la interfaz web de FlexGet",
"Password of the LibreDB Studio administrator": "Contraseña del administrador de LibreDB Studio",
+ "Password of the MariaDB user": "Contraseña del usuario de MariaDB",
"Password of the MineOS web interface user": "Contraseña del usuario de la interfaz web de MineOS",
"Password of the NetBox admin account": "Contraseña de la cuenta de administrador de NetBox",
"Password of the OpenList admin user": "Contraseña del usuario administrador de OpenList",
@@ -4492,8 +4526,10 @@
"Password of the PostgreSQL user": "Contraseña del usuario de PostgreSQL",
"Password of the ShareCoveX panel administrator (8 characters or more)": "Contraseña del administrador del panel de ShareCoveX (8 caracteres o más)",
"Password of the SnapOtter admin user": "Contraseña del usuario administrador de SnapOtter",
+ "Password of the admin account": "Contraseña de la cuenta admin",
"Password of the deprecated Flowise application login (only read by Flowise versions before 3.0.1)": "Contraseña del acceso antiguo de la aplicación Flowise (solo la leen las versiones de Flowise anteriores a 3.0.1)",
"Password of the main AdGuard Home": "Contraseña del AdGuard Home principal",
+ "Password of the superuser account": "Contraseña de la cuenta de superusuario",
"Password or API token secret:": "Contraseña o token secreto de API:",
"Password or secret": "Contraseña o secreto",
"Password reset completed.": "Restablecimiento de contraseña completado.",
@@ -4601,6 +4637,7 @@
"Pool:": "Pool",
"Port": "Puerto",
"Port must be a number between 1 and 65535.": "El puerto debe ser un número entre 1 y 65535.",
+ "Port of the database server (mysql and pgsql only)": "Puerto del servidor de base de datos (solo mysql y pgsql)",
"Port:": "Puerto:",
"Portal IP and port are correct": "La IP y el puerto del portal son correctos",
"Portal is reachable": "El portal es accesible",
@@ -4729,6 +4766,7 @@
"ProxMenux cannot override NFS server-side permissions from the host.": "ProxMenux no puede anular los permisos del lado del servidor NFS del host.",
"ProxMenux customizations removed from bashrc": "Personalizaciones de ProxMenux eliminadas de bashrc",
"ProxMenux does not give a container the system of its host.": "ProxMenux no le da a un contenedor el sistema de su host.",
+ "ProxMenux does not install them. The installation asks how to connect to them.": "ProxMenux no los instala. La instalación pide cómo conectarse a ellos.",
"ProxMenux does not validate the contents; any keyfile your PBS accepts is accepted here.": "ProxMenux no valida los contenidos; cualquier archivo de claves que acepte su PBS se acepta aquí.",
"ProxMenux files:": "Archivos ProxMenux:",
"ProxMenux logo applied": "Logotipo de ProxMenux aplicado",
@@ -4773,6 +4811,7 @@
"Proxmox web interface: Datacenter > Storage > Add > SMB/CIFS": "Interfaz web de Proxmox: Centro de datos > Almacenamiento > Agregar > SMB/CIFS",
"Proxmox web interface: Datacenter > Storage > Add > ZFS": "Interfaz web de Proxmox: Centro de datos > Almacenamiento > Agregar > ZFS",
"Proxmox web interface: Datacenter > Storage > Add > iSCSI": "Interfaz web de Proxmox: Centro de datos > Almacenamiento > Agregar > iSCSI",
+ "Proxy address to reach the API (optional)": "Dirección del proxy para llegar a la API (opcional)",
"Public UDP port clients connect to": "Puerto UDP público al que se conectan los clientes",
"Public UDP port peers connect to": "Puerto UDP público al que se conectan los peers",
"Public URL of phpMyAdmin when it is served behind a reverse proxy": "URL pública de phpMyAdmin cuando se sirve detrás de un proxy inverso",
@@ -4917,6 +4956,7 @@
"Recreating the container...": "Recreando el contenedor...",
"Recreating the container:": "Recreando el contenedor:",
"Refresh APT index and verify repositories:": "Actualizar el índice APT y verificar los repositorios:",
+ "Refresh token of the connector, from the Twingate admin console": "Token de renovación del conector, de la consola de administración de Twingate",
"Refresh your browser (Ctrl+Shift+R) to see changes": "Actualiza tu navegador (Ctrl+Shift+R) para ver los cambios",
"Refresh your browser to see changes (server restart may be required)": "Actualice su navegador para ver los cambios (es posible que sea necesario reiniciar el servidor)",
"Refreshing apt cache...": "Actualizando caché de apt...",
@@ -5158,6 +5198,7 @@
"Restoring the stack records...": "Restaurando los registros de la pila...",
"Results will be saved automatically to:": "Los resultados se guardarán automáticamente en:",
"Results will be saved to:": "Los resultados se guardarán en:",
+ "Results:": "Resultados:",
"Retention": "Retención",
"Retention policy (prune-backups syntax).": "Política de retención (sintaxis de prune-backups).",
"RetroArch is a frontend for emulators, game engines and media players. It enables you to run classic games on a wide range of computers and consoles through its slick graphical interface.": "RetroArch es un frontend para emuladores, motores de juego y reproductores multimedia. Permite ejecutar juegos clásicos en una gran variedad de ordenadores y consolas con una interfaz gráfica muy cuidada.",
@@ -5334,6 +5375,7 @@
"Scanning network for Samba servers...": "Escaneando red en busca de servidores Samba...",
"Schedule": "Programación",
"Schedule of the automatic restart, in cron format (for example 0 6 * * * for every day at 6:00)": "Programación del reinicio automático, en formato cron (por ejemplo 0 6 * * * para todos los días a las 6:00)",
+ "Schedule of the tests in cron format": "Programación de las pruebas en formato cron",
"Schedule:": "Cronograma:",
"Scheduled backup job created": "Trabajo de backup programado creado",
"Scheduled backup jobs": "Trabajos de backup programados",
@@ -5348,8 +5390,10 @@
"Scripts in": "Scripts en",
"ScummVM is a program which allows you to run certain classic graphical adventure and role-playing games, provided you already have their data files. The clever part about this: ScummVM just replaces the executables shipped with the games, allowing you to play them on systems for which they were never designed! ScummVM is a complete rewrite of these games' executables and is not an emulator.": "ScummVM es un programa que permite ejecutar ciertas aventuras gráficas y juegos de rol clásicos, siempre que ya tengas sus archivos de datos. Lo ingenioso es que ScummVM sustituye los ejecutables de los juegos, de modo que puedes jugarlos en sistemas para los que nunca se diseñaron. ScummVM es una reescritura completa de esos ejecutables y no es un emulador.",
"Sealskin is a self-hosted, client-server platform that enables users to run powerful, containerized desktop applications streamed directly to a web browser. It uses a browser extension to intercept user actions—such as clicking a link or downloading a file and redirects them to a secure, isolated application environment running on a remote server.": "Sealskin es una plataforma cliente-servidor autoalojada que permite ejecutar potentes aplicaciones de escritorio en contenedores transmitidas directamente al navegador. Usa una extensión del navegador para interceptar acciones del usuario, como hacer clic en un enlace o descargar un archivo, y redirigirlas a un entorno de aplicación seguro y aislado en un servidor remoto.",
+ "Search": "Buscar",
"Search Results for:": "Resultados de búsqueda para:",
"Search applications": "Buscar aplicaciones",
+ "Search options and applications": "Buscar opciones y aplicaciones",
"Search results for:": "Resultados de la búsqueda para:",
"Search/Filter Scripts": "Buscar/filtrar scripts",
"Searchable document archive with OCR": "Archivo de documentos con búsqueda y OCR",
@@ -5487,6 +5531,7 @@
"Select the GPU to pass through to the VM:": "Seleccione la GPU para pasar a la VM:",
"Select the GPU(s) to add to LXC": "Seleccione las GPU para agregar a LXC",
"Select the LXC container:": "Seleccione el contenedor LXC:",
+ "Select the LXC. OCI containers are not included.": "Selecciona el LXC. Los contenedores OCI no se incluyen.",
"Select the Linux distribution to install:": "Seleccione la distribución de Linux para instalar:",
"Select the NAS system to install:": "Seleccione el sistema NAS a instalar:",
"Select the NVIDIA driver version to install:": "Seleccione la versión del controlador NVIDIA para instalar:",
@@ -5555,6 +5600,7 @@
"Sending backup to PBS...": "Enviando backup a PBS...",
"Server": "Servidor",
"Server (listen for incoming tests on TCP 5201)": "Servidor (espera pruebas entrantes en TCP 5201)",
+ "Server IDs to test against, separated by commas (optional)": "ID de los servidores contra los que probar, separados por comas (opcional)",
"Server IP/hostname is correct": "La IP/nombre de host del servidor es correcta",
"Server IP:": "IP del servidor:",
"Server is accessible:": "El servidor es accesible:",
@@ -5609,6 +5655,7 @@
"Settings": "Ajustes",
"Settings post-install Proxmox": "Configuración post instalación de Proxmox",
"Setup Persistent Network Names": "Configurar nombres de red persistentes",
+ "Setup key from the NetBird dashboard": "Clave de configuración (setup key) del panel de NetBird",
"Share Access Error": "Error de acceso al recurso compartido",
"Share Name": "Nombre compartido",
"Share Options": "Opciones para compartir",
@@ -5651,6 +5698,7 @@
"Should show 'unprivileged: 1' if it's unprivileged": "Debería mostrar 'sin privilegios: 1' si no tiene privilegios",
"Should show fewer or no issues": "Debería mostrar menos o ningún problema",
"Should show pve-manager/9.x.x": "Debería mostrar pve-manager/9.x.x",
+ "Shoutrrr addresses for the notifications (optional)": "Direcciones de Shoutrrr para las notificaciones (opcional)",
"Show All Scripts": "Mostrar todos los scripts",
"Show CT users in table format": "Mostrar usuarios de CT en formato de tabla",
"Show Container Privilege Status": "Mostrar estado de privilegios de los contenedores",
@@ -6056,7 +6104,10 @@
"The Immich startup was modified or cannot be reproduced": "La startup Immich fue modificada o no se puede reproducir",
"The LXC changed during verification; nothing was adopted": "El LXC cambió durante la verificación; no se ha incorporado nada",
"The LXC changed while reviewing; nothing was adopted": "El LXC cambió durante la revisión; no se ha incorporado nada",
+ "The LXC could not be started.": "No se pudo arrancar el LXC.",
+ "The LXC has no Bash to run this script with. The addon was not run.": "El LXC no tiene Bash disponible para ejecutar este script. No se ha ejecutado el addon.",
"The LXC has stopped": "El LXC ha parado",
+ "The LXC is stopped. Do you want to start it to run the addon?": "El LXC está detenido. ¿Quieres arrancarlo para ejecutar el addon?",
"The Lounge starts in public mode: anyone who reaches the address opens the client without logging in, and the IRC networks added are lost when the session ends.": "The Lounge arranca en modo público: cualquiera que llegue a la dirección abre el cliente sin iniciar sesión, y las redes IRC añadidas se pierden al terminar la sesión.",
"The MAC address of the container cannot be kept": "La dirección MAC del contenedor no se puede mantener",
"The NVIDIA Container Toolkit repository definition was empty.": "La definición del repositorio de NVIDIA Container Toolkit estaba vacía.",
@@ -6563,6 +6614,7 @@
"Then run this option again:": "Luego ejecute esta opción nuevamente:",
"Then update /etc/fstab on the host with the same options.": "Luego actualice /etc/fstab en el host con las mismas opciones.",
"There are extra disks or bind mounts outside the journal; the rootfs is not replaced": "Hay discos o bind mounts adicionales fuera del diario de la operación; el rootfs no se reemplaza",
+ "There are no LXC containers available. OCI containers are not included.": "No hay contenedores LXC disponibles. Los contenedores OCI no se incluyen.",
"There is no temporary container of this operation to keep the current disks": "No hay contenedor temporal de esta operación para mantener los discos actuales",
"There is no verified backup; a modified container is not touched": "No hay backup verificado; un contenedor modificado no se toca",
"These Proxmox resources were added outside ProxMenux:": "Se añadieron estos recursos en Proxmox fuera de ProxMenux:",
@@ -6575,6 +6627,7 @@
"These manual changes cannot be adopted safely:": "Estos cambios manuales no se pueden incorporar con seguridad:",
"These paths will not be restored live and will be extracted for manual recovery.": "Estas rutas no se restaurarán en vivo y se extraerán para su recuperación manual.",
"These values are asked during the installation:": "Estos valores se preguntan durante la instalación:",
+ "They are described in the image documentation:": "Están descritos en la documentación de la imagen:",
"They will be added to oci-compose before continuing. Unsupported or changed resources are not imported.": "Se añadirán al oci-compose antes de continuar. No se importarán recursos incompatibles o modificados.",
"This CIFS share is mounted with restrictive permissions.": "Este recurso compartido CIFS está montado con permisos restrictivos.",
"This GPU is considered incompatible with GPU passthrough to a VM in ProxMenux.": "Esta GPU se considera incompatible con el passthrough de GPU a una VM en ProxMenux.",
@@ -6584,6 +6637,7 @@
"This application cannot be recovered yet:": "Esta aplicación todavía no se puede recuperar:",
"This application cannot be recovered yet; nothing was changed:": "Esta aplicación todavía no se puede recuperar; no se ha cambiado nada:",
"This application is not an Immich installed by ProxMenux": "Esta aplicación no es un Immich instalado por ProxMenux",
+ "This application is not in the OCI catalog.": "Esta aplicación no está en el catálogo OCI.",
"This application is under watchdog: it is restarted automatically when it crashes. Turn the watchdog off?": "Esta aplicación está bajo vigilancia: se reinicia automáticamente cuando falla. ¿Desactivar la vigilancia?",
"This archive does not contain a recognized backup layout.": "Este archivo no contiene un diseño de backup reconocido.",
"This backup does not contain any restorable paths.": "Este backup no contiene rutas restaurables.",
@@ -7030,6 +7084,9 @@
"User name of the administrator of the web interface": "Nombre de usuario del administrador de la interfaz web",
"User name of the deprecated Flowise application login (only read by Flowise versions before 3.0.1)": "Nombre de usuario del acceso antiguo de la aplicación Flowise (solo lo leen las versiones de Flowise anteriores a 3.0.1)",
"User of the AdGuard Home that receives the settings": "Usuario del AdGuard Home que recibe la configuración",
+ "User of the MariaDB server": "Usuario del servidor MariaDB",
+ "User of the PostgreSQL server": "Usuario del servidor PostgreSQL",
+ "User of the database server (mysql and pgsql only)": "Usuario del servidor de base de datos (solo mysql y pgsql)",
"User of the main AdGuard Home": "Usuario del AdGuard Home principal",
"User-friendly WebUI for LLMs (Formerly Ollama WebUI)": "WebUI fácil de usar para LLM (antes Ollama WebUI)",
"Username": "Nombre de usuario",
@@ -7265,6 +7322,7 @@
"Where do you want to mount inside container?": "¿Dónde desea montar dentro del contenedor?",
"Where do you want to mount the NFS export?": "¿Dónde desea montar la exportación NFS?",
"Where do you want to mount the Samba share?": "¿Dónde quieres montar el recurso compartido Samba?",
+ "Where do you want to run this addon?": "¿Dónde quieres ejecutar este addon?",
"Where is the OVA/OVF file located?": "¿Dónde se encuentra el archivo OVA/OVF?",
"Where to mount inside container?": "¿Dónde montarlo dentro del contenedor?",
"Where to store": "Dónde almacenar",
@@ -7482,6 +7540,7 @@
"experimental on this GPU": "experimental en esta GPU",
"ext4 — Proxmox dir storage (recommended)": "ext4 — dir de Proxmox (recomendado)",
"ext4 — recommended, most compatible": "ext4 — recomendado, el más compatible",
+ "external, already running": "externo, ya en funcionamiento",
"fail2ban-client could not communicate with the server": "fail2ban-client no pudo comunicarse con el servidor",
"fail2ban-client successfully communicated with the server": "fail2ban-client se comunicó correctamente con el servidor",
"failed": "fallido",
@@ -7656,6 +7715,7 @@
"needs a privileged LXC": "necesita un LXC privilegiado",
"needs a relaxed AppArmor or seccomp profile": "necesita un perfil de AppArmor o seccomp relajado",
"needs stack review": "necesita revisión de pila",
+ "needs these external services, already running:": "necesita estos servicios externos, ya en funcionamiento:",
"never": "nunca",
"next free": "siguiente libre",
"next free block": "siguiente bloque libre",
diff --git a/oci/VALIDATION.md b/oci/VALIDATION.md
index 339a016d..9e760381 100644
--- a/oci/VALIDATION.md
+++ b/oci/VALIDATION.md
@@ -2,7 +2,7 @@
-43 of 336 applications in the OCI catalog have been tested for real: 41 in the ProxMenux lab and 2 by the community. The OCI installer shows them as verified, with a ✓ in the lists.
+43 of 334 applications in the OCI catalog have been tested for real: 41 in the ProxMenux lab and 2 by the community. The OCI installer shows them as verified, with a ✓ in the lists.
Each test describes the scenario that was run and names the image it ran on. It does not cover every possible configuration of the application, and a newer image has not been tested until someone reports it.
diff --git a/oci/catalog/apps/2fauth.json b/oci/catalog/apps/2fauth.json
index 32aec4b5..468010de 100644
--- a/oci/catalog/apps/2fauth.json
+++ b/oci/catalog/apps/2fauth.json
@@ -233,7 +233,7 @@
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
- "memory_mb": 128,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/apps/actualbudget.json b/oci/catalog/apps/actualbudget.json
index 3ac8a4a6..94d9a264 100644
--- a/oci/catalog/apps/actualbudget.json
+++ b/oci/catalog/apps/actualbudget.json
@@ -210,7 +210,7 @@
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
- "memory_mb": 128,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/apps/adguard-home.json b/oci/catalog/apps/adguard-home.json
index d811c81d..b3df1c3d 100644
--- a/oci/catalog/apps/adguard-home.json
+++ b/oci/catalog/apps/adguard-home.json
@@ -291,7 +291,7 @@
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
- "memory_mb": 128,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/apps/adminer.json b/oci/catalog/apps/adminer.json
index dbd75a29..a0e1e433 100644
--- a/oci/catalog/apps/adminer.json
+++ b/oci/catalog/apps/adminer.json
@@ -170,7 +170,7 @@
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
- "memory_mb": 128,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/apps/alist.json b/oci/catalog/apps/alist.json
index 11511147..4b6da070 100644
--- a/oci/catalog/apps/alist.json
+++ b/oci/catalog/apps/alist.json
@@ -211,7 +211,7 @@
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
- "memory_mb": 128,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/apps/archivebox.json b/oci/catalog/apps/archivebox.json
index f85fdb8a..b6e4d018 100644
--- a/oci/catalog/apps/archivebox.json
+++ b/oci/catalog/apps/archivebox.json
@@ -513,7 +513,7 @@
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
- "memory_mb": 128,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/apps/audiobookshelf.json b/oci/catalog/apps/audiobookshelf.json
index 54203ad0..f211b591 100644
--- a/oci/catalog/apps/audiobookshelf.json
+++ b/oci/catalog/apps/audiobookshelf.json
@@ -286,7 +286,7 @@
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
- "memory_mb": 128,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/apps/bentopdf.json b/oci/catalog/apps/bentopdf.json
index 41695b68..ce275314 100644
--- a/oci/catalog/apps/bentopdf.json
+++ b/oci/catalog/apps/bentopdf.json
@@ -214,7 +214,7 @@
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
- "memory_mb": 128,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/apps/bitcoin-knots.json b/oci/catalog/apps/bitcoin-knots.json
index 17d8854c..1d059d4a 100644
--- a/oci/catalog/apps/bitcoin-knots.json
+++ b/oci/catalog/apps/bitcoin-knots.json
@@ -48,7 +48,9 @@
}
],
"display_version": null,
- "updated_at": "2026-04-03"
+ "updated_at": "2026-04-03",
+ "hidden": true,
+ "hidden_reason": "Its image stops without closing the application cleanly, also under Docker; hidden until the image is fixed"
},
"source": {
"provider": "linuxserver.io",
diff --git a/oci/catalog/apps/blinko.json b/oci/catalog/apps/blinko.json
index 71bd6270..e5bb7665 100644
--- a/oci/catalog/apps/blinko.json
+++ b/oci/catalog/apps/blinko.json
@@ -364,7 +364,7 @@
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
- "memory_mb": 512,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/apps/chatgpt-next-web.json b/oci/catalog/apps/chatgpt-next-web.json
index 19c3f277..3b21e01f 100644
--- a/oci/catalog/apps/chatgpt-next-web.json
+++ b/oci/catalog/apps/chatgpt-next-web.json
@@ -226,7 +226,7 @@
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
- "memory_mb": 128,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/apps/cloudbeaver.json b/oci/catalog/apps/cloudbeaver.json
index 036e27a1..4d1758f1 100644
--- a/oci/catalog/apps/cloudbeaver.json
+++ b/oci/catalog/apps/cloudbeaver.json
@@ -213,7 +213,7 @@
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
- "memory_mb": 256,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/apps/convertx.json b/oci/catalog/apps/convertx.json
index ae1c7dc0..33e4cf79 100644
--- a/oci/catalog/apps/convertx.json
+++ b/oci/catalog/apps/convertx.json
@@ -237,7 +237,7 @@
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
- "memory_mb": 500,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/apps/copyparty.json b/oci/catalog/apps/copyparty.json
index 2345eeb3..1676786a 100644
--- a/oci/catalog/apps/copyparty.json
+++ b/oci/catalog/apps/copyparty.json
@@ -244,7 +244,7 @@
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
- "memory_mb": 512,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/apps/ddns-go.json b/oci/catalog/apps/ddns-go.json
index 7f431509..32595a79 100644
--- a/oci/catalog/apps/ddns-go.json
+++ b/oci/catalog/apps/ddns-go.json
@@ -211,7 +211,7 @@
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
- "memory_mb": 128,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/apps/deepseek-ocr-nvidia.json b/oci/catalog/apps/deepseek-ocr-nvidia.json
index b5d3e800..09db6503 100644
--- a/oci/catalog/apps/deepseek-ocr-nvidia.json
+++ b/oci/catalog/apps/deepseek-ocr-nvidia.json
@@ -261,7 +261,7 @@
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
- "memory_mb": 512,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/apps/docmost.json b/oci/catalog/apps/docmost.json
index 69b16273..a2395275 100644
--- a/oci/catalog/apps/docmost.json
+++ b/oci/catalog/apps/docmost.json
@@ -371,7 +371,7 @@
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
- "memory_mb": 512,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/apps/duplicati-official.json b/oci/catalog/apps/duplicati-official.json
new file mode 100644
index 00000000..a9850ac6
--- /dev/null
+++ b/oci/catalog/apps/duplicati-official.json
@@ -0,0 +1,555 @@
+{
+ "schema_version": "0.5.0",
+ "kind": "proxmenux.oci-template",
+ "id": "image-duplicati-official",
+ "status": "generated-unvalidated",
+ "catalog_ui": {
+ "title": {
+ "en_US": "Duplicati Official"
+ },
+ "tagline": {
+ "en_US": "Official Duplicati image: encrypted, incremental backups to local, network and cloud storage."
+ },
+ "description": {
+ "en_US": "Official Duplicati image: encrypted, incremental backups to local, network and cloud storage."
+ },
+ "category": "backup",
+ "category_label": "Backup & Recovery",
+ "author": "Duplicati",
+ "developer": "Duplicati",
+ "icon": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/duplicati.webp",
+ "thumbnail": null,
+ "screenshots": [],
+ "architectures": [
+ "amd64",
+ "arm64"
+ ],
+ "launch": {
+ "scheme": "http",
+ "port": 8200,
+ "path": "/"
+ },
+ "website": "https://www.duplicati.com/",
+ "documentation": "https://docs.duplicati.com/",
+ "repository": "https://github.com/duplicati/duplicati",
+ "tips": [],
+ "mini_changelog": [],
+ "display_version": null,
+ "updated_at": null
+ },
+ "source": {
+ "provider": "duplicati",
+ "repository": "https://github.com/duplicati/duplicati",
+ "revision": "66c05069de88ccb6054907665ef7813e0cf664f4e1a2032cd943a1b8d72e8530",
+ "image_repository_url": "https://hub.docker.com/r/duplicati/duplicati",
+ "readme_pushed_at": "",
+ "compose_sha256": "66c05069de88ccb6054907665ef7813e0cf664f4e1a2032cd943a1b8d72e8530",
+ "generated_at": "2026-10-11T09:05:05+00:00"
+ },
+ "container_contract": {
+ "service_name": "duplicati",
+ "container_name": "duplicati",
+ "image": {
+ "reference": "duplicati/duplicati:latest",
+ "registry": "docker.io",
+ "repository": "duplicati/duplicati",
+ "tag": "latest",
+ "digest": null,
+ "pull_policy": "resolve-written-reference-to-architecture-digest-at-install"
+ },
+ "environment": [
+ {
+ "name": "SETTINGS_ENCRYPTION_KEY",
+ "example": "",
+ "required": true,
+ "sensitive": true,
+ "source": "docker-compose"
+ },
+ {
+ "name": "DUPLICATI__WEBSERVICE_PASSWORD",
+ "example": "",
+ "required": true,
+ "sensitive": true,
+ "source": "docker-compose",
+ "prompt": "Password of the Duplicati web interface"
+ }
+ ],
+ "volumes": [
+ {
+ "id": "volume-0",
+ "container_path": "/data",
+ "compose_source_example": "./data",
+ "read_only": false,
+ "required": true,
+ "installation_choice": [
+ "managed-volume",
+ "host-bind"
+ ],
+ "default": "managed-volume",
+ "managed_volume": {
+ "backup": true,
+ "default_size_gb": 8
+ }
+ },
+ {
+ "id": "volume-1",
+ "container_path": "/backups",
+ "compose_source_example": "./backups",
+ "read_only": false,
+ "required": true,
+ "installation_choice": [
+ "managed-volume",
+ "host-bind"
+ ],
+ "default": "managed-volume",
+ "managed_volume": {
+ "backup": true,
+ "default_size_gb": 8
+ }
+ },
+ {
+ "id": "volume-2",
+ "container_path": "/source",
+ "compose_source_example": "./source",
+ "read_only": false,
+ "required": true,
+ "installation_choice": [
+ "managed-volume",
+ "host-bind"
+ ],
+ "default": "managed-volume",
+ "managed_volume": {
+ "backup": true,
+ "default_size_gb": 8
+ }
+ }
+ ],
+ "ports": [
+ {
+ "container_port": 8200,
+ "published_example": 8200,
+ "protocol": "tcp",
+ "required": true,
+ "proxmox_behavior": "listener-on-dedicated-lxc-address-no-nat"
+ }
+ ],
+ "related_services": [],
+ "restart": "unless-stopped",
+ "stop_grace_period": null,
+ "original_compose": "services:\n duplicati:\n image: duplicati/duplicati:latest\n container_name: duplicati\n ports:\n - 8200:8200\n volumes:\n - ./data:/data\n - ./backups:/backups\n - ./source:/source\n environment:\n - SETTINGS_ENCRYPTION_KEY=${GENERATED_SETTINGS_ENCRYPTION_KEY}\n - DUPLICATI__WEBSERVICE_PASSWORD=${GENERATED_DUPLICATI_WEBSERVICE_PASSWORD}\n restart: unless-stopped\n"
+ },
+ "compose_stack": {
+ "project_name": "duplicati",
+ "deployment_model": "one-native-oci-lxc-per-compose-service",
+ "user_experience": "single-application-install",
+ "main_service": "duplicati",
+ "service_count": 1,
+ "services": [
+ {
+ "name": "duplicati",
+ "image": "duplicati/duplicati:latest",
+ "is_main": true,
+ "role": "frontend",
+ "vmid_offset": 0,
+ "depends_on": [],
+ "frontend_network": true,
+ "private_network": false,
+ "compose": {
+ "image": "duplicati/duplicati:latest",
+ "container_name": "duplicati",
+ "ports": [
+ "8200:8200"
+ ],
+ "volumes": [
+ "./data:/data",
+ "./backups:/backups",
+ "./source:/source"
+ ],
+ "environment": [
+ "SETTINGS_ENCRYPTION_KEY=${GENERATED_SETTINGS_ENCRYPTION_KEY}",
+ "DUPLICATI__WEBSERVICE_PASSWORD=${GENERATED_DUPLICATI_WEBSERVICE_PASSWORD}"
+ ],
+ "restart": "unless-stopped"
+ }
+ }
+ ],
+ "top_level": {},
+ "networking": {
+ "frontend": "selected-proxmox-bridge",
+ "private_required": false,
+ "private_creation": "automatic-create-if-missing",
+ "private_address_allocation": "automatic-static-address-per-service",
+ "service_discovery": "private-addresses-with-compose-service-host-aliases",
+ "dependency_external_access": "disabled-unless-service-publishes-ports",
+ "prompt_user_for_private_network": false
+ },
+ "storage": [
+ {
+ "id": "duplicati-volume-0",
+ "service": "duplicati",
+ "container_path": "/data",
+ "mode": "managed-volume",
+ "user_selectable": false,
+ "backup": true,
+ "shared_with_other_lxc": false,
+ "source_path": null,
+ "source_path_prompt": null
+ },
+ {
+ "id": "duplicati-volume-1",
+ "service": "duplicati",
+ "container_path": "/backups",
+ "mode": "managed-volume",
+ "user_selectable": false,
+ "backup": true,
+ "shared_with_other_lxc": false,
+ "source_path": null,
+ "source_path_prompt": null
+ },
+ {
+ "id": "duplicati-volume-2",
+ "service": "duplicati",
+ "container_path": "/source",
+ "mode": "managed-volume",
+ "user_selectable": false,
+ "backup": true,
+ "shared_with_other_lxc": false,
+ "source_path": null,
+ "source_path_prompt": null
+ }
+ ],
+ "orchestration": {
+ "reserve_vmids_atomically": 1,
+ "start_order": [
+ "duplicati"
+ ],
+ "stop_order": [
+ "duplicati"
+ ],
+ "dependency_readiness": "compose-healthcheck-then-port-or-process-fallback",
+ "rollback_on_failure": "remove-new-rootfs-preserve-created-persistent-volumes"
+ },
+ "installer_inputs": {
+ "prompted": [
+ "stack_name",
+ "base_vmid",
+ "rootfs_storage",
+ "persistent_data_destinations",
+ "frontend_bridge",
+ "frontend_ipv4_mode"
+ ],
+ "automatic": [
+ "dependent_vmids",
+ "private_bridge",
+ "private_subnet",
+ "private_service_addresses",
+ "compose_service_aliases",
+ "generated_secrets",
+ "dependency_start_and_stop_order"
+ ],
+ "generated_secrets": [
+ {
+ "id": "duplicati-webservice-password",
+ "strategy": "generate-cryptographically-random-at-install",
+ "bindings": [
+ {
+ "service": "duplicati",
+ "environment_variable": "DUPLICATI__WEBSERVICE_PASSWORD"
+ }
+ ]
+ },
+ {
+ "id": "settings-encryption-key",
+ "strategy": "generate-cryptographically-random-at-install",
+ "bindings": [
+ {
+ "service": "duplicati",
+ "environment_variable": "SETTINGS_ENCRYPTION_KEY"
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "first_run": {
+ "endpoints": [
+ {
+ "label": "Web UI",
+ "scheme": "http",
+ "port": 8200,
+ "path": "/",
+ "source": "compose-metadata"
+ }
+ ],
+ "credentials": [
+ {
+ "label": "Duplicati web interface (password only)",
+ "type": "configured-or-installer-generated",
+ "username": "Not required (password only)",
+ "password": null,
+ "password_environment": "DUPLICATI__WEBSERVICE_PASSWORD",
+ "change_required": false,
+ "source": "duplicati-documentation",
+ "retrieval": null
+ }
+ ]
+ },
+ "proxmox": {
+ "runtime": "native-oci-lxc",
+ "technology_status": "proxmox-technology-preview",
+ "defaults": {
+ "unprivileged": true,
+ "ostype": "auto-from-image",
+ "cores": 2,
+ "memory_mb": 1024,
+ "swap_mb": 512,
+ "rootfs_size_gb": 8,
+ "rootfs_storage": "local-lvm",
+ "volume_storage": "local-lvm",
+ "template_storage": "local",
+ "bridge": "vmbr0",
+ "ipv4": "dhcp",
+ "firewall": true,
+ "host_managed_network": true,
+ "onboot": false,
+ "features": [
+ "nesting=1"
+ ],
+ "shutdown_timeout_seconds": 30
+ },
+ "image_metadata_policy": {
+ "entrypoint": "import-from-oci-image",
+ "cmd": "import-from-oci-image",
+ "environment": "import-image-env-then-apply-compose-overrides",
+ "user": "import-from-oci-image",
+ "working_dir": "import-from-oci-image",
+ "stop_signal": "import-from-oci-image"
+ },
+ "installer_profile": {
+ "generated_sensitive_environment": {
+ "SETTINGS_ENCRYPTION_KEY": {
+ "strategy": "token-hex",
+ "bytes": 16,
+ "prompt": false
+ },
+ "DUPLICATI__WEBSERVICE_PASSWORD": {
+ "strategy": "token-hex",
+ "bytes": 12,
+ "prompt": true
+ }
+ },
+ "volume_preparations": [
+ {
+ "container_path": "/data",
+ "remove_lost_found": true,
+ "owner_strategy": "mapped-application-user",
+ "only_when_mount_type": "managed-volume"
+ },
+ {
+ "container_path": "/backups",
+ "remove_lost_found": true,
+ "owner_strategy": "mapped-application-user",
+ "only_when_mount_type": "managed-volume"
+ },
+ {
+ "container_path": "/source",
+ "remove_lost_found": true,
+ "owner_strategy": "mapped-application-user",
+ "only_when_mount_type": "managed-volume"
+ }
+ ]
+ },
+ "adaptations": [
+ {
+ "id": "imported-compose-source",
+ "upstream_behavior": "The source definition deploys the complete Docker Compose application model.",
+ "native_lxc_behavior": "The source model is preserved and remains blocked until every service option has a reviewed native Proxmox mapping.",
+ "reason": "Catalog import must not imply runtime compatibility.",
+ "behavioral_impact": "No automatic installation before review.",
+ "validation": "pending-per-application"
+ },
+ {
+ "id": "rolling-latest-image",
+ "upstream_behavior": "A discovered Compose may pin a release tag or digest.",
+ "native_lxc_behavior": "ProxMenux selects the same image repository with the latest tag for catalog installations.",
+ "reason": "The automatic catalog intentionally offers rolling latest images; pinned versions belong to the future manual installer.",
+ "behavioral_impact": "The installed release can be newer than the discovered Compose revision.",
+ "validation": "pending-per-application"
+ },
+ {
+ "id": "dedicated-lxc-network",
+ "upstream_behavior": "Docker publishes selected container ports on the Docker host.",
+ "native_lxc_behavior": "A reviewed native application will listen on its original container ports at a dedicated LXC address.",
+ "reason": "A native LXC has its own address and does not require Docker port NAT.",
+ "behavioral_impact": "Published ports are metadata; ProxMenux URLs use the matching container target port.",
+ "validation": "pending-per-application"
+ },
+ {
+ "id": "compose-shm-size",
+ "upstream_behavior": "Compose sets the size of the container /dev/shm tmpfs.",
+ "native_lxc_behavior": "ProxMenux mounts a native LXC tmpfs at /dev/shm with the same requested capacity.",
+ "reason": "The OCI image runs directly as an LXC and therefore needs the equivalent Proxmox mount entry.",
+ "behavioral_impact": "None expected.",
+ "validation": "not-requested-by-compose"
+ },
+ {
+ "id": "compose-command",
+ "upstream_behavior": "Compose replaces the image Cmd while retaining its Entrypoint.",
+ "native_lxc_behavior": "ProxMenux reads the official OCI Entrypoint and combines it with the Compose command as the native LXC init command.",
+ "reason": "Proxmox stores the effective OCI process as one entrypoint string.",
+ "behavioral_impact": "None expected.",
+ "validation": "not-requested-by-compose"
+ },
+ {
+ "id": "compose-privileged-mode",
+ "upstream_behavior": "Compose selects whether the container runs in privileged mode.",
+ "native_lxc_behavior": "ProxMenux keeps the LXC unprivileged by default and exposes the broad Compose privilege request only as an explicit compatibility option.",
+ "reason": "A Compose privilege request is source metadata, not proof that the image technically requires a privileged LXC.",
+ "behavioral_impact": "A privileged LXC has weaker isolation from the Proxmox host.",
+ "validation": "native-equivalent"
+ },
+ {
+ "id": "compose-process-runtime",
+ "upstream_behavior": "Compose can replace Entrypoint, User and WorkingDir and request an init process or interactive terminal.",
+ "native_lxc_behavior": "ProxMenux applies the process overrides through native LXC init directives; lxc-init provides PID 1 supervision and the CT console provides terminal access.",
+ "reason": "The OCI process must start with the same identity, command and working directory without Docker.",
+ "behavioral_impact": "Compose stdin_open and tty become access through the Proxmox LXC console.",
+ "validation": "not-requested-by-compose"
+ },
+ {
+ "id": "compose-healthcheck",
+ "upstream_behavior": "Docker periodically executes the declared container healthcheck.",
+ "native_lxc_behavior": "For a single-service LXC, ProxMenux translates HTTP localhost checks into a mandatory first-start service check.",
+ "reason": "Proxmox has no persistent Docker health state, while the installer still must detect a failed first boot.",
+ "behavioral_impact": "The check runs during installation rather than continuously after installation.",
+ "validation": "not-requested-by-compose"
+ },
+ {
+ "id": "compose-cpu-priority",
+ "upstream_behavior": "Docker cpu_shares sets a relative scheduling weight with 1024 as its neutral value.",
+ "native_lxc_behavior": "ProxMenux converts the relative weight to Proxmox cpuunits with 100 as its neutral value and lets the user review it.",
+ "reason": "Both settings express relative CPU priority on different scales.",
+ "behavioral_impact": "Rounding and Proxmox minimum limits can slightly change very low weights.",
+ "validation": "not-requested-by-compose"
+ },
+ {
+ "id": "compose-resource-limits",
+ "upstream_behavior": "mem_limit sets the memory ceiling; ulimits sets process soft/hard resource limits.",
+ "native_lxc_behavior": "mem_limit supplies the editable Proxmox memory default, rounded up to MiB; ulimits maps to lxc.prlimit with -1 represented as unlimited.",
+ "reason": "Use native Proxmox memory and LXC prlimits, without a wrapper or changing the host kernel configuration.",
+ "behavioral_impact": "User-selected memory overrides Compose. Swap is a separate choice. Kernel restrictions still apply; nproc counts processes by real UID, not by container.",
+ "validation": "not-requested-by-compose"
+ },
+ {
+ "id": "compose-network-identity",
+ "upstream_behavior": "Compose can set hostname, MAC address, extra hosts and attach a service to Docker networks.",
+ "native_lxc_behavior": "ProxMenux applies hostname and MAC to net0, writes additional host aliases into the LXC and uses its dedicated bridge connection for single-service networks.",
+ "reason": "A dedicated LXC has its own network namespace and does not need a Docker bridge per service.",
+ "behavioral_impact": "host-gateway resolves to the IPv4 address of the selected Proxmox bridge.",
+ "validation": "not-requested-by-compose"
+ },
+ {
+ "id": "compose-network-mode",
+ "upstream_behavior": "Docker host mode removes Docker network isolation; bridge and default use a Docker-managed network.",
+ "native_lxc_behavior": "The OCI process uses the dedicated LXC network namespace directly, so host, bridge and default all listen on the LXC address without Docker NAT.",
+ "reason": "The LXC is the application host and already has its own address and port namespace.",
+ "behavioral_impact": "host means the LXC host, never the Proxmox host; this preserves Proxmox network isolation.",
+ "validation": "not-requested-by-compose"
+ },
+ {
+ "id": "compose-capabilities-and-sysctls",
+ "upstream_behavior": "Compose can add Linux capabilities and set kernel parameters in the container network namespace.",
+ "native_lxc_behavior": "ProxMenux validates requested capabilities against the native LXC capability set and writes namespaced network settings as lxc.sysctl directives.",
+ "reason": "A native OCI-LXC already starts with the namespaced capability set; lxc.cap.keep would incorrectly discard unrelated required capabilities.",
+ "behavioral_impact": "Host-global capabilities such as SYS_MODULE remain blocked until their host prerequisite is explicitly adapted.",
+ "validation": "not-requested-by-compose"
+ },
+ {
+ "id": "docker-engine-metadata",
+ "upstream_behavior": "Compose labels annotate Docker objects and the json-file logging driver rotates Docker-managed logs.",
+ "native_lxc_behavior": "Labels remain source metadata; Docker json-file settings are not applied because the OCI process runs directly under LXC.",
+ "reason": "There is no Docker object or Docker json-file log behind a native OCI-LXC application.",
+ "behavioral_impact": "Docker-only label consumers and Docker log-driver rotation do not exist in the native deployment.",
+ "validation": "not-requested-by-compose"
+ },
+ {
+ "id": "compose-device-passthrough",
+ "upstream_behavior": "Compose passes host character devices or requests an NVIDIA runtime GPU.",
+ "native_lxc_behavior": "ProxMenux converts recognized device declarations to native Proxmox dev resources; NVIDIA profiles also inject compatible host driver libraries read-only.",
+ "reason": "Native OCI-LXC does not execute Docker device or NVIDIA runtime hooks.",
+ "behavioral_impact": "Hardware is exposed only after explicit user confirmation and host-path validation.",
+ "validation": "not-requested-by-compose"
+ },
+ {
+ "id": "compose-host-ipc",
+ "upstream_behavior": "ipc: host shares the Docker host IPC namespace, commonly to avoid Docker's small default shared-memory allocation.",
+ "native_lxc_behavior": "The application keeps the LXC IPC namespace and receives a configurable 1 GiB /dev/shm instead of sharing Proxmox host IPC.",
+ "reason": "Processes in a single native LXC already share one IPC namespace; retaining isolation is safer than exposing host IPC.",
+ "behavioral_impact": "The application cannot exchange IPC objects with processes on the Proxmox host.",
+ "validation": "not-requested-by-compose"
+ }
+ ]
+ },
+ "compatibility": {
+ "automatic_install_candidate": true,
+ "validated": false,
+ "supported_compose_keys": [
+ "cap_add",
+ "command",
+ "container_name",
+ "cpu_shares",
+ "deploy",
+ "devices",
+ "entrypoint",
+ "environment",
+ "extra_hosts",
+ "group_add",
+ "healthcheck",
+ "hostname",
+ "image",
+ "init",
+ "ipc",
+ "labels",
+ "logging",
+ "mac_address",
+ "mem_limit",
+ "network_mode",
+ "networks",
+ "ports",
+ "privileged",
+ "restart",
+ "runtime",
+ "security_opt",
+ "shm_size",
+ "stdin_open",
+ "stop_grace_period",
+ "sysctls",
+ "tty",
+ "ulimits",
+ "user",
+ "volumes",
+ "working_dir"
+ ],
+ "untranslated_blockers": [],
+ "policy": "Single-image definitions are installable when every declared Compose option has a native Proxmox translation. Multi-image and unsupported runtime features remain blocked until their orchestrator or mapping is available."
+ },
+ "validation": {
+ "schema": "passed-at-generation",
+ "clean_install": "pending",
+ "service_health": "pending",
+ "restart_persistence": "pending",
+ "backup_restore": "pending",
+ "update_preserves_data": "pending"
+ },
+ "lifecycle": {
+ "update_strategy": "resolve-latest-image-then-apply-reviewed-native-lxc-update",
+ "registry_state": {
+ "resolved_architecture": null,
+ "resolved_digest": null,
+ "image_version_label": null,
+ "image_created": null
+ },
+ "change_detection": "compare-compose-sha256-and-resolved-latest-image-digest",
+ "automatic_unattended_updates": false
+ }
+}
diff --git a/oci/catalog/apps/embystat.json b/oci/catalog/apps/embystat.json
index 30b1f655..4472fca6 100644
--- a/oci/catalog/apps/embystat.json
+++ b/oci/catalog/apps/embystat.json
@@ -238,7 +238,7 @@
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
- "memory_mb": 128,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/apps/emulatorjs.json b/oci/catalog/apps/emulatorjs.json
index 13718c52..d925f08a 100644
--- a/oci/catalog/apps/emulatorjs.json
+++ b/oci/catalog/apps/emulatorjs.json
@@ -35,7 +35,9 @@
"tips": [],
"mini_changelog": [],
"display_version": null,
- "updated_at": null
+ "updated_at": null,
+ "hidden": true,
+ "hidden_reason": "Its image no longer publishes a version for any architecture; hidden until it does"
},
"source": {
"provider": "linuxserver.io",
diff --git a/oci/catalog/apps/esphome.json b/oci/catalog/apps/esphome.json
index f34a54f2..478002f6 100644
--- a/oci/catalog/apps/esphome.json
+++ b/oci/catalog/apps/esphome.json
@@ -231,7 +231,7 @@
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
- "memory_mb": 512,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/apps/etherpad.json b/oci/catalog/apps/etherpad.json
index e3040cab..7e8b5d27 100644
--- a/oci/catalog/apps/etherpad.json
+++ b/oci/catalog/apps/etherpad.json
@@ -318,7 +318,7 @@
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
- "memory_mb": 256,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/apps/excalidraw.json b/oci/catalog/apps/excalidraw.json
index ef6c1654..0df7f47c 100644
--- a/oci/catalog/apps/excalidraw.json
+++ b/oci/catalog/apps/excalidraw.json
@@ -173,7 +173,7 @@
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
- "memory_mb": 256,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/apps/firefly.json b/oci/catalog/apps/firefly.json
index 81db99f2..ba44cbd0 100644
--- a/oci/catalog/apps/firefly.json
+++ b/oci/catalog/apps/firefly.json
@@ -249,7 +249,7 @@
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
- "memory_mb": 128,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/apps/flaresolverr.json b/oci/catalog/apps/flaresolverr.json
index 07f69f0b..3c5e48c1 100644
--- a/oci/catalog/apps/flaresolverr.json
+++ b/oci/catalog/apps/flaresolverr.json
@@ -237,7 +237,7 @@
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
- "memory_mb": 128,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/apps/flowise.json b/oci/catalog/apps/flowise.json
index ec7a1cd3..b5bad139 100644
--- a/oci/catalog/apps/flowise.json
+++ b/oci/catalog/apps/flowise.json
@@ -300,7 +300,7 @@
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
- "memory_mb": 256,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/apps/gateway-go.json b/oci/catalog/apps/gateway-go.json
index fd4ccb11..09eec148 100644
--- a/oci/catalog/apps/gateway-go.json
+++ b/oci/catalog/apps/gateway-go.json
@@ -211,7 +211,7 @@
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
- "memory_mb": 128,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/apps/gitea.json b/oci/catalog/apps/gitea.json
index e8e3aa2e..5b02d08b 100644
--- a/oci/catalog/apps/gitea.json
+++ b/oci/catalog/apps/gitea.json
@@ -242,7 +242,7 @@
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
- "memory_mb": 256,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/apps/glances.json b/oci/catalog/apps/glances.json
index 02a6c1f3..c441cce7 100644
--- a/oci/catalog/apps/glances.json
+++ b/oci/catalog/apps/glances.json
@@ -243,7 +243,7 @@
"unprivileged": false,
"ostype": "unmanaged",
"cores": 2,
- "memory_mb": 512,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/apps/grafana.json b/oci/catalog/apps/grafana.json
index 81d193df..472bd03b 100644
--- a/oci/catalog/apps/grafana.json
+++ b/oci/catalog/apps/grafana.json
@@ -211,7 +211,7 @@
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
- "memory_mb": 128,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/apps/homeassistant-official.json b/oci/catalog/apps/homeassistant-official.json
index af324258..1e263365 100644
--- a/oci/catalog/apps/homeassistant-official.json
+++ b/oci/catalog/apps/homeassistant-official.json
@@ -288,7 +288,7 @@
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
- "memory_mb": 256,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/apps/hugo.json b/oci/catalog/apps/hugo.json
index 6a285770..8e8de341 100644
--- a/oci/catalog/apps/hugo.json
+++ b/oci/catalog/apps/hugo.json
@@ -247,7 +247,7 @@
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
- "memory_mb": 256,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/apps/karakeep.json b/oci/catalog/apps/karakeep.json
index 76e7dc1d..d943f6a8 100644
--- a/oci/catalog/apps/karakeep.json
+++ b/oci/catalog/apps/karakeep.json
@@ -407,7 +407,7 @@
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
- "memory_mb": 512,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/apps/kavita-jvmilazz0.json b/oci/catalog/apps/kavita-jvmilazz0.json
index ba19aa34..6684e8d5 100644
--- a/oci/catalog/apps/kavita-jvmilazz0.json
+++ b/oci/catalog/apps/kavita-jvmilazz0.json
@@ -254,7 +254,7 @@
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
- "memory_mb": 128,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/apps/komga.json b/oci/catalog/apps/komga.json
index c258f5cb..4f656f34 100644
--- a/oci/catalog/apps/komga.json
+++ b/oci/catalog/apps/komga.json
@@ -294,7 +294,7 @@
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
- "memory_mb": 128,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/apps/label-studio.json b/oci/catalog/apps/label-studio.json
index 1b541fb8..7b8739ad 100644
--- a/oci/catalog/apps/label-studio.json
+++ b/oci/catalog/apps/label-studio.json
@@ -237,7 +237,7 @@
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
- "memory_mb": 256,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/apps/libredb-studio.json b/oci/catalog/apps/libredb-studio.json
index ec034ab1..14d4da2e 100644
--- a/oci/catalog/apps/libredb-studio.json
+++ b/oci/catalog/apps/libredb-studio.json
@@ -296,7 +296,7 @@
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
- "memory_mb": 256,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/apps/linkwarden.json b/oci/catalog/apps/linkwarden.json
index cb04c3b8..dffe71a0 100644
--- a/oci/catalog/apps/linkwarden.json
+++ b/oci/catalog/apps/linkwarden.json
@@ -467,7 +467,7 @@
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
- "memory_mb": 512,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/apps/logseq.json b/oci/catalog/apps/logseq.json
index 0f5d9872..92ee7cb6 100644
--- a/oci/catalog/apps/logseq.json
+++ b/oci/catalog/apps/logseq.json
@@ -275,7 +275,7 @@
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
- "memory_mb": 128,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/apps/lucky.json b/oci/catalog/apps/lucky.json
index 6878f2b3..f1433952 100644
--- a/oci/catalog/apps/lucky.json
+++ b/oci/catalog/apps/lucky.json
@@ -207,7 +207,7 @@
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
- "memory_mb": 128,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/apps/lyrionmusicserver.json b/oci/catalog/apps/lyrionmusicserver.json
index 8b08c640..5f73ff18 100644
--- a/oci/catalog/apps/lyrionmusicserver.json
+++ b/oci/catalog/apps/lyrionmusicserver.json
@@ -389,7 +389,7 @@
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
- "memory_mb": 256,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/apps/memos.json b/oci/catalog/apps/memos.json
index e40a9227..abf6170c 100644
--- a/oci/catalog/apps/memos.json
+++ b/oci/catalog/apps/memos.json
@@ -238,7 +238,7 @@
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
- "memory_mb": 128,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/apps/n8n.json b/oci/catalog/apps/n8n.json
index 5862f5e4..a161194a 100644
--- a/oci/catalog/apps/n8n.json
+++ b/oci/catalog/apps/n8n.json
@@ -230,7 +230,7 @@
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
- "memory_mb": 320,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/apps/netbird.json b/oci/catalog/apps/netbird.json
index 6ac9bdf0..76bffa84 100644
--- a/oci/catalog/apps/netbird.json
+++ b/oci/catalog/apps/netbird.json
@@ -247,7 +247,7 @@
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
- "memory_mb": 128,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/apps/nextcloud-official.json b/oci/catalog/apps/nextcloud-official.json
index ad64fdb8..27d0a3ab 100644
--- a/oci/catalog/apps/nextcloud-official.json
+++ b/oci/catalog/apps/nextcloud-official.json
@@ -223,7 +223,7 @@
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
- "memory_mb": 512,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/apps/nginxproxymanager.json b/oci/catalog/apps/nginxproxymanager.json
index 6c9dda62..be605e7f 100644
--- a/oci/catalog/apps/nginxproxymanager.json
+++ b/oci/catalog/apps/nginxproxymanager.json
@@ -267,7 +267,7 @@
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
- "memory_mb": 128,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/apps/node-red.json b/oci/catalog/apps/node-red.json
index 64c5aa78..066aa2fb 100644
--- a/oci/catalog/apps/node-red.json
+++ b/oci/catalog/apps/node-red.json
@@ -211,7 +211,7 @@
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
- "memory_mb": 128,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/apps/nzbfast.json b/oci/catalog/apps/nzbfast.json
index 5efed1c5..62ab3f81 100644
--- a/oci/catalog/apps/nzbfast.json
+++ b/oci/catalog/apps/nzbfast.json
@@ -321,7 +321,7 @@
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
- "memory_mb": 128,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/apps/open-webui-cuda.json b/oci/catalog/apps/open-webui-cuda.json
index 2c34636d..a62686e1 100644
--- a/oci/catalog/apps/open-webui-cuda.json
+++ b/oci/catalog/apps/open-webui-cuda.json
@@ -233,7 +233,7 @@
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
- "memory_mb": 512,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/apps/open-webui-ollama.json b/oci/catalog/apps/open-webui-ollama.json
index d057f636..6d0369cd 100644
--- a/oci/catalog/apps/open-webui-ollama.json
+++ b/oci/catalog/apps/open-webui-ollama.json
@@ -249,7 +249,7 @@
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
- "memory_mb": 512,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/apps/open-webui.json b/oci/catalog/apps/open-webui.json
index 79b77b3a..e051d915 100644
--- a/oci/catalog/apps/open-webui.json
+++ b/oci/catalog/apps/open-webui.json
@@ -233,7 +233,7 @@
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
- "memory_mb": 512,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/apps/openhab.json b/oci/catalog/apps/openhab.json
index 203dc82c..2af80a5a 100644
--- a/oci/catalog/apps/openhab.json
+++ b/oci/catalog/apps/openhab.json
@@ -353,7 +353,7 @@
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
- "memory_mb": 128,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/apps/openlist.json b/oci/catalog/apps/openlist.json
index cf7ddcd8..699a2db5 100644
--- a/oci/catalog/apps/openlist.json
+++ b/oci/catalog/apps/openlist.json
@@ -242,7 +242,7 @@
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
- "memory_mb": 256,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/apps/openspeedtest.json b/oci/catalog/apps/openspeedtest.json
index 53593358..a2e5912a 100644
--- a/oci/catalog/apps/openspeedtest.json
+++ b/oci/catalog/apps/openspeedtest.json
@@ -175,7 +175,7 @@
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
- "memory_mb": 128,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/apps/opodsync.json b/oci/catalog/apps/opodsync.json
index d2680e52..c99f50c6 100644
--- a/oci/catalog/apps/opodsync.json
+++ b/oci/catalog/apps/opodsync.json
@@ -209,7 +209,7 @@
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
- "memory_mb": 512,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/apps/pdfding.json b/oci/catalog/apps/pdfding.json
index 395d8d91..b935fd18 100644
--- a/oci/catalog/apps/pdfding.json
+++ b/oci/catalog/apps/pdfding.json
@@ -297,7 +297,7 @@
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
- "memory_mb": 128,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/apps/peanut.json b/oci/catalog/apps/peanut.json
index 51fa53f8..28e65b0a 100644
--- a/oci/catalog/apps/peanut.json
+++ b/oci/catalog/apps/peanut.json
@@ -221,7 +221,7 @@
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
- "memory_mb": 256,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/apps/photoprism.json b/oci/catalog/apps/photoprism.json
index e18061be..500fbcc8 100644
--- a/oci/catalog/apps/photoprism.json
+++ b/oci/catalog/apps/photoprism.json
@@ -281,7 +281,7 @@
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
- "memory_mb": 512,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/apps/pihole.json b/oci/catalog/apps/pihole.json
index 0cb42c98..57ed8c6d 100644
--- a/oci/catalog/apps/pihole.json
+++ b/oci/catalog/apps/pihole.json
@@ -288,7 +288,7 @@
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
- "memory_mb": 128,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/apps/pinchflat.json b/oci/catalog/apps/pinchflat.json
index 5a079d46..994f3350 100644
--- a/oci/catalog/apps/pinchflat.json
+++ b/oci/catalog/apps/pinchflat.json
@@ -269,7 +269,7 @@
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
- "memory_mb": 256,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/apps/pingvin-share.json b/oci/catalog/apps/pingvin-share.json
index 41eda8ca..dd299349 100644
--- a/oci/catalog/apps/pingvin-share.json
+++ b/oci/catalog/apps/pingvin-share.json
@@ -252,7 +252,7 @@
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
- "memory_mb": 256,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/apps/portainer.json b/oci/catalog/apps/portainer.json
index 7adf13e3..759063f5 100644
--- a/oci/catalog/apps/portainer.json
+++ b/oci/catalog/apps/portainer.json
@@ -267,7 +267,7 @@
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
- "memory_mb": 128,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/apps/psitransfer.json b/oci/catalog/apps/psitransfer.json
index 7384a0dd..b4689f00 100644
--- a/oci/catalog/apps/psitransfer.json
+++ b/oci/catalog/apps/psitransfer.json
@@ -232,7 +232,7 @@
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
- "memory_mb": 256,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/apps/qbittorrent-hotio.json b/oci/catalog/apps/qbittorrent-hotio.json
index ba4b2d8f..47763764 100644
--- a/oci/catalog/apps/qbittorrent-hotio.json
+++ b/oci/catalog/apps/qbittorrent-hotio.json
@@ -278,7 +278,7 @@
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
- "memory_mb": 256,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/apps/qui.json b/oci/catalog/apps/qui.json
index 19dcf5e2..2f07166a 100644
--- a/oci/catalog/apps/qui.json
+++ b/oci/catalog/apps/qui.json
@@ -307,7 +307,7 @@
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
- "memory_mb": 128,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/apps/rdtclient.json b/oci/catalog/apps/rdtclient.json
index 29447342..eefad3fb 100644
--- a/oci/catalog/apps/rdtclient.json
+++ b/oci/catalog/apps/rdtclient.json
@@ -281,7 +281,7 @@
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
- "memory_mb": 128,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/apps/readarr.json b/oci/catalog/apps/readarr.json
index a1d42968..c7e533ad 100644
--- a/oci/catalog/apps/readarr.json
+++ b/oci/catalog/apps/readarr.json
@@ -302,7 +302,7 @@
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
- "memory_mb": 128,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/apps/retroarch-inglebard.json b/oci/catalog/apps/retroarch-inglebard.json
index fb67744f..7b368c85 100644
--- a/oci/catalog/apps/retroarch-inglebard.json
+++ b/oci/catalog/apps/retroarch-inglebard.json
@@ -181,7 +181,7 @@
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
- "memory_mb": 128,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/apps/romm.json b/oci/catalog/apps/romm.json
index aebcbb3e..cd98a442 100644
--- a/oci/catalog/apps/romm.json
+++ b/oci/catalog/apps/romm.json
@@ -542,7 +542,7 @@
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
- "memory_mb": 512,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/apps/sickchill.json b/oci/catalog/apps/sickchill.json
index 0553892c..0de62bd3 100644
--- a/oci/catalog/apps/sickchill.json
+++ b/oci/catalog/apps/sickchill.json
@@ -35,7 +35,9 @@
"tips": [],
"mini_changelog": [],
"display_version": null,
- "updated_at": null
+ "updated_at": null,
+ "hidden": true,
+ "hidden_reason": "Its image no longer publishes a version for any architecture; hidden until it does"
},
"source": {
"provider": "linuxserver.io",
@@ -302,7 +304,7 @@
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
- "memory_mb": 128,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/apps/siyuan-note.json b/oci/catalog/apps/siyuan-note.json
index e2de9f5f..9da16c4f 100644
--- a/oci/catalog/apps/siyuan-note.json
+++ b/oci/catalog/apps/siyuan-note.json
@@ -231,7 +231,7 @@
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
- "memory_mb": 512,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/apps/stable-diffusion-webui-nvidia.json b/oci/catalog/apps/stable-diffusion-webui-nvidia.json
index b9f792f9..2da77dae 100644
--- a/oci/catalog/apps/stable-diffusion-webui-nvidia.json
+++ b/oci/catalog/apps/stable-diffusion-webui-nvidia.json
@@ -296,7 +296,7 @@
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
- "memory_mb": 512,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/apps/tailscale.json b/oci/catalog/apps/tailscale.json
index 774601a3..c763012f 100644
--- a/oci/catalog/apps/tailscale.json
+++ b/oci/catalog/apps/tailscale.json
@@ -244,7 +244,7 @@
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
- "memory_mb": 128,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/apps/teable.json b/oci/catalog/apps/teable.json
index 39b67beb..40910c31 100644
--- a/oci/catalog/apps/teable.json
+++ b/oci/catalog/apps/teable.json
@@ -425,7 +425,7 @@
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
- "memory_mb": 512,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/apps/trilium.json b/oci/catalog/apps/trilium.json
index 46879e05..7f4d89d2 100644
--- a/oci/catalog/apps/trilium.json
+++ b/oci/catalog/apps/trilium.json
@@ -213,7 +213,7 @@
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
- "memory_mb": 256,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/apps/twingate-connector.json b/oci/catalog/apps/twingate-connector.json
index c47c2abc..f37a70ec 100644
--- a/oci/catalog/apps/twingate-connector.json
+++ b/oci/catalog/apps/twingate-connector.json
@@ -216,7 +216,7 @@
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
- "memory_mb": 500,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/apps/unifi-controller.json b/oci/catalog/apps/unifi-controller.json
index 2411d60f..4a0bb12e 100644
--- a/oci/catalog/apps/unifi-controller.json
+++ b/oci/catalog/apps/unifi-controller.json
@@ -342,7 +342,7 @@
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
- "memory_mb": 128,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/apps/uptimekuma.json b/oci/catalog/apps/uptimekuma.json
index 0ab13ac1..fbe62a72 100644
--- a/oci/catalog/apps/uptimekuma.json
+++ b/oci/catalog/apps/uptimekuma.json
@@ -211,7 +211,7 @@
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
- "memory_mb": 128,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/apps/v2raya.json b/oci/catalog/apps/v2raya.json
index 43487c68..89511dd3 100644
--- a/oci/catalog/apps/v2raya.json
+++ b/oci/catalog/apps/v2raya.json
@@ -260,7 +260,7 @@
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
- "memory_mb": 128,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/apps/vaultwarden.json b/oci/catalog/apps/vaultwarden.json
index 2520d5cb..c2ca821b 100644
--- a/oci/catalog/apps/vaultwarden.json
+++ b/oci/catalog/apps/vaultwarden.json
@@ -231,7 +231,7 @@
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
- "memory_mb": 256,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/apps/vocechat.json b/oci/catalog/apps/vocechat.json
index 4d0d200e..795adfdc 100644
--- a/oci/catalog/apps/vocechat.json
+++ b/oci/catalog/apps/vocechat.json
@@ -221,7 +221,7 @@
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
- "memory_mb": 128,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/apps/wallabag.json b/oci/catalog/apps/wallabag.json
index 99fe72ef..d1ef18aa 100644
--- a/oci/catalog/apps/wallabag.json
+++ b/oci/catalog/apps/wallabag.json
@@ -242,7 +242,7 @@
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
- "memory_mb": 512,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/apps/weknora.json b/oci/catalog/apps/weknora.json
index 9c6abb16..5aca62ef 100644
--- a/oci/catalog/apps/weknora.json
+++ b/oci/catalog/apps/weknora.json
@@ -890,7 +890,7 @@
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
- "memory_mb": 128,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/apps/ztnet.json b/oci/catalog/apps/ztnet.json
index ee269f9a..37930b62 100644
--- a/oci/catalog/apps/ztnet.json
+++ b/oci/catalog/apps/ztnet.json
@@ -382,7 +382,7 @@
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
- "memory_mb": 256,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/curated/duplicati-official.json b/oci/catalog/curated/duplicati-official.json
new file mode 100644
index 00000000..a9850ac6
--- /dev/null
+++ b/oci/catalog/curated/duplicati-official.json
@@ -0,0 +1,555 @@
+{
+ "schema_version": "0.5.0",
+ "kind": "proxmenux.oci-template",
+ "id": "image-duplicati-official",
+ "status": "generated-unvalidated",
+ "catalog_ui": {
+ "title": {
+ "en_US": "Duplicati Official"
+ },
+ "tagline": {
+ "en_US": "Official Duplicati image: encrypted, incremental backups to local, network and cloud storage."
+ },
+ "description": {
+ "en_US": "Official Duplicati image: encrypted, incremental backups to local, network and cloud storage."
+ },
+ "category": "backup",
+ "category_label": "Backup & Recovery",
+ "author": "Duplicati",
+ "developer": "Duplicati",
+ "icon": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/duplicati.webp",
+ "thumbnail": null,
+ "screenshots": [],
+ "architectures": [
+ "amd64",
+ "arm64"
+ ],
+ "launch": {
+ "scheme": "http",
+ "port": 8200,
+ "path": "/"
+ },
+ "website": "https://www.duplicati.com/",
+ "documentation": "https://docs.duplicati.com/",
+ "repository": "https://github.com/duplicati/duplicati",
+ "tips": [],
+ "mini_changelog": [],
+ "display_version": null,
+ "updated_at": null
+ },
+ "source": {
+ "provider": "duplicati",
+ "repository": "https://github.com/duplicati/duplicati",
+ "revision": "66c05069de88ccb6054907665ef7813e0cf664f4e1a2032cd943a1b8d72e8530",
+ "image_repository_url": "https://hub.docker.com/r/duplicati/duplicati",
+ "readme_pushed_at": "",
+ "compose_sha256": "66c05069de88ccb6054907665ef7813e0cf664f4e1a2032cd943a1b8d72e8530",
+ "generated_at": "2026-10-11T09:05:05+00:00"
+ },
+ "container_contract": {
+ "service_name": "duplicati",
+ "container_name": "duplicati",
+ "image": {
+ "reference": "duplicati/duplicati:latest",
+ "registry": "docker.io",
+ "repository": "duplicati/duplicati",
+ "tag": "latest",
+ "digest": null,
+ "pull_policy": "resolve-written-reference-to-architecture-digest-at-install"
+ },
+ "environment": [
+ {
+ "name": "SETTINGS_ENCRYPTION_KEY",
+ "example": "",
+ "required": true,
+ "sensitive": true,
+ "source": "docker-compose"
+ },
+ {
+ "name": "DUPLICATI__WEBSERVICE_PASSWORD",
+ "example": "",
+ "required": true,
+ "sensitive": true,
+ "source": "docker-compose",
+ "prompt": "Password of the Duplicati web interface"
+ }
+ ],
+ "volumes": [
+ {
+ "id": "volume-0",
+ "container_path": "/data",
+ "compose_source_example": "./data",
+ "read_only": false,
+ "required": true,
+ "installation_choice": [
+ "managed-volume",
+ "host-bind"
+ ],
+ "default": "managed-volume",
+ "managed_volume": {
+ "backup": true,
+ "default_size_gb": 8
+ }
+ },
+ {
+ "id": "volume-1",
+ "container_path": "/backups",
+ "compose_source_example": "./backups",
+ "read_only": false,
+ "required": true,
+ "installation_choice": [
+ "managed-volume",
+ "host-bind"
+ ],
+ "default": "managed-volume",
+ "managed_volume": {
+ "backup": true,
+ "default_size_gb": 8
+ }
+ },
+ {
+ "id": "volume-2",
+ "container_path": "/source",
+ "compose_source_example": "./source",
+ "read_only": false,
+ "required": true,
+ "installation_choice": [
+ "managed-volume",
+ "host-bind"
+ ],
+ "default": "managed-volume",
+ "managed_volume": {
+ "backup": true,
+ "default_size_gb": 8
+ }
+ }
+ ],
+ "ports": [
+ {
+ "container_port": 8200,
+ "published_example": 8200,
+ "protocol": "tcp",
+ "required": true,
+ "proxmox_behavior": "listener-on-dedicated-lxc-address-no-nat"
+ }
+ ],
+ "related_services": [],
+ "restart": "unless-stopped",
+ "stop_grace_period": null,
+ "original_compose": "services:\n duplicati:\n image: duplicati/duplicati:latest\n container_name: duplicati\n ports:\n - 8200:8200\n volumes:\n - ./data:/data\n - ./backups:/backups\n - ./source:/source\n environment:\n - SETTINGS_ENCRYPTION_KEY=${GENERATED_SETTINGS_ENCRYPTION_KEY}\n - DUPLICATI__WEBSERVICE_PASSWORD=${GENERATED_DUPLICATI_WEBSERVICE_PASSWORD}\n restart: unless-stopped\n"
+ },
+ "compose_stack": {
+ "project_name": "duplicati",
+ "deployment_model": "one-native-oci-lxc-per-compose-service",
+ "user_experience": "single-application-install",
+ "main_service": "duplicati",
+ "service_count": 1,
+ "services": [
+ {
+ "name": "duplicati",
+ "image": "duplicati/duplicati:latest",
+ "is_main": true,
+ "role": "frontend",
+ "vmid_offset": 0,
+ "depends_on": [],
+ "frontend_network": true,
+ "private_network": false,
+ "compose": {
+ "image": "duplicati/duplicati:latest",
+ "container_name": "duplicati",
+ "ports": [
+ "8200:8200"
+ ],
+ "volumes": [
+ "./data:/data",
+ "./backups:/backups",
+ "./source:/source"
+ ],
+ "environment": [
+ "SETTINGS_ENCRYPTION_KEY=${GENERATED_SETTINGS_ENCRYPTION_KEY}",
+ "DUPLICATI__WEBSERVICE_PASSWORD=${GENERATED_DUPLICATI_WEBSERVICE_PASSWORD}"
+ ],
+ "restart": "unless-stopped"
+ }
+ }
+ ],
+ "top_level": {},
+ "networking": {
+ "frontend": "selected-proxmox-bridge",
+ "private_required": false,
+ "private_creation": "automatic-create-if-missing",
+ "private_address_allocation": "automatic-static-address-per-service",
+ "service_discovery": "private-addresses-with-compose-service-host-aliases",
+ "dependency_external_access": "disabled-unless-service-publishes-ports",
+ "prompt_user_for_private_network": false
+ },
+ "storage": [
+ {
+ "id": "duplicati-volume-0",
+ "service": "duplicati",
+ "container_path": "/data",
+ "mode": "managed-volume",
+ "user_selectable": false,
+ "backup": true,
+ "shared_with_other_lxc": false,
+ "source_path": null,
+ "source_path_prompt": null
+ },
+ {
+ "id": "duplicati-volume-1",
+ "service": "duplicati",
+ "container_path": "/backups",
+ "mode": "managed-volume",
+ "user_selectable": false,
+ "backup": true,
+ "shared_with_other_lxc": false,
+ "source_path": null,
+ "source_path_prompt": null
+ },
+ {
+ "id": "duplicati-volume-2",
+ "service": "duplicati",
+ "container_path": "/source",
+ "mode": "managed-volume",
+ "user_selectable": false,
+ "backup": true,
+ "shared_with_other_lxc": false,
+ "source_path": null,
+ "source_path_prompt": null
+ }
+ ],
+ "orchestration": {
+ "reserve_vmids_atomically": 1,
+ "start_order": [
+ "duplicati"
+ ],
+ "stop_order": [
+ "duplicati"
+ ],
+ "dependency_readiness": "compose-healthcheck-then-port-or-process-fallback",
+ "rollback_on_failure": "remove-new-rootfs-preserve-created-persistent-volumes"
+ },
+ "installer_inputs": {
+ "prompted": [
+ "stack_name",
+ "base_vmid",
+ "rootfs_storage",
+ "persistent_data_destinations",
+ "frontend_bridge",
+ "frontend_ipv4_mode"
+ ],
+ "automatic": [
+ "dependent_vmids",
+ "private_bridge",
+ "private_subnet",
+ "private_service_addresses",
+ "compose_service_aliases",
+ "generated_secrets",
+ "dependency_start_and_stop_order"
+ ],
+ "generated_secrets": [
+ {
+ "id": "duplicati-webservice-password",
+ "strategy": "generate-cryptographically-random-at-install",
+ "bindings": [
+ {
+ "service": "duplicati",
+ "environment_variable": "DUPLICATI__WEBSERVICE_PASSWORD"
+ }
+ ]
+ },
+ {
+ "id": "settings-encryption-key",
+ "strategy": "generate-cryptographically-random-at-install",
+ "bindings": [
+ {
+ "service": "duplicati",
+ "environment_variable": "SETTINGS_ENCRYPTION_KEY"
+ }
+ ]
+ }
+ ]
+ }
+ },
+ "first_run": {
+ "endpoints": [
+ {
+ "label": "Web UI",
+ "scheme": "http",
+ "port": 8200,
+ "path": "/",
+ "source": "compose-metadata"
+ }
+ ],
+ "credentials": [
+ {
+ "label": "Duplicati web interface (password only)",
+ "type": "configured-or-installer-generated",
+ "username": "Not required (password only)",
+ "password": null,
+ "password_environment": "DUPLICATI__WEBSERVICE_PASSWORD",
+ "change_required": false,
+ "source": "duplicati-documentation",
+ "retrieval": null
+ }
+ ]
+ },
+ "proxmox": {
+ "runtime": "native-oci-lxc",
+ "technology_status": "proxmox-technology-preview",
+ "defaults": {
+ "unprivileged": true,
+ "ostype": "auto-from-image",
+ "cores": 2,
+ "memory_mb": 1024,
+ "swap_mb": 512,
+ "rootfs_size_gb": 8,
+ "rootfs_storage": "local-lvm",
+ "volume_storage": "local-lvm",
+ "template_storage": "local",
+ "bridge": "vmbr0",
+ "ipv4": "dhcp",
+ "firewall": true,
+ "host_managed_network": true,
+ "onboot": false,
+ "features": [
+ "nesting=1"
+ ],
+ "shutdown_timeout_seconds": 30
+ },
+ "image_metadata_policy": {
+ "entrypoint": "import-from-oci-image",
+ "cmd": "import-from-oci-image",
+ "environment": "import-image-env-then-apply-compose-overrides",
+ "user": "import-from-oci-image",
+ "working_dir": "import-from-oci-image",
+ "stop_signal": "import-from-oci-image"
+ },
+ "installer_profile": {
+ "generated_sensitive_environment": {
+ "SETTINGS_ENCRYPTION_KEY": {
+ "strategy": "token-hex",
+ "bytes": 16,
+ "prompt": false
+ },
+ "DUPLICATI__WEBSERVICE_PASSWORD": {
+ "strategy": "token-hex",
+ "bytes": 12,
+ "prompt": true
+ }
+ },
+ "volume_preparations": [
+ {
+ "container_path": "/data",
+ "remove_lost_found": true,
+ "owner_strategy": "mapped-application-user",
+ "only_when_mount_type": "managed-volume"
+ },
+ {
+ "container_path": "/backups",
+ "remove_lost_found": true,
+ "owner_strategy": "mapped-application-user",
+ "only_when_mount_type": "managed-volume"
+ },
+ {
+ "container_path": "/source",
+ "remove_lost_found": true,
+ "owner_strategy": "mapped-application-user",
+ "only_when_mount_type": "managed-volume"
+ }
+ ]
+ },
+ "adaptations": [
+ {
+ "id": "imported-compose-source",
+ "upstream_behavior": "The source definition deploys the complete Docker Compose application model.",
+ "native_lxc_behavior": "The source model is preserved and remains blocked until every service option has a reviewed native Proxmox mapping.",
+ "reason": "Catalog import must not imply runtime compatibility.",
+ "behavioral_impact": "No automatic installation before review.",
+ "validation": "pending-per-application"
+ },
+ {
+ "id": "rolling-latest-image",
+ "upstream_behavior": "A discovered Compose may pin a release tag or digest.",
+ "native_lxc_behavior": "ProxMenux selects the same image repository with the latest tag for catalog installations.",
+ "reason": "The automatic catalog intentionally offers rolling latest images; pinned versions belong to the future manual installer.",
+ "behavioral_impact": "The installed release can be newer than the discovered Compose revision.",
+ "validation": "pending-per-application"
+ },
+ {
+ "id": "dedicated-lxc-network",
+ "upstream_behavior": "Docker publishes selected container ports on the Docker host.",
+ "native_lxc_behavior": "A reviewed native application will listen on its original container ports at a dedicated LXC address.",
+ "reason": "A native LXC has its own address and does not require Docker port NAT.",
+ "behavioral_impact": "Published ports are metadata; ProxMenux URLs use the matching container target port.",
+ "validation": "pending-per-application"
+ },
+ {
+ "id": "compose-shm-size",
+ "upstream_behavior": "Compose sets the size of the container /dev/shm tmpfs.",
+ "native_lxc_behavior": "ProxMenux mounts a native LXC tmpfs at /dev/shm with the same requested capacity.",
+ "reason": "The OCI image runs directly as an LXC and therefore needs the equivalent Proxmox mount entry.",
+ "behavioral_impact": "None expected.",
+ "validation": "not-requested-by-compose"
+ },
+ {
+ "id": "compose-command",
+ "upstream_behavior": "Compose replaces the image Cmd while retaining its Entrypoint.",
+ "native_lxc_behavior": "ProxMenux reads the official OCI Entrypoint and combines it with the Compose command as the native LXC init command.",
+ "reason": "Proxmox stores the effective OCI process as one entrypoint string.",
+ "behavioral_impact": "None expected.",
+ "validation": "not-requested-by-compose"
+ },
+ {
+ "id": "compose-privileged-mode",
+ "upstream_behavior": "Compose selects whether the container runs in privileged mode.",
+ "native_lxc_behavior": "ProxMenux keeps the LXC unprivileged by default and exposes the broad Compose privilege request only as an explicit compatibility option.",
+ "reason": "A Compose privilege request is source metadata, not proof that the image technically requires a privileged LXC.",
+ "behavioral_impact": "A privileged LXC has weaker isolation from the Proxmox host.",
+ "validation": "native-equivalent"
+ },
+ {
+ "id": "compose-process-runtime",
+ "upstream_behavior": "Compose can replace Entrypoint, User and WorkingDir and request an init process or interactive terminal.",
+ "native_lxc_behavior": "ProxMenux applies the process overrides through native LXC init directives; lxc-init provides PID 1 supervision and the CT console provides terminal access.",
+ "reason": "The OCI process must start with the same identity, command and working directory without Docker.",
+ "behavioral_impact": "Compose stdin_open and tty become access through the Proxmox LXC console.",
+ "validation": "not-requested-by-compose"
+ },
+ {
+ "id": "compose-healthcheck",
+ "upstream_behavior": "Docker periodically executes the declared container healthcheck.",
+ "native_lxc_behavior": "For a single-service LXC, ProxMenux translates HTTP localhost checks into a mandatory first-start service check.",
+ "reason": "Proxmox has no persistent Docker health state, while the installer still must detect a failed first boot.",
+ "behavioral_impact": "The check runs during installation rather than continuously after installation.",
+ "validation": "not-requested-by-compose"
+ },
+ {
+ "id": "compose-cpu-priority",
+ "upstream_behavior": "Docker cpu_shares sets a relative scheduling weight with 1024 as its neutral value.",
+ "native_lxc_behavior": "ProxMenux converts the relative weight to Proxmox cpuunits with 100 as its neutral value and lets the user review it.",
+ "reason": "Both settings express relative CPU priority on different scales.",
+ "behavioral_impact": "Rounding and Proxmox minimum limits can slightly change very low weights.",
+ "validation": "not-requested-by-compose"
+ },
+ {
+ "id": "compose-resource-limits",
+ "upstream_behavior": "mem_limit sets the memory ceiling; ulimits sets process soft/hard resource limits.",
+ "native_lxc_behavior": "mem_limit supplies the editable Proxmox memory default, rounded up to MiB; ulimits maps to lxc.prlimit with -1 represented as unlimited.",
+ "reason": "Use native Proxmox memory and LXC prlimits, without a wrapper or changing the host kernel configuration.",
+ "behavioral_impact": "User-selected memory overrides Compose. Swap is a separate choice. Kernel restrictions still apply; nproc counts processes by real UID, not by container.",
+ "validation": "not-requested-by-compose"
+ },
+ {
+ "id": "compose-network-identity",
+ "upstream_behavior": "Compose can set hostname, MAC address, extra hosts and attach a service to Docker networks.",
+ "native_lxc_behavior": "ProxMenux applies hostname and MAC to net0, writes additional host aliases into the LXC and uses its dedicated bridge connection for single-service networks.",
+ "reason": "A dedicated LXC has its own network namespace and does not need a Docker bridge per service.",
+ "behavioral_impact": "host-gateway resolves to the IPv4 address of the selected Proxmox bridge.",
+ "validation": "not-requested-by-compose"
+ },
+ {
+ "id": "compose-network-mode",
+ "upstream_behavior": "Docker host mode removes Docker network isolation; bridge and default use a Docker-managed network.",
+ "native_lxc_behavior": "The OCI process uses the dedicated LXC network namespace directly, so host, bridge and default all listen on the LXC address without Docker NAT.",
+ "reason": "The LXC is the application host and already has its own address and port namespace.",
+ "behavioral_impact": "host means the LXC host, never the Proxmox host; this preserves Proxmox network isolation.",
+ "validation": "not-requested-by-compose"
+ },
+ {
+ "id": "compose-capabilities-and-sysctls",
+ "upstream_behavior": "Compose can add Linux capabilities and set kernel parameters in the container network namespace.",
+ "native_lxc_behavior": "ProxMenux validates requested capabilities against the native LXC capability set and writes namespaced network settings as lxc.sysctl directives.",
+ "reason": "A native OCI-LXC already starts with the namespaced capability set; lxc.cap.keep would incorrectly discard unrelated required capabilities.",
+ "behavioral_impact": "Host-global capabilities such as SYS_MODULE remain blocked until their host prerequisite is explicitly adapted.",
+ "validation": "not-requested-by-compose"
+ },
+ {
+ "id": "docker-engine-metadata",
+ "upstream_behavior": "Compose labels annotate Docker objects and the json-file logging driver rotates Docker-managed logs.",
+ "native_lxc_behavior": "Labels remain source metadata; Docker json-file settings are not applied because the OCI process runs directly under LXC.",
+ "reason": "There is no Docker object or Docker json-file log behind a native OCI-LXC application.",
+ "behavioral_impact": "Docker-only label consumers and Docker log-driver rotation do not exist in the native deployment.",
+ "validation": "not-requested-by-compose"
+ },
+ {
+ "id": "compose-device-passthrough",
+ "upstream_behavior": "Compose passes host character devices or requests an NVIDIA runtime GPU.",
+ "native_lxc_behavior": "ProxMenux converts recognized device declarations to native Proxmox dev resources; NVIDIA profiles also inject compatible host driver libraries read-only.",
+ "reason": "Native OCI-LXC does not execute Docker device or NVIDIA runtime hooks.",
+ "behavioral_impact": "Hardware is exposed only after explicit user confirmation and host-path validation.",
+ "validation": "not-requested-by-compose"
+ },
+ {
+ "id": "compose-host-ipc",
+ "upstream_behavior": "ipc: host shares the Docker host IPC namespace, commonly to avoid Docker's small default shared-memory allocation.",
+ "native_lxc_behavior": "The application keeps the LXC IPC namespace and receives a configurable 1 GiB /dev/shm instead of sharing Proxmox host IPC.",
+ "reason": "Processes in a single native LXC already share one IPC namespace; retaining isolation is safer than exposing host IPC.",
+ "behavioral_impact": "The application cannot exchange IPC objects with processes on the Proxmox host.",
+ "validation": "not-requested-by-compose"
+ }
+ ]
+ },
+ "compatibility": {
+ "automatic_install_candidate": true,
+ "validated": false,
+ "supported_compose_keys": [
+ "cap_add",
+ "command",
+ "container_name",
+ "cpu_shares",
+ "deploy",
+ "devices",
+ "entrypoint",
+ "environment",
+ "extra_hosts",
+ "group_add",
+ "healthcheck",
+ "hostname",
+ "image",
+ "init",
+ "ipc",
+ "labels",
+ "logging",
+ "mac_address",
+ "mem_limit",
+ "network_mode",
+ "networks",
+ "ports",
+ "privileged",
+ "restart",
+ "runtime",
+ "security_opt",
+ "shm_size",
+ "stdin_open",
+ "stop_grace_period",
+ "sysctls",
+ "tty",
+ "ulimits",
+ "user",
+ "volumes",
+ "working_dir"
+ ],
+ "untranslated_blockers": [],
+ "policy": "Single-image definitions are installable when every declared Compose option has a native Proxmox translation. Multi-image and unsupported runtime features remain blocked until their orchestrator or mapping is available."
+ },
+ "validation": {
+ "schema": "passed-at-generation",
+ "clean_install": "pending",
+ "service_health": "pending",
+ "restart_persistence": "pending",
+ "backup_restore": "pending",
+ "update_preserves_data": "pending"
+ },
+ "lifecycle": {
+ "update_strategy": "resolve-latest-image-then-apply-reviewed-native-lxc-update",
+ "registry_state": {
+ "resolved_architecture": null,
+ "resolved_digest": null,
+ "image_version_label": null,
+ "image_created": null
+ },
+ "change_detection": "compare-compose-sha256-and-resolved-latest-image-digest",
+ "automatic_unattended_updates": false
+ }
+}
diff --git a/oci/catalog/curated/open-webui-cuda.json b/oci/catalog/curated/open-webui-cuda.json
index f2473ad9..2cf90c35 100644
--- a/oci/catalog/curated/open-webui-cuda.json
+++ b/oci/catalog/curated/open-webui-cuda.json
@@ -233,7 +233,7 @@
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
- "memory_mb": 512,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/curated/open-webui-ollama.json b/oci/catalog/curated/open-webui-ollama.json
index 58365f36..be01b724 100644
--- a/oci/catalog/curated/open-webui-ollama.json
+++ b/oci/catalog/curated/open-webui-ollama.json
@@ -249,7 +249,7 @@
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
- "memory_mb": 512,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/curated/open-webui.json b/oci/catalog/curated/open-webui.json
index 98ddbfb1..48b39434 100644
--- a/oci/catalog/curated/open-webui.json
+++ b/oci/catalog/curated/open-webui.json
@@ -233,7 +233,7 @@
"unprivileged": true,
"ostype": "auto-from-image",
"cores": 2,
- "memory_mb": 512,
+ "memory_mb": 1024,
"swap_mb": 512,
"rootfs_size_gb": 8,
"rootfs_storage": "local-lvm",
diff --git a/oci/catalog/generation-report-curated.json b/oci/catalog/generation-report-curated.json
index a08de59b..c797e035 100644
--- a/oci/catalog/generation-report-curated.json
+++ b/oci/catalog/generation-report-curated.json
@@ -1,5 +1,6 @@
{
"generated": [
+ "duplicati-official",
"emby-official",
"fileflows",
"frigate",
@@ -21,7 +22,7 @@
"stremio",
"tandoor"
],
- "generated_count": 20,
+ "generated_count": 21,
"failed": [],
"failed_count": 0
}
diff --git a/oci/catalog/index.json b/oci/catalog/index.json
index 3874c542..6a73ff8b 100644
--- a/oci/catalog/index.json
+++ b/oci/catalog/index.json
@@ -921,7 +921,7 @@
"requires_relaxed_confinement": false,
"optional_relaxed_confinement": false,
"requires_security_confirmation": false,
- "hidden": false,
+ "hidden": true,
"template_id": "linuxserver-bitcoin-knots"
},
{
@@ -2608,6 +2608,38 @@
"hidden": false,
"template_id": "linuxserver-duplicati"
},
+ {
+ "id": "duplicati-official",
+ "provider": "duplicati",
+ "template_family": "curated-profile",
+ "title": "Duplicati Official",
+ "repository": "https://github.com/duplicati/duplicati",
+ "description": "Official Duplicati image: encrypted, incremental backups to local, network and cloud storage.",
+ "website": "https://www.duplicati.com/",
+ "icon": "https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/duplicati.webp",
+ "architectures": [
+ "amd64",
+ "arm64"
+ ],
+ "updated_at": null,
+ "main_image": "duplicati/duplicati:latest",
+ "category": "backup",
+ "category_label": "Backup & Recovery",
+ "replaces_discovered_ids": [],
+ "curated_path": "catalog/curated/duplicati-official.json",
+ "template": "apps/duplicati-official.json",
+ "template_status": "generated-unvalidated",
+ "automatic_install_candidate": true,
+ "untranslated_blockers": [],
+ "requires_privileged_lxc": false,
+ "optional_privileged_lxc": false,
+ "requires_host_pid_namespace": false,
+ "requires_relaxed_confinement": false,
+ "optional_relaxed_confinement": false,
+ "requires_security_confirmation": false,
+ "hidden": false,
+ "template_id": "image-duplicati-official"
+ },
{
"id": "eden",
"provider": "linuxserver.io",
@@ -2775,7 +2807,7 @@
"requires_relaxed_confinement": false,
"optional_relaxed_confinement": false,
"requires_security_confirmation": false,
- "hidden": false,
+ "hidden": true,
"template_id": "image-emulatorjs"
},
{
@@ -9861,7 +9893,7 @@
"requires_relaxed_confinement": false,
"optional_relaxed_confinement": false,
"requires_security_confirmation": false,
- "hidden": false,
+ "hidden": true,
"template_id": "image-sickchill"
},
{
diff --git a/oci/catalog/overlays/bitcoin-knots.json b/oci/catalog/overlays/bitcoin-knots.json
index b1cbf5eb..35fae84d 100644
--- a/oci/catalog/overlays/bitcoin-knots.json
+++ b/oci/catalog/overlays/bitcoin-knots.json
@@ -1,4 +1,8 @@
{
+ "catalog_ui": {
+ "hidden": true,
+ "hidden_reason": "Its image stops without closing the application cleanly, also under Docker; hidden until the image is fixed"
+ },
"proxmox": {
"installer_profile": {
"volume_preparations": [
diff --git a/oci/catalog/overlays/bookstack.json b/oci/catalog/overlays/bookstack.json
index f4c6870c..afc17874 100644
--- a/oci/catalog/overlays/bookstack.json
+++ b/oci/catalog/overlays/bookstack.json
@@ -109,7 +109,27 @@
"owner_strategy": "mapped-root",
"only_when_mount_type": "managed-volume"
}
+ ],
+ "external_services": [
+ "MariaDB"
]
}
+ },
+ "environment_overrides": {
+ "APP_URL": {
+ "prompt": "Address BookStack will be opened at (for example http://192.168.1.50:6875)"
+ },
+ "DB_HOST": {
+ "prompt": "Address of the MariaDB server"
+ },
+ "DB_USERNAME": {
+ "prompt": "User of the MariaDB server"
+ },
+ "DB_DATABASE": {
+ "prompt": "Name of the database on the MariaDB server"
+ },
+ "DB_PASSWORD": {
+ "prompt": "Password of the MariaDB user"
+ }
}
}
diff --git a/oci/catalog/overlays/chatgpt-next-web.json b/oci/catalog/overlays/chatgpt-next-web.json
new file mode 100644
index 00000000..6140668f
--- /dev/null
+++ b/oci/catalog/overlays/chatgpt-next-web.json
@@ -0,0 +1,12 @@
+{
+ "environment_overrides": {
+ "CODE": {
+ "required": false,
+ "prompt": "Access password of the web interface (optional)"
+ },
+ "PROXY_URL": {
+ "required": false,
+ "prompt": "Proxy address to reach the API (optional)"
+ }
+ }
+}
diff --git a/oci/catalog/overlays/ddns-updater.json b/oci/catalog/overlays/ddns-updater.json
new file mode 100644
index 00000000..986e0836
--- /dev/null
+++ b/oci/catalog/overlays/ddns-updater.json
@@ -0,0 +1,12 @@
+{
+ "environment_overrides": {
+ "CONFIG": {
+ "required": false,
+ "prompt": "Configuration in JSON on one line (optional)"
+ },
+ "SHOUTRRR_ADDRESSES": {
+ "required": false,
+ "prompt": "Shoutrrr addresses for the notifications (optional)"
+ }
+ }
+}
diff --git a/oci/catalog/overlays/emulatorjs.json b/oci/catalog/overlays/emulatorjs.json
index cb0e91f2..b7e675e6 100644
--- a/oci/catalog/overlays/emulatorjs.json
+++ b/oci/catalog/overlays/emulatorjs.json
@@ -1,4 +1,8 @@
{
+ "catalog_ui": {
+ "hidden": true,
+ "hidden_reason": "Its image no longer publishes a version for any architecture; hidden until it does"
+ },
"proxmox": {
"installer_profile": {
"volume_preparations": [
diff --git a/oci/catalog/overlays/foldingathome.json b/oci/catalog/overlays/foldingathome.json
index b1cbf5eb..09ee1618 100644
--- a/oci/catalog/overlays/foldingathome.json
+++ b/oci/catalog/overlays/foldingathome.json
@@ -10,5 +10,13 @@
}
]
}
+ },
+ "environment_overrides": {
+ "ACCOUNT_TOKEN": {
+ "prompt": "Account token from the settings of https://app.foldingathome.org"
+ },
+ "MACHINE_NAME": {
+ "prompt": "Name for this machine in Folding@home (no spaces)"
+ }
}
}
diff --git a/oci/catalog/overlays/habridge.json b/oci/catalog/overlays/habridge.json
index b1cbf5eb..890fbfa3 100644
--- a/oci/catalog/overlays/habridge.json
+++ b/oci/catalog/overlays/habridge.json
@@ -8,7 +8,19 @@
"owner_strategy": "mapped-root",
"only_when_mount_type": "managed-volume"
}
- ]
+ ],
+ "generated_sensitive_environment": {
+ "SEC_KEY": {
+ "strategy": "token-hex",
+ "bytes": 16,
+ "prompt": false
+ }
+ }
+ }
+ },
+ "environment_overrides": {
+ "SEC_KEY": {
+ "sensitive": true
}
}
}
diff --git a/oci/catalog/overlays/healthchecks.json b/oci/catalog/overlays/healthchecks.json
index b1cbf5eb..695a0023 100644
--- a/oci/catalog/overlays/healthchecks.json
+++ b/oci/catalog/overlays/healthchecks.json
@@ -10,5 +10,22 @@
}
]
}
+ },
+ "environment_overrides": {
+ "SECRET_KEY": {
+ "required": false
+ },
+ "SITE_ROOT": {
+ "prompt": "Address Healthchecks will be opened at (for example http://192.168.1.50:8000)"
+ },
+ "SITE_NAME": {
+ "example": "Healthchecks"
+ },
+ "SUPERUSER_EMAIL": {
+ "prompt": "Email address of the superuser account"
+ },
+ "SUPERUSER_PASSWORD": {
+ "prompt": "Password of the superuser account"
+ }
}
}
diff --git a/oci/catalog/overlays/lychee.json b/oci/catalog/overlays/lychee.json
index ae775b06..9e020fa2 100644
--- a/oci/catalog/overlays/lychee.json
+++ b/oci/catalog/overlays/lychee.json
@@ -16,5 +16,30 @@
}
]
}
+ },
+ "environment_overrides": {
+ "DB_CONNECTION": {
+ "example": "sqlite",
+ "prompt": "Database type (sqlite, mysql or pgsql)"
+ },
+ "DB_DATABASE": {
+ "example": "/config/lychee.sqlite",
+ "prompt": "Database file for sqlite, or database name for mysql and pgsql"
+ },
+ "DB_HOST": {
+ "required": false,
+ "prompt": "Address of the database server (mysql and pgsql only)"
+ },
+ "DB_PORT": {
+ "required": false,
+ "prompt": "Port of the database server (mysql and pgsql only)"
+ },
+ "DB_USERNAME": {
+ "required": false,
+ "prompt": "User of the database server (mysql and pgsql only)"
+ },
+ "DB_PASSWORD": {
+ "required": false
+ }
}
}
diff --git a/oci/catalog/overlays/manyfold.json b/oci/catalog/overlays/manyfold.json
index ab6d89e8..a9f3ed8b 100644
--- a/oci/catalog/overlays/manyfold.json
+++ b/oci/catalog/overlays/manyfold.json
@@ -14,7 +14,22 @@
"owner_strategy": "mapped-root",
"only_when_mount_type": "managed-volume"
}
+ ],
+ "external_services": [
+ "Redis/Valkey"
]
}
+ },
+ "environment_overrides": {
+ "DATABASE_URL": {
+ "example": "sqlite3:/config/manyfold.sqlite3",
+ "prompt": "Database (a SQLite file, or postgresql://user:password@address:5432/name)"
+ },
+ "REDIS_URL": {
+ "prompt": "Address of Redis or Valkey (for example redis://192.168.1.20:6379/1)"
+ },
+ "SECRET_KEY_BASE": {
+ "required": false
+ }
}
}
diff --git a/oci/catalog/overlays/minisatip.json b/oci/catalog/overlays/minisatip.json
index b1cbf5eb..20a360d2 100644
--- a/oci/catalog/overlays/minisatip.json
+++ b/oci/catalog/overlays/minisatip.json
@@ -10,5 +10,11 @@
}
]
}
+ },
+ "environment_overrides": {
+ "RUN_OPTS": {
+ "required": false,
+ "prompt": "Extra parameters for minisatip (optional)"
+ }
}
}
diff --git a/oci/catalog/overlays/netbird.json b/oci/catalog/overlays/netbird.json
index 00a4ca7c..fbf2666b 100644
--- a/oci/catalog/overlays/netbird.json
+++ b/oci/catalog/overlays/netbird.json
@@ -10,5 +10,14 @@
}
]
}
+ },
+ "environment_overrides": {
+ "NB_SETUP_KEY": {
+ "prompt": "Setup key from the NetBird dashboard"
+ },
+ "NB_MANAGEMENT_URL": {
+ "example": "https://api.netbird.io:443",
+ "prompt": "Address of the NetBird management server"
+ }
}
}
diff --git a/oci/catalog/overlays/netbox.json b/oci/catalog/overlays/netbox.json
index 5a6986a1..82defe18 100644
--- a/oci/catalog/overlays/netbox.json
+++ b/oci/catalog/overlays/netbox.json
@@ -203,7 +203,55 @@
"owner_strategy": "mapped-root",
"only_when_mount_type": "managed-volume"
}
+ ],
+ "external_services": [
+ "PostgreSQL",
+ "Redis/Valkey"
]
}
+ },
+ "environment_overrides": {
+ "SUPERUSER_EMAIL": {
+ "prompt": "Email address of the admin account"
+ },
+ "ALLOWED_HOST": {
+ "prompt": "Name or address NetBox will be opened at (for example netbox.example.com)"
+ },
+ "DB_HOST": {
+ "prompt": "Address of the PostgreSQL server"
+ },
+ "DB_PORT": {
+ "example": "5432"
+ },
+ "DB_NAME": {
+ "example": "netbox"
+ },
+ "DB_USER": {
+ "prompt": "User of the PostgreSQL server"
+ },
+ "REDIS_HOST": {
+ "prompt": "Address of the Redis or Valkey server"
+ },
+ "REDIS_PORT": {
+ "example": "6379"
+ },
+ "REDIS_USERNAME": {
+ "required": false
+ },
+ "REDIS_PASSWORD": {
+ "required": false
+ },
+ "REDIS_DB_TASK": {
+ "example": "0"
+ },
+ "REDIS_DB_CACHE": {
+ "example": "1"
+ },
+ "DB_PASSWORD": {
+ "prompt": "Password of the PostgreSQL user"
+ },
+ "SUPERUSER_PASSWORD": {
+ "prompt": "Password of the admin account"
+ }
}
}
diff --git a/oci/catalog/overlays/pinchflat.json b/oci/catalog/overlays/pinchflat.json
index b1103515..40bf74d4 100644
--- a/oci/catalog/overlays/pinchflat.json
+++ b/oci/catalog/overlays/pinchflat.json
@@ -14,7 +14,10 @@
"owner_strategy": "mapped-root",
"only_when_mount_type": "managed-volume"
}
- ]
+ ],
+ "resources": {
+ "memory_default_mb": 1024
+ }
}
}
}
diff --git a/oci/catalog/overlays/sickchill.json b/oci/catalog/overlays/sickchill.json
index f9414092..787b1187 100644
--- a/oci/catalog/overlays/sickchill.json
+++ b/oci/catalog/overlays/sickchill.json
@@ -1,4 +1,8 @@
{
+ "catalog_ui": {
+ "hidden": true,
+ "hidden_reason": "Its image no longer publishes a version for any architecture; hidden until it does"
+ },
"proxmox": {
"installer_profile": {
"volume_preparations": [
diff --git a/oci/catalog/overlays/speedtest-tracker.json b/oci/catalog/overlays/speedtest-tracker.json
index 959dbd87..09cc2d79 100644
--- a/oci/catalog/overlays/speedtest-tracker.json
+++ b/oci/catalog/overlays/speedtest-tracker.json
@@ -139,5 +139,18 @@
}
]
}
+ },
+ "environment_overrides": {
+ "APP_URL": {
+ "prompt": "Address Speedtest Tracker will be opened at (for example http://192.168.1.50)"
+ },
+ "SPEEDTEST_SCHEDULE": {
+ "example": "0 */6 * * *",
+ "prompt": "Schedule of the tests in cron format"
+ },
+ "SPEEDTEST_SERVERS": {
+ "required": false,
+ "prompt": "Server IDs to test against, separated by commas (optional)"
+ }
}
}
diff --git a/oci/catalog/overlays/twingate-connector.json b/oci/catalog/overlays/twingate-connector.json
new file mode 100644
index 00000000..250ecab5
--- /dev/null
+++ b/oci/catalog/overlays/twingate-connector.json
@@ -0,0 +1,16 @@
+{
+ "environment_overrides": {
+ "TWINGATE_NETWORK": {
+ "prompt": "Name of the Twingate network (the part before .twingate.com)"
+ },
+ "TWINGATE_ACCESS_TOKEN": {
+ "prompt": "Access token of the connector, from the Twingate admin console"
+ },
+ "TWINGATE_REFRESH_TOKEN": {
+ "prompt": "Refresh token of the connector, from the Twingate admin console"
+ },
+ "TWINGATE_LABEL_HOSTNAME": {
+ "required": false
+ }
+ }
+}
diff --git a/oci/remote/install_oci.sh b/oci/remote/install_oci.sh
index 760a1935..85514401 100755
--- a/oci/remote/install_oci.sh
+++ b/oci/remote/install_oci.sh
@@ -440,11 +440,13 @@ cleanup_volume_seed_staging() {
}
capture_volume_seeds() {
- local seed_count=0 rootfs="/var/lib/lxc/${VMID}/rootfs"
+ local seed_count=0 volume_count=0 rootfs="/var/lib/lxc/${VMID}/rootfs"
local encoded item target source_path selected_mount_type mount_types source stage index=0 mounted=0 failed=0
+ local -A declared_seeds=()
seed_count=$(jq '.proxmox.installer_profile.volume_seeds? // [] | length' "$TEMPLATE_FILE")
- (( seed_count > 0 )) || return 0
+ volume_count=$(jq '[.mounts[]? | select(.type == "managed-volume")] | length' "$DEPLOYMENT_FILE")
+ (( seed_count > 0 || volume_count > 0 )) || return 0
mount_ct_rootfs
mounted=1
@@ -453,6 +455,7 @@ capture_volume_seeds() {
[[ -n $encoded ]] || continue
item=$(printf '%s' "$encoded" | base64 -d)
target=$(jq -er '.container_path' <<<"$item")
+ declared_seeds[$target]=1
source_path=$(jq -er '.source_path' <<<"$item")
mount_types=$(jq -c '.mount_types // ["managed-volume"]' <<<"$item")
selected_mount_type=$(jq -r --arg target "$target" \
@@ -489,6 +492,24 @@ capture_volume_seeds() {
index=$((index + 1))
done < <(jq -r '.proxmox.installer_profile.volume_seeds[]? | @base64' "$TEMPLATE_FILE")
+ # Docker fills a new volume with what the image has at that path, with its
+ # owner and permissions. Every new container volume gets the same here, so
+ # an image that runs as a user of its own finds its data directory its own.
+ while (( failed == 0 )) && IFS= read -r target; do
+ [[ -n $target && -z ${declared_seeds[$target]:-} ]] || continue
+ [[ $target == /* && $target != *[[:space:]]* && $target != *","* ]] || continue
+ is_reused_managed_mount "$target" && continue
+ [[ -d "${rootfs}${target}" && ! -L "${rootfs}${target}" ]] || continue
+ source=$(readlink -e -- "${rootfs}${target}") || continue
+ [[ $source == "${rootfs}"/* ]] || continue
+ stage="${VOLUME_SEED_STAGE_ROOT}/${index}"
+ install -d -m 0700 "$stage" && cp -a -- "${source}/." "${stage}/" || { failed=1; break; }
+ VOLUME_SEED_TARGETS+=("$target")
+ VOLUME_SEED_STAGES+=("$stage")
+ VOLUME_SEED_MOUNT_TYPES+=("managed-volume")
+ index=$((index + 1))
+ done < <(jq -r '.mounts[]? | select(.type == "managed-volume") | .container_path' "$DEPLOYMENT_FILE")
+
if (( mounted == 1 )); then
oci_quiet pct unmount "$VMID" || failed=1
fi
diff --git a/oci/src/proxmenux_oci/casaos.py b/oci/src/proxmenux_oci/casaos.py
index b70403ad..ada25152 100644
--- a/oci/src/proxmenux_oci/casaos.py
+++ b/oci/src/proxmenux_oci/casaos.py
@@ -702,15 +702,26 @@ def _compose_stack_contract(
}
-def _memory_mb(service: dict[str, Any]) -> int:
- value = (((service.get("deploy") or {}).get("resources") or {}).get("reservations") or {}).get("memory")
+def _megabytes(value: Any) -> int | None:
match = re.fullmatch(r"\s*(\d+(?:\.\d+)?)\s*([KMG]?)B?\s*", str(value or ""), re.I)
if not match:
- return 1024
- number = float(match.group(1))
- unit = match.group(2).upper()
+ return None
factors = {"": 1 / (1024 * 1024), "K": 1 / 1024, "M": 1, "G": 1024}
- return max(128, int(number * factors[unit]))
+ return int(float(match.group(1)) * factors[match.group(2).upper()])
+
+
+def _memory_mb(service: dict[str, Any]) -> int:
+ """The memory the container is given. What a Compose file reserves is the
+ least the application is promised, not the most it may use: Docker lets it
+ grow past that, and a container limited to it is stopped by the kernel as
+ soon as it needs more. A reservation only raises the default; a limit the
+ file sets is the memory of the container."""
+ resources = (service.get("deploy") or {}).get("resources") or {}
+ limit = _megabytes((resources.get("limits") or {}).get("memory"))
+ reservation = _megabytes((resources.get("reservations") or {}).get("memory")) or 0
+ if limit:
+ return max(128, limit, reservation)
+ return max(1024, reservation)
def _blockers(
diff --git a/oci/src/proxmenux_oci/catalog.py b/oci/src/proxmenux_oci/catalog.py
index 1e37243d..430b1218 100644
--- a/oci/src/proxmenux_oci/catalog.py
+++ b/oci/src/proxmenux_oci/catalog.py
@@ -21,6 +21,9 @@ SUPPORTED_CATALOG_ARCHITECTURES = ("amd64", "arm64")
# Size of a container volume the installation does not ask about: its size is
# not the user's decision, so it is given room to grow.
MINIMUM_VOLUME_GB = 16
+# The time files of the host that a Compose file mounts. The container takes
+# its time zone from Proxmox.
+TIME_SETTINGS = ("/etc/localtime", "/etc/timezone")
def supported_architectures(values: list[str]) -> list[str]:
@@ -372,10 +375,26 @@ class Catalog:
"sizes": data.get("sizes", {})}
return self._volume_policy
- def _apply_volume_policy(self, app_id: str, contract: dict[str, Any]) -> None:
+ def _apply_volume_policy(self, app_id: str, contract: dict[str, Any],
+ profile: dict[str, Any] | None = None) -> None:
"""A path that holds content of the user is offered as a container
volume or as a host directory, and the installation asks which one; the
- state of the application stays in a container volume without asking."""
+ state of the application stays in a container volume without asking.
+ As a volume it belongs to the user the application writes with, the one
+ a host directory created for the same path is given to. It is always
+ mounted: an image marks it optional because everyone keeps that content
+ somewhere else, not because the application works without it."""
+ # A time file can only be the file of the host: as a volume it would be
+ # a directory in the place of that file, and the container would not start.
+ dropped = {volume["container_path"] for volume in contract.get("volumes", [])
+ if volume["container_path"] in TIME_SETTINGS
+ and "managed-volume" in volume.get("installation_choice", [])}
+ if dropped:
+ contract["volumes"] = [volume for volume in contract["volumes"]
+ if volume["container_path"] not in dropped]
+ if profile and "volume_preparations" in profile:
+ profile["volume_preparations"] = [item for item in profile["volume_preparations"]
+ if item.get("container_path") not in dropped]
policy = self.volume_policy()
shared = policy["shared_paths"] | set(policy["applications"].get(app_id, []))
# A volume known to stay small is created at its stated size.
@@ -386,7 +405,12 @@ class Catalog:
continue
optional = "skip" in choices
if volume["container_path"] in shared or volume.get("default") == "host-bind":
- volume["installation_choice"] = ["managed-volume", "host-bind"] + (["skip"] if optional else [])
+ volume["installation_choice"] = ["managed-volume", "host-bind"]
+ if volume.get("default") == "skip":
+ volume["default"] = "managed-volume"
+ for preparation in (profile or {}).get("volume_preparations", []):
+ if preparation.get("container_path") == volume["container_path"]:
+ preparation["owner_strategy"] = "mapped-application-user"
continue
volume["installation_choice"] = ["managed-volume"] + (["skip"] if optional else [])
if volume.get("default") not in volume["installation_choice"]:
@@ -732,7 +756,8 @@ class Catalog:
template = json.loads(path.read_text(encoding="utf-8"))
self._apply_overlay(item["id"], template)
self._apply_category(item["id"], template["catalog_ui"])
- self._apply_volume_policy(item["id"], template["container_contract"])
+ self._apply_volume_policy(item["id"], template["container_contract"],
+ template.get("proxmox", {}).get("installer_profile"))
self.validate(template, required=False)
return template
diff --git a/oci/src/proxmenux_oci/cli.py b/oci/src/proxmenux_oci/cli.py
index dfe01b9b..10be0374 100644
--- a/oci/src/proxmenux_oci/cli.py
+++ b/oci/src/proxmenux_oci/cli.py
@@ -22,11 +22,12 @@ from .installer import (
InstallError,
build_deployment,
build_rclone_mount_deployment,
+ external_services_notice,
redacted,
run_remote_install,
run_remote_rclone_mount,
)
-from .ui import APP_TITLE, UserCancelled, interactive_ui
+from .ui import APP_TITLE, DETAIL_WINDOW_WIDTH, UserCancelled, interactive_ui, menu_size
PROJECT_ROOT = Path(__file__).resolve().parents[2]
@@ -65,7 +66,7 @@ def is_tested(item: dict[str, Any]) -> bool:
or bool((item.get("community_tested") or {}).get("by")))
-MENU_SIZE = (22, 75, 15)
+MENU_SIZE = menu_size()
MULTI_LABEL = r"\Z4MULTI\Zn"
TESTED_LABEL = r"\Z2✓\Zn"
@@ -114,7 +115,8 @@ def _yes_no(value: Any) -> str:
# ---------------------------------------------------------------- summaries
-DETAIL_WIDTH = 92
+# The description fits the page of the application, inside its borders.
+DETAIL_WIDTH = DETAIL_WINDOW_WIDTH - 6
SERVICE_KINDS = (("postgres", "PostgreSQL"), ("valkey", "Valkey"), ("redis", "Redis"), ("mariadb", "MariaDB"),
("mysql", "MySQL"), ("mongo", "MongoDB"), ("meilisearch", "Meilisearch"))
@@ -200,6 +202,9 @@ def _app_detail_text(catalog: Catalog, item: dict[str, Any], template: dict[str,
lines.append(f" {_service_kind(service):<20} {_image_label(service.get('image'))}")
else:
row(translate("Image"), _image_label(contract["image"]["reference"]))
+ external = profile.get("external_services") or []
+ if external:
+ row(translate("Needs"), f"{', '.join(external)} ({translate('external, already running')})")
volumes = contract.get("volumes", [])
if volumes:
width = max(28, *(len(volume["container_path"]) + 2 for volume in volumes))
@@ -470,6 +475,11 @@ def _rclone_mount(catalog: Catalog, ui) -> None:
def _app_detail(catalog: Catalog, ui, item: dict[str, Any]) -> None:
template = catalog.compose(item["id"])
+ # Said first, before anything of the application is shown or asked.
+ notice = external_services_notice(template)
+ if notice and not ui.confirm(f"{notice}\n\n{translate('Continue?')}", False,
+ title=source_text(template["catalog_ui"]["title"]) or item["id"]):
+ return
actions = []
actions += [("default", translate("Install with default settings")),
("advanced", translate("Install with advanced settings"))]
@@ -610,6 +620,8 @@ def build_parser() -> argparse.ArgumentParser:
subparsers.add_parser("apply-icons", help="Resolve catalog icons against the jsdelivr icon sets")
show_parser = subparsers.add_parser("show", help="Show the summary of a template")
show_parser.add_argument("app")
+ open_parser = subparsers.add_parser("open", help="Open the page of an application in the menu")
+ open_parser.add_argument("app")
install_parser = subparsers.add_parser("install", help="Configure and install an application")
install_parser.add_argument("app")
install_parser.add_argument("--host", default="auto", help="'auto'/'local', or root@IP for development")
@@ -672,10 +684,25 @@ def main(argv: list[str] | None = None) -> int:
if args.command == "show":
print(_template_summary_text(catalog.compose(args.app)))
return 0
+ if args.command == "open":
+ item = next((item for item in _installable(catalog.load_index()["applications"])
+ if item["id"] == args.app), None)
+ ui = interactive_ui()
+ if item is None:
+ ui.message(translate("This application is not in the OCI catalog."))
+ return 1
+ try:
+ _app_detail(catalog, ui, item)
+ except UserCancelled:
+ pass
+ return 0
if args.command == "install":
template = catalog.compose(args.app)
if not template["compatibility"]["automatic_install_candidate"]:
raise InstallError("Installation blocked: " + ", ".join(template["compatibility"]["untranslated_blockers"]))
+ notice = external_services_notice(template)
+ if notice:
+ print(notice + "\n")
deployment = build_deployment(template, None, ADVANCED_MODE if args.advanced else DEFAULT_MODE)
print(_deployment_summary_text(template, deployment))
if not args.dry_run and input("\nInstall? [y/N]: ").strip().casefold() not in {"y", "yes"}:
diff --git a/oci/src/proxmenux_oci/custom.py b/oci/src/proxmenux_oci/custom.py
index 13b8cd81..45a9609d 100644
--- a/oci/src/proxmenux_oci/custom.py
+++ b/oci/src/proxmenux_oci/custom.py
@@ -15,6 +15,7 @@ import yaml
from . import console
from .casaos import convert_casaos_compose, normalize_app_id
+from .catalog import TIME_SETTINGS
from .cli import install_template
from .i18n import source_text
from .installer import ADVANCED_MODE, DEFAULT_MODE
@@ -61,7 +62,6 @@ def _published_port(service: dict[str, Any]) -> str | None:
# settings are the usual exception and are harmless.
HOST_SYSTEM_PATHS = ('/', '/bin', '/boot', '/dev', '/etc', '/lib', '/lib64', '/proc',
'/root', '/run', '/sbin', '/sys', '/usr', '/var')
-TIME_SETTINGS = ('/etc/localtime', '/etc/timezone')
def _check_mounts(service: dict[str, Any]) -> None:
diff --git a/oci/src/proxmenux_oci/installer.py b/oci/src/proxmenux_oci/installer.py
index 8cdd5958..04414723 100644
--- a/oci/src/proxmenux_oci/installer.py
+++ b/oci/src/proxmenux_oci/installer.py
@@ -68,6 +68,21 @@ def _image_documentation(template: dict[str, Any]) -> str | None:
if str(ui.get(key) or "").startswith(("http://", "https://"))), None)
+def external_services_notice(template: dict[str, Any]) -> str | None:
+ """What an application needs already running on the network, as a database
+ of its own. Said before its installation asks how to reach it, so it can be
+ left for when that is ready."""
+ services = template.get("proxmox", {}).get("installer_profile", {}).get("external_services") or []
+ if not services:
+ return None
+ lines = [f"{source_text(template['catalog_ui']['title'])} {translate('needs these external services, already running:')} {', '.join(services)}",
+ translate("ProxMenux does not install them. The installation asks how to connect to them.")]
+ documentation = _image_documentation(template)
+ if documentation:
+ lines.append(f"{translate('They are described in the image documentation:')} {documentation}")
+ return "\n\n".join(lines)
+
+
def _hostname_default(value: str) -> str:
hostname = re.sub(r"[^a-z0-9-]+", "-", value.casefold()).strip("-")[:63].rstrip("-")
return hostname or "oci-app"
@@ -498,7 +513,10 @@ def build_deployment(
"source": source,
"size_gb": size,
"backup": backup,
- "read_only": item["read_only"],
+ # Read-only is for a host directory that already holds the
+ # content. A container volume starts empty, and read-only
+ # nothing could ever be put in it.
+ "read_only": item["read_only"] and volume_mode != "managed-volume",
"create_if_missing": bool(
policy.get("create_if_missing", not _is_system_bind(item))
),
diff --git a/oci/src/proxmenux_oci/management.py b/oci/src/proxmenux_oci/management.py
index 6ead495f..41e9928b 100644
--- a/oci/src/proxmenux_oci/management.py
+++ b/oci/src/proxmenux_oci/management.py
@@ -12,6 +12,7 @@ import tempfile
from . import images
from .i18n import N_, source_text, translate
+from .ui import menu_size
STATUS_LABELS = {'installed': N_('installed'), 'failed': N_('failed'), 'updating': N_('updating'),
'recovering': N_('recovering'), 'installing': N_('installing'),
@@ -268,7 +269,7 @@ def _interactive_management(project, ui):
else:
line += f"\\Z1{translate(STATUS_LABELS.get(r['status'], r['status']))}\\Zn"
options.append((str(r['vmid']), f"{line:<72}"))
- selection = ui.choose(header, options, size=(22, 75, 15), colors=True,
+ selection = ui.choose(header, options, size=menu_size(), colors=True,
title=translate('Manage installed OCI applications'))
if selection is None:
return
diff --git a/oci/src/proxmenux_oci/ui.py b/oci/src/proxmenux_oci/ui.py
index abda0aa2..cbd2d4e9 100644
--- a/oci/src/proxmenux_oci/ui.py
+++ b/oci/src/proxmenux_oci/ui.py
@@ -146,7 +146,7 @@ class TerminalUI:
return value
print(translate("The values do not match. Enter them again."))
- def confirm(self, text: str, default: bool = False) -> bool:
+ def confirm(self, text: str, default: bool = False, title: str | None = None) -> bool:
suffix = " [Y/n]" if default else " [y/N]"
value = input(f"{text}{suffix}: ").strip().casefold()
if self.back_enabled and value == ':back':
@@ -187,6 +187,20 @@ class TerminalUI:
print(text)
+# Width of the windows of the installer: questions, choices and notices. The
+# final summary and the page of an application, which show tables, are wider.
+MENU_WIDTH = 72
+SUMMARY_WIDTH = 78
+DETAIL_WINDOW_WIDTH = 90
+
+
+def menu_size() -> tuple[int, int, int]:
+ """Height, width and rows of a list of the menu: as tall as the main menu
+ of ProxMenux, cut to the terminal."""
+ height = max(10, min(24, shutil.get_terminal_size((100, 30)).lines - 2))
+ return height, 75, height - 8
+
+
@dataclass
class DialogUI:
"""dialog widgets with the ProxMenux backtitle, used for every menu shown
@@ -227,7 +241,7 @@ class DialogUI:
def ask(self, text: str, default: str | None = None, required: bool = True, title: str | None = None) -> str:
while True:
- height, width = self._size(text, 10, 78, 7)
+ height, width = self._size(text, 10, MENU_WIDTH, 7)
result = self._run(["--inputbox", f"\n{text}", height, width, default or ""], title)
if result.returncode != 0:
raise UserCancelled(text)
@@ -238,7 +252,7 @@ class DialogUI:
def password(self, text: str, required: bool = True, title: str | None = None) -> str:
while True:
- height, width = self._size(text, 10, 78, 7)
+ height, width = self._size(text, 10, MENU_WIDTH, 7)
result = self._run(["--insecure", "--passwordbox", f"\n{text}", height, width], title)
if result.returncode != 0:
raise UserCancelled(text)
@@ -256,7 +270,7 @@ class DialogUI:
self.message(translate("The values do not match. Enter them again."))
def confirm(self, text: str, default: bool = False, title: str | None = None) -> bool:
- height, width = self._size(text, 9, 78, 6)
+ height, width = self._size(text, 9, MENU_WIDTH, 6)
widget = ["--yesno", f"\n{text}", height, width]
if not default:
widget = ["--defaultno", *widget]
@@ -275,7 +289,7 @@ class DialogUI:
return result.stderr.strip() if result.returncode == 0 else None
terminal = shutil.get_terminal_size((100, 30))
tag_width = max((len(tag) for tag, _ in options), default=0) + 2 if show_tags else 0
- width = min(max(60, max((len(label) for _, label in options), default=0) + tag_width + 14,
+ width = min(max(MENU_WIDTH, max((len(label) for _, label in options), default=0) + tag_width + 14,
max((len(line) for line in text.splitlines()), default=0) + 6),
terminal.columns - 4, 110)
text_lines = sum(max(1, len(textwrap.wrap(line, width - 6) or [""])) for line in text.splitlines() or [""])
@@ -295,13 +309,14 @@ class DialogUI:
"""A tall menu under a block of information. When the information does
not fit on screen it is shown first in a scrollable box."""
terminal = shutil.get_terminal_size((100, 30))
- width = min(terminal.columns - 4, 104)
+ width = min(terminal.columns - 4, DETAIL_WINDOW_WIDTH)
text_lines = sum(max(1, len(textwrap.wrap(line, width - 6) or [""])) for line in text.splitlines() or [""])
available = terminal.lines - 2
needed = text_lines + len(options) + 9
if needed > available:
self._run(["--colors", "--msgbox", f"\n{text}", str(available), str(width)], title)
- text, needed = translate("Select an option"), len(options) + 10
+ # On its own it is one more menu, as wide as the others.
+ text, needed, width = translate("Select an option"), len(options) + 10, min(width, MENU_WIDTH)
height = min(available, needed)
widget = ["--colors"] + (["--default-item", default] if default else [])
widget += ["--menu", f"\n{text}", str(height), str(width), str(len(options))]
@@ -313,7 +328,7 @@ class DialogUI:
def checklist(self, text, options, defaults, title: str | None = None):
terminal = shutil.get_terminal_size((100, 30))
height = min(max(len(options) + 9, 14), terminal.lines - 2)
- widget = ["--separate-output", "--checklist", f"\n{text}", str(height), "78",
+ widget = ["--separate-output", "--checklist", f"\n{text}", str(height), str(MENU_WIDTH),
str(max(4, min(len(options), height - 8)))]
for tag, label in options:
widget += [tag, label, "on" if tag in defaults else "off"]
@@ -323,20 +338,20 @@ class DialogUI:
return result.stderr.split()
def message(self, text: str, title: str | None = None) -> None:
- height, width = self._size(text, 8, 84, 6)
+ height, width = self._size(text, 8, MENU_WIDTH, 6)
self._run(["--msgbox", f"\n{text}", height, width], title)
def review(self, text: str, title: str | None = None, question: str | None = None,
default: bool = True) -> bool:
body = f"{text}\n\n{question or translate('Continue?')}"
- height, width = self._size(body, 12, 90, 6)
+ height, width = self._size(body, 12, SUMMARY_WIDTH, 6)
widget = ["--yesno", f"\n{body}", height, width]
if not default:
widget = ["--defaultno", *widget]
return self._run(widget, title).returncode == 0
def info(self, text: str, title: str | None = None) -> None:
- height, width = self._size(text, 6, 70, 5)
+ height, width = self._size(text, 6, MENU_WIDTH, 5)
self._run(["--infobox", f"\n{text}", height, width], title)
diff --git a/oci/tests/test_container_memory.py b/oci/tests/test_container_memory.py
new file mode 100644
index 00000000..f4698fe1
--- /dev/null
+++ b/oci/tests/test_container_memory.py
@@ -0,0 +1,47 @@
+"""What a Compose file reserves for an application is the least it is promised,
+not the most it may use. A container limited to that figure is stopped by the
+kernel as soon as the application needs more."""
+
+from pathlib import Path
+import sys
+import unittest
+
+ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT / "src"))
+
+from proxmenux_oci.casaos import _memory_mb
+from proxmenux_oci.catalog import Catalog
+
+
+def service(reservation=None, limit=None):
+ resources = {}
+ if reservation:
+ resources["reservations"] = {"memory": reservation}
+ if limit:
+ resources["limits"] = {"memory": limit}
+ return {"deploy": {"resources": resources}} if resources else {}
+
+
+class ContainerMemoryTests(unittest.TestCase):
+ def test_a_reservation_is_not_the_memory_of_the_container(self):
+ self.assertEqual(_memory_mb(service(reservation="64M")), 1024)
+ self.assertEqual(_memory_mb(service(reservation="256M")), 1024)
+
+ def test_a_larger_reservation_raises_it(self):
+ self.assertEqual(_memory_mb(service(reservation="2G")), 2048)
+
+ def test_a_limit_the_file_sets_is_the_memory_of_the_container(self):
+ self.assertEqual(_memory_mb(service(limit="512M")), 512)
+ self.assertEqual(_memory_mb(service(reservation="128M", limit="128M")), 128)
+
+ def test_nothing_stated_is_the_default(self):
+ self.assertEqual(_memory_mb({}), 1024)
+
+ def test_the_recipes_that_only_reserve_memory_get_the_default(self):
+ catalog = Catalog(ROOT)
+ for app in ("homeassistant-official", "unifi-controller", "grafana", "gitea", "vaultwarden", "uptimekuma"):
+ self.assertGreaterEqual(catalog.compose(app)["proxmox"]["defaults"]["memory_mb"], 1024, app)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/oci/tests/test_content_volume_owner.py b/oci/tests/test_content_volume_owner.py
new file mode 100644
index 00000000..f311b163
--- /dev/null
+++ b/oci/tests/test_content_volume_owner.py
@@ -0,0 +1,91 @@
+"""A path that holds content of the user is offered as a container volume or
+as a host directory. Either way it belongs to the user the application writes
+with: a volume left to root is one the application cannot write to."""
+
+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
+
+
+def owners(template):
+ profile = template["proxmox"]["installer_profile"]
+ return {item["container_path"]: item["owner_strategy"] for item in profile["volume_preparations"]}
+
+
+class ContentVolumeOwnerTests(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ cls.catalog = Catalog(ROOT)
+
+ def test_the_content_of_the_user_belongs_to_the_application(self):
+ found = owners(self.catalog.compose("duplicati"))
+ self.assertEqual(found["/backups"], "mapped-application-user")
+ self.assertEqual(found["/source"], "mapped-application-user")
+
+ def test_the_state_of_the_application_stays_as_the_recipe_says(self):
+ self.assertEqual(owners(self.catalog.compose("duplicati"))["/config"], "mapped-root")
+
+ def test_it_follows_the_choice_the_installation_offers(self):
+ contract = {"volumes": [
+ {"container_path": "/downloads", "installation_choice": ["managed-volume", "host-bind", "skip"]},
+ {"container_path": "/config", "installation_choice": ["managed-volume", "host-bind"],
+ "managed_volume": {"default_size_gb": 4}}]}
+ profile = {"volume_preparations": [
+ {"container_path": "/downloads", "owner_strategy": "mapped-root"},
+ {"container_path": "/config", "owner_strategy": "mapped-root"}]}
+ self.catalog._apply_volume_policy("an-application", contract, profile)
+ self.assertEqual({item["container_path"]: item["owner_strategy"] for item in profile["volume_preparations"]},
+ {"/downloads": "mapped-application-user", "/config": "mapped-root"})
+ self.assertEqual(contract["volumes"][1]["installation_choice"], ["managed-volume"])
+
+ def test_the_content_of_the_user_is_always_mounted(self):
+ contract = {"volumes": [
+ {"container_path": "/downloads", "installation_choice": ["managed-volume", "host-bind", "skip"],
+ "default": "skip"},
+ {"container_path": "/var/run/docker.sock", "installation_choice": ["host-bind", "skip"], "default": "skip"}]}
+ self.catalog._apply_volume_policy("an-application", contract, {})
+ self.assertEqual(contract["volumes"][0]["installation_choice"], ["managed-volume", "host-bind"])
+ self.assertEqual(contract["volumes"][0]["default"], "managed-volume")
+ # What is not content of the user, as a socket of the host, can still be left out.
+ self.assertEqual(contract["volumes"][1]["installation_choice"], ["host-bind", "skip"])
+ for app, path in (("sonarr", "/downloads"), ("manyfold", "/libraries"), ("qbittorrent", "/downloads")):
+ volume = next(item for item in self.catalog.compose(app)["container_contract"]["volumes"]
+ if item["container_path"] == path)
+ self.assertNotIn("skip", volume["installation_choice"], app)
+ self.assertEqual(volume["default"], "managed-volume", app)
+
+ def test_a_container_volume_is_never_read_only(self):
+ source = (ROOT / "src" / "proxmenux_oci" / "installer.py").read_text()
+ self.assertIn('"read_only": item["read_only"] and volume_mode != "managed-volume"', source)
+
+ def test_the_time_files_of_the_host_are_not_mounted(self):
+ for app in ("openhab", "lyrionmusicserver"):
+ template = self.catalog.compose(app)
+ paths = [volume["container_path"] for volume in template["container_contract"]["volumes"]]
+ self.assertNotIn("/etc/localtime", paths, app)
+ self.assertNotIn("/etc/timezone", paths, app)
+ self.assertNotIn("/etc/localtime", owners(template), app)
+
+ def test_a_new_volume_is_filled_from_the_image_as_docker_does(self):
+ # Declared or not: an image that runs as a user of its own, as Node-RED
+ # or Grafana, finds its data directory with its own owner.
+ installer = (ROOT / "remote" / "install_oci.sh").read_text()
+ capture = installer[installer.index("capture_volume_seeds() {"):installer.index("apply_volume_seeds() {")]
+ self.assertIn('select(.type == "managed-volume") | .container_path\' "$DEPLOYMENT_FILE")', capture)
+ self.assertIn('is_reused_managed_mount "$target" && continue', capture)
+ self.assertIn('cp -a -- "${source}/." "${stage}/"', capture)
+ self.assertIn("[[ -n $target && -z ${declared_seeds[$target]:-} ]] || continue", capture)
+
+ def test_a_recipe_without_preparations_is_left_alone(self):
+ contract = {"volumes": [{"container_path": "/downloads", "installation_choice": ["managed-volume", "host-bind"]}]}
+ self.catalog._apply_volume_policy("an-application", contract, None)
+ self.catalog._apply_volume_policy("an-application", contract, {})
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/oci/tests/test_external_services.py b/oci/tests/test_external_services.py
new file mode 100644
index 00000000..195e2857
--- /dev/null
+++ b/oci/tests/test_external_services.py
@@ -0,0 +1,74 @@
+"""Some images are only the application and expect a database that is already
+running. The installation says so before its first question, with the address
+of the image documentation, and asks for the connection in plain words."""
+
+from pathlib import Path
+import sys
+import unittest
+
+ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT / "src"))
+
+from proxmenux_oci import i18n
+from proxmenux_oci.catalog import Catalog
+from proxmenux_oci.installer import external_services_notice
+
+
+def asked_without_a_proposal(template):
+ return [item["name"] for item in template["container_contract"]["environment"]
+ if item["required"] and item["name"] not in ("PUID", "PGID", "TZ")
+ and not item["sensitive"] and item.get("example") in (None, "")]
+
+
+class ExternalServicesTests(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ cls.language, i18n._language = getattr(i18n, "_language", None), "en"
+ cls.catalog = Catalog(ROOT)
+
+ @classmethod
+ def tearDownClass(cls):
+ i18n._language = cls.language
+
+ def test_the_notice_names_the_services_and_the_image_documentation(self):
+ notice = external_services_notice(self.catalog.compose("manyfold"))
+ self.assertIn("Manyfold needs these external services, already running: Redis/Valkey", notice)
+ self.assertIn("ProxMenux does not install them", notice)
+ self.assertIn("https://docs.linuxserver.io/images/docker-manyfold/", notice)
+
+ def test_an_application_that_needs_nothing_else_has_no_notice(self):
+ self.assertIsNone(external_services_notice(self.catalog.compose("duplicati")))
+ self.assertIsNone(external_services_notice(self.catalog.compose("lychee")))
+
+ def test_every_connection_question_says_what_it_asks_for(self):
+ for app in ("manyfold", "bookstack", "netbox"):
+ template = self.catalog.compose(app)
+ self.assertTrue(external_services_notice(template), app)
+ for item in template["container_contract"]["environment"]:
+ if item["name"] in asked_without_a_proposal(template):
+ self.assertTrue(item.get("prompt"), f"{app}: {item['name']}")
+
+ def test_no_default_installation_asks_for_a_value_without_saying_what_it_is(self):
+ from proxmenux_oci.installer import _example_default
+ unexplained = {}
+ for item in self.catalog.load_index()["applications"]:
+ if item.get("hidden") or not item.get("automatic_install_candidate"):
+ continue
+ template = self.catalog.compose(item["id"])
+ for variable in template["container_contract"].get("environment", []):
+ if (variable["required"] and not variable["sensitive"] and not variable.get("prompt")
+ and variable.get("prompt_user", True) is not False
+ and variable["name"] not in ("PUID", "PGID", "TZ")
+ and _example_default(variable["name"], variable["example"], "Etc/UTC") in (None, "")):
+ unexplained.setdefault(item["id"], []).append(variable["name"])
+ self.assertEqual(unexplained, {})
+
+ def test_an_image_with_a_database_of_its_own_installs_without_asking_for_one(self):
+ self.assertEqual(asked_without_a_proposal(self.catalog.compose("lychee")), [])
+ database = {item["name"]: item for item in self.catalog.compose("lychee")["container_contract"]["environment"]}
+ self.assertEqual(database["DB_CONNECTION"]["example"], "sqlite")
+ self.assertFalse(database["DB_HOST"]["required"])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/scripts/help_info_menu.sh b/scripts/help_info_menu.sh
index 873ac07a..770400ca 100644
--- a/scripts/help_info_menu.sh
+++ b/scripts/help_info_menu.sh
@@ -1077,7 +1077,25 @@ show_tools_commands() {
# ===============================================================
# Help and Info Menu
# ===============================================================
+# A section given as argument, as the menu search does, is opened first.
+PRESELECTED_OPTION="${1:-}"
while true; do
+ if [[ "$PRESELECTED_OPTION" =~ ^[1-9]$ ]]; then
+ OPTION="$PRESELECTED_OPTION"
+ PRESELECTED_OPTION=""
+ case $OPTION in
+ 1) show_system_commands ;;
+ 2) show_vm_ct_commands ;;
+ 3) show_storage_commands ;;
+ 4) show_network_commands ;;
+ 5) show_update_commands ;;
+ 6) show_gpu_commands ;;
+ 7) show_zfs_commands ;;
+ 8) show_backup_commands ;;
+ 9) show_tools_commands ;;
+ esac
+ continue
+ fi
OPTION=$(dialog --stdout \
--title "$(translate 'Help and Info')" \
--menu "$(translate 'Select a category of useful commands:')" 20 70 9 \
diff --git a/scripts/menus/config_menu.sh b/scripts/menus/config_menu.sh
index 1cf1cdef..87ba734a 100644
--- a/scripts/menus/config_menu.sh
+++ b/scripts/menus/config_menu.sh
@@ -613,8 +613,18 @@ show_config_menu() {
menu_options+=("$option_num" "$(translate "Return to Main Menu")")
option_actions[$option_num]="return_main"
+ # An action given as argument, as the menu search does, is opened
+ # first; the menu follows.
+ OPTION=""
+ if [[ -n "$PRESELECTED_OPTION" ]]; then
+ local index
+ for index in "${!option_actions[@]}"; do
+ [[ "${option_actions[$index]}" == "$PRESELECTED_OPTION" ]] && OPTION="$index"
+ done
+ PRESELECTED_OPTION=""
+ fi
# Show menu
- OPTION=$(dialog --clear --backtitle "$BACKTITLE" \
+ [[ -n "$OPTION" ]] || OPTION=$(dialog --clear --backtitle "$BACKTITLE" \
--title "$(translate "Configuration Menu")" \
--menu "$(translate "Select an option:")" 20 70 10 \
"${menu_options[@]}" 3>&1 1>&2 2>&3)
@@ -854,4 +864,5 @@ uninstall_proxmenu() {
# ==========================================================
# Main execution
+PRESELECTED_OPTION="${1:-}"
show_config_menu
diff --git a/scripts/menus/create_vm_menu.sh b/scripts/menus/create_vm_menu.sh
index 11279ccb..6c081a27 100644
--- a/scripts/menus/create_vm_menu.sh
+++ b/scripts/menus/create_vm_menu.sh
@@ -130,27 +130,35 @@ function start_vm_configuration() {
+# A system type given as argument, as the menu search does, is opened first;
+# the menu follows.
+PRESELECTED_OPTION="${1:-}"
while true; do
VM_STORAGE_IOMMU_PENDING_REBOOT=0
WIZARD_CONFLICT_POLICY=""
WIZARD_CONFLICT_SCOPE=""
export WIZARD_CONFLICT_POLICY WIZARD_CONFLICT_SCOPE
- OS_TYPE=$(dialog --colors --backtitle "ProxMenux" \
- --title "$(translate "Select System Type")" \
- --menu "\n$(translate "Choose the type of virtual system to install:")" 20 70 10 \
- 1 "$(translate "Create VM System NAS")" \
- 2 "$(translate "Create VM System Windows")" \
- 3 "$(translate "Create VM System Linux")" \
- "" "" \
- "" "\Z4───────────────── $(translate "Community Scripts") ─────────────────\Zn" \
- "" "" \
- 4 "$(translate "Create VM System macOS (OSX-PROXMOX)")" \
- 5 "$(translate "Create VM System Others (based Linux)")" \
- "" "" \
- 6 "$(translate "Return to Main Menu")" \
- 3>&1 1>&2 2>&3)
+ if [[ "$PRESELECTED_OPTION" =~ ^[1-5]$ ]]; then
+ OS_TYPE="$PRESELECTED_OPTION"
+ PRESELECTED_OPTION=""
+ else
+ OS_TYPE=$(dialog --colors --backtitle "ProxMenux" \
+ --title "$(translate "Select System Type")" \
+ --menu "\n$(translate "Choose the type of virtual system to install:")" 20 70 10 \
+ 1 "$(translate "Create VM System NAS")" \
+ 2 "$(translate "Create VM System Windows")" \
+ 3 "$(translate "Create VM System Linux")" \
+ "" "" \
+ "" "\Z4───────────────── $(translate "Community Scripts") ─────────────────\Zn" \
+ "" "" \
+ 4 "$(translate "Create VM System macOS (OSX-PROXMOX)")" \
+ 5 "$(translate "Create VM System Others (based Linux)")" \
+ "" "" \
+ 6 "$(translate "Return to Main Menu")" \
+ 3>&1 1>&2 2>&3)
- [[ $? -ne 0 || "$OS_TYPE" == "6" ]] && exec bash "$MENU_REPO/main_menu.sh"
+ [[ $? -ne 0 || "$OS_TYPE" == "6" ]] && exec bash "$MENU_REPO/main_menu.sh"
+ fi
case "$OS_TYPE" in
1)
diff --git a/scripts/menus/helper_plain_text.py b/scripts/menus/helper_plain_text.py
new file mode 100644
index 00000000..b81857d5
--- /dev/null
+++ b/scripts/menus/helper_plain_text.py
@@ -0,0 +1,46 @@
+#!/usr/bin/env python3
+"""Convert catalogue HTML to terminal text without altering shell examples."""
+import re
+import sys
+from html.parser import HTMLParser
+
+
+class PlainText(HTMLParser):
+ def __init__(self):
+ super().__init__(convert_charrefs=True)
+ self.parts = []
+ self.hidden = 0
+
+ def handle_starttag(self, tag, attrs):
+ if tag in ('script', 'style'):
+ self.hidden += 1
+ elif not self.hidden:
+ if tag in ('p', 'div', 'br', 'ul', 'ol', 'pre', 'blockquote',
+ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'):
+ self.parts.append('\n')
+ elif tag == 'li':
+ self.parts.append('\n• ')
+
+ def handle_endtag(self, tag):
+ if tag in ('script', 'style'):
+ self.hidden = max(0, self.hidden - 1)
+ elif not self.hidden and tag in ('p', 'div', 'li', 'ul', 'ol', 'pre',
+ 'blockquote', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'):
+ self.parts.append('\n')
+
+ def handle_data(self, data):
+ if not self.hidden:
+ self.parts.append(data)
+
+
+def plain_text(value):
+ parser = PlainText()
+ parser.feed(value)
+ parser.close()
+ result = ''.join(parser.parts).replace('\xa0', ' ')
+ result = '\n'.join(line.strip() for line in result.splitlines())
+ return re.sub(r'\n{3,}', '\n\n', result).strip()
+
+
+if __name__ == '__main__':
+ sys.stdout.write(plain_text(sys.stdin.read()))
diff --git a/scripts/menus/main_menu.sh b/scripts/menus/main_menu.sh
index 4b735973..82e0391f 100644
--- a/scripts/menus/main_menu.sh
+++ b/scripts/menus/main_menu.sh
@@ -52,12 +52,20 @@ show_menu() {
while true; do
- local menu_title="Main ProxMenux"
+ local menu_title="Main menu"
+
+ # As tall as its options need, cut to the terminal.
+ local height=24 limit=$(( $(tput lines 2>/dev/null || echo 24) - 2 ))
+ (( height > limit )) && height=$limit
+ local rows=$(( height - 8 ))
+ (( rows < 3 )) && rows=3
dialog --clear \
--backtitle "ProxMenux" \
--title "$(translate "$menu_title")" \
- --menu "\n$(translate "Select an option:")" 22 70 14 \
+ --menu "$(translate "Select an option:")" "$height" 75 "$rows" \
+ search "$(translate "Search options and applications")" \
+ "" "" \
1 "$(translate "Settings post-install Proxmox")" \
2 "$(translate "Hardware: GPUs and Coral-TPU")" \
3 "$(translate "Create VM from template or script")" \
@@ -85,6 +93,21 @@ show_menu() {
OPTION=$(<"$TEMP_FILE")
case $OPTION in
+ search)
+ # A result that is a sub-menu comes back as its path: it is
+ # opened from here, as the options below are.
+ local handoff
+ local -a target=()
+ handoff=$(mktemp)
+ python3 "$LOCAL_SCRIPTS/menus/search_menu.py" --handoff "$handoff"
+ mapfile -t target < "$handoff"
+ rm -f "$handoff"
+ if [[ "${target[0]:-}" == "$LOCAL_SCRIPTS"/* && -f "${target[0]}" ]]; then
+ rm -f "$TEMP_FILE"
+ exec bash "${target[@]}"
+ fi
+ ;;
+ "") ;;
1) exec bash "$LOCAL_SCRIPTS/menus/menu_post_install.sh" ;;
2) exec bash "$LOCAL_SCRIPTS/menus/hw_grafics_menu.sh" ;;
3) exec bash "$LOCAL_SCRIPTS/menus/create_vm_menu.sh" ;;
diff --git a/scripts/menus/menu_Helper_Scripts.sh b/scripts/menus/menu_Helper_Scripts.sh
index add6d3ea..ab0fcaf0 100644
--- a/scripts/menus/menu_Helper_Scripts.sh
+++ b/scripts/menus/menu_Helper_Scripts.sh
@@ -60,15 +60,22 @@ done
# Without these the menu would hang silently for minutes when the network
# is down or the upstream is slow, with no signal to the user. Audit
# Tier 6 — `menu_Helper_Scripts.sh` curl sin --max-time.
-CACHE_JSON=$(curl -s --connect-timeout 5 --max-time 15 "$HELPERS_JSON_URL")
+CACHE_JSON=$(curl -fsSL --connect-timeout 5 --max-time 15 "$HELPERS_JSON_URL")
# Validate that the JSON loaded correctly
if ! echo "$CACHE_JSON" | jq -e 'if type == "array" and length > 0 then true else false end' >/dev/null 2>&1; then
dialog --title "Helper Scripts" \
--msgbox "Error: Could not load helpers cache.\nCheck your internet connection and try again.\n\nURL: $HELPERS_JSON_URL" 10 70
+ # Opened for one script, as the menu search does: back to where it came from.
+ [[ -n "${1:-}" ]] && exit 1
exec bash "$LOCAL_SCRIPTS/menus/main_menu.sh"
fi
+# The menus are as tall as the main menu, cut to the terminal.
+MENU_HEIGHT=$(( $(tput lines 2>/dev/null || echo 24) - 2 ))
+(( MENU_HEIGHT > 24 )) && MENU_HEIGHT=24
+MENU_ROWS=$(( MENU_HEIGHT - 8 ))
+
# ---------------------------------------------------------------------------
# Build category map directly from the cache (id → name).
# Uses transpose to pair categories[] and category_names[] arrays — no
@@ -107,15 +114,99 @@ get_type_label() {
# Download and execute a script URL, with optional mirror fallback
# ---------------------------------------------------------------------------
download_script() {
- local url="$1"
+ local url="$1" script_content
- # Same timeouts as the cache fetch above so a stalled mirror can't hang
- # the menu indefinitely. The HEAD probe and the GET both get bounded.
- if curl --silent --connect-timeout 5 --max-time 10 --head --fail "$url" >/dev/null; then
- bash <(curl -s --connect-timeout 5 --max-time 60 "$url")
- else
+ # The script is downloaded whole before anything runs: a download that
+ # fails or comes back empty runs nothing, on the host or in a container.
+ if ! script_content=$(curl -fsSL --connect-timeout 5 --max-time 60 "$url") || [[ -z "${script_content//[[:space:]]/}" ]]; then
dialog --title "Helper Scripts" --msgbox "$(translate "Error: Failed to download the script.")" 8 70
+ return 1
fi
+ clear
+ if [[ -n "${HELPER_LXC_TARGET:-}" ]]; then
+ pct exec "$HELPER_LXC_TARGET" --keep-env 0 -- \
+ env TERM="${TERM:-xterm}" bash -c "$script_content"
+ else
+ bash -c "$script_content"
+ fi
+}
+
+# ---------------------------------------------------------------------------
+# Where an addon runs. The catalog says it for each one: on the Proxmox host,
+# inside a container, or either. Everything that is not an addon runs on the
+# host, which is where a script that creates a container or a VM belongs.
+# $1 type $2 the "execute_in" of the catalog, separated by spaces
+# Prints host, lxc or ask.
+# ---------------------------------------------------------------------------
+helper_target_choice() {
+ local type="$1" places=" $2 " host=0 lxc=0
+ [[ "$type" == addon ]] || { echo host; return; }
+ [[ "$places" == *" pve "* ]] && host=1
+ [[ "$places" == *" lxc "* ]] && lxc=1
+ if (( host == 1 && lxc == 0 )); then
+ echo host
+ elif (( lxc == 1 && host == 0 )); then
+ echo lxc
+ else
+ echo ask
+ fi
+}
+
+# The containers an addon can be run in: every LXC but the OCI ones, which
+# are an application image and not a system to add things to.
+helper_lxc_rows() {
+ local ctid status lock name
+ while read -r ctid status lock name; do
+ [[ "$ctid" =~ ^[0-9]+$ ]] || continue
+ pmx_lxc_is_oci "$ctid" && continue
+ # pct list leaves the Lock column blank for a container that has none.
+ [[ -n "$name" ]] || { name="$lock"; lock=""; }
+ printf '%s\t%s · %s%s\n' "$ctid" "$name" "$status" "${lock:+ · $lock}"
+ done < <(pct list)
+}
+
+# Asks where to run an addon and leaves the container in HELPER_LXC_TARGET,
+# empty for the host. Returns 1 when nothing has to run.
+# $1 name of the addon $2 host, lxc or ask
+select_helper_target() {
+ HELPER_LXC_TARGET=""
+ local title="$1" target="$2" ctid label state
+ [[ "$target" == host ]] && return 0
+ if [[ "$target" == ask ]]; then
+ target=$(dialog --clear --backtitle "ProxMenux" --title "$title" \
+ --menu "$(translate "Where do you want to run this addon?")" 12 75 2 \
+ lxc "$(translate "In an LXC")" \
+ host "$(translate "On the host")" 3>&1 1>&2 2>&3) || return 1
+ [[ "$target" == host ]] && return 0
+ fi
+ [[ "$target" == lxc ]] || return 1
+ local -a containers=()
+ while IFS=$'\t' read -r ctid label; do
+ containers+=("$ctid" "$label")
+ done < <(helper_lxc_rows)
+ if [[ ${#containers[@]} -eq 0 ]]; then
+ dialog --title "$title" --msgbox "$(translate "There are no LXC containers available. OCI containers are not included.")" 9 75
+ return 1
+ fi
+ ctid=$(dialog --clear --backtitle "ProxMenux" --title "$title · LXC" \
+ --menu "$(translate "Select the LXC. OCI containers are not included.")" "$MENU_HEIGHT" 75 "$MENU_ROWS" \
+ "${containers[@]}" 3>&1 1>&2 2>&3) || return 1
+ [[ "$ctid" =~ ^[0-9]+$ && -f "/etc/pve/lxc/$ctid.conf" ]] || return 1
+ pmx_lxc_is_oci "$ctid" && return 1
+ state=$(pct status "$ctid") || return 1
+ if [[ "$state" != "status: running" ]]; then
+ dialog --backtitle "ProxMenux" --title "$title · LXC $ctid" \
+ --yesno "\n$(translate "The LXC is stopped. Do you want to start it to run the addon?")" 10 75 || return 1
+ if ! pct start "$ctid"; then
+ dialog --title "$title · LXC $ctid" --msgbox "$(translate "The LXC could not be started.")" 8 75
+ return 1
+ fi
+ fi
+ if ! pct exec "$ctid" --keep-env 0 -- sh -c 'command -v bash >/dev/null'; then
+ dialog --title "$title · LXC $ctid" --msgbox "$(translate "The LXC has no Bash to run this script with. The addon was not run.")" 9 75
+ return 1
+ fi
+ HELPER_LXC_TARGET="$ctid"
}
RETURN_TO_MAIN=false
@@ -172,13 +263,15 @@ run_script_by_slug() {
local first="${script_infos[0]}"
local name desc notes warnings port website
name=$(decode "$first" ".name")
- desc=$(decode "$first" ".desc")
- notes=$(decode "$first" '.notes // [] | join("\n")')
+ # The catalog writes some of its texts in HTML.
+ local plain="$LOCAL_SCRIPTS/menus/helper_plain_text.py"
+ desc=$(decode "$first" ".desc" | python3 "$plain")
+ notes=$(decode "$first" '.notes // [] | join("\n")' | python3 "$plain")
# Sprint 11.7: PocketBase upstream tags certain notes with type=="warning".
# The cache generator splits them into a separate `warnings` array so the
# menu can render them in red with a dedicated WARNINGS header instead of
# burying them inside the regular notes block.
- warnings=$(decode "$first" '.warnings // [] | join("\n")')
+ warnings=$(decode "$first" '.warnings // [] | join("\n")' | python3 "$plain")
port=$(decode "$first" ".port // 0")
website=$(decode "$first" ".website // empty")
@@ -271,6 +364,10 @@ run_script_by_slug() {
gh_url=$(decode "$selected" ".script_url")
mirror_url=$(decode "$selected" ".script_url_mirror // empty")
+ # An addon is run where the catalog says it belongs.
+ select_helper_target "$name" "$(helper_target_choice \
+ "$(decode "$selected" '.type // empty')" "$(decode "$selected" '.execute_in // [] | join(" ")')")" || return
+
if [[ "$sel_src" == "GH" ]]; then
download_script "$gh_url"
elif [[ "$sel_src" == "MR" ]]; then
@@ -388,7 +485,7 @@ search_and_filter_scripts() {
selected=$(dialog --colors --backtitle "ProxMenux" \
--title "$title" \
--menu "$(translate "Select a script or action:")" \
- 22 75 15 "${menu_items[@]}" 3>&1 1>&2 2>&3)
+ "$MENU_HEIGHT" 75 "$MENU_ROWS" "${menu_items[@]}" 3>&1 1>&2 2>&3)
[[ $? -ne 0 ]] && return
@@ -416,6 +513,12 @@ search_and_filter_scripts() {
done
}
+# A script given as argument, as the menu search does, is opened on its own.
+if [[ -n "${1:-}" ]]; then
+ run_script_by_slug "$1"
+ exit 0
+fi
+
# ---------------------------------------------------------------------------
# Main loop — category list built from embedded category data.
# We map scriptcatXXXXX IDs to short numeric indices so dialog doesn't show
@@ -442,7 +545,7 @@ while true; do
SELECTED_IDX=$(dialog --backtitle "ProxMenux" \
--title "Proxmox VE Helper-Scripts" \
--menu "$(translate "Select a category or search for scripts:")" \
- 22 75 15 "${MENU_ITEMS[@]}" 3>&1 1>&2 2>&3) || {
+ "$MENU_HEIGHT" 75 "$MENU_ROWS" "${MENU_ITEMS[@]}" 3>&1 1>&2 2>&3) || {
dialog --clear --title "ProxMenux" \
--msgbox "\n\n$(translate "Visit the website to discover more scripts, stay updated with the latest updates, and support the project:")\n\nhttps://community-scripts.github.io/ProxmoxVE" 15 70
exec bash "$LOCAL_SCRIPTS/menus/main_menu.sh"
@@ -484,7 +587,7 @@ while true; do
SCRIPT_INDEX=$(dialog --colors --backtitle "ProxMenux" \
--title "$(translate "Scripts in") ${CATEGORY_NAMES[$SELECTED]}" \
--menu "$(translate "Choose a script to execute:"):" \
- 22 75 15 "${SCRIPTS[@]}" 3>&1 1>&2 2>&3) || break
+ "$MENU_HEIGHT" 75 "$MENU_ROWS" "${SCRIPTS[@]}" 3>&1 1>&2 2>&3) || break
SCRIPT_SELECTED="${INDEX_TO_SLUG[$SCRIPT_INDEX]}"
run_script_by_slug "$SCRIPT_SELECTED"
diff --git a/scripts/menus/menu_post_install.sh b/scripts/menus/menu_post_install.sh
index d26a22fa..447583e7 100644
--- a/scripts/menus/menu_post_install.sh
+++ b/scripts/menus/menu_post_install.sh
@@ -288,6 +288,7 @@ show_menu() {
declare -A script_commands
local counter=1
+ local preselected=""
# Sprint 12C: re-evaluate available updates on every loop so the
# entry vanishes after the user has applied everything (and the
@@ -317,6 +318,7 @@ show_menu() {
formatted_item=$(format_menu_item "$translated_name" "$source")
menu_items+=("$counter" "$formatted_item")
script_commands["$counter"]="$command"
+ [[ -n "$PRESELECTED_OPTION" && "$name" == "$PRESELECTED_OPTION" ]] && preselected="$counter"
((counter++))
done
@@ -332,6 +334,7 @@ show_menu() {
formatted_item=$(format_menu_item "$translated_name" "$source")
menu_items+=("$counter" "$formatted_item")
script_commands["$counter"]="$command"
+ [[ -n "$PRESELECTED_OPTION" && "$name" == "$PRESELECTED_OPTION" ]] && preselected="$counter"
((counter++))
done
@@ -340,15 +343,23 @@ show_menu() {
menu_items+=("0" "$(translate "Return to Main Menu")")
- exec 3>&1
- script_selection=$(dialog --clear --colors \
- --backtitle "ProxMenux" \
- --title "$(translate "Post-Installation Scripts")" \
- --menu "\n$(translate "Select a post-installation script:")\n" \
- 24 84 16 \
- "${menu_items[@]}" 2>&1 1>&3)
- exit_status=$?
- exec 3>&-
+ # An option given as argument, as the menu search does, is opened
+ # first; the menu follows.
+ if [[ -n "$preselected" ]]; then
+ script_selection="$preselected"
+ exit_status=0
+ PRESELECTED_OPTION=""
+ else
+ exec 3>&1
+ script_selection=$(dialog --clear --colors \
+ --backtitle "ProxMenux" \
+ --title "$(translate "Post-Installation Scripts")" \
+ --menu "\n$(translate "Select a post-installation script:")\n" \
+ 24 84 16 \
+ "${menu_items[@]}" 2>&1 1>&3)
+ exit_status=$?
+ exec 3>&-
+ fi
if [ $exit_status -ne 0 ] || [ "$script_selection" = "0" ]; then
@@ -372,4 +383,5 @@ show_menu() {
# ==========================================================
+PRESELECTED_OPTION="${1:-}"
show_menu
diff --git a/scripts/menus/network_menu.sh b/scripts/menus/network_menu.sh
index 8b4bfa79..0c2bb8a9 100644
--- a/scripts/menus/network_menu.sh
+++ b/scripts/menus/network_menu.sh
@@ -1237,6 +1237,7 @@ show_menu() {
declare -A script_commands
local counter=1
+ local preselected=""
for script in "${PROXMENUX_SCRIPTS[@]}"; do
IFS='|' read -r name source command <<< "$script"
@@ -1246,6 +1247,7 @@ show_menu() {
formatted_item=$(format_menu_item "$translated_name" "$source")
menu_items+=("$counter" "$formatted_item")
script_commands["$counter"]="$command"
+ [[ -n "$PRESELECTED_OPTION" && "$name" == "$PRESELECTED_OPTION" ]] && preselected="$counter"
((counter++))
done
@@ -1261,6 +1263,7 @@ show_menu() {
formatted_item=$(format_menu_item "$translated_name" "$source")
menu_items+=("$counter" "$formatted_item")
script_commands["$counter"]="$command"
+ [[ -n "$PRESELECTED_OPTION" && "$name" == "$PRESELECTED_OPTION" ]] && preselected="$counter"
((counter++))
done
@@ -1269,15 +1272,23 @@ show_menu() {
menu_items+=("0" "$(translate "Return to Main Menu")")
- exec 3>&1
- script_selection=$(dialog --clear --colors \
- --backtitle "ProxMenux" \
- --title "$(translate "Network Management")" \
- --menu "\n$(translate "Select a network management option:")\n" \
- 28 82 19 \
- "${menu_items[@]}" 2>&1 1>&3)
- exit_status=$?
- exec 3>&-
+ # An option given as argument, as the menu search does, is opened
+ # first; the menu follows.
+ if [[ -n "$preselected" ]]; then
+ script_selection="$preselected"
+ exit_status=0
+ PRESELECTED_OPTION=""
+ else
+ exec 3>&1
+ script_selection=$(dialog --clear --colors \
+ --backtitle "ProxMenux" \
+ --title "$(translate "Network Management")" \
+ --menu "\n$(translate "Select a network management option:")\n" \
+ 28 82 19 \
+ "${menu_items[@]}" 2>&1 1>&3)
+ exit_status=$?
+ exec 3>&-
+ fi
if [ $exit_status -ne 0 ] || [ "$script_selection" = "0" ]; then
@@ -1301,4 +1312,5 @@ show_menu() {
# ==========================================================
+PRESELECTED_OPTION="${1:-}"
show_menu
diff --git a/scripts/menus/search_index.json b/scripts/menus/search_index.json
new file mode 100644
index 00000000..749caef2
--- /dev/null
+++ b/scripts/menus/search_index.json
@@ -0,0 +1,563 @@
+[
+ {
+ "title": "Settings post-install Proxmox",
+ "menu": "Settings post-install Proxmox",
+ "script": "menus/menu_post_install.sh",
+ "keywords": ""
+ },
+ {
+ "title": "Hardware: GPUs and Coral-TPU",
+ "menu": "Hardware: GPUs and Coral-TPU",
+ "script": "menus/hw_grafics_menu.sh",
+ "keywords": ""
+ },
+ {
+ "title": "Create VM from template or script",
+ "menu": "Create VM from template or script",
+ "script": "menus/create_vm_menu.sh",
+ "keywords": ""
+ },
+ {
+ "title": "Disk Manager",
+ "menu": "Disk Manager",
+ "script": "menus/storage_menu.sh",
+ "keywords": ""
+ },
+ {
+ "title": "Storage & Share Manager",
+ "menu": "Storage & Share Manager",
+ "script": "menus/share_menu.sh",
+ "keywords": ""
+ },
+ {
+ "title": "Proxmox VE Helper Scripts",
+ "menu": "Proxmox VE Helper Scripts",
+ "script": "menus/menu_Helper_Scripts.sh",
+ "keywords": ""
+ },
+ {
+ "title": "OCI manager Apps (beta)",
+ "menu": "OCI manager Apps (beta)",
+ "script": "oci/oci_manager_apps.sh",
+ "keywords": "docker container image contenedor imagen apps"
+ },
+ {
+ "title": "Network Management",
+ "menu": "Network Management",
+ "script": "menus/network_menu.sh",
+ "keywords": "network red vpn bridge nic"
+ },
+ {
+ "title": "Security",
+ "menu": "Security",
+ "script": "menus/security_menu.sh",
+ "keywords": "security seguridad firewall"
+ },
+ {
+ "title": "Utilities and Tools",
+ "menu": "Utilities and Tools",
+ "script": "menus/utilities_menu.sh",
+ "keywords": ""
+ },
+ {
+ "title": "Host Backup & Restore",
+ "menu": "Host Backup & Restore",
+ "script": "backup_restore/backup_host.sh",
+ "keywords": "backup restore copia respaldo restaurar pbs borg"
+ },
+ {
+ "title": "Help and Info Commands",
+ "menu": "Help and Info Commands",
+ "script": "help_info_menu.sh",
+ "keywords": ""
+ },
+ {
+ "title": "Settings",
+ "menu": "Settings",
+ "script": "menus/config_menu.sh",
+ "keywords": "settings ajustes configuracion"
+ },
+ {
+ "title": "Automated post-installation script",
+ "menu": "Settings post-install Proxmox",
+ "script": "menus/menu_post_install.sh",
+ "keywords": "post postinstall optimizations optimizaciones auto",
+ "argument": "Automated post-installation script"
+ },
+ {
+ "title": "Customizable post-installation script",
+ "menu": "Settings post-install Proxmox",
+ "script": "post_install/customizable_post_install.sh",
+ "keywords": "post postinstall optimizations optimizaciones custom log2ram"
+ },
+ {
+ "title": "Uninstall optimizations",
+ "menu": "Settings post-install Proxmox",
+ "script": "post_install/uninstall-tools.sh",
+ "keywords": "post uninstall desinstalar"
+ },
+ {
+ "title": "Install/Update NVIDIA Drivers (Host + LXC)",
+ "menu": "Hardware: GPUs and Coral-TPU",
+ "script": "gpu_tpu/nvidia_installer.sh",
+ "keywords": "gpu nvidia driver"
+ },
+ {
+ "title": "Install/Update Coral TPU on Host",
+ "menu": "Hardware: GPUs and Coral-TPU",
+ "script": "gpu_tpu/install_coral.sh",
+ "keywords": "coral tpu usb"
+ },
+ {
+ "title": "Add GPU to LXC (Intel | AMD | NVIDIA)",
+ "menu": "Hardware: GPUs and Coral-TPU",
+ "script": "gpu_tpu/add_gpu_lxc.sh",
+ "keywords": "gpu passthrough lxc igpu"
+ },
+ {
+ "title": "Add Coral TPU to LXC",
+ "menu": "Hardware: GPUs and Coral-TPU",
+ "script": "gpu_tpu/install_coral_lxc.sh",
+ "keywords": "coral tpu lxc usb"
+ },
+ {
+ "title": "Add GPU to VM (Intel | AMD | NVIDIA)",
+ "menu": "Hardware: GPUs and Coral-TPU",
+ "script": "gpu_tpu/add_gpu_vm.sh",
+ "keywords": "gpu passthrough vm"
+ },
+ {
+ "title": "Switch GPU Mode (VM <-> LXC)",
+ "menu": "Hardware: GPUs and Coral-TPU",
+ "script": "gpu_tpu/switch_gpu_mode.sh",
+ "keywords": "gpu mode modo"
+ },
+ {
+ "title": "Manual CLI Guide (GPU/TPU)",
+ "menu": "Hardware: GPUs and Coral-TPU",
+ "script": "gpu_tpu/gpu-tpu-manual-guide.sh",
+ "keywords": "gpu tpu guide guia"
+ },
+ {
+ "title": "Create VM System NAS",
+ "menu": "Create VM from template or script",
+ "script": "menus/create_vm_menu.sh",
+ "keywords": "vm virtual nas",
+ "argument": "1"
+ },
+ {
+ "title": "Create VM System Windows",
+ "menu": "Create VM from template or script",
+ "script": "menus/create_vm_menu.sh",
+ "keywords": "vm virtual windows",
+ "argument": "2"
+ },
+ {
+ "title": "Create VM System Linux",
+ "menu": "Create VM from template or script",
+ "script": "menus/create_vm_menu.sh",
+ "keywords": "vm virtual linux",
+ "argument": "3"
+ },
+ {
+ "title": "Create VM System macOS (OSX-PROXMOX)",
+ "menu": "Create VM from template or script",
+ "script": "menus/create_vm_menu.sh",
+ "keywords": "vm virtual macos osx",
+ "argument": "4"
+ },
+ {
+ "title": "Create VM System Others (based Linux)",
+ "menu": "Create VM from template or script",
+ "script": "menus/create_vm_menu.sh",
+ "keywords": "vm virtual",
+ "argument": "5"
+ },
+ {
+ "title": "ZimaOS",
+ "menu": "Create VM from template or script",
+ "script": "vm/zimaos.sh",
+ "keywords": "zima nas vm"
+ },
+ {
+ "title": "Synology DSM",
+ "menu": "Create VM from template or script",
+ "script": "vm/synology.sh",
+ "keywords": "synology dsm nas loader vm"
+ },
+ {
+ "title": "Import Disk to VM",
+ "menu": "Disk Manager",
+ "script": "storage/disk-passthrough.sh",
+ "keywords": "disk disco passthrough"
+ },
+ {
+ "title": "Import Disk Image to VM",
+ "menu": "Disk Manager",
+ "script": "storage/import-disk-image.sh",
+ "keywords": "disk disco import image imagen"
+ },
+ {
+ "title": "Add Controller or NVMe PCIe to VM",
+ "menu": "Disk Manager",
+ "script": "storage/add_controller_nvme_vm.sh",
+ "keywords": "nvme pcie controller passthrough"
+ },
+ {
+ "title": "Import Disk to LXC",
+ "menu": "Disk Manager",
+ "script": "storage/disk-passthrough_ct.sh",
+ "keywords": "disk disco passthrough lxc"
+ },
+ {
+ "title": "Format / Wipe Physical Disk (Safe)",
+ "menu": "Disk Manager",
+ "script": "storage/format-disk.sh",
+ "keywords": "disk disco format formatear wipe"
+ },
+ {
+ "title": "SMART Disk Health & Test",
+ "menu": "Disk Manager",
+ "script": "storage/smart-disk-test.sh",
+ "keywords": "smart disk disco health salud"
+ },
+ {
+ "title": "Manual CLI Guide (Disk and Storage Manager)",
+ "menu": "Disk Manager",
+ "script": "storage/disk-storage-manual-guide.sh",
+ "keywords": "disk storage guide guia"
+ },
+ {
+ "title": "Add NFS share as Proxmox Storage",
+ "menu": "Storage & Share Manager",
+ "script": "share/nfs_host.sh",
+ "keywords": "nfs storage almacenamiento mount montar"
+ },
+ {
+ "title": "Add Samba share as Proxmox Storage",
+ "menu": "Storage & Share Manager",
+ "script": "share/samba_host.sh",
+ "keywords": "samba smb cifs storage almacenamiento mount montar"
+ },
+ {
+ "title": "Add iSCSI Target as Proxmox Storage",
+ "menu": "Storage & Share Manager",
+ "script": "share/iscsi_host.sh",
+ "keywords": "iscsi storage almacenamiento"
+ },
+ {
+ "title": "Add Local Disk as Proxmox Storage",
+ "menu": "Storage & Share Manager",
+ "script": "share/disk_host.sh",
+ "keywords": "disk disco local storage almacenamiento"
+ },
+ {
+ "title": "Create Shared Directory on Host",
+ "menu": "Storage & Share Manager",
+ "script": "share/local-shared-manager.sh",
+ "keywords": "directory directorio carpeta share compartir"
+ },
+ {
+ "title": "Configure LXC Mount Points (Host ↔ Container)",
+ "menu": "Storage & Share Manager",
+ "script": "share/lxc-mount-manager_minimal.sh",
+ "keywords": "mount montaje host container contenedor bind"
+ },
+ {
+ "title": "Configure NFS Client in LXC (only privileged)",
+ "menu": "Storage & Share Manager",
+ "script": "share/nfs_client.sh",
+ "keywords": "nfs share lxc"
+ },
+ {
+ "title": "Configure Samba Client in LXC (only privileged)",
+ "menu": "Storage & Share Manager",
+ "script": "share/samba_client.sh",
+ "keywords": "smb samba cifs share lxc"
+ },
+ {
+ "title": "Configure NFS Server in LXC (only privileged)",
+ "menu": "Storage & Share Manager",
+ "script": "share/nfs_lxc_server.sh",
+ "keywords": "nfs share lxc"
+ },
+ {
+ "title": "Configure Samba Server in LXC (only privileged)",
+ "menu": "Storage & Share Manager",
+ "script": "share/samba_lxc_server.sh",
+ "keywords": "smb samba cifs share lxc"
+ },
+ {
+ "title": "Help & Info (commands)",
+ "menu": "Storage & Share Manager",
+ "script": "share/commands_share.sh",
+ "keywords": "help ayuda commands comandos share"
+ },
+ {
+ "title": "Real-time network usage (iftop)",
+ "menu": "Network Management",
+ "script": "menus/network_menu.sh",
+ "keywords": "network red iftop traffic trafico",
+ "argument": "Real-time network usage (iftop)"
+ },
+ {
+ "title": "Network monitoring tool (iptraf-ng)",
+ "menu": "Network Management",
+ "script": "menus/network_menu.sh",
+ "keywords": "network red iptraf traffic trafico",
+ "argument": "Network monitoring tool (iptraf-ng)"
+ },
+ {
+ "title": "Bandwidth test (iperf3)",
+ "menu": "Network Management",
+ "script": "menus/network_menu.sh",
+ "keywords": "network red iperf3 speed velocidad",
+ "argument": "Bandwidth test (iperf3)"
+ },
+ {
+ "title": "Show Routing Table",
+ "menu": "Network Management",
+ "script": "menus/network_menu.sh",
+ "keywords": "network red route rutas",
+ "argument": "Show Routing Table"
+ },
+ {
+ "title": "Test Connectivity",
+ "menu": "Network Management",
+ "script": "menus/network_menu.sh",
+ "keywords": "network red ping conectividad",
+ "argument": "Test Connectivity"
+ },
+ {
+ "title": "Advanced Diagnostics",
+ "menu": "Network Management",
+ "script": "menus/network_menu.sh",
+ "keywords": "network red diagnostics diagnostico",
+ "argument": "Advanced Diagnostics"
+ },
+ {
+ "title": "Analyze Bridge Configuration",
+ "menu": "Network Management",
+ "script": "menus/network_menu.sh",
+ "keywords": "network red bridge vmbr puente",
+ "argument": "Analyze Bridge Configuration"
+ },
+ {
+ "title": "Analyze Network Configuration",
+ "menu": "Network Management",
+ "script": "menus/network_menu.sh",
+ "keywords": "network red interfaces config",
+ "argument": "Analyze Network Configuration"
+ },
+ {
+ "title": "Setup Persistent Network Names",
+ "menu": "Network Management",
+ "script": "menus/network_menu.sh",
+ "keywords": "network red nic names nombres interfaces",
+ "argument": "Setup Persistent Network Names"
+ },
+ {
+ "title": "Restart Network Service",
+ "menu": "Network Management",
+ "script": "menus/network_menu.sh",
+ "keywords": "network red restart reiniciar red",
+ "argument": "Restart Network Service"
+ },
+ {
+ "title": "Show Network Config File",
+ "menu": "Network Management",
+ "script": "menus/network_menu.sh",
+ "keywords": "network red interfaces config",
+ "argument": "Show Network Config File"
+ },
+ {
+ "title": "Create Network Backup",
+ "menu": "Network Management",
+ "script": "menus/network_menu.sh",
+ "keywords": "network red backup red",
+ "argument": "Create Network Backup"
+ },
+ {
+ "title": "Restore Network Backup",
+ "menu": "Network Management",
+ "script": "menus/network_menu.sh",
+ "keywords": "network red restore restaurar red",
+ "argument": "Restore Network Backup"
+ },
+ {
+ "title": "Disable NIC Offloading (Intel e1000e)",
+ "menu": "Network Management",
+ "script": "menus/network_menu.sh",
+ "keywords": "network red intel e1000e offloading nic",
+ "argument": "Disable NIC Offloading (Intel e1000e)"
+ },
+ {
+ "title": "Fail2Ban - Intrusion Prevention",
+ "menu": "Security",
+ "script": "security/fail2ban_installer.sh",
+ "keywords": "fail2ban security seguridad ssh"
+ },
+ {
+ "title": "Lynis - Security Audit",
+ "menu": "Security",
+ "script": "security/lynis_installer.sh",
+ "keywords": "lynis audit auditoria security seguridad"
+ },
+ {
+ "title": "UUp Dump ISO creator Custom",
+ "menu": "Utilities and Tools",
+ "script": "utilities/uup_dump_iso_creator.sh",
+ "keywords": "windows iso uup dump"
+ },
+ {
+ "title": "System Utilities Installer",
+ "menu": "Utilities and Tools",
+ "script": "utilities/system_utils.sh",
+ "keywords": "utilities tools herramientas paquetes packages"
+ },
+ {
+ "title": "Proxmox System Update",
+ "menu": "Utilities and Tools",
+ "script": "menus/utilities_menu.sh",
+ "keywords": "update upgrade actualizar apt",
+ "argument": "3"
+ },
+ {
+ "title": "Upgrade PVE 8 to PVE 9",
+ "menu": "Utilities and Tools",
+ "script": "menus/utilities_menu.sh",
+ "keywords": "upgrade pve8 pve9 actualizar",
+ "argument": "4"
+ },
+ {
+ "title": "Export VM to OVA or OVF",
+ "menu": "Utilities and Tools",
+ "script": "utilities/export_vm_ova_ovf.sh",
+ "keywords": "export exportar vm ova ovf"
+ },
+ {
+ "title": "Import VM from OVA or OVF",
+ "menu": "Utilities and Tools",
+ "script": "utilities/import_vm_ova_ovf.sh",
+ "keywords": "import importar vm ova ovf"
+ },
+ {
+ "title": "Useful System Commands",
+ "menu": "Help and Info Commands",
+ "script": "help_info_menu.sh",
+ "keywords": "help ayuda commands comandos cli",
+ "argument": "1"
+ },
+ {
+ "title": "VM and CT Management Commands",
+ "menu": "Help and Info Commands",
+ "script": "help_info_menu.sh",
+ "keywords": "help ayuda commands comandos cli",
+ "argument": "2"
+ },
+ {
+ "title": "Storage and Disks Commands",
+ "menu": "Help and Info Commands",
+ "script": "help_info_menu.sh",
+ "keywords": "help ayuda commands comandos cli",
+ "argument": "3"
+ },
+ {
+ "title": "Network Commands",
+ "menu": "Help and Info Commands",
+ "script": "help_info_menu.sh",
+ "keywords": "help ayuda commands comandos cli",
+ "argument": "4"
+ },
+ {
+ "title": "Updates and Packages Commands",
+ "menu": "Help and Info Commands",
+ "script": "help_info_menu.sh",
+ "keywords": "help ayuda commands comandos cli",
+ "argument": "5"
+ },
+ {
+ "title": "GPU Passthrough Commands",
+ "menu": "Help and Info Commands",
+ "script": "help_info_menu.sh",
+ "keywords": "help ayuda commands comandos cli",
+ "argument": "6"
+ },
+ {
+ "title": "ZFS Management Commands",
+ "menu": "Help and Info Commands",
+ "script": "help_info_menu.sh",
+ "keywords": "help ayuda commands comandos cli",
+ "argument": "7"
+ },
+ {
+ "title": "Backup and Restore Commands",
+ "menu": "Help and Info Commands",
+ "script": "help_info_menu.sh",
+ "keywords": "help ayuda commands comandos cli",
+ "argument": "8"
+ },
+ {
+ "title": "System CLI Tools",
+ "menu": "Help and Info Commands",
+ "script": "help_info_menu.sh",
+ "keywords": "help ayuda commands comandos cli",
+ "argument": "9"
+ },
+ {
+ "title": "Change Language",
+ "menu": "Settings",
+ "script": "menus/config_menu.sh",
+ "keywords": "language idioma",
+ "argument": "change_language"
+ },
+ {
+ "title": "Change Release Channel",
+ "menu": "Settings",
+ "script": "menus/config_menu.sh",
+ "keywords": "beta stable channel canal",
+ "argument": "change_release_channel"
+ },
+ {
+ "title": "Show Version Information",
+ "menu": "Settings",
+ "script": "menus/config_menu.sh",
+ "keywords": "version",
+ "argument": "show_version_info"
+ },
+ {
+ "title": "Uninstall ProxMenux",
+ "menu": "Settings",
+ "script": "menus/config_menu.sh",
+ "keywords": "uninstall desinstalar",
+ "argument": "uninstall_proxmenu"
+ },
+ {
+ "title": "Reset ProxMenux Monitor Password",
+ "menu": "Settings",
+ "script": "menus/config_menu.sh",
+ "keywords": "monitor password contraseña",
+ "argument": "reset_monitor_password"
+ },
+ {
+ "title": "Show Monitor Service Status",
+ "menu": "Settings",
+ "script": "menus/config_menu.sh",
+ "keywords": "monitor service servicio",
+ "argument": "show_monitor_status"
+ },
+ {
+ "title": "Activate ProxMenux Monitor",
+ "menu": "Settings",
+ "script": "menus/config_menu.sh",
+ "keywords": "monitor",
+ "argument": "toggle_monitor"
+ },
+ {
+ "title": "Deactivate ProxMenux Monitor",
+ "menu": "Settings",
+ "script": "menus/config_menu.sh",
+ "keywords": "monitor",
+ "argument": "toggle_monitor"
+ }
+]
diff --git a/scripts/menus/search_menu.py b/scripts/menus/search_menu.py
new file mode 100644
index 00000000..ae7ec875
--- /dev/null
+++ b/scripts/menus/search_menu.py
@@ -0,0 +1,308 @@
+#!/usr/bin/env python3
+# ==========================================================
+# ProxMenux - Menu search
+# ==========================================================
+# Author : MacRimi
+# Copyright : (c) 2024 MacRimi
+# License : GPL-3.0
+# https://github.com/MacRimi/ProxMenux/blob/main/LICENSE
+# ==========================================================
+# Description:
+# Finds an option by name or by related words across the ProxMenux menu,
+# the OCI catalog and the Helper-Scripts, and opens it where it lives.
+# ==========================================================
+"""Search across the ProxMenux menu, the OCI catalog and the Helper-Scripts."""
+import argparse
+import json
+import os
+from pathlib import Path
+import re
+import shutil
+import subprocess
+import time
+import unicodedata
+import urllib.request
+
+BASE = Path('/usr/local/share/proxmenux')
+SCRIPTS = BASE / 'scripts'
+INDEX = Path(__file__).resolve().with_name('search_index.json')
+OCI_INDEX = BASE / 'oci/engine/catalog/index.json'
+HELPERS_URL = 'https://raw.githubusercontent.com/MacRimi/ProxMenux/refs/heads/main/json/helpers_cache.json'
+HELPERS_CACHE = BASE / 'helpers_cache.json'
+HELPERS_MAX_AGE = 24 * 3600
+# Results come in this order: the menu itself, then the two catalogs.
+SOURCES = ('ProxMenux', 'OCI Manager', 'Helper-Scripts')
+# Words an OCI application is also found by, when its name does not say them.
+OCI_KEYWORDS = {'sharecovex': 'samba smb cifs nfs share compartir archivos time machine'}
+
+_translations = None
+
+
+def translate(text):
+ """The text in the language of the menu; the text itself when it has none."""
+ global _translations
+ if _translations is None:
+ _translations = {}
+ try:
+ language = json.loads((BASE / 'config.json').read_text()).get('language', 'en')
+ if isinstance(language, str) and re.fullmatch(r'[a-z]{2}', language) and language != 'en':
+ loaded = json.loads((BASE / 'lang' / f'{language}.json').read_text(encoding='utf-8'))
+ if isinstance(loaded, dict):
+ _translations = loaded
+ except (OSError, ValueError):
+ pass
+ value = _translations.get(text)
+ return value if isinstance(value, str) and value else text
+
+
+def tidy(text):
+ """A menu label on one line: the menus pad theirs to line them up."""
+ return re.sub(r'\s+', ' ', text).strip()
+
+
+def fold(value):
+ return ''.join(c for c in unicodedata.normalize('NFKD', value.casefold()) if not unicodedata.combining(c))
+
+
+def returns_to_a_menu(script):
+ """Whether a script ends by opening a menu itself, as the sub-menus do."""
+ try:
+ return bool(re.search(r'exec bash "\$(LOCAL_SCRIPTS|MENU_REPO)/', script.read_text(errors='ignore')))
+ except OSError:
+ return False
+
+
+def own_entries():
+ try:
+ index = json.loads(INDEX.read_text(encoding='utf-8'))
+ except (OSError, ValueError):
+ return []
+ entries = []
+ for item in index:
+ script = SCRIPTS / item['script']
+ if not script.is_file():
+ continue
+ title = tidy(translate(item['title']))
+ # An option that lives inside a menu is given to that menu, which
+ # opens it first.
+ option = [item['argument']] if item.get('argument') else []
+ entries.append(dict(title=title, source='ProxMenux', kind=tidy(translate(item['menu'])),
+ terms=f"{title} {tidy(item['title'])} {item.get('keywords', '')}", related='',
+ action=['bash', str(script), *option], handoff=returns_to_a_menu(script)))
+ return entries
+
+
+def utility_entries():
+ source = SCRIPTS / 'global/utils-install-functions.sh'
+ installer = SCRIPTS / 'utilities/system_utils.sh'
+ try:
+ block = re.search(r'^PROXMENUX_UTILS=\(\s*\n(.*?)^\)', source.read_text(), re.M | re.S)
+ except OSError:
+ return []
+ if not block or not installer.is_file():
+ return []
+ kind = tidy(translate('System Utilities Installer'))
+ return [dict(title=package, source='ProxMenux', kind=kind, terms=f'{package} {command}',
+ related=f'{description} {translate(description)}',
+ action=['bash', str(installer), package], handoff=False)
+ for package, command, description in
+ re.findall(r'^\s*"([^":]+):([^":]+):([^"\n]+)"', block.group(1), re.M)]
+
+
+def post_install_entries():
+ script = SCRIPTS / 'post_install/customizable_post_install.sh'
+ try:
+ block = re.search(r'^\s*local options=\(\s*\n(.*?)^\s*\)', script.read_text(), re.M | re.S)
+ except OSError:
+ return []
+ if not block:
+ return []
+ kind = tidy(translate('Customizable post-installation script'))
+ return [dict(title=tidy(translate(title)), source='ProxMenux', kind=kind,
+ terms=f'{translate(title)} {title} {token}', related=f'{category} post postinstall',
+ action=['bash', str(script), token], handoff=False)
+ for category, title, token in
+ re.findall(r'^\s*"([^"|]+)\|([^"|]+)\|([A-Za-z0-9_]+)"', block.group(1), re.M)]
+
+
+def oci_entries():
+ launcher = SCRIPTS / 'oci/oci_manager_apps.sh'
+ try:
+ applications = json.loads(OCI_INDEX.read_text(encoding='utf-8')).get('applications', [])
+ except (OSError, ValueError):
+ return []
+ if not launcher.is_file():
+ return []
+ kind = translate('OCI application')
+ return [dict(title=item.get('title') or item['id'], source='OCI Manager', kind=kind,
+ terms=f"{item.get('title') or item['id']} {item['id']} {OCI_KEYWORDS.get(item['id'], '')}",
+ related=str(item.get('category_label') or ''),
+ action=['bash', str(launcher), 'open', item['id']], handoff=False)
+ for item in applications
+ if not item.get('hidden') and item.get('automatic_install_candidate')]
+
+
+def helpers_catalog(refresh=True):
+ """The Helper-Scripts catalog. It is kept on the host and downloaded again
+ once a day; without a connection the one already here is used."""
+ try:
+ fresh = time.time() - HELPERS_CACHE.stat().st_mtime < HELPERS_MAX_AGE
+ except OSError:
+ fresh = False
+ if refresh and not fresh:
+ try:
+ with urllib.request.urlopen(HELPERS_URL, timeout=15) as response:
+ data = response.read()
+ if isinstance(json.loads(data), list):
+ temporary = HELPERS_CACHE.with_name(HELPERS_CACHE.name + '.new')
+ temporary.write_bytes(data)
+ os.replace(temporary, HELPERS_CACHE)
+ except (OSError, ValueError):
+ pass
+ try:
+ loaded = json.loads(HELPERS_CACHE.read_text(encoding='utf-8'))
+ except (OSError, ValueError):
+ return []
+ return loaded if isinstance(loaded, list) else []
+
+
+def helper_entries(refresh=True):
+ launcher = SCRIPTS / 'menus/menu_Helper_Scripts.sh'
+ if not launcher.is_file():
+ return []
+ entries, seen = [], set()
+ for item in helpers_catalog(refresh):
+ slug = item.get('slug')
+ if not slug or slug in seen or not item.get('name'):
+ continue
+ seen.add(slug)
+ entries.append(dict(title=item['name'], source='Helper-Scripts', kind=str(item.get('type', '')).upper(),
+ terms=f"{item['name']} {slug}",
+ related=' '.join(item.get('category_names', [])),
+ action=['bash', str(launcher), slug], handoff=False))
+ return entries
+
+
+def catalog(refresh=True):
+ return own_entries() + utility_entries() + post_install_entries() + oci_entries() + helper_entries(refresh)
+
+
+def search(items, query):
+ """What matches every word of the query: by its name first, then by its
+ category. The long description of an application is not searched, as it
+ names other applications and would bring them all."""
+ query = fold(query.strip())
+ wanted = re.findall(r'[a-z0-9]+', query)
+ if not wanted:
+ return []
+
+ def has_all(text):
+ words = re.findall(r'[a-z0-9]+', text)
+ return all(any(word.startswith(term) for word in words) for term in wanted)
+
+ ranked = []
+ for item in items:
+ name = fold(item['title'])
+ terms = fold(item['terms'])
+ if has_all(terms):
+ depth = 0
+ elif has_all(f"{terms} {fold(item['related'])}"):
+ depth = 1
+ else:
+ continue
+ score = 0 if name == query else 1 if name.startswith(query) else 2 if query in name else 3
+ source = SOURCES.index(item['source']) if item['source'] in SOURCES else len(SOURCES)
+ ranked.append((depth, source, score, name, item))
+ return [row[4] for row in sorted(ranked, key=lambda row: row[:4])]
+
+
+def result_rows(matches, width):
+ """One line per result in three columns. Only what is shown is cut."""
+ labels = (translate('Name'), translate('Source'), translate('Type'))
+ source_width = max(len(labels[1]), max(len(item['source']) for item in matches))
+ kind_width = min(29, max(len(labels[2]), max(len(item['kind']) for item in matches)))
+ name_width = max(12, width - source_width - kind_width - 6)
+
+ def cell(value, size):
+ value = re.sub(r'[\x00-\x1f\x7f]', ' ', value)
+ return (value if len(value) <= size else value[:size - 1] + '…').ljust(size)
+
+ def line(name, source, kind):
+ return f'{cell(name, name_width)} {cell(source, source_width)} {cell(kind, kind_width)}'
+
+ return line(*labels), [(str(number), line(item['title'], item['source'], item['kind']))
+ for number, item in enumerate(matches)]
+
+
+def dialog(kind, title, text, rows=(), initial='', width=100, height=23):
+ terminal = shutil.get_terminal_size((100, 30))
+ width = max(40, min(width, terminal.columns))
+ height = max(8, min(height, terminal.lines - 2))
+ # The heading of the results is lined up with spaces, which are kept.
+ args = ['dialog', '--clear', '--no-collapse', '--backtitle', 'ProxMenux', '--title', title]
+ if kind == 'menu':
+ args += ['--menu', text, str(height), str(width), str(max(3, min(len(rows), height - 6)))]
+ for tag, label in rows:
+ args += [tag, label]
+ elif kind == 'inputbox':
+ args += ['--inputbox', text, '10', str(min(width, 90)), initial]
+ elif kind == 'infobox':
+ args += ['--infobox', text, '5', str(min(width, 60))]
+ else:
+ args += ['--msgbox', text, '8', str(min(width, 70))]
+ with open('/dev/tty', 'w') as tty:
+ result = subprocess.run(args + ['--stdout'], stdout=subprocess.PIPE, stderr=tty, text=True, check=False)
+ return result.stdout.strip() if result.returncode == 0 else None
+
+
+def show_results(matches, query, handoff):
+ """The results, until one that opens a menu is chosen or the list is left."""
+ while True:
+ columns = min(100, shutil.get_terminal_size((100, 24)).columns)
+ heading, rows = result_rows(matches, max(60, columns - 16))
+ tag_width = max(len(tag) for tag, _ in rows)
+ row_width = max(len(label) for _, label in rows)
+ # The list is centred in its window; the heading starts where its rows do.
+ heading = ' ' * (max(0, (columns - 8 - row_width - tag_width - 2) // 2) + tag_width + 4) + heading
+ selected = dialog('menu', f"{translate('Results:')} {query} ({len(matches)})", heading, rows)
+ if selected is None:
+ return False
+ item = matches[int(selected)]
+ if item['handoff'] and handoff:
+ # A sub-menu goes back to the main menu on its own: the main menu opens it.
+ handoff.write_text('\n'.join(item['action'][1:]) + '\n')
+ return True
+ subprocess.run(item['action'], check=False)
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('--search', metavar='QUERY', help='print the matches as JSON and exit')
+ parser.add_argument('--handoff', type=Path, help='file that receives the sub-menu the main menu has to open')
+ args = parser.parse_args()
+ if args.search is not None:
+ print(json.dumps(search(catalog(), args.search), ensure_ascii=False, indent=2))
+ return
+ try:
+ outdated = time.time() - HELPERS_CACHE.stat().st_mtime >= HELPERS_MAX_AGE
+ except OSError:
+ outdated = True
+ if outdated:
+ dialog('infobox', translate('Search'), '\n' + translate('Loading the Helper-Scripts catalog...'))
+ items = catalog()
+ query = ''
+ while True:
+ query = dialog('inputbox', translate('Search'),
+ translate('Name or part of the option or of the application'), initial=query)
+ if query is None:
+ return
+ matches = search(items, query)
+ if not matches:
+ dialog('msgbox', translate('No results'), f"\n{translate('No matches for:')} {query}")
+ continue
+ if show_results(matches, query, args.handoff):
+ return
+
+
+if __name__ == '__main__':
+ main()
diff --git a/scripts/menus/utilities_menu.sh b/scripts/menus/utilities_menu.sh
index 4b5b897c..c31c1c98 100644
--- a/scripts/menus/utilities_menu.sh
+++ b/scripts/menus/utilities_menu.sh
@@ -44,7 +44,14 @@ load_language
initialize_cache
# ==========================================================
+ # An option given as argument, as the menu search does, is opened first;
+ # the menu follows.
+ PRESELECTED_OPTION="${1:-}"
while true; do
+ if [[ "$PRESELECTED_OPTION" =~ ^[1-6]$ ]]; then
+ OPTION="$PRESELECTED_OPTION"
+ PRESELECTED_OPTION=""
+ else
OPTION=$(dialog --clear --backtitle "ProxMenux" --title "$(translate "Utilities Menu")" \
--menu "\n$(translate "Select an option:")" 20 70 11 \
"1" "$(translate "UUp Dump ISO creator Custom")" \
@@ -55,6 +62,7 @@ initialize_cache
"6" "$(translate "Import VM from OVA or OVF")" \
"7" "$(translate "Return to Main Menu")" \
2>&1 >/dev/tty)
+ fi
case $OPTION in
1)
diff --git a/scripts/post_install/customizable_post_install.sh b/scripts/post_install/customizable_post_install.sh
index 73671115..ea6f1bea 100644
--- a/scripts/post_install/customizable_post_install.sh
+++ b/scripts/post_install/customizable_post_install.sh
@@ -3812,6 +3812,7 @@ main_menu() {
local checklist_items=()
local i=1
+ local preselected_item=""
local desc_index=0
local previous_category=""
@@ -3830,7 +3831,12 @@ main_menu() {
local line
line="$(format_custom_post_line "$desc_translated" "$category_translated")"
- checklist_items+=("$i" "$line" "off")
+ local state="off"
+ if [[ -n "${PRESELECTED_OPTION:-}" && "$function_name" == "$PRESELECTED_OPTION" ]]; then
+ state="on"
+ preselected_item="$i"
+ fi
+ checklist_items+=("$i" "$line" "$state")
i=$((i + 1))
previous_category="$category"
done
@@ -3839,6 +3845,7 @@ main_menu() {
selected_indices=$(dialog --clear \
--backtitle "ProxMenux" \
--title "$(translate "Post-Installation Options")" \
+ --default-item "${preselected_item:-1}" \
--checklist "$HEADER" 22 88 15 \
"${checklist_items[@]}" \
2>&1 1>&3)
@@ -3967,6 +3974,8 @@ done
# update wrapper that re-runs a single function), don't trigger the
# extremeshok warning or the main menu.
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
+ # An option given as argument, as the menu search does, comes selected.
+ PRESELECTED_OPTION="${1:-}"
check_extremeshok_warning
main_menu
fi
diff --git a/scripts/utilities/system_utils.sh b/scripts/utilities/system_utils.sh
index cae5ca33..afa9ac3c 100644
--- a/scripts/utilities/system_utils.sh
+++ b/scripts/utilities/system_utils.sh
@@ -47,6 +47,9 @@ if [[ -f "$LOCAL_SCRIPTS/global/utils-install-functions.sh" ]]; then
fi
# ==========================================================
+# A package given as argument, as the menu search does, opens the custom
+# selection with it marked.
+PRESELECTED_UTILITY="${1:-}"
install_system_utils() {
show_main_utilities_menu() {
@@ -71,7 +74,9 @@ install_system_utils() {
local utilities=()
for util_entry in "${PROXMENUX_UTILS[@]}"; do
IFS=':' read -r pkg cmd desc <<< "$util_entry"
- utilities+=("$pkg" "$(translate "$desc")" "OFF")
+ local state="OFF"
+ [[ -n "$PRESELECTED_UTILITY" && "$pkg" == "$PRESELECTED_UTILITY" ]] && state="ON"
+ utilities+=("$pkg" "$(translate "$desc")" "$state")
done
local selected
@@ -205,10 +210,15 @@ install_system_utils() {
# Main menu loop
while true; do
- choice=$(show_main_utilities_menu)
+ if [[ -n "$PRESELECTED_UTILITY" ]]; then
+ choice=1
+ else
+ choice=$(show_main_utilities_menu)
+ fi
case $choice in
1)
selected=$(show_custom_selection)
+ PRESELECTED_UTILITY=""
install_selected_utilities "$selected"
;;
2)