Spaces:
Sleeping
Sleeping
Graham Paasch
commited on
Commit
·
86dc617
1
Parent(s):
0f56f53
Add brownfield import mode and sponsor LLM support
Browse files- agent/llm_client.py +51 -1
- agent/netbox_client.py +77 -0
- agent/pipeline_engine.py +263 -3
- agent/topology_diagram.py +41 -12
- app.py +89 -8
- debug_network.py +23 -0
agent/llm_client.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
"""
|
| 2 |
LLM Client for Overgrowth Pipeline
|
| 3 |
-
Supports multiple providers: OpenAI, Anthropic, Blaxel, SambaNova, Nebius, Hugging Face
|
| 4 |
"""
|
| 5 |
|
| 6 |
import os
|
|
@@ -46,6 +46,9 @@ class LLMClient:
|
|
| 46 |
self.sambanova_key = _get_env(["SAMBA_NOVA_MCP_1ST_BDAY"])
|
| 47 |
self.nebius_key = _get_env(["NEBIUS_MCP_1ST_BDAY"])
|
| 48 |
self.huggingface_key = _get_env(["HUGGING_FACE_MCP_1ST_BDAY"])
|
|
|
|
|
|
|
|
|
|
| 49 |
|
| 50 |
# Determine which provider to use
|
| 51 |
self.provider = self._detect_provider()
|
|
@@ -75,6 +78,7 @@ class LLMClient:
|
|
| 75 |
"sambanova_key": "present" if _present(["SAMBA_NOVA_MCP_1ST_BDAY"]) else "missing",
|
| 76 |
"nebius_key": "present" if _present(["NEBIUS_MCP_1ST_BDAY"]) else "missing",
|
| 77 |
"huggingface_key": "present" if _present(["HUGGING_FACE_MCP_1ST_BDAY"]) else "missing",
|
|
|
|
| 78 |
"provider": "unknown",
|
| 79 |
}
|
| 80 |
if status["anthropic_key"] == "present":
|
|
@@ -87,6 +91,8 @@ class LLMClient:
|
|
| 87 |
status["provider"] = "sambanova"
|
| 88 |
elif status["nebius_key"] == "present":
|
| 89 |
status["provider"] = "nebius"
|
|
|
|
|
|
|
| 90 |
elif status["huggingface_key"] == "present":
|
| 91 |
status["provider"] = "huggingface"
|
| 92 |
return status
|
|
@@ -103,6 +109,8 @@ class LLMClient:
|
|
| 103 |
return "sambanova"
|
| 104 |
elif self.nebius_key:
|
| 105 |
return "nebius"
|
|
|
|
|
|
|
| 106 |
elif self.huggingface_key:
|
| 107 |
return "huggingface"
|
| 108 |
return None
|
|
@@ -131,6 +139,8 @@ class LLMClient:
|
|
| 131 |
return self._call_sambanova(messages, temperature, max_tokens)
|
| 132 |
elif self.provider == "nebius":
|
| 133 |
return self._call_nebius(messages, temperature, max_tokens)
|
|
|
|
|
|
|
| 134 |
elif self.provider == "huggingface":
|
| 135 |
return self._call_huggingface(messages, temperature, max_tokens)
|
| 136 |
|
|
@@ -448,6 +458,46 @@ class LLMClient:
|
|
| 448 |
monitor.complete_call(call_id, success=False, error_message=str(e))
|
| 449 |
raise
|
| 450 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 451 |
def _call_huggingface(self, messages, temperature, max_tokens):
|
| 452 |
"""Call Hugging Face Inference API (text generation)."""
|
| 453 |
import requests
|
|
|
|
| 1 |
"""
|
| 2 |
LLM Client for Overgrowth Pipeline
|
| 3 |
+
Supports multiple providers: OpenAI, Anthropic, Blaxel, SambaNova, Nebius, Hugging Face, Modal
|
| 4 |
"""
|
| 5 |
|
| 6 |
import os
|
|
|
|
| 46 |
self.sambanova_key = _get_env(["SAMBA_NOVA_MCP_1ST_BDAY"])
|
| 47 |
self.nebius_key = _get_env(["NEBIUS_MCP_1ST_BDAY"])
|
| 48 |
self.huggingface_key = _get_env(["HUGGING_FACE_MCP_1ST_BDAY"])
|
| 49 |
+
self.modal_key = _get_env(["MODAL_API_KEY", "MODAL_TOKEN"])
|
| 50 |
+
self.modal_base_url = os.getenv("MODAL_BASE_URL")
|
| 51 |
+
self.modal_model = os.getenv("MODAL_MODEL", "gpt-4o-mini")
|
| 52 |
|
| 53 |
# Determine which provider to use
|
| 54 |
self.provider = self._detect_provider()
|
|
|
|
| 78 |
"sambanova_key": "present" if _present(["SAMBA_NOVA_MCP_1ST_BDAY"]) else "missing",
|
| 79 |
"nebius_key": "present" if _present(["NEBIUS_MCP_1ST_BDAY"]) else "missing",
|
| 80 |
"huggingface_key": "present" if _present(["HUGGING_FACE_MCP_1ST_BDAY"]) else "missing",
|
| 81 |
+
"modal_key": "present" if _present(["MODAL_API_KEY", "MODAL_TOKEN"]) else "missing",
|
| 82 |
"provider": "unknown",
|
| 83 |
}
|
| 84 |
if status["anthropic_key"] == "present":
|
|
|
|
| 91 |
status["provider"] = "sambanova"
|
| 92 |
elif status["nebius_key"] == "present":
|
| 93 |
status["provider"] = "nebius"
|
| 94 |
+
elif status["modal_key"] == "present":
|
| 95 |
+
status["provider"] = "modal"
|
| 96 |
elif status["huggingface_key"] == "present":
|
| 97 |
status["provider"] = "huggingface"
|
| 98 |
return status
|
|
|
|
| 109 |
return "sambanova"
|
| 110 |
elif self.nebius_key:
|
| 111 |
return "nebius"
|
| 112 |
+
elif self.modal_key:
|
| 113 |
+
return "modal"
|
| 114 |
elif self.huggingface_key:
|
| 115 |
return "huggingface"
|
| 116 |
return None
|
|
|
|
| 139 |
return self._call_sambanova(messages, temperature, max_tokens)
|
| 140 |
elif self.provider == "nebius":
|
| 141 |
return self._call_nebius(messages, temperature, max_tokens)
|
| 142 |
+
elif self.provider == "modal":
|
| 143 |
+
return self._call_modal(messages, temperature, max_tokens)
|
| 144 |
elif self.provider == "huggingface":
|
| 145 |
return self._call_huggingface(messages, temperature, max_tokens)
|
| 146 |
|
|
|
|
| 458 |
monitor.complete_call(call_id, success=False, error_message=str(e))
|
| 459 |
raise
|
| 460 |
|
| 461 |
+
def _call_modal(self, messages, temperature, max_tokens):
|
| 462 |
+
"""Call Modal's OpenAI-compatible endpoint (optional sponsor integration)."""
|
| 463 |
+
import requests
|
| 464 |
+
call_id = str(uuid.uuid4())
|
| 465 |
+
model = self.modal_model or "gpt-4o-mini"
|
| 466 |
+
base_url = (self.modal_base_url or "https://api.modal.com/v1").rstrip("/")
|
| 467 |
+
endpoint = f"{base_url}/chat/completions"
|
| 468 |
+
if monitor:
|
| 469 |
+
monitor.start_call(call_id, "llm", "modal", model, temperature=temperature)
|
| 470 |
+
if not self.modal_key:
|
| 471 |
+
raise RuntimeError("Modal provider selected but MODAL_API_KEY/MODAL_TOKEN is missing")
|
| 472 |
+
payload = {
|
| 473 |
+
"model": model,
|
| 474 |
+
"messages": [{"role": m.role, "content": m.content} for m in messages],
|
| 475 |
+
"temperature": temperature,
|
| 476 |
+
"max_tokens": max_tokens,
|
| 477 |
+
}
|
| 478 |
+
headers = {
|
| 479 |
+
"Authorization": f"Bearer {self.modal_key}",
|
| 480 |
+
"Content-Type": "application/json",
|
| 481 |
+
}
|
| 482 |
+
try:
|
| 483 |
+
resp = requests.post(endpoint, json=payload, headers=headers, timeout=45)
|
| 484 |
+
resp.raise_for_status()
|
| 485 |
+
data = resp.json()
|
| 486 |
+
content = data.get("choices", [{}])[0].get("message", {}).get("content", "")
|
| 487 |
+
usage = data.get("usage", {})
|
| 488 |
+
if monitor:
|
| 489 |
+
monitor.complete_call(
|
| 490 |
+
call_id,
|
| 491 |
+
success=True,
|
| 492 |
+
input_tokens=usage.get("prompt_tokens"),
|
| 493 |
+
output_tokens=usage.get("completion_tokens"),
|
| 494 |
+
)
|
| 495 |
+
return content
|
| 496 |
+
except Exception as e:
|
| 497 |
+
if monitor:
|
| 498 |
+
monitor.complete_call(call_id, success=False, error_message=str(e))
|
| 499 |
+
raise
|
| 500 |
+
|
| 501 |
def _call_huggingface(self, messages, temperature, max_tokens):
|
| 502 |
"""Call Hugging Face Inference API (text generation)."""
|
| 503 |
import requests
|
agent/netbox_client.py
CHANGED
|
@@ -7,6 +7,7 @@ import os
|
|
| 7 |
import logging
|
| 8 |
from typing import Dict, List, Optional, Any
|
| 9 |
from dataclasses import dataclass
|
|
|
|
| 10 |
|
| 11 |
logger = logging.getLogger(__name__)
|
| 12 |
|
|
@@ -245,6 +246,82 @@ class NetBoxClient:
|
|
| 245 |
except Exception as e:
|
| 246 |
logger.error(f"Failed to get prefixes: {e}")
|
| 247 |
return []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 248 |
|
| 249 |
# ==================== Helper Methods ====================
|
| 250 |
|
|
|
|
| 7 |
import logging
|
| 8 |
from typing import Dict, List, Optional, Any
|
| 9 |
from dataclasses import dataclass
|
| 10 |
+
from ipaddress import ip_network
|
| 11 |
|
| 12 |
logger = logging.getLogger(__name__)
|
| 13 |
|
|
|
|
| 246 |
except Exception as e:
|
| 247 |
logger.error(f"Failed to get prefixes: {e}")
|
| 248 |
return []
|
| 249 |
+
|
| 250 |
+
# ==================== Brownfield Export ====================
|
| 251 |
+
|
| 252 |
+
def export_network_model(self, site: Optional[str] = None) -> Dict[str, Any]:
|
| 253 |
+
"""
|
| 254 |
+
Export devices, VLANs, and prefixes from NetBox into a simplified model
|
| 255 |
+
that can be fed into the Overgrowth pipeline for brownfield validation.
|
| 256 |
+
"""
|
| 257 |
+
if self.mock_mode:
|
| 258 |
+
logger.warning("NetBox client in mock mode - returning empty export")
|
| 259 |
+
return {"devices": [], "vlans": [], "subnets": [], "routing": {"protocol": "static"}, "services": ["DHCP", "DNS", "NTP"]}
|
| 260 |
+
|
| 261 |
+
filters = {"site": site} if site else {}
|
| 262 |
+
|
| 263 |
+
devices_raw = self.get_devices(**filters)
|
| 264 |
+
vlans_raw = self.get_vlans(**filters)
|
| 265 |
+
prefixes_raw = self.get_prefixes(**filters)
|
| 266 |
+
|
| 267 |
+
devices = []
|
| 268 |
+
for dev in devices_raw:
|
| 269 |
+
dev_type = dev.get("device_type") or {}
|
| 270 |
+
manufacturer = dev_type.get("manufacturer") or {}
|
| 271 |
+
mgmt = dev.get("primary_ip4") or dev.get("primary_ip") or {}
|
| 272 |
+
mgmt_ip = mgmt.get("address", "0.0.0.0/32").split("/")[0]
|
| 273 |
+
devices.append(
|
| 274 |
+
{
|
| 275 |
+
"name": dev.get("name", "device"),
|
| 276 |
+
"role": (dev.get("device_role") or {}).get("name", "access").lower(),
|
| 277 |
+
"model": dev_type.get("model", "unknown"),
|
| 278 |
+
"vendor": manufacturer.get("slug", "other"),
|
| 279 |
+
"mgmt_ip": mgmt_ip,
|
| 280 |
+
"location": (dev.get("site") or {}).get("name", "unspecified"),
|
| 281 |
+
"interfaces": [],
|
| 282 |
+
}
|
| 283 |
+
)
|
| 284 |
+
|
| 285 |
+
vlans = []
|
| 286 |
+
for vlan in vlans_raw:
|
| 287 |
+
vlans.append(
|
| 288 |
+
{
|
| 289 |
+
"id": vlan.get("vid"),
|
| 290 |
+
"name": vlan.get("name") or f"VLAN{vlan.get('vid')}",
|
| 291 |
+
"purpose": vlan.get("description") or "Imported from NetBox",
|
| 292 |
+
"subnet": None,
|
| 293 |
+
}
|
| 294 |
+
)
|
| 295 |
+
|
| 296 |
+
subnets = []
|
| 297 |
+
for prefix in prefixes_raw:
|
| 298 |
+
cidr = prefix.get("prefix")
|
| 299 |
+
gateway = None
|
| 300 |
+
try:
|
| 301 |
+
net = ip_network(cidr, strict=False)
|
| 302 |
+
hosts = list(net.hosts())
|
| 303 |
+
if hosts:
|
| 304 |
+
gateway = str(hosts[0])
|
| 305 |
+
except Exception:
|
| 306 |
+
gateway = None
|
| 307 |
+
subnets.append(
|
| 308 |
+
{
|
| 309 |
+
"network": cidr,
|
| 310 |
+
"gateway": gateway or (cidr.split("/")[0] if cidr else "0.0.0.0"),
|
| 311 |
+
"vlan": (prefix.get("vlan") or {}).get("vid"),
|
| 312 |
+
"purpose": prefix.get("description") or "Imported prefix",
|
| 313 |
+
}
|
| 314 |
+
)
|
| 315 |
+
|
| 316 |
+
routing = {"protocol": "static", "networks": [s.get("network") for s in subnets if s.get("network")]}
|
| 317 |
+
|
| 318 |
+
return {
|
| 319 |
+
"devices": devices,
|
| 320 |
+
"vlans": vlans,
|
| 321 |
+
"subnets": subnets,
|
| 322 |
+
"routing": routing,
|
| 323 |
+
"services": ["DHCP", "DNS", "NTP"],
|
| 324 |
+
}
|
| 325 |
|
| 326 |
# ==================== Helper Methods ====================
|
| 327 |
|
agent/pipeline_engine.py
CHANGED
|
@@ -19,6 +19,7 @@ from pathlib import Path
|
|
| 19 |
import json
|
| 20 |
import yaml
|
| 21 |
import logging
|
|
|
|
| 22 |
from .netbox_client import NetBoxClient
|
| 23 |
|
| 24 |
logger = logging.getLogger(__name__)
|
|
@@ -210,6 +211,257 @@ class OvergrowthPipeline:
|
|
| 210 |
self.ray_executor = None
|
| 211 |
self.parallel_mode = False
|
| 212 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 213 |
def stage0_preflight(self, model: NetworkModel) -> Dict[str, Any]:
|
| 214 |
"""
|
| 215 |
Stage 0: Pre-flight validation (BEFORE deployment)
|
|
@@ -1099,7 +1351,7 @@ Be specific and practical. Use RFC1918 addressing. Consider scalability and secu
|
|
| 1099 |
|
| 1100 |
return results
|
| 1101 |
|
| 1102 |
-
def stage8_validation(self, model: NetworkModel) -> Dict[str, Any]:
|
| 1103 |
"""
|
| 1104 |
Stage 8: Validate actual state matches intended state
|
| 1105 |
Continuous reconciliation with automatic remediation
|
|
@@ -1111,7 +1363,8 @@ Be specific and practical. Use RFC1918 addressing. Consider scalability and secu
|
|
| 1111 |
results = {
|
| 1112 |
'status': 'completed',
|
| 1113 |
'validation_passed': False,
|
| 1114 |
-
'checks_performed': []
|
|
|
|
| 1115 |
}
|
| 1116 |
|
| 1117 |
# Run drift detection
|
|
@@ -1133,7 +1386,7 @@ Be specific and practical. Use RFC1918 addressing. Consider scalability and secu
|
|
| 1133 |
results['compliance_report'] = compliance
|
| 1134 |
|
| 1135 |
# Apply auto-remediation if enabled
|
| 1136 |
-
if drift_results.get('remediation_plan'):
|
| 1137 |
logger.info("Applying automatic remediation for approved fixes...")
|
| 1138 |
|
| 1139 |
remediation_results = self.suzieq.apply_remediation(
|
|
@@ -1144,6 +1397,13 @@ Be specific and practical. Use RFC1918 addressing. Consider scalability and secu
|
|
| 1144 |
|
| 1145 |
logger.info(f"Remediation: {remediation_results['applied']} applied, "
|
| 1146 |
f"{remediation_results['skipped']} require approval")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1147 |
|
| 1148 |
if results['validation_passed']:
|
| 1149 |
logger.info("✓ Validation PASSED - network state matches SoT")
|
|
|
|
| 19 |
import json
|
| 20 |
import yaml
|
| 21 |
import logging
|
| 22 |
+
import os
|
| 23 |
from .netbox_client import NetBoxClient
|
| 24 |
|
| 25 |
logger = logging.getLogger(__name__)
|
|
|
|
| 211 |
self.ray_executor = None
|
| 212 |
self.parallel_mode = False
|
| 213 |
|
| 214 |
+
def _build_model_from_dict(self, data: Dict[str, Any], description: str,
|
| 215 |
+
constraints: Optional[List[str]] = None) -> NetworkModel:
|
| 216 |
+
"""
|
| 217 |
+
Normalize raw data into a NetworkModel with safe defaults.
|
| 218 |
+
Used by brownfield import paths (NetBox, GNS3, existing YAML).
|
| 219 |
+
"""
|
| 220 |
+
constraints = constraints or []
|
| 221 |
+
intent_data = data.get('intent') or {}
|
| 222 |
+
intent = NetworkIntent(
|
| 223 |
+
description=intent_data.get('description', description),
|
| 224 |
+
business_requirements=intent_data.get('business_requirements', ["Maintain current network safely"]),
|
| 225 |
+
constraints=intent_data.get('constraints', []) + constraints,
|
| 226 |
+
timeline=intent_data.get('timeline'),
|
| 227 |
+
budget=intent_data.get('budget')
|
| 228 |
+
)
|
| 229 |
+
|
| 230 |
+
allowed_roles = {"core", "distribution", "access", "edge", "firewall", "router", "wireless"}
|
| 231 |
+
allowed_vendors = {"cisco", "juniper", "arista", "hp", "dell", "ubiquiti", "mikrotik", "other"}
|
| 232 |
+
role_synonyms = {
|
| 233 |
+
"ap": "wireless",
|
| 234 |
+
"access_point": "wireless",
|
| 235 |
+
"access-point": "wireless",
|
| 236 |
+
"wifi": "wireless",
|
| 237 |
+
"server": "access",
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
devices: List[Device] = []
|
| 241 |
+
mgmt_seed = 11
|
| 242 |
+
for dev in data.get("devices", []):
|
| 243 |
+
mgmt_ip = dev.get("mgmt_ip") or f"10.255.0.{mgmt_seed}"
|
| 244 |
+
mgmt_seed += 1
|
| 245 |
+
vendor = (dev.get("vendor") or "other").lower()
|
| 246 |
+
role = (dev.get("role") or "access").lower().replace(" ", "_")
|
| 247 |
+
role = role_synonyms.get(role, role)
|
| 248 |
+
if role not in allowed_roles:
|
| 249 |
+
role = "access"
|
| 250 |
+
if vendor not in allowed_vendors:
|
| 251 |
+
vendor = "other"
|
| 252 |
+
devices.append(
|
| 253 |
+
Device(
|
| 254 |
+
name=dev.get("name", f"device-{mgmt_seed}"),
|
| 255 |
+
role=role,
|
| 256 |
+
model=dev.get("model", "Unknown"),
|
| 257 |
+
vendor=vendor,
|
| 258 |
+
mgmt_ip=mgmt_ip,
|
| 259 |
+
location=dev.get("location", "unspecified"),
|
| 260 |
+
interfaces=dev.get("interfaces", [])
|
| 261 |
+
)
|
| 262 |
+
)
|
| 263 |
+
|
| 264 |
+
if not devices:
|
| 265 |
+
devices.append(
|
| 266 |
+
Device(
|
| 267 |
+
name="imported-edge-01",
|
| 268 |
+
role="edge",
|
| 269 |
+
model="Imported",
|
| 270 |
+
vendor="other",
|
| 271 |
+
mgmt_ip="10.255.0.10",
|
| 272 |
+
location="unspecified",
|
| 273 |
+
interfaces=[]
|
| 274 |
+
)
|
| 275 |
+
)
|
| 276 |
+
|
| 277 |
+
vlans = data.get("vlans") or [{
|
| 278 |
+
"id": 999,
|
| 279 |
+
"name": "brownfield_mgmt",
|
| 280 |
+
"subnet": "10.255.0.0/24",
|
| 281 |
+
"purpose": "Imported management network"
|
| 282 |
+
}]
|
| 283 |
+
subnets = data.get("subnets") or [{
|
| 284 |
+
"network": "10.255.0.0/24",
|
| 285 |
+
"gateway": "10.255.0.1",
|
| 286 |
+
"vlan": vlans[0]["id"],
|
| 287 |
+
"purpose": "Brownfield management overlay"
|
| 288 |
+
}]
|
| 289 |
+
routing = data.get("routing") or {"protocol": "static", "networks": [s.get("network") for s in subnets if s.get("network")]}
|
| 290 |
+
services = data.get("services") or ["DHCP", "DNS", "NTP"]
|
| 291 |
+
|
| 292 |
+
return NetworkModel(
|
| 293 |
+
name=data.get("name") or "brownfield_network",
|
| 294 |
+
version=data.get("version") or "1.0.0",
|
| 295 |
+
intent=intent,
|
| 296 |
+
devices=devices,
|
| 297 |
+
vlans=vlans,
|
| 298 |
+
subnets=subnets,
|
| 299 |
+
routing=routing,
|
| 300 |
+
services=services
|
| 301 |
+
)
|
| 302 |
+
|
| 303 |
+
def _model_from_topology(self, topology: Dict[str, Any]) -> Dict[str, Any]:
|
| 304 |
+
"""Convert a GNS3 topology snapshot into a basic NetworkModel dict."""
|
| 305 |
+
nodes = topology.get("nodes", [])
|
| 306 |
+
devices = []
|
| 307 |
+
mgmt_seed = 11
|
| 308 |
+
for node in nodes:
|
| 309 |
+
node_type = (node.get("node_type") or "").lower()
|
| 310 |
+
name = node.get("name", f"node-{mgmt_seed}")
|
| 311 |
+
role = "access"
|
| 312 |
+
if "fw" in name.lower() or "firewall" in name.lower():
|
| 313 |
+
role = "firewall"
|
| 314 |
+
elif node_type in {"router", "qemu"}:
|
| 315 |
+
role = "router"
|
| 316 |
+
elif node_type in {"switch", "sw"}:
|
| 317 |
+
role = "distribution"
|
| 318 |
+
elif "ap" in name.lower():
|
| 319 |
+
role = "wireless"
|
| 320 |
+
devices.append({
|
| 321 |
+
"name": name,
|
| 322 |
+
"role": role,
|
| 323 |
+
"model": node.get("properties", {}).get("symbol") or "GNS3 node",
|
| 324 |
+
"vendor": "other",
|
| 325 |
+
"mgmt_ip": f"10.254.0.{mgmt_seed}",
|
| 326 |
+
"location": topology.get("project", "gns3"),
|
| 327 |
+
"interfaces": []
|
| 328 |
+
})
|
| 329 |
+
mgmt_seed += 1
|
| 330 |
+
|
| 331 |
+
vlan = {
|
| 332 |
+
"id": 999,
|
| 333 |
+
"name": "brownfield_mgmt",
|
| 334 |
+
"subnet": "10.254.0.0/24",
|
| 335 |
+
"purpose": "Imported from GNS3"
|
| 336 |
+
}
|
| 337 |
+
subnet = {
|
| 338 |
+
"network": "10.254.0.0/24",
|
| 339 |
+
"gateway": "10.254.0.1",
|
| 340 |
+
"vlan": vlan["id"],
|
| 341 |
+
"purpose": "Management overlay"
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
+
return {
|
| 345 |
+
"name": topology.get("project", "gns3-import"),
|
| 346 |
+
"version": "1.0.0",
|
| 347 |
+
"devices": devices,
|
| 348 |
+
"vlans": [vlan],
|
| 349 |
+
"subnets": [subnet],
|
| 350 |
+
"routing": {"protocol": "static", "networks": [subnet["network"]]},
|
| 351 |
+
"services": ["DHCP", "DNS", "NTP"],
|
| 352 |
+
"links": topology.get("links", [])
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
def run_brownfield_pipeline(
|
| 356 |
+
self,
|
| 357 |
+
consultation_input: str,
|
| 358 |
+
source: str = "netbox",
|
| 359 |
+
project_name: Optional[str] = None,
|
| 360 |
+
sot_path: Optional[str] = None,
|
| 361 |
+
simulation_only: bool = True,
|
| 362 |
+
read_only_validation: bool = True
|
| 363 |
+
) -> Dict[str, Any]:
|
| 364 |
+
"""
|
| 365 |
+
Brownfield/read-only workflow: import an existing network and validate without pushing changes.
|
| 366 |
+
Supports NetBox, GNS3, or an existing SoT file.
|
| 367 |
+
"""
|
| 368 |
+
logger.info(f"Starting brownfield pipeline from source={source}")
|
| 369 |
+
source = (source or "netbox").lower()
|
| 370 |
+
import_summary = {"source": source, "project": project_name}
|
| 371 |
+
topology = None
|
| 372 |
+
raw_model: Dict[str, Any] = {}
|
| 373 |
+
|
| 374 |
+
if source == "netbox":
|
| 375 |
+
if self.use_netbox and not self.netbox.mock_mode:
|
| 376 |
+
raw_model = self.netbox.export_network_model()
|
| 377 |
+
import_summary["note"] = "Imported from NetBox/Nautobot"
|
| 378 |
+
else:
|
| 379 |
+
logger.warning("NetBox unavailable; falling back to local SoT file")
|
| 380 |
+
import_summary["note"] = "NetBox unavailable - using local SoT"
|
| 381 |
+
source = "file"
|
| 382 |
+
|
| 383 |
+
if source == "gns3":
|
| 384 |
+
try:
|
| 385 |
+
from agent.network_ops import get_lab_topology
|
| 386 |
+
topo_name = project_name or os.getenv("GNS3_PROJECT_NAME", "overgrowth")
|
| 387 |
+
topology = get_lab_topology(topo_name)
|
| 388 |
+
raw_model = self._model_from_topology(topology)
|
| 389 |
+
import_summary["note"] = f"Discovered topology from GNS3 project '{topo_name}'"
|
| 390 |
+
except Exception as e:
|
| 391 |
+
logger.error(f"Failed to import from GNS3: {e}")
|
| 392 |
+
import_summary["note"] = f"GNS3 import failed: {e}"
|
| 393 |
+
|
| 394 |
+
if source == "file" or not raw_model:
|
| 395 |
+
path = Path(sot_path or self.sot_file)
|
| 396 |
+
try:
|
| 397 |
+
content = path.read_text()
|
| 398 |
+
if path.suffix.lower() in [".yaml", ".yml"]:
|
| 399 |
+
raw_model = yaml.safe_load(content) or {}
|
| 400 |
+
else:
|
| 401 |
+
raw_model = json.loads(content)
|
| 402 |
+
import_summary["note"] = f"Loaded SoT from {path}"
|
| 403 |
+
except Exception as e:
|
| 404 |
+
logger.error(f"Could not load SoT from {path}: {e}")
|
| 405 |
+
import_summary["note"] = f"File load failed ({path}): {e}"
|
| 406 |
+
raw_model = raw_model or {}
|
| 407 |
+
|
| 408 |
+
model = self._build_model_from_dict(
|
| 409 |
+
raw_model,
|
| 410 |
+
description=consultation_input or "Brownfield validation",
|
| 411 |
+
constraints=["brownfield-import"]
|
| 412 |
+
)
|
| 413 |
+
|
| 414 |
+
# Persist SoT snapshot even in read-only mode
|
| 415 |
+
self.sot_file.write_text(model.to_yaml())
|
| 416 |
+
logger.info(f"Saved imported source of truth to {self.sot_file}")
|
| 417 |
+
|
| 418 |
+
results: Dict[str, Any] = {
|
| 419 |
+
"mode": {
|
| 420 |
+
"source": source,
|
| 421 |
+
"simulation_only": simulation_only,
|
| 422 |
+
"read_only_validation": read_only_validation,
|
| 423 |
+
"import_summary": import_summary.get("note")
|
| 424 |
+
},
|
| 425 |
+
"intent": asdict(model.intent),
|
| 426 |
+
"questions": [],
|
| 427 |
+
"import_summary": import_summary
|
| 428 |
+
}
|
| 429 |
+
|
| 430 |
+
results["model"] = model.to_dict()
|
| 431 |
+
results["preflight"] = self.stage0_preflight(model)
|
| 432 |
+
|
| 433 |
+
diagrams = self.stage3_generate_diagram(model)
|
| 434 |
+
if topology:
|
| 435 |
+
diagrams["ascii"] = topology.get("ascii_diagram", diagrams.get("ascii"))
|
| 436 |
+
diagrams["mermaid"] = topology.get("mermaid_diagram", diagrams.get("mermaid"))
|
| 437 |
+
diagrams["summary"] = topology.get("summary", diagrams.get("summary"))
|
| 438 |
+
results["diagrams"] = diagrams
|
| 439 |
+
|
| 440 |
+
bom = self.stage4_generate_bom(model)
|
| 441 |
+
results["bom"] = asdict(bom)
|
| 442 |
+
results["shopping_list"] = bom.to_shopping_list()
|
| 443 |
+
guide = self.stage5_generate_setup_guide(model, bom)
|
| 444 |
+
results["setup_guide"] = guide.to_markdown()
|
| 445 |
+
|
| 446 |
+
# Deployment is skipped unless explicitly requested and validation passed
|
| 447 |
+
if (not simulation_only) and results["preflight"].get("ready_to_deploy") and (not read_only_validation):
|
| 448 |
+
results["deployment"] = self.stage6_autonomous_deploy(
|
| 449 |
+
model=model,
|
| 450 |
+
credentials=None,
|
| 451 |
+
dry_run=False
|
| 452 |
+
)
|
| 453 |
+
else:
|
| 454 |
+
results["deployment_status"] = "skipped"
|
| 455 |
+
results["deployment_reason"] = "simulation_only" if simulation_only else "read_only_validation"
|
| 456 |
+
|
| 457 |
+
results["observability"] = self.stage7_observability(model)
|
| 458 |
+
results["validation"] = self.stage8_validation(
|
| 459 |
+
model,
|
| 460 |
+
apply_remediation=not read_only_validation
|
| 461 |
+
)
|
| 462 |
+
|
| 463 |
+
return results
|
| 464 |
+
|
| 465 |
def stage0_preflight(self, model: NetworkModel) -> Dict[str, Any]:
|
| 466 |
"""
|
| 467 |
Stage 0: Pre-flight validation (BEFORE deployment)
|
|
|
|
| 1351 |
|
| 1352 |
return results
|
| 1353 |
|
| 1354 |
+
def stage8_validation(self, model: NetworkModel, apply_remediation: bool = True) -> Dict[str, Any]:
|
| 1355 |
"""
|
| 1356 |
Stage 8: Validate actual state matches intended state
|
| 1357 |
Continuous reconciliation with automatic remediation
|
|
|
|
| 1363 |
results = {
|
| 1364 |
'status': 'completed',
|
| 1365 |
'validation_passed': False,
|
| 1366 |
+
'checks_performed': [],
|
| 1367 |
+
'read_only': not apply_remediation
|
| 1368 |
}
|
| 1369 |
|
| 1370 |
# Run drift detection
|
|
|
|
| 1386 |
results['compliance_report'] = compliance
|
| 1387 |
|
| 1388 |
# Apply auto-remediation if enabled
|
| 1389 |
+
if apply_remediation and drift_results.get('remediation_plan'):
|
| 1390 |
logger.info("Applying automatic remediation for approved fixes...")
|
| 1391 |
|
| 1392 |
remediation_results = self.suzieq.apply_remediation(
|
|
|
|
| 1397 |
|
| 1398 |
logger.info(f"Remediation: {remediation_results['applied']} applied, "
|
| 1399 |
f"{remediation_results['skipped']} require approval")
|
| 1400 |
+
elif drift_results.get('remediation_plan'):
|
| 1401 |
+
# Document skipped remediation when running in read-only mode
|
| 1402 |
+
results['remediation'] = {
|
| 1403 |
+
'applied': 0,
|
| 1404 |
+
'skipped': len(drift_results['remediation_plan']),
|
| 1405 |
+
'reason': 'read-only validation mode'
|
| 1406 |
+
}
|
| 1407 |
|
| 1408 |
if results['validation_passed']:
|
| 1409 |
logger.info("✓ Validation PASSED - network state matches SoT")
|
agent/topology_diagram.py
CHANGED
|
@@ -71,39 +71,59 @@ def generate_mermaid_diagram(topology: Dict) -> str:
|
|
| 71 |
return "graph TD\n A[No Topology Data]"
|
| 72 |
|
| 73 |
lines = []
|
| 74 |
-
lines.append("
|
|
|
|
|
|
|
|
|
|
| 75 |
|
| 76 |
# Create node definitions with icons based on type
|
| 77 |
node_map = {}
|
| 78 |
for i, node in enumerate(nodes):
|
| 79 |
node_id = f"N{i}"
|
| 80 |
-
node_map[node
|
| 81 |
node_type = node.get('node_type', 'unknown')
|
| 82 |
|
| 83 |
-
# Choose icon based on type
|
| 84 |
-
if node_type == 'qemu' or 'SW' in node
|
| 85 |
icon = "🔀" # Switch
|
| 86 |
-
elif node_type == 'vpcs' or 'PC' in node
|
| 87 |
icon = "💻" # PC
|
| 88 |
elif node_type == 'cloud':
|
| 89 |
icon = "☁️" # Cloud
|
|
|
|
|
|
|
| 90 |
else:
|
| 91 |
icon = "📦"
|
| 92 |
|
| 93 |
status = node.get('status', 'unknown')
|
|
|
|
|
|
|
|
|
|
| 94 |
if status == "started":
|
| 95 |
-
lines.append(f
|
| 96 |
elif status == "stopped":
|
| 97 |
-
lines.append(f
|
| 98 |
else:
|
| 99 |
-
lines.append(f
|
| 100 |
|
| 101 |
-
# Add links
|
| 102 |
-
# Note: GNS3 link format is complex, this is a simplified version
|
| 103 |
if links:
|
| 104 |
lines.append("\n %% Network Links")
|
| 105 |
-
|
| 106 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
|
| 108 |
return "\n".join(lines)
|
| 109 |
|
|
@@ -164,4 +184,13 @@ def generate_topology_summary(topology: Dict) -> str:
|
|
| 164 |
console_type = node.get('console_type', 'telnet')
|
| 165 |
summary.append(f"- **{node['name']}**: {console_type}://localhost:{console}")
|
| 166 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 167 |
return "\n".join(summary)
|
|
|
|
| 71 |
return "graph TD\n A[No Topology Data]"
|
| 72 |
|
| 73 |
lines = []
|
| 74 |
+
lines.append("flowchart TD")
|
| 75 |
+
lines.append(" classDef started stroke:#16a34a,stroke-width:2px;")
|
| 76 |
+
lines.append(" classDef stopped stroke:#e11d48,stroke-width:2px;")
|
| 77 |
+
lines.append(" classDef planned stroke:#0ea5e9,stroke-dasharray: 4 2;")
|
| 78 |
|
| 79 |
# Create node definitions with icons based on type
|
| 80 |
node_map = {}
|
| 81 |
for i, node in enumerate(nodes):
|
| 82 |
node_id = f"N{i}"
|
| 83 |
+
node_map[node.get('name', f'node-{i}')] = node_id
|
| 84 |
node_type = node.get('node_type', 'unknown')
|
| 85 |
|
| 86 |
+
# Choose icon based on type/role
|
| 87 |
+
if node_type == 'qemu' or 'SW' in node.get('name', ''):
|
| 88 |
icon = "🔀" # Switch
|
| 89 |
+
elif node_type == 'vpcs' or 'PC' in node.get('name', '') or 'POS' in node.get('name', ''):
|
| 90 |
icon = "💻" # PC
|
| 91 |
elif node_type == 'cloud':
|
| 92 |
icon = "☁️" # Cloud
|
| 93 |
+
elif 'fw' in node.get('name', '').lower():
|
| 94 |
+
icon = "🛡️" # Firewall
|
| 95 |
else:
|
| 96 |
icon = "📦"
|
| 97 |
|
| 98 |
status = node.get('status', 'unknown')
|
| 99 |
+
role_label = node.get('node_type', 'node')
|
| 100 |
+
label = f"{icon} {node.get('name', 'node')}<br/>{role_label}"
|
| 101 |
+
lines.append(f' {node_id}["{label}"]')
|
| 102 |
if status == "started":
|
| 103 |
+
lines.append(f" class {node_id} started;")
|
| 104 |
elif status == "stopped":
|
| 105 |
+
lines.append(f" class {node_id} stopped;")
|
| 106 |
else:
|
| 107 |
+
lines.append(f" class {node_id} planned;")
|
| 108 |
|
| 109 |
+
# Add links with lightweight styling
|
|
|
|
| 110 |
if links:
|
| 111 |
lines.append("\n %% Network Links")
|
| 112 |
+
for idx, link in enumerate(links):
|
| 113 |
+
src = link.get('src') or link.get('source') or link.get('a_node')
|
| 114 |
+
dst = link.get('dst') or link.get('target') or link.get('z_node')
|
| 115 |
+
src_id = node_map.get(src)
|
| 116 |
+
dst_id = node_map.get(dst)
|
| 117 |
+
if not src_id or not dst_id:
|
| 118 |
+
continue
|
| 119 |
+
label = link.get('label') or link.get('status') or ""
|
| 120 |
+
edge = f" {src_id} ---"
|
| 121 |
+
if label:
|
| 122 |
+
edge += f"|{label}| "
|
| 123 |
+
else:
|
| 124 |
+
edge += " "
|
| 125 |
+
edge += f"{dst_id}"
|
| 126 |
+
lines.append(edge)
|
| 127 |
|
| 128 |
return "\n".join(lines)
|
| 129 |
|
|
|
|
| 184 |
console_type = node.get('console_type', 'telnet')
|
| 185 |
summary.append(f"- **{node['name']}**: {console_type}://localhost:{console}")
|
| 186 |
|
| 187 |
+
if links:
|
| 188 |
+
summary.append("")
|
| 189 |
+
summary.append("### Sample Links")
|
| 190 |
+
for link in links[:5]:
|
| 191 |
+
src = link.get('src') or link.get('source') or link.get('a_node') or '?'
|
| 192 |
+
dst = link.get('dst') or link.get('target') or link.get('z_node') or '?'
|
| 193 |
+
link_status = link.get('status', 'planned')
|
| 194 |
+
summary.append(f"- {src} ↔ {dst} ({link_status})")
|
| 195 |
+
|
| 196 |
return "\n".join(summary)
|
app.py
CHANGED
|
@@ -186,6 +186,14 @@ textarea, input, select {
|
|
| 186 |
|
| 187 |
|
| 188 |
def build_ui():
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
with gr.Blocks(
|
| 190 |
title="Overgrowth – a living digital environment",
|
| 191 |
css=VINE_CSS,
|
|
@@ -260,6 +268,8 @@ def build_ui():
|
|
| 260 |
6. 🤖 **Autonomous Deploy** - AI configures devices (Ansible/Netmiko)
|
| 261 |
7. 👁️ **Observability** - Monitoring, SNMP, topology discovery
|
| 262 |
8. ✅ **Validation** - pytest-based network testing & compliance
|
|
|
|
|
|
|
| 263 |
""")
|
| 264 |
|
| 265 |
with gr.Row(elem_classes=["og-main-row"]):
|
|
@@ -274,6 +284,33 @@ def build_ui():
|
|
| 274 |
),
|
| 275 |
lines=10,
|
| 276 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 277 |
run_pipeline_btn = gr.Button("🚀 Run Full Pipeline", variant="primary", size="lg")
|
| 278 |
|
| 279 |
with gr.Column(scale=2, elem_classes=["og-panel", "og-tabs"]):
|
|
@@ -294,7 +331,7 @@ def build_ui():
|
|
| 294 |
diagram_output = gr.HTML(label="Network Diagram")
|
| 295 |
|
| 296 |
# Pipeline handler
|
| 297 |
-
def run_pipeline(user_input):
|
| 298 |
"""Execute the full automation pipeline"""
|
| 299 |
from agent.pipeline_engine import OvergrowthPipeline
|
| 300 |
from agent.llm_client import LLMClient
|
|
@@ -306,7 +343,24 @@ def build_ui():
|
|
| 306 |
llm_env = LLMClient.env_status()
|
| 307 |
|
| 308 |
# Run the pipeline (this will generate API calls that get tracked)
|
| 309 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 310 |
|
| 311 |
# If the pipeline stopped early for clarifications, show questions and exit
|
| 312 |
if results.get("needs_more_input"):
|
|
@@ -338,7 +392,19 @@ def build_ui():
|
|
| 338 |
status += f"- Blaxel key: {llm_env.get('blaxel_key', 'missing')}\n"
|
| 339 |
status += f"- SambaNova key: {llm_env.get('sambanova_key', 'missing')}\n"
|
| 340 |
status += f"- Nebius key: {llm_env.get('nebius_key', 'missing')}\n"
|
| 341 |
-
status += f"- HuggingFace key: {llm_env.get('huggingface_key', 'missing')}\n
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 342 |
if stats.errors:
|
| 343 |
from agent.api_monitor import monitor as api_monitor_instance
|
| 344 |
recent_errors = api_monitor_instance.get_recent_errors(limit=3)
|
|
@@ -390,6 +456,15 @@ def build_ui():
|
|
| 390 |
status += f"- ⚠️ {warning}\n"
|
| 391 |
status += "\n"
|
| 392 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 393 |
# Completed stages
|
| 394 |
status += "### Completed Stages:\n"
|
| 395 |
status += "0. " + ("✅" if ready_to_deploy else "❌") + " Pre-flight Validation\n"
|
|
@@ -398,11 +473,17 @@ def build_ui():
|
|
| 398 |
status += "3. ✅ Diagrams - Visualizations created\n"
|
| 399 |
status += "4. ✅ Bill of Materials - Shopping list ready\n"
|
| 400 |
status += "5. ✅ Setup Guide - Deployment instructions generated\n"
|
| 401 |
-
|
|
|
|
| 402 |
if ready_to_deploy:
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 406 |
else:
|
| 407 |
status += "6. 🚫 Autonomous Deploy - BLOCKED (fix validation errors)\n"
|
| 408 |
status += "7. 🚫 Observability - BLOCKED\n"
|
|
@@ -473,7 +554,7 @@ def build_ui():
|
|
| 473 |
# Event Handlers
|
| 474 |
run_pipeline_btn.click(
|
| 475 |
fn=run_pipeline,
|
| 476 |
-
inputs=[pipeline_input],
|
| 477 |
outputs=[pipeline_status, sot_output, bom_output, setup_output, diagram_output, api_stats, api_activity],
|
| 478 |
)
|
| 479 |
|
|
|
|
| 186 |
|
| 187 |
|
| 188 |
def build_ui():
|
| 189 |
+
# Preload available GNS3 projects for brownfield imports
|
| 190 |
+
try:
|
| 191 |
+
project_choices = [
|
| 192 |
+
p.get("name") for p in get_lab_projects() if isinstance(p, dict) and p.get("name")
|
| 193 |
+
]
|
| 194 |
+
except Exception:
|
| 195 |
+
project_choices = []
|
| 196 |
+
|
| 197 |
with gr.Blocks(
|
| 198 |
title="Overgrowth – a living digital environment",
|
| 199 |
css=VINE_CSS,
|
|
|
|
| 268 |
6. 🤖 **Autonomous Deploy** - AI configures devices (Ansible/Netmiko)
|
| 269 |
7. 👁️ **Observability** - Monitoring, SNMP, topology discovery
|
| 270 |
8. ✅ **Validation** - pytest-based network testing & compliance
|
| 271 |
+
|
| 272 |
+
_Brownfield ready: import existing NetBox/GNS3/SoT data, run simulation-only, and keep validation read-only._
|
| 273 |
""")
|
| 274 |
|
| 275 |
with gr.Row(elem_classes=["og-main-row"]):
|
|
|
|
| 284 |
),
|
| 285 |
lines=10,
|
| 286 |
)
|
| 287 |
+
mode_choice = gr.Dropdown(
|
| 288 |
+
label="Pipeline Mode",
|
| 289 |
+
choices=[
|
| 290 |
+
"Design new (greenfield)",
|
| 291 |
+
"Import from NetBox",
|
| 292 |
+
"Import from GNS3",
|
| 293 |
+
"Load existing SoT file"
|
| 294 |
+
],
|
| 295 |
+
value="Design new (greenfield)"
|
| 296 |
+
)
|
| 297 |
+
gns3_project_input = gr.Dropdown(
|
| 298 |
+
label="GNS3 project (brownfield)",
|
| 299 |
+
choices=project_choices or ["overgrowth"],
|
| 300 |
+
value=project_choices[0] if project_choices else "overgrowth"
|
| 301 |
+
)
|
| 302 |
+
sot_path_input = gr.Textbox(
|
| 303 |
+
label="Existing SoT path",
|
| 304 |
+
value="infra/network_model.yaml"
|
| 305 |
+
)
|
| 306 |
+
simulation_only = gr.Checkbox(
|
| 307 |
+
label="Simulation-only (skip deployment)",
|
| 308 |
+
value=True
|
| 309 |
+
)
|
| 310 |
+
read_only_validation = gr.Checkbox(
|
| 311 |
+
label="Read-only validation (no remediation)",
|
| 312 |
+
value=True
|
| 313 |
+
)
|
| 314 |
run_pipeline_btn = gr.Button("🚀 Run Full Pipeline", variant="primary", size="lg")
|
| 315 |
|
| 316 |
with gr.Column(scale=2, elem_classes=["og-panel", "og-tabs"]):
|
|
|
|
| 331 |
diagram_output = gr.HTML(label="Network Diagram")
|
| 332 |
|
| 333 |
# Pipeline handler
|
| 334 |
+
def run_pipeline(user_input, mode_choice, gns3_project, sot_path, simulation_only_flag, read_only_flag):
|
| 335 |
"""Execute the full automation pipeline"""
|
| 336 |
from agent.pipeline_engine import OvergrowthPipeline
|
| 337 |
from agent.llm_client import LLMClient
|
|
|
|
| 343 |
llm_env = LLMClient.env_status()
|
| 344 |
|
| 345 |
# Run the pipeline (this will generate API calls that get tracked)
|
| 346 |
+
mode_map = {
|
| 347 |
+
"Design new (greenfield)": None,
|
| 348 |
+
"Import from NetBox": "netbox",
|
| 349 |
+
"Import from GNS3": "gns3",
|
| 350 |
+
"Load existing SoT file": "file",
|
| 351 |
+
}
|
| 352 |
+
chosen_source = mode_map.get(mode_choice)
|
| 353 |
+
if chosen_source:
|
| 354 |
+
results = pipeline.run_brownfield_pipeline(
|
| 355 |
+
consultation_input=user_input,
|
| 356 |
+
source=chosen_source,
|
| 357 |
+
project_name=gns3_project,
|
| 358 |
+
sot_path=sot_path,
|
| 359 |
+
simulation_only=simulation_only_flag,
|
| 360 |
+
read_only_validation=read_only_flag,
|
| 361 |
+
)
|
| 362 |
+
else:
|
| 363 |
+
results = pipeline.run_full_pipeline(user_input)
|
| 364 |
|
| 365 |
# If the pipeline stopped early for clarifications, show questions and exit
|
| 366 |
if results.get("needs_more_input"):
|
|
|
|
| 392 |
status += f"- Blaxel key: {llm_env.get('blaxel_key', 'missing')}\n"
|
| 393 |
status += f"- SambaNova key: {llm_env.get('sambanova_key', 'missing')}\n"
|
| 394 |
status += f"- Nebius key: {llm_env.get('nebius_key', 'missing')}\n"
|
| 395 |
+
status += f"- HuggingFace key: {llm_env.get('huggingface_key', 'missing')}\n"
|
| 396 |
+
status += f"- Modal key: {llm_env.get('modal_key', 'missing')}\n\n"
|
| 397 |
+
|
| 398 |
+
mode_info = results.get("mode")
|
| 399 |
+
if mode_info:
|
| 400 |
+
status += "### 🌳 Pipeline Mode\n"
|
| 401 |
+
status += f"- Brownfield source: **{mode_info.get('source', 'greenfield')}**\n"
|
| 402 |
+
status += f"- Simulation-only: {'Yes' if mode_info.get('simulation_only') else 'No'}\n"
|
| 403 |
+
status += f"- Read-only validation: {'Yes' if mode_info.get('read_only_validation') else 'No'}\n"
|
| 404 |
+
import_note = results.get("import_summary", {}).get("note") if isinstance(results.get("import_summary"), dict) else None
|
| 405 |
+
if import_note:
|
| 406 |
+
status += f"- Import notes: {import_note}\n"
|
| 407 |
+
status += "\n"
|
| 408 |
if stats.errors:
|
| 409 |
from agent.api_monitor import monitor as api_monitor_instance
|
| 410 |
recent_errors = api_monitor_instance.get_recent_errors(limit=3)
|
|
|
|
| 456 |
status += f"- ⚠️ {warning}\n"
|
| 457 |
status += "\n"
|
| 458 |
|
| 459 |
+
status += "### Validation Findings\n"
|
| 460 |
+
status += f"- Errors: {len(preflight.get('errors', []))}\n"
|
| 461 |
+
status += f"- Warnings: {len(preflight.get('warnings', []))}\n"
|
| 462 |
+
status += f"- Info: {len(preflight.get('info', []))}\n"
|
| 463 |
+
if preflight.get('batfish_analysis'):
|
| 464 |
+
bf = preflight['batfish_analysis']
|
| 465 |
+
status += f"- Batfish: {'mock' if bf.get('mock_mode') else 'analysis ran'} | Loops: {len(bf.get('routing_loops', []))} | Undefined refs: {len(bf.get('undefined_references', []))}\n"
|
| 466 |
+
status += "\n"
|
| 467 |
+
|
| 468 |
# Completed stages
|
| 469 |
status += "### Completed Stages:\n"
|
| 470 |
status += "0. " + ("✅" if ready_to_deploy else "❌") + " Pre-flight Validation\n"
|
|
|
|
| 473 |
status += "3. ✅ Diagrams - Visualizations created\n"
|
| 474 |
status += "4. ✅ Bill of Materials - Shopping list ready\n"
|
| 475 |
status += "5. ✅ Setup Guide - Deployment instructions generated\n"
|
| 476 |
+
sim_only = results.get("mode", {}).get("simulation_only")
|
| 477 |
+
ro_mode = results.get("mode", {}).get("read_only_validation")
|
| 478 |
if ready_to_deploy:
|
| 479 |
+
if sim_only or ro_mode:
|
| 480 |
+
status += "6. 🧪 Autonomous Deploy - Skipped (simulation/read-only)\n"
|
| 481 |
+
status += "7. 👁️ Observability - Snapshot only\n"
|
| 482 |
+
status += "8. ✅ Validation - Read-only checks complete\n\n"
|
| 483 |
+
else:
|
| 484 |
+
status += "6. ⏳ Autonomous Deploy - Ready for execution\n"
|
| 485 |
+
status += "7. ⏳ Observability - Ready for setup\n"
|
| 486 |
+
status += "8. ⏳ Validation - Ready for verification\n\n"
|
| 487 |
else:
|
| 488 |
status += "6. 🚫 Autonomous Deploy - BLOCKED (fix validation errors)\n"
|
| 489 |
status += "7. 🚫 Observability - BLOCKED\n"
|
|
|
|
| 554 |
# Event Handlers
|
| 555 |
run_pipeline_btn.click(
|
| 556 |
fn=run_pipeline,
|
| 557 |
+
inputs=[pipeline_input, mode_choice, gns3_project_input, sot_path_input, simulation_only, read_only_validation],
|
| 558 |
outputs=[pipeline_status, sot_output, bom_output, setup_output, diagram_output, api_stats, api_activity],
|
| 559 |
)
|
| 560 |
|
debug_network.py
CHANGED
|
@@ -1,9 +1,15 @@
|
|
| 1 |
# debug_network.py
|
| 2 |
import os, socket, requests
|
|
|
|
| 3 |
|
| 4 |
def debug_connectivity():
|
| 5 |
print("DEBUG: OPENAI_API_KEY present:", bool(os.getenv("OPENAI_API_KEY")))
|
| 6 |
print("DEBUG: ANTHROPIC_API_KEY present:", bool(os.getenv("ANTHROPIC_API_KEY")))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
for host in ["api.openai.com", "api.anthropic.com"]:
|
| 9 |
try:
|
|
@@ -12,6 +18,23 @@ def debug_connectivity():
|
|
| 12 |
except Exception as e:
|
| 13 |
print(f"DEBUG: DNS FAILED for {host}: {e}")
|
| 14 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
try:
|
| 16 |
r = requests.get("https://api.openai.com/v1/models", timeout=5)
|
| 17 |
print("DEBUG: OpenAI /v1/models status:", r.status_code)
|
|
|
|
| 1 |
# debug_network.py
|
| 2 |
import os, socket, requests
|
| 3 |
+
from urllib.parse import urlsplit
|
| 4 |
|
| 5 |
def debug_connectivity():
|
| 6 |
print("DEBUG: OPENAI_API_KEY present:", bool(os.getenv("OPENAI_API_KEY")))
|
| 7 |
print("DEBUG: ANTHROPIC_API_KEY present:", bool(os.getenv("ANTHROPIC_API_KEY")))
|
| 8 |
+
print("DEBUG: BLAXEL key present:", bool(os.getenv("BLAXEL_MCP_1ST_BDAY")))
|
| 9 |
+
print("DEBUG: SAMBA_NOVA key present:", bool(os.getenv("SAMBA_NOVA_MCP_1ST_BDAY")))
|
| 10 |
+
print("DEBUG: NEBIUS key present:", bool(os.getenv("NEBIUS_MCP_1ST_BDAY")))
|
| 11 |
+
print("DEBUG: HUGGING_FACE key present:", bool(os.getenv("HUGGING_FACE_MCP_1ST_BDAY")))
|
| 12 |
+
print("DEBUG: MODAL key present:", bool(os.getenv("MODAL_API_KEY") or os.getenv("MODAL_TOKEN")))
|
| 13 |
|
| 14 |
for host in ["api.openai.com", "api.anthropic.com"]:
|
| 15 |
try:
|
|
|
|
| 18 |
except Exception as e:
|
| 19 |
print(f"DEBUG: DNS FAILED for {host}: {e}")
|
| 20 |
|
| 21 |
+
# Probe sponsor endpoints if configured
|
| 22 |
+
def _check_host_from_url(env_var):
|
| 23 |
+
base = os.getenv(env_var)
|
| 24 |
+
if not base:
|
| 25 |
+
return
|
| 26 |
+
host = urlsplit(base).hostname
|
| 27 |
+
if not host:
|
| 28 |
+
return
|
| 29 |
+
try:
|
| 30 |
+
ip = socket.gethostbyname(host)
|
| 31 |
+
print(f"DEBUG: DNS for {env_var} ({host}) -> {ip}")
|
| 32 |
+
except Exception as e:
|
| 33 |
+
print(f"DEBUG: DNS FAILED for {env_var} ({host}): {e}")
|
| 34 |
+
|
| 35 |
+
for env_var in ["BLAXEL_BASE_URL", "SAMBA_NOVA_BASE_URL", "NEBIUS_BASE_URL", "HUGGINGFACE_MODEL", "MODAL_BASE_URL"]:
|
| 36 |
+
_check_host_from_url(env_var)
|
| 37 |
+
|
| 38 |
try:
|
| 39 |
r = requests.get("https://api.openai.com/v1/models", timeout=5)
|
| 40 |
print("DEBUG: OpenAI /v1/models status:", r.status_code)
|