diff --git a/README.md b/README.md index d05b22d..a070ad9 100644 --- a/README.md +++ b/README.md @@ -65,8 +65,12 @@ as intuitive as Dockge, as capable as Portainer for Compose workflows. - **Port conflict detector**: pre-deploy check against host-bound ports (`/proc/net/tcp[6]`) and running container bindings, with a confirm dialog. - **Resource limits**: CPU/memory sliders in the editor → `deploy.resources.limits`. -- **Template library**: bundled templates (Jellyfin, Vaultwarden, Uptime-Kuma, - Paperless-NGX, Gitea) with `{{VARIABLE}}` forms; save any stack as a custom template. +- **Template library**: each template is a ready-to-run **stack folder** in git + (`backend/templates//` — `compose.yaml` + `.env.example` + `template.json`). + Bundled set: Jellyfin, Vaultwarden, Uptime-Kuma, Paperless-NGX, Gitea. "Pull" + copies the whole folder into a new stack (`.env.example` → `.env`) which you then + edit and deploy. Save any stack back as a custom template (stored under + `${DATA_DIR}/templates/`). Add your own by dropping a folder into the templates dir. - **Healthcheck status** surfaced per container in the stack overview. ### Phase 4 — Operations @@ -434,7 +438,7 @@ POST /api/editor/services | add-volume | set-gpu | add-device | remove-device GET /api/images | /updates POST /api/images/check POST /api/ports/conflicts POST /api/editor/set-resources GET /api/templates | /{id} POST /api/templates/{id}/instantiate -POST /api/templates DELETE /api/templates/custom/{slug} +POST /api/templates | /from-stack DELETE /api/templates/custom/{slug} ``` ### Phase 4 endpoints diff --git a/ROADMAP.md b/ROADMAP.md index 2e4efd1..60ae134 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -208,6 +208,24 @@ RemoteStackDetail** (not the new-stack editor, which has no dir yet). --- +## Phase 25 — Templates as stack folders ☑ DONE — shipped 0.31.0 + +Templates reworked from DB rows + `manifest.json` + `{{VAR}}` mustache rendering +to **stack-shaped folders**: `backend/templates//` with `compose.yaml`, +optional `.env.example` and a `template.json` (name/description/tags/gpu). +"Pull" copies the whole folder into a new stack (`.env.example` → `.env`); +custom templates live under `${DATA_DIR}/templates/` and are written by +"Save as template" (stack detail) or the manual save endpoint. + +- Backend: `template_service` rewritten (folder scan, traversal-guarded resolve, + `copy_into_stack`, `save_from_stack`); `Template` DB table dropped with a + one-time startup migration (`{{VAR}}` → `${VAR}`, vars → `.env.example`). +- API: `POST /api/templates/from-stack` new; instantiate no longer takes `values`. +- Frontend: Templates page shows compose/env preview + file list, delete for + custom templates; StackDetail gains "Save as template". + +--- + ## After 23 Remaining un-built ideas from the gap analysis (not chosen this round): Health-monitoring & alerting (Docker-events → notify; note `/ws/events` already diff --git a/backend/agent_app.py b/backend/agent_app.py index c358b5b..51f6979 100644 --- a/backend/agent_app.py +++ b/backend/agent_app.py @@ -66,7 +66,7 @@ def _map_docker(exc: DockerError): raise HTTPException(status_code=code, detail=exc.detail or exc.error) raise exc # falls through to the global 502 DockerError handler -AGENT_VERSION = "0.30.0" +AGENT_VERSION = "0.31.0" # --------------------------------------------------------------------------- # diff --git a/backend/main.py b/backend/main.py index e07e1be..940f338 100644 --- a/backend/main.py +++ b/backend/main.py @@ -35,7 +35,7 @@ from routers import ( volumes, ws, ) -from services import schedule_service, update_service +from services import schedule_service, template_service, update_service logging.basicConfig(level=logging.INFO) logger = logging.getLogger("stackpilot") @@ -50,6 +50,12 @@ async def lifespan(app: FastAPI): stacks.sync_discovered_stacks(session) except Exception as exc: # noqa: BLE001 logger.warning("Stack discovery failed: %s", exc) + try: + moved = template_service.migrate_legacy_db_templates() + if moved: + logger.info("Migrated %d custom template(s) from the database to folders", moved) + except Exception as exc: # noqa: BLE001 + logger.warning("Legacy template migration failed: %s", exc) update_task = asyncio.create_task(update_service.background_loop()) schedule_task = asyncio.create_task(schedule_service.scheduler_loop()) logger.info("StackPilot backend ready on port %s", settings.PORT) @@ -58,7 +64,7 @@ async def lifespan(app: FastAPI): schedule_task.cancel() -app = FastAPI(title="StackPilot", version="0.30.0", lifespan=lifespan) +app = FastAPI(title="StackPilot", version="0.31.0", lifespan=lifespan) app.add_middleware( CORSMiddleware, diff --git a/backend/models/__init__.py b/backend/models/__init__.py index 48f9128..68f5ca0 100644 --- a/backend/models/__init__.py +++ b/backend/models/__init__.py @@ -6,10 +6,9 @@ from models.backup_destination import BackupDestination from models.backup_schedule import BackupSchedule from models.setting import Setting, Webhook from models.stack import Stack -from models.template import Template from models.user import User __all__ = [ - "User", "Stack", "AuditLog", "Template", "Setting", "Webhook", "Agent", + "User", "Stack", "AuditLog", "Setting", "Webhook", "Agent", "BackupDestination", "BackupSchedule", "AutoUpdate", ] diff --git a/backend/models/template.py b/backend/models/template.py index e626c07..f46b20e 100644 --- a/backend/models/template.py +++ b/backend/models/template.py @@ -1,34 +1,11 @@ from __future__ import annotations -from datetime import datetime, timezone from typing import Optional -from sqlmodel import Field, SQLModel +from sqlmodel import SQLModel - -def _now() -> datetime: - return datetime.now(timezone.utc) - - -class Template(SQLModel, table=True): - """User-saved custom template (bundled ones live on disk).""" - - id: Optional[int] = Field(default=None, primary_key=True) - slug: str = Field(index=True, unique=True) - name: str - description: Optional[str] = None - tags: str = "" # comma separated - yaml: str = "" - created_at: datetime = Field(default_factory=_now) - - -# --- API schemas --- - - -class TemplateVariable(SQLModel): - name: str - description: str = "" - default: str = "" +# Templates are stored as stack-shaped folders on disk (see +# services/template_service.py), not in the database. These are API schemas only. class TemplateSummary(SQLModel): @@ -41,18 +18,26 @@ class TemplateSummary(SQLModel): class TemplateDetail(TemplateSummary): - yaml: str - variables: list[TemplateVariable] = [] + compose: str = "" + env: str = "" + files: list[str] = [] # relative paths the template ships class TemplateSaveRequest(SQLModel): name: str description: Optional[str] = None tags: list[str] = [] - yaml: str + gpu: Optional[str] = None + compose: str + env: str = "" + + +class TemplateFromStackRequest(SQLModel): + stack_id: str + name: str + description: Optional[str] = None class TemplateInstantiateRequest(SQLModel): name: str # new stack name - values: dict[str, str] = {} agent_id: int | None = None # None = local host; otherwise deploy to a remote agent diff --git a/backend/routers/templates.py b/backend/routers/templates.py index f2d65b3..2333a9e 100644 --- a/backend/routers/templates.py +++ b/backend/routers/templates.py @@ -1,4 +1,8 @@ -"""Template library endpoints.""" +"""Template library endpoints. + +Templates are stack-shaped folders on disk. Listing reads them; "instantiate" +(pull) copies the whole folder into a new stack, which is then editable. +""" from __future__ import annotations import os @@ -10,7 +14,11 @@ from auth import get_current_user, require_admin from database import get_session from models.agent import Agent from models.stack import Stack -from models.template import TemplateInstantiateRequest, TemplateSaveRequest +from models.template import ( + TemplateFromStackRequest, + TemplateInstantiateRequest, + TemplateSaveRequest, +) from models.user import User from services import agent_service, audit_service, compose_service, template_service from services.agent_service import AgentError @@ -24,19 +32,17 @@ def _ip(request: Request) -> str: @router.get("") def list_templates( - session: Session = Depends(get_session), _user: User = Depends(get_current_user), ) -> list[dict]: - return template_service.list_templates(session) + return template_service.list_templates() @router.get("/{template_id}") def get_template( template_id: str, - session: Session = Depends(get_session), _user: User = Depends(get_current_user), ) -> dict: - tpl = template_service.get_template(session, template_id) + tpl = template_service.get_template(template_id) if not tpl: raise HTTPException(status_code=404, detail="Template not found") return tpl @@ -49,13 +55,32 @@ def save_template( session: Session = Depends(get_session), user: User = Depends(require_admin), ) -> dict: - tpl = template_service.save_custom( - session, body.name, body.yaml, body.description or "", body.tags + slug = template_service.save_custom( + body.name, body.compose, body.env, body.description or "", body.tags, body.gpu ) audit_service.record( - session, user=user.username, action="template.save", target=tpl.slug, ip=_ip(request) + session, user=user.username, action="template.save", target=slug, ip=_ip(request) ) - return {"id": f"custom:{tpl.slug}", "name": tpl.name} + return {"id": f"custom:{slug}", "name": body.name} + + +@router.post("/from-stack") +def save_from_stack( + body: TemplateFromStackRequest, + request: Request, + session: Session = Depends(get_session), + user: User = Depends(require_admin), +) -> dict: + if not session.get(Stack, body.stack_id) and not os.path.isdir( + compose_service.stack_dir(body.stack_id) + ): + raise HTTPException(status_code=404, detail=f"Stack '{body.stack_id}' not found") + slug = template_service.save_from_stack(body.stack_id, body.name, body.description or "") + audit_service.record( + session, user=user.username, action="template.save", + target=slug, detail=body.stack_id, ip=_ip(request), + ) + return {"id": f"custom:{slug}", "name": body.name} @router.delete("/custom/{slug}") @@ -65,7 +90,7 @@ def delete_template( session: Session = Depends(get_session), user: User = Depends(require_admin), ) -> dict: - if not template_service.delete_custom(session, slug): + if not template_service.delete_custom(slug): raise HTTPException(status_code=404, detail="Custom template not found") audit_service.record( session, user=user.username, action="template.delete", target=slug, ip=_ip(request) @@ -81,12 +106,11 @@ async def instantiate( session: Session = Depends(get_session), user: User = Depends(require_admin), ) -> dict: - tpl = template_service.get_template(session, template_id) + tpl = template_service.get_template(template_id) if not tpl: raise HTTPException(status_code=404, detail="Template not found") - rendered = template_service.render(tpl["yaml"], body.values) - + # Remote host: agents don't share our filesystem, so ship compose + env. if body.agent_id is not None: agent = session.get(Agent, body.agent_id) if not agent: @@ -94,7 +118,7 @@ async def instantiate( try: result = await agent_service.call( session, agent, "POST", "/agent/stacks", - json={"name": body.name, "yaml": rendered, "env": None}, + json={"name": body.name, "yaml": tpl["compose"], "env": tpl["env"] or None}, ) except AgentError as exc: raise HTTPException( @@ -107,11 +131,18 @@ async def instantiate( ) return {"id": result.get("id"), "name": body.name, "agent_id": agent.id} + # Local host: copy the whole template folder into a new stack. stack_id = compose_service.slugify(body.name) if session.get(Stack, stack_id) or os.path.isdir(compose_service.stack_dir(stack_id)): raise HTTPException(status_code=409, detail=f"Stack '{stack_id}' already exists") - compose_service.write_compose(stack_id, rendered) + try: + template_service.copy_into_stack(template_id, stack_id) + except FileExistsError: + raise HTTPException(status_code=409, detail=f"Stack '{stack_id}' already exists") + except FileNotFoundError: + raise HTTPException(status_code=404, detail="Template not found") + stack = Stack(id=stack_id, name=body.name, description=tpl.get("description")) session.add(stack) session.commit() diff --git a/backend/services/template_service.py b/backend/services/template_service.py index 6316b31..63aad98 100644 --- a/backend/services/template_service.py +++ b/backend/services/template_service.py @@ -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 diff --git a/backend/templates/gitea/.env.example b/backend/templates/gitea/.env.example new file mode 100644 index 0000000..e35f7d1 --- /dev/null +++ b/backend/templates/gitea/.env.example @@ -0,0 +1,5 @@ +PUID=1000 +PGID=1000 +DATA_PATH=/srv/gitea +HTTP_PORT=3000 +SSH_PORT=2222 diff --git a/backend/templates/gitea.yaml b/backend/templates/gitea/compose.yaml similarity index 57% rename from backend/templates/gitea.yaml rename to backend/templates/gitea/compose.yaml index 6a270b4..19590c7 100644 --- a/backend/templates/gitea.yaml +++ b/backend/templates/gitea/compose.yaml @@ -4,12 +4,12 @@ services: container_name: gitea restart: unless-stopped environment: - - USER_UID={{PUID}} - - USER_GID={{PGID}} + - USER_UID=${PUID:-1000} + - USER_GID=${PGID:-1000} ports: - - "{{HTTP_PORT}}:3000" - - "{{SSH_PORT}}:22" + - "${HTTP_PORT:-3000}:3000" + - "${SSH_PORT:-2222}:22" volumes: - - {{DATA_PATH}}:/data + - ${DATA_PATH:-/srv/gitea}:/data - /etc/timezone:/etc/timezone:ro - /etc/localtime:/etc/localtime:ro diff --git a/backend/templates/gitea/template.json b/backend/templates/gitea/template.json new file mode 100644 index 0000000..39e4326 --- /dev/null +++ b/backend/templates/gitea/template.json @@ -0,0 +1,9 @@ +{ + "name": "Gitea", + "description": "Lightweight self-hosted Git service.", + "tags": [ + "git", + "dev" + ], + "gpu": null +} diff --git a/backend/templates/jellyfin.yaml b/backend/templates/jellyfin.yaml deleted file mode 100644 index c144949..0000000 --- a/backend/templates/jellyfin.yaml +++ /dev/null @@ -1,14 +0,0 @@ -services: - jellyfin: - image: jellyfin/jellyfin:latest - container_name: jellyfin - restart: unless-stopped - environment: - - PUID={{PUID}} - - PGID={{PGID}} - - TZ={{TZ}} - ports: - - "{{HTTP_PORT}}:8096" - volumes: - - {{CONFIG_PATH}}:/config - - {{MEDIA_PATH}}:/media diff --git a/backend/templates/jellyfin/.env.example b/backend/templates/jellyfin/.env.example new file mode 100644 index 0000000..a12e507 --- /dev/null +++ b/backend/templates/jellyfin/.env.example @@ -0,0 +1,6 @@ +PUID=1000 +PGID=1000 +TZ=Europe/Berlin +CONFIG_PATH=/srv/jellyfin/config +MEDIA_PATH=/srv/media +HTTP_PORT=8096 diff --git a/backend/templates/jellyfin/compose.yaml b/backend/templates/jellyfin/compose.yaml new file mode 100644 index 0000000..2a8bb11 --- /dev/null +++ b/backend/templates/jellyfin/compose.yaml @@ -0,0 +1,14 @@ +services: + jellyfin: + image: jellyfin/jellyfin:latest + container_name: jellyfin + restart: unless-stopped + environment: + - PUID=${PUID:-1000} + - PGID=${PGID:-1000} + - TZ=${TZ:-Europe/Berlin} + ports: + - "${HTTP_PORT:-8096}:8096" + volumes: + - ${CONFIG_PATH:-/srv/jellyfin/config}:/config + - ${MEDIA_PATH:-/srv/media}:/media diff --git a/backend/templates/jellyfin/template.json b/backend/templates/jellyfin/template.json new file mode 100644 index 0000000..1bd6dcb --- /dev/null +++ b/backend/templates/jellyfin/template.json @@ -0,0 +1,9 @@ +{ + "name": "Jellyfin", + "description": "Free media streaming server with optional hardware transcoding.", + "tags": [ + "media", + "streaming" + ], + "gpu": "NVIDIA / Intel optional" +} diff --git a/backend/templates/manifest.json b/backend/templates/manifest.json deleted file mode 100644 index eb5c367..0000000 --- a/backend/templates/manifest.json +++ /dev/null @@ -1,74 +0,0 @@ -[ - { - "id": "jellyfin", - "name": "Jellyfin", - "description": "Free media streaming server with optional hardware transcoding.", - "tags": ["media", "streaming"], - "gpu": "NVIDIA / Intel optional", - "file": "jellyfin.yaml", - "variables": [ - { "name": "PUID", "description": "User ID", "default": "1000" }, - { "name": "PGID", "description": "Group ID", "default": "1000" }, - { "name": "TZ", "description": "Timezone", "default": "Europe/Berlin" }, - { "name": "CONFIG_PATH", "description": "Host path for config", "default": "/srv/jellyfin/config" }, - { "name": "MEDIA_PATH", "description": "Host path to media library", "default": "/srv/media" }, - { "name": "HTTP_PORT", "description": "Web UI port", "default": "8096" } - ] - }, - { - "id": "vaultwarden", - "name": "Vaultwarden", - "description": "Lightweight Bitwarden-compatible password manager.", - "tags": ["password-manager", "security"], - "gpu": null, - "file": "vaultwarden.yaml", - "variables": [ - { "name": "TZ", "description": "Timezone", "default": "Europe/Berlin" }, - { "name": "DATA_PATH", "description": "Host path for data", "default": "/srv/vaultwarden" }, - { "name": "HTTP_PORT", "description": "Web UI port", "default": "8200" }, - { "name": "ADMIN_TOKEN", "description": "Admin panel token", "default": "change-me" } - ] - }, - { - "id": "uptime-kuma", - "name": "Uptime Kuma", - "description": "Self-hosted uptime / status monitoring tool.", - "tags": ["monitoring"], - "gpu": null, - "file": "uptime-kuma.yaml", - "variables": [ - { "name": "DATA_PATH", "description": "Host path for data", "default": "/srv/uptime-kuma" }, - { "name": "HTTP_PORT", "description": "Web UI port", "default": "3001" } - ] - }, - { - "id": "paperless-ngx", - "name": "Paperless-NGX", - "description": "Document management system that indexes scanned documents.", - "tags": ["documents"], - "gpu": null, - "file": "paperless-ngx.yaml", - "variables": [ - { "name": "TZ", "description": "Timezone", "default": "Europe/Berlin" }, - { "name": "DATA_PATH", "description": "Host path base", "default": "/srv/paperless" }, - { "name": "HTTP_PORT", "description": "Web UI port", "default": "8000" }, - { "name": "ADMIN_USER", "description": "Admin username", "default": "admin" }, - { "name": "ADMIN_PASSWORD", "description": "Admin password", "default": "change-me" } - ] - }, - { - "id": "gitea", - "name": "Gitea", - "description": "Lightweight self-hosted Git service.", - "tags": ["git", "dev"], - "gpu": null, - "file": "gitea.yaml", - "variables": [ - { "name": "PUID", "description": "User ID", "default": "1000" }, - { "name": "PGID", "description": "Group ID", "default": "1000" }, - { "name": "DATA_PATH", "description": "Host path for data", "default": "/srv/gitea" }, - { "name": "HTTP_PORT", "description": "Web UI port", "default": "3000" }, - { "name": "SSH_PORT", "description": "SSH port", "default": "2222" } - ] - } -] diff --git a/backend/templates/paperless-ngx/.env.example b/backend/templates/paperless-ngx/.env.example new file mode 100644 index 0000000..d2a807f --- /dev/null +++ b/backend/templates/paperless-ngx/.env.example @@ -0,0 +1,5 @@ +TZ=Europe/Berlin +DATA_PATH=/srv/paperless +HTTP_PORT=8000 +ADMIN_USER=admin +ADMIN_PASSWORD=change-me diff --git a/backend/templates/paperless-ngx.yaml b/backend/templates/paperless-ngx/compose.yaml similarity index 55% rename from backend/templates/paperless-ngx.yaml rename to backend/templates/paperless-ngx/compose.yaml index fb60ae2..39daefa 100644 --- a/backend/templates/paperless-ngx.yaml +++ b/backend/templates/paperless-ngx/compose.yaml @@ -4,7 +4,7 @@ services: container_name: paperless-redis restart: unless-stopped volumes: - - {{DATA_PATH}}/redis:/data + - ${DATA_PATH:-/srv/paperless}/redis:/data db: image: postgres:16-alpine @@ -15,7 +15,7 @@ services: - POSTGRES_USER=paperless - POSTGRES_PASSWORD=paperless volumes: - - {{DATA_PATH}}/db:/var/lib/postgresql/data + - ${DATA_PATH:-/srv/paperless}/db:/var/lib/postgresql/data webserver: image: ghcr.io/paperless-ngx/paperless-ngx:latest @@ -27,12 +27,12 @@ services: environment: - PAPERLESS_REDIS=redis://broker:6379 - PAPERLESS_DBHOST=db - - PAPERLESS_TIME_ZONE={{TZ}} - - PAPERLESS_ADMIN_USER={{ADMIN_USER}} - - PAPERLESS_ADMIN_PASSWORD={{ADMIN_PASSWORD}} + - PAPERLESS_TIME_ZONE=${TZ:-Europe/Berlin} + - PAPERLESS_ADMIN_USER=${ADMIN_USER:-admin} + - PAPERLESS_ADMIN_PASSWORD=${ADMIN_PASSWORD:-change-me} ports: - - "{{HTTP_PORT}}:8000" + - "${HTTP_PORT:-8000}:8000" volumes: - - {{DATA_PATH}}/data:/usr/src/paperless/data - - {{DATA_PATH}}/media:/usr/src/paperless/media - - {{DATA_PATH}}/consume:/usr/src/paperless/consume + - ${DATA_PATH:-/srv/paperless}/data:/usr/src/paperless/data + - ${DATA_PATH:-/srv/paperless}/media:/usr/src/paperless/media + - ${DATA_PATH:-/srv/paperless}/consume:/usr/src/paperless/consume diff --git a/backend/templates/paperless-ngx/template.json b/backend/templates/paperless-ngx/template.json new file mode 100644 index 0000000..3e18c50 --- /dev/null +++ b/backend/templates/paperless-ngx/template.json @@ -0,0 +1,8 @@ +{ + "name": "Paperless-NGX", + "description": "Document management system that indexes scanned documents.", + "tags": [ + "documents" + ], + "gpu": null +} diff --git a/backend/templates/uptime-kuma/.env.example b/backend/templates/uptime-kuma/.env.example new file mode 100644 index 0000000..ad18a96 --- /dev/null +++ b/backend/templates/uptime-kuma/.env.example @@ -0,0 +1,2 @@ +DATA_PATH=/srv/uptime-kuma +HTTP_PORT=3001 diff --git a/backend/templates/uptime-kuma.yaml b/backend/templates/uptime-kuma/compose.yaml similarity index 63% rename from backend/templates/uptime-kuma.yaml rename to backend/templates/uptime-kuma/compose.yaml index 0cbd726..445368e 100644 --- a/backend/templates/uptime-kuma.yaml +++ b/backend/templates/uptime-kuma/compose.yaml @@ -4,6 +4,6 @@ services: container_name: uptime-kuma restart: unless-stopped ports: - - "{{HTTP_PORT}}:3001" + - "${HTTP_PORT:-3001}:3001" volumes: - - {{DATA_PATH}}:/app/data + - ${DATA_PATH:-/srv/uptime-kuma}:/app/data diff --git a/backend/templates/uptime-kuma/template.json b/backend/templates/uptime-kuma/template.json new file mode 100644 index 0000000..fe85653 --- /dev/null +++ b/backend/templates/uptime-kuma/template.json @@ -0,0 +1,8 @@ +{ + "name": "Uptime Kuma", + "description": "Self-hosted uptime / status monitoring tool.", + "tags": [ + "monitoring" + ], + "gpu": null +} diff --git a/backend/templates/vaultwarden/.env.example b/backend/templates/vaultwarden/.env.example new file mode 100644 index 0000000..4acdd44 --- /dev/null +++ b/backend/templates/vaultwarden/.env.example @@ -0,0 +1,4 @@ +TZ=Europe/Berlin +DATA_PATH=/srv/vaultwarden +HTTP_PORT=8200 +ADMIN_TOKEN=change-me diff --git a/backend/templates/vaultwarden.yaml b/backend/templates/vaultwarden/compose.yaml similarity index 55% rename from backend/templates/vaultwarden.yaml rename to backend/templates/vaultwarden/compose.yaml index 0a55e56..c517a71 100644 --- a/backend/templates/vaultwarden.yaml +++ b/backend/templates/vaultwarden/compose.yaml @@ -4,10 +4,10 @@ services: container_name: vaultwarden restart: unless-stopped environment: - - TZ={{TZ}} - - ADMIN_TOKEN={{ADMIN_TOKEN}} + - TZ=${TZ:-Europe/Berlin} + - ADMIN_TOKEN=${ADMIN_TOKEN:-change-me} - WEBSOCKET_ENABLED=true ports: - - "{{HTTP_PORT}}:80" + - "${HTTP_PORT:-8200}:80" volumes: - - {{DATA_PATH}}:/data + - ${DATA_PATH:-/srv/vaultwarden}:/data diff --git a/backend/templates/vaultwarden/template.json b/backend/templates/vaultwarden/template.json new file mode 100644 index 0000000..bef0046 --- /dev/null +++ b/backend/templates/vaultwarden/template.json @@ -0,0 +1,9 @@ +{ + "name": "Vaultwarden", + "description": "Lightweight Bitwarden-compatible password manager.", + "tags": [ + "password-manager", + "security" + ], + "gpu": null +} diff --git a/frontend/package.json b/frontend/package.json index a9329ad..7fded5b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "stackpilot-frontend", "private": true, - "version": "0.30.0", + "version": "0.31.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/api/templates.ts b/frontend/src/api/templates.ts index 00b9275..b3f4bbc 100644 --- a/frontend/src/api/templates.ts +++ b/frontend/src/api/templates.ts @@ -1,11 +1,5 @@ import api from "./client"; -export interface TemplateVariable { - name: string; - description: string; - default: string; -} - export interface TemplateSummary { id: string; name: string; @@ -16,28 +10,32 @@ export interface TemplateSummary { } export interface TemplateDetail extends TemplateSummary { - yaml: string; - variables: TemplateVariable[]; + compose: string; + env: string; + files: string[]; } export const templatesApi = { list: () => api.get("/api/templates").then((r) => r.data), get: (id: string) => api.get(`/api/templates/${id}`).then((r) => r.data), - instantiate: ( - id: string, - name: string, - values: Record, - agentId?: number | null - ) => + instantiate: (id: string, name: string, agentId?: number | null) => api .post<{ id: string; name: string; agent_id: number | null }>( `/api/templates/${id}/instantiate`, - { name, values, agent_id: agentId ?? null } + { name, agent_id: agentId ?? null } ) .then((r) => r.data), - save: (body: { name: string; description?: string; tags: string[]; yaml: string }) => - api.post("/api/templates", body).then((r) => r.data), + save: (body: { + name: string; + description?: string; + tags: string[]; + gpu?: string | null; + compose: string; + env?: string; + }) => api.post("/api/templates", body).then((r) => r.data), + saveFromStack: (body: { stack_id: string; name: string; description?: string }) => + api.post<{ id: string; name: string }>("/api/templates/from-stack", body).then((r) => r.data), remove: (slug: string) => api.delete(`/api/templates/custom/${slug}`).then((r) => r.data), }; diff --git a/frontend/src/pages/StackDetail.tsx b/frontend/src/pages/StackDetail.tsx index 0dfa2aa..38cfa76 100644 --- a/frontend/src/pages/StackDetail.tsx +++ b/frontend/src/pages/StackDetail.tsx @@ -10,9 +10,10 @@ import { Pencil, Power, Trash2, + LayoutTemplate, } from "lucide-react"; import { toast } from "sonner"; -import { Badge, Button, Card, Spinner, StatusDot } from "@/components/ui"; +import { Badge, Button, Card, Input, Spinner, StatusDot } from "@/components/ui"; import { ConfirmDialog } from "@/components/ui/ConfirmDialog"; import { LogViewer } from "@/components/stacks/LogViewer"; import { ContainerCard } from "@/components/stacks/ContainerCard"; @@ -20,6 +21,7 @@ import { AutoUpdatePanel } from "@/components/stacks/AutoUpdatePanel"; import { SecretsPanel } from "@/components/stacks/SecretsPanel"; import { BackupButton } from "@/components/stacks/BackupRestore"; import { stacksApi } from "@/api/stacks"; +import { templatesApi } from "@/api/templates"; import { apiErrorMessage } from "@/api/client"; import { useAuthStore } from "@/store/auth"; import { useStackActions } from "@/hooks/useStackActions"; @@ -78,6 +80,7 @@ export function StackDetail() { Down + + {open && ( + setOpen(false)} + > + + + )} + + ); +} diff --git a/frontend/src/pages/Templates.tsx b/frontend/src/pages/Templates.tsx index d3a7d0f..76ea29d 100644 --- a/frontend/src/pages/Templates.tsx +++ b/frontend/src/pages/Templates.tsx @@ -1,8 +1,9 @@ import { useState } from "react"; import { useNavigate } from "react-router-dom"; -import { useQuery } from "@tanstack/react-query"; -import { LayoutTemplate, Cpu, Package } from "lucide-react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { LayoutTemplate, Cpu, Package, Trash2, FileCode } from "lucide-react"; import { Badge, Button, Card, Input, Spinner } from "@/components/ui"; +import { ConfirmDialog } from "@/components/ui/ConfirmDialog"; import { templatesApi, type TemplateDetail, type TemplateSummary } from "@/api/templates"; import { agentsApi } from "@/api/agents"; import { apiErrorMessage } from "@/api/client"; @@ -14,7 +15,9 @@ const selectClass = export function Templates() { const isAdmin = useAuthStore((s) => s.user?.role === "admin"); + const queryClient = useQueryClient(); const [selected, setSelected] = useState(null); + const [toDelete, setToDelete] = useState(null); const { data, isLoading } = useQuery({ queryKey: ["templates"], queryFn: templatesApi.list }); const open = async (t: TemplateSummary) => { @@ -25,10 +28,28 @@ export function Templates() { } }; + const remove = async () => { + if (!toDelete) return; + const slug = toDelete.id.replace(/^custom:/, ""); + try { + await templatesApi.remove(slug); + toast.success(`Template '${toDelete.name}' deleted`); + queryClient.invalidateQueries({ queryKey: ["templates"] }); + } catch (e) { + toast.error(apiErrorMessage(e)); + } finally { + setToDelete(null); + } + }; + if (isLoading) return ; return (
+

+ Templates are stored as ready-to-run stack folders. Pull one to copy it into a new stack, + then edit it like any other. +

{data?.map((t) => ( @@ -55,9 +76,16 @@ export function Templates() { )}
{isAdmin && ( - +
+ + {t.source === "custom" && ( + + )} +
)} ))} @@ -66,6 +94,17 @@ export function Templates() { {selected && ( setSelected(null)} /> )} + + {toDelete && ( + setToDelete(null)} + /> + )}
); } @@ -79,9 +118,6 @@ function UseTemplateDialog({ }) { const navigate = useNavigate(); const [name, setName] = useState(template.name); - const [values, setValues] = useState>( - Object.fromEntries(template.variables.map((v) => [v.name, v.default])) - ); const [host, setHost] = useState("local"); const [busy, setBusy] = useState(false); @@ -96,7 +132,7 @@ function UseTemplateDialog({ setBusy(true); try { const agentId = host === "local" ? null : Number(host); - const res = await templatesApi.instantiate(template.id, name, values, agentId); + const res = await templatesApi.instantiate(template.id, name, agentId); toast.success(`Stack '${res.name}' created`); if (res.agent_id != null) navigate(`/hosts/${res.agent_id}/stacks/${res.id}`); else navigate(`/stacks/${res.id}/edit`); @@ -109,7 +145,7 @@ function UseTemplateDialog({ return (
-
+

Use “{template.name}”

)} - {template.variables.map((v) => ( - - ))} + + {template.files.length > 0 && ( +
+ Files +
+ {template.files.map((f) => ( + + {f} + + ))} +
+
+ )} + +
+ compose.yaml +
+              {template.compose}
+            
+
+ + {template.env.trim() && ( +
+ .env (defaults) +
+                {template.env}
+              
+
+ )}
+

+ The whole folder is copied into a new stack you can edit and deploy afterwards. +

- +