cdc4318e42ecfe0e7e010a0f247d59d922a1503b
[pyi2ncommon] / src / simple_cnf.py
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
21 """
22
23 SUMMARY
24 ------------------------------------------------------
25 Read / write / merge guest cnf var sets, even on host.
26
27 Copyright: Intra2net AG
28
29
30 CONTENTS
31 ------------------------------------------------------
32
33 This 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
35 interfaces, most of all class :py:class:`SimpleCnf`. It is completely
36 independent of the :py:mod:`cnfline` package and its included subclasses
37 (modules in `shared.cnfline`, starting with ``build_`` and ``configure_``).
38
39 Class :py:class:`SimpleCnf` represents a hierarchical set of conf vars and
40 provides functions to deal with that hierarchy. Under the hood, all functions
41 here (and probably also in :py:mod:`cnfvar`) work with conf vars represented as
42 dictionaries and lists thereof. Conf var dicts have keys `number`, `varname`,
43 `instance`, `data`, `comment` and possibly `parent` and/or `children`.
44 `varname` is a regular lower-case string, `data` is a (utf8) string, `comment`
45 is usually None, `number`, `parent` and `instance` are int. If a conf var has
46 children, then this is a list of conf var dicts. `parent` is only present if a
47 conf-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
49 dict 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
57
58 INTERFACE
59 ------------------------------------------------------
60 """
61
62 import os
63 import json
64 import tempfile
65 import time
66 import logging
67 log = logging.getLogger('pyi2ncommon.simple_cnf')
68
69 from . import arnied_wrapper
70 from . import cnfvar
71 from . import sysmisc
72
73 ###############################################################################
74 #                                  constants
75 ###############################################################################
76
77 #: timeout for copying temporary config files to VM objects (seconds)
78 COPY_FILES_TIMEOUT = 15
79
80 #: additional location of configuration files
81 ADD_CNFFILE_PATH = "/tmp/configs"
82
83
84 ###############################################################################
85 # EXCEPTIONS
86 ###############################################################################
87
88
89 class 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
104 class 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
118 def 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
136 def 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
151 def 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
204 def 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
227 class SimpleCnf(object):
228     """
229     Representation of hierarchical configuration of variables.
230
231     Based on C++ `cnf_vars` as visualized by *get_cnf*.
232
233     Internal data representation: list of conf var dicts; see module doc for
234     details
235     """
236
237     def __init__(self, cnf=None):
238         """
239         Creates a simple configuration.
240
241         Does not check whether given cnf list contains only valid data.
242         Does not recurse into dicts.
243
244         :param cnf: initial set of conf var data (default: None = empty conf)
245         :type cnf: list or anything that :py:func:`get_cnf` can read
246         """
247         if cnf is None:
248             self.__cnfvars = []
249         elif isinstance(cnf, list):
250             self.__cnfvars = cnf
251         elif isinstance(cnf, dict):
252             self.__cnfvars = get_cnf(cnf)
253         else:
254             raise InvalidCnf ("cannot handle %s type inputs" % type (cnf))
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
275         :param str varname: name of conf var; will be converted to lower-case
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
281         varname = varname.lower()
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
291         :param str varname: name of conf var; only required arg; case ignored
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)
313             cnfvar.renumber_vars({'cnf':new_children}, number)
314             children = new_children
315         elif number is None:
316             number = self._find_new_number(self.__cnfvars)
317
318         new_var = dict(varname=varname.lower(), data=data,
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
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
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
363         self.__cnfvars.extend(new_vars)
364
365     def append_file(self, cnf, replacements=None):
366         """Append conf var data from file."""
367         return self.append_file_generic(cnfvar.read_cnf, cnf,
368                                         replacements=replacements)
369
370     def append_file_json(self, cnf, replacements=None):
371         """Append conf var data from json file."""
372         return self.append_file_generic(cnfvar.read_cnf_json, cnf,
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:
420             cnfvar.output_json({"cnf": current}, out, renumber=True)
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")
438             cnfvar.renumber_vars(current)
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         """
450         return cnfvar.dump_json_string({"cnf": self.__cnfvars}, renumber=True)
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;
504                      if string value, will be converted to lower case
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'
516             item = item.lower()
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;
548                          will be converted to lower case
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:
563             name = name.lower()
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
586         return SimpleCnf(list(entry for entry in self.__cnfvars
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:
637             raise ValueError('get_single_dict only possible if len == 1 (is {0})!'
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
652         if isinstance (other_cnf, SimpleCnf) is False:
653             return False
654
655         return sorted(self.__cnfvars, key=key_func) \
656             == sorted(other_cnf.__cnfvars, key=key_func)   # pylint: disable=protected-access