Files
taylanbakircioglu b34d7cf811 fix: reactivate disabled backend servers from the UI + v1.6.3 (Issue #24)
A backend server toggled OFF (is_active=false) vanished from the UI with no way
to reactivate it: GET /api/backends honored include_inactive for backends but the
server sub-queries hardcoded 'AND is_active = TRUE'.

- get_backends: server sub-queries now honor include_inactive (default callers
  unchanged); added last_config_status to the server payload so the UI can tell a
  DISABLED server (re-enableable) from a DELETION (pending delete).
- toggle_server: persists an entity snapshot so an Apply-Management Reject rolls
  back is_active (previously left the server stuck disabled).
- BackendServers.js: requests include_inactive, shows disabled servers with the
  ON/OFF switch + an 'Inactive' tag, hides only DELETION-pending servers, and
  keeps soft-deleted BACKENDS hidden (so include_inactive doesn't resurface them).
- Config generation unchanged: disabled servers stay '# DISABLED:' comments and
  convert back to live lines when re-enabled.

Startup migration hardening (multi-replica / rolling-deploy safety): create_essential_tables
fails fast on lock contention and retries; run_all_migrations is serialized by a
session advisory lock and gated by a schema_migrations version marker, so an
already-current schema is skipped instead of issuing lock-heavy DDL that a serving
replica's traffic could block at startup. Idempotent and fail-open.

Version reported consistently across all layers (version.json, backend fallback,
frontend package) -> 1.6.3.
2026-06-02 02:42:48 +03:00

2045 lines
103 KiB
Python

from fastapi import APIRouter, HTTPException, Request, Header
from typing import Optional, List, Any
import logging
import time
import hashlib
import json
from models.backend import BackendConfig, BackendConfigUpdate, ServerConfig
from database.connection import get_database_connection, close_database_connection
from utils.activity_log import log_user_activity
from services.haproxy_config import generate_haproxy_config_for_cluster
router = APIRouter(prefix="/api/backends", tags=["backends", "servers"])
logger = logging.getLogger(__name__)
# Bulgu #71 / #72 (round-22 audit) — shared helpers for safe
# manipulation of `use_backend` rule strings on backend delete /
# rename cascades. Pre-fix the delete path used a naive
# `backend_name in rule` substring match to decide which rules
# referenced the deleted backend — which collaterally wiped:
#
# * `use_backend api-v2 if is_apiv2` (when deleting "api")
# * `use_backend mobile_api if is_mob` (deleting "api")
# * any acl_rule whose body happened to mention the backend
# name, e.g. `is_api hdr(host) -i api.example.com` (deleting
# "api" would also delete the unrelated ACL definition).
#
# Worse, the rename path didn't update `use_backend_rules` at all,
# so renaming the backend silently broke every routing rule that
# referenced it — the rendered HAProxy config would reference a
# non-existent backend and the agent's `haproxy -c` would either
# reject the reload or send traffic to `default_backend`.
#
# `_extract_use_backend_target` parses the first non-keyword token
# (the backend name) so callers can compare EXACTLY. ACL rules are
# intentionally not touched here — ACLs are reusable predicates,
# not tied to any single backend; the prior coupling was a bug.
def _extract_use_backend_target(rule: Any) -> Optional[str]:
"""Return the backend name targeted by a `use_backend` rule
string, or None for non-string / empty / malformed input.
Handles both stored shapes:
* raw HAProxy form: ``"use_backend api if is_api"``
* FE-stripped form: ``"api if is_api"`` (the FE rule builder
drops the ``use_backend`` keyword on round-trip).
"""
if not isinstance(rule, str):
return None
s = rule.strip()
if not s:
return None
if s.lower().startswith("use_backend "):
s = s[len("use_backend "):].lstrip()
parts = s.split(None, 1)
if not parts:
return None
return parts[0]
def _rename_use_backend_target(rule: Any, old_name: str, new_name: str) -> Any:
"""Return a copy of `rule` with the targeted backend name
rewritten from `old_name` to `new_name`. Rules that don't
target `old_name` are returned UNCHANGED so unrelated rules
are never mutated. Preserves the ``use_backend `` prefix
exactly as it appeared in the input."""
if not isinstance(rule, str):
return rule
s = rule.strip()
if not s:
return rule
prefix = ""
body = s
if s.lower().startswith("use_backend "):
prefix = "use_backend "
body = s[len("use_backend "):].lstrip()
parts = body.split(None, 1)
if not parts or parts[0] != old_name:
return rule
rest = parts[1] if len(parts) > 1 else ""
return f"{prefix}{new_name}{' ' + rest if rest else ''}"
def filter_httpchk_from_options(options: Optional[str]) -> Optional[str]:
"""
Filter out 'option httpchk' directives from options field.
These should be configured via the health_check_uri field instead to avoid duplication.
Args:
options: Multi-line string containing HAProxy option directives
Returns:
Filtered options string without 'option httpchk' lines, or None if empty
"""
if not options:
return options
# Split by newline, filter out httpchk lines, rejoin
filtered_lines = [
line for line in options.split('\n')
if line.strip() and 'httpchk' not in line.lower()
]
# Return None if no lines remain after filtering
if not filtered_lines:
return None
return '\n'.join(filtered_lines)
async def validate_user_cluster_access(user_id: int, cluster_id: int, conn):
"""Validate that user has access to the specified cluster"""
# Check if cluster exists
cluster_exists = await conn.fetchval("""
SELECT id FROM haproxy_clusters WHERE id = $1
""", cluster_id)
if not cluster_exists:
raise HTTPException(
status_code=404,
detail="Cluster not found"
)
# Check if user is admin - admins can access everything
is_admin = await conn.fetchval("""
SELECT is_admin FROM users WHERE id = $1
""", user_id)
if is_admin:
logger.info(f"Admin user {user_id} granted access to cluster {cluster_id}")
return True
# Check if user_pool_access table exists (for backward compatibility)
table_exists = await conn.fetchval("""
SELECT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_name = 'user_pool_access'
)
""")
if not table_exists:
# Fallback to basic validation if table doesn't exist yet
logger.warning("user_pool_access table not found, using basic cluster validation")
return True
# Check if expires_at column exists (for backward compatibility)
expires_at_exists = await conn.fetchval("""
SELECT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'user_pool_access' AND column_name = 'expires_at'
)
""")
# Regular users need explicit pool access
if expires_at_exists:
user_access = await conn.fetchrow("""
SELECT upa.access_level, hc.id, hc.name
FROM haproxy_clusters hc
JOIN haproxy_cluster_pools hcp ON hc.pool_id = hcp.id
JOIN user_pool_access upa ON hcp.id = upa.pool_id
WHERE upa.user_id = $1 AND hc.id = $2 AND upa.is_active = TRUE
AND (upa.expires_at IS NULL OR upa.expires_at > CURRENT_TIMESTAMP)
""", user_id, cluster_id)
else:
# Fallback query without expires_at column
user_access = await conn.fetchrow("""
SELECT upa.access_level, hc.id, hc.name
FROM haproxy_clusters hc
JOIN haproxy_cluster_pools hcp ON hc.pool_id = hcp.id
JOIN user_pool_access upa ON hcp.id = upa.pool_id
WHERE upa.user_id = $1 AND hc.id = $2 AND upa.is_active = TRUE
""", user_id, cluster_id)
if not user_access:
raise HTTPException(
status_code=403,
detail="You don't have access to this cluster. Please contact your administrator."
)
logger.info(f"User {user_id} granted {user_access['access_level']} access to cluster {cluster_id}")
return True
# Health check functions (simplified versions)
async def check_server_health_via_haproxy(backend_name: str, server_name: str, cluster_id: int) -> str:
"""Check server health via HAProxy stats socket through agent"""
# Simplified implementation - would normally connect to agent
return "UNKNOWN"
async def check_server_health(host: str, port: int, timeout: int = 5) -> str:
"""Legacy TCP connection health check"""
import asyncio
try:
reader, writer = await asyncio.wait_for(
asyncio.open_connection(host, port),
timeout=timeout
)
writer.close()
await writer.wait_closed()
return "UP"
except:
return "DOWN"
@router.get("", summary="Get All Backends", response_description="List of backends with servers")
async def get_backends(
cluster_id: Optional[int] = None,
include_inactive: bool = False,
authorization: str = Header(None),
):
"""
# Get All Backends
Retrieve all backend configurations with their associated servers. Optionally filter by cluster.
## Query Parameters
- **cluster_id** (optional): Filter backends by cluster ID
## Example Request - All Backends
```bash
curl -X GET "{BASE_URL}/api/backends" \\
-H "Authorization: Bearer eyJhbGciOiJIUz..."
```
## Example Request - By Cluster
```bash
curl -X GET "{BASE_URL}/api/backends?cluster_id=1" \\
-H "Authorization: Bearer eyJhbGciOiJIUz..."
```
## Example Response
```json
[
{
"id": 1,
"name": "web-backend",
"balance_method": "roundrobin",
"mode": "http",
"cluster_id": 1,
"health_check_uri": "/health",
"health_check_interval": "5s",
"cookie_name": "SERVERID",
"servers": [
{
"id": 1,
"name": "web-01",
"address": "10.0.1.10",
"port": 8080,
"weight": 100,
"check_enabled": true,
"backup": false,
"status": "UP"
},
{
"id": 2,
"name": "web-02",
"address": "10.0.1.11",
"port": 8080,
"weight": 100,
"check_enabled": true,
"backup": false,
"status": "UP"
}
]
}
]
```
## Load Balancing Methods
- **roundrobin**: Each server used in turn (default)
- **leastconn**: Server with least connections
- **source**: Based on source IP hash
- **uri**: Based on URI hash
- **static-rr**: Static round-robin (no dynamic weight)
- **random**: Random server selection (HAProxy 1.9+)
- **first**: First server with available slots
"""
try:
# R18c audit fix (round 6 #2 — KRITIK info leak): require an
# authenticated caller. Pre-fix the endpoint accepted
# anonymous GETs and returned full backend topology
# (server addresses, ports, ca-file paths, weights). With
# wizard-created rows now in the table, any unauthenticated
# reader could enumerate the platform's complete backend
# inventory including upstream IP addresses behind the
# reverse proxy.
from auth_middleware import get_current_user_from_token
await get_current_user_from_token(authorization)
conn = await get_database_connection()
# Get backends with optional cluster filter
# include_inactive parameter controls whether to show soft-deleted (is_active=FALSE) backends
# Default FALSE: Normal views (BackendServers, FrontendManagement, Dashboard) only show active
# Set TRUE: Apply Management shows all including deleted for pending change visibility
if cluster_id:
if include_inactive:
backends = await conn.fetch("""
SELECT id, name, balance_method, mode, health_check_uri,
health_check_interval, health_check_expected_status, fullconn,
cookie_name, cookie_options, default_server_inter, default_server_fall, default_server_rise,
request_headers, response_headers, options,
is_active, created_at, updated_at, cluster_id, last_config_status,
timeout_connect, timeout_server, timeout_queue
FROM backends WHERE cluster_id = $1 ORDER BY name
""", cluster_id)
else:
backends = await conn.fetch("""
SELECT id, name, balance_method, mode, health_check_uri,
health_check_interval, health_check_expected_status, fullconn,
cookie_name, cookie_options, default_server_inter, default_server_fall, default_server_rise,
request_headers, response_headers, options,
is_active, created_at, updated_at, cluster_id, last_config_status,
timeout_connect, timeout_server, timeout_queue
FROM backends WHERE cluster_id = $1 AND is_active = TRUE ORDER BY name
""", cluster_id)
else:
if include_inactive:
backends = await conn.fetch("""
SELECT id, name, balance_method, mode, health_check_uri,
health_check_interval, health_check_expected_status, fullconn,
cookie_name, cookie_options, default_server_inter, default_server_fall, default_server_rise,
request_headers, response_headers, options,
is_active, created_at, updated_at, cluster_id, last_config_status,
timeout_connect, timeout_server, timeout_queue
FROM backends ORDER BY name
""")
else:
backends = await conn.fetch("""
SELECT id, name, balance_method, mode, health_check_uri,
health_check_interval, health_check_expected_status, fullconn,
cookie_name, cookie_options, default_server_inter, default_server_fall, default_server_rise,
request_headers, response_headers, options,
is_active, created_at, updated_at, cluster_id, last_config_status,
timeout_connect, timeout_server, timeout_queue
FROM backends WHERE is_active = TRUE ORDER BY name
""")
result = []
# Issue #24: servers must honor include_inactive exactly like the backend
# queries above. Pre-fix these sub-queries hardcoded `is_active = TRUE`
# (added in f34a6ee to hide soft-deleted entities), so a server toggled
# OFF (is_active=false) vanished from the UI with no way to reactivate it.
# Default callers (include_inactive=false) keep the is_active filter →
# byte-identical behavior; include_inactive=true now also returns inactive
# (disabled / soft-deleted) servers. last_config_status is selected so the
# frontend can tell DISABLED (re-enableable) from DELETION (pending delete).
server_active_filter = "" if include_inactive else "AND is_active = TRUE"
for backend in backends:
if cluster_id:
servers = await conn.fetch(f"""
SELECT id, server_name, server_address, server_port, weight, maxconn,
check_enabled, check_port, backup_server, ssl_enabled, ssl_verify, ssl_certificate_id,
ssl_sni, ssl_min_ver, ssl_max_ver, ssl_ciphers,
cookie_value, inter, fall, rise,
is_active, cluster_id, last_config_status,
haproxy_status, haproxy_status_updated_at, backend_name
FROM backend_servers
WHERE backend_name = $1 AND cluster_id = $2 {server_active_filter} ORDER BY server_name
""", backend["name"], cluster_id)
else:
servers = await conn.fetch(f"""
SELECT id, server_name, server_address, server_port, weight, maxconn,
check_enabled, check_port, backup_server, ssl_enabled, ssl_verify, ssl_certificate_id,
ssl_sni, ssl_min_ver, ssl_max_ver, ssl_ciphers,
cookie_value, inter, fall, rise,
is_active, cluster_id, last_config_status,
haproxy_status, haproxy_status_updated_at, backend_name
FROM backend_servers
WHERE backend_name = $1 {server_active_filter} ORDER BY server_name
""", backend["name"])
# Prepare server list with real-time HAProxy status from agents
server_list = []
for s in servers:
# Use real-time HAProxy status if available from agent, otherwise fallback
server_status = "UNKNOWN"
status_age_minutes = None
if s.get("haproxy_status") and s.get("haproxy_status_updated_at"):
server_status = s["haproxy_status"]
# Calculate how old the status is
import datetime
now = datetime.datetime.now(datetime.timezone.utc)
updated_at = s["haproxy_status_updated_at"]
if hasattr(updated_at, 'replace'): # Handle timezone-naive datetime
updated_at = updated_at.replace(tzinfo=datetime.timezone.utc)
age_seconds = (now - updated_at).total_seconds()
status_age_minutes = int(age_seconds / 60)
# If status is too old (>5 minutes), mark as stale
if age_seconds > 300: # 5 minutes
server_status = f"{server_status} (stale)"
elif s["cluster_id"]:
# Fallback to HAProxy-based check (but this should be deprecated)
server_status = await check_server_health_via_haproxy(
backend_name=backend["name"],
server_name=s["server_name"],
cluster_id=s["cluster_id"]
)
else:
# Final fallback to TCP check for servers without cluster assignment
server_status = await check_server_health(s["server_address"], s["server_port"])
server_list.append({
"id": s["id"],
"name": s["server_name"],
"server_name": s["server_name"], # CRITICAL FIX: Include server_name field in API response
"address": f"{s['server_address']}:{s['server_port']}",
"weight": s["weight"],
"check_enabled": s["check_enabled"],
"check_port": s.get("check_port"),
"backup_server": s["backup_server"],
"ssl_enabled": s.get("ssl_enabled", False),
"ssl_verify": s.get("ssl_verify"),
"ssl_certificate_id": s.get("ssl_certificate_id"),
# CRITICAL FIX: Include SSL advanced options for backend servers
"ssl_sni": s.get("ssl_sni"),
"ssl_min_ver": s.get("ssl_min_ver"),
"ssl_max_ver": s.get("ssl_max_ver"),
"ssl_ciphers": s.get("ssl_ciphers"),
"cookie_value": s.get("cookie_value"),
"inter": s.get("inter"),
"fall": s.get("fall"),
"rise": s.get("rise"),
"is_active": s["is_active"],
"last_config_status": s.get("last_config_status") or "APPLIED", # Issue #24: lets UI distinguish DISABLED (re-enableable) from DELETION
"status": server_status,
"status_age_minutes": status_age_minutes,
"last_status_update": s.get("haproxy_status_updated_at").isoformat().replace('+00:00', 'Z') if s.get("haproxy_status_updated_at") else None,
"backend_name": s["backend_name"],
"cluster_id": s["cluster_id"] # CRITICAL FIX: Include cluster_id field in API response
})
result.append({
"id": backend["id"],
"name": backend["name"],
"balance_method": backend["balance_method"],
"mode": backend["mode"],
"health_check_uri": backend.get("health_check_uri"),
"health_check_interval": backend.get("health_check_interval"),
"health_check_expected_status": backend.get("health_check_expected_status"),
"fullconn": backend.get("fullconn"),
"cookie_name": backend.get("cookie_name"),
"cookie_options": backend.get("cookie_options"),
"default_server_inter": backend.get("default_server_inter"),
"default_server_fall": backend.get("default_server_fall"),
"default_server_rise": backend.get("default_server_rise"),
"request_headers": backend.get("request_headers"),
"response_headers": backend.get("response_headers"),
"options": backend.get("options"),
"timeout_connect": backend.get("timeout_connect"),
"timeout_server": backend.get("timeout_server"),
"timeout_queue": backend.get("timeout_queue"),
"config_status": backend.get("last_config_status") or "APPLIED",
"last_config_status": backend.get("last_config_status") if backend.get("last_config_status") is not None else "APPLIED",
"is_active": backend.get("is_active", True),
"cluster_id": backend["cluster_id"],
"created_at": backend["created_at"].isoformat().replace('+00:00', 'Z') if backend["created_at"] else None,
"updated_at": backend["updated_at"].isoformat().replace('+00:00', 'Z') if backend["updated_at"] else None,
"servers": server_list
})
# Check for pending configurations by cluster
pending_backend_ids = set()
if result:
cluster_ids = [b["cluster_id"] for b in result if b["cluster_id"]]
if cluster_ids:
try:
# Check for backend-specific and server-specific pending changes
# More robust approach: Check cluster-level pending changes and map to backends
# Get all PENDING versions in these clusters
pending_versions = await conn.fetch("""
SELECT version_name, cluster_id FROM config_versions
WHERE cluster_id = ANY($1) AND status = 'PENDING'
""", cluster_ids)
# Extract backend IDs from version names
# CRITICAL: Validate that backend actually belongs to version's cluster (prevent orphan versions)
for version in pending_versions:
version_name = version['version_name']
version_cluster_id = version['cluster_id']
# Backend changes: backend-{id}-{action}-{timestamp}
if version_name.startswith('backend-'):
parts = version_name.split('-')
if len(parts) >= 2 and parts[1].isdigit():
backend_id = int(parts[1])
# CRITICAL: Verify backend actually belongs to this cluster (prevent ID reuse false positives)
backend_cluster_check = await conn.fetchval("""
SELECT cluster_id FROM backends WHERE id = $1
""", backend_id)
# Only add to pending_backend_ids if backend exists in the version's cluster
# Handle NULL cluster_id (legacy data): NULL == NULL should match
if backend_cluster_check == version_cluster_id:
pending_backend_ids.add(backend_id)
elif backend_cluster_check is None and version_cluster_id is None:
# Both NULL - legacy backend with legacy version
pending_backend_ids.add(backend_id)
else:
logger.warning(f"ORPHAN VERSION: {version_name} in cluster {version_cluster_id} references backend {backend_id} from cluster {backend_cluster_check}")
# Server changes: server-{server_id}-{action}-{timestamp}
elif version_name.startswith('server-'):
parts = version_name.split('-')
if len(parts) >= 2 and parts[1].isdigit():
server_id = int(parts[1])
# Find which backend this server belongs to
# CRITICAL: Also validate server belongs to version's cluster
backend_row = await conn.fetchrow("""
SELECT b.id as backend_id, bs.cluster_id as server_cluster_id
FROM backend_servers bs
JOIN backends b ON bs.backend_name = b.name AND b.cluster_id = bs.cluster_id
WHERE bs.id = $1
""", server_id)
if backend_row and backend_row['server_cluster_id'] == version_cluster_id:
pending_backend_ids.add(backend_row['backend_id'])
elif backend_row:
logger.warning(f"ORPHAN VERSION: {version_name} in cluster {version_cluster_id} references server {server_id} from cluster {backend_row['server_cluster_id']}")
logger.info(f"BACKEND API: Found {len(pending_backend_ids)} backends with pending changes: {pending_backend_ids}")
except Exception as e:
logger.warning(f"BACKEND API: Failed to check pending configs: {e}")
pending_backend_ids = set()
# Add has_pending_config field to each backend (entity-specific)
for backend in result:
# Check if backend has pending changes via:
# 1. Config versions (pending_backend_ids) - version name parsing
# 2. Entity's own last_config_status (for bulk import and other operations)
# 3. Backend is inactive (soft delete) AND pending - NOT if already APPLIED
has_config_version = backend["id"] in pending_backend_ids
has_pending_status = backend.get("last_config_status") == "PENDING"
is_inactive = not backend.get("is_active", True)
is_rejected = backend.get("last_config_status") == "REJECTED"
is_applied = backend.get("last_config_status") == "APPLIED"
# CRITICAL FIX: Inactive backend should only be pending if not already APPLIED/REJECTED
# Problem: Backend with is_active=FALSE and last_config_status='APPLIED' was showing as pending
# Result: Apply/Reject couldn't process it (no PENDING status), but UI kept showing it
# Solution: Inactive backend is only pending if last_config_status is PENDING
is_inactive_and_pending = is_inactive and has_pending_status
# CRITICAL: Exclude REJECTED and APPLIED inactive entities from pending
backend["has_pending_config"] = (has_config_version or has_pending_status or is_inactive_and_pending) and not is_rejected and not (is_inactive and is_applied)
# Enhanced debug logging
if backend['name'] == 'backend7' or has_config_version or has_pending_status:
logger.info(f"BACKEND DEBUG {backend['id']} ({backend['name']}): has_config_version={has_config_version}, has_pending_status={has_pending_status}, is_inactive={is_inactive}, config_status={backend.get('config_status')}, final_has_pending={backend['has_pending_config']}")
else:
logger.debug(f"BACKEND {backend['id']} ({backend['name']}): has_config_version={has_config_version}, has_pending_status={has_pending_status}, is_inactive={is_inactive}, final={backend['has_pending_config']}")
await close_database_connection(conn)
return {"backends": result}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("")
async def create_backend(backend: BackendConfig, authorization: str = Header(None)):
"""Create new backend configuration"""
try:
# Get current user for activity logging and cluster validation
from auth_middleware import get_current_user_from_token, check_user_permission
current_user = await get_current_user_from_token(authorization)
# Check permission for backend create
has_permission = await check_user_permission(current_user["id"], "backends", "create")
if not has_permission:
raise HTTPException(
status_code=403,
detail="Insufficient permissions: backends.create required"
)
conn = await get_database_connection()
# Validate cluster access for multi-cluster security
if backend.cluster_id:
await validate_user_cluster_access(current_user['id'], backend.cluster_id, conn)
# CRITICAL FIX: Check for inactive (soft-deleted) backends and clean them up
# Problem: Backend soft-deleted (is_active=FALSE) but unique constraint still blocks creation
# Solution: If inactive backend exists, hard delete it first (with all related data)
# SAFETY: Check updated_at to avoid race condition with agent config-sync
# Agent sync temporarily marks backends as inactive, we must not delete those!
inactive_backend = await conn.fetchrow("""
SELECT id, name, updated_at FROM backends
WHERE name = $1 AND (cluster_id = $2 OR cluster_id IS NULL) AND is_active = FALSE
AND updated_at < NOW() - INTERVAL '30 seconds'
""", backend.name, backend.cluster_id)
if inactive_backend:
logger.warning(f"BACKEND CREATE: Found stale inactive backend '{backend.name}' (id={inactive_backend['id']}, inactive since {inactive_backend['updated_at']}). Cleaning up before creating new one.")
# Hard delete inactive backend and all related data
# 1. Delete related config versions
await conn.execute("""
DELETE FROM config_versions
WHERE cluster_id = $1
AND (version_name LIKE $2 OR config_content LIKE $3)
""", backend.cluster_id, f"%backend-{inactive_backend['id']}-%", f"%backend {backend.name}%")
# 2. Delete related servers
if backend.cluster_id:
await conn.execute("""
DELETE FROM backend_servers
WHERE backend_name = $1 AND cluster_id = $2
""", backend.name, backend.cluster_id)
else:
await conn.execute("""
DELETE FROM backend_servers
WHERE backend_name = $1 AND cluster_id IS NULL
""", backend.name)
# 3. Hard delete the backend itself
await conn.execute("DELETE FROM backends WHERE id = $1", inactive_backend['id'])
logger.info(f"BACKEND CREATE: Cleaned up inactive backend '{backend.name}' and all related data")
# CRITICAL: Check for reserved names that conflict with common HAProxy listen sections
# Agent preserves existing listen blocks (e.g., 'listen stats') from local config
# Creating backends with these names causes "proxy has same name" errors
reserved_names = {'stats', 'haproxy-stats', 'haproxy_stats', 'monitoring', 'admin', 'health', 'status'}
if backend.name.lower() in reserved_names:
await close_database_connection(conn)
raise HTTPException(
status_code=400,
detail=f"Backend name '{backend.name}' is reserved. It conflicts with common HAProxy "
f"listen sections (e.g., 'listen stats'). Please choose a different name."
)
# DYNAMIC COLLISION CHECK: Check against agents' preserved listen blocks
# Agents report their local listen blocks via config-sync, we check for conflicts here
# NOTE: Wrapped in try-except for backwards compatibility (column may not exist before migration)
if backend.cluster_id:
try:
collision_check = await conn.fetch("""
SELECT a.name as agent_name, a.preserved_listen_blocks
FROM agents a
JOIN haproxy_clusters hc ON hc.pool_id = a.pool_id
WHERE hc.id = $1 AND a.preserved_listen_blocks IS NOT NULL
""", backend.cluster_id)
for agent in collision_check:
listen_blocks = agent['preserved_listen_blocks'] or []
if isinstance(listen_blocks, str):
try:
listen_blocks = json.loads(listen_blocks)
except:
listen_blocks = []
# Case-insensitive comparison (HAProxy proxy names are case-insensitive)
listen_blocks_lower = [lb.lower() for lb in listen_blocks if isinstance(lb, str)]
if backend.name.lower() in listen_blocks_lower:
await close_database_connection(conn)
raise HTTPException(
status_code=400,
detail=f"Backend name '{backend.name}' conflicts with an existing 'listen {backend.name}' "
f"block on agent '{agent['agent_name']}'. Either rename this backend or remove the "
f"listen block from the agent's local HAProxy configuration."
)
except HTTPException:
raise # Re-raise HTTP exceptions (collision detected)
except Exception as e:
# Column may not exist yet (before migration) - skip check gracefully
logger.debug(f"Dynamic collision check skipped: {e}")
# Check if backend name already exists in the same cluster (only active backends)
existing = await conn.fetchrow("""
SELECT id FROM backends
WHERE name = $1 AND (cluster_id = $2 OR cluster_id IS NULL) AND is_active = TRUE
""", backend.name, backend.cluster_id)
if existing:
await close_database_connection(conn)
raise HTTPException(status_code=400, detail=f"Backend '{backend.name}' already exists")
# Filter out 'option httpchk' from options field (should use health_check_uri instead)
filtered_options = filter_httpchk_from_options(backend.options)
if filtered_options != backend.options and backend.options:
logger.info(f"Backend '{backend.name}': Filtered 'option httpchk' from options field. Use Health Check URI field instead.")
# Insert new backend
backend_id = await conn.fetchval("""
INSERT INTO backends (name, balance_method, mode, health_check_uri, health_check_interval,
health_check_expected_status, fullconn, cookie_name, cookie_options,
default_server_inter, default_server_fall, default_server_rise,
request_headers, response_headers, options,
timeout_connect, timeout_server, timeout_queue, cluster_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19) RETURNING id
""", backend.name, backend.balance_method, backend.mode,
backend.health_check_uri, backend.health_check_interval,
backend.health_check_expected_status, backend.fullconn, backend.cookie_name, backend.cookie_options,
backend.default_server_inter, backend.default_server_fall, backend.default_server_rise,
backend.request_headers, backend.response_headers, filtered_options,
backend.timeout_connect, backend.timeout_server, backend.timeout_queue, backend.cluster_id)
# If cluster_id provided, create new config version for agents
sync_results = []
has_servers = False # Initialize before try block for proper scope
if backend.cluster_id:
try:
# CRITICAL FIX: Check if backend has servers before creating config version
# This helps users understand why config is empty
has_servers = await conn.fetchval("""
SELECT EXISTS(
SELECT 1 FROM backend_servers
WHERE backend_name = $1 AND cluster_id = $2 AND is_active = TRUE
)
""", backend.name, backend.cluster_id)
# Generate new HAProxy config
config_content = await generate_haproxy_config_for_cluster(backend.cluster_id)
# Create new config version
config_hash = hashlib.sha256(config_content.encode()).hexdigest()
version_name = f"backend-{backend_id}-create-{int(time.time())}"
# Create description based on whether backend has servers
if not has_servers:
version_description = f"Backend '{backend.name}' created without servers. Will be deployed to HAProxy after adding servers."
else:
version_description = f"Backend '{backend.name}' created with servers."
# Get system admin user ID for created_by (fresh DB has admin with ID 1)
admin_user_id = await conn.fetchval("SELECT id FROM users WHERE username = 'admin' LIMIT 1") or 1
# Try with status field first, fallback to old behavior if field doesn't exist
try:
config_version_id = await conn.fetchval("""
INSERT INTO config_versions
(cluster_id, version_name, config_content, checksum, created_by, is_active, status, description)
VALUES ($1, $2, $3, $4, $5, FALSE, 'PENDING', $6)
RETURNING id
""", backend.cluster_id, version_name, config_content, config_hash, admin_user_id, version_description)
logger.info(f"APPLY WORKFLOW: Created PENDING config version {version_name} for cluster {backend.cluster_id} (has_servers={has_servers})")
# Mark entity as PENDING for UI
await conn.execute("UPDATE backends SET last_config_status = 'PENDING' WHERE id = $1", backend_id)
# Don't notify agents yet - wait for manual Apply
if has_servers:
sync_results = [{'node': 'pending', 'success': True, 'version': version_name, 'status': 'PENDING', 'message': 'Backend created. Click Apply to activate.'}]
else:
sync_results = [{'node': 'pending', 'success': True, 'version': version_name, 'status': 'PENDING', 'message': 'Backend created without servers. Add servers then click Apply to deploy.'}]
except Exception as status_error:
logger.warning(f"FALLBACK: Status field not available, using old immediate-apply behavior: {status_error}")
# Fallback to old behavior without status field
config_version_id = await conn.fetchval("""
INSERT INTO config_versions
(cluster_id, version_name, config_content, checksum, created_by, is_active, description)
VALUES ($1, $2, $3, $4, $5, TRUE, $6)
RETURNING id
""", backend.cluster_id, version_name, config_content, config_hash, admin_user_id, version_description)
# Deactivate previous versions for this cluster
await conn.execute("""
UPDATE config_versions
SET is_active = FALSE
WHERE cluster_id = $1 AND id != $2
""", backend.cluster_id, config_version_id)
logger.info(f"FALLBACK: Using immediate-apply, agents notified")
except Exception as e:
logger.error(f"Cluster config update failed for backend {backend.name}: {e}")
# Still return success for database save, but with sync warning
sync_results = [{'node': 'cluster', 'success': False, 'error': str(e)}]
has_servers = False # Set to False if config generation fails
await close_database_connection(conn)
# CRITICAL FIX: Use has_servers from initial check (backend just created, no servers yet)
# No need for second DB query - backend is brand new, servers added in separate endpoint
has_servers_final = has_servers if backend.cluster_id else False
# Create user-friendly message
if has_servers_final:
message = f"Backend '{backend.name}' created successfully with servers"
else:
message = f"Backend '{backend.name}' created successfully. ⚠️ Add servers and click Apply to deploy to HAProxy."
return {
"message": message,
"id": backend_id,
"backend": backend.dict(),
"sync_results": sync_results,
"has_servers": has_servers_final
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/{backend_id}/servers")
async def add_server_to_backend(
backend_id: int,
server: ServerConfig,
authorization: str = Header(None),
):
"""Add server to backend.
Bulgu #76 (round-22 audit) — pre-fix this handler had NO
authentication at all (no `authorization` Header, no call
to `get_current_user_from_token`, no `check_user_permission`).
Anyone who could reach the API surface could POST a server
into any backend in any cluster — a complete write-access
bypass on the data plane. The sibling DELETE / PUT / toggle
handlers all required authentication, so the omission was
almost certainly an oversight rather than intentional.
"""
try:
from auth_middleware import get_current_user_from_token, check_user_permission
current_user = await get_current_user_from_token(authorization)
has_permission = await check_user_permission(current_user["id"], "backends", "update")
if not has_permission:
raise HTTPException(
status_code=403,
detail="Insufficient permissions: backends.update required",
)
conn = await get_database_connection()
# Get backend name and cluster_id
backend = await conn.fetchrow("SELECT name, cluster_id FROM backends WHERE id = $1", backend_id)
if not backend:
await close_database_connection(conn)
raise HTTPException(status_code=404, detail="Backend not found")
# Multi-tenancy: validate the operator has access to this cluster.
if backend['cluster_id']:
await validate_user_cluster_access(current_user['id'], backend['cluster_id'], conn)
# Check if server name already exists in this backend within the same cluster (only active servers)
existing = await conn.fetchrow("""
SELECT id FROM backend_servers
WHERE backend_name = $1 AND server_name = $2 AND cluster_id = $3 AND is_active = TRUE
""", backend["name"], server.server_name, backend["cluster_id"])
if existing:
await close_database_connection(conn)
raise HTTPException(status_code=400, detail=f"Server '{server.server_name}' already exists in backend '{backend['name']}' within this cluster")
# Add server with cluster_id from backend (including SSL advanced options)
server_id = await conn.fetchval("""
INSERT INTO backend_servers
(backend_id, backend_name, server_name, server_address, server_port, weight,
maxconn, check_enabled, check_port, backup_server, ssl_enabled, ssl_verify, ssl_certificate_id,
ssl_sni, ssl_min_ver, ssl_max_ver, ssl_ciphers,
cookie_value, inter, fall, rise, cluster_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22) RETURNING id
""", backend_id, backend["name"], server.server_name, server.server_address, server.server_port, server.weight,
server.max_connections, server.check_enabled, server.check_port, server.backup_server,
server.ssl_enabled, server.ssl_verify, server.ssl_certificate_id,
server.ssl_sni, server.ssl_min_ver, server.ssl_max_ver, server.ssl_ciphers,
server.cookie_value, server.inter, server.fall, server.rise,
backend["cluster_id"])
# If backend has cluster_id, create new config version for agents
sync_results = []
if backend["cluster_id"]:
try:
# Generate new HAProxy config
config_content = await generate_haproxy_config_for_cluster(backend["cluster_id"])
# Create new config version
config_hash = hashlib.sha256(config_content.encode()).hexdigest()
version_name = f"server-{server_id}-add-{int(time.time())}"
# Get system admin user ID for created_by (fresh DB has admin with ID 1)
admin_user_id = await conn.fetchval("SELECT id FROM users WHERE username = 'admin' LIMIT 1") or 1
# Try with status field first, fallback to old behavior if field doesn't exist
try:
config_version_id = await conn.fetchval("""
INSERT INTO config_versions
(cluster_id, version_name, config_content, checksum, created_by, is_active, status)
VALUES ($1, $2, $3, $4, $5, FALSE, 'PENDING')
RETURNING id
""", backend["cluster_id"], version_name, config_content, config_hash, admin_user_id)
logger.info(f"APPLY WORKFLOW: Created PENDING config version {version_name} for cluster {backend['cluster_id']}")
# Mark parent backend as PENDING for Apply Management
await conn.execute("UPDATE backends SET last_config_status = 'PENDING' WHERE id = $1", backend_id)
logger.info(f"BACKEND SYNC: Marked backend {backend_id} as PENDING due to server addition")
# Don't notify agents yet - wait for manual Apply
sync_results = [{'node': 'pending', 'success': True, 'version': version_name, 'status': 'PENDING', 'message': 'Server changes created. Click Apply to activate.'}]
except Exception as status_error:
logger.warning(f"FALLBACK: Status field not available, using old immediate-apply behavior: {status_error}")
# Fallback to old behavior without status field
config_version_id = await conn.fetchval("""
INSERT INTO config_versions
(cluster_id, version_name, config_content, checksum, created_by, is_active)
VALUES ($1, $2, $3, $4, $5, TRUE)
RETURNING id
""", backend["cluster_id"], version_name, config_content, config_hash, admin_user_id)
# Deactivate previous versions for this cluster
await conn.execute("""
UPDATE config_versions
SET is_active = FALSE
WHERE cluster_id = $1 AND id != $2
""", backend["cluster_id"], config_version_id)
logger.info(f"Created new config version {version_name} for cluster {backend['cluster_id']}")
except Exception as e:
logger.error(f"Cluster config update failed for server {server.server_name}: {e}")
# Still return success for database save, but with sync warning
sync_results = [{'node': 'cluster', 'success': False, 'error': str(e)}]
await close_database_connection(conn)
return {
"message": f"Server '{server.server_name}' added to backend '{backend['name']}' successfully",
"id": server_id,
"server": server.dict(),
"sync_results": sync_results
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{backend_id}/config-versions")
async def get_backend_config_versions(backend_id: int, authorization: str = Header(None)):
"""Get config version history for a specific backend"""
try:
# Verify user authentication
from auth_middleware import get_current_user_from_token
current_user = await get_current_user_from_token(authorization)
conn = await get_database_connection()
# Get backend info first
backend_info = await conn.fetchrow("""
SELECT b.id, b.name, c.name as cluster_name, b.cluster_id
FROM backends b
LEFT JOIN haproxy_clusters c ON b.cluster_id = c.id
WHERE b.id = $1
""", backend_id)
if not backend_info:
await close_database_connection(conn)
raise HTTPException(status_code=404, detail="Backend not found")
# Get all APPLIED config versions that are related to this backend (including server changes)
# For server changes, we need to check if the server belongs to this backend
versions = await conn.fetch("""
SELECT cv.id, cv.version_name, cv.description, cv.status, cv.is_active,
cv.created_at, cv.file_size, cv.checksum,
u.username as created_by_username
FROM config_versions cv
LEFT JOIN users u ON cv.created_by = u.id
WHERE cv.cluster_id = $1 AND cv.status = 'APPLIED'
AND (cv.version_name ~ $2 OR
(cv.version_name ~ '^server-[0-9]+-' AND
EXISTS (SELECT 1 FROM backend_servers bs
JOIN backends b ON bs.backend_name = b.name
WHERE b.id = $3 AND bs.id = SUBSTRING(cv.version_name FROM 'server-([0-9]+)-')::int)))
ORDER BY cv.created_at DESC
""", backend_info['cluster_id'], f'^backend-{backend_id}-', backend_id)
await close_database_connection(conn)
# Format the response
formatted_versions = []
for version in versions:
version_type = "Backend"
if "server-" in version["version_name"]:
version_type = "Backend Server"
formatted_versions.append({
"id": version["id"],
"version_name": version["version_name"],
"description": version["description"] or f"{version_type} configuration update",
"type": version_type,
"status": version["status"],
"is_active": version["is_active"],
"created_at": version["created_at"].isoformat().replace('+00:00', 'Z') if version["created_at"] else None,
"created_by": version["created_by_username"] or "System",
"file_size": version["file_size"],
"checksum": version["checksum"][:8] + "..." if version["checksum"] else "No checksum"
})
return {
"versions": formatted_versions,
"entity_info": {
"entityName": backend_info["name"],
"clusterName": backend_info["cluster_name"] or "No Cluster",
"clusterId": backend_info["cluster_id"]
}
}
except Exception as e:
logger.error(f"Error fetching backend config versions: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.put("/{backend_id}")
async def update_backend(backend_id: int, backend_update: BackendConfigUpdate, request: Request, authorization: str = Header(None)):
"""Update an existing HAProxy backend and its servers"""
try:
from auth_middleware import get_current_user_from_token, check_user_permission
current_user = await get_current_user_from_token(authorization)
# Check permission for backend update
has_permission = await check_user_permission(current_user["id"], "backends", "update")
if not has_permission:
raise HTTPException(
status_code=403,
detail="Insufficient permissions: backends.update required"
)
conn = await get_database_connection()
# Check if backend exists
existing_backend = await conn.fetchrow("SELECT * FROM backends WHERE id = $1", backend_id)
if not existing_backend:
await close_database_connection(conn)
raise HTTPException(status_code=404, detail="Backend not found")
cluster_id = existing_backend['cluster_id']
# Validate cluster access for multi-cluster security
if cluster_id:
await validate_user_cluster_access(current_user['id'], cluster_id, conn)
# Merge existing data with update request
update_data = backend_update.dict(exclude_unset=True)
updated_backend_data = {**existing_backend, **update_data}
# Build the update query dynamically
update_fields = []
update_values = []
param_idx = 1
# Check if backend name is being changed
old_backend_name = existing_backend['name']
new_backend_name = update_data.get('name', old_backend_name)
backend_name_changed = old_backend_name != new_backend_name
# CRITICAL: Validate new name if backend is being renamed
if backend_name_changed:
# Check for reserved names
reserved_names = {'stats', 'haproxy-stats', 'haproxy_stats', 'monitoring', 'admin', 'health', 'status'}
if new_backend_name.lower() in reserved_names:
await close_database_connection(conn)
raise HTTPException(
status_code=400,
detail=f"Backend name '{new_backend_name}' is reserved. It conflicts with common HAProxy "
f"listen sections (e.g., 'listen stats'). Please choose a different name."
)
# Check for collision with agent listen blocks
# NOTE: Wrapped in try-except for backwards compatibility
if cluster_id:
try:
collision_check = await conn.fetch("""
SELECT a.name as agent_name, a.preserved_listen_blocks
FROM agents a
JOIN haproxy_clusters hc ON hc.pool_id = a.pool_id
WHERE hc.id = $1 AND a.preserved_listen_blocks IS NOT NULL
""", cluster_id)
for agent in collision_check:
listen_blocks = agent['preserved_listen_blocks'] or []
if isinstance(listen_blocks, str):
try:
listen_blocks = json.loads(listen_blocks)
except:
listen_blocks = []
# Case-insensitive comparison (HAProxy proxy names are case-insensitive)
listen_blocks_lower = [lb.lower() for lb in listen_blocks if isinstance(lb, str)]
if new_backend_name.lower() in listen_blocks_lower:
await close_database_connection(conn)
raise HTTPException(
status_code=400,
detail=f"Backend name '{new_backend_name}' conflicts with an existing 'listen' block "
f"on agent '{agent['agent_name']}'. Choose a different name."
)
except HTTPException:
raise # Re-raise HTTP exceptions
except Exception as e:
logger.debug(f"Dynamic collision check skipped on update: {e}")
# Check if new name already exists
name_exists = await conn.fetchrow(
"SELECT id FROM backends WHERE name = $1 AND id != $2",
new_backend_name, backend_id
)
if name_exists:
await close_database_connection(conn)
raise HTTPException(status_code=400, detail=f"Backend name '{new_backend_name}' already exists")
# Filter out 'option httpchk' from options field if present (should use health_check_uri instead)
if 'options' in update_data and update_data['options']:
filtered_options = filter_httpchk_from_options(update_data['options'])
if filtered_options != update_data['options']:
logger.info(f"Backend '{old_backend_name}': Filtered 'option httpchk' from options field. Use Health Check URI field instead.")
update_data['options'] = filtered_options
for field, value in update_data.items():
if field in ['name', 'balance_method', 'mode', 'health_check_uri', 'health_check_interval',
'health_check_expected_status', 'fullconn', 'cookie_name', 'cookie_options',
'default_server_inter', 'default_server_fall', 'default_server_rise',
'request_headers', 'response_headers', 'options', 'timeout_connect', 'timeout_server', 'timeout_queue']:
update_fields.append(f"{field} = ${param_idx}")
update_values.append(value)
param_idx += 1
if not update_fields:
# Nothing to update besides servers, which are handled separately
# Still, we should regenerate config if server list is being updated
pass
else:
update_fields.append("updated_at = CURRENT_TIMESTAMP")
query = f"UPDATE backends SET {', '.join(update_fields)} WHERE id = ${param_idx}"
update_values.append(backend_id)
await conn.execute(query, *update_values)
# CRITICAL FIX: Update server backend_name references if backend name changed
if backend_name_changed:
logger.info(f"BACKEND UPDATE: Backend name changed from '{old_backend_name}' to '{new_backend_name}', updating server references")
# Bulgu #73 (round-22 audit) — cluster_id filter was
# MISSING pre-fix. The `backends` table allows the
# same name in different clusters (the unique key is
# `(cluster_id, name)`), so the un-scoped UPDATE
# would rewrite `backend_servers.backend_name` ACROSS
# CLUSTERS, leaving the OTHER cluster's backend
# orphaned (its servers now point at the new name on
# this cluster). Multi-tenant data-pollution at the
# storage layer. Scope to the rename's home cluster.
if cluster_id is not None:
await conn.execute("""
UPDATE backend_servers
SET backend_name = $1, updated_at = CURRENT_TIMESTAMP
WHERE backend_name = $2 AND cluster_id = $3
""", new_backend_name, old_backend_name, cluster_id)
else:
# Legacy cluster_id=NULL rows
await conn.execute("""
UPDATE backend_servers
SET backend_name = $1, updated_at = CURRENT_TIMESTAMP
WHERE backend_name = $2 AND cluster_id IS NULL
""", new_backend_name, old_backend_name)
if cluster_id is not None:
updated_servers_count = await conn.fetchval("""
SELECT COUNT(*) FROM backend_servers
WHERE backend_name = $1 AND cluster_id = $2
""", new_backend_name, cluster_id)
else:
updated_servers_count = await conn.fetchval("""
SELECT COUNT(*) FROM backend_servers
WHERE backend_name = $1 AND cluster_id IS NULL
""", new_backend_name)
logger.info(f"BACKEND UPDATE: Updated {updated_servers_count} server references to new backend name")
# CRITICAL FIX: Update frontend default_backend references if backend name changed
logger.info(f"FRONTEND UPDATE: Updating frontend default_backend references from '{old_backend_name}' to '{new_backend_name}'")
if cluster_id is not None:
await conn.execute("""
UPDATE frontends
SET default_backend = $1, last_config_status = 'PENDING', updated_at = CURRENT_TIMESTAMP
WHERE default_backend = $2 AND cluster_id = $3
""", new_backend_name, old_backend_name, cluster_id)
else:
await conn.execute("""
UPDATE frontends
SET default_backend = $1, last_config_status = 'PENDING', updated_at = CURRENT_TIMESTAMP
WHERE default_backend = $2 AND cluster_id IS NULL
""", new_backend_name, old_backend_name)
if cluster_id is not None:
updated_frontends_count = await conn.fetchval("""
SELECT COUNT(*) FROM frontends
WHERE default_backend = $1 AND cluster_id = $2
""", new_backend_name, cluster_id)
else:
updated_frontends_count = await conn.fetchval("""
SELECT COUNT(*) FROM frontends
WHERE default_backend = $1 AND cluster_id IS NULL
""", new_backend_name)
logger.info(f"FRONTEND UPDATE: Updated {updated_frontends_count} frontend default_backend references to new backend name")
# Bulgu #72 (round-22 audit) — cascade the rename
# into every frontend's `use_backend_rules` JSONB so
# `use_backend <old_name> if <cond>` becomes
# `use_backend <new_name> if <cond>`. Pre-fix the
# rename only touched `default_backend` and
# `backend_servers`; the routing rules silently
# broke because they still referenced the disappeared
# backend name. The agent's `haproxy -c` would then
# either fail the reload (`'no such backend'`) or —
# if a `default_backend` was also configured — emit
# traffic to the default and the operator would see
# 503 / wrong-app responses without an obvious
# control-plane cause.
#
# ACL rules are NOT cascaded — they don't reference
# backend names (they reference path/host patterns),
# and even if an ACL definition shared a backend's
# name as a substring, that was coincidence, not a
# contract.
if cluster_id is not None:
frontends_with_use_backend = await conn.fetch("""
SELECT id, name, use_backend_rules
FROM frontends
WHERE cluster_id = $1 AND is_active = TRUE
""", cluster_id)
else:
frontends_with_use_backend = await conn.fetch("""
SELECT id, name, use_backend_rules
FROM frontends
WHERE cluster_id IS NULL AND is_active = TRUE
""")
rename_count = 0
for fe in frontends_with_use_backend:
raw_rules = fe['use_backend_rules'] if fe['use_backend_rules'] else []
# asyncpg JSONB → already decoded; handle the
# legacy string-shaped column defensively.
if isinstance(raw_rules, str):
try:
raw_rules = json.loads(raw_rules)
except (TypeError, ValueError):
raw_rules = []
if not isinstance(raw_rules, list):
continue
renamed = [
_rename_use_backend_target(r, old_backend_name, new_backend_name)
for r in raw_rules
]
if renamed != raw_rules:
await conn.execute("""
UPDATE frontends
SET use_backend_rules = $1,
last_config_status = 'PENDING',
updated_at = CURRENT_TIMESTAMP
WHERE id = $2
""", json.dumps(renamed), fe['id'])
rename_count += 1
logger.info(
f"BACKEND RENAME: cascaded {old_backend_name!r}"
f"{new_backend_name!r} in frontend"
f" {fe['name']!r} use_backend_rules"
)
if rename_count:
logger.info(
f"BACKEND RENAME: updated use_backend_rules in"
f" {rename_count} frontend(s)"
)
async with conn.transaction():
# Update main backend properties
# await conn.execute("""
# UPDATE backends SET
# name = $1,
# balance_method = $2,
# mode = $3,
# health_check_uri = $4,
# health_check_interval = $5,
# timeout_connect = $6,
# timeout_server = $7,
# timeout_queue = $8,
# updated_at = CURRENT_TIMESTAMP
# WHERE id = $9
# """, updated_backend_data.get('name'), updated_backend_data.get('balance_method'),
# updated_backend_data.get('mode'), updated_backend_data.get('health_check_uri'),
# updated_backend_data.get('health_check_interval'),
# updated_backend_data.get('timeout_connect'), updated_backend_data.get('timeout_server'),
# updated_backend_data.get('timeout_queue'),
# backend_id)
# Mark PENDING for UI before generating config
await conn.execute("UPDATE backends SET last_config_status = 'PENDING' WHERE id = $1", backend_id)
# 🆕 PHASE 2: Create entity snapshot for rollback
from utils.entity_snapshot import save_entity_snapshot
entity_snapshot_metadata = await save_entity_snapshot(
conn=conn,
entity_type="backend",
entity_id=backend_id,
old_values=existing_backend, # Full record from line 763
new_values=update_data, # Only updated fields
operation="UPDATE"
)
# Get pre-apply snapshot (for diff viewer)
old_config = await conn.fetchval("""
SELECT config_content FROM config_versions
WHERE cluster_id = $1 AND status = 'APPLIED' AND is_active = TRUE
ORDER BY created_at DESC LIMIT 1
""", cluster_id)
# Merge metadata: pre_apply_snapshot + entity_snapshot
metadata = {
"pre_apply_snapshot": old_config or "", # For diff viewer
**entity_snapshot_metadata # For rollback
}
# Create a PENDING config version
config_content = await generate_haproxy_config_for_cluster(cluster_id, conn=conn)
config_hash = hashlib.sha256(config_content.encode()).hexdigest()
version_name = f"backend-{backend_id}-update-{int(time.time())}"
await conn.execute("""
INSERT INTO config_versions (cluster_id, version_name, config_content, checksum, created_by, status, metadata)
VALUES ($1, $2, $3, $4, $5, 'PENDING', $6)
""", cluster_id, version_name, config_content, config_hash, current_user['id'],
json.dumps(metadata) if metadata else None)
await close_database_connection(conn)
# Log activity with comprehensive details
await log_user_activity(
user_id=current_user["id"],
action='update',
resource_type='backend',
resource_id=str(backend_id),
details={
'backend_name': updated_backend_data.get('name'),
'balance_method': updated_backend_data.get('balance_method'),
'cluster_id': updated_backend_data.get('cluster_id'),
'mode': updated_backend_data.get('mode'),
'health_check_uri': updated_backend_data.get('health_check_uri')
},
ip_address=str(request.client.host) if request.client else None,
user_agent=request.headers.get('user-agent')
)
return {"message": f"Backend '{updated_backend_data.get('name')}' updated successfully. Please apply changes."}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to update backend: {e}")
raise HTTPException(status_code=500, detail=f"Failed to update backend: {str(e)}")
@router.delete("/{backend_id}", summary="Delete Backend", response_description="Backend deleted successfully")
async def delete_backend(backend_id: int, authorization: str = Header(None)):
"""
# Delete Backend
Delete a backend configuration and all its servers.
## Path Parameters
- **backend_id**: Backend ID to delete
## Important Notes
- All associated servers will be deleted
- Frontends using this backend will need to be updated
- Change will be applied to agents on next sync
## Example Request
```bash
curl -X DELETE "{BASE_URL}/api/backends/1" \\
-H "Authorization: Bearer eyJhbGciOiJIUz..."
```
## Example Response
```json
{
"message": "Backend 'api-backend' and its servers deleted successfully"
}
```
## Error Responses
- **403**: Insufficient permissions or backend is in use by frontends
- **404**: Backend not found
- **500**: Server error
"""
try:
from auth_middleware import get_current_user_from_token, check_user_permission
current_user = await get_current_user_from_token(authorization)
# Check permission for backend delete
has_permission = await check_user_permission(current_user["id"], "backends", "delete")
if not has_permission:
raise HTTPException(
status_code=403,
detail="Insufficient permissions: backends.delete required"
)
conn = await get_database_connection()
# Check if backend exists (including inactive ones)
backend = await conn.fetchrow("SELECT name, cluster_id, is_active FROM backends WHERE id = $1", backend_id)
if not backend:
await close_database_connection(conn)
raise HTTPException(status_code=404, detail="Backend not found")
# Validate cluster access for multi-cluster security
if backend['cluster_id']:
await validate_user_cluster_access(current_user['id'], backend['cluster_id'], conn)
# Before deleting backend, handle dependencies properly
backend_name = backend["name"]
cluster_id = backend["cluster_id"]
is_already_inactive = not backend["is_active"]
# CRITICAL FIX: If backend is already inactive (soft-deleted), do HARD DELETE
# Problem: Soft-deleted backends remain in DB and block unique constraint
# Solution: Hard delete inactive backends and all related data
if is_already_inactive:
logger.warning(f"BACKEND DELETE: Backend '{backend_name}' (id={backend_id}) is already inactive. Performing HARD DELETE.")
# Hard delete: Remove all traces from database
# 1. Delete related config versions
await conn.execute("""
DELETE FROM config_versions
WHERE cluster_id = $1
AND (version_name LIKE $2 OR config_content LIKE $3)
""", cluster_id, f"%backend-{backend_id}-%", f"%backend {backend_name}%")
# 2. Delete related servers (HARD DELETE)
if cluster_id is not None:
await conn.execute("""
DELETE FROM backend_servers
WHERE backend_name = $1 AND cluster_id = $2
""", backend_name, cluster_id)
else:
await conn.execute("""
DELETE FROM backend_servers
WHERE backend_name = $1 AND cluster_id IS NULL
""", backend_name)
# 3. Delete the backend itself (HARD DELETE)
await conn.execute("DELETE FROM backends WHERE id = $1", backend_id)
await close_database_connection(conn)
logger.info(f"BACKEND DELETE: Hard deleted inactive backend '{backend_name}' and all related data")
return {"message": f"Inactive backend '{backend_name}' has been permanently deleted from database"}
# Normal flow for ACTIVE backends: Soft delete
# 1. Soft delete all servers belonging to this backend (mark inactive)
# CRITICAL: Include cluster_id to prevent affecting other clusters with same backend name
# Handle NULL cluster_id (legacy data) - must use IS NULL check
if cluster_id is not None:
await conn.execute("""
UPDATE backend_servers
SET is_active = FALSE, updated_at = CURRENT_TIMESTAMP
WHERE backend_name = $1 AND cluster_id = $2
""", backend_name, cluster_id)
else:
await conn.execute("""
UPDATE backend_servers
SET is_active = FALSE, updated_at = CURRENT_TIMESTAMP
WHERE backend_name = $1 AND cluster_id IS NULL
""", backend_name)
# 2. Update frontends that use this backend (set default_backend to NULL)
# CRITICAL: Include cluster_id to prevent affecting other clusters
# Handle NULL cluster_id (legacy data)
if cluster_id is not None:
await conn.execute("""
UPDATE frontends
SET default_backend = NULL, last_config_status = 'PENDING', updated_at = CURRENT_TIMESTAMP
WHERE default_backend = $1 AND cluster_id = $2
""", backend_name, cluster_id)
else:
await conn.execute("""
UPDATE frontends
SET default_backend = NULL, last_config_status = 'PENDING', updated_at = CURRENT_TIMESTAMP
WHERE default_backend = $1 AND cluster_id IS NULL
""", backend_name)
# 2b. CRITICAL FIX: Remove use_backend rules referencing deleted backend from frontends
# Frontend may have ACL rules like "use_backend Apmserver if Apmserver"
# These must be removed to prevent HAProxy validation errors
# Handle NULL cluster_id (legacy data)
if cluster_id is not None:
frontends_with_acl_refs = await conn.fetch("""
SELECT id, name, use_backend_rules, acl_rules
FROM frontends
WHERE cluster_id = $1 AND is_active = TRUE
""", cluster_id)
else:
frontends_with_acl_refs = await conn.fetch("""
SELECT id, name, use_backend_rules, acl_rules
FROM frontends
WHERE cluster_id IS NULL AND is_active = TRUE
""")
for frontend in frontends_with_acl_refs:
# Parse use_backend_rules (JSONB array)
use_backend_rules = frontend['use_backend_rules'] if frontend['use_backend_rules'] else []
acl_rules = frontend['acl_rules'] if frontend['acl_rules'] else []
# Bulgu #71 (round-22 audit) — drop only the use_backend
# entries whose FIRST TOKEN matches the deleted backend
# exactly. Pre-fix the naive `backend_name not in rule`
# substring filter wiped `use_backend api-v2 ...` when
# "api" was deleted (and similarly for any `*api*` /
# `api*` backend pair). The `acl_rules` list is left
# untouched on purpose — ACL definitions are reusable
# predicates (e.g. `is_api hdr(host) -i api.example.com`)
# and have no semantic dependency on the deleted backend
# even when their name happens to share a substring.
filtered_use_backend = [
rule for rule in use_backend_rules
if _extract_use_backend_target(rule) != backend_name
]
filtered_acl = list(acl_rules)
# Update frontend if rules were removed
if len(filtered_use_backend) != len(use_backend_rules):
import json
await conn.execute("""
UPDATE frontends
SET use_backend_rules = $1, acl_rules = $2,
last_config_status = 'PENDING', updated_at = CURRENT_TIMESTAMP
WHERE id = $3
""", json.dumps(filtered_use_backend), json.dumps(filtered_acl), frontend['id'])
logger.info(
f"BACKEND DELETE: Cleaned use_backend rules for frontend "
f"'{frontend['name']}' (removed {len(use_backend_rules) - len(filtered_use_backend)} "
f"rule(s) targeting {backend_name!r}, "
f"{len(filtered_acl)} acl_rules preserved)"
)
# 3. Soft delete the backend (mark as inactive and set PENDING)
await conn.execute("""
UPDATE backends
SET is_active = FALSE, last_config_status = 'PENDING', updated_at = CURRENT_TIMESTAMP
WHERE id = $1
""", backend_id)
# Prepare user-friendly success message
success_message = f"Backend '{backend_name}' and its associated servers have been deleted. Any frontends using this backend have been updated."
await close_database_connection(conn)
# Create new config version for Apply Changes workflow
sync_results = []
if cluster_id:
try:
from services.haproxy_config import create_pending_config_version
version_result = await create_pending_config_version(
cluster_id=cluster_id,
change_description=f"Delete backend: {backend_name}",
user_id=current_user["id"],
entity_type="backend",
entity_id=backend_id
)
logger.info(f"BACKEND DELETE: Created pending config version {version_result['version']} for cluster {cluster_id}")
except Exception as e:
logger.error(f"BACKEND DELETE: Failed to create config version for cluster {cluster_id}: {e}")
# Log activity
await log_user_activity(
user_id=current_user["id"],
action='delete',
resource_type='backend',
resource_id=str(backend_id),
details={'backend_name': backend['name']}
)
return {"message": success_message}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to delete backend: {e}")
raise HTTPException(status_code=500, detail=f"Failed to delete backend: {str(e)}")
@router.delete("/servers/{server_id}")
async def delete_server(server_id: int, request: Request, authorization: str = Header(None)):
"""Delete a server from backend.
Bulgu #77 (round-22 audit) — pre-fix this handler only
authenticated the caller (`get_current_user_from_token`)
and did NOT check `backends.update` permission, so any
logged-in user — including read-only viewers — could
delete servers. The sibling backend-level DELETE / PUT /
POST handlers all enforced `backends.delete` /
`backends.update`; servers are part of the same RBAC
surface and were missing the same gate.
Bulgu #79 (round-22 audit) — also missing cluster
access validation. An operator scoped to cluster 1 could
delete a server in cluster 2 if they had `backends.update`
permission globally.
"""
try:
from auth_middleware import get_current_user_from_token, check_user_permission
current_user = await get_current_user_from_token(authorization)
has_permission = await check_user_permission(current_user["id"], "backends", "update")
if not has_permission:
raise HTTPException(
status_code=403,
detail="Insufficient permissions: backends.update required",
)
conn = await get_database_connection()
# Server's cluster is fetched right below; we validate
# access AFTER the fetch so the 404 path takes priority
# over the 403 (mirrors existing patterns in this file).
# Get server info before deletion
server = await conn.fetchrow("""
SELECT id, server_name, backend_name, ip_address, port, cluster_id, is_active
FROM backend_servers WHERE id = $1
""", server_id)
if not server:
await close_database_connection(conn)
raise HTTPException(status_code=404, detail="Server not found")
cluster_id = server['cluster_id']
backend_name = server['backend_name']
server_name = server['server_name']
# Bulgu #79 — validate the operator has access to the
# server's owning cluster BEFORE accepting the delete.
if cluster_id:
await validate_user_cluster_access(current_user['id'], cluster_id, conn)
# Get request body for cluster_id validation
request_body = await request.json() if hasattr(request, 'json') else {}
expected_cluster_id = request_body.get('cluster_id')
# Validate cluster ownership for multi-cluster security
if expected_cluster_id and cluster_id != expected_cluster_id:
await close_database_connection(conn)
raise HTTPException(
status_code=403,
detail=f"Server belongs to cluster {cluster_id}, not cluster {expected_cluster_id}"
)
# Use the validate_user_cluster_access helper function
await validate_user_cluster_access(current_user['id'], cluster_id, conn)
logger.info(f"Server delete: server_id={server_id}, name={server_name}, backend={backend_name}, cluster_id={cluster_id}")
# 1. Soft delete the server first (mark as inactive and for deletion)
await conn.execute("""
UPDATE backend_servers
SET is_active = FALSE, last_config_status = 'DELETION', updated_at = CURRENT_TIMESTAMP
WHERE id = $1
""", server_id)
# 2. Also mark the parent backend as PENDING since its server list changed
await conn.execute("""
UPDATE backends
SET last_config_status = 'PENDING', updated_at = CURRENT_TIMESTAMP
WHERE name = $1 AND cluster_id = $2
""", backend_name, cluster_id)
# Create config version for server deletion (like frontend/SSL delete)
sync_results = []
if cluster_id:
try:
# Generate new HAProxy config without this server (now inactive)
from services.haproxy_config import generate_haproxy_config_for_cluster
config_content = await generate_haproxy_config_for_cluster(cluster_id)
logger.info(f"SERVER DELETE DEBUG: Generated config content length: {len(config_content) if config_content else 0}")
if config_content:
logger.info(f"SERVER DELETE DEBUG: Config contains 'DISABLED': {'DISABLED' in config_content}")
else:
logger.error(f"SERVER DELETE DEBUG: Config content is empty or None!")
# Create new config version
config_hash = __import__('hashlib').sha256(config_content.encode()).hexdigest()
version_name = f"server-{server_id}-delete-{int(__import__('time').time())}"
# Get system admin user ID for created_by
admin_user_id = await conn.fetchval("SELECT id FROM users WHERE username = 'admin' LIMIT 1") or 1
# Create PENDING config version (server will be deleted on Apply)
config_version_id = await conn.fetchval("""
INSERT INTO config_versions
(cluster_id, version_name, config_content, checksum, created_by, is_active, status)
VALUES ($1, $2, $3, $4, $5, FALSE, 'PENDING')
RETURNING id
""", cluster_id, version_name, config_content, config_hash, admin_user_id)
logger.info(f"APPLY WORKFLOW: Created PENDING config version {version_name} for cluster {cluster_id}")
# Don't notify agents yet - wait for manual Apply
sync_results = [{'node': 'pending', 'success': True, 'version': version_name, 'status': 'PENDING', 'message': 'Server deletion created. Click Apply to activate.'}]
except Exception as sync_error:
logger.error(f"Server deletion sync failed: {sync_error}")
sync_results = [{"cluster_id": cluster_id, "success": False, "error": str(sync_error)}]
await close_database_connection(conn)
# Log user activity
if current_user and current_user.get('id'):
await log_user_activity(
user_id=current_user['id'],
action='delete',
resource_type='server',
resource_id=str(server_id),
details={
'server_name': server_name,
'backend_name': backend_name,
'cluster_id': cluster_id,
'ip_address': server.get('ip_address'),
'port': server.get('port')
},
ip_address=str(request.client.host) if request.client else None,
user_agent=request.headers.get('user-agent')
)
return {
"message": f"Server '{server_name}' deleted successfully from backend '{backend_name}'",
"sync_results": sync_results,
"requires_apply": len(sync_results) > 0 and any(r.get('success') for r in sync_results)
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Server deletion failed: {e}")
raise HTTPException(status_code=500, detail=f"Failed to delete server: {str(e)}")
@router.put("/servers/{server_id}")
async def update_server(server_id: int, server_data: dict, request: Request, authorization: str = Header(None)):
"""Update server details.
Bulgu #77 (round-22 audit) — see `delete_server` above; the
same authn-only / no-RBAC gap existed here.
"""
try:
from auth_middleware import get_current_user_from_token, check_user_permission
current_user = await get_current_user_from_token(authorization)
has_permission = await check_user_permission(current_user["id"], "backends", "update")
if not has_permission:
raise HTTPException(
status_code=403,
detail="Insufficient permissions: backends.update required",
)
conn = await get_database_connection()
# PHASE 2: Get FULL server record for snapshot
existing_server = await conn.fetchrow("""
SELECT * FROM backend_servers WHERE id = $1
""", server_id)
if not existing_server:
await close_database_connection(conn)
raise HTTPException(status_code=404, detail="Server not found")
cluster_id = existing_server['cluster_id']
# Bulgu #79 — validate cluster access.
if cluster_id:
await validate_user_cluster_access(current_user['id'], cluster_id, conn)
# Build dynamic update query
update_fields = []
update_values = []
param_idx = 1
# Allow updating these fields (including SSL advanced options)
allowed_fields = ['server_name', 'server_address', 'server_port', 'weight', 'max_connections',
'check_enabled', 'check_port', 'backup_server', 'ssl_enabled', 'ssl_verify', 'ssl_certificate_id',
'ssl_sni', 'ssl_min_ver', 'ssl_max_ver', 'ssl_ciphers',
'cookie_value', 'inter', 'fall', 'rise', 'is_active']
for field in allowed_fields:
if field in server_data:
update_fields.append(f"{field} = ${param_idx}")
update_values.append(server_data[field])
param_idx += 1
if not update_fields:
await close_database_connection(conn)
raise HTTPException(status_code=400, detail="No valid fields to update")
# Add timestamp and server_id
update_fields.append("updated_at = CURRENT_TIMESTAMP")
query = f"UPDATE backend_servers SET {', '.join(update_fields)} WHERE id = ${param_idx}"
update_values.append(server_id)
await conn.execute(query, *update_values)
# CRITICAL FIX: Also mark the parent Backend as PENDING for Agent Sync (same as server toggle/delete)
try:
await conn.execute("""
UPDATE backends SET last_config_status = 'PENDING'
WHERE name = $1 AND cluster_id = $2
""", existing_server['backend_name'], cluster_id)
logger.info(f"BACKEND SYNC: Marked backend '{existing_server['backend_name']}' as PENDING due to server update")
except Exception as e:
logger.error(f"Failed to mark parent backend as PENDING: {e}")
await close_database_connection(conn)
# Generate new config after database update (like frontend/backend)
sync_results = []
if cluster_id:
try:
from services.haproxy_config import generate_haproxy_config_for_cluster
from utils.entity_snapshot import save_entity_snapshot
import hashlib
import time
# 🆕 PHASE 2: Create entity snapshot for rollback
entity_snapshot_metadata = await save_entity_snapshot(
conn=conn,
entity_type="server",
entity_id=server_id,
old_values=existing_server, # Full record with ALL fields
new_values=server_data, # Only updated fields
operation="UPDATE"
)
# Generate new HAProxy config (after database commit)
config_content = await generate_haproxy_config_for_cluster(cluster_id)
# Create new config version
config_hash = hashlib.sha256(config_content.encode()).hexdigest()
version_name = f"server-{server_id}-update-{int(time.time())}"
# Get system admin user ID for created_by
conn2 = await get_database_connection()
admin_user_id = await conn2.fetchval("SELECT id FROM users WHERE username = 'admin' LIMIT 1") or 1
# Get pre-apply snapshot (for diff viewer)
old_config = await conn2.fetchval("""
SELECT config_content FROM config_versions
WHERE cluster_id = $1 AND status = 'APPLIED' AND is_active = TRUE
ORDER BY created_at DESC LIMIT 1
""", cluster_id)
# Merge metadata: pre_apply_snapshot + entity_snapshot
metadata = {
"pre_apply_snapshot": old_config or "", # For diff viewer
**entity_snapshot_metadata # For rollback
}
# Create PENDING config version with metadata
config_version_id = await conn2.fetchval("""
INSERT INTO config_versions
(cluster_id, version_name, config_content, checksum, created_by, is_active, status, metadata)
VALUES ($1, $2, $3, $4, $5, FALSE, 'PENDING', $6)
RETURNING id
""", cluster_id, version_name, config_content, config_hash, admin_user_id,
json.dumps(metadata) if metadata else None)
logger.info(f"APPLY WORKFLOW: Created PENDING config version {version_name} for cluster {cluster_id}")
# Mark entity config status as PENDING for UI (if column exists)
try:
await conn2.execute("UPDATE backend_servers SET last_config_status = 'PENDING' WHERE id = $1", server_id)
except Exception:
# Column doesn't exist, skip this step
logger.info("backend_servers.last_config_status column not found, skipping status update")
# CRITICAL FIX: Also mark the parent Backend as PENDING for Agent Sync
try:
await conn2.execute("""
UPDATE backends SET last_config_status = 'PENDING'
WHERE name = $1 AND cluster_id = $2
""", existing_server['backend_name'], cluster_id)
logger.info(f"BACKEND SYNC: Marked backend '{existing_server['backend_name']}' as PENDING due to server update")
except Exception as e:
logger.warning(f"Failed to update backend last_config_status: {e}")
await close_database_connection(conn2)
# Don't notify agents yet - wait for manual Apply
sync_results = [{'node': 'pending', 'success': True, 'version': version_name, 'status': 'PENDING', 'message': 'Server updated. Click Apply to activate.'}]
except Exception as e:
logger.error(f"Config generation failed for server update: {e}")
sync_results = [{'node': 'cluster', 'success': False, 'error': str(e)}]
# Log activity
await log_user_activity(
user_id=current_user["id"],
action='update',
resource_type='server',
resource_id=str(server_id),
details={'server_name': server_data.get('server_name', existing_server['server_name'])}
)
return {
"message": "Server updated successfully",
"sync_results": sync_results,
"requires_apply": len(sync_results) > 0 and any(r.get('success') for r in sync_results)
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Server update failed: {e}")
raise HTTPException(status_code=500, detail=f"Failed to update server: {str(e)}")
@router.put("/servers/{server_id}/toggle")
async def toggle_server(server_id: int, request: Request, authorization: str = Header(None)):
"""Toggle server enabled/disabled status.
Bulgu #77 (round-22 audit) — see `delete_server` above.
"""
logger.error(f"SERVER TOGGLE DEBUG: Starting toggle for server_id={server_id}")
try:
from auth_middleware import get_current_user_from_token, check_user_permission
current_user = await get_current_user_from_token(authorization)
has_permission = await check_user_permission(current_user["id"], "backends", "update")
if not has_permission:
raise HTTPException(
status_code=403,
detail="Insufficient permissions: backends.update required",
)
logger.error(f"SERVER TOGGLE DEBUG: User authenticated: {current_user.get('username')}")
conn = await get_database_connection()
# Get server info. Issue #24: fetch the FULL row (not just 5 columns) so we
# can snapshot the pre-toggle state for reject-rollback (see config version below).
server = await conn.fetchrow("""
SELECT * FROM backend_servers WHERE id = $1
""", server_id)
if not server:
await close_database_connection(conn)
raise HTTPException(status_code=404, detail="Server not found")
# Bulgu #79 — validate cluster access.
if server['cluster_id']:
await validate_user_cluster_access(current_user['id'], server['cluster_id'], conn)
# Toggle server status
new_status = not server['is_active']
logger.error(f"SERVER TOGGLE DEBUG: Toggling server {server['server_name']} from {server['is_active']} to {new_status}")
await conn.execute("""
UPDATE backend_servers
SET is_active = $1, updated_at = CURRENT_TIMESTAMP
WHERE id = $2
""", new_status, server_id)
logger.error(f"SERVER TOGGLE DEBUG: Server status updated successfully")
cluster_id = server['cluster_id']
# Mark entity config status as PENDING for UI (if column exists) - same as server edit
try:
await conn.execute("UPDATE backend_servers SET last_config_status = 'PENDING' WHERE id = $1", server_id)
logger.error(f"SERVER TOGGLE DEBUG: Server {server_id} marked as PENDING")
except Exception as e:
# Column doesn't exist, skip this step
logger.error(f"SERVER TOGGLE DEBUG: backend_servers.last_config_status column not found: {e}")
# CRITICAL FIX: Also mark the parent Backend as PENDING for Agent Sync - same as server edit
if cluster_id:
try:
await conn.execute("""
UPDATE backends SET last_config_status = 'PENDING'
WHERE name = $1 AND cluster_id = $2
""", server['backend_name'], cluster_id)
logger.error(f"SERVER TOGGLE DEBUG: Backend '{server['backend_name']}' marked as PENDING due to server toggle")
except Exception as e:
logger.error(f"SERVER TOGGLE DEBUG: Failed to mark backend as PENDING: {e}")
# Log activity
from utils.activity_log import log_user_activity
await log_user_activity(
current_user['id'],
f"Server {'enabled' if new_status else 'disabled'}",
f"server",
f"Toggled server '{server['server_name']}' in backend '{server['backend_name']}' to {'enabled' if new_status else 'disabled'}",
cluster_id
)
await close_database_connection(conn)
# Generate new config version after database update (same as server edit)
if cluster_id:
try:
from services.haproxy_config import generate_haproxy_config_for_cluster
import hashlib
import time
# Generate new HAProxy config (after database commit)
config_content = await generate_haproxy_config_for_cluster(cluster_id)
# Create new config version
config_hash = hashlib.sha256(config_content.encode()).hexdigest()
version_name = f"server-{server_id}-toggle-{int(time.time())}"
# Get system admin user ID for created_by
conn2 = await get_database_connection()
admin_user_id = await conn2.fetchval("SELECT id FROM users WHERE username = 'admin' LIMIT 1") or 1
# Issue #24: persist an entity snapshot so a Reject of this toggle
# rolls back is_active to its pre-toggle value. Pre-fix the toggle's
# config version carried NO metadata, so reject only reset
# last_config_status and the server stayed disabled (out of sync with
# the still-active live config). Mirrors the server-edit snapshot path;
# reject's rollback_entity_from_snapshot('server') restores is_active.
import json
from utils.entity_snapshot import save_entity_snapshot
entity_snapshot_metadata = await save_entity_snapshot(
conn=conn2,
entity_type="server",
entity_id=server_id,
old_values=dict(server), # full pre-toggle row
new_values={"is_active": new_status},
operation="UPDATE",
)
old_config = await conn2.fetchval("""
SELECT config_content FROM config_versions
WHERE cluster_id = $1 AND status = 'APPLIED' AND is_active = TRUE
ORDER BY created_at DESC LIMIT 1
""", cluster_id)
metadata = {"pre_apply_snapshot": old_config or "", **entity_snapshot_metadata}
# Create PENDING config version
config_version_id = await conn2.fetchval("""
INSERT INTO config_versions
(cluster_id, version_name, config_content, checksum, created_by, is_active, status, metadata)
VALUES ($1, $2, $3, $4, $5, FALSE, 'PENDING', $6)
RETURNING id
""", cluster_id, version_name, config_content, config_hash, admin_user_id, json.dumps(metadata))
logger.error(f"SERVER TOGGLE DEBUG: Created PENDING config version {version_name} for cluster {cluster_id}")
await close_database_connection(conn2)
except Exception as config_error:
logger.error(f"SERVER TOGGLE DEBUG: Failed to create config version: {config_error}")
logger.error(f"SERVER TOGGLE DEBUG: Toggle completed successfully for server {server['server_name']}")
return {
"success": True,
"message": f"Server '{server['server_name']}' {'enabled' if new_status else 'disabled'} successfully",
"server_id": server_id,
"enabled": new_status
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Error toggling server {server_id}: {e}")
raise HTTPException(status_code=500, detail="Failed to toggle server status")