Belle II Software  release-08-01-10
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 """
15 Script to make sure the conditions database interface is behaving as expected.
16 
17 We do this by creating a local http server which acts as a mock database for
18 getting 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 
30 import sys
31 import os
32 import basf2
33 from http.server import HTTPServer, BaseHTTPRequestHandler
34 from urllib.parse import urlparse, parse_qs
35 from b2test_utils import clean_working_directory, safe_process, skip_test, configure_logging_for_tests
36 import multiprocessing
37 import shutil
38 
39 
40 class 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  '{{ "name": "{}", "globalTagStatus": {{ "name": "{}" }} }}'.format(
126  gtname, self.globaltags[gtname]))
127  else:
128  return self.send_error(404)
129 
130  if url.path.endswith("/iovPayloads/"):
131  exp = params["expNumber"][0]
132  run = params["runNumber"][0]
133 
134  if int(exp) > 100:
135  self.send_error(int(exp))
136  return
137 
138  if int(run) > 1:
139  exp = None
140 
141  if exp in self.payloads:
142  baseurl = "http://%s:%s" % self.server.socket.getsockname()
143  return self.reply(self.payloads[exp] % dict(exp=exp, run=run, baseurl=baseurl))
144  else:
145  # check if a fallback payload file exists in the conditions_testpayloads directory
146  filename = os.path.basename(url.path)
147  # replace rev_3 with rev_1
148  filename = filename.replace("rev_3", "rev_1")
149  basedir = basf2.find_file("framework/tests/conditions_testpayloads")
150  path = os.path.join(basedir, filename)
151  if os.path.isfile(path):
152  # ok, file exists. let's serve it
153  self.send_response(200)
154  self.end_headers()
155  with open(path, "rb") as f:
156  shutil.copyfileobj(f, self.wfile)
157  return
158 
159  # fall back: just return file not found
160  self.send_error(404)
161 
162 
163 def run_mockdb(pipe):
164  """Startup the mock conditions db server and send the port we listen on back
165  to the parent process"""
166  # listen on port 0 means we want to listen on any free port which would be
167  # nice. But since the new code prints the full url including port when there
168  # is a problem we choose a fixed one and hope that it it's free ...
169  try:
170  httpd = HTTPServer(("127.0.0.1", 12701), SimpleConditionsDB)
171  except OSError:
172  pipe.send(None)
173  skip_test("Socket 12701 is in use, cannot continue")
174  return
175  # so see which port we actually got
176  port = httpd.socket.getsockname()[1]
177  # and send to parent
178  pipe.send(port)
179  # now start listening
180  httpd.serve_forever()
181 
182 
183 def run_redirect(pipe, redir_port):
184  """Startup the redirection server: any request should be transparently forwarded
185  to the other using 308 http replies"""
186 
187  class SimpleRedirectServer(BaseHTTPRequestHandler):
188  def do_GET(self):
189  self.send_response(308)
190  self.send_header("Location", f"http://127.0.0.1:{redir_port}{self.path}")
191  self.end_headers()
192 
193  def log_message(self, format, *args):
194  """Override default logging to remove timestamp"""
195  print("Redirect Server:", format % args)
196 
197  def log_error(self, *args):
198  """Disable error logs"""
199 
200  try:
201  httpd = HTTPServer(("127.0.0.1", 12702), SimpleRedirectServer)
202  pipe.send(12702)
203  except OSError:
204  pipe.send(None)
205  skip_test("Socket 12702 is in use, cannot continue")
206  return
207 
208  # now start listening
209  httpd.serve_forever()
210 
211 
212 def dbprocess(host, path, lastChangeCallback=lambda: None, *, globaltag="localtest"):
213  """Process a given path in a child process so that FATAL will not abort this
214  script but just the child and configure to use a central database at the given host"""
215  # Run the path in a child process inside of a clean working directory
216  with clean_working_directory():
217  # make logging more reproducible by replacing some strings
218  configure_logging_for_tests()
219  basf2.logging.log_level = basf2.LogLevel.DEBUG
220  basf2.logging.debug_level = 30
221  basf2.conditions.reset()
222  basf2.conditions.expert_settings(download_cache_location="db-cache")
223  basf2.conditions.override_globaltags([globaltag])
224  if host:
225  basf2.conditions.metadata_providers = [host]
226  basf2.conditions.payload_locations = []
227  lastChangeCallback()
228  safe_process(path)
229 
230 
231 def set_serverlist(serverlist):
232  """Set a list of database servers."""
233  basf2.conditions.metadata_providers = serverlist + [e for e in basf2.conditions.metadata_providers if not e.startswith("http")]
234 
235 
236 os.environ.pop('BELLE2_CONDB_METADATA', None)
237 
238 # keep timeouts short for testing
239 basf2.conditions.expert_settings(backoff_factor=1, connection_timeout=5,
240  stalled_timeout=5, max_retries=3)
241 
242 # set the random seed to something fixed
243 basf2.set_random_seed("something important")
244 # and create a pipe so we can send the port we listen on from child to parent
245 conn = multiprocessing.Pipe(False)
246 # now start the mock conditions database as daemon so it gets killed at the end
247 # of the script
248 mock_conditionsdb = multiprocessing.Process(target=run_mockdb, args=(conn[1],))
249 mock_conditionsdb.daemon = True
250 mock_conditionsdb.start()
251 # mock db has started when we recieve the port number from the child, so wait for that
252 mock_port = conn[0].recv()
253 # if the port we got is None the server didn't start ... so bail
254 if mock_port is None:
255  sys.exit(1)
256 
257 # startup redirect server, same as conditionsdb_
258 redir_server = multiprocessing.Process(target=run_redirect, args=(conn[1], mock_port))
259 redir_server.daemon = True
260 redir_server.start()
261 redir_port = conn[0].recv()
262 if redir_port is None:
263  sys.exit(1)
264 
265 # and remember host for database access
266 mock_host = f"http://localhost:{mock_port}/"
267 redir_host = f"http://localhost:{redir_port}/"
268 
269 # create a simple processing path with just event info setter an a module which
270 # prints the beamparameters from the database
271 main = basf2.Path()
272 evtinfo = main.add_module("EventInfoSetter")
273 main.add_module("PrintBeamParameters")
274 
275 # run trough a set of experiments, each time we want to process two runs to make
276 # sure that it works correctly for more than one run
277 for exp in range(len(SimpleConditionsDB.payloads) + 1):
278  try:
279  basf2.B2INFO(f">>> check exp {exp}: {SimpleConditionsDB.payloads[str(exp)][0:20]}...)")
280  except KeyError:
281  basf2.B2INFO(f">>> check exp {exp}")
282  evtinfo.param({"expList": [exp, exp, exp], "runList": [0, 1, 2], "evtNumList": [1, 1, 1]})
283  dbprocess(mock_host, main)
284  # and again using redirection
285  dbprocess(redir_host, main)
286 
287 basf2.B2INFO(">>> check that a invalid global tag or a misspelled global tag actually throw errors")
288 evtinfo.param({"expList": [3], "runList": [0], "evtNumList": [1]})
289 for gt in ["newgt", "invalidgt", "horriblymisspelled",
290  "h͌̉e̳̞̞͆ͨ̏͋̕ ͍͚̱̰̀͡c͟o͛҉̟̰̫͔̟̪̠m̴̀ͯ̿͌ͨ̃͆e̡̦̦͖̳͉̗ͨͬ̑͌̃ͅt̰̝͈͚͍̳͇͌h̭̜̙̦̣̓̌̃̓̀̉͜!̱̞̻̈̿̒̀͢!̋̽̍̈͐ͫ͏̠̹̺̜̬͍ͅ"]:
291  dbprocess(mock_host, main, globaltag=gt)
292 
293 basf2.B2INFO(">>> check retry on 503 errors")
294 evtinfo.param({"expList": [503], "runList": [0], "evtNumList": [1]})
295 dbprocess(mock_host, main)
296 basf2.B2INFO(">>> check again without retries")
297 basf2.conditions.expert_settings(max_retries=0)
298 dbprocess(mock_host, main)
299 
300 # the following ones fail, no need for 3 times
301 evtinfo.param({"expList": [0], "runList": [0], "evtNumList": [1]})
302 
303 basf2.B2INFO(">>> try to open localhost on port 0, this should always be refused")
304 dbprocess("http://localhost:0", main)
305 
306 basf2.B2INFO(">>> and once more with a non existing host name to check for lookup errors")
307 dbprocess("http://nosuchurl/", main)
308 
309 basf2.B2INFO(">>> and once more with a non existing protocol")
310 dbprocess("nosuchproto://nosuchurl/", main)
311 
312 basf2.B2INFO(">>> and once more with a totally bogus url")
313 dbprocess("h͌̉e̳̞̞͆ͨ̏͋̕ ͍͚̱̰̀͡c͟o͛҉̟̰̫͔̟̪̠m̴̀ͯ̿͌ͨ̃͆e̡̦̦͖̳͉̗ͨͬ̑͌̃ͅt̰̝͈͚͍̳͇͌h̭̜̙̦̣̓̌̃̓̀̉͜!̱̞̻̈̿̒̀͢!̋̽̍̈͐ͫ͏̠̹̺̜̬͍ͅ", main)
314 
315 basf2.B2INFO(""">>> try to have a list of servers from environment variable
316  We expect that it fails over to the third server, {mock_host}, but then succeeds
317 """)
318 evtinfo.param({"expList": [3], "runList": [0], "evtNumList": [1]})
319 serverlist = [
320  "http://localhost:0",
321  "http://h͌̉e̳̞̞͆ͨ̏͋̕c͟o͛҉̟̰̫͔̟̪̠m̴̀ͯ̿͌ͨ̃͆e̡̦̦͖̳͉̗ͨͬ̑͌̃ͅt̰̝͈͚͍̳͇͌h̭̜̙̦̣̓̌̃̓̀̉͜!̱̞̻̈̿̒̀͢",
322  mock_host
323 ]
324 os.environ["BELLE2_CONDB_SERVERLIST"] = " ".join(serverlist)
325 dbprocess("", main)
326 
327 # ok, try again with the steering file settings instead of environment variable
328 basf2.B2INFO(""">>> try to have a list of servers from steering file
329  We expect that it fails over to the third server, {mock_host}, but then succeeds
330 """)
331 dbprocess("", main, lastChangeCallback=lambda: set_serverlist(serverlist))
332 
333 if "ssl" in sys.argv:
334  # ok, test SSL connectivity ... for now we just want to accept anything. This
335  # is disabled by default since I don't want to depend on badssl.com to be
336  # available
337  for hostname in ("expired", "wrong.host", "self-signed", "untrusted-root"):
338  dbprocess(f"https://{hostname}.badssl.com/", main)
339 
340 # @endcond