Skip to content

Commit bce8fda

Browse files
fix: validate summary-backed NetMon control paths (#437)
* fix: validate summary-backed NetMon control paths What changed: - add local NetMon API probe and comms testbed validation coverage - use control-liveness APIs at edge call sites that depend on summary-backed visibility - fail closed on unknown supervisor state during Deeploy target selection Why: - normal nodes need safe command/config visibility from summaries without treating summaries as heartbeat evidence * Bump version from 2.10.312 to 2.10.313
1 parent e3b25c4 commit bce8fda

9 files changed

Lines changed: 525 additions & 22 deletions

docker-compose_comms.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ x-edge-comms-node: &edge-comms-node
2929
EE_MQTT_COMMAND_QOS: "2"
3030
EE_NETMON_ORACLE_ONLY_HEARTBEAT_MODE: "1"
3131
EE_ENABLE_NETMON_API_PROBE: "1"
32+
# The comms testbed does not run child workloads, so DinD TLS only adds a
33+
# local certificate-validity dependency that can stop worker startup.
34+
DOCKER_TLS_CERTDIR: ""
3235
# Only the isolated non-EVM comms testbed may trust supervisor-marked
3336
# NET_MON_01 summaries without a blockchain oracle registry.
3437
EE_NETMON_ACCEPT_LOCAL_SUPERVISOR_SUMMARY: "1"

extensions/business/deeploy/deeploy_mixin.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,19 @@ def _normalize_node_specs_address(self, node_addr):
8787
return raw_addr if raw_addr.startswith("0xai_") else f"0xai_{raw_addr}"
8888

8989

90+
def _netmon_node_is_online_for_control(self, node_addr):
91+
"""
92+
Use NetMon's explicit command/control liveness predicate when available.
93+
94+
Summary-backed liveness is only a preflight for control/deploy requests; the
95+
actual mutation still depends on command dispatch and response handling.
96+
"""
97+
checker = getattr(self.netmon, "network_node_is_online_for_control", None)
98+
if callable(checker):
99+
return checker(node_addr)
100+
return self.netmon.network_node_is_online(node_addr)
101+
102+
90103
def _get_node_specs(self, target_nodes):
91104
"""
92105
Return total and live-available CPU, memory, and disk specs for nodes.
@@ -107,7 +120,7 @@ def _get_node_specs(self, target_nodes):
107120
try:
108121
result[node_addr] = {
109122
"node_alias": self.netmon.network_node_eeid(node_addr),
110-
"node_is_online": self.netmon.network_node_is_online(node_addr),
123+
"node_is_online": self._netmon_node_is_online_for_control(node_addr),
111124
"cpu": {
112125
"total": self._node_specs_number(self.netmon.network_node_total_cpu_cores(node_addr)),
113126
"available": self._node_specs_number(self.netmon.network_node_avail_cpu_cores(node_addr)),

extensions/business/deeploy/deeploy_target_nodes_mixin.py

Lines changed: 112 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import math
2+
13
from naeural_core.main.net_mon import NetMonCt
24

35
from extensions.business.deeploy.deeploy_const import (
@@ -73,6 +75,89 @@ def _parse_memory(self, mem):
7375
else:
7476
return int(float(mem)) # assume bytes
7577

78+
def _parse_node_telemetry_cpu(self, value):
79+
"""
80+
Parse node CPU telemetry. Missing or malformed node telemetry must fail
81+
closed as zero capacity instead of crashing deployment preflight.
82+
"""
83+
try:
84+
cpu = self._parse_cpu_value(value, default=0.0)
85+
except ValueError:
86+
return 0.0
87+
if cpu is None or not math.isfinite(cpu) or cpu < 0:
88+
return 0.0
89+
return cpu
90+
91+
def _parse_node_telemetry_memory_bytes(self, value):
92+
"""
93+
Parse node memory telemetry. NetMon reports memory in GB, but mixed-version
94+
or malformed summaries must fail closed as zero capacity, not abort deploy.
95+
"""
96+
try:
97+
memory_bytes = self._parse_memory(value)
98+
except (AttributeError, OverflowError, TypeError, ValueError):
99+
return 0
100+
if not math.isfinite(memory_bytes) or memory_bytes < 0:
101+
return 0
102+
return int(memory_bytes)
103+
104+
def _parse_node_telemetry_bytes(self, value):
105+
"""
106+
Parse byte-valued node telemetry with the same fail-closed policy.
107+
"""
108+
try:
109+
parsed = float(value)
110+
except (TypeError, ValueError):
111+
return 0
112+
if not math.isfinite(parsed) or parsed < 0:
113+
return 0
114+
return int(parsed)
115+
116+
def _parse_node_telemetry_bool(self, value):
117+
"""
118+
Parse boolean node telemetry with a fail-closed policy. This protects
119+
Deeploy against mixed-version NetMon summaries returning "false"/"0" as
120+
strings, which Python would otherwise treat as truthy.
121+
"""
122+
if isinstance(value, bool):
123+
return value
124+
if isinstance(value, str):
125+
value = value.strip().lower()
126+
if value in {"1", "true", "yes", "y"}:
127+
return True
128+
if value in {"0", "false", "no", "n", ""}:
129+
return False
130+
return False
131+
if isinstance(value, (int, float)) and not isinstance(value, bool):
132+
return math.isfinite(value) and value == 1
133+
return False
134+
135+
136+
def _parse_node_supervisor_state(self, value):
137+
"""
138+
Parse supervisor telemetry for target selection.
139+
140+
Unlike generic capability booleans, unknown supervisor state must not be
141+
coerced to False: deploying to a supervisor is unsafe, so missing/malformed
142+
summary data is treated as "unknown" and filtered out by callers.
143+
"""
144+
if isinstance(value, bool):
145+
return value
146+
if isinstance(value, str):
147+
value = value.strip().lower()
148+
if value == "":
149+
return None
150+
if value in {"1", "true", "yes", "y"}:
151+
return True
152+
if value in {"0", "false", "no", "n"}:
153+
return False
154+
return None
155+
if isinstance(value, (int, float)) and not isinstance(value, bool):
156+
if math.isfinite(value) and value in (0, 1):
157+
return bool(value)
158+
return None
159+
return None
160+
76161
def _get_request_plugin_signatures_from_pipeline(self, inputs):
77162
"""
78163
Extract plugin signatures from normalized request payload.
@@ -268,14 +353,16 @@ def __find_suitable_nodes_for_container_app(self, nodes_with_resources, containe
268353
self.Pd(f"Node {addr} projected usage (with new app): CPU={used_cpu} cores, Memory={used_memory} bytes")
269354

270355
# Check if the node has enough resources
271-
self.Pd(f"Node {addr} total resources: CPU={node_resources['cpu']} cores, Memory={node_resources['memory']} bytes")
356+
node_cpu = self._parse_node_telemetry_cpu(node_resources.get('cpu'))
357+
node_memory = self._parse_node_telemetry_bytes(node_resources.get('memory'))
358+
self.Pd(f"Node {addr} total resources: CPU={node_cpu} cores, Memory={node_memory} bytes")
272359
has_failed = False
273-
if used_cpu > node_resources['cpu']:
274-
self.Pd(f"Node {addr} has not enough CPU cores. used_cpu ({used_cpu}) > node_cpu ({node_resources['cpu']})")
360+
if used_cpu > node_cpu:
361+
self.Pd(f"Node {addr} has not enough CPU cores. used_cpu ({used_cpu}) > node_cpu ({node_cpu})")
275362
has_failed = True
276363

277-
if used_memory > node_resources['memory']:
278-
self.Pd(f"Node {addr} has not enough RAM. used_memory ({used_memory}) > node_memory ({node_resources['memory']})")
364+
if used_memory > node_memory:
365+
self.Pd(f"Node {addr} has not enough RAM. used_memory ({used_memory}) > node_memory ({node_memory})")
279366
has_failed = True
280367

281368
if has_failed:
@@ -371,7 +458,7 @@ def __check_nodes_capabilities_and_extract_resources(self, nodes: list['str'], i
371458
for addr in nodes:
372459
# Check if the node supports the requested plugin
373460
if requires_container_capabilities:
374-
is_did_supported = self.netmon.network_node_has_did(addr=addr)
461+
is_did_supported = self._parse_node_telemetry_bool(self.netmon.network_node_has_did(addr=addr))
375462
if not is_did_supported:
376463
self.Pd(f"Node {addr} does not support container deployments. Skipping...")
377464
continue
@@ -383,23 +470,24 @@ def __check_nodes_capabilities_and_extract_resources(self, nodes: list['str'], i
383470
continue
384471

385472
self.Pd(f"Node {addr} cont in function.")
386-
total_cpu = self.netmon.network_node_total_cpu_cores(addr)
473+
total_cpu = self._parse_node_telemetry_cpu(self.netmon.network_node_total_cpu_cores(addr))
387474

388475
total_memory = self.netmon.network_node_total_mem(addr)
389-
total_memory_bytes = self._parse_memory(total_memory)
476+
total_memory_bytes = self._parse_node_telemetry_memory_bytes(total_memory)
390477

391478
current_node_total_resources = {
392479
'cpu': total_cpu,
393480
'memory': total_memory_bytes,
394481
}
395482

396483
if node_res_req:
484+
node_req_cpu = self._parse_cpu_value(node_req_cpu, default=None)
397485

398-
if total_cpu < node_req_cpu:
486+
if node_req_cpu is not None and total_cpu < node_req_cpu:
399487
self.Pd(f"Node {addr} has not enough CPU cores in total. Skipping...")
400488
continue
401489

402-
if total_memory_bytes < node_req_memory_bytes:
490+
if node_req_memory_bytes > 0 and total_memory_bytes < node_req_memory_bytes:
403491
self.Pd(f"Node {addr} has not enought RAM in total. Skipping...")
404492
continue
405493

@@ -426,8 +514,9 @@ def _find_nodes_for_deeployment(self, inputs):
426514
for addr, value in network_nodes.items():
427515
ai_addr = self.bc.maybe_add_prefix(addr)
428516

429-
is_online = self.netmon.network_node_is_online(ai_addr)
430-
if value.get('is_supervisor') is True or not is_online:
517+
is_online = self._netmon_node_is_online_for_control(ai_addr)
518+
is_supervisor = self._parse_node_supervisor_state(self.netmon.network_node_is_supervisor(ai_addr))
519+
if is_supervisor is not False or not is_online:
431520
# FIXME: Disabled for now, as the most of the nodes are are marked as non-trusted.
432521
# if value.get('is_supervisor') is True or not value.get('trusted', False) or not is_online:
433522
continue
@@ -537,11 +626,11 @@ def _check_nodes_availability(self, inputs, skip_resource_check=False):
537626

538627
for node in inputs.target_nodes:
539628
addr = self._check_and_maybe_convert_address(node)
540-
is_supervisor = self.netmon.network_node_is_supervisor(addr=addr)
541-
if is_supervisor:
542-
msg = f"{DEEPLOY_ERRORS.NODES6}: Node {addr} is a supervisor node and cannot be used for deeployment"
629+
is_supervisor = self._parse_node_supervisor_state(self.netmon.network_node_is_supervisor(addr=addr))
630+
if is_supervisor is not False:
631+
msg = f"{DEEPLOY_ERRORS.NODES6}: Node {addr} is a supervisor node or has unknown supervisor state and cannot be used for deeployment"
543632
raise ValueError(msg)
544-
is_online = self.netmon.network_node_is_online(addr)
633+
is_online = self._netmon_node_is_online_for_control(addr)
545634
if is_online:
546635
if skip_resources:
547636
self.Pd(f"Skipping resource validation for node {addr}")
@@ -588,15 +677,18 @@ def check_node_available_resources(self, addr, inputs):
588677
return result
589678

590679
# Get available resources
591-
avail_cpu = self.netmon.network_node_get_cpu_avail_cores(addr)
680+
avail_cpu = self._parse_node_telemetry_cpu(self.netmon.network_node_get_cpu_avail_cores(addr))
592681
avail_mem = self.netmon.network_node_available_memory(addr) # in GB
593-
avail_mem_bytes = self._parse_memory(f"{avail_mem}g")
594-
avail_disk = self.netmon.network_node_available_disk(addr) # in bytes
682+
avail_mem_bytes = self._parse_node_telemetry_memory_bytes(avail_mem)
683+
avail_disk = self._parse_node_telemetry_bytes(self.netmon.network_node_available_disk(addr)) # in bytes
595684

596685
# Get required resources from the request
597686
required_resources = self._aggregate_container_resources(inputs) or {}
598687
required_mem = required_resources.get(DEEPLOY_RESOURCES.MEMORY, DEFAULT_CONTAINER_RESOURCES.MEMORY)
599-
required_cpu = required_resources.get(DEEPLOY_RESOURCES.CPU, DEFAULT_CONTAINER_RESOURCES.CPU)
688+
required_cpu = self._parse_cpu_value(
689+
required_resources.get(DEEPLOY_RESOURCES.CPU, DEFAULT_CONTAINER_RESOURCES.CPU),
690+
default=DEFAULT_CONTAINER_RESOURCES.CPU,
691+
)
600692

601693
required_mem_bytes = self._parse_memory(required_mem)
602694

extensions/business/deeploy/tests/test_node_specs.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,27 @@ def __init__(self):
2121
"0xai_node_beta": {
2222
"alias": "beta",
2323
"online": False,
24+
"control_online": False,
2425
"cpu_total": 4,
2526
"cpu_avail": 1,
2627
"mem_total": 16,
2728
"mem_avail": 8,
2829
"disk_total": 256,
2930
"disk_avail": 120,
3031
},
32+
"0xai_node_gamma": {
33+
"alias": "gamma",
34+
"online": False,
35+
"control_online": True,
36+
"cpu_total": 12,
37+
"cpu_avail": 9,
38+
"mem_total": 64,
39+
"mem_avail": 40,
40+
"disk_total": 1024,
41+
"disk_avail": 900,
42+
},
3143
}
44+
self.nodes["0xai_node_alpha"]["control_online"] = self.nodes["0xai_node_alpha"]["online"]
3245

3346
def _get(self, addr, key):
3447
if addr not in self.nodes:
@@ -41,6 +54,9 @@ def network_node_eeid(self, addr):
4154
def network_node_is_online(self, addr):
4255
return self._get(addr, "online")
4356

57+
def network_node_is_online_for_control(self, addr):
58+
return self._get(addr, "control_online")
59+
4460
def network_node_total_cpu_cores(self, addr):
4561
return self._get(addr, "cpu_total")
4662

@@ -84,6 +100,18 @@ def test_builds_node_specs_for_unique_requested_nodes(self):
84100
)
85101
self.assertEqual(specs["0xai_node_beta"]["node_is_online"], False)
86102

103+
def test_node_specs_use_control_liveness_for_summary_backed_nodes(self):
104+
plugin = make_deeploy_plugin()
105+
plugin.netmon = _NetmonStub()
106+
plugin.bc = SimpleNamespace(
107+
maybe_add_prefix=lambda addr: addr if str(addr).startswith("0xai_") else f"0xai_{addr}"
108+
)
109+
110+
specs = plugin._get_node_specs(["node_gamma"])
111+
112+
self.assertFalse(plugin.netmon.network_node_is_online("0xai_node_gamma"))
113+
self.assertTrue(specs["0xai_node_gamma"]["node_is_online"])
114+
87115
def test_returns_per_node_error_without_failing_entire_specs_request(self):
88116
plugin = make_deeploy_plugin()
89117
plugin.netmon = _NetmonStub()

0 commit comments

Comments
 (0)