43ff842825934f05a9e8dea8ce6ce8e373059023
[pyi2ncommon] / src / sysmisc.py
1 # This Python file uses the following encoding: utf-8
2
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
23 """
24 Miscellaneous system utility: Collection of various common system stuff / idioms.
25
26 Copyright: 2015 Intra2net AG
27
28 The library exports the symbols below and some custom logging functions.
29
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):
57                if re.match('\\d', line):
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
72
73 The logging functions either use the format capability or play
74 the simple role of providing shorter names.
75 """
76 import re
77 import subprocess
78 import hashlib
79 import os
80 import stat
81 import time
82 import types
83 import uuid
84 from contextlib import contextmanager
85 import enum
86 import logging
87 llog = logging.getLogger('pyi2ncommon.sysmisc')
88
89
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")
94
95
96 ###############################################################################
97 # HELPERS
98 ###############################################################################
99
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)
111
112     :arg str path: path to temporarily switch to
113     """
114     orig_wd = os.getcwd()
115     os.chdir(path)
116     try:
117         yield
118     finally:
119         os.chdir(orig_wd)
120
121
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     """
162     Use */proc/mounts* to find filesystem mount points.
163
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)
177             pat = re.compile(r"^\S+\s+(\S+)\s+" + fstype + r"\s+.*$")
178             mps = [mp.group(1)
179                    for mp in map(lambda line: re.match(pat, line), lines)
180                    if mp]
181     except IOError:
182         raise IOError(f"Failed to read {procmounts}")
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
217     # rest_output,_ = proc.communicate()
218     # for line in rest_output:
219     #     func(line)
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
244 class ServiceState(enum.Enum):
245     """State of a system service, see `get_service_state`."""
246
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
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
331     This replaces the ``reboot-intranator`` executable by script which
332     replaces itself by the backed up executable upon the next invocation.
333     """
334     # path   = utils.system_output("which reboot")
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`.
367     :type cmd: [str] | types.FunctionType
368     :param int timeout: Blocking timeout
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`
372     :returns: A Pair of result and error message if appropriate or `None`.
373     :rtype: (run_result, str | None)
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
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
420     Example:     provider.cnf, 'PROVIDER_LOCALIP,0: "(\\d+)"', 127.0.0.1
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
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 ""
463     fmt, label = str(fmt), str(label)  # yes
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)