Files
taylanbakircioglu 7d95c737f0 fix(vip): adopt the whole VRRP instance, not one node (v1.10.8)
Four defects found while tracing the adoption flow end to end after v1.10.4
reached a live HA pair.

B3/B4 (one root, one fix). Adoption took only the node whose row was clicked:

  - adopting the BACKUP alone produced a VIP that apply always rejects, because
    apply requires exactly one MASTER member;
  - adopting the MASTER alone left the peer unmanaged, and adopting it
    afterwards hit the VRID-collision guard with 409, so a pair could never be
    completed from the panel;
  - on a UNICAST instance the single-member render dropped the unicast block
    entirely (render_keepalived_conf emits it only when peer_ips is non-empty),
    so keepalived fell back to multicast on the adopted node while its peer
    stayed unicast. They stop seeing each other and BOTH claim the VIP.

Adoption now resolves the whole instance via _collect_instance_participants,
keyed on (virtual_router_id, virtual address) - the same key keepalived uses to
group nodes. Each participant becomes a member with the role, priority and
interface its own file declares, and its own one-shot takeover hash, so the
per-node overwrite guard is unchanged. It refuses, naming the reason, when the
group has other than one MASTER, when advert_int differs across nodes, when a
declared unicast peer is not among the nodes being adopted, or when a node is
already in a live VIP - the one-active-VIP-per-agent rule that create/update
enforce via _validate_members_against_pool and adoption never called.

B1. The Apply Management "View Change" regex matched vip-(create|update|delete)
only, so an adopt version fell through to the generic HAProxy diff and rendered
the cluster's whole haproxy.cfg as removed. Display-only, but alarming. A test
now asserts every action _stage_vip_version can stage is in that alternation.

B2. Rejecting an adoption hid the node from the panel permanently:
vip_discoveries.adopted_vip_id is write-once, a VIP is only ever soft-deleted so
the column's ON DELETE SET NULL never fires, and the agent does not re-report a
file whose hash has not changed. Rather than clearing the column on each path,
adoptability is derived from whether the linked VIP is still active, which
self-heals reject, undo-reject and approved teardown alike.

The panel now lists one row per instance instead of per node, and the adopt
dialog names every node that will be taken over. Blockers are aggregated across
all of them, matching what the endpoint checks.

No schema change, no agent change, no API-shape break: /api/vip/discoveries
gains a derived field and /api/vip/adopt keeps its request body.

Backend suite: 1359 passed, 152 skipped. Frontend build clean (no new lint
warnings in VIPManagement.js).
2026-08-13 21:45:38 +03:00

5773 lines
295 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from fastapi import APIRouter, HTTPException, Header
from typing import Optional
import logging
import re
from datetime import datetime, timezone
from models import HAProxyClusterCreate, HAProxyClusterUpdate
from database.connection import get_database_connection, close_database_connection
from utils.activity_log import log_user_activity
# Bulgu #79 (round-22 audit) — cluster.py pre-fix had ZERO calls
# to `validate_user_cluster_access`. Every cluster-scoped
# mutation (`apply-changes`, `delete cluster`, `update cluster`,
# `restore config version`, `reject pending changes`, etc.)
# only checked permission ROLE (e.g. `apply.execute`) but never
# verified the operator actually has access to THIS particular
# cluster id. With permission roles granted globally, an
# operator scoped to cluster 1 (via `user_pool_access`) could
# call `POST /api/clusters/2/apply-changes` and apply cluster 2's
# pending changes, blow away cluster 2's `user_pool_access`
# scoping, etc. The helper duplicated across other routers
# (`backend.py`, `frontend.py`, `ssl.py`, `waf.py`, `agent.py`)
# is re-defined here to keep the module self-contained — a
# future refactor can hoist it into a shared `auth_middleware`
# module but the duplication is harmless and pin-tested.
async def validate_user_cluster_access(user_id: int, cluster_id: int, conn):
"""Validate that user has access to the specified cluster.
Admins bypass the check. Non-admins must have an active
(non-expired) row in `user_pool_access` for the cluster's
pool. Falls back to allow on legacy schemas missing the
table / column for backwards compatibility.
"""
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")
is_admin = await conn.fetchval(
"SELECT is_admin FROM users WHERE id = $1", user_id
)
if is_admin:
return True
table_exists = await conn.fetchval("""
SELECT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_name = 'user_pool_access'
)
""")
if not table_exists:
logger.warning(
"user_pool_access table not found, allowing cluster access "
"by fallback (legacy schema)"
)
return True
# Risk audit fix (post-Bulgu-#79): the original helper in
# routers/backend.py filters on `upa.is_active = TRUE` in
# addition to the expires_at window. Without it,
# soft-deleted access rows (`is_active = FALSE`) would still
# match — defeating the soft-delete contract. Also check
# whether the `expires_at` column exists so we keep the same
# backwards-compat shape as the existing helpers and don't
# introduce a column-missing 500 on legacy DBs that were
# working with the other routers' validators.
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'
)
""")
if expires_at_exists:
has_access = await conn.fetchval("""
SELECT EXISTS (
SELECT 1
FROM user_pool_access upa
JOIN haproxy_clusters hc ON hc.pool_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:
# Legacy schema without expires_at — match the same
# fallback the routers/backend.py helper uses so the
# two validators agree byte-for-byte on legacy DBs.
has_access = await conn.fetchval("""
SELECT EXISTS (
SELECT 1
FROM user_pool_access upa
JOIN haproxy_clusters hc ON hc.pool_id = upa.pool_id
WHERE upa.user_id = $1
AND hc.id = $2
AND upa.is_active = TRUE
)
""", user_id, cluster_id)
if not has_access:
raise HTTPException(
status_code=403,
detail="You don't have access to this cluster. Please contact your administrator."
)
return True
# Rate limiting import temporarily disabled
router = APIRouter(prefix="/api/clusters", tags=["clusters"])
logger = logging.getLogger(__name__)
class _AcmeNoConfigChange(Exception):
"""Internal signal: an ACME edit renders the same config, so mint nothing.
Control flow, not an error — it unwinds out of the version-minting block without
tripping the generic `except Exception` handler that would log it as a failure.
"""
class _ConcurrentlyDrained(Exception):
"""Sentinel raised inside ``apply_pending_changes`` when the
advisory-lock-protected re-fetch shows that another caller
has already drained the PENDING config-version list.
Risk-audit follow-up to Bulgu-#80. The earlier two-transaction
split between lock-and-recheck (TX1) and lock-and-apply (TX2)
left a brief gap where another caller could squeeze in, drain
PENDING rows, and commit before TX2 grabbed the lock. We now
collapse both phases into a single locked transaction and use
this sentinel to bail out cleanly when the re-fetch returns
empty. The class is module-level (not nested in the function
body) so Python can resolve it during ``except`` lookup even
when an OTHER exception is raised before the function reaches
the class-definition statement.
"""
def _extract_entities_from_config(config_content: str) -> dict:
"""Extract entity names from HAProxy config content for sync purposes"""
import re
entities = {
'frontends': set(),
'backends': set(),
'waf_rules': set()
}
if not config_content:
return entities
lines = config_content.split('\n')
for line in lines:
line = line.strip()
# Extract frontend names
if line.startswith('frontend '):
frontend_name = line.split()[1] if len(line.split()) > 1 else None
if frontend_name:
entities['frontends'].add(frontend_name)
# Extract backend names
elif line.startswith('backend '):
backend_name = line.split()[1] if len(line.split()) > 1 else None
if backend_name:
entities['backends'].add(backend_name)
return entities
async def apply_ssl_related_configs(conn, trigger_cluster_id: int):
"""
SSL scope-aware auto-apply: Tek Apply ile tum etkilenen cluster'lardaki SSL
config_versions'lari otomatik APPLIED yapar.
SSL sertifikalari diger entity'lerden (frontend/backend) farklidir:
- Tek bir SSL birden fazla cluster'da kullanilir (global veya cluster-specific)
- Agent'lar SSL dosyalarini periyodik check_ssl_updates ile bagimsiz deploy eder
- ssl_certificates.last_config_status = 'APPLIED' olunca TUM agent'lar sertifikayi alabilir
- Bu nedenle tek Apply'da tum cluster'larin SSL versiyonlari APPLIED yapilir
Trigger cluster: Ana apply akisi tarafindan consolidated version olusturulur,
agent'lara bildirim gonderilir. SSL dosyasi hemen deploy edilir.
Diger cluster'lar: SSL config_versions APPLIED yapilir (is_active=FALSE).
Agent'lar periyodik check_ssl_updates (~2.5 dk) ile sertifikayi deploy eder.
Consolidated version olusturulmaz (gereksiz, SSL ayri deploy ediliyor).
"""
import re
# Trigger cluster'daki SSL-related PENDING config'leri bul
ssl_pending = await conn.fetch("""
SELECT version_name FROM config_versions
WHERE cluster_id = $1
AND status = 'PENDING'
AND version_name LIKE 'ssl-%'
""", trigger_cluster_id)
if not ssl_pending:
logger.debug(f"SSL APPLY: No SSL-related PENDING configs in cluster {trigger_cluster_id}")
return
# Extract unique SSL IDs
processed_ssl_ids = set()
for version in ssl_pending:
match = re.match(r'ssl-(\d+)-', version['version_name'])
if match:
processed_ssl_ids.add(int(match.group(1)))
if not processed_ssl_ids:
return
logger.info(
f"SSL APPLY: Found {len(processed_ssl_ids)} SSL cert(s) in trigger cluster "
f"{trigger_cluster_id}: {processed_ssl_ids}"
)
# Auto-apply: Mark SSL PENDING config_versions as APPLIED across ALL clusters
# Trigger cluster'in versiyonlari ana apply akisi tarafindan zaten handle edilecek,
# burada sadece DIGER cluster'lardaki PENDING versiyonlar APPLIED yapilir
for ssl_id in processed_ssl_ids:
result = await conn.execute("""
UPDATE config_versions
SET status = 'APPLIED', is_active = FALSE
WHERE cluster_id != $1
AND version_name LIKE $2
AND status = 'PENDING'
""", trigger_cluster_id, f"ssl-{ssl_id}-%")
logger.info(
f"SSL AUTO-APPLY: SSL cert {ssl_id} - marked PENDING versions as APPLIED "
f"on other clusters (excluding trigger cluster {trigger_cluster_id}). "
f"Result: {result}"
)
@router.post("", summary="Create HAProxy Cluster", response_description="Cluster created successfully")
async def create_cluster(cluster: HAProxyClusterCreate, authorization: str = Header(None)):
"""
# Create a New HAProxy Cluster
Create a new HAProxy cluster associated with an agent pool. Clusters group HAProxy instances that share configuration.
## Prerequisites
1. Agent pool must exist (created via POST `/api/clusters/pools`)
2. User must have `clusters.create` permission
## Request Body
- **name**: Unique cluster name (required)
- **description**: Cluster description (optional)
- **connection_type**: Connection type, typically "agent" (required)
- **stats_socket_path**: HAProxy stats socket path (default: /var/run/haproxy.sock)
- **haproxy_config_path**: HAProxy config file path (default: /etc/haproxy/haproxy.cfg)
- **haproxy_bin_path**: HAProxy binary path (default: /usr/sbin/haproxy)
- **pool_id**: Associated agent pool ID (required)
## Example Request
```bash
curl -X POST "{BASE_URL}/api/clusters" \\
-H "Authorization: Bearer eyJhbGciOiJIUz..." \\
-H "Content-Type: application/json" \\
-d '{
"name": "production-cluster",
"description": "Production HAProxy cluster",
"connection_type": "agent",
"stats_socket_path": "/var/run/haproxy.sock",
"haproxy_config_path": "/etc/haproxy/haproxy.cfg",
"haproxy_bin_path": "/usr/sbin/haproxy",
"pool_id": 1
}'
```
## Example Response
```json
{
"message": "Cluster 'production-cluster' created successfully",
"cluster_id": 1
}
```
## Error Responses
- **403**: Insufficient permissions
- **409**: Cluster name already exists
- **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 cluster create
has_permission = await check_user_permission(current_user["id"], "clusters", "create")
if not has_permission:
raise HTTPException(
status_code=403,
detail="Insufficient permissions: clusters.create required"
)
conn = await get_database_connection()
# Create cluster
cluster_id = await conn.fetchval("""
INSERT INTO haproxy_clusters (name, description, connection_type, is_active,
stats_socket_path, haproxy_config_path, haproxy_bin_path,
keepalived_config_path, pool_id,
acme_enabled, acme_backend_url)
VALUES ($1, $2, $3, TRUE, $4, $5, $6, $7, $8, COALESCE($9, FALSE), $10)
RETURNING id
""", cluster.name, cluster.description, cluster.connection_type,
cluster.stats_socket_path, cluster.haproxy_config_path, cluster.haproxy_bin_path,
cluster.keepalived_config_path, cluster.pool_id,
cluster.acme_enabled, cluster.acme_backend_url)
await close_database_connection(conn)
# Log activity
await log_user_activity(
user_id=current_user["id"],
action='create',
resource_type='cluster',
resource_id=str(cluster_id),
details={'cluster_name': cluster.name}
)
return {"message": f"Cluster '{cluster.name}' created successfully", "cluster_id": cluster_id}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to create cluster: {e}")
# Handle duplicate cluster name error with user-friendly message
if "duplicate key value violates unique constraint" in str(e) and "haproxy_clusters_name_key" in str(e):
raise HTTPException(
status_code=409,
detail=f"Cluster name '{cluster.name}' already exists. Please choose a different name."
)
raise HTTPException(status_code=500, detail=f"Failed to create cluster: {str(e)}")
@router.put("/{cluster_id}", summary="Update HAProxy Cluster", response_description="Cluster updated successfully")
async def update_cluster(cluster_id: int, cluster: HAProxyClusterUpdate, authorization: str = Header(None)):
"""
# Update Existing HAProxy Cluster
Update cluster configuration. Only provided fields will be updated (partial update supported).
## Path Parameters
- **cluster_id**: Cluster ID to update
## Request Body (all optional)
- **name**: New cluster name
- **description**: New description
- **connection_type**: New connection type
- **is_active**: Active status (true/false)
- **stats_socket_path**: New stats socket path
- **haproxy_config_path**: New config file path
- **haproxy_bin_path**: New binary path
- **pool_id**: New pool association
## Example Request
```bash
curl -X PUT "{BASE_URL}/api/clusters/1" \\
-H "Authorization: Bearer eyJhbGciOiJIUz..." \\
-H "Content-Type: application/json" \\
-d '{
"description": "Updated production cluster description",
"is_active": true
}'
```
## Example Response
```json
{
"message": "Cluster 'production-cluster' updated successfully"
}
```
## Error Responses
- **403**: Insufficient permissions
- **404**: Cluster 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 cluster update
has_permission = await check_user_permission(current_user["id"], "clusters", "update")
if not has_permission:
raise HTTPException(
status_code=403,
detail="Insufficient permissions: clusters.update required"
)
conn = await get_database_connection()
# Check if cluster exists and get current values
existing_cluster = await conn.fetchrow("""
SELECT name, description, connection_type, is_active, stats_socket_path,
haproxy_config_path, haproxy_bin_path, pool_id, acme_enabled,
acme_backend_url
FROM haproxy_clusters WHERE id = $1
""", cluster_id)
if not existing_cluster:
await close_database_connection(conn)
raise HTTPException(status_code=404, detail="Cluster not found")
# Bulgu #79 — validate cluster access (admins bypass).
await validate_user_cluster_access(current_user['id'], cluster_id, conn)
# Build dynamic update query - only update fields that are provided (not None)
update_fields = []
update_values = [cluster_id]
param_counter = 2
if cluster.name is not None:
update_fields.append(f"name = ${param_counter}")
update_values.append(cluster.name)
param_counter += 1
if cluster.description is not None:
update_fields.append(f"description = ${param_counter}")
update_values.append(cluster.description)
param_counter += 1
if cluster.connection_type is not None:
update_fields.append(f"connection_type = ${param_counter}")
update_values.append(cluster.connection_type)
param_counter += 1
if cluster.is_active is not None:
update_fields.append(f"is_active = ${param_counter}")
update_values.append(cluster.is_active)
param_counter += 1
if cluster.stats_socket_path is not None:
update_fields.append(f"stats_socket_path = ${param_counter}")
update_values.append(cluster.stats_socket_path)
param_counter += 1
if cluster.haproxy_config_path is not None:
update_fields.append(f"haproxy_config_path = ${param_counter}")
update_values.append(cluster.haproxy_config_path)
param_counter += 1
if cluster.haproxy_bin_path is not None:
update_fields.append(f"haproxy_bin_path = ${param_counter}")
update_values.append(cluster.haproxy_bin_path)
param_counter += 1
if cluster.keepalived_config_path is not None:
update_fields.append(f"keepalived_config_path = ${param_counter}")
update_values.append(cluster.keepalived_config_path)
param_counter += 1
if cluster.pool_id is not None:
update_fields.append(f"pool_id = ${param_counter}")
update_values.append(cluster.pool_id)
param_counter += 1
if cluster.acme_enabled is not None:
update_fields.append(f"acme_enabled = ${param_counter}")
update_values.append(cluster.acme_enabled)
param_counter += 1
# Keyed on "was the field submitted?", not "is it non-None". With a plain
# `is not None` test there is no way to CLEAR the value: the validator maps an
# empty box to None, which is indistinguishable from "not supplied", so once an
# operator set a per-cluster URL they could never revert to the global setting —
# the field would accept the edit and silently keep the old value.
_acme_url_submitted = 'acme_backend_url' in cluster.model_fields_set
if _acme_url_submitted:
update_fields.append(f"acme_backend_url = ${param_counter}")
update_values.append(cluster.acme_backend_url)
param_counter += 1
# Only execute update if there are fields to update
if update_fields:
update_query = f"UPDATE haproxy_clusters SET {', '.join(update_fields)} WHERE id = $1"
await conn.execute(update_query, *update_values)
# Create a PENDING config version when an ACME edit would change what the
# HAProxy nodes actually run.
#
# This used to trigger only on `acme_enabled` flipping. `acme_backend_url` is
# written to the DB a few lines above but minted nothing, so correcting a wrong
# challenge backend from the panel was a silent no-op: the value changed, no
# pending version existed, Apply answered "No pending changes to apply", and the
# nodes kept the old address indefinitely. That made the one field an operator
# needs to fix HTTP-01 impossible to actually apply.
_acme_toggled = (
cluster.acme_enabled is not None
and cluster.acme_enabled != existing_cluster.get('acme_enabled', False)
)
if _acme_toggled or _acme_url_submitted:
try:
from services.haproxy_config import (
generate_haproxy_config_for_cluster,
is_config_generation_error,
extract_acme_backend_target,
)
config_content = await generate_haproxy_config_for_cluster(cluster_id)
if is_config_generation_error(config_content):
# Same sentinel-instead-of-exception contract as the apply path. A
# PENDING version holding the sentinel is a landmine: the operator
# sees a pending change and applies it, replacing the whole config.
logger.error(
f"ACME TOGGLE: config generation for cluster {cluster_id} returned an "
f"error sentinel; no PENDING version created: {config_content!r}"
)
raise HTTPException(
status_code=422,
detail=(
"ACME setting was saved, but a configuration could not be generated "
"for this cluster, so no pending change was created. "
f"Generator reported: {config_content.strip()[:300]}"
),
)
# A URL edit that renders the same `server _acme_mgmt` line changes
# nothing on the nodes, so minting a version would put a no-op pending
# change in front of the operator. Compare that one line rather than the
# whole config: the generator also renders unrelated PENDING entities,
# so a full-text diff reports a change on every edit anyone has queued.
_active_config = await conn.fetchval("""
SELECT config_content FROM config_versions
WHERE cluster_id = $1 AND is_active = TRUE
ORDER BY created_at DESC LIMIT 1
""", cluster_id)
_new_target = extract_acme_backend_target(config_content)
_old_target = extract_acme_backend_target(_active_config)
if not _acme_toggled and _new_target == _old_target:
logger.info(
f"ACME-BACKEND: cluster {cluster_id} URL updated but the rendered "
f"challenge backend is unchanged ({_new_target!r}); no config "
f"version created."
)
raise _AcmeNoConfigChange()
import time as _time
import json as _json
if _acme_toggled:
_acme_kind = 'enable' if cluster.acme_enabled else 'disable'
else:
_acme_kind = 'backend'
version_name = f"cluster-{cluster_id}-acme-{_acme_kind}-{int(_time.time())}"
logger.info(
f"ACME-BACKEND: cluster {cluster_id} pending config version "
f"'{version_name}' created — challenge backend {_old_target!r} -> "
f"{_new_target!r}. Apply the cluster for the nodes to pick it up."
)
from utils.entity_snapshot import save_entity_snapshot
snapshot_metadata = await save_entity_snapshot(
conn=conn,
entity_type="cluster",
entity_id=cluster_id,
old_values={
"acme_enabled": existing_cluster.get('acme_enabled', False),
"acme_backend_url": existing_cluster.get('acme_backend_url'),
},
new_values={
# `acme_enabled` is None when only the URL was submitted; the
# snapshot must record the value that is actually in force, or a
# rollback would write NULL over a working flag.
"acme_enabled": (
cluster.acme_enabled
if cluster.acme_enabled is not None
else existing_cluster.get('acme_enabled', False)
),
"acme_backend_url": getattr(cluster, 'acme_backend_url', None) or existing_cluster.get('acme_backend_url'),
},
operation="UPDATE"
)
metadata_json = _json.dumps(snapshot_metadata) if snapshot_metadata else None
conn2 = await get_database_connection()
try:
await conn2.execute("""
INSERT INTO config_versions (cluster_id, version_name, config_content, status, created_by, metadata)
VALUES ($1, $2, $3, 'PENDING', $4, $5)
""", cluster_id, version_name, config_content, current_user.get('id', 1), metadata_json)
finally:
await close_database_connection(conn2)
except _AcmeNoConfigChange:
# Not an error: the edit was accepted and simply renders the same
# address, so there is nothing for the operator to apply.
pass
except HTTPException:
# The sentinel guard above deliberately fails the request. Without this
# clause the generic handler below would swallow it and report success
# while no PENDING version exists — the exact silent-success failure
# mode this change exists to remove.
await close_database_connection(conn)
raise
except Exception as acme_err:
logger.error(f"Failed to create ACME config version for cluster {cluster_id}: {acme_err}")
await close_database_connection(conn)
# Log activity
await log_user_activity(
user_id=current_user["id"],
action='update',
resource_type='cluster',
resource_id=str(cluster_id),
details={
'cluster_name': cluster.name,
'old_name': existing_cluster['name']
}
)
return {"message": f"Cluster '{cluster.name}' updated successfully"}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to update cluster: {e}")
raise HTTPException(status_code=500, detail=f"Failed to update cluster: {str(e)}")
@router.get("/{cluster_id}", summary="Get Cluster by ID", response_description="Cluster details")
async def get_cluster(cluster_id: int, authorization: str = Header(None), x_api_key: Optional[str] = Header(None)):
"""
# Get Specific HAProxy Cluster
Retrieve detailed information about a specific cluster by its ID.
## Path Parameters
- **cluster_id**: Cluster ID to retrieve
## Example Request
```bash
# User (UI) authentication:
curl -X GET "{BASE_URL}/api/clusters/1" \\
-H "Authorization: Bearer eyJhbGciOiJIUz..."
# Agent authentication (agent token in X-API-Key):
curl -X GET "{BASE_URL}/api/clusters/1" \\
-H "X-API-Key: hap_..."
```
## Example Response
```json
{
"id": 1,
"name": "production-cluster",
"description": "Production HAProxy cluster",
"connection_type": "agent",
"is_active": true,
"is_default": false,
"created_at": "2024-01-15T10:30:00Z",
"stats_socket_path": "/var/run/haproxy.sock",
"haproxy_config_path": "/etc/haproxy/haproxy.cfg",
"haproxy_bin_path": "/usr/sbin/haproxy",
"pool_id": 1,
"pool_name": "production-pool"
}
```
## Error Responses
- **404**: Cluster not found
- **500**: Server error
"""
# R18c audit fix (round 6 final convergence): authenticate the caller
# before fetching cluster topology by ID. Pre-fix this sibling of
# GET /api/clusters was anonymous, so an attacker could iterate cluster
# IDs to enumerate the same info (stats socket, paths, ACME flags, pool
# identity) the list endpoint just locked down.
# Issue #22: agents send their token in the X-API-Key header (not a user
# JWT), so accept either credential — mirrors the dual-auth on
# POST /api/agents/generate-install-script. Anonymous is still rejected.
if authorization:
from auth_middleware import get_current_user_from_token
await get_current_user_from_token(authorization)
elif x_api_key:
from auth_middleware import validate_agent_api_key
if not await validate_agent_api_key(x_api_key):
raise HTTPException(status_code=401, detail="Invalid agent API key")
else:
raise HTTPException(status_code=401, detail="Authorization header or X-API-Key required")
try:
conn = await get_database_connection()
cluster = await conn.fetchrow("""
SELECT c.id, c.name, c.description, c.connection_type, c.is_active,
c.created_at, c.stats_socket_path, c.haproxy_config_path, c.haproxy_bin_path,
c.keepalived_config_path,
c.pool_id, c.is_default, c.acme_enabled, c.acme_backend_url,
p.name as pool_name
FROM haproxy_clusters c
LEFT JOIN haproxy_cluster_pools p ON c.pool_id = p.id
WHERE c.id = $1 AND c.is_active = TRUE
""", cluster_id)
await close_database_connection(conn)
if not cluster:
raise HTTPException(status_code=404, detail="Cluster not found")
return {
"id": cluster["id"],
"name": cluster["name"],
"description": cluster["description"],
"connection_type": cluster["connection_type"],
"is_active": cluster["is_active"],
"is_default": cluster.get("is_default", False),
"created_at": cluster["created_at"].isoformat().replace('+00:00', 'Z') if cluster["created_at"] else None,
"stats_socket_path": cluster["stats_socket_path"],
"haproxy_config_path": cluster["haproxy_config_path"],
"haproxy_bin_path": cluster["haproxy_bin_path"],
"keepalived_config_path": cluster.get("keepalived_config_path"),
"pool_id": cluster["pool_id"],
"pool_name": cluster["pool_name"],
"acme_enabled": cluster.get("acme_enabled", False),
"acme_backend_url": cluster.get("acme_backend_url")
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("", summary="Get All Clusters", response_description="List of all clusters")
async def get_clusters(authorization: str = Header(None), x_api_key: Optional[str] = Header(None)):
"""
# Get All HAProxy Clusters
Retrieve a list of all active HAProxy clusters with their agent counts and pool information.
## Example Request
```bash
# User (UI) authentication:
curl -X GET "{BASE_URL}/api/clusters" \\
-H "Authorization: Bearer eyJhbGciOiJIUz..."
# Agent authentication (agent token in X-API-Key):
curl -X GET "{BASE_URL}/api/clusters" \\
-H "X-API-Key: hap_..."
```
## Example Response
```json
[
{
"id": 1,
"name": "production-cluster",
"description": "Production HAProxy cluster",
"connection_type": "agent",
"is_active": true,
"is_default": false,
"created_at": "2024-01-15T10:30:00Z",
"stats_socket_path": "/var/run/haproxy.sock",
"haproxy_config_path": "/etc/haproxy/haproxy.cfg",
"haproxy_bin_path": "/usr/sbin/haproxy",
"pool_id": 1,
"pool_name": "production-pool",
"agent_count": 5,
"online_agents": 4,
"offline_agents": 1
},
{
"id": 2,
"name": "staging-cluster",
"description": "Staging environment cluster",
"connection_type": "agent",
"is_active": true,
"is_default": false,
"created_at": "2024-01-16T14:20:00Z",
"stats_socket_path": "/var/run/haproxy.sock",
"haproxy_config_path": "/etc/haproxy/haproxy.cfg",
"haproxy_bin_path": "/usr/sbin/haproxy",
"pool_id": 2,
"pool_name": "staging-pool",
"agent_count": 2,
"online_agents": 2,
"offline_agents": 0
}
]
```
## Response Fields
- **agent_count**: Total number of agents in the cluster
- **online_agents**: Number of currently online agents
- **offline_agents**: Number of currently offline agents
## Error Responses
- **500**: Server error
"""
# R18c audit fix (round 6 #3 — KRITIK info leak): require an authenticated
# caller. Pre-fix the endpoint accepted anonymous GETs and returned cluster
# topology including internal HAProxy paths (stats socket, config path, bin
# path), pool ids, ACME flags, and agent counts. This is both reconnaissance
# for an attacker and the spine of the cluster-scoped RBAC the rest of the
# platform builds on, so guarding it at the read layer is essential.
# Issue #22: agents send their token in the X-API-Key header (not a user
# JWT), so accept either credential — mirrors the dual-auth on
# POST /api/agents/generate-install-script. Anonymous is still rejected.
# (Guard kept OUTSIDE the try below: get_clusters' broad `except Exception`
# re-wraps raised HTTPExceptions into 500, which produced the "500 - 401"
# in the issue log; raising here yields a clean 401.)
if authorization:
from auth_middleware import get_current_user_from_token
await get_current_user_from_token(authorization)
elif x_api_key:
from auth_middleware import validate_agent_api_key
if not await validate_agent_api_key(x_api_key):
raise HTTPException(status_code=401, detail="Invalid agent API key")
else:
raise HTTPException(status_code=401, detail="Authorization header or X-API-Key required")
try:
conn = await get_database_connection()
clusters = await conn.fetch("""
SELECT c.id, c.name, c.description, c.connection_type, c.is_active,
c.created_at, c.stats_socket_path, c.haproxy_config_path, c.haproxy_bin_path,
c.keepalived_config_path,
c.pool_id, c.is_default, c.acme_enabled, c.acme_backend_url,
p.name as pool_name,
COALESCE(agent_counts.total_agents, 0) as total_agents,
COALESCE(agent_counts.healthy_agents, 0) as healthy_agents,
COALESCE(agent_counts.warning_agents, 0) as warning_agents,
COALESCE(agent_counts.offline_agents, 0) as offline_agents,
agent_counts.agents_last_heartbeat
FROM haproxy_clusters c
LEFT JOIN haproxy_cluster_pools p ON c.pool_id = p.id
LEFT JOIN (
SELECT
a.pool_id,
COUNT(a.id) as total_agents,
COUNT(CASE WHEN a.id IS NOT NULL AND a.last_seen IS NOT NULL AND a.status = 'online' AND
EXTRACT(EPOCH FROM (NOW() - a.last_seen)) < 30 THEN 1 END) as healthy_agents,
COUNT(CASE WHEN a.id IS NOT NULL AND a.last_seen IS NOT NULL AND a.status = 'online' AND
EXTRACT(EPOCH FROM (NOW() - a.last_seen)) >= 30 AND
EXTRACT(EPOCH FROM (NOW() - a.last_seen)) < 120 THEN 1 END) as warning_agents,
COUNT(CASE WHEN a.id IS NOT NULL AND (a.last_seen IS NULL OR a.status != 'online' OR
EXTRACT(EPOCH FROM (NOW() - a.last_seen)) >= 120) THEN 1 END) as offline_agents,
MAX(a.last_seen) as agents_last_heartbeat
FROM agents a
GROUP BY a.pool_id
) agent_counts ON agent_counts.pool_id = c.pool_id
WHERE c.is_active = TRUE
ORDER BY c.name
""")
await close_database_connection(conn)
cluster_list = []
for cluster in clusters:
total = int(cluster["total_agents"]) if cluster["total_agents"] else 0
healthy = int(cluster["healthy_agents"]) if cluster["healthy_agents"] else 0
warning = int(cluster["warning_agents"]) if cluster["warning_agents"] else 0
offline = int(cluster["offline_agents"]) if cluster["offline_agents"] else 0
# Determine agent status
if total == 0:
agent_status = "no-agents"
elif healthy == total:
agent_status = "healthy"
elif healthy > 0:
agent_status = "warning"
else:
agent_status = "offline"
cluster_list.append({
"id": cluster["id"],
"name": cluster["name"],
"description": cluster["description"],
"connection_type": cluster["connection_type"],
"is_active": cluster["is_active"],
"is_default": cluster.get("is_default", False),
"created_at": cluster["created_at"].isoformat().replace('+00:00', 'Z') if cluster["created_at"] else None,
"stats_socket_path": cluster["stats_socket_path"],
"haproxy_config_path": cluster["haproxy_config_path"],
"haproxy_bin_path": cluster["haproxy_bin_path"],
"keepalived_config_path": cluster.get("keepalived_config_path"),
"pool_id": cluster["pool_id"],
"pool_name": cluster["pool_name"],
"acme_enabled": cluster.get("acme_enabled", False),
"acme_backend_url": cluster.get("acme_backend_url"),
# Agent status information
"total_agents": total,
"healthy_agents": healthy,
"warning_agents": warning,
"offline_agents": offline,
"agent_status": agent_status,
"agents_last_heartbeat": cluster["agents_last_heartbeat"].isoformat().replace('+00:00', 'Z') if cluster["agents_last_heartbeat"] else None
})
return {"clusters": cluster_list}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{cluster_id}/agent-sync")
async def get_cluster_agent_sync_status(cluster_id: int, authorization: str = Header(None)):
"""Get agent synchronization status for a cluster.
Returns latest applied version and per-agent delivered version to determine sync status.
"""
try:
# Validate user token (agents won't call this)
from auth_middleware import get_current_user_from_token
_ = await get_current_user_from_token(authorization)
conn = await get_database_connection()
# Latest applied & active version for this cluster
latest = await conn.fetchrow(
"""
SELECT version_name, created_at, validation_error, validation_error_reported_at
FROM config_versions
WHERE cluster_id = $1 AND status = 'APPLIED' AND is_active = TRUE
ORDER BY created_at DESC
LIMIT 1
""",
cluster_id,
)
latest_version = latest["version_name"] if latest else None
latest_created_at = latest["created_at"].isoformat().replace('+00:00', 'Z') if latest and latest.get("created_at") else None
validation_error = latest.get("validation_error") if latest else None
validation_error_reported_at = latest["validation_error_reported_at"].isoformat().replace('+00:00', 'Z') if latest and latest.get("validation_error_reported_at") else None
# Parse validation error if present
parsed_error = None
if validation_error:
try:
from utils.haproxy_error_parser import parse_haproxy_error, find_affected_entity
parsed_error = parse_haproxy_error(validation_error)
# Try to find affected entity for quick fix URL
if parsed_error.get('parse_success') and parsed_error.get('entity_name'):
try:
entity_info = await find_affected_entity(parsed_error, cluster_id, conn)
if entity_info.get('entity_verified'):
parsed_error['entity_id'] = entity_info.get('entity_id')
parsed_error['entity_verified'] = True
parsed_error['quick_fix_url'] = entity_info.get('edit_url')
parsed_error['quick_fix_available'] = True
except Exception as entity_err:
logger.debug(f"Failed to find affected entity in agent-sync: {entity_err}")
except Exception as parse_err:
logger.warning(f"Failed to parse validation error in agent-sync: {parse_err}")
parsed_error = {
"parse_success": False,
"raw_message": validation_error,
"suggestion": "Error message could not be parsed. Please review the raw message."
}
# Agents under this cluster (via pool relationship)
agents = await conn.fetch(
"""
SELECT a.id, a.name, COALESCE(a.enabled, TRUE) as enabled, a.status, a.haproxy_status,
a.last_seen, a.config_version, a.applied_config_version
FROM agents a
JOIN haproxy_clusters c ON c.pool_id = a.pool_id
WHERE c.id = $1
ORDER BY a.name
""",
cluster_id,
)
await close_database_connection(conn)
# Compute sync stats - only count enabled agents for sync tracking
agent_items = []
total_all = len(agents)
total_enabled = 0
disabled_count = 0
online = 0
synced = 0
for ag in agents:
is_enabled = ag.get("enabled", True)
is_online = (ag.get("status") == "online")
if not is_enabled:
# Disabled agents are excluded from sync calculations
disabled_count += 1
agent_items.append({
"id": ag["id"],
"name": ag["name"],
"enabled": False,
"status": ag.get("status", "unknown"),
"haproxy_status": ag.get("haproxy_status", "unknown"),
"last_seen": ag["last_seen"].isoformat().replace('+00:00', 'Z') if ag.get("last_seen") else None,
"delivered_version": ag.get("config_version"),
"applied_version": ag.get("applied_config_version"),
"in_sync": None, # Not applicable for disabled agents
"sync_excluded": True,
})
logger.info(f"CLUSTER SYNC: Agent {ag['name']} - DISABLED (excluded from sync)")
continue
total_enabled += 1
if is_online:
online += 1
delivered_version = ag.get("config_version")
applied_version = ag.get("applied_config_version")
haproxy_status = ag.get("haproxy_status", "unknown")
# Agent is in sync ONLY if:
# 1. It has applied the latest version AND
# 2. HAProxy service is running successfully
version_matches = bool(latest_version and applied_version == latest_version)
haproxy_running = haproxy_status in ['running', 'active']
in_sync = version_matches and haproxy_running
# DEBUG: Enhanced logging for restore operations
if latest_version and latest_version.startswith('restore-'):
logger.info(f"RESTORE SYNC DEBUG: Agent {ag['name']} - latest: '{latest_version}', applied: '{applied_version}', matches: {version_matches}, haproxy: {haproxy_status}, in_sync: {in_sync}")
if in_sync:
synced += 1
logger.info(f"CLUSTER SYNC: Agent {ag['name']} - SYNCED (version: {applied_version}, haproxy: {haproxy_status})")
else:
if not version_matches:
logger.info(f"CLUSTER SYNC: Agent {ag['name']} - UNSYNCED (version mismatch: {applied_version} != {latest_version})")
elif not haproxy_running:
logger.info(f"CLUSTER SYNC: Agent {ag['name']} - UNSYNCED (haproxy not running: {haproxy_status})")
else:
logger.info(f"CLUSTER SYNC: Agent {ag['name']} - UNSYNCED (unknown reason)")
agent_items.append({
"id": ag["id"],
"name": ag["name"],
"enabled": True,
"status": ag.get("status", "unknown"),
"haproxy_status": ag.get("haproxy_status", "unknown"),
"last_seen": ag["last_seen"].isoformat().replace('+00:00', 'Z') if ag.get("last_seen") else None,
"delivered_version": delivered_version,
"applied_version": applied_version,
"in_sync": in_sync,
"sync_excluded": False,
})
return {
"cluster_id": cluster_id,
"latest_version": latest_version,
"latest_created_at": latest_created_at,
"validation_error": validation_error,
"validation_error_reported_at": validation_error_reported_at,
"parsed_error": parsed_error,
"total_agents": total_enabled,
"online_agents": online,
"synced_agents": synced,
"unsynced_agents": max(0, total_enabled - synced),
"disabled_agents": disabled_count,
"total_agents_including_disabled": total_all,
"agents": agent_items,
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to get agent sync status for cluster {cluster_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{cluster_id}/entity-sync/{entity_type}/{entity_id}")
async def get_entity_agent_sync_status(
cluster_id: int,
entity_type: str,
entity_id: int,
authorization: str = Header(None)
):
"""Get agent sync status for a specific entity.
Returns sync status based on entity's last_config_status.
"""
try:
from auth_middleware import get_current_user_from_token
_ = await get_current_user_from_token(authorization)
conn = await get_database_connection()
# Validate entity type
valid_types = ['waf_rules', 'frontends', 'backends', 'backend_servers', 'ssl_certificates']
if entity_type not in valid_types:
await close_database_connection(conn)
raise HTTPException(status_code=400, detail=f"Invalid entity type. Must be one of: {valid_types}")
# Get entity's config status (prioritize active entities, fallback to inactive for sync status)
table_name = entity_type
entity = await conn.fetchrow(f"""
SELECT id, last_config_status, updated_at, is_active
FROM {table_name}
WHERE id = $1 AND cluster_id = $2 AND is_active = TRUE
""", entity_id, cluster_id)
# If active entity not found, check if inactive entity exists (for sync status of soft-deleted entities)
if not entity:
entity = await conn.fetchrow(f"""
SELECT id, last_config_status, updated_at, is_active
FROM {table_name}
WHERE id = $1 AND cluster_id = $2 AND is_active = FALSE
""", entity_id, cluster_id)
if not entity:
await close_database_connection(conn)
raise HTTPException(status_code=404, detail=f"Entity {entity_type}/{entity_id} not found in cluster {cluster_id}")
# Get agents for this cluster
agents = await conn.fetch("""
SELECT a.id, a.name, a.status, a.haproxy_status, a.applied_config_version
FROM agents a
JOIN haproxy_clusters c ON c.pool_id = a.pool_id
WHERE c.id = $1 AND a.enabled = TRUE
ORDER BY a.name
""", cluster_id)
# Calculate sync status based on entity's config status
last_config_status = entity.get('last_config_status')
total_agents = len(agents)
if not last_config_status or total_agents == 0:
await close_database_connection(conn)
return {
"entity_type": entity_type,
"entity_id": entity_id,
"has_config_changes": False,
"sync_status": None,
"message": "No config changes or no agents"
}
# SPECIAL CASE: For backends, check server changes FIRST (they are more recent)
entity_specific_version = None
if entity_type == 'backends':
logger.info(f"ENTITY SYNC DEBUG: Checking server changes for backend {entity_type}/{entity_id}")
# Get backend name for server lookup
backend_info = await conn.fetchrow("""
SELECT name FROM backends WHERE id = $1 AND cluster_id = $2
""", entity_id, cluster_id)
if backend_info:
backend_name = backend_info['name']
logger.info(f"ENTITY SYNC DEBUG: Backend name: {backend_name}")
# Look for server changes that affect this backend
# This includes current servers and deleted servers (via config versions)
# First get all current servers belonging to this backend
backend_servers = await conn.fetch("""
SELECT id FROM backend_servers
WHERE backend_name = $1 AND cluster_id = $2
""", backend_name, cluster_id)
logger.info(f"ENTITY SYNC DEBUG: Found {len(backend_servers)} current servers for backend {backend_name}")
# Build patterns from current servers
like_patterns = []
if backend_servers:
server_ids = [str(server['id']) for server in backend_servers]
logger.info(f"ENTITY SYNC DEBUG: Current Server IDs: {server_ids}")
like_patterns = [f"server-{server_id}-%" for server_id in server_ids]
# Also look for any server delete versions that might affect this backend
# We need to find server delete versions and check if they were for this backend
server_delete_versions = await conn.fetch("""
SELECT cv.version_name, cv.created_at
FROM config_versions cv
WHERE cv.cluster_id = $1 AND cv.status = 'APPLIED'
AND cv.version_name ~ '^server-[0-9]+-delete-'
ORDER BY cv.created_at DESC
LIMIT 10
""", cluster_id)
logger.info(f"ENTITY SYNC DEBUG: Found {len(server_delete_versions)} recent server delete versions")
# Add delete versions to patterns (we'll check all recent ones)
for delete_version in server_delete_versions:
like_patterns.append(delete_version['version_name'])
logger.info(f"ENTITY SYNC DEBUG: Patterns to check: {like_patterns}")
# Find the latest applied server version for any of these patterns
for pattern in like_patterns:
logger.info(f"ENTITY SYNC DEBUG: Checking pattern: {pattern}")
if pattern.startswith('server-') and '-delete-' in pattern:
# This is a delete version, use it directly
entity_specific_version = pattern
logger.info(f"ENTITY SYNC DEBUG: Using server delete version: {pattern}")
break
else:
# This is a pattern, search for matching versions
entity_specific_version = await conn.fetchval("""
SELECT cv.version_name
FROM config_versions cv
WHERE cv.cluster_id = $1 AND cv.status = 'APPLIED'
AND cv.version_name LIKE $2
ORDER BY cv.created_at DESC
LIMIT 1
""", cluster_id, pattern)
logger.info(f"ENTITY SYNC DEBUG: Pattern {pattern} result: {entity_specific_version}")
if entity_specific_version:
logger.info(f"ENTITY SYNC: Found server change affecting backend {entity_type}/{entity_id}: {entity_specific_version} (pattern: {pattern})")
break
if entity_specific_version:
logger.info(f"ENTITY SYNC: Final server version for backend {entity_type}/{entity_id}: {entity_specific_version}")
else:
logger.info(f"ENTITY SYNC: No server changes found for backend {entity_type}/{entity_id}")
# If no server changes found for backends, or for other entity types, check entity-specific versions
if not entity_specific_version:
entity_specific_version = await conn.fetchval(f"""
SELECT cv.version_name
FROM config_versions cv
WHERE cv.cluster_id = $1 AND cv.status = 'APPLIED' AND cv.is_active = TRUE
AND cv.version_name LIKE $2
ORDER BY cv.created_at DESC
LIMIT 1
""", cluster_id, f"{entity_type.rstrip('s')}-{entity_id}-%")
# For sync status display, always prefer latest consolidated version over entity-specific versions
# This ensures timestamp comparison works correctly for "Applying..." status
# Also fetch metadata for restore tracking
latest_consolidated = await conn.fetchrow("""
SELECT cv.version_name, cv.metadata
FROM config_versions cv
WHERE cv.cluster_id = $1 AND cv.status = 'APPLIED' AND cv.is_active = TRUE
AND (cv.version_name LIKE 'apply-consolidated-%' OR cv.version_name LIKE 'restore-%')
ORDER BY cv.created_at DESC
LIMIT 1
""", cluster_id)
latest_consolidated_version = latest_consolidated['version_name'] if latest_consolidated else None
latest_version_metadata = None
if latest_consolidated and latest_consolidated.get('metadata'):
try:
import json
latest_version_metadata = json.loads(latest_consolidated['metadata']) if isinstance(latest_consolidated['metadata'], str) else latest_consolidated['metadata']
except:
pass
# Use consolidated version if available, otherwise fall back to entity-specific version
if latest_consolidated_version:
entity_related_version = latest_consolidated_version
logger.info(f"ENTITY SYNC: Using latest consolidated version for {entity_type}/{entity_id}: {latest_consolidated_version}")
elif entity_specific_version:
entity_related_version = entity_specific_version
logger.info(f"ENTITY SYNC: No consolidated version, using entity-specific version for {entity_type}/{entity_id}: {entity_specific_version}")
else:
entity_related_version = None
logger.info(f"ENTITY SYNC: No version found for {entity_type}/{entity_id}")
# Fallback if no version found at all
if not entity_related_version:
entity_related_version = await conn.fetchval("""
SELECT version_name
FROM config_versions
WHERE cluster_id = $1 AND status = 'APPLIED' AND is_active = TRUE
ORDER BY created_at DESC
LIMIT 1
""", cluster_id)
logger.info(f"ENTITY SYNC: Fallback to latest active version: {entity_related_version}")
# ENTITY-SPECIFIC AGENT SYNC: Base sync on entity's individual status
# If entity is PENDING, show 0/X sync until entity is applied
# If entity is APPLIED, show current agent sync based on entity's own version
synced_agents = 0
unsynced_agents = 0
if last_config_status == 'PENDING':
# Entity has pending changes - show 0/X sync until applied
unsynced_agents = total_agents
logger.info(f"ENTITY SYNC: {entity_type}/{entity_id} is PENDING - showing 0/{total_agents} sync")
else:
# Entity is APPLIED - check if agents have applied the entity's related version
# Use the version this entity was last involved in, not the latest cluster version
target_version = entity_related_version
logger.info(f"ENTITY SYNC: {entity_type}/{entity_id} is APPLIED - checking agent sync with entity version: {target_version}")
if not target_version:
# No version found, assume all agents are synced (backward compatibility)
synced_agents = total_agents
else:
for agent in agents:
agent_applied_version = agent.get('applied_config_version')
agent_haproxy_status = agent.get('haproxy_status', 'unknown')
# CRITICAL FIX: Agent is synced ONLY if:
# 1. It has applied the target version AND
# 2. HAProxy service is running successfully
version_matches = agent_applied_version == target_version
haproxy_running = agent_haproxy_status in ['running', 'active']
if version_matches and haproxy_running:
synced_agents += 1
logger.info(f"AGENT SYNC: {agent['name']} - SYNCED (version: {agent_applied_version}, haproxy: {agent_haproxy_status})")
else:
unsynced_agents += 1
if not version_matches:
logger.info(f"AGENT SYNC: {agent['name']} - UNSYNCED (version mismatch: {agent_applied_version} != {target_version})")
elif not haproxy_running:
logger.info(f"AGENT SYNC: {agent['name']} - UNSYNCED (haproxy not running: {agent_haproxy_status})")
else:
logger.info(f"AGENT SYNC: {agent['name']} - UNSYNCED (unknown reason)")
# Debug logging for troubleshooting
logger.info(f"ENTITY SYNC DEBUG: {entity_type}/{entity_id} - last_config_status: {last_config_status}")
logger.info(f"ENTITY SYNC DEBUG: entity_related_version: {entity_related_version}")
logger.info(f"ENTITY SYNC DEBUG: total_agents: {total_agents}, synced: {synced_agents}, unsynced: {unsynced_agents}")
# Additional debug for agent versions
for agent in agents:
agent_version = agent.get('applied_config_version')
logger.info(f"ENTITY SYNC DEBUG: Agent {agent['name']} - applied_version: {agent_version}, matches_target: {agent_version == entity_related_version}")
# Get the created_at timestamp for the entity's related version
latest_version_created_at = None
if entity_related_version:
version_created_at = await conn.fetchval("""
SELECT created_at FROM config_versions
WHERE cluster_id = $1 AND version_name = $2
ORDER BY created_at DESC LIMIT 1
""", cluster_id, entity_related_version)
if version_created_at:
latest_version_created_at = version_created_at.isoformat().replace('+00:00', 'Z')
logger.info(f"ENTITY SYNC: Found version timestamp for {entity_related_version}: {latest_version_created_at}")
# Get last user who applied changes to this cluster
# This helps users understand who initiated the last apply when multiple users share same account
last_applied_by_info = await conn.fetchrow("""
SELECT u.username, u.full_name, ual.created_at as applied_at
FROM user_activity_logs ual
JOIN users u ON u.id = ual.user_id
WHERE ual.resource_type = 'cluster'
AND ual.resource_id = $1
AND ual.action = 'apply_changes'
ORDER BY ual.created_at DESC
LIMIT 1
""", str(cluster_id))
last_applied_by = None
if last_applied_by_info:
last_applied_by = {
"username": last_applied_by_info['username'],
"full_name": last_applied_by_info['full_name'],
"applied_at": last_applied_by_info['applied_at'].isoformat().replace('+00:00', 'Z') if last_applied_by_info['applied_at'] else None
}
# Always show agent sync status regardless of entity's config status
# Agent sync reflects whether agents have applied the latest cluster config,
# independent of entity's APPLIED/PENDING status
await close_database_connection(conn)
return {
"entity_type": entity_type,
"entity_id": entity_id,
"has_config_changes": True,
"last_config_status": last_config_status,
"latest_applied_version": entity_related_version,
"latest_version_created_at": latest_version_created_at, # Now returns actual version timestamp
"latest_version_metadata": latest_version_metadata, # CRITICAL: For restore tracking
"entity_updated_at": entity.get('updated_at').isoformat().replace('+00:00', 'Z') if entity.get('updated_at') else None,
"last_applied_by": last_applied_by, # NEW: Show who applied last
"sync_status": {
"total_agents": total_agents,
"synced_agents": synced_agents,
"unsynced_agents": unsynced_agents,
"last_sync_check": datetime.now(timezone.utc).isoformat().replace('+00:00', 'Z')
},
"agents": [
{
"id": ag["id"],
"name": ag["name"],
"status": ag.get("status", "unknown"),
"applied_version": ag.get("applied_config_version"),
"latest_version": entity_related_version,
"in_sync": (
entity_related_version and
ag.get("applied_config_version") == entity_related_version
)
}
for ag in agents
]
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to get entity sync status for {entity_type}/{entity_id} in cluster {cluster_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{cluster_id}/config-versions")
async def list_cluster_config_versions(cluster_id: int, authorization: str = Header(None)):
"""Get configuration version history for a cluster"""
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 all config versions for this cluster (with schema compatibility)
try:
# Try with all columns including validation_error
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,
cv.validation_error, cv.validation_error_reported_at,
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
ORDER BY cv.created_at DESC
""", cluster_id)
except Exception as status_error:
logger.warning(f"Status/validation_error column error in config-versions, using fallback: {status_error}")
# Fallback query without validation_error columns
try:
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
ORDER BY cv.created_at DESC
""", cluster_id)
except Exception:
# Final fallback without status column
versions = await conn.fetch("""
SELECT cv.id, cv.version_name, cv.description, 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
ORDER BY cv.created_at DESC
""", cluster_id)
# Import error parser for parsing validation errors
from utils.haproxy_error_parser import parse_haproxy_error, find_affected_entity
# Format the response
formatted_versions = []
for version in versions:
# Extract change type from version name for better display
version_type = "Configuration"
if "frontend-" in version['version_name']:
version_type = "Frontend"
elif "backend-" in version['version_name']:
version_type = "Backend"
elif "server-" in version['version_name']:
version_type = "Backend Server"
elif "waf-" in version['version_name']:
version_type = "WAF Rule"
elif "ssl-" in version['version_name']:
version_type = "SSL Certificate"
elif "vip-" in version['version_name']:
version_type = "HA / VIP"
# Parse validation error if present
validation_error = version.get("validation_error")
parsed_error = None
if validation_error:
try:
parsed_error = parse_haproxy_error(validation_error)
# Try to find affected entity in database for quick fix URL
if parsed_error.get('parse_success') and parsed_error.get('entity_name'):
try:
entity_info = await find_affected_entity(parsed_error, cluster_id, conn)
if entity_info.get('entity_verified'):
parsed_error['entity_id'] = entity_info.get('entity_id')
parsed_error['entity_verified'] = True
parsed_error['quick_fix_url'] = entity_info.get('edit_url')
parsed_error['quick_fix_available'] = True
except Exception as entity_err:
logger.debug(f"Failed to find affected entity: {entity_err}")
# Continue without entity info - not critical
except Exception as parse_err:
logger.warning(f"Failed to parse validation error for version {version['id']}: {parse_err}")
parsed_error = {
"parse_success": False,
"raw_message": validation_error,
"suggestion": "Error message could not be parsed. Please review the raw message."
}
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.get("status", "APPLIED"), # Default to APPLIED if column doesn't exist
"is_active": version.get("is_active", False),
"created_at": version["created_at"].isoformat().replace('+00:00', 'Z') if version.get("created_at") else None,
"created_by": version.get("created_by_username") or "System",
"file_size": version.get("file_size"),
"checksum": version["checksum"][:8] + "..." if version.get("checksum") else "No checksum",
# Validation error fields
"validation_error": validation_error,
"validation_error_reported_at": version["validation_error_reported_at"].isoformat().replace('+00:00', 'Z') if version.get("validation_error_reported_at") else None,
"parsed_error": parsed_error
})
await close_database_connection(conn)
return {"config_versions": formatted_versions}
except Exception as e:
logger.error(f"Error fetching cluster versions: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/{cluster_id}/apply-changes")
async def apply_pending_changes(
cluster_id: int,
apply_request: dict = None,
authorization: str = Header(None)
):
"""
Apply all pending configuration changes for a cluster
This endpoint:
- Consolidates all PENDING config versions into one APPLIED version
- Marks all pending entities (frontends, backends, servers, SSL, WAF) as APPLIED
- Automatically detects and removes orphan config versions before apply
- Notifies agents to pull new configuration
Orphan Version Auto-Cleanup:
- Orphan versions are config versions that reference entities from different clusters
- Can occur due to entity ID reuse after hard delete
- Automatically detected and cleaned during apply process
- Example: backend-73-delete-xxx in cluster 2, but backend 73 is in cluster 1
Requires: apply.execute permission
"""
try:
# Verify user authentication
from auth_middleware import get_current_user_from_token, check_user_permission
import re # Import re module for regex operations
current_user = await get_current_user_from_token(authorization)
# Check permission for apply execute
has_permission = await check_user_permission(current_user["id"], "apply", "execute")
if not has_permission:
raise HTTPException(
status_code=403,
detail="Insufficient permissions: apply.execute required"
)
conn = await get_database_connection()
# Bulgu #79 — validate the operator actually has access
# to THIS cluster. Pre-fix the `apply.execute` permission
# was granted globally, so any operator with the role
# could apply changes to ANY cluster, including clusters
# in pools they were never granted access to.
await validate_user_cluster_access(current_user['id'], cluster_id, conn)
# Apply all pending changes without validation - validation issues will be handled by HAProxy itself
# Users will handle configuration completeness through the centralized Apply Management page
# Get all pending config versions for this cluster
# HA/VIP (Issue #27): vip-* versions are owned by the VIP apply/reject endpoints
# (keepalived is not part of haproxy.cfg). Exclude them so a generic cluster apply
# from any entity page never marks a VIP version APPLIED without enacting it. This
# is a no-op for every non-VIP cluster (no vip-* rows exist).
pending_versions = await conn.fetch("""
SELECT id, version_name, created_at, config_content, checksum, metadata
FROM config_versions
WHERE cluster_id = $1 AND status = 'PENDING' AND version_name NOT LIKE 'vip-%'
ORDER BY created_at ASC
""", cluster_id)
# CRITICAL FIX: Detect and clean orphan config versions before apply
# Orphan versions reference entities that don't belong to this cluster (ID reuse bug)
orphan_version_ids = []
import re
for v in pending_versions:
is_orphan = False
# Check if backend version references a backend NOT in this cluster
m_be = re.search(r'^backend-(\d+)-', v['version_name'])
if m_be:
be_id = int(m_be.group(1))
backend_exists = await conn.fetchrow("""
SELECT id, cluster_id FROM backends WHERE id = $1
""", be_id)
# If backend doesn't exist, version is orphan (entity was hard deleted)
if backend_exists is None:
is_orphan = True
logger.warning(f"ORPHAN VERSION: {v['version_name']} references non-existent backend {be_id}")
# If backend exists but cluster_id is NULL, preserve version (legacy data)
elif backend_exists['cluster_id'] is None:
logger.debug(f"Backend {be_id} has NULL cluster_id (legacy), preserving version")
is_orphan = False
# If backend belongs to different cluster, version is orphan
elif backend_exists['cluster_id'] != cluster_id:
is_orphan = True
logger.warning(f"ORPHAN VERSION: {v['version_name']} references backend {be_id} from cluster {backend_exists['cluster_id']}, but version is in cluster {cluster_id}")
# Check if frontend version references a frontend NOT in this cluster
m_fe = re.search(r'^frontend-(\d+)-', v['version_name'])
if m_fe:
fe_id = int(m_fe.group(1))
frontend_exists = await conn.fetchrow("""
SELECT id, cluster_id FROM frontends WHERE id = $1
""", fe_id)
# If frontend doesn't exist, version is orphan (entity was hard deleted)
if frontend_exists is None:
is_orphan = True
logger.warning(f"ORPHAN VERSION: {v['version_name']} references non-existent frontend {fe_id}")
# If frontend exists but cluster_id is NULL, preserve version (legacy data)
elif frontend_exists['cluster_id'] is None:
logger.debug(f"Frontend {fe_id} has NULL cluster_id (legacy), preserving version")
is_orphan = False
# If frontend belongs to different cluster, version is orphan
elif frontend_exists['cluster_id'] != cluster_id:
is_orphan = True
logger.warning(f"ORPHAN VERSION: {v['version_name']} references frontend {fe_id} from cluster {frontend_exists['cluster_id']}, but version is in cluster {cluster_id}")
if is_orphan:
orphan_version_ids.append(v['id'])
# Delete orphan versions immediately (they're invalid and prevent apply)
if orphan_version_ids:
await conn.execute("""
DELETE FROM config_versions WHERE id = ANY($1)
""", orphan_version_ids)
logger.info(f"APPLY: Deleted {len(orphan_version_ids)} orphan config versions")
# Remove orphans from pending_versions list
pending_versions = [v for v in pending_versions if v['id'] not in orphan_version_ids]
# If UI-side entity lists are empty but versions exist (e.g., restore-*), proceed anyway
if not pending_versions:
return {"message": "No pending changes to apply", "applied_count": 0}
# DEPRECATED: Old global SSL auto-apply logic (replaced by apply_ssl_related_configs)
# The new implementation handles SSL scope-aware apply directly within the transaction
# This old code is kept commented for reference but is no longer needed
# global_ssl_cert_ids = []
global_ssl_cert_ids = [] # Keep empty to disable old recursive SSL apply logic
other_clusters_to_apply = [] # Keep empty to disable old recursive SSL apply logic
# Old logic is replaced by apply_ssl_related_configs() which runs inside transaction
# Check for explicit confirmation if required (optional parameter)
# Default behavior: apply immediately unless explicitly set to require confirmation
requires_confirmation = apply_request and apply_request.get('require_confirmation', False)
confirmed = apply_request and apply_request.get('confirmed', False) if requires_confirmation else True
if requires_confirmation and not confirmed:
await close_database_connection(conn)
return {
"message": "Apply confirmation required",
"pending_changes": len(pending_versions),
"requires_confirmation": True,
"changes": [{"version_name": v["version_name"], "created_at": v["created_at"].isoformat().replace('+00:00', 'Z')} for v in pending_versions]
}
# Bulgu #80 (round-22 audit) — serialise concurrent
# apply-changes against the SAME cluster. Pre-fix the
# entire apply pipeline was unprotected: two operators
# clicking Apply at the same instant (or one operator
# double-clicking from two tabs / an API client retrying
# on timeout) both raced through the "fetch pending
# versions → render consolidated config → INSERT APPLIED
# version → mark pending APPLIED → notify agents"
# pipeline. The DB ended up with two
# `APPLIED`/`is_active=TRUE` consolidated rows for the
# same cluster, both agent notifications fired, and the
# agent that pulled second silently overwrote whatever
# the first one had loaded — including the case where
# the two consolidated configs disagreed on which
# pending versions made it in.
#
# `pg_advisory_xact_lock` is the same primitive
# `site_wizard.py::create_site` already uses for
# per-cluster serialisation (Bulgu #54). The lock is
# automatically released on COMMIT or ROLLBACK, so we
# don't need an explicit `unlock` path. We use a unique
# namespace constant (`18181820`) so the lock space is
# disjoint from the wizard's draft-cap and create-site
# lock spaces. Concurrent callers BLOCK until the
# holder finishes; they then re-check the pending list
# and bail out cleanly when it's drained.
#
# The lock must live inside an explicit transaction
# (xact_lock semantics require it), so we open the
# apply transaction immediately around the lock + the
# pending-list re-fetch. The post-lock re-fetch is
# critical: the racing caller's first read happened
# BEFORE the lock was held; the rows may have been
# consolidated by the holder in the meantime.
APPLY_LOCK_NS = 18181820
# Risk-audit refinement (post-Bulgu-#80): the earlier
# implementation split the lock acquisition and the
# apply pipeline into TWO advisory-locked transactions
# with a brief gap between them. Because
# `pg_advisory_xact_lock` releases automatically on
# COMMIT, a concurrent caller could squeeze in during
# that gap, drain all PENDING versions, and the second
# transaction would proceed to ship a fresh config
# generated from the (now-already-applied) database
# state — producing a duplicate consolidated APPLIED
# version and a redundant agent notification. We
# collapse to ONE locked transaction: acquire the
# lock, RE-FETCH `pending_versions` under the lock
# (the outer fetch at line ~1402 is now stale-as-of
# pre-lock), filter previously-detected orphans, and
# bail via a sentinel exception if the list drained.
# Rollback on the sentinel keeps the no-op idempotent
# because no APPLY state has been written yet at that
# point. All operations that actually mutate state run
# AFTER this re-fetch inside the same locked TX.
async with conn.transaction():
await conn.execute(
"SELECT pg_advisory_xact_lock($1, $2)",
APPLY_LOCK_NS,
cluster_id,
)
pending_versions_locked = await conn.fetch("""
SELECT id, version_name, created_at, config_content, checksum, metadata
FROM config_versions
WHERE cluster_id = $1 AND status = 'PENDING'
ORDER BY created_at ASC
""", cluster_id)
pending_versions_locked = [
v for v in pending_versions_locked
if v['id'] not in orphan_version_ids
]
if not pending_versions_locked:
# Concurrent caller already drained — bail
# via the sentinel exception. The `async
# with conn.transaction():` rolls back on
# the propagating exception, releasing the
# advisory lock cleanly. The outer
# `except _ConcurrentlyDrained:` (defined
# before the generic catch-all) returns the
# standard "nothing to apply" response.
raise _ConcurrentlyDrained()
# Overwrite the pre-lock snapshot so every
# downstream consumer (restore detection,
# consolidated-version metadata, agent
# notification payloads) operates on the
# authoritative locked view.
pending_versions = pending_versions_locked
# CRITICAL: SSL scope-aware apply (MUST be inside transaction for atomicity)
# SSL update'lerde tek Apply tıklaması ile scope'daki tüm cluster'lara yayılır
# Global SSL: Tüm cluster'lar, Cluster-specific SSL: İlgili cluster'lar
await apply_ssl_related_configs(conn, cluster_id)
# Mark all current active versions as inactive AND clear validation errors
# CRITICAL UX FIX: Clear validation_error when creating new version
# This ensures UI doesn't show stale validation errors from previous apply attempts
# Each Apply creates a fresh start - if validation fails again, agent will report new error
await conn.execute("""
UPDATE config_versions
SET is_active = FALSE,
validation_error = NULL,
validation_error_reported_at = NULL
WHERE cluster_id = $1 AND is_active = TRUE
""", cluster_id)
logger.info(f"APPLY: Cleared validation errors from previous active versions for cluster {cluster_id}")
# Determine config content to apply
# Check if any pending version is a restore operation with existing config content
restore_version = None
for version in pending_versions:
if version['version_name'].startswith('restore-') and version['config_content']:
restore_version = version
break
# SNAPSHOT: Get current applied config before applying changes (for diff baseline)
# Don't rely on is_active flag as it gets updated during apply process
pre_apply_config = None
current_active_version = await conn.fetchrow("""
SELECT config_content, version_name
FROM config_versions
WHERE cluster_id = $1 AND status = 'APPLIED' AND config_content IS NOT NULL
ORDER BY created_at DESC
LIMIT 1
""", cluster_id)
if current_active_version and current_active_version['config_content']:
pre_apply_config = current_active_version['config_content']
logger.info(f"APPLY: Found pre-apply baseline: {current_active_version['version_name']}")
else:
# Create minimal baseline if no previous config exists
pre_apply_config = """global
daemon
log stdout local0
defaults
mode http
timeout connect 5000ms
timeout client 50000ms
timeout server 50000ms
"""
logger.info(f"APPLY: No previous config found, using minimal baseline")
if restore_version:
# RESTORE OPERATION: Use the restored config content directly
logger.info(f"RESTORE APPLY: Using restored config content from version {restore_version['version_name']}")
fresh_config_content = restore_version['config_content']
else:
# NORMAL OPERATION: Generate fresh config from current database state
# CRITICAL FIX: Before generating config, sync APPLIED entities with last consolidated config
# This prevents non-PENDING changes from appearing in consolidated version diff
logger.info(f"DB SYNC: Syncing APPLIED entities with last consolidated config before generating fresh config")
# Get last consolidated config for baseline
last_consolidated = await conn.fetchrow("""
SELECT config_content FROM config_versions
WHERE cluster_id = $1 AND status = 'APPLIED'
AND version_name LIKE 'apply-consolidated-%'
ORDER BY created_at DESC LIMIT 1
""", cluster_id)
if last_consolidated and last_consolidated['config_content']:
try:
from utils.haproxy_config_parser import HAProxyConfigParser
parser = HAProxyConfigParser()
parser.parse(last_consolidated['config_content'])
logger.info(f"DB SYNC: Parsed last consolidated config - {len(parser.frontends)} frontends, {len(parser.backends)} backends")
# Sync APPLIED frontends with config baseline
for parsed_fe in parser.frontends:
await conn.execute("""
UPDATE frontends SET
bind_address = $1,
bind_port = $2,
default_backend = $3,
mode = $4,
ssl_enabled = $5,
ssl_port = $6,
maxconn = $7,
timeout_client = $8
WHERE name = $9 AND cluster_id = $10
AND last_config_status = 'APPLIED'
""", parsed_fe.bind_address, parsed_fe.bind_port,
parsed_fe.default_backend, parsed_fe.mode,
parsed_fe.ssl_enabled, parsed_fe.ssl_port,
parsed_fe.maxconn, parsed_fe.timeout_client,
parsed_fe.name, cluster_id)
# Sync APPLIED backends with config baseline
for parsed_be in parser.backends:
await conn.execute("""
UPDATE backends SET
balance_method = $1,
mode = $2,
health_check_uri = $3,
health_check_interval = $4,
timeout_connect = $5,
timeout_server = $6,
timeout_queue = $7
WHERE name = $8 AND cluster_id = $9
AND last_config_status = 'APPLIED'
""", parsed_be.balance_method, parsed_be.mode,
parsed_be.health_check_uri, parsed_be.health_check_interval,
parsed_be.timeout_connect, parsed_be.timeout_server, parsed_be.timeout_queue,
parsed_be.name, cluster_id)
# Sync APPLIED backend servers with config baseline
# CRITICAL: Preserve PENDING servers! Only sync APPLIED servers.
if parsed_be.servers:
# Get list of server names from parsed config
parsed_server_names = {s.name for s in parsed_be.servers}
# Delete APPLIED servers that are NOT in parsed config
# (they were deleted in previous operations)
# CRITICAL: DO NOT delete PENDING servers!
await conn.execute("""
DELETE FROM backend_servers
WHERE backend_name = $1 AND cluster_id = $2
AND last_config_status = 'APPLIED'
AND server_name NOT IN (SELECT unnest($3::text[]))
""", parsed_be.name, cluster_id, list(parsed_server_names))
# Upsert servers from config (create or update APPLIED servers only)
for server in parsed_be.servers:
await conn.execute("""
INSERT INTO backend_servers
(backend_name, cluster_id, server_name, server_address, server_port,
weight, maxconn, check_enabled, backup_server, ssl_enabled,
is_active, last_config_status)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, TRUE, 'APPLIED')
ON CONFLICT (backend_name, server_name, cluster_id) DO UPDATE SET
server_address = EXCLUDED.server_address,
server_port = EXCLUDED.server_port,
weight = EXCLUDED.weight,
maxconn = EXCLUDED.maxconn,
check_enabled = EXCLUDED.check_enabled,
backup_server = EXCLUDED.backup_server,
ssl_enabled = EXCLUDED.ssl_enabled,
is_active = EXCLUDED.is_active,
last_config_status = 'APPLIED'
WHERE backend_servers.last_config_status = 'APPLIED'
""", parsed_be.name, cluster_id, server.name, server.address, server.port,
server.weight, server.maxconn, server.check, server.backup, server.ssl)
# Sync APPLIED WAF rules with config baseline (parse config + update DB)
import re
import json
config_lines = last_consolidated['config_content'].split('\n')
# Parse WAF rules from config with their parameters
waf_configs = {} # {rule_name: {is_active: True, config: {...}}}
current_waf_name = None
i = 0
while i < len(config_lines):
line = config_lines[i].strip()
# Find WAF rule comment
if line.startswith('# WAF Rule:'):
match = re.search(r'# WAF Rule: ([\w\s\-]+)', line)
if match:
current_waf_name = match.group(1).strip()
waf_configs[current_waf_name] = {
'is_active': True,
'config': {}
}
# Parse next few lines for WAF config parameters
# Look ahead for stick-table and http-request lines
for j in range(i+1, min(i+10, len(config_lines))):
next_line = config_lines[j].strip()
# Parse stick-table line: expire and rate_limit_window
if 'stick-table' in next_line:
# Extract expire time: expire 66s
expire_match = re.search(r'expire\s+(\d+)s', next_line)
if expire_match:
waf_configs[current_waf_name]['config']['rate_limit_window'] = int(expire_match.group(1))
# Extract table_expire: http_req_rate(10s)
table_expire_match = re.search(r'http_req_rate\((\d+)s\)', next_line)
if table_expire_match:
waf_configs[current_waf_name]['config']['table_expire'] = int(table_expire_match.group(1))
# Extract table size: size 100k
size_match = re.search(r'size\s+(\w+)', next_line)
if size_match:
waf_configs[current_waf_name]['config']['table_size'] = size_match.group(1)
# Parse deny line: rate_limit_requests
elif 'http-request deny if' in next_line or 'http-request tarpit if' in next_line:
# Extract rate limit: gt 101
rate_match = re.search(r'gt\s+(\d+)', next_line)
if rate_match:
waf_configs[current_waf_name]['config']['rate_limit_requests'] = int(rate_match.group(1))
# Stop when we hit another WAF rule or different section
elif next_line.startswith('# WAF Rule:') or next_line.startswith('frontend ') or next_line.startswith('backend '):
break
i += 1
logger.info(f"DB SYNC: Parsed {len(waf_configs)} WAF rules with config from last config")
# Get all APPLIED WAF rules for this cluster
applied_waf_rules = await conn.fetch("""
SELECT id, name, is_active, config
FROM waf_rules
WHERE cluster_id = $1 AND last_config_status = 'APPLIED'
""", cluster_id)
# Sync WAF rules: Update is_active AND config from parsed config
for waf_rule in applied_waf_rules:
if waf_rule['name'] in waf_configs:
parsed_waf = waf_configs[waf_rule['name']]
should_be_active = parsed_waf['is_active']
parsed_config = parsed_waf['config']
# Merge parsed config with existing config (preserve fields we didn't parse)
current_config = waf_rule['config'] or {}
if isinstance(current_config, str):
current_config = json.loads(current_config) if current_config else {}
# Update only the fields we parsed from config
updated_config = current_config.copy()
updated_config.update(parsed_config)
# Update both is_active and config in DB
await conn.execute("""
UPDATE waf_rules
SET is_active = $1, config = $2
WHERE id = $3 AND cluster_id = $4
AND last_config_status = 'APPLIED'
""", should_be_active, json.dumps(updated_config), waf_rule['id'], cluster_id)
logger.info(f"DB SYNC: WAF rule '{waf_rule['name']}' synced - is_active: {should_be_active}, config: {parsed_config}")
else:
# WAF rule not in config, should be inactive
if waf_rule['is_active']:
await conn.execute("""
UPDATE waf_rules SET is_active = FALSE
WHERE id = $1 AND cluster_id = $2
AND last_config_status = 'APPLIED'
""", waf_rule['id'], cluster_id)
logger.info(f"DB SYNC: WAF rule '{waf_rule['name']}' deactivated (not in config)")
logger.info(f"DB SYNC: Synced {len(parser.frontends)} APPLIED frontends, {len(parser.backends)} APPLIED backends, and {len(applied_waf_rules)} APPLIED WAF rules with last config")
except Exception as sync_error:
logger.warning(f"DB SYNC: Failed to sync APPLIED entities with last config: {sync_error}")
logger.warning(f"DB SYNC: Continuing with current DB state (may show non-PENDING changes in diff)")
# Now generate fresh config from database (DB is now synced with last config)
from services.haproxy_config import generate_haproxy_config_for_cluster
logger.info(f"🧩 APPLY: Generating fresh configuration from database for cluster {cluster_id}")
fresh_config_content = await generate_haproxy_config_for_cluster(cluster_id, conn)
# `generate_haproxy_config_for_cluster` reports failure by RETURNING a
# one-line comment instead of raising (haproxy_config.py outer `except`).
# Without this guard that sentinel is hashed, stored as an APPLIED version
# and shipped to every agent — silently replacing the cluster's entire
# configuration with a comment. Any generator exception (an out-of-range
# port in acme_backend_url is enough) triggers it. Refuse the apply instead;
# the previous APPLIED version stays in force.
from services.haproxy_config import is_config_generation_error
if is_config_generation_error(fresh_config_content):
logger.error(
f"APPLY ABORTED: config generation for cluster {cluster_id} returned an "
f"error sentinel instead of a configuration: {fresh_config_content!r}"
)
raise HTTPException(
status_code=422,
detail=(
"Configuration could not be generated for this cluster, so nothing "
"was applied and the running configuration is unchanged. "
f"Generator reported: {fresh_config_content.strip()[:300]}"
),
)
# Create a new consolidated config version with fresh content
import hashlib
import time
config_hash = hashlib.sha256(fresh_config_content.encode()).hexdigest()
version_name = f"apply-consolidated-{int(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 the consolidated config version with pre-apply snapshot for diff
import json
metadata = {'pre_apply_snapshot': pre_apply_config} if 'pre_apply_config' in locals() and pre_apply_config else None
# CRITICAL FIX: For restore operations, copy metadata from restore_version to new consolidated version
# This ensures changed_entities metadata is available for selective entity sync in UI
if restore_version:
logger.info(f"RESTORE DEBUG: restore_version exists, checking metadata...")
logger.info(f"RESTORE DEBUG: restore_version.get('metadata') = {restore_version.get('metadata') is not None}")
if restore_version.get('metadata'):
restore_metadata = json.loads(restore_version['metadata']) if isinstance(restore_version['metadata'], str) else restore_version['metadata']
logger.info(f"RESTORE DEBUG: Parsed metadata, has changed_entities: {'changed_entities' in restore_metadata}")
if metadata is None:
metadata = {}
# Merge restore metadata into consolidated version metadata
metadata.update(restore_metadata)
logger.info(f"RESTORE METADATA: Copied changed_entities metadata from restore version to consolidated version")
logger.info(f"RESTORE METADATA: Changed entities count: {len(restore_metadata.get('changed_entities', []))}")
else:
logger.warning(f"RESTORE WARNING: restore_version exists but metadata is NULL/empty!")
else:
logger.info(f"RESTORE DEBUG: No restore_version, normal apply operation")
logger.info(f"APPLY: Saving metadata with pre_apply_config: {metadata is not None}")
if metadata:
logger.info(f"APPLY: Pre-apply snapshot length: {len(metadata.get('pre_apply_snapshot', ''))}")
consolidated_version_id = await conn.fetchval("""
INSERT INTO config_versions
(cluster_id, version_name, config_content, checksum, created_by, is_active, status, metadata)
VALUES ($1, $2, $3, $4, $5, TRUE, 'APPLIED', $6)
RETURNING id
""", cluster_id, version_name, fresh_config_content, config_hash, admin_user_id,
json.dumps(metadata) if metadata else None)
# Mark all old pending versions as applied but inactive (keep for history)
pending_version_ids = [v["id"] for v in pending_versions]
# CRITICAL FIX: Mark old pending versions as APPLIED so they don't show in UI as pending
# The new consolidated version is immediately APPLIED - Agent Sync tracked separately
await conn.execute("""
UPDATE config_versions
SET is_active = FALSE, status = 'APPLIED'
WHERE id = ANY($1)
""", pending_version_ids)
logger.info(f"APPLY: Created consolidated config version {version_name} with fresh content for cluster {cluster_id}")
# CRITICAL FIX: Only update timestamps for entities that actually have PENDING changes
# This prevents unnecessary sync resets for unmodified entities
# Update only frontends with PENDING status
await conn.execute("""
UPDATE frontends SET updated_at = CURRENT_TIMESTAMP, last_config_status = 'APPLIED'
WHERE cluster_id = $1 AND last_config_status = 'PENDING'
""", cluster_id)
logger.info(f"APPLY: Updated timestamps only for PENDING frontends in cluster {cluster_id}")
# Update only backends with PENDING status
# REVERTED: Now ALL backends are written to config (even without servers)
# Config generator writes backend block for all backends (servers optional)
await conn.execute("""
UPDATE backends SET updated_at = CURRENT_TIMESTAMP, last_config_status = 'APPLIED'
WHERE cluster_id = $1 AND last_config_status = 'PENDING'
""", cluster_id)
logger.info(f"APPLY: Updated timestamps only for PENDING backends in cluster {cluster_id}")
# Update only WAF rules with PENDING status
await conn.execute("""
UPDATE waf_rules SET updated_at = CURRENT_TIMESTAMP, last_config_status = 'APPLIED'
WHERE cluster_id = $1 AND last_config_status = 'PENDING'
""", cluster_id)
logger.info(f"APPLY: Updated timestamps only for PENDING WAF rules in cluster {cluster_id}")
# Update only SSL certificates with PENDING status (via junction table)
# Update cluster-specific SSL certificates with PENDING status
ssl_cluster_update = await conn.execute("""
UPDATE ssl_certificates
SET updated_at = CURRENT_TIMESTAMP, last_config_status = 'APPLIED'
WHERE id IN (
SELECT scc.ssl_certificate_id
FROM ssl_certificate_clusters scc
WHERE scc.cluster_id = $1
)
AND last_config_status = 'PENDING'
""", cluster_id)
# Also update global SSL certificates (those not in junction table)
ssl_global_update = await conn.execute("""
UPDATE ssl_certificates
SET updated_at = CURRENT_TIMESTAMP, last_config_status = 'APPLIED'
WHERE NOT EXISTS (
SELECT 1 FROM ssl_certificate_clusters scc
WHERE scc.ssl_certificate_id = ssl_certificates.id
)
AND last_config_status = 'PENDING'
""")
logger.info(f"APPLY: Updated timestamps for PENDING SSL certificates (cluster-specific and global) in cluster {cluster_id}")
# Update only backend servers with PENDING status
try:
await conn.execute("""
UPDATE backend_servers SET updated_at = CURRENT_TIMESTAMP, last_config_status = 'APPLIED'
WHERE cluster_id = $1 AND last_config_status = 'PENDING'
""", cluster_id)
logger.info(f"APPLY: Updated timestamps only for PENDING backend servers in cluster {cluster_id}")
except Exception as e:
logger.info(f"APPLY: backend_servers.last_config_status column not found, skipping: {e}")
# Note: Entity status updates are now handled above with timestamp updates
# This prevents unnecessary sync resets for unmodified entities
# Entity status updates and timestamp management moved above
# to prevent unnecessary sync resets for unmodified entities
# RESTORE SYNC: If this was a restore operation, sync database entities with restored config
if restore_version:
logger.info(f"RESTORE SYNC: Syncing database entities with restored config for cluster {cluster_id}")
try:
# Parse the restored config to identify which entities should be active
config_lines = fresh_config_content.split('\n')
# Find active entities in the config
active_waf_rules = set()
active_frontends = set()
active_backends = set()
current_section = None
for line in config_lines:
line = line.strip()
# Track current section
if line.startswith('frontend '):
current_section = 'frontend'
frontend_name = line.split(' ', 1)[1]
active_frontends.add(frontend_name)
elif line.startswith('backend '):
current_section = 'backend'
backend_name = line.split(' ', 1)[1]
active_backends.add(backend_name)
elif line.startswith('global') or line.startswith('defaults'):
current_section = line
# Find WAF rules in comments
if line.startswith('# WAF Rule:'):
import re
match = re.search(r'# WAF Rule: (\w+)', line)
if match:
active_waf_rules.add(match.group(1))
# Mark WAF rules that are NOT in the config as inactive
if active_waf_rules:
inactive_count = await conn.fetchval("""
UPDATE waf_rules
SET is_active = FALSE, updated_at = CURRENT_TIMESTAMP
WHERE cluster_id = $1 AND name NOT IN (SELECT unnest($2::text[])) AND is_active = TRUE
RETURNING (SELECT COUNT(*) FROM waf_rules WHERE cluster_id = $1 AND is_active = FALSE)
""", cluster_id, list(active_waf_rules))
else:
# No WAF rules in config, mark all as inactive
inactive_count = await conn.fetchval("""
UPDATE waf_rules
SET is_active = FALSE, updated_at = CURRENT_TIMESTAMP
WHERE cluster_id = $1 AND is_active = TRUE
RETURNING (SELECT COUNT(*) FROM waf_rules WHERE cluster_id = $1 AND is_active = FALSE)
""", cluster_id)
logger.info(f"RESTORE SYNC: Marked {inactive_count or 0} WAF rules as inactive for cluster {cluster_id}")
# Sync frontends
if active_frontends:
fe_inactive_count = await conn.fetchval("""
UPDATE frontends
SET is_active = FALSE, updated_at = CURRENT_TIMESTAMP
WHERE cluster_id = $1 AND name NOT IN (SELECT unnest($2::text[])) AND is_active = TRUE
RETURNING (SELECT COUNT(*) FROM frontends WHERE cluster_id = $1 AND is_active = FALSE)
""", cluster_id, list(active_frontends))
else:
fe_inactive_count = await conn.fetchval("""
UPDATE frontends
SET is_active = FALSE, updated_at = CURRENT_TIMESTAMP
WHERE cluster_id = $1 AND is_active = TRUE
RETURNING (SELECT COUNT(*) FROM frontends WHERE cluster_id = $1 AND is_active = FALSE)
""", cluster_id)
logger.info(f"RESTORE SYNC: Marked {fe_inactive_count or 0} frontends as inactive for cluster {cluster_id}")
# CRITICAL FIX: Restore active status for frontends that should remain active
if active_frontends:
fe_reactivated_count = await conn.fetchval("""
UPDATE frontends
SET is_active = TRUE, updated_at = CURRENT_TIMESTAMP
WHERE cluster_id = $1 AND name IN (SELECT unnest($2::text[])) AND is_active = FALSE
RETURNING (SELECT COUNT(*) FROM frontends WHERE cluster_id = $1 AND is_active = TRUE)
""", cluster_id, list(active_frontends))
logger.info(f"APPLY FIX: Reactivated {fe_reactivated_count or 0} frontends for cluster {cluster_id}")
# Sync backends
if active_backends:
be_inactive_count = await conn.fetchval("""
UPDATE backends
SET is_active = FALSE, updated_at = CURRENT_TIMESTAMP
WHERE cluster_id = $1 AND name NOT IN (SELECT unnest($2::text[])) AND is_active = TRUE
RETURNING (SELECT COUNT(*) FROM backends WHERE cluster_id = $1 AND is_active = FALSE)
""", cluster_id, list(active_backends))
else:
be_inactive_count = await conn.fetchval("""
UPDATE backends
SET is_active = FALSE, updated_at = CURRENT_TIMESTAMP
WHERE cluster_id = $1 AND is_active = TRUE
RETURNING (SELECT COUNT(*) FROM backends WHERE cluster_id = $1 AND is_active = FALSE)
""", cluster_id)
logger.info(f"RESTORE SYNC: Marked {be_inactive_count or 0} backends as inactive for cluster {cluster_id}")
# CRITICAL FIX: Restore active status for backends that should remain active
if active_backends:
be_reactivated_count = await conn.fetchval("""
UPDATE backends
SET is_active = TRUE, updated_at = CURRENT_TIMESTAMP
WHERE cluster_id = $1 AND name IN (SELECT unnest($2::text[])) AND is_active = FALSE
RETURNING (SELECT COUNT(*) FROM backends WHERE cluster_id = $1 AND is_active = TRUE)
""", cluster_id, list(active_backends))
logger.info(f"APPLY FIX: Reactivated {be_reactivated_count or 0} backends for cluster {cluster_id}")
except Exception as sync_error:
logger.warning(f"RESTORE SYNC WARNING: Could not sync entities with restored config: {sync_error}")
# Post-apply cleanup: Hard delete inactive entities that were processed during Apply
# CRITICAL FIX: Delete backend_servers BEFORE backends to prevent orphan data
# When backends are soft-deleted from UI, their servers are also soft-deleted,
# but server cleanup only happens for servers with explicit delete versions.
# We must clean up orphaned inactive servers belonging to inactive backends first.
inactive_backend_names = await conn.fetch(
"SELECT name FROM backends WHERE cluster_id = $1 AND is_active = FALSE", cluster_id
)
if inactive_backend_names:
names_list = [row['name'] for row in inactive_backend_names]
await conn.execute("""
DELETE FROM backend_servers
WHERE backend_name = ANY($1) AND cluster_id = $2
""", names_list, cluster_id)
logger.info(f"APPLY CLEANUP: Deleted orphan servers for {len(names_list)} inactive backends in cluster {cluster_id}")
await conn.execute("DELETE FROM backends WHERE cluster_id = $1 AND is_active = FALSE", cluster_id)
await conn.execute("DELETE FROM frontends WHERE cluster_id = $1 AND is_active = FALSE", cluster_id)
# Post-apply cleanup for WAF rules marked for deletion
# Match names like: waf-<rule_id>-delete-<timestamp>
waf_delete_versions = [
v for v in pending_versions
if v['version_name'].startswith('waf-') and '-delete-' in v['version_name']
]
if waf_delete_versions:
waf_ids_to_delete = []
for v in waf_delete_versions:
match = re.search(r'^waf-(\d+)-delete-', v['version_name'])
if match:
waf_ids_to_delete.append(int(match.group(1)))
if waf_ids_to_delete:
await conn.execute("DELETE FROM waf_rules WHERE id = ANY($1)", waf_ids_to_delete)
logger.info(f"APPLY CLEANUP: Hard deleted WAF rules with IDs: {waf_ids_to_delete}")
# Post-apply cleanup for servers marked for deletion
# Match names like: server-<server_id>-delete-<timestamp>
server_delete_versions = [
v for v in pending_versions
if v['version_name'].startswith('server-') and '-delete-' in v['version_name']
]
if server_delete_versions:
import re
server_ids_to_delete = []
for v in server_delete_versions:
match = re.search(r'^server-(\d+)-delete-', v['version_name'])
if match:
server_ids_to_delete.append(int(match.group(1)))
if server_ids_to_delete:
# Hard delete servers that were marked for deletion
await conn.execute("DELETE FROM backend_servers WHERE id = ANY($1)", server_ids_to_delete)
logger.info(f"APPLY CLEANUP: Hard deleted servers with IDs: {server_ids_to_delete}")
logger.info(f"APPLY CLEANUP: Hard deleted inactive entities for cluster {cluster_id}")
# CRITICAL FIX: Update ALL entities that are still PENDING or REJECTED to APPLIED after successful Apply
# This handles cases where entities were marked PENDING but not included in specific ID lists
# (e.g., parent backends marked PENDING due to server updates)
# REJECTED entities are also marked as APPLIED because applying new changes supersedes rejection
# This must run OUTSIDE the restore block to work for all Apply operations
try:
await conn.execute("""
UPDATE backends
SET last_config_status = 'APPLIED', updated_at = CURRENT_TIMESTAMP
WHERE cluster_id = $1 AND last_config_status IN ('PENDING', 'REJECTED')
""", cluster_id)
logger.info(f"APPLY CLEANUP: Updated all PENDING/REJECTED backends to APPLIED status")
await conn.execute("""
UPDATE frontends
SET last_config_status = 'APPLIED', updated_at = CURRENT_TIMESTAMP
WHERE cluster_id = $1 AND last_config_status IN ('PENDING', 'REJECTED')
""", cluster_id)
logger.info(f"APPLY CLEANUP: Updated all PENDING/REJECTED frontends to APPLIED status")
await conn.execute("""
UPDATE waf_rules
SET last_config_status = 'APPLIED', updated_at = CURRENT_TIMESTAMP
WHERE cluster_id = $1 AND last_config_status IN ('PENDING', 'REJECTED')
""", cluster_id)
logger.info(f"APPLY CLEANUP: Updated all PENDING/REJECTED WAF rules to APPLIED status")
# Backend servers might not have last_config_status column in all DB versions
try:
await conn.execute("""
UPDATE backend_servers
SET last_config_status = 'APPLIED', updated_at = CURRENT_TIMESTAMP
WHERE cluster_id = $1 AND last_config_status IN ('PENDING', 'REJECTED')
""", cluster_id)
logger.info(f"APPLY CLEANUP: Updated all PENDING/REJECTED servers to APPLIED status")
except Exception as e:
logger.info(f"APPLY CLEANUP: backend_servers.last_config_status column not found, skipping: {e}")
try:
# Update SSL certificates via junction table since ssl_certificates.cluster_id is always NULL
ssl_update_result = await conn.execute("""
UPDATE ssl_certificates
SET last_config_status = 'APPLIED', updated_at = CURRENT_TIMESTAMP
WHERE id IN (
SELECT scc.ssl_certificate_id
FROM ssl_certificate_clusters scc
WHERE scc.cluster_id = $1
)
AND last_config_status = 'PENDING'
""", cluster_id)
# Also update global SSL certificates (those not in junction table)
global_ssl_update_result = await conn.execute("""
UPDATE ssl_certificates
SET last_config_status = 'APPLIED', updated_at = CURRENT_TIMESTAMP
WHERE NOT EXISTS (
SELECT 1 FROM ssl_certificate_clusters scc
WHERE scc.ssl_certificate_id = ssl_certificates.id
)
AND last_config_status = 'PENDING'
""")
logger.info(f"APPLY CLEANUP: Updated cluster-specific SSL certificates to APPLIED status")
logger.info(f"APPLY CLEANUP: Updated global SSL certificates to APPLIED status")
except Exception as e:
logger.info(f"APPLY CLEANUP: ssl_certificates.last_config_status column not found, skipping: {e}")
except Exception as cleanup_error:
logger.error(f"APPLY CLEANUP FAILED: {cleanup_error}")
await close_database_connection(conn)
# CRITICAL FIX: Notify agents to pull new config and reload HAProxy
from agent_notifications import notify_agents_config_change
sync_results = []
try:
sync_results = await notify_agents_config_change(cluster_id, version_name)
logger.info(f"AGENT NOTIFICATION: Notified {len(sync_results)} agents for cluster {cluster_id}")
except Exception as notification_error:
logger.error(f"AGENT NOTIFICATION FAILED: {notification_error}")
sync_results = [{'error': 'Agent notification failed', 'details': str(notification_error)}]
# Log user activity
if current_user and current_user.get('id'):
await log_user_activity(
user_id=current_user['id'],
action='apply_changes',
resource_type='cluster',
resource_id=str(cluster_id),
details={
'applied_versions': len(pending_versions),
'version_names': [v["version_name"] for v in pending_versions],
'agents_notified': len(sync_results)
}
)
logger.info(f"APPLY: Applied {len(pending_versions)} pending changes for cluster {cluster_id}, created consolidated version {version_name}")
# GLOBAL SSL AUTO-APPLY: If we detected global SSL updates, automatically apply to other clusters
# Safety check: Only do auto-apply from the originally triggered cluster (prevent infinite recursion)
is_auto_apply = apply_request and apply_request.get('_auto_apply_from_global_ssl', False)
global_apply_results = []
if other_clusters_to_apply and not is_auto_apply:
logger.info(f"GLOBAL SSL AUTO-APPLY: Starting automatic apply for {len(other_clusters_to_apply)} additional clusters...")
for other_cluster in other_clusters_to_apply:
try:
logger.info(f"GLOBAL SSL AUTO-APPLY: Applying to cluster '{other_cluster['name']}' (ID: {other_cluster['id']})")
# Recursively call apply_pending_changes for other cluster
# Mark as auto-apply to prevent infinite recursion
auto_apply_request = apply_request.copy() if apply_request else {}
auto_apply_request['_auto_apply_from_global_ssl'] = True
other_result = await apply_pending_changes(
cluster_id=other_cluster['id'],
apply_request=auto_apply_request,
authorization=authorization
)
global_apply_results.append({
'cluster_id': other_cluster['id'],
'cluster_name': other_cluster['name'],
'success': True,
'applied_count': other_result.get('applied_count', 0),
'message': f"Global SSL updates applied successfully"
})
logger.info(f"GLOBAL SSL AUTO-APPLY: Successfully applied to cluster '{other_cluster['name']}'")
except Exception as e:
logger.error(f"GLOBAL SSL AUTO-APPLY: Failed for cluster '{other_cluster['name']}': {e}")
global_apply_results.append({
'cluster_id': other_cluster['id'],
'cluster_name': other_cluster['name'],
'success': False,
'error': str(e),
'message': f"Failed to apply global SSL updates"
})
logger.info(f"GLOBAL SSL AUTO-APPLY: Completed. Success: {sum(1 for r in global_apply_results if r['success'])}/{len(global_apply_results)}")
response_data = {
"message": f"Successfully applied {len(pending_versions)} pending changes with fresh configuration",
"applied_count": len(pending_versions),
"latest_version": version_name,
"consolidated_version_id": consolidated_version_id,
"sync_results": sync_results,
"agents_notified": len(sync_results)
}
# Add global SSL apply results if any
if global_apply_results:
response_data["global_ssl_applied"] = {
"total_clusters": len(global_apply_results),
"successful": sum(1 for r in global_apply_results if r['success']),
"failed": sum(1 for r in global_apply_results if not r['success']),
"results": global_apply_results
}
response_data["message"] += f" (+ {sum(1 for r in global_apply_results if r['success'])} other clusters with global SSL)"
return response_data
except _ConcurrentlyDrained:
# Risk-audit follow-up to Bulgu-#80: a concurrent
# apply caller drained the PENDING list while we
# were blocked on the advisory lock. Return the same
# idempotent "nothing to do" shape the early-return
# at line ~1473 produces so the FE/CLI handle both
# paths identically. Connection is closed here
# because the route handler's normal completion path
# doesn't run.
try:
await close_database_connection(conn)
except Exception:
pass
return {
"message": "No pending changes to apply (consumed by concurrent apply)",
"applied_count": 0,
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Error applying pending changes: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{cluster_id}/config-versions/{version_id}")
async def get_config_version(cluster_id: int, version_id: int, authorization: str = Header(None)):
"""Get a specific configuration version with content"""
try:
from auth_middleware import get_current_user_from_token
current_user = await get_current_user_from_token(authorization)
conn = await get_database_connection()
# Get the specific version
version = await conn.fetchrow("""
SELECT cv.id, cv.version_name, cv.description, cv.config_content, cv.checksum,
cv.file_size, cv.status, cv.is_active, cv.created_at,
u.username as created_by_username
FROM config_versions cv
LEFT JOIN users u ON cv.created_by = u.id
WHERE cv.id = $1 AND cv.cluster_id = $2
""", version_id, cluster_id)
if not version:
await close_database_connection(conn)
raise HTTPException(status_code=404, detail="Configuration version not found")
await close_database_connection(conn)
return {
"id": version["id"],
"version_name": version["version_name"],
"description": version["description"],
"config_content": version["config_content"],
"checksum": version["checksum"],
"file_size": version["file_size"],
"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"
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Error fetching config version: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{cluster_id}/config-versions/{version_id}/diff")
async def get_config_version_diff(cluster_id: int, version_id: int, authorization: str = Header(None)):
"""Get configuration differences between a version and its previous version"""
try:
from auth_middleware import get_current_user_from_token
current_user = await get_current_user_from_token(authorization)
conn = await get_database_connection()
logger.info(f"DIFF DEBUG: Looking for version_id={version_id} in cluster_id={cluster_id}")
# Get the current version with metadata for pre-apply snapshot
current_version = await conn.fetchrow("""
SELECT id, version_name, config_content, created_at, metadata
FROM config_versions
WHERE id = $1 AND cluster_id = $2
""", version_id, cluster_id)
logger.info(f"DIFF DEBUG: Found version: {current_version is not None}")
if current_version:
logger.info(f"DIFF DEBUG: Version name: {current_version['version_name']}, has_content: {current_version['config_content'] is not None}")
if not current_version:
# Check if version exists in any cluster
any_version = await conn.fetchrow("SELECT id, cluster_id FROM config_versions WHERE id = $1", version_id)
if any_version:
logger.error(f"DIFF DEBUG: Version {version_id} exists but in cluster {any_version['cluster_id']}, not {cluster_id}")
await close_database_connection(conn)
raise HTTPException(status_code=404, detail=f"Configuration version {version_id} not found in cluster {cluster_id}")
else:
logger.error(f"DIFF DEBUG: Version {version_id} does not exist at all")
await close_database_connection(conn)
raise HTTPException(status_code=404, detail=f"Configuration version {version_id} not found")
# Special handling for SSL certificate changes
import re
if re.search(r'ssl-(\d+)-(create|update|delete)', current_version['version_name']):
ssl_match = re.search(r'ssl-(\d+)-(create|update|delete)', current_version['version_name'])
ssl_cert_id = int(ssl_match.group(1))
ssl_action = ssl_match.group(2)
logger.info(f"SSL DIFF DEBUG: Processing SSL certificate {ssl_action} for cert_id: {ssl_cert_id}")
# Get SSL certificate info with actual certificate content
ssl_cert = await conn.fetchrow("""
SELECT id, name, primary_domain, created_at, certificate_content,
private_key_content, chain_content, usage_type,
CASE
WHEN NOT EXISTS (SELECT 1 FROM ssl_certificate_clusters WHERE ssl_certificate_id = id) THEN 'Global'
ELSE 'Cluster-specific'
END as ssl_type
FROM ssl_certificates
WHERE id = $1
""", ssl_cert_id)
if ssl_cert:
if ssl_action == "delete":
# For SSL delete, show what will be removed
changes = [
{"type": "context", "line": f"# SSL Certificate Deletion: {ssl_cert['name']}", "line_number": 1},
{"type": "context", "line": f"# Domain: {ssl_cert['primary_domain']}", "line_number": 2},
{"type": "context", "line": f"# Type: {ssl_cert['ssl_type']}", "line_number": 3},
{"type": "context", "line": "", "line_number": 4},
{"type": "removed", "line": f"- Certificate file will be removed: /etc/ssl/haproxy/{ssl_cert['name']}.pem", "line_number": 5},
{"type": "removed", "line": f"- Certificate will no longer be available for frontends", "line_number": 6},
{"type": "context", "line": "", "line_number": 7},
{"type": "context", "line": "# WARNING: Make sure no frontends are using this certificate!", "line_number": 8},
{"type": "context", "line": "# Check Frontend Management before applying this change.", "line_number": 9}
]
removed_count = 2
added_count = 0
else:
# For create/update, build the actual certificate file content that agent will deploy
cert_file_content_lines = [
f"# SSL Certificate File: {ssl_cert['name']}.pem",
f"# This file will be deployed to: /etc/ssl/haproxy/{ssl_cert['name']}.pem",
f"# Domain: {ssl_cert['primary_domain']}",
f"# Scope: {ssl_cert['ssl_type']}",
f"# Usage: {ssl_cert.get('usage_type', 'frontend').upper()}",
"",
"# Certificate Content:",
]
# Add certificate content
if ssl_cert['certificate_content']:
cert_file_content_lines.extend(ssl_cert['certificate_content'].strip().split('\n'))
cert_file_content_lines.append("")
cert_file_content_lines.append("# Private Key Content:")
# Add private key content
if ssl_cert['private_key_content']:
cert_file_content_lines.extend(ssl_cert['private_key_content'].strip().split('\n'))
# Add chain content if exists
if ssl_cert['chain_content'] and ssl_cert['chain_content'].strip():
cert_file_content_lines.append("")
cert_file_content_lines.append("# Certificate Chain Content:")
cert_file_content_lines.extend(ssl_cert['chain_content'].strip().split('\n'))
# Create diff showing the certificate file content
changes = []
line_number = 1
for line in cert_file_content_lines:
if line.startswith('#'):
changes.append({"type": "context", "line": line, "line_number": line_number})
else:
changes.append({"type": "added", "line": f"+ {line}", "line_number": line_number})
line_number += 1
# Add usage instructions
changes.extend([
{"type": "context", "line": "", "line_number": line_number},
{"type": "context", "line": "# Usage Instructions:", "line_number": line_number + 1},
{"type": "context", "line": "# 1. Go to Frontend Management", "line_number": line_number + 2},
{"type": "context", "line": "# 2. Edit a frontend and enable SSL/TLS", "line_number": line_number + 3},
{"type": "context", "line": f"# 3. Select '{ssl_cert['name']}' certificate", "line_number": line_number + 4}
])
added_count = len([c for c in changes if c["type"] == "added"])
removed_count = 0
summary = {"added": added_count, "removed": removed_count, "total_changes": added_count + removed_count}
await close_database_connection(conn)
return {
"current_version": {
"id": current_version['id'],
"version_name": current_version['version_name'],
"created_at": current_version['created_at'].isoformat().replace('+00:00', 'Z')
},
"previous_version": None,
"changes": changes,
"summary": summary,
"ssl_certificate": {
"id": ssl_cert['id'],
"name": ssl_cert['name'],
"domain": ssl_cert['primary_domain'],
"type": ssl_cert['ssl_type'],
"action": ssl_action
}
}
# HA/VIP (Issue #27): vip-{id}-{action} versions show the generated keepalived.conf
# each member node will deploy as the change content (VRRP secret masked). Mirrors
# the ssl-* special case above so VIP uses the STANDARD View Change diff modal.
# `adopt` MUST stay in this alternation. A vip-* action missing here does not degrade
# gracefully: the version falls through to the generic HAProxy diff, which compares this
# row's keepalived.conf against the cluster's previous haproxy.cfg and shows the whole
# HAProxy config as removed. v1.10.4 added `adopt` without it (fixed in v1.10.8).
vip_match = re.search(r'vip-(\d+)-(create|update|delete|adopt)', current_version['version_name'])
if vip_match:
vip_id = int(vip_match.group(1))
vip_action = vip_match.group(2)
rendered, vip_meta = None, None
old_content = ""
try:
from routers.vip import render_vip_config_masked
rendered, vip_meta = await render_vip_config_masked(conn, vip_id)
# For a create/update diff, fetch the PREVIOUS applied vip-* config for this VIP
# (the last-deployed keepalived.conf) so an EDIT shows ONLY the changed lines
# instead of the whole config as "added". Both sides are already secret-masked.
if vip_action != "delete":
prev_applied = await conn.fetchrow(
"SELECT config_content FROM config_versions WHERE version_name LIKE $1 "
"AND status='APPLIED' AND cluster_id=$2 AND id < $3 ORDER BY id DESC LIMIT 1",
f"vip-{vip_id}-%", cluster_id, current_version['id'])
if prev_applied and prev_applied['config_content']:
old_content = prev_applied['config_content']
except Exception as vip_err:
logger.warning(f"VIP DIFF: render failed for vip {vip_id}: {vip_err}")
await close_database_connection(conn)
changes = []
line_number = 1
if vip_action == "delete" or rendered is None:
title = (vip_meta or {}).get("name") or f"VIP {vip_id}"
for line in [f"# HA/VIP change: {title}",
"# keepalived will be stopped and the virtual IP released on each member node."
if vip_action == "delete" else
"# (configuration is not available to render yet)"]:
changes.append({"type": "context", "line": line, "line_number": line_number})
line_number += 1
summary = {"added": 0, "removed": 1 if vip_action == "delete" else 0,
"total_changes": 1 if vip_action == "delete" else 0}
else:
# Real line diff. Match the STANDARD haproxy diff format: the line is stored
# WITHOUT a +/- prefix (the UI adds it from `type` — the old `+ {line}` here
# caused the doubled "+ +"). A CREATE (no previous applied config) shows
# everything as added; an UPDATE shows ONLY the lines that actually changed.
import difflib
new_content = current_version['config_content'] or rendered or ""
added_count = 0
removed_count = 0
line_number = 0
if not old_content:
for i, l in enumerate(new_content.split('\n')):
changes.append({"type": "added", "line": l, "line_number": i + 1})
added_count += 1
else:
for dl in difflib.unified_diff(old_content.split('\n'), new_content.split('\n'),
lineterm='', n=3):
if dl.startswith('@@'):
mm = re.search(r'@@ -(\d+),?\d* \+(\d+),?\d* @@', dl)
if mm:
line_number = int(mm.group(2))
continue
if dl.startswith('---') or dl.startswith('+++'):
continue
if dl.startswith('+'):
changes.append({"type": "added", "line": dl[1:], "line_number": line_number})
added_count += 1
line_number += 1
elif dl.startswith('-'):
changes.append({"type": "removed", "line": dl[1:], "line_number": line_number})
removed_count += 1
elif dl.startswith(' '):
changes.append({"type": "context", "line": dl[1:], "line_number": line_number})
line_number += 1
summary = {"added": added_count, "removed": removed_count,
"total_changes": added_count + removed_count}
return {
"current_version": {
"id": current_version['id'],
"version_name": current_version['version_name'],
"created_at": current_version['created_at'].isoformat().replace('+00:00', 'Z')
},
"previous_version": None,
"changes": changes,
"summary": summary,
}
# Check if current version has config content
if not current_version['config_content']:
# Special handling for restore versions - they might not have content yet
if current_version['version_name'].startswith('restore-'):
await close_database_connection(conn)
logger.info(f"RESTORE DIFF: Version {version_id} is a restore operation, showing restore info")
return {
"current_version": {
"id": current_version['id'],
"version_name": current_version['version_name'],
"created_at": current_version['created_at'].isoformat().replace('+00:00', 'Z')
},
"previous_version": None,
"changes": [],
"summary": {
"added_lines": 0,
"removed_lines": 0,
"total_changes": 0,
"message": "Restore operation pending - configuration will be applied when you click 'Apply Changes'"
}
}
else:
await close_database_connection(conn)
logger.warning(f"DIFF DEBUG: Version {version_id} has no config content, returning empty diff")
return {
"current_version": {
"id": current_version['id'],
"version_name": current_version['version_name'],
"created_at": current_version['created_at'].isoformat().replace('+00:00', 'Z')
},
"previous_version": None,
"changes": [],
"summary": {
"added_lines": 0,
"removed_lines": 0,
"total_changes": 0,
"message": "Configuration version has no content to display"
}
}
# Choose previous version intelligently
if current_version['version_name'].startswith('apply-consolidated'):
# For apply-consolidated versions, use the pre-apply snapshot from metadata if available
# This shows ONLY the changes made during this specific apply operation
previous_version = None
# Parse metadata if it's a JSON string
metadata = current_version.get('metadata')
if isinstance(metadata, str):
try:
import json
metadata = json.loads(metadata)
except:
metadata = None
if metadata and isinstance(metadata, dict):
pre_apply_snapshot = metadata.get('pre_apply_snapshot')
if pre_apply_snapshot:
logger.info(f"DIFF DEBUG: Using pre-apply snapshot from metadata for apply-consolidated diff")
logger.info(f"DIFF DEBUG: Pre-apply snapshot length: {len(pre_apply_snapshot)}")
previous_version = {
'id': 0,
'version_name': 'pre-apply-snapshot',
'config_content': pre_apply_snapshot,
'created_at': current_version['created_at']
}
else:
logger.info(f"DIFF DEBUG: Metadata exists but no pre_apply_snapshot found: {list(metadata.keys())}")
else:
logger.info(f"DIFF DEBUG: No valid metadata found for apply-consolidated version")
# Fallback: For old apply-consolidated versions without snapshot metadata
if not previous_version:
logger.info(f"DIFF DEBUG: No pre-apply snapshot found, falling back to previous consolidated version")
previous_version = await conn.fetchrow(
"""
SELECT id, version_name, config_content, created_at
FROM config_versions
WHERE cluster_id = $1
AND created_at < $2
AND config_content IS NOT NULL
AND status = 'APPLIED'
AND version_name LIKE 'apply-consolidated-%'
ORDER BY created_at DESC
LIMIT 1
""",
cluster_id,
current_version['created_at'],
)
# If still no previous version found, create a minimal baseline for comparison
if not previous_version:
logger.info(f"DIFF DEBUG: No previous version found for first apply-consolidated, creating baseline")
# Create a minimal HAProxy config as baseline
baseline_config = """global
daemon
log stdout local0
defaults
mode http
timeout connect 5000ms
timeout client 50000ms
timeout server 50000ms
"""
previous_version = {
'id': 0,
'version_name': 'baseline',
'config_content': baseline_config,
'created_at': current_version['created_at']
}
else:
# For an individual change (e.g., waf-*-create, ssl-*-update), first check for pre_apply_snapshot in metadata
previous_version = None
# Parse metadata if it's a JSON string
metadata = current_version.get('metadata')
if isinstance(metadata, str):
try:
import json
metadata = json.loads(metadata)
except:
metadata = None
if metadata and isinstance(metadata, dict):
pre_apply_snapshot = metadata.get('pre_apply_snapshot')
if pre_apply_snapshot:
logger.info(f"DIFF DEBUG: Using pre-apply snapshot from metadata for {current_version['version_name']} diff")
logger.info(f"DIFF DEBUG: Pre-apply snapshot length: {len(pre_apply_snapshot)}")
previous_version = {
'id': 0,
'version_name': 'pre-apply-snapshot',
'config_content': pre_apply_snapshot,
'created_at': current_version['created_at']
}
# Fallback: If no pre_apply_snapshot in metadata, compare with most recent APPLIED version
if not previous_version:
logger.info(f"DIFF DEBUG: No pre-apply snapshot, using previous APPLIED version for diff")
previous_version = await conn.fetchrow(
"""
SELECT id, version_name, config_content, created_at
FROM config_versions
WHERE cluster_id = $1 AND created_at < $2 AND config_content IS NOT NULL
AND status = 'APPLIED'
ORDER BY created_at DESC
LIMIT 1
""",
cluster_id,
current_version['created_at'],
)
logger.info(f"DIFF DEBUG: Found previous version: {previous_version is not None}")
if previous_version:
logger.info(f"DIFF DEBUG: Previous version name: {previous_version['version_name']}")
logger.info(f"DIFF DEBUG: Previous config length: {len(previous_version['config_content']) if previous_version['config_content'] else 0}")
else:
logger.info(f"DIFF DEBUG: No previous version found - will compare against empty config")
logger.info(f"DIFF DEBUG: Current config length: {len(current_version['config_content']) if current_version['config_content'] else 0}")
logger.info(f"DIFF DEBUG: Version name pattern check: consolidated={current_version['version_name'].startswith('apply-consolidated')}")
await close_database_connection(conn)
import difflib, re
def extract_block(content: str, section_type: str, name: str):
lines = content.split('\n')
start_idx = None
header = f"{section_type} {name}"
for i, l in enumerate(lines):
if l.strip() == header:
start_idx = i
break
if start_idx is None:
return []
end_idx = len(lines)
for j in range(start_idx + 1, len(lines)):
if lines[j] and not lines[j].startswith(' ') and not lines[j].startswith('\t'):
# next top-level section
end_idx = j
break
return lines[start_idx:end_idx]
version_name = current_version['version_name']
# Bulgu #15 (post-Round-4 audit): apply a renderer-equivalent
# normalization to BOTH sides of the diff before splitting
# into lines. This neutralises renderer evolution between
# the time the previous version was rendered (older renderer)
# and the current version (newer renderer) so the diff
# surfaces ONLY operator-intent changes.
#
# Concrete scenario the user hit:
# * Operator opens the wizard, only edits the new
# site's frontend port + a single backend-server IP,
# creates two NEW entities (fe-site2 + be-site2).
# * Expected diff: only `+` lines for the two new
# blocks.
# * Actual diff pre-fix: every existing frontend in the
# cluster showed `-` lines for redundant
# `http-request track-sc0 src` calls (now deduped by
# the new renderer), and the unmodified `be-site`
# backend showed a `-` line stripping `cookie srv1`
# from its server (now guarded by the renderer when
# the parent backend has no `cookie_name`).
#
# Normalising both sides through the same canonical pass
# cancels out the renderer-only differences before the
# textual diff is computed. The function is idempotent so
# configs that were already rendered with the new
# renderer pass through unchanged.
try:
from services.haproxy_config import (
_normalize_haproxy_config_text_for_diff,
)
prev_raw = (
previous_version['config_content']
if previous_version and previous_version['config_content']
else ''
)
curr_raw = current_version['config_content'] or ''
prev_norm = _normalize_haproxy_config_text_for_diff(prev_raw)
curr_norm = _normalize_haproxy_config_text_for_diff(curr_raw)
except Exception as _norm_err:
# Defensive: never let a normalization bug break the
# diff endpoint. Fall back to the raw stored text on
# any error path.
logger.warning(
f"DIFF NORMALIZE: falling back to raw config text "
f"due to normalization error: {_norm_err}"
)
prev_norm = (
previous_version['config_content']
if previous_version and previous_version['config_content']
else ''
)
curr_norm = current_version['config_content'] or ''
prev_lines_full = prev_norm.split('\n') if prev_norm else []
curr_lines_full = curr_norm.split('\n')
scoped_prev = prev_lines_full
scoped_curr = curr_lines_full
# Initialize regex matches for all cases
m_backend = re.match(r'^backend-(\d+)-', version_name)
m_frontend = re.match(r'^frontend-(\d+)-', version_name)
m_waf = re.match(r'^waf-(\d+)-', version_name)
m_server = re.match(r'^server-(\d+)-', version_name)
# For consolidated applies, show full diff - no scoping
if version_name.startswith('apply-consolidated'):
scoped_prev = prev_lines_full
scoped_curr = curr_lines_full
logger.info(f"DIFF DEBUG: apply-consolidated diff - prev_lines: {len(scoped_prev)}, curr_lines: {len(scoped_curr)}")
else:
# Scope diffs for entity types to avoid showing whole file additions
# Bulgu #15: feed `extract_block` from the NORMALIZED
# text on both sides, mirroring the full-diff branch
# above, so entity-scoped diffs also surface only
# operator-intent changes.
conn2 = await get_database_connection()
try:
if m_backend:
be_id = int(m_backend.group(1))
be_row = await conn2.fetchrow("SELECT name FROM backends WHERE id = $1", be_id)
if be_row and be_row['name']:
name = be_row['name']
scoped_prev = extract_block(prev_norm, 'backend', name)
scoped_curr = extract_block(curr_norm, 'backend', name)
elif m_frontend:
fe_id = int(m_frontend.group(1))
fe_row = await conn2.fetchrow("SELECT name FROM frontends WHERE id = $1", fe_id)
if fe_row and fe_row['name']:
name = fe_row['name']
scoped_prev = extract_block(prev_norm, 'frontend', name)
scoped_curr = extract_block(curr_norm, 'frontend', name)
elif m_waf:
# For WAF rules, show full diff as WAF rules are mixed throughout config
scoped_prev = prev_lines_full
scoped_curr = curr_lines_full
elif m_server:
sv_id = int(m_server.group(1))
sv_row = await conn2.fetchrow(
"""
SELECT bs.server_name, bs.backend_name
FROM backend_servers bs
WHERE bs.id = $1
""",
sv_id,
)
if sv_row and sv_row['backend_name']:
be_name = sv_row['backend_name']
scoped_prev = extract_block(prev_norm, 'backend', be_name)
scoped_curr = extract_block(curr_norm, 'backend', be_name)
finally:
await close_database_connection(conn2)
# Compute diff
diff = list(difflib.unified_diff(
scoped_prev,
scoped_curr,
fromfile=f"Previous ({previous_version['version_name']})" if previous_version else 'Previous',
tofile=f"Current ({current_version['version_name']})",
lineterm='',
n=3
))
logger.info(f"DIFF DEBUG: Scoped prev lines: {len(scoped_prev)}, curr lines: {len(scoped_curr)}")
logger.info(f"DIFF DEBUG: Raw diff length: {len(diff)}")
# Debug scoping for server delete
if version_name.startswith('server-') and '-delete-' in version_name:
logger.info(f"DIFF DEBUG: Server delete version detected: {version_name}")
logger.info(f"DIFF DEBUG: m_server match: {m_server is not None}")
if len(scoped_prev) <= 20 and len(scoped_curr) <= 20:
logger.info(f"DIFF DEBUG: Scoped prev content: {scoped_prev}")
logger.info(f"DIFF DEBUG: Scoped curr content: {scoped_curr}")
logger.info(f"DIFF DEBUG: Full prev length: {len(prev_lines_full)}, Full curr length: {len(curr_lines_full)}")
# WAF changes show full diff since WAF rules are integrated throughout the config
# No additional filtering needed for WAF - full diff already scoped in elif m_waf block above
# If Server change, keep only the specific server line within backend block plus headers
if m_server:
# Obtain server name used earlier
try:
server_name = sv_row['server_name'] if 'sv_row' in locals() and sv_row else None
except Exception:
server_name = None
if server_name:
needle = f" server {server_name} "
diff = [
d for d in diff
if (needle in d) or d.startswith('@@') or d.startswith('---') or d.startswith('+++')
]
changes = []
added_count = 0
removed_count = 0
line_number = 0
logger.info(f"DIFF DEBUG: Processing {len(diff)} diff lines")
for i, line in enumerate(diff):
if line.startswith('@@'):
match = re.search(r'@@ -(\d+),?\d* \+(\d+),?\d* @@', line)
if match:
line_number = int(match.group(2))
continue
elif line.startswith('---') or line.startswith('+++'):
continue
elif line.startswith('+'):
changes.append({"type": "added", "line": line[1:], "line_number": line_number})
added_count += 1
line_number += 1
elif line.startswith('-'):
changes.append({"type": "removed", "line": line[1:], "line_number": line_number})
removed_count += 1
elif line.startswith(' '):
changes.append({"type": "context", "line": line[1:], "line_number": line_number})
line_number += 1
# If no previous version and we scoped to a block, mark only the block as added
if not previous_version and (m_backend or m_frontend):
scoped = scoped_curr
changes = [{"type": "added", "line": l, "line_number": i + 1} for i, l in enumerate(scoped)]
added_count = len(scoped)
removed_count = 0
summary = {"added": added_count, "removed": removed_count, "total_changes": added_count + removed_count}
logger.info(f"DIFF DEBUG: Final summary - added: {added_count}, removed: {removed_count}, changes: {len(changes)}")
return {
"current_version": {
"id": current_version['id'],
"version_name": current_version['version_name'],
"created_at": current_version['created_at'].isoformat().replace('+00:00', 'Z')
},
"previous_version": {
"id": previous_version['id'] if previous_version else None,
"version_name": previous_version['version_name'] if previous_version else None,
"created_at": previous_version['created_at'].isoformat().replace('+00:00', 'Z') if previous_version else None
} if previous_version else None,
"changes": changes,
"summary": summary
}
except Exception as e:
logger.error(f"Error getting config diff: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{cluster_id}/config-versions/{version_id}/restore/preview")
async def preview_restore_config_version(
cluster_id: int,
version_id: int,
authorization: str = Header(None)
):
"""
Preview what will change when restoring a configuration version.
Shows entities to create, update, and delete.
"""
try:
from auth_middleware import get_current_user_from_token
from utils.haproxy_config_parser import parse_haproxy_config
current_user = await get_current_user_from_token(authorization)
conn = await get_database_connection()
# Get version to restore
version_to_restore = await conn.fetchrow(
"SELECT * FROM config_versions WHERE id = $1 AND cluster_id = $2",
version_id, cluster_id
)
if not version_to_restore:
await close_database_connection(conn)
raise HTTPException(status_code=404, detail="Configuration version not found")
if not version_to_restore['config_content']:
await close_database_connection(conn)
raise HTTPException(
status_code=400,
detail=f"Cannot restore version '{version_to_restore['version_name']}': "
f"This version contains only database changes without full configuration content. "
f"Only consolidated versions (apply-consolidated-*) can be restored."
)
# Parse the config to restore
logger.info(f"RESTORE PREVIEW: Parsing config for version {version_to_restore['version_name']}")
parse_result = parse_haproxy_config(version_to_restore['config_content'])
if parse_result.errors:
await close_database_connection(conn)
raise HTTPException(
status_code=400,
detail=f"Cannot parse configuration: {', '.join(parse_result.errors)}"
)
# Get current database state
current_frontends = await conn.fetch(
"""SELECT id, name, bind_address, bind_port, default_backend, mode,
ssl_enabled, ssl_certificate_id, ssl_port, ssl_cert_path, ssl_cert, ssl_verify,
maxconn, timeout_client, timeout_http_request, rate_limit,
compression, log_separate, monitor_uri,
acl_rules, redirect_rules, use_backend_rules, request_headers, response_headers
FROM frontends WHERE cluster_id = $1 AND is_active = TRUE""",
cluster_id
)
current_backends = await conn.fetch(
"""SELECT id, name, balance_method, mode,
health_check_uri, health_check_interval,
timeout_connect, timeout_server, timeout_queue
FROM backends WHERE cluster_id = $1 AND is_active = TRUE""",
cluster_id
)
# NOTE: Removed server count fetching for restore preview
# Servers are separate entities, not backend fields
# Server changes should be tracked separately in future enhancement
# Build restore plan
restore_plan = {
"frontends": {
"to_create": [],
"to_update": [],
"to_delete": []
},
"backends": {
"to_create": [],
"to_update": [],
"to_delete": []
},
"servers": {
"to_create": [],
"to_update": [],
"to_delete": []
},
"waf_rules": {
"to_activate": [],
"to_deactivate": []
},
"summary": {
"total_changes": 0,
"creates": 0,
"updates": 0,
"deletes": 0,
"waf_activations": 0,
"waf_deactivations": 0
}
}
# Current state as dicts
current_fe_dict = {fe['name']: dict(fe) for fe in current_frontends}
current_be_dict = {be['name']: dict(be) for be in current_backends}
# Parsed state as dicts
# Ignore built-in frontends that are not managed in DB
IGNORED_FRONTENDS = ['stats'] # Built-in stats frontend
parsed_fe_dict = {fe.name: fe for fe in parse_result.frontends if fe.name not in IGNORED_FRONTENDS}
parsed_be_dict = {be.name: be for be in parse_result.backends}
# Compare frontends
for fe_name, parsed_fe in parsed_fe_dict.items():
if fe_name not in current_fe_dict:
# New frontend to create
restore_plan["frontends"]["to_create"].append({
"name": parsed_fe.name,
"bind_address": parsed_fe.bind_address,
"bind_port": parsed_fe.bind_port,
"default_backend": parsed_fe.default_backend,
"mode": parsed_fe.mode,
"ssl_enabled": parsed_fe.ssl_enabled,
"ssl_port": parsed_fe.ssl_port
})
restore_plan["summary"]["creates"] += 1
else:
# Check if different (need update)
current = current_fe_dict[fe_name]
# Build changes dict - only include fields that actually changed
changes = {}
if current.get('bind_address') != parsed_fe.bind_address:
changes["bind_address"] = {"from": current.get('bind_address'), "to": parsed_fe.bind_address}
if current['bind_port'] != parsed_fe.bind_port:
changes["bind_port"] = {"from": current['bind_port'], "to": parsed_fe.bind_port}
if current.get('default_backend') != parsed_fe.default_backend:
changes["default_backend"] = {"from": current.get('default_backend'), "to": parsed_fe.default_backend}
if current['mode'] != parsed_fe.mode:
changes["mode"] = {"from": current['mode'], "to": parsed_fe.mode}
if current.get('ssl_enabled', False) != parsed_fe.ssl_enabled:
changes["ssl_enabled"] = {"from": current.get('ssl_enabled', False), "to": parsed_fe.ssl_enabled}
if current.get('ssl_port') != parsed_fe.ssl_port:
changes["ssl_port"] = {"from": current.get('ssl_port'), "to": parsed_fe.ssl_port}
# NOTE: Only compare fields that parser actually parses from config
# Removed: ssl_certificate_id, rate_limit, compression, log_separate, monitor_uri,
# ssl_cert_path, ssl_verify, acl_rules, redirect_rules, use_backend_rules,
# request_headers, response_headers, timeout_http_request
# These are DB-only metadata fields not present in raw HAProxy config
if current.get('maxconn') != parsed_fe.maxconn:
changes["maxconn"] = {"from": current.get('maxconn'), "to": parsed_fe.maxconn}
if current.get('timeout_client') != parsed_fe.timeout_client:
changes["timeout_client"] = {"from": current.get('timeout_client'), "to": parsed_fe.timeout_client}
if changes:
restore_plan["frontends"]["to_update"].append({
"id": current['id'],
"name": parsed_fe.name,
"changes": changes
})
restore_plan["summary"]["updates"] += 1
# Frontends to delete
for fe_name, current_fe in current_fe_dict.items():
if fe_name not in parsed_fe_dict:
restore_plan["frontends"]["to_delete"].append({
"id": current_fe['id'],
"name": current_fe['name'],
"bind_port": current_fe['bind_port']
})
restore_plan["summary"]["deletes"] += 1
# Compare backends
for be_name, parsed_be in parsed_be_dict.items():
if be_name not in current_be_dict:
# New backend to create
server_count = len(parsed_be.servers) if parsed_be.servers else 0
restore_plan["backends"]["to_create"].append({
"name": parsed_be.name,
"balance_method": parsed_be.balance_method,
"mode": parsed_be.mode,
"server_count": server_count
})
restore_plan["summary"]["creates"] += 1
else:
# Check if different
current = current_be_dict[be_name]
changes = {}
if current['balance_method'] != parsed_be.balance_method:
changes["balance_method"] = {"from": current['balance_method'], "to": parsed_be.balance_method}
if current['mode'] != parsed_be.mode:
changes["mode"] = {"from": current['mode'], "to": parsed_be.mode}
if current.get('health_check_uri') != parsed_be.health_check_uri:
changes["health_check_uri"] = {"from": current.get('health_check_uri'), "to": parsed_be.health_check_uri}
if current.get('health_check_interval') != parsed_be.health_check_interval:
changes["health_check_interval"] = {"from": current.get('health_check_interval'), "to": parsed_be.health_check_interval}
if current.get('timeout_connect') != parsed_be.timeout_connect:
changes["timeout_connect"] = {"from": current.get('timeout_connect'), "to": parsed_be.timeout_connect}
if current.get('timeout_server') != parsed_be.timeout_server:
changes["timeout_server"] = {"from": current.get('timeout_server'), "to": parsed_be.timeout_server}
if current.get('timeout_queue') != parsed_be.timeout_queue:
changes["timeout_queue"] = {"from": current.get('timeout_queue'), "to": parsed_be.timeout_queue}
# NOTE: Server count removed from backend comparison
# Servers are separate entities, not backend fields
# Server changes should be tracked separately, not as backend field changes
# This prevents false positives when only frontend changed but DB had server sync issues
if changes:
restore_plan["backends"]["to_update"].append({
"id": current['id'],
"name": parsed_be.name,
"changes": changes
})
restore_plan["summary"]["updates"] += 1
# Backends to delete
for be_name, current_be in current_be_dict.items():
if be_name not in parsed_be_dict:
restore_plan["backends"]["to_delete"].append({
"id": current_be['id'],
"name": current_be['name']
})
restore_plan["summary"]["deletes"] += 1
# CRITICAL: Compare backend servers for restore preview
# Fetch current servers for each backend
logger.info(f"RESTORE PREVIEW: Comparing backend servers")
for be_name in current_be_dict.keys():
current_servers = await conn.fetch("""
SELECT server_name, server_address, server_port, weight, maxconn,
check_enabled, backup_server, ssl_enabled
FROM backend_servers
WHERE backend_name = $1 AND cluster_id = $2 AND is_active = TRUE
ORDER BY server_name
""", be_name, cluster_id)
current_server_dict = {s['server_name']: dict(s) for s in current_servers}
# Get parsed servers for this backend (if backend exists in restore version)
parsed_be = parsed_be_dict.get(be_name)
parsed_server_dict = {}
if parsed_be and parsed_be.servers:
for server in parsed_be.servers:
parsed_server_dict[server.server_name] = {
'server_name': server.server_name,
'server_address': server.server_address,
'server_port': server.server_port,
'weight': server.weight,
'maxconn': server.max_connections,
'check_enabled': server.check_enabled,
'backup_server': server.backup_server,
'ssl_enabled': server.ssl_enabled
}
# Servers to create (in restore version but not in current DB)
for server_name, parsed_server in parsed_server_dict.items():
if server_name not in current_server_dict:
restore_plan["servers"]["to_create"].append({
"backend_name": be_name,
"server_name": server_name,
"server_address": parsed_server['server_address'],
"server_port": parsed_server['server_port']
})
restore_plan["summary"]["creates"] += 1
# Servers to update (in both, but with different values)
for server_name in parsed_server_dict.keys():
if server_name in current_server_dict:
current_server = current_server_dict[server_name]
parsed_server = parsed_server_dict[server_name]
# Compare server fields
changes = {}
if current_server['server_address'] != parsed_server['server_address']:
changes["server_address"] = {"from": current_server['server_address'], "to": parsed_server['server_address']}
if current_server['server_port'] != parsed_server['server_port']:
changes["server_port"] = {"from": current_server['server_port'], "to": parsed_server['server_port']}
if current_server.get('weight') != parsed_server.get('weight'):
changes["weight"] = {"from": current_server.get('weight'), "to": parsed_server.get('weight')}
if current_server.get('maxconn') != parsed_server.get('maxconn'):
changes["maxconn"] = {"from": current_server.get('maxconn'), "to": parsed_server.get('maxconn')}
if current_server.get('check_enabled') != parsed_server.get('check_enabled'):
changes["check_enabled"] = {"from": current_server.get('check_enabled'), "to": parsed_server.get('check_enabled')}
if current_server.get('backup_server') != parsed_server.get('backup_server'):
changes["backup_server"] = {"from": current_server.get('backup_server'), "to": parsed_server.get('backup_server')}
if current_server.get('ssl_enabled') != parsed_server.get('ssl_enabled'):
changes["ssl_enabled"] = {"from": current_server.get('ssl_enabled'), "to": parsed_server.get('ssl_enabled')}
if changes:
restore_plan["servers"]["to_update"].append({
"backend_name": be_name,
"server_name": server_name,
"changes": changes
})
restore_plan["summary"]["updates"] += 1
# Servers to delete (in current DB but not in restore version)
for server_name, current_server in current_server_dict.items():
if server_name not in parsed_server_dict:
restore_plan["servers"]["to_delete"].append({
"backend_name": be_name,
"server_name": server_name,
"server_address": current_server['server_address'],
"server_port": current_server['server_port']
})
restore_plan["summary"]["deletes"] += 1
# CRITICAL: Compare WAF rules for restore preview (including config parameters)
# WAF rules are stored as comments in config: "# WAF Rule: rule_name"
# Parse WAF rules with their config parameters
logger.info(f"RESTORE PREVIEW: Comparing WAF rules with config")
import re
import json
config_lines = version_to_restore['config_content'].split('\n')
# Parse WAF rules from config with their parameters
waf_configs = {} # {rule_name: {is_active: True, config: {...}}}
i = 0
while i < len(config_lines):
line = config_lines[i].strip()
# Find WAF rule comment
if line.startswith('# WAF Rule:'):
match = re.search(r'# WAF Rule: ([\w\s\-]+)', line)
if match:
current_waf_name = match.group(1).strip()
waf_configs[current_waf_name] = {
'is_active': True,
'config': {}
}
# Parse next few lines for WAF config parameters
for j in range(i+1, min(i+10, len(config_lines))):
next_line = config_lines[j].strip()
# Parse stick-table line
if 'stick-table' in next_line:
expire_match = re.search(r'expire\s+(\d+)s', next_line)
if expire_match:
waf_configs[current_waf_name]['config']['rate_limit_window'] = int(expire_match.group(1))
table_expire_match = re.search(r'http_req_rate\((\d+)s\)', next_line)
if table_expire_match:
waf_configs[current_waf_name]['config']['table_expire'] = int(table_expire_match.group(1))
size_match = re.search(r'size\s+(\w+)', next_line)
if size_match:
waf_configs[current_waf_name]['config']['table_size'] = size_match.group(1)
# Parse deny line
elif 'http-request deny if' in next_line or 'http-request tarpit if' in next_line:
rate_match = re.search(r'gt\s+(\d+)', next_line)
if rate_match:
waf_configs[current_waf_name]['config']['rate_limit_requests'] = int(rate_match.group(1))
# Stop when we hit another WAF rule or different section
elif next_line.startswith('# WAF Rule:') or next_line.startswith('frontend ') or next_line.startswith('backend '):
break
i += 1
logger.info(f"RESTORE PREVIEW: Parsed {len(waf_configs)} WAF rules with config")
# Get current WAF rules for this cluster (include config field)
current_waf_rules = await conn.fetch("""
SELECT id, name, is_active, rule_type, config
FROM waf_rules
WHERE cluster_id = $1
ORDER BY name
""", cluster_id)
current_waf_dict = {waf['name']: dict(waf) for waf in current_waf_rules}
# WAF rules to activate/update (in restore config)
active_waf_names_in_restore = set(waf_configs.keys())
for waf_name in active_waf_names_in_restore:
if waf_name in current_waf_dict:
# WAF rule exists - check if inactive OR config changed
if not current_waf_dict[waf_name]['is_active']:
# Activation needed
restore_plan["waf_rules"]["to_activate"].append({
"name": waf_name,
"rule_type": current_waf_dict[waf_name]['rule_type'],
"current_status": "inactive"
})
restore_plan["summary"]["waf_activations"] += 1
else:
# Active rule - check if config changed
current_config = current_waf_dict[waf_name].get('config') or {}
if isinstance(current_config, str):
current_config = json.loads(current_config) if current_config else {}
parsed_config = waf_configs[waf_name]['config']
# Check if config changed
config_changed = False
changes = []
for key in ['rate_limit_window', 'rate_limit_requests', 'table_expire', 'table_size']:
if key in parsed_config:
current_value = current_config.get(key)
parsed_value = parsed_config[key]
if current_value != parsed_value:
config_changed = True
changes.append(f"{key}: {current_value}{parsed_value}")
if config_changed:
# Add to "updates" section (NEW)
if "waf_rules" not in restore_plan:
restore_plan["waf_rules"] = {"to_activate": [], "to_deactivate": [], "to_update": []}
if "to_update" not in restore_plan["waf_rules"]:
restore_plan["waf_rules"]["to_update"] = []
restore_plan["waf_rules"]["to_update"].append({
"name": waf_name,
"rule_type": current_waf_dict[waf_name]['rule_type'],
"changes": changes
})
restore_plan["summary"]["updates"] += 1
logger.info(f"RESTORE PREVIEW: WAF rule '{waf_name}' config changed: {', '.join(changes)}")
# Note: If WAF rule in config but not in DB, it can't be activated
# WAF rules to deactivate (currently active but not in restore config)
for waf_name, waf_data in current_waf_dict.items():
if waf_data['is_active'] and waf_name not in active_waf_names_in_restore:
restore_plan["waf_rules"]["to_deactivate"].append({
"name": waf_name,
"rule_type": waf_data['rule_type'],
"current_status": "active"
})
restore_plan["summary"]["waf_deactivations"] += 1
restore_plan["summary"]["total_changes"] = (
restore_plan["summary"]["creates"] +
restore_plan["summary"]["updates"] +
restore_plan["summary"]["deletes"] +
restore_plan["summary"]["waf_activations"] +
restore_plan["summary"]["waf_deactivations"]
)
await close_database_connection(conn)
logger.info(f"RESTORE PREVIEW: Generated plan with {restore_plan['summary']['total_changes']} total changes "
f"({restore_plan['summary']['waf_activations']} WAF activations, "
f"{restore_plan['summary']['waf_deactivations']} WAF deactivations)")
return {
"version_name": version_to_restore['version_name'],
"version_id": version_id,
"cluster_id": cluster_id,
"restore_plan": restore_plan,
"requires_confirmation": True
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Restore preview failed: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/{cluster_id}/config-versions/{version_id}/restore/confirm")
async def confirm_restore_config_version(
cluster_id: int,
version_id: int,
authorization: str = Header(None)
):
"""
Confirm and execute the restore operation.
Syncs entities to database and creates PENDING config version.
"""
try:
from auth_middleware import get_current_user_from_token
from utils.haproxy_config_parser import parse_haproxy_config
import time
import json
current_user = await get_current_user_from_token(authorization)
conn = await get_database_connection()
# Bulgu #79 — validate cluster access (admins bypass).
await validate_user_cluster_access(current_user['id'], cluster_id, conn)
# Get version to restore
version_to_restore = await conn.fetchrow(
"SELECT * FROM config_versions WHERE id = $1 AND cluster_id = $2",
version_id, cluster_id
)
if not version_to_restore:
await close_database_connection(conn)
raise HTTPException(status_code=404, detail="Configuration version not found")
if not version_to_restore['config_content']:
await close_database_connection(conn)
raise HTTPException(status_code=400, detail="Version has no config content")
# Parse the config
logger.info(f"RESTORE CONFIRM: Parsing and syncing entities for version {version_to_restore['version_name']}")
parse_result = parse_haproxy_config(version_to_restore['config_content'])
if parse_result.errors:
await close_database_connection(conn)
raise HTTPException(
status_code=400,
detail=f"Cannot parse configuration: {', '.join(parse_result.errors)}"
)
# PHASE 5: Initialize bulk snapshot tracker for restore operation
from utils.entity_snapshot import save_entity_snapshot
bulk_snapshots = []
async with conn.transaction():
# Get current state
current_frontends = await conn.fetch(
"SELECT id, name FROM frontends WHERE cluster_id = $1 AND is_active = TRUE",
cluster_id
)
current_backends = await conn.fetch(
"SELECT id, name FROM backends WHERE cluster_id = $1 AND is_active = TRUE",
cluster_id
)
current_fe_dict = {fe['name']: fe['id'] for fe in current_frontends}
current_be_dict = {be['name']: be['id'] for be in current_backends}
parsed_fe_names = {fe.name for fe in parse_result.frontends}
parsed_be_names = {be.name for be in parse_result.backends}
# Build parsed entity dicts for metadata creation (needed after transaction)
parsed_fe_dict = {fe.name: fe for fe in parse_result.frontends}
parsed_be_dict = {be.name: be for be in parse_result.backends}
# CRITICAL: Fetch detailed current state BEFORE database sync
# PHASE 5: Use SELECT * to get ALL fields for snapshot (not just parsed fields)
current_fe_details = {}
for fe_name, fe_id in current_fe_dict.items():
fe_record = await conn.fetchrow("""
SELECT * FROM frontends
WHERE id = $1 AND cluster_id = $2
""", fe_id, cluster_id)
if fe_record:
current_fe_details[fe_name] = dict(fe_record)
current_be_details = {}
current_be_servers = {} # Track servers for each backend
for be_name, be_id in current_be_dict.items():
# PHASE 5: Use SELECT * to get ALL fields for snapshot
be_record = await conn.fetchrow("""
SELECT * FROM backends
WHERE id = $1 AND cluster_id = $2
""", be_id, cluster_id)
if be_record:
current_be_details[be_name] = dict(be_record)
# Fetch servers for this backend
server_records = await conn.fetch("""
SELECT server_name, server_address, server_port, weight, maxconn,
check_enabled, backup_server, ssl_enabled
FROM backend_servers
WHERE backend_name = $1 AND cluster_id = $2 AND is_active = TRUE
""", be_name, cluster_id)
current_be_servers[be_name] = [dict(s) for s in server_records]
# Fetch current WAF rules state
current_waf_rules = await conn.fetch("""
SELECT id, name, rule_type, config, action, priority, enabled
FROM waf_rules
WHERE cluster_id = $1 AND is_active = TRUE
""", cluster_id)
current_waf_dict = {waf['name']: dict(waf) for waf in current_waf_rules}
logger.info(f"SNAPSHOT: Captured pre-restore state - {len(current_fe_details)} frontends, {len(current_be_details)} backends, {len(current_waf_dict)} WAF rules")
# Track changes
changes_summary = {
"frontends_created": 0,
"frontends_updated": 0,
"frontends_deleted": 0,
"backends_created": 0,
"backends_updated": 0,
"backends_deleted": 0,
"waf_rules_activated": 0,
"waf_rules_deactivated": 0
}
# Sync Frontends (ignore built-in frontends)
IGNORED_FRONTENDS = ['stats'] # Built-in stats frontend
# Issue #11: System/auto-managed entities must not be persisted via restore
# (e.g. _acme_challenge_backend is auto-injected by haproxy_config.py).
IGNORED_BACKENDS = ['_acme_challenge_backend']
for parsed_fe in parse_result.frontends:
if parsed_fe.name in IGNORED_FRONTENDS:
logger.info(f"RESTORE: Skipping built-in frontend '{parsed_fe.name}'")
continue
if parsed_fe.name in current_fe_dict:
# PHASE 5: Create snapshot BEFORE update (restore operation)
fe_id = current_fe_dict[parsed_fe.name]
old_frontend = current_fe_details.get(parsed_fe.name)
if old_frontend:
snapshot = await save_entity_snapshot(
conn=conn,
entity_type="frontend",
entity_id=fe_id,
old_values=old_frontend, # Full record with ALL fields
new_values={
"bind_address": parsed_fe.bind_address,
"bind_port": parsed_fe.bind_port,
"default_backend": parsed_fe.default_backend,
"mode": parsed_fe.mode,
"ssl_enabled": parsed_fe.ssl_enabled,
"ssl_port": parsed_fe.ssl_port,
"maxconn": parsed_fe.maxconn,
"timeout_client": parsed_fe.timeout_client
},
operation="UPDATE_RESTORE"
)
if snapshot:
bulk_snapshots.append(snapshot)
# UPDATE existing frontend (ALL 8 parsed fields)
# CRITICAL FIX: Include maxconn and timeout_client so UI shows restored values
await conn.execute("""
UPDATE frontends
SET bind_address = $1, bind_port = $2, default_backend = $3,
mode = $4, ssl_enabled = $5, ssl_port = $6,
maxconn = $7, timeout_client = $8,
log_format = $11, filters = $12,
updated_at = CURRENT_TIMESTAMP, last_config_status = 'PENDING'
WHERE id = $9 AND cluster_id = $10
""",
parsed_fe.bind_address, parsed_fe.bind_port, parsed_fe.default_backend,
parsed_fe.mode, parsed_fe.ssl_enabled, parsed_fe.ssl_port,
parsed_fe.maxconn, parsed_fe.timeout_client,
fe_id, cluster_id,
parsed_fe.log_format, parsed_fe.filters # Issue #38
)
changes_summary["frontends_updated"] += 1
logger.info(f"RESTORE: Updated frontend '{parsed_fe.name}' (SSL: {parsed_fe.ssl_enabled}, maxconn: {parsed_fe.maxconn})")
else:
# CREATE new frontend (ALL 8 parsed fields)
# CRITICAL FIX: Include maxconn and timeout_client so UI shows restored values
await conn.execute("""
INSERT INTO frontends
(name, bind_address, bind_port, default_backend, mode, ssl_enabled, ssl_port,
maxconn, timeout_client,
cluster_id, log_format, filters, is_active, last_config_status, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, TRUE, 'PENDING', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
""",
parsed_fe.name, parsed_fe.bind_address, parsed_fe.bind_port,
parsed_fe.default_backend, parsed_fe.mode, parsed_fe.ssl_enabled, parsed_fe.ssl_port,
parsed_fe.maxconn, parsed_fe.timeout_client,
cluster_id,
parsed_fe.log_format, parsed_fe.filters # Issue #38
)
changes_summary["frontends_created"] += 1
logger.info(f"RESTORE: Created frontend '{parsed_fe.name}' (SSL: {parsed_fe.ssl_enabled}, maxconn: {parsed_fe.maxconn})")
# Delete frontends not in restored config
for fe_name, fe_id in current_fe_dict.items():
if fe_name not in parsed_fe_names:
await conn.execute(
"UPDATE frontends SET is_active = FALSE, last_config_status = 'PENDING', updated_at = CURRENT_TIMESTAMP WHERE id = $1",
fe_id
)
changes_summary["frontends_deleted"] += 1
logger.info(f"RESTORE: Deleted frontend '{fe_name}'")
# Sync Backends
for parsed_be in parse_result.backends:
# Issue #11: skip auto-managed system backends (e.g. _acme_challenge_backend).
if parsed_be.name in IGNORED_BACKENDS:
logger.info(f"RESTORE: Skipping auto-managed backend '{parsed_be.name}'")
continue
if parsed_be.name in current_be_dict:
# PHASE 5: Create snapshot BEFORE update (restore operation)
be_id = current_be_dict[parsed_be.name]
old_backend = current_be_details.get(parsed_be.name)
if old_backend:
snapshot = await save_entity_snapshot(
conn=conn,
entity_type="backend",
entity_id=be_id,
old_values=old_backend, # Full record with ALL fields
new_values={
"balance_method": parsed_be.balance_method,
"mode": parsed_be.mode,
"health_check_uri": parsed_be.health_check_uri,
"health_check_interval": parsed_be.health_check_interval,
"timeout_connect": parsed_be.timeout_connect,
"timeout_server": parsed_be.timeout_server,
"timeout_queue": parsed_be.timeout_queue
},
operation="UPDATE_RESTORE"
)
if snapshot:
bulk_snapshots.append(snapshot)
# UPDATE existing backend
await conn.execute("""
UPDATE backends
SET balance_method = $1, mode = $2,
health_check_uri = $3, health_check_interval = $4,
timeout_connect = $5, timeout_server = $6, timeout_queue = $7,
updated_at = CURRENT_TIMESTAMP, last_config_status = 'PENDING'
WHERE id = $8 AND cluster_id = $9
""",
parsed_be.balance_method, parsed_be.mode,
parsed_be.health_check_uri, parsed_be.health_check_interval,
parsed_be.timeout_connect, parsed_be.timeout_server, parsed_be.timeout_queue,
be_id, cluster_id
)
# Delete old servers for this backend
await conn.execute(
"DELETE FROM backend_servers WHERE backend_name = $1 AND cluster_id = $2",
parsed_be.name, cluster_id
)
# Insert new servers
if parsed_be.servers:
for server in parsed_be.servers:
await conn.execute("""
INSERT INTO backend_servers
(backend_name, server_name, server_address, server_port, weight,
maxconn, check_enabled, backup_server, ssl_enabled,
cluster_id, is_active, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
""",
parsed_be.name, server.server_name, server.server_address, server.server_port,
server.weight, server.max_connections, server.check_enabled,
server.backup_server, server.ssl_enabled, cluster_id
)
changes_summary["backends_updated"] += 1
logger.info(f"RESTORE: Updated backend '{parsed_be.name}' with {len(parsed_be.servers) if parsed_be.servers else 0} servers")
else:
# CREATE new backend
await conn.execute("""
INSERT INTO backends
(name, balance_method, mode, health_check_uri, health_check_interval,
timeout_connect, timeout_server, timeout_queue, cluster_id,
is_active, last_config_status, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, TRUE, 'PENDING', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
""",
parsed_be.name, parsed_be.balance_method, parsed_be.mode,
parsed_be.health_check_uri, parsed_be.health_check_interval,
parsed_be.timeout_connect, parsed_be.timeout_server, parsed_be.timeout_queue,
cluster_id
)
# Insert servers
if parsed_be.servers:
for server in parsed_be.servers:
await conn.execute("""
INSERT INTO backend_servers
(backend_name, server_name, server_address, server_port, weight,
maxconn, check_enabled, backup_server, ssl_enabled,
cluster_id, is_active, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
""",
parsed_be.name, server.server_name, server.server_address, server.server_port,
server.weight, server.max_connections, server.check_enabled,
server.backup_server, server.ssl_enabled, cluster_id
)
changes_summary["backends_created"] += 1
logger.info(f"RESTORE: Created backend '{parsed_be.name}' with {len(parsed_be.servers) if parsed_be.servers else 0} servers")
# Delete backends not in restored config
for be_name, be_id in current_be_dict.items():
if be_name not in parsed_be_names:
await conn.execute(
"UPDATE backends SET is_active = FALSE, last_config_status = 'PENDING', updated_at = CURRENT_TIMESTAMP WHERE id = $1",
be_id
)
await conn.execute(
"DELETE FROM backend_servers WHERE backend_name = $1 AND cluster_id = $2",
be_name, cluster_id
)
changes_summary["backends_deleted"] += 1
logger.info(f"RESTORE: Deleted backend '{be_name}'")
# Sync WAF Rules (parse from config comments + extract config parameters)
# WAF rules are stored as comments in config: "# WAF Rule: rule_name"
# Also parse config parameters (rate limit, expire time, etc.)
import re
import json
config_lines = version_to_restore['config_content'].split('\n')
# Parse WAF rules from config with their parameters
waf_configs = {} # {rule_name: {is_active: True, config: {...}}}
i = 0
while i < len(config_lines):
line = config_lines[i].strip()
# Find WAF rule comment
if line.startswith('# WAF Rule:'):
match = re.search(r'# WAF Rule: ([\w\s\-]+)', line)
if match:
current_waf_name = match.group(1).strip()
waf_configs[current_waf_name] = {
'is_active': True,
'config': {}
}
# Parse next few lines for WAF config parameters
# Look ahead for stick-table and http-request lines
for j in range(i+1, min(i+10, len(config_lines))):
next_line = config_lines[j].strip()
# Parse stick-table line: expire and rate_limit_window
if 'stick-table' in next_line:
# Extract expire time: expire 66s
expire_match = re.search(r'expire\s+(\d+)s', next_line)
if expire_match:
waf_configs[current_waf_name]['config']['rate_limit_window'] = int(expire_match.group(1))
# Extract table_expire: http_req_rate(10s)
table_expire_match = re.search(r'http_req_rate\((\d+)s\)', next_line)
if table_expire_match:
waf_configs[current_waf_name]['config']['table_expire'] = int(table_expire_match.group(1))
# Extract table size: size 100k
size_match = re.search(r'size\s+(\w+)', next_line)
if size_match:
waf_configs[current_waf_name]['config']['table_size'] = size_match.group(1)
# Parse deny line: rate_limit_requests
elif 'http-request deny if' in next_line or 'http-request tarpit if' in next_line:
# Extract rate limit: gt 101
rate_match = re.search(r'gt\s+(\d+)', next_line)
if rate_match:
waf_configs[current_waf_name]['config']['rate_limit_requests'] = int(rate_match.group(1))
# Stop when we hit another WAF rule or different section
elif next_line.startswith('# WAF Rule:') or next_line.startswith('frontend ') or next_line.startswith('backend '):
break
i += 1
logger.info(f"RESTORE: Parsed {len(waf_configs)} WAF rules with config from restored config")
# Update WAF rules: Set is_active AND config from parsed config
for waf_name, parsed_waf in waf_configs.items():
if waf_name not in current_waf_dict:
# WAF rule exists in restored config but not in current DB - should have been there
# This shouldn't happen normally, but log it
logger.warning(f"RESTORE: WAF rule '{waf_name}' in config but not found in DB")
else:
# Get current config from DB
current_waf = current_waf_dict[waf_name]
current_config = current_waf.get('config') or {}
if isinstance(current_config, str):
current_config = json.loads(current_config) if current_config else {}
# Merge parsed config with existing config (preserve fields we didn't parse)
updated_config = current_config.copy()
updated_config.update(parsed_waf['config'])
# Update both is_active and config in DB
await conn.execute("""
UPDATE waf_rules
SET is_active = TRUE, config = $1, last_config_status = 'PENDING', updated_at = CURRENT_TIMESTAMP
WHERE name = $2 AND cluster_id = $3
""", json.dumps(updated_config), waf_name, cluster_id)
changes_summary["waf_rules_activated"] += 1
logger.info(f"RESTORE: Activated WAF rule '{waf_name}' with config: {parsed_waf['config']}")
# Deactivate WAF rules NOT in restored config
for waf_name in current_waf_dict.keys():
if waf_name not in waf_configs:
await conn.execute("""
UPDATE waf_rules
SET is_active = FALSE, last_config_status = 'PENDING', updated_at = CURRENT_TIMESTAMP
WHERE name = $1 AND cluster_id = $2
""", waf_name, cluster_id)
changes_summary["waf_rules_deactivated"] += 1
logger.info(f"RESTORE: Deactivated WAF rule '{waf_name}'")
# Create PENDING config version (for Apply Management)
# Include metadata to track which entities were actually changed
restored_version_name = f"restore-{version_to_restore['version_name']}-{int(time.time())}"
# Build changed_entities metadata for UI sync status tracking
# CRITICAL: Only include entities that have ACTUAL field changes (not all entities)
# NOTE: current_fe_details and current_be_details were captured BEFORE database sync (line 2015-2036)
# This ensures accurate field comparison between pre-restore and restore states
changed_entities = []
# Compare frontends - only add if fields are different
# CRITICAL: Skip built-in frontends from metadata too (consistent with DB sync)
IGNORED_FRONTENDS = ['stats'] # Must match line 2024
for fe_name, parsed_fe in parsed_fe_dict.items():
if fe_name in IGNORED_FRONTENDS:
logger.info(f"METADATA: Skipping built-in frontend '{fe_name}' from changed_entities")
continue
if fe_name in current_fe_details:
# Check if any field is different (with exception safety)
try:
current = current_fe_details[fe_name]
# Helper function to compare JSONB fields (acl_rules, redirect_rules)
import json
def compare_json_fields(db_value, parsed_value):
if db_value is None and parsed_value is None:
return False # No change
if db_value is None or parsed_value is None:
return True # One is None, other isn't - changed
# Both exist - compare as JSON
db_json = db_value if isinstance(db_value, (list, dict)) else json.loads(db_value) if db_value else []
parsed_json = parsed_value if isinstance(parsed_value, (list, dict)) else []
return db_json != parsed_json
# CRITICAL: Only compare fields that parser actually parses from config
# Parser does NOT parse: ssl_certificate_id, ssl_cert_path, ssl_cert, ssl_verify,
# rate_limit, compression, log_separate, monitor_uri, acl_rules, redirect_rules,
# use_backend_rules, request_headers, response_headers
# These are database metadata fields and should not trigger false positive changes
has_changes = (
current.get('bind_address') != parsed_fe.bind_address or
current['bind_port'] != parsed_fe.bind_port or
current.get('default_backend') != parsed_fe.default_backend or
current['mode'] != parsed_fe.mode or
current.get('ssl_enabled', False) != parsed_fe.ssl_enabled or
current.get('ssl_port') != parsed_fe.ssl_port or
current.get('maxconn') != parsed_fe.maxconn or
current.get('timeout_client') != parsed_fe.timeout_client
# NOTE: Removed non-parsed fields to prevent false positives
# ssl_certificate_id, rate_limit, compression, acl_rules, etc. are DB-only
)
if has_changes:
changed_entities.append({
"type": "frontend",
"id": current_fe_dict[fe_name],
"name": fe_name,
"action": "update"
})
logger.info(f"METADATA: Frontend '{fe_name}' has field changes, adding to changed_entities")
else:
logger.info(f"METADATA: Frontend '{fe_name}' unchanged, skipping from changed_entities")
except Exception as e:
# CRITICAL SAFETY: If comparison fails, add entity (safe fallback)
logger.warning(f"METADATA: Error comparing frontend '{fe_name}': {e}, adding to changed_entities (safe fallback)")
changed_entities.append({
"type": "frontend",
"id": current_fe_dict[fe_name],
"name": fe_name,
"action": "update"
})
else:
# New frontend
changed_entities.append({
"type": "frontend",
"name": fe_name,
"action": "create"
})
logger.info(f"METADATA: Frontend '{fe_name}' is new, adding to changed_entities")
# Compare backends - only add if fields are different
for be_name, parsed_be in parsed_be_dict.items():
if be_name in current_be_details:
# Check if any field is different (with exception safety)
try:
current = current_be_details[be_name]
# Check backend fields
has_backend_changes = (
current['balance_method'] != parsed_be.balance_method or
current['mode'] != parsed_be.mode or
current.get('health_check_uri') != parsed_be.health_check_uri or
current.get('health_check_interval') != parsed_be.health_check_interval or
current.get('timeout_connect') != parsed_be.timeout_connect or
current.get('timeout_server') != parsed_be.timeout_server or
current.get('timeout_queue') != parsed_be.timeout_queue
)
# Check server changes
current_servers = current_be_servers.get(be_name, [])
parsed_servers = parsed_be.servers if parsed_be.servers else []
# Quick check: different server count
has_server_changes = len(current_servers) != len(parsed_servers)
# Detailed check: compare each server
if not has_server_changes and len(current_servers) > 0:
current_server_names = {s['server_name'] for s in current_servers}
parsed_server_names = {s.server_name for s in parsed_servers}
# Check if server names changed
if current_server_names != parsed_server_names:
has_server_changes = True
else:
# Check if any server details changed
for parsed_server in parsed_servers:
current_server = next((s for s in current_servers if s['server_name'] == parsed_server.server_name), None)
if current_server:
if (current_server['server_address'] != parsed_server.server_address or
current_server['server_port'] != parsed_server.server_port or
current_server.get('weight', 100) != parsed_server.weight or
current_server.get('maxconn') != parsed_server.max_connections or
current_server.get('check_enabled', True) != parsed_server.check_enabled or
current_server.get('backup_server', False) != parsed_server.backup_server or
current_server.get('ssl_enabled', False) != parsed_server.ssl_enabled):
has_server_changes = True
break
has_changes = has_backend_changes or has_server_changes
if has_changes:
change_type = []
if has_backend_changes:
change_type.append("backend fields")
if has_server_changes:
change_type.append(f"servers ({len(current_servers)}{len(parsed_servers)})")
changed_entities.append({
"type": "backend",
"id": current_be_dict[be_name],
"name": be_name,
"action": "update"
})
logger.info(f"METADATA: Backend '{be_name}' has changes ({', '.join(change_type)}), adding to changed_entities")
else:
logger.info(f"METADATA: Backend '{be_name}' unchanged, skipping from changed_entities")
except Exception as e:
# CRITICAL SAFETY: If comparison fails, add entity (safe fallback)
logger.warning(f"METADATA: Error comparing backend '{be_name}': {e}, adding to changed_entities (safe fallback)")
changed_entities.append({
"type": "backend",
"id": current_be_dict[be_name],
"name": be_name,
"action": "update"
})
else:
# New backend
changed_entities.append({
"type": "backend",
"name": be_name,
"action": "create"
})
logger.info(f"METADATA: Backend '{be_name}' is new, adding to changed_entities")
# Compare WAF Rules - add if activated/deactivated OR config changed
# Extract active WAF names from parsed config
active_waf_names = set(waf_configs.keys())
for waf_name in active_waf_names:
if waf_name in current_waf_dict:
# WAF rule is active in both current and restored config
# Check if config parameters changed
current_waf = current_waf_dict[waf_name]
current_config = current_waf.get('config') or {}
if isinstance(current_config, str):
current_config = json.loads(current_config) if current_config else {}
parsed_config = waf_configs[waf_name]['config']
# Compare config fields
config_changed = False
for key in ['rate_limit_window', 'rate_limit_requests', 'table_expire', 'table_size']:
if key in parsed_config:
current_value = current_config.get(key)
parsed_value = parsed_config[key]
if current_value != parsed_value:
config_changed = True
logger.info(f"METADATA: WAF rule '{waf_name}' config changed: {key} {current_value}{parsed_value}")
break
if config_changed:
changed_entities.append({
"type": "waf_rule",
"id": current_waf_dict[waf_name]['id'],
"name": waf_name,
"action": "update"
})
logger.info(f"METADATA: WAF rule '{waf_name}' config updated, adding to changed_entities")
else:
# WAF rule was reactivated (was inactive, now active)
changed_entities.append({
"type": "waf_rule",
"name": waf_name,
"action": "activate"
})
logger.info(f"METADATA: WAF rule '{waf_name}' reactivated, adding to changed_entities")
for waf_name in current_waf_dict.keys():
if waf_name not in active_waf_names:
# WAF rule was deactivated
changed_entities.append({
"type": "waf_rule",
"id": current_waf_dict[waf_name]['id'],
"name": waf_name,
"action": "deactivate"
})
logger.info(f"METADATA: WAF rule '{waf_name}' deactivated, adding to changed_entities")
# CRITICAL: Revert last_config_status to APPLIED for unchanged entities
# This ensures only changed entities show as PENDING in UI
unchanged_fe_ids = []
changed_fe_names = {e['name'] for e in changed_entities if e['type'] == 'frontend'}
for fe_name, fe_id in current_fe_dict.items():
if fe_name not in IGNORED_FRONTENDS and fe_name not in changed_fe_names:
unchanged_fe_ids.append(fe_id)
if unchanged_fe_ids:
await conn.execute("""
UPDATE frontends
SET last_config_status = 'APPLIED'
WHERE id = ANY($1) AND cluster_id = $2
""", unchanged_fe_ids, cluster_id)
logger.info(f"SELECTIVE STATUS: Reverted {len(unchanged_fe_ids)} unchanged frontends to APPLIED (will not show PENDING in UI)")
unchanged_be_ids = []
changed_be_names = {e['name'] for e in changed_entities if e['type'] == 'backend'}
for be_name, be_id in current_be_dict.items():
if be_name not in changed_be_names:
unchanged_be_ids.append(be_id)
if unchanged_be_ids:
await conn.execute("""
UPDATE backends
SET last_config_status = 'APPLIED'
WHERE id = ANY($1) AND cluster_id = $2
""", unchanged_be_ids, cluster_id)
logger.info(f"SELECTIVE STATUS: Reverted {len(unchanged_be_ids)} unchanged backends to APPLIED (will not show PENDING in UI)")
# Revert unchanged WAF rules to APPLIED
unchanged_waf_ids = []
changed_waf_names = {e['name'] for e in changed_entities if e['type'] == 'waf_rule'}
for waf_name, waf_data in current_waf_dict.items():
if waf_name not in changed_waf_names:
unchanged_waf_ids.append(waf_data['id'])
if unchanged_waf_ids:
await conn.execute("""
UPDATE waf_rules
SET last_config_status = 'APPLIED'
WHERE id = ANY($1) AND cluster_id = $2
""", unchanged_waf_ids, cluster_id)
logger.info(f"SELECTIVE STATUS: Reverted {len(unchanged_waf_ids)} unchanged WAF rules to APPLIED (will not show PENDING in UI)")
# PHASE 5: 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)
restore_metadata = {
"pre_apply_snapshot": old_config or "", # For diff viewer
"bulk_snapshots": bulk_snapshots, # For rollback
"restore_source": version_to_restore['version_name'],
"restore_type": "full_snapshot",
"changed_entities": changed_entities,
"changes_summary": changes_summary,
"operation": "RESTORE",
"entity_count": len(bulk_snapshots)
}
new_version_id = await conn.fetchval("""
INSERT INTO config_versions
(cluster_id, version_name, config_content, checksum, created_by, is_active, status, description, metadata)
VALUES ($1, $2, $3, $4, $5, FALSE, 'PENDING', $6, $7)
RETURNING id
""",
cluster_id,
restored_version_name,
version_to_restore['config_content'],
version_to_restore['checksum'],
current_user['id'],
f"Restored from version '{version_to_restore['version_name']}' - {changes_summary['frontends_updated']}F~, {changes_summary['frontends_created']}F+, {changes_summary['backends_updated']}B~, {changes_summary['backends_created']}B+",
json.dumps(restore_metadata)
)
await close_database_connection(conn)
await log_user_activity(
user_id=current_user['id'],
action="restore_config_version_confirmed",
resource_type="cluster",
resource_id=str(cluster_id),
details={
"restored_version_name": version_to_restore['version_name'],
"new_version_name": restored_version_name,
"changes": changes_summary
}
)
logger.info(f"RESTORE COMPLETE: {restored_version_name} - {changes_summary}")
return {
"message": "Configuration restored successfully! Entities synced to database.",
"restored_version": version_to_restore['version_name'],
"new_version_name": restored_version_name,
"new_version_id": new_version_id,
"status": "PENDING",
"changes": changes_summary,
"next_step": "Go to Apply Management and click 'Apply All Changes' to activate the restored configuration."
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Restore confirmation failed: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.patch("/{cluster_id}/config-versions/{version_id}/undo-reject")
async def undo_reject_config_version(
cluster_id: int,
version_id: int,
authorization: str = Header(None)
):
"""Undo rejection of a configuration version - mark it as PENDING again"""
try:
from auth_middleware import get_current_user_from_token
current_user = await get_current_user_from_token(authorization)
conn = await get_database_connection()
# Bulgu #79 — validate cluster access (admins bypass).
await validate_user_cluster_access(current_user['id'], cluster_id, conn)
# Check if version exists and is REJECTED
version_to_undo = await conn.fetchrow(
"SELECT * FROM config_versions WHERE id = $1 AND cluster_id = $2",
version_id, cluster_id
)
if not version_to_undo:
await close_database_connection(conn)
raise HTTPException(status_code=404, detail="Configuration version not found")
if version_to_undo['status'] != 'REJECTED':
await close_database_connection(conn)
raise HTTPException(status_code=400, detail="Only REJECTED versions can be undone")
# Bulgu #16 (round-6 audit) — defensive guard against
# undo of bulk-site-create-* / bulk-import-* / restore-*
# rejections.
#
# Why this guard exists: the reject path for these
# destructive bulk operations calls
# `rollback_entity_from_snapshot` for every entity in
# `metadata.bulk_snapshots`. For CREATE-op snapshots that
# routes to `_rollback_create` which HARD-DELETEs the rows
# (frontends / backends / backend_servers / letsencrypt_orders
# / ssl_certificates). The wizard's `_entity_snapshot`
# helper stores `new_values={}` so we have no preserved
# state to recreate from.
#
# The pre-fix undo silently flipped the version status
# back to PENDING and matched zero rows on the entity-
# status UPDATE. The next agent pull would render config
# WITHOUT the wizard's frontends/backends (they no longer
# exist), the apply would succeed cosmetically, and the
# operator would be confused why their "undone" site is
# nowhere to be found.
#
# Explicit 409 here forces the operator to re-create via
# the wizard — the only path that produces a recoverable
# state. The UI can hide / disable the Undo button for
# bulk versions in REJECTED state to make this guard
# self-documenting.
version_name = version_to_undo['version_name'] or ''
DESTRUCTIVE_PREFIXES = (
'bulk-site-create-',
'bulk-import-',
'bulk-proxied-host-create-', # legacy naming, pre-1.5.0
'restore-',
)
if version_name.startswith(DESTRUCTIVE_PREFIXES):
await close_database_connection(conn)
raise HTTPException(
status_code=409,
detail=(
f"Cannot undo rejection of '{version_name}'. This "
"version represents a destructive bulk operation: on "
"reject the underlying entities (frontends, backends, "
"servers, certs) were permanently deleted and the "
"snapshot does not carry enough state to recreate "
"them. Re-create the site via the wizard / re-run the "
"bulk import instead — that path also produces a "
"clean PENDING version that you can audit on Apply "
"Management."
),
)
# HA/VIP (Issue #27): vip-* versions are owned by the VIP entity, so undo is handled
# by the VIP router — it re-stages the rejected change as PENDING from the version's
# captured pending_state (reactivating the VIP if a rejected create soft-deleted it,
# or re-applying a rejected edit). Returns an error string if it can't (e.g. the
# name/address/VRID was reused since reject) -> surface a clean 409.
if version_name.startswith('vip-'):
from routers.vip import restore_vip_from_rejected_version
err = await restore_vip_from_rejected_version(conn, version_id)
await close_database_connection(conn)
if err:
raise HTTPException(status_code=409, detail=f"Cannot undo this VIP change — {err}.")
return {"message": "VIP change restored to PENDING — review and Apply it from Apply Management",
"version_name": version_name}
async with conn.transaction():
# Mark the version as PENDING again
await conn.execute("""
UPDATE config_versions
SET status = 'PENDING', updated_at = CURRENT_TIMESTAMP
WHERE id = $1
""", version_id)
# Also update the entity status to PENDING if this is an entity-specific version
import re
try:
m_fe = re.search(r'frontend-(\d+)-', version_to_undo['version_name'])
m_be = re.search(r'backend-(\d+)-', version_to_undo['version_name'])
m_wf = re.search(r'waf-(\d+)-', version_to_undo['version_name'])
m_srv = re.search(r'server-(\d+)-', version_to_undo['version_name'])
m_ssl = re.search(r'ssl-(\d+)-', version_to_undo['version_name'])
if m_fe:
fe_id = int(m_fe.group(1))
await conn.execute(
"UPDATE frontends SET last_config_status = 'PENDING' WHERE id = $1 AND cluster_id = $2",
fe_id, cluster_id
)
elif m_be:
be_id = int(m_be.group(1))
await conn.execute(
"UPDATE backends SET last_config_status = 'PENDING' WHERE id = $1 AND cluster_id = $2",
be_id, cluster_id
)
elif m_wf:
wf_id = int(m_wf.group(1))
await conn.execute(
"UPDATE waf_rules SET last_config_status = 'PENDING' WHERE id = $1 AND cluster_id = $2",
wf_id, cluster_id
)
elif m_srv:
srv_id = int(m_srv.group(1))
await conn.execute(
"UPDATE backend_servers SET last_config_status = 'PENDING' WHERE id = $1 AND cluster_id = $2",
srv_id, cluster_id
)
logger.info(f"UNDO REJECT: Marked server {srv_id} as PENDING")
elif m_ssl:
ssl_id = int(m_ssl.group(1))
ssl_pattern = f'ssl-{ssl_id}-%'
# SSL AUTO-UNDO: Undo ALL REJECTED ssl versions on ALL clusters
# Consistent with auto-reject: if reject is global, undo is global
auto_undo_result = await conn.execute("""
UPDATE config_versions
SET status = 'PENDING', updated_at = CURRENT_TIMESTAMP
WHERE version_name LIKE $1
AND status = 'REJECTED'
""", ssl_pattern)
auto_undo_count = int(auto_undo_result.split()[-1]) if auto_undo_result else 0
logger.info(f"SSL AUTO-UNDO: Restored {auto_undo_count} REJECTED SSL {ssl_id} versions to PENDING across all clusters")
# Determine last_config_status based on whether any cluster already APPLIED
has_applied = await conn.fetchval("""
SELECT EXISTS(
SELECT 1 FROM config_versions
WHERE version_name LIKE $1
AND status = 'APPLIED'
)
""", ssl_pattern)
if has_applied:
# Some cluster already deployed - keep APPLIED so agents can continue fetching
logger.info(f"UNDO REJECT: SSL {ssl_id} has APPLIED versions on other clusters, keeping last_config_status = APPLIED")
else:
# No cluster deployed - set PENDING so changes await Apply
await conn.execute(
"UPDATE ssl_certificates SET last_config_status = 'PENDING' WHERE id = $1",
ssl_id
)
logger.info(f"UNDO REJECT: Marked SSL certificate {ssl_id} as PENDING (no cluster has applied)")
except Exception as e:
logger.warning(f"Could not update entity status for version {version_to_undo['version_name']}: {e}")
await close_database_connection(conn)
await log_user_activity(
user_id=current_user['id'],
action="undo_reject_config_version",
resource_type="cluster",
resource_id=str(cluster_id),
details={
"version_name": version_to_undo['version_name'],
"version_id": version_id
}
)
return {
"message": "Configuration rejection undone successfully.",
"version_name": version_to_undo['version_name'],
"version_id": version_id,
"status": "PENDING"
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Undo reject configuration version failed: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{cluster_id}/ssl_certificates/{cert_id}/agent-sync")
async def get_ssl_certificate_agent_sync_status(cluster_id: int, cert_id: int, authorization: str = Header(None)):
"""Get agent synchronization status for a specific SSL certificate based on file deployment"""
try:
from auth_middleware import get_current_user_from_token
_ = await get_current_user_from_token(authorization)
conn = await get_database_connection()
# Get SSL certificate info
ssl_cert = await conn.fetchrow(
"""
SELECT name, primary_domain
FROM ssl_certificates
WHERE id = $1 AND is_active = TRUE
""",
cert_id
)
if not ssl_cert:
await close_database_connection(conn)
raise HTTPException(status_code=404, detail="SSL certificate not found")
# Check if SSL has a PENDING config version for this cluster (not yet applied)
# This is critical for Deployment Status tab accuracy
ssl_pending_version = await conn.fetchval("""
SELECT cv.status
FROM config_versions cv
WHERE cv.cluster_id = $1
AND cv.version_name LIKE $2
AND cv.status = 'PENDING'
ORDER BY cv.created_at DESC
LIMIT 1
""", cluster_id, f"ssl-{cert_id}-%")
ssl_config_status_for_cluster = 'PENDING' if ssl_pending_version else None
# FIXED: Use same logic as generic entity-sync endpoint
# Get both version name and timestamp in one query
ssl_specific_version_info = await conn.fetchrow("""
SELECT cv.version_name, cv.created_at
FROM config_versions cv
WHERE cv.cluster_id = $1 AND cv.status = 'APPLIED' AND cv.is_active = TRUE
AND cv.version_name LIKE $2
ORDER BY cv.created_at DESC
LIMIT 1
""", cluster_id, f"ssl-{cert_id}-%")
# If SSL has its own version, use that; otherwise use latest consolidated version
if ssl_specific_version_info:
latest_version = ssl_specific_version_info['version_name']
latest_version_created_at = ssl_specific_version_info['created_at']
ssl_config_status_for_cluster = 'APPLIED'
logger.info(f"SSL SYNC: Found SSL-specific APPLIED version for cert {cert_id}: {latest_version}")
else:
# No SSL-specific APPLIED version — check consolidated
consolidated_version_info = await conn.fetchrow("""
SELECT cv.version_name, cv.created_at
FROM config_versions cv
WHERE cv.cluster_id = $1 AND cv.status = 'APPLIED' AND cv.is_active = TRUE
AND cv.version_name LIKE 'apply-consolidated-%'
ORDER BY cv.created_at DESC
LIMIT 1
""", cluster_id)
if consolidated_version_info:
latest_version = consolidated_version_info['version_name']
latest_version_created_at = consolidated_version_info['created_at']
# If there's a PENDING SSL version but only consolidated is APPLIED,
# the SSL hasn't been applied to this cluster yet
if not ssl_config_status_for_cluster:
ssl_config_status_for_cluster = 'APPLIED'
logger.info(f"SSL SYNC: No SSL-specific version for cert {cert_id}, using consolidated: {latest_version}")
else:
latest_version = None
latest_version_created_at = None
if not ssl_config_status_for_cluster:
ssl_config_status_for_cluster = None
logger.info(f"SSL SYNC: No version found for cert {cert_id}")
# Get agents for this cluster
agents = await conn.fetch(
"""
SELECT a.id, a.name, COALESCE(a.enabled, TRUE) as enabled, a.status, a.haproxy_status,
a.last_seen, a.config_version, a.applied_config_version
FROM agents a
JOIN haproxy_clusters c ON c.pool_id = a.pool_id
WHERE c.id = $1
ORDER BY a.name
""",
cluster_id
)
# Expected SSL file path on agent servers
ssl_filename = f"{ssl_cert['name']}.pem"
expected_ssl_path = f"/etc/ssl/haproxy/{ssl_filename}"
# For SSL certificates, sync means:
# 1. Agent is online
# 2. SSL file exists on agent server at expected path
# 3. Agent has applied the latest cluster config version (which includes SSL changes)
agent_items = []
total_enabled = 0
disabled_count = 0
synced = 0
for ag in agents:
is_enabled = ag.get("enabled", True)
if not is_enabled:
# Disabled agents are excluded from SSL sync calculations
disabled_count += 1
agent_items.append({
"name": ag["name"],
"enabled": False,
"status": ag.get("status", "unknown"),
"haproxy_status": ag.get("haproxy_status", "unknown"),
"last_seen": ag["last_seen"].isoformat() if ag.get("last_seen") else None,
"delivered_version": ag.get("applied_config_version"),
"expected_ssl_path": expected_ssl_path,
"ssl_file_deployed": None,
"in_sync": None,
"sync_excluded": True
})
logger.info(f"SSL SYNC: Agent {ag['name']} - DISABLED (excluded from sync)")
continue
total_enabled += 1
is_online = (ag.get("status") == "online")
delivered_version = ag.get("applied_config_version")
# SSL sync criteria:
# - Agent must be online
# - Agent must have applied the latest cluster config version (which includes SSL deployment)
# Note: We can't directly check if SSL file exists on remote server from here
# So we assume if agent applied the latest cluster config, SSL file was deployed successfully
version_synced = delivered_version == latest_version if latest_version else True
ssl_file_deployed = is_online and version_synced # Assumption: if latest config applied, SSL file is deployed
in_sync = ssl_file_deployed
if in_sync:
synced += 1
agent_items.append({
"name": ag["name"],
"enabled": True,
"status": ag.get("status", "unknown"),
"haproxy_status": ag.get("haproxy_status", "unknown"),
"last_seen": ag["last_seen"].isoformat() if ag.get("last_seen") else None,
"delivered_version": delivered_version,
"expected_ssl_path": expected_ssl_path,
"ssl_file_deployed": ssl_file_deployed,
"in_sync": in_sync,
"sync_excluded": False
})
# Get SSL certificate entity_updated_at for proper sync status detection
ssl_cert_details = await conn.fetchrow(
"""
SELECT updated_at, created_at
FROM ssl_certificates
WHERE id = $1 AND is_active = TRUE
""",
cert_id
)
# Get last user who applied changes to this cluster (same as entity-sync endpoint)
last_applied_by_info = await conn.fetchrow("""
SELECT u.username, u.full_name, ual.created_at as applied_at
FROM user_activity_logs ual
JOIN users u ON u.id = ual.user_id
WHERE ual.resource_type = 'cluster'
AND ual.resource_id = $1
AND ual.action = 'apply_changes'
ORDER BY ual.created_at DESC
LIMIT 1
""", str(cluster_id))
last_applied_by = None
if last_applied_by_info:
last_applied_by = {
"username": last_applied_by_info['username'],
"full_name": last_applied_by_info['full_name'],
"applied_at": last_applied_by_info['applied_at'].isoformat().replace('+00:00', 'Z') if last_applied_by_info['applied_at'] else None
}
await close_database_connection(conn)
# Use updated_at if available, otherwise created_at
entity_updated_at = ssl_cert_details['updated_at'] if ssl_cert_details['updated_at'] else ssl_cert_details['created_at']
# Count offline agents (enabled but not online)
offline_agents = sum(
1 for ag in agents
if ag.get("enabled", True) and ag.get("status") != "online"
)
# If SSL is PENDING for this cluster, override sync data to prevent misleading "SYNCED" display
# Admin should see "NOT APPLIED" instead of false "100% SYNCED" for unapplied clusters
if ssl_config_status_for_cluster == 'PENDING':
synced = 0
offline_agents = 0
logger.info(f"SSL SYNC: SSL cert {cert_id} is PENDING for cluster {cluster_id}, overriding sync to 0")
return {
"sync_status": {
"synced_agents": synced,
"total_agents": total_enabled,
"disabled_agents": disabled_count,
"offline_agents": offline_agents,
"sync_percentage": round((synced / total_enabled * 100) if total_enabled > 0 else 0, 1),
"last_sync_check": datetime.now(timezone.utc).isoformat()
},
"ssl_config_status": ssl_config_status_for_cluster,
"entity_updated_at": entity_updated_at.isoformat() if entity_updated_at else None,
"latest_applied_version": latest_version,
"latest_version_created_at": latest_version_created_at.isoformat() if latest_version_created_at else None,
"version_applied_at": latest_version_created_at.isoformat() if latest_version_created_at else None,
"last_applied_by": last_applied_by,
"ssl_certificate": {
"name": ssl_cert["name"],
"domain": ssl_cert["primary_domain"],
"expected_path": expected_ssl_path
},
"agents": agent_items
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Error getting SSL certificate agent sync status: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/{cluster_id}", summary="Delete HAProxy Cluster", response_description="Cluster deleted successfully")
async def delete_cluster(cluster_id: int, authorization: str = Header(None)):
"""
# Delete HAProxy Cluster
Delete a cluster. The cluster must not have any dependencies (agents, backends, frontends, etc.).
## Path Parameters
- **cluster_id**: Cluster ID to delete
## Prerequisites
Before deleting a cluster, you must:
1. Remove or reassign all agents
2. Delete all backends
3. Delete all frontends
4. Delete all WAF rules
5. Delete all SSL certificates
## Example Request
```bash
curl -X DELETE "{BASE_URL}/api/clusters/1" \\
-H "Authorization: Bearer eyJhbGciOiJIUz..."
```
## Example Response
```json
{
"message": "Cluster 'production-cluster' deleted successfully"
}
```
## Error Responses
- **403**: Insufficient permissions
- **404**: Cluster not found
- **409**: Cluster has dependencies (cannot be deleted)
```json
{
"detail": "Cannot delete cluster 'production-cluster'. Dependencies exist: 5 active agent(s), 3 backend(s), 2 frontend(s)"
}
```
- **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 cluster delete
has_permission = await check_user_permission(current_user["id"], "clusters", "delete")
if not has_permission:
raise HTTPException(
status_code=403,
detail="Insufficient permissions: clusters.delete required"
)
conn = await get_database_connection()
# Check if cluster exists
cluster = await conn.fetchrow("SELECT name FROM haproxy_clusters WHERE id = $1", cluster_id)
if not cluster:
await close_database_connection(conn)
raise HTTPException(status_code=404, detail="Cluster not found")
# Bulgu #79 — validate cluster access (admins bypass).
await validate_user_cluster_access(current_user['id'], cluster_id, conn)
# Check for dependencies before deletion
dependencies = []
# Check active agents (via pool relationship)
active_agents = await conn.fetchval("""
SELECT COUNT(*) FROM agents a
INNER JOIN haproxy_clusters hc ON hc.pool_id = a.pool_id
WHERE hc.id = $1
""", cluster_id)
if active_agents > 0:
dependencies.append(f"{active_agents} active agent(s)")
# Check backends
backends_count = await conn.fetchval("SELECT COUNT(*) FROM backends WHERE cluster_id = $1", cluster_id)
if backends_count > 0:
dependencies.append(f"{backends_count} backend(s)")
# Check frontends
frontends_count = await conn.fetchval("SELECT COUNT(*) FROM frontends WHERE cluster_id = $1", cluster_id)
if frontends_count > 0:
dependencies.append(f"{frontends_count} frontend(s)")
# Check WAF rules associated with frontends in this cluster
waf_rules_count = await conn.fetchval("""
SELECT COUNT(DISTINCT wr.id) FROM waf_rules wr
INNER JOIN frontend_waf_rules fwr ON wr.id = fwr.waf_rule_id
INNER JOIN frontends f ON fwr.frontend_id = f.id
WHERE f.cluster_id = $1
""", cluster_id)
if waf_rules_count > 0:
dependencies.append(f"{waf_rules_count} WAF rule(s)")
# If dependencies exist, prevent deletion
if dependencies:
await close_database_connection(conn)
deps_text = ", ".join(dependencies)
raise HTTPException(
status_code=400,
detail=f"Cannot delete cluster: it has {deps_text}. Please remove these dependencies first."
)
# Use transaction to ensure atomic deletion
async with conn.transaction():
# Delete config versions first (they reference cluster)
await conn.execute("DELETE FROM config_versions WHERE cluster_id = $1", cluster_id)
# R18 audit fix (round 3 #8): wizard_drafts.payload carries
# the chosen cluster_id as JSONB. Without explicit cleanup,
# deleting a cluster left every operator's saved Site Drafts
# pointing at a non-existent cluster — Resume failed at the
# cluster Select (404) until the 30-day TTL pruned them.
# Drafts are user-scoped (no FK), so we have to scan the
# JSONB payload. The (payload->>'cluster_id') JSON path is
# text; cast to int and compare against the deleted cluster.
# Phase I: dual-filter — purge BOTH legacy
# `wizard_type='proxied_host'` and post-rebrand
# `wizard_type='site'` drafts that pointed at this
# now-deleted cluster, otherwise pre-rename drafts would
# linger as orphans until their 30-day TTL fires.
try:
deleted_drafts = await conn.execute(
"""
DELETE FROM wizard_drafts
WHERE wizard_type IN ('site', 'proxied_host')
AND (payload->>'cluster_id') ~ '^[0-9]+$'
AND ((payload->>'cluster_id')::int) = $1
""",
cluster_id,
)
if deleted_drafts and 'DELETE 0' not in str(deleted_drafts):
logger.info(
f"CLUSTER DELETE CASCADE: pruned wizard_drafts referencing "
f"cluster_id={cluster_id} ({deleted_drafts})"
)
except Exception as draft_e:
# Non-fatal — the cluster delete should still proceed
# even if the drafts table is missing or the JSONB path
# fails (very old DB schemas).
logger.warning(
f"CLUSTER DELETE CASCADE: failed to prune wizard_drafts "
f"for cluster_id={cluster_id}: {draft_e}"
)
# Delete cluster
await conn.execute("DELETE FROM haproxy_clusters WHERE id = $1", cluster_id)
await close_database_connection(conn)
# Log activity
await log_user_activity(
user_id=current_user["id"],
action='delete',
resource_type='cluster',
resource_id=str(cluster_id),
details={'name': cluster['name']}
)
return {"message": f"Cluster '{cluster['name']}' deleted successfully"}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to delete cluster: {e}")
raise HTTPException(status_code=500, detail=f"Failed to delete cluster: {str(e)}")
@router.get("/{cluster_id}/agents/configs")
async def get_cluster_agent_configs(cluster_id: int, authorization: str = Header(None)):
"""Get configuration files from all agents in a cluster"""
try:
# Check if this is an agent token or user JWT
is_agent_token = False
if authorization and authorization.startswith("Bearer "):
token = authorization.split(" ")[1]
# Agent tokens are simple strings, JWT tokens have 3 parts separated by dots
if len(token.split('.')) != 3:
is_agent_token = True
if not is_agent_token:
# For UI users, validate JWT token
from auth_middleware import get_current_user_from_token
current_user = await get_current_user_from_token(authorization)
conn = await get_database_connection()
# Check if cluster exists
cluster = await conn.fetchrow("SELECT name FROM haproxy_clusters WHERE id = $1", cluster_id)
if not cluster:
await close_database_connection(conn)
raise HTTPException(status_code=404, detail="Cluster not found")
# Get all agents in this cluster (via pool relationship)
agents = await conn.fetch("""
SELECT a.id, a.name, a.hostname, a.ip_address, a.status, a.last_seen
FROM agents a
INNER JOIN haproxy_clusters hc ON hc.pool_id = a.pool_id
WHERE hc.id = $1
ORDER BY a.name
""", cluster_id)
await close_database_connection(conn)
# Generate mock configurations for each agent
configs = []
configs_retrieved = 0
for agent in agents:
try:
if agent["status"] == "online":
# Generate realistic HAProxy config content
config_content = f"""# HAProxy Configuration for {agent['name']}
# Generated from agent: {agent['hostname']} ({agent['ip_address']})
# Last seen: {agent['last_seen']}
global
daemon
log stdout local0
maxconn 4096
user haproxy
group haproxy
defaults
mode http
timeout connect 5000ms
timeout client 50000ms
timeout server 50000ms
frontend main
bind *:80
bind *:443 ssl crt /etc/ssl/certs/haproxy.pem
redirect scheme https if !{{ ssl_fc }}
# Default backend
default_backend web_servers
backend web_servers
balance roundrobin
server web1 192.168.1.10:80 check
server web2 192.168.1.11:80 check
"""
configs.append({
"agent_id": agent["id"],
"agent_name": agent["name"],
"hostname": agent["hostname"],
"ip_address": agent["ip_address"],
"status": agent["status"],
"config_content": config_content,
"config_hash": f"sha256:{hash(config_content) % 1000000:06d}",
"retrieved_at": "2025-01-30T16:50:00Z",
"file_size": len(config_content)
})
configs_retrieved += 1
else:
configs.append({
"agent_id": agent["id"],
"agent_name": agent["name"],
"hostname": agent["hostname"],
"ip_address": agent["ip_address"],
"status": agent["status"],
"config_content": None,
"error": f"Agent {agent['name']} is {agent['status']}, cannot retrieve config"
})
except Exception as agent_error:
configs.append({
"agent_id": agent["id"],
"agent_name": agent["name"],
"hostname": agent["hostname"],
"ip_address": agent["ip_address"],
"status": agent["status"],
"config_content": None,
"error": f"Failed to retrieve config: {str(agent_error)}"
})
return {
"cluster_id": cluster_id,
"cluster_name": cluster["name"],
"total_agents": len(agents),
"configs_retrieved": configs_retrieved,
"configs": configs,
"retrieved_at": "2025-01-30T16:50:00Z"
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to get cluster agent configs: {e}")
raise HTTPException(status_code=500, detail=f"Failed to get cluster agent configs: {str(e)}")
@router.delete("/{cluster_id}/pending-changes")
async def reject_all_pending_changes(cluster_id: int, authorization: str = Header(None)):
"""
Reject all pending configuration changes for a cluster
This endpoint:
- Marks all PENDING config versions as REJECTED
- Updates entity status (frontends, backends, servers) to REJECTED
- Automatically detects and removes orphan config versions
- Prevents orphan versions from cluttering the system
Orphan Version Auto-Cleanup:
- Detects config versions referencing entities from different clusters
- Removes versions referencing deleted/non-existent entities
- Multi-cluster isolation: Only updates entities in the target cluster
- Example: Rejects backend-73-* only if backend 73 belongs to this cluster
Use Case:
- Discard unwanted configuration changes
- Clean up phantom pending changes
- Remove orphan versions without manual database intervention
Requires: User authentication (admin or user with cluster access)
"""
try:
from auth_middleware import get_current_user_from_token
current_user = await get_current_user_from_token(authorization)
conn = await get_database_connection()
# Check if cluster exists
cluster = await conn.fetchrow("SELECT id, name FROM haproxy_clusters WHERE id = $1", cluster_id)
if not cluster:
await close_database_connection(conn)
raise HTTPException(status_code=404, detail=f"Cluster {cluster_id} not found")
# Bulgu #79 — validate cluster access (admins bypass).
await validate_user_cluster_access(current_user['id'], cluster_id, conn)
# Get all pending config versions for this cluster (CRITICAL: Include metadata for rollback!)
# HA/VIP (Issue #27): exclude vip-* versions — they are rejected/reverted by the
# VIP reject endpoint (which restores keepalived state), not the generic rollback.
# ORDER BY created_at ASC: the rollback loop dedups per entity and keeps the FIRST-processed
# snapshot, so the OLDEST snapshot must win — its old_values hold the true pre-change state.
# Critical when one entity has multiple pending versions (e.g. cluster ACME enable->disable->enable):
# rolling back to the oldest restores the original acme_enabled. (Matches the apply SELECT, which
# already orders created_at ASC.)
pending_versions = await conn.fetch("""
SELECT id, version_name, metadata FROM config_versions
WHERE cluster_id = $1 AND status = 'PENDING' AND version_name NOT LIKE 'vip-%'
ORDER BY created_at ASC
""", cluster_id)
# CRITICAL FIX: Detect and clean orphan config versions
# Orphan versions reference entities that don't belong to this cluster (ID reuse bug)
orphan_version_ids = []
import re
for v in pending_versions:
is_orphan = False
# Check if backend version references a backend NOT in this cluster
m_be = re.search(r'^backend-(\d+)-', v['version_name'])
if m_be:
be_id = int(m_be.group(1))
backend_exists = await conn.fetchrow("""
SELECT id, cluster_id FROM backends WHERE id = $1
""", be_id)
# If backend doesn't exist, version is orphan (entity was hard deleted)
if backend_exists is None:
is_orphan = True
logger.warning(f"ORPHAN VERSION (reject): {v['version_name']} references non-existent backend {be_id}")
# If backend exists but cluster_id is NULL, preserve version (legacy data)
elif backend_exists['cluster_id'] is None:
logger.debug(f"Backend {be_id} has NULL cluster_id (legacy), preserving version")
is_orphan = False
# If backend belongs to different cluster, version is orphan
elif backend_exists['cluster_id'] != cluster_id:
is_orphan = True
logger.warning(f"ORPHAN VERSION (reject): {v['version_name']} references backend {be_id} from cluster {backend_exists['cluster_id']}, but version is in cluster {cluster_id}")
# Check if frontend version references a frontend NOT in this cluster
m_fe = re.search(r'^frontend-(\d+)-', v['version_name'])
if m_fe:
fe_id = int(m_fe.group(1))
frontend_exists = await conn.fetchrow("""
SELECT id, cluster_id FROM frontends WHERE id = $1
""", fe_id)
# If frontend doesn't exist, version is orphan (entity was hard deleted)
if frontend_exists is None:
is_orphan = True
logger.warning(f"ORPHAN VERSION (reject): {v['version_name']} references non-existent frontend {fe_id}")
# If frontend exists but cluster_id is NULL, preserve version (legacy data)
elif frontend_exists['cluster_id'] is None:
logger.debug(f"Frontend {fe_id} has NULL cluster_id (legacy), preserving version")
is_orphan = False
# If frontend belongs to different cluster, version is orphan
elif frontend_exists['cluster_id'] != cluster_id:
is_orphan = True
logger.warning(f"ORPHAN VERSION (reject): {v['version_name']} references frontend {fe_id} from cluster {frontend_exists['cluster_id']}, but version is in cluster {cluster_id}")
if is_orphan:
orphan_version_ids.append(v['id'])
# Delete orphan versions immediately (they're invalid)
# CRITICAL: Also clean entity status for orphan-referenced entities in THIS cluster
if orphan_version_ids:
# Extract entity IDs from orphan versions to clean their status
orphan_be_ids = []
orphan_fe_ids = []
for v in [ver for ver in pending_versions if ver['id'] in orphan_version_ids]:
m_be = re.search(r'^backend-(\d+)-', v['version_name'])
if m_be:
orphan_be_ids.append(int(m_be.group(1)))
m_fe = re.search(r'^frontend-(\d+)-', v['version_name'])
if m_fe:
orphan_fe_ids.append(int(m_fe.group(1)))
# Clean entity status ONLY for entities in THIS cluster with PENDING status
# This prevents orphan versions from leaving entity status stuck as PENDING
if orphan_be_ids:
await conn.execute("""
UPDATE backends
SET last_config_status = 'APPLIED'
WHERE id = ANY($1) AND cluster_id = $2 AND last_config_status = 'PENDING'
""", orphan_be_ids, cluster_id)
logger.info(f"REJECT: Cleaned PENDING status for {len(orphan_be_ids)} backends affected by orphan versions")
if orphan_fe_ids:
await conn.execute("""
UPDATE frontends
SET last_config_status = 'APPLIED'
WHERE id = ANY($1) AND cluster_id = $2 AND last_config_status = 'PENDING'
""", orphan_fe_ids, cluster_id)
logger.info(f"REJECT: Cleaned PENDING status for {len(orphan_fe_ids)} frontends affected by orphan versions")
# Now delete orphan versions
await conn.execute("""
DELETE FROM config_versions WHERE id = ANY($1)
""", orphan_version_ids)
logger.info(f"REJECT: Deleted {len(orphan_version_ids)} orphan config versions")
# Remove orphans from pending_versions list
pending_versions = [v for v in pending_versions if v['id'] not in orphan_version_ids]
if not pending_versions:
await close_database_connection(conn)
return {"message": "No pending changes found to reject", "rejected_count": 0}
# PHASE 3: Rollback entities from snapshots BEFORE marking as REJECTED
from utils.entity_snapshot import rollback_entity_from_snapshot
import json as json_module
rollback_success_count = 0
rollback_fail_count = 0
rollback_skip_count = 0
rolled_back_entities = set()
for version in pending_versions:
try:
# Parse metadata
metadata = json_module.loads(version['metadata']) if version['metadata'] else {}
logger.info(f"REJECT DEBUG: Version {version['version_name']} - metadata exists={version['metadata'] is not None}")
logger.info(f"REJECT DEBUG: Parsed metadata keys={list(metadata.keys()) if metadata else 'EMPTY'}")
# Check for single entity snapshot
entity_snapshot = metadata.get('entity_snapshot')
logger.info(f"REJECT DEBUG: entity_snapshot exists={entity_snapshot is not None}")
if entity_snapshot:
entity_key = (entity_snapshot.get('entity_type'), entity_snapshot.get('entity_id'))
if entity_key in rolled_back_entities:
logger.info(f"REJECT ROLLBACK: Skipping duplicate rollback for {entity_key[0]} {entity_key[1]} (already restored from oldest snapshot)")
rollback_skip_count += 1
elif entity_snapshot.get('entity_type') == 'ssl_certificate':
ssl_entity_id = entity_snapshot.get('entity_id')
logger.info(
f"REJECT: Rolling back SSL certificate {ssl_entity_id} content to pre-update state"
)
success = await rollback_entity_from_snapshot(conn, entity_snapshot)
if success:
rollback_success_count += 1
rolled_back_entities.add(entity_key)
logger.info(f"REJECT ROLLBACK: Rolled back SSL certificate {ssl_entity_id}")
else:
rollback_fail_count += 1
logger.warning(f"REJECT ROLLBACK: Failed to rollback SSL certificate {ssl_entity_id}")
else:
logger.info(f"REJECT DEBUG: Calling rollback for {entity_snapshot.get('entity_type')} {entity_snapshot.get('entity_id')}")
logger.info(f"REJECT DEBUG: old_values exists={('old_values' in entity_snapshot)}")
logger.info(f"REJECT DEBUG: operation={entity_snapshot.get('operation')}")
success = await rollback_entity_from_snapshot(conn, entity_snapshot)
logger.info(f"REJECT DEBUG: Rollback result={success}")
if success:
rollback_success_count += 1
rolled_back_entities.add(entity_key)
logger.info(f"REJECT ROLLBACK: Rolled back {entity_snapshot['entity_type']} {entity_snapshot['entity_id']}")
else:
rollback_fail_count += 1
logger.warning(f"REJECT ROLLBACK: Failed to rollback {entity_snapshot.get('entity_type')} {entity_snapshot.get('entity_id')}")
# Check for bulk snapshots (bulk import, restore)
bulk_snapshots = metadata.get('bulk_snapshots', [])
if bulk_snapshots:
# R18c audit fix (round 1 #6): walk bulk snapshots
# in REVERSE creation order. The wizard appends in
# the order backend → servers → ssl_certificate
# → HTTP frontend → HTTPS frontend (which
# references the cert via `ssl_certificate_id` /
# `ssl_certificate_ids`). Pre-fix the rollback
# walked forward and tried to DELETE the cert
# BEFORE the frontend that referenced it. With
# deployments that have an FK on
# `frontends.ssl_certificate_id` (added in
# ensure_frontends_ssl_columns over time), the
# cert delete fired a FK violation and the
# rollback aborted, leaving the wizard's HTTPS
# frontend stranded as a CREATE without a
# rollback peer. Reversing the iteration restores
# the natural delete order (children before
# parents) so a strict FK schema rolls back
# cleanly. For deployments without the FK the
# change is a behaviour-preserving no-op.
snapshot_iter = list(reversed(bulk_snapshots))
logger.info(
f"REJECT ROLLBACK: Processing bulk snapshot with "
f"{len(bulk_snapshots)} entities (reverse-order)"
)
for snapshot_wrapper in snapshot_iter:
entity_snap = snapshot_wrapper.get('entity_snapshot')
if entity_snap:
# SSL entity rollback in bulk: Always rollback (Auto-Reject handles cross-cluster)
if entity_snap.get('entity_type') == 'ssl_certificate':
ssl_eid = entity_snap.get('entity_id')
logger.info(f"REJECT: Rolling back SSL certificate {ssl_eid} from bulk snapshot")
success = await rollback_entity_from_snapshot(conn, entity_snap)
if success:
rollback_success_count += 1
else:
rollback_fail_count += 1
# If no snapshot found
if not entity_snapshot and not bulk_snapshots:
rollback_skip_count += 1
logger.debug(f"REJECT ROLLBACK: No entity snapshot in version {version['version_name']}, skipping rollback")
except Exception as rollback_error:
logger.error(f"REJECT ROLLBACK ERROR: Version {version['version_name']}: {rollback_error}", exc_info=True)
rollback_fail_count += 1
logger.info(
f"REJECT ROLLBACK SUMMARY: success={rollback_success_count}, "
f"failed={rollback_fail_count}, skipped={rollback_skip_count}"
)
# Mark all pending config versions as REJECTED (don't delete them)
# HA/VIP (Issue #27): leave vip-* versions to the VIP reject endpoint.
rejected_count = len(pending_versions)
await conn.execute("""
UPDATE config_versions
SET status = 'REJECTED'
WHERE cluster_id = $1 AND status = 'PENDING' AND version_name NOT LIKE 'vip-%'
""", cluster_id)
# Update WAF rules status to APPLIED (rolled back)
try:
waf_ids = []
import re
for v in pending_versions:
m = re.search(r'^waf-(\d+)-', v['version_name'])
if m:
waf_ids.append(int(m.group(1)))
if waf_ids:
await conn.execute("UPDATE waf_rules SET last_config_status = 'APPLIED' WHERE id = ANY($1)", waf_ids)
logger.info(f"REJECT: Updated {len(waf_ids)} WAF rules status to APPLIED (rolled back)")
except Exception as _:
pass
# PHASE 3: Update entity statuses to APPLIED (entities were rolled back)
# CRITICAL: Entities are now at their old values, so status should be APPLIED not REJECTED
# CRITICAL FIX: Only update entities that actually belong to this cluster (prevent orphan versions)
try:
fe_ids = []
be_ids = []
srv_ids = []
ssl_ids = []
bulk_import_entity_ids = {
"frontends": [],
"backends": [],
"servers": [],
"letsencrypt_orders": [],
# R18 audit fix: wizard upload-mode hosts create a NEW
# ssl_certificates row and add a snapshot for it. Pre-R18
# this list omitted ssl_certificate, so reject left an
# orphan SSL row + PEM material on disk while the
# frontend/backend got cleaned up. Symmetrical handling
# is required for atomic wizard rollback.
"ssl_certificates": [],
}
import re
for v in pending_versions:
# CRITICAL FIX: Detect bulk import versions (bulk-import-*, restore-*)
# v1.5.0: also covers wizard-created versions:
# * bulk-site-create-* — current naming (post-rename)
# * bulk-proxied-host-create-* — legacy naming (pre-rename),
# kept so historical APPLIED
# versions still reject cleanly
# M4/L11.
is_bulk_version = (
v['version_name'].startswith('bulk-import-') or
v['version_name'].startswith('restore-') or
v['version_name'].startswith('bulk-site-create-') or
v['version_name'].startswith('bulk-proxied-host-create-')
)
if is_bulk_version:
# Extract entity IDs from metadata bulk_snapshots
metadata = json_module.loads(v['metadata']) if v['metadata'] else {}
bulk_snapshots = metadata.get('bulk_snapshots', [])
logger.info(f"REJECT: Bulk version '{v['version_name']}' has {len(bulk_snapshots)} snapshots")
for snapshot_wrapper in bulk_snapshots:
entity_snap = snapshot_wrapper.get('entity_snapshot')
if entity_snap:
entity_type = entity_snap.get('entity_type')
entity_id = entity_snap.get('entity_id')
operation = entity_snap.get('operation')
# CRITICAL FIX: Only track CREATE operations for force deletion
# UPDATE operations should be rolled back, not deleted!
# If we delete an UPDATE entity, we lose the original entity that existed before bulk import
if operation == "CREATE":
# Track bulk import entities for verification (only newly created ones)
if entity_type == "frontend":
bulk_import_entity_ids["frontends"].append(entity_id)
elif entity_type == "backend":
bulk_import_entity_ids["backends"].append(entity_id)
elif entity_type == "server":
bulk_import_entity_ids["servers"].append(entity_id)
elif entity_type == "letsencrypt_order":
# v1.5.0: wizard's staged ACME order for the new
# site. Reject path must clean it up so the user
# is not left with a dangling wizard_staged
# order pointing at a frontend that no longer
# exists. (R43/M27)
bulk_import_entity_ids["letsencrypt_orders"].append(entity_id)
elif entity_type == "ssl_certificate":
# R18 audit fix: track for force-delete
# parity with frontends/backends/servers.
# Without this the wizard's upload-mode
# cert row is left orphaned after a
# rejected wizard PENDING version.
bulk_import_entity_ids["ssl_certificates"].append(entity_id)
else:
# Normal entity-specific version (frontend-5-update, backend-3-create, etc.)
m1 = re.search(r'^frontend-(\d+)-', v['version_name'])
if m1:
fe_ids.append(int(m1.group(1)))
m2 = re.search(r'^backend-(\d+)-', v['version_name'])
if m2:
be_ids.append(int(m2.group(1)))
# CRITICAL FIX: Also extract server IDs from version names
m3 = re.search(r'^server-(\d+)-', v['version_name'])
if m3:
srv_ids.append(int(m3.group(1)))
m4 = re.search(r'^ssl-(\d+)-', v['version_name'])
if m4:
ssl_ids.append(int(m4.group(1)))
# CRITICAL: Only update entities that belong to THIS cluster (multi-cluster isolation)
# Status = APPLIED because entities were rolled back to their old values
if fe_ids:
await conn.execute("""
UPDATE frontends SET last_config_status = 'APPLIED'
WHERE id = ANY($1) AND cluster_id = $2
""", fe_ids, cluster_id)
logger.info(f"REJECT: Updated {len(fe_ids)} frontends status to APPLIED (rolled back)")
if be_ids:
await conn.execute("""
UPDATE backends SET last_config_status = 'APPLIED'
WHERE id = ANY($1) AND cluster_id = $2
""", be_ids, cluster_id)
logger.info(f"REJECT: Updated {len(be_ids)} backends status to APPLIED (rolled back)")
if srv_ids:
await conn.execute("""
UPDATE backend_servers SET last_config_status = 'APPLIED'
WHERE id = ANY($1) AND cluster_id = $2
""", srv_ids, cluster_id)
logger.info(f"REJECT: Updated {len(srv_ids)} servers status to APPLIED (rolled back)")
# SSL status update + auto-reject in a SINGLE transaction for atomicity
# Prevents partial state where SSL is APPLIED but other clusters still PENDING
if ssl_ids:
async with conn.transaction():
await conn.execute("""
UPDATE ssl_certificates SET last_config_status = 'APPLIED'
WHERE id = ANY($1)
""", ssl_ids)
logger.info(f"REJECT: Updated {len(ssl_ids)} SSL certificates status to APPLIED (rolled back)")
# Disable auto-renewal for rejected LE certs to prevent renewal loops
await conn.execute("""
UPDATE ssl_certificates SET auto_renew = FALSE
WHERE id = ANY($1) AND source = 'letsencrypt'
""", ssl_ids)
# SSL AUTO-REJECT: Reject ALL remaining PENDING SSL config_versions on ALL other clusters
# SSL is a global entity - reject on one cluster means reject everywhere
unique_ssl_ids = list(set(ssl_ids))
total_auto_rejected = 0
for ssl_id in unique_ssl_ids:
ssl_pattern = f'ssl-{ssl_id}-%'
auto_result = await conn.execute("""
UPDATE config_versions
SET status = 'REJECTED', updated_at = CURRENT_TIMESTAMP
WHERE cluster_id != $1
AND version_name LIKE $2
AND status = 'PENDING'
""", cluster_id, ssl_pattern)
auto_count = int(auto_result.split()[-1]) if auto_result else 0
total_auto_rejected += auto_count
if auto_count > 0:
logger.info(
f"SSL AUTO-REJECT: Rejected {auto_count} PENDING versions for SSL {ssl_id} "
f"on other clusters (triggered by cluster {cluster_id} reject)"
)
if total_auto_rejected > 0:
logger.info(f"SSL AUTO-REJECT: Total {total_auto_rejected} versions auto-rejected across all other clusters")
rejected_count += total_auto_rejected
# CRITICAL FIX: Verify bulk import entities were properly rolled back (deleted)
if (
bulk_import_entity_ids["frontends"]
or bulk_import_entity_ids["backends"]
or bulk_import_entity_ids["servers"]
or bulk_import_entity_ids["letsencrypt_orders"]
or bulk_import_entity_ids["ssl_certificates"]
):
# Check if bulk import entities still exist (rollback failed)
remaining_fe = await conn.fetchval("""
SELECT COUNT(*) FROM frontends
WHERE id = ANY($1) AND cluster_id = $2
""", bulk_import_entity_ids["frontends"], cluster_id) if bulk_import_entity_ids["frontends"] else 0
remaining_be = await conn.fetchval("""
SELECT COUNT(*) FROM backends
WHERE id = ANY($1) AND cluster_id = $2
""", bulk_import_entity_ids["backends"], cluster_id) if bulk_import_entity_ids["backends"] else 0
remaining_srv = await conn.fetchval("""
SELECT COUNT(*) FROM backend_servers
WHERE id = ANY($1) AND cluster_id = $2
""", bulk_import_entity_ids["servers"], cluster_id) if bulk_import_entity_ids["servers"] else 0
# v1.5.0 wizard staged ACME orders are NOT cluster-scoped via cluster_id
# column (cluster_ids JSONB). Their pre_apply_snapshot does the
# rollback only via metadata. So we treat any matching id-by-id
# row that still exists as "remaining" and force delete.
remaining_acme = await conn.fetchval("""
SELECT COUNT(*) FROM letsencrypt_orders
WHERE id = ANY($1)
""", bulk_import_entity_ids["letsencrypt_orders"]) if bulk_import_entity_ids["letsencrypt_orders"] else 0
# R18 audit fix: ssl_certificates rows created by the
# wizard's upload-mode flow. These are global (not
# cluster-scoped via the cluster_id column directly —
# the join lives in ssl_certificate_clusters), so we
# match by id only.
remaining_ssl = await conn.fetchval("""
SELECT COUNT(*) FROM ssl_certificates
WHERE id = ANY($1)
""", bulk_import_entity_ids["ssl_certificates"]) if bulk_import_entity_ids["ssl_certificates"] else 0
total_remaining = (
remaining_fe + remaining_be + remaining_srv
+ remaining_acme + remaining_ssl
)
if total_remaining > 0:
# CRITICAL: Bulk import entities were NOT deleted by rollback!
# This is a data corruption - entities should have been deleted
logger.error(
f"REJECT ROLLBACK FAILED: {total_remaining} bulk import entities still exist "
f"(fe={remaining_fe}, be={remaining_be}, srv={remaining_srv}, "
f"acme={remaining_acme}). "
f"Expected 0 after rollback DELETE. This indicates rollback failure."
)
# Force delete the entities since rollback failed
if bulk_import_entity_ids["frontends"]:
deleted_fe = await conn.execute("""
DELETE FROM frontends
WHERE id = ANY($1) AND cluster_id = $2
""", bulk_import_entity_ids["frontends"], cluster_id)
logger.warning(f"REJECT CLEANUP: Force deleted {deleted_fe} frontends from failed bulk import")
if bulk_import_entity_ids["backends"]:
deleted_be = await conn.execute("""
DELETE FROM backends
WHERE id = ANY($1) AND cluster_id = $2
""", bulk_import_entity_ids["backends"], cluster_id)
logger.warning(f"REJECT CLEANUP: Force deleted {deleted_be} backends (+ cascade servers) from failed bulk import")
# Servers are cascade deleted with backends, but clean any orphans
if bulk_import_entity_ids["servers"]:
deleted_srv = await conn.execute("""
DELETE FROM backend_servers
WHERE id = ANY($1) AND cluster_id = $2
""", bulk_import_entity_ids["servers"], cluster_id)
logger.warning(f"REJECT CLEANUP: Force deleted {deleted_srv} orphan servers from failed bulk import")
# v1.5.0 (R43/M27): wizard-staged ACME orders. Cascade
# also removes acme_challenges (ON DELETE CASCADE).
if bulk_import_entity_ids["letsencrypt_orders"]:
deleted_acme = await conn.execute("""
DELETE FROM letsencrypt_orders
WHERE id = ANY($1)
""", bulk_import_entity_ids["letsencrypt_orders"])
logger.warning(
f"REJECT CLEANUP: Force deleted {deleted_acme} wizard-staged "
f"letsencrypt_orders from failed bulk import"
)
# R18 audit fix: wizard upload-mode ssl_certificates
# rows. ON DELETE CASCADE on ssl_certificate_clusters
# cleans the junction; auto_renew=FALSE was already
# applied above for safety.
if bulk_import_entity_ids["ssl_certificates"]:
deleted_ssl = await conn.execute("""
DELETE FROM ssl_certificates
WHERE id = ANY($1)
""", bulk_import_entity_ids["ssl_certificates"])
logger.warning(
f"REJECT CLEANUP: Force deleted {deleted_ssl} wizard "
f"ssl_certificates from failed bulk import"
)
else:
total_tracked = (
len(bulk_import_entity_ids["frontends"])
+ len(bulk_import_entity_ids["backends"])
+ len(bulk_import_entity_ids["servers"])
+ len(bulk_import_entity_ids["letsencrypt_orders"])
+ len(bulk_import_entity_ids["ssl_certificates"])
)
logger.info(
f"REJECT ROLLBACK SUCCESS: All {total_tracked} "
f"bulk import entities were properly deleted"
)
except Exception as e:
logger.error(f"Failed to update entity statuses during reject: {e}", exc_info=True)
pass
# FINAL CLEANUP: Remove orphan entity statuses without corresponding config versions
# This handles edge cases where entities have PENDING status but no PENDING version exists
# CRITICAL: Only clean if NO pending versions exist at all (prevents bulk import issues)
try:
# Check if there are ANY pending versions left
any_pending_versions = await conn.fetchval("""
SELECT EXISTS(
SELECT 1 FROM config_versions
WHERE cluster_id = $1 AND status = 'PENDING'
)
""", cluster_id)
# Only run orphan cleanup if NO pending versions exist
# This prevents cleaning entities from bulk-import or other non-ID-specific versions
if not any_pending_versions:
# ADDITIONAL SAFETY: Check if we just rejected bulk import versions
# If yes, DO NOT run final cleanup (bulk entities should already be deleted)
has_bulk_versions = any(
v['version_name'].startswith('bulk-import-')
or v['version_name'].startswith('restore-')
or v['version_name'].startswith('bulk-site-create-') # v1.5.0 (current naming)
or v['version_name'].startswith('bulk-proxied-host-create-') # v1.5.0 legacy
for v in pending_versions
)
if has_bulk_versions:
logger.info("REJECT: Skipping final cleanup (bulk import was rejected - entities already handled)")
else:
# Safe to clean orphan entities (no bulk import, no pending versions)
cleaned_be = await conn.execute("""
UPDATE backends
SET last_config_status = 'APPLIED'
WHERE cluster_id = $1 AND last_config_status = 'PENDING'
""", cluster_id)
cleaned_fe = await conn.execute("""
UPDATE frontends
SET last_config_status = 'APPLIED'
WHERE cluster_id = $1 AND last_config_status = 'PENDING'
""", cluster_id)
cleaned_srv = await conn.execute("""
UPDATE backend_servers
SET last_config_status = 'APPLIED'
WHERE cluster_id = $1 AND last_config_status = 'PENDING'
""", cluster_id)
logger.info(f"REJECT: Final orphan cleanup - cleaned {cleaned_be} backends, {cleaned_fe} frontends, {cleaned_srv} servers (no pending versions)")
else:
logger.debug("REJECT: Skipping final cleanup (pending versions still exist)")
except Exception as cleanup_error:
logger.error(f"Final orphan entity cleanup failed: {cleanup_error}", exc_info=True)
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='reject_pending_changes',
resource_type='cluster',
resource_id=str(cluster_id),
details={
'cluster_name': cluster['name'],
'rejected_count': rejected_count,
'version_names': [v['version_name'] for v in pending_versions]
}
)
return {
"message": f"Successfully rejected {rejected_count} pending changes for cluster {cluster['name']}",
"rejected_count": rejected_count
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Error rejecting pending changes: {e}")
raise HTTPException(status_code=500, detail=str(e))