""" Config template CRUD, preview, and push API endpoints. All routes are tenant-scoped under: /api/tenants/{tenant_id}/templates/ Provides: - GET /templates -- list templates (optional tag filter) - POST /templates -- create a template - GET /templates/{id} -- get single template - PUT /templates/{id} -- update a template - DELETE /templates/{id} -- delete a template - POST /templates/{id}/preview -- preview rendered template for a device - POST /templates/{id}/push -- push template to devices (sequential rollout) - GET /templates/push-status/{rollout_id} -- poll push progress RLS is enforced via get_db() (app_user engine with tenant context). RBAC: viewer = read (GET/preview); operator and above = write (POST/PUT/DELETE/push). """ import asyncio import logging import uuid from datetime import datetime, timezone from typing import Any, Optional from fastapi import APIRouter, Depends, HTTPException, Query, Request, status from pydantic import BaseModel, ConfigDict from sqlalchemy import delete, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.database import get_db from app.middleware.rate_limit import limiter from app.middleware.rbac import require_min_role, require_scope from app.middleware.tenant_context import CurrentUser, get_current_user from app.models.config_template import ConfigTemplate, ConfigTemplateTag, TemplatePushJob from app.models.device import Device from app.services import template_service logger = logging.getLogger(__name__) router = APIRouter(tags=["templates"]) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- async def _check_tenant_access( current_user: CurrentUser, tenant_id: uuid.UUID, db: AsyncSession ) -> None: """Verify the current user is allowed to access the given tenant.""" if current_user.is_super_admin: from app.database import set_tenant_context await set_tenant_context(db, str(tenant_id)) return if current_user.tenant_id != tenant_id: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Access denied: you do not belong to this tenant.", ) def _serialize_template(template: ConfigTemplate, include_content: bool = False) -> dict: """Serialize a ConfigTemplate to a response dict.""" result: dict[str, Any] = { "id": str(template.id), "name": template.name, "description": template.description, "tags": [tag.name for tag in template.tags], "variable_count": len(template.variables) if template.variables else 0, "created_at": template.created_at.isoformat(), "updated_at": template.updated_at.isoformat(), } if include_content: result["content"] = template.content result["variables"] = template.variables or [] return result # --------------------------------------------------------------------------- # Request/Response schemas # --------------------------------------------------------------------------- class VariableDef(BaseModel): model_config = ConfigDict(extra="forbid") name: str type: str = "string" # string | ip | integer | boolean | subnet default: Optional[str] = None description: Optional[str] = None class TemplateCreateRequest(BaseModel): model_config = ConfigDict(extra="forbid") name: str description: Optional[str] = None content: str variables: list[VariableDef] = [] tags: list[str] = [] class TemplateUpdateRequest(BaseModel): model_config = ConfigDict(extra="forbid") name: str description: Optional[str] = None content: str variables: list[VariableDef] = [] tags: list[str] = [] class PreviewRequest(BaseModel): model_config = ConfigDict(extra="forbid") device_id: str variables: dict[str, str] = {} class PushRequest(BaseModel): model_config = ConfigDict(extra="forbid") device_ids: list[str] variables: dict[str, str] = {} # --------------------------------------------------------------------------- # CRUD endpoints # --------------------------------------------------------------------------- @router.get( "/tenants/{tenant_id}/templates", summary="List config templates", dependencies=[require_scope("config:read")], ) async def list_templates( tenant_id: uuid.UUID, tag: Optional[str] = Query(None, description="Filter by tag name"), current_user: CurrentUser = Depends(get_current_user), _role: CurrentUser = Depends(require_min_role("viewer")), db: AsyncSession = Depends(get_db), ) -> list[dict]: """List all config templates for a tenant with optional tag filtering.""" await _check_tenant_access(current_user, tenant_id, db) query = ( select(ConfigTemplate) .options(selectinload(ConfigTemplate.tags)) .where(ConfigTemplate.tenant_id == tenant_id) # type: ignore[arg-type] .order_by(ConfigTemplate.updated_at.desc()) ) if tag: query = query.where( ConfigTemplate.id.in_( # type: ignore[attr-defined] select(ConfigTemplateTag.template_id).where( ConfigTemplateTag.name == tag, ConfigTemplateTag.tenant_id == tenant_id, # type: ignore[arg-type] ) ) ) result = await db.execute(query) templates = result.scalars().all() return [_serialize_template(t) for t in templates] @router.post( "/tenants/{tenant_id}/templates", summary="Create a config template", status_code=status.HTTP_201_CREATED, dependencies=[require_scope("config:write")], ) @limiter.limit("20/minute") async def create_template( request: Request, tenant_id: uuid.UUID, body: TemplateCreateRequest, current_user: CurrentUser = Depends(get_current_user), _role: CurrentUser = Depends(require_min_role("operator")), db: AsyncSession = Depends(get_db), ) -> dict: """Create a new config template with Jinja2 content and variable definitions.""" await _check_tenant_access(current_user, tenant_id, db) # Auto-extract variables from content for comparison detected = template_service.extract_variables(body.content) provided_names = {v.name for v in body.variables} unmatched = set(detected) - provided_names if unmatched: logger.warning( "Template '%s' has undeclared variables: %s (auto-adding as string type)", body.name, unmatched, ) # Create template template = ConfigTemplate( tenant_id=tenant_id, name=body.name, description=body.description, content=body.content, variables=[v.model_dump() for v in body.variables], ) db.add(template) await db.flush() # Get the generated ID # Create tags for tag_name in body.tags: tag = ConfigTemplateTag( tenant_id=tenant_id, name=tag_name, template_id=template.id, ) db.add(tag) await db.flush() # Re-query with tags loaded result = await db.execute( select(ConfigTemplate) .options(selectinload(ConfigTemplate.tags)) .where(ConfigTemplate.id == template.id) # type: ignore[arg-type] ) template = result.scalar_one() return _serialize_template(template, include_content=True) @router.get( "/tenants/{tenant_id}/templates/{template_id}", summary="Get a single config template", dependencies=[require_scope("config:read")], ) async def get_template( tenant_id: uuid.UUID, template_id: uuid.UUID, current_user: CurrentUser = Depends(get_current_user), _role: CurrentUser = Depends(require_min_role("viewer")), db: AsyncSession = Depends(get_db), ) -> dict: """Get a config template with full content, variables, and tags.""" await _check_tenant_access(current_user, tenant_id, db) result = await db.execute( select(ConfigTemplate) .options(selectinload(ConfigTemplate.tags)) .where( ConfigTemplate.id == template_id, # type: ignore[arg-type] ConfigTemplate.tenant_id == tenant_id, # type: ignore[arg-type] ) ) template = result.scalar_one_or_none() if template is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Template {template_id} not found", ) return _serialize_template(template, include_content=True) @router.put( "/tenants/{tenant_id}/templates/{template_id}", summary="Update a config template", dependencies=[require_scope("config:write")], ) @limiter.limit("20/minute") async def update_template( request: Request, tenant_id: uuid.UUID, template_id: uuid.UUID, body: TemplateUpdateRequest, current_user: CurrentUser = Depends(get_current_user), _role: CurrentUser = Depends(require_min_role("operator")), db: AsyncSession = Depends(get_db), ) -> dict: """Update an existing config template.""" await _check_tenant_access(current_user, tenant_id, db) result = await db.execute( select(ConfigTemplate) .options(selectinload(ConfigTemplate.tags)) .where( ConfigTemplate.id == template_id, # type: ignore[arg-type] ConfigTemplate.tenant_id == tenant_id, # type: ignore[arg-type] ) ) template = result.scalar_one_or_none() if template is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Template {template_id} not found", ) # Update fields template.name = body.name template.description = body.description template.content = body.content template.variables = [v.model_dump() for v in body.variables] # Replace tags: delete old, create new await db.execute( delete(ConfigTemplateTag).where( ConfigTemplateTag.template_id == template_id # type: ignore[arg-type] ) ) for tag_name in body.tags: tag = ConfigTemplateTag( tenant_id=tenant_id, name=tag_name, template_id=template.id, ) db.add(tag) await db.flush() # Re-query with fresh tags result = await db.execute( select(ConfigTemplate) .options(selectinload(ConfigTemplate.tags)) .where(ConfigTemplate.id == template.id) # type: ignore[arg-type] ) template = result.scalar_one() return _serialize_template(template, include_content=True) @router.delete( "/tenants/{tenant_id}/templates/{template_id}", status_code=status.HTTP_204_NO_CONTENT, summary="Delete a config template", dependencies=[require_scope("config:write")], ) @limiter.limit("5/minute") async def delete_template( request: Request, tenant_id: uuid.UUID, template_id: uuid.UUID, current_user: CurrentUser = Depends(get_current_user), _role: CurrentUser = Depends(require_min_role("operator")), db: AsyncSession = Depends(get_db), ) -> None: """Delete a config template. Tags are cascade-deleted. Push jobs are SET NULL.""" await _check_tenant_access(current_user, tenant_id, db) result = await db.execute( select(ConfigTemplate).where( ConfigTemplate.id == template_id, # type: ignore[arg-type] ConfigTemplate.tenant_id == tenant_id, # type: ignore[arg-type] ) ) template = result.scalar_one_or_none() if template is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Template {template_id} not found", ) await db.delete(template) # --------------------------------------------------------------------------- # Preview & Push endpoints # --------------------------------------------------------------------------- @router.post( "/tenants/{tenant_id}/templates/{template_id}/preview", summary="Preview template rendered for a specific device", dependencies=[require_scope("config:read")], ) async def preview_template( tenant_id: uuid.UUID, template_id: uuid.UUID, body: PreviewRequest, current_user: CurrentUser = Depends(get_current_user), _role: CurrentUser = Depends(require_min_role("viewer")), db: AsyncSession = Depends(get_db), ) -> dict: """Render a template with device context and custom variables for preview.""" await _check_tenant_access(current_user, tenant_id, db) # Load template result = await db.execute( select(ConfigTemplate).where( ConfigTemplate.id == template_id, # type: ignore[arg-type] ConfigTemplate.tenant_id == tenant_id, # type: ignore[arg-type] ) ) template = result.scalar_one_or_none() if template is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Template {template_id} not found", ) # Load device result = await db.execute( select(Device).where(Device.id == body.device_id) # type: ignore[arg-type] ) device = result.scalar_one_or_none() if device is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Device {body.device_id} not found", ) # Validate variables against type definitions if template.variables: for var_def in template.variables: var_name = var_def.get("name", "") var_type = var_def.get("type", "string") value = body.variables.get(var_name) if value is None: # Use default if available default = var_def.get("default") if default is not None: body.variables[var_name] = default continue error = template_service.validate_variable(var_name, value, var_type) if error: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=error, ) # Render try: rendered = template_service.render_template( template.content, { "hostname": device.hostname, "ip_address": device.ip_address, "model": device.model, }, body.variables, ) except Exception as exc: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=f"Template rendering failed: {exc}", ) return { "rendered": rendered, "device_hostname": device.hostname, } @router.post( "/tenants/{tenant_id}/templates/{template_id}/push", summary="Push template to devices (sequential rollout with panic-revert)", dependencies=[require_scope("config:write")], ) @limiter.limit("5/minute") async def push_template( request: Request, tenant_id: uuid.UUID, template_id: uuid.UUID, body: PushRequest, current_user: CurrentUser = Depends(get_current_user), _role: CurrentUser = Depends(require_min_role("operator")), db: AsyncSession = Depends(get_db), ) -> dict: """Start a template push to one or more devices. Creates push jobs for each device and starts a background sequential rollout. Returns the rollout_id for status polling. """ await _check_tenant_access(current_user, tenant_id, db) # Load template result = await db.execute( select(ConfigTemplate).where( ConfigTemplate.id == template_id, # type: ignore[arg-type] ConfigTemplate.tenant_id == tenant_id, # type: ignore[arg-type] ) ) template = result.scalar_one_or_none() if template is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Template {template_id} not found", ) if not body.device_ids: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="At least one device_id is required", ) # Validate variables if template.variables: for var_def in template.variables: var_name = var_def.get("name", "") var_type = var_def.get("type", "string") value = body.variables.get(var_name) if value is None: default = var_def.get("default") if default is not None: body.variables[var_name] = default continue error = template_service.validate_variable(var_name, value, var_type) if error: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=error, ) rollout_id = uuid.uuid4() jobs_created = [] for device_id_str in body.device_ids: # Load device to render template per-device result = await db.execute( select(Device).where(Device.id == device_id_str) # type: ignore[arg-type] ) device = result.scalar_one_or_none() if device is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Device {device_id_str} not found", ) # Render template with this device's context try: rendered = template_service.render_template( template.content, { "hostname": device.hostname, "ip_address": device.ip_address, "model": device.model, }, body.variables, ) except Exception as exc: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=f"Template rendering failed for device {device.hostname}: {exc}", ) # Create push job job = TemplatePushJob( tenant_id=tenant_id, template_id=template_id, device_id=device.id, rollout_id=rollout_id, rendered_content=rendered, status="pending", ) db.add(job) jobs_created.append({ "job_id": str(job.id), "device_id": str(device.id), "device_hostname": device.hostname, }) await db.flush() # Start background push task asyncio.create_task(template_service.push_to_devices(str(rollout_id))) return { "rollout_id": str(rollout_id), "jobs": jobs_created, } @router.get( "/tenants/{tenant_id}/templates/push-status/{rollout_id}", summary="Poll push progress for a rollout", dependencies=[require_scope("config:read")], ) async def push_status( tenant_id: uuid.UUID, rollout_id: uuid.UUID, current_user: CurrentUser = Depends(get_current_user), _role: CurrentUser = Depends(require_min_role("viewer")), db: AsyncSession = Depends(get_db), ) -> dict: """Return all push job statuses for a rollout with device hostnames.""" await _check_tenant_access(current_user, tenant_id, db) result = await db.execute( select(TemplatePushJob, Device.hostname) .join(Device, TemplatePushJob.device_id == Device.id) # type: ignore[arg-type] .where( TemplatePushJob.rollout_id == rollout_id, # type: ignore[arg-type] TemplatePushJob.tenant_id == tenant_id, # type: ignore[arg-type] ) .order_by(TemplatePushJob.created_at.asc()) ) rows = result.all() jobs = [] for job, hostname in rows: jobs.append({ "device_id": str(job.device_id), "hostname": hostname, "status": job.status, "error_message": job.error_message, "started_at": job.started_at.isoformat() if job.started_at else None, "completed_at": job.completed_at.isoformat() if job.completed_at else None, }) return { "rollout_id": str(rollout_id), "jobs": jobs, }