feat(oci): content folders belong to the application, and an application says first what it needs

A path offered as a container volume or as a host directory is created for the user the application runs as, also as a volume, and is always mounted: the option of leaving it out is gone for those folders.

A container volume is never read-only, which is kept for a host directory that already holds the content, and the time files of the host are not turned into volumes.

An application that expects a database or another service that is already running shows it, with the address of its image documentation, before anything is asked, and its page lists it under Needs.

The installer windows share one width; the page of an application and the final summary are a little wider.
This commit is contained in:
MacRimi
2026-10-11 12:02:04 +02:00
parent 155cafcb73
commit 56d7a1839b
6 changed files with 126 additions and 20 deletions
+29 -4
View File
@@ -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
+16 -3
View File
@@ -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_WIDTH, UserCancelled, interactive_ui
PROJECT_ROOT = Path(__file__).resolve().parents[2]
@@ -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_TEXT_WIDTH = DETAIL_WIDTH - 6
SERVICE_KINDS = (("postgres", "PostgreSQL"), ("valkey", "Valkey"), ("redis", "Redis"), ("mariadb", "MariaDB"),
("mysql", "MySQL"), ("mongo", "MongoDB"), ("meilisearch", "Meilisearch"))
@@ -159,7 +161,7 @@ def _app_detail_text(catalog: Catalog, item: dict[str, Any], template: dict[str,
# drifting, and it goes through translate() like every other string.
description = translate(source_text(ui.get("tagline")) or source_text(ui.get("description")))
if description:
wrapped = textwrap.wrap(description, DETAIL_WIDTH)
wrapped = textwrap.wrap(description, DETAIL_TEXT_WIDTH)
if len(wrapped) > 8:
wrapped = wrapped[:8]
wrapped[-1] = wrapped[-1].rstrip(" .,;") + "…"
@@ -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"))]
@@ -676,6 +686,9 @@ def main(argv: list[str] | None = None) -> int:
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"}:
+1 -1
View File
@@ -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:
+19 -1
View File
@@ -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))
),
+19 -11
View File
@@ -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,13 @@ 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_WIDTH = 90
@dataclass
class DialogUI:
"""dialog widgets with the ProxMenux backtitle, used for every menu shown
@@ -227,7 +234,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 +245,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 +263,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 +282,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 +302,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_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 +321,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 +331,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)