blob: 7cc32bb1f13c218bd62b93319147d51e8f36522f [file] [log] [blame]
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +02001# osmo_gsm_tester: manage resources
2#
3# Copyright (C) 2016-2017 by sysmocom - s.f.m.c. GmbH
4#
5# Author: Neels Hofmeyr <neels@hofmeyr.de>
6#
7# This program is free software: you can redistribute it and/or modify
Harald Welte27205342017-06-03 09:51:45 +02008# it under the terms of the GNU General Public License as
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +02009# published by the Free Software Foundation, either version 3 of the
10# License, or (at your option) any later version.
11#
12# This program is distributed in the hope that it will be useful,
13# but WITHOUT ANY WARRANTY; without even the implied warranty of
14# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
Harald Welte27205342017-06-03 09:51:45 +020015# GNU General Public License for more details.
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +020016#
Harald Welte27205342017-06-03 09:51:45 +020017# You should have received a copy of the GNU General Public License
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +020018# along with this program. If not, see <http://www.gnu.org/licenses/>.
19
20import os
Neels Hofmeyr3531a192017-03-28 14:30:28 +020021import time
22import copy
23import atexit
24import pprint
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +020025
26from . import log
27from . import config
Neels Hofmeyr3531a192017-03-28 14:30:28 +020028from . import util
29from . import schema
30from . import ofono_client
31from . import osmo_nitb
Neels Hofmeyr391afe32017-05-18 19:22:12 +020032from . import bts_sysmo, bts_osmotrx
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +020033
Neels Hofmeyr3531a192017-03-28 14:30:28 +020034from .util import is_dict, is_list
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +020035
Neels Hofmeyr3531a192017-03-28 14:30:28 +020036HASH_KEY = '_hash'
37RESERVED_KEY = '_reserved_by'
38USED_KEY = '_used'
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +020039
Neels Hofmeyr3531a192017-03-28 14:30:28 +020040RESOURCES_CONF = 'resources.conf'
41LAST_USED_MSISDN_FILE = 'last_used_msisdn.state'
42RESERVED_RESOURCES_FILE = 'reserved_resources.state'
43
Neels Hofmeyr76d81032017-05-18 18:35:32 +020044R_IP_ADDRESS = 'ip_address'
Neels Hofmeyr3531a192017-03-28 14:30:28 +020045R_BTS = 'bts'
46R_ARFCN = 'arfcn'
47R_MODEM = 'modem'
Neels Hofmeyr76d81032017-05-18 18:35:32 +020048R_ALL = (R_IP_ADDRESS, R_BTS, R_ARFCN, R_MODEM)
Neels Hofmeyr3531a192017-03-28 14:30:28 +020049
50RESOURCES_SCHEMA = {
Neels Hofmeyr76d81032017-05-18 18:35:32 +020051 'ip_address[].addr': schema.IPV4,
Neels Hofmeyr3531a192017-03-28 14:30:28 +020052 'bts[].label': schema.STR,
53 'bts[].type': schema.STR,
Neels Hofmeyr17c139e2017-04-12 02:42:02 +020054 'bts[].ipa_unit_id': schema.INT,
Neels Hofmeyr3531a192017-03-28 14:30:28 +020055 'bts[].addr': schema.IPV4,
56 'bts[].band': schema.BAND,
Your Name44af3412017-04-13 03:11:59 +020057 'bts[].trx_list[].hw_addr': schema.HWADDR,
58 'bts[].trx_list[].net_device': schema.STR,
Neels Hofmeyr3531a192017-03-28 14:30:28 +020059 'arfcn[].arfcn': schema.INT,
60 'arfcn[].band': schema.BAND,
61 'modem[].label': schema.STR,
62 'modem[].path': schema.STR,
63 'modem[].imsi': schema.IMSI,
64 'modem[].ki': schema.KI,
65 }
66
67WANT_SCHEMA = util.dict_add(
68 dict([('%s[].times' % r, schema.INT) for r in R_ALL]),
69 RESOURCES_SCHEMA)
70
71KNOWN_BTS_TYPES = {
Your Name44af3412017-04-13 03:11:59 +020072 'osmo-bts-sysmo': bts_sysmo.SysmoBts,
73 'osmo-bts-trx': bts_osmotrx.OsmoBtsTrx,
Neels Hofmeyr3531a192017-03-28 14:30:28 +020074 }
75
76def register_bts_type(name, clazz):
77 KNOWN_BTS_TYPES[name] = clazz
78
79class ResourcesPool(log.Origin):
80 _remember_to_free = None
81 _registered_exit_handler = False
82
83 def __init__(self):
84 self.config_path = config.get_config_file(RESOURCES_CONF)
85 self.state_dir = config.get_state_dir()
86 self.set_name(conf=self.config_path, state=self.state_dir.path)
87 self.read_conf()
88
89 def read_conf(self):
90 self.all_resources = Resources(config.read(self.config_path, RESOURCES_SCHEMA))
91 self.all_resources.set_hashes()
92
93 def reserve(self, origin, want):
94 '''
95 attempt to reserve the resources specified in the dict 'want' for
96 'origin'. Obtain a lock on the resources lock dir, verify that all
97 wanted resources are available, and if yes mark them as reserved.
98
99 On success, return a reservation object which can be used to release
100 the reservation. The reservation will be freed automatically on program
101 exit, if not yet done manually.
102
103 'origin' should be an Origin() instance.
104
105 'want' is a dict matching WANT_SCHEMA, which is the same as
106 the RESOURCES_SCHEMA, except each entity that can be reserved has a 'times'
107 field added, to indicate how many of those should be reserved.
108
109 If an entry has only a 'times' set, any of the resources may be
110 reserved without further limitations.
111
112 ResourcesPool may also be selected with narrowed down constraints.
Neels Hofmeyr76d81032017-05-18 18:35:32 +0200113 This would reserve one IP address, two modems, one BTS of type
Neels Hofmeyr391afe32017-05-18 19:22:12 +0200114 sysmo and one of type trx, plus 2 ARFCNs in the 1800 band:
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200115
116 {
Neels Hofmeyr76d81032017-05-18 18:35:32 +0200117 'ip_address': [ { 'times': 1 } ],
Neels Hofmeyr391afe32017-05-18 19:22:12 +0200118 'bts': [ { 'type': 'sysmo', 'times': 1 }, { 'type': 'trx', 'times': 1 } ],
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200119 'arfcn': [ { 'band': 'GSM-1800', 'times': 2 } ],
120 'modem': [ { 'times': 2 } ],
121 }
122
123 A times=1 value is implicit, so the above is equivalent to:
124
125 {
Neels Hofmeyr76d81032017-05-18 18:35:32 +0200126 'ip_address': [ {} ],
Neels Hofmeyr391afe32017-05-18 19:22:12 +0200127 'bts': [ { 'type': 'sysmo' }, { 'type': 'trx' } ],
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200128 'arfcn': [ { 'band': 'GSM-1800', 'times': 2 } ],
129 'modem': [ { 'times': 2 } ],
130 }
131 '''
132 schema.validate(want, WANT_SCHEMA)
133
134 # replicate items that have a 'times' > 1
135 want = copy.deepcopy(want)
136 for key, item_list in want.items():
137 more_items = []
138 for item in item_list:
139 times = int(item.pop('times'))
140 if times and times > 1:
141 for i in range(times - 1):
142 more_items.append(copy.deepcopy(item))
143 item_list.extend(more_items)
144
145 origin_id = origin.origin_id()
146
147 with self.state_dir.lock(origin_id):
148 rrfile_path = self.state_dir.mk_parentdir(RESERVED_RESOURCES_FILE)
149 reserved = Resources(config.read(rrfile_path, if_missing_return={}))
Neels Hofmeyrcccbe592017-05-07 01:16:07 +0200150 to_be_reserved = self.all_resources.without(reserved).find(origin, want)
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200151
152 to_be_reserved.mark_reserved_by(origin_id)
153
154 reserved.add(to_be_reserved)
155 config.write(rrfile_path, reserved)
156
157 self.remember_to_free(to_be_reserved)
158 return ReservedResources(self, origin, to_be_reserved)
159
160 def free(self, origin, to_be_freed):
161 with self.state_dir.lock(origin.origin_id()):
162 rrfile_path = self.state_dir.mk_parentdir(RESERVED_RESOURCES_FILE)
163 reserved = Resources(config.read(rrfile_path, if_missing_return={}))
164 reserved.drop(to_be_freed)
165 config.write(rrfile_path, reserved)
166 self.forget_freed(to_be_freed)
167
168 def register_exit_handler(self):
169 if self._registered_exit_handler:
170 return
171 atexit.register(self.clean_up_registered_resources)
172 self._registered_exit_handler = True
173
174 def unregister_exit_handler(self):
175 if not self._registered_exit_handler:
176 return
177 atexit.unregister(self.clean_up_registered_resources)
178 self._registered_exit_handler = False
179
180 def clean_up_registered_resources(self):
181 if not self._remember_to_free:
182 return
183 self.free(log.Origin('atexit.clean_up_registered_resources()'),
184 self._remember_to_free)
185
186 def remember_to_free(self, to_be_reserved):
187 self.register_exit_handler()
188 if not self._remember_to_free:
189 self._remember_to_free = Resources()
190 self._remember_to_free.add(to_be_reserved)
191
192 def forget_freed(self, freed):
193 if freed is self._remember_to_free:
194 self._remember_to_free.clear()
195 else:
196 self._remember_to_free.drop(freed)
197 if not self._remember_to_free:
198 self.unregister_exit_handler()
199
200 def next_msisdn(self, origin):
201 origin_id = origin.origin_id()
202
203 with self.state_dir.lock(origin_id):
204 msisdn_path = self.state_dir.child(LAST_USED_MSISDN_FILE)
205 with log.Origin(msisdn_path):
Neels Hofmeyrf1a90292017-05-02 16:31:15 +0200206 last_msisdn = '1000'
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200207 if os.path.exists(msisdn_path):
208 if not os.path.isfile(msisdn_path):
209 raise RuntimeError('path should be a file but is not: %r' % msisdn_path)
210 with open(msisdn_path, 'r') as f:
211 last_msisdn = f.read().strip()
212 schema.msisdn(last_msisdn)
213
214 next_msisdn = util.msisdn_inc(last_msisdn)
215 with open(msisdn_path, 'w') as f:
216 f.write(next_msisdn)
217 return next_msisdn
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200218
219
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200220class NoResourceExn(Exception):
221 pass
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200222
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200223class Resources(dict):
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200224
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200225 def __init__(self, all_resources={}, do_copy=True):
226 if do_copy:
227 all_resources = copy.deepcopy(all_resources)
228 self.update(all_resources)
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200229
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200230 def drop(self, reserved, fail_if_not_found=True):
231 # protect from modifying reserved because we're the same object
232 if reserved is self:
233 raise RuntimeError('Refusing to drop a list of resources from itself.'
234 ' This is probably a bug where a list of Resources()'
235 ' should have been copied but is passed as-is.'
236 ' use Resources.clear() instead.')
237
238 for key, reserved_list in reserved.items():
239 my_list = self.get(key) or []
240
241 if my_list is reserved_list:
242 self.pop(key)
243 continue
244
245 for reserved_item in reserved_list:
246 found = False
247 reserved_hash = reserved_item.get(HASH_KEY)
248 if not reserved_hash:
249 raise RuntimeError('Resources.drop() only works with hashed items')
250
251 for i in range(len(my_list)):
252 my_item = my_list[i]
253 my_hash = my_item.get(HASH_KEY)
254 if not my_hash:
255 raise RuntimeError('Resources.drop() only works with hashed items')
256 if my_hash == reserved_hash:
257 found = True
258 my_list.pop(i)
259 break
260
261 if fail_if_not_found and not found:
262 raise RuntimeError('Asked to drop resource from a pool, but the'
263 ' resource was not found: %s = %r' % (key, reserved_item))
264
265 if not my_list:
266 self.pop(key)
267 return self
268
269 def without(self, reserved):
270 return Resources(self).drop(reserved)
271
Neels Hofmeyrcccbe592017-05-07 01:16:07 +0200272 def find(self, for_origin, want, skip_if_marked=None, do_copy=True, raise_if_missing=True, log_label='Reserving'):
Neels Hofmeyr2fade332017-05-06 23:18:23 +0200273 '''
274 Pass a dict of resource requirements, e.g.:
275 want = {
276 'bts': [ {'type': 'osmo-bts-sysmo',}, {} ],
277 'modem': [ {'times': 3} ]
278 }
279 This function tries to find a combination from the available resources that
280 matches these requiremens. The returnvalue is a dict (wrapped in a Resources class)
281 that contains the matching resources in the order of 'want' dict: in above
282 example, the returned dict would have a 'bts' list with the first item being
283 a sysmoBTS, the second item being any other available BTS.
284
285 If skip_if_marked is passed, any resource that contains this key is skipped.
286 E.g. if a BTS has the USED_KEY set like
287 reserved_resources = { 'bts' : {..., '_used': True} }
288 then this may be skipped by passing skip_if_marked='_used'
289 (or rather skip_if_marked=USED_KEY).
290
291 If do_copy is True, the returned dict is a deep copy and does not share
292 lists with any other Resources dict.
293
294 If raise_if_missing is False, this will return an empty item for any
295 resource that had no match, instead of immediately raising an exception.
296 '''
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200297 matches = {}
Neels Hofmeyr17c139e2017-04-12 02:42:02 +0200298 for key, want_list in sorted(want.items()): # sorted for deterministic test results
Neels Hofmeyrcccbe592017-05-07 01:16:07 +0200299 # here we have a resource of a given type, e.g. 'bts', with a list
300 # containing as many BTSes as the caller wants to reserve/use. Each
301 # list item contains specifics for the particular BTS.
Neels Hofmeyr2a1a1fa2017-05-29 01:36:21 +0200302 my_list = self.get(key, [])
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200303
Neels Hofmeyrf9e86932017-06-06 19:47:40 +0200304 if log_label:
305 for_origin.log(log_label, len(want_list), 'x', key, '(candidates: %d)'%len(my_list))
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200306
307 # Try to avoid a less constrained item snatching away a resource
308 # from a more detailed constrained requirement.
309
Neels Hofmeyr2fade332017-05-06 23:18:23 +0200310 # first record all matches, so that each requested item has a list
311 # of all available resources that match it. Some resources may
312 # appear for multiple requested items. Store matching indexes.
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200313 all_matches = []
314 for want_item in want_list:
315 item_match_list = []
316 for i in range(len(my_list)):
317 my_item = my_list[i]
318 if skip_if_marked and my_item.get(skip_if_marked):
319 continue
320 if item_matches(my_item, want_item, ignore_keys=('times',)):
321 item_match_list.append(i)
322 if not item_match_list:
Neels Hofmeyr9b907702017-05-06 23:20:33 +0200323 if raise_if_missing:
324 raise NoResourceExn('No matching resource available for %s = %r'
325 % (key, want_item))
326 else:
327 # this one failed... see below
328 all_matches = []
329 break
330
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200331 all_matches.append( item_match_list )
332
333 if not all_matches:
Neels Hofmeyr9b907702017-05-06 23:20:33 +0200334 # ...this one failed. Makes no sense to solve resource
335 # allocations, return an empty list for this key to mark
336 # failure.
337 matches[key] = []
338 continue
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200339
340 # figure out who gets what
Neels Hofmeyra8a05a22017-06-06 19:47:40 +0200341 try:
342 solution = solve(all_matches)
343 except NotSolvable:
344 # instead of a cryptic error message, raise an exception that
345 # conveys meaning to the user.
346 raise NoResourceExn('Could not resolve request to reserve resources: '
347 '%d x %s with requirements: %r' % (len(want_list), key, want_list))
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200348 picked = [ my_list[i] for i in solution if i is not None ]
Neels Hofmeyrcccbe592017-05-07 01:16:07 +0200349 for_origin.dbg('Picked', config.tostr(picked))
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200350 matches[key] = picked
351
352 return Resources(matches, do_copy=do_copy)
353
354 def set_hashes(self):
355 for key, item_list in self.items():
356 for item in item_list:
357 item[HASH_KEY] = util.hash_obj(item, HASH_KEY, RESERVED_KEY, USED_KEY)
358
359 def add(self, more):
360 if more is self:
361 raise RuntimeError('adding a list of resources to itself?')
362 config.add(self, copy.deepcopy(more))
363
364 def combine(self, more_rules):
365 if more_rules is self:
366 raise RuntimeError('combining a list of resource rules with itself?')
367 config.combine(self, copy.deepcopy(more))
368
369 def mark_reserved_by(self, origin_id):
370 for key, item_list in self.items():
371 for item in item_list:
372 item[RESERVED_KEY] = origin_id
373
374
Neels Hofmeyra8a05a22017-06-06 19:47:40 +0200375class NotSolvable(Exception):
376 pass
377
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200378def solve(all_matches):
379 '''
380 all_matches shall be a list of index-lists.
381 all_matches[i] is the list of indexes that item i can use.
382 Return a solution so that each i gets a different index.
383 solve([ [0, 1, 2],
384 [0],
385 [0, 2] ]) == [1, 0, 2]
386 '''
387
388 def all_differ(l):
389 return len(set(l)) == len(l)
390
391 def search_in_permutations(fixed=[]):
392 idx = len(fixed)
393 for i in range(len(all_matches[idx])):
394 val = all_matches[idx][i]
395 # don't add a val that's already in the list
396 if val in fixed:
397 continue
398 l = list(fixed)
399 l.append(val)
400 if len(l) == len(all_matches):
401 # found a solution
402 return l
403 # not at the end yet, add next digit
404 r = search_in_permutations(l)
405 if r:
406 # nested search_in_permutations() call found a solution
407 return r
408 # this entire branch yielded no solution
409 return None
410
411 if not all_matches:
412 raise RuntimeError('Cannot solve: no candidates')
413
414 solution = search_in_permutations()
415 if not solution:
Neels Hofmeyra8a05a22017-06-06 19:47:40 +0200416 raise NotSolvable('The requested resource requirements are not solvable %r'
417 % all_matches)
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200418 return solution
419
420
421def contains_hash(list_of_dicts, a_hash):
422 for d in list_of_dicts:
423 if d.get(HASH_KEY) == a_hash:
424 return True
425 return False
426
427def item_matches(item, wanted_item, ignore_keys=None):
428 if is_dict(wanted_item):
429 # match up two dicts
430 if not isinstance(item, dict):
431 return False
432 for key, wanted_val in wanted_item.items():
433 if ignore_keys and key in ignore_keys:
434 continue
435 if not item_matches(item.get(key), wanted_val, ignore_keys=ignore_keys):
436 return False
437 return True
438
439 if is_list(wanted_item):
440 # multiple possible values
441 if item not in wanted_item:
442 return False
443 return True
444
445 return item == wanted_item
446
447
448class ReservedResources(log.Origin):
449 '''
450 After all resources have been figured out, this is the API that a test case
451 gets to interact with resources. From those resources that have been
452 reserved for it, it can pick some to mark them as currently in use.
453 Functions like nitb() provide a resource by automatically picking its
454 dependencies from so far unused (but reserved) resource.
455 '''
456
457 def __init__(self, resources_pool, origin, reserved):
458 self.resources_pool = resources_pool
459 self.origin = origin
460 self.reserved = reserved
461
462 def __repr__(self):
463 return 'resources(%s)=%s' % (self.origin.name(), pprint.pformat(self.reserved))
464
465 def get(self, kind, specifics=None):
466 if specifics is None:
467 specifics = {}
468 self.dbg('requesting use of', kind, specifics=specifics)
469 want = { kind: [specifics] }
Neels Hofmeyrcccbe592017-05-07 01:16:07 +0200470 available_dict = self.reserved.find(self.origin, want, skip_if_marked=USED_KEY,
471 do_copy=False, raise_if_missing=False,
472 log_label='Using')
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200473 available = available_dict.get(kind)
474 self.dbg(available=len(available))
475 if not available:
Neels Hofmeyrf9e86932017-06-06 19:47:40 +0200476 # cook up a detailed error message for the current situation
477 kind_reserved = self.reserved.get(kind, [])
478 used_count = len([r for r in kind_reserved if USED_KEY in r])
479 matching = self.reserved.find(self.origin, want, raise_if_missing=False, log_label=None).get(kind, [])
480 if not matching:
481 msg = 'none of the reserved resources matches requirements %r' % specifics
482 elif not (used_count < len(kind_reserved)):
483 msg = 'suite.conf reserved only %d x %r.' % (len(kind_reserved), kind)
484 else:
485 msg = ('No unused resource left that matches the requirements;'
486 ' Of reserved %d x %r, %d match the requirements, but all are already in use;'
487 ' Requirements: %r'
488 % (len(kind_reserved), kind, len(matching), specifics))
489 raise NoResourceExn('When trying to use instance nr %d of %r: %s' % (used_count + 1, kind, msg))
490
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200491 pick = available[0]
492 self.dbg(using=pick)
493 assert not pick.get(USED_KEY)
494 pick[USED_KEY] = True
495 return copy.deepcopy(pick)
496
497 def put(self, item):
498 if not item.get(USED_KEY):
499 raise RuntimeError('Can only put() a resource that is used: %r' % item)
500 hash_to_put = item.get(HASH_KEY)
501 if not hash_to_put:
502 raise RuntimeError('Can only put() a resource that has a hash marker: %r' % item)
503 for key, item_list in self.reserved.items():
504 my_list = self.get(key)
505 for my_item in my_list:
506 if hash_to_put == my_item.get(HASH_KEY):
507 my_item.pop(USED_KEY)
508
509 def put_all(self):
510 for key, item_list in self.reserved.items():
511 my_list = self.get(key)
512 for my_item in my_list:
513 if my_item.get(USED_KEY):
514 my_item.pop(USED_KEY)
515
516 def free(self):
Neels Hofmeyred4e5282017-05-29 02:53:54 +0200517 if self.reserved:
518 self.resources_pool.free(self.origin, self.reserved)
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200519 self.reserved = None
520
Neels Hofmeyr2d1d5612017-05-22 20:02:41 +0200521 def counts(self):
522 counts = {}
523 for key in self.reserved.keys():
524 counts[key] = self.count(key)
525 return counts
526
527 def count(self, key):
528 return len(self.reserved.get(key) or [])
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200529
530# vim: expandtab tabstop=4 shiftwidth=4