blob: 5cfbeafbd0e0d5377c023a23bfcb4b62cf31e991 [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
32from . 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
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,
Neels Hofmeyr17c139e2017-04-12 02:42:02 +020057 'bts[].trx[].hw_addr': schema.HWADDR,
Neels Hofmeyr3531a192017-03-28 14:30:28 +020058 'arfcn[].arfcn': schema.INT,
59 'arfcn[].band': schema.BAND,
60 'modem[].label': schema.STR,
61 'modem[].path': schema.STR,
62 'modem[].imsi': schema.IMSI,
63 'modem[].ki': schema.KI,
64 }
65
66WANT_SCHEMA = util.dict_add(
67 dict([('%s[].times' % r, schema.INT) for r in R_ALL]),
68 RESOURCES_SCHEMA)
69
70KNOWN_BTS_TYPES = {
71 'sysmo': bts_sysmo.SysmoBts,
72 'osmotrx': bts_osmotrx.OsmoBtsTrx,
73 }
74
75def register_bts_type(name, clazz):
76 KNOWN_BTS_TYPES[name] = clazz
77
78class ResourcesPool(log.Origin):
79 _remember_to_free = None
80 _registered_exit_handler = False
81
82 def __init__(self):
83 self.config_path = config.get_config_file(RESOURCES_CONF)
84 self.state_dir = config.get_state_dir()
85 self.set_name(conf=self.config_path, state=self.state_dir.path)
86 self.read_conf()
87
88 def read_conf(self):
89 self.all_resources = Resources(config.read(self.config_path, RESOURCES_SCHEMA))
90 self.all_resources.set_hashes()
91
92 def reserve(self, origin, want):
93 '''
94 attempt to reserve the resources specified in the dict 'want' for
95 'origin'. Obtain a lock on the resources lock dir, verify that all
96 wanted resources are available, and if yes mark them as reserved.
97
98 On success, return a reservation object which can be used to release
99 the reservation. The reservation will be freed automatically on program
100 exit, if not yet done manually.
101
102 'origin' should be an Origin() instance.
103
104 'want' is a dict matching WANT_SCHEMA, which is the same as
105 the RESOURCES_SCHEMA, except each entity that can be reserved has a 'times'
106 field added, to indicate how many of those should be reserved.
107
108 If an entry has only a 'times' set, any of the resources may be
109 reserved without further limitations.
110
111 ResourcesPool may also be selected with narrowed down constraints.
112 This would reserve one NITB IP address, two modems, one BTS of type
113 sysmo and one of type oct, plus 2 ARFCNs in the 1800 band:
114
115 {
116 'nitb_iface': [ { 'times': 1 } ],
117 'bts': [ { 'type': 'sysmo', 'times': 1 }, { 'type': 'oct', 'times': 1 } ],
118 'arfcn': [ { 'band': 'GSM-1800', 'times': 2 } ],
119 'modem': [ { 'times': 2 } ],
120 }
121
122 A times=1 value is implicit, so the above is equivalent to:
123
124 {
125 'nitb_iface': [ {} ],
126 'bts': [ { 'type': 'sysmo' }, { 'type': 'oct' } ],
127 'arfcn': [ { 'band': 'GSM-1800', 'times': 2 } ],
128 'modem': [ { 'times': 2 } ],
129 }
130 '''
131 schema.validate(want, WANT_SCHEMA)
132
133 # replicate items that have a 'times' > 1
134 want = copy.deepcopy(want)
135 for key, item_list in want.items():
136 more_items = []
137 for item in item_list:
138 times = int(item.pop('times'))
139 if times and times > 1:
140 for i in range(times - 1):
141 more_items.append(copy.deepcopy(item))
142 item_list.extend(more_items)
143
144 origin_id = origin.origin_id()
145
146 with self.state_dir.lock(origin_id):
147 rrfile_path = self.state_dir.mk_parentdir(RESERVED_RESOURCES_FILE)
148 reserved = Resources(config.read(rrfile_path, if_missing_return={}))
149 to_be_reserved = self.all_resources.without(reserved).find(want)
150
151 to_be_reserved.mark_reserved_by(origin_id)
152
153 reserved.add(to_be_reserved)
154 config.write(rrfile_path, reserved)
155
156 self.remember_to_free(to_be_reserved)
157 return ReservedResources(self, origin, to_be_reserved)
158
159 def free(self, origin, to_be_freed):
160 with self.state_dir.lock(origin.origin_id()):
161 rrfile_path = self.state_dir.mk_parentdir(RESERVED_RESOURCES_FILE)
162 reserved = Resources(config.read(rrfile_path, if_missing_return={}))
163 reserved.drop(to_be_freed)
164 config.write(rrfile_path, reserved)
165 self.forget_freed(to_be_freed)
166
167 def register_exit_handler(self):
168 if self._registered_exit_handler:
169 return
170 atexit.register(self.clean_up_registered_resources)
171 self._registered_exit_handler = True
172
173 def unregister_exit_handler(self):
174 if not self._registered_exit_handler:
175 return
176 atexit.unregister(self.clean_up_registered_resources)
177 self._registered_exit_handler = False
178
179 def clean_up_registered_resources(self):
180 if not self._remember_to_free:
181 return
182 self.free(log.Origin('atexit.clean_up_registered_resources()'),
183 self._remember_to_free)
184
185 def remember_to_free(self, to_be_reserved):
186 self.register_exit_handler()
187 if not self._remember_to_free:
188 self._remember_to_free = Resources()
189 self._remember_to_free.add(to_be_reserved)
190
191 def forget_freed(self, freed):
192 if freed is self._remember_to_free:
193 self._remember_to_free.clear()
194 else:
195 self._remember_to_free.drop(freed)
196 if not self._remember_to_free:
197 self.unregister_exit_handler()
198
199 def next_msisdn(self, origin):
200 origin_id = origin.origin_id()
201
202 with self.state_dir.lock(origin_id):
203 msisdn_path = self.state_dir.child(LAST_USED_MSISDN_FILE)
204 with log.Origin(msisdn_path):
205 last_msisdn = '1'
206 if os.path.exists(msisdn_path):
207 if not os.path.isfile(msisdn_path):
208 raise RuntimeError('path should be a file but is not: %r' % msisdn_path)
209 with open(msisdn_path, 'r') as f:
210 last_msisdn = f.read().strip()
211 schema.msisdn(last_msisdn)
212
213 next_msisdn = util.msisdn_inc(last_msisdn)
214 with open(msisdn_path, 'w') as f:
215 f.write(next_msisdn)
216 return next_msisdn
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200217
218
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200219class NoResourceExn(Exception):
220 pass
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200221
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200222class Resources(dict):
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200223
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200224 def __init__(self, all_resources={}, do_copy=True):
225 if do_copy:
226 all_resources = copy.deepcopy(all_resources)
227 self.update(all_resources)
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200228
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200229 def drop(self, reserved, fail_if_not_found=True):
230 # protect from modifying reserved because we're the same object
231 if reserved is self:
232 raise RuntimeError('Refusing to drop a list of resources from itself.'
233 ' This is probably a bug where a list of Resources()'
234 ' should have been copied but is passed as-is.'
235 ' use Resources.clear() instead.')
236
237 for key, reserved_list in reserved.items():
238 my_list = self.get(key) or []
239
240 if my_list is reserved_list:
241 self.pop(key)
242 continue
243
244 for reserved_item in reserved_list:
245 found = False
246 reserved_hash = reserved_item.get(HASH_KEY)
247 if not reserved_hash:
248 raise RuntimeError('Resources.drop() only works with hashed items')
249
250 for i in range(len(my_list)):
251 my_item = my_list[i]
252 my_hash = my_item.get(HASH_KEY)
253 if not my_hash:
254 raise RuntimeError('Resources.drop() only works with hashed items')
255 if my_hash == reserved_hash:
256 found = True
257 my_list.pop(i)
258 break
259
260 if fail_if_not_found and not found:
261 raise RuntimeError('Asked to drop resource from a pool, but the'
262 ' resource was not found: %s = %r' % (key, reserved_item))
263
264 if not my_list:
265 self.pop(key)
266 return self
267
268 def without(self, reserved):
269 return Resources(self).drop(reserved)
270
271 def find(self, want, skip_if_marked=None, do_copy=True):
272 matches = {}
Neels Hofmeyr17c139e2017-04-12 02:42:02 +0200273 for key, want_list in sorted(want.items()): # sorted for deterministic test results
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200274 with log.Origin(want=key):
275 my_list = self.get(key)
276
277 log.dbg(None, None, 'Looking for', len(want_list), 'x', key, ', candidates:', len(my_list))
278
279 # Try to avoid a less constrained item snatching away a resource
280 # from a more detailed constrained requirement.
281
282 # first record all matches
283 all_matches = []
284 for want_item in want_list:
285 item_match_list = []
286 for i in range(len(my_list)):
287 my_item = my_list[i]
288 if skip_if_marked and my_item.get(skip_if_marked):
289 continue
290 if item_matches(my_item, want_item, ignore_keys=('times',)):
291 item_match_list.append(i)
292 if not item_match_list:
293 raise NoResourceExn('No matching resource available for %s = %r'
294 % (key, want_item))
295 all_matches.append( item_match_list )
296
297 if not all_matches:
298 raise NoResourceExn('No matching resource available for %s = %r'
299 % (key, want_list))
300
301 # figure out who gets what
302 solution = solve(all_matches)
303 picked = [ my_list[i] for i in solution if i is not None ]
Neels Hofmeyr17c139e2017-04-12 02:42:02 +0200304 log.dbg(None, None, 'Picked', config.tostr(picked))
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200305 matches[key] = picked
306
307 return Resources(matches, do_copy=do_copy)
308
309 def set_hashes(self):
310 for key, item_list in self.items():
311 for item in item_list:
312 item[HASH_KEY] = util.hash_obj(item, HASH_KEY, RESERVED_KEY, USED_KEY)
313
314 def add(self, more):
315 if more is self:
316 raise RuntimeError('adding a list of resources to itself?')
317 config.add(self, copy.deepcopy(more))
318
319 def combine(self, more_rules):
320 if more_rules is self:
321 raise RuntimeError('combining a list of resource rules with itself?')
322 config.combine(self, copy.deepcopy(more))
323
324 def mark_reserved_by(self, origin_id):
325 for key, item_list in self.items():
326 for item in item_list:
327 item[RESERVED_KEY] = origin_id
328
329
330def solve(all_matches):
331 '''
332 all_matches shall be a list of index-lists.
333 all_matches[i] is the list of indexes that item i can use.
334 Return a solution so that each i gets a different index.
335 solve([ [0, 1, 2],
336 [0],
337 [0, 2] ]) == [1, 0, 2]
338 '''
339
340 def all_differ(l):
341 return len(set(l)) == len(l)
342
343 def search_in_permutations(fixed=[]):
344 idx = len(fixed)
345 for i in range(len(all_matches[idx])):
346 val = all_matches[idx][i]
347 # don't add a val that's already in the list
348 if val in fixed:
349 continue
350 l = list(fixed)
351 l.append(val)
352 if len(l) == len(all_matches):
353 # found a solution
354 return l
355 # not at the end yet, add next digit
356 r = search_in_permutations(l)
357 if r:
358 # nested search_in_permutations() call found a solution
359 return r
360 # this entire branch yielded no solution
361 return None
362
363 if not all_matches:
364 raise RuntimeError('Cannot solve: no candidates')
365
366 solution = search_in_permutations()
367 if not solution:
368 raise NoResourceExn('The requested resource requirements are not solvable %r'
369 % all_matches)
370 return solution
371
372
373def contains_hash(list_of_dicts, a_hash):
374 for d in list_of_dicts:
375 if d.get(HASH_KEY) == a_hash:
376 return True
377 return False
378
379def item_matches(item, wanted_item, ignore_keys=None):
380 if is_dict(wanted_item):
381 # match up two dicts
382 if not isinstance(item, dict):
383 return False
384 for key, wanted_val in wanted_item.items():
385 if ignore_keys and key in ignore_keys:
386 continue
387 if not item_matches(item.get(key), wanted_val, ignore_keys=ignore_keys):
388 return False
389 return True
390
391 if is_list(wanted_item):
392 # multiple possible values
393 if item not in wanted_item:
394 return False
395 return True
396
397 return item == wanted_item
398
399
400class ReservedResources(log.Origin):
401 '''
402 After all resources have been figured out, this is the API that a test case
403 gets to interact with resources. From those resources that have been
404 reserved for it, it can pick some to mark them as currently in use.
405 Functions like nitb() provide a resource by automatically picking its
406 dependencies from so far unused (but reserved) resource.
407 '''
408
409 def __init__(self, resources_pool, origin, reserved):
410 self.resources_pool = resources_pool
411 self.origin = origin
412 self.reserved = reserved
413
414 def __repr__(self):
415 return 'resources(%s)=%s' % (self.origin.name(), pprint.pformat(self.reserved))
416
417 def get(self, kind, specifics=None):
418 if specifics is None:
419 specifics = {}
420 self.dbg('requesting use of', kind, specifics=specifics)
421 want = { kind: [specifics] }
422 available_dict = self.reserved.find(want, skip_if_marked=USED_KEY, do_copy=False)
423 available = available_dict.get(kind)
424 self.dbg(available=len(available))
425 if not available:
426 raise NoResourceExn('No unused resource found: %r%s' %
427 (kind,
428 (' matching %r' % specifics) if specifics else '')
429 )
430 pick = available[0]
431 self.dbg(using=pick)
432 assert not pick.get(USED_KEY)
433 pick[USED_KEY] = True
434 return copy.deepcopy(pick)
435
436 def put(self, item):
437 if not item.get(USED_KEY):
438 raise RuntimeError('Can only put() a resource that is used: %r' % item)
439 hash_to_put = item.get(HASH_KEY)
440 if not hash_to_put:
441 raise RuntimeError('Can only put() a resource that has a hash marker: %r' % item)
442 for key, item_list in self.reserved.items():
443 my_list = self.get(key)
444 for my_item in my_list:
445 if hash_to_put == my_item.get(HASH_KEY):
446 my_item.pop(USED_KEY)
447
448 def put_all(self):
449 for key, item_list in self.reserved.items():
450 my_list = self.get(key)
451 for my_item in my_list:
452 if my_item.get(USED_KEY):
453 my_item.pop(USED_KEY)
454
455 def free(self):
456 self.resources_pool.free(self.origin, self.reserved)
457 self.reserved = None
458
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200459
460# vim: expandtab tabstop=4 shiftwidth=4