server-up/app/core/boilerplates.py
2026-05-09 13:55:29 +00:00

247 lines
8.8 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=True,
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:
# Collapse 3+ blank lines into 2 — block conditionals leave gaps behind.
return re.sub(r"\n{3,}", "\n\n", 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")