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