blob: 2a64772ba8387a64ee66ca7300d499093dd29f41 [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
8# it under the terms of the GNU Affero General Public License as
9# 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
15# GNU Affero General Public License for more details.
16#
17# You should have received a copy of the GNU Affero General Public License
18# 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
Your Name44af3412017-04-13 03:11:59 +020032from . import bts_sysmo, bts_osmotrx, bts_octphy
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
44R_NITB_IFACE = 'nitb_iface'
45R_BTS = 'bts'
46R_ARFCN = 'arfcn'
47R_MODEM = 'modem'
48R_ALL = (R_NITB_IFACE, R_BTS, R_ARFCN, R_MODEM)
49
50RESOURCES_SCHEMA = {
51 'nitb_iface[].addr': schema.IPV4,
52 '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,
74 'osmo-bts-octphy': bts_octphy.OsmoBtsOctphy,
Neels Hofmeyr3531a192017-03-28 14:30:28 +020075 }
76
77def register_bts_type(name, clazz):
78 KNOWN_BTS_TYPES[name] = clazz
79
80class ResourcesPool(log.Origin):
81 _remember_to_free = None
82 _registered_exit_handler = False
83
84 def __init__(self):
85 self.config_path = config.get_config_file(RESOURCES_CONF)
86 self.state_dir = config.get_state_dir()
87 self.set_name(conf=self.config_path, state=self.state_dir.path)
88 self.read_conf()
89
90 def read_conf(self):
91 self.all_resources = Resources(config.read(self.config_path, RESOURCES_SCHEMA))
92 self.all_resources.set_hashes()
93
94 def reserve(self, origin, want):
95 '''
96 attempt to reserve the resources specified in the dict 'want' for
97 'origin'. Obtain a lock on the resources lock dir, verify that all
98 wanted resources are available, and if yes mark them as reserved.
99
100 On success, return a reservation object which can be used to release
101 the reservation. The reservation will be freed automatically on program
102 exit, if not yet done manually.
103
104 'origin' should be an Origin() instance.
105
106 'want' is a dict matching WANT_SCHEMA, which is the same as
107 the RESOURCES_SCHEMA, except each entity that can be reserved has a 'times'
108 field added, to indicate how many of those should be reserved.
109
110 If an entry has only a 'times' set, any of the resources may be
111 reserved without further limitations.
112
113 ResourcesPool may also be selected with narrowed down constraints.
114 This would reserve one NITB IP address, two modems, one BTS of type
115 sysmo and one of type oct, plus 2 ARFCNs in the 1800 band:
116
117 {
118 'nitb_iface': [ { 'times': 1 } ],
119 'bts': [ { 'type': 'sysmo', 'times': 1 }, { 'type': 'oct', 'times': 1 } ],
120 'arfcn': [ { 'band': 'GSM-1800', 'times': 2 } ],
121 'modem': [ { 'times': 2 } ],
122 }
123
124 A times=1 value is implicit, so the above is equivalent to:
125
126 {
127 'nitb_iface': [ {} ],
128 'bts': [ { 'type': 'sysmo' }, { 'type': 'oct' } ],
129 'arfcn': [ { 'band': 'GSM-1800', 'times': 2 } ],
130 'modem': [ { 'times': 2 } ],
131 }
132 '''
133 schema.validate(want, WANT_SCHEMA)
134
135 # replicate items that have a 'times' > 1
136 want = copy.deepcopy(want)
137 for key, item_list in want.items():
138 more_items = []
139 for item in item_list:
140 times = int(item.pop('times'))
141 if times and times > 1:
142 for i in range(times - 1):
143 more_items.append(copy.deepcopy(item))
144 item_list.extend(more_items)
145
146 origin_id = origin.origin_id()
147
148 with self.state_dir.lock(origin_id):
149 rrfile_path = self.state_dir.mk_parentdir(RESERVED_RESOURCES_FILE)
150 reserved = Resources(config.read(rrfile_path, if_missing_return={}))
Neels Hofmeyrcccbe592017-05-07 01:16:07 +0200151 to_be_reserved = self.all_resources.without(reserved).find(origin, want)
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200152
153 to_be_reserved.mark_reserved_by(origin_id)
154
155 reserved.add(to_be_reserved)
156 config.write(rrfile_path, reserved)
157
158 self.remember_to_free(to_be_reserved)
159 return ReservedResources(self, origin, to_be_reserved)
160
161 def free(self, origin, to_be_freed):
162 with self.state_dir.lock(origin.origin_id()):
163 rrfile_path = self.state_dir.mk_parentdir(RESERVED_RESOURCES_FILE)
164 reserved = Resources(config.read(rrfile_path, if_missing_return={}))
165 reserved.drop(to_be_freed)
166 config.write(rrfile_path, reserved)
167 self.forget_freed(to_be_freed)
168
169 def register_exit_handler(self):
170 if self._registered_exit_handler:
171 return
172 atexit.register(self.clean_up_registered_resources)
173 self._registered_exit_handler = True
174
175 def unregister_exit_handler(self):
176 if not self._registered_exit_handler:
177 return
178 atexit.unregister(self.clean_up_registered_resources)
179 self._registered_exit_handler = False
180
181 def clean_up_registered_resources(self):
182 if not self._remember_to_free:
183 return
184 self.free(log.Origin('atexit.clean_up_registered_resources()'),
185 self._remember_to_free)
186
187 def remember_to_free(self, to_be_reserved):
188 self.register_exit_handler()
189 if not self._remember_to_free:
190 self._remember_to_free = Resources()
191 self._remember_to_free.add(to_be_reserved)
192
193 def forget_freed(self, freed):
194 if freed is self._remember_to_free:
195 self._remember_to_free.clear()
196 else:
197 self._remember_to_free.drop(freed)
198 if not self._remember_to_free:
199 self.unregister_exit_handler()
200
201 def next_msisdn(self, origin):
202 origin_id = origin.origin_id()
203
204 with self.state_dir.lock(origin_id):
205 msisdn_path = self.state_dir.child(LAST_USED_MSISDN_FILE)
206 with log.Origin(msisdn_path):
Neels Hofmeyrf1a90292017-05-02 16:31:15 +0200207 last_msisdn = '1000'
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200208 if os.path.exists(msisdn_path):
209 if not os.path.isfile(msisdn_path):
210 raise RuntimeError('path should be a file but is not: %r' % msisdn_path)
211 with open(msisdn_path, 'r') as f:
212 last_msisdn = f.read().strip()
213 schema.msisdn(last_msisdn)
214
215 next_msisdn = util.msisdn_inc(last_msisdn)
216 with open(msisdn_path, 'w') as f:
217 f.write(next_msisdn)
218 return next_msisdn
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200219
220
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200221class NoResourceExn(Exception):
222 pass
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200223
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200224class Resources(dict):
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200225
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200226 def __init__(self, all_resources={}, do_copy=True):
227 if do_copy:
228 all_resources = copy.deepcopy(all_resources)
229 self.update(all_resources)
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200230
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200231 def drop(self, reserved, fail_if_not_found=True):
232 # protect from modifying reserved because we're the same object
233 if reserved is self:
234 raise RuntimeError('Refusing to drop a list of resources from itself.'
235 ' This is probably a bug where a list of Resources()'
236 ' should have been copied but is passed as-is.'
237 ' use Resources.clear() instead.')
238
239 for key, reserved_list in reserved.items():
240 my_list = self.get(key) or []
241
242 if my_list is reserved_list:
243 self.pop(key)
244 continue
245
246 for reserved_item in reserved_list:
247 found = False
248 reserved_hash = reserved_item.get(HASH_KEY)
249 if not reserved_hash:
250 raise RuntimeError('Resources.drop() only works with hashed items')
251
252 for i in range(len(my_list)):
253 my_item = my_list[i]
254 my_hash = my_item.get(HASH_KEY)
255 if not my_hash:
256 raise RuntimeError('Resources.drop() only works with hashed items')
257 if my_hash == reserved_hash:
258 found = True
259 my_list.pop(i)
260 break
261
262 if fail_if_not_found and not found:
263 raise RuntimeError('Asked to drop resource from a pool, but the'
264 ' resource was not found: %s = %r' % (key, reserved_item))
265
266 if not my_list:
267 self.pop(key)
268 return self
269
270 def without(self, reserved):
271 return Resources(self).drop(reserved)
272
Neels Hofmeyrcccbe592017-05-07 01:16:07 +0200273 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 +0200274 '''
275 Pass a dict of resource requirements, e.g.:
276 want = {
277 'bts': [ {'type': 'osmo-bts-sysmo',}, {} ],
278 'modem': [ {'times': 3} ]
279 }
280 This function tries to find a combination from the available resources that
281 matches these requiremens. The returnvalue is a dict (wrapped in a Resources class)
282 that contains the matching resources in the order of 'want' dict: in above
283 example, the returned dict would have a 'bts' list with the first item being
284 a sysmoBTS, the second item being any other available BTS.
285
286 If skip_if_marked is passed, any resource that contains this key is skipped.
287 E.g. if a BTS has the USED_KEY set like
288 reserved_resources = { 'bts' : {..., '_used': True} }
289 then this may be skipped by passing skip_if_marked='_used'
290 (or rather skip_if_marked=USED_KEY).
291
292 If do_copy is True, the returned dict is a deep copy and does not share
293 lists with any other Resources dict.
294
295 If raise_if_missing is False, this will return an empty item for any
296 resource that had no match, instead of immediately raising an exception.
297 '''
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200298 matches = {}
Neels Hofmeyr17c139e2017-04-12 02:42:02 +0200299 for key, want_list in sorted(want.items()): # sorted for deterministic test results
Neels Hofmeyrcccbe592017-05-07 01:16:07 +0200300 # here we have a resource of a given type, e.g. 'bts', with a list
301 # containing as many BTSes as the caller wants to reserve/use. Each
302 # list item contains specifics for the particular BTS.
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200303 my_list = self.get(key)
304
Neels Hofmeyrcccbe592017-05-07 01:16:07 +0200305 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
341 solution = solve(all_matches)
342 picked = [ my_list[i] for i in solution if i is not None ]
Neels Hofmeyrcccbe592017-05-07 01:16:07 +0200343 for_origin.dbg('Picked', config.tostr(picked))
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200344 matches[key] = picked
345
346 return Resources(matches, do_copy=do_copy)
347
348 def set_hashes(self):
349 for key, item_list in self.items():
350 for item in item_list:
351 item[HASH_KEY] = util.hash_obj(item, HASH_KEY, RESERVED_KEY, USED_KEY)
352
353 def add(self, more):
354 if more is self:
355 raise RuntimeError('adding a list of resources to itself?')
356 config.add(self, copy.deepcopy(more))
357
358 def combine(self, more_rules):
359 if more_rules is self:
360 raise RuntimeError('combining a list of resource rules with itself?')
361 config.combine(self, copy.deepcopy(more))
362
363 def mark_reserved_by(self, origin_id):
364 for key, item_list in self.items():
365 for item in item_list:
366 item[RESERVED_KEY] = origin_id
367
368
369def solve(all_matches):
370 '''
371 all_matches shall be a list of index-lists.
372 all_matches[i] is the list of indexes that item i can use.
373 Return a solution so that each i gets a different index.
374 solve([ [0, 1, 2],
375 [0],
376 [0, 2] ]) == [1, 0, 2]
377 '''
378
379 def all_differ(l):
380 return len(set(l)) == len(l)
381
382 def search_in_permutations(fixed=[]):
383 idx = len(fixed)
384 for i in range(len(all_matches[idx])):
385 val = all_matches[idx][i]
386 # don't add a val that's already in the list
387 if val in fixed:
388 continue
389 l = list(fixed)
390 l.append(val)
391 if len(l) == len(all_matches):
392 # found a solution
393 return l
394 # not at the end yet, add next digit
395 r = search_in_permutations(l)
396 if r:
397 # nested search_in_permutations() call found a solution
398 return r
399 # this entire branch yielded no solution
400 return None
401
402 if not all_matches:
403 raise RuntimeError('Cannot solve: no candidates')
404
405 solution = search_in_permutations()
406 if not solution:
407 raise NoResourceExn('The requested resource requirements are not solvable %r'
408 % all_matches)
409 return solution
410
411
412def contains_hash(list_of_dicts, a_hash):
413 for d in list_of_dicts:
414 if d.get(HASH_KEY) == a_hash:
415 return True
416 return False
417
418def item_matches(item, wanted_item, ignore_keys=None):
419 if is_dict(wanted_item):
420 # match up two dicts
421 if not isinstance(item, dict):
422 return False
423 for key, wanted_val in wanted_item.items():
424 if ignore_keys and key in ignore_keys:
425 continue
426 if not item_matches(item.get(key), wanted_val, ignore_keys=ignore_keys):
427 return False
428 return True
429
430 if is_list(wanted_item):
431 # multiple possible values
432 if item not in wanted_item:
433 return False
434 return True
435
436 return item == wanted_item
437
438
439class ReservedResources(log.Origin):
440 '''
441 After all resources have been figured out, this is the API that a test case
442 gets to interact with resources. From those resources that have been
443 reserved for it, it can pick some to mark them as currently in use.
444 Functions like nitb() provide a resource by automatically picking its
445 dependencies from so far unused (but reserved) resource.
446 '''
447
448 def __init__(self, resources_pool, origin, reserved):
449 self.resources_pool = resources_pool
450 self.origin = origin
451 self.reserved = reserved
452
453 def __repr__(self):
454 return 'resources(%s)=%s' % (self.origin.name(), pprint.pformat(self.reserved))
455
456 def get(self, kind, specifics=None):
457 if specifics is None:
458 specifics = {}
459 self.dbg('requesting use of', kind, specifics=specifics)
460 want = { kind: [specifics] }
Neels Hofmeyrcccbe592017-05-07 01:16:07 +0200461 available_dict = self.reserved.find(self.origin, want, skip_if_marked=USED_KEY,
462 do_copy=False, raise_if_missing=False,
463 log_label='Using')
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200464 available = available_dict.get(kind)
465 self.dbg(available=len(available))
466 if not available:
467 raise NoResourceExn('No unused resource found: %r%s' %
468 (kind,
469 (' matching %r' % specifics) if specifics else '')
470 )
471 pick = available[0]
472 self.dbg(using=pick)
473 assert not pick.get(USED_KEY)
474 pick[USED_KEY] = True
475 return copy.deepcopy(pick)
476
477 def put(self, item):
478 if not item.get(USED_KEY):
479 raise RuntimeError('Can only put() a resource that is used: %r' % item)
480 hash_to_put = item.get(HASH_KEY)
481 if not hash_to_put:
482 raise RuntimeError('Can only put() a resource that has a hash marker: %r' % item)
483 for key, item_list in self.reserved.items():
484 my_list = self.get(key)
485 for my_item in my_list:
486 if hash_to_put == my_item.get(HASH_KEY):
487 my_item.pop(USED_KEY)
488
489 def put_all(self):
490 for key, item_list in self.reserved.items():
491 my_list = self.get(key)
492 for my_item in my_list:
493 if my_item.get(USED_KEY):
494 my_item.pop(USED_KEY)
495
496 def free(self):
497 self.resources_pool.free(self.origin, self.reserved)
498 self.reserved = None
499
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200500
501# vim: expandtab tabstop=4 shiftwidth=4