Commit | Line | Data |
---|---|---|
f49f6323 PD |
1 | # This Python file uses the following encoding: utf-8 |
2 | ||
11cbb815 PD |
3 | # The software in this package is distributed under the GNU General |
4 | # Public License version 2 (with a special exception described below). | |
5 | # | |
6 | # A copy of GNU General Public License (GPL) is included in this distribution, | |
7 | # in the file COPYING.GPL. | |
8 | # | |
9 | # As a special exception, if other files instantiate templates or use macros | |
10 | # or inline functions from this file, or you compile this file and link it | |
11 | # with other works to produce a work based on this file, this file | |
12 | # does not by itself cause the resulting work to be covered | |
13 | # by the GNU General Public License. | |
14 | # | |
15 | # However the source code for this file must still be made available | |
16 | # in accordance with section (3) of the GNU General Public License. | |
17 | # | |
18 | # This exception does not invalidate any other reasons why a work based | |
19 | # on this file might be covered by the GNU General Public License. | |
20 | # | |
21 | # Copyright (c) 2016-2018 Intra2net AG <info@intra2net.com> | |
22 | ||
f49f6323 | 23 | """ |
f49f6323 PD |
24 | Miscellaneous system utility: Collection of various common system stuff / idioms. |
25 | ||
26 | Copyright: 2015 Intra2net AG | |
27 | ||
f49f6323 PD |
28 | The library exports the symbols below and some custom logging functions. |
29 | ||
fcec8a63 CH |
30 | * run_cmd_with_pipe |
31 | Wrapper for the default use case of the cumbersome "subprocess" library. | |
32 | Accepts a list of arguments that describe the command invocation. Returns | |
33 | ``True`` and the contents of ``stdout`` if the pipe returned sucessfully, | |
34 | ``False`` plus ``stderr`` and the exit status otherwise. For example:: | |
35 | ||
36 | import sysmisc | |
37 | (success, output, _ret) = sysmisc.run_cmd_with_pipe([ "/usr/bin/date", "+%F" ]) | |
38 | if success is True: | |
39 | print("Today is %s" % output) | |
40 | else: | |
41 | print("Failed to read date from pipe.") | |
42 | ||
43 | * get_mountpoints_by_type | |
44 | Extract mount points for the given file system type from */proc/mounts*. | |
45 | Returns ``None`` if the file system is not mounted, a list of mount points | |
46 | otherwise. Raises a test error if */proc/mounts* cannot be accessed. | |
47 | ||
48 | * read_linewise | |
49 | Similar to run_cmd_with_pipe but allows processing of output line-by-line | |
50 | as it becomes available. This may be necessary when the underlying binary | |
51 | creates lots of output that cannot be buffered until the process finishes. | |
52 | Example:: | |
53 | ||
54 | import re | |
55 | import sysmisc | |
56 | def parse(line): | |
27fb89d3 | 57 | if re.match('\\d', line): |
fcec8a63 CH |
58 | print('found digits in line!') |
59 | sysmisc.read_linewise('dump_db', parse) | |
60 | ||
61 | * hash_file | |
62 | Return a hash of a file. | |
63 | ||
64 | * cheat_reboot | |
65 | Replace the reboot binary with a fake one. | |
66 | ||
67 | * cmd_block_till | |
68 | Run a command and wait until a condition evaluates to True. | |
69 | ||
70 | * cd | |
71 | A context manager that temporarily changes the current working directory | |
872eaff1 | 72 | |
f49f6323 PD |
73 | The logging functions either use the format capability or play |
74 | the simple role of providing shorter names. | |
f49f6323 | 75 | """ |
f49f6323 PD |
76 | import re |
77 | import subprocess | |
78 | import hashlib | |
79 | import os | |
80 | import stat | |
81 | import time | |
82 | import types | |
83 | import uuid | |
872eaff1 | 84 | from contextlib import contextmanager |
6ae1ef20 | 85 | import enum |
f49f6323 | 86 | import logging |
3de8b4d8 | 87 | llog = logging.getLogger('pyi2ncommon.sysmisc') |
f49f6323 PD |
88 | |
89 | ||
7628bc48 CH |
90 | __all__ = ("inf", "run_cmd_with_pipe", "get_mountpoints_by_type", |
91 | "read_linewise", "hash_file", "cheat_reboot", | |
92 | "RUN_RESULT_OK", "RUN_RESULT_TIMEDOUT", "RUN_RESULT_FAIL", | |
93 | "RUN_RESULT_NAME", "cmd_block_till") | |
f49f6323 PD |
94 | |
95 | ||
96 | ############################################################################### | |
97 | # HELPERS | |
98 | ############################################################################### | |
99 | ||
872eaff1 CH |
100 | @contextmanager |
101 | def cd(path): | |
102 | """ | |
103 | A context manager which changes the working directory. | |
104 | ||
105 | Changes current working directory to the given path, and then changes it | |
106 | back to its previous value on exit. | |
107 | ||
108 | Taken from comment for python recipe by Greg Warner at | |
109 | http://code.activestate.com/recipes/576620-changedirectory-context-manager/ | |
110 | (MIT license) | |
79da5258 | 111 | |
7628bc48 | 112 | :arg str path: path to temporarily switch to |
872eaff1 CH |
113 | """ |
114 | orig_wd = os.getcwd() | |
115 | os.chdir(path) | |
116 | try: | |
117 | yield | |
118 | finally: | |
119 | os.chdir(orig_wd) | |
120 | ||
121 | ||
f49f6323 PD |
122 | def run_cmd_with_pipe(argv, inp=None): |
123 | """ | |
124 | Read from a process pipe. | |
125 | ||
126 | :param argv: arguments to use for creating a process | |
127 | :type argv: [str] | |
128 | :param inp: Text to be piped into the program’s standard input. | |
129 | :type inp: str | |
130 | :returns: a processes' stdout along with a status info | |
131 | :rtype: bool * str * (int option) | |
132 | ||
133 | Executes a binary and reads its output from a pipe. Returns a triple | |
134 | encoding the programm success, its output either from stdout or stderr, | |
135 | as well the exit status if non-zero. | |
136 | ||
137 | If your process creates a lot of output, consider using | |
138 | :py:func:`read_linewise` instead. | |
139 | """ | |
140 | llog.debug("About to execute \"" + " ".join(argv) + "\"") | |
141 | stdin = None | |
142 | if isinstance(inp, str): | |
143 | stdin = subprocess.PIPE | |
144 | p = subprocess.Popen(argv, | |
145 | stdin=stdin, | |
146 | stdout=subprocess.PIPE, | |
147 | stderr=subprocess.PIPE) | |
148 | if p.stdin is not None: | |
149 | p.stdin.write(inp.encode()) | |
150 | (stdout, stderr) = p.communicate() | |
151 | exit_status = p.wait() | |
152 | if exit_status != 0: | |
153 | return False, stderr.decode(), exit_status | |
154 | return True, stdout.decode(), None | |
155 | ||
156 | ||
157 | procmounts = "/proc/mounts" | |
158 | ||
159 | ||
160 | def get_mountpoints_by_type(fstype): | |
161 | """ | |
ecd1a2fe CH |
162 | Use */proc/mounts* to find filesystem mount points. |
163 | ||
f49f6323 PD |
164 | Determine where some filesystem is mounted by reading the list |
165 | of mountpoints from */proc/mounts*. | |
166 | ||
167 | :param str fstype: filesystem type | |
168 | :returns: any mountpoints found | |
169 | :rtype: str list option or None | |
170 | :raises: :py:class:`IOError` if failed to read the process mounts | |
171 | """ | |
172 | llog.debug("Determine mountpoints of %s." % fstype) | |
173 | mps = None | |
174 | try: | |
175 | with open(procmounts, "r") as m: | |
176 | lines = list(m) | |
7628bc48 | 177 | pat = re.compile(r"^\S+\s+(\S+)\s+" + fstype + r"\s+.*$") |
f49f6323 | 178 | mps = [mp.group(1) |
ecd1a2fe | 179 | for mp in map(lambda line: re.match(pat, line), lines) |
f49f6323 | 180 | if mp] |
7628bc48 CH |
181 | except IOError: |
182 | raise IOError(f"Failed to read {procmounts}") | |
f49f6323 PD |
183 | if not mps: |
184 | return None | |
185 | return mps | |
186 | ||
187 | ||
188 | def read_linewise(cmd, func, **kwargs): | |
189 | """ | |
190 | Run `cmd` using subprocess, applying `func` to each line of stdout/stderr. | |
191 | ||
192 | :param str cmd: command to read linewise | |
193 | :param func: function to apply on each stdout line | |
194 | :type func: function | |
195 | :param kwargs: extra arguments for the subprocess initiation | |
196 | :returns: the process' returncode from :py:meth:`subprocess.Popen.wait` | |
197 | :rtype: int | |
198 | ||
199 | Creates a subprocess.Popen object with given `cmd`, `bufsize`=1, `stdout`=PIPE, | |
200 | `stderr`=STDOUT, `universal_newlines`=True and the given `kwargs`. | |
201 | ||
202 | As opposed to :py:func:`run_cmd_with_pipe`, output is not gathered and | |
203 | returned but processed as it becomes available and then discared. This | |
204 | allows to process output even if there is much of it. | |
205 | """ | |
206 | proc = subprocess.Popen(cmd, bufsize=1, stdout=subprocess.PIPE, | |
207 | stderr=subprocess.STDOUT, universal_newlines=True, | |
208 | **kwargs) | |
209 | ||
210 | for line in proc.stdout: | |
211 | func(line) | |
212 | # (raises a ValueError in communicate() line: | |
213 | # "Mixing iteration and read methods would lose data") | |
214 | # if proc.poll() is not None: | |
215 | # break | |
216 | ||
7628bc48 CH |
217 | # rest_output,_ = proc.communicate() |
218 | # for line in rest_output: | |
219 | # func(line) | |
f49f6323 PD |
220 | |
221 | return proc.wait() | |
222 | ||
223 | ||
224 | def hash_file(fname, new=hashlib.sha512, bs=4096): | |
225 | """ | |
226 | Return a file hash. | |
227 | ||
228 | :param str fname: name of the file to hash | |
229 | :param new: constructor algorithm | |
230 | :type new: builtin_function_or_method | |
231 | :param int bs: read and write up to 'bs' bytes at a time | |
232 | :returns: hexadecimal digest of the strings read from the file | |
233 | :rtype: str | |
234 | """ | |
235 | hsh = new() | |
236 | with open(fname, "rb") as fp: | |
237 | buf = fp.read(bs) | |
238 | while len(buf) > 0: | |
239 | hsh.update(buf) | |
240 | buf = fp.read(bs) | |
241 | return hsh.hexdigest() | |
242 | ||
243 | ||
6ae1ef20 CH |
244 | class ServiceState(enum.Enum): |
245 | """State of a system service, see `get_service_state`.""" | |
ecd1a2fe | 246 | |
6ae1ef20 CH |
247 | RUNNING = 0 |
248 | DEAD_WITH_PIDFILE = 1 | |
249 | DEAD_WITH_LOCKFILE = 2 | |
250 | STOPPED = 3 | |
251 | SERVICE_UNKNOWN = 4 | |
252 | OTHER = -1 | |
253 | ||
254 | ||
255 | def get_service_state(service_name: str) -> ServiceState: | |
256 | """ | |
257 | Get state of a system service. | |
258 | ||
259 | Calls "service {service_name} status", since that works on older and newer systemd-based | |
260 | installs, and tries to interpret return code as defined by the | |
261 | "Linux Standard Base PDA Specification 3.0RC1", Chapter 8.2 ("Init Script Actions", | |
262 | https://refspecs.linuxbase.org/LSB_3.0.0/LSB-PDA/LSB-PDA/iniscrptact.html ). | |
263 | """ | |
264 | code = subprocess.run(["service", service_name, "status"], | |
265 | capture_output=True).returncode | |
266 | if code == 0: | |
267 | return ServiceState.RUNNING | |
268 | elif code == 1: | |
269 | return ServiceState.DEAD_WITH_PIDFILE | |
270 | elif code == 2: | |
271 | return ServiceState.DEAD_WITH_LOCKFILE | |
272 | elif code == 3: | |
273 | return ServiceState.STOPPED | |
274 | elif code == 4: | |
275 | return ServiceState.SERVICE_UNKNOWN | |
276 | else: | |
277 | return ServiceState.OTHER | |
278 | ||
279 | ||
f49f6323 PD |
280 | cheat_tpl = """\ |
281 | #!/bin/sh | |
282 | set -u | |
283 | ||
284 | path="%s" # <- location of original executable, == location of script | |
285 | backup="%s" # <- location of backup | |
286 | chksum="%s" # <- sha512(orig_reboot) | |
287 | log="%s" # <- stdout | |
288 | ||
289 | msg () { | |
290 | echo "[$(date '+%%F %%T')] $*" &>> "${log}" | |
291 | } | |
292 | ||
293 | msg "Fake reboot invoked. Restoring executable from ${backup}." | |
294 | ||
295 | if [ "${chksum}" = "$(sha512sum ${path} | cut -d ' ' -f 1)" ]; then | |
296 | msg "Real reboot executable already in place at ${path}. Aborting." | |
297 | exit 1 | |
298 | fi | |
299 | ||
300 | if [ ! -x "${backup}" ]; then | |
301 | msg "No backup executable at ${backup}!." | |
302 | exit 1 | |
303 | fi | |
304 | ||
305 | if [ "${chksum}" = "$(sha512sum ${backup} | cut -d ' ' -f 1)" ] | |
306 | then | |
307 | msg "Installing backup executable from ${backup} as ${path}." | |
308 | if ! mv -f "${backup}" "${path}"; then | |
309 | msg "Failed to replace ${path}." | |
310 | fi | |
311 | else | |
312 | msg "Checksum mismatch of ${backup}: Expected ${chksum}." | |
313 | exit 1 | |
314 | fi | |
315 | ||
316 | msg "Fake reboot successful -- next invocation will reboot indeed." | |
317 | ||
318 | """ | |
319 | ||
320 | backup_infix = "backup" | |
321 | backup_fmt = "%s.%s_%s" | |
322 | logfile = "/var/log/cheat_reboot.log" | |
323 | ||
324 | ||
325 | def cheat_reboot(): | |
326 | """ | |
327 | Skip one reboot. | |
328 | ||
329 | :raises: :py:class:`exceptions.OSError` if backup target already exists | |
330 | ||
bdb845b9 PD |
331 | This replaces the ``reboot-intranator`` executable by script which |
332 | replaces itself by the backed up executable upon the next invocation. | |
f49f6323 | 333 | """ |
7628bc48 | 334 | # path = utils.system_output("which reboot") |
f49f6323 PD |
335 | path = "/usr/intranator/bin/reboot-intranator" |
336 | suffix = uuid.uuid1() | |
337 | backup = backup_fmt % (path, backup_infix, suffix) | |
338 | ||
339 | if os.path.exists(backup): | |
340 | raise OSError("Target %s already exists." % backup) | |
341 | ||
342 | hexa = hash_file(path) | |
343 | llog.debug("Found reboot at %s, hash %s; dst %s." % (path, hexa, backup)) | |
344 | subprocess.check_call(["mv", "-f", path, backup]) # backup existing binary | |
345 | script = cheat_tpl % (path, backup, hexa, logfile) | |
346 | with open(path, "w") as fp: | |
347 | fp.write(script) # write script content to original location | |
348 | mode = os.stat(path).st_mode | |
349 | os.chmod(path, mode | stat.S_IXUSR | stat.S_IXGRP) # ug+x | |
350 | ||
351 | ||
352 | RUN_RESULT_OK = 0 # → success, cond returned True within timeout | |
353 | RUN_RESULT_TIMEDOUT = 1 # → success, but timeout elapsed | |
354 | RUN_RESULT_FAIL = 2 # → fail | |
355 | ||
356 | RUN_RESULT_NAME = ( | |
357 | {RUN_RESULT_OK: "RUN_RESULT_OK", RUN_RESULT_TIMEDOUT: "RUN_RESULT_TIMEDOUT", RUN_RESULT_FAIL: "RUN_RESULT_FAIL"} | |
358 | ) | |
359 | ||
360 | ||
361 | def cmd_block_till(cmd, timeout, cond, interval=1, *userdata, **kwuserdata): | |
362 | """ | |
363 | Run ``cmd`` and wait until :py:func`cond` evaluates to True. | |
364 | ||
365 | :param cmd: Command line or callback to execute. Function arguments must | |
366 | have the same signature as :py:func:`run_cmd_with_pipe`. | |
7628bc48 | 367 | :type cmd: [str] | types.FunctionType |
f49f6323 | 368 | :param int timeout: Blocking timeout |
7628bc48 CH |
369 | :param cond: Function to call; code will wait for this to return something |
370 | other than `False` | |
371 | :param interval: Time (in seconds) to sleep between each attempt at `cond` | |
df036fbe | 372 | :returns: A Pair of result and error message if appropriate or `None`. |
7628bc48 | 373 | :rtype: (run_result, str | None) |
f49f6323 PD |
374 | """ |
375 | llog.debug("cmd_block_till: %r, %d s, %r", cmd, timeout, cond) | |
376 | if isinstance(cmd, types.FunctionType): | |
377 | succ, out, _ = cmd() | |
378 | elif isinstance(cmd, list): | |
379 | succ, out, _ = run_cmd_with_pipe(cmd) # caution: never pass further arguments! | |
380 | else: | |
381 | raise TypeError("cmd_block_till: invalid type (cmd=%r); expected " | |
382 | "function or argv", cmd) | |
383 | ||
384 | if timeout < 0: | |
385 | return RUN_RESULT_FAIL, "cmd_block_till: invalid timeout; nonnegative " \ | |
386 | "integer expected" | |
387 | if succ is False: | |
388 | return RUN_RESULT_FAIL, "cmd_block_till: command %r failed (%s)" \ | |
389 | % (cmd, str(out)) | |
390 | t_0 = time.time() # brr; cf. PEP 418 as to why | |
391 | while cond(*userdata, **kwuserdata) is False: | |
392 | t_now = time.time() | |
393 | dt = t_now - t_0 | |
394 | if dt > timeout: | |
395 | return RUN_RESULT_TIMEDOUT, "cmd_block_till: command %r exceeded " \ | |
396 | "%d s timeout" % (cmd, timeout) | |
397 | llog.debug("cmd_block_till: condition not satisfied after %d s, " | |
398 | "retrying for another %d s" % (dt, timeout - dt)) | |
399 | time.sleep(interval) | |
400 | return RUN_RESULT_OK, None | |
401 | ||
402 | ||
4965c436 PD |
403 | def replace_file_regex(edited_file, value, regex=None, ignore_fail=False): |
404 | """ | |
405 | Replace with value in a provided file using an optional regex or entirely. | |
406 | ||
407 | :param str edited_file: file to use for the replacement | |
408 | :param str value: value to replace the first matched group with | |
409 | :param regex: more restrictive regular expression to use when replacing with value | |
410 | :type regex: str or None | |
411 | :param bool ignore_fail: whether to ignore regex mismatching | |
412 | :raises: :py:class:`ValueError` if (also default) `regex` doesn't have a match | |
413 | ||
414 | In order to ensure better matching capabilities you are supposed to | |
415 | provide a regex pattern with at least one subgroup to match your value. | |
416 | What this means is that the value you like to replace is not directly | |
417 | searched into the config text but matched within a larger regex in | |
418 | in order to avoid any mismatch. | |
419 | ||
ecd1a2fe | 420 | Example: provider.cnf, 'PROVIDER_LOCALIP,0: "(\\d+)"', 127.0.0.1 |
4965c436 PD |
421 | """ |
422 | pattern = regex.encode() if regex else "(.+)" | |
423 | ||
424 | with open(edited_file, "rb") as file_handle: | |
425 | text = file_handle.read() | |
426 | match_line = re.search(pattern, text) | |
427 | ||
428 | if match_line is None and not ignore_fail: | |
429 | raise ValueError(f"Pattern {pattern} not found in {edited_file}") | |
430 | elif match_line is not None: | |
431 | old_line = match_line.group(0) | |
432 | text = text[:match_line.start(1)] + value.encode() + text[match_line.end(1):] | |
433 | line = re.search(pattern, text).group(0) | |
434 | llog.debug(f"Updating {old_line} to {line} in {edited_file}") | |
435 | with open(edited_file, "wb") as file_handle: | |
436 | file_handle.write(text) | |
437 | ||
438 | ||
f49f6323 PD |
439 | ############################################################################### |
440 | # LOGGING | |
441 | ############################################################################### | |
442 | ||
443 | CURRENT_TEST_STAGE = None | |
444 | CURRENT_TEST_NAME = None | |
445 | LOG_TAG = "%s/%s" % (os.path.basename(__file__), os.uname()[1]) | |
446 | LOG_INDENT = " " | |
447 | ||
448 | ||
449 | def enter_test_stage(s): | |
450 | """Group events into stages for status updates.""" | |
451 | global CURRENT_TEST_STAGE | |
452 | CURRENT_TEST_STAGE = s | |
453 | llog.info("Transitioning to test stage %s", s) | |
454 | ||
455 | ||
456 | def progress(fmt, *args): | |
457 | """Status updates that stand out among the log noise.""" | |
458 | if isinstance(CURRENT_TEST_STAGE, str): | |
459 | label = "/%s" % CURRENT_TEST_STAGE | |
460 | else: | |
461 | label = "" | |
462 | name = CURRENT_TEST_NAME if isinstance(CURRENT_TEST_NAME, str) else "" | |
7628bc48 | 463 | fmt, label = str(fmt), str(label) # yes |
f49f6323 PD |
464 | llog.info("[%s%s] %s" % (name, label, fmt), *args) |
465 | # TODO: this method is more dynamic | |
466 | # llog.info("[%s%s] %s%s" % (LOG_TAG, "", LOG_INDENT*indent, fmt), *args) | |
467 | ||
468 | ||
469 | # these methods serve as shorter names | |
470 | def inf(fmt, *args): | |
471 | """Short name for INFO logging.""" | |
472 | llog.info(fmt, *args) | |
473 | ||
474 | ||
475 | def dbg(fmt, *args): | |
476 | """Short name for DEBUG logging.""" | |
477 | llog.debug(fmt, *args) | |
478 | ||
479 | ||
480 | def err(fmt, *args): | |
481 | """Short name for ERROR logging.""" | |
482 | llog.error(fmt, *args) | |
483 | ||
484 | ||
485 | def wrn(fmt, *args): | |
486 | """Short name for WARN logging.""" | |
487 | llog.error(fmt, *args) | |
488 | ||
489 | ||
490 | # these methods use the format capability | |
491 | def log(level, text, *args, **kwargs): | |
492 | """Log at any level using format capability.""" | |
493 | llog.log(level, text.format(*args), **kwargs) | |
494 | ||
495 | ||
496 | def info(text, *args, **kwargs): | |
497 | """Log at INFO level using format capability.""" | |
498 | log(logging.INFO, text, *args, **kwargs) | |
499 | ||
500 | ||
501 | def debug(text, *args, **kwargs): | |
502 | """Log at DEBUG level using format capability.""" | |
503 | log(logging.DEBUG, text, *args, **kwargs) | |
504 | ||
505 | ||
506 | def error(text, *args, **kwargs): | |
507 | """Log at ERROR level using format capability.""" | |
508 | log(logging.ERROR, text, *args, **kwargs) | |
509 | ||
510 | ||
511 | def warn(text, *args, **kwargs): | |
512 | """Log at WARN level using format capability.""" | |
513 | log(logging.WARN, text, *args, **kwargs) |