# osmo_gsm_tester: manage resources # # Copyright (C) 2016-2017 by sysmocom - s.f.m.c. GmbH # # Author: Neels Hofmeyr # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as # published by the Free Software Foundation, either version 3 of the # License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . import os import time import copy import atexit import pprint from . import log from . import config from . import util from . import schema from . import ofono_client from . import osmo_nitb from . import bts_sysmo, bts_osmotrx, bts_octphy from .util import is_dict, is_list HASH_KEY = '_hash' RESERVED_KEY = '_reserved_by' USED_KEY = '_used' RESOURCES_CONF = 'resources.conf' LAST_USED_MSISDN_FILE = 'last_used_msisdn.state' RESERVED_RESOURCES_FILE = 'reserved_resources.state' R_NITB_IFACE = 'nitb_iface' R_BTS = 'bts' R_ARFCN = 'arfcn' R_MODEM = 'modem' R_ALL = (R_NITB_IFACE, R_BTS, R_ARFCN, R_MODEM) RESOURCES_SCHEMA = { 'nitb_iface[].addr': schema.IPV4, 'bts[].label': schema.STR, 'bts[].type': schema.STR, 'bts[].ipa_unit_id': schema.INT, 'bts[].addr': schema.IPV4, 'bts[].band': schema.BAND, 'bts[].trx_list[].hw_addr': schema.HWADDR, 'bts[].trx_list[].net_device': schema.STR, 'arfcn[].arfcn': schema.INT, 'arfcn[].band': schema.BAND, 'modem[].label': schema.STR, 'modem[].path': schema.STR, 'modem[].imsi': schema.IMSI, 'modem[].ki': schema.KI, } WANT_SCHEMA = util.dict_add( dict([('%s[].times' % r, schema.INT) for r in R_ALL]), RESOURCES_SCHEMA) KNOWN_BTS_TYPES = { 'osmo-bts-sysmo': bts_sysmo.SysmoBts, 'osmo-bts-trx': bts_osmotrx.OsmoBtsTrx, 'osmo-bts-octphy': bts_octphy.OsmoBtsOctphy, } def register_bts_type(name, clazz): KNOWN_BTS_TYPES[name] = clazz class ResourcesPool(log.Origin): _remember_to_free = None _registered_exit_handler = False def __init__(self): self.config_path = config.get_config_file(RESOURCES_CONF) self.state_dir = config.get_state_dir() self.set_name(conf=self.config_path, state=self.state_dir.path) self.read_conf() def read_conf(self): self.all_resources = Resources(config.read(self.config_path, RESOURCES_SCHEMA)) self.all_resources.set_hashes() def reserve(self, origin, want): ''' attempt to reserve the resources specified in the dict 'want' for 'origin'. Obtain a lock on the resources lock dir, verify that all wanted resources are available, and if yes mark them as reserved. On success, return a reservation object which can be used to release the reservation. The reservation will be freed automatically on program exit, if not yet done manually. 'origin' should be an Origin() instance. 'want' is a dict matching WANT_SCHEMA, which is the same as the RESOURCES_SCHEMA, except each entity that can be reserved has a 'times' field added, to indicate how many of those should be reserved. If an entry has only a 'times' set, any of the resources may be reserved without further limitations. ResourcesPool may also be selected with narrowed down constraints. This would reserve one NITB IP address, two modems, one BTS of type sysmo and one of type oct, plus 2 ARFCNs in the 1800 band: { 'nitb_iface': [ { 'times': 1 } ], 'bts': [ { 'type': 'sysmo', 'times': 1 }, { 'type': 'oct', 'times': 1 } ], 'arfcn': [ { 'band': 'GSM-1800', 'times': 2 } ], 'modem': [ { 'times': 2 } ], } A times=1 value is implicit, so the above is equivalent to: { 'nitb_iface': [ {} ], 'bts': [ { 'type': 'sysmo' }, { 'type': 'oct' } ], 'arfcn': [ { 'band': 'GSM-1800', 'times': 2 } ], 'modem': [ { 'times': 2 } ], } ''' schema.validate(want, WANT_SCHEMA) # replicate items that have a 'times' > 1 want = copy.deepcopy(want) for key, item_list in want.items(): more_items = [] for item in item_list: times = int(item.pop('times')) if times and times > 1: for i in range(times - 1): more_items.append(copy.deepcopy(item)) item_list.extend(more_items) origin_id = origin.origin_id() with self.state_dir.lock(origin_id): rrfile_path = self.state_dir.mk_parentdir(RESERVED_RESOURCES_FILE) reserved = Resources(config.read(rrfile_path, if_missing_return={})) to_be_reserved = self.all_resources.without(reserved).find(want) to_be_reserved.mark_reserved_by(origin_id) reserved.add(to_be_reserved) config.write(rrfile_path, reserved) self.remember_to_free(to_be_reserved) return ReservedResources(self, origin, to_be_reserved) def free(self, origin, to_be_freed): with self.state_dir.lock(origin.origin_id()): rrfile_path = self.state_dir.mk_parentdir(RESERVED_RESOURCES_FILE) reserved = Resources(config.read(rrfile_path, if_missing_return={})) reserved.drop(to_be_freed) config.write(rrfile_path, reserved) self.forget_freed(to_be_freed) def register_exit_handler(self): if self._registered_exit_handler: return atexit.register(self.clean_up_registered_resources) self._registered_exit_handler = True def unregister_exit_handler(self): if not self._registered_exit_handler: return atexit.unregister(self.clean_up_registered_resources) self._registered_exit_handler = False def clean_up_registered_resources(self): if not self._remember_to_free: return self.free(log.Origin('atexit.clean_up_registered_resources()'), self._remember_to_free) def remember_to_free(self, to_be_reserved): self.register_exit_handler() if not self._remember_to_free: self._remember_to_free = Resources() self._remember_to_free.add(to_be_reserved) def forget_freed(self, freed): if freed is self._remember_to_free: self._remember_to_free.clear() else: self._remember_to_free.drop(freed) if not self._remember_to_free: self.unregister_exit_handler() def next_msisdn(self, origin): origin_id = origin.origin_id() with self.state_dir.lock(origin_id): msisdn_path = self.state_dir.child(LAST_USED_MSISDN_FILE) with log.Origin(msisdn_path): last_msisdn = '1' if os.path.exists(msisdn_path): if not os.path.isfile(msisdn_path): raise RuntimeError('path should be a file but is not: %r' % msisdn_path) with open(msisdn_path, 'r') as f: last_msisdn = f.read().strip() schema.msisdn(last_msisdn) next_msisdn = util.msisdn_inc(last_msisdn) with open(msisdn_path, 'w') as f: f.write(next_msisdn) return next_msisdn class NoResourceExn(Exception): pass class Resources(dict): def __init__(self, all_resources={}, do_copy=True): if do_copy: all_resources = copy.deepcopy(all_resources) self.update(all_resources) def drop(self, reserved, fail_if_not_found=True): # protect from modifying reserved because we're the same object if reserved is self: raise RuntimeError('Refusing to drop a list of resources from itself.' ' This is probably a bug where a list of Resources()' ' should have been copied but is passed as-is.' ' use Resources.clear() instead.') for key, reserved_list in reserved.items(): my_list = self.get(key) or [] if my_list is reserved_list: self.pop(key) continue for reserved_item in reserved_list: found = False reserved_hash = reserved_item.get(HASH_KEY) if not reserved_hash: raise RuntimeError('Resources.drop() only works with hashed items') for i in range(len(my_list)): my_item = my_list[i] my_hash = my_item.get(HASH_KEY) if not my_hash: raise RuntimeError('Resources.drop() only works with hashed items') if my_hash == reserved_hash: found = True my_list.pop(i) break if fail_if_not_found and not found: raise RuntimeError('Asked to drop resource from a pool, but the' ' resource was not found: %s = %r' % (key, reserved_item)) if not my_list: self.pop(key) return self def without(self, reserved): return Resources(self).drop(reserved) def find(self, want, skip_if_marked=None, do_copy=True): matches = {} for key, want_list in sorted(want.items()): # sorted for deterministic test results with log.Origin(want=key): my_list = self.get(key) log.dbg(None, None, 'Looking for', len(want_list), 'x', key, ', candidates:', len(my_list)) # Try to avoid a less constrained item snatching away a resource # from a more detailed constrained requirement. # first record all matches all_matches = [] for want_item in want_list: item_match_list = [] for i in range(len(my_list)): my_item = my_list[i] if skip_if_marked and my_item.get(skip_if_marked): continue if item_matches(my_item, want_item, ignore_keys=('times',)): item_match_list.append(i) if not item_match_list: raise NoResourceExn('No matching resource available for %s = %r' % (key, want_item)) all_matches.append( item_match_list ) if not all_matches: raise NoResourceExn('No matching resource available for %s = %r' % (key, want_list)) # figure out who gets what solution = solve(all_matches) picked = [ my_list[i] for i in solution if i is not None ] log.dbg(None, None, 'Picked', config.tostr(picked)) matches[key] = picked return Resources(matches, do_copy=do_copy) def set_hashes(self): for key, item_list in self.items(): for item in item_list: item[HASH_KEY] = util.hash_obj(item, HASH_KEY, RESERVED_KEY, USED_KEY) def add(self, more): if more is self: raise RuntimeError('adding a list of resources to itself?') config.add(self, copy.deepcopy(more)) def combine(self, more_rules): if more_rules is self: raise RuntimeError('combining a list of resource rules with itself?') config.combine(self, copy.deepcopy(more)) def mark_reserved_by(self, origin_id): for key, item_list in self.items(): for item in item_list: item[RESERVED_KEY] = origin_id def solve(all_matches): ''' all_matches shall be a list of index-lists. all_matches[i] is the list of indexes that item i can use. Return a solution so that each i gets a different index. solve([ [0, 1, 2], [0], [0, 2] ]) == [1, 0, 2] ''' def all_differ(l): return len(set(l)) == len(l) def search_in_permutations(fixed=[]): idx = len(fixed) for i in range(len(all_matches[idx])): val = all_matches[idx][i] # don't add a val that's already in the list if val in fixed: continue l = list(fixed) l.append(val) if len(l) == len(all_matches): # found a solution return l # not at the end yet, add next digit r = search_in_permutations(l) if r: # nested search_in_permutations() call found a solution return r # this entire branch yielded no solution return None if not all_matches: raise RuntimeError('Cannot solve: no candidates') solution = search_in_permutations() if not solution: raise NoResourceExn('The requested resource requirements are not solvable %r' % all_matches) return solution def contains_hash(list_of_dicts, a_hash): for d in list_of_dicts: if d.get(HASH_KEY) == a_hash: return True return False def item_matches(item, wanted_item, ignore_keys=None): if is_dict(wanted_item): # match up two dicts if not isinstance(item, dict): return False for key, wanted_val in wanted_item.items(): if ignore_keys and key in ignore_keys: continue if not item_matches(item.get(key), wanted_val, ignore_keys=ignore_keys): return False return True if is_list(wanted_item): # multiple possible values if item not in wanted_item: return False return True return item == wanted_item class ReservedResources(log.Origin): ''' After all resources have been figured out, this is the API that a test case gets to interact with resources. From those resources that have been reserved for it, it can pick some to mark them as currently in use. Functions like nitb() provide a resource by automatically picking its dependencies from so far unused (but reserved) resource. ''' def __init__(self, resources_pool, origin, reserved): self.resources_pool = resources_pool self.origin = origin self.reserved = reserved def __repr__(self): return 'resources(%s)=%s' % (self.origin.name(), pprint.pformat(self.reserved)) def get(self, kind, specifics=None): if specifics is None: specifics = {} self.dbg('requesting use of', kind, specifics=specifics) want = { kind: [specifics] } available_dict = self.reserved.find(want, skip_if_marked=USED_KEY, do_copy=False) available = available_dict.get(kind) self.dbg(available=len(available)) if not available: raise NoResourceExn('No unused resource found: %r%s' % (kind, (' matching %r' % specifics) if specifics else '') ) pick = available[0] self.dbg(using=pick) assert not pick.get(USED_KEY) pick[USED_KEY] = True return copy.deepcopy(pick) def put(self, item): if not item.get(USED_KEY): raise RuntimeError('Can only put() a resource that is used: %r' % item) hash_to_put = item.get(HASH_KEY) if not hash_to_put: raise RuntimeError('Can only put() a resource that has a hash marker: %r' % item) for key, item_list in self.reserved.items(): my_list = self.get(key) for my_item in my_list: if hash_to_put == my_item.get(HASH_KEY): my_item.pop(USED_KEY) def put_all(self): for key, item_list in self.reserved.items(): my_list = self.get(key) for my_item in my_list: if my_item.get(USED_KEY): my_item.pop(USED_KEY) def free(self): self.resources_pool.free(self.origin, self.reserved) self.reserved = None # vim: expandtab tabstop=4 shiftwidth=4