blob: 1f0d4644c798543f4fdf2a596255828be7dede25 [file] [log] [blame]
#!/usr/bin/env python2.7
# Copyright 2014 The Chromium Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Parses BuildBot `actions.log' and extracts events.
These events can be uploaded as JSON to monitoring endpoints.
"""
import argparse
import collections
import datetime
import glob
import httplib
import json
import logging
import os
import pprint
import re
import sys
import requests
class PostFailedError(RuntimeError):
pass
# Holds information for a master that will be operated on.
_Master = collections.namedtuple('Master',
('name', 'path')
)
class Master(_Master):
"""Class that encapsulates a Master, its data, and operations."""
# Disable "Class has no '__init__' warning" | pylint: disable=W0232
# Example action line:
# ** Fri Aug 22 18:30:50 PDT 2014 make stop
ACTIONS_RE = re.compile(r'\*\*\s+(.+?)\t(\w.+)')
# The format to use for the timestamp file.
TIMESTAMP_FORMAT = '%Y-%m-%d-%H-%M-%S'
@property
def actions_log_path(self):
"""Returns: the path to this Master's 'actions.log' file."""
return os.path.join(self.path, 'actions.log')
@property
def timestamp_path(self):
"""Returns: the path to this Master's timestamp file."""
return os.path.join(self.path, 'actions_parser.timestamp')
@classmethod
def fromdir(cls, d):
"""Instantiates a new Master given its base directory."""
name = os.path.split(d)[1]
if name.startswith('master.'):
name = name[7:]
return cls(name=name, path=d)
def get_timestamp(self):
"""Returns the timestamp for this master, or None if there is no timestamp.
"""
try:
with open(self.timestamp_path, 'r') as fd:
timestamp = fd.read()
except IOError:
logging.debug('Failed to open difference file for "%s" at: %s',
self.name, self.timestamp_path)
return None
try:
return datetime.datetime.strptime(timestamp, self.TIMESTAMP_FORMAT)
except ValueError as e:
logging.warning('Failed to load timestamp from "%s": %s',
timestamp, e.message)
def write_timestamp(self, dt):
"""Writes a new timestamp file for this master with the value 'dt'."""
value = dt.strftime(self.TIMESTAMP_FORMAT)
logging.info('Writing timestamp for "%s" at "%s" to: %s',
self.name, dt, self.timestamp_path)
with open(self.timestamp_path, 'w') as fd:
fd.write(value)
def delete_timestamp(self):
"""Deletes the timestamp file for this master, if it exists."""
logging.debug('Removing timestamp file for "%s" at: %s',
self.name, self.timestamp_path)
try:
os.remove(self.timestamp_path)
except Exception as e:
# Failure to remove is not a fatal error. If the file doesn't exist, it's
# not even an error.
logging.debug('Failed to remove timestamp at [%s]: %s',
self.timestamp_path, e.message)
def load_actions(self, threshold=None):
"""Loads the set of actions from this master.
Args:
threshold: (datetime) if not None, only returns actions with timestamps
after this time.
"""
if not os.path.exists(self.actions_log_path):
logging.warning('No "actions.log" found for master %s at: %s',
self.name, self.actions_log_path)
# Load matches from the 'actions.log'.
matches = []
with open(self.actions_log_path, 'r') as fd:
for line in fd:
match = self.ACTIONS_RE.match(line)
if match:
matches.append(match)
# Parse the matches into actions
actions = []
timestamps = []
for match in matches:
datestr, action = match.groups()
try:
date = datetime.datetime.strptime(datestr, '%a %b %d %H:%M:%S %Z %Y')
except ValueError as e:
logging.error('Failed to parse date from "%s": %s', datestr, e.message)
continue
if threshold and date <= threshold:
logging.debug('Skipping action "%s" from "%s" at %s (below threshold)',
action, self.name, date)
continue
# Parse action
action = action.strip()
actions.append((date, action))
timestamps.append(date)
# Calculate the largest timestamp (None if there were none)
last_timestamp = (None) if not timestamps else (max(timestamps))
return actions, last_timestamp
def do_post(actions, endpoint):
"""Transcribes 'actions' into a JSON dictionary and POSTs it to an endpoint.
Args:
actions: JSON-nable data to POST.
endpoint: The endpoint URL.
"""
def json_default(obj):
if isinstance(obj, datetime.datetime):
return obj.isoformat()
raise TypeError('Don\'t know how to translate "%s" to JSON' % (
type(obj).__name__,))
json_actions = json.dumps(actions, default=json_default)
logging.debug('Posting JSON data to [%s]: %s', endpoint, json_actions)
r = requests.post(endpoint, data=json_actions, verify=True)
if r.status_code != httplib.OK:
logging.error('Failed to POST JSON data to [%s]: %s',
endpoint, r.status_code)
raise PostFailedError('Unsuccessful HTTP response (%s)' % (r.status_code,))
return 0
def get_master(checkout_root, name):
"""Returns a Master instance for 'name', or None if one doesn't exist."""
# Prepend name with 'master.' if not specified.
if not name.startswith('master.'):
name = 'master.%s' % (name,)
# Identify masters as directories containing 'master.cfg' files.
glob_path = os.path.join(checkout_root, '*', 'masters', name, 'master.cfg')
for candidate in glob.iglob(glob_path):
return Master.fromdir(os.path.split(candidate)[0])
return None
def get_all_masters(checkout_root):
"""Identifies all Master instances by probing a checkout root."""
masters = []
# Identify masters as directories containing 'master.cfg' files.
glob_path = os.path.join(checkout_root, '*', 'masters', '*', 'master.cfg')
for candidate in glob.iglob(glob_path):
master = Master.fromdir(os.path.split(candidate)[0])
# Discard this master if there is no 'actions.log' file.
if not os.path.exists(master.actions_log_path):
logging.debug('Discarding master "%s": no "actions.log" available.',
master.name)
continue
masters.append(master)
return masters
def main():
parser = argparse.ArgumentParser(
description='Parse a master\'s actions.log file for events.',
prog='./runit.py actions_parser.py')
parser.add_argument(
'-v', '--verbose', action='count',
help='Increases logging verbosity. Can be specified multiple times.')
parser.add_argument(
'-C', '--checkout-root', action='store', metavar='PATH',
help='The checkout root to use for "master" probing.')
parser.add_argument(
'-A', '--all', action='store_true',
help='Include output for all Masters hosted on this system.')
parser.add_argument(
'-P', '--post', metavar='ENDPOINT',
help='Post JSON actions for each master to the specified ENDPOINT.')
parser.add_argument(
'-c', '--clear-difference', action='store_true',
help='Clear any existing difference files.')
parser.add_argument(
'-D', '--difference', action='store_true',
help='Calculate the actions since the "difference" time. After '
'successful operation, a new difference file will be written.')
parser.add_argument(
'mastername', nargs='*',
help='The names of masters to extract action information for.')
args = parser.parse_args()
log_levels = (logging.WARNING, logging.INFO, logging.DEBUG)
logging.getLogger().setLevel(log_levels[max(args.verbose, len(log_levels)-1)])
# Identify the checkout root
checkout_root = args.checkout_root
if not checkout_root:
# Our script is in '<ROOT>/<build>/scripts/tools'; we want <ROOT>.
checkout_root = os.path.abspath(os.path.join(
os.path.dirname(__file__), os.pardir, os.pardir, os.pardir))
checkout_root = os.path.expanduser(checkout_root)
logging.debug('Using checkout root: %s', checkout_root)
# Get the list of masters to run against.
masters = []
if args.all:
masters += get_all_masters(checkout_root)
missing_masters = []
for name in args.mastername:
master = get_master(checkout_root, name)
if not master:
missing_masters.append(name)
continue
masters.append(master)
if missing_masters:
logging.error('Unable to locate masters: %s', ', '.join(missing_masters))
return 1
logging.debug('Collecting "actions" information for %d master(s)',
len(masters))
if logging.getLogger().isEnabledFor(logging.DEBUG):
for master in masters:
logging.debug(' - %s', master.name)
# Construct our actions JSON-able dictionary
actions = {}
timestamps = {}
for master in masters:
if args.clear_difference:
master.delete_timestamp()
actions[master.name], timestamps[master.name] = master.load_actions(
threshold=(None) if not args.difference else (master.get_timestamp()),
)
# Post JSON to endpoint, if configured.
if actions and args.post:
try:
do_post(actions, args.post)
except PostFailedError:
logging.exception('Failed to POST to endpoint [%s]' % (args.post,))
return 2
elif logging.getLogger().isEnabledFor(logging.INFO):
logging.info('Loaded action set:\n%s', pprint.pformat(actions))
# Write the difference file. At this point, all operations have been
# successful.
if args.difference:
logging.info('Writing updated timestamp files')
for master in masters:
timestamp = timestamps.get(master.name)
if not timestamp:
continue
master.write_timestamp(timestamp)
return 0
if __name__ == '__main__':
logging.basicConfig(level=logging.WARNING)
return_code = 1
try:
return_code = main()
except Exception:
logging.exception('Uncaught exception during execution')
finally:
sys.exit(return_code)