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