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