1#!/usr/bin/python
2#
3# Copyright (c) 2012 The Chromium OS Authors. All rights reserved.
4# Use of this source code is governed by a BSD-style license that can be
5# found in the LICENSE file.
6
7"""CrOS suite scheduler.  Will schedule suites based on configured triggers.
8
9The Scheduler understands two main primitives: Events and Tasks.  Each stanza
10in the config file specifies a Task that triggers on a given Event.
11
12Events:
13  The scheduler supports two kinds of Events: timed events, and
14  build system events -- like a particular build artifact becoming available.
15  Every Event has a set of Tasks that get run whenever the event happens.
16
17Tasks:
18  Basically, event handlers.  A Task is specified in the config file like so:
19  [NightlyPower]
20  suite: power
21  run_on: nightly
22  pool: remote_power
23  branch_specs: >=R20,factory
24
25  This specifies a Task that gets run whenever the 'nightly' event occurs.
26  The Task schedules a suite of tests called 'power' on the pool of machines
27  called 'remote_power', for both the factory branch and all active release
28  branches from R20 on.
29
30
31On startup, the scheduler reads in a config file that provides a few
32parameters for certain supported Events (the time/day of the 'weekly'
33and 'nightly' triggers, for example), and configures all the Tasks
34that will be in play.
35"""
36
37import getpass, logging, logging.handlers, optparse, os, re, signal, sys
38import traceback
39import common
40import board_enumerator, deduping_scheduler, driver, forgiving_config_parser
41import manifest_versions, sanity
42from autotest_lib.client.common_lib import global_config
43from autotest_lib.client.common_lib import logging_config, logging_manager
44from autotest_lib.server.cros.dynamic_suite import frontend_wrappers
45try:
46    from autotest_lib.frontend import setup_django_environment
47    # server_manager_utils depend on django which
48    # may not be available when people run checks with --sanity
49    from autotest_lib.site_utils import server_manager_utils
50except ImportError:
51    server_manager_utils = None
52    logging.debug('Could not load server_manager_utils module, expected '
53                  'if you are running sanity check or pre-submit hook')
54
55
56CONFIG_SECTION = 'SCHEDULER'
57
58CONFIG_SECTION_SERVER = 'SERVER'
59
60
61def signal_handler(signal, frame):
62    """Singnal hanlder to exit gracefully.
63
64    @param signal: signum
65    @param frame: stack frame object
66    """
67    logging.info('Signal %d received.  Exiting gracefully...', signal)
68    sys.exit(0)
69
70
71class SeverityFilter(logging.Filter):
72    """Filters out messages of anything other than self._level"""
73    def __init__(self, level):
74        self._level = level
75
76
77    def filter(self, record):
78        """Causes only messages of |self._level| severity to be logged."""
79        return record.levelno == self._level
80
81
82class SchedulerLoggingConfig(logging_config.LoggingConfig):
83    """Configure loggings for scheduler, e.g., email setup."""
84    def __init__(self):
85        super(SchedulerLoggingConfig, self).__init__()
86        self._from_address = global_config.global_config.get_config_value(
87                CONFIG_SECTION, "notify_email_from", default=getpass.getuser())
88
89        self._notify_address = global_config.global_config.get_config_value(
90                CONFIG_SECTION, "notify_email",
91                default='chromeos-lab-admins@google.com')
92
93        self._smtp_server = global_config.global_config.get_config_value(
94                CONFIG_SECTION_SERVER, "smtp_server", default='localhost')
95
96        self._smtp_port = global_config.global_config.get_config_value(
97                CONFIG_SECTION_SERVER, "smtp_port", default=None)
98
99        self._smtp_user = global_config.global_config.get_config_value(
100                CONFIG_SECTION_SERVER, "smtp_user", default='')
101
102        self._smtp_password = global_config.global_config.get_config_value(
103                CONFIG_SECTION_SERVER, "smtp_password", default='')
104
105
106    @classmethod
107    def get_log_name(cls):
108        """Get timestamped log name of suite_scheduler, e.g.,
109        suite_scheduler.log.2013-2-1-02-05-06.
110
111        @param cls: class
112        """
113        return cls.get_timestamped_log_name('suite_scheduler')
114
115
116    def add_smtp_handler(self, subject, level=logging.ERROR):
117        """Add smtp handler to logging handler to trigger email when logging
118        occurs.
119
120        @param subject: email subject.
121        @param level: level of logging to trigger smtp handler.
122        """
123        if not self._smtp_user or not self._smtp_password:
124            creds = None
125        else:
126            creds = (self._smtp_user, self._smtp_password)
127        server = self._smtp_server
128        if self._smtp_port:
129            server = (server, self._smtp_port)
130
131        handler = logging.handlers.SMTPHandler(server,
132                                               self._from_address,
133                                               [self._notify_address],
134                                               subject,
135                                               creds)
136        handler.setLevel(level)
137        # We want to send mail for the given level, and only the given level.
138        # One can add more handlers to send messages for other levels.
139        handler.addFilter(SeverityFilter(level))
140        handler.setFormatter(
141            logging.Formatter('%(asctime)s %(levelname)-5s %(message)s'))
142        self.logger.addHandler(handler)
143        return handler
144
145
146    def configure_logging(self, log_dir=None):
147        super(SchedulerLoggingConfig, self).configure_logging(use_console=True)
148
149        if not log_dir:
150            return
151        base = self.get_log_name()
152
153        self.add_file_handler(base + '.DEBUG', logging.DEBUG, log_dir=log_dir)
154        self.add_file_handler(base + '.INFO', logging.INFO, log_dir=log_dir)
155        self.add_smtp_handler('Suite scheduler ERROR', logging.ERROR)
156        self.add_smtp_handler('Suite scheduler WARNING', logging.WARN)
157
158
159def parse_options():
160    """Parse commandline options."""
161    usage = "usage: %prog [options]"
162    parser = optparse.OptionParser(usage=usage)
163    parser.add_option('-f', '--config_file', dest='config_file',
164                      metavar='/path/to/config', default='suite_scheduler.ini',
165                      help='Scheduler config. Defaults to suite_scheduler.ini')
166    parser.add_option('-e', '--events', dest='events',
167                      metavar='list,of,events',
168                      help='Handle listed events once each, then exit.  '\
169                        'Must also specify a build to test.')
170    parser.add_option('-i', '--build', dest='build',
171                      help='If handling a list of events, the build to test.'\
172                        ' Ignored otherwise.')
173    parser.add_option('-d', '--log_dir', dest='log_dir',
174                      help='Log to a file in the specified directory.')
175    parser.add_option('-l', '--list_events', dest='list',
176                      action='store_true', default=False,
177                      help='List supported events and exit.')
178    parser.add_option('-r', '--repo_dir', dest='tmp_repo_dir', default=None,
179                      help=('Path to a tmpdir containing manifest versions. '
180                            'This option is only used for testing.'))
181    parser.add_option('-t', '--sanity', dest='sanity', action='store_true',
182                      default=False,
183                      help='Check the config file for any issues.')
184    parser.add_option('-b', '--file_bug', dest='file_bug', action='store_true',
185                      default=False,
186                      help='File bugs for known suite scheduling exceptions.')
187
188
189    options, args = parser.parse_args()
190    return parser, options, args
191
192
193def main():
194    """Entry point for suite_scheduler.py"""
195    signal.signal(signal.SIGINT, signal_handler)
196    signal.signal(signal.SIGHUP, signal_handler)
197    signal.signal(signal.SIGTERM, signal_handler)
198
199    parser, options, args = parse_options()
200    if args or options.events and not options.build:
201        parser.print_help()
202        return 1
203
204    if options.config_file and not os.path.exists(options.config_file):
205        logging.error('Specified config file %s does not exist.',
206                      options.config_file)
207        return 1
208
209    config = forgiving_config_parser.ForgivingConfigParser()
210    config.read(options.config_file)
211
212    if options.list:
213        print 'Supported events:'
214        for event_class in driver.Driver.EVENT_CLASSES:
215            print '  ', event_class.KEYWORD
216        return 0
217
218    # If we're just sanity checking, we can stop after we've parsed the
219    # config file.
220    if options.sanity:
221        # config_file_getter generates a high amount of noise at DEBUG level
222        logging.getLogger().setLevel(logging.WARNING)
223        d = driver.Driver(None, None, True)
224        d.SetUpEventsAndTasks(config, None)
225        tasks_per_event = d.TasksFromConfig(config)
226        # flatten [[a]] -> [a]
227        tasks = [x for y in tasks_per_event.values() for x in y]
228        control_files_exist = sanity.CheckControlFileExistance(tasks)
229        return control_files_exist
230
231    logging_manager.configure_logging(SchedulerLoggingConfig(),
232                                      log_dir=options.log_dir)
233    if not options.log_dir:
234        logging.info('Not logging to a file, as --log_dir was not passed.')
235
236    # If server database is enabled, check if the server has role
237    # `suite_scheduler`. If the server does not have suite_scheduler role,
238    # exception will be raised and suite scheduler will not continue to run.
239    if not server_manager_utils:
240        raise ImportError(
241            'Could not import autotest_lib.site_utils.server_manager_utils')
242    if server_manager_utils.use_server_db():
243        server_manager_utils.confirm_server_has_role(hostname='localhost',
244                                                     role='suite_scheduler')
245
246    afe_server = global_config.global_config.get_config_value(
247                CONFIG_SECTION_SERVER, "suite_scheduler_afe", default=None)
248
249    afe = frontend_wrappers.RetryingAFE(
250            server=afe_server, timeout_min=10, delay_sec=5, debug=False)
251    logging.info('Connecting to: %s' , afe.server)
252    enumerator = board_enumerator.BoardEnumerator(afe)
253    scheduler = deduping_scheduler.DedupingScheduler(afe, options.file_bug)
254    mv = manifest_versions.ManifestVersions(options.tmp_repo_dir)
255    d = driver.Driver(scheduler, enumerator)
256    d.SetUpEventsAndTasks(config, mv)
257
258    try:
259        if options.events:
260            # Act as though listed events have just happened.
261            keywords = re.split('\s*,\s*', options.events)
262            if not options.tmp_repo_dir:
263                logging.warn('To run a list of events, you may need to use '
264                             '--repo_dir to specify a folder that already has '
265                             'manifest repo set up. This is needed for suites '
266                             'requiring firmware update.')
267            logging.info('Forcing events: %r', keywords)
268            d.ForceEventsOnceForBuild(keywords, options.build)
269        else:
270            if not options.tmp_repo_dir:
271                mv.Initialize()
272            d.RunForever(config, mv)
273    except Exception as e:
274        logging.error('Fatal exception in suite_scheduler: %r\n%s', e,
275                      traceback.format_exc())
276        return 1
277
278if __name__ == "__main__":
279    sys.exit(main())
280