server-up/server-up/core/boilerplates.py
bes-r 9fb96fe1bc
Some checks failed
Deploy server-up / deploy (push) Has been cancelled
v0.3.02 - Boilerplate fixes
2026-05-20 20:40:06 +02:00

298 lines
11 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, 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")