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