Deprecate cnfvar_old and arnied_wrapper
[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
d7cdea2c
CH
27DEPRECATED! 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
f49f6323
PD
30Copyright: Intra2net AG
31
32
33CONTENTS
34------------------------------------------------------
35
36This 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
38interfaces, most of all class :py:class:`SimpleCnf`. It is completely
39independent of the :py:mod:`cnfline` package and its included subclasses
40(modules in `shared.cnfline`, starting with ``build_`` and ``configure_``).
41
42Class :py:class:`SimpleCnf` represents a hierarchical set of conf vars and
43provides functions to deal with that hierarchy. Under the hood, all functions
44here (and probably also in :py:mod:`cnfvar`) work with conf vars represented as
45dictionaries and lists thereof. Conf var dicts have keys `number`, `varname`,
46`instance`, `data`, `comment` and possibly `parent` and/or `children`.
1c21e862 47`varname` is a regular lower-case string, `data` is a (utf8) string, `comment`
f49f6323
PD
48is usually None, `number`, `parent` and `instance` are int. If a conf var has
49children, then this is a list of conf var dicts. `parent` is only present if a
50conf-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
52dict 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
2dd87078 60
f49f6323
PD
61INTERFACE
62------------------------------------------------------
63"""
64
65import os
30521dad 66import json
f49f6323
PD
67import tempfile
68import time
69import logging
3de8b4d8 70log = logging.getLogger('pyi2ncommon.simple_cnf')
f49f6323 71
30521dad 72from . import arnied_wrapper
7abff5a7 73from . import cnfvar_old
30521dad 74from . import sysmisc
f49f6323
PD
75
76###############################################################################
77# constants
78###############################################################################
79
80#: timeout for copying temporary config files to VM objects (seconds)
81COPY_FILES_TIMEOUT = 15
82
83#: additional location of configuration files
84ADD_CNFFILE_PATH = "/tmp/configs"
85
86
87###############################################################################
88# EXCEPTIONS
89###############################################################################
90
91
92class 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
107class 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
121def 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
139def 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
154def 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
207def 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
230class SimpleCnf(object):
231 """
232 Representation of hierarchical configuration of variables.
233
234 Based on C++ `cnf_vars` as visualized by *get_cnf*.
235
6ac093df
CH
236 Internal data representation: list of conf var dicts; see module doc for
237 details
f49f6323
PD
238 """
239
240 def __init__(self, cnf=None):
241 """
242 Creates a simple configuration.
243
6ac093df
CH
244 Does not check whether given cnf list contains only valid data.
245 Does not recurse into dicts.
246
f49f6323 247 :param cnf: initial set of conf var data (default: None = empty conf)
ce20f5b6 248 :type cnf: list or anything that :py:func:`get_cnf` can read
f49f6323 249 """
ce20f5b6
PG
250 if cnf is None:
251 self.__cnfvars = []
252 elif isinstance(cnf, list):
f49f6323 253 self.__cnfvars = cnf
ce20f5b6 254 elif isinstance(cnf, dict):
f49f6323
PD
255 self.__cnfvars = get_cnf(cnf)
256 else:
ce20f5b6 257 raise InvalidCnf ("cannot handle %s type inputs" % type (cnf))
f49f6323
PD
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
1c21e862 278 :param str varname: name of conf var; will be converted to lower-case
f49f6323
PD
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
cfcef185 284 varname = varname.lower()
f49f6323
PD
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
1c21e862 294 :param str varname: name of conf var; only required arg; case ignored
f49f6323
PD
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)
7abff5a7 316 cnfvar_old.renumber_vars({'cnf':new_children}, number)
f49f6323
PD
317 children = new_children
318 elif number is None:
319 number = self._find_new_number(self.__cnfvars)
320
cfcef185 321 new_var = dict(varname=varname.lower(), data=data,
f49f6323
PD
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
55e596ea
PG
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
f49f6323
PD
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
15b856e5 366 self.__cnfvars.extend(new_vars)
f49f6323
PD
367
368 def append_file(self, cnf, replacements=None):
369 """Append conf var data from file."""
7abff5a7 370 return self.append_file_generic(cnfvar_old.read_cnf, cnf,
f49f6323
PD
371 replacements=replacements)
372
373 def append_file_json(self, cnf, replacements=None):
374 """Append conf var data from json file."""
7abff5a7 375 return self.append_file_generic(cnfvar_old.read_cnf_json, cnf,
f49f6323
PD
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:
7abff5a7 423 cnfvar_old.output_json({"cnf": current}, out, renumber=True)
f49f6323
PD
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")
7abff5a7 441 cnfvar_old.renumber_vars(current)
f49f6323
PD
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 """
7abff5a7 453 return cnfvar_old.dump_json_string({"cnf": self.__cnfvars}, renumber=True)
f49f6323
PD
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;
1c21e862 507 if string value, will be converted to lower case
f49f6323
PD
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'
cfcef185 519 item = item.lower()
f49f6323
PD
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;
1c21e862 551 will be converted to lower case
f49f6323
PD
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:
cfcef185 566 name = name.lower()
f49f6323
PD
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
ce20f5b6 589 return SimpleCnf(list(entry for entry in self.__cnfvars
f49f6323
PD
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:
0bc34b4b 640 raise ValueError('get_single_dict only possible if len == 1 (is {0})!'
f49f6323
PD
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
ee39385c
PG
655 if isinstance (other_cnf, SimpleCnf) is False:
656 return False
657
f49f6323
PD
658 return sorted(self.__cnfvars, key=key_func) \
659 == sorted(other_cnf.__cnfvars, key=key_func) # pylint: disable=protected-access