Rename cnfvar module to cnfvar_old
[pyi2ncommon] / src / simple_cnf.py
CommitLineData
11cbb815
PD
1# The software in this package is distributed under the GNU General
2# Public License version 2 (with a special exception described below).
3#
4# A copy of GNU General Public License (GPL) is included in this distribution,
5# in the file COPYING.GPL.
6#
7# As a special exception, if other files instantiate templates or use macros
8# or inline functions from this file, or you compile this file and link it
9# with other works to produce a work based on this file, this file
10# does not by itself cause the resulting work to be covered
11# by the GNU General Public License.
12#
13# However the source code for this file must still be made available
14# in accordance with section (3) of the GNU General Public License.
15#
16# This exception does not invalidate any other reasons why a work based
17# on this file might be covered by the GNU General Public License.
18#
19# Copyright (c) 2016-2018 Intra2net AG <info@intra2net.com>
20
f49f6323
PD
21"""
22
23SUMMARY
24------------------------------------------------------
25Read / write / merge guest cnf var sets, even on host.
26
27Copyright: Intra2net AG
28
29
30CONTENTS
31------------------------------------------------------
32
33This module can be viewed as a convenience-wrapper around module
34:py:mod:`cnfvar`. It uses many of its function but provides some higher-level
35interfaces, most of all class :py:class:`SimpleCnf`. It is completely
36independent of the :py:mod:`cnfline` package and its included subclasses
37(modules in `shared.cnfline`, starting with ``build_`` and ``configure_``).
38
39Class :py:class:`SimpleCnf` represents a hierarchical set of conf vars and
40provides functions to deal with that hierarchy. Under the hood, all functions
41here (and probably also in :py:mod:`cnfvar`) work with conf vars represented as
42dictionaries and lists thereof. Conf var dicts have keys `number`, `varname`,
43`instance`, `data`, `comment` and possibly `parent` and/or `children`.
1c21e862 44`varname` is a regular lower-case string, `data` is a (utf8) string, `comment`
f49f6323
PD
45is usually None, `number`, `parent` and `instance` are int. If a conf var has
46children, then this is a list of conf var dicts. `parent` is only present if a
47conf-var is a child. Several conf vars, if not wrapped in a
48:py:class:`SimpleCnf`, appear either as simple list of conf var dicts or as a
49dict with a single key `cnf` whose value is a list of conf var dicts. (Function
50:py:func:`get_cnf` returns the former given the latter).
51
52.. todo:: Exceptions Invalid[Json]Cnf are used inconsistently (e.g. check type
53 of function arguments `-->` should be ValueError) and difference
54 between them is unclear. Also name differs only in case from
55 :py:class:`cnfvar.InvalidCNF`
56
2dd87078 57
f49f6323
PD
58INTERFACE
59------------------------------------------------------
60"""
61
62import os
30521dad 63import json
f49f6323
PD
64import tempfile
65import time
66import logging
3de8b4d8 67log = logging.getLogger('pyi2ncommon.simple_cnf')
f49f6323 68
30521dad 69from . import arnied_wrapper
7abff5a7 70from . import cnfvar_old
30521dad 71from . import sysmisc
f49f6323
PD
72
73###############################################################################
74# constants
75###############################################################################
76
77#: timeout for copying temporary config files to VM objects (seconds)
78COPY_FILES_TIMEOUT = 15
79
80#: additional location of configuration files
81ADD_CNFFILE_PATH = "/tmp/configs"
82
83
84###############################################################################
85# EXCEPTIONS
86###############################################################################
87
88
89class InvalidCnf(Exception):
90 """Exception that indicates a general problem with conf var processing."""
91
92 def __init__(self, m):
93 """Create an invalid config exception."""
94 msg = "Invalid CNF_VAR: %s" % m
95 super(InvalidCnf, self).__init__(msg)
96 self.msg = msg
97 self.pfx = "[CNF]"
98
99 def __str__(self):
100 """Get a string version of the exception message."""
101 return "%s %s" % (self.pfx, self.msg)
102
103
104class InvalidJsonCnf(InvalidCnf):
105 """Exception that indicates a general problem with conf var processing."""
106
107 def __init__(self, m):
108 """Create an invalid JSON config exception."""
109 super(InvalidJsonCnf, self).__init__(m)
110 self.pfx = "[CNF:JSON]"
111
112
113###############################################################################
114# auxiliary functions
115###############################################################################
116
117
118def get_cnf(cnf):
119 """
120 "Convert" a config dict to a list of conf var dicts.
121
122 This just removes the top-level 'cnf' key and returns its value.
123
124 :param cnf: config dictionary
125 :type cnf: {str, [dict]}
126 :returns: list of cnf var dicts
127 :rtype: [{str, int or str or None}]
128 :raises: :py:class:`InvalidJsonCnf` if there is no `cnf` field found
129 """
130 cnf_vars = cnf.get("cnf")
131 if cnf_vars is None:
132 raise InvalidJsonCnf("toplevel \"cnf\" field required")
133 return cnf_vars
134
135
136def gen_tmpname():
137 """
138 Get a (quite) safe temporary file name for config file.
139
140 :returns: temporary file name
141 :rtype: str
142 """
143 now = time.time()
144 file_handle, file_name = tempfile.mkstemp(prefix="simple_%d_" % int(now),
145 suffix=".cnf")
146 os.close(file_handle)
147 os.unlink(file_name)
148 return file_name
149
150
151def set_values(cnf_vars, replacements):
152 """
153 Recursively replace values in configuration
154
155 Works in-place, meaning that no new configuration is created and returned
156 but instead argument `cnf_vars` is modified (and nothing returned).
157
158 :param cnf_vars: config where replacements are to be made
159 :type cnf_vars: [{str, int or str or None}] or {str, [dict]}
160 :param replacements: what to replace and what to replace it with
161 :type replacements: {str, str} or [(str, str)]
162 :raises: :py:class:`InvalidJsonCnf` if cnf_vars is neither dict or list
163 """
164 # determine set replace_me of keys to replace and function get that returns
165 # value for key or empty string if key not in replacements
166 replace_me = None
167 get = None
168 if isinstance(replacements, dict):
169 replace_me = set(k.lower() for k in replacements.keys())
170 get = lambda var: str(replacements.get(var, "")) # pylint: disable=function-redefined
171 elif isinstance(replacements, list):
172 replace_me = set(r[0].lower() for r in replacements)
173
174 def get(var): # pylint: disable=function-redefined
175 """Get replacement value for given variable name."""
176 try:
177 return str(next(r[1] for r in replacements if r[0] == var))
178 except StopIteration:
179 return ""
180 else:
181 raise TypeError("replacements must be dictionary or key-value list")
182
183 # check type of arg "cnf_vars"
184 if isinstance(cnf_vars, dict):
185 cnf_vars = cnf_vars["cnf"] # operate on the var list
186 if not isinstance(cnf_vars, list):
187 raise InvalidJsonCnf("ill-formed CNF_VAR: expected list, got %s (%s)"
188 % (type(cnf_vars), cnf_vars))
189
190 def aux(varlist):
191 """Internal recursive function to replace values."""
192 for var in varlist:
193 varname = var["varname"].lower()
194 if varname in replace_me:
195 var["data"] = str(get(varname))
196 children = var.get("children", None)
197 if children is not None:
198 aux(children)
199
200 # apply function on complete cnf_vars
201 aux(cnf_vars)
202
203
204def lookup_cnf_file(fname):
205 """
206 Searches for config file with given name in default locations.
207
208 :param str fname: file name of config file (without path)
209 :returns: first existing config file found in default locations
210 :rtype: str
211 :raises: :py:class:`IOError` if no such config file was found
212 """
213 locations = [arnied_wrapper.SRC_CONFIG_DIR, ADD_CNFFILE_PATH]
214 for path in locations:
215 fullpath = os.path.join(path, fname)
216 if os.path.isfile(fullpath):
217 return fullpath
218 raise IOError("config file %s does not exist in any of the readable "
219 "locations %s" % (fname, locations))
220
221
222###############################################################################
223# primary class
224###############################################################################
225
226
227class SimpleCnf(object):
228 """
229 Representation of hierarchical configuration of variables.
230
231 Based on C++ `cnf_vars` as visualized by *get_cnf*.
232
6ac093df
CH
233 Internal data representation: list of conf var dicts; see module doc for
234 details
f49f6323
PD
235 """
236
237 def __init__(self, cnf=None):
238 """
239 Creates a simple configuration.
240
6ac093df
CH
241 Does not check whether given cnf list contains only valid data.
242 Does not recurse into dicts.
243
f49f6323 244 :param cnf: initial set of conf var data (default: None = empty conf)
ce20f5b6 245 :type cnf: list or anything that :py:func:`get_cnf` can read
f49f6323 246 """
ce20f5b6
PG
247 if cnf is None:
248 self.__cnfvars = []
249 elif isinstance(cnf, list):
f49f6323 250 self.__cnfvars = cnf
ce20f5b6 251 elif isinstance(cnf, dict):
f49f6323
PD
252 self.__cnfvars = get_cnf(cnf)
253 else:
ce20f5b6 254 raise InvalidCnf ("cannot handle %s type inputs" % type (cnf))
f49f6323
PD
255
256 def _find_new_number(self, cnf_vars):
257 """Recursive helper function to find new unique (line) number."""
258 if not cnf_vars:
259 return 1
260 new_numbers = [1, ] # in case cnf_vars is empty
261 for cnf_var in cnf_vars:
262 new_numbers.append(cnf_var['number'] + 1)
263 try:
264 new_numbers.append(self._find_new_number(cnf_var['children']))
265 except KeyError:
266 pass
267 return max(new_numbers) # this is max(all numbers) + 1
268
269 def _find_new_instance(self, varname):
270 """
271 Find an instance number for variable with non-unique varname.
272
273 Will only check on top level, is not recursive.
274
1c21e862 275 :param str varname: name of conf var; will be converted to lower-case
f49f6323
PD
276 :returns: instance number for which there is no other conf var of same
277 name (0 if there is not other conf var with that name)
278 :rtype: int
279 """
280 result = 0
cfcef185 281 varname = varname.lower()
f49f6323
PD
282 for entry in self.__cnfvars:
283 if entry['varname'] == varname:
284 result = max(result, entry['number']+1)
285 return result
286
287 def add(self, varname, data='', number=None, instance=None, children=None):
288 """
289 Add a cnf var to config on top level.
290
1c21e862 291 :param str varname: name of conf var; only required arg; case ignored
f49f6323
PD
292 :param str data: conf var's value
293 :param int number: line number of that conf var; if given as None
294 (default) the function looks through config to find
295 a new number that is not taken; must be positive!
296 Value will be ignored if children are given.
297 :param int instance: Instance of the new conf var or None (default).
298 If None, then function looks through config to
299 find a new unique instance number
300 :param children: child confs for given conf var. Children's parent
301 and line attributes will be set in this function
302 :type children: :py:class:`SimpleCnf`
303 """
304 if instance is None:
305 instance = self._find_new_instance(varname)
306 if children:
307 number = self._find_new_number(self.__cnfvars) # need top number
308 new_children = []
309 for child in children:
310 new_dict = child.get_single_dict()
311 new_dict['parent'] = number
312 new_children.append(new_dict)
7abff5a7 313 cnfvar_old.renumber_vars({'cnf':new_children}, number)
f49f6323
PD
314 children = new_children
315 elif number is None:
316 number = self._find_new_number(self.__cnfvars)
317
cfcef185 318 new_var = dict(varname=varname.lower(), data=data,
f49f6323
PD
319 number=number, comment=None, instance=instance)
320 if children:
321 new_var['children'] = children # only add if non-empty
322 self.__cnfvars.append(new_var)
323
55e596ea
PG
324 def add_single(self, varname, data=u'', number=None):
325 """
326 Add a single cnf var to config on top level.
327
328 Compatibility API.
329 """
330 return self.add (varname, data=data, number=number)
331
f49f6323
PD
332 def append_file_generic(self, reader, cnf, replacements=None):
333 """
334 Append conf var data from file.
335
336 If `replacements` are given, calls :py:meth:`set_values` with these
337 before adding values to config.
338
339 :param cnf: file name or dictionary of conf vars
340 :type cnf: str or {str, int or str or None}
341 :param replacements: see help in :py:meth:`set_values`
342 """
343 log.info("append CNF_VARs from file")
344 new_vars = None
345 if callable(reader) is False:
346 raise TypeError("append_file_generic: reader must be callable, "
347 "not %s" % type(reader))
348 if isinstance(cnf, dict):
349 new_vars = get_cnf(cnf)
350 elif isinstance(cnf, str):
351 fullpath = lookup_cnf_file(cnf)
352 with open(fullpath, "rb") as chan:
353 cnfdata = chan.read()
354 tmp = reader(cnfdata)
355 new_vars = get_cnf(tmp)
356 if new_vars is None:
357 raise InvalidCnf("Cannot append object \"%s\" of type \"%s\"."
358 % (cnf, type(cnf)))
359
360 if replacements is not None:
361 set_values(new_vars, replacements)
362
15b856e5 363 self.__cnfvars.extend(new_vars)
f49f6323
PD
364
365 def append_file(self, cnf, replacements=None):
366 """Append conf var data from file."""
7abff5a7 367 return self.append_file_generic(cnfvar_old.read_cnf, cnf,
f49f6323
PD
368 replacements=replacements)
369
370 def append_file_json(self, cnf, replacements=None):
371 """Append conf var data from json file."""
7abff5a7 372 return self.append_file_generic(cnfvar_old.read_cnf_json, cnf,
f49f6323
PD
373 replacements=replacements)
374
375 def append_guest_vars(self, vm=None, varname=None, replacements=None):
376 """
377 Append content from machine's "real" config to this object.
378
379 Runs `get_cnf -j [varname]` on local host or VM (depending on arg
380 `vm`), converts output and appends it to this objects' conf var set.
381 If replacements are given, runs :py:meth:`set_values`, first.
382
383 :param vm: a guest vm or None to run on local host
384 :type vm: VM object or None
385 :param str varname: optional root of conf vars to append. If given as
386 None (default), append complete conf
387 :param replacements: see help in :py:meth:`set_values`
388 """
389 cnf = arnied_wrapper.get_cnfvar(varname=varname, vm=vm)
390 new_vars = get_cnf(cnf)
391
392 log.info("apply substitutions to extracted CNF_VARs")
393 if replacements is not None:
394 set_values(new_vars, replacements)
395
396 current = self.__cnfvars
397 current.extend(new_vars)
398
399 def save(self, filename=None):
400 """
401 Saves this object's configuration data to a file.
402
403 The output file's content can be interpreted by `set_cnf -j`.
404
405 :param str filename: name of file to write config to; if None (default)
406 the config will be written to a temporary file
407 :returns: filename that was written to
408 :rtype: str
409 """
410 log.info("save configuration")
411 current = self.__cnfvars
412 if not current:
413 raise InvalidCnf("No variables to write.")
414
415 if filename is None:
416 # create temporary filename
417 filename = arnied_wrapper.generate_config_path(dumped=True)
418
419 with open(filename, 'w') as out:
7abff5a7 420 cnfvar_old.output_json({"cnf": current}, out, renumber=True)
f49f6323
PD
421
422 return filename
423
424 def apply(self, vm=None, renumber=True):
425 """
426 Apply object's config on VM or local host.
427
428 Runs a `set_cnf` with complete internal config data, possibly waits for
429 generate to finish afterwards.
430
431 :param vm: a guest vm or None to apply on local host
432 :type vm: VM object or None
433 :param bool renumber: re-number conf vars before application
434 """
435 current = self.__cnfvars
436 if renumber:
437 log.info("enforce consistent CNF_LINE numbering")
7abff5a7 438 cnfvar_old.renumber_vars(current)
f49f6323
PD
439 log.info("inject configuration %s" % "into guest" if vm else "in place")
440 arnied_wrapper.set_cnf_dynamic({"cnf": current},
441 config_file=gen_tmpname(), vm=vm)
442
443 def __str__(self):
444 """
445 Get a config in json format, ready for `set_cnf -j`.
446
447 :returns: config in json format
448 :rtype: str
449 """
7abff5a7 450 return cnfvar_old.dump_json_string({"cnf": self.__cnfvars}, renumber=True)
f49f6323
PD
451
452 def pretty_print(self, print_func=None):
453 """
454 Get a string representation of this simple_cnf that is human-readable
455
456 Result is valid json with nice line breaks and indentation but not
457 renumbered (so may not be fit for parsing)
458 """
459 for line in json.dumps({"cnf": self.__cnfvars}, check_circular=False,
460 indent=4, sort_keys=True).splitlines():
461 if print_func is None:
462 print(line)
463 else:
464 print_func(line)
465
466 def __iter__(self):
467 """
468 Return an iterator over the contents of this simple cnf.
469
470 The iteration might not be ordered by line number nor entry nor
471 anything else. No guarantees made!
472
473 The entries returned by the iterator are :py:class:`SimpleCnf`.
474
475 Example::
476
477 for cnf_list in iter(my_cnf['PROXY_ACCESSLIST']):
478 print('checking proxy list {0} with {1} children'
479 .format(cnf_list.get_value(), len(cnf_list)))
480 """
481 # self.__cnfvars is a list of dicts, each with the same fields
482 for dict_entry in self.__cnfvars:
483 yield SimpleCnf([dict_entry, ])
484
485 def __getitem__(self, item):
486 """
487 Called by `cnf['key']` or `cnf[line_number]`; returns subset of cnf.
488
489 Processing time is O(n) where n is the number of top-level entries in
490 simple cnf.
491
492 Examples (on VM)::
493
494 all = SimpleCnf()
495 all.append_guest_vars()
496 len(all) # --> probably huge
497 len(all['user']) # should give the number of users
498
499 # should result in the same as all['user']:
500 users = SimpleCnf()
501 users.append_guest_vars(varname='user')
502
503 :param item: line number or value to specify a cnf subset;
1c21e862 504 if string value, will be converted to lower case
f49f6323
PD
505 :type item: int or str
506 :returns: another simple cnf that contains a subset of this simple cnf
507 :rtype: :py:class:`SimpleCnf`
508
509 .. seealso:: method :py:func:`get` (more general than this)
510 """
511 # determine whether arg 'item' is a key name or a line number
512 if isinstance(item, int): # is line number
513 dict_key = 'number'
514 else: # assume key name
515 dict_key = 'varname'
cfcef185 516 item = item.lower()
f49f6323
PD
517
518 # search all entries for matches
519 results = [dict_entry for dict_entry in self.__cnfvars
520 if dict_entry[dict_key] == item]
521
522 # convert result to a simple cnf
523 return SimpleCnf(results)
524
525 def __len__(self):
526 """
527 Get the number of top-level entries in cnf.
528
529 :returns: number of top-level entries in cnf
530 :rtype: int
531 """
532 return len(self.__cnfvars)
533
534 def get(self, name=None, value=None, instance=None, line=None):
535 """
536 Get a subset of this config that matches ALL of given criteria.
537
538 For example, if :py:func:`get_cnf` contains the line
539 '1121 USER,1: "admin"', all of these examples will result in the same
540 simple cnf::
541
542 cnf.get(name='user', value='admin')
543 cnf.get(name='user', instance=1)
544 cnf.get(name='user').get(value='admin')
545 cnf.get(line=1121)
546
547 :param str name: conf var name (key) or None to not use this criterion;
1c21e862 548 will be converted to lower case
f49f6323
PD
549 :param str value: value of conf var or None to not use this criterion
550 :param int instance: instance number of value in a list (e.g. USERS)
551 or None to not use this criterion
552 :param int line: line number of None to not use this criterion
553 :returns: a simple cnf that contains only entries that match ALL of the
554 given criteria. If nothing matches the given criteria, an
555 empty simple cnf will be returned
556 :rtype: :py:class:`SimpleCnf`
557
558 .. seealso:: method :py:func:`__getitem__` (less general than this)
559 """
560 if name is None:
561 name_test = lambda test_val: True
562 else:
cfcef185 563 name = name.lower()
f49f6323
PD
564 name_test = lambda test_val: name == test_val['varname']
565
566 if value is None:
567 value_test = lambda test_val: True
568 else:
569 value = str(value)
570 value_test = lambda test_val: test_val['data'] == value
571
572 if instance is None:
573 instance_test = lambda test_val: True
574 elif not isinstance(instance, int):
575 raise ValueError('expect int value for instance!')
576 else:
577 instance_test = lambda test_val: instance == test_val['instance']
578
579 if line is None:
580 line_test = lambda test_val: True
581 elif not isinstance(line, int):
582 raise ValueError('expect int value for line number!')
583 else:
584 line_test = lambda test_val: test_val['number'] == line
585
ce20f5b6 586 return SimpleCnf(list(entry for entry in self.__cnfvars
f49f6323
PD
587 if name_test(entry) and value_test(entry)
588 and instance_test(entry) and line_test(entry)))
589
590 def get_children(self):
591 """
592 Get children of simple cnf of just 1 entry.
593
594 :returns: simple cnf children or an empty simple cnf if entry has
595 no children
596 :rtype: :py:class:`SimpleCnf`
597 :raises: :py:class:`ValueError` if this simple cnf has more
598 than 1 entry
599 """
600 if len(self) != 1:
601 raise ValueError('get_children only possible if len == 1 (is {0})!'
602 .format(len(self)))
603 try:
604 result = self.__cnfvars[0]['children']
605 except KeyError:
606 return SimpleCnf()
607
608 for entry in result:
609 try:
610 del entry['parent']
611 except KeyError:
612 pass
613 return SimpleCnf(result)
614
615 def get_value(self):
616 """
617 Get a value of a simple cnf of just 1 entry.
618
619 :returns: str cnf value/data
620 :rtype: str
621 :raises: :py:class:`ValueError` if this simple cnf has more
622 than 1 entry
623 """
624 if len(self) != 1:
625 raise ValueError('get_value only possible if len == 1 (is {0})!'
626 .format(len(self)))
627 return self.__cnfvars[0]['data']
628
629 def get_single_dict(self):
630 """
631 Get a dictionary of a simple cnf of just 1 entry.
632
633 :returns: dictionary of a simple cnf
634 :rtype: {str, int or str or None}
635 """
636 if len(self) != 1:
0bc34b4b 637 raise ValueError('get_single_dict only possible if len == 1 (is {0})!'
f49f6323
PD
638 .format(len(self)))
639 return self.__cnfvars[0]
640
641 def __eq__(self, other_cnf):
642 """
643 Determine wether `self` == `other_cnf`.
644
645 :param other_cnf: cnf to compare with
646 :type other_cnf: :py:class:`SimpleCnf`
647 :returns: whether all cnf var entries are equal
648 :rtype: bool
649 """
650 key_func = lambda cnf_var_entry: cnf_var_entry['number']
651
ee39385c
PG
652 if isinstance (other_cnf, SimpleCnf) is False:
653 return False
654
f49f6323
PD
655 return sorted(self.__cnfvars, key=key_func) \
656 == sorted(other_cnf.__cnfvars, key=key_func) # pylint: disable=protected-access