halyn 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
halyn/control_plane.py ADDED
@@ -0,0 +1,354 @@
1
+ # Copyright (c) 2026 Elmadani SALKA. All rights reserved.
2
+ # Licensed under the MIT License. See LICENSE file.
3
+ """
4
+ Control Plane — The brain of Halyn.
5
+
6
+ Integrates everything:
7
+ Discovery → Consent → Connect → Autonomy → Intent → Audit → Watchdog
8
+
9
+ This is the single entry point. One class. Everything wired.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import logging
16
+ import time
17
+ from dataclasses import dataclass, field
18
+ from typing import Any
19
+
20
+ from nrp import NRPDriver, NRPId, NRPManifest, EventBus, Severity
21
+
22
+ from .engine import Engine
23
+ from .types import Action, Result, ActionStatus
24
+ from .autonomy import AutonomyController, Level, DomainPolicy, PRESET_DOMAINS
25
+ from .audit import AuditStore
26
+ from .consent import ConsentStore, ConsentLevel
27
+ from .intent import IntentChain, IntentStore
28
+ from .watchdog import Watchdog, Health, check_event_bus, check_disk_space
29
+ from .discovery import Scanner, DiscoveredNode
30
+ from .nrp_bridge import register_nrp_node
31
+ from .config import HalynConfig
32
+
33
+ log = logging.getLogger("halyn.control_plane")
34
+
35
+
36
+ class ControlPlane:
37
+ """
38
+ The complete Halyn runtime.
39
+
40
+ Usage:
41
+ cp = ControlPlane.from_config("halyn.yml")
42
+ await cp.start()
43
+ result = await cp.execute("server/prod.observe", {"channels": "cpu,ram"})
44
+ await cp.stop()
45
+ """
46
+
47
+ def __init__(self, config: HalynConfig | None = None) -> None:
48
+ self.config = config or HalynConfig.load()
49
+
50
+ # Core
51
+ self.engine = Engine()
52
+ self.event_bus = EventBus()
53
+
54
+ # Safety & Control
55
+ self.autonomy = AutonomyController(default_level=Level.SUPERVISED)
56
+ self.audit = AuditStore(f"{self.config.data_dir}/audit.db")
57
+ self.consent = ConsentStore(f"{self.config.data_dir}/consent.db")
58
+ self.intents = IntentStore(f"{self.config.data_dir}/intent.db")
59
+
60
+ # Health
61
+ self.watchdog = Watchdog(interval=15.0)
62
+
63
+ # Discovery
64
+ self.scanner = Scanner()
65
+
66
+ # State
67
+ self._nodes: dict[str, NRPDriver] = {}
68
+ self._manifests: dict[str, NRPManifest] = {}
69
+ self._running = False
70
+ self._emergency_stop = False
71
+
72
+ @classmethod
73
+ def from_config(cls, path: str = "") -> ControlPlane:
74
+ config = HalynConfig.load(path)
75
+ cp = cls(config)
76
+
77
+ # Load domain policies from config
78
+ for name, domain_cfg in config.domains.items():
79
+ if isinstance(domain_cfg, dict):
80
+ cp.autonomy.add_domain(DomainPolicy(
81
+ name=name,
82
+ level=Level(domain_cfg.get("level", 1)),
83
+ node_patterns=domain_cfg.get("nodes", ["*"]),
84
+ confirm_commands=domain_cfg.get("confirm", []),
85
+ blocked_commands=domain_cfg.get("blocked", []),
86
+ max_actions_per_hour=domain_cfg.get("max_actions_per_hour", 1000),
87
+ ))
88
+ elif name in PRESET_DOMAINS:
89
+ cp.autonomy.add_domain(PRESET_DOMAINS[name])
90
+
91
+ return cp
92
+
93
+ async def start(self) -> None:
94
+ """Start all services."""
95
+ self._running = True
96
+
97
+ # Register watchdog checks
98
+ self.watchdog.register("event_bus", lambda: check_event_bus(self.event_bus))
99
+ self.watchdog.register("disk", lambda: check_disk_space("/"))
100
+ self.watchdog.on_failsafe(self._failsafe)
101
+
102
+ # Start background tasks
103
+ asyncio.create_task(self.event_bus.process_loop())
104
+ asyncio.create_task(self.watchdog.run())
105
+
106
+ # Auto-connect nodes from config
107
+ for node_cfg in self.config.nodes:
108
+ try:
109
+ await self._connect_from_config(node_cfg)
110
+ except Exception as exc:
111
+ log.error("control_plane.auto_connect_failed node=%s error=%s",
112
+ node_cfg.get("id", "?"), exc)
113
+
114
+ log.info("control_plane.started nodes=%d tools=%d",
115
+ len(self._nodes), len(self.engine.registry.tool_names))
116
+
117
+ async def stop(self) -> None:
118
+ """Graceful shutdown."""
119
+ self._running = False
120
+ self.watchdog.stop()
121
+
122
+ for nrp_id, driver in self._nodes.items():
123
+ try:
124
+ await driver.disconnect()
125
+ except Exception:
126
+ pass
127
+
128
+ self.audit.close()
129
+ self.consent.close()
130
+ self.intents.close()
131
+ log.info("control_plane.stopped")
132
+
133
+ async def execute(
134
+ self,
135
+ tool: str,
136
+ args: dict[str, Any] | None = None,
137
+ user_id: str = "",
138
+ llm_model: str = "",
139
+ intent_text: str = "",
140
+ ) -> Result:
141
+ """
142
+ Execute an action through the full pipeline:
143
+ Intent → Autonomy Check → Engine Execute → Audit
144
+ """
145
+ args = args or {}
146
+ action = Action(tool=tool, args=args)
147
+
148
+ # Emergency stop check
149
+ if self._emergency_stop:
150
+ return Result(status=ActionStatus.DENIED, error="EMERGENCY STOP ACTIVE")
151
+
152
+ # Build intent chain
153
+ chain = IntentChain(
154
+ user_id=user_id, llm_model=llm_model,
155
+ node=action.node, domain="",
156
+ )
157
+ if intent_text:
158
+ chain.request(intent_text)
159
+
160
+ # Get tool spec for autonomy check
161
+ spec = self.engine.registry.get_spec(tool)
162
+ if not spec:
163
+ chain.blocked(f"Tool not found: {tool}")
164
+ self.intents.save(chain)
165
+ return Result(status=ActionStatus.FAILED, error=f"Tool not found: {tool}")
166
+
167
+ tool_dangerous = getattr(spec, 'dangerous', False)
168
+
169
+ # Autonomy check
170
+ decision, reason = self.autonomy.check(action, spec.category, tool_dangerous)
171
+ chain.add("autonomy", f"{decision}: {reason}", level=decision)
172
+
173
+ if decision == "deny":
174
+ chain.blocked(reason)
175
+ self.intents.save(chain)
176
+ self.audit.record(
177
+ tool=tool, node=action.node, args=args,
178
+ status="denied", decision=decision,
179
+ user_id=user_id, llm_model=llm_model,
180
+ intent=intent_text, domain=chain.domain,
181
+ )
182
+ return Result(status=ActionStatus.DENIED, error=reason)
183
+
184
+ if decision == "confirm":
185
+ # Create confirmation request
186
+ req_id = f"req-{int(time.time()*1000)}"
187
+ self.autonomy.request_confirmation(req_id, action, reason)
188
+ chain.add("confirm_required", f"Waiting for approval: {reason}", request_id=req_id)
189
+ self.intents.save(chain)
190
+ return Result(
191
+ status=ActionStatus.DENIED,
192
+ error=f"Confirmation required: {reason}",
193
+ data={"request_id": req_id, "reason": reason},
194
+ )
195
+
196
+ # Execute
197
+ chain.action(f"Executing {tool}", tool=tool)
198
+ t0 = time.time()
199
+ result = await self.engine.execute(action)
200
+ duration = (time.time() - t0) * 1000
201
+
202
+ # Record result
203
+ if result.ok:
204
+ chain.result(str(result.data)[:200], success=True)
205
+ else:
206
+ chain.result(f"Failed: {result.error}", success=False)
207
+
208
+ # Save intent + audit
209
+ self.intents.save(chain)
210
+ self.audit.record(
211
+ tool=tool, node=action.node, args=args,
212
+ result=str(result.data or result.error)[:500],
213
+ status="ok" if result.ok else "error",
214
+ duration_ms=duration,
215
+ user_id=user_id, llm_model=llm_model,
216
+ intent=intent_text, domain=chain.domain,
217
+ decision=decision,
218
+ )
219
+
220
+ return result
221
+
222
+ # ─── Node Management ────────────────────────
223
+
224
+ async def connect_node(
225
+ self,
226
+ nrp_id: str,
227
+ driver: NRPDriver,
228
+ require_consent: bool = True,
229
+ ) -> NRPManifest | None:
230
+ """Connect a node with consent check."""
231
+ # Check consent
232
+ if require_consent:
233
+ record = self.consent.check(nrp_id)
234
+ if record is None or record.level == ConsentLevel.PENDING:
235
+ # Request consent
236
+ self.consent.request_consent(nrp_id, "New device")
237
+ log.info("control_plane.consent_pending nrp_id=%s", nrp_id)
238
+ return None
239
+ if record.level == ConsentLevel.DENY:
240
+ log.info("control_plane.consent_denied nrp_id=%s", nrp_id)
241
+ return None
242
+
243
+ # Register via NRP bridge
244
+ manifest = await register_nrp_node(
245
+ self.engine, nrp_id, driver, self.event_bus,
246
+ )
247
+ self._nodes[nrp_id] = driver
248
+ self._manifests[nrp_id] = manifest
249
+
250
+ # Register watchdog
251
+ async def check_node() -> Health:
252
+ try:
253
+ hb = await asyncio.wait_for(driver.heartbeat(), timeout=10)
254
+ return Health.GREEN if hb.get("alive") else Health.RED
255
+ except Exception:
256
+ return Health.RED
257
+ self.watchdog.register(f"node:{nrp_id}", check_node)
258
+
259
+ log.info("control_plane.connected nrp_id=%s tools=%d",
260
+ nrp_id, len(manifest.act) + 4)
261
+ return manifest
262
+
263
+ async def scan(self, config: dict[str, Any] | None = None) -> list[DiscoveredNode]:
264
+ """Scan the network for new devices."""
265
+ nodes = await self.scanner.scan_all(config)
266
+ log.info("control_plane.scan found=%d", len(nodes))
267
+ return nodes
268
+
269
+ # ─── Emergency Stop ─────────────────────────
270
+
271
+ async def emergency_stop(self) -> None:
272
+ """STOP EVERYTHING. Immediately."""
273
+ self._emergency_stop = True
274
+ log.critical("EMERGENCY STOP ACTIVATED")
275
+
276
+ await self.event_bus.emit_simple(
277
+ "halyn.control_plane", "emergency_stop",
278
+ Severity.EMERGENCY,
279
+ )
280
+
281
+ # Tell all nodes to stop
282
+ for nrp_id, driver in self._nodes.items():
283
+ try:
284
+ await driver.act("emergency_stop", {})
285
+ except Exception:
286
+ pass
287
+
288
+ self.audit.record(
289
+ tool="EMERGENCY_STOP", status="executed",
290
+ intent="Emergency stop activated",
291
+ )
292
+
293
+ async def resume(self) -> None:
294
+ """Resume after emergency stop."""
295
+ self._emergency_stop = False
296
+ log.info("control_plane.resumed")
297
+ self.audit.record(tool="RESUME", status="executed")
298
+
299
+ # ─── Status ─────────────────────────────────
300
+
301
+ def status(self) -> dict[str, Any]:
302
+ """Complete system status."""
303
+ return {
304
+ "running": self._running,
305
+ "emergency_stop": self._emergency_stop,
306
+ "nodes": len(self._nodes),
307
+ "tools": len(self.engine.registry.tool_names),
308
+ "audit_entries": self.audit.count,
309
+ "audit_chain_valid": self.audit.verify_chain()[0],
310
+ "pending_consents": self.consent.pending_count(),
311
+ "pending_confirmations": len(self.autonomy.get_pending()),
312
+ "watchdog": self.watchdog.status_report(),
313
+ "event_bus": {
314
+ "total": self.event_bus.total,
315
+ "pending": self.event_bus.pending,
316
+ },
317
+ }
318
+
319
+ # ─── Internal ───────────────────────────────
320
+
321
+ async def _connect_from_config(self, node_cfg: dict[str, Any]) -> None:
322
+ """Connect a node from YAML config."""
323
+ nrp_id = node_cfg.get("id", "")
324
+ driver_type = node_cfg.get("driver", "")
325
+
326
+ if driver_type == "ssh":
327
+ from .drivers.ssh import SSHDriver
328
+ driver = SSHDriver(
329
+ host=node_cfg.get("host", ""),
330
+ user=node_cfg.get("user", ""),
331
+ key_path=node_cfg.get("key", ""),
332
+ )
333
+ elif driver_type == "http_auto":
334
+ from .drivers.http_auto import HTTPAutoDriver
335
+ driver = HTTPAutoDriver(
336
+ base_url=node_cfg.get("base_url", ""),
337
+ auth_token=node_cfg.get("auth_token", ""),
338
+ )
339
+ elif driver_type == "mqtt":
340
+ from .drivers.mqtt import MQTTDriver
341
+ driver = MQTTDriver(
342
+ broker=node_cfg.get("broker", ""),
343
+ topic=node_cfg.get("topic", "#"),
344
+ )
345
+ else:
346
+ log.warning("control_plane.unknown_driver type=%s", driver_type)
347
+ return
348
+
349
+ await self.connect_node(nrp_id, driver, require_consent=False)
350
+
351
+ async def _failsafe(self) -> None:
352
+ """Failsafe handler — called when watchdog detects critical failure."""
353
+ log.critical("FAILSAFE ACTIVATED — putting all nodes in safe mode")
354
+ await self.emergency_stop()
halyn/discovery.py ADDED
@@ -0,0 +1,323 @@
1
+ # Copyright (c) 2026 Elmadani SALKA. All rights reserved.
2
+ # Licensed under the MIT License. See LICENSE file.
3
+ """
4
+ Discovery — Find nodes on the network automatically.
5
+
6
+ Network scanner for NRP-compatible devices.
7
+ Probes SSH, MQTT, HTTP, Docker, and OPC-UA endpoints.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import json
14
+ import logging
15
+ import socket
16
+ import subprocess
17
+ from dataclasses import dataclass, field
18
+ from typing import Any
19
+
20
+ log = logging.getLogger("halyn.discovery")
21
+
22
+
23
+ @dataclass(frozen=True, slots=True)
24
+ class DiscoveredNode:
25
+ """A node found during scan. Not yet connected."""
26
+ address: str # IP or hostname
27
+ port: int = 0
28
+ protocol: str = "" # ssh, mqtt, http, ros2, opcua, docker, mdns
29
+ name: str = "" # Human-readable name if available
30
+ metadata: dict[str, Any] = field(default_factory=dict)
31
+
32
+ @property
33
+ def suggested_nrp_id(self) -> str:
34
+ host = self.address.replace(".", "-")
35
+ kind = self.protocol or "unknown"
36
+ return f"nrp://local/{kind}/{host}"
37
+
38
+
39
+ class Scanner:
40
+ """
41
+ Network scanner. Discovers potential NRP nodes.
42
+
43
+ Probes common ports and protocols to find devices
44
+ that could be connected via NRP drivers.
45
+ """
46
+
47
+ # Common ports and what they indicate
48
+ PORT_MAP: dict[int, str] = {
49
+ 22: "ssh",
50
+ 80: "http",
51
+ 443: "https",
52
+ 1883: "mqtt",
53
+ 8883: "mqtt-tls",
54
+ 4840: "opcua",
55
+ 2375: "docker",
56
+ 2376: "docker-tls",
57
+ 7400: "ros2-dds",
58
+ 8080: "http",
59
+ 8443: "https",
60
+ 8935: "halyn",
61
+ 9090: "prometheus",
62
+ 3000: "grafana",
63
+ 5432: "postgres",
64
+ 6379: "redis",
65
+ 11311: "ros-master",
66
+ }
67
+
68
+ def __init__(self, timeout: float = 1.0, max_concurrent: int = 100) -> None:
69
+ self._timeout = timeout
70
+ self._max_concurrent = max_concurrent
71
+
72
+ async def scan_host(self, host: str, ports: list[int] | None = None) -> list[DiscoveredNode]:
73
+ """Scan a single host for open ports."""
74
+ ports = ports or list(self.PORT_MAP.keys())
75
+ nodes: list[DiscoveredNode] = []
76
+ sem = asyncio.Semaphore(self._max_concurrent)
77
+
78
+ async def probe(port: int) -> DiscoveredNode | None:
79
+ async with sem:
80
+ try:
81
+ _, writer = await asyncio.wait_for(
82
+ asyncio.open_connection(host, port),
83
+ timeout=self._timeout,
84
+ )
85
+ writer.close()
86
+ await writer.wait_closed()
87
+ protocol = self.PORT_MAP.get(port, "tcp")
88
+ return DiscoveredNode(
89
+ address=host, port=port, protocol=protocol,
90
+ name=f"{host}:{port}",
91
+ metadata={"port": port, "protocol": protocol},
92
+ )
93
+ except (asyncio.TimeoutError, ConnectionRefusedError, OSError):
94
+ return None
95
+
96
+ results = await asyncio.gather(*[probe(p) for p in ports])
97
+ return [n for n in results if n is not None]
98
+
99
+ async def scan_subnet(self, subnet: str, ports: list[int] | None = None) -> list[DiscoveredNode]:
100
+ """
101
+ Scan a subnet (e.g. '192.168.1.0/24') for live hosts.
102
+ Uses ping sweep first, then port scan on responders.
103
+ """
104
+ # Parse subnet
105
+ if "/" not in subnet:
106
+ subnet += "/24"
107
+ base, prefix = subnet.rsplit("/", 1)
108
+ prefix = int(prefix)
109
+
110
+ if prefix < 24:
111
+ log.warning("discovery.subnet too large: %s (max /24)", subnet)
112
+ return []
113
+
114
+ # Generate host list
115
+ parts = base.split(".")
116
+ hosts = []
117
+ if prefix == 24:
118
+ for i in range(1, 255):
119
+ hosts.append(f"{parts[0]}.{parts[1]}.{parts[2]}.{i}")
120
+ else:
121
+ hosts = [base]
122
+
123
+ # Ping sweep (fast)
124
+ live_hosts = await self._ping_sweep(hosts)
125
+ log.info("discovery.ping_sweep found=%d/%d", len(live_hosts), len(hosts))
126
+
127
+ # Port scan live hosts
128
+ all_nodes: list[DiscoveredNode] = []
129
+ for host in live_hosts:
130
+ nodes = await self.scan_host(host, ports)
131
+ all_nodes.extend(nodes)
132
+
133
+ return all_nodes
134
+
135
+ async def scan_ssh(self, hosts: list[str], user: str = "", key_path: str = "") -> list[DiscoveredNode]:
136
+ """Quick SSH reachability check on a list of hosts."""
137
+ nodes: list[DiscoveredNode] = []
138
+ for host in hosts:
139
+ try:
140
+ cmd = ["ssh", "-o", "StrictHostKeyChecking=no",
141
+ "-o", "ConnectTimeout=3", "-o", "BatchMode=yes"]
142
+ if key_path:
143
+ cmd += ["-i", key_path]
144
+ target = f"{user}@{host}" if user else host
145
+ cmd += [target, "hostname"]
146
+ r = subprocess.run(cmd, capture_output=True, text=True, timeout=5)
147
+ if r.returncode == 0:
148
+ hostname = r.stdout.strip()
149
+ nodes.append(DiscoveredNode(
150
+ address=host, port=22, protocol="ssh",
151
+ name=hostname,
152
+ metadata={"hostname": hostname, "user": user},
153
+ ))
154
+ except (subprocess.TimeoutExpired, Exception) as e:
155
+ log.debug("discovery.ssh_failed host=%s error=%s", host, e)
156
+ return nodes
157
+
158
+ async def scan_mqtt(self, broker: str = "localhost", port: int = 1883) -> list[DiscoveredNode]:
159
+ """Check if an MQTT broker is reachable and list topics."""
160
+ try:
161
+ _, writer = await asyncio.wait_for(
162
+ asyncio.open_connection(broker, port),
163
+ timeout=self._timeout,
164
+ )
165
+ writer.close()
166
+ await writer.wait_closed()
167
+ return [DiscoveredNode(
168
+ address=broker, port=port, protocol="mqtt",
169
+ name=f"MQTT Broker {broker}",
170
+ metadata={"broker": broker, "port": port},
171
+ )]
172
+ except Exception:
173
+ return []
174
+
175
+ async def scan_docker(self, host: str = "localhost") -> list[DiscoveredNode]:
176
+ """Discover Docker containers on a host."""
177
+ nodes: list[DiscoveredNode] = []
178
+ try:
179
+ cmd = ["docker", "ps", "--format", '{{.Names}}\t{{.Image}}\t{{.Status}}']
180
+ if host != "localhost":
181
+ cmd = ["docker", "-H", f"tcp://{host}:2375"] + cmd[1:]
182
+ r = subprocess.run(cmd, capture_output=True, text=True, timeout=5)
183
+ if r.returncode == 0:
184
+ for line in r.stdout.strip().split("\n"):
185
+ if not line:
186
+ continue
187
+ parts = line.split("\t")
188
+ name = parts[0] if parts else "unknown"
189
+ image = parts[1] if len(parts) > 1 else ""
190
+ status = parts[2] if len(parts) > 2 else ""
191
+ nodes.append(DiscoveredNode(
192
+ address=host, port=2375, protocol="docker",
193
+ name=name,
194
+ metadata={"image": image, "status": status, "container": name},
195
+ ))
196
+ except Exception as e:
197
+ log.debug("discovery.docker_failed host=%s error=%s", host, e)
198
+ return nodes
199
+
200
+ async def scan_http(self, urls: list[str]) -> list[DiscoveredNode]:
201
+ """Check HTTP endpoints for OpenAPI/health."""
202
+ nodes: list[DiscoveredNode] = []
203
+ try:
204
+ import aiohttp
205
+ except ImportError:
206
+ return nodes
207
+
208
+ async with aiohttp.ClientSession() as session:
209
+ for url in urls:
210
+ try:
211
+ async with session.get(url, timeout=aiohttp.ClientTimeout(total=3)) as resp:
212
+ if resp.status < 500:
213
+ # Try to find OpenAPI spec
214
+ has_openapi = False
215
+ for spec_path in ["/openapi.json", "/swagger.json"]:
216
+ try:
217
+ spec_url = url.rstrip("/") + spec_path
218
+ async with session.get(spec_url, timeout=aiohttp.ClientTimeout(total=3)) as sr:
219
+ if sr.status == 200:
220
+ has_openapi = True
221
+ break
222
+ except Exception:
223
+ pass
224
+
225
+ from urllib.parse import urlparse
226
+ parsed = urlparse(url)
227
+ nodes.append(DiscoveredNode(
228
+ address=parsed.hostname or url,
229
+ port=parsed.port or (443 if parsed.scheme == "https" else 80),
230
+ protocol="http",
231
+ name=parsed.hostname or url,
232
+ metadata={"url": url, "status": resp.status,
233
+ "has_openapi": has_openapi},
234
+ ))
235
+ except Exception:
236
+ pass
237
+ return nodes
238
+
239
+ async def scan_all(self, config: dict[str, Any] | None = None) -> list[DiscoveredNode]:
240
+ """
241
+ Run all scanners based on config.
242
+ Returns deduplicated list of discovered nodes.
243
+ """
244
+ config = config or {}
245
+ all_nodes: list[DiscoveredNode] = []
246
+
247
+ # SSH hosts
248
+ ssh_hosts = config.get("ssh_hosts", [])
249
+ if ssh_hosts:
250
+ nodes = await self.scan_ssh(
251
+ ssh_hosts,
252
+ user=config.get("ssh_user", ""),
253
+ key_path=config.get("ssh_key", ""),
254
+ )
255
+ all_nodes.extend(nodes)
256
+ log.info("discovery.ssh found=%d", len(nodes))
257
+
258
+ # Subnet scan
259
+ subnets = config.get("subnets", [])
260
+ for subnet in subnets:
261
+ nodes = await self.scan_subnet(subnet)
262
+ all_nodes.extend(nodes)
263
+ log.info("discovery.subnet %s found=%d", subnet, len(nodes))
264
+
265
+ # MQTT brokers
266
+ mqtt_brokers = config.get("mqtt_brokers", [])
267
+ for broker in mqtt_brokers:
268
+ nodes = await self.scan_mqtt(broker)
269
+ all_nodes.extend(nodes)
270
+
271
+ # Docker
272
+ docker_hosts = config.get("docker_hosts", ["localhost"])
273
+ for host in docker_hosts:
274
+ nodes = await self.scan_docker(host)
275
+ all_nodes.extend(nodes)
276
+ log.info("discovery.docker %s found=%d", host, len(nodes))
277
+
278
+ # HTTP APIs
279
+ http_urls = config.get("http_urls", [])
280
+ if http_urls:
281
+ nodes = await self.scan_http(http_urls)
282
+ all_nodes.extend(nodes)
283
+ log.info("discovery.http found=%d", len(nodes))
284
+
285
+ log.info("discovery.complete total=%d", len(all_nodes))
286
+ return all_nodes
287
+
288
+ async def _ping_sweep(self, hosts: list[str]) -> list[str]:
289
+ """Fast ping sweep using asyncio."""
290
+ live: list[str] = []
291
+ sem = asyncio.Semaphore(50)
292
+
293
+ async def ping(host: str) -> str | None:
294
+ async with sem:
295
+ try:
296
+ proc = await asyncio.create_subprocess_exec(
297
+ "ping", "-c", "1", "-W", "1", host,
298
+ stdout=asyncio.subprocess.DEVNULL,
299
+ stderr=asyncio.subprocess.DEVNULL,
300
+ )
301
+ await asyncio.wait_for(proc.wait(), timeout=2.0)
302
+ return host if proc.returncode == 0 else None
303
+ except Exception:
304
+ return None
305
+
306
+ results = await asyncio.gather(*[ping(h) for h in hosts])
307
+ return [h for h in results if h is not None]
308
+
309
+ def format_results(self, nodes: list[DiscoveredNode]) -> str:
310
+ """Human-readable scan results."""
311
+ if not nodes:
312
+ return "No nodes discovered."
313
+ lines = [f"Discovered {len(nodes)} node(s):\n"]
314
+ for n in nodes:
315
+ meta = ""
316
+ if n.metadata.get("hostname"):
317
+ meta = f" ({n.metadata['hostname']})"
318
+ elif n.metadata.get("has_openapi"):
319
+ meta = " (OpenAPI detected)"
320
+ elif n.metadata.get("container"):
321
+ meta = f" [{n.metadata.get('image', '')}]"
322
+ lines.append(f" {n.suggested_nrp_id:45s} {n.protocol:8s} {n.address}:{n.port}{meta}")
323
+ return "\n".join(lines)
File without changes