diff --git a/dynamic/__init__.py b/dynamic/__init__.py index 0a0b8f23..b5df5482 100644 --- a/dynamic/__init__.py +++ b/dynamic/__init__.py @@ -1 +1 @@ -from .client import DynamicClient +from .client import * # NOQA diff --git a/dynamic/client.py b/dynamic/client.py index fffbe678..29eb7a30 100755 --- a/dynamic/client.py +++ b/dynamic/client.py @@ -11,39 +11,85 @@ from kubernetes.client.api_client import ApiClient from kubernetes.client.rest import ApiException +from openshift.dynamic.exceptions import ResourceNotFoundError, ResourceNotUniqueError, api_exception + +__all__ = [ + 'DynamicClient', + 'ResourceInstance', + 'Resource', + 'Subresource', + 'ResourceContainer', + 'ResourceField', +] + + +def meta_request(func): + """ Handles parsing response structure and translating API Exceptions """ + def inner(self, resource, *args, **kwargs): + serialize = kwargs.pop('serialize', True) + try: + resp = func(self, resource, *args, **kwargs) + except ApiException as e: + raise api_exception(e) + if serialize: + return serialize(resource, resp) + return resp + + return inner + +def serialize(resource, response): + try: + return ResourceInstance(resource, load_json(response)) + except ValueError: + return response.data + +def load_json(response): + return json.loads(response.data) + class DynamicClient(object): + """ A kubernetes client that dynamically discovers and interacts with + the kubernetes API + """ def __init__(self, client): self.client = client + self.configuration = client.configuration + self._load_server_info() self.__resources = ResourceContainer(self.parse_api_groups()) + def _load_server_info(self): + self.__version = {'kubernetes': load_json(self.request('get', '/version'))} + try: + self.__version['openshift'] = load_json(self.request('get', '/version/openshift')) + except ApiException: + pass + @property def resources(self): return self.__resources + @property + def version(self): + return self.__version + def default_groups(self): groups = {} groups['api'] = { '': { 'v1': self.get_resources_for_api_version('api', '', 'v1', True) }} - try: - self.request('get', '/version/openshift') - is_openshift = True - except ApiException: - is_openshift = False - - if is_openshift: + if self.version.get('openshift'): groups['oapi'] = { '': { 'v1': self.get_resources_for_api_version('oapi', '', 'v1', True) }} + return groups def parse_api_groups(self): - "" + """ Discovers all API groups present in the cluster """ prefix = 'apis' - groups_response = self.request('GET', '/{}'.format(prefix))['groups'] + groups_response = load_json(self.request('GET', '/{}'.format(prefix)))['groups'] groups = self.default_groups() groups[prefix] = {} @@ -61,12 +107,18 @@ def get_resources_for_api_version(self, prefix, group, version, preferred): """ returns a dictionary of resources associated with provided groupVersion""" resources = {} + subresources = {} path = '/'.join(filter(None, [prefix, group, version])) - resources_response = self.request('GET', path)['resources'] + resources_response = load_json(self.request('GET', path))['resources'] - # TODO: Filter out subresources for now - resources_raw = filter(lambda resource: '/' not in resource['name'], resources_response) + resources_raw = list(filter(lambda resource: '/' not in resource['name'], resources_response)) + subresources_raw = list(filter(lambda resource: '/' in resource['name'], resources_response)) + for subresource in subresources_raw: + resource, name = subresource['name'].split('/') + if not subresources.get(resource): + subresources[resource] = {} + subresources[resource][name] = subresource for resource in resources_raw: resources[resource['kind']] = Resource( @@ -75,89 +127,65 @@ def get_resources_for_api_version(self, prefix, group, version, preferred): api_version=version, client=self, preferred=preferred, + subresources=subresources.get(resource['name']), **resource ) return resources - def list(self, resource, namespace=None, label_selector=None, field_selector=None): - path_params = {} - if resource.namespaced and namespace: - resource_path = resource.urls['namespaced_base'] - path_params['namespace'] = namespace - else: - resource_path = resource.urls['base'] - return ResourceInstance(resource, self.request('get', resource_path, path_params=path_params, label_selector=label_selector, field_selector=field_selector)) - - def get(self, resource, name=None, namespace=None, label_selector=None, field_selector=None): - if name is None: - return self.list(resource, namespace=namespace, label_selector=label_selector, field_selector=field_selector) - path_params = {'name': name} - if resource.namespaced and namespace: - resource_path = resource.urls['namespaced_full'] - path_params['namespace'] = namespace - else: - resource_path = resource.urls['full'] - return ResourceInstance(resource, self.request('get', resource_path, path_params=path_params, label_selector=label_selector, field_selector=field_selector)) + def ensure_namespace(self, resource, namespace, body): + namespace = namespace or body.get('metadata', {}).get('namespace') + if not namespace: + raise ValueError("Namespace is required for {}.{}".format(resource.group_version, resource.kind)) + return namespace - def create(self, resource, body, namespace=None): - path_params = {} - if resource.namespaced and namespace: - resource_path = resource.urls['namespaced_base'] - path_params['namespace'] = namespace - elif resource.namespaced and not namespace: - if body.get('metadata') and body['metadata'].get('namespace'): - resource_path = resource.urls['namespaced_base'] - path_params['namespace'] = body['metadata']['namespace'] - else: - resource_path = resource.urls['base'] - return ResourceInstance(resource, self.request('post', resource_path, path_params=path_params, body=body)) + @meta_request + def get(self, resource, name=None, namespace=None, **kwargs): + path = resource.path(name=name, namespace=namespace) + return self.request('get', path, **kwargs) - def delete(self, resource, name=None, namespace=None, label_selector=None, field_selector=None): + @meta_request + def create(self, resource, body=None, namespace=None, **kwargs): + if resource.namespaced: + namespace = self.ensure_namespace(resource, namespace, body) + path = resource.path(namespace=namespace) + return self.request('post', path, body=body, **kwargs) + + @meta_request + def delete(self, resource, name=None, namespace=None, label_selector=None, field_selector=None, **kwargs): if not (name or label_selector or field_selector): - raise Exception("At least one of name|label_selector|field_selector is required") - path_params = {} - if name: - path_params['name'] = name - if resource.namespaced and namespace: - resource_path = resource.urls['namespaced_full'] - path_params['namespace'] = namespace - else: - resource_path = resource.urls['full'] - return ResourceInstance(resource, self.request('delete', resource_path, path_params=path_params, label_selector=label_selector, field_selector=field_selector)) - - def replace(self, resource, body, name=None, namespace=None): - if name is None: - name = body['metadata']['name'] - path_params = {'name': name} - if resource.namespaced and namespace: - resource_path = resource.urls['namespaced_full'] - path_params['namespace'] = namespace - elif resource.namespaced and not namespace: - if body.get('metadata') and body['metadata'].get('namespace'): - resource_path = resource.urls['namespaced_full'] - path_params['namespace'] = body['metadata']['namespace'] - else: - resource_path = resource.urls['full'] + raise ValueError("At least one of name|label_selector|field_selector is required") + if resource.namespaced and not (label_selector or field_selector or namespace): + raise ValueError("At least one of namespace|label_selector|field_selector is required") + path = resource.path(name=name, namespace=namespace) + return self.request('delete', path, label_selector=label_selector, field_selector=field_selector, **kwargs) + + @meta_request + def replace(self, resource, body=None, name=None, namespace=None, **kwargs): + body = body or {} + name = name or body.get('metadata', {}).get('name') + if not name: + raise ValueError("name is required to replace {}.{}".format(resource.group_version, resource.kind)) + if resource.namespaced: + namespace = self.ensure_namespace(resource, namespace, body) + path = resource.path(name=name, namespace=namespace) + return self.request('put', path, body=body, **kwargs) + + @meta_request + def patch(self, resource, body=None, name=None, namespace=None, **kwargs): + body = body or {} + name = name or body.get('metadata', {}).get('name') + if not name: + raise ValueError("name is required to patch {}.{}".format(resource.group_version, resource.kind)) + if resource.namespaced: + namespace = self.ensure_namespace(resource, namespace, body) - return ResourceInstance(resource, self.request('put', resource_path, path_params=path_params, body=body)) + path = resource.path(name=name, namespace=namespace, **kwargs) - def update(self, resource, body, name=None, namespace=None): - if name is None: - name = body['metadata']['name'] - path_params = {'name': name} - if resource.namespaced and namespace: - resource_path = resource.urls['namespaced_full'] - path_params['namespace'] = namespace - elif resource.namespaced and not namespace: - if body.get('metadata') and body['metadata'].get('namespace'): - resource_path = resource.urls['namespaced_full'] - path_params['namespace'] = body['metadata']['namespace'] - else: - resource_path = resource.urls['full'] content_type = self.client.\ select_header_content_type(['application/json-patch+json', 'application/merge-patch+json', 'application/strategic-merge-patch+json']) - return ResourceInstance(resource, self.request('patch', resource_path, path_params=path_params, body=body, content_type=content_type)) + return self.request('patch', path, body=body, content_type=content_type) + def request(self, method, path, body=None, **params): @@ -166,13 +194,26 @@ def request(self, method, path, body=None, **params): path_params = params.get('path_params', {}) query_params = params.get('query_params', []) - if 'pretty' in params: + if params.get('pretty'): query_params.append(('pretty', params['pretty'])) - if params.get('label_selector'): - query_params.append(('labelSelector', params['label_selector'])) + if params.get('_continue'): + query_params.append(('continue', params['_continue'])) + if params.get('include_uninitialized'): + query_params.append(('includeUninitialized', params['include_uninitialized'])) if params.get('field_selector'): query_params.append(('fieldSelector', params['field_selector'])) - header_params = {} + if params.get('label_selector'): + query_params.append(('labelSelector', params['label_selector'])) + if params.get('limit'): + query_params.append(('limit', params['limit'])) + if params.get('resource_version'): + query_params.append(('resourceVersion', params['resource_version'])) + if params.get('timeout_seconds'): + query_params.append(('timeoutSeconds', params['timeout_seconds'])) + if params.get('watch'): + query_params.append(('watch', params['watch'])) + + header_params = params.get('header_params', {}) form_params = [] local_var_files = {} # HTTP header `Accept` @@ -191,7 +232,7 @@ def request(self, method, path, body=None, **params): # Authentication setting auth_settings = ['BearerToken'] - return json.loads(self.client.call_api( + return self.client.call_api( path, method.upper(), path_params, @@ -199,20 +240,23 @@ def request(self, method, path, body=None, **params): header_params, body=body, post_params=form_params, + async=params.get('async'), files=local_var_files, auth_settings=auth_settings, - _preload_content=False - )[0].data) + _preload_content=False, + _return_http_data_only=params.get('_return_http_data_only', True) + ) class Resource(object): + """ Represents an API resource type, containing the information required to build urls for requests """ def __init__(self, prefix=None, group=None, api_version=None, kind=None, namespaced=False, verbs=None, name=None, preferred=False, client=None, - singularName=None, shortNames=None, categories=None, **kwargs): + singularName=None, shortNames=None, categories=None, subresources=None, **kwargs): if None in (api_version, kind, prefix): - raise Exception("At least prefix, kind, and api_version must be provided") + raise ValueError("At least prefix, kind, and api_version must be provided") self.prefix = prefix self.group = group @@ -226,6 +270,9 @@ def __init__(self, prefix=None, group=None, api_version=None, kind=None, self.singular_name = singularName self.short_names = shortNames self.categories = categories + self.subresources = { + k: Subresource(self, **v) for k, v in (subresources or {}).items() + } self.extra_args = kwargs @@ -236,7 +283,7 @@ def group_version(self): return self.api_version def __repr__(self): - return '<{}({}.{}>)'.format(self.__class__.__name__, self.group_version, self.kind) + return '<{}({}/{}>)'.format(self.__class__.__name__, self.group_version, self.name) @property def urls(self): @@ -248,15 +295,72 @@ def urls(self): 'namespaced_full': '/{}/namespaces/{{namespace}}/{}/{{name}}'.format(full_prefix, self.name) } + def path(self, name=None, namespace=None): + url_type = [] + path_params = {} + if self.namespaced and namespace: + url_type.append('namespaced') + path_params['namespace'] = namespace + if name: + url_type.append('full') + path_params['name'] = name + else: + url_type.append('base') + return self.urls['_'.join(url_type)].format(**path_params) + def __getattr__(self, name): + if name in self.subresources: + return self.subresources[name] return partial(getattr(self.client, name), self) +class Subresource(Resource): + """ Represents a subresource of an API resource. This generally includes operations + like scale, as well as status objects for an instantiated resource + """ + + def __init__(self, parent, **kwargs): + self.parent = parent + self.prefix = parent.prefix + self.group = parent.group + self.api_version = parent.api_version + self.kind = kwargs.pop('kind') + self.name = kwargs.pop('name') + self.subresource = self.name.split('/')[1] + self.namespaced = kwargs.pop('namespaced', False) + self.verbs = kwargs.pop('verbs', None) + self.extra_args = kwargs + + @property + def urls(self): + full_prefix = '{}/{}'.format(self.prefix, self.group_version) + return { + 'full': '/{}/{}/{{name}}/{}'.format(full_prefix, self.parent.name, self.subresource), + 'namespaced_full': '/{}/namespaces/{{namespace}}/{}/{{name}}/{}'.format(full_prefix, self.parent.name, self.subresource) + } + + def __getattr__(self, name): + return partial(getattr(self.parent.client, name), self) + + class ResourceContainer(object): + """ A convenient container for storing discovered API resources. Allows + easy searching and retrieval of specific resources + """ + def __init__(self, resources): self.__resources = resources + @property + def api_groups(self): + """ list available api groups """ + return self.__resources['apis'].keys() + def get(self, **kwargs): + """ Same as search, but will throw an error if there are multiple or no + results. If there are multiple results and only one is an exact match + on api_version, that resource will be returned. + """ results = self.search(**kwargs) if len(results) > 1 and kwargs.get('api_version'): results = [ @@ -265,11 +369,21 @@ def get(self, **kwargs): if len(results) == 1: return results[0] elif not results: - raise Exception('No matches found for {}'.format(kwargs)) + raise ResourceNotFoundError('No matches found for {}'.format(kwargs)) else: - raise Exception('Multiple matches found for {}: {}'.format(kwargs, results)) + raise ResourceNotUniqueError('Multiple matches found for {}: {}'.format(kwargs, results)) def search(self, **kwargs): + """ Takes keyword arguments and returns matching resources. The search + will happen in the following order: + prefix: The api prefix for a resource, ie, /api, /oapi, /apis. Can usually be ignored + group: The api group of a resource. Will also be extracted from api_version if it is present there + api_version: The api version of a resource + kind: The kind of the resource + arbitrary arguments (see below), in random order + + The arbitrary arguments can be any valid attribute for an openshift.dynamic.Resource object + """ return self.__search(self.__build_search(**kwargs), self.__resources) def __build_search(self, kind=None, api_version=None, prefix=None, **kwargs): @@ -310,6 +424,10 @@ def __iter__(self): class ResourceField(object): + """ A parsed instance of an API resource attribute. It exists + solely to ease interaction with API objects by allowing + attributes to be accessed with '.' notation + """ def __init__(self, **kwargs): self.__dict__.update(kwargs) @@ -321,13 +439,27 @@ def __eq__(self, other): return self.__dict__ == other.__dict__ def __getitem__(self, name): - return self.__dict__[name] + return self.__dict__.get(name) + + def __getattr__(self, name): + return self.__dict__.get(name) + + def __setattr__(self, name, value): + self.__dict__[name] = value def __dir__(self): return dir(type(self)) + list(self.__dict__.keys()) + def __iter__(self): + for k, v in self.__dict__.items(): + yield (k, v) + class ResourceInstance(object): + """ A parsed instance of an API resource. It exists solely to + ease interaction with API objects by allowing attributes to + be accessed with '.' notation. + """ def __init__(self, resource, instance): self.resource_type = resource @@ -384,7 +516,10 @@ def main(): key = resource.urls['namespaced_full'] else: key = resource.urls['full'] - ret[key] = {k: v for k, v in resource.__dict__.items() if k != 'client'} + ret[key] = {k: v for k, v in resource.__dict__.items() if k not in ('client', 'subresources')} + ret[key]['subresources'] = {} + for name, value in resource.subresources.items(): + ret[key]['subresources'][name] = {k: v for k, v in value.__dict__.items() if k != 'parent'} print(yaml.safe_dump(ret)) return 0 diff --git a/dynamic/exceptions.py b/dynamic/exceptions.py new file mode 100644 index 00000000..e3603a02 --- /dev/null +++ b/dynamic/exceptions.py @@ -0,0 +1,81 @@ +import sys +import traceback + +from kubernetes.client.rest import ApiException + + +def api_exception(e): + """ + Returns the proper Exception class for the given kubernetes.client.rest.ApiException object + https://github.com/kubernetes/community/blob/master/contributors/devel/api-conventions.md#success-codes + """ + _, _, exc_traceback = sys.exc_info() + tb = '\n'.join(traceback.format_tb(exc_traceback)) + return { + 400: BadRequestError, + 401: UnauthorizedError, + 403: ForbiddenError, + 404: NotFoundError, + 405: MethodNotAllowedError, + 409: ConflictError, + 410: GoneError, + 422: UnprocessibleEntityError, + 429: TooManyRequestsError, + 500: InternalServerError, + 503: ServiceUnavailableError, + 504: ServerTimeoutError, + }.get(e.status, DynamicApiError)(e, tb) + + +class DynamicApiError(ApiException): + """ Generic API Error for the dynamic client """ + def __init__(self, e, tb=None): + self.status = e.status + self.reason = e.reason + self.body = e.body + self.headers = e.headers + self.original_traceback = tb + + def __str__(self): + error_message = [self.status, "Reason: {}".format(self.reason)] + if self.headers: + error_message.append("HTTP response headers: {}".format(self.headers)) + + if self.body: + error_message.append("HTTP response body: {}".format(self.body)) + + if self.original_traceback: + error_message.append("Original traceback: \n{}".format(self.original_traceback)) + + return '\n'.join(error_message) + +class ResourceNotFoundError(Exception): + """ Resource was not found in available APIs """ +class ResourceNotUniqueError(Exception): + """ Parameters given matched multiple API resources """ + +# HTTP Errors +class BadRequestError(DynamicApiError): + """ 400: StatusBadRequest """ +class UnauthorizedError(DynamicApiError): + """ 401: StatusUnauthorized """ +class ForbiddenError(DynamicApiError): + """ 403: StatusForbidden """ +class NotFoundError(DynamicApiError): + """ 404: StatusNotFound """ +class MethodNotAllowedError(DynamicApiError): + """ 405: StatusMethodNotAllowed """ +class ConflictError(DynamicApiError): + """ 409: StatusConflict """ +class GoneError(DynamicApiError): + """ 410: StatusGone """ +class UnprocessibleEntityError(DynamicApiError): + """ 422: StatusUnprocessibleEntity """ +class TooManyRequestsError(DynamicApiError): + """ 429: StatusTooManyRequests """ +class InternalServerError(DynamicApiError): + """ 500: StatusInternalServer """ +class ServiceUnavailableError(DynamicApiError): + """ 503: StatusServiceUnavailable """ +class ServerTimeoutError(DynamicApiError): + """ 504: StatusServerTimeout """