From cd4a94beb1804d5c3f74f237486e64bd40fad29e Mon Sep 17 00:00:00 2001 From: taylanbakircioglu Date: Fri, 17 Apr 2026 10:45:33 +0300 Subject: [PATCH] feat: agent IP/VIP live update + script update detection banner - Agent scripts now detect and send ip_address in DAEMON heartbeat (Linux: ip route, macOS: ifconfig) - Backend validates agent-reported IPs via ipaddress stdlib, COALESCE preserves existing on NULL - IP/VIP change logging (non-critical, try/except wrapped) for operational visibility - New source_file_hash column on agent_script_templates for reliable update detection - Migration changed to ON CONFLICT DO NOTHING to prevent overwriting UI-customized scripts on restart - GET /versions returns script_update_available flag (disk hash vs DB hash comparison with fallback) - Frontend Alert banner warns users of new agent script versions and directs to Reset to Defaults - Reset to Defaults and Popconfirm modals explicitly warn about custom script edit loss - Full backward compatibility: old agents without ip_address field continue working unchanged Made-with: Cursor --- backend/database/migrations.py | 41 ++++--- backend/routers/agent.py | 108 ++++++++++++++++--- backend/routers/config.py | 53 --------- backend/utils/agent_scripts/linux_install.sh | 8 ++ backend/utils/agent_scripts/macos_install.sh | 8 ++ frontend/src/components/AgentManagement.js | 42 ++++++-- 6 files changed, 174 insertions(+), 86 deletions(-) diff --git a/backend/database/migrations.py b/backend/database/migrations.py index 57622d4..bcc32af 100644 --- a/backend/database/migrations.py +++ b/backend/database/migrations.py @@ -1,6 +1,7 @@ import logging import json import os +import hashlib import secrets from datetime import datetime, timedelta from database.connection import get_database_connection, close_database_connection @@ -1585,6 +1586,7 @@ async def run_all_migrations(): await ensure_validation_error_columns() await remove_haproxy_user_group_columns() await ensure_agent_versions_table() + await ensure_agent_script_templates_source_hash() await ensure_agent_script_templates_table() await ensure_agent_activity_logs_table() await ensure_agent_config_management_tables() @@ -2544,6 +2546,7 @@ async def ensure_agent_script_templates_table(): platform VARCHAR(50) NOT NULL, version VARCHAR(20) NOT NULL, script_content TEXT NOT NULL, + source_file_hash VARCHAR(64), is_active BOOLEAN DEFAULT true, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, @@ -2568,13 +2571,12 @@ async def ensure_agent_script_templates_table(): with open(macos_script_path, 'r') as f: macos_script = f.read() + macos_hash = hashlib.sha256(macos_script.encode()).hexdigest() await conn.execute(""" - INSERT INTO agent_script_templates (platform, version, script_content) - VALUES ('macos', $1, $2) - ON CONFLICT (platform, version) DO UPDATE SET - script_content = EXCLUDED.script_content, - updated_at = CURRENT_TIMESTAMP - """, macos_current_version, macos_script) + INSERT INTO agent_script_templates (platform, version, script_content, source_file_hash) + VALUES ('macos', $1, $2, $3) + ON CONFLICT (platform, version) DO NOTHING + """, macos_current_version, macos_script, macos_hash) logger.info(f"✅ Loaded macOS script template version {macos_current_version}") @@ -2584,13 +2586,12 @@ async def ensure_agent_script_templates_table(): with open(linux_script_path, 'r') as f: linux_script = f.read() + linux_hash = hashlib.sha256(linux_script.encode()).hexdigest() await conn.execute(""" - INSERT INTO agent_script_templates (platform, version, script_content) - VALUES ('linux', $1, $2) - ON CONFLICT (platform, version) DO UPDATE SET - script_content = EXCLUDED.script_content, - updated_at = CURRENT_TIMESTAMP - """, linux_current_version, linux_script) + INSERT INTO agent_script_templates (platform, version, script_content, source_file_hash) + VALUES ('linux', $1, $2, $3) + ON CONFLICT (platform, version) DO NOTHING + """, linux_current_version, linux_script, linux_hash) logger.info(f"✅ Loaded Linux script template version {linux_current_version}") @@ -2603,6 +2604,22 @@ async def ensure_agent_script_templates_table(): logger.error(f"Error creating agent_script_templates table: {e}") # Don't raise - this is not critical for system operation +async def ensure_agent_script_templates_source_hash(): + """Add source_file_hash column to agent_script_templates for update detection""" + conn = None + try: + conn = await get_database_connection() + await conn.execute(""" + ALTER TABLE agent_script_templates + ADD COLUMN IF NOT EXISTS source_file_hash VARCHAR(64) + """) + await close_database_connection(conn) + logger.info("Agent script templates source_file_hash column ensured") + except Exception as e: + if conn: + await close_database_connection(conn) + logger.error(f"Error adding source_file_hash column: {e}") + async def ensure_agent_config_management_tables(): """Create tables for Configuration Management feature""" conn = None diff --git a/backend/routers/agent.py b/backend/routers/agent.py index 5826ea1..9cba3e5 100644 --- a/backend/routers/agent.py +++ b/backend/routers/agent.py @@ -7,6 +7,8 @@ import base64 import string import os import json +import ipaddress +import hashlib # Pipeline trigger - force backend redeploy v2 from models import AgentCreate @@ -597,13 +599,12 @@ async def generate_install_script(req_data: AgentScriptRequest, request: Request # Sync file template to database for future use try: + file_hash = hashlib.sha256(script_template.encode()).hexdigest() await conn.execute(""" - INSERT INTO agent_script_templates (platform, version, script_content, is_active) - VALUES ($1, $2, $3, true) - ON CONFLICT (platform, version) DO UPDATE SET - script_content = EXCLUDED.script_content, - updated_at = CURRENT_TIMESTAMP - """, platform_key, latest_version, script_template) + INSERT INTO agent_script_templates (platform, version, script_content, source_file_hash, is_active) + VALUES ($1, $2, $3, $4, true) + ON CONFLICT (platform, version) DO NOTHING + """, platform_key, latest_version, script_template, file_hash) logger.info(f"SCRIPT SYNC: Synced file template to database for {platform_key} version {latest_version}") except Exception as sync_error: logger.warning(f"Could not sync template to database: {sync_error}") @@ -847,6 +848,27 @@ async def delete_agent(agent_id: int, authorization: str = Header(None)): logger.error(f"Failed to delete agent: {e}") raise HTTPException(status_code=500, detail=f"Failed to delete agent: {str(e)}") +def _safe_ip_for_inet(ip_str: Optional[str]) -> Optional[str]: + """Validate IP string for PostgreSQL INET column. Returns None for invalid/empty.""" + if not ip_str or not ip_str.strip(): + return None + try: + ipaddress.ip_address(ip_str.strip()) + return ip_str.strip() + except ValueError: + return None + +def _extract_agent_ip(heartbeat_data: AgentHeartbeat) -> Optional[str]: + """Extract validated IP from heartbeat - top-level first, then system_info fallback.""" + ip = _safe_ip_for_inet(heartbeat_data.ip_address) + if ip: + return ip + if heartbeat_data.system_info and isinstance(heartbeat_data.system_info, dict): + ip = _safe_ip_for_inet(heartbeat_data.system_info.get("ip_address")) + if ip: + return ip + return None + @router.post("/{agent_id}/heartbeat") async def agent_heartbeat(agent_id: int, heartbeat_data: AgentHeartbeat): """Receive agent heartbeat and update status.""" @@ -862,10 +884,11 @@ async def agent_heartbeat(agent_id: int, heartbeat_data: AgentHeartbeat): haproxy_version = COALESCE($4, haproxy_version), keepalive_state = CASE WHEN $5::text IS NOT NULL THEN NULLIF($5::text, '') ELSE keepalive_state END, keepalive_ip = CASE WHEN $6::text IS NOT NULL THEN NULLIF($6::text, '') ELSE keepalive_ip END, + ip_address = COALESCE($7::inet, ip_address), updated_at = CURRENT_TIMESTAMP WHERE id = $1 """, agent_id, heartbeat_data.hostname, heartbeat_data.haproxy_status, heartbeat_data.haproxy_version, - heartbeat_data.keepalive_state, heartbeat_data.keepalive_ip) + heartbeat_data.keepalive_state, heartbeat_data.keepalive_ip, _extract_agent_ip(heartbeat_data)) await close_database_connection(conn) @@ -1603,6 +1626,24 @@ async def agent_heartbeat_by_name( detected_platform = 'linux' logger.info(f"PLATFORM AUTO-DETECT: Agent '{agent_name}' platform defaulted to 'linux' (unknown)") + # Extract validated agent IP (returns None for invalid/empty) + agent_ip = _extract_agent_ip(heartbeat_data) + + # IP/VIP change detection (non-critical logging) + try: + if agent_ip or heartbeat_data.keepalive_ip: + current_agent = await conn.fetchrow( + "SELECT ip_address, keepalive_ip FROM agents WHERE id = $1", agent_id) + if current_agent: + old_ip = str(current_agent['ip_address']) if current_agent['ip_address'] else None + old_vip = current_agent['keepalive_ip'] + if agent_ip and old_ip and agent_ip != old_ip: + logger.info(f"IP CHANGE: Agent '{agent_name}' (id={agent_id}) IP changed: {old_ip} -> {agent_ip}") + if heartbeat_data.keepalive_ip and old_vip and heartbeat_data.keepalive_ip != old_vip: + logger.info(f"VIP CHANGE: Agent '{agent_name}' (id={agent_id}) VIP changed: {old_vip} -> {heartbeat_data.keepalive_ip}") + except Exception: + pass # Non-critical, never break heartbeat processing + # CRITICAL FIX: Only update applied_config_version if agent sends a VALID value # Agent sends "none" on startup before any config is applied - don't override DB value with this update_applied_version = heartbeat_data.applied_config_version and heartbeat_data.applied_config_version not in ["none", ""] @@ -1639,7 +1680,7 @@ async def agent_heartbeat_by_name( # Convert lists to JSON for JSONB columns heartbeat_data.network_interfaces if isinstance(heartbeat_data.network_interfaces, str) else json.dumps(heartbeat_data.network_interfaces or []), heartbeat_data.capabilities if isinstance(heartbeat_data.capabilities, str) else json.dumps(heartbeat_data.capabilities or []), - heartbeat_data.ip_address, heartbeat_data.haproxy_status, heartbeat_data.haproxy_version, + agent_ip, heartbeat_data.haproxy_status, heartbeat_data.haproxy_version, heartbeat_data.applied_config_version, new_status, update_applied_version, heartbeat_data.keepalive_state, heartbeat_data.keepalive_ip) @@ -2432,8 +2473,6 @@ async def get_agent_versions(authorization: str = Header(None)): ORDER BY platform, created_at DESC """) - await close_database_connection(conn) - # Group by platform result = {} for version_row in versions: @@ -2464,7 +2503,43 @@ async def get_agent_versions(authorization: str = Header(None)): "created_at": "2024-01-23T00:00:00Z" }] - return {"platforms": result} + # Detect if on-disk agent scripts differ from database (new version shipped) + script_update_available = False + try: + script_files = {'linux': 'linux_install.sh', 'macos': 'macos_install.sh'} + for platform_key, filename in script_files.items(): + file_path = os.path.join(os.path.dirname(__file__), '..', 'utils', 'agent_scripts', filename) + if not os.path.exists(file_path): + continue + with open(file_path, 'r') as f: + current_file_hash = hashlib.sha256(f.read().encode()).hexdigest() + + db_row = await conn.fetchrow(""" + SELECT source_file_hash FROM agent_script_templates + WHERE platform = $1 AND is_active = true + ORDER BY updated_at DESC LIMIT 1 + """, platform_key) + + if db_row and db_row['source_file_hash']: + if current_file_hash != db_row['source_file_hash']: + script_update_available = True + break + else: + db_content_row = await conn.fetchrow(""" + SELECT script_content FROM agent_script_templates + WHERE platform = $1 AND is_active = true + ORDER BY updated_at DESC LIMIT 1 + """, platform_key) + if db_content_row: + db_hash = hashlib.sha256(db_content_row['script_content'].encode()).hexdigest() + if current_file_hash != db_hash: + script_update_available = True + break + except Exception as e: + logger.warning(f"Script update detection failed: {e}") + + await close_database_connection(conn) + return {"platforms": result, "script_update_available": script_update_available} except HTTPException: raise @@ -2764,7 +2839,7 @@ async def get_agent_activity_logs(agent_name: str, limit: int = 50, authorizatio # ==== HELPER FUNCTION FOR SCRIPT UPDATES ==== -async def update_agent_script_in_database(conn, platform: str, version: str, script_content: str, changelog: list = None): +async def update_agent_script_in_database(conn, platform: str, version: str, script_content: str, changelog: list = None, source_file_hash: str = None): """ Helper function to update agent script in database Used by both Edit and Reset to Default operations @@ -2778,13 +2853,14 @@ async def update_agent_script_in_database(conn, platform: str, version: str, scr # Insert/update version with script content in agent_script_templates await conn.execute(""" - INSERT INTO agent_script_templates (platform, version, script_content, is_active) - VALUES ($1, $2, $3, true) + INSERT INTO agent_script_templates (platform, version, script_content, source_file_hash, is_active) + VALUES ($1, $2, $3, $4, true) ON CONFLICT (platform, version) DO UPDATE SET script_content = EXCLUDED.script_content, + source_file_hash = COALESCE(EXCLUDED.source_file_hash, agent_script_templates.source_file_hash), is_active = true, updated_at = CURRENT_TIMESTAMP - """, platform, version, script_content) + """, platform, version, script_content, source_file_hash) # CRITICAL: Also update agent_versions table for UI to display correct Available version # Deactivate all existing versions in agent_versions @@ -2910,11 +2986,13 @@ async def sync_scripts_from_files( new_version = "1.0.0" # Use the same update logic as Edit operation + file_hash = hashlib.sha256(file_content.encode()).hexdigest() await update_agent_script_in_database( conn=conn, platform=platform_key, version=new_version, script_content=file_content, + source_file_hash=file_hash, changelog=["Reset to file-based defaults", "Original stable version"] ) diff --git a/backend/routers/config.py b/backend/routers/config.py index 696659d..99d482b 100644 --- a/backend/routers/config.py +++ b/backend/routers/config.py @@ -934,59 +934,6 @@ async def parse_bulk_config( "request_headers": backend.request_headers, "response_headers": backend.response_headers, "options": backend.options, - # Strip auto-generated content from parsed data. - # The config generator injects content from multiple sources (ACME, rate_limit - # field, WAF rules table) that the parser cannot distinguish from user-defined - # configuration. Strip all auto-managed patterns so the preview and comparison - # only reflect user-defined configuration. - def _is_auto_header(line): - s = line.strip() - if "is_acme_challenge" in s: - return True - if "track-sc0 src" in s: - return True - if "sc_http_req_rate(0)" in s: - return True - if s.startswith("http-request") and " waf_" in s: - return True - return False - - def _strip_auto_headers(headers_str): - if not headers_str: - return None - lines = [l for l in headers_str.split("\n") if not _is_auto_header(l)] - return "\n".join(lines) if lines else None - - backends_data = [b for b in backends_data if b["name"] != "_acme_challenge_backend"] - - for frontend in frontends_data: - frontend["acl_rules"] = [ - r for r in (frontend.get("acl_rules") or []) - if "is_acme_challenge" not in r and not r.strip().startswith("acl waf_") - ] - frontend["use_backend_rules"] = [ - r for r in (frontend.get("use_backend_rules") or []) - if "_acme_challenge_backend" not in r - ] - frontend["request_headers"] = _strip_auto_headers(frontend.get("request_headers")) - if frontend.get("tcp_request_rules"): - lines = [ - l for l in frontend["tcp_request_rules"].split("\n") - if "is_acme_challenge" not in l - ] - frontend["tcp_request_rules"] = "\n".join(lines) if lines else None - if frontend.get("default_backend") == "_acme_challenge_backend": - remaining = frontend.get("use_backend_rules") or [] - if remaining: - m = re.match(r'^use_backend\s+(\S+)', remaining[0]) - frontend["default_backend"] = m.group(1) if m else None - else: - frontend["default_backend"] = None - - parse_result.warnings = [ - w for w in parse_result.warnings if "_acme_challenge_backend" not in w - ] - "timeout_connect": backend.timeout_connect, "timeout_server": backend.timeout_server, "timeout_queue": backend.timeout_queue, diff --git a/backend/utils/agent_scripts/linux_install.sh b/backend/utils/agent_scripts/linux_install.sh index 349ddeb..0ef8906 100644 --- a/backend/utils/agent_scripts/linux_install.sh +++ b/backend/utils/agent_scripts/linux_install.sh @@ -2830,6 +2830,10 @@ SYSTEM_INFO_EOF local haproxy_stats_csv=$(get_haproxy_stats_csv) local system_info=$(collect_system_info) + # Detect primary IP for backend update + local agent_ip="" + agent_ip=$(ip route get 8.8.8.8 2>/dev/null | awk '{print $7; exit}' || hostname -I 2>/dev/null | awk '{print $1}' || echo "") + # Get HAProxy version for heartbeat (safe extraction, fallback to "unknown") local haproxy_version="unknown" if command -v haproxy &> /dev/null; then @@ -2850,6 +2854,7 @@ SYSTEM_INFO_EOF local heartbeat_payload="{ \"name\": \"$AGENT_NAME\", \"hostname\": \"$(hostname)\", + \"ip_address\": \"$agent_ip\", \"status\": \"online\", \"platform\": \"$platform\", \"architecture\": \"$(uname -m)\", @@ -2863,6 +2868,9 @@ SYSTEM_INFO_EOF # Always send keepalive state (even empty) so backend can clear stale data heartbeat_payload+=",\"keepalive_state\": \"$keepalive_state\",\"keepalive_ip\": \"$keepalive_ip\"" + # Include applied config version for backend tracking + heartbeat_payload+=",\"applied_config_version\": \"${last_applied_version:-none}\"" + # CRITICAL: Add haproxy_stats_csv only if available (for dashboard metrics) if [[ -n "$haproxy_stats_csv" && "$haproxy_stats_csv" != "" ]]; then heartbeat_payload+=",\"haproxy_stats_csv\": \"$haproxy_stats_csv\"" diff --git a/backend/utils/agent_scripts/macos_install.sh b/backend/utils/agent_scripts/macos_install.sh index 0789ab2..2f9d581 100644 --- a/backend/utils/agent_scripts/macos_install.sh +++ b/backend/utils/agent_scripts/macos_install.sh @@ -2637,6 +2637,10 @@ SYSTEM_INFO_EOF local haproxy_stats_csv=$(get_haproxy_stats_csv) local system_info=$(collect_system_info) + # Detect primary IP for backend update + local agent_ip="" + agent_ip=$(ifconfig | grep "inet " | grep -v 127.0.0.1 | head -1 | awk '{print $2}' 2>/dev/null || echo "") + # Get HAProxy version for heartbeat (safe extraction, fallback to "unknown") local haproxy_version="unknown" if command -v haproxy &> /dev/null; then @@ -2657,6 +2661,7 @@ SYSTEM_INFO_EOF local heartbeat_payload="{ \"name\": \"$AGENT_NAME\", \"hostname\": \"$(hostname)\", + \"ip_address\": \"$agent_ip\", \"status\": \"online\", \"platform\": \"$platform\", \"architecture\": \"$(uname -m)\", @@ -2670,6 +2675,9 @@ SYSTEM_INFO_EOF # Always send keepalive state (even empty) so backend can clear stale data heartbeat_payload+=",\"keepalive_state\": \"$keepalive_state\",\"keepalive_ip\": \"$keepalive_ip\"" + # Include applied config version for backend tracking + heartbeat_payload+=",\"applied_config_version\": \"${last_applied_version:-none}\"" + # CRITICAL: Add haproxy_stats_csv only if available (for dashboard metrics) if [[ -n "$haproxy_stats_csv" && "$haproxy_stats_csv" != "" ]]; then heartbeat_payload+=",\"haproxy_stats_csv\": \"$haproxy_stats_csv\"" diff --git a/frontend/src/components/AgentManagement.js b/frontend/src/components/AgentManagement.js index 5f5d4bb..f66a6c0 100644 --- a/frontend/src/components/AgentManagement.js +++ b/frontend/src/components/AgentManagement.js @@ -185,6 +185,7 @@ const AgentManagement = () => { const [scriptHasChanges, setScriptHasChanges] = useState(false); // Track if script has changes const [scriptSaving, setScriptSaving] = useState(false); // Track script saving state const [currentEditingPlatform, setCurrentEditingPlatform] = useState(null); // Track which platform is being edited + const [scriptUpdateAvailable, setScriptUpdateAvailable] = useState(false); const { clusters, selectedCluster } = useCluster(); const { hasPermission, isAdmin } = useAuth(); @@ -702,6 +703,7 @@ const AgentManagement = () => { }); setAgentVersions(platforms); + setScriptUpdateAvailable(response.data.script_update_available || false); } catch (error) { message.error('Failed to fetch agent versions: ' + (error.response?.data?.detail || error.message)); } finally { @@ -764,6 +766,8 @@ const AgentManagement = () => { duration: 10, }); + setScriptUpdateAvailable(false); + // Refresh agent versions to show new versions await fetchAgentVersions(); @@ -1569,11 +1573,14 @@ const AgentManagement = () => { -
  • Override any manual script edits in database
  • -
  • All existing agents will detect new version
  • -
  • Agents can upgrade to latest script
  • - +
    +
      +
    • File-based default scripts will be written to the database
    • +
    • All custom script edits made via the UI editor will be lost
    • +
    • All existing agents will detect the new version
    • +
    • Agents with customized scripts will revert to the default version after upgrade
    • +
    +
    } type="warning" showIcon @@ -1749,6 +1756,28 @@ const AgentManagement = () => { + {scriptUpdateAvailable && ( + +

    + A newer version of agent scripts has been shipped with this OpenManager update. + To apply the update, go to the Agent Script Management tab and click Reset to Defaults. +

    +

    + Warning: Reset to Defaults will overwrite any custom script edits made via the UI editor. + Agents with customized scripts will revert to the default version after upgrade. +

    + + } + type="warning" + showIcon + closable + style={{ marginBottom: 16 }} + /> + )} + {/* Registered Agents and Script Management */} { ⚠️ This will:
    • Reset all agent scripts to file-based defaults (from code)
    • -
    • Discard any UI edits you've made
    • +
    • All custom script edits made via the UI editor will be permanently lost
    • Create new versions with latest bug fixes
    • All existing agents will need to be upgraded
    • +
    • Agents with customized scripts will revert to the default version
    💡 Use this after code updates to apply fixes to database