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