| OLD | NEW |
| 1 # Copyright 2015 The LUCI Authors. All rights reserved. | 1 # Copyright 2015 The LUCI Authors. All rights reserved. |
| 2 # Use of this source code is governed under the Apache License, Version 2.0 | 2 # Use of this source code is governed under the Apache License, Version 2.0 |
| 3 # that can be found in the LICENSE file. | 3 # that can be found in the LICENSE file. |
| 4 | 4 |
| 5 """Internal bot API handlers.""" | 5 """Internal bot API handlers.""" |
| 6 | 6 |
| 7 import base64 | 7 import base64 |
| 8 import json | 8 import json |
| 9 import logging | 9 import logging |
| 10 | 10 |
| (...skipping 74 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
| 85 | 85 |
| 86 Do not warn about unexpected keys. | 86 Do not warn about unexpected keys. |
| 87 """ | 87 """ |
| 88 actual_keys = frozenset(actual_keys) | 88 actual_keys = frozenset(actual_keys) |
| 89 missing = minimum_keys - actual_keys | 89 missing = minimum_keys - actual_keys |
| 90 if missing: | 90 if missing: |
| 91 msg_missing = (' missing: %s' % sorted(missing)) if missing else '' | 91 msg_missing = (' missing: %s' % sorted(missing)) if missing else '' |
| 92 return 'Unexpected %s%s; did you make a typo?' % (name, msg_missing) | 92 return 'Unexpected %s%s; did you make a typo?' % (name, msg_missing) |
| 93 | 93 |
| 94 | 94 |
| 95 def get_bot_contact_server(request): | |
| 96 """Gets the server contacted by the bot. | |
| 97 | |
| 98 Usually, this is the URL of the Swarming server itself, but if the bot | |
| 99 is communicating to the server by a gRPC intermediary, this will be the | |
| 100 IP address of the gRPC endpoint. The Native API will have an http or | |
| 101 https protocol, while gRPC endpoints will have a fake "grpc://" protocol. | |
| 102 This is to help consumers of this information (mainly the bot code | |
| 103 generators) distinguish between native and gRPC bots. | |
| 104 """ | |
| 105 server = request.host_url | |
| 106 if 'luci-grpc' in request.headers: | |
| 107 server = 'grpc://%s' % request.headers['luci-grpc'] | |
| 108 return server | |
| 109 | |
| 110 | |
| 111 class _BotApiHandler(auth.ApiHandler): | 95 class _BotApiHandler(auth.ApiHandler): |
| 112 """Like ApiHandler, but also implements machine authentication.""" | 96 """Like ApiHandler, but also implements machine authentication.""" |
| 113 | 97 |
| 114 # Bots are passing credentials through special headers (not cookies), no need | 98 # Bots are passing credentials through special headers (not cookies), no need |
| 115 # for XSRF tokens. | 99 # for XSRF tokens. |
| 116 xsrf_token_enforce_on = () | 100 xsrf_token_enforce_on = () |
| 117 | 101 |
| 118 @classmethod | 102 @classmethod |
| 119 def get_auth_methods(cls, conf): | 103 def get_auth_methods(cls, conf): |
| 120 return [auth.machine_authentication, auth.oauth_authentication] | 104 return [auth.machine_authentication, auth.oauth_authentication] |
| (...skipping 46 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
| 167 | 151 |
| 168 # TODO(vadimsh): Remove is_ip_whitelisted_machine check once all bots are | 152 # TODO(vadimsh): Remove is_ip_whitelisted_machine check once all bots are |
| 169 # using auth for bootstrap and updating. | 153 # using auth for bootstrap and updating. |
| 170 if (not acl.is_bootstrapper() and | 154 if (not acl.is_bootstrapper() and |
| 171 not acl.is_ip_whitelisted_machine() and | 155 not acl.is_ip_whitelisted_machine() and |
| 172 not (bot_id and bot_auth.is_authenticated_bot(bot_id, machine_type))): | 156 not (bot_id and bot_auth.is_authenticated_bot(bot_id, machine_type))): |
| 173 raise auth.AuthorizationError('Not allowed to access the bot code') | 157 raise auth.AuthorizationError('Not allowed to access the bot code') |
| 174 | 158 |
| 175 return bot_code.generate_bootstrap_token() if generate_token else None | 159 return bot_code.generate_bootstrap_token() if generate_token else None |
| 176 | 160 |
| 177 def get_bot_contact_server(self): | |
| 178 """Gets the server contacted by the bot.""" | |
| 179 return get_bot_contact_server(self.request) | |
| 180 | |
| 181 | 161 |
| 182 class BootstrapHandler(_BotAuthenticatingHandler): | 162 class BootstrapHandler(_BotAuthenticatingHandler): |
| 183 """Returns python code to run to bootstrap a swarming bot.""" | 163 """Returns python code to run to bootstrap a swarming bot.""" |
| 184 | 164 |
| 185 @auth.public # auth inside check_bot_code_access() | 165 @auth.public # auth inside check_bot_code_access() |
| 186 def get(self): | 166 def get(self): |
| 187 # We must pass a bootstrap token (generating it, if necessary) to | 167 # We must pass a bootstrap token (generating it, if necessary) to |
| 188 # get_bootstrap(...), since bootstrap.py uses tokens exclusively (it can't | 168 # get_bootstrap(...), since bootstrap.py uses tokens exclusively (it can't |
| 189 # transparently pass OAuth headers to /bot_code). | 169 # transparently pass OAuth headers to /bot_code). |
| 190 bootstrap_token = self.check_bot_code_access( | 170 bootstrap_token = self.check_bot_code_access( |
| 191 bot_id=None, generate_token=True) | 171 bot_id=None, generate_token=True) |
| 192 self.response.headers['Content-Type'] = 'text/x-python' | 172 self.response.headers['Content-Type'] = 'text/x-python' |
| 193 self.response.headers['Content-Disposition'] = ( | 173 self.response.headers['Content-Disposition'] = ( |
| 194 'attachment; filename="swarming_bot_bootstrap.py"') | 174 'attachment; filename="swarming_bot_bootstrap.py"') |
| 195 self.response.out.write( | 175 self.response.out.write( |
| 196 bot_code.get_bootstrap(self.request.host_url, bootstrap_token).content) | 176 bot_code.get_bootstrap(self.request.host_url, bootstrap_token).content) |
| 197 | 177 |
| 198 | 178 |
| 199 class BotCodeHandler(_BotAuthenticatingHandler): | 179 class BotCodeHandler(_BotAuthenticatingHandler): |
| 200 """Returns a zip file with all the files required by a bot. | 180 """Returns a zip file with all the files required by a bot. |
| 201 | 181 |
| 202 Optionally specify the hash version to download. If so, the returned data is | 182 Optionally specify the hash version to download. If so, the returned data is |
| 203 cacheable. | 183 cacheable. |
| 204 """ | 184 """ |
| 205 | 185 |
| 206 @auth.public # auth inside check_bot_code_access() | 186 @auth.public # auth inside check_bot_code_access() |
| 207 def get(self, version=None): | 187 def get(self, version=None): |
| 208 server = self.get_bot_contact_server() | 188 server = self.request.host_url |
| 209 self.check_bot_code_access( | 189 self.check_bot_code_access( |
| 210 bot_id=self.request.get('bot_id'), generate_token=False) | 190 bot_id=self.request.get('bot_id'), generate_token=False) |
| 211 if version: | 191 if version: |
| 212 expected, _ = bot_code.get_bot_version(server) | 192 expected, _ = bot_code.get_bot_version(server) |
| 213 if version != expected: | 193 if version != expected: |
| 214 # This can happen when the server is rapidly updated. | 194 # This can happen when the server is rapidly updated. |
| 215 logging.error('Requested Swarming bot %s, have %s', version, expected) | 195 logging.error('Requested Swarming bot %s, have %s', version, expected) |
| 216 self.abort(404) | 196 self.abort(404) |
| 217 self.response.headers['Cache-Control'] = 'public, max-age=3600' | 197 self.response.headers['Cache-Control'] = 'public, max-age=3600' |
| 218 else: | 198 else: |
| (...skipping 157 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
| 376 return result | 356 return result |
| 377 | 357 |
| 378 # Look for admin enforced quarantine. | 358 # Look for admin enforced quarantine. |
| 379 if bool(bot_settings and bot_settings.quarantined): | 359 if bool(bot_settings and bot_settings.quarantined): |
| 380 result.quarantined_msg = 'Quarantined by admin' | 360 result.quarantined_msg = 'Quarantined by admin' |
| 381 return result | 361 return result |
| 382 | 362 |
| 383 task_queues.assert_bot(dimensions) | 363 task_queues.assert_bot(dimensions) |
| 384 return result | 364 return result |
| 385 | 365 |
| 386 def get_bot_contact_server(self): | |
| 387 """Gets the server contacted by the bot.""" | |
| 388 return get_bot_contact_server(self.request) | |
| 389 | |
| 390 | 366 |
| 391 class BotHandshakeHandler(_BotBaseHandler): | 367 class BotHandshakeHandler(_BotBaseHandler): |
| 392 """First request to be called to get initial data like bot code version. | 368 """First request to be called to get initial data like bot code version. |
| 393 | 369 |
| 394 The bot is server-controlled so the server doesn't have to support multiple | 370 The bot is server-controlled so the server doesn't have to support multiple |
| 395 API version. When running a task, the bot sync the the version specific URL. | 371 API version. When running a task, the bot sync the the version specific URL. |
| 396 Once a bot finishes its currently running task, it'll be immediately upgraded | 372 Once a bot finishes its currently running task, it'll be immediately upgraded |
| 397 on its next poll. | 373 on its next poll. |
| 398 | 374 |
| 399 This endpoint does not return commands to the bot, for example to upgrade | 375 This endpoint does not return commands to the bot, for example to upgrade |
| (...skipping 15 matching lines...) Expand all Loading... |
| 415 res = self._process() | 391 res = self._process() |
| 416 bot_management.bot_event( | 392 bot_management.bot_event( |
| 417 event_type='bot_connected', bot_id=res.bot_id, | 393 event_type='bot_connected', bot_id=res.bot_id, |
| 418 external_ip=self.request.remote_addr, | 394 external_ip=self.request.remote_addr, |
| 419 authenticated_as=auth.get_peer_identity().to_bytes(), | 395 authenticated_as=auth.get_peer_identity().to_bytes(), |
| 420 dimensions=res.dimensions, state=res.state, | 396 dimensions=res.dimensions, state=res.state, |
| 421 version=res.version, quarantined=bool(res.quarantined_msg), | 397 version=res.version, quarantined=bool(res.quarantined_msg), |
| 422 task_id='', task_name=None, message=res.quarantined_msg) | 398 task_id='', task_name=None, message=res.quarantined_msg) |
| 423 | 399 |
| 424 data = { | 400 data = { |
| 425 'bot_version': bot_code.get_bot_version(self.get_bot_contact_server())[0], | 401 'bot_version': bot_code.get_bot_version(self.request.host_url)[0], |
| 426 'server_version': utils.get_app_version(), | 402 'server_version': utils.get_app_version(), |
| 427 'bot_group_cfg_version': res.bot_group_cfg.version, | 403 'bot_group_cfg_version': res.bot_group_cfg.version, |
| 428 'bot_group_cfg': { | 404 'bot_group_cfg': { |
| 429 # Let the bot know its server-side dimensions (from bots.cfg file). | 405 # Let the bot know its server-side dimensions (from bots.cfg file). |
| 430 'dimensions': res.bot_group_cfg.dimensions, | 406 'dimensions': res.bot_group_cfg.dimensions, |
| 431 }, | 407 }, |
| 432 } | 408 } |
| 433 if res.bot_group_cfg.bot_config_script_content: | 409 if res.bot_group_cfg.bot_config_script_content: |
| 434 logging.info( | 410 logging.info( |
| 435 'Injecting %s: %d bytes', | 411 'Injecting %s: %d bytes', |
| (...skipping 177 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
| 613 external_ip=self.request.remote_addr, | 589 external_ip=self.request.remote_addr, |
| 614 authenticated_as=auth.get_peer_identity().to_bytes(), | 590 authenticated_as=auth.get_peer_identity().to_bytes(), |
| 615 dimensions=res.dimensions, state=res.state, | 591 dimensions=res.dimensions, state=res.state, |
| 616 version=res.version, quarantined=quarantined, | 592 version=res.version, quarantined=quarantined, |
| 617 task_id=task_id, task_name=task_name, message=res.quarantined_msg) | 593 task_id=task_id, task_name=task_name, message=res.quarantined_msg) |
| 618 | 594 |
| 619 # Bot version is host-specific because the host URL is embedded in | 595 # Bot version is host-specific because the host URL is embedded in |
| 620 # swarming_bot.zip | 596 # swarming_bot.zip |
| 621 logging.debug('Fetching bot code version') | 597 logging.debug('Fetching bot code version') |
| 622 expected_version, _ = bot_code.get_bot_version( | 598 expected_version, _ = bot_code.get_bot_version( |
| 623 self.get_bot_contact_server()) | 599 self.request.host_url) |
| 624 if res.version != expected_version: | 600 if res.version != expected_version: |
| 625 bot_event('request_update') | 601 bot_event('request_update') |
| 626 self._cmd_update(expected_version) | 602 self._cmd_update(expected_version) |
| 627 return | 603 return |
| 628 if quarantined: | 604 if quarantined: |
| 629 bot_event('request_sleep') | 605 bot_event('request_sleep') |
| 630 self._cmd_sleep(sleep_streak, quarantined) | 606 self._cmd_sleep(sleep_streak, quarantined) |
| 631 return | 607 return |
| 632 | 608 |
| 633 # If the server-side per-bot config for the bot has changed, we need | 609 # If the server-side per-bot config for the bot has changed, we need |
| (...skipping 435 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
| 1069 ('/swarming/api/v1/bot/poll', BotPollHandler), | 1045 ('/swarming/api/v1/bot/poll', BotPollHandler), |
| 1070 ('/swarming/api/v1/bot/server_ping', ServerPingHandler), | 1046 ('/swarming/api/v1/bot/server_ping', ServerPingHandler), |
| 1071 ('/swarming/api/v1/bot/task_update', BotTaskUpdateHandler), | 1047 ('/swarming/api/v1/bot/task_update', BotTaskUpdateHandler), |
| 1072 ('/swarming/api/v1/bot/task_update/<task_id:[a-f0-9]+>', | 1048 ('/swarming/api/v1/bot/task_update/<task_id:[a-f0-9]+>', |
| 1073 BotTaskUpdateHandler), | 1049 BotTaskUpdateHandler), |
| 1074 ('/swarming/api/v1/bot/task_error', BotTaskErrorHandler), | 1050 ('/swarming/api/v1/bot/task_error', BotTaskErrorHandler), |
| 1075 ('/swarming/api/v1/bot/task_error/<task_id:[a-f0-9]+>', | 1051 ('/swarming/api/v1/bot/task_error/<task_id:[a-f0-9]+>', |
| 1076 BotTaskErrorHandler), | 1052 BotTaskErrorHandler), |
| 1077 ] | 1053 ] |
| 1078 return [webapp2.Route(*i) for i in routes] | 1054 return [webapp2.Route(*i) for i in routes] |
| OLD | NEW |