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