aetherruntime-arch 2.0.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.
- aetherruntime/__init__.py +314 -0
- aetherruntime/__version__.py +1 -0
- aetherruntime/cli.py +306 -0
- aetherruntime/containment/__init__.py +39 -0
- aetherruntime/containment/isolation.py +253 -0
- aetherruntime/containment/policy.py +273 -0
- aetherruntime/detection/__init__.py +35 -0
- aetherruntime/detection/alert.py +190 -0
- aetherruntime/detection/classifier.py +298 -0
- aetherruntime/halt/__init__.py +37 -0
- aetherruntime/halt/order.py +248 -0
- aetherruntime/halt/quorum.py +280 -0
- aetherruntime/models.py +489 -0
- aetherruntime/monitoring/__init__.py +33 -0
- aetherruntime/monitoring/baseline.py +282 -0
- aetherruntime/monitoring/telemetry.py +235 -0
- aetherruntime/py.typed +0 -0
- aetherruntime/recovery/__init__.py +37 -0
- aetherruntime/recovery/replay.py +271 -0
- aetherruntime/recovery/snapshot.py +199 -0
- aetherruntime/simulation/__init__.py +37 -0
- aetherruntime/simulation/network.py +216 -0
- aetherruntime/simulation/node.py +341 -0
- aetherruntime/simulation/testbed.py +262 -0
- aetherruntime_arch-2.0.0.dist-info/METADATA +748 -0
- aetherruntime_arch-2.0.0.dist-info/RECORD +30 -0
- aetherruntime_arch-2.0.0.dist-info/WHEEL +5 -0
- aetherruntime_arch-2.0.0.dist-info/entry_points.txt +2 -0
- aetherruntime_arch-2.0.0.dist-info/licenses/LICENSE +26 -0
- aetherruntime_arch-2.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AetherRuntime v2.0.0 — Simulation: Network (Layer 6, simulation only)
|
|
3
|
+
|
|
4
|
+
This module provides a simple peer-to-peer network simulator for
|
|
5
|
+
testing AetherRuntime. It supports:
|
|
6
|
+
- N nodes (agents)
|
|
7
|
+
- Message passing between nodes
|
|
8
|
+
- Network partitions
|
|
9
|
+
- Gossip propagation (for halt orders)
|
|
10
|
+
|
|
11
|
+
This is a simulation, not a production network.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import random
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from enum import Enum
|
|
19
|
+
from typing import Optional
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
23
|
+
# Enums
|
|
24
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class NetworkState(str, Enum):
|
|
28
|
+
"""Network operational state."""
|
|
29
|
+
CONNECTED = "connected"
|
|
30
|
+
PARTITIONED = "partitioned"
|
|
31
|
+
DEGRADED = "degraded"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
35
|
+
# Partition schedule
|
|
36
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class PartitionSchedule:
|
|
41
|
+
"""
|
|
42
|
+
A schedule for network partitions.
|
|
43
|
+
|
|
44
|
+
Example:
|
|
45
|
+
>>> schedule = PartitionSchedule()
|
|
46
|
+
>>> schedule.add_step(step=10, partition=True)
|
|
47
|
+
>>> schedule.add_step(step=20, partition=False)
|
|
48
|
+
"""
|
|
49
|
+
events: list[tuple[int, bool]] = field(default_factory=list)
|
|
50
|
+
|
|
51
|
+
def add_step(self, step: int, partition: bool) -> None:
|
|
52
|
+
"""Add a partition event at a given step."""
|
|
53
|
+
self.events.append((step, partition))
|
|
54
|
+
self.events.sort(key=lambda e: e[0])
|
|
55
|
+
|
|
56
|
+
def state_at(self, step: int) -> bool:
|
|
57
|
+
"""Return True if partitioned at `step`, False otherwise."""
|
|
58
|
+
current = False
|
|
59
|
+
for event_step, is_partition in self.events:
|
|
60
|
+
if event_step <= step:
|
|
61
|
+
current = is_partition
|
|
62
|
+
else:
|
|
63
|
+
break
|
|
64
|
+
return current
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
68
|
+
# Message
|
|
69
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class Message:
|
|
74
|
+
"""A message between two nodes."""
|
|
75
|
+
sender: str
|
|
76
|
+
receiver: str
|
|
77
|
+
payload: dict
|
|
78
|
+
timestamp: str
|
|
79
|
+
message_type: str = "generic"
|
|
80
|
+
|
|
81
|
+
def to_dict(self) -> dict:
|
|
82
|
+
return {
|
|
83
|
+
"sender": self.sender,
|
|
84
|
+
"receiver": self.receiver,
|
|
85
|
+
"payload": self.payload,
|
|
86
|
+
"timestamp": self.timestamp,
|
|
87
|
+
"type": self.message_type,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
92
|
+
# Network
|
|
93
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class Network:
|
|
97
|
+
"""
|
|
98
|
+
A simple peer-to-peer network simulator.
|
|
99
|
+
|
|
100
|
+
Attributes:
|
|
101
|
+
n_nodes: Number of nodes.
|
|
102
|
+
state: Current network state.
|
|
103
|
+
partition_schedule: Schedule of partitions.
|
|
104
|
+
message_queue: Pending messages.
|
|
105
|
+
delivered_messages: Delivered messages (for analysis).
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
def __init__(
|
|
109
|
+
self,
|
|
110
|
+
n_nodes: int,
|
|
111
|
+
partition_schedule: Optional[PartitionSchedule] = None,
|
|
112
|
+
seed: Optional[int] = None,
|
|
113
|
+
) -> None:
|
|
114
|
+
if n_nodes <= 0:
|
|
115
|
+
raise ValueError("n_nodes must be > 0")
|
|
116
|
+
self.n_nodes = n_nodes
|
|
117
|
+
self.node_ids = [f"node-{i:03d}" for i in range(n_nodes)]
|
|
118
|
+
self.state = NetworkState.CONNECTED
|
|
119
|
+
self.partition_schedule = partition_schedule or PartitionSchedule()
|
|
120
|
+
self.message_queue: list[Message] = []
|
|
121
|
+
self.delivered_messages: list[Message] = []
|
|
122
|
+
self.step = 0
|
|
123
|
+
self._rng = random.Random(seed)
|
|
124
|
+
|
|
125
|
+
def step_forward(self) -> None:
|
|
126
|
+
"""Advance the simulation by one step."""
|
|
127
|
+
self.step += 1
|
|
128
|
+
if self.partition_schedule.state_at(self.step):
|
|
129
|
+
self.state = NetworkState.PARTITIONED
|
|
130
|
+
else:
|
|
131
|
+
self.state = NetworkState.CONNECTED
|
|
132
|
+
|
|
133
|
+
def is_partitioned(self) -> bool:
|
|
134
|
+
"""Check if the network is currently partitioned."""
|
|
135
|
+
return self.state == NetworkState.PARTITIONED
|
|
136
|
+
|
|
137
|
+
def broadcast(self, sender: str, payload: dict, message_type: str = "generic") -> int:
|
|
138
|
+
"""
|
|
139
|
+
Broadcast a message from `sender` to all other nodes.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
sender: The sender node ID.
|
|
143
|
+
payload: The message payload.
|
|
144
|
+
message_type: Type of message (for classification).
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
Number of messages queued.
|
|
148
|
+
"""
|
|
149
|
+
if sender not in self.node_ids:
|
|
150
|
+
raise ValueError(f"Unknown sender: {sender}")
|
|
151
|
+
count = 0
|
|
152
|
+
for receiver in self.node_ids:
|
|
153
|
+
if receiver == sender:
|
|
154
|
+
continue
|
|
155
|
+
self.send(sender, receiver, payload, message_type)
|
|
156
|
+
count += 1
|
|
157
|
+
return count
|
|
158
|
+
|
|
159
|
+
def send(
|
|
160
|
+
self,
|
|
161
|
+
sender: str,
|
|
162
|
+
receiver: str,
|
|
163
|
+
payload: dict,
|
|
164
|
+
message_type: str = "generic",
|
|
165
|
+
) -> bool:
|
|
166
|
+
"""
|
|
167
|
+
Send a message from `sender` to `receiver`.
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
True if queued successfully, False if dropped (partition).
|
|
171
|
+
"""
|
|
172
|
+
if sender not in self.node_ids or receiver not in self.node_ids:
|
|
173
|
+
raise ValueError("Unknown sender or receiver")
|
|
174
|
+
if self.is_partitioned():
|
|
175
|
+
# Drop message during partition
|
|
176
|
+
return False
|
|
177
|
+
from datetime import datetime, timezone
|
|
178
|
+
msg = Message(
|
|
179
|
+
sender=sender,
|
|
180
|
+
receiver=receiver,
|
|
181
|
+
payload=payload,
|
|
182
|
+
timestamp=datetime.now(timezone.utc).isoformat(),
|
|
183
|
+
message_type=message_type,
|
|
184
|
+
)
|
|
185
|
+
self.message_queue.append(msg)
|
|
186
|
+
return True
|
|
187
|
+
|
|
188
|
+
def deliver_all(self) -> int:
|
|
189
|
+
"""
|
|
190
|
+
Deliver all queued messages.
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
Number of messages delivered.
|
|
194
|
+
"""
|
|
195
|
+
n = len(self.message_queue)
|
|
196
|
+
self.delivered_messages.extend(self.message_queue)
|
|
197
|
+
self.message_queue.clear()
|
|
198
|
+
return n
|
|
199
|
+
|
|
200
|
+
def pending_count(self) -> int:
|
|
201
|
+
"""Count pending messages."""
|
|
202
|
+
return len(self.message_queue)
|
|
203
|
+
|
|
204
|
+
def delivered_count(self) -> int:
|
|
205
|
+
"""Count delivered messages."""
|
|
206
|
+
return len(self.delivered_messages)
|
|
207
|
+
|
|
208
|
+
def statistics(self) -> dict:
|
|
209
|
+
"""Return network statistics."""
|
|
210
|
+
return {
|
|
211
|
+
"n_nodes": self.n_nodes,
|
|
212
|
+
"step": self.step,
|
|
213
|
+
"state": self.state.value,
|
|
214
|
+
"pending": self.pending_count(),
|
|
215
|
+
"delivered": self.delivered_count(),
|
|
216
|
+
}
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AetherRuntime v2.0.0 — Simulation: Node (Layer 6, simulation only)
|
|
3
|
+
|
|
4
|
+
This module provides a Node class that represents an agent in the
|
|
5
|
+
simulated network. Each node has:
|
|
6
|
+
- A unique agent (from models)
|
|
7
|
+
- A telemetry log (from monitoring)
|
|
8
|
+
- A baseline registry (from monitoring)
|
|
9
|
+
- An alert registry (from detection)
|
|
10
|
+
- A containment manager (from containment)
|
|
11
|
+
- A halt order registry (from halt)
|
|
12
|
+
- A snapshot registry (from recovery)
|
|
13
|
+
|
|
14
|
+
Nodes can:
|
|
15
|
+
- Collect telemetry
|
|
16
|
+
- Update baselines
|
|
17
|
+
- Detect anomalies
|
|
18
|
+
- Contain themselves
|
|
19
|
+
- Halt themselves
|
|
20
|
+
- Recover themselves
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import random
|
|
26
|
+
import secrets
|
|
27
|
+
from typing import Optional
|
|
28
|
+
|
|
29
|
+
from ..models import (
|
|
30
|
+
Agent,
|
|
31
|
+
AgentStatus,
|
|
32
|
+
AnomalyAlert,
|
|
33
|
+
Baseline,
|
|
34
|
+
BaselineModel,
|
|
35
|
+
ContainmentLevel,
|
|
36
|
+
ContainmentPolicy,
|
|
37
|
+
HaltOrder,
|
|
38
|
+
RecoveryRecord,
|
|
39
|
+
Snapshot,
|
|
40
|
+
TelemetryCategory,
|
|
41
|
+
TelemetryRecord,
|
|
42
|
+
create_agent,
|
|
43
|
+
)
|
|
44
|
+
from ..monitoring.telemetry import TelemetryLog, collect
|
|
45
|
+
from ..monitoring.baseline import BaselineRegistry, update_baseline
|
|
46
|
+
from ..detection.alert import AlertRegistry
|
|
47
|
+
from ..detection.classifier import detect
|
|
48
|
+
from ..containment.policy import ContainmentRegistry, contain
|
|
49
|
+
from ..containment.isolation import IsolationManager
|
|
50
|
+
from ..halt.order import HaltOrderRegistry, propose_halt
|
|
51
|
+
from ..halt.quorum import approve_halt, QuorumTracker
|
|
52
|
+
from ..recovery.snapshot import SnapshotRegistry, create_snapshot
|
|
53
|
+
from ..recovery.replay import RecoveryRegistry, recover
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
57
|
+
# Node
|
|
58
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Node:
|
|
62
|
+
"""
|
|
63
|
+
A node in the simulated network.
|
|
64
|
+
|
|
65
|
+
Wraps an Agent with all per-node state:
|
|
66
|
+
- telemetry_log
|
|
67
|
+
- baseline_registry
|
|
68
|
+
- alert_registry
|
|
69
|
+
- containment_registry
|
|
70
|
+
- isolation_manager
|
|
71
|
+
- halt_order_registry
|
|
72
|
+
- snapshot_registry
|
|
73
|
+
- recovery_registry
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
def __init__(
|
|
77
|
+
self,
|
|
78
|
+
agent: Optional[Agent] = None,
|
|
79
|
+
seed: Optional[int] = None,
|
|
80
|
+
anomaly_threshold: float = 2.0,
|
|
81
|
+
) -> None:
|
|
82
|
+
# ─── Agent ───
|
|
83
|
+
if agent is None:
|
|
84
|
+
public_key = secrets.token_bytes(32)
|
|
85
|
+
agent = create_agent(public_key=public_key)
|
|
86
|
+
self.agent = agent
|
|
87
|
+
|
|
88
|
+
# ─── Per-node state ───
|
|
89
|
+
self.telemetry_log = TelemetryLog()
|
|
90
|
+
self.baseline_registry = BaselineRegistry()
|
|
91
|
+
self.alert_registry = AlertRegistry()
|
|
92
|
+
self.containment_registry = ContainmentRegistry()
|
|
93
|
+
self.isolation_manager = IsolationManager()
|
|
94
|
+
self.halt_order_registry = HaltOrderRegistry()
|
|
95
|
+
self.snapshot_registry = SnapshotRegistry()
|
|
96
|
+
self.recovery_registry = RecoveryRegistry()
|
|
97
|
+
|
|
98
|
+
# ─── Configuration ───
|
|
99
|
+
self.anomaly_threshold = anomaly_threshold
|
|
100
|
+
self._rng = random.Random(seed)
|
|
101
|
+
|
|
102
|
+
# ─── Identity ───
|
|
103
|
+
@property
|
|
104
|
+
def node_id(self) -> str:
|
|
105
|
+
return self.agent.agent_id
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def status(self) -> AgentStatus:
|
|
109
|
+
return self.agent.status
|
|
110
|
+
|
|
111
|
+
@status.setter
|
|
112
|
+
def status(self, value: AgentStatus) -> None:
|
|
113
|
+
self.agent.status = value
|
|
114
|
+
|
|
115
|
+
# ─── Monitoring ───
|
|
116
|
+
def start(self) -> None:
|
|
117
|
+
"""Start the node (set status to running)."""
|
|
118
|
+
self.agent.status = AgentStatus.RUNNING
|
|
119
|
+
|
|
120
|
+
def collect_telemetry(
|
|
121
|
+
self,
|
|
122
|
+
category: TelemetryCategory,
|
|
123
|
+
value: float,
|
|
124
|
+
metadata: Optional[dict] = None,
|
|
125
|
+
) -> TelemetryRecord:
|
|
126
|
+
"""Collect a telemetry record."""
|
|
127
|
+
previous = self.telemetry_log.last_timestamp(self.agent.agent_id)
|
|
128
|
+
record = collect(
|
|
129
|
+
agent=self.agent,
|
|
130
|
+
category=category,
|
|
131
|
+
value=value,
|
|
132
|
+
previous_timestamp=previous,
|
|
133
|
+
metadata=metadata,
|
|
134
|
+
)
|
|
135
|
+
self.telemetry_log.append(record)
|
|
136
|
+
return record
|
|
137
|
+
|
|
138
|
+
def update_baseline(
|
|
139
|
+
self,
|
|
140
|
+
category: TelemetryCategory,
|
|
141
|
+
model_type: BaselineModel = BaselineModel.MEAN_STD,
|
|
142
|
+
window_size: int = 10,
|
|
143
|
+
baseline_window: Optional[int] = None,
|
|
144
|
+
) -> Baseline:
|
|
145
|
+
"""
|
|
146
|
+
Update the baseline for a category.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
category: Telemetry category.
|
|
150
|
+
model_type: Model type.
|
|
151
|
+
window_size: Minimum window size (for validation).
|
|
152
|
+
baseline_window: If provided, use only the FIRST `baseline_window`
|
|
153
|
+
telemetry records (assumed normal). Default: all records.
|
|
154
|
+
"""
|
|
155
|
+
all_records = self.telemetry_log.get(self.agent.agent_id)
|
|
156
|
+
if baseline_window is not None:
|
|
157
|
+
all_records = all_records[:baseline_window]
|
|
158
|
+
|
|
159
|
+
baseline = update_baseline(
|
|
160
|
+
agent=self.agent,
|
|
161
|
+
category=category,
|
|
162
|
+
model_type=model_type,
|
|
163
|
+
window_size=window_size,
|
|
164
|
+
telemetry_records=all_records,
|
|
165
|
+
existing_baseline=self.baseline_registry.get(
|
|
166
|
+
self.agent.agent_id, category
|
|
167
|
+
),
|
|
168
|
+
)
|
|
169
|
+
# Register the baseline so detect_anomaly can find it
|
|
170
|
+
self.baseline_registry.put(baseline)
|
|
171
|
+
return baseline
|
|
172
|
+
|
|
173
|
+
# ─── Detection ───
|
|
174
|
+
def detect_anomaly(
|
|
175
|
+
self,
|
|
176
|
+
record: TelemetryRecord,
|
|
177
|
+
) -> Optional[AnomalyAlert]:
|
|
178
|
+
"""Detect an anomaly in a telemetry record."""
|
|
179
|
+
baseline = self.baseline_registry.get(
|
|
180
|
+
self.agent.agent_id, record.category
|
|
181
|
+
)
|
|
182
|
+
if baseline is None:
|
|
183
|
+
return None
|
|
184
|
+
alert = detect(record, baseline, self.anomaly_threshold)
|
|
185
|
+
if alert is not None:
|
|
186
|
+
self.alert_registry.put(alert)
|
|
187
|
+
self.agent.status = AgentStatus.ANOMALOUS
|
|
188
|
+
return alert
|
|
189
|
+
|
|
190
|
+
# ─── Containment ───
|
|
191
|
+
def contain(
|
|
192
|
+
self,
|
|
193
|
+
alert: AnomalyAlert,
|
|
194
|
+
level: ContainmentLevel = ContainmentLevel.L1_THROTTLE,
|
|
195
|
+
expiry_seconds: int = 300,
|
|
196
|
+
) -> ContainmentPolicy:
|
|
197
|
+
"""Contain the node."""
|
|
198
|
+
existing = self.containment_registry.get_by_agent(self.agent.agent_id)
|
|
199
|
+
existing_policy = existing[-1] if existing else None
|
|
200
|
+
policy = contain(
|
|
201
|
+
agent=self.agent,
|
|
202
|
+
alert=alert,
|
|
203
|
+
level=level,
|
|
204
|
+
expiry_seconds=expiry_seconds,
|
|
205
|
+
existing_policy=existing_policy,
|
|
206
|
+
)
|
|
207
|
+
self.containment_registry.put(policy)
|
|
208
|
+
self.isolation_manager.apply(policy)
|
|
209
|
+
return policy
|
|
210
|
+
|
|
211
|
+
def release(self) -> None:
|
|
212
|
+
"""Release the node from containment."""
|
|
213
|
+
self.isolation_manager.release(self.agent.agent_id)
|
|
214
|
+
|
|
215
|
+
# ─── Halt ───
|
|
216
|
+
def propose_halt(
|
|
217
|
+
self,
|
|
218
|
+
target: "Node",
|
|
219
|
+
reason: str,
|
|
220
|
+
quorum_threshold: float = 0.67,
|
|
221
|
+
) -> HaltOrder:
|
|
222
|
+
"""Propose a halt order for a target node."""
|
|
223
|
+
signature = secrets.token_bytes(64)
|
|
224
|
+
order = propose_halt(
|
|
225
|
+
agent=target.agent,
|
|
226
|
+
proposer=self.agent,
|
|
227
|
+
proposer_signature=signature,
|
|
228
|
+
reason=reason,
|
|
229
|
+
quorum_threshold=quorum_threshold,
|
|
230
|
+
)
|
|
231
|
+
self.halt_order_registry.put(order)
|
|
232
|
+
return order
|
|
233
|
+
|
|
234
|
+
def approve_halt(
|
|
235
|
+
self,
|
|
236
|
+
order: HaltOrder,
|
|
237
|
+
total_authorized_nodes: int,
|
|
238
|
+
) -> None:
|
|
239
|
+
"""Approve a halt order."""
|
|
240
|
+
signature = secrets.token_bytes(64)
|
|
241
|
+
approve_halt(
|
|
242
|
+
halt_order=order,
|
|
243
|
+
approver=self.agent,
|
|
244
|
+
approver_signature=signature,
|
|
245
|
+
total_authorized_nodes=total_authorized_nodes,
|
|
246
|
+
)
|
|
247
|
+
if order.is_enforced():
|
|
248
|
+
target_id = order.agent_id
|
|
249
|
+
if target_id == self.agent.agent_id:
|
|
250
|
+
self.agent.status = AgentStatus.HALTED
|
|
251
|
+
|
|
252
|
+
# ─── Recovery ───
|
|
253
|
+
def snapshot(self, state: Optional[bytes] = None) -> Snapshot:
|
|
254
|
+
"""Take a snapshot of the node's state."""
|
|
255
|
+
if state is None:
|
|
256
|
+
state = f"state-of-{self.agent.agent_id}".encode("utf-8")
|
|
257
|
+
snapshot = create_snapshot(self.agent, state)
|
|
258
|
+
self.snapshot_registry.put(snapshot)
|
|
259
|
+
return snapshot
|
|
260
|
+
|
|
261
|
+
def recover(
|
|
262
|
+
self,
|
|
263
|
+
halt_order: HaltOrder,
|
|
264
|
+
snapshot: Snapshot,
|
|
265
|
+
reauthorizer: "Node",
|
|
266
|
+
) -> RecoveryRecord:
|
|
267
|
+
"""Recover the node from a halt."""
|
|
268
|
+
record = recover(
|
|
269
|
+
agent=self.agent,
|
|
270
|
+
halt_order=halt_order,
|
|
271
|
+
snapshot=snapshot,
|
|
272
|
+
replay_log=[],
|
|
273
|
+
reauthorizer=reauthorizer.agent,
|
|
274
|
+
)
|
|
275
|
+
self.recovery_registry.put(record)
|
|
276
|
+
return record
|
|
277
|
+
|
|
278
|
+
# ─── Statistics ───
|
|
279
|
+
def stats(self) -> dict:
|
|
280
|
+
"""Return node statistics."""
|
|
281
|
+
return {
|
|
282
|
+
"agent_id": self.agent.agent_id,
|
|
283
|
+
"status": self.agent.status.value,
|
|
284
|
+
"telemetry_count": self.telemetry_log.count(self.agent.agent_id),
|
|
285
|
+
"baseline_count": len(
|
|
286
|
+
self.baseline_registry.get_all(self.agent.agent_id)
|
|
287
|
+
),
|
|
288
|
+
"alert_count": self.alert_registry.count_by_agent(
|
|
289
|
+
self.agent.agent_id
|
|
290
|
+
),
|
|
291
|
+
"containment_count": len(
|
|
292
|
+
self.containment_registry.get_by_agent(self.agent.agent_id)
|
|
293
|
+
),
|
|
294
|
+
"halt_count": len(
|
|
295
|
+
self.halt_order_registry.get_by_agent(self.agent.agent_id)
|
|
296
|
+
),
|
|
297
|
+
"snapshot_count": self.snapshot_registry.count_by_agent(
|
|
298
|
+
self.agent.agent_id
|
|
299
|
+
),
|
|
300
|
+
"recovery_count": len(
|
|
301
|
+
self.recovery_registry.get_by_agent(self.agent.agent_id)
|
|
302
|
+
),
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
def __repr__(self) -> str:
|
|
306
|
+
return (
|
|
307
|
+
f"Node(id={self.agent.agent_id[:8]}..., "
|
|
308
|
+
f"status={self.agent.status.value})"
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
313
|
+
# Cluster factory
|
|
314
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def create_cluster(
|
|
318
|
+
n_nodes: int,
|
|
319
|
+
seed: Optional[int] = None,
|
|
320
|
+
anomaly_threshold: float = 2.0,
|
|
321
|
+
) -> list[Node]:
|
|
322
|
+
"""
|
|
323
|
+
Create a cluster of N nodes.
|
|
324
|
+
|
|
325
|
+
Args:
|
|
326
|
+
n_nodes: Number of nodes.
|
|
327
|
+
seed: Random seed (for reproducibility).
|
|
328
|
+
anomaly_threshold: Threshold for anomaly detection.
|
|
329
|
+
|
|
330
|
+
Returns:
|
|
331
|
+
List of Node instances.
|
|
332
|
+
"""
|
|
333
|
+
if n_nodes <= 0:
|
|
334
|
+
raise ValueError("n_nodes must be > 0")
|
|
335
|
+
nodes = []
|
|
336
|
+
for i in range(n_nodes):
|
|
337
|
+
node_seed = (seed + i) if seed is not None else None
|
|
338
|
+
node = Node(seed=node_seed, anomaly_threshold=anomaly_threshold)
|
|
339
|
+
node.start()
|
|
340
|
+
nodes.append(node)
|
|
341
|
+
return nodes
|