Skip to content

Commit dedc969

Browse files
committed
Add simulate subcommand to network survey script
This change adds a `simulate` command to the network survey script that takes a network topology in graphml format (such as the one produced by the `survey` command) and simulates a survey of said network. It is intended as a tool to use during development of the script itself to test the script without running a survey on the network, and should make it easier to address issues such as #2592. This is an initial version of the `simulate` command that will evolve as we need to test the script under additional scenarios.
1 parent cb2a373 commit dedc969

File tree

4 files changed

+210
-21
lines changed

4 files changed

+210
-21
lines changed

.gitignore

+2
Original file line numberDiff line numberDiff line change
@@ -110,3 +110,5 @@ min-testcases/
110110
/src/util/xdrquery/XDRQueryParser.h
111111
/src/util/xdrquery/XDRQueryParser.cpp
112112
/src/util/xdrquery/stack.hh
113+
114+
__pycache__

scripts/OverlaySurvey.py

100644100755
+64-21
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,17 @@
6060
import sys
6161
import time
6262

63+
import overlay_survey.simulation as sim
64+
65+
# A SurveySimulation, if running in simulation mode, or None otherwise.
66+
SIMULATION = None
67+
68+
def get_request(url, params=None):
69+
""" Make a GET request, or simulate one if running in simulation mode. """
70+
if SIMULATION:
71+
return SIMULATION.get(url=url, params=params)
72+
else:
73+
return requests.get(url=url, params=params)
6374

6475
def next_peer(direction_tag, node_info):
6576
if direction_tag in node_info and node_info[direction_tag]:
@@ -140,7 +151,7 @@ def send_requests(peer_list, params, request_url):
140151
# Submit `limit` queries roughly every ledger
141152
for key in peer_list:
142153
params["node"] = key
143-
requests.get(url=request_url, params=params)
154+
get_request(url=request_url, params=params)
144155
print("Send request to %s" % key)
145156
global request_count
146157
request_count += 1
@@ -224,8 +235,8 @@ def get_tier1_stats(augmented_directed_graph):
224235

225236
def augment(args):
226237
graph = nx.read_graphml(args.graphmlInput)
227-
data = requests.get("https://api.stellarbeat.io/v1/nodes").json()
228-
transitive_quorum = requests.get(
238+
data = get_request("https://api.stellarbeat.io/v1/nodes").json()
239+
transitive_quorum = get_request(
229240
"https://api.stellarbeat.io/v1/").json()["transitiveQuorumSet"]
230241

231242
for obj in data:
@@ -273,6 +284,13 @@ def run_survey(args):
273284
"inboundPeers": {},
274285
"outboundPeers": {}
275286
})
287+
if args.simulate:
288+
global SIMULATION
289+
try:
290+
SIMULATION = sim.SurveySimulation(args.simGraph, args.simRoot)
291+
except sim.SimulationError as e:
292+
print(f"Error: {e}")
293+
sys.exit(1)
276294

277295
url = args.node
278296

@@ -285,7 +303,7 @@ def run_survey(args):
285303
params = {'duration': duration}
286304

287305
# reset survey
288-
requests.get(url=stop_survey)
306+
get_request(url=stop_survey)
289307

290308
peer_list = set()
291309
if args.nodeList:
@@ -296,7 +314,7 @@ def run_survey(args):
296314

297315
peers_params = {'fullkeys': "true"}
298316

299-
peers = requests.get(url=peers, params=peers_params).json()[
317+
peers = get_request(url=peers, params=peers_params).json()[
300318
"authenticated_peers"]
301319

302320
# seed initial peers off of /peers endpoint
@@ -307,9 +325,10 @@ def run_survey(args):
307325
for peer in peers["outbound"]:
308326
peer_list.add(peer["id"])
309327

310-
self_name = requests.get(url + "/scp?limit=0&fullkeys=true").json()["you"]
328+
scp_params = {'fullkeys': "true", 'limit': 0}
329+
self_name = get_request(url + "/scp", scp_params).json()["you"]
311330
graph.add_node(self_name,
312-
version=requests.get(url + "/info").json()["info"]["build"],
331+
version=get_request(url + "/info").json()["info"]["build"],
313332
numTotalInboundPeers=len(peers["inbound"] or []),
314333
numTotalOutboundPeers=len(peers["outbound"] or []))
315334

@@ -328,7 +347,7 @@ def run_survey(args):
328347
time.sleep(1)
329348

330349
print("Fetching survey result")
331-
data = requests.get(url=survey_result).json()
350+
data = get_request(url=survey_result).json()
332351
print("Done")
333352

334353
if "topology" in data:
@@ -366,6 +385,12 @@ def run_survey(args):
366385
print("New peers: %s Retrying: %s" %
367386
(new_peers, len(peer_list)-new_peers))
368387

388+
# sanity check that simulation produced a graph isomorphic to the input
389+
assert (not args.simulate or
390+
nx.is_isomorphic(graph, nx.read_graphml(args.simGraph))), \
391+
("Simulation produced a graph that is not isomorphic to the input "
392+
"graph")
393+
369394
if nx.is_empty(graph):
370395
print("Graph is empty!")
371396
sys.exit(0)
@@ -395,19 +420,8 @@ def flatten(args):
395420
json.dump(output_graph, output_file)
396421
sys.exit(0)
397422

398-
399-
def main():
400-
# construct the argument parse and parse the arguments
401-
argument_parser = argparse.ArgumentParser()
402-
argument_parser.add_argument("-gs",
403-
"--graphStats",
404-
help="output file for graph stats")
405-
406-
subparsers = argument_parser.add_subparsers()
407-
408-
parser_survey = subparsers.add_parser('survey',
409-
help="run survey and "
410-
"analyze results")
423+
def init_parser_survey(parser_survey):
424+
"""Initialize the `survey` subcommand"""
411425
parser_survey.add_argument("-n",
412426
"--node",
413427
required=True,
@@ -429,6 +443,35 @@ def main():
429443
help="optional list of seed nodes")
430444
parser_survey.set_defaults(func=run_survey)
431445

446+
def main():
447+
# construct the argument parse and parse the arguments
448+
argument_parser = argparse.ArgumentParser()
449+
argument_parser.add_argument("-gs",
450+
"--graphStats",
451+
help="output file for graph stats")
452+
453+
subparsers = argument_parser.add_subparsers()
454+
455+
parser_survey = subparsers.add_parser('survey',
456+
help="run survey and "
457+
"analyze results")
458+
parser_survey.set_defaults(simulate=False)
459+
init_parser_survey(parser_survey)
460+
parser_simulate = subparsers.add_parser('simulate',
461+
help="simulate survey run")
462+
# `simulate` supports all arguments that `survey` does, plus some additional
463+
# arguments for the simulation itself.
464+
init_parser_survey(parser_simulate)
465+
parser_simulate.add_argument("-s",
466+
"--simGraph",
467+
required=True,
468+
help="graphml file to simulate network from")
469+
parser_simulate.add_argument("-r",
470+
"--simRoot",
471+
required=True,
472+
help="node to start simulation from")
473+
parser_simulate.set_defaults(simulate=True)
474+
432475
parser_analyze = subparsers.add_parser('analyze',
433476
help="write stats for "
434477
"the graphml input graph")

scripts/README.md

+3
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ This folder is for storing any scripts that may be helpful for using stellar-cor
1717
- `-nl NODELIST`, `--nodeList NODELIST` - list of seed nodes. One node per line. (Optional)
1818
- `-gmlw GRAPHMLWRITE`, `--graphmlWrite GRAPHMLWRITE` - output file for graphml file
1919
- `-sr SURVEYRESULT`, `--surveyResult SURVEYRESULT` - output file for survey results
20+
- sub command `simulate` - simulate a run of the `survey` subcommand without any network calls. Takes the same arguments as `survey`, plus the following:
21+
- `-s SIMGRAPH`, `--simGraph SIMGRAPH` - Network topology to simulate in graphml format.
22+
- `r SIMROOT`, `--simRoot SIMROOT` - Node in graph to start simulation from.
2023
- sub command `analyze` - analyze an existing graph
2124
- `-gmla GRAPHMLANALYZE`, `--graphmlAnalyze GRAPHMLANALYZE` - input graphml file
2225
- sub command `augment` - augment an existing graph with information from stellarbeat.io. Currently, only Public Network graphs are supported.

scripts/overlay_survey/simulation.py

+141
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
"""
2+
This module simulates the HTTP endpoints of stellar-core's overlay survey
3+
"""
4+
5+
import networkx as nx
6+
7+
class SimulationError(Exception):
8+
"""An error that occurs during simulation"""
9+
10+
class SimulatedResponse:
11+
"""Simulates a `requests.Response`"""
12+
def __init__(self, json):
13+
self._json = json
14+
15+
def json(self):
16+
"""Simulates the `json` method of a `requests.Response`"""
17+
return self._json
18+
19+
class SurveySimulation:
20+
"""
21+
Simulates the HTTP endpoints of stellar-core's overlay survey. Raises
22+
SimulationError if `root_node` is not in the graph represented by
23+
`graph_path`.
24+
"""
25+
def __init__(self, graph_path, root_node):
26+
# The graph of the network being simulated
27+
self._graph = nx.read_graphml(graph_path)
28+
if root_node not in self._graph.nodes:
29+
raise SimulationError(f"root node '{root_node}' not in graph")
30+
# The node the simulation is being performed from
31+
self._root_node = root_node
32+
# The set of requests that have not yet been simulated
33+
self._pending_requests = []
34+
# The results of the simulation
35+
self._results = {"topology" : {}}
36+
print(f"simulating from {root_node}")
37+
38+
def _info(self, params):
39+
"""
40+
Simulate the info endpoint. Only fills in the version info for the
41+
root node.
42+
"""
43+
assert not params, f"Unsupported info invocation with params: {params}"
44+
version = self._graph.nodes[self._root_node]["version"]
45+
return SimulatedResponse({"info" : {"build" : version}})
46+
47+
def _peers(self, params):
48+
"""
49+
Simulate the peers endpoint. Only fills in the "id" field of each
50+
authenticated peer.
51+
"""
52+
assert params == {"fullkeys": "true"}, \
53+
f"Unsupported peers invocation with params: {params}"
54+
json = {"authenticated_peers": {"inbound": [], "outbound": []}}
55+
for peer in self._graph.in_edges(self._root_node):
56+
json["authenticated_peers"]["inbound"].append({"id" : peer[0]})
57+
for peer in self._graph.out_edges(self._root_node):
58+
json["authenticated_peers"]["outbound"].append({"id" : peer[1]})
59+
return SimulatedResponse(json)
60+
61+
def _scp(self, params):
62+
"""Simulate the scp endpoint. Only fills in the "you" field"""
63+
assert params == {"fullkeys": "true", "limit": 0}, \
64+
f"Unsupported scp invocation with params: {params}"
65+
return SimulatedResponse({"you": self._root_node})
66+
67+
def _surveytopology(self, params):
68+
"""
69+
Simulate the surveytopology endpoint. This endpoint currently ignores
70+
the `duration` parameter
71+
"""
72+
assert params.keys() == {"node", "duration"}, \
73+
f"Unsupported surveytopology invocation with params: {params}"
74+
if params["node"] != self._root_node:
75+
self._pending_requests.append(params["node"])
76+
77+
def _addpeer(self, node_id, edge_data, peers):
78+
"""
79+
Given data on a graph edge in `edge_data`, translate to the expected
80+
getsurveyresult json and add to `peers` list
81+
"""
82+
# Start with data on the edge itself
83+
peer_json = edge_data.copy()
84+
# Add peer's node id and version
85+
peer_json["nodeId"] = node_id
86+
peer_json["version"] = self._graph.nodes[node_id]["version"]
87+
# Add to inboundPeers
88+
peers.append(peer_json)
89+
90+
91+
def _getsurveyresult(self, params):
92+
"""Simulate the getsurveyresult endpoint"""
93+
assert not params, \
94+
f"Unsupported getsurveyresult invocation with params: {params}"
95+
96+
# For simulation purposes, the survey is in progress so long as there
97+
# are still pending requests to simulate.
98+
self._results["surveyInProgress"] = bool(self._pending_requests)
99+
100+
# Update results
101+
while self._pending_requests:
102+
node = self._pending_requests.pop()
103+
104+
# Start with info on the node itself
105+
node_json = self._graph.nodes[node].copy()
106+
107+
# Remove "version" field, which is not part of stellar-core's
108+
# response
109+
del node_json["version"]
110+
111+
# Generate inboundPeers list
112+
node_json["inboundPeers"] = []
113+
for (node_id, _, data) in self._graph.in_edges(node, True):
114+
self._addpeer(node_id, data, node_json["inboundPeers"])
115+
116+
# Generate outboundPeers list
117+
node_json["outboundPeers"] = []
118+
for (_, node_id, data) in self._graph.out_edges(node, True):
119+
self._addpeer(node_id, data, node_json["outboundPeers"])
120+
121+
self._results["topology"][node] = node_json
122+
return SimulatedResponse(self._results)
123+
124+
def get(self, url, params):
125+
"""Simulate a GET request"""
126+
endpoint = url.split("/")[-1]
127+
if endpoint == "stopsurvey":
128+
# Do nothing
129+
return
130+
if endpoint == "info":
131+
return self._info(params)
132+
if endpoint == "peers":
133+
return self._peers(params)
134+
if endpoint == "scp":
135+
return self._scp(params)
136+
if endpoint == "surveytopology":
137+
return self._surveytopology(params)
138+
if endpoint == "getsurveyresult":
139+
return self._getsurveyresult(params)
140+
raise SimulationError("Received GET request for unknown endpoint "
141+
f"'{endpoint}' with params '{params}'")

0 commit comments

Comments
 (0)