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
This commit is contained in:
taylanbakircioglu
2026-04-17 10:45:33 +03:00
parent 61024e24cb
commit cd4a94beb1
6 changed files with 174 additions and 86 deletions
+29 -12
View File
@@ -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
+93 -15
View File
@@ -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"]
)
-53
View File
@@ -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,
@@ -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\""
@@ -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\""
+36 -6
View File
@@ -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 = () => {
<Alert
message="This will sync agent scripts from files to database"
description={
<ul style={{ marginTop: '8px', paddingLeft: '20px', marginBottom: 0 }}>
<li>Override any manual script edits in database</li>
<li>All existing agents will detect new version</li>
<li>Agents can upgrade to latest script</li>
</ul>
<div>
<ul style={{ marginTop: '8px', paddingLeft: '20px', marginBottom: 8 }}>
<li>File-based default scripts will be written to the database</li>
<li><strong>All custom script edits made via the UI editor will be lost</strong></li>
<li>All existing agents will detect the new version</li>
<li>Agents with customized scripts will revert to the default version after upgrade</li>
</ul>
</div>
}
type="warning"
showIcon
@@ -1749,6 +1756,28 @@ const AgentManagement = () => {
<Divider />
{scriptUpdateAvailable && (
<Alert
message="New Agent Script Version Available"
description={
<div>
<p style={{ marginBottom: 8 }}>
A newer version of agent scripts has been shipped with this OpenManager update.
To apply the update, go to the <strong>Agent Script Management</strong> tab and click <strong>Reset to Defaults</strong>.
</p>
<p style={{ marginBottom: 0, color: '#faad14' }}>
<strong>Warning:</strong> 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.
</p>
</div>
}
type="warning"
showIcon
closable
style={{ marginBottom: 16 }}
/>
)}
{/* Registered Agents and Script Management */}
<Tabs defaultActiveKey="agents">
<TabPane
@@ -1902,9 +1931,10 @@ const AgentManagement = () => {
<Text strong> This will:</Text>
<ul style={{ marginTop: '8px', marginBottom: '8px', paddingLeft: '20px' }}>
<li>Reset all agent scripts to file-based defaults (from code)</li>
<li>Discard any UI edits you've made</li>
<li><strong>All custom script edits made via the UI editor will be permanently lost</strong></li>
<li>Create new versions with latest bug fixes</li>
<li>All existing agents will need to be upgraded</li>
<li>Agents with customized scripts will revert to the default version</li>
</ul>
<Text type="warning">
💡 Use this after code updates to apply fixes to database