site_sysinfo.py revision aded6c65365a20cb624f2478885b037cefa05676
1# Copyright (c) 2011 The Chromium OS Authors. All rights reserved.
2# Use of this source code is governed by a BSD-style license that can be
3# found in the LICENSE file.
4
5import logging
6import os
7
8from autotest_lib.client.common_lib import log
9from autotest_lib.client.common_lib import error, utils, global_config
10from autotest_lib.client.bin import base_sysinfo, utils
11from autotest_lib.client.cros import constants, tpm_dam
12
13get_value = global_config.global_config.get_config_value
14collect_corefiles = get_value('CLIENT', 'collect_corefiles',
15                              type=bool, default=True)
16
17
18logfile = base_sysinfo.logfile
19command = base_sysinfo.command
20
21
22class logdir(base_sysinfo.loggable):
23    """Represents a log directory."""
24    def __init__(self, directory, additional_exclude=None):
25        super(logdir, self).__init__(directory, log_in_keyval=False)
26        self.dir = directory
27        self.additional_exclude = additional_exclude
28
29
30    def __repr__(self):
31        return "site_sysinfo.logdir(%r, %s)" % (self.dir,
32                                                self.additional_exclude)
33
34
35    def __eq__(self, other):
36        if isinstance(other, logdir):
37            return (self.dir == other.dir and
38                    self.additional_exclude == other.additional_exclude)
39        elif isinstance(other, base_sysinfo.loggable):
40            return False
41        return NotImplemented
42
43
44    def __ne__(self, other):
45        result = self.__eq__(other)
46        if result is NotImplemented:
47            return result
48        return not result
49
50
51    def __hash__(self):
52        return hash(self.dir) + hash(self.additional_exclude)
53
54
55    def run(self, log_dir):
56        """Copies this log directory to the specified directory.
57
58        @param log_dir: The destination log directory.
59        """
60        if os.path.exists(self.dir):
61            parent_dir = os.path.dirname(self.dir)
62            utils.system("mkdir -p %s%s" % (log_dir, parent_dir))
63            # Take source permissions and add ugo+r so files are accessible via
64            # archive server.
65            additional_exclude_str = ""
66            if self.additional_exclude:
67                additional_exclude_str = "--exclude=" + self.additional_exclude
68
69            utils.system("rsync --no-perms --chmod=ugo+r -a --exclude=autoserv*"
70                         " %s %s %s%s" % (additional_exclude_str, self.dir,
71                                          log_dir, parent_dir))
72
73
74class file_stat(object):
75    """Store the file size and inode, used for retrieving new data in file."""
76    def __init__(self, file_path):
77        """Collect the size and inode information of a file.
78
79        @param file_path: full path to the file.
80
81        """
82        stat = os.stat(file_path)
83        # Start size of the file, skip that amount of bytes when do diff.
84        self.st_size = stat.st_size
85        # inode of the file. If inode is changed, treat this as a new file and
86        # copy the whole file.
87        self.st_ino = stat.st_ino
88
89
90class diffable_logdir(logdir):
91    """Represents a log directory that only new content will be copied.
92
93    An instance of this class should be added in both
94    before_iteration_loggables and after_iteration_loggables. This is to
95    guarantee the file status information is collected when run method is
96    called in before_iteration_loggables, and diff is executed when run
97    method is called in after_iteration_loggables.
98
99    """
100    def __init__(self, directory, additional_exclude=None,
101                 keep_file_hierarchy=True, append_diff_in_name=True):
102        """
103        Constructor of a diffable_logdir instance.
104
105        @param directory: directory to be diffed after an iteration finished.
106        @param additional_exclude: additional dir to be excluded, not used.
107        @param keep_file_hierarchy: True if need to preserve full path, e.g.,
108            sysinfo/var/log/sysstat, v.s. sysinfo/sysstat if it's False.
109        @param append_diff_in_name: True if you want to append '_diff' to the
110            folder name to indicate it's a diff, e.g., var/log_diff. Option
111            keep_file_hierarchy must be True for this to take effect.
112
113        """
114        super(diffable_logdir, self).__init__(directory, additional_exclude)
115        self.additional_exclude = additional_exclude
116        self.keep_file_hierarchy = keep_file_hierarchy
117        self.append_diff_in_name = append_diff_in_name
118        # Init dictionary to store all file status for files in the directory.
119        self._log_stats = {}
120
121
122    def _get_init_status_of_src_dir(self, src_dir):
123        """Get initial status of files in src_dir folder.
124
125        @param src_dir: directory to be diff-ed.
126
127        """
128        # Dictionary used to store the initial status of files in src_dir.
129        for file_path in self._get_all_files(src_dir):
130            self._log_stats[file_path] = file_stat(file_path)
131        self.file_stats_collected = True
132
133
134    def _get_all_files(self, path):
135        """Iterate through files in given path including subdirectories.
136
137        @param path: root directory.
138        @return: an iterator that iterates through all files in given path
139            including subdirectories.
140
141        """
142        if not os.path.exists(path):
143            yield []
144        for root, dirs, files in os.walk(path):
145            for f in files:
146                if f.startswith('autoserv'):
147                    continue
148                yield os.path.join(root, f)
149
150
151    def _copy_new_data_in_file(self, file_path, src_dir, dest_dir):
152        """Copy all new data in a file to target directory.
153
154        @param file_path: full path to the file to be copied.
155        @param src_dir: source directory to do the diff.
156        @param dest_dir: target directory to store new data of src_dir.
157
158        """
159        bytes_to_skip = 0
160        if self._log_stats.has_key(file_path):
161            prev_stat = self._log_stats[file_path]
162            new_stat = os.stat(file_path)
163            if new_stat.st_ino == prev_stat.st_ino:
164                bytes_to_skip = prev_stat.st_size
165            if new_stat.st_size == bytes_to_skip:
166                return
167            elif new_stat.st_size < prev_stat.st_size:
168                # File is modified to a smaller size, copy whole file.
169                bytes_to_skip = 0
170        try:
171            with open(file_path, 'r') as in_log:
172                if bytes_to_skip > 0:
173                    in_log.seek(bytes_to_skip)
174                # Skip src_dir in path, e.g., src_dir/[sub_dir]/file_name.
175                target_path = os.path.join(dest_dir,
176                                           os.path.relpath(file_path, src_dir))
177                target_dir = os.path.dirname(target_path)
178                if not os.path.exists(target_dir):
179                    os.makedirs(target_dir)
180                with open(target_path, "w") as out_log:
181                    out_log.write(in_log.read())
182        except IOError as e:
183            logging.error('Diff %s failed with error: %s', file_path, e)
184
185
186    def _log_diff(self, src_dir, dest_dir):
187        """Log all of the new data in src_dir to dest_dir.
188
189        @param src_dir: source directory to do the diff.
190        @param dest_dir: target directory to store new data of src_dir.
191
192        """
193        if self.keep_file_hierarchy:
194            dir = src_dir.lstrip('/')
195            if self.append_diff_in_name:
196                dir = dir.rstrip('/') + '_diff'
197            dest_dir = os.path.join(dest_dir, dir)
198
199        if not os.path.exists(dest_dir):
200            os.makedirs(dest_dir)
201
202        for src_file in self._get_all_files(src_dir):
203            self._copy_new_data_in_file(src_file, src_dir, dest_dir)
204
205
206    def run(self, log_dir, collect_init_status=True, collect_all=False):
207        """Copies new content from self.dir to the destination log_dir.
208
209        @param log_dir: The destination log directory.
210        @param collect_init_status: Set to True if run method is called to
211            collect the initial status of files.
212        @param collect_all: Set to True to force to collect all files.
213
214        """
215        if collect_init_status:
216            self._get_init_status_of_src_dir(self.dir)
217        elif os.path.exists(self.dir):
218            # Always create a copy of the new logs to help debugging.
219            self._log_diff(self.dir, log_dir)
220            if collect_all:
221                logdir_temp = logdir(self.dir)
222                logdir_temp.run(log_dir)
223
224
225class purgeable_logdir(logdir):
226    """Represents a log directory that will be purged."""
227    def __init__(self, directory, additional_exclude=None):
228        super(purgeable_logdir, self).__init__(directory, additional_exclude)
229        self.additional_exclude = additional_exclude
230
231    def run(self, log_dir):
232        """Copies this log dir to the destination dir, then purges the source.
233
234        @param log_dir: The destination log directory.
235        """
236        super(purgeable_logdir, self).run(log_dir)
237
238        if os.path.exists(self.dir):
239            utils.system("rm -rf %s/*" % (self.dir))
240
241
242class site_sysinfo(base_sysinfo.base_sysinfo):
243    """Represents site system info."""
244    def __init__(self, job_resultsdir):
245        super(site_sysinfo, self).__init__(job_resultsdir)
246        crash_exclude_string = None
247        if not collect_corefiles:
248            crash_exclude_string = "*.core"
249
250        # This is added in before and after_iteration_loggables. When run is
251        # called in before_iteration_loggables, it collects file status in
252        # the directory. When run is called in after_iteration_loggables, diff
253        # is executed.
254        # self.diffable_loggables is only initialized if the instance does not
255        # have this attribute yet. The sysinfo instance could be loaded
256        # from an earlier pickle dump, which has already initialized attribute
257        # self.diffable_loggables.
258        if not hasattr(self, 'diffable_loggables'):
259            diffable_log = diffable_logdir(constants.LOG_DIR)
260            self.diffable_loggables = set()
261            self.diffable_loggables.add(diffable_log)
262
263        # add in some extra command logging
264        self.boot_loggables.add(command("ls -l /boot",
265                                        "boot_file_list"))
266        self.before_iteration_loggables.add(
267            command(constants.CHROME_VERSION_COMMAND, "chrome_version"))
268        self.boot_loggables.add(command("crossystem", "crossystem"))
269        self.test_loggables.add(
270            purgeable_logdir(
271                os.path.join(constants.CRYPTOHOME_MOUNT_PT, "log")))
272        # We only want to gather and purge crash reports after the client test
273        # runs in case a client test is checking that a crash found at boot
274        # (such as a kernel crash) is handled.
275        self.after_iteration_loggables.add(
276            purgeable_logdir(
277                os.path.join(constants.CRYPTOHOME_MOUNT_PT, "crash"),
278                additional_exclude=crash_exclude_string))
279        self.after_iteration_loggables.add(
280            purgeable_logdir(constants.CRASH_DIR,
281                             additional_exclude=crash_exclude_string))
282        self.test_loggables.add(
283            logfile(os.path.join(constants.USER_DATA_DIR,
284                                 ".Google/Google Talk Plugin/gtbplugin.log")))
285        self.test_loggables.add(purgeable_logdir(
286                constants.CRASH_DIR,
287                additional_exclude=crash_exclude_string))
288        # Collect files under /tmp/crash_reporter, which contain the procfs
289        # copy of those crashed processes whose core file didn't get converted
290        # into minidump. We need these additional files for post-mortem analysis
291        # of the conversion failure.
292        self.test_loggables.add(
293            purgeable_logdir(constants.CRASH_REPORTER_RESIDUE_DIR))
294
295
296    @log.log_and_ignore_errors("pre-test sysinfo error:")
297    def log_before_each_test(self, test):
298        """Logging hook called before a test starts.
299
300        @param test: A test object.
301        """
302        super(site_sysinfo, self).log_before_each_test(test)
303
304        for log in self.diffable_loggables:
305            log.run(log_dir=None, collect_init_status=True)
306
307
308    @log.log_and_ignore_errors("post-test sysinfo error:")
309    def log_after_each_test(self, test):
310        """Logging hook called after a test finishs.
311
312        @param test: A test object.
313        """
314        super(site_sysinfo, self).log_after_each_test(test)
315
316        test_sysinfodir = self._get_sysinfodir(test.outputdir)
317
318        for log in self.diffable_loggables:
319            log.run(log_dir=test_sysinfodir, collect_init_status=False,
320                    collect_all=not test.success)
321
322
323    def _get_chrome_version(self):
324        """Gets the Chrome version number and milestone as strings.
325
326        Invokes "chrome --version" to get the version number and milestone.
327
328        @return A tuple (chrome_ver, milestone) where "chrome_ver" is the
329            current Chrome version number as a string (in the form "W.X.Y.Z")
330            and "milestone" is the first component of the version number
331            (the "W" from "W.X.Y.Z").  If the version number cannot be parsed
332            in the "W.X.Y.Z" format, the "chrome_ver" will be the full output
333            of "chrome --version" and the milestone will be the empty string.
334
335        """
336        version_string = utils.system_output(constants.CHROME_VERSION_COMMAND,
337                                             ignore_status=True)
338        return utils.parse_chrome_version(version_string)
339
340
341    def log_test_keyvals(self, test_sysinfodir):
342        keyval = super(site_sysinfo, self).log_test_keyvals(test_sysinfodir)
343
344        lsb_lines = utils.system_output(
345            "cat /etc/lsb-release",
346            ignore_status=True).splitlines()
347        lsb_dict = dict(item.split("=") for item in lsb_lines)
348
349        for lsb_key in lsb_dict.keys():
350            # Special handling for build number
351            if lsb_key == "CHROMEOS_RELEASE_DESCRIPTION":
352                keyval["CHROMEOS_BUILD"] = (
353                    lsb_dict[lsb_key].rstrip(")").split(" ")[3])
354            keyval[lsb_key] = lsb_dict[lsb_key]
355
356        # Get the hwid (hardware ID), if applicable.
357        try:
358            keyval["hwid"] = utils.system_output('crossystem hwid')
359        except error.CmdError:
360            # The hwid may not be available (e.g, when running on a VM).
361            # If the output of 'crossystem mainfw_type' is 'nonchrome', then
362            # we expect the hwid to not be avilable, and we can proceed in this
363            # case.  Otherwise, the hwid is missing unexpectedly.
364            mainfw_type = utils.system_output('crossystem mainfw_type')
365            if mainfw_type == 'nonchrome':
366                logging.info(
367                    'HWID not available; not logging it as a test keyval.')
368            else:
369                logging.exception('HWID expected but could not be identified; '
370                                  'output of "crossystem mainfw_type" is "%s"',
371                                  mainfw_type)
372                raise
373
374        # Get the chrome version and milestone numbers.
375        keyval["CHROME_VERSION"], keyval["MILESTONE"] = (
376                self._get_chrome_version())
377
378        # Get the dictionary attack counter.
379        keyval["TPM_DICTIONARY_ATTACK_COUNTER"] = (
380                tpm_dam.get_dictionary_attack_counter())
381
382        # Return the updated keyvals.
383        return keyval
384
385
386    def add_logdir(self, log_path):
387        """Collect files in log_path to sysinfo folder.
388
389        This method can be called from a control file for test to collect files
390        in a specified folder. autotest creates a folder
391        [test result dir]/sysinfo folder with the full path of log_path and copy
392        all files in log_path to that folder.
393
394        @param log_path: Full path of a folder that test needs to collect files
395                         from, e.g.,
396                         /mnt/stateful_partition/unencrypted/preserve/log
397        """
398        self.test_loggables.add(logdir(log_path))
399