blob: 6275a4756b6cdce2372540daddeeed4b781ae461 [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 '.',
64 os.path.join(os.getenv('HOME'), '.config', 'osmo_gsm_tester'),
65 '/usr/local/etc/osmo_gsm_tester',
66 '/etc/osmo_gsm_tester'
67 ]
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
84def get_config_file(basename, fail_if_missing=True):
85 if ENV_CONF:
86 locations = [ ENV_CONF ]
87 else:
88 locations = DEFAULT_CONFIG_LOCATIONS
89
90 for l in locations:
91 p = os.path.join(l, basename)
92 if os.path.isfile(p):
93 return p
94 if not fail_if_missing:
95 return None
96 raise RuntimeError('configuration file not found: %r in %r' % (basename,
97 [os.path.abspath(p) for p in locations]))
98
99def read_config_file(basename, validation_schema=None, if_missing_return=False):
100 fail_if_missing = True
101 if if_missing_return is not False:
102 fail_if_missing = False
103 path = get_config_file(basename, fail_if_missing=fail_if_missing)
104 return read(path, validation_schema=validation_schema, if_missing_return=if_missing_return)
105
106def get_configured_path(label, allow_unset=False):
107 global PATHS
108
109 env_name = ENV_PREFIX + label.upper()
110 env_path = os.getenv(env_name)
111 if env_path:
112 return env_path
113
114 if PATHS is None:
115 paths_file = get_config_file(PATHS_CONF)
116 PATHS = read(paths_file, PATHS_SCHEMA)
117 p = PATHS.get(label)
118 if p is None and not allow_unset:
119 raise RuntimeError('missing configuration in %s: %r' % (PATHS_CONF, label))
120
121 if p.startswith(PATHS_TEMPDIR_STR):
122 p = os.path.join(get_tempdir(), p[len(PATHS_TEMPDIR_STR):])
123 return p
124
125def get_state_dir():
126 return Dir(get_configured_path(PATH_STATE_DIR))
127
128def get_suites_dir():
129 return Dir(get_configured_path(PATH_SUITES_DIR))
130
131def get_scenarios_dir():
132 return Dir(get_configured_path(PATH_SCENARIOS_DIR))
133
134def read(path, validation_schema=None, if_missing_return=False):
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200135 with log.Origin(path):
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200136 if not os.path.isfile(path) and if_missing_return is not False:
137 return if_missing_return
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200138 with open(path, 'r') as f:
139 config = yaml.safe_load(f)
140 config = _standardize(config)
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200141 if validation_schema:
142 schema.validate(config, validation_schema)
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200143 return config
144
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200145def write(path, config):
146 with log.Origin(path):
147 with open(path, 'w') as f:
148 f.write(tostr(config))
149
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200150def tostr(config):
151 return _tostr(_standardize(config))
152
153def _tostr(config):
154 return yaml.dump(config, default_flow_style=False)
155
156def _standardize_item(item):
157 if isinstance(item, (tuple, list)):
158 return [_standardize_item(i) for i in item]
159 if isinstance(item, dict):
160 return dict([(key.lower(), _standardize_item(val)) for key,val in item.items()])
161 return str(item)
162
163def _standardize(config):
164 config = yaml.safe_load(_tostr(_standardize_item(config)))
165 return config
166
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200167def get_defaults(for_kind):
168 defaults = read_config_file('default.conf', if_missing_return={})
169 return defaults.get(for_kind, {})
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200170
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200171def get_scenario(name, validation_schema=None):
172 scenarios_dir = get_scenarios_dir()
173 if not name.endswith('.conf'):
174 name = name + '.conf'
175 path = scenarios_dir.child(name)
176 if not os.path.isfile(path):
177 raise RuntimeError('No such scenario file: %r' % path)
178 return read(path, validation_schema=validation_schema)
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200179
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200180def add(dest, src):
181 if is_dict(dest):
182 if not is_dict(src):
183 raise ValueError('cannot add to dict a value of type: %r' % type(src))
184
185 for key, val in src.items():
186 dest_val = dest.get(key)
187 if dest_val is None:
188 dest[key] = val
189 else:
190 with log.Origin(key=key):
191 add(dest_val, val)
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200192 return
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200193 if is_list(dest):
194 if not is_list(src):
195 raise ValueError('cannot add to list a value of type: %r' % type(src))
196 dest.extend(src)
197 return
198 if dest == src:
199 return
200 raise ValueError('cannot add dicts, conflicting items (values %r and %r)'
201 % (dest, src))
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200202
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200203def combine(dest, src):
204 if is_dict(dest):
205 if not is_dict(src):
206 raise ValueError('cannot combine dict with a value of type: %r' % type(src))
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200207
Neels Hofmeyr3531a192017-03-28 14:30:28 +0200208 for key, val in src.items():
209 dest_val = dest.get(key)
210 if dest_val is None:
211 dest[key] = val
212 else:
213 with log.Origin(key=key):
214 combine(dest_val, val)
215 return
216 if is_list(dest):
217 if not is_list(src):
218 raise ValueError('cannot combine list with a value of type: %r' % type(src))
219 for i in range(len(src)):
220 with log.Origin(idx=i):
221 combine(dest[i], src[i])
222 return
223 if dest == src:
224 return
225 raise ValueError('cannot combine 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 overlay(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 with log.Origin(key=key):
236 dest[key] = overlay(dest_val, val)
237 return dest
238 if is_list(dest):
239 if not is_list(src):
240 raise ValueError('cannot combine list with a value of type: %r' % type(src))
241 for i in range(len(src)):
242 with log.Origin(idx=i):
243 dest[i] = overlay(dest[i], src[i])
244 return dest
245 return src
Neels Hofmeyrdae3d3c2017-03-28 12:16:58 +0200246
247# vim: expandtab tabstop=4 shiftwidth=4