"""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 from fastapi import APIRouter, Depends, HTTPException, Request from sqlmodel import Session from auth import get_current_user, require_admin from database import get_session from models.stack import Stack from models.template import ( TemplateFromStackRequest, TemplateInstantiateRequest, TemplateSaveRequest, ) from models.user import User from services import audit_service, compose_service, template_service router = APIRouter(prefix="/api/templates", tags=["templates"]) def _ip(request: Request) -> str: return request.client.host if request.client else "unknown" @router.get("") def list_templates( _user: User = Depends(get_current_user), ) -> list[dict]: return template_service.list_templates() @router.get("/{template_id}") def get_template( template_id: str, _admin: User = Depends(require_admin), ) -> dict: """Full template incl. compose and env. Admin only: "save stack as template" snapshots the stack's real ``.env`` into the template, so this can carry live credentials. Only admins can instantiate a template anyway; the listing above stays open to everyone.""" tpl = template_service.get_template(template_id) if not tpl: raise HTTPException(status_code=404, detail="Template not found") return tpl @router.post("") def save_template( body: TemplateSaveRequest, request: Request, session: Session = Depends(get_session), user: User = Depends(require_admin), ) -> dict: 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=slug, ip=_ip(request) ) 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}") def delete_template( slug: str, request: Request, session: Session = Depends(get_session), user: User = Depends(require_admin), ) -> dict: 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) ) return {"ok": True} @router.post("/{template_id}/instantiate", status_code=201) def instantiate( template_id: str, body: TemplateInstantiateRequest, request: Request, session: Session = Depends(get_session), user: User = Depends(require_admin), ) -> dict: tpl = template_service.get_template(template_id) if not tpl: raise HTTPException(status_code=404, detail="Template not found") # 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") try: template_service.copy_into_stack(template_id, stack_id) except FileExistsError as exc: raise HTTPException( status_code=409, detail=f"Stack '{stack_id}' already exists" ) from exc except FileNotFoundError as exc: raise HTTPException(status_code=404, detail="Template not found") from exc stack = Stack(id=stack_id, name=body.name, description=tpl.get("description")) session.add(stack) session.commit() audit_service.record( session, user=user.username, action="template.instantiate", target=stack_id, detail=template_id, ip=_ip(request), ) return {"id": stack_id, "name": body.name}