Belle II Software development
conditions.py
1#!/usr/bin/env python3
2
3
10
11# this is a test executable, not a module so we don't need doxygen warnings
12# @cond SUPPRESS_DOXYGEN
13
14"""
15Script to make sure the conditions database interface is behaving as expected.
16
17We do this by creating a local http server which acts as a mock database for
18getting the payload information and payloads and then we run through different scenarios:
19 - unknown host
20 - connection refused
21 - url not found (404)
22 - retry on (503)
23 - corrupt payload information
24 - incomplete payload information
25 - correct payload information
26 - payload file missing
27 - payload file checksum mismatch
28"""
29
30import sys
31import os
32import basf2
33from http.server import HTTPServer, BaseHTTPRequestHandler
34from urllib.parse import urlparse, parse_qs
35from b2test_utils import clean_working_directory, safe_process, skip_test, configure_logging_for_tests
36import multiprocessing
37import shutil
38
39
40class SimpleConditionsDB(BaseHTTPRequestHandler):
41 """Simple ConditionsDB server which handles just the two things needed to
42 test the interface: get a list of payloads for the current run and download
43 a payloadfile. It will return different payloads for the experiments to
44 check for different error conditions"""
45
46
47 globaltag_states = """[
48 { "name": "OPEN" },
49 { "name": "TESTING" },
50 { "name": "VALIDATED" },
51 { "name": "PUBLISHED" },
52 { "name": "RUNNING" },
53 { "name": "INVALID" }
54 ]"""
55
56
57 example_payload = """[{{
58 "payload": {{
59 "baseUrl": "%(baseurl)s",
60 "payloadId":1,
61 "checksum":"{checksum}",
62 "revision":{revision},
63 "payloadUrl":"dbstore_BeamParameters_rev_{revision}.root",
64 "basf2Module": {{ "name":"BeamParameters", "basf2Package": {{ "name":"dbstore" }} }}
65 }}, "payloadIov": {{
66 "expStart": %(exp)s,
67 "expEnd": %(exp)s,
68 "runStart": %(run)s,
69 "runEnd": %(run)s
70 }}
71 }}]"""
72
73
74 payloads = {
75 # let's start with empty information
76 "0": "[]",
77 # or one child but the wrong one
78 "1": '[{ "foo": { } }]',
79 # or let's have some invalid XML
80 "2": '[{ "foo',
81 # let's provide one correct payload
82 "3": example_payload.format(checksum="2447fbcf76419fbbc7c6d015ef507769", revision="1"),
83 # same payload but checksum mismatch
84 "4": example_payload.format(checksum="00[wrong checksum]", revision="1"),
85 # non existing payload file
86 "5": example_payload.format(checksum="00[missing]", revision="2"),
87 # duplicate payload, or in this case triple
88 "6": example_payload.format(checksum="2447fbcf76419fbbc7c6d015ef507769", revision="2")[:-1] + "," +
89 example_payload.format(checksum="2447fbcf76419fbbc7c6d015ef507769", revision="1")[1:-1] + "," +
90 example_payload.format(checksum="2447fbcf76419fbbc7c6d015ef507769", revision="3")[1:],
91 }
92
93
94 globaltags = {
95 "localtest": "PUBLISHED",
96 "newgt": "NEW",
97 "invalidgt": "INVALID",
98 }
99
100 def reply(self, xml):
101 """Return a given xml string"""
102 self.send_response(200)
103 self.end_headers()
104 self.wfile.write(xml.encode())
105
106 def log_message(self, format, *args):
107 """Override default logging to remove timestamp"""
108 print("MockConditionsDB:", format % args)
109
110 def log_error(self, *args):
111 """Disable error logs"""
112
113 def do_GET(self):
114 """Parse a get request"""
115 url = urlparse(self.path)
116 params = parse_qs(url.query)
117 if url.path == "/v2/globalTagStatus":
118 return self.reply(self.globaltag_states)
119 # return mock payload info
120 elif url.path.startswith("/v2/globalTag"):
121 # gt info
122 gtname = url.path.split("/")[-1]
123 if gtname in self.globaltags:
124 return self.reply(
125 f'{{ "name": "{gtname}", "globalTagStatus": {{ "name": "{self.globaltags[gtname]}" }} }}')
126 else:
127 return self.send_error(404)
128
129 if url.path.endswith("/iovPayloads/"):
130 exp = params["expNumber"][0]
131 run = params["runNumber"][0]
132
133 if int(exp) > 100:
134 self.send_error(int(exp))
135 return
136
137 if int(run) > 1:
138 exp = None
139
140 if exp in self.payloads:
141 baseurl = "http://%s:%s" % self.server.socket.getsockname()
142 return self.reply(self.payloads[exp] % dict(exp=exp, run=run, baseurl=baseurl))
143 else:
144 # check if a fallback payload file exists in the conditions_testpayloads directory
145 filename = os.path.basename(url.path)
146 # replace rev_3 with rev_1
147 filename = filename.replace("rev_3", "rev_1")
148 basedir = basf2.find_file("framework/tests/conditions_testpayloads")
149 path = os.path.join(basedir, filename)
150 if os.path.isfile(path):
151 # ok, file exists. let's serve it
152 self.send_response(200)
153 self.end_headers()
154 with open(path, "rb") as f:
155 shutil.copyfileobj(f, self.wfile)
156 return
157
158 # fall back: just return file not found
159 self.send_error(404)
160
161
162def run_mockdb(pipe):
163 """Startup the mock conditions db server and send the port we listen on back
164 to the parent process"""
165 # listen on port 0 means we want to listen on any free port which would be
166 # nice. But since the new code prints the full url including port when there
167 # is a problem we choose a fixed one and hope that it it's free ...
168 try:
169 httpd = HTTPServer(("127.0.0.1", 12701), SimpleConditionsDB)
170 except OSError:
171 pipe.send(None)
172 skip_test("Socket 12701 is in use, cannot continue")
173 return
174 # so see which port we actually got
175 port = httpd.socket.getsockname()[1]
176 # and send to parent
177 pipe.send(port)
178 # now start listening
179 httpd.serve_forever()
180
181
182def run_redirect(pipe, redir_port):
183 """Startup the redirection server: any request should be transparently forwarded
184 to the other using 308 http replies"""
185
186 class SimpleRedirectServer(BaseHTTPRequestHandler):
187 def do_GET(self):
188 self.send_response(308)
189 self.send_header("Location", f"http://127.0.0.1:{redir_port}{self.path}")
190 self.end_headers()
191
192 def log_message(self, format, *args):
193 """Override default logging to remove timestamp"""
194 print("Redirect Server:", format % args)
195
196 def log_error(self, *args):
197 """Disable error logs"""
198
199 try:
200 httpd = HTTPServer(("127.0.0.1", 12702), SimpleRedirectServer)
201 pipe.send(12702)
202 except OSError:
203 pipe.send(None)
204 skip_test("Socket 12702 is in use, cannot continue")
205 return
206
207 # now start listening
208 httpd.serve_forever()
209
210
211def dbprocess(host, path, lastChangeCallback=lambda: None, *, globaltag="localtest"):
212 """Process a given path in a child process so that FATAL will not abort this
213 script but just the child and configure to use a central database at the given host"""
214 # Run the path in a child process inside of a clean working directory
215 with clean_working_directory():
216 # make logging more reproducible by replacing some strings
217 configure_logging_for_tests()
218 basf2.logging.log_level = basf2.LogLevel.DEBUG
219 basf2.logging.debug_level = 30
220 basf2.conditions.reset()
221 basf2.conditions.expert_settings(download_cache_location="db-cache")
222 basf2.conditions.override_globaltags([globaltag])
223 if host:
224 basf2.conditions.metadata_providers = [host]
225 basf2.conditions.payload_locations = []
226 lastChangeCallback()
227 safe_process(path)
228
229
230def set_serverlist(serverlist):
231 """Set a list of database servers."""
232 basf2.conditions.metadata_providers = serverlist + [e for e in basf2.conditions.metadata_providers if not e.startswith("http")]
233
234
235os.environ.pop('BELLE2_CONDB_METADATA', None)
236
237# keep timeouts short for testing
238basf2.conditions.expert_settings(backoff_factor=1, connection_timeout=5,
239 stalled_timeout=5, max_retries=3)
240
241# set the random seed to something fixed
242basf2.set_random_seed("something important")
243# and create a pipe so we can send the port we listen on from child to parent
244conn = multiprocessing.Pipe(False)
245# now start the mock conditions database as daemon so it gets killed at the end
246# of the script
247mock_conditionsdb = multiprocessing.Process(target=run_mockdb, args=(conn[1],))
248mock_conditionsdb.daemon = True
249mock_conditionsdb.start()
250# mock db has started when we recieve the port number from the child, so wait for that
251mock_port = conn[0].recv()
252# if the port we got is None the server didn't start ... so bail
253if mock_port is None:
254 sys.exit(1)
255
256# startup redirect server, same as conditionsdb_
257redir_server = multiprocessing.Process(target=run_redirect, args=(conn[1], mock_port))
258redir_server.daemon = True
259redir_server.start()
260redir_port = conn[0].recv()
261if redir_port is None:
262 sys.exit(1)
263
264# and remember host for database access
265mock_host = f"http://localhost:{mock_port}/"
266redir_host = f"http://localhost:{redir_port}/"
267
268# create a simple processing path with just event info setter an a module which
269# prints the beamparameters from the database
270main = basf2.Path()
271evtinfo = main.add_module("EventInfoSetter")
272main.add_module("PrintBeamParameters")
273
274# run trough a set of experiments, each time we want to process two runs to make
275# sure that it works correctly for more than one run
276for exp in range(len(SimpleConditionsDB.payloads) + 1):
277 try:
278 basf2.B2INFO(f">>> check exp {exp}: {SimpleConditionsDB.payloads[str(exp)][0:20]}...)")
279 except KeyError:
280 basf2.B2INFO(f">>> check exp {exp}")
281 evtinfo.param({"expList": [exp, exp, exp], "runList": [0, 1, 2], "evtNumList": [1, 1, 1]})
282 dbprocess(mock_host, main)
283 # and again using redirection
284 dbprocess(redir_host, main)
285
286basf2.B2INFO(">>> check that a invalid global tag or a misspelled global tag actually throw errors")
287evtinfo.param({"expList": [3], "runList": [0], "evtNumList": [1]})
288for gt in ["newgt", "invalidgt", "horriblymisspelled",
289 "h͌̉e̳̞̞͆ͨ̏͋̕ ͍͚̱̰̀͡c͟o͛҉̟̰̫͔̟̪̠m̴̀ͯ̿͌ͨ̃͆e̡̦̦͖̳͉̗ͨͬ̑͌̃ͅt̰̝͈͚͍̳͇͌h̭̜̙̦̣̓̌̃̓̀̉͜!̱̞̻̈̿̒̀͢!̋̽̍̈͐ͫ͏̠̹̺̜̬͍ͅ"]:
290 dbprocess(mock_host, main, globaltag=gt)
291
292basf2.B2INFO(">>> check retry on 503 errors")
293evtinfo.param({"expList": [503], "runList": [0], "evtNumList": [1]})
294dbprocess(mock_host, main)
295basf2.B2INFO(">>> check again without retries")
296basf2.conditions.expert_settings(max_retries=0)
297dbprocess(mock_host, main)
298
299# the following ones fail, no need for 3 times
300evtinfo.param({"expList": [0], "runList": [0], "evtNumList": [1]})
301
302basf2.B2INFO(">>> try to open localhost on port 0, this should always be refused")
303dbprocess("http://localhost:0", main)
304
305basf2.B2INFO(">>> and once more with a non existing host name to check for lookup errors")
306dbprocess("http://nosuchurl/", main)
307
308basf2.B2INFO(">>> and once more with a non existing protocol")
309dbprocess("nosuchproto://nosuchurl/", main)
310
311basf2.B2INFO(">>> and once more with a totally bogus url")
312dbprocess("h͌̉e̳̞̞͆ͨ̏͋̕ ͍͚̱̰̀͡c͟o͛҉̟̰̫͔̟̪̠m̴̀ͯ̿͌ͨ̃͆e̡̦̦͖̳͉̗ͨͬ̑͌̃ͅt̰̝͈͚͍̳͇͌h̭̜̙̦̣̓̌̃̓̀̉͜!̱̞̻̈̿̒̀͢!̋̽̍̈͐ͫ͏̠̹̺̜̬͍ͅ", main)
313
314basf2.B2INFO(""">>> try to have a list of servers from environment variable
315 We expect that it fails over to the third server, {mock_host}, but then succeeds
316""")
317evtinfo.param({"expList": [3], "runList": [0], "evtNumList": [1]})
318serverlist = [
319 "http://localhost:0",
320 "http://h͌̉e̳̞̞͆ͨ̏͋̕c͟o͛҉̟̰̫͔̟̪̠m̴̀ͯ̿͌ͨ̃͆e̡̦̦͖̳͉̗ͨͬ̑͌̃ͅt̰̝͈͚͍̳͇͌h̭̜̙̦̣̓̌̃̓̀̉͜!̱̞̻̈̿̒̀͢",
321 mock_host
322]
323os.environ["BELLE2_CONDB_SERVERLIST"] = " ".join(serverlist)
324dbprocess("", main)
325
326# ok, try again with the steering file settings instead of environment variable
327basf2.B2INFO(""">>> try to have a list of servers from steering file
328 We expect that it fails over to the third server, {mock_host}, but then succeeds
329""")
330dbprocess("", main, lastChangeCallback=lambda: set_serverlist(serverlist))
331
332if "ssl" in sys.argv:
333 # ok, test SSL connectivity ... for now we just want to accept anything. This
334 # is disabled by default since I don't want to depend on badssl.com to be
335 # available
336 for hostname in ("expired", "wrong.host", "self-signed", "untrusted-root"):
337 dbprocess(f"https://{hostname}.badssl.com/", main)
338
339# @endcond