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