600 lines
25 KiB
Python
600 lines
25 KiB
Python
"""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")
|