blob: f6e81ac3837e92487ed0b1862708f1f19b2f3079 [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
55
Neels Hofmeyr3531a192017-03-28 14:30:28 +020056from . import log, schema, util
57from .util import is_dict, is_list, Dir, get_tempdir
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +020058
Neels Hofmeyr3531a192017-03-28 14:30:28 +020059ENV_PREFIX = 'OSMO_GSM_TESTER_'
60ENV_CONF = os.getenv(ENV_PREFIX + 'CONF')
61
Neels Hofmeyrf15eaf92017-06-05 18:03:53 +020062override_conf = None
63
Neels Hofmeyr3531a192017-03-28 14:30:28 +020064DEFAULT_CONFIG_LOCATIONS = [
65 '.',
Your Name3c6673a2017-04-08 18:52:39 +020066 os.path.join(os.getenv('HOME'), '.config', 'osmo-gsm-tester'),
67 '/usr/local/etc/osmo-gsm-tester',
68 '/etc/osmo-gsm-tester'
Neels Hofmeyr3531a192017-03-28 14:30:28 +020069 ]
70
71PATHS_CONF = 'paths.conf'
Neels Hofmeyrd46ea132017-04-08 15:56:31 +020072DEFAULT_SUITES_CONF = 'default-suites.conf'
Neels Hofmeyr3531a192017-03-28 14:30:28 +020073PATH_STATE_DIR = 'state_dir'
74PATH_SUITES_DIR = 'suites_dir'
75PATH_SCENARIOS_DIR = 'scenarios_dir'
76PATHS_SCHEMA = {
77 PATH_STATE_DIR: schema.STR,
78 PATH_SUITES_DIR: schema.STR,
79 PATH_SCENARIOS_DIR: schema.STR,
80 }
81
82PATHS_TEMPDIR_STR = '$TEMPDIR'
83
84PATHS = None
85
Your Name3c6673a2017-04-08 18:52:39 +020086def _get_config_file(basename, fail_if_missing=True):
Neels Hofmeyrf15eaf92017-06-05 18:03:53 +020087 if override_conf:
88 locations = [ override_conf ]
89 elif ENV_CONF:
Neels Hofmeyr3531a192017-03-28 14:30:28 +020090 locations = [ ENV_CONF ]
91 else:
92 locations = DEFAULT_CONFIG_LOCATIONS
93
94 for l in locations:
Neels Hofmeyref9ed2d2017-05-04 16:39:29 +020095 real_l = os.path.realpath(l)
96 p = os.path.realpath(os.path.join(real_l, basename))
Neels Hofmeyr3531a192017-03-28 14:30:28 +020097 if os.path.isfile(p):
Neels Hofmeyr1a7a3f02017-06-10 01:18:27 +020098 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 +020099 return (p, real_l)
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200100 if not fail_if_missing:
Your Name3c6673a2017-04-08 18:52:39 +0200101 return None, None
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200102 raise RuntimeError('configuration file not found: %r in %r' % (basename,
103 [os.path.abspath(p) for p in locations]))
104
Your Name3c6673a2017-04-08 18:52:39 +0200105def get_config_file(basename, fail_if_missing=True):
106 path, found_in = _get_config_file(basename, fail_if_missing)
107 return path
108
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200109def read_config_file(basename, validation_schema=None, if_missing_return=False):
110 fail_if_missing = True
111 if if_missing_return is not False:
112 fail_if_missing = False
113 path = get_config_file(basename, fail_if_missing=fail_if_missing)
Your Name3c6673a2017-04-08 18:52:39 +0200114 if path is None:
115 return if_missing_return
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200116 return read(path, validation_schema=validation_schema, if_missing_return=if_missing_return)
117
118def get_configured_path(label, allow_unset=False):
119 global PATHS
120
121 env_name = ENV_PREFIX + label.upper()
122 env_path = os.getenv(env_name)
123 if env_path:
Neels Hofmeyref9ed2d2017-05-04 16:39:29 +0200124 real_env_path = os.path.realpath(env_path)
Neels Hofmeyr1a7a3f02017-06-10 01:18:27 +0200125 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 +0200126 return real_env_path
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200127
128 if PATHS is None:
Your Name3c6673a2017-04-08 18:52:39 +0200129 paths_file, found_in = _get_config_file(PATHS_CONF)
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200130 PATHS = read(paths_file, PATHS_SCHEMA)
Neels Hofmeyref9ed2d2017-05-04 16:39:29 +0200131 # sorted for deterministic regression test results
132 for key, path in sorted(PATHS.items()):
Your Name3c6673a2017-04-08 18:52:39 +0200133 if not path.startswith(os.pathsep):
Neels Hofmeyref9ed2d2017-05-04 16:39:29 +0200134 PATHS[key] = os.path.realpath(os.path.join(found_in, path))
Neels Hofmeyr1a7a3f02017-06-10 01:18:27 +0200135 log.dbg(paths_file + ': relative path', path, 'is', PATHS[key], _category=log.C_CNF)
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200136 p = PATHS.get(label)
137 if p is None and not allow_unset:
138 raise RuntimeError('missing configuration in %s: %r' % (PATHS_CONF, label))
139
Neels Hofmeyr1a7a3f02017-06-10 01:18:27 +0200140 log.dbg('Found path', label, 'as', p, _category=log.C_CNF)
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200141 if p.startswith(PATHS_TEMPDIR_STR):
142 p = os.path.join(get_tempdir(), p[len(PATHS_TEMPDIR_STR):])
Neels Hofmeyr1a7a3f02017-06-10 01:18:27 +0200143 log.dbg('Path', label, 'contained', PATHS_TEMPDIR_STR, 'and becomes', p, _category=log.C_CNF)
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200144 return p
145
146def get_state_dir():
147 return Dir(get_configured_path(PATH_STATE_DIR))
148
149def get_suites_dir():
150 return Dir(get_configured_path(PATH_SUITES_DIR))
151
152def get_scenarios_dir():
153 return Dir(get_configured_path(PATH_SCENARIOS_DIR))
154
155def read(path, validation_schema=None, if_missing_return=False):
Neels Hofmeyr1a7a3f02017-06-10 01:18:27 +0200156 log.ctx(path)
157 if not os.path.isfile(path) and if_missing_return is not False:
158 return if_missing_return
159 with open(path, 'r') as f:
160 config = yaml.safe_load(f)
161 config = _standardize(config)
162 if validation_schema:
163 schema.validate(config, validation_schema)
164 return config
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200165
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200166def write(path, config):
Neels Hofmeyr1a7a3f02017-06-10 01:18:27 +0200167 log.ctx(path)
168 with open(path, 'w') as f:
169 f.write(tostr(config))
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200170
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200171def tostr(config):
172 return _tostr(_standardize(config))
173
174def _tostr(config):
175 return yaml.dump(config, default_flow_style=False)
176
177def _standardize_item(item):
178 if isinstance(item, (tuple, list)):
179 return [_standardize_item(i) for i in item]
180 if isinstance(item, dict):
181 return dict([(key.lower(), _standardize_item(val)) for key,val in item.items()])
182 return str(item)
183
184def _standardize(config):
185 config = yaml.safe_load(_tostr(_standardize_item(config)))
186 return config
187
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200188def get_defaults(for_kind):
Neels Hofmeyr05837ad2017-04-14 04:18:06 +0200189 defaults = read_config_file('defaults.conf', if_missing_return={})
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200190 return defaults.get(for_kind, {})
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200191
Your Name44af3412017-04-13 03:11:59 +0200192class Scenario(log.Origin, dict):
193 def __init__(self, name, path):
Neels Hofmeyr1a7a3f02017-06-10 01:18:27 +0200194 super().__init__(log.C_TST, name)
Your Name44af3412017-04-13 03:11:59 +0200195 self.path = path
196
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200197def get_scenario(name, validation_schema=None):
198 scenarios_dir = get_scenarios_dir()
199 if not name.endswith('.conf'):
200 name = name + '.conf'
201 path = scenarios_dir.child(name)
202 if not os.path.isfile(path):
203 raise RuntimeError('No such scenario file: %r' % path)
Your Name44af3412017-04-13 03:11:59 +0200204 sc = Scenario(name, path)
205 sc.update(read(path, validation_schema=validation_schema))
206 return sc
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200207
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200208def add(dest, src):
209 if is_dict(dest):
210 if not is_dict(src):
211 raise ValueError('cannot add to dict a value of type: %r' % type(src))
212
213 for key, val in src.items():
214 dest_val = dest.get(key)
215 if dest_val is None:
216 dest[key] = val
217 else:
Neels Hofmeyr1a7a3f02017-06-10 01:18:27 +0200218 log.ctx(key=key)
219 add(dest_val, val)
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200220 return
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200221 if is_list(dest):
222 if not is_list(src):
223 raise ValueError('cannot add to list a value of type: %r' % type(src))
224 dest.extend(src)
225 return
226 if dest == src:
227 return
228 raise ValueError('cannot add dicts, conflicting items (values %r and %r)'
229 % (dest, src))
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200230
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200231def combine(dest, src):
232 if is_dict(dest):
233 if not is_dict(src):
234 raise ValueError('cannot combine dict with a value of type: %r' % type(src))
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200235
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200236 for key, val in src.items():
Neels Hofmeyr1a7a3f02017-06-10 01:18:27 +0200237 log.ctx(key=key)
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200238 dest_val = dest.get(key)
239 if dest_val is None:
240 dest[key] = val
241 else:
Neels Hofmeyr1a7a3f02017-06-10 01:18:27 +0200242 combine(dest_val, val)
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200243 return
244 if is_list(dest):
245 if not is_list(src):
246 raise ValueError('cannot combine list with a value of type: %r' % type(src))
247 for i in range(len(src)):
Neels Hofmeyr1a7a3f02017-06-10 01:18:27 +0200248 log.ctx(idx=i)
249 combine(dest[i], src[i])
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200250 return
251 if dest == src:
252 return
253 raise ValueError('cannot combine dicts, conflicting items (values %r and %r)'
254 % (dest, src))
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200255
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200256def overlay(dest, src):
257 if is_dict(dest):
258 if not is_dict(src):
259 raise ValueError('cannot combine dict with a value of type: %r' % type(src))
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200260
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200261 for key, val in src.items():
Neels Hofmeyr1a7a3f02017-06-10 01:18:27 +0200262 log.ctx(key=key)
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200263 dest_val = dest.get(key)
Neels Hofmeyr1a7a3f02017-06-10 01:18:27 +0200264 dest[key] = overlay(dest_val, val)
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200265 return dest
266 if is_list(dest):
267 if not is_list(src):
268 raise ValueError('cannot combine list with a value of type: %r' % type(src))
269 for i in range(len(src)):
Neels Hofmeyr1a7a3f02017-06-10 01:18:27 +0200270 log.ctx(key=key)
271 dest[i] = overlay(dest[i], src[i])
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200272 return dest
273 return src
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200274# vim: expandtab tabstop=4 shiftwidth=4