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
+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,
+44 -2
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,8 +427,24 @@ 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):
continue
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)):
value = ""
+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()