247 lines
8.8 KiB
Python
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")
|