b4e7d0a0944f390e2b866fe8f20d2b5e5ff4a61c
[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
25 SUMMARY
26 ------------------------------------------------------
27 Miscellaneous system utility: Collection of various common system stuff / idioms.
28
29 Copyright: 2015 Intra2net AG
30
31
32 CONTENTS
33 ------------------------------------------------------
34 The library exports the symbols below and some custom logging functions.
35
36 run_cmd_with_pipe
37     Wrapper for the default use case of the cumbersome "subprocess" library.
38     Acceps a list of arguments that describe the command invocation. Returns
39     ``True`` and the contents of ``stdout`` if the pipe returned sucessfully,
40     ``False`` plus ``stderr`` and the exit status otherwise. For example::
41
42         import sysmisc
43         (success, output, _ret) = sysmisc.run_cmd_with_pipe([ "/usr/bin/date", "+%F" ])
44         if success is True:
45             print("Today is %s" % output)
46         else:
47             print("Failed to read date from pipe.")
48
49 get_mountpoints_by_type
50     Extract mount points for the given file system type from */proc/mounts*.
51     Returns ``None`` if the file system is not mounted, a list of mount points
52     otherwise. Raises a test error if */proc/mounts* cannot be accessed.
53
54 read_linewise
55     Similar to run_cmd_with_pipe but allows processing of output line-by-line
56     as it becomes available. This may be necessary when the underlying binary
57     creates lots of output that cannot be buffered until the process finishes.
58     Example::
59
60         import re
61         import sysmisc
62         def parse(line):
63            if re.match('\d', line):
64                print('found digits in line!')
65         sysmisc.read_linewise('dump_db', parse)
66
67 hash_file
68     Return a hash of a flie.
69
70 cheat_reboot
71     Replace the reboot binary with a fake one.
72
73 cmd_block_till
74     Run a command and wait until a condition evaluates to True.
75
76 cd
77     A context manager that temporarily changes the current working directory
78
79 The logging functions either use the format capability or play
80 the simple role of providing shorter names.
81
82
83 INTERFACE
84 ------------------------------------------------------
85
86 """
87
88 from __future__ import print_function
89
90 import re
91 import subprocess
92 import hashlib
93 import os
94 import stat
95 import time
96 import types
97 import uuid
98 from contextlib import contextmanager
99 import logging
100 llog = logging.getLogger('pyi2ncommon.sysmisc')
101
102
103 __all__ = ("inf", "run_cmd_with_pipe", "get_mountpoints_by_type", "read_linewise", "hash_file", "cheat_reboot", "RUN_RESULT_OK", "RUN_RESULT_TIMEDOUT", "RUN_RESULT_FAIL", "RUN_RESULT_NAME", "cmd_block_till")
104
105
106 ###############################################################################
107 # HELPERS
108 ###############################################################################
109
110 @contextmanager
111 def cd(path):
112     """
113     A context manager which changes the working directory.
114
115     Changes current working directory to the given path, and then changes it
116     back to its previous value on exit.
117
118     Taken from comment for python recipe by Greg Warner at
119     http://code.activestate.com/recipes/576620-changedirectory-context-manager/
120     (MIT license)
121
122     :arg str path: paht to temporarily switch to
123     """
124     orig_wd = os.getcwd()
125     os.chdir(path)
126     try:
127         yield
128     finally:
129         os.chdir(orig_wd)
130
131
132 def run_cmd_with_pipe(argv, inp=None):
133     """
134     Read from a process pipe.
135
136     :param argv: arguments to use for creating a process
137     :type argv: [str]
138     :param inp: Text to be piped into the program’s standard input.
139     :type  inp: str
140     :returns: a processes' stdout along with a status info
141     :rtype: bool * str * (int option)
142
143     Executes a binary and reads its output from a pipe. Returns a triple
144     encoding the programm success, its output either from stdout or stderr,
145     as well the exit status if non-zero.
146
147     If your process creates a lot of output, consider using
148     :py:func:`read_linewise` instead.
149     """
150     llog.debug("About to execute \"" + " ".join(argv) + "\"")
151     stdin = None
152     if isinstance(inp, str):
153         stdin = subprocess.PIPE
154     p = subprocess.Popen(argv,
155                          stdin=stdin,
156                          stdout=subprocess.PIPE,
157                          stderr=subprocess.PIPE)
158     if p.stdin is not None:
159         p.stdin.write(inp.encode())
160     (stdout, stderr) = p.communicate()
161     exit_status = p.wait()
162     if exit_status != 0:
163         return False, stderr.decode(), exit_status
164     return True, stdout.decode(), None
165
166
167 procmounts = "/proc/mounts"
168
169
170 def get_mountpoints_by_type(fstype):
171     """
172     Determine where some filesystem is mounted by reading the list
173     of mountpoints from */proc/mounts*.
174
175     :param str fstype: filesystem type
176     :returns: any mountpoints found
177     :rtype: str list option or None
178     :raises: :py:class:`IOError` if failed to read the process mounts
179     """
180     llog.debug("Determine mountpoints of %s." % fstype)
181     mps = None
182     try:
183         with open(procmounts, "r") as m:
184             lines = list(m)
185             pat = re.compile("^[^\s]+\s+([^\s]+)\s+" + fstype + "\s+.*$")
186             mps = [mp.group(1)
187                    for mp in map(lambda l: re.match(pat, l), lines)
188                    if mp]
189     except IOError as e:
190         raise IOError("Failed to read %s." % procmounts)
191     if not mps:
192         return None
193     return mps
194
195
196 def read_linewise(cmd, func, **kwargs):
197     """
198     Run `cmd` using subprocess, applying `func` to each line of stdout/stderr.
199
200     :param str cmd: command to read linewise
201     :param func: function to apply on each stdout line
202     :type func: function
203     :param kwargs: extra arguments for the subprocess initiation
204     :returns: the process' returncode from :py:meth:`subprocess.Popen.wait`
205     :rtype: int
206
207     Creates a subprocess.Popen object with given `cmd`, `bufsize`=1, `stdout`=PIPE,
208     `stderr`=STDOUT, `universal_newlines`=True and the given `kwargs`.
209
210     As opposed to :py:func:`run_cmd_with_pipe`, output is not gathered and
211     returned but processed as it becomes available and then discared. This
212     allows to process output even if there is much of it.
213     """
214     proc = subprocess.Popen(cmd, bufsize=1, stdout=subprocess.PIPE,
215                             stderr=subprocess.STDOUT, universal_newlines=True,
216                             **kwargs)
217
218     for line in proc.stdout:
219         func(line)
220     #    (raises a ValueError in communicate() line:
221     #     "Mixing iteration and read methods would lose data")
222     #    if proc.poll() is not None:
223     #        break
224
225     #rest_output,_ = proc.communicate()
226     #for line in rest_output:
227     #    func(line)
228
229     return proc.wait()
230
231
232 def hash_file(fname, new=hashlib.sha512, bs=4096):
233     """
234     Return a file hash.
235
236     :param str fname: name of the file to hash
237     :param new: constructor algorithm
238     :type new: builtin_function_or_method
239     :param int bs: read and write up to 'bs' bytes at a time
240     :returns: hexadecimal digest of the strings read from the file
241     :rtype: str
242     """
243     hsh = new()
244     with open(fname, "rb") as fp:
245         buf = fp.read(bs)
246         while len(buf) > 0:
247             hsh.update(buf)
248             buf = fp.read(bs)
249         return hsh.hexdigest()
250
251
252 cheat_tpl = """\
253 #!/bin/sh
254 set -u
255
256 path="%s"   # <- location of original executable, == location of script
257 backup="%s" # <- location of backup
258 chksum="%s" # <- sha512(orig_reboot)
259 log="%s"    # <- stdout
260
261 msg () {
262     echo "[$(date '+%%F %%T')] $*" &>> "${log}"
263 }
264
265 msg "Fake reboot invoked. Restoring executable from ${backup}."
266
267 if [ "${chksum}" = "$(sha512sum ${path} | cut -d ' ' -f 1)" ]; then
268     msg "Real reboot executable already in place at ${path}. Aborting."
269     exit 1
270 fi
271
272 if [ ! -x "${backup}" ]; then
273     msg "No backup executable at ${backup}!."
274     exit 1
275 fi
276
277 if [ "${chksum}" = "$(sha512sum ${backup} | cut -d ' ' -f 1)" ]
278 then
279     msg "Installing backup executable from ${backup} as ${path}."
280     if ! mv -f "${backup}" "${path}"; then
281         msg "Failed to replace ${path}."
282     fi
283 else
284     msg "Checksum mismatch of ${backup}: Expected ${chksum}."
285     exit 1
286 fi
287
288 msg "Fake reboot successful -- next invocation will reboot indeed."
289
290 """
291
292 backup_infix = "backup"
293 backup_fmt = "%s.%s_%s"
294 logfile = "/var/log/cheat_reboot.log"
295
296
297 def cheat_reboot():
298     """
299     Skip one reboot.
300
301     :raises: :py:class:`exceptions.OSError` if backup target already exists
302
303     This replaces the ``reboot-intranator`` executable by script which
304     replaces itself by the backed up executable upon the next invocation.
305     """
306     #path   = utils.system_output("which reboot")
307     path = "/usr/intranator/bin/reboot-intranator"
308     suffix = uuid.uuid1()
309     backup = backup_fmt % (path, backup_infix, suffix)
310
311     if os.path.exists(backup):
312         raise OSError("Target %s already exists." % backup)
313
314     hexa = hash_file(path)
315     llog.debug("Found reboot at %s, hash %s; dst %s." % (path, hexa, backup))
316     subprocess.check_call(["mv", "-f", path, backup])  # backup existing binary
317     script = cheat_tpl % (path, backup, hexa, logfile)
318     with open(path, "w") as fp:
319         fp.write(script)  # write script content to original location
320     mode = os.stat(path).st_mode
321     os.chmod(path, mode | stat.S_IXUSR | stat.S_IXGRP)  # ug+x
322
323
324 RUN_RESULT_OK = 0  # → success, cond returned True within timeout
325 RUN_RESULT_TIMEDOUT = 1  # → success, but timeout elapsed
326 RUN_RESULT_FAIL = 2  # → fail
327
328 RUN_RESULT_NAME = (
329     {RUN_RESULT_OK: "RUN_RESULT_OK", RUN_RESULT_TIMEDOUT: "RUN_RESULT_TIMEDOUT", RUN_RESULT_FAIL: "RUN_RESULT_FAIL"}
330 )
331
332
333 def cmd_block_till(cmd, timeout, cond, interval=1, *userdata, **kwuserdata):
334     """
335     Run ``cmd`` and wait until :py:func`cond` evaluates to True.
336
337     :param cmd: Command line or callback to execute. Function arguments must
338                 have the same signature as :py:func:`run_cmd_with_pipe`.
339     :type  cmd: [str] | types.FunctionType
340     :param int timeout: Blocking timeout
341
342     :returns:       Pair of result and error message if appropriate or
343                     :py:value:`None`.
344     :rtype:         (run_result, str | None)
345     """
346     llog.debug("cmd_block_till: %r, %d s, %r", cmd, timeout, cond)
347     if isinstance(cmd, types.FunctionType):
348         succ, out, _ = cmd()
349     elif isinstance(cmd, list):
350         succ, out, _ = run_cmd_with_pipe(cmd)  # caution: never pass further arguments!
351     else:
352         raise TypeError("cmd_block_till: invalid type (cmd=%r); expected "
353                         "function or argv", cmd)
354
355     if timeout < 0:
356         return RUN_RESULT_FAIL, "cmd_block_till: invalid timeout; nonnegative " \
357                                 "integer expected"
358     if succ is False:
359         return RUN_RESULT_FAIL, "cmd_block_till: command %r failed (%s)" \
360                                 % (cmd, str(out))
361     t_0 = time.time()  # brr; cf. PEP 418 as to why
362     while cond(*userdata, **kwuserdata) is False:
363         t_now = time.time()
364         dt = t_now - t_0
365         if dt > timeout:
366             return RUN_RESULT_TIMEDOUT, "cmd_block_till: command %r exceeded " \
367                                         "%d s timeout" % (cmd, timeout)
368         llog.debug("cmd_block_till: condition not satisfied after %d s, "
369                    "retrying for another %d s" % (dt, timeout - dt))
370         time.sleep(interval)
371     return RUN_RESULT_OK, None
372
373
374 ###############################################################################
375 # LOGGING
376 ###############################################################################
377
378 CURRENT_TEST_STAGE = None
379 CURRENT_TEST_NAME = None
380 LOG_TAG = "%s/%s" % (os.path.basename(__file__), os.uname()[1])
381 LOG_INDENT = "  "
382
383
384 def enter_test_stage(s):
385     """Group events into stages for status updates."""
386     global CURRENT_TEST_STAGE
387     CURRENT_TEST_STAGE = s
388     llog.info("Transitioning to test stage %s", s)
389
390
391 def progress(fmt, *args):
392     """Status updates that stand out among the log noise."""
393     if isinstance(CURRENT_TEST_STAGE, str):
394         label = "/%s" % CURRENT_TEST_STAGE
395     else:
396         label = ""
397     name = CURRENT_TEST_NAME if isinstance(CURRENT_TEST_NAME, str) else ""
398     fmt, label = str(fmt), str(label) # yes
399     llog.info("[%s%s] %s" % (name, label, fmt), *args)
400     # TODO: this method is more dynamic
401     # llog.info("[%s%s] %s%s" % (LOG_TAG, "", LOG_INDENT*indent, fmt), *args)
402
403
404 # these methods serve as shorter names
405 def inf(fmt, *args):
406     """Short name for INFO logging."""
407     llog.info(fmt, *args)
408
409
410 def dbg(fmt, *args):
411     """Short name for DEBUG logging."""
412     llog.debug(fmt, *args)
413
414
415 def err(fmt, *args):
416     """Short name for ERROR logging."""
417     llog.error(fmt, *args)
418
419
420 def wrn(fmt, *args):
421     """Short name for WARN logging."""
422     llog.error(fmt, *args)
423
424
425 # these methods use the format capability
426 def log(level, text, *args, **kwargs):
427     """Log at any level using format capability."""
428     llog.log(level, text.format(*args), **kwargs)
429
430
431 def info(text, *args, **kwargs):
432     """Log at INFO level using format capability."""
433     log(logging.INFO, text, *args, **kwargs)
434
435
436 def debug(text, *args, **kwargs):
437     """Log at DEBUG level using format capability."""
438     log(logging.DEBUG, text, *args, **kwargs)
439
440
441 def error(text, *args, **kwargs):
442     """Log at ERROR level using format capability."""
443     log(logging.ERROR, text, *args, **kwargs)
444
445
446 def warn(text, *args, **kwargs):
447     """Log at WARN level using format capability."""
448     log(logging.WARN, text, *args, **kwargs)