server-up/server-up/core/boilerplates.py
Ramon 1575db42c2
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 5m5s
v0.8.14-beta - Raspberry Pi compatibiliteit en UniFi Mongo herstellen
2026-08-04 16:49:24 +02:00

600 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""ChristianLempa Boilerplates compatibility layer.
Detects, parses and renders templates from the boilerplates-library format:
<stack>/
template.json (metadata + variable schema)
files/
compose.yaml (Jinja-like template with << var >> and <%- if %> syntax)
Renders boilerplate templates into a flat directory that Server Up can manage
just like any other stack — i.e. a single compose file (+ optional .env).
"""
from __future__ import annotations
import json, platform, re, shutil
from pathlib import Path
from typing import Any
# Jinja delimiters used by the boilerplates library
# Variable expressions use << >> and statement blocks use <% %>.
_VAR_OPEN, _VAR_CLOSE = "<<", ">>"
_BLK_OPEN, _BLK_CLOSE = "<%", "%>"
# ── Detection ────────────────────────────────────────────────────────────────
def is_boilerplate(d: Path) -> bool:
"""True if the directory follows the ChristianLempa boilerplate layout."""
if not d.is_dir():
return False
tj = d / "template.json"
fd = d / "files"
return tj.is_file() and fd.is_dir()
def read_template(d: Path) -> dict | None:
tj = d / "template.json"
if not tj.exists():
return None
try:
return json.loads(tj.read_text(encoding="utf-8"))
except Exception:
return None
def metadata(d: Path) -> dict:
"""Return Server-Up-style metadata extracted from template.json."""
t = read_template(d) or {}
md = t.get("metadata") or {}
icon = md.get("icon") or {}
icon_url = ""
if isinstance(icon, dict):
prov = icon.get("provider")
iid = icon.get("id")
if prov == "selfhst" and iid:
icon_url = f"https://cdn.jsdelivr.net/gh/selfhst/icons/png/{iid}.png"
elif prov == "dashboard-icons" and iid:
icon_url = f"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/{iid}.png"
elif iid and (iid.startswith("http://") or iid.startswith("https://")):
icon_url = iid
from core import categories as cat
tags = md.get("tags", []) or []
beschrijving = md.get("description", "")
return {
"name": md.get("name") or d.name,
"description": beschrijving,
"tags": tags,
# Expliciet in template.json, anders afgeleid uit de tags. Zo krijgen
# ook apps uit externe repo's een zinnige indeling.
"categories": cat.normalize(md.get("categories"), tags, beschrijving),
"logo_url": icon_url,
"version": (md.get("version") or {}).get("name", ""),
"kind": t.get("kind", "compose"),
"format": "boilerplate",
"draft": bool(md.get("draft")),
"depends_on": _afhankelijkheden(md.get("depends_on")),
"architectures": _architecturen(md.get("architectures")),
"architecture_note": str(md.get("architecture_note") or "").strip(),
}
_ARCH_ALIASES = {
"x86_64": "amd64", "x86-64": "amd64", "amd64": "amd64",
"aarch64": "arm64", "arm64": "arm64", "arm64v8": "arm64",
"armv7l": "arm/v7", "armv7": "arm/v7", "arm/v7": "arm/v7",
"armv6l": "arm/v6", "armv6": "arm/v6", "arm/v6": "arm/v6",
}
def _architectuur(waarde: str) -> str:
return _ARCH_ALIASES.get(str(waarde or "").strip().lower(), "")
def _architecturen(waarde) -> list[str]:
"Normaliseer Docker-architecturen en negeer onbekende schrijfwijzen."
if isinstance(waarde, str):
waarde = [waarde]
uit: list[str] = []
for item in (waarde or []):
arch = _architectuur(item)
if arch and arch not in uit:
uit.append(arch)
return uit
def host_architecture(machine: str | None = None) -> str:
"Docker-architectuurnaam van de host waarop Server Up draait."
return _architectuur(machine if machine is not None else platform.machine())
def compatibility(meta: dict, machine: str | None = None) -> dict:
"Beoordeel expliciete template-metadata voor de huidige host."
toegestaan = _architecturen(meta.get("architectures"))
huidig = host_architecture(machine)
bekend = bool(toegestaan and huidig)
ondersteund = not bekend or huidig in toegestaan
reden = ""
if bekend and not ondersteund:
labels = {"amd64": "AMD64", "arm64": "ARM64",
"arm/v7": "ARM 32-bit", "arm/v6": "ARMv6"}
verwacht = ", ".join(labels.get(a, a) for a in toegestaan)
reden = (f"Deze app ondersteunt {labels.get(huidig, huidig)} niet. "
f"Ondersteund: {verwacht}.")
notitie = str(meta.get("architecture_note") or "").strip()
if notitie:
reden += f" {notitie}"
return {"known": bekend, "supported": ondersteund,
"architecture": huidig, "reason": reden}
def _afhankelijkheden(waarde) -> list[dict]:
"""Welke andere apps heeft dit sjabloon nodig, en waarom?
Een `connect`-veld wijst naar een andere app en vult een adres in, maar zegt
niets over noodzaak. Zigbee2MQTT zonder MQTT-broker start prima en doet
niets — en de reden staat nergens. Hiermee kan het sjabloon dat wél zeggen.
Zowel `["mosquitto"]` als `[{"app": "mosquitto", "reason": ""}]` mag; de
korte vorm is voor sjablonen uit externe repo's, die niets van deze sleutel
hoeven te weten.
"""
uit: list[dict] = []
for item in (waarde or []):
if isinstance(item, str):
item = {"app": item}
if not isinstance(item, dict):
continue
app = str(item.get("app") or "").strip()
if app:
uit.append({"app": app, "reason": str(item.get("reason") or "").strip()})
return uit
# ── Variable schema → Server Up form fields ──────────────────────────────────
def fields(d: Path) -> list[dict]:
"""Flatten template.json variables into a list of UI fields.
Each field has: name, type, title, group, default, required,
options (for enum), needs (visibility deps), placeholder, description.
"""
t = read_template(d) or {}
groepen = t.get("variables", []) or []
# Vanaf twee schakelbare groepen krijgt het kiezen van onderdelen een eigen
# stap. Bij één groep zou dat een scherm met één vinkje opleveren; dan blijft
# de schakelaar staan waar zijn eigen velden staan.
eigen_stap = sum(1 for g in groepen if g.get("toggle")) >= 2
out: list[dict] = []
for grp in groepen:
gname = grp.get("title") or grp.get("name", "")
toggle = grp.get("toggle")
sectie = grp.get("section", "")
if toggle:
# De groepsschakelaar is zelf geen item in template.json, waardoor
# hij nergens een startwaarde kreeg en dus altijd uit stond. Als
# synthetisch veld krijgt hij die wel; de UI toont hem als schakelaar
# in de kop en slaat hem over in de lijst eronder.
out.append({
"name": toggle, "type": "bool",
"title": gname, "group": gname, "group_toggle": toggle,
"section": sectie,
"default": bool(grp.get("toggle_default", True)),
"required": False, "options": [], "needs": [],
"placeholder": "", "description": grp.get("description", ""),
"is_group_toggle": True,
"step": "onderdelen" if eigen_stap else _stap_van_groep(grp),
"credential": "",
"secret": False, "generate": "",
# `advanced` op de groep gaat over zijn vélden; de schakelaar
# zelf is juist het eerste wat je kiest en verdwijnt daar niet
# mee achter het uitklapblok. Wil je hem toch verbergen, dan
# zeg je dat apart met `toggle_advanced`.
"advanced": bool(grp.get("toggle_advanced")),
})
groep_geavanceerd = bool(grp.get("advanced"))
for item in grp.get("items", []) or []:
cfg = item.get("config") or {}
out.append({
"name": item.get("name", ""),
"type": item.get("type", "str"),
"title": item.get("title") or item.get("name", ""),
"group": gname,
"group_toggle": toggle,
# Kopje waaronder de groep in de stap Onderdelen komt te staan,
# zodat vijftig schakelaars niet één lange lijst worden.
"section": sectie,
"default": item.get("default"),
"required": bool(item.get("required")),
"options": cfg.get("options") or [],
"needs": item.get("needs") or [],
"placeholder": cfg.get("placeholder", ""),
"description": item.get("description", ""),
# Geavanceerde velden verdwijnen achter een uitklapblok, zodat
# het formulier bij het installeren kort blijft.
"advanced": bool(item.get("advanced")) or groep_geavanceerd,
# Uitgebreide toelichting voor het info-venster; valt terug op
# description als die er niet is.
"help": item.get("help", ""),
# Verwijst naar een andere app: de interface biedt dan een
# keuzelijst met wat er al ge?nstalleerd is in plaats van een
# leeg tekstveld. {"app": "mosquitto", "scheme": "mqtt",
# "port": 1883, "path": ""}
"connect": item.get("connect") or None,
# Wachtwoorden en sleutels: het invoerveld verbergt de tekst en
# de waarde wordt gemaskeerd in het installatielog.
"secret": bool(item.get("secret")) or _lijkt_geheim(item),
# Waarden die de gebruiker toch niet zelf kan bedenken (JWT-
# sleutels, database-wachtwoorden) worden voorgevuld met iets
# willekeurigs. "auto" = ook meteen invullen, True = alleen een
# knop (voor wachtwoorden waarmee je zelf inlogt).
"generate": item.get("generate") or "",
# In welke vorm de gegenereerde waarde moet komen. Laravel wil
# `base64:` ervoor, Homarr en LibreChat willen hexadecimaal van
# een vaste lengte. Zonder dit kreeg je een geldige willekeurige
# string die de app niet accepteert.
"secret_format": item.get("secret_format") or "",
"secret_bytes": item.get("secret_bytes") or 0,
# Een geheim dat de gebruiker ergens anders vandaan haalt: het
# client-secret van een OIDC-toepassing, een wachtwoord dat hij
# zelf kiest. Daar valt niets voor te genereren, en het is geen
# vergeten instelling.
"eigen_invoer": bool(item.get("eigen_invoer")),
# In welke stap van het invulmenu dit veld thuishoort.
"step": _stap_van(item, toggle),
# "username" of "password": wordt getoond in het paneel
# Inloggegevens, zodat je na de installatie kunt opzoeken
# waarmee je moet inloggen.
"credential": item.get("credential") or _credential_van(item),
})
return out
# ── Indeling in het invulmenu ────────────────────────────────────────────────
# Vaste stappen voor elke app. Eén stap per groep werkt niet: de mediane app
# heeft één groep en arr-stack heeft er vijftig. 'onderdelen' verschijnt alleen
# bij een sjabloon dat meer dan één schakelbare groep heeft.
STAPPEN = ("onderdelen", "basis", "verbinden", "instellingen", "geheimen")
_BASIS_NAMEN = ("service_name", "appdata_dir", "data_root", "timezone", "tz",
"puid", "pgid", "umask", "media_dir", "downloads_dir")
def _stap_van_groep(grp: dict) -> str:
"""Waar hoort de schakelaar van deze groep als hij geen eigen stap krijgt?
Bij zijn eigen velden — anders staat de schakelaar in een ander scherm dan
waar hij iets doet. Een groep zonder velden valt terug op 'instellingen'.
"""
for item in grp.get("items", []) or []:
return _stap_van(item, grp.get("toggle", ""))
return "instellingen"
def _stap_van(item: dict, groep_toggle: str = "") -> str:
"""In welke stap hoort dit veld?
Expliciet in template.json gaat voor; anders afgeleid uit naam en type.
"""
expliciet = (item.get("step") or "").strip().lower()
if expliciet in STAPPEN:
return expliciet
naam = (item.get("name") or "").lower()
if item.get("connect"):
return "verbinden"
if item.get("secret") or _lijkt_geheim(item):
return "geheimen"
# Een gebruikersnaam hoort bij het wachtwoord waarmee je inlogt, niet ergens
# tussen de overige instellingen.
if _credential_van(item) == "username":
return "geheimen"
# Poorten horen bij de basis, maar alleen als ze ook echt een poort zijn.
if item.get("type") == "int" and "port" in naam:
return "basis"
if naam in _BASIS_NAMEN:
return "basis"
return "instellingen"
_GEBRUIKERSNAAM_DELEN = ("user", "username", "email", "login", "account")
def _credential_van(item: dict) -> str:
"""Herken inloggegevens, zodat de bestaande 96 sjablonen meteen werken."""
naam = (item.get("name") or "").lower()
if _lijkt_geheim(item) and any(w in naam for w in ("password", "passwd",
"wachtwoord", "adminpass")):
return "password"
if _lijkt_geheim(item):
return "" # tokens en sleutels zijn geen inloggegevens
delen = naam.split("_")
if any(d in _GEBRUIKERSNAAM_DELEN for d in delen) or "email" in naam:
return "username"
# Ook samenstellingen: superuser_name, adminuser, login_name.
if any(w in naam for w in ("user", "login", "email")) and "password" not in naam:
return "username"
return ""
# Namen die vrijwel altijd een geheim aanduiden. Bedoeld om te voorkomen dat een
# nieuw template het per ongeluk als gewoon tekstveld toont.
_GEHEIM_WOORDEN = ("password", "passwd", "secret", "token", "apikey",
"jwt", "wachtwoord", "sleutel", "adminpass")
# Losse woorddelen: 'key' als heel woord in de naam (app_key, agent_key,
# wireguard_private_key), maar niet in iets als 'keyboard_layout'.
_GEHEIM_DELEN = ("key", "keys")
def _lijkt_geheim(item: dict) -> bool:
naam = (item.get("name") or "").lower()
if any(w in naam for w in _GEHEIM_WOORDEN):
return True
return any(deel in _GEHEIM_DELEN for deel in naam.split("_"))
def ontbrekende_verplichte(d: Path, values: dict[str, Any]) -> list[str]:
"""Verplichte velden zonder waarde.
Zonder deze controle installeerde je een app met een leeg wachtwoord of een
lege sleutel: de container start dan met een onbruikbare configuratie, of —
erger — met een geheim dat leeg is. Het formulier toonde alleen een
sterretje en hield niets tegen.
"""
ontbreekt = []
for veld in fields(d):
if not veld.get("required") or veld.get("is_group_toggle"):
continue
# Velden die achter een uitgeschakelde groepsschakelaar zitten tellen
# niet mee; die komen sowieso niet in het compose-bestand terecht.
toggle = veld.get("group_toggle")
if toggle and not _waarheid(values.get(toggle, True)):
continue
if not _waarheid(veld.get("needs"), values):
continue
waarde = values.get(veld["name"], veld.get("default"))
if waarde is None or (isinstance(waarde, str) and not waarde.strip()):
ontbreekt.append(veld.get("title") or veld["name"])
return ontbreekt
def _waarheid(waarde, values: dict | None = None) -> bool:
"""Evalueer een schakelaarwaarde, of een `needs`-lijst tegen de waarden."""
if values is not None:
# waarde is hier de needs-lijst: elke voorwaarde moet kloppen.
for nodig in (waarde or []):
if isinstance(nodig, str):
if not _waarheid(values.get(nodig)):
return False
elif isinstance(nodig, dict):
naam = nodig.get("name") or nodig.get("field")
if naam and str(values.get(naam, "")) != str(nodig.get("value", "")):
return False
return True
if isinstance(waarde, str):
return waarde.strip().lower() not in ("", "0", "false", "no", "nee", "off")
return bool(waarde)
# ── Templating engine ────────────────────────────────────────────────────────
class BoilerplateError(Exception):
pass
def _coerce(value: Any, typ: str):
if typ == "bool":
if isinstance(value, bool):
return value
s = str(value).strip().lower()
return s in ("1", "true", "yes", "on")
if typ == "int":
try:
return int(str(value).strip())
except (ValueError, TypeError):
return 0
return str(value) if value is not None else ""
def build_context(d: Path, values: dict[str, Any]) -> dict[str, Any]:
"""Merge user-provided values with defaults declared in template.json,
coercing each value to its declared type."""
ctx: dict[str, Any] = {}
for f in fields(d):
name = f["name"]
if name in values and values[name] not in (None, ""):
ctx[name] = _coerce(values[name], f["type"])
elif f.get("default") is not None:
ctx[name] = _coerce(f["default"], f["type"])
else:
# Sensible defaults so Jinja doesn't blow up on undefined vars
ctx[name] = "" if f["type"] != "bool" else False
# Allow extra values to pass through (e.g. derived service_name)
for k, v in values.items():
if k not in ctx and v is not None:
ctx[k] = v
return ctx
_env = None
def _jinja_env():
"""Create (once) and return the Jinja2 environment for the boilerplates delimiters."""
global _env
if _env is None:
try:
from jinja2 import ChainableUndefined
# Sandboxed: templates komen uit externe git-repo's en worden al
# gerenderd in /api/store/preview, dus vóór een installatie. Een
# gewone Environment laat `<< ''.__class__.__mro__ >>`-trucs toe
# en daarmee code-uitvoering in het app-proces.
from jinja2.sandbox import SandboxedEnvironment
except ImportError as e:
raise BoilerplateError("Jinja2 is required to render boilerplate templates") from e
_env = SandboxedEnvironment(
variable_start_string=_VAR_OPEN,
variable_end_string=_VAR_CLOSE,
block_start_string=_BLK_OPEN,
block_end_string=_BLK_CLOSE,
comment_start_string="<#",
comment_end_string="#>",
# trim_blocks=False is cruciaal. De boilerplates gebruiken `<%- ... %>`
# waar de `-` zelf al de voorgaande whitespace+newline stript. Als
# trim_blocks óók de newline NÁ de tag eet, vloeien opeenvolgende
# regels samen tot één regel ("mapping values not allowed in this
# context" — homepage, authentik, etc.).
trim_blocks=False,
lstrip_blocks=True,
keep_trailing_newline=True,
undefined=ChainableUndefined,
)
return _env
def render_text(template: str, ctx: dict[str, Any]) -> str:
env = _jinja_env()
try:
return env.from_string(template).render(**ctx)
except Exception as e:
raise BoilerplateError(f"render failed: {e}") from e
def render_to_dir(src: Path, dest: Path, values: dict[str, Any]) -> list[str]:
"""Render every file under <src>/files/ into <dest>/, applying the template
engine to text-like files. Binary files are copied verbatim.
Returns the list of relative paths written.
"""
files_dir = src / "files"
if not files_dir.is_dir():
raise BoilerplateError(f"no files/ directory in {src}")
ctx = build_context(src, values)
dest.mkdir(parents=True, exist_ok=True)
written: list[str] = []
for p in sorted(files_dir.rglob("*")):
if not p.is_file():
continue
rel = p.relative_to(files_dir)
out = dest / rel
out.parent.mkdir(parents=True, exist_ok=True)
if _looks_textual(p):
try:
txt = p.read_text(encoding="utf-8")
except UnicodeDecodeError:
shutil.copy2(p, out)
written.append(str(rel))
continue
rendered = render_text(txt, ctx)
# Drop blocks of pure whitespace left over after conditionals strip
rendered = _tidy(rendered)
out.write_text(rendered, encoding="utf-8")
# Een tekstscript kan uitvoerbaar zijn. write_text maakt een nieuw
# bestand met standaardrechten; behoud daarom de bronmodus.
try:
shutil.copymode(p, out)
except OSError:
pass
else:
shutil.copy2(p, out)
written.append(str(rel))
# Always ensure the default compose name exists; rename if needed
_normalize_compose_name(dest)
return written
# ── Helpers ──────────────────────────────────────────────────────────────────
_TEXT_EXTS = {".yml", ".yaml", ".env", ".conf", ".cfg", ".ini", ".json",
".toml", ".sh", ".md", ".txt", ".tmpl", ".tpl", ".j2", ""}
def _looks_textual(p: Path) -> bool:
if p.suffix.lower() in _TEXT_EXTS:
return True
# also accept dotfiles like ".env"
if p.name.startswith(".") and not p.suffix:
return True
try:
chunk = p.read_bytes()[:512]
chunk.decode("utf-8")
return b"\x00" not in chunk
except Exception:
return False
def _tidy(text: str) -> str:
"""Repareer artefacten die conditional-blocks achterlaten in YAML output:
1. Verwijder mapping-sleutels die geen inhoud hebben (bv. `volumes:` met alleen
een whitespace-blok eronder dat door een uitgeschakelde `<%- if %>` ontstond).
Een sleutel `key:` is "verlaten" als de eerstvolgende regel met content óf
op gelijk-of-minder indent staat (volgende sibling) óf het einde is.
2. Vouw 3+ lege regels in tot 2.
"""
text = _drop_empty_mappings(text)
return re.sub(r"\n{3,}", "\n\n", text)
_KEY_RE = re.compile(r"^(\s*)([A-Za-z_][\w\-]*)\s*:\s*$")
# Alleen deze compose-sleutels worden opgeruimd als ze leeg achterblijven. Dat
# zijn de sleutels die een `<% if %>`-blok leeg kan maken.
#
# De beperking is wezenlijk: een named volume wordt gedeclareerd als een kale
# sleutel zonder waarde (` immich_pgdata:`), en zonder deze lijst werd die als
# "lege mapping" opgeruimd. Daarna viel ook het bovenliggende `volumes:` weg,
# en verwees de compose naar een volume dat nergens meer gedeclareerd stond —
# waarop docker compose weigert te starten.
_OPRUIMBARE_SLEUTELS = frozenset({
"ports", "environment", "volumes", "depends_on", "networks", "labels",
"devices", "cap_add", "cap_drop", "sysctls", "extra_hosts", "dns",
"expose", "profiles", "tmpfs", "secrets", "configs", "command",
"entrypoint", "healthcheck", "deploy", "links", "env_file", "build",
})
def _drop_empty_mappings(text: str) -> str:
"""Verwijder ´keys´ die op een lege regel of een gelijk/minder-indent sibling
worden gevolgd — meerdere passes voor cascade-effect (parent kan leeg worden
nadat een child is opgeruimd)."""
for _ in range(5):
lines = text.splitlines()
keep = [True] * len(lines)
for i, line in enumerate(lines):
m = _KEY_RE.match(line)
if not m:
continue
if m.group(2) not in _OPRUIMBARE_SLEUTELS:
continue
indent = len(m.group(1))
# Zoek de eerste niet-lege, niet-commentaar regel hierna
j = i + 1
while j < len(lines) and (lines[j].strip() == "" or lines[j].lstrip().startswith("#")):
j += 1
if j >= len(lines):
# Sleutel aan het einde van het bestand zonder inhoud
keep[i] = False
continue
next_line = lines[j]
stripped = next_line.lstrip()
next_indent = len(next_line) - len(stripped)
# Geen kinderen → meer indent zou dat zijn. Sibling/uncle = leeg.
if next_indent <= indent:
keep[i] = False
new_text = "\n".join(l for l, k in zip(lines, keep) if k)
if new_text == text:
break
text = new_text
if not text.endswith("\n"):
text += "\n"
return text
def _normalize_compose_name(dest: Path):
"""Ensure there's a docker-compose.yml at root; rename compose.yaml if not."""
if (dest / "docker-compose.yml").exists() or (dest / "compose.yml").exists():
return
cy = dest / "compose.yaml"
if cy.exists():
# Some tooling expects docker-compose.yml; symlink-style rename.
cy.rename(dest / "docker-compose.yml")