1 # This Python file uses the following encoding: utf-8
3 # The software in this package is distributed under the GNU General
4 # Public License version 2 (with a special exception described below).
6 # A copy of GNU General Public License (GPL) is included in this distribution,
7 # in the file COPYING.GPL.
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.
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.
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.
21 # Copyright (c) 2016-2018 Intra2net AG <info@intra2net.com>
24 Miscellaneous system utility: Collection of various common system stuff / idioms.
26 Copyright: 2015 Intra2net AG
28 The library exports the symbols below and some custom logging functions.
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::
37 (success, output, _ret) = sysmisc.run_cmd_with_pipe([ "/usr/bin/date", "+%F" ])
39 print("Today is %s" % output)
41 print("Failed to read date from pipe.")
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.
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.
57 if re.match('\\d', line):
58 print('found digits in line!')
59 sysmisc.read_linewise('dump_db', parse)
62 Return a hash of a file.
65 Replace the reboot binary with a fake one.
68 Run a command and wait until a condition evaluates to True.
71 A context manager that temporarily changes the current working directory
73 The logging functions either use the format capability or play
74 the simple role of providing shorter names.
84 from contextlib import contextmanager
87 llog = logging.getLogger('pyi2ncommon.sysmisc')
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")
96 ###############################################################################
98 ###############################################################################
103 A context manager which changes the working directory.
105 Changes current working directory to the given path, and then changes it
106 back to its previous value on exit.
108 Taken from comment for python recipe by Greg Warner at
109 http://code.activestate.com/recipes/576620-changedirectory-context-manager/
112 :arg str path: path to temporarily switch to
114 orig_wd = os.getcwd()
122 def run_cmd_with_pipe(argv, inp=None):
124 Read from a process pipe.
126 :param argv: arguments to use for creating a process
128 :param inp: Text to be piped into the program’s standard input.
130 :returns: a processes' stdout along with a status info
131 :rtype: bool * str * (int option)
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.
137 If your process creates a lot of output, consider using
138 :py:func:`read_linewise` instead.
140 llog.debug("About to execute \"" + " ".join(argv) + "\"")
142 if isinstance(inp, str):
143 stdin = subprocess.PIPE
144 p = subprocess.Popen(argv,
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()
153 return False, stderr.decode(), exit_status
154 return True, stdout.decode(), None
157 procmounts = "/proc/mounts"
160 def get_mountpoints_by_type(fstype):
162 Use */proc/mounts* to find filesystem mount points.
164 Determine where some filesystem is mounted by reading the list
165 of mountpoints from */proc/mounts*.
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
172 llog.debug("Determine mountpoints of %s." % fstype)
175 with open(procmounts, "r") as m:
177 pat = re.compile(r"^\S+\s+(\S+)\s+" + fstype + r"\s+.*$")
179 for mp in map(lambda line: re.match(pat, line), lines)
182 raise IOError(f"Failed to read {procmounts}")
188 def read_linewise(cmd, func, **kwargs):
190 Run `cmd` using subprocess, applying `func` to each line of stdout/stderr.
192 :param str cmd: command to read linewise
193 :param func: function to apply on each stdout line
195 :param kwargs: extra arguments for the subprocess initiation
196 :returns: the process' returncode from :py:meth:`subprocess.Popen.wait`
199 Creates a subprocess.Popen object with given `cmd`, `bufsize`=1, `stdout`=PIPE,
200 `stderr`=STDOUT, `universal_newlines`=True and the given `kwargs`.
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.
206 proc = subprocess.Popen(cmd, bufsize=1, stdout=subprocess.PIPE,
207 stderr=subprocess.STDOUT, universal_newlines=True,
210 for line in proc.stdout:
212 # (raises a ValueError in communicate() line:
213 # "Mixing iteration and read methods would lose data")
214 # if proc.poll() is not None:
217 # rest_output,_ = proc.communicate()
218 # for line in rest_output:
224 def hash_file(fname, new=hashlib.sha512, bs=4096):
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
236 with open(fname, "rb") as fp:
241 return hsh.hexdigest()
244 class ServiceState(enum.Enum):
245 """State of a system service, see `get_service_state`."""
248 DEAD_WITH_PIDFILE = 1
249 DEAD_WITH_LOCKFILE = 2
255 def get_service_state(service_name: str) -> ServiceState:
257 Get state of a system service.
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 ).
264 code = subprocess.run(["service", service_name, "status"],
265 capture_output=True).returncode
267 return ServiceState.RUNNING
269 return ServiceState.DEAD_WITH_PIDFILE
271 return ServiceState.DEAD_WITH_LOCKFILE
273 return ServiceState.STOPPED
275 return ServiceState.SERVICE_UNKNOWN
277 return ServiceState.OTHER
284 path="%s" # <- location of original executable, == location of script
285 backup="%s" # <- location of backup
286 chksum="%s" # <- sha512(orig_reboot)
290 echo "[$(date '+%%F %%T')] $*" &>> "${log}"
293 msg "Fake reboot invoked. Restoring executable from ${backup}."
295 if [ "${chksum}" = "$(sha512sum ${path} | cut -d ' ' -f 1)" ]; then
296 msg "Real reboot executable already in place at ${path}. Aborting."
300 if [ ! -x "${backup}" ]; then
301 msg "No backup executable at ${backup}!."
305 if [ "${chksum}" = "$(sha512sum ${backup} | cut -d ' ' -f 1)" ]
307 msg "Installing backup executable from ${backup} as ${path}."
308 if ! mv -f "${backup}" "${path}"; then
309 msg "Failed to replace ${path}."
312 msg "Checksum mismatch of ${backup}: Expected ${chksum}."
316 msg "Fake reboot successful -- next invocation will reboot indeed."
320 backup_infix = "backup"
321 backup_fmt = "%s.%s_%s"
322 logfile = "/var/log/cheat_reboot.log"
329 :raises: :py:class:`exceptions.OSError` if backup target already exists
331 This replaces the ``reboot-intranator`` executable by script which
332 replaces itself by the backed up executable upon the next invocation.
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)
339 if os.path.exists(backup):
340 raise OSError("Target %s already exists." % backup)
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
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
357 {RUN_RESULT_OK: "RUN_RESULT_OK", RUN_RESULT_TIMEDOUT: "RUN_RESULT_TIMEDOUT", RUN_RESULT_FAIL: "RUN_RESULT_FAIL"}
361 def cmd_block_till(cmd, timeout, cond, interval=1, *userdata, **kwuserdata):
363 Run ``cmd`` and wait until :py:func`cond` evaluates to True.
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
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)
375 llog.debug("cmd_block_till: %r, %d s, %r", cmd, timeout, cond)
376 if isinstance(cmd, types.FunctionType):
378 elif isinstance(cmd, list):
379 succ, out, _ = run_cmd_with_pipe(cmd) # caution: never pass further arguments!
381 raise TypeError("cmd_block_till: invalid type (cmd=%r); expected "
382 "function or argv", cmd)
385 return RUN_RESULT_FAIL, "cmd_block_till: invalid timeout; nonnegative " \
388 return RUN_RESULT_FAIL, "cmd_block_till: command %r failed (%s)" \
390 t_0 = time.time() # brr; cf. PEP 418 as to why
391 while cond(*userdata, **kwuserdata) is False:
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))
400 return RUN_RESULT_OK, None
403 def replace_file_regex(edited_file, value, regex=None, ignore_fail=False):
405 Replace with value in a provided file using an optional regex or entirely.
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
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.
420 Example: provider.cnf, 'PROVIDER_LOCALIP,0: "(\\d+)"', 127.0.0.1
422 pattern = regex.encode() if regex else "(.+)"
424 with open(edited_file, "rb") as file_handle:
425 text = file_handle.read()
426 match_line = re.search(pattern, text)
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)
439 ###############################################################################
441 ###############################################################################
443 CURRENT_TEST_STAGE = None
444 CURRENT_TEST_NAME = None
445 LOG_TAG = "%s/%s" % (os.path.basename(__file__), os.uname()[1])
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)
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
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)
469 # these methods serve as shorter names
471 """Short name for INFO logging."""
472 llog.info(fmt, *args)
476 """Short name for DEBUG logging."""
477 llog.debug(fmt, *args)
481 """Short name for ERROR logging."""
482 llog.error(fmt, *args)
486 """Short name for WARN logging."""
487 llog.error(fmt, *args)
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)
496 def info(text, *args, **kwargs):
497 """Log at INFO level using format capability."""
498 log(logging.INFO, text, *args, **kwargs)
501 def debug(text, *args, **kwargs):
502 """Log at DEBUG level using format capability."""
503 log(logging.DEBUG, text, *args, **kwargs)
506 def error(text, *args, **kwargs):
507 """Log at ERROR level using format capability."""
508 log(logging.ERROR, text, *args, **kwargs)
511 def warn(text, *args, **kwargs):
512 """Log at WARN level using format capability."""
513 log(logging.WARN, text, *args, **kwargs)