import json import jsonschema import pkg_resources from six.moves import urllib from dcos import http, util from dcos.errors import DCOSException, DCOSHTTPException logger = util.get_logger(__name__) def create_client(url, timeout): return RpcClient(url, timeout) def load_error_json_schema(): """Reads and parses Marathon error response JSON schema from file :returns: the parsed JSON schema :rtype: dict """ schema_path = 'data/marathon/error.schema.json' schema_bytes = pkg_resources.resource_string('dcos', schema_path) return json.loads(schema_bytes.decode('utf-8')) class RpcClient(object): """Convenience class for making requests against a common RPC API. For example, it ensures the same base URL is used for all requests. This class is also useful as a target for mocks in unit tests, because it presents a minimal, application-focused interface. :param base_url: the URL prefix to use for all requests :type base_url: str :param timeout: number of seconds to wait for a response :type timeout: float """ def __init__(self, base_url, timeout=http.DEFAULT_TIMEOUT): if not base_url.endswith('/'): base_url += '/' self._base_url = base_url self._timeout = timeout ERROR_JSON_VALIDATOR = jsonschema.Draft4Validator(load_error_json_schema()) RESOURCE_TYPES = ['app', 'group', 'pod'] @classmethod def response_error_message(cls, status_code, reason, request_method, request_url, json_body): """Renders a human-readable error message from the given response data. :param status_code: the integer status code from an HTTP response :type status_code: int :param reason: human-readable text representation of the status code :type reason: str :param request_method: the HTTP method used for the request :type request_method: str :param request_url: the URL the request was sent to :type request_url: str :param json_body: the response body, parsed as JSON, or None if parsing failed :type json_body: dict | list | str | int | bool | None :return: the rendered error message :rtype: str """ if status_code == 400: template = 'Error on request [{} {}]: HTTP 400: {}{}' suffix = '' if json_body is not None: json_str = json.dumps(json_body, indent=2, sort_keys=True) suffix = ':\n' + json_str return template.format(request_method, request_url, reason, suffix) if status_code == 409: path = urllib.parse.urlparse(request_url).path path_name = (name for name in cls.RESOURCE_TYPES if name in path) resource_name = next(path_name, 'resource') template = ('Changes blocked: ' 'deployment already in progress for {}.') return template.format(resource_name) if json_body is None: template = 'Error decoding response from [{}]: HTTP {}: {}' return template.format(request_url, status_code, reason) if not cls.ERROR_JSON_VALIDATOR.is_valid(json_body): log_str = 'Server did not return a message: %s' logger.error(log_str, json_body) return _default_dcos_error() message = json_body.get('message') if message is None: message = '\n'.join(err['error'] for err in json_body['errors']) return _default_dcos_error(message) return 'Error: {}'.format(message) def http_req(self, method_fn, path, *args, **kwargs): """Make an HTTP request, and raise a DCOS-specific exception for HTTP error codes. :param method_fn: function to call that invokes a specific HTTP method :type method_fn: function :param path: the endpoint path to append to this object's base URL :type path: str :param args: additional args to pass to `method_fn` :type args: [object] :param kwargs: kwargs to pass to `method_fn` :type kwargs: dict :returns: `method_fn` return value :rtype: requests.Response """ url = self._base_url + path.lstrip('/') if 'timeout' not in kwargs: kwargs['timeout'] = self._timeout try: return method_fn(url, *args, **kwargs) except DCOSHTTPException as e: text = _get_response_text(e.response) logger.error('DCOS Error: %s\n%s', e.response.reason, text) try: json_body = e.response.json() except: logger.exception( 'Unable to decode response body as a JSON value: %r', e.response) json_body = None message = RpcClient.response_error_message( status_code=e.response.status_code, reason=e.response.reason, request_method=e.response.request.method, request_url=e.response.request.url, json_body=json_body) raise DCOSException(message) def _get_response_text(response): try: return response.text except: return '' def _default_dcos_error(message=""): """ :param message: additional message :type message: str :returns: dcos specific error message :rtype: str """ return ("Service likely misconfigured. Please check your proxy or " "Service URL settings. See dcos config --help. {}").format( message)