feat(oci): explain optional image variables and show their example value as a hint

This commit is contained in:
MacRimi
2026-09-29 20:49:45 +02:00
parent c40a335301
commit 438cb935d0
5 changed files with 112 additions and 2 deletions
+8
View File
@@ -970,6 +970,7 @@
"Configure backup destinations": "configurar destinos de copia de seguridad",
"Configure backup destinations (PBS, Borg, local)": "configurar destinos de copia de seguridad (PBS, Borg, local)",
"Configure the Google Coral APT repository": "Configurar el repositorio de Google Coral APT",
"Configure the optional variable": "¿Configurar la variable opcional",
"Configure the remote in the Rclone Web UI first. Checking its name may start a stopped CT, even in a dry run. Applying the mount restarts the CT and attempts to publish two FUSE views on the host; a dry run does not publish them.": "Configura primero el remoto en la interfaz web de Rclone. Comprobar su nombre puede arrancar un CT detenido, incluso en una simulación. Aplicar el montaje reinicia el CT e intenta publicar dos vistas FUSE en el host; una simulación no las publica.",
"Configure with username and password": "Configurar con nombre de usuario y contraseña",
"Configured Ports": "Puertos configurados",
@@ -2020,6 +2021,7 @@
"Exact name of the remote": "Nombre exacto del remoto",
"Example output: rootfs: local-lvm:vm-114-disk-0,size=8G": "Salida de ejemplo: rootfs: local-lvm:vm-114-disk-0,size=8G",
"Example target: /dev/sdb": "Destino de ejemplo: /dev/sdb",
"Example value:": "Valor de ejemplo:",
"Example: /dev/pve/vm-114-disk-0": "Ejemplo: /dev/pve/vm-114-disk-0",
"Example: /rpool/data/subvol-114-disk-0": "Ejemplo: /rpool/data/subvol-114-disk-0",
"Example: MOUNT_PATH=\"/rpool/data/subvol-114-disk-0\"": "Ejemplo: MOUNT_PATH=\"/rpool/data/subvol-114-disk-0\"",
@@ -2319,6 +2321,7 @@
"Flycast is a multi-platform Sega Dreamcast, Naomi, Naomi 2, and Atomiswave emulator derived from reicast.": "Flycast es un multiplataforma Sega Dreamcast, Naomi, Naomi 2, y el emulador de onda Atomis derivado del reicast.",
"Folder Name": "Nombre de la carpeta",
"Folders in /mnt": "Carpetas en /mnt",
"Folders to share, separated by ; (for example /downloads/incoming;/my_movies)": "Carpetas que compartir, separadas por ; (por ejemplo /downloads/incoming;/my_movies)",
"Folding@home is a distributed computing project for simulating protein dynamics, including the process of protein folding and the movements of proteins implicated in a variety of diseases. It brings together citizen scientists who volunteer to run simulations of protein dynamics on their personal computers. Insights from this data are helping scientists to better understand biology, and providing new opportunities for developing therapeutics.": "Folding@home es un proyecto de computación distribuido para simular dinámicas de proteínas, incluyendo el proceso de plegado de proteínas y los movimientos de proteínas implicados en una variedad de enfermedades. Reúne a los científicos citizen que se ofrecen como voluntarios para realizar simulaciones de dinámicas de proteínas en sus computadoras personales. Las visiones de estos datos están ayudando a los científicos a comprender mejor la biología y brindando nuevas oportunidades para desarrollar la terapéutica.",
"Follow post-restore progress live from ProxMenux Monitor → Backups tab after the reboot.": "siga el progreso posterior a la restauración en vivo desde la pestaña ProxMenux Monitor → Copias de seguridad después del reinicio.",
"For LVM - Create mount directory and mount:": "Para LVM: cree el directorio de montaje y monte:",
@@ -2744,6 +2747,7 @@
"If you are sure you want to use it, remove the RAID metadata or format it manually using external tools.": "si es sure y desea usarlo, elimine los metadatos RAID o formatéelo manualmente usando herramientas externas.",
"If you choose No, install": "Si elige No, instale",
"If you continue, some adjustments may be duplicated or conflict with those already made by xshok.": "Si continúa, es posible que algunos ajustes se dupliquen o entren en conflicto con los que ya realizó xshok.",
"If you do not know what a variable does, answer No: the image keeps its own value.": "Si no sabes qué hace una variable, responde No: la imagen mantiene su propio valor.",
"If you have a Coral M.2 / PCIe device that is physically installed but not detected by lspci, cancel here and check your hardware first before proceeding.": "Si tiene un dispositivo Coral M.2/PCIe instalado físicamente que lspci no detecta, cancele esta operación y revise primero el hardware.",
"If you lose connectivity, you can restore from backup using the console.": "Si pierde la conectividad, puede restaurar desde la copia de seguridad usando la consola.",
"If you lose or reinstall this host without a copy of the passphrase somewhere else (password manager, offline note, another host, USB stick...), every encrypted archive in this Borg repository becomes UNRECOVERABLE.": "si pierde o reinstala este host sin una copia de la frase de contraseña en otro lugar (administrador de contraseñas, nota sin conexión, otro host, memoria USB...), todos los archivos cifrados en este repositorio Borg se vuelven IRRECUPERABLES.",
@@ -4937,6 +4941,7 @@
"Restart Network": "Reiniciar la red",
"Restart Network Service": "Reiniciar el servicio de red",
"Restart Web UI proxy": "Reinicie el proxy de la interfaz de usuario web",
"Restart aMule automatically on a schedule (true or false)": "Reiniciar aMule automáticamente según una programación (true o false)",
"Restart and enable Samba:": "Reinicie y habilite Samba:",
"Restart container to activate mount?": "¿Reiniciar el contenedor para activar el montaje?",
"Restarting ProxMenux Monitor service...": "Reiniciando el servicio ProxMenux Monitor...",
@@ -5152,6 +5157,7 @@
"Scanning network for NFS servers...": "Escaneando la red en busca de servidores NFS...",
"Scanning network for Samba servers...": "Escaneando red en busca de servidores Samba...",
"Schedule": "Cronograma",
"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:": "Cronograma:",
"Scheduled backup job created": "Tarea de copia de seguridad programada creada",
"Scheduled backup jobs": "Tareas de copia de seguridad programadas",
@@ -5428,6 +5434,7 @@
"Share added successfully.": "Compartir agregado exitosamente.",
"Share deleted and Samba service restarted.": "Compartir eliminado y servicio Samba reiniciado.",
"Share exists:": "Compartir existe:",
"Share folders automatically (true or false)": "Compartir carpetas automáticamente (true o false)",
"Share name:": "Nombre compartido:",
"Share not found on server:": "Compartir no encontrado en el servidor:",
"Share path:": "Compartir ruta:",
@@ -6069,6 +6076,7 @@
"The image declares data paths that are still stored in the rootfs": "La imagen declara caminos de datos que aún están almacenados en los rootfs",
"The image did not grant the application user access to the devices; check its native init. Host permissions were not relaxed.": "La imagen no concedió el acceso del usuario de la aplicación a los dispositivos; comprobar su entrada nativa. Los permisos del host no se relajaron.",
"The image did not pass the integrity check": "La imagen no pasó el control de integridad",
"The image documentation describes them:": "La documentación de la imagen las describe:",
"The image does not declare support for this architecture:": "La imagen no declara soporte para esta arquitectura:",
"The image download did not complete correctly; downloading it again...": "La descarga de la imagen no se completó correctamente; descargándola de nuevo...",
"The image expects files that are given to it one by one:": "La imagen espera archivos que se le dan uno por uno:",
+4
View File
@@ -104,6 +104,7 @@
},
{
"name": "MOD_AUTO_RESTART_ENABLED",
"prompt": "Restart aMule automatically on a schedule (true or false)",
"example": "",
"required": false,
"sensitive": false,
@@ -111,6 +112,7 @@
},
{
"name": "MOD_AUTO_RESTART_CRON",
"prompt": "Schedule of the automatic restart, in cron format (for example 0 6 * * * for every day at 6:00)",
"example": "",
"required": false,
"sensitive": false,
@@ -118,6 +120,7 @@
},
{
"name": "MOD_AUTO_SHARE_ENABLED",
"prompt": "Share folders automatically (true or false)",
"example": "",
"required": false,
"sensitive": false,
@@ -125,6 +128,7 @@
},
{
"name": "MOD_AUTO_SHARE_DIRECTORIES",
"prompt": "Folders to share, separated by ; (for example /downloads/incoming;/my_movies)",
"example": "",
"required": false,
"sensitive": false,
+4
View File
@@ -104,6 +104,7 @@
},
{
"name": "MOD_AUTO_RESTART_ENABLED",
"prompt": "Restart aMule automatically on a schedule (true or false)",
"example": "",
"required": false,
"sensitive": false,
@@ -111,6 +112,7 @@
},
{
"name": "MOD_AUTO_RESTART_CRON",
"prompt": "Schedule of the automatic restart, in cron format (for example 0 6 * * * for every day at 6:00)",
"example": "",
"required": false,
"sensitive": false,
@@ -118,6 +120,7 @@
},
{
"name": "MOD_AUTO_SHARE_ENABLED",
"prompt": "Share folders automatically (true or false)",
"example": "",
"required": false,
"sensitive": false,
@@ -125,6 +128,7 @@
},
{
"name": "MOD_AUTO_SHARE_DIRECTORIES",
"prompt": "Folders to share, separated by ; (for example /downloads/incoming;/my_movies)",
"example": "",
"required": false,
"sensitive": false,
+43 -1
View File
@@ -43,6 +43,31 @@ def _example_default(name: str, example: str | None, timezone: str | None = None
return example
def _compose_example(template: dict[str, Any], name: str) -> str | None:
"""The value the image's own Compose file shows for a variable, as a hint."""
try:
import yaml
compose = yaml.safe_load(template["container_contract"].get("original_compose") or "") or {}
except Exception:
return None
for service in (compose.get("services") or {}).values():
environment = (service or {}).get("environment") or {}
if isinstance(environment, list):
environment = dict(str(entry).split("=", 1) for entry in environment if "=" in str(entry))
value = environment.get(name) if isinstance(environment, dict) else None
if value in (None, "") or re.search(r"\$\{?[A-Za-z_]", str(value)):
continue
value = " ".join(str(value).split())
return value if len(value) <= 60 else value[:57] + "..."
return None
def _image_documentation(template: dict[str, Any]) -> str | None:
ui = template.get("catalog_ui", {})
return next((ui[key] for key in ("documentation", "repository", "website")
if str(ui.get(key) or "").startswith(("http://", "https://"))), None)
def _hostname_default(value: str) -> str:
hostname = re.sub(r"[^a-z0-9-]+", "-", value.casefold()).strip("-")[:63].rstrip("-")
return hostname or "oci-app"
@@ -381,6 +406,7 @@ def build_deployment(
host_firewall = confirm_host_monitor_firewall(ui, template, bridge) if host_monitor else None
environment: list[dict[str, str]] = []
optional_explained = False
for item in template["container_contract"]["environment"]:
name = item["name"]
if item.get("prompt"):
@@ -401,7 +427,23 @@ def build_deployment(
# The image keeps its own default for every optional setting.
if not advanced:
continue
if default in (None, "") and not ui.confirm(f"{translate('Configure')} {label}", False):
if default in (None, ""):
question = f"{translate('Configure the optional variable')} {name}?"
if item.get("prompt"):
question = f"{translate(item['prompt'])}\n\n{question}"
example = _compose_example(template, name)
if example:
question += f"\n\n{translate('Example value:')} {example}"
if not optional_explained:
# Said once, with the first optional variable.
notice = translate("If you do not know what a variable does, answer No: the image keeps "
"its own value.")
documentation = _image_documentation(template)
if documentation:
notice += f"\n{translate('The image documentation describes them:')} {documentation}"
question = f"{notice}\n\n{question}"
optional_explained = True
if not ui.confirm(question, False):
continue
if item["sensitive"]:
if generated and (not advanced or not generated.get("prompt", True)):
+52
View File
@@ -0,0 +1,52 @@
"""Optional image variables are asked with what identifies them, and stay unset by default."""
from pathlib import Path
import sys
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
sys.path.insert(0, str(Path(__file__).resolve().parent))
from proxmenux_oci.catalog import Catalog
from proxmenux_oci.installer import ADVANCED_MODE, build_deployment
from test_advanced_flow_order import RecordingUI, addresses, storages
@patch("proxmenux_oci.i18n.language", return_value="en")
@patch("proxmenux_oci.installer.host.storages", side_effect=storages)
@patch("proxmenux_oci.installer.host.bridges", return_value=[{"iface": "vmbr0", "cidr": "192.0.2.10/24"}])
@patch("proxmenux_oci.installer.host.timezone", return_value="Europe/Madrid")
@patch("proxmenux_oci.installer.access.ask_ipv4", return_value=("dhcp", None))
@patch("proxmenux_oci.installer.access.ask_addresses", side_effect=addresses)
class OptionalVariableTests(unittest.TestCase):
def build(self, app, ui):
return build_deployment(Catalog(ROOT).compose(app), ui, ADVANCED_MODE)
def optional_questions(self, ui):
return [text for text in ui.asked if "Configure the optional variable" in text]
def test_first_question_explains_them_and_links_the_documentation(self, *_):
ui = RecordingUI()
plan = self.build("amule", ui)
questions = self.optional_questions(ui)
self.assertIn("answer No: the image keeps its own value", questions[0])
self.assertIn("https://github.com/ngosang/docker-amule", questions[0])
self.assertTrue(all("answer No" not in text for text in questions[1:]))
restart = next(text for text in questions if "MOD_AUTO_RESTART_ENABLED" in text)
self.assertIn("Restart aMule automatically on a schedule (true or false)", restart)
self.assertIn("Example value: true", restart)
self.assertFalse([e for e in plan["environment"] if e["name"].startswith("MOD_")])
def test_the_example_is_a_hint_not_a_default(self, *_):
ui = RecordingUI()
asked_defaults = {}
ui.confirm = lambda text, default=False: ui.asked.append(text) or "MOD_AUTO_RESTART_CRON" in text
ui.ask = lambda text, default=None, required=True: asked_defaults.setdefault(text, default) or ""
self.build("amule", ui)
prompt = next(text for text in asked_defaults if text.startswith("Schedule of the automatic restart"))
self.assertIn(asked_defaults[prompt], (None, ""))
if __name__ == "__main__":
unittest.main()