Fix decoding on "get_cnf --json"
[pyi2ncommon] / src / arnied_wrapper.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 Guest utility to wrap arnied related functionality through python calls.
26
27 Copyright: Intra2net AG
28
29
30 There are three types of setting some cnfvar configuration:
31
32 1) static (:py:class:`set_cnf`) - oldest method using a static preprocessed
33    config file without modifying its content in any way
34 2) semi-dynamic (:py:class:`set_cnf_semidynamic`) - old method also using
35    static file but rather as a template, replacing regex-matched values to
36    adapt it to different configurations
37 3) dynamic (:py:class:`set_cnf_dynamic`) - new method using dictionaries
38    and custom cnfvar classes and writing them into config files of a desired
39    format (json, cnf, or raw)
40
41
42 INTERFACE
43 ------------------------------------------------------
44
45 """
46
47 import os
48 import sys
49 import time
50 import re
51 import subprocess
52 import shutil
53 import tempfile
54 import logging
55 log = logging.getLogger('pyi2ncommon.arnied_wrapper')
56
57 from .cnfline import build_cnfvar
58 from . import cnfvar
59 from . import sysmisc
60
61
62
63 #: default set_cnf binary
64 BIN_SET_CNF = "/usr/intranator/bin/set_cnf"
65 #: default location for template configuration files
66 SRC_CONFIG_DIR = "."
67 #: default location for dumped configuration files
68 DUMP_CONFIG_DIR = "."
69
70
71 class ConfigError(Exception):
72     pass
73
74
75 def run_cmd(cmd="", ignore_errors=False, vm=None, timeout=60):
76     """
77     Universal command run wrapper.
78
79     :param str cmd: command to run
80     :param bool ignore_errors: whether not to raise error on command failure
81     :param vm: vm to run on if running on a guest instead of the host
82     :type vm: VM object or None
83     :param int timeout: amount of seconds to wait for the program to run
84     :returns: command result output
85     :rtype: str
86     :raises: :py:class:`OSError` if command failed and cannot be ignored
87     """
88     if vm is not None:
89         status, stdout = vm.session.cmd_status_output(cmd, timeout=timeout)
90         stdout = stdout.encode()
91         stderr = b""
92         if status != 0:
93             stderr = stdout
94             stdout = b""
95             if not ignore_errors:
96                 raise subprocess.CalledProcessError(status, cmd, stderr=stderr)
97         return subprocess.CompletedProcess(cmd, status, stdout=stdout, stderr=stderr)
98     else:
99         return subprocess.run(cmd, check=not ignore_errors, shell=True, capture_output=True)
100
101
102 def verify_running(process='arnied', timeout=60, vm=None):
103     """
104     Verify if a given process is running via 'pgrep'.
105     Normally this is used to check if arnied is running.
106
107     :param str process: process to verify if running
108     :param int timeout: run verification timeout
109     :param vm: vm to run on if running on a guest instead of the host
110     :type vm: VM object or None
111     :raises: :py:class:`RuntimeError` if process is not running
112     """
113     platform_str = ""
114     if vm is not None:
115         vm.verify_alive()
116         platform_str = " on %s" % vm.name
117     for i in range(timeout):
118         log.info("Checking whether %s is running%s (%i\%i)",
119                  process, platform_str, i, timeout)
120         result = run_cmd(cmd="pgrep -l -x %s" % process,
121                          ignore_errors=True, vm=vm)
122         if result.returncode == 0:
123             log.debug(result)
124             return
125         time.sleep(1)
126     raise RuntimeError("Process %s does not seem to be running" % process)
127
128
129 # Basic functionality
130
131
132 def accept_licence(vm=None):
133     """
134     Accept the Intra2net license.
135
136     :param vm: vm to run on if running on a guest instead of the host
137     :type vm: VM object or None
138
139     This is mostly useful for simplified webpage access.
140     """
141     cmd = 'echo "LICENSE_ACCEPTED,0: \\"1\\"" | set_cnf'
142     result = run_cmd(cmd=cmd, ignore_errors=True, vm=vm)
143     log.debug(result)
144     cmd = "/usr/intranator/bin/arnied_helper --wait-for-program-end GENERATE"
145     run_cmd(cmd=cmd, vm=vm)
146
147
148 def go_online(provider_id, wait_online=True, timeout=60, vm=None):
149     """
150     Go online with the given provider id.
151
152     :param provider_id: provider to go online with
153     :type provider_id: int
154     :param wait_online: whether to wait until online
155     :type wait_online: bool
156     :param vm: vm to run on if running on a guest instead of the host
157     :type vm: VM object or None
158
159     .. seealso:: :py:func:`go_offline`, :py:func:`wait_for_online`
160     """
161     log.info("Switching to online mode with provider %d", provider_id)
162
163     get_cnf_res = run_cmd(cmd='get_cnf PROVIDER %d' % provider_id, vm=vm)
164     if b'PROVIDER,' not in get_cnf_res.stdout:
165         log.warning('There is no PROVIDER %d on the vm. Skipping go_online.',
166                     provider_id)
167         return
168
169     cmd = 'tell-connd --online P%i' % provider_id
170     result = run_cmd(cmd=cmd, vm=vm)
171     log.debug(result)
172
173     if wait_online:
174         wait_for_online(provider_id, timeout=timeout, vm=vm)
175
176
177 def go_offline(wait_offline=True, vm=None):
178     """
179     Go offline.
180
181     :param wait_offline: whether to wait until offline
182     :type wait_offline: bool
183     :param vm: vm to run on if running on a guest instead of the host
184     :type vm: VM object or None
185
186     .. seealso:: :py:func:`go_online`, :py:func:`wait_for_offline`
187     """
188     cmd = 'tell-connd --offline'
189     result = run_cmd(cmd=cmd, vm=vm)
190     log.debug(result)
191
192     if wait_offline:
193         if wait_offline is True:
194             wait_for_offline(vm=vm)
195         else:
196             wait_for_offline(wait_offline, vm=vm)
197
198
199 def wait_for_offline(timeout=60, vm=None):
200     """
201     Wait for arnied to signal we are offline.
202
203     :param int timeout: maximum timeout for waiting
204     :param vm: vm to run on if running on a guest instead of the host
205     :type vm: VM object or None
206     """
207     _wait_for_online_status('offline', None, timeout, vm)
208
209
210 def wait_for_online(provider_id, timeout=60, vm=None):
211     """
212     Wait for arnied to signal we are online.
213
214     :param provider_id: provider to go online with
215     :type provider_id: int
216     :param int timeout: maximum timeout for waiting
217     :param vm: vm to run on if running on a guest instead of the host
218     :type vm: VM object or None
219     """
220     _wait_for_online_status('online', provider_id, timeout, vm)
221
222
223 def _wait_for_online_status(status, provider_id, timeout, vm):
224     # Don't use tell-connd --status here since the actual
225     # ONLINE signal to arnied is transmitted
226     # asynchronously via arnieclient_muxer.
227
228     if status == 'online':
229         expected_output = 'DEFAULT: 2'
230         set_status_func = lambda: go_online(provider_id, False, vm)
231     elif status == 'offline':
232         expected_output = 'DEFAULT: 0'
233         set_status_func = lambda: go_offline(False, vm)
234     else:
235         raise ValueError('expect status "online" or "offline", not "{0}"!'
236                          .format(status))
237
238     log.info("Waiting for arnied to be {0} within {1} seconds"
239              .format(status, timeout))
240
241     for i in range(timeout):
242         # arnied might invalidate the connd "connection barrier"
243         # after generate was running and switch to OFFLINE (race condition).
244         # -> tell arnied every ten seconds to go online again
245         if i % 10 == 0 and i != 0:
246             set_status_func()
247
248         cmd = '/usr/intranator/bin/get_var ONLINE'
249         result = run_cmd(cmd=cmd, ignore_errors=True, vm=vm)
250         log.debug(result)
251
252         if expected_output in result.stdout.decode():
253             log.info("arnied is {0}. Continuing.".format(status))
254             return
255
256         time.sleep(1)
257
258     raise RuntimeError("We didn't manage to go {0} within {1} seconds\n"
259                        .format(status, timeout))
260
261
262 def disable_virscan(vm=None):
263     """
264     Disable virscan that could block GENERATE and thus all configurations.
265
266     :param vm: vm to run on if running on a guest instead of the host
267     :type vm: VM object or None
268     """
269     log.info("Disabling virus database update")
270     unset_cnf("VIRSCAN_UPDATE_CRON", vm=vm)
271
272     cmd = "echo 'VIRSCAN_UPDATE_DNS_PUSH,0:\"0\"' |set_cnf"
273     result = run_cmd(cmd=cmd, vm=vm)
274     log.debug(result)
275
276     # TODO: this intervention should be solved in later arnied_helper tool
277     cmd = "rm -f /var/intranator/schedule/UPDATE_VIRSCAN_NODIAL*"
278     result = run_cmd(cmd=cmd, vm=vm)
279     log.debug(result)
280     log.info("Virus database update disabled")
281
282
283 def email_transfer(vm=None):
284     """
285     Transfer all the emails using the guest tool arnied_helper.
286
287     :param vm: vm to run on if running on a guest instead of the host
288     :type vm: VM object or None
289     """
290     cmd = "/usr/intranator/bin/arnied_helper --transfer-mail"
291     result = run_cmd(cmd=cmd, vm=vm)
292     log.debug(result)
293
294
295 def wait_for_email_transfer(timeout=300, vm=None):
296     """
297     Wait until the mail queue is empty and all emails are sent.
298
299     :param int timeout: email transfer timeout
300     :param vm: vm to run on if running on a guest instead of the host
301     :type vm: VM object or None
302     """
303     for i in range(timeout):
304         if i % 10 == 0:
305             # Retrigger mail queue in case something is deferred
306             # by an amavisd-new reconfiguration
307             run_cmd(cmd='postqueue -f', vm=vm)
308             log.info('Waiting for SMTP queue to get empty (%i/%i s)',
309                      i, timeout)
310         if not run_cmd(cmd='postqueue -j', vm=vm).stdout:
311             log.debug('SMTP queue is empty')
312             return
313         time.sleep(1)
314     log.warning('Timeout reached but SMTP queue still not empty after {} s'
315                 .format(timeout))
316
317
318 def schedule(program, exec_time=0, optional_args="", vm=None):
319     """
320     Schedule a program to be executed at a given unix time stamp.
321
322     :param str program: program whose execution is scheduled
323     :param int exec_time: scheduled time of program's execution
324     :param str optional_args: optional command line arguments
325     :param vm: vm to run on if running on a guest instead of the host
326     :type vm: VM object or None
327     """
328     log.info("Scheduling %s to be executed at %i", program, exec_time)
329     schedule_dir = "/var/intranator/schedule"
330     # clean previous schedules of the same program
331     files = vm.session.cmd("ls " + schedule_dir).split() if vm else os.listdir(schedule_dir)
332     for file_name in files:
333         if file_name.startswith(program.upper()):
334             log.debug("Removing previous scheduled %s", file_name)
335             if vm:
336                 vm.session.cmd("rm -f " + os.path.join(schedule_dir, file_name))
337             else:
338                 os.unlink(os.path.join(schedule_dir, file_name))
339
340     contents = "%i\n%s\n" % (exec_time, optional_args)
341
342     tmp_file = tempfile.NamedTemporaryFile(mode="w+",
343                                         prefix=program.upper() + "_",
344                                         dir=DUMP_CONFIG_DIR,
345                                         delete=False)
346     log.debug("Created temporary file %s", tmp_file.name)
347     tmp_file.write(contents)
348     tmp_file.close()
349     moved_tmp_file = os.path.join(schedule_dir, os.path.basename(tmp_file.name))
350
351     if vm:
352         vm.copy_files_to(tmp_file.name, moved_tmp_file)
353         os.remove(tmp_file.name)
354     else:
355         shutil.move(tmp_file.name, moved_tmp_file)
356
357     log.debug("Moved temporary file to %s", moved_tmp_file)
358
359
360 def wait_for_run(program, timeout=300, retries=10, vm=None):
361     """
362     Wait for a program using the guest arnied_helper tool.
363
364     :param str program: scheduled or running program to wait for
365     :param int timeout: program run timeout
366     :param int retries: number of tries to verify that the program is scheduled or running
367     :param vm: vm to run on if running on a guest instead of the host
368     :type vm: VM object or None
369     """
370     log.info("Waiting for program %s to finish with timeout %i",
371              program, timeout)
372     for i in range(retries):
373         cmd = "/usr/intranator/bin/arnied_helper --is-scheduled-or-running " \
374             + program.upper()
375         check_scheduled = run_cmd(cmd=cmd, ignore_errors=True, vm=vm)
376         if check_scheduled.returncode == 0:
377             break
378         time.sleep(1)
379     else:
380         log.warning("The program %s was not scheduled and is not running", program)
381         return
382     cmd = "/usr/intranator/bin/arnied_helper --wait-for-program-end " \
383         + program.upper() + " --wait-for-program-timeout " + str(timeout)
384     # add one second to make sure arnied_helper is finished when we expire
385     result = run_cmd(cmd=cmd, vm=vm, timeout=timeout+1)
386     log.debug(result.stdout)
387
388
389 # Configuration functionality
390
391 def get_cnf(cnf_key, cnf_index=1, regex=".*", compact=False, timeout=30, vm=None):
392     """
393     Query arnied for a `cnf_key` and extract some information via regex.
394
395     :param str cnf_key: queried cnf key
396     :param int cnf_index: index of the cnf key
397     :param str regex: regex to apply on the queried cnf key data
398     :param bool compact: whether to retrieve compact version of the matched cnf keys
399     :param int timeout: arnied run verification timeout
400     :param vm: vm to run on if running on a guest instead of the host
401     :type vm: VM object or None
402     :returns: extracted information via the regex
403     :rtype: Match object
404
405     If `cnf_index` is set to -1, retrieve and perform regex matching on all instances.
406     """
407     verify_running(timeout=timeout, vm=vm)
408     platform_str = ""
409     if vm is not None:
410         platform_str = " from %s" % vm.name
411     log.info("Extracting arnied value %s for %s%s using pattern %s",
412              cnf_index, cnf_key, platform_str, regex)
413     cmd = "get_cnf%s %s%s" % (" -c " if compact else "", cnf_key,
414                               " %s" % cnf_index if cnf_index != -1 else "")
415     output = run_cmd(cmd=cmd, vm=vm).stdout.decode()
416     return re.search(regex, output, flags=re.DOTALL)
417
418
419 def get_cnf_id(cnf_key, value, timeout=30, vm=None):
420     """
421     Get the id of a configuration of type `cnf_key` and name `value`.
422
423     :param str cnf_key: queried cnf key
424     :param str value: cnf value of the cnf key
425     :param int timeout: arnied run verification timeout
426     :param vm: vm to run on if running on a guest instead of the host
427     :type vm: VM object or None
428     :returns: the cnf id or -1 if no such cnf variable
429     :rtype: int
430     """
431     verify_running(timeout=timeout, vm=vm)
432     regex = "%s,(\d+): \"%s\"" % (cnf_key, value)
433     cnf_id = get_cnf(cnf_key, cnf_index=-1, regex=regex, compact=True, vm=vm)
434     if cnf_id is None:
435         cnf_id = -1
436     else:
437         cnf_id = int(cnf_id.group(1))
438     log.info("Retrieved id \"%s\" for %s is %i", value, cnf_key, cnf_id)
439     return cnf_id
440
441
442 def get_cnfvar(varname=None, instance=None, data=None, timeout=30, vm=None):
443     """
444     Invoke get_cnf and return a nested CNF structure.
445
446     :param str varname: "varname" field of the CNF_VAR to look up
447     :param instance: "instance" of that variable to return
448     :type instance: int
449     :param str data: "data" field by which the resulting CNF_VAR list should be filtered
450     :param int timeout: arnied run verification timeout
451     :param vm: vm to run on if running on a guest instead of the host
452     :type vm: VM object or None
453     :returns: the resulting "cnfvar" structure or None if the lookup fails or the result could not be parsed
454     :rtype: cnfvar option
455     """
456     verify_running(timeout=timeout, vm=vm)
457     # firstly, build argv for get_cnf
458     cmd = ["get_cnf", "-j"]
459     if varname is not None:
460         cmd.append("%s" % varname)
461         if instance:
462             cmd.append("%d" % instance)
463     cmd_line = " ".join(cmd)
464
465     # now invoke get_cnf
466     result = run_cmd(cmd=cmd_line, vm=vm)
467     (status, raw) = result.returncode, result.stdout
468     if status != 0:
469         log.info("error %d executing \"%s\"", status, cmd_line)
470         log.debug(raw)
471         return None
472
473     # reading was successful, attempt to parse what we got
474     try:
475         # The output from "get_cnf -j" is already utf-8. This contrast with
476         # the output of "get_cnf" (no json) which is latin1.
477         if isinstance(raw, bytes):
478             raw = raw.decode("utf-8")
479         cnf = cnfvar.read_cnf_json(raw)
480     except TypeError as exn:
481         log.info("error \"%s\" parsing result of \"%s\"", exn, cmd_line)
482         return None
483     except cnfvar.InvalidCNF as exn:
484         log.info("error \"%s\" validating result of \"%s\"", exn, cmd_line)
485         return None
486
487     if data is not None:
488         return cnfvar.get_vars(cnf, data=data)
489
490     return cnf
491
492
493 def get_cnfvar_id(varname, data, timeout=30, vm=None):
494     """
495     Similar to :py:func:`get_cnf_id` but uses :py:func:`get_cnfvar`.
496
497     :param str varname: "varname" field of the CNF_VAR to look up
498     :param str data: "data" field by which the resulting CNF_VAR list should be filtered
499     :param int timeout: arnied run verification timeout
500     :param vm: vm to run on if running on a guest instead of the host
501     :type vm: VM object or None
502     :returns: the cnf id or -1 if no such cnf variable
503     :rtype: int
504     """
505     verify_running(timeout=timeout, vm=vm)
506     log.info("Extracting from arnied CNF_VAR %s with data %s",
507              varname, data)
508     cnf = get_cnfvar(varname=varname, data=data, vm=vm)
509     variables = cnf["cnf"]
510     if len(variables) == 0:
511         log.info("CNF_VAR extraction unsuccessful, defaulting to -1")
512         # preserve behavior
513         return -1
514     first_instance = int(variables[0]["instance"])
515     log.info("CNF_VAR instance lookup yielded %d results, returning first value (%d)",
516              len(variables), first_instance)
517     return first_instance
518
519
520 def wait_for_generate(timeout=300, vm=None):
521     """
522     Wait for the 'generate' program to complete.
523
524     Arguments are similar to the ones from :py:method:`wait_for_run`.
525     """
526     wait_for_run('generate', timeout=timeout, retries=1, vm=vm)
527     wait_for_run('generate_offline', timeout=timeout, retries=1, vm=vm)
528
529
530 def unset_cnf(varname="", instance="", timeout=30, vm=None):
531     """
532     Remove configuration from arnied.
533
534     :param str varname: "varname" field of the CNF_VAR to unset
535     :param int instance: "instance" of that variable to unset
536     :param int timeout: arnied run verification timeout
537     :param vm: vm to run on if running on a guest instead of the host
538     :type vm: VM object or None
539     """
540     verify_running(timeout=timeout, vm=vm)
541
542     cmd = "get_cnf %s %s | set_cnf -x" % (varname, instance)
543     run_cmd(cmd=cmd, vm=vm)
544
545     wait_for_generate(vm=vm)
546
547
548 def set_cnf(config_files, kind="cnf", timeout=30, vm=None):
549     """
550     Perform static arnied configuration through a set of config files.
551
552     :param config_files: config files to use for the configuration
553     :type config_files: [str]
554     :param str kind: "json" or "cnf"
555     :param int timeout: arnied run verification timeout
556     :param vm: vm to run on if running on a guest instead of the host
557     :type vm: VM object or None
558     :raises: :py:class:`ConfigError` if cannot apply file
559
560     The config files must be provided and are always expected to be found on
561     the host. If these are absolute paths, they will be kept as is or
562     otherwise will be searched for in `SRC_CONFIG_DIR`. If a vm is provided,
563     the config files will be copied there as temporary files before applying.
564     """
565     log.info("Setting arnied configuration")
566     verify_running(timeout=timeout, vm=vm)
567
568     config_paths = prep_config_paths(config_files)
569     for config_path in config_paths:
570         with open(config_path, "rt", errors='replace') as config:
571             log.debug("Contents of applied %s:\n%s", config_path, config.read())
572         if vm is not None:
573             new_config_path = generate_config_path()
574             vm.copy_files_to(config_path, new_config_path)
575             config_path = new_config_path
576         argv = ["set_cnf", kind == "json" and "-j" or "", config_path]
577
578         result = run_cmd(" ".join(argv), ignore_errors=True, vm=vm)
579         logging.debug(result)
580         if result.returncode != 0:
581             raise ConfigError("Failed to apply config %s%s, set_cnf returned %d"
582                               % (config_path,
583                                  " on %s" % vm.name if vm is not None else "",
584                                  result.returncode))
585
586     try:
587         wait_for_generate(vm=vm)
588     except Exception as ex:
589         # handle cases of remote configuration that leads to connection meltdown
590         if vm is not None and isinstance(ex, sys.modules["aexpect"].ShellProcessTerminatedError):
591             log.info("Resetting connection to %s", vm.name)
592             vm.session = vm.wait_for_login(timeout=10)
593             log.debug("Connection reset via remote error: %s", ex)
594         else:
595             raise ex
596
597
598 def set_cnf_semidynamic(config_files, params_dict, regex_dict=None,
599                         kind="cnf", timeout=30, vm=None):
600     """
601     Perform semi-dynamic arnied configuration from an updated version of the
602     config files.
603
604     :param config_files: config files to use for the configuration
605     :type config_files: [str]
606     :param params_dict: parameters to override the defaults in the config files
607     :type params_dict: {str, str}
608     :param regex_dict: regular expressions to use for matching the overriden parameters
609     :type regex_dict: {str, str} or None
610     :param str kind: "json" or "cnf"
611     :param int timeout: arnied run verification timeout
612     :param vm: vm to run on if running on a guest instead of the host
613     :type vm: VM object or None
614
615     The config files must be provided and are always expected to be found on
616     the host. If these are absolute paths, they will be kept as is or
617     otherwise will be searched for in `SRC_CONFIG_DIR`. If a vm is provided,
618     the config files will be copied there as temporary files before applying.
619     """
620     log.info("Performing semi-dynamic arnied configuration")
621
622     config_paths = prep_cnf(config_files, params_dict, regex_dict)
623     set_cnf(config_paths, kind=kind, timeout=timeout, vm=vm)
624
625     log.info("Semi-dynamic arnied configuration successful!")
626
627
628 def set_cnf_dynamic(cnf, config_file=None, kind="cnf", timeout=30, vm=None):
629     """
630     Perform dynamic arnied configuration from fully generated config files.
631
632     :param cnf: one key with the same value as *kind* and a list of cnfvars as value
633     :type cnf: {str, str}
634     :param config_file: optional user supplied filename
635     :type config_file: str or None
636     :param str kind: "json", "cnf", or "raw"
637     :param int timeout: arnied run verification timeout
638     :param vm: vm to run on if running on a guest instead of the host
639     :type vm: VM object or None
640     :raises: :py:class:`ValueError` if `kind` is not an acceptable value
641     :raises: :py:class:`ConfigError` if cannot apply file
642
643     The config file might not be provided in which case a temporary file will
644     be generated and saved on the host's `DUMP_CONFIG_DIR` of not provided as
645     an absolute path. If a vm is provided, the config file will be copied there
646     as a temporary file before applying.
647     """
648     if config_file is None:
649         config_path = generate_config_path(dumped=True)
650     elif os.path.isabs(config_file):
651         config_path = config_file
652     else:
653         config_path = os.path.join(os.path.abspath(DUMP_CONFIG_DIR), config_file)
654     generated = config_file is None
655     config_file = os.path.basename(config_path)
656     log.info("Using %s cnf file %s%s",
657              "generated" if generated else "user-supplied",
658              config_file, " on %s" % vm.name if vm is not None else "")
659
660     # Important to write bytes here to ensure text is encoded with latin-1
661     fd = open(config_path, "wb")
662     try:
663         SET_CNF_METHODS = {
664             "raw": cnfvar.write_cnf_raw,
665             "json": cnfvar.write_cnf_json,
666             "cnf": cnfvar.write_cnf
667         }
668         SET_CNF_METHODS[kind](cnf, out=fd)
669     except KeyError:
670         raise ValueError("Invalid set_cnf method \"%s\"; expected \"json\" or \"cnf\""
671                          % kind)
672     finally:
673         fd.close()
674     log.info("Generated config file %s", config_path)
675
676     kind = "cnf" if kind != "json" else kind
677     set_cnf([config_path], kind=kind, timeout=timeout, vm=vm)
678
679
680 def set_cnf_pipe(cnf, timeout=30, block=False):
681     """
682     Set local configuration by talking to arnied via ``set_cnf``.
683
684     :param cnf: one key with the same value as *kind* and a list of cnfvars as value
685     :type cnf: {str, str}
686     :param int timeout: arnied run verification timeout
687     :param bool block: whether to wait for generate to complete the
688                        configuration change
689     :returns: whether ``set_cnf`` succeeded or not
690     :rtype: bool
691
692     This is obviously not generic but supposed to be run on the guest.
693     """
694     log.info("Setting arnied configuration through local pipe")
695     verify_running(timeout=timeout)
696
697     st, out, exit = sysmisc.run_cmd_with_pipe([BIN_SET_CNF, "-j"], inp=str(cnf))
698
699     if st is False:
700         log.error("Error applying configuration; status=%r" % exit)
701         log.error("and stderr:\n%s" % out)
702         return False
703     log.debug("Configuration successfully passed to set_cnf, "
704               "read %d B from pipe" % len(out))
705
706     if block is True:
707         log.debug("Waiting for config job to complete")
708         wait_for_generate()
709
710     log.debug("Exiting sucessfully")
711     return True
712
713
714 def prep_config_paths(config_files, config_dir=None):
715     """
716     Prepare absolute paths for all configs at an expected location.
717
718     :param config_files: config files to use for the configuration
719     :type config_files: [str]
720     :param config_dir: config directory to prepend to the filepaths
721     :type config_dir: str or None
722     :returns: list of the full config paths
723     :rtype: [str]
724     """
725     if config_dir is None:
726         config_dir = SRC_CONFIG_DIR
727     config_paths = []
728     for config_file in config_files:
729         if os.path.isabs(config_file):
730             # Absolute path: The user requested a specific file
731             # f.e. needed for dynamic arnied config update
732             config_path = config_file
733         else:
734             config_path = os.path.join(os.path.abspath(config_dir),
735                                        config_file)
736         logging.debug("Using %s for original path %s", config_path, config_file)
737         config_paths.append(config_path)
738     return config_paths
739
740
741 def prep_cnf_value(config_file, value,
742                    regex=None, template_key=None, ignore_fail=False):
743     """
744     Replace value in a provided arnied config file.
745
746     :param str config_file: file to use for the replacement
747     :param str value: value to replace the first matched group with
748     :param regex: regular expression to use when replacing a cnf value
749     :type regex: str or None
750     :param template_key: key of a quick template to use for the regex
751     :type template_key: str or None
752     :param bool ignore_fail: whether to ignore regex mismatching
753     :raises: :py:class:`ValueError` if (also default) `regex` doesn't have a match
754
755     In order to ensure better matching capabilities you are supposed to
756     provide a regex pattern with at least one subgroup to match your value.
757     What this means is that the value you like to replace is not directly
758     searched into the config text but matched within a larger regex in
759     in order to avoid any mismatch.
760
761     Example:
762     provider.cnf, 'PROVIDER_LOCALIP,0: "(\d+)"', 127.0.0.1
763     """
764     if template_key is None:
765         pattern = regex.encode()
766     else:
767         samples = {"provider": 'PROVIDER_LOCALIP,\d+: "(\d+\.\d+\.\d+\.\d+)"',
768                    "global_destination_addr": 'SPAMFILTER_GLOBAL_DESTINATION_ADDR,0: "bounce_target@(.*)"'}
769         pattern = samples[template_key].encode()
770
771     with open(config_file, "rb") as file_handle:
772         text = file_handle.read()
773     match_line = re.search(pattern, text)
774
775     if match_line is None and not ignore_fail:
776         raise ValueError("Pattern %s not found in %s" % (pattern, config_file))
777     elif match_line is not None:
778         old_line = match_line.group(0)
779         text = text[:match_line.start(1)] + value.encode() + text[match_line.end(1):]
780         line = re.search(pattern, text).group(0)
781         log.debug("Updating %s to %s in %s", old_line, line, config_file)
782         with open(config_file, "wb") as file_handle:
783             file_handle.write(text)
784
785
786 def prep_cnf(config_files, params_dict, regex_dict=None):
787     """
788     Update all config files with the default overriding parameters,
789     i.e. override the values hard-coded in those config files.
790
791     :param config_files: config files to use for the configuration
792     :type config_files: [str]
793     :param params_dict: parameters to override the defaults in the config files
794     :type params_dict: {str, str}
795     :param regex_dict: regular expressions to use for matching the overriden parameters
796     :type regex_dict: {str, str} or None
797     :returns: list of prepared (modified) config paths
798     :rtype: [str]
799     """
800     log.info("Preparing %s template config files", len(config_files))
801
802     src_config_paths = prep_config_paths(config_files)
803     new_config_paths = []
804     for config_path in src_config_paths:
805         new_config_path = generate_config_path(dumped=True)
806         shutil.copy(config_path, new_config_path)
807         new_config_paths.append(new_config_path)
808
809     for config_path in new_config_paths:
810         for param_key in params_dict.keys():
811             if regex_dict is None:
812                 regex_val = "\s+%s,\d+: \"(.*)\"" % param_key.upper()
813             elif param_key in regex_dict.keys():
814                 regex_val = regex_dict[param_key] % param_key.upper()
815             elif re.match("\w*_\d+$", param_key):
816                 final_parameter, parent_id = \
817                     re.match("(\w*)_(\d+)$", param_key).group(1, 2)
818                 regex_val = "\(%s\) %s,\d+: \"(.*)\"" \
819                     % (parent_id, final_parameter.upper())
820                 log.debug("Requested regex for %s is '%s'",
821                           param_key, regex_val)
822             else:
823                 regex_val = "\s+%s,\d+: \"(.*)\"" % param_key.upper()
824             prep_cnf_value(config_path, params_dict[param_key],
825                            regex=regex_val, ignore_fail=True)
826         log.info("Prepared template config file %s", config_path)
827
828     return new_config_paths
829
830
831 def generate_config_path(dumped=False):
832     """
833     Generate path for a temporary config name.
834
835     :param bool dumped: whether the file should be in the dump
836                         directory or in temporary directory
837     :returns: generated config file path
838     :rtype: str
839     """
840     dir = os.path.abspath(DUMP_CONFIG_DIR) if dumped else None
841     fd, filename = tempfile.mkstemp(suffix=".cnf", dir=dir)
842     os.close(fd)
843     os.unlink(filename)
844     return filename
845
846
847 # enum
848 Delete = 0
849 Update = 1
850 Add = 2
851 Child = 3
852
853
854 def batch_update_cnf(cnf, vars):
855     """
856     Perform a batch update of multiple cnf variables.
857
858     :param cnf: CNF variable to update
859     :type cnf: BuildCnfVar object
860     :param vars: tuples of enumerated action and subtuple with data
861     :type vars: [(int, (str, int, str))]
862     :returns: updated CNF variable
863     :rtype: BuildCnfVar object
864
865     The actions are indexed in the same order: delete, update, add, child.
866     """
867     last = 0
868     for (action, data) in vars:
869         if action == Update:
870             var, ref, val = data
871             last = cnf.update_cnf(var, ref, val)
872         elif action == Add:
873             var, ref, val = data
874             last = cnf.add_cnf(var, ref, val)
875         elif action == Delete:
876             last = cnf.del_cnf(data)
877         elif action == Child:  # only one depth supported
878             var, ref, val = data
879             # do not update last
880             cnf.add_cnf(var, ref, val, different_parent_line_no=last)
881     return cnf
882
883
884 def build_cnf(kind, instance=0, vals=[], data="", filename=None):
885     """
886     Build a CNF variable and save it in a config file.
887
888     :param str kind: name of the CNF variable
889     :param int instance: instance number of the CNF variable
890     :param vals: tuples of enumerated action and subtuple with data
891     :type vals: [(int, (str, int, str))]
892     :param str data: data for the CNF variable
893     :param filename: optional custom name of the config file
894     :type filename: str or None
895     :returns: name of the saved config file
896     :rtype: str
897     """
898     builder = build_cnfvar.BuildCnfVar(kind, instance=instance, data=data)
899     batch_update_cnf(builder, vals)
900     filename = generate_config_path(dumped=True) if filename is None else filename
901     [filename] = prep_config_paths([filename], DUMP_CONFIG_DIR)
902     builder.save(filename)
903     return filename
904