Graham Paasch commited on
Commit
86dc617
·
1 Parent(s): 0f56f53

Add brownfield import mode and sponsor LLM support

Browse files
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("graph TD")
 
 
 
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['name']] = node_id
81
  node_type = node.get('node_type', 'unknown')
82
 
83
- # Choose icon based on type
84
- if node_type == 'qemu' or 'SW' in node['name']:
85
  icon = "🔀" # Switch
86
- elif node_type == 'vpcs' or 'PC' in node['name'] or 'POS' in node['name']:
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' {node_id}["{icon} {node["name"]}<br/>🟢 Running"]')
96
  elif status == "stopped":
97
- lines.append(f' {node_id}["{icon} {node["name"]}<br/>🔴 Stopped"]')
98
  else:
99
- lines.append(f' {node_id}["{icon} {node["name"]}"]')
100
 
101
- # Add links (simplified - just show connections exist)
102
- # Note: GNS3 link format is complex, this is a simplified version
103
  if links:
104
  lines.append("\n %% Network Links")
105
- # For now, just note that links exist
106
- lines.append(f" %% {len(links)} links configured")
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- results = pipeline.run_full_pipeline(user_input)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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\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
- status += "6. ⏳ Autonomous Deploy - Ready for execution\n"
404
- status += "7. Observability - Ready for setup\n"
405
- status += "8. Validation - Ready for verification\n\n"
 
 
 
 
 
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)