blob: 52b23c76eaae3fdb64a80af41f8eefc3f63b8f05 [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 Hofmeyrcccbe592017-05-07 01:16:07 +0200304 for_origin.log(log_label, len(want_list), 'x', key, '(candidates: %d)'%len(my_list))
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200305
306 # Try to avoid a less constrained item snatching away a resource
307 # from a more detailed constrained requirement.
308
Neels Hofmeyr2fade332017-05-06 23:18:23 +0200309 # first record all matches, so that each requested item has a list
310 # of all available resources that match it. Some resources may
311 # appear for multiple requested items. Store matching indexes.
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200312 all_matches = []
313 for want_item in want_list:
314 item_match_list = []
315 for i in range(len(my_list)):
316 my_item = my_list[i]
317 if skip_if_marked and my_item.get(skip_if_marked):
318 continue
319 if item_matches(my_item, want_item, ignore_keys=('times',)):
320 item_match_list.append(i)
321 if not item_match_list:
Neels Hofmeyr9b907702017-05-06 23:20:33 +0200322 if raise_if_missing:
323 raise NoResourceExn('No matching resource available for %s = %r'
324 % (key, want_item))
325 else:
326 # this one failed... see below
327 all_matches = []
328 break
329
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200330 all_matches.append( item_match_list )
331
332 if not all_matches:
Neels Hofmeyr9b907702017-05-06 23:20:33 +0200333 # ...this one failed. Makes no sense to solve resource
334 # allocations, return an empty list for this key to mark
335 # failure.
336 matches[key] = []
337 continue
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200338
339 # figure out who gets what
Neels Hofmeyra8a05a22017-06-06 19:47:40 +0200340 try:
341 solution = solve(all_matches)
342 except NotSolvable:
343 # instead of a cryptic error message, raise an exception that
344 # conveys meaning to the user.
345 raise NoResourceExn('Could not resolve request to reserve resources: '
346 '%d x %s with requirements: %r' % (len(want_list), key, want_list))
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200347 picked = [ my_list[i] for i in solution if i is not None ]
Neels Hofmeyrcccbe592017-05-07 01:16:07 +0200348 for_origin.dbg('Picked', config.tostr(picked))
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200349 matches[key] = picked
350
351 return Resources(matches, do_copy=do_copy)
352
353 def set_hashes(self):
354 for key, item_list in self.items():
355 for item in item_list:
356 item[HASH_KEY] = util.hash_obj(item, HASH_KEY, RESERVED_KEY, USED_KEY)
357
358 def add(self, more):
359 if more is self:
360 raise RuntimeError('adding a list of resources to itself?')
361 config.add(self, copy.deepcopy(more))
362
363 def combine(self, more_rules):
364 if more_rules is self:
365 raise RuntimeError('combining a list of resource rules with itself?')
366 config.combine(self, copy.deepcopy(more))
367
368 def mark_reserved_by(self, origin_id):
369 for key, item_list in self.items():
370 for item in item_list:
371 item[RESERVED_KEY] = origin_id
372
373
Neels Hofmeyra8a05a22017-06-06 19:47:40 +0200374class NotSolvable(Exception):
375 pass
376
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200377def solve(all_matches):
378 '''
379 all_matches shall be a list of index-lists.
380 all_matches[i] is the list of indexes that item i can use.
381 Return a solution so that each i gets a different index.
382 solve([ [0, 1, 2],
383 [0],
384 [0, 2] ]) == [1, 0, 2]
385 '''
386
387 def all_differ(l):
388 return len(set(l)) == len(l)
389
390 def search_in_permutations(fixed=[]):
391 idx = len(fixed)
392 for i in range(len(all_matches[idx])):
393 val = all_matches[idx][i]
394 # don't add a val that's already in the list
395 if val in fixed:
396 continue
397 l = list(fixed)
398 l.append(val)
399 if len(l) == len(all_matches):
400 # found a solution
401 return l
402 # not at the end yet, add next digit
403 r = search_in_permutations(l)
404 if r:
405 # nested search_in_permutations() call found a solution
406 return r
407 # this entire branch yielded no solution
408 return None
409
410 if not all_matches:
411 raise RuntimeError('Cannot solve: no candidates')
412
413 solution = search_in_permutations()
414 if not solution:
Neels Hofmeyra8a05a22017-06-06 19:47:40 +0200415 raise NotSolvable('The requested resource requirements are not solvable %r'
416 % all_matches)
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200417 return solution
418
419
420def contains_hash(list_of_dicts, a_hash):
421 for d in list_of_dicts:
422 if d.get(HASH_KEY) == a_hash:
423 return True
424 return False
425
426def item_matches(item, wanted_item, ignore_keys=None):
427 if is_dict(wanted_item):
428 # match up two dicts
429 if not isinstance(item, dict):
430 return False
431 for key, wanted_val in wanted_item.items():
432 if ignore_keys and key in ignore_keys:
433 continue
434 if not item_matches(item.get(key), wanted_val, ignore_keys=ignore_keys):
435 return False
436 return True
437
438 if is_list(wanted_item):
439 # multiple possible values
440 if item not in wanted_item:
441 return False
442 return True
443
444 return item == wanted_item
445
446
447class ReservedResources(log.Origin):
448 '''
449 After all resources have been figured out, this is the API that a test case
450 gets to interact with resources. From those resources that have been
451 reserved for it, it can pick some to mark them as currently in use.
452 Functions like nitb() provide a resource by automatically picking its
453 dependencies from so far unused (but reserved) resource.
454 '''
455
456 def __init__(self, resources_pool, origin, reserved):
457 self.resources_pool = resources_pool
458 self.origin = origin
459 self.reserved = reserved
460
461 def __repr__(self):
462 return 'resources(%s)=%s' % (self.origin.name(), pprint.pformat(self.reserved))
463
464 def get(self, kind, specifics=None):
465 if specifics is None:
466 specifics = {}
467 self.dbg('requesting use of', kind, specifics=specifics)
468 want = { kind: [specifics] }
Neels Hofmeyrcccbe592017-05-07 01:16:07 +0200469 available_dict = self.reserved.find(self.origin, want, skip_if_marked=USED_KEY,
470 do_copy=False, raise_if_missing=False,
471 log_label='Using')
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200472 available = available_dict.get(kind)
473 self.dbg(available=len(available))
474 if not available:
Neels Hofmeyr2d1d5612017-05-22 20:02:41 +0200475 raise NoResourceExn('When trying to reserve %r nr %d: No unused resource found%s' %
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200476 (kind,
Neels Hofmeyr2d1d5612017-05-22 20:02:41 +0200477 self.count(kind) + 1,
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200478 (' matching %r' % specifics) if specifics else '')
479 )
480 pick = available[0]
481 self.dbg(using=pick)
482 assert not pick.get(USED_KEY)
483 pick[USED_KEY] = True
484 return copy.deepcopy(pick)
485
486 def put(self, item):
487 if not item.get(USED_KEY):
488 raise RuntimeError('Can only put() a resource that is used: %r' % item)
489 hash_to_put = item.get(HASH_KEY)
490 if not hash_to_put:
491 raise RuntimeError('Can only put() a resource that has a hash marker: %r' % item)
492 for key, item_list in self.reserved.items():
493 my_list = self.get(key)
494 for my_item in my_list:
495 if hash_to_put == my_item.get(HASH_KEY):
496 my_item.pop(USED_KEY)
497
498 def put_all(self):
499 for key, item_list in self.reserved.items():
500 my_list = self.get(key)
501 for my_item in my_list:
502 if my_item.get(USED_KEY):
503 my_item.pop(USED_KEY)
504
505 def free(self):
Neels Hofmeyred4e5282017-05-29 02:53:54 +0200506 if self.reserved:
507 self.resources_pool.free(self.origin, self.reserved)
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200508 self.reserved = None
509
Neels Hofmeyr2d1d5612017-05-22 20:02:41 +0200510 def counts(self):
511 counts = {}
512 for key in self.reserved.keys():
513 counts[key] = self.count(key)
514 return counts
515
516 def count(self, key):
517 return len(self.reserved.get(key) or [])
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200518
519# vim: expandtab tabstop=4 shiftwidth=4