Merge branch 'cnfline-templates' into cnfvar-deprecations
[pyi2ncommon] / src / simple_cnf.py
CommitLineData
11cbb815
PD
1# The software in this package is distributed under the GNU General
2# Public License version 2 (with a special exception described below).
3#
4# A copy of GNU General Public License (GPL) is included in this distribution,
5# in the file COPYING.GPL.
6#
7# As a special exception, if other files instantiate templates or use macros
8# or inline functions from this file, or you compile this file and link it
9# with other works to produce a work based on this file, this file
10# does not by itself cause the resulting work to be covered
11# by the GNU General Public License.
12#
13# However the source code for this file must still be made available
14# in accordance with section (3) of the GNU General Public License.
15#
16# This exception does not invalidate any other reasons why a work based
17# on this file might be covered by the GNU General Public License.
18#
19# Copyright (c) 2016-2018 Intra2net AG <info@intra2net.com>
20
f49f6323
PD
21"""
22
23SUMMARY
24------------------------------------------------------
25Read / write / merge guest cnf var sets, even on host.
26
b7e04a3e
CH
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!
d7cdea2c 30
f49f6323
PD
31Copyright: Intra2net AG
32
33
34CONTENTS
35------------------------------------------------------
36
37This 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
39interfaces, most of all class :py:class:`SimpleCnf`. It is completely
40independent of the :py:mod:`cnfline` package and its included subclasses
41(modules in `shared.cnfline`, starting with ``build_`` and ``configure_``).
42
43Class :py:class:`SimpleCnf` represents a hierarchical set of conf vars and
44provides functions to deal with that hierarchy. Under the hood, all functions
45here (and probably also in :py:mod:`cnfvar`) work with conf vars represented as
46dictionaries and lists thereof. Conf var dicts have keys `number`, `varname`,
47`instance`, `data`, `comment` and possibly `parent` and/or `children`.
1c21e862 48`varname` is a regular lower-case string, `data` is a (utf8) string, `comment`
f49f6323
PD
49is usually None, `number`, `parent` and `instance` are int. If a conf var has
50children, then this is a list of conf var dicts. `parent` is only present if a
51conf-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
53dict 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
2dd87078 61
f49f6323
PD
62INTERFACE
63------------------------------------------------------
64"""
65
66import os
30521dad 67import json
f49f6323
PD
68import tempfile
69import time
70import logging
3de8b4d8 71log = logging.getLogger('pyi2ncommon.simple_cnf')
f49f6323 72
30521dad 73from . import arnied_wrapper
7abff5a7 74from . import cnfvar_old
30521dad 75from . import sysmisc
f49f6323
PD
76
77###############################################################################
78# constants
79###############################################################################
80
81#: timeout for copying temporary config files to VM objects (seconds)
82COPY_FILES_TIMEOUT = 15
83
84#: additional location of configuration files
85ADD_CNFFILE_PATH = "/tmp/configs"
86
87
88###############################################################################
89# EXCEPTIONS
90###############################################################################
91
92
93class 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
108class 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
122def 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
140def 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
155def 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
208def 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
231class SimpleCnf(object):
232 """
233 Representation of hierarchical configuration of variables.
234
235 Based on C++ `cnf_vars` as visualized by *get_cnf*.
236
6ac093df
CH
237 Internal data representation: list of conf var dicts; see module doc for
238 details
f49f6323
PD
239 """
240
241 def __init__(self, cnf=None):
242 """
243 Creates a simple configuration.
244
6ac093df
CH
245 Does not check whether given cnf list contains only valid data.
246 Does not recurse into dicts.
247
f49f6323 248 :param cnf: initial set of conf var data (default: None = empty conf)
ce20f5b6 249 :type cnf: list or anything that :py:func:`get_cnf` can read
f49f6323 250 """
ce20f5b6
PG
251 if cnf is None:
252 self.__cnfvars = []
253 elif isinstance(cnf, list):
f49f6323 254 self.__cnfvars = cnf
ce20f5b6 255 elif isinstance(cnf, dict):
f49f6323
PD
256 self.__cnfvars = get_cnf(cnf)
257 else:
ce20f5b6 258 raise InvalidCnf ("cannot handle %s type inputs" % type (cnf))
f49f6323
PD
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
1c21e862 279 :param str varname: name of conf var; will be converted to lower-case
f49f6323
PD
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
cfcef185 285 varname = varname.lower()
f49f6323
PD
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
1c21e862 295 :param str varname: name of conf var; only required arg; case ignored
f49f6323
PD
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)
7abff5a7 317 cnfvar_old.renumber_vars({'cnf':new_children}, number)
f49f6323
PD
318 children = new_children
319 elif number is None:
320 number = self._find_new_number(self.__cnfvars)
321
cfcef185 322 new_var = dict(varname=varname.lower(), data=data,
f49f6323
PD
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
55e596ea
PG
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
f49f6323
PD
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
15b856e5 367 self.__cnfvars.extend(new_vars)
f49f6323
PD
368
369 def append_file(self, cnf, replacements=None):
370 """Append conf var data from file."""
7abff5a7 371 return self.append_file_generic(cnfvar_old.read_cnf, cnf,
f49f6323
PD
372 replacements=replacements)
373
374 def append_file_json(self, cnf, replacements=None):
375 """Append conf var data from json file."""
7abff5a7 376 return self.append_file_generic(cnfvar_old.read_cnf_json, cnf,
f49f6323
PD
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:
7abff5a7 424 cnfvar_old.output_json({"cnf": current}, out, renumber=True)
f49f6323
PD
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")
7abff5a7 442 cnfvar_old.renumber_vars(current)
f49f6323
PD
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 """
7abff5a7 454 return cnfvar_old.dump_json_string({"cnf": self.__cnfvars}, renumber=True)
f49f6323
PD
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;
1c21e862 508 if string value, will be converted to lower case
f49f6323
PD
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'
cfcef185 520 item = item.lower()
f49f6323
PD
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;
1c21e862 552 will be converted to lower case
f49f6323
PD
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:
cfcef185 567 name = name.lower()
f49f6323
PD
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
ce20f5b6 590 return SimpleCnf(list(entry for entry in self.__cnfvars
f49f6323
PD
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:
0bc34b4b 641 raise ValueError('get_single_dict only possible if len == 1 (is {0})!'
f49f6323
PD
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
ee39385c
PG
656 if isinstance (other_cnf, SimpleCnf) is False:
657 return False
658
f49f6323
PD
659 return sorted(self.__cnfvars, key=key_func) \
660 == sorted(other_cnf.__cnfvars, key=key_func) # pylint: disable=protected-access