blob: 4c24501aaf3e54c911db2c4a9056486ad88d3a7c [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
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
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
62DEFAULT_CONFIG_LOCATIONS = [
63 '.',
Your Name3c6673a2017-04-08 18:52:39 +020064 os.path.join(os.getenv('HOME'), '.config', 'osmo-gsm-tester'),
65 '/usr/local/etc/osmo-gsm-tester',
66 '/etc/osmo-gsm-tester'
Neels Hofmeyr3531a192017-03-28 14:30:28 +020067 ]
68
69PATHS_CONF = 'paths.conf'
Neels Hofmeyrd46ea132017-04-08 15:56:31 +020070DEFAULT_SUITES_CONF = 'default-suites.conf'
Neels Hofmeyr3531a192017-03-28 14:30:28 +020071PATH_STATE_DIR = 'state_dir'
72PATH_SUITES_DIR = 'suites_dir'
73PATH_SCENARIOS_DIR = 'scenarios_dir'
74PATHS_SCHEMA = {
75 PATH_STATE_DIR: schema.STR,
76 PATH_SUITES_DIR: schema.STR,
77 PATH_SCENARIOS_DIR: schema.STR,
78 }
79
80PATHS_TEMPDIR_STR = '$TEMPDIR'
81
82PATHS = None
83
Your Name3c6673a2017-04-08 18:52:39 +020084def _get_config_file(basename, fail_if_missing=True):
Neels Hofmeyr3531a192017-03-28 14:30:28 +020085 if ENV_CONF:
86 locations = [ ENV_CONF ]
87 else:
88 locations = DEFAULT_CONFIG_LOCATIONS
89
90 for l in locations:
Neels Hofmeyref9ed2d2017-05-04 16:39:29 +020091 real_l = os.path.realpath(l)
92 p = os.path.realpath(os.path.join(real_l, basename))
Neels Hofmeyr3531a192017-03-28 14:30:28 +020093 if os.path.isfile(p):
Neels Hofmeyref9ed2d2017-05-04 16:39:29 +020094 log.dbg(None, log.C_CNF, 'Found config file', basename, 'as', p, 'in', l, 'which is', real_l)
95 return (p, real_l)
Neels Hofmeyr3531a192017-03-28 14:30:28 +020096 if not fail_if_missing:
Your Name3c6673a2017-04-08 18:52:39 +020097 return None, None
Neels Hofmeyr3531a192017-03-28 14:30:28 +020098 raise RuntimeError('configuration file not found: %r in %r' % (basename,
99 [os.path.abspath(p) for p in locations]))
100
Your Name3c6673a2017-04-08 18:52:39 +0200101def get_config_file(basename, fail_if_missing=True):
102 path, found_in = _get_config_file(basename, fail_if_missing)
103 return path
104
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200105def read_config_file(basename, validation_schema=None, if_missing_return=False):
106 fail_if_missing = True
107 if if_missing_return is not False:
108 fail_if_missing = False
109 path = get_config_file(basename, fail_if_missing=fail_if_missing)
Your Name3c6673a2017-04-08 18:52:39 +0200110 if path is None:
111 return if_missing_return
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200112 return read(path, validation_schema=validation_schema, if_missing_return=if_missing_return)
113
114def get_configured_path(label, allow_unset=False):
115 global PATHS
116
117 env_name = ENV_PREFIX + label.upper()
118 env_path = os.getenv(env_name)
119 if env_path:
Neels Hofmeyref9ed2d2017-05-04 16:39:29 +0200120 real_env_path = os.path.realpath(env_path)
121 log.dbg(None, log.C_CNF, 'Found path', label, 'as', env_path, 'in', '$' + env_name, 'which is', real_env_path)
122 return real_env_path
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200123
124 if PATHS is None:
Your Name3c6673a2017-04-08 18:52:39 +0200125 paths_file, found_in = _get_config_file(PATHS_CONF)
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200126 PATHS = read(paths_file, PATHS_SCHEMA)
Neels Hofmeyref9ed2d2017-05-04 16:39:29 +0200127 # sorted for deterministic regression test results
128 for key, path in sorted(PATHS.items()):
Your Name3c6673a2017-04-08 18:52:39 +0200129 if not path.startswith(os.pathsep):
Neels Hofmeyref9ed2d2017-05-04 16:39:29 +0200130 PATHS[key] = os.path.realpath(os.path.join(found_in, path))
131 log.dbg(None, log.C_CNF, paths_file + ': relative path', path, 'is', PATHS[key])
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200132 p = PATHS.get(label)
133 if p is None and not allow_unset:
134 raise RuntimeError('missing configuration in %s: %r' % (PATHS_CONF, label))
135
Neels Hofmeyref9ed2d2017-05-04 16:39:29 +0200136 log.dbg(None, log.C_CNF, 'Found path', label, 'as', p)
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200137 if p.startswith(PATHS_TEMPDIR_STR):
138 p = os.path.join(get_tempdir(), p[len(PATHS_TEMPDIR_STR):])
Neels Hofmeyref9ed2d2017-05-04 16:39:29 +0200139 log.dbg(None, log.C_CNF, 'Path', label, 'contained', PATHS_TEMPDIR_STR, 'and becomes', p)
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200140 return p
141
142def get_state_dir():
143 return Dir(get_configured_path(PATH_STATE_DIR))
144
145def get_suites_dir():
146 return Dir(get_configured_path(PATH_SUITES_DIR))
147
148def get_scenarios_dir():
149 return Dir(get_configured_path(PATH_SCENARIOS_DIR))
150
151def read(path, validation_schema=None, if_missing_return=False):
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200152 with log.Origin(path):
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200153 if not os.path.isfile(path) and if_missing_return is not False:
154 return if_missing_return
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200155 with open(path, 'r') as f:
156 config = yaml.safe_load(f)
157 config = _standardize(config)
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200158 if validation_schema:
159 schema.validate(config, validation_schema)
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200160 return config
161
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200162def write(path, config):
163 with log.Origin(path):
164 with open(path, 'w') as f:
165 f.write(tostr(config))
166
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200167def tostr(config):
168 return _tostr(_standardize(config))
169
170def _tostr(config):
171 return yaml.dump(config, default_flow_style=False)
172
173def _standardize_item(item):
174 if isinstance(item, (tuple, list)):
175 return [_standardize_item(i) for i in item]
176 if isinstance(item, dict):
177 return dict([(key.lower(), _standardize_item(val)) for key,val in item.items()])
178 return str(item)
179
180def _standardize(config):
181 config = yaml.safe_load(_tostr(_standardize_item(config)))
182 return config
183
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200184def get_defaults(for_kind):
Neels Hofmeyr05837ad2017-04-14 04:18:06 +0200185 defaults = read_config_file('defaults.conf', if_missing_return={})
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200186 return defaults.get(for_kind, {})
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200187
Your Name44af3412017-04-13 03:11:59 +0200188class Scenario(log.Origin, dict):
189 def __init__(self, name, path):
190 self.set_name(name)
191 self.set_log_category(log.C_TST)
192 self.path = path
193
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200194def get_scenario(name, validation_schema=None):
195 scenarios_dir = get_scenarios_dir()
196 if not name.endswith('.conf'):
197 name = name + '.conf'
198 path = scenarios_dir.child(name)
199 if not os.path.isfile(path):
200 raise RuntimeError('No such scenario file: %r' % path)
Your Name44af3412017-04-13 03:11:59 +0200201 sc = Scenario(name, path)
202 sc.update(read(path, validation_schema=validation_schema))
203 return sc
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200204
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200205def add(dest, src):
206 if is_dict(dest):
207 if not is_dict(src):
208 raise ValueError('cannot add to dict a value of type: %r' % type(src))
209
210 for key, val in src.items():
211 dest_val = dest.get(key)
212 if dest_val is None:
213 dest[key] = val
214 else:
215 with log.Origin(key=key):
216 add(dest_val, val)
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200217 return
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200218 if is_list(dest):
219 if not is_list(src):
220 raise ValueError('cannot add to list a value of type: %r' % type(src))
221 dest.extend(src)
222 return
223 if dest == src:
224 return
225 raise ValueError('cannot add dicts, conflicting items (values %r and %r)'
226 % (dest, src))
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200227
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200228def combine(dest, src):
229 if is_dict(dest):
230 if not is_dict(src):
231 raise ValueError('cannot combine dict with a value of type: %r' % type(src))
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200232
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200233 for key, val in src.items():
234 dest_val = dest.get(key)
235 if dest_val is None:
236 dest[key] = val
237 else:
238 with log.Origin(key=key):
239 combine(dest_val, val)
240 return
241 if is_list(dest):
242 if not is_list(src):
243 raise ValueError('cannot combine list with a value of type: %r' % type(src))
244 for i in range(len(src)):
245 with log.Origin(idx=i):
246 combine(dest[i], src[i])
247 return
248 if dest == src:
249 return
250 raise ValueError('cannot combine dicts, conflicting items (values %r and %r)'
251 % (dest, src))
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200252
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200253def overlay(dest, src):
254 if is_dict(dest):
255 if not is_dict(src):
256 raise ValueError('cannot combine dict with a value of type: %r' % type(src))
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200257
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200258 for key, val in src.items():
259 dest_val = dest.get(key)
260 with log.Origin(key=key):
261 dest[key] = overlay(dest_val, val)
262 return dest
263 if is_list(dest):
264 if not is_list(src):
265 raise ValueError('cannot combine list with a value of type: %r' % type(src))
266 for i in range(len(src)):
267 with log.Origin(idx=i):
268 dest[i] = overlay(dest[i], src[i])
269 return dest
270 return src
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200271
272# vim: expandtab tabstop=4 shiftwidth=4