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
@@ -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
|
- **Port conflict detector**: pre-deploy check against host-bound ports
|
||||||
(`/proc/net/tcp[6]`) and running container bindings, with a confirm dialog.
|
(`/proc/net/tcp[6]`) and running container bindings, with a confirm dialog.
|
||||||
- **Resource limits**: CPU/memory sliders in the editor → `deploy.resources.limits`.
|
- **Resource limits**: CPU/memory sliders in the editor → `deploy.resources.limits`.
|
||||||
- **Template library**: bundled templates (Jellyfin, Vaultwarden, Uptime-Kuma,
|
- **Template library**: each template is a ready-to-run **stack folder** in git
|
||||||
Paperless-NGX, Gitea) with `{{VARIABLE}}` forms; save any stack as a custom template.
|
(`backend/templates/<slug>/` — `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.
|
- **Healthcheck status** surfaced per container in the stack overview.
|
||||||
|
|
||||||
### Phase 4 — Operations
|
### 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
|
GET /api/images | /updates POST /api/images/check
|
||||||
POST /api/ports/conflicts POST /api/editor/set-resources
|
POST /api/ports/conflicts POST /api/editor/set-resources
|
||||||
GET /api/templates | /{id} POST /api/templates/{id}/instantiate
|
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
|
### Phase 4 endpoints
|
||||||
|
|||||||
+18
@@ -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/<slug>/` 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
|
## After 23
|
||||||
Remaining un-built ideas from the gap analysis (not chosen this round):
|
Remaining un-built ideas from the gap analysis (not chosen this round):
|
||||||
Health-monitoring & alerting (Docker-events → notify; note `/ws/events` already
|
Health-monitoring & alerting (Docker-events → notify; note `/ws/events` already
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ def _map_docker(exc: DockerError):
|
|||||||
raise HTTPException(status_code=code, detail=exc.detail or exc.error)
|
raise HTTPException(status_code=code, detail=exc.detail or exc.error)
|
||||||
raise exc # falls through to the global 502 DockerError handler
|
raise exc # falls through to the global 502 DockerError handler
|
||||||
|
|
||||||
AGENT_VERSION = "0.30.0"
|
AGENT_VERSION = "0.31.0"
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|||||||
+8
-2
@@ -35,7 +35,7 @@ from routers import (
|
|||||||
volumes,
|
volumes,
|
||||||
ws,
|
ws,
|
||||||
)
|
)
|
||||||
from services import schedule_service, update_service
|
from services import schedule_service, template_service, update_service
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
logger = logging.getLogger("stackpilot")
|
logger = logging.getLogger("stackpilot")
|
||||||
@@ -50,6 +50,12 @@ async def lifespan(app: FastAPI):
|
|||||||
stacks.sync_discovered_stacks(session)
|
stacks.sync_discovered_stacks(session)
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
logger.warning("Stack discovery failed: %s", exc)
|
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())
|
update_task = asyncio.create_task(update_service.background_loop())
|
||||||
schedule_task = asyncio.create_task(schedule_service.scheduler_loop())
|
schedule_task = asyncio.create_task(schedule_service.scheduler_loop())
|
||||||
logger.info("StackPilot backend ready on port %s", settings.PORT)
|
logger.info("StackPilot backend ready on port %s", settings.PORT)
|
||||||
@@ -58,7 +64,7 @@ async def lifespan(app: FastAPI):
|
|||||||
schedule_task.cancel()
|
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(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
|
|||||||
@@ -6,10 +6,9 @@ from models.backup_destination import BackupDestination
|
|||||||
from models.backup_schedule import BackupSchedule
|
from models.backup_schedule import BackupSchedule
|
||||||
from models.setting import Setting, Webhook
|
from models.setting import Setting, Webhook
|
||||||
from models.stack import Stack
|
from models.stack import Stack
|
||||||
from models.template import Template
|
|
||||||
from models.user import User
|
from models.user import User
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"User", "Stack", "AuditLog", "Template", "Setting", "Webhook", "Agent",
|
"User", "Stack", "AuditLog", "Setting", "Webhook", "Agent",
|
||||||
"BackupDestination", "BackupSchedule", "AutoUpdate",
|
"BackupDestination", "BackupSchedule", "AutoUpdate",
|
||||||
]
|
]
|
||||||
|
|||||||
+15
-30
@@ -1,34 +1,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from sqlmodel import Field, SQLModel
|
from sqlmodel import SQLModel
|
||||||
|
|
||||||
|
# Templates are stored as stack-shaped folders on disk (see
|
||||||
def _now() -> datetime:
|
# services/template_service.py), not in the database. These are API schemas only.
|
||||||
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 = ""
|
|
||||||
|
|
||||||
|
|
||||||
class TemplateSummary(SQLModel):
|
class TemplateSummary(SQLModel):
|
||||||
@@ -41,18 +18,26 @@ class TemplateSummary(SQLModel):
|
|||||||
|
|
||||||
|
|
||||||
class TemplateDetail(TemplateSummary):
|
class TemplateDetail(TemplateSummary):
|
||||||
yaml: str
|
compose: str = ""
|
||||||
variables: list[TemplateVariable] = []
|
env: str = ""
|
||||||
|
files: list[str] = [] # relative paths the template ships
|
||||||
|
|
||||||
|
|
||||||
class TemplateSaveRequest(SQLModel):
|
class TemplateSaveRequest(SQLModel):
|
||||||
name: str
|
name: str
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
tags: list[str] = []
|
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):
|
class TemplateInstantiateRequest(SQLModel):
|
||||||
name: str # new stack name
|
name: str # new stack name
|
||||||
values: dict[str, str] = {}
|
|
||||||
agent_id: int | None = None # None = local host; otherwise deploy to a remote agent
|
agent_id: int | None = None # None = local host; otherwise deploy to a remote agent
|
||||||
|
|||||||
@@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
@@ -10,7 +14,11 @@ from auth import get_current_user, require_admin
|
|||||||
from database import get_session
|
from database import get_session
|
||||||
from models.agent import Agent
|
from models.agent import Agent
|
||||||
from models.stack import Stack
|
from models.stack import Stack
|
||||||
from models.template import TemplateInstantiateRequest, TemplateSaveRequest
|
from models.template import (
|
||||||
|
TemplateFromStackRequest,
|
||||||
|
TemplateInstantiateRequest,
|
||||||
|
TemplateSaveRequest,
|
||||||
|
)
|
||||||
from models.user import User
|
from models.user import User
|
||||||
from services import agent_service, audit_service, compose_service, template_service
|
from services import agent_service, audit_service, compose_service, template_service
|
||||||
from services.agent_service import AgentError
|
from services.agent_service import AgentError
|
||||||
@@ -24,19 +32,17 @@ def _ip(request: Request) -> str:
|
|||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
def list_templates(
|
def list_templates(
|
||||||
session: Session = Depends(get_session),
|
|
||||||
_user: User = Depends(get_current_user),
|
_user: User = Depends(get_current_user),
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
return template_service.list_templates(session)
|
return template_service.list_templates()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{template_id}")
|
@router.get("/{template_id}")
|
||||||
def get_template(
|
def get_template(
|
||||||
template_id: str,
|
template_id: str,
|
||||||
session: Session = Depends(get_session),
|
|
||||||
_user: User = Depends(get_current_user),
|
_user: User = Depends(get_current_user),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
tpl = template_service.get_template(session, template_id)
|
tpl = template_service.get_template(template_id)
|
||||||
if not tpl:
|
if not tpl:
|
||||||
raise HTTPException(status_code=404, detail="Template not found")
|
raise HTTPException(status_code=404, detail="Template not found")
|
||||||
return tpl
|
return tpl
|
||||||
@@ -49,13 +55,32 @@ def save_template(
|
|||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
user: User = Depends(require_admin),
|
user: User = Depends(require_admin),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
tpl = template_service.save_custom(
|
slug = template_service.save_custom(
|
||||||
session, body.name, body.yaml, body.description or "", body.tags
|
body.name, body.compose, body.env, body.description or "", body.tags, body.gpu
|
||||||
)
|
)
|
||||||
audit_service.record(
|
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}")
|
@router.delete("/custom/{slug}")
|
||||||
@@ -65,7 +90,7 @@ def delete_template(
|
|||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
user: User = Depends(require_admin),
|
user: User = Depends(require_admin),
|
||||||
) -> dict:
|
) -> 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")
|
raise HTTPException(status_code=404, detail="Custom template not found")
|
||||||
audit_service.record(
|
audit_service.record(
|
||||||
session, user=user.username, action="template.delete", target=slug, ip=_ip(request)
|
session, user=user.username, action="template.delete", target=slug, ip=_ip(request)
|
||||||
@@ -81,12 +106,11 @@ async def instantiate(
|
|||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
user: User = Depends(require_admin),
|
user: User = Depends(require_admin),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
tpl = template_service.get_template(session, template_id)
|
tpl = template_service.get_template(template_id)
|
||||||
if not tpl:
|
if not tpl:
|
||||||
raise HTTPException(status_code=404, detail="Template not found")
|
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:
|
if body.agent_id is not None:
|
||||||
agent = session.get(Agent, body.agent_id)
|
agent = session.get(Agent, body.agent_id)
|
||||||
if not agent:
|
if not agent:
|
||||||
@@ -94,7 +118,7 @@ async def instantiate(
|
|||||||
try:
|
try:
|
||||||
result = await agent_service.call(
|
result = await agent_service.call(
|
||||||
session, agent, "POST", "/agent/stacks",
|
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:
|
except AgentError as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -107,11 +131,18 @@ async def instantiate(
|
|||||||
)
|
)
|
||||||
return {"id": result.get("id"), "name": body.name, "agent_id": agent.id}
|
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)
|
stack_id = compose_service.slugify(body.name)
|
||||||
if session.get(Stack, stack_id) or os.path.isdir(compose_service.stack_dir(stack_id)):
|
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")
|
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"))
|
stack = Stack(id=stack_id, name=body.name, description=tpl.get("description"))
|
||||||
session.add(stack)
|
session.add(stack)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|||||||
@@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from functools import lru_cache
|
import shutil
|
||||||
from typing import Optional
|
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")
|
_META_NAME = "template.json"
|
||||||
_VAR_RE = re.compile(r"\{\{\s*([A-Za-z0-9_]+)\s*\}\}")
|
_ENV_EXAMPLE = ".env.example"
|
||||||
|
_CUSTOM_PREFIX = "custom:"
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=1)
|
def custom_dir() -> str:
|
||||||
def _manifest() -> list[dict]:
|
return os.path.join(settings.DATA_DIR, "templates")
|
||||||
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 _read_template_file(filename: str) -> str:
|
# --------------------------------------------------------------------------- #
|
||||||
path = os.path.join(_TEMPLATES_DIR, filename)
|
# Low-level helpers
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
def _read_file(path: str) -> str:
|
||||||
try:
|
try:
|
||||||
with open(path, "r", encoding="utf-8") as fh:
|
with open(path, "r", encoding="utf-8") as fh:
|
||||||
return fh.read()
|
return fh.read()
|
||||||
@@ -34,134 +46,217 @@ def _read_template_file(filename: str) -> str:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def extract_variables(yaml_str: str) -> list[str]:
|
def _load_meta(folder: str, slug: str) -> dict:
|
||||||
seen: list[str] = []
|
"""Metadata from template.json, with sensible fallbacks."""
|
||||||
for m in _VAR_RE.finditer(yaml_str):
|
meta: dict = {}
|
||||||
if m.group(1) not in seen:
|
raw = _read_file(os.path.join(folder, _META_NAME))
|
||||||
seen.append(m.group(1))
|
if raw:
|
||||||
return seen
|
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 _is_template(folder: str) -> bool:
|
||||||
def repl(m: re.Match) -> str:
|
return os.path.isdir(folder) and compose_service.find_compose_file(folder) is not None
|
||||||
key = m.group(1)
|
|
||||||
return str(values.get(key, m.group(0)))
|
|
||||||
|
|
||||||
return _VAR_RE.sub(repl, yaml_str)
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
def _list_files(folder: str) -> list[str]:
|
||||||
# Listing
|
"""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] = []
|
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(
|
out.append(
|
||||||
{
|
{
|
||||||
"id": entry["id"],
|
"id": f"{_CUSTOM_PREFIX}{slug}" if source == "custom" else slug,
|
||||||
"name": entry["name"],
|
"name": meta["name"],
|
||||||
"description": entry.get("description"),
|
"description": meta["description"],
|
||||||
"tags": entry.get("tags", []),
|
"tags": meta["tags"],
|
||||||
"gpu": entry.get("gpu"),
|
"gpu": meta["gpu"],
|
||||||
"source": "bundled",
|
"source": source,
|
||||||
}
|
|
||||||
)
|
|
||||||
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",
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def get_template(session: Session, template_id: str) -> Optional[dict]:
|
# --------------------------------------------------------------------------- #
|
||||||
if template_id.startswith("custom:"):
|
# Listing / detail
|
||||||
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,
|
|
||||||
}
|
|
||||||
|
|
||||||
for entry in _manifest():
|
|
||||||
if entry["id"] == template_id:
|
def list_templates() -> list[dict]:
|
||||||
yaml_str = _read_template_file(entry["file"])
|
return _scan(BUNDLED_DIR, "bundled") + _scan(custom_dir(), "custom")
|
||||||
declared = {v["name"]: v for v in entry.get("variables", [])}
|
|
||||||
# Merge declared metadata with any vars actually present.
|
|
||||||
variables = []
|
def get_template(template_id: str) -> Optional[dict]:
|
||||||
for name in extract_variables(yaml_str):
|
folder = _resolve_dir(template_id)
|
||||||
meta = declared.get(name, {})
|
if not folder:
|
||||||
variables.append(
|
return None
|
||||||
{
|
slug = os.path.basename(folder)
|
||||||
"name": name,
|
meta = _load_meta(folder, slug)
|
||||||
"description": meta.get("description", ""),
|
compose_file = compose_service.find_compose_file(folder)
|
||||||
"default": meta.get("default", ""),
|
return {
|
||||||
}
|
"id": template_id,
|
||||||
)
|
"name": meta["name"],
|
||||||
return {
|
"description": meta["description"],
|
||||||
"id": entry["id"],
|
"tags": meta["tags"],
|
||||||
"name": entry["name"],
|
"gpu": meta["gpu"],
|
||||||
"description": entry.get("description"),
|
"source": "custom" if template_id.startswith(_CUSTOM_PREFIX) else "bundled",
|
||||||
"tags": entry.get("tags", []),
|
"compose": _read_file(compose_file) if compose_file else "",
|
||||||
"gpu": entry.get("gpu"),
|
"env": _read_file(os.path.join(folder, _ENV_EXAMPLE)),
|
||||||
"source": "bundled",
|
"files": _list_files(folder),
|
||||||
"yaml": yaml_str,
|
}
|
||||||
"variables": variables,
|
|
||||||
}
|
|
||||||
return None
|
# --------------------------------------------------------------------------- #
|
||||||
|
# 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(
|
def save_custom(
|
||||||
session: Session, name: str, yaml_str: str, description: str = "", tags: list[str] | None = None
|
name: str,
|
||||||
) -> Template:
|
compose: str,
|
||||||
slug = re.sub(r"[^a-z0-9_-]+", "-", name.strip().lower()).strip("-") or "template"
|
env: str = "",
|
||||||
existing = session.exec(select(Template).where(Template.slug == slug)).first()
|
description: str = "",
|
||||||
if existing:
|
tags: list[str] | None = None,
|
||||||
existing.name = name
|
gpu: str | None = None,
|
||||||
existing.description = description
|
) -> str:
|
||||||
existing.tags = ",".join(tags or [])
|
"""Write a custom template folder; returns its slug. Overwrites if it exists."""
|
||||||
existing.yaml = yaml_str
|
slug = compose_service.slugify(name)
|
||||||
session.add(existing)
|
folder = os.path.join(custom_dir(), slug)
|
||||||
session.commit()
|
os.makedirs(folder, exist_ok=True)
|
||||||
session.refresh(existing)
|
meta = {
|
||||||
return existing
|
"name": name,
|
||||||
tpl = Template(
|
"description": description or None,
|
||||||
slug=slug,
|
"tags": tags or [],
|
||||||
name=name,
|
"gpu": gpu,
|
||||||
description=description,
|
}
|
||||||
tags=",".join(tags or []),
|
with open(os.path.join(folder, _META_NAME), "w", encoding="utf-8") as fh:
|
||||||
yaml=yaml_str,
|
json.dump(meta, fh, indent=2)
|
||||||
)
|
fh.write("\n")
|
||||||
session.add(tpl)
|
with open(os.path.join(folder, compose_service.DEFAULT_COMPOSE_NAME), "w", encoding="utf-8") as fh:
|
||||||
session.commit()
|
fh.write(compose or "services:\n")
|
||||||
session.refresh(tpl)
|
example = os.path.join(folder, _ENV_EXAMPLE)
|
||||||
return tpl
|
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:
|
def save_from_stack(stack_id: str, name: str, description: str = "") -> str:
|
||||||
tpl = session.exec(select(Template).where(Template.slug == slug)).first()
|
"""Snapshot an existing stack's compose + env into a custom template."""
|
||||||
if not tpl:
|
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
|
return False
|
||||||
session.delete(tpl)
|
shutil.rmtree(folder)
|
||||||
session.commit()
|
|
||||||
return True
|
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
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
PUID=1000
|
||||||
|
PGID=1000
|
||||||
|
DATA_PATH=/srv/gitea
|
||||||
|
HTTP_PORT=3000
|
||||||
|
SSH_PORT=2222
|
||||||
@@ -4,12 +4,12 @@ services:
|
|||||||
container_name: gitea
|
container_name: gitea
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
- USER_UID={{PUID}}
|
- USER_UID=${PUID:-1000}
|
||||||
- USER_GID={{PGID}}
|
- USER_GID=${PGID:-1000}
|
||||||
ports:
|
ports:
|
||||||
- "{{HTTP_PORT}}:3000"
|
- "${HTTP_PORT:-3000}:3000"
|
||||||
- "{{SSH_PORT}}:22"
|
- "${SSH_PORT:-2222}:22"
|
||||||
volumes:
|
volumes:
|
||||||
- {{DATA_PATH}}:/data
|
- ${DATA_PATH:-/srv/gitea}:/data
|
||||||
- /etc/timezone:/etc/timezone:ro
|
- /etc/timezone:/etc/timezone:ro
|
||||||
- /etc/localtime:/etc/localtime:ro
|
- /etc/localtime:/etc/localtime:ro
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"name": "Gitea",
|
||||||
|
"description": "Lightweight self-hosted Git service.",
|
||||||
|
"tags": [
|
||||||
|
"git",
|
||||||
|
"dev"
|
||||||
|
],
|
||||||
|
"gpu": 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
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
PUID=1000
|
||||||
|
PGID=1000
|
||||||
|
TZ=Europe/Berlin
|
||||||
|
CONFIG_PATH=/srv/jellyfin/config
|
||||||
|
MEDIA_PATH=/srv/media
|
||||||
|
HTTP_PORT=8096
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"name": "Jellyfin",
|
||||||
|
"description": "Free media streaming server with optional hardware transcoding.",
|
||||||
|
"tags": [
|
||||||
|
"media",
|
||||||
|
"streaming"
|
||||||
|
],
|
||||||
|
"gpu": "NVIDIA / Intel optional"
|
||||||
|
}
|
||||||
@@ -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" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
TZ=Europe/Berlin
|
||||||
|
DATA_PATH=/srv/paperless
|
||||||
|
HTTP_PORT=8000
|
||||||
|
ADMIN_USER=admin
|
||||||
|
ADMIN_PASSWORD=change-me
|
||||||
@@ -4,7 +4,7 @@ services:
|
|||||||
container_name: paperless-redis
|
container_name: paperless-redis
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- {{DATA_PATH}}/redis:/data
|
- ${DATA_PATH:-/srv/paperless}/redis:/data
|
||||||
|
|
||||||
db:
|
db:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
@@ -15,7 +15,7 @@ services:
|
|||||||
- POSTGRES_USER=paperless
|
- POSTGRES_USER=paperless
|
||||||
- POSTGRES_PASSWORD=paperless
|
- POSTGRES_PASSWORD=paperless
|
||||||
volumes:
|
volumes:
|
||||||
- {{DATA_PATH}}/db:/var/lib/postgresql/data
|
- ${DATA_PATH:-/srv/paperless}/db:/var/lib/postgresql/data
|
||||||
|
|
||||||
webserver:
|
webserver:
|
||||||
image: ghcr.io/paperless-ngx/paperless-ngx:latest
|
image: ghcr.io/paperless-ngx/paperless-ngx:latest
|
||||||
@@ -27,12 +27,12 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- PAPERLESS_REDIS=redis://broker:6379
|
- PAPERLESS_REDIS=redis://broker:6379
|
||||||
- PAPERLESS_DBHOST=db
|
- PAPERLESS_DBHOST=db
|
||||||
- PAPERLESS_TIME_ZONE={{TZ}}
|
- PAPERLESS_TIME_ZONE=${TZ:-Europe/Berlin}
|
||||||
- PAPERLESS_ADMIN_USER={{ADMIN_USER}}
|
- PAPERLESS_ADMIN_USER=${ADMIN_USER:-admin}
|
||||||
- PAPERLESS_ADMIN_PASSWORD={{ADMIN_PASSWORD}}
|
- PAPERLESS_ADMIN_PASSWORD=${ADMIN_PASSWORD:-change-me}
|
||||||
ports:
|
ports:
|
||||||
- "{{HTTP_PORT}}:8000"
|
- "${HTTP_PORT:-8000}:8000"
|
||||||
volumes:
|
volumes:
|
||||||
- {{DATA_PATH}}/data:/usr/src/paperless/data
|
- ${DATA_PATH:-/srv/paperless}/data:/usr/src/paperless/data
|
||||||
- {{DATA_PATH}}/media:/usr/src/paperless/media
|
- ${DATA_PATH:-/srv/paperless}/media:/usr/src/paperless/media
|
||||||
- {{DATA_PATH}}/consume:/usr/src/paperless/consume
|
- ${DATA_PATH:-/srv/paperless}/consume:/usr/src/paperless/consume
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "Paperless-NGX",
|
||||||
|
"description": "Document management system that indexes scanned documents.",
|
||||||
|
"tags": [
|
||||||
|
"documents"
|
||||||
|
],
|
||||||
|
"gpu": null
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
DATA_PATH=/srv/uptime-kuma
|
||||||
|
HTTP_PORT=3001
|
||||||
@@ -4,6 +4,6 @@ services:
|
|||||||
container_name: uptime-kuma
|
container_name: uptime-kuma
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "{{HTTP_PORT}}:3001"
|
- "${HTTP_PORT:-3001}:3001"
|
||||||
volumes:
|
volumes:
|
||||||
- {{DATA_PATH}}:/app/data
|
- ${DATA_PATH:-/srv/uptime-kuma}:/app/data
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "Uptime Kuma",
|
||||||
|
"description": "Self-hosted uptime / status monitoring tool.",
|
||||||
|
"tags": [
|
||||||
|
"monitoring"
|
||||||
|
],
|
||||||
|
"gpu": null
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
TZ=Europe/Berlin
|
||||||
|
DATA_PATH=/srv/vaultwarden
|
||||||
|
HTTP_PORT=8200
|
||||||
|
ADMIN_TOKEN=change-me
|
||||||
@@ -4,10 +4,10 @@ services:
|
|||||||
container_name: vaultwarden
|
container_name: vaultwarden
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
- TZ={{TZ}}
|
- TZ=${TZ:-Europe/Berlin}
|
||||||
- ADMIN_TOKEN={{ADMIN_TOKEN}}
|
- ADMIN_TOKEN=${ADMIN_TOKEN:-change-me}
|
||||||
- WEBSOCKET_ENABLED=true
|
- WEBSOCKET_ENABLED=true
|
||||||
ports:
|
ports:
|
||||||
- "{{HTTP_PORT}}:80"
|
- "${HTTP_PORT:-8200}:80"
|
||||||
volumes:
|
volumes:
|
||||||
- {{DATA_PATH}}:/data
|
- ${DATA_PATH:-/srv/vaultwarden}:/data
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"name": "Vaultwarden",
|
||||||
|
"description": "Lightweight Bitwarden-compatible password manager.",
|
||||||
|
"tags": [
|
||||||
|
"password-manager",
|
||||||
|
"security"
|
||||||
|
],
|
||||||
|
"gpu": null
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "stackpilot-frontend",
|
"name": "stackpilot-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.30.0",
|
"version": "0.31.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -1,11 +1,5 @@
|
|||||||
import api from "./client";
|
import api from "./client";
|
||||||
|
|
||||||
export interface TemplateVariable {
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
default: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TemplateSummary {
|
export interface TemplateSummary {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -16,28 +10,32 @@ export interface TemplateSummary {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface TemplateDetail extends TemplateSummary {
|
export interface TemplateDetail extends TemplateSummary {
|
||||||
yaml: string;
|
compose: string;
|
||||||
variables: TemplateVariable[];
|
env: string;
|
||||||
|
files: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const templatesApi = {
|
export const templatesApi = {
|
||||||
list: () => api.get<TemplateSummary[]>("/api/templates").then((r) => r.data),
|
list: () => api.get<TemplateSummary[]>("/api/templates").then((r) => r.data),
|
||||||
get: (id: string) =>
|
get: (id: string) =>
|
||||||
api.get<TemplateDetail>(`/api/templates/${id}`).then((r) => r.data),
|
api.get<TemplateDetail>(`/api/templates/${id}`).then((r) => r.data),
|
||||||
instantiate: (
|
instantiate: (id: string, name: string, agentId?: number | null) =>
|
||||||
id: string,
|
|
||||||
name: string,
|
|
||||||
values: Record<string, string>,
|
|
||||||
agentId?: number | null
|
|
||||||
) =>
|
|
||||||
api
|
api
|
||||||
.post<{ id: string; name: string; agent_id: number | null }>(
|
.post<{ id: string; name: string; agent_id: number | null }>(
|
||||||
`/api/templates/${id}/instantiate`,
|
`/api/templates/${id}/instantiate`,
|
||||||
{ name, values, agent_id: agentId ?? null }
|
{ name, agent_id: agentId ?? null }
|
||||||
)
|
)
|
||||||
.then((r) => r.data),
|
.then((r) => r.data),
|
||||||
save: (body: { name: string; description?: string; tags: string[]; yaml: string }) =>
|
save: (body: {
|
||||||
api.post("/api/templates", body).then((r) => r.data),
|
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) =>
|
remove: (slug: string) =>
|
||||||
api.delete(`/api/templates/custom/${slug}`).then((r) => r.data),
|
api.delete(`/api/templates/custom/${slug}`).then((r) => r.data),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,9 +10,10 @@ import {
|
|||||||
Pencil,
|
Pencil,
|
||||||
Power,
|
Power,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
LayoutTemplate,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { toast } from "sonner";
|
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 { ConfirmDialog } from "@/components/ui/ConfirmDialog";
|
||||||
import { LogViewer } from "@/components/stacks/LogViewer";
|
import { LogViewer } from "@/components/stacks/LogViewer";
|
||||||
import { ContainerCard } from "@/components/stacks/ContainerCard";
|
import { ContainerCard } from "@/components/stacks/ContainerCard";
|
||||||
@@ -20,6 +21,7 @@ import { AutoUpdatePanel } from "@/components/stacks/AutoUpdatePanel";
|
|||||||
import { SecretsPanel } from "@/components/stacks/SecretsPanel";
|
import { SecretsPanel } from "@/components/stacks/SecretsPanel";
|
||||||
import { BackupButton } from "@/components/stacks/BackupRestore";
|
import { BackupButton } from "@/components/stacks/BackupRestore";
|
||||||
import { stacksApi } from "@/api/stacks";
|
import { stacksApi } from "@/api/stacks";
|
||||||
|
import { templatesApi } from "@/api/templates";
|
||||||
import { apiErrorMessage } from "@/api/client";
|
import { apiErrorMessage } from "@/api/client";
|
||||||
import { useAuthStore } from "@/store/auth";
|
import { useAuthStore } from "@/store/auth";
|
||||||
import { useStackActions } from "@/hooks/useStackActions";
|
import { useStackActions } from "@/hooks/useStackActions";
|
||||||
@@ -78,6 +80,7 @@ export function StackDetail() {
|
|||||||
<Power className="h-4 w-4" /> Down
|
<Power className="h-4 w-4" /> Down
|
||||||
</Button>
|
</Button>
|
||||||
<BackupButton stackId={id} />
|
<BackupButton stackId={id} />
|
||||||
|
<SaveAsTemplateButton stackId={id} defaultName={data.name} />
|
||||||
<Link to={`/stacks/${id}/edit`}>
|
<Link to={`/stacks/${id}/edit`}>
|
||||||
<Button>
|
<Button>
|
||||||
<Pencil className="h-4 w-4" /> Edit
|
<Pencil className="h-4 w-4" /> Edit
|
||||||
@@ -230,3 +233,49 @@ function DeleteStackButton({ stackId }: { stackId: string }) {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SaveAsTemplateButton({ stackId, defaultName }: { stackId: string; defaultName: string }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [name, setName] = useState(defaultName);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (!name.trim()) {
|
||||||
|
toast.error("Template name required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await templatesApi.saveFromStack({ stack_id: stackId, name: name.trim() });
|
||||||
|
toast.success(`Saved template “${name.trim()}”`);
|
||||||
|
setOpen(false);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(apiErrorMessage(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={() => setOpen(true)}>
|
||||||
|
<LayoutTemplate className="h-4 w-4" /> Save as template
|
||||||
|
</Button>
|
||||||
|
{open && (
|
||||||
|
<ConfirmDialog
|
||||||
|
title="Save as template"
|
||||||
|
message="Snapshots this stack's compose and .env into a reusable custom template."
|
||||||
|
confirmLabel="Save template"
|
||||||
|
busy={busy}
|
||||||
|
onConfirm={save}
|
||||||
|
onCancel={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
<label className="block space-y-1">
|
||||||
|
<span className="text-xs font-medium text-slate-500">Template name</span>
|
||||||
|
<Input value={name} onChange={(e) => setName(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
</ConfirmDialog>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { LayoutTemplate, Cpu, Package } from "lucide-react";
|
import { LayoutTemplate, Cpu, Package, Trash2, FileCode } from "lucide-react";
|
||||||
import { Badge, Button, Card, Input, Spinner } from "@/components/ui";
|
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 { templatesApi, type TemplateDetail, type TemplateSummary } from "@/api/templates";
|
||||||
import { agentsApi } from "@/api/agents";
|
import { agentsApi } from "@/api/agents";
|
||||||
import { apiErrorMessage } from "@/api/client";
|
import { apiErrorMessage } from "@/api/client";
|
||||||
@@ -14,7 +15,9 @@ const selectClass =
|
|||||||
|
|
||||||
export function Templates() {
|
export function Templates() {
|
||||||
const isAdmin = useAuthStore((s) => s.user?.role === "admin");
|
const isAdmin = useAuthStore((s) => s.user?.role === "admin");
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const [selected, setSelected] = useState<TemplateDetail | null>(null);
|
const [selected, setSelected] = useState<TemplateDetail | null>(null);
|
||||||
|
const [toDelete, setToDelete] = useState<TemplateSummary | null>(null);
|
||||||
const { data, isLoading } = useQuery({ queryKey: ["templates"], queryFn: templatesApi.list });
|
const { data, isLoading } = useQuery({ queryKey: ["templates"], queryFn: templatesApi.list });
|
||||||
|
|
||||||
const open = async (t: TemplateSummary) => {
|
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 <Spinner />;
|
if (isLoading) return <Spinner />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
Templates are stored as ready-to-run stack folders. Pull one to copy it into a new stack,
|
||||||
|
then edit it like any other.
|
||||||
|
</p>
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||||
{data?.map((t) => (
|
{data?.map((t) => (
|
||||||
<Card key={t.id} className="flex flex-col gap-2">
|
<Card key={t.id} className="flex flex-col gap-2">
|
||||||
@@ -55,9 +76,16 @@ export function Templates() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<Button variant="outline" className="mt-2" onClick={() => open(t)}>
|
<div className="mt-2 flex gap-2">
|
||||||
Use template
|
<Button variant="outline" className="flex-1" onClick={() => open(t)}>
|
||||||
</Button>
|
Use template
|
||||||
|
</Button>
|
||||||
|
{t.source === "custom" && (
|
||||||
|
<Button variant="outline" onClick={() => setToDelete(t)} title="Delete template">
|
||||||
|
<Trash2 className="h-4 w-4 text-red-500" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
@@ -66,6 +94,17 @@ export function Templates() {
|
|||||||
{selected && (
|
{selected && (
|
||||||
<UseTemplateDialog template={selected} onClose={() => setSelected(null)} />
|
<UseTemplateDialog template={selected} onClose={() => setSelected(null)} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{toDelete && (
|
||||||
|
<ConfirmDialog
|
||||||
|
title={`Delete template “${toDelete.name}”?`}
|
||||||
|
message="This removes the custom template folder. Existing stacks are not affected."
|
||||||
|
confirmLabel="Delete"
|
||||||
|
danger
|
||||||
|
onConfirm={remove}
|
||||||
|
onCancel={() => setToDelete(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -79,9 +118,6 @@ function UseTemplateDialog({
|
|||||||
}) {
|
}) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [name, setName] = useState(template.name);
|
const [name, setName] = useState(template.name);
|
||||||
const [values, setValues] = useState<Record<string, string>>(
|
|
||||||
Object.fromEntries(template.variables.map((v) => [v.name, v.default]))
|
|
||||||
);
|
|
||||||
const [host, setHost] = useState("local");
|
const [host, setHost] = useState("local");
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
@@ -96,7 +132,7 @@ function UseTemplateDialog({
|
|||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
const agentId = host === "local" ? null : Number(host);
|
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`);
|
toast.success(`Stack '${res.name}' created`);
|
||||||
if (res.agent_id != null) navigate(`/hosts/${res.agent_id}/stacks/${res.id}`);
|
if (res.agent_id != null) navigate(`/hosts/${res.agent_id}/stacks/${res.id}`);
|
||||||
else navigate(`/stacks/${res.id}/edit`);
|
else navigate(`/stacks/${res.id}/edit`);
|
||||||
@@ -109,7 +145,7 @@ function UseTemplateDialog({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||||
<div className="max-h-[85vh] w-full max-w-lg overflow-auto rounded-xl border border-slate-200 bg-card p-5 shadow-xl dark:border-slate-700 dark:bg-card-dark">
|
<div className="max-h-[85vh] w-full max-w-2xl overflow-auto rounded-xl border border-slate-200 bg-card p-5 shadow-xl dark:border-slate-700 dark:bg-card-dark">
|
||||||
<h2 className="mb-3 sp-heading text-lg">Use “{template.name}”</h2>
|
<h2 className="mb-3 sp-heading text-lg">Use “{template.name}”</h2>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<label className="block space-y-1">
|
<label className="block space-y-1">
|
||||||
@@ -129,22 +165,45 @@ function UseTemplateDialog({
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
{template.variables.map((v) => (
|
|
||||||
<label key={v.name} className="block space-y-1">
|
{template.files.length > 0 && (
|
||||||
<span className="text-xs font-medium text-slate-500">
|
<div className="space-y-1">
|
||||||
{v.name}
|
<span className="text-xs font-medium text-slate-500">Files</span>
|
||||||
{v.description && <span className="ml-1 font-normal text-slate-400">— {v.description}</span>}
|
<div className="flex flex-wrap gap-1">
|
||||||
</span>
|
{template.files.map((f) => (
|
||||||
<Input
|
<span
|
||||||
value={values[v.name] ?? ""}
|
key={f}
|
||||||
onChange={(e) => setValues((s) => ({ ...s, [v.name]: e.target.value }))}
|
className="inline-flex items-center gap-1 rounded-md bg-slate-100 px-2 py-0.5 font-mono text-xs text-slate-600 dark:bg-slate-700 dark:text-slate-300"
|
||||||
/>
|
>
|
||||||
</label>
|
<FileCode className="h-3 w-3" /> {f}
|
||||||
))}
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<span className="text-xs font-medium text-slate-500">compose.yaml</span>
|
||||||
|
<pre className="max-h-64 overflow-auto rounded-lg bg-slate-900 p-3 text-xs text-slate-100">
|
||||||
|
{template.compose}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{template.env.trim() && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<span className="text-xs font-medium text-slate-500">.env (defaults)</span>
|
||||||
|
<pre className="max-h-40 overflow-auto rounded-lg bg-slate-900 p-3 text-xs text-slate-100">
|
||||||
|
{template.env}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<p className="mt-3 text-xs text-slate-400">
|
||||||
|
The whole folder is copied into a new stack you can edit and deploy afterwards.
|
||||||
|
</p>
|
||||||
<div className="mt-4 flex justify-end gap-2">
|
<div className="mt-4 flex justify-end gap-2">
|
||||||
<Button variant="outline" onClick={onClose}>Cancel</Button>
|
<Button variant="outline" onClick={onClose}>Cancel</Button>
|
||||||
<Button onClick={create} loading={busy}>Create stack</Button>
|
<Button onClick={create} loading={busy}>Pull into stack</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user