| 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) |