ci: add GitHub Pages deployment workflow for docs site Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
614 lines
20 KiB
Python
614 lines
20 KiB
Python
"""
|
|
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,
|
|
}
|