blob: 3098960aa79ca76dd2563ccec9f71d84984b0d8f [file] [log] [blame]
Neels Hofmeyr3531a192017-03-28 14:30:28 +02001
2# osmo_gsm_tester: specifics for running a sysmoBTS
3#
4# Copyright (C) 2016-2017 by sysmocom - s.f.m.c. GmbH
5#
6# Author: Neels Hofmeyr <neels@hofmeyr.de>
7#
8# This program is free software: you can redistribute it and/or modify
Harald Welte27205342017-06-03 09:51:45 +02009# it under the terms of the GNU General Public License as
Neels Hofmeyr3531a192017-03-28 14:30:28 +020010# published by the Free Software Foundation, either version 3 of the
11# License, or (at your option) any later version.
12#
13# This program is distributed in the hope that it will be useful,
14# but WITHOUT ANY WARRANTY; without even the implied warranty of
15# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
Harald Welte27205342017-06-03 09:51:45 +020016# GNU General Public License for more details.
Neels Hofmeyr3531a192017-03-28 14:30:28 +020017#
Harald Welte27205342017-06-03 09:51:45 +020018# You should have received a copy of the GNU General Public License
Neels Hofmeyr3531a192017-03-28 14:30:28 +020019# along with this program. If not, see <http://www.gnu.org/licenses/>.
20
21import socket
22import struct
Neels Hofmeyr5b04ef22020-12-01 04:48:38 +010023import re
Neels Hofmeyr3531a192017-03-28 14:30:28 +020024
Pau Espin Pedrole1a58bd2020-04-10 20:46:07 +020025from ..core import log
Neels Hofmeyr5b04ef22020-12-01 04:48:38 +010026from ..core.event_loop import MainLoop
27
28VERB_SET = 'SET'
29VERB_GET = 'GET'
30VERB_SET_REPLY = 'SET_REPLY'
31VERB_GET_REPLY = 'GET_REPLY'
32VERB_TRAP = 'TRAP'
33VERB_ERROR = 'ERROR'
34RECV_VERBS = (VERB_GET_REPLY, VERB_SET_REPLY, VERB_TRAP, VERB_ERROR)
35recv_re = re.compile('(%s) ([0-9]+) (.*)' % ('|'.join(RECV_VERBS)),
36 re.MULTILINE + re.DOTALL)
Neels Hofmeyr3531a192017-03-28 14:30:28 +020037
38class CtrlInterfaceExn(Exception):
39 pass
40
41class OsmoCtrl(log.Origin):
Neels Hofmeyrf80f7cc2020-12-06 22:51:13 +010042 _next_id = 1
Neels Hofmeyr3531a192017-03-28 14:30:28 +020043
44 def __init__(self, host, port):
Neels Hofmeyr1a7a3f02017-06-10 01:18:27 +020045 super().__init__(log.C_BUS, 'Ctrl', host=host, port=port)
Neels Hofmeyr3531a192017-03-28 14:30:28 +020046 self.host = host
47 self.port = port
48 self.sck = None
Neels Hofmeyrf79a86f2020-11-30 22:04:41 +010049
50 def next_id(self):
Neels Hofmeyrf80f7cc2020-12-06 22:51:13 +010051 ret = OsmoCtrl._next_id
52 OsmoCtrl._next_id += 1
Neels Hofmeyrf79a86f2020-11-30 22:04:41 +010053 return ret
Neels Hofmeyr3531a192017-03-28 14:30:28 +020054
55 def prefix_ipa_ctrl_header(self, data):
56 if isinstance(data, str):
57 data = data.encode('utf-8')
58 s = struct.pack(">HBB", len(data)+1, 0xee, 0)
59 return s + data
60
61 def remove_ipa_ctrl_header(self, data):
62 if (len(data) < 4):
63 raise CtrlInterfaceExn("Answer too short!")
64 (plen, ipa_proto, osmo_proto) = struct.unpack(">HBB", data[:4])
65 if (plen + 3 > len(data)):
66 self.err('Warning: Wrong payload length', expected=plen, got=len(data)-3)
67 if (ipa_proto != 0xee or osmo_proto != 0):
68 raise CtrlInterfaceExn("Wrong protocol in answer!")
69 return data[4:plen+3], data[plen+3:]
70
Neels Hofmeyr5b04ef22020-12-01 04:48:38 +010071 def try_connect(self):
72 '''Do a connection attempt, return True when successful, False otherwise.
73 Does not raise exceptions, but logs them to the debug log.'''
74 assert self.sck is None
75 try:
76 self.dbg('Connecting')
77 sck = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
78 try:
79 sck.connect((self.host, self.port))
80 except:
81 sck.close()
82 raise
83 # set self.sck only after the connect was successful
84 self.sck = sck
85 return True
86 except:
87 self.dbg('Failed to connect', sys.exc_info()[0])
88 return False
89
90 def connect(self, timeout=30):
91 '''Connect to the CTRL self.host and self.port, retry for 'timeout' seconds.'''
92 MainLoop.wait(self.try_connect, timestep=3, timeout=timeout)
Neels Hofmeyr3531a192017-03-28 14:30:28 +020093 self.sck.setblocking(1)
Neels Hofmeyr05439d72020-12-01 03:52:55 +010094 self.sck.settimeout(10)
Neels Hofmeyr3531a192017-03-28 14:30:28 +020095
96 def disconnect(self):
Neels Hofmeyr5b04ef22020-12-01 04:48:38 +010097 if self.sck is None:
98 return
Neels Hofmeyr3531a192017-03-28 14:30:28 +020099 self.dbg('Disconnecting')
Neels Hofmeyr5b04ef22020-12-01 04:48:38 +0100100 self.sck.close()
101 self.sck = None
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200102
Neels Hofmeyr5b04ef22020-12-01 04:48:38 +0100103 def _recv(self, verbs, match_args=None, match_id=None, attempts=10, length=1024):
104 '''Receive until a response matching the verbs / args / msg-id is obtained from CTRL.
105 The general socket timeout applies for each attempt made, see connect().
106 Multiple attempts may be necessary if, for example, intermediate
107 messages are received that do not relate to what is expected, like
108 TRAPs that are not interesting.
109
110 To receive a GET_REPLY / SET_REPLY:
111 verb, rx_id, val = _recv(('GET_REPLY', 'ERROR'), match_id=used_id)
112 if verb == 'ERROR':
113 raise CtrlInterfaceExn()
114 print(val)
115
116 To receive a TRAP:
117 verb, rx_id, val = _recv('TRAP', 'bts_connection_status connected')
118 # val == 'bts_connection_status connected'
119
120 If the CTRL is not connected yet, open and close a connection for
121 this operation only.
122 '''
123
124 # allow calling for both already connected VTY as well as establishing
125 # a connection just for this command.
126 if self.sck is None:
127 with self:
128 return self._recv(verbs, match_args=match_args,
129 match_id=match_id, attempts=attempts, length=length)
130
131 if isinstance(verbs, str):
132 verbs = (verbs, )
133
134 for i in range(attempts):
135 data = self.sck.recv(length)
136 self.dbg('Receiving', data=data)
137 while len(data) > 0:
138 msg, data = self.remove_ipa_ctrl_header(data)
139 msg_str = msg.decode('utf-8')
140
141 m = recv_re.fullmatch(msg_str)
142 if m is None:
143 raise CtrlInterfaceExn('Received garbage: %r' % data)
144
145 rx_verb, rx_id, rx_args = m.groups()
146 rx_id = int(rx_id)
147
148 if match_id is not None and match_id != rx_id:
149 continue
150
151 if verbs and rx_verb not in verbs:
152 continue
153
154 if match_args and not rx_args.startswith(match_args):
155 continue
156
157 return rx_verb, rx_id, rx_args
158 raise CtrlInterfaceExn('No answer found: ' + reply_header)
159
160 def _sendrecv(self, verb, send_args, *recv_args, use_id=None, **recv_kwargs):
161 '''Send a request and receive a matching response.
162 If the CTRL is not connected yet, open and close a connection for
163 this operation only.
164 '''
165 if self.sck is None:
166 with self:
167 return self._sendrecv(verb, send_args, *recv_args, use_id=use_id, **recv_kwargs)
168
169 if use_id is None:
170 use_id = self.next_id()
171
172 # send
173 data = '{verb} {use_id} {send_args}'.format(**locals())
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200174 self.dbg('Sending', data=data)
175 data = self.prefix_ipa_ctrl_header(data)
176 self.sck.send(data)
177
Neels Hofmeyr5b04ef22020-12-01 04:48:38 +0100178 # receive reply
179 recv_kwargs['match_id'] = use_id
180 return self._recv(*recv_args, **recv_kwargs)
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200181
Neels Hofmeyr5b04ef22020-12-01 04:48:38 +0100182 def set_var(self, var, value):
183 '''Set the value of a specific variable on a CTRL interface, and return the response, e.g.:
184 assert set_var('subscriber-modify-v1', '901701234567,2342') == 'OK'
185 If the CTRL is not connected yet, open and close a connection for
186 this operation only.
187 '''
188 verb, rx_id, args = self._sendrecv(VERB_SET, '%s %s' % (var, value), (VERB_SET_REPLY, VERB_ERROR))
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200189
Neels Hofmeyr5b04ef22020-12-01 04:48:38 +0100190 if verb == VERB_ERROR:
191 raise CtrlInterfaceExn('SET %s = %s returned %r' % (var, value, ' '.join((verb, str(rx_id), args))))
192
193 var_and_space = var + ' '
194 if not args.startswith(var_and_space):
195 raise CtrlInterfaceExn('SET %s = %s returned SET_REPLY for different var: %r'
196 % (var, value, ' '.join((verb, str(rx_id), args))))
197
198 return args[len(var_and_space):]
199
200 def get_var(self, var):
201 '''Get the value of a specific variable from a CTRL interface:
202 assert get_var('bts.0.oml-connection-state') == 'connected'
203 If the CTRL is not connected yet, open and close a connection for
204 this operation only.
205 '''
206 verb, rx_id, args = self._sendrecv(VERB_GET, var, (VERB_GET_REPLY, VERB_ERROR))
207
208 if verb == VERB_ERROR:
209 raise CtrlInterfaceExn('GET %s returned %r' % (var, ' '.join((verb, str(rx_id), args))))
210
211 var_and_space = var + ' '
212 if not args.startswith(var_and_space):
213 raise CtrlInterfaceExn('GET %s returned GET_REPLY for different var: %r'
214 % (var, value, ' '.join((verb, str(rx_id), args))))
215
216 return args[len(var_and_space):]
217
218 def get_int_var(self, var):
219 '''Same as get_var() but return an int'''
220 return int(self.get_var(var))
221
222 def get_trap(self, name):
223 '''Read from CTRL until a TRAP of this name is received.
224 If name is None, any TRAP is returned.
225 If the CTRL is not connected yet, open and close a connection for
226 this operation only.
227 '''
228 verb, rx_id, args = self._recv(VERB_TRAP, name)
229 name_and_space = var + ' '
230 # _recv() should ensure this:
231 assert args.startswith(name_and_space)
232 return args[len(name_and_space):]
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200233
234 def __enter__(self):
235 self.connect()
236 return self
237
238 def __exit__(self, *exc_info):
239 self.disconnect()
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200240
Neels Hofmeyr53540582020-11-30 22:04:26 +0100241class RateCountersExn(log.Error):
242 pass
243
244class RateCounters(dict):
245 '''Usage example:
246 counter_names = (
247 'handover:completed',
248 'handover:stopped',
249 'handover:no_channel',
250 'handover:timeout',
251 'handover:failed',
252 'handover:error',
253 )
254
255 # initialize the listing of CTRL vars of the counters to watch.
256 # First on the 'bsc' node:
257 # rate_ctr.abs.bsc.0.handover:completed
258 # rate_ctr.abs.bsc.0.handover:stopped
259 # ...
260 counters = RateCounters('bsc', counter_names, from_ctrl=bsc.ctrl)
261
262 # And also add counters for two 'bts' instances:
263 # rate_ctr.abs.bts.0.handover:completed
264 # rate_ctr.abs.bts.0.handover:stopped
265 # ...
266 # rate_ctr.abs.bts.1.handover:completed
267 # ...
268 counters.add(RateCounters('bts', counter_names, instances=(0, 1)))
269
270 # read initial counter values, from the bsc_ctrl, as set in
271 # counters.from_ctrl in the RateCounters() constructor above.
272 counters.read()
273
274 # Do some actions that should increment counters in the SUT
275 do_a_handover()
276
277 if approach_without_wait:
278 # increment the counters as expected
279 counters.inc('bts', 'handover:completed')
280
281 # read counters from CTRL again, and fail if they differ
282 counters.verify()
283
284 if approach_with_wait:
285 # you can wait for counters to change. counters.changed() does not
286 # modify counters' values, just reads values from CTRL and stores
287 # the changes in counters.diff.
288 wait(counters.changed, timeout=20)
289
290 # log which counters changed by how much, found in counters.diff
291 # after each counters.changed() call:
292 print(counters.diff.str(skip_zero_vals=True))
293
294 if check_all_vals:
295 # Assert all values:
296 expected_diff = counters.copy().clear()
297 expected_diff.inc('bts', 'handover:completed', instances=(0, 1))
298 counters.diff.expect(expected_diff)
299 else:
300 # Assert only some specific counters:
301 expected_diff = RateCounters()
302 expected_diff.inc('bts', 'handover:completed', instances=(0, 1))
303 counters.diff.expect(expected_diff)
304
305 # update counters to the last read values if desired
306 counters.add(counters.diff)
307 '''
308
309 def __init__(self, instance_names=(), counter_names=(), instances=0, kinds='abs', init_val=0, from_ctrl=None):
310 def init_cb(var):
311 self[var] = init_val
312 RateCounters.for_each(init_cb, instance_names, counter_names, instances, kinds, results=False)
313 self.from_ctrl = from_ctrl
314 self.diff = None
315
316 @staticmethod
317 def for_each(callback_func, instance_names, counter_names, instances=0, kinds='abs', results=True):
318 '''Call callback_func for a set of rate counter var names, mostly
319 called by more convenient functions. See inc() for a comprehensive
320 explanation.
321 '''
322 if type(instance_names) is str:
323 instance_names = (instance_names, )
324 if type(counter_names) is str:
325 counter_names = (counter_names, )
326 if type(kinds) is str:
327 kinds = (kinds, )
328 if type(instances) is int:
329 instances = (instances, )
330 if results is True:
331 results = RateCounters()
332 elif results is False:
333 results = None
334 for instance_name in instance_names:
335 for instance_nr in instances:
336 for counter_name in counter_names:
337 for kind in kinds:
338 var = 'rate_ctr.{kind}.{instance_name}.{instance_nr}.{counter_name}'.format(**locals())
339 result = callback_func(var)
340 if results is not None:
341 results[var] = result
342 return results
343
344 def __str__(self):
345 return self.str(', ', '')
346
347 def str(self, sep='\n| ', prefix='\n| ', vals=None, skip_zero_vals=False):
348 '''The 'vals' arg is useful to print a plain dict() of counter values like a RateCounters class.
349 By default print self.'''
350 if vals is None:
351 vals = self
352 return prefix + sep.join('%s = %d' % (var, val) for var, val in sorted(vals.items())
353 if (not skip_zero_vals) or (val != 0))
354
355 def inc(self, instance_names, counter_names, inc=1, instances=0, kinds='abs'):
356 '''Increment a set of counters.
357 inc('xyz', 'val') --> rate_ctr.abs.xyz.0.val += 1
358
359 inc('xyz', ('foo', 'bar')) --> rate_ctr.abs.xyz.0.foo += 1
360 rate_ctr.abs.xyz.0.bar += 1
361
362 inc(('xyz', 'pqr'), 'val') --> rate_ctr.abs.xyz.0.val += 1
363 rate_ctr.abs.pqr.0.val += 1
364
365 inc('xyz', 'val', instances=range(3))
366 --> rate_ctr.abs.xyz.0.val += 1
367 rate_ctr.abs.xyz.1.val += 1
368 rate_ctr.abs.xyz.2.val += 1
369 '''
370 def inc_cb(var):
371 val = self.get(var, 0)
372 val += inc
373 self[var] = val
374 return val
375 RateCounters.for_each(inc_cb, instance_names, counter_names, instances, kinds, results=False)
376 return self
377
378 def add(self, rate_counters):
379 '''Add the given values up to the values in self.
380 rate_counters can be a RateCounters instance or a plain dict of CTRL
381 var as key and counter integer as value.
382 '''
383 for var, add_val in rate_counters.items():
384 val = self.get(var, 0)
385 val += add_val
386 self[var] = val
387 return self
388
389 def subtract(self, rate_counters):
390 '''Same as add(), but subtract values from self instead.
391 Useful to verify counters relative to an arbitrary reference.'''
392 for var, subtract_val in rate_counters.items():
393 val = self.get(var, 0)
394 val -= subtract_val
395 self[var] = val
396 return self
397
398
399 def clear(self, val=0):
400 '''Set all counts to 0 (or a specific value)'''
401 for var in self.keys():
402 self[var] = val
403 return self
404
405 def copy(self):
406 '''Return a copy of all keys and values stored in self.'''
407 cpy = RateCounters(from_ctrl = self.from_ctrl)
408 cpy.update(self)
409 return cpy
410
411 def read(self):
412 '''Read all counters from the CTRL connection passed to RateCounters(from_ctrl=x).
413 The CTRL must be connected, e.g.
414 with bsc.ctrl() as ctrl:
415 counters = RateCounters(ctrl)
416 counters.read()
417 '''
418 for var in self.keys():
419 self[var] = self.from_ctrl.get_int_var(var)
420 self.from_ctrl.dbg('Read counters:', self.str())
421 return self
422
423 def verify(self):
424 '''Read counters from CTRL and assert that they match the current counts'''
425 got_vals = self.copy()
426 got_vals.read()
427 got_vals.expect(self)
428
429 def changed(self):
430 '''Read counters from CTRL, and return True if anyone is different now.
431 Store the difference in counts in self.diff (replace self.diff for
432 each changed() call). The counts in self are never modified.'''
433 self.diff = None
434 got_vals = self.copy()
435 got_vals.read()
436 if self != got_vals:
437 self.diff = got_vals
438 self.diff.subtract(self)
439 self.from_ctrl.dbg('Changed counters:', self.diff.str(skip_zero_vals=True))
440 return True
441 return False
442
443 def expect(self, expect_vals):
444 '''Iterate expect_vals and fail if any counter value differs from self.
445 expect_vals can be a RateCounters instance or a plain dict of CTRL
446 var as key and counter integer as value.
447 '''
448 ok = 0
449 errs = []
450 for var, expect_val in expect_vals.items():
451 got_val = self.get(var)
452 if got_val is None:
453 errs.append('expected {var} == {expect_val}, but no such value found'.format(**locals()))
454 continue
455 if got_val != expect_val:
456 errs.append('expected {var} == {expect_val}, but is {got_val}'.format(**locals()))
457 continue
458 ok += 1
459 if errs:
460 self.from_ctrl.dbg('Expected rate counters:', self.str(vals=expect_vals))
461 self.from_ctrl.dbg('Got rate counters:', self.str())
462 raise RateCountersExn('%d of %d rate counters mismatch:' % (len(errs), len(errs) + ok), '\n| ' + '\n| '.join(errs))
463 else:
464 self.from_ctrl.log('Verified %d rate counters' % ok)
465 self.from_ctrl.dbg('Verified %d rate counters:' % ok, expect_vals)
466
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200467# vim: expandtab tabstop=4 shiftwidth=4