blob: a8860d3907a62431711ed9c17bd357ee99bee7c1 [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#
Pau Espin Pedrol6c6c0e82020-05-11 18:30:58 +02003# Copyright (C) 2016-2020 by sysmocom - s.f.m.c. GmbH
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +02004#
5# Author: Neels Hofmeyr <neels@hofmeyr.de>
Pau Espin Pedrol6c6c0e82020-05-11 18:30:58 +02006# Author: Pau Espin Pedrol <pespin@sysmocom.de>
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +02007#
8# This program is free software: you can redistribute it and/or modify
Harald Welte27205342017-06-03 09:51:45 +02009# it under the terms of the GNU General Public License as
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +020010# published by the Free Software Foundation, either version 3 of the
11# License, or (at your option) any later version.
12#
13# This program is distributed in the hope that it will be useful,
14# but WITHOUT ANY WARRANTY; without even the implied warranty of
15# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
Harald Welte27205342017-06-03 09:51:45 +020016# GNU General Public License for more details.
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +020017#
Harald Welte27205342017-06-03 09:51:45 +020018# You should have received a copy of the GNU General Public License
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +020019# along with this program. If not, see <http://www.gnu.org/licenses/>.
20
21# discussion for choice of config file format:
22#
23# Python syntax is insane, because it allows the config file to run arbitrary
24# python commands.
25#
26# INI file format is nice and simple, but it doesn't allow having the same
27# section numerous times (e.g. to define several modems or BTS models) and does
28# not support nesting.
29#
30# JSON has too much braces and quotes to be easy to type
31#
Neels Hofmeyr3531a192017-03-28 14:30:28 +020032# YAML formatting is lean, but:
33# - too powerful. The normal load() allows arbitrary code execution. There is
34# safe_load().
35# - allows several alternative ways of formatting, better to have just one
36# authoritative style.
37# - tries to detect types. It would be better to receive every setting as
38# simple string rather than e.g. an IMSI as an integer.
39# - e.g. an IMSI starting with a zero is interpreted as octal value, resulting
40# in super confusing error messages if the user merely forgets to quote it.
41# - does not tell me which line a config item came from, so no detailed error
42# message is possible.
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +020043#
Neels Hofmeyr3531a192017-03-28 14:30:28 +020044# The Python ConfigParserShootout page has numerous contestants, but many of
45# those seem to be not widely used / standardized or even tested.
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +020046# https://wiki.python.org/moin/ConfigParserShootout
47#
48# The optimum would be a stripped down YAML format.
49# In the lack of that, we shall go with yaml.load_safe() + a round trip
50# (feeding back to itself), converting keys to lowercase and values to string.
Neels Hofmeyr3531a192017-03-28 14:30:28 +020051# There is no solution for octal interpretations nor config file source lines
52# unless, apparently, we implement our own config parser.
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +020053
54import yaml
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +020055import os
Pau Espin Pedrol802dfe52017-09-12 13:43:40 +020056import copy
Pau Espin Pedrol6c6c0e82020-05-11 18:30:58 +020057import pprint
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +020058
Pau Espin Pedrolea8c3d42020-05-04 12:05:05 +020059from . import log, util, template
60from . import schema
Neels Hofmeyr3531a192017-03-28 14:30:28 +020061from .util import is_dict, is_list, Dir, get_tempdir
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +020062
Neels Hofmeyrf15eaf92017-06-05 18:03:53 +020063override_conf = None
64
Pau Espin Pedrol6c6c0e82020-05-11 18:30:58 +020065CFG_STATE_DIR = 'state_dir'
66CFG_SUITES_DIR = 'suites_dir'
67CFG_SCENARIOS_DIR = 'scenarios_dir'
Pau Espin Pedrole972c9c2020-05-12 15:06:55 +020068CFG_TRIAL_DIR = 'trial_dir'
Pau Espin Pedrol6c6c0e82020-05-11 18:30:58 +020069CFG_DEFAULT_SUITES_CONF = 'default_suites_conf_path'
70CFG_DEFAULTS_CONF = 'defaults_conf_path'
71CFG_RESOURCES_CONF = 'resource_conf_path'
72MAIN_CONFIG_SCHEMA = {
73 CFG_STATE_DIR: schema.STR,
Pau Espin Pedrol66ef9452020-05-25 13:26:41 +020074 CFG_SUITES_DIR + '[]': schema.STR,
75 CFG_SCENARIOS_DIR + '[]': schema.STR,
Pau Espin Pedrole972c9c2020-05-12 15:06:55 +020076 CFG_TRIAL_DIR: schema.STR,
Pau Espin Pedrol6c6c0e82020-05-11 18:30:58 +020077 CFG_DEFAULT_SUITES_CONF: schema.STR,
78 CFG_DEFAULTS_CONF: schema.STR,
79 CFG_RESOURCES_CONF: schema.STR,
Neels Hofmeyr3531a192017-03-28 14:30:28 +020080 }
81
Pau Espin Pedrol6c6c0e82020-05-11 18:30:58 +020082DF_CFG_STATE_DIR = '/var/tmp/osmo-gsm-tester/state/'
Pau Espin Pedrol66ef9452020-05-25 13:26:41 +020083DF_CFG_SUITES_DIR = ['./suites']
84DF_CFG_SCENARIOS_DIR = ['./scenarios']
Pau Espin Pedrole972c9c2020-05-12 15:06:55 +020085DF_CFG_TRIAL_DIR = './trial'
Pau Espin Pedrol6c6c0e82020-05-11 18:30:58 +020086DF_CFG_DEFAULT_SUITES_CONF = './default-suites.conf'
87DF_CFG_DEFAULTS_CONF = './defaults.conf'
88DF_CFG_RESOURCES_CONF = './resources.conf'
Neels Hofmeyr3531a192017-03-28 14:30:28 +020089
Pau Espin Pedrol6c6c0e82020-05-11 18:30:58 +020090DEFAULT_CONFIG_FILENAME = 'main.conf'
Neels Hofmeyr3531a192017-03-28 14:30:28 +020091
Pau Espin Pedrol6c6c0e82020-05-11 18:30:58 +020092DEFAULT_CONFIG_LOCATIONS = [
93 '.',
94 os.path.join(os.getenv('HOME'), '.config', 'osmo-gsm-tester', DEFAULT_CONFIG_FILENAME),
95 os.path.join('/usr/local/etc/osmo-gsm-tester', DEFAULT_CONFIG_FILENAME),
96 os.path.join('/etc/osmo-gsm-tester', DEFAULT_CONFIG_FILENAME)
97 ]
98
99MAIN_CONFIG = None
100MAIN_CONFIG_PATH = None
101
102def _find_main_config_path():
Neels Hofmeyrf15eaf92017-06-05 18:03:53 +0200103 if override_conf:
104 locations = [ override_conf ]
Pau Espin Pedrol06c82ae2020-05-07 18:15:53 +0200105 elif os.getenv('OSMO_GSM_TESTER_CONF'):
106 ENV_CONF = os.getenv('OSMO_GSM_TESTER_CONF')
Pau Espin Pedrol6c6c0e82020-05-11 18:30:58 +0200107 log.err('Using environment variable OSMO_GSM_TESTER_CONF=%s(/paths.conf) is deprecated. Rather use -c command line argument!' % ENV_CONF)
108 locations = [ ENV_CONF + 'paths.conf' ] # directory is expected in OSMO_GSM_TESTER_CONF, bakcward compatibility
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200109 else:
110 locations = DEFAULT_CONFIG_LOCATIONS
111
112 for l in locations:
Neels Hofmeyref9ed2d2017-05-04 16:39:29 +0200113 real_l = os.path.realpath(l)
Pau Espin Pedrol6c6c0e82020-05-11 18:30:58 +0200114 if os.path.isfile(real_l):
115 log.dbg('Found main configuration file in ', l, 'which is', real_l, _category=log.C_CNF)
116 return real_l
117 raise RuntimeError('Main configuration file not found in %r' % ([l for l in locations]))
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200118
Pau Espin Pedrol6c6c0e82020-05-11 18:30:58 +0200119def _get_main_config_path():
120 global MAIN_CONFIG_PATH
121 if MAIN_CONFIG_PATH is None:
122 MAIN_CONFIG_PATH = _find_main_config_path()
123 return MAIN_CONFIG_PATH
124
Pau Espin Pedrol66ef9452020-05-25 13:26:41 +0200125def main_config_path_to_abspath(val):
Pau Espin Pedrol6c6c0e82020-05-11 18:30:58 +0200126 'Relative files in main config are relative towards the config file, not towards $CWD'
Pau Espin Pedrol66ef9452020-05-25 13:26:41 +0200127 # If val is a list of paths, recurse to translate its paths.
128 if isinstance(val, list):
129 for i in range(len(val)):
130 val[i] = main_config_path_to_abspath(val[i])
131 return val
132 if not val.startswith(os.pathsep):
133 return os.path.realpath(os.path.join(os.path.dirname(_get_main_config_path()), val))
134 return val
Your Name3c6673a2017-04-08 18:52:39 +0200135
Pau Espin Pedrol6c6c0e82020-05-11 18:30:58 +0200136def _get_main_config():
137 global MAIN_CONFIG
138 if MAIN_CONFIG is None:
139 cfg = read(_get_main_config_path(), MAIN_CONFIG_SCHEMA)
Pau Espin Pedrole66e3ae2020-06-15 13:57:54 +0200140 if cfg is None:
141 cfg = {}
Pau Espin Pedrol6c6c0e82020-05-11 18:30:58 +0200142 MAIN_CONFIG = {
143 CFG_STATE_DIR: DF_CFG_STATE_DIR,
144 CFG_SUITES_DIR: DF_CFG_SUITES_DIR,
145 CFG_SCENARIOS_DIR: DF_CFG_SCENARIOS_DIR,
Pau Espin Pedrole972c9c2020-05-12 15:06:55 +0200146 CFG_TRIAL_DIR: DF_CFG_TRIAL_DIR,
Pau Espin Pedrol6c6c0e82020-05-11 18:30:58 +0200147 CFG_DEFAULT_SUITES_CONF: DF_CFG_DEFAULT_SUITES_CONF,
148 CFG_DEFAULTS_CONF: DF_CFG_DEFAULTS_CONF,
149 CFG_RESOURCES_CONF: DF_CFG_RESOURCES_CONF,
150 }
151 overlay(MAIN_CONFIG, cfg)
152 for key, path in sorted(MAIN_CONFIG.items()):
153 MAIN_CONFIG[key] = main_config_path_to_abspath(path)
154 log.dbg('MAIN CONFIG:\n' + pprint.pformat(MAIN_CONFIG), _category=log.C_CNF)
155 return MAIN_CONFIG
156
157def get_main_config_value(cfg_name, fail_if_missing=True):
158 cfg = _get_main_config()
159 f = cfg.get(cfg_name, None)
160 if f is None and fail_if_missing:
161 raise RuntimeError('Missing configuration %s' % (cfg_name))
162 return f
163
164def read_config_file(cfg_name, validation_schema=None, if_missing_return=False):
165 '''Read content of config file cfg_name (referring to key in main config).
166 If "if_missing_return" is different than False, then instead of failing it will return whatever it is stored in that arg
167 '''
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200168 fail_if_missing = True
169 if if_missing_return is not False:
170 fail_if_missing = False
Pau Espin Pedrol6c6c0e82020-05-11 18:30:58 +0200171 path = get_main_config_value(cfg_name, fail_if_missing=fail_if_missing)
Your Name3c6673a2017-04-08 18:52:39 +0200172 if path is None:
173 return if_missing_return
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200174 return read(path, validation_schema=validation_schema, if_missing_return=if_missing_return)
175
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200176def get_state_dir():
Pau Espin Pedrol6c6c0e82020-05-11 18:30:58 +0200177 return Dir(get_main_config_value(CFG_STATE_DIR))
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200178
Pau Espin Pedrol66ef9452020-05-25 13:26:41 +0200179def get_suites_dirs():
180 return [Dir(d) for d in get_main_config_value(CFG_SUITES_DIR)]
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200181
Pau Espin Pedrol66ef9452020-05-25 13:26:41 +0200182def get_scenarios_dirs():
183 return [Dir(d) for d in get_main_config_value(CFG_SCENARIOS_DIR)]
Pau Espin Pedrol6c6c0e82020-05-11 18:30:58 +0200184
185DEFAULTS_CONF = None
186def get_defaults(for_kind):
187 global DEFAULTS_CONF
188 if DEFAULTS_CONF is None:
189 DEFAULTS_CONF = read_config_file(CFG_DEFAULTS_CONF, if_missing_return={})
190 defaults = DEFAULTS_CONF.get(for_kind, {})
191 return copy.deepcopy(defaults)
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200192
193def read(path, validation_schema=None, if_missing_return=False):
Neels Hofmeyr1a7a3f02017-06-10 01:18:27 +0200194 log.ctx(path)
195 if not os.path.isfile(path) and if_missing_return is not False:
196 return if_missing_return
197 with open(path, 'r') as f:
198 config = yaml.safe_load(f)
199 config = _standardize(config)
Pau Espin Pedrole66e3ae2020-06-15 13:57:54 +0200200 if config and validation_schema:
Neels Hofmeyr1a7a3f02017-06-10 01:18:27 +0200201 schema.validate(config, validation_schema)
202 return config
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200203
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200204def write(path, config):
Neels Hofmeyr1a7a3f02017-06-10 01:18:27 +0200205 log.ctx(path)
206 with open(path, 'w') as f:
207 f.write(tostr(config))
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200208
Pau Espin Pedrol4e6b5072020-05-11 15:12:07 +0200209def fromstr(config_str, validation_schema=None):
210 config = yaml.safe_load(config_str)
211 config = _standardize(config)
212 if validation_schema is not None:
213 schema.validate(config, validation_schema)
214 return config
215
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200216def tostr(config):
217 return _tostr(_standardize(config))
218
219def _tostr(config):
220 return yaml.dump(config, default_flow_style=False)
221
222def _standardize_item(item):
Pau Espin Pedrol7691f2d2020-02-18 12:12:01 +0100223 if item is None:
224 return None
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200225 if isinstance(item, (tuple, list)):
226 return [_standardize_item(i) for i in item]
227 if isinstance(item, dict):
228 return dict([(key.lower(), _standardize_item(val)) for key,val in item.items()])
229 return str(item)
230
231def _standardize(config):
232 config = yaml.safe_load(_tostr(_standardize_item(config)))
233 return config
234
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200235def overlay(dest, src):
236 if is_dict(dest):
237 if not is_dict(src):
238 raise ValueError('cannot combine dict with a value of type: %r' % type(src))
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200239
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200240 for key, val in src.items():
Neels Hofmeyr1a7a3f02017-06-10 01:18:27 +0200241 log.ctx(key=key)
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200242 dest_val = dest.get(key)
Neels Hofmeyr1a7a3f02017-06-10 01:18:27 +0200243 dest[key] = overlay(dest_val, val)
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200244 return dest
245 if is_list(dest):
246 if not is_list(src):
247 raise ValueError('cannot combine list with a value of type: %r' % type(src))
Pau Espin Pedrol27532042017-09-15 15:31:52 +0200248 copy_len = min(len(src),len(dest))
249 for i in range(copy_len):
Pau Espin Pedrolebced952017-08-28 17:21:34 +0200250 log.ctx(idx=i)
Neels Hofmeyr1a7a3f02017-06-10 01:18:27 +0200251 dest[i] = overlay(dest[i], src[i])
Pau Espin Pedrol27532042017-09-15 15:31:52 +0200252 for i in range(copy_len, len(src)):
253 dest.append(src[i])
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200254 return dest
255 return src
Pau Espin Pedrol802dfe52017-09-12 13:43:40 +0200256
257def replicate_times(d):
Pau Espin Pedrol0b302792017-09-10 16:33:10 +0200258 '''
259 replicate items that have a "times" > 1
260
261 'd' is a dict matching WANT_SCHEMA, which is the same as
262 the RESOURCES_SCHEMA, except each entity that can be reserved has a 'times'
263 field added, to indicate how many of those should be reserved.
264 '''
Pau Espin Pedrol802dfe52017-09-12 13:43:40 +0200265 d = copy.deepcopy(d)
266 for key, item_list in d.items():
Pau Espin Pedrol26050342017-09-12 15:02:25 +0200267 idx = 0
268 while idx < len(item_list):
269 item = item_list[idx]
270 times = int(item.pop('times', 1))
271 for j in range(1, times):
272 item_list.insert(idx + j, copy.deepcopy(item))
273 idx += times
Pau Espin Pedrol802dfe52017-09-12 13:43:40 +0200274 return d
275
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200276# vim: expandtab tabstop=4 shiftwidth=4