Phase 25: templates as stack folders (0.31.0)
Templates are now stack-shaped folders (compose.yaml + .env.example +
template.json) instead of DB rows + manifest.json + {{VAR}} rendering.
Pull copies the folder into a new stack; custom templates persist under
DATA_DIR/templates. Adds POST /api/templates/from-stack and a one-time
startup migration for pre-0.31 DB templates (drops the template table).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
34cb215266
commit
1609b8bcc3
@@ -1,32 +1,44 @@
|
||||
"""Template library — bundled (on-disk) + custom (DB)."""
|
||||
"""Template library — stack-shaped folders.
|
||||
|
||||
A template is just a directory laid out like a real stack (``compose.yaml`` plus
|
||||
optional ``.env.example`` and any extra files), accompanied by a small
|
||||
``template.json`` describing it. "Pulling" a template copies the whole folder
|
||||
into a new stack, which is then editable like any other stack.
|
||||
|
||||
Two roots are scanned:
|
||||
|
||||
* **bundled** — ``backend/templates/`` ships in the image / git repo (read-only).
|
||||
* **custom** — ``${DATA_DIR}/templates/`` is writable and persists on the data
|
||||
volume; this is where "save stack as template" writes to.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from functools import lru_cache
|
||||
import shutil
|
||||
from typing import Optional
|
||||
|
||||
from sqlmodel import Session, select
|
||||
from config import settings
|
||||
from services import compose_service
|
||||
|
||||
from models.template import Template
|
||||
BUNDLED_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "templates")
|
||||
|
||||
_TEMPLATES_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "templates")
|
||||
_VAR_RE = re.compile(r"\{\{\s*([A-Za-z0-9_]+)\s*\}\}")
|
||||
_META_NAME = "template.json"
|
||||
_ENV_EXAMPLE = ".env.example"
|
||||
_CUSTOM_PREFIX = "custom:"
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _manifest() -> list[dict]:
|
||||
path = os.path.join(_TEMPLATES_DIR, "manifest.json")
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
return json.load(fh)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return []
|
||||
def custom_dir() -> str:
|
||||
return os.path.join(settings.DATA_DIR, "templates")
|
||||
|
||||
|
||||
def _read_template_file(filename: str) -> str:
|
||||
path = os.path.join(_TEMPLATES_DIR, filename)
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Low-level helpers
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _read_file(path: str) -> str:
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
return fh.read()
|
||||
@@ -34,134 +46,217 @@ def _read_template_file(filename: str) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def extract_variables(yaml_str: str) -> list[str]:
|
||||
seen: list[str] = []
|
||||
for m in _VAR_RE.finditer(yaml_str):
|
||||
if m.group(1) not in seen:
|
||||
seen.append(m.group(1))
|
||||
return seen
|
||||
def _load_meta(folder: str, slug: str) -> dict:
|
||||
"""Metadata from template.json, with sensible fallbacks."""
|
||||
meta: dict = {}
|
||||
raw = _read_file(os.path.join(folder, _META_NAME))
|
||||
if raw:
|
||||
try:
|
||||
meta = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
meta = {}
|
||||
return {
|
||||
"name": meta.get("name") or slug.replace("-", " ").title(),
|
||||
"description": meta.get("description"),
|
||||
"tags": meta.get("tags") or [],
|
||||
"gpu": meta.get("gpu"),
|
||||
}
|
||||
|
||||
|
||||
def render(yaml_str: str, values: dict[str, str]) -> str:
|
||||
def repl(m: re.Match) -> str:
|
||||
key = m.group(1)
|
||||
return str(values.get(key, m.group(0)))
|
||||
|
||||
return _VAR_RE.sub(repl, yaml_str)
|
||||
def _is_template(folder: str) -> bool:
|
||||
return os.path.isdir(folder) and compose_service.find_compose_file(folder) is not None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Listing
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _list_files(folder: str) -> list[str]:
|
||||
"""Relative paths shipped by the template (excludes the metadata file)."""
|
||||
out: list[str] = []
|
||||
for root, _dirs, files in os.walk(folder):
|
||||
for fn in sorted(files):
|
||||
rel = os.path.relpath(os.path.join(root, fn), folder)
|
||||
if rel == _META_NAME:
|
||||
continue
|
||||
out.append(rel)
|
||||
return sorted(out)
|
||||
|
||||
|
||||
def list_templates(session: Session) -> list[dict]:
|
||||
def _resolve_dir(template_id: str) -> Optional[str]:
|
||||
"""Map a template id to its on-disk folder (guards against traversal)."""
|
||||
if template_id.startswith(_CUSTOM_PREFIX):
|
||||
slug = template_id[len(_CUSTOM_PREFIX):]
|
||||
base = custom_dir()
|
||||
else:
|
||||
slug = template_id
|
||||
base = BUNDLED_DIR
|
||||
slug = os.path.basename(slug.strip())
|
||||
if not slug:
|
||||
return None
|
||||
path = os.path.join(base, slug)
|
||||
return path if _is_template(path) else None
|
||||
|
||||
|
||||
def _scan(base: str, source: str) -> list[dict]:
|
||||
out: list[dict] = []
|
||||
for entry in _manifest():
|
||||
if not os.path.isdir(base):
|
||||
return out
|
||||
for slug in sorted(os.listdir(base)):
|
||||
folder = os.path.join(base, slug)
|
||||
if not _is_template(folder):
|
||||
continue
|
||||
meta = _load_meta(folder, slug)
|
||||
out.append(
|
||||
{
|
||||
"id": entry["id"],
|
||||
"name": entry["name"],
|
||||
"description": entry.get("description"),
|
||||
"tags": entry.get("tags", []),
|
||||
"gpu": entry.get("gpu"),
|
||||
"source": "bundled",
|
||||
}
|
||||
)
|
||||
for tpl in session.exec(select(Template)).all():
|
||||
out.append(
|
||||
{
|
||||
"id": f"custom:{tpl.slug}",
|
||||
"name": tpl.name,
|
||||
"description": tpl.description,
|
||||
"tags": [t for t in tpl.tags.split(",") if t],
|
||||
"gpu": None,
|
||||
"source": "custom",
|
||||
"id": f"{_CUSTOM_PREFIX}{slug}" if source == "custom" else slug,
|
||||
"name": meta["name"],
|
||||
"description": meta["description"],
|
||||
"tags": meta["tags"],
|
||||
"gpu": meta["gpu"],
|
||||
"source": source,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def get_template(session: Session, template_id: str) -> Optional[dict]:
|
||||
if template_id.startswith("custom:"):
|
||||
slug = template_id.split(":", 1)[1]
|
||||
tpl = session.exec(select(Template).where(Template.slug == slug)).first()
|
||||
if not tpl:
|
||||
return None
|
||||
variables = [
|
||||
{"name": v, "description": "", "default": ""}
|
||||
for v in extract_variables(tpl.yaml)
|
||||
]
|
||||
return {
|
||||
"id": template_id,
|
||||
"name": tpl.name,
|
||||
"description": tpl.description,
|
||||
"tags": [t for t in tpl.tags.split(",") if t],
|
||||
"gpu": None,
|
||||
"source": "custom",
|
||||
"yaml": tpl.yaml,
|
||||
"variables": variables,
|
||||
}
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Listing / detail
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
for entry in _manifest():
|
||||
if entry["id"] == template_id:
|
||||
yaml_str = _read_template_file(entry["file"])
|
||||
declared = {v["name"]: v for v in entry.get("variables", [])}
|
||||
# Merge declared metadata with any vars actually present.
|
||||
variables = []
|
||||
for name in extract_variables(yaml_str):
|
||||
meta = declared.get(name, {})
|
||||
variables.append(
|
||||
{
|
||||
"name": name,
|
||||
"description": meta.get("description", ""),
|
||||
"default": meta.get("default", ""),
|
||||
}
|
||||
)
|
||||
return {
|
||||
"id": entry["id"],
|
||||
"name": entry["name"],
|
||||
"description": entry.get("description"),
|
||||
"tags": entry.get("tags", []),
|
||||
"gpu": entry.get("gpu"),
|
||||
"source": "bundled",
|
||||
"yaml": yaml_str,
|
||||
"variables": variables,
|
||||
}
|
||||
return None
|
||||
|
||||
def list_templates() -> list[dict]:
|
||||
return _scan(BUNDLED_DIR, "bundled") + _scan(custom_dir(), "custom")
|
||||
|
||||
|
||||
def get_template(template_id: str) -> Optional[dict]:
|
||||
folder = _resolve_dir(template_id)
|
||||
if not folder:
|
||||
return None
|
||||
slug = os.path.basename(folder)
|
||||
meta = _load_meta(folder, slug)
|
||||
compose_file = compose_service.find_compose_file(folder)
|
||||
return {
|
||||
"id": template_id,
|
||||
"name": meta["name"],
|
||||
"description": meta["description"],
|
||||
"tags": meta["tags"],
|
||||
"gpu": meta["gpu"],
|
||||
"source": "custom" if template_id.startswith(_CUSTOM_PREFIX) else "bundled",
|
||||
"compose": _read_file(compose_file) if compose_file else "",
|
||||
"env": _read_file(os.path.join(folder, _ENV_EXAMPLE)),
|
||||
"files": _list_files(folder),
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Pull (instantiate) — copy the whole folder into a new stack
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def copy_into_stack(template_id: str, stack_id: str, override: Optional[str] = None) -> None:
|
||||
"""Copy a template folder into a fresh stack directory.
|
||||
|
||||
The ``template.json`` is left behind and any ``.env.example`` is promoted to
|
||||
a real ``.env`` so the pulled stack is immediately runnable + editable.
|
||||
"""
|
||||
src = _resolve_dir(template_id)
|
||||
if not src:
|
||||
raise FileNotFoundError(f"Template '{template_id}' not found")
|
||||
dst = compose_service.stack_dir(stack_id, override)
|
||||
if os.path.exists(dst):
|
||||
raise FileExistsError(f"Stack '{stack_id}' already exists")
|
||||
shutil.copytree(src, dst, ignore=shutil.ignore_patterns(_META_NAME))
|
||||
example = os.path.join(dst, _ENV_EXAMPLE)
|
||||
env = os.path.join(dst, ".env")
|
||||
if os.path.isfile(example) and not os.path.isfile(env):
|
||||
os.replace(example, env)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Save / delete custom templates (folder-based, persisted on the data volume)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def save_custom(
|
||||
session: Session, name: str, yaml_str: str, description: str = "", tags: list[str] | None = None
|
||||
) -> Template:
|
||||
slug = re.sub(r"[^a-z0-9_-]+", "-", name.strip().lower()).strip("-") or "template"
|
||||
existing = session.exec(select(Template).where(Template.slug == slug)).first()
|
||||
if existing:
|
||||
existing.name = name
|
||||
existing.description = description
|
||||
existing.tags = ",".join(tags or [])
|
||||
existing.yaml = yaml_str
|
||||
session.add(existing)
|
||||
session.commit()
|
||||
session.refresh(existing)
|
||||
return existing
|
||||
tpl = Template(
|
||||
slug=slug,
|
||||
name=name,
|
||||
description=description,
|
||||
tags=",".join(tags or []),
|
||||
yaml=yaml_str,
|
||||
)
|
||||
session.add(tpl)
|
||||
session.commit()
|
||||
session.refresh(tpl)
|
||||
return tpl
|
||||
name: str,
|
||||
compose: str,
|
||||
env: str = "",
|
||||
description: str = "",
|
||||
tags: list[str] | None = None,
|
||||
gpu: str | None = None,
|
||||
) -> str:
|
||||
"""Write a custom template folder; returns its slug. Overwrites if it exists."""
|
||||
slug = compose_service.slugify(name)
|
||||
folder = os.path.join(custom_dir(), slug)
|
||||
os.makedirs(folder, exist_ok=True)
|
||||
meta = {
|
||||
"name": name,
|
||||
"description": description or None,
|
||||
"tags": tags or [],
|
||||
"gpu": gpu,
|
||||
}
|
||||
with open(os.path.join(folder, _META_NAME), "w", encoding="utf-8") as fh:
|
||||
json.dump(meta, fh, indent=2)
|
||||
fh.write("\n")
|
||||
with open(os.path.join(folder, compose_service.DEFAULT_COMPOSE_NAME), "w", encoding="utf-8") as fh:
|
||||
fh.write(compose or "services:\n")
|
||||
example = os.path.join(folder, _ENV_EXAMPLE)
|
||||
if env.strip():
|
||||
with open(example, "w", encoding="utf-8") as fh:
|
||||
fh.write(env)
|
||||
elif os.path.isfile(example):
|
||||
os.remove(example)
|
||||
return slug
|
||||
|
||||
|
||||
def delete_custom(session: Session, slug: str) -> bool:
|
||||
tpl = session.exec(select(Template).where(Template.slug == slug)).first()
|
||||
if not tpl:
|
||||
def save_from_stack(stack_id: str, name: str, description: str = "") -> str:
|
||||
"""Snapshot an existing stack's compose + env into a custom template."""
|
||||
compose = compose_service.read_compose(stack_id)
|
||||
env = compose_service.read_env(stack_id)
|
||||
return save_custom(name, compose, env, description=description)
|
||||
|
||||
|
||||
def delete_custom(slug: str) -> bool:
|
||||
folder = os.path.join(custom_dir(), os.path.basename(slug.strip()))
|
||||
if not _is_template(folder):
|
||||
return False
|
||||
session.delete(tpl)
|
||||
session.commit()
|
||||
shutil.rmtree(folder)
|
||||
return True
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Legacy migration (pre-0.31 custom templates lived in the database)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
_LEGACY_VAR_RE = re.compile(r"\{\{\s*([A-Za-z0-9_]+)\s*\}\}")
|
||||
|
||||
|
||||
def migrate_legacy_db_templates() -> int:
|
||||
"""One-time: move custom templates out of the dropped ``template`` table.
|
||||
|
||||
Old templates used ``{{VAR}}`` placeholders; compose interpolates ``${VAR}``
|
||||
from ``.env``, so placeholders are rewritten and the variable names land in
|
||||
the template's ``.env.example``. Returns the number of templates moved.
|
||||
"""
|
||||
from sqlalchemy import inspect, text
|
||||
|
||||
from database import engine
|
||||
|
||||
if not inspect(engine).has_table("template"):
|
||||
return 0
|
||||
moved = 0
|
||||
with engine.begin() as conn:
|
||||
rows = conn.execute(
|
||||
text("SELECT name, description, tags, yaml FROM template")
|
||||
).all()
|
||||
for name, description, tags, yaml_str in rows:
|
||||
compose = _LEGACY_VAR_RE.sub(r"${\1}", yaml_str or "")
|
||||
variables = dict.fromkeys(_LEGACY_VAR_RE.findall(yaml_str or ""))
|
||||
env = "".join(f"{v}=\n" for v in variables)
|
||||
save_custom(
|
||||
name or "template",
|
||||
compose,
|
||||
env,
|
||||
description=description or "",
|
||||
tags=[t for t in (tags or "").split(",") if t],
|
||||
)
|
||||
moved += 1
|
||||
conn.execute(text("DROP TABLE template"))
|
||||
return moved
|
||||
|
||||
Reference in New Issue
Block a user