Files
NetBirdMSP-Appliance/app/routers/customers.py
twothatit 1bbe4904a7 fix: resolve circular import, async blocking, SELinux and delete timeout issues
- Extract shared SlowAPI limiter to app/limiter.py to break circular
  import between app.main and app.routers.auth
- Seed default SystemConfig row (id=1) on first DB init so settings
  page works out of the box
- Make all docker_service.compose_* functions async (run_in_executor)
  so long docker pulls/stops no longer block the async event loop
- Propagate async to netbird_service stop/start/restart and await
  callers in deployments router
- Move customer delete to BackgroundTasks so the HTTP response returns
  immediately and avoids frontend "Network error" on slow machines
- docker-compose: add :z SELinux labels, mount docker.sock directly,
  add security_opt label:disable for socket access, extra_hosts for
  host.docker.internal, enable DELETE/VOLUMES on socket proxy
- npm_service: auto-detect outbound host IP via UDP socket when
  HOST_IP env var is not set

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-19 00:30:25 +01:00

256 lines
7.6 KiB
Python

"""Customer CRUD API endpoints with automatic deployment on create."""
import logging
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from app.database import SessionLocal, get_db
from app.dependencies import get_current_user
from app.models import Customer, Deployment, DeploymentLog, User
from app.services import netbird_service
from app.utils.validators import CustomerCreate, CustomerUpdate
logger = logging.getLogger(__name__)
router = APIRouter()
@router.post("", status_code=status.HTTP_201_CREATED)
async def create_customer(
payload: CustomerCreate,
background_tasks: BackgroundTasks,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Create a new customer and trigger auto-deployment.
Validates that the subdomain is unique, creates the customer record,
and launches deployment in the background.
Args:
payload: Customer creation data.
background_tasks: FastAPI background task runner.
Returns:
Created customer dict with deployment status.
"""
# Check subdomain uniqueness
existing = db.query(Customer).filter(Customer.subdomain == payload.subdomain).first()
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Subdomain '{payload.subdomain}' is already in use.",
)
customer = Customer(
name=payload.name,
company=payload.company,
subdomain=payload.subdomain,
email=payload.email,
max_devices=payload.max_devices,
notes=payload.notes,
status="deploying",
)
db.add(customer)
db.commit()
db.refresh(customer)
logger.info("Customer %d (%s) created by %s.", customer.id, customer.subdomain, current_user.username)
# Deploy in background so the HTTP response returns immediately.
# We create a dedicated DB session for the background task because
# the request session will be closed once the response is sent.
async def _deploy_in_background(customer_id: int) -> None:
bg_db = SessionLocal()
try:
await netbird_service.deploy_customer(bg_db, customer_id)
except Exception:
logger.exception("Background deployment failed for customer %d", customer_id)
finally:
bg_db.close()
background_tasks.add_task(_deploy_in_background, customer.id)
response = customer.to_dict()
response["deployment"] = {"deployment_status": "deploying"}
return response
@router.get("")
async def list_customers(
page: int = Query(default=1, ge=1),
per_page: int = Query(default=25, ge=1, le=100),
search: Optional[str] = Query(default=None),
status_filter: Optional[str] = Query(default=None, alias="status"),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""List customers with pagination, search, and status filter.
Args:
page: Page number (1-indexed).
per_page: Items per page.
search: Search in name, subdomain, email.
status_filter: Filter by status.
Returns:
Paginated customer list with metadata.
"""
query = db.query(Customer)
if search:
like_term = f"%{search}%"
query = query.filter(
(Customer.name.ilike(like_term))
| (Customer.subdomain.ilike(like_term))
| (Customer.email.ilike(like_term))
| (Customer.company.ilike(like_term))
)
if status_filter:
query = query.filter(Customer.status == status_filter)
total = query.count()
customers = (
query.order_by(Customer.created_at.desc())
.offset((page - 1) * per_page)
.limit(per_page)
.all()
)
items = []
for c in customers:
data = c.to_dict()
if c.deployment:
data["deployment"] = c.deployment.to_dict()
else:
data["deployment"] = None
items.append(data)
return {
"items": items,
"total": total,
"page": page,
"per_page": per_page,
"pages": (total + per_page - 1) // per_page if total > 0 else 1,
}
@router.get("/{customer_id}")
async def get_customer(
customer_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Get detailed customer information including deployment and logs.
Args:
customer_id: Customer ID.
Returns:
Customer dict with deployment info and recent logs.
"""
customer = db.query(Customer).filter(Customer.id == customer_id).first()
if not customer:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Customer not found.",
)
data = customer.to_dict()
data["deployment"] = customer.deployment.to_dict() if customer.deployment else None
data["logs"] = [
log.to_dict()
for log in db.query(DeploymentLog)
.filter(DeploymentLog.customer_id == customer_id)
.order_by(DeploymentLog.created_at.desc())
.limit(50)
.all()
]
return data
@router.put("/{customer_id}")
async def update_customer(
customer_id: int,
payload: CustomerUpdate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Update customer information.
Args:
customer_id: Customer ID.
payload: Fields to update.
Returns:
Updated customer dict.
"""
customer = db.query(Customer).filter(Customer.id == customer_id).first()
if not customer:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Customer not found.",
)
update_data = payload.model_dump(exclude_none=True)
for field, value in update_data.items():
if hasattr(customer, field):
setattr(customer, field, value)
customer.updated_at = datetime.utcnow()
db.commit()
db.refresh(customer)
logger.info("Customer %d updated by %s.", customer_id, current_user.username)
return customer.to_dict()
@router.delete("/{customer_id}")
async def delete_customer(
customer_id: int,
background_tasks: BackgroundTasks,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Delete a customer and clean up all resources.
Removes containers, NPM proxy, instance directory, and database records.
Cleanup runs in background so the response returns immediately.
Args:
customer_id: Customer ID.
Returns:
Confirmation message.
"""
customer = db.query(Customer).filter(Customer.id == customer_id).first()
if not customer:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Customer not found.",
)
# Mark as deleting immediately so UI reflects the state
customer.status = "inactive"
db.commit()
async def _delete_in_background(cid: int) -> None:
bg_db = SessionLocal()
try:
await netbird_service.undeploy_customer(bg_db, cid)
c = bg_db.query(Customer).filter(Customer.id == cid).first()
if c:
bg_db.delete(c)
bg_db.commit()
logger.info("Customer %d deleted by %s.", cid, current_user.username)
except Exception:
logger.exception("Background delete failed for customer %d", cid)
finally:
bg_db.close()
background_tasks.add_task(_delete_in_background, customer_id)
return {"message": f"Customer {customer_id} deletion started."}