blob: 67308070a7309937ff62263f9079bd2dc02a58be [file] [log] [blame]
Neels Hofmeyr3531a192017-03-28 14:30:28 +02001# osmo_gsm_tester: read and manage config files and global config
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +02002#
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
20# discussion for choice of config file format:
21#
22# Python syntax is insane, because it allows the config file to run arbitrary
23# python commands.
24#
25# INI file format is nice and simple, but it doesn't allow having the same
26# section numerous times (e.g. to define several modems or BTS models) and does
27# not support nesting.
28#
29# JSON has too much braces and quotes to be easy to type
30#
Neels Hofmeyr3531a192017-03-28 14:30:28 +020031# YAML formatting is lean, but:
32# - too powerful. The normal load() allows arbitrary code execution. There is
33# safe_load().
34# - allows several alternative ways of formatting, better to have just one
35# authoritative style.
36# - tries to detect types. It would be better to receive every setting as
37# simple string rather than e.g. an IMSI as an integer.
38# - e.g. an IMSI starting with a zero is interpreted as octal value, resulting
39# in super confusing error messages if the user merely forgets to quote it.
40# - does not tell me which line a config item came from, so no detailed error
41# message is possible.
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +020042#
Neels Hofmeyr3531a192017-03-28 14:30:28 +020043# The Python ConfigParserShootout page has numerous contestants, but many of
44# those seem to be not widely used / standardized or even tested.
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +020045# https://wiki.python.org/moin/ConfigParserShootout
46#
47# The optimum would be a stripped down YAML format.
48# In the lack of that, we shall go with yaml.load_safe() + a round trip
49# (feeding back to itself), converting keys to lowercase and values to string.
Neels Hofmeyr3531a192017-03-28 14:30:28 +020050# There is no solution for octal interpretations nor config file source lines
51# unless, apparently, we implement our own config parser.
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +020052
53import yaml
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +020054import os
Pau Espin Pedrol802dfe52017-09-12 13:43:40 +020055import copy
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +020056
Pau Espin Pedrolea8c3d42020-05-04 12:05:05 +020057from . import log, util, template
58from . import schema
Neels Hofmeyr3531a192017-03-28 14:30:28 +020059from .util import is_dict, is_list, Dir, get_tempdir
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +020060
Neels Hofmeyr3531a192017-03-28 14:30:28 +020061ENV_PREFIX = 'OSMO_GSM_TESTER_'
62ENV_CONF = os.getenv(ENV_PREFIX + 'CONF')
63
Neels Hofmeyrf15eaf92017-06-05 18:03:53 +020064override_conf = None
65
Neels Hofmeyr3531a192017-03-28 14:30:28 +020066DEFAULT_CONFIG_LOCATIONS = [
67 '.',
Your Name3c6673a2017-04-08 18:52:39 +020068 os.path.join(os.getenv('HOME'), '.config', 'osmo-gsm-tester'),
69 '/usr/local/etc/osmo-gsm-tester',
70 '/etc/osmo-gsm-tester'
Neels Hofmeyr3531a192017-03-28 14:30:28 +020071 ]
72
73PATHS_CONF = 'paths.conf'
Neels Hofmeyrd46ea132017-04-08 15:56:31 +020074DEFAULT_SUITES_CONF = 'default-suites.conf'
Pau Espin Pedrola2e81f52020-03-11 20:07:23 +010075DEFAULTS_CONF = 'defaults.conf'
Pau Espin Pedrol66a38912020-03-11 20:11:08 +010076RESOURCES_CONF = 'resources.conf'
77
Neels Hofmeyr3531a192017-03-28 14:30:28 +020078PATH_STATE_DIR = 'state_dir'
79PATH_SUITES_DIR = 'suites_dir'
80PATH_SCENARIOS_DIR = 'scenarios_dir'
81PATHS_SCHEMA = {
82 PATH_STATE_DIR: schema.STR,
83 PATH_SUITES_DIR: schema.STR,
84 PATH_SCENARIOS_DIR: schema.STR,
85 }
86
87PATHS_TEMPDIR_STR = '$TEMPDIR'
88
89PATHS = None
90
Your Name3c6673a2017-04-08 18:52:39 +020091def _get_config_file(basename, fail_if_missing=True):
Neels Hofmeyrf15eaf92017-06-05 18:03:53 +020092 if override_conf:
93 locations = [ override_conf ]
94 elif ENV_CONF:
Neels Hofmeyr3531a192017-03-28 14:30:28 +020095 locations = [ ENV_CONF ]
96 else:
97 locations = DEFAULT_CONFIG_LOCATIONS
98
99 for l in locations:
Neels Hofmeyref9ed2d2017-05-04 16:39:29 +0200100 real_l = os.path.realpath(l)
101 p = os.path.realpath(os.path.join(real_l, basename))
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200102 if os.path.isfile(p):
Neels Hofmeyr1a7a3f02017-06-10 01:18:27 +0200103 log.dbg('Found config file', basename, 'as', p, 'in', l, 'which is', real_l, _category=log.C_CNF)
Neels Hofmeyref9ed2d2017-05-04 16:39:29 +0200104 return (p, real_l)
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200105 if not fail_if_missing:
Your Name3c6673a2017-04-08 18:52:39 +0200106 return None, None
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200107 raise RuntimeError('configuration file not found: %r in %r' % (basename,
108 [os.path.abspath(p) for p in locations]))
109
Your Name3c6673a2017-04-08 18:52:39 +0200110def get_config_file(basename, fail_if_missing=True):
111 path, found_in = _get_config_file(basename, fail_if_missing)
112 return path
113
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200114def read_config_file(basename, validation_schema=None, if_missing_return=False):
115 fail_if_missing = True
116 if if_missing_return is not False:
117 fail_if_missing = False
118 path = get_config_file(basename, fail_if_missing=fail_if_missing)
Your Name3c6673a2017-04-08 18:52:39 +0200119 if path is None:
120 return if_missing_return
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200121 return read(path, validation_schema=validation_schema, if_missing_return=if_missing_return)
122
123def get_configured_path(label, allow_unset=False):
124 global PATHS
125
126 env_name = ENV_PREFIX + label.upper()
127 env_path = os.getenv(env_name)
128 if env_path:
Neels Hofmeyref9ed2d2017-05-04 16:39:29 +0200129 real_env_path = os.path.realpath(env_path)
Neels Hofmeyr1a7a3f02017-06-10 01:18:27 +0200130 log.dbg('Found path', label, 'as', env_path, 'in', '$' + env_name, 'which is', real_env_path, _category=log.C_CNF)
Neels Hofmeyref9ed2d2017-05-04 16:39:29 +0200131 return real_env_path
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200132
133 if PATHS is None:
Your Name3c6673a2017-04-08 18:52:39 +0200134 paths_file, found_in = _get_config_file(PATHS_CONF)
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200135 PATHS = read(paths_file, PATHS_SCHEMA)
Neels Hofmeyref9ed2d2017-05-04 16:39:29 +0200136 # sorted for deterministic regression test results
137 for key, path in sorted(PATHS.items()):
Your Name3c6673a2017-04-08 18:52:39 +0200138 if not path.startswith(os.pathsep):
Neels Hofmeyref9ed2d2017-05-04 16:39:29 +0200139 PATHS[key] = os.path.realpath(os.path.join(found_in, path))
Neels Hofmeyr1a7a3f02017-06-10 01:18:27 +0200140 log.dbg(paths_file + ': relative path', path, 'is', PATHS[key], _category=log.C_CNF)
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200141 p = PATHS.get(label)
142 if p is None and not allow_unset:
143 raise RuntimeError('missing configuration in %s: %r' % (PATHS_CONF, label))
144
Neels Hofmeyr1a7a3f02017-06-10 01:18:27 +0200145 log.dbg('Found path', label, 'as', p, _category=log.C_CNF)
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200146 if p.startswith(PATHS_TEMPDIR_STR):
147 p = os.path.join(get_tempdir(), p[len(PATHS_TEMPDIR_STR):])
Neels Hofmeyr1a7a3f02017-06-10 01:18:27 +0200148 log.dbg('Path', label, 'contained', PATHS_TEMPDIR_STR, 'and becomes', p, _category=log.C_CNF)
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200149 return p
150
151def get_state_dir():
152 return Dir(get_configured_path(PATH_STATE_DIR))
153
154def get_suites_dir():
155 return Dir(get_configured_path(PATH_SUITES_DIR))
156
157def get_scenarios_dir():
158 return Dir(get_configured_path(PATH_SCENARIOS_DIR))
159
160def read(path, validation_schema=None, if_missing_return=False):
Neels Hofmeyr1a7a3f02017-06-10 01:18:27 +0200161 log.ctx(path)
162 if not os.path.isfile(path) and if_missing_return is not False:
163 return if_missing_return
164 with open(path, 'r') as f:
165 config = yaml.safe_load(f)
166 config = _standardize(config)
167 if validation_schema:
168 schema.validate(config, validation_schema)
169 return config
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200170
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200171def write(path, config):
Neels Hofmeyr1a7a3f02017-06-10 01:18:27 +0200172 log.ctx(path)
173 with open(path, 'w') as f:
174 f.write(tostr(config))
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200175
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200176def tostr(config):
177 return _tostr(_standardize(config))
178
179def _tostr(config):
180 return yaml.dump(config, default_flow_style=False)
181
182def _standardize_item(item):
Pau Espin Pedrol7691f2d2020-02-18 12:12:01 +0100183 if item is None:
184 return None
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200185 if isinstance(item, (tuple, list)):
186 return [_standardize_item(i) for i in item]
187 if isinstance(item, dict):
188 return dict([(key.lower(), _standardize_item(val)) for key,val in item.items()])
189 return str(item)
190
191def _standardize(config):
192 config = yaml.safe_load(_tostr(_standardize_item(config)))
193 return config
194
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200195def get_defaults(for_kind):
Pau Espin Pedrola2e81f52020-03-11 20:07:23 +0100196 defaults = read_config_file(DEFAULTS_CONF, if_missing_return={})
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200197 return defaults.get(for_kind, {})
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200198
Your Name44af3412017-04-13 03:11:59 +0200199class Scenario(log.Origin, dict):
Pau Espin Pedrol6ed30122020-02-27 17:03:15 +0100200 def __init__(self, name, path, param_list=[]):
Neels Hofmeyr1a7a3f02017-06-10 01:18:27 +0200201 super().__init__(log.C_TST, name)
Your Name44af3412017-04-13 03:11:59 +0200202 self.path = path
Pau Espin Pedrol6ed30122020-02-27 17:03:15 +0100203 self.param_list = param_list
204
Pau Espin Pedrol20a49112020-04-01 19:48:41 +0200205 @classmethod
206 def count_cont_char_backward(cls, str, before_pos, c):
207 n = 0
208 i = before_pos - 1
209 while i >= 0:
210 if str[i] != c:
211 break
212 n += 1
213 i -= 1
214 return n
215
216 @classmethod
217 def split_scenario_parameters(cls, str):
218 cur_pos = 0
219 param_li = []
220 saved = ''
221 # Split into a list, but we want to escape '\,' to avoid splitting parameters containing commas.
222 while True:
223 prev_pos = cur_pos
224 cur_pos = str.find(',', prev_pos)
225 if cur_pos == -1:
226 param_li.append(str[prev_pos:])
227 break
228 if cur_pos == 0:
229 param_li.append('')
230 elif cur_pos != 0 and str[cur_pos - 1] == '\\' and cls.count_cont_char_backward(str, cur_pos, '\\') % 2 == 1:
231 saved += str[prev_pos:cur_pos - 1] + ','
232 else:
233 param_li.append(saved + str[prev_pos:cur_pos])
234 saved = ''
235 cur_pos += 1
236 i = 0
237 # Also escape '\\' -> '\'
238 while i < len(param_li):
239 param_li[i] = param_li[i].replace('\\\\', '\\')
240 i += 1
241 return param_li
242
243 @classmethod
244 def from_param_list_str(cls, name, path, param_list_str):
245 param_list = cls.split_scenario_parameters(param_list_str)
246 return cls(name, path, param_list)
247
Pau Espin Pedrol6ed30122020-02-27 17:03:15 +0100248 def read_from_file(self, validation_schema):
249 with open(self.path, 'r') as f:
250 config_str = f.read()
251 if len(self.param_list) != 0:
252 param_dict = {}
253 i = 1
254 for param in self.param_list:
255 param_dict['param' + str(i)] = param
256 i += 1
257 self.dbg(param_dict=param_dict)
258 config_str = template.render_strbuf_inline(config_str, param_dict)
259 config = yaml.safe_load(config_str)
260 config = _standardize(config)
261 if validation_schema:
262 schema.validate(config, validation_schema)
263 self.update(config)
Your Name44af3412017-04-13 03:11:59 +0200264
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200265def get_scenario(name, validation_schema=None):
266 scenarios_dir = get_scenarios_dir()
267 if not name.endswith('.conf'):
268 name = name + '.conf'
Pau Espin Pedrol6ed30122020-02-27 17:03:15 +0100269 is_parametrized_file = '@' in name
270 param_list = []
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200271 path = scenarios_dir.child(name)
Pau Espin Pedrol6ed30122020-02-27 17:03:15 +0100272 if not is_parametrized_file:
273 if not os.path.isfile(path):
274 raise RuntimeError('No such scenario file: %r' % path)
Pau Espin Pedrol20a49112020-04-01 19:48:41 +0200275 sc = Scenario(name, path)
Pau Espin Pedrol6ed30122020-02-27 17:03:15 +0100276 else: # parametrized scenario:
277 # Allow first matching complete matching names (eg: scenario@param1,param2.conf),
278 # this allows setting specific content in different files for specific values.
279 if not os.path.isfile(path):
280 # get "scenario@.conf" from "scenario@param1,param2.conf":
281 prefix_name = name[:name.index("@")+1] + '.conf'
282 path = scenarios_dir.child(prefix_name)
283 if not os.path.isfile(path):
284 raise RuntimeError('No such scenario file: %r (nor %s)' % (path, name))
285 # At this point, we have existing file path. Let's now scrap the parameter(s):
286 # get param1,param2 str from scenario@param1,param2.conf
287 param_list_str = name.split('@', 1)[1][:-len('.conf')]
Pau Espin Pedrol20a49112020-04-01 19:48:41 +0200288 sc = Scenario.from_param_list_str(name, path, param_list_str)
Pau Espin Pedrol6ed30122020-02-27 17:03:15 +0100289 sc.read_from_file(validation_schema)
Your Name44af3412017-04-13 03:11:59 +0200290 return sc
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200291
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200292def overlay(dest, src):
293 if is_dict(dest):
294 if not is_dict(src):
295 raise ValueError('cannot combine dict with a value of type: %r' % type(src))
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200296
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200297 for key, val in src.items():
Neels Hofmeyr1a7a3f02017-06-10 01:18:27 +0200298 log.ctx(key=key)
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200299 dest_val = dest.get(key)
Neels Hofmeyr1a7a3f02017-06-10 01:18:27 +0200300 dest[key] = overlay(dest_val, val)
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200301 return dest
302 if is_list(dest):
303 if not is_list(src):
304 raise ValueError('cannot combine list with a value of type: %r' % type(src))
Pau Espin Pedrol27532042017-09-15 15:31:52 +0200305 copy_len = min(len(src),len(dest))
306 for i in range(copy_len):
Pau Espin Pedrolebced952017-08-28 17:21:34 +0200307 log.ctx(idx=i)
Neels Hofmeyr1a7a3f02017-06-10 01:18:27 +0200308 dest[i] = overlay(dest[i], src[i])
Pau Espin Pedrol27532042017-09-15 15:31:52 +0200309 for i in range(copy_len, len(src)):
310 dest.append(src[i])
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200311 return dest
312 return src
Pau Espin Pedrol802dfe52017-09-12 13:43:40 +0200313
314def replicate_times(d):
Pau Espin Pedrol0b302792017-09-10 16:33:10 +0200315 '''
316 replicate items that have a "times" > 1
317
318 'd' is a dict matching WANT_SCHEMA, which is the same as
319 the RESOURCES_SCHEMA, except each entity that can be reserved has a 'times'
320 field added, to indicate how many of those should be reserved.
321 '''
Pau Espin Pedrol802dfe52017-09-12 13:43:40 +0200322 d = copy.deepcopy(d)
323 for key, item_list in d.items():
Pau Espin Pedrol26050342017-09-12 15:02:25 +0200324 idx = 0
325 while idx < len(item_list):
326 item = item_list[idx]
327 times = int(item.pop('times', 1))
328 for j in range(1, times):
329 item_list.insert(idx + j, copy.deepcopy(item))
330 idx += times
Pau Espin Pedrol802dfe52017-09-12 13:43:40 +0200331 return d
332
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200333# vim: expandtab tabstop=4 shiftwidth=4