""" Entity Snapshot Module for HAProxy OpenManager This module captures snapshots of entity changes so they can be restored on reject of a config_version. Usage: # Capture an UPDATE snapshot. old_entity = await conn.fetchrow("SELECT * FROM frontends WHERE id = 5") snapshot_metadata = await save_entity_snapshot( conn=conn, entity_type="frontend", entity_id=5, old_values=old_entity, new_values={"bind_port": 443}, operation="UPDATE" ) # Persist the snapshot on the config version. await conn.execute( "INSERT INTO config_versions (..., metadata) VALUES (..., $1)", json.dumps(snapshot_metadata) ) # Roll back when the config version is rejected. await rollback_entity_from_snapshot(conn, snapshot_metadata["entity_snapshot"]) Supported Operations: - UPDATE: restore the entity's fields to their pre-change values - CREATE: delete the newly-created entity (used by bulk import / wizard) - UPDATE_RESTORE: undo an UPDATE performed during a restore flow - DELETE: re-create a soft-deleted entity (currently unused — soft delete is preferred) Feature Flag: ENTITY_SNAPSHOT_ENABLED=true|false Default: false — safe-by-default until the operator opts in. Author: Taylan Bakırcıoğlu Date: 2025-01-13 Version: 1.0.0 """ import json import os import logging from typing import Dict, Any, Optional, List from datetime import datetime # Setup logger logger = logging.getLogger(__name__) # Entity snapshot enabled by default # Can be disabled with ENTITY_SNAPSHOT_ENABLED=false if needed ENTITY_SNAPSHOT_ENABLED = os.getenv("ENTITY_SNAPSHOT_ENABLED", "true").lower() == "true" async def save_entity_snapshot( conn, entity_type: str, entity_id: int, old_values: Dict[str, Any], new_values: Optional[Dict[str, Any]] = None, operation: str = "UPDATE" ) -> Dict[str, Any]: """ Entity snapshot'ı metadata formatında hazırla. Args: conn: Database connection (asyncpg) entity_type: Entity tipi ('frontend', 'backend', 'waf_rule', 'ssl_certificate', 'server') entity_id: Entity ID old_values: Eski değerler (asyncpg Record'dan dict) new_values: Yeni değerler (opsiyonel, UPDATE için) operation: İşlem tipi ('CREATE', 'UPDATE', 'DELETE', 'UPDATE_RESTORE') Returns: Dict containing entity_snapshot metadata: { "entity_snapshot": { "entity_type": "frontend", "entity_id": 5, "operation": "UPDATE", "timestamp": "2025-01-13T10:30:45Z", "old_values": {...}, "new_values": {...}, # Sadece değişen alanlar (compaction) "changed_fields": ["bind_port", "ssl_enabled"] } } Example: old_frontend = await conn.fetchrow("SELECT * FROM frontends WHERE id = 5") metadata = await save_entity_snapshot( conn=conn, entity_type="frontend", entity_id=5, old_values=dict(old_frontend), new_values={"bind_port": 443, "ssl_enabled": True}, operation="UPDATE" ) """ # Feature flag check if not ENTITY_SNAPSHOT_ENABLED: logger.debug(f"Entity snapshot disabled by feature flag for {entity_type} {entity_id}") return {} try: # Convert asyncpg Record to dict if needed if hasattr(old_values, '__iter__') and not isinstance(old_values, dict): old_values = dict(old_values) # CRITICAL FIX: Convert non-JSON-serializable types to JSON-safe format # Use try-catch for each value to handle asyncpg types, datetime, etc. import json as json_test serializable_old_values = {} for key, value in old_values.items(): try: # Test if value is JSON serializable json_test.dumps(value) # If no exception, use as-is serializable_old_values[key] = value except (TypeError, ValueError): # Value is not JSON serializable, convert it if value is None: serializable_old_values[key] = None elif hasattr(value, 'isoformat'): # datetime, date, time objects serializable_old_values[key] = str(value) else: # Everything else: convert to string serializable_old_values[key] = str(value) # CRITICAL FIX: Serialize new_values the same way as old_values # Without this, datetime objects in new_values (e.g., expiry_date from SSL update) # cause the final JSON serialization test to FAIL, resulting in empty snapshot ({}) # which means reject/rollback can NEVER find the snapshot to restore old values serializable_new_values = {} if new_values: for key, value in new_values.items(): try: json_test.dumps(value) serializable_new_values[key] = value except (TypeError, ValueError): if value is None: serializable_new_values[key] = None elif hasattr(value, 'isoformat'): serializable_new_values[key] = str(value) else: serializable_new_values[key] = str(value) # Calculate changed fields for UPDATE operations # Compare serialized old_values with serialized new_values for consistent comparison changed_fields = [] if operation in ["UPDATE", "UPDATE_RESTORE"] and serializable_new_values: changed_fields = [ field for field, new_val in serializable_new_values.items() if field in serializable_old_values and serializable_old_values[field] != new_val ] # Build snapshot snapshot = { "entity_type": entity_type, "entity_id": entity_id, "operation": operation, "timestamp": datetime.utcnow().isoformat() + "Z", "old_values": serializable_old_values, # JSON-safe values "changed_fields": changed_fields } # For UPDATE: add new_values (compaction - only changed fields) if operation in ["UPDATE", "UPDATE_RESTORE"] and serializable_new_values: # Only store changed fields to save space (compaction) snapshot["new_values"] = { field: serializable_new_values[field] for field in changed_fields } logger.info( f"SNAPSHOT: Created for {entity_type} {entity_id} " f"(operation={operation}, changed_fields={len(changed_fields)})" ) logger.info(f"SNAPSHOT DEBUG: old_values keys={list(serializable_old_values.keys())[:10]}") logger.info(f"SNAPSHOT DEBUG: Serializable check - can serialize to JSON: {len(serializable_old_values)} fields") # Test if snapshot is JSON serializable try: import json as json_final_test json_final_test.dumps(snapshot) logger.info(f"SNAPSHOT DEBUG: Final JSON test PASSED for {entity_type} {entity_id}") except Exception as json_err: logger.error(f"SNAPSHOT DEBUG: Final JSON test FAILED for {entity_type} {entity_id}: {json_err}") return {} return {"entity_snapshot": snapshot} except Exception as e: logger.error(f"SNAPSHOT ERROR: Failed to create snapshot for {entity_type} {entity_id}: {e}") # Return empty dict on error (graceful degradation) return {} async def rollback_entity_from_snapshot( conn, entity_snapshot: Dict[str, Any] ) -> bool: """ Entity'yi snapshot'taki eski değerlerine geri yükle. Args: conn: Database connection (asyncpg) entity_snapshot: Snapshot dict (from metadata.entity_snapshot) Returns: True if rollback successful, False otherwise Example: metadata = json.loads(version['metadata']) entity_snapshot = metadata.get('entity_snapshot') if entity_snapshot: success = await rollback_entity_from_snapshot(conn, entity_snapshot) if success: logger.info("Rollback successful") else: logger.warning("Rollback failed") """ if not ENTITY_SNAPSHOT_ENABLED: logger.warning("ROLLBACK DEBUG: Entity snapshot disabled by feature flag, skipping rollback") return False entity_type = entity_snapshot.get("entity_type") entity_id = entity_snapshot.get("entity_id") operation = entity_snapshot.get("operation") old_values = entity_snapshot.get("old_values") logger.info(f"ROLLBACK DEBUG: entity_type={entity_type}, entity_id={entity_id}, operation={operation}") logger.info(f"ROLLBACK DEBUG: old_values exists={old_values is not None}, old_values length={len(old_values) if old_values else 0}") # R18 audit fix: the historical guard `not all([..., old_values])` # treated `old_values={}` as missing because `{}` is falsy in Python. # The wizard's bulk_snapshots always emit `"old_values": {}` for # CREATE entries (there's nothing to restore on rollback — the # rollback path is a DELETE) — so EVERY wizard CREATE snapshot # silently bypassed the rollback shim. Rejecting a wizard PENDING # version then visibly removed the config_versions row but left the # wizard-created backends/servers/frontends/SSL certs orphaned in # the DB. Fix: only require old_values for UPDATE/DELETE; for # CREATE the field is intentionally empty. if not all([entity_type, entity_id, operation]): logger.warning( "ROLLBACK: Invalid snapshot data, skipping rollback (missing: " f"{[k for k in ['entity_type', 'entity_id', 'operation'] if not entity_snapshot.get(k)]})" ) return False if operation in ("UPDATE", "UPDATE_RESTORE", "DELETE") and not old_values: logger.warning( f"ROLLBACK: {operation} snapshot for {entity_type} {entity_id} " "is missing old_values — cannot restore prior state" ) return False try: if operation in ["UPDATE", "UPDATE_RESTORE"]: # Entity'yi eski değerlerine geri yükle logger.info(f"ROLLBACK DEBUG: Calling _rollback_update for {entity_type} {entity_id}") success = await _rollback_update(conn, entity_type, entity_id, old_values) logger.info(f"ROLLBACK DEBUG: _rollback_update returned {success}") return success elif operation == "CREATE": # Yeni oluşturulan entity'yi sil # ÖNEMLİ: Sadece bulk import ile TAMAMEN YENİ oluşturulan entity'ler için! success = await _rollback_create(conn, entity_type, entity_id) return success elif operation == "DELETE": # Silinen entity'yi geri yükle # NOT: Şu an soft-delete kullanıldığı için kullanılmıyor success = await _rollback_delete(conn, entity_type, entity_id, old_values) return success else: logger.warning(f"ROLLBACK: Unknown operation '{operation}', skipping") return False except Exception as e: logger.error(f"ROLLBACK ERROR: Failed for {entity_type} {entity_id}: {e}") return False async def _rollback_update( conn, entity_type: str, entity_id: int, old_values: Dict[str, Any] ) -> bool: """ Entity'yi UPDATE öncesi haline döndür. ÖNEMLİ: Bu fonksiyon var olan entity'lerin field'larını eski değerlere döndürür. Entity'yi KESİNLİKLE silmez! Args: conn: Database connection entity_type: Entity tipi entity_id: Entity ID old_values: Eski değerler (snapshot'tan) Returns: True if successful, False otherwise """ try: if entity_type == "frontend": # Frontend'i eski değerlerine geri yükle logger.info(f"ROLLBACK UPDATE DEBUG: Frontend {entity_id} - restoring bind_port={old_values.get('bind_port')}") logger.info(f"ROLLBACK UPDATE DEBUG: old_values sample: name={old_values.get('name')}, bind_port={old_values.get('bind_port')}, ssl_enabled={old_values.get('ssl_enabled')}") result = await conn.execute(""" UPDATE frontends SET name = $1, bind_address = $2, bind_port = $3, default_backend = $4, mode = $5, ssl_enabled = $6, ssl_certificate_id = $7, ssl_certificate_ids = $8, ssl_port = $9, ssl_cert_path = $10, ssl_cert = $11, ssl_verify = $12, ssl_alpn = $13, ssl_npn = $14, ssl_ciphers = $15, ssl_ciphersuites = $16, ssl_min_ver = $17, ssl_max_ver = $18, ssl_strict_sni = $19, acl_rules = $20, redirect_rules = $21, use_backend_rules = $22, request_headers = $23, response_headers = $24, options = $25, tcp_request_rules = $26, timeout_client = $27, timeout_http_request = $28, rate_limit = $29, compression = $30, log_separate = $31, monitor_uri = $32, maxconn = $33, cluster_id = $34, is_active = $35, last_config_status = $36, log_format = $37, filters = $38, updated_at = CURRENT_TIMESTAMP WHERE id = $39 """, old_values.get('name'), old_values.get('bind_address'), old_values.get('bind_port'), old_values.get('default_backend'), old_values.get('mode'), old_values.get('ssl_enabled'), old_values.get('ssl_certificate_id'), old_values.get('ssl_certificate_ids'), old_values.get('ssl_port'), old_values.get('ssl_cert_path'), old_values.get('ssl_cert'), old_values.get('ssl_verify'), old_values.get('ssl_alpn'), old_values.get('ssl_npn'), old_values.get('ssl_ciphers'), old_values.get('ssl_ciphersuites'), old_values.get('ssl_min_ver'), old_values.get('ssl_max_ver'), old_values.get('ssl_strict_sni'), old_values.get('acl_rules'), old_values.get('redirect_rules'), old_values.get('use_backend_rules'), old_values.get('request_headers'), old_values.get('response_headers'), old_values.get('options'), old_values.get('tcp_request_rules'), old_values.get('timeout_client'), old_values.get('timeout_http_request'), old_values.get('rate_limit'), old_values.get('compression'), old_values.get('log_separate'), old_values.get('monitor_uri'), old_values.get('maxconn'), old_values.get('cluster_id'), old_values.get('is_active'), old_values.get('last_config_status'), old_values.get('log_format'), # Issue #38 old_values.get('filters'), # Issue #38 entity_id ) logger.info(f"ROLLBACK UPDATE: Frontend {entity_id} restored to previous state (UPDATE query result={result})") # Verify rollback verify = await conn.fetchrow("SELECT bind_port, last_config_status FROM frontends WHERE id = $1", entity_id) logger.info(f"ROLLBACK UPDATE VERIFY: Frontend {entity_id} after rollback - bind_port={verify['bind_port']}, status={verify['last_config_status']}") return True elif entity_type == "backend": # Backend'i eski değerlerine geri yükle # SCHEMA: backends (ALL FIELDS from migrations.py line 1943-1962 + additions 1966-2006) await conn.execute(""" UPDATE backends SET name = $1, balance_method = $2, mode = $3, health_check_uri = $4, health_check_interval = $5, health_check_expected_status = $6, fullconn = $7, timeout_connect = $8, timeout_server = $9, timeout_queue = $10, cluster_id = $11, maxconn = $12, cookie_name = $13, cookie_options = $14, default_server_inter = $15, default_server_fall = $16, default_server_rise = $17, request_headers = $18, response_headers = $19, options = $20, is_active = $21, last_config_status = $22, updated_at = CURRENT_TIMESTAMP WHERE id = $23 """, old_values.get('name'), old_values.get('balance_method'), old_values.get('mode'), old_values.get('health_check_uri'), old_values.get('health_check_interval'), old_values.get('health_check_expected_status'), old_values.get('fullconn'), old_values.get('timeout_connect'), old_values.get('timeout_server'), old_values.get('timeout_queue'), old_values.get('cluster_id'), old_values.get('maxconn'), old_values.get('cookie_name'), old_values.get('cookie_options'), old_values.get('default_server_inter'), old_values.get('default_server_fall'), old_values.get('default_server_rise'), old_values.get('request_headers'), old_values.get('response_headers'), old_values.get('options'), old_values.get('is_active'), old_values.get('last_config_status'), entity_id ) logger.info(f"ROLLBACK UPDATE: Backend {entity_id} restored to previous state") return True elif entity_type == "waf_rule": # WAF rule'u eski değerlerine geri yükle # SCHEMA: waf_rules (ALL FIELDS from migrations.py line 2144-2160) await conn.execute(""" UPDATE waf_rules SET name = $1, rule_type = $2, config = $3, action = $4, priority = $5, description = $6, enabled = $7, cluster_id = $8, is_active = $9, last_config_status = $10, updated_at = CURRENT_TIMESTAMP WHERE id = $11 """, old_values.get('name'), old_values.get('rule_type'), old_values.get('config'), old_values.get('action'), old_values.get('priority'), old_values.get('description'), old_values.get('enabled'), old_values.get('cluster_id'), old_values.get('is_active'), old_values.get('last_config_status'), entity_id ) logger.info(f"ROLLBACK UPDATE: WAF rule {entity_id} restored to previous state") return True elif entity_type == "ssl_certificate": # SSL certificate'i eski degerlerine geri yukle # expiry_date snapshot'ta string olarak saklanir, datetime'a cevirilmesi gerekir expiry_date_val = None raw_expiry = old_values.get('expiry_date') if raw_expiry: try: from datetime import datetime as dt_parse if isinstance(raw_expiry, str): # Parse ISO format string back to datetime clean = raw_expiry.replace('Z', '+00:00') expiry_date_val = dt_parse.fromisoformat(clean).replace(tzinfo=None) else: expiry_date_val = raw_expiry except Exception as ed_err: logger.warning(f"ROLLBACK: Could not parse expiry_date '{raw_expiry}': {ed_err}, skipping expiry_date restore") expiry_date_val = None await conn.execute(""" UPDATE ssl_certificates SET name = $1, primary_domain = $2, certificate_content = $3, private_key_content = $4, chain_content = $5, issuer = $6, status = $7, fingerprint = $8, days_until_expiry = $9, all_domains = $10, cluster_id = $11, last_config_status = $12, usage_type = $13, is_active = $14, expiry_date = $15, updated_at = CURRENT_TIMESTAMP WHERE id = $16 """, old_values.get('name'), old_values.get('primary_domain'), old_values.get('certificate_content'), old_values.get('private_key_content'), old_values.get('chain_content'), old_values.get('issuer'), old_values.get('status'), old_values.get('fingerprint'), old_values.get('days_until_expiry'), old_values.get('all_domains'), old_values.get('cluster_id'), old_values.get('last_config_status'), old_values.get('usage_type'), old_values.get('is_active'), expiry_date_val, entity_id ) logger.info(f"ROLLBACK UPDATE: SSL certificate {entity_id} restored to previous state (including content and expiry)") return True elif entity_type == "cluster": await conn.execute(""" UPDATE haproxy_clusters SET acme_enabled = $1, acme_backend_url = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $3 """, old_values.get('acme_enabled'), old_values.get('acme_backend_url'), entity_id ) logger.info(f"ROLLBACK UPDATE: Cluster {entity_id} acme_enabled restored to {old_values.get('acme_enabled')}") return True elif entity_type == "server": # Server'ı eski değerlerine geri yükle # SCHEMA: backend_servers (ALL FIELDS from migrations.py line 2010-2036) await conn.execute(""" UPDATE backend_servers SET backend_id = $1, backend_name = $2, server_name = $3, server_address = $4, server_port = $5, weight = $6, maxconn = $7, check_enabled = $8, check_port = $9, backup_server = $10, ssl_enabled = $11, ssl_verify = $12, ssl_certificate_id = $13, ssl_sni = $14, ssl_min_ver = $15, ssl_max_ver = $16, ssl_ciphers = $17, cookie_value = $18, inter = $19, fall = $20, rise = $21, cluster_id = $22, is_active = $23, last_config_status = $24, haproxy_status = $25, updated_at = CURRENT_TIMESTAMP WHERE id = $26 """, old_values.get('backend_id'), old_values.get('backend_name'), old_values.get('server_name'), old_values.get('server_address'), old_values.get('server_port'), old_values.get('weight'), old_values.get('maxconn'), old_values.get('check_enabled'), old_values.get('check_port'), old_values.get('backup_server'), old_values.get('ssl_enabled'), old_values.get('ssl_verify'), old_values.get('ssl_certificate_id'), old_values.get('ssl_sni'), old_values.get('ssl_min_ver'), old_values.get('ssl_max_ver'), old_values.get('ssl_ciphers'), old_values.get('cookie_value'), old_values.get('inter'), old_values.get('fall'), old_values.get('rise'), old_values.get('cluster_id'), old_values.get('is_active'), old_values.get('last_config_status'), old_values.get('haproxy_status'), entity_id ) logger.info(f"ROLLBACK UPDATE: Server {entity_id} restored to previous state") return True else: logger.warning(f"ROLLBACK UPDATE: Unsupported entity type '{entity_type}'") return False except Exception as e: logger.error(f"ROLLBACK UPDATE ERROR: {entity_type} {entity_id}: {e}", exc_info=True) return False async def _rollback_create( conn, entity_type: str, entity_id: int ) -> bool: """ Yeni oluşturulan entity'yi sil (reject edilen CREATE). ÖNEMLİ: Bu fonksiyon SADECE bulk import ile TAMAMEN YENİ oluşturulan entity'ler için kullanılır. Var olan entity'nin UPDATE'i için KESİNLİKLE kullanılmaz! Kullanım Senaryosu: - Bulk import ile 5 yeni backend oluşturuldu - Kullanıcı reject yaptı - Bu fonksiyon 5 backend'i siler Args: conn: Database connection entity_type: Entity tipi entity_id: Entity ID (silinecek) Returns: True if successful, False otherwise """ try: if entity_type == "frontend": await conn.execute("DELETE FROM frontends WHERE id = $1", entity_id) logger.info(f"ROLLBACK CREATE: Deleted frontend {entity_id}") return True elif entity_type == "backend": # Cascade delete: servers otomatik silinecek (foreign key) await conn.execute("DELETE FROM backends WHERE id = $1", entity_id) logger.info(f"ROLLBACK CREATE: Deleted backend {entity_id} (+ cascade servers)") return True elif entity_type == "waf_rule": await conn.execute("DELETE FROM waf_rules WHERE id = $1", entity_id) logger.info(f"ROLLBACK CREATE: Deleted WAF rule {entity_id}") return True elif entity_type == "ssl_certificate": await conn.execute("DELETE FROM ssl_certificates WHERE id = $1", entity_id) logger.info(f"ROLLBACK CREATE: Deleted SSL certificate {entity_id}") return True elif entity_type == "server": await conn.execute("DELETE FROM backend_servers WHERE id = $1", entity_id) logger.info(f"ROLLBACK CREATE: Deleted server {entity_id}") return True elif entity_type == "letsencrypt_order": # v1.5.0 Feature B: wizard-staged ACME orders attached to a # bulk-site-create-* version (legacy: bulk-proxied-host-create-*). # Cascade also removes acme_challenges (ON DELETE CASCADE). await conn.execute( "DELETE FROM letsencrypt_orders WHERE id = $1", entity_id ) logger.info( f"ROLLBACK CREATE: Deleted letsencrypt_order {entity_id} " "(+ cascade acme_challenges)" ) return True else: logger.warning(f"ROLLBACK CREATE: Unsupported entity type '{entity_type}'") return False except Exception as e: logger.error(f"ROLLBACK CREATE ERROR: {entity_type} {entity_id}: {e}", exc_info=True) return False async def _rollback_delete( conn, entity_type: str, entity_id: int, old_values: Dict[str, Any] ) -> bool: """ Silinen entity'yi geri yükle (reject edilen DELETE). NOT: Şu an projede soft-delete kullanıldığı için bu fonksiyon aktif olarak kullanılmıyor. Gelecekte hard-delete kullanılırsa bu fonksiyon implement edilecek. Future Implementation: - Entity'yi INSERT ile geri yükle - Foreign key'leri geri yükle - Related entity'leri geri yükle Args: conn: Database connection entity_type: Entity tipi entity_id: Entity ID old_values: Eski değerler (snapshot'tan) Returns: True if successful, False otherwise """ logger.warning( f"ROLLBACK DELETE: Not implemented yet for {entity_type} {entity_id}. " "Currently using soft-delete (is_active=false). Hard-delete rollback is a future feature." ) return False