base_sysinfo.py revision 2dc4a41a1a5b7f0d57ed0390700a4d1caf37f97f
1import abc
2import glob
3import logging
4import os
5import re
6import shutil
7import subprocess
8
9from distutils import dir_util
10
11from autotest_lib.client.common_lib import log
12from autotest_lib.client.cros import constants
13from autotest_lib.client.bin import utils, package
14
15_DEFAULT_COMMANDS_TO_LOG_PER_TEST = []
16_DEFAULT_COMMANDS_TO_LOG_PER_BOOT = [
17    'lspci -vvn',
18    'gcc --version',
19    'ld --version',
20    'mount',
21    'hostname',
22    'uptime',
23]
24_DEFAULT_COMMANDS_TO_LOG_BEFORE_ITERATION = []
25_DEFAULT_COMMANDS_TO_LOG_AFTER_ITERATION = []
26
27_DEFAULT_FILES_TO_LOG_PER_TEST = []
28_DEFAULT_FILES_TO_LOG_PER_BOOT = [
29    '/proc/pci',
30    '/proc/meminfo',
31    '/proc/slabinfo',
32    '/proc/version',
33    '/proc/cpuinfo',
34    '/proc/modules',
35    '/proc/interrupts',
36    '/proc/partitions',
37    '/var/log/',
38] + list(constants.LOG_PSTORE_DIRS)
39_DEFAULT_FILES_TO_LOG_BEFORE_ITERATION = [
40    '/proc/schedstat', '/proc/meminfo', '/proc/slabinfo', '/proc/interrupts'
41]
42_DEFAULT_FILES_TO_LOG_AFTER_ITERATION = [
43    '/proc/schedstat', '/proc/meminfo', '/proc/slabinfo', '/proc/interrupts'
44]
45
46
47class loggable(object):
48    """ Abstract class for representing all things "loggable" by sysinfo. """
49
50    def __init__(self, logf, log_in_keyval):
51        self.logf = logf
52        self.log_in_keyval = log_in_keyval
53
54    def readline(self, logdir):
55        """Reads one line from the log.
56
57        @param logdir: The log directory.
58        @return A line from the log, or the empty string if the log doesn't
59            exist.
60        """
61        path = os.path.join(logdir, self.logf)
62        if os.path.exists(path):
63            return utils.read_one_line(path)
64        else:
65            return ""
66
67    @abc.abstractmethod
68    def run(self, logdir):
69        """Executes this loggable creating output in logdir.
70
71        @param logdir: The output directory.
72        """
73        raise NotImplementedError()
74
75
76class logfile(loggable):
77    """Represents a log file."""
78    def __init__(self, path, logf=None, log_in_keyval=False):
79        if not logf:
80            logf = os.path.basename(path)
81        super(logfile, self).__init__(logf, log_in_keyval)
82        self.path = path
83
84
85    def __repr__(self):
86        r = "sysinfo.logfile(%r, %r, %r)"
87        r %= (self.path, self.logf, self.log_in_keyval)
88        return r
89
90
91    def __eq__(self, other):
92        if isinstance(other, logfile):
93            return (self.path, self.logf) == (other.path, other.logf)
94        elif isinstance(other, loggable):
95            return False
96        return NotImplemented
97
98
99    def __ne__(self, other):
100        result = self.__eq__(other)
101        if result is NotImplemented:
102            return result
103        return not result
104
105
106    def __hash__(self):
107        return hash((self.path, self.logf))
108
109
110    def run(self, logdir):
111        """Copies the log file or directory to the specified directory.
112
113        @param logdir: The log directory.
114        """
115        if os.path.exists(self.path):
116            if os.path.isdir(self.path):
117                dst = os.path.join(logdir, self.logf, self.path.lstrip('/'))
118                dir_util.copy_tree(self.path, dst)
119            else:
120                dst = os.path.join(logdir, self.logf)
121                shutil.copyfile(self.path, dst)
122
123
124class command(loggable):
125    """Represents a command."""
126    def __init__(self, cmd, logf=None, log_in_keyval=False, compress_log=False):
127        if not logf:
128            logf = cmd.replace(" ", "_")
129        super(command, self).__init__(logf, log_in_keyval)
130        self.cmd = cmd
131        self._compress_log = compress_log
132
133
134    def __repr__(self):
135        r = "sysinfo.command(%r, %r, %r)"
136        r %= (self.cmd, self.logf, self.log_in_keyval)
137        return r
138
139
140    def __eq__(self, other):
141        if isinstance(other, command):
142            return (self.cmd, self.logf) == (other.cmd, other.logf)
143        elif isinstance(other, loggable):
144            return False
145        return NotImplemented
146
147
148    def __ne__(self, other):
149        result = self.__eq__(other)
150        if result is NotImplemented:
151            return result
152        return not result
153
154
155    def __hash__(self):
156        return hash((self.cmd, self.logf))
157
158
159    def run(self, logdir):
160        """Runs the command.
161
162        @param logdir: The log directory.
163        """
164        env = os.environ.copy()
165        if "PATH" not in env:
166            env["PATH"] = "/usr/bin:/bin"
167        logf_path = os.path.join(logdir, self.logf)
168        stdin = open(os.devnull, "r")
169        stderr = open(os.devnull, "w")
170        stdout = open(logf_path, "w")
171        try:
172            subprocess.call(self.cmd,
173                            stdin=stdin,
174                            stdout=stdout,
175                            stderr=stderr,
176                            shell=True,
177                            env=env)
178        finally:
179            for f in (stdin, stdout, stderr):
180                f.close()
181            if self._compress_log and os.path.exists(logf_path):
182                utils.system('gzip -9 "%s"' % logf_path, ignore_status=True)
183
184
185class base_sysinfo(object):
186    """Represents system info."""
187    def __init__(self, job_resultsdir):
188        self.sysinfodir = self._get_sysinfodir(job_resultsdir)
189
190        # pull in the post-test logs to collect
191        self.test_loggables = set()
192        for cmd in _DEFAULT_COMMANDS_TO_LOG_PER_TEST:
193            self.test_loggables.add(command(cmd))
194        for filename in _DEFAULT_FILES_TO_LOG_PER_TEST:
195            self.test_loggables.add(logfile(filename))
196
197        # pull in the EXTRA post-boot logs to collect
198        self.boot_loggables = set()
199        for cmd in _DEFAULT_COMMANDS_TO_LOG_PER_BOOT:
200            self.boot_loggables.add(command(cmd))
201        for filename in _DEFAULT_FILES_TO_LOG_PER_BOOT:
202            self.boot_loggables.add(logfile(filename))
203
204        # pull in the pre test iteration logs to collect
205        self.before_iteration_loggables = set()
206        for cmd in _DEFAULT_COMMANDS_TO_LOG_BEFORE_ITERATION:
207            self.before_iteration_loggables.add(
208                command(cmd, logf=cmd.replace(" ", "_") + '.before'))
209        for fname in _DEFAULT_FILES_TO_LOG_BEFORE_ITERATION:
210            self.before_iteration_loggables.add(
211                logfile(fname, logf=os.path.basename(fname) + '.before'))
212
213        # pull in the post test iteration logs to collect
214        self.after_iteration_loggables = set()
215        for cmd in _DEFAULT_COMMANDS_TO_LOG_AFTER_ITERATION:
216            self.after_iteration_loggables.add(
217                command(cmd, logf=cmd.replace(" ", "_") + '.after'))
218        for fname in _DEFAULT_FILES_TO_LOG_AFTER_ITERATION:
219            self.after_iteration_loggables.add(
220                logfile(fname, logf=os.path.basename(fname) + '.after'))
221
222        # add in a couple of extra files and commands we want to grab
223        self.test_loggables.add(command("df -mP", logf="df"))
224        # We compress the dmesg because it can get large when kernels are
225        # configured with a large buffer and some tests trigger OOMs or
226        # other large "spam" that fill it up...
227        self.test_loggables.add(command('dmesg -c',
228                                        logf='dmesg',
229                                        compress_log=True))
230        self.test_loggables.add(command('journalctl -o export',
231                                        logf='journal',
232                                        compress_log=True))
233        self.boot_loggables.add(logfile('/proc/cmdline', log_in_keyval=True))
234        # log /proc/mounts but with custom filename since we already
235        # log the output of the "mount" command as the filename "mount"
236        self.boot_loggables.add(logfile('/proc/mounts', logf='proc_mounts'))
237        self.boot_loggables.add(command('uname -a',
238                                        logf='uname',
239                                        log_in_keyval=True))
240        self._installed_packages = []
241
242
243    def serialize(self):
244        """Returns parameters in a dictionary."""
245        return {"boot": self.boot_loggables, "test": self.test_loggables}
246
247
248    def deserialize(self, serialized):
249        """Stores parameters from a dictionary into instance variables.
250
251        @param serialized: A dictionary containing parameters to store as
252            instance variables.
253        """
254        self.boot_loggables = serialized["boot"]
255        self.test_loggables = serialized["test"]
256
257
258    @staticmethod
259    def _get_sysinfodir(resultsdir):
260        sysinfodir = os.path.join(resultsdir, "sysinfo")
261        if not os.path.exists(sysinfodir):
262            os.makedirs(sysinfodir)
263        return sysinfodir
264
265
266    def _get_reboot_count(self):
267        if not glob.glob(os.path.join(self.sysinfodir, "*")):
268            return -1
269        else:
270            return len(glob.glob(os.path.join(self.sysinfodir, "boot.*")))
271
272
273    def _get_boot_subdir(self, next=False):
274        reboot_count = self._get_reboot_count()
275        if next:
276            reboot_count += 1
277        if reboot_count < 1:
278            return self.sysinfodir
279        else:
280            boot_dir = "boot.%d" % (reboot_count - 1)
281            return os.path.join(self.sysinfodir, boot_dir)
282
283
284    def _get_iteration_subdir(self, test, iteration):
285        iter_dir = "iteration.%d" % iteration
286
287        logdir = os.path.join(self._get_sysinfodir(test.outputdir), iter_dir)
288        if not os.path.exists(logdir):
289            os.mkdir(logdir)
290        return logdir
291
292
293    @log.log_and_ignore_errors("post-reboot sysinfo error:")
294    def log_per_reboot_data(self):
295        """Logging hook called when a job starts, and again after any reboot."""
296        logdir = self._get_boot_subdir(next=True)
297        if not os.path.exists(logdir):
298            os.mkdir(logdir)
299
300        _run_loggables_ignoring_errors(
301                self.test_loggables | self.boot_loggables,
302                logdir)
303
304        # also log any installed packages
305        installed_path = os.path.join(logdir, "installed_packages")
306        installed_packages = "\n".join(package.list_all()) + "\n"
307        utils.open_write_close(installed_path, installed_packages)
308
309
310    @log.log_and_ignore_errors("pre-test sysinfo error:")
311    def log_before_each_test(self, test):
312        """Logging hook called before a test starts.
313
314        @param test: A test object.
315        """
316        self._installed_packages = package.list_all()
317        if os.path.exists("/var/log/messages"):
318            stat = os.stat("/var/log/messages")
319            self._messages_size = stat.st_size
320            self._messages_inode = stat.st_ino
321
322        if os.path.exists("/var/log/journal"):
323            # Find the current journal cursor so we later can save new messages.
324            cmd = "/usr/bin/journalctl  -n0 --show-cursor -q"
325            try:
326                cursor = utils.system_output(cmd)
327                prefix = "-- cursor: "
328                pos = cursor.find(prefix) + len(prefix)
329                self._journal_cursor = cursor[pos:]
330            except Exception, e:
331                logging.error("error running journalctl --show-cursor: %s", e)
332
333
334    @log.log_and_ignore_errors("post-test sysinfo error:")
335    def log_after_each_test(self, test):
336        """Logging hook called after a test finishs.
337
338        @param test: A test object.
339        """
340        test_sysinfodir = self._get_sysinfodir(test.outputdir)
341
342        # create a symlink in the test sysinfo dir to the current boot
343        reboot_dir = self._get_boot_subdir()
344        assert os.path.exists(reboot_dir)
345        symlink_dest = os.path.join(test_sysinfodir, "reboot_current")
346        symlink_src = utils.get_relative_path(reboot_dir,
347                                              os.path.dirname(symlink_dest))
348        try:
349            os.symlink(symlink_src, symlink_dest)
350        except Exception, e:
351            raise Exception, '%s: whilst linking %s to %s' % (e, symlink_src,
352                                                              symlink_dest)
353
354        # run all the standard logging commands
355        _run_loggables_ignoring_errors(self.test_loggables, test_sysinfodir)
356
357        # grab any new data from /var/log/messages
358        self._log_messages(test_sysinfodir)
359
360        # grab any new data from systemd journal
361        self._log_journal(test_sysinfodir)
362
363        # log some sysinfo data into the test keyval file
364        keyval = self.log_test_keyvals(test_sysinfodir)
365        test.write_test_keyval(keyval)
366
367        # log any changes to installed packages
368        old_packages = set(self._installed_packages)
369        new_packages = set(package.list_all())
370        added_path = os.path.join(test_sysinfodir, "added_packages")
371        added_packages = "\n".join(new_packages - old_packages) + "\n"
372        utils.open_write_close(added_path, added_packages)
373        removed_path = os.path.join(test_sysinfodir, "removed_packages")
374        removed_packages = "\n".join(old_packages - new_packages) + "\n"
375        utils.open_write_close(removed_path, removed_packages)
376
377
378    @log.log_and_ignore_errors("pre-test siteration sysinfo error:")
379    def log_before_each_iteration(self, test, iteration=None):
380        """Logging hook called before a test iteration.
381
382        @param test: A test object.
383        @param iteration: A test iteration.
384        """
385        if not iteration:
386            iteration = test.iteration
387        logdir = self._get_iteration_subdir(test, iteration)
388        _run_loggables_ignoring_errors(self.before_iteration_loggables, logdir)
389        # Start each log with the board name for orientation.
390        board = utils.get_board_with_frequency_and_memory()
391        logging.info('ChromeOS BOARD = %s', board)
392        # Leave some autotest bread crumbs in the system logs.
393        utils.system('logger "autotest starting iteration %s on %s"' % (logdir,
394                                                                        board),
395                     ignore_status=True)
396
397
398    @log.log_and_ignore_errors("post-test siteration sysinfo error:")
399    def log_after_each_iteration(self, test, iteration=None):
400        """Logging hook called after a test iteration.
401
402        @param test: A test object.
403        @param iteration: A test iteration.
404        """
405        if not iteration:
406            iteration = test.iteration
407        logdir = self._get_iteration_subdir(test, iteration)
408        _run_loggables_ignoring_errors(self.after_iteration_loggables, logdir)
409        utils.system('logger "autotest finished iteration %s"' % logdir,
410                     ignore_status=True)
411
412
413    def _log_messages(self, logdir):
414        """Log all of the new data in /var/log/messages."""
415        try:
416            # log all of the new data in /var/log/messages
417            bytes_to_skip = 0
418            if hasattr(self, "_messages_size"):
419                current_inode = os.stat("/var/log/messages").st_ino
420                if current_inode == self._messages_inode:
421                    bytes_to_skip = self._messages_size
422            in_messages = open("/var/log/messages")
423            in_messages.seek(bytes_to_skip)
424            out_messages = open(os.path.join(logdir, "messages"), "w")
425            out_messages.write(in_messages.read())
426            in_messages.close()
427            out_messages.close()
428        except Exception, e:
429            logging.error("/var/log/messages collection failed with %s", e)
430
431
432    def _log_journal(self, logdir):
433        """Log all of the new data in systemd journal."""
434        if not hasattr(self, "_journal_cursor"):
435            return
436
437        cmd = "/usr/bin/journalctl --after-cursor \"%s\"" % (
438            self._journal_cursor)
439        try:
440            with open(os.path.join(logdir, "journal"), "w") as journal:
441              journal.write(utils.system_output(cmd))
442        except Exception, e:
443            logging.error("journal collection failed with %s", e)
444
445
446    @staticmethod
447    def _read_sysinfo_keyvals(loggables, logdir):
448        keyval = {}
449        for log in loggables:
450            if log.log_in_keyval:
451                keyval["sysinfo-" + log.logf] = log.readline(logdir)
452        return keyval
453
454
455    def log_test_keyvals(self, test_sysinfodir):
456        """Logging hook called by log_after_each_test.
457
458        Collects keyval entries to be written in the test keyval.
459
460        @param test_sysinfodir: The test's system info directory.
461        """
462        keyval = {}
463
464        # grab any loggables that should be in the keyval
465        keyval.update(self._read_sysinfo_keyvals(self.test_loggables,
466                                                 test_sysinfodir))
467        keyval.update(self._read_sysinfo_keyvals(
468            self.boot_loggables, os.path.join(test_sysinfodir,
469                                              'reboot_current')))
470
471        # remove hostname from uname info
472        #   Linux lpt36 2.6.18-smp-230.1 #1 [4069269] SMP Fri Oct 24 11:30:...
473        if "sysinfo-uname" in keyval:
474            kernel_vers = " ".join(keyval["sysinfo-uname"].split()[2:])
475            keyval["sysinfo-uname"] = kernel_vers
476
477        # grab the total avail memory, not used by sys tables
478        path = os.path.join(test_sysinfodir, "reboot_current", "meminfo")
479        if os.path.exists(path):
480            mem_data = open(path).read()
481            match = re.search(r'^MemTotal:\s+(\d+) kB$', mem_data, re.MULTILINE)
482            if match:
483                keyval["sysinfo-memtotal-in-kb"] = match.group(1)
484
485        # guess the system's total physical memory, including sys tables
486        keyval["sysinfo-phys-mbytes"] = utils.rounded_memtotal()//1024
487
488        # return what we collected
489        return keyval
490
491def _run_loggables_ignoring_errors(loggables, output_dir):
492    """Runs the given loggables robustly.
493
494    In the event of any one of the loggables raising an exception, we print a
495    traceback and continue on.
496
497    @param loggables: An iterable of base_sysinfo.loggable objects.
498    @param output_dir: Path to the output directory.
499    """
500    for log in loggables:
501        try:
502            log.run(output_dir)
503        except Exception:
504            logging.exception(
505                    'Failed to collect loggable %r to %s. Continuing...',
506                    log, output_dir)
507