| # Copyright 2015 The LUCI Authors. All rights reserved. |
| # Use of this source code is governed under the Apache License, Version 2.0 |
| # that can be found in the LICENSE file. |
| |
| """Internal bot API handlers.""" |
| |
| import base64 |
| import json |
| import logging |
| |
| import webob |
| import webapp2 |
| |
| from google.appengine.api import app_identity |
| from google.appengine.ext import ndb |
| from google.appengine import runtime |
| |
| from components import auth |
| from components import ereporter2 |
| from components import utils |
| from server import acl |
| from server import bot_auth |
| from server import bot_code |
| from server import bot_management |
| from server import config |
| from server import service_accounts |
| from server import task_pack |
| from server import task_queues |
| from server import task_request |
| from server import task_result |
| from server import task_scheduler |
| |
| |
| def has_unexpected_subset_keys(expected_keys, minimum_keys, actual_keys, name): |
| """Returns an error if unexpected keys are present or expected keys are |
| missing. |
| |
| Accepts optional keys. |
| |
| This is important to catch typos. |
| """ |
| actual_keys = frozenset(actual_keys) |
| superfluous = actual_keys - expected_keys |
| missing = minimum_keys - actual_keys |
| if superfluous or missing: |
| msg_missing = (' missing: %s' % sorted(missing)) if missing else '' |
| msg_superfluous = ( |
| (' superfluous: %s' % sorted(superfluous)) if superfluous else '') |
| return 'Unexpected %s%s%s; did you make a typo?' % ( |
| name, msg_missing, msg_superfluous) |
| |
| |
| def has_unexpected_keys(expected_keys, actual_keys, name): |
| """Return an error if unexpected keys are present or expected keys are |
| missing. |
| """ |
| return has_unexpected_subset_keys( |
| expected_keys, expected_keys, actual_keys, name) |
| |
| |
| def log_unexpected_subset_keys( |
| expected_keys, minimum_keys, actual_keys, request, source, name): |
| """Logs an error if unexpected keys are present or expected keys are missing. |
| |
| Accepts optional keys. |
| |
| This is important to catch typos. |
| """ |
| message = has_unexpected_subset_keys( |
| expected_keys, minimum_keys, actual_keys, name) |
| if message: |
| ereporter2.log_request(request, source=source, message=message) |
| return message |
| |
| |
| def log_unexpected_keys(expected_keys, actual_keys, request, source, name): |
| """Logs an error if unexpected keys are present or expected keys are missing. |
| """ |
| return log_unexpected_subset_keys( |
| expected_keys, expected_keys, actual_keys, request, source, name) |
| |
| |
| def has_missing_keys(minimum_keys, actual_keys, name): |
| """Returns an error if expected keys are not present. |
| |
| Do not warn about unexpected keys. |
| """ |
| actual_keys = frozenset(actual_keys) |
| missing = minimum_keys - actual_keys |
| if missing: |
| msg_missing = (' missing: %s' % sorted(missing)) if missing else '' |
| return 'Unexpected %s%s; did you make a typo?' % (name, msg_missing) |
| |
| |
| def get_bot_contact_server(request): |
| """Gets the server contacted by the bot. |
| |
| Usually, this is the URL of the Swarming server itself, but if the bot |
| is communicating to the server by a gRPC intermediary, this will be the |
| IP address of the gRPC endpoint. The Native API will have an http or |
| https protocol, while gRPC endpoints will have a fake "grpc://" protocol. |
| This is to help consumers of this information (mainly the bot code |
| generators) distinguish between native and gRPC bots. |
| """ |
| server = request.host_url |
| if 'luci-grpc' in request.headers: |
| server = 'grpc://%s' % request.headers['luci-grpc'] |
| return server |
| |
| |
| class _BotApiHandler(auth.ApiHandler): |
| """Like ApiHandler, but also implements machine authentication.""" |
| |
| # Bots are passing credentials through special headers (not cookies), no need |
| # for XSRF tokens. |
| xsrf_token_enforce_on = () |
| |
| @classmethod |
| def get_auth_methods(cls, conf): |
| return [auth.machine_authentication, auth.oauth_authentication] |
| |
| |
| class _BotAuthenticatingHandler(auth.AuthenticatingHandler): |
| """Like AuthenticatingHandler, but also implements machine authentication. |
| |
| Handlers inheriting this class are used during bot bootstrap and self-update. |
| |
| Unlike _BotApiHandler handlers, _BotAuthenticatingHandler handlers don't check |
| dimensions or bot_id, since they are not yet known when these handlers are |
| called. They merely check that the bot credentials are known to the server, or |
| the endpoint is being used by an account authorized to do bot bootstrap. |
| """ |
| |
| # Bots are passing credentials through special headers (not cookies), no need |
| # for XSRF tokens. |
| xsrf_token_enforce_on = () |
| |
| @classmethod |
| def get_auth_methods(cls, conf): |
| return [auth.machine_authentication, auth.oauth_authentication] |
| |
| def check_bot_code_access(self, bot_id, generate_token): |
| """Raises AuthorizationError if caller is not authorized to access bot code. |
| |
| Four variants here: |
| 1. A valid bootstrap token is passed as '?tok=...' parameter. |
| 2. An user, allowed to do a bootstrap, is using their credentials. |
| 3. An IP whitelisted machine is making this call. |
| 4. A bot (with given bot_id) is using it's own machine credentials. |
| |
| In later three cases we optionally generate and return a new bootstrap |
| token, that can be used to authorize /bot_code calls. |
| """ |
| existing_token = self.request.get('tok') |
| if existing_token: |
| payload = bot_code.validate_bootstrap_token(existing_token) |
| if payload is None: |
| raise auth.AuthorizationError('Invalid bootstrap token') |
| logging.debug('Using bootstrap token %r', payload) |
| return existing_token |
| |
| machine_type = None |
| if bot_id: |
| bot_info = bot_management.get_info_key(bot_id).get() |
| if bot_info: |
| machine_type = bot_info.machine_type |
| |
| # TODO(vadimsh): Remove is_ip_whitelisted_machine check once all bots are |
| # using auth for bootstrap and updating. |
| if (not acl.is_bootstrapper() and |
| not acl.is_ip_whitelisted_machine() and |
| not (bot_id and bot_auth.is_authenticated_bot(bot_id, machine_type))): |
| raise auth.AuthorizationError('Not allowed to access the bot code') |
| |
| return bot_code.generate_bootstrap_token() if generate_token else None |
| |
| def get_bot_contact_server(self): |
| """Gets the server contacted by the bot.""" |
| return get_bot_contact_server(self.request) |
| |
| |
| class BootstrapHandler(_BotAuthenticatingHandler): |
| """Returns python code to run to bootstrap a swarming bot.""" |
| |
| @auth.public # auth inside check_bot_code_access() |
| def get(self): |
| # We must pass a bootstrap token (generating it, if necessary) to |
| # get_bootstrap(...), since bootstrap.py uses tokens exclusively (it can't |
| # transparently pass OAuth headers to /bot_code). |
| bootstrap_token = self.check_bot_code_access( |
| bot_id=None, generate_token=True) |
| self.response.headers['Content-Type'] = 'text/x-python' |
| self.response.headers['Content-Disposition'] = ( |
| 'attachment; filename="swarming_bot_bootstrap.py"') |
| self.response.out.write( |
| bot_code.get_bootstrap(self.request.host_url, bootstrap_token).content) |
| |
| |
| class BotCodeHandler(_BotAuthenticatingHandler): |
| """Returns a zip file with all the files required by a bot. |
| |
| Optionally specify the hash version to download. If so, the returned data is |
| cacheable. |
| """ |
| |
| @auth.public # auth inside check_bot_code_access() |
| def get(self, version=None): |
| server = self.get_bot_contact_server() |
| self.check_bot_code_access( |
| bot_id=self.request.get('bot_id'), generate_token=False) |
| if version: |
| expected, _ = bot_code.get_bot_version(server) |
| if version != expected: |
| # This can happen when the server is rapidly updated. |
| logging.error('Requested Swarming bot %s, have %s', version, expected) |
| self.abort(404) |
| self.response.headers['Cache-Control'] = 'public, max-age=3600' |
| else: |
| self.response.headers['Cache-Control'] = 'no-cache, no-store' |
| self.response.headers['Content-Type'] = 'application/octet-stream' |
| self.response.headers['Content-Disposition'] = ( |
| 'attachment; filename="swarming_bot.zip"') |
| self.response.out.write( |
| bot_code.get_swarming_bot_zip(server)) |
| |
| |
| class _ProcessResult(object): |
| """Returned by _BotBaseHandler._process.""" |
| |
| # A dict with parsed JSON request body, as it was received. |
| request = None |
| # Bot identifier, extracted from 'id' dimension. |
| bot_id = None |
| # Version of the bot code, as reported by the bot itself. |
| version = None |
| # Dict with bot state (as reported by the bot). |
| state = None |
| # Dict with bot dimensions (union of bot-reported and server-side ones). |
| dimensions = None |
| # Instance of BotGroupConfig with server-side bot config (from bots.cfg). |
| bot_group_cfg = None |
| # Bot quarantine message (or None if the bot is not in a quarantine). |
| quarantined_msg = None |
| # DateTime indicating UTC time when bot will be reclaimed by Machine Provider, |
| # or None if this is not a Machine Provider bot. |
| lease_expiration_ts = None |
| |
| def __init__(self, **kwargs): |
| for k, v in kwargs.iteritems(): |
| # Typo catching assert, ensure _ProcessResult class has the attribute. |
| assert hasattr(self, k), k |
| setattr(self, k, v) |
| |
| |
| class _BotBaseHandler(_BotApiHandler): |
| """ |
| Request body is a JSON dict: |
| { |
| "dimensions": <dict of properties>, |
| "state": <dict of properties>, |
| "version": <sha-1 of swarming_bot.zip uncompressed content>, |
| } |
| """ |
| |
| EXPECTED_KEYS = {u'dimensions', u'state', u'version'} |
| REQUIRED_STATE_KEYS = {u'running_time', u'sleep_streak'} |
| |
| def _process(self): |
| """Fetches bot info and settings, does authorization and quarantine checks. |
| |
| Returns: |
| _ProcessResult instance, see its fields for more info. |
| |
| Raises: |
| auth.AuthorizationError if bot's credentials are invalid. |
| """ |
| request = self.parse_body() |
| version = request.get('version', None) |
| |
| dimensions = request.get('dimensions') or {} |
| state = request.get('state') or {} |
| bot_id = None |
| if dimensions.get('id'): |
| dimension_id = dimensions['id'] |
| if (isinstance(dimension_id, list) and len(dimension_id) == 1 |
| and isinstance(dimension_id[0], unicode)): |
| bot_id = dimensions['id'][0] |
| |
| lease_expiration_ts = None |
| machine_type = None |
| if bot_id: |
| logging.debug('Fetching bot info and settings') |
| bot_info, bot_settings = ndb.get_multi([ |
| bot_management.get_info_key(bot_id), |
| bot_management.get_settings_key(bot_id)]) |
| if bot_info: |
| lease_expiration_ts = bot_info.lease_expiration_ts |
| machine_type = bot_info.machine_type |
| |
| # Make sure bot self-reported ID matches the authentication token. Raises |
| # auth.AuthorizationError if not. |
| logging.debug('Fetching bot group config') |
| bot_group_cfg = bot_auth.validate_bot_id_and_fetch_config( |
| bot_id, machine_type) |
| |
| # The server side dimensions from bot_group_cfg override bot-provided ones. |
| # If both server side config and bot report some dimension, server side |
| # config wins. We still emit an error if bot tries to supply the dimension |
| # and it disagrees with the server defined one. Don't report ['default'] as |
| # an error, bot sends it in the handshake before it knows anything at all. |
| for dim_key, from_cfg in bot_group_cfg.dimensions.iteritems(): |
| from_bot = sorted(dimensions.get(dim_key) or []) |
| from_cfg = sorted(from_cfg) |
| if from_bot and from_bot != ['default'] and from_bot != from_cfg: |
| logging.error( |
| 'Dimensions in bots.cfg doesn\'t match ones provided by the bot\n' |
| 'bot_id: "%s", key: "%s", from_bot: %s, from_cfg: %s', |
| bot_id, dim_key, from_bot, from_cfg) |
| dimensions[dim_key] = from_cfg |
| |
| # Fill in all result fields except 'quarantined_msg'. |
| result = _ProcessResult( |
| request=request, |
| bot_id=bot_id, |
| version=version, |
| state=state, |
| dimensions=dimensions, |
| bot_group_cfg=bot_group_cfg, |
| lease_expiration_ts=lease_expiration_ts) |
| |
| # The bot may decide to "self-quarantine" itself. Accept both via |
| # dimensions or via state. See bot_management._BotCommon.quarantined for |
| # more details. |
| if (bool(dimensions.get('quarantined')) or |
| bool(state.get('quarantined'))): |
| result.quarantined_msg = 'Bot self-quarantined' |
| return result |
| |
| quarantined_msg = None |
| # Use a dummy 'for' to be able to break early from the block. |
| for _ in [0]: |
| |
| quarantined_msg = has_unexpected_keys( |
| self.EXPECTED_KEYS, request, 'keys') |
| if quarantined_msg: |
| break |
| |
| quarantined_msg = has_missing_keys( |
| self.REQUIRED_STATE_KEYS, state, 'state') |
| if quarantined_msg: |
| break |
| |
| if not bot_id: |
| quarantined_msg = 'Missing bot id' |
| break |
| if not dimensions.get('pool'): |
| quarantined_msg = 'Missing \'pool\' dimension' |
| break |
| |
| if not all( |
| config.validate_dimension_key(key) and |
| isinstance(values, list) and |
| all(config.validate_dimension_value(value) for value in values) |
| for key, values in dimensions.iteritems()): |
| quarantined_msg = ( |
| 'Invalid dimensions type:\n%s' % json.dumps(dimensions, |
| sort_keys=True, indent=2, separators=(',', ': '))) |
| break |
| |
| if quarantined_msg: |
| line = 'Quarantined Bot\nhttps://%s/restricted/bot/%s\n%s' % ( |
| app_identity.get_default_version_hostname(), bot_id, |
| quarantined_msg) |
| ereporter2.log_request(self.request, source='bot', message=line) |
| result.quarantined_msg = quarantined_msg |
| return result |
| |
| # Look for admin enforced quarantine. |
| if bool(bot_settings and bot_settings.quarantined): |
| result.quarantined_msg = 'Quarantined by admin' |
| return result |
| |
| task_queues.assert_bot(dimensions) |
| return result |
| |
| def get_bot_contact_server(self): |
| """Gets the server contacted by the bot.""" |
| return get_bot_contact_server(self.request) |
| |
| |
| class BotHandshakeHandler(_BotBaseHandler): |
| """First request to be called to get initial data like bot code version. |
| |
| The bot is server-controlled so the server doesn't have to support multiple |
| API version. When running a task, the bot sync the the version specific URL. |
| Once a bot finishes its currently running task, it'll be immediately upgraded |
| on its next poll. |
| |
| This endpoint does not return commands to the bot, for example to upgrade |
| itself. It'll be told so when it does its first poll. |
| |
| Response body is a JSON dict: |
| { |
| "bot_version": <sha-1 of swarming_bot.zip uncompressed content>, |
| "server_version": "138-193f1f3", |
| "bot_group_cfg_version": "0123abcdef", |
| "bot_group_cfg": { |
| "dimensions": { <server-defined dimensions> }, |
| } |
| } |
| """ |
| |
| @auth.public # auth happens in self._process() |
| def post(self): |
| res = self._process() |
| bot_management.bot_event( |
| event_type='bot_connected', bot_id=res.bot_id, |
| external_ip=self.request.remote_addr, |
| authenticated_as=auth.get_peer_identity().to_bytes(), |
| dimensions=res.dimensions, state=res.state, |
| version=res.version, quarantined=bool(res.quarantined_msg), |
| task_id='', task_name=None, message=res.quarantined_msg) |
| |
| data = { |
| 'bot_version': bot_code.get_bot_version(self.get_bot_contact_server())[0], |
| 'server_version': utils.get_app_version(), |
| 'bot_group_cfg_version': res.bot_group_cfg.version, |
| 'bot_group_cfg': { |
| # Let the bot know its server-side dimensions (from bots.cfg file). |
| 'dimensions': res.bot_group_cfg.dimensions, |
| }, |
| } |
| if res.bot_group_cfg.bot_config_script_content: |
| logging.info( |
| 'Injecting %s: %d bytes', |
| res.bot_group_cfg.bot_config_script, |
| len(res.bot_group_cfg.bot_config_script_content)) |
| data['bot_config'] = res.bot_group_cfg.bot_config_script_content |
| self.send_response(data) |
| |
| |
| class BotOAuthTokenHandler(_BotApiHandler): |
| """Called when bot wants to get a service account OAuth access token. |
| |
| There are two flavors of service accounts the bot may use: |
| * 'system': this account is associated directly with the bot (in bots.cfg), |
| and can be used at any time (when running a task or not). |
| * 'task': this account is associated with the task currently executing on |
| the bot, and may be used only when bot is actually running this task. |
| |
| The flavor of account is specified via 'account_id' request field. See |
| ACCEPTED_KEYS for format of other keys. |
| |
| The returned token is expected to be alive for at least ~5 min, but can live |
| longer (but no longer than ~1h). In general assume the token is short-lived. |
| |
| Multiple bots may share exact same access token if their configuration match |
| (the token is cached by Swarming for performance reasons). |
| |
| Besides the token, the response also contains the actual service account |
| email (if it is really configured), or two special strings in place of the |
| email: |
| * "none" if the bot is not configured to use service accounts at all. |
| * "bot" if the bot should use tokens produced by bot_config.py hook. |
| |
| Response body is a JSON dict: |
| { |
| "service_account": <str email> or "none" or "bot", |
| "access_token": <str with actual token (if account is configured)>, |
| "expiry": <int with unix timestamp in seconds (if account is configured)> |
| } |
| """ |
| ACCEPTED_KEYS = { |
| u'account_id', # 'system' or 'task' |
| u'id', # bot ID |
| u'scopes', # list of requested OAuth scopes |
| u'task_id', # optional task ID, required if using 'task' account |
| } |
| REQUIRED_KEYS = {u'account_id', u'id', u'scopes'} |
| |
| @auth.public # auth happens in bot_auth.validate_bot_id_and_fetch_config() |
| def post(self): |
| request = self.parse_body() |
| msg = log_unexpected_subset_keys( |
| self.ACCEPTED_KEYS, self.REQUIRED_KEYS, request, self.request, 'bot', |
| 'keys') |
| if msg: |
| self.abort_with_error(400, error=msg) |
| |
| account_id = request['account_id'] |
| bot_id = request['id'] |
| scopes = request['scopes'] |
| task_id = request.get('task_id') |
| |
| # Scopes should be a list of strings, always. |
| if (not scopes or not isinstance(scopes, list) or |
| not all(isinstance(s, basestring) for s in scopes)): |
| self.abort_with_error(400, error='"scopes" must be a list of strings') |
| |
| # Only two flavors of accounts are supported. |
| if account_id not in ('system', 'task'): |
| self.abort_with_error( |
| 400, error='Unknown "account_id", expecting "task" or "system"') |
| |
| # If using 'task' account, task_id is required. We'll double check the bot |
| # still executes this task (based on data in datastore), and if so, will |
| # use a service account associated with this particular task. |
| if account_id == 'task' and not task_id: |
| self.abort_with_error( |
| 400, error='"task_id" is required when using "account_id" == "task"') |
| |
| # Need machine type associated with the bot for bots.cfg query below. |
| # BotInfo also contains ID of a task the bot currently executes (to compare |
| # with 'task_id' request parameter). |
| machine_type = None |
| current_task_id = None |
| bot_info = bot_management.get_info_key(bot_id).get() |
| if bot_info: |
| machine_type = bot_info.machine_type |
| current_task_id = bot_info.task_id |
| |
| # Make sure bot self-reported ID matches the authentication token. Raises |
| # auth.AuthorizationError if not. Also fetches corresponding BotGroupConfig |
| # that contains system service account email for this bot. |
| bot_group_cfg = bot_auth.validate_bot_id_and_fetch_config( |
| bot_id, machine_type) |
| |
| # At this point, the request is valid structurally, and the bot used proper |
| # authentication when making it. |
| logging.info('Requesting a "%s" token with scopes %s', account_id, scopes) |
| |
| # This is mostly a precaution against confused bot processes. We can always |
| # just use 'current_task_id' to look up per-task service account. Datastore |
| # is the source of truth here, not whatever bot reports. |
| if account_id == 'task' and task_id != current_task_id: |
| logging.error( |
| 'Bot %s requested "task" access token for task %s, but runs %s', |
| bot_id, task_id, current_task_id) |
| self.abort_with_error( |
| 400, error='Wrong task_id: the bot is not executing this task') |
| |
| account = None # an email or 'bot' or 'none' |
| token = None # service_accounts.AccessToken |
| try: |
| if account_id == 'task': |
| account, token = service_accounts.get_task_account_token( |
| task_id, scopes) |
| elif account_id == 'system': |
| account, token = service_accounts.get_system_account_token( |
| bot_group_cfg.system_service_account, scopes) |
| else: |
| raise AssertionError('Impossible, there is a check above') |
| except auth.AccessTokenError as exc: |
| # Note: no need to log this, it is already logged at the source. Also |
| # we cautiously do not return any error details to the bot, just in case |
| # they may contain something we don't want to disclose. |
| if exc.transient: |
| self.abort_with_error( |
| 500, error='Transient error when generating the token') |
| self.abort_with_error( |
| 403, error='Fatal error when generating the token, see server logs') |
| |
| # Note: the token info is already logged by service_accounts.get_*_token. |
| if token: |
| self.send_response({ |
| 'service_account': account, |
| 'access_token': token.access_token, |
| 'expiry': token.expiry, |
| }) |
| else: |
| assert account in ('bot', 'none'), account |
| self.send_response({'service_account': account}) |
| |
| |
| class BotPollHandler(_BotBaseHandler): |
| """The bot polls for a task; returns either a task, update command or sleep. |
| |
| In case of exception on the bot, this is enough to get it just far enough to |
| eventually self-update to a working version. This is to ensure that coding |
| errors in bot code doesn't kill all the fleet at once, they should still be up |
| just enough to be able to self-update again even if they don't get task |
| assigned anymore. |
| """ |
| |
| @auth.public # auth happens in self._process() |
| def post(self): |
| """Handles a polling request. |
| |
| Be very permissive on missing values. This can happen because of errors |
| on the bot, *we don't want to deny them the capacity to update*, so that the |
| bot code is eventually fixed and the bot self-update to this working code. |
| |
| It makes recovery of the fleet in case of catastrophic failure much easier. |
| """ |
| logging.debug('Request started') |
| if config.settings().force_bots_to_sleep_and_not_run_task: |
| # Ignore everything, just sleep. Tell the bot it is quarantined to inform |
| # it that it won't be running anything anyway. Use a large streak so it |
| # will sleep for 60s. |
| self._cmd_sleep(1000, True) |
| return |
| |
| res = self._process() |
| sleep_streak = res.state.get('sleep_streak', 0) |
| quarantined = bool(res.quarantined_msg) |
| |
| # Note bot existence at two places, one for stats at 1 minute resolution, |
| # the other for the list of known bots. |
| |
| def bot_event(event_type, task_id=None, task_name=None): |
| bot_management.bot_event( |
| event_type=event_type, bot_id=res.bot_id, |
| external_ip=self.request.remote_addr, |
| authenticated_as=auth.get_peer_identity().to_bytes(), |
| dimensions=res.dimensions, state=res.state, |
| version=res.version, quarantined=quarantined, |
| task_id=task_id, task_name=task_name, message=res.quarantined_msg) |
| |
| # Bot version is host-specific because the host URL is embedded in |
| # swarming_bot.zip |
| logging.debug('Fetching bot code version') |
| expected_version, _ = bot_code.get_bot_version( |
| self.get_bot_contact_server()) |
| if res.version != expected_version: |
| bot_event('request_update') |
| self._cmd_update(expected_version) |
| return |
| if quarantined: |
| bot_event('request_sleep') |
| self._cmd_sleep(sleep_streak, quarantined) |
| return |
| |
| # If the server-side per-bot config for the bot has changed, we need |
| # to restart this particular bot, so it picks up new config in /handshake. |
| # Do this check only for bots that know about server-side per-bot configs |
| # already (such bots send 'bot_group_cfg_version' state attribute). |
| cur_bot_cfg_ver = res.state.get('bot_group_cfg_version') |
| if cur_bot_cfg_ver and cur_bot_cfg_ver != res.bot_group_cfg.version: |
| bot_event('request_restart') |
| self._cmd_bot_restart('Restarting to pick up new bots.cfg config') |
| return |
| |
| # |
| # At that point, the bot should be in relatively good shape since it's |
| # running the right version. It is still possible that invalid code was |
| # pushed to the server, so be diligent about it. |
| # |
| |
| # TODO(maruel): Remove this and migrate all use cases in bot_config.py |
| # on_bot_idle(). |
| # Bot may need a reboot if it is running for too long. We do not reboot |
| # quarantined bots. |
| needs_restart, restart_message = bot_management.should_restart_bot( |
| res.bot_id, res.state) |
| if needs_restart: |
| bot_event('request_restart') |
| self._cmd_host_reboot(restart_message) |
| return |
| |
| # The bot is in good shape. Try to grab a task. |
| try: |
| # This is a fairly complex function call, exceptions are expected. |
| request, secret_bytes, run_result = task_scheduler.bot_reap_task( |
| res.dimensions, res.version, res.lease_expiration_ts) |
| if not request: |
| # No task found, tell it to sleep a bit. |
| bot_event('request_sleep') |
| self._cmd_sleep(sleep_streak, quarantined) |
| return |
| |
| try: |
| # This part is tricky since it intentionally runs a transaction after |
| # another one. |
| if request.properties.is_terminate: |
| bot_event('bot_terminate', task_id=run_result.task_id) |
| self._cmd_terminate(run_result.task_id) |
| else: |
| bot_event( |
| 'request_task', task_id=run_result.task_id, |
| task_name=request.name) |
| self._cmd_run( |
| request, secret_bytes, run_result.key, |
| res.bot_id, res.bot_group_cfg) |
| except: |
| logging.exception('Dang, exception after reaping') |
| raise |
| except runtime.DeadlineExceededError: |
| # If the timeout happened before a task was assigned there is no problems. |
| # If the timeout occurred after a task was assigned, that task will |
| # timeout (BOT_DIED) since the bot didn't get the details required to |
| # run it) and it will automatically get retried (TODO) when the task times |
| # out. |
| # TODO(maruel): Note the task if possible and hand it out on next poll. |
| # https://code.google.com/p/swarming/issues/detail?id=130 |
| self.abort(500, 'Deadline') |
| |
| def _cmd_run( |
| self, request, secret_bytes, run_result_key, bot_id, bot_group_cfg): |
| logging.info('Run: %s', request.task_id) |
| out = { |
| 'cmd': 'run', |
| 'manifest': { |
| 'bot_id': bot_id, |
| 'caches': [ |
| c.to_dict() for c in request.properties.caches |
| ], |
| 'cipd_input': { |
| 'client_package': ( |
| request.properties.cipd_input.client_package.to_dict()), |
| 'packages': [ |
| p.to_dict() for p in request.properties.cipd_input.packages |
| ], |
| 'server': request.properties.cipd_input.server, |
| } if request.properties.cipd_input else None, |
| 'command': request.properties.command, |
| 'dimensions': request.properties.dimensions, |
| 'env': request.properties.env, |
| 'extra_args': request.properties.extra_args, |
| 'grace_period': request.properties.grace_period_secs, |
| 'hard_timeout': request.properties.execution_timeout_secs, |
| 'host': utils.get_versioned_hosturl(), |
| 'io_timeout': request.properties.io_timeout_secs, |
| 'secret_bytes': (secret_bytes.secret_bytes.encode('base64') |
| if secret_bytes else None), |
| 'isolated': { |
| 'input': request.properties.inputs_ref.isolated, |
| 'namespace': request.properties.inputs_ref.namespace, |
| 'server': request.properties.inputs_ref.isolatedserver, |
| } if request.properties.inputs_ref else None, |
| 'outputs': request.properties.outputs, |
| 'service_accounts': { |
| 'system': { |
| # 'none', 'bot' or email. Bot interprets 'none' and 'bot' locally. |
| # When it sees something else, it uses /oauth_token API endpoint to |
| # grab tokens through server. |
| 'service_account': bot_group_cfg.system_service_account or 'none', |
| }, |
| 'task': { |
| # Same here. |
| 'service_account': request.service_account, |
| }, |
| }, |
| 'task_id': task_pack.pack_run_result_key(run_result_key), |
| }, |
| } |
| self.send_response(utils.to_json_encodable(out)) |
| |
| def _cmd_sleep(self, sleep_streak, quarantined): |
| duration = task_scheduler.exponential_backoff(sleep_streak) |
| logging.debug( |
| 'Sleep: streak: %d; duration: %ds; quarantined: %s', |
| sleep_streak, duration, quarantined) |
| out = { |
| 'cmd': 'sleep', |
| 'duration': duration, |
| 'quarantined': quarantined, |
| } |
| self.send_response(out) |
| |
| def _cmd_terminate(self, task_id): |
| logging.info('Terminate: %s', task_id) |
| out = { |
| 'cmd': 'terminate', |
| 'task_id': task_id, |
| } |
| self.send_response(out) |
| |
| def _cmd_update(self, expected_version): |
| logging.info('Update: %s', expected_version) |
| out = { |
| 'cmd': 'update', |
| 'version': expected_version, |
| } |
| self.send_response(out) |
| |
| def _cmd_host_reboot(self, message): |
| logging.info('Rebooting host: %s', message) |
| out = { |
| 'cmd': 'host_reboot', |
| 'message': message, |
| } |
| self.send_response(out) |
| |
| def _cmd_bot_restart(self, message): |
| logging.info('Restarting bot: %s', message) |
| out = { |
| 'cmd': 'bot_restart', |
| 'message': message, |
| } |
| self.send_response(out) |
| |
| |
| class BotEventHandler(_BotBaseHandler): |
| """On signal that a bot had an event worth logging.""" |
| |
| EXPECTED_KEYS = _BotBaseHandler.EXPECTED_KEYS | {u'event', u'message'} |
| |
| ALLOWED_EVENTS = ('bot_error', 'bot_log', 'bot_rebooting', 'bot_shutdown') |
| |
| @auth.public # auth happens in self._process() |
| def post(self): |
| res = self._process() |
| event = res.request.get('event') |
| if event not in self.ALLOWED_EVENTS: |
| logging.error('Unexpected event type') |
| self.abort_with_error(400, error='Unsupported event type') |
| message = res.request.get('message') |
| # Record the event in a BotEvent entity so it can be listed on the bot's |
| # page. |
| bot_management.bot_event( |
| event_type=event, bot_id=res.bot_id, |
| external_ip=self.request.remote_addr, |
| authenticated_as=auth.get_peer_identity().to_bytes(), |
| dimensions=res.dimensions, state=res.state, |
| version=res.version, quarantined=bool(res.quarantined_msg), |
| task_id=None, task_name=None, |
| message=message) |
| |
| if event == 'bot_error': |
| # Also logs this to ereporter2, so it will be listed in the server's |
| # hourly ereporter2 report. THIS IS NOISY so it should only be done with |
| # issues requiring action. In this case, include again the bot's URL since |
| # there's no context in the report. Redundantly include the bot id so |
| # messages are bucketted by bot. |
| line = ( |
| '%s\n' |
| '\nhttps://%s/restricted/bot/%s') % ( |
| message, app_identity.get_default_version_hostname(), res.bot_id) |
| ereporter2.log_request(self.request, source='bot', message=line) |
| self.send_response({}) |
| |
| |
| class BotTaskUpdateHandler(_BotApiHandler): |
| """Receives updates from a Bot for a task. |
| |
| The handler verifies packets are processed in order and will refuse |
| out-of-order packets. |
| """ |
| ACCEPTED_KEYS = { |
| u'bot_overhead', u'cipd_pins', u'cipd_stats', u'cost_usd', u'duration', |
| u'exit_code', u'hard_timeout', u'id', u'io_timeout', u'isolated_stats', |
| u'output', u'output_chunk_start', u'outputs_ref', u'task_id', |
| } |
| REQUIRED_KEYS = {u'id', u'task_id'} |
| |
| @auth.public # auth happens in bot_auth.validate_bot_id_and_fetch_config() |
| def post(self, task_id=None): |
| # Unlike handshake and poll, we do not accept invalid keys here. This code |
| # path is much more strict. |
| request = self.parse_body() |
| msg = log_unexpected_subset_keys( |
| self.ACCEPTED_KEYS, self.REQUIRED_KEYS, request, self.request, 'bot', |
| 'keys') |
| if msg: |
| self.abort_with_error(400, error=msg) |
| |
| bot_id = request['id'] |
| task_id = request['task_id'] |
| |
| machine_type = None |
| bot_info = bot_management.get_info_key(bot_id).get() |
| if bot_info: |
| machine_type = bot_info.machine_type |
| |
| # Make sure bot self-reported ID matches the authentication token. Raises |
| # auth.AuthorizationError if not. |
| bot_auth.validate_bot_id_and_fetch_config(bot_id, machine_type) |
| |
| bot_overhead = request.get('bot_overhead') |
| cipd_pins = request.get('cipd_pins') |
| cipd_stats = request.get('cipd_stats') |
| cost_usd = request.get('cost_usd', 0) |
| duration = request.get('duration') |
| exit_code = request.get('exit_code') |
| hard_timeout = request.get('hard_timeout') |
| io_timeout = request.get('io_timeout') |
| isolated_stats = request.get('isolated_stats') |
| output = request.get('output') |
| output_chunk_start = request.get('output_chunk_start') |
| outputs_ref = request.get('outputs_ref') |
| |
| if (isolated_stats or cipd_stats) and bot_overhead is None: |
| ereporter2.log_request( |
| request=self.request, |
| source='server', |
| category='task_failure', |
| message='Failed to update task: %s' % task_id) |
| self.abort_with_error( |
| 400, |
| error='isolated_stats and cipd_stats require bot_overhead to be set' |
| '\nbot_overhead: %s\nisolate_stats: %s' % |
| (bot_overhead, isolated_stats)) |
| |
| run_result_key = task_pack.unpack_run_result_key(task_id) |
| performance_stats = None |
| if bot_overhead is not None: |
| performance_stats = task_result.PerformanceStats( |
| bot_overhead=bot_overhead) |
| if isolated_stats: |
| download = isolated_stats.get('download') or {} |
| upload = isolated_stats.get('upload') or {} |
| def unpack_base64(d, k): |
| x = d.get(k) |
| if x: |
| return base64.b64decode(x) |
| performance_stats.isolated_download = task_result.OperationStats( |
| duration=download.get('duration'), |
| initial_number_items=download.get('initial_number_items'), |
| initial_size=download.get('initial_size'), |
| items_cold=unpack_base64(download, 'items_cold'), |
| items_hot=unpack_base64(download, 'items_hot')) |
| performance_stats.isolated_upload = task_result.OperationStats( |
| duration=upload.get('duration'), |
| items_cold=unpack_base64(upload, 'items_cold'), |
| items_hot=unpack_base64(upload, 'items_hot')) |
| if cipd_stats: |
| performance_stats.package_installation = task_result.OperationStats( |
| duration=cipd_stats.get('duration')) |
| |
| if output is not None: |
| try: |
| output = base64.b64decode(output) |
| except UnicodeEncodeError as e: |
| logging.error('Failed to decode output\n%s\n%r', e, output) |
| output = output.encode('ascii', 'replace') |
| except TypeError as e: |
| # Save the output as-is instead. The error will be logged in ereporter2 |
| # and returning a HTTP 500 would only force the bot to stay in a retry |
| # loop. |
| logging.error('Failed to decode output\n%s\n%r', e, output) |
| if outputs_ref: |
| outputs_ref = task_request.FilesRef(**outputs_ref) |
| |
| if cipd_pins: |
| cipd_pins = task_result.CipdPins( |
| client_package=task_request.CipdPackage( |
| **cipd_pins['client_package']), |
| packages=[ |
| task_request.CipdPackage(**args) for args in cipd_pins['packages']] |
| ) |
| |
| try: |
| state = task_scheduler.bot_update_task( |
| run_result_key=run_result_key, |
| bot_id=bot_id, |
| output=output, |
| output_chunk_start=output_chunk_start, |
| exit_code=exit_code, |
| duration=duration, |
| hard_timeout=hard_timeout, |
| io_timeout=io_timeout, |
| cost_usd=cost_usd, |
| outputs_ref=outputs_ref, |
| cipd_pins=cipd_pins, |
| performance_stats=performance_stats) |
| if not state: |
| logging.info('Failed to update, please retry') |
| self.abort_with_error(500, error='Failed to update, please retry') |
| |
| if state in (task_result.State.COMPLETED, task_result.State.TIMED_OUT): |
| action = 'task_completed' |
| elif state == task_result.State.CANCELED: |
| action = 'task_canceled' |
| else: |
| assert state in ( |
| task_result.State.BOT_DIED, task_result.State.RUNNING), state |
| action = 'task_update' |
| bot_management.bot_event( |
| event_type=action, bot_id=bot_id, |
| external_ip=self.request.remote_addr, |
| authenticated_as=auth.get_peer_identity().to_bytes(), |
| dimensions=None, state=None, |
| version=None, quarantined=None, task_id=task_id, task_name=None) |
| except ValueError as e: |
| ereporter2.log_request( |
| request=self.request, |
| source='server', |
| category='task_failure', |
| message='Failed to update task: %s' % e) |
| self.abort_with_error(400, error=str(e)) |
| except webob.exc.HTTPException: |
| raise |
| except Exception as e: |
| logging.exception('Internal error: %s', e) |
| self.abort_with_error(500, error=str(e)) |
| self.send_response( |
| {'must_stop': state == task_result.State.CANCELED, 'ok': True}) |
| |
| |
| class BotTaskErrorHandler(_BotApiHandler): |
| """It is a specialized version of ereporter2's /ereporter2/api/v1/on_error |
| that also attaches a task id to it. |
| |
| This formally kills the task, marking it as an internal failure. This can be |
| used by bot_main.py to kill the task when task_runner misbehaved. |
| """ |
| |
| EXPECTED_KEYS = {u'id', u'message', u'task_id'} |
| |
| @auth.public # auth happens in bot_auth.validate_bot_id_and_fetch_config |
| def post(self, task_id=None): |
| request = self.parse_body() |
| bot_id = request.get('id') |
| task_id = request.get('task_id', '') |
| message = request.get('message', 'unknown') |
| |
| machine_type = None |
| bot_info = bot_management.get_info_key(bot_id).get() |
| if bot_info: |
| machine_type = bot_info.machine_type |
| |
| # Make sure bot self-reported ID matches the authentication token. Raises |
| # auth.AuthorizationError if not. |
| bot_auth.validate_bot_id_and_fetch_config(bot_id, machine_type) |
| |
| bot_management.bot_event( |
| event_type='task_error', bot_id=bot_id, |
| external_ip=self.request.remote_addr, |
| authenticated_as=auth.get_peer_identity().to_bytes(), |
| dimensions=None, state=None, |
| version=None, quarantined=None, task_id=task_id, task_name=None, |
| message=message) |
| line = ( |
| 'Bot: https://%s/restricted/bot/%s\n' |
| 'Task failed: https://%s/user/task/%s\n' |
| '%s') % ( |
| app_identity.get_default_version_hostname(), bot_id, |
| app_identity.get_default_version_hostname(), task_id, |
| message) |
| ereporter2.log_request(self.request, source='bot', message=line) |
| |
| msg = log_unexpected_keys( |
| self.EXPECTED_KEYS, request, self.request, 'bot', 'keys') |
| if msg: |
| self.abort_with_error(400, error=msg) |
| |
| msg = task_scheduler.bot_kill_task( |
| task_pack.unpack_run_result_key(task_id), bot_id) |
| if msg: |
| logging.error(msg) |
| self.abort_with_error(400, error=msg) |
| self.send_response({}) |
| |
| |
| class ServerPingHandler(webapp2.RequestHandler): |
| """Handler to ping when checking if the server is up. |
| |
| This handler should be extremely lightweight. It shouldn't do any |
| computations, it should just state that the server is up. It's open to |
| everyone for simplicity and performance. |
| """ |
| |
| def get(self): |
| self.response.headers['Content-Type'] = 'text/plain; charset=utf-8' |
| self.response.out.write('Server up') |
| |
| |
| def get_routes(): |
| routes = [ |
| ('/bootstrap', BootstrapHandler), |
| ('/bot_code', BotCodeHandler), |
| # 40 for old sha1 digest so old bot can still update, 64 for current |
| # sha256 digest. |
| ('/swarming/api/v1/bot/bot_code/<version:[0-9a-f]{40,64}>', |
| BotCodeHandler), |
| ('/swarming/api/v1/bot/event', BotEventHandler), |
| ('/swarming/api/v1/bot/handshake', BotHandshakeHandler), |
| ('/swarming/api/v1/bot/oauth_token', BotOAuthTokenHandler), |
| ('/swarming/api/v1/bot/poll', BotPollHandler), |
| ('/swarming/api/v1/bot/server_ping', ServerPingHandler), |
| ('/swarming/api/v1/bot/task_update', BotTaskUpdateHandler), |
| ('/swarming/api/v1/bot/task_update/<task_id:[a-f0-9]+>', |
| BotTaskUpdateHandler), |
| ('/swarming/api/v1/bot/task_error', BotTaskErrorHandler), |
| ('/swarming/api/v1/bot/task_error/<task_id:[a-f0-9]+>', |
| BotTaskErrorHandler), |
| ] |
| return [webapp2.Route(*i) for i in routes] |