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