298 lines
11 KiB
Python
298 lines
11 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, 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
|
||
return {
|
||
"name": md.get("name") or d.name,
|
||
"description": md.get("description", ""),
|
||
"tags": md.get("tags", []) or [],
|
||
"logo_url": icon_url,
|
||
"version": (md.get("version") or {}).get("name", ""),
|
||
"kind": t.get("kind", "compose"),
|
||
"format": "boilerplate",
|
||
"draft": bool(md.get("draft")),
|
||
}
|
||
|
||
|
||
# ── 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 {}
|
||
out: list[dict] = []
|
||
for grp in t.get("variables", []) or []:
|
||
gname = grp.get("title") or grp.get("name", "")
|
||
toggle = grp.get("toggle")
|
||
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,
|
||
"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", ""),
|
||
})
|
||
return out
|
||
|
||
|
||
# ── 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 Environment, ChainableUndefined
|
||
except ImportError as e:
|
||
raise BoilerplateError("Jinja2 is required to render boilerplate templates") from e
|
||
_env = Environment(
|
||
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")
|
||
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*$")
|
||
|
||
|
||
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
|
||
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")
|