"""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.agent import Agent from models.stack import Stack 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 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, _user: User = Depends(get_current_user), ) -> dict: 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) async 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") # 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: raise HTTPException(status_code=404, detail=f"Agent {body.agent_id} not found") try: result = await agent_service.call( session, agent, "POST", "/agent/stacks", json={"name": body.name, "yaml": tpl["compose"], "env": tpl["env"] or None}, ) except AgentError as exc: raise HTTPException( status_code=exc.status if exc.status >= 400 else 502, detail={"error": exc.error, "detail": exc.detail}, ) audit_service.record( session, user=user.username, action="template.instantiate", target=f"{agent.name}/{result.get('id')}", detail=template_id, ip=_ip(request), ) 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") 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() 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, "agent_id": None}