diff --git a/.travis.yml b/.travis.yml index b42e83d7..7b9c365d 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,15 +5,12 @@ cache: - pip language: python python: -- '2.7' -- '3.5' +- '3.9' env: -- TEST_SUITE=unit OPENSHIFT_VERSION=latest -- TEST_SUITE=unit OPENSHIFT_VERSION=3.10 -- TEST_SUITE=unit OPENSHIFT_VERSION=3.11 -- TEST_SUITE=dynamic-functional OPENSHIFT_VERSION=3.10 -- TEST_SUITE=dynamic-functional OPENSHIFT_VERSION=3.11 -- TEST_SUITE=dynamic-functional OPENSHIFT_VERSION=latest + global: + - COVERALLS_PARALLEL=true + jobs: + - TEST_SUITE=unit script: tox install: - pip install tox-travis coveralls @@ -22,20 +19,21 @@ after_success: jobs: include: - stage: lint - python: '2.7' + python: '3.9' install: - pip install tox-travis script: tox -e py27-lint env: - - TEST_SUITE=lint OPENSHIFT_VERSION=latest - - python: '3.5' + - TEST_SUITE=lint + - python: '3.9' install: - pip install tox-travis script: tox -e py35-lint env: - - TEST_SUITE=lint OPENSHIFT_VERSION=latest + - TEST_SUITE=lint - stage: deploy script: skip + python: '3.9' deploy: provider: pypi user: openshift @@ -44,15 +42,18 @@ jobs: on: tags: true repo: openshift/openshift-restclient-python - condition: "$TRAVIS_TAG =~ ^v[0-9]\\.[0-9]\\.[0-9](([ab]|dev|rc)[0-9])?$" + condition: "$TRAVIS_TAG =~ ^v[0-9]+\\.[0-9]+\\.[0-9]+(([ab]|dev|rc)[0-9]+)?$" - stage: test-deploy + python: '3.9' script: python -c "import openshift ; print(openshift.__version__)" install: - pip install openshift stages: -- lint -- test +# - lint +# - test - name: deploy if: (tag is present) and (type = push) - name: test-deploy if: (tag is present) and (type = push) +notifications: + webhooks: https://coveralls.io/webhook diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..e7f67ce2 --- /dev/null +++ b/Makefile @@ -0,0 +1,14 @@ +.PHONY: test + +ARTIFACT_DIR ?= ./ + +test: test-lint test-unit test-integration + +test-lint: + flake8 . + +test-unit: + pytest test/unit -v -r s + +test-integration: + pytest test/integration -v -r s diff --git a/OWNERS b/OWNERS new file mode 100644 index 00000000..b463655f --- /dev/null +++ b/OWNERS @@ -0,0 +1,10 @@ +approvers: + - fabianvf + - jmrodri + - willthames + - asmacdo +reviewers: + - fabianvf + - jmrodri + - willthames + - asmacdo diff --git a/README.md b/README.md index 5a97cc56..d2b8a565 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ OpenShift python client Python client for the [Kubernetes](https://kubernetes.io/) and [OpenShift](http://openshift.redhat.com/) APIs. -There are two ways this project interacts with the Kubernets and OpenShift APIs. +There are two ways this project interacts with the Kubernetes and OpenShift APIs. The first, **now deprecated**, is to use models and functions generated with swagger from the API spec. The second, new approach, is to use a single model and client to generically interact with all resources on the server. The @@ -22,6 +22,7 @@ API servers or Custom Resource Definitions. * [Create a Route](#create-a-route) * [List Projects](#list-projects) * [Custom Resources](#custom-resources) + * [OpenShift Login with username and password](#openshift-login-with-username-and-password) * [Available Methods for Resources](#available-methods-for-resources) * [Get](#get) * [Create](#create) @@ -102,7 +103,7 @@ metadata: spec: selector: app: MyApp -ports: + ports: - protocol: TCP port: 8080 targetPort: 9376 @@ -232,6 +233,44 @@ for item in foo_resources.get().items: print(item.metadata.name) ``` +## OpenShift Login with username and password + +```python +from kubernetes import client +from openshift.dynamic import DynamicClient +from openshift.helper.userpassauth import OCPLoginConfiguration + +apihost = 'https://api.cluster.example.com:6443' +username = 'demo-user' +password = 'insecure' + +kubeConfig = OCPLoginConfiguration(ocp_username=username, ocp_password=password) +kubeConfig.host = apihost +kubeConfig.verify_ssl = True +kubeConfig.ssl_ca_cert = './ocp.pem' # use a certificate bundle for the TLS validation + +# Retrieve the auth token +kubeConfig.get_token() + +print('Auth token: {0}'.format(kubeConfig.api_key)) +print('Token expires: {0}'.format(kubeConfig.api_key_expires)) + +k8s_client = client.ApiClient(kubeConfig) + +dyn_client = DynamicClient(k8s_client) +v1_projects = dyn_client.resources.get(api_version='project.openshift.io/v1', kind='Project') +project_list = v1_projects.get() + +for project in project_list.items: + print(project.metadata.name) + +# Renew the auth token +kubeConfig.get_token() + +print('Auth token: {0}'.format(kubeConfig.api_key)) +print('Token expires: {0}'.format(kubeConfig.api_key_expires)) +``` + # Available Methods for Resources The generic Resource class supports the following methods, though every resource kind does not support every method. diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 00000000..fb9e3946 --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,13 @@ +# Instructions for creating a release + +- [ ] Check out the release branch. Release branches are named `release-{MAJOR}.{MINOR}`. +- [ ] Update `CLIENT_VERSION` in [`scripts/constants.py`](scripts/constants.py#L26) to reflect the new version. +- [ ] Update `KUBERNETES_CLIENT_VERSION` in [`scripts/constants.py`](scripts/constants.py#L27) to reflect an updated `kubernetes` python package, if needed. +- [ ] Run [`scripts/update_version.sh`](scripts/update_version.sh). This will update the version numbers everywhere they are referenced. +- [ ] Commit the changes made by the previous step. If you have push access, you can push the branch up directly. If not, you can open a PR and a maintainer will review and merge it. +- [ ] In the GitHub UI go to the [`releases`](https://github.com/openshift/openshift-restclient-python/releases) tab. Select [`Draft a new release`](https://github.com/openshift/openshift-restclient-python/releases/new). +- [ ] For tag version, fill in `v` followed by the value for `CLIENT_VERSION` that you added to [`constants.py`](scripts/constants.py#L26), ie, if your `CLIENT_VERSION` is `0.36.2`, your tag will be `v0.36.2` +- [ ] Ensure that the `Target` is set to the proper `release-{MAJOR}.{MINOR}`, NOT master. +- [ ] For `Release title`, just put `Release` followed by the new version you set in `CLIENT_VERSION` +- [ ] For the release notes, summarize the features and bugfixes that have been made since the last release. There is no automation for this part yet unfortunately. +- [ ] Click `Publish release`. This will kick off a travis-ci build, and once that succeeds the package will be uploaded to pypi automatically. diff --git a/conftest.py b/conftest.py deleted file mode 100644 index ecf0c41b..00000000 --- a/conftest.py +++ /dev/null @@ -1,7 +0,0 @@ -def pytest_addoption(parser): - parser.addoption("--openshift-version", action="store", default='latest', - help="Version of OpenShift to test against for functional tests") - - -def pytest_report_header(config): - return "OpenShift version: {}".format(config.getoption('--openshift-version')) diff --git a/openshift/dynamic/apply.py b/openshift/dynamic/apply.py index 162892fc..d9331784 100644 --- a/openshift/dynamic/apply.py +++ b/openshift/dynamic/apply.py @@ -1,35 +1,118 @@ +from collections import OrderedDict from copy import deepcopy import json +import sys -from openshift.dynamic.exceptions import NotFoundError +from openshift.dynamic.exceptions import NotFoundError, ApplyException LAST_APPLIED_CONFIG_ANNOTATION = 'kubectl.kubernetes.io/last-applied-configuration' -def apply_object(resource, definition): - desired_annotation = dict( +POD_SPEC_SUFFIXES = { + 'containers': 'name', + 'initContainers': 'name', + 'ephemeralContainers': 'name', + 'volumes': 'name', + 'imagePullSecrets': 'name', + 'containers.volumeMounts': 'mountPath', + 'containers.volumeDevices': 'devicePath', + 'containers.env': 'name', + 'containers.ports': 'containerPort', + 'initContainers.volumeMounts': 'mountPath', + 'initContainers.volumeDevices': 'devicePath', + 'initContainers.env': 'name', + 'initContainers.ports': 'containerPort', + 'ephemeralContainers.volumeMounts': 'mountPath', + 'ephemeralContainers.volumeDevices': 'devicePath', + 'ephemeralContainers.env': 'name', + 'ephemeralContainers.ports': 'containerPort', +} + +POD_SPEC_PREFIXES = [ + 'Pod.spec', + 'Deployment.spec.template.spec', + 'DaemonSet.spec.template.spec', + 'StatefulSet.spec.template.spec', + 'Job.spec.template.spec', + 'Cronjob.spec.jobTemplate.spec.template.spec', +] + +# patch merge keys taken from generated.proto files under +# staging/src/k8s.io/api in kubernetes/kubernetes +STRATEGIC_MERGE_PATCH_KEYS = { + 'Service.spec.ports': 'port', + 'ServiceAccount.secrets': 'name', + 'ValidatingWebhookConfiguration.webhooks': 'name', + 'MutatingWebhookConfiguration.webhooks': 'name', +} + +STRATEGIC_MERGE_PATCH_KEYS.update( + {"%s.%s" % (prefix, key): value + for prefix in POD_SPEC_PREFIXES + for key, value in POD_SPEC_SUFFIXES.items()} +) + + +if sys.version_info.major >= 3: + json_loads_byteified = json.loads +else: + # https://stackoverflow.com/a/33571117 + def json_loads_byteified(json_text): + return _byteify( + json.loads(json_text, object_hook=_byteify), + ignore_dicts=True + ) + + def _byteify(data, ignore_dicts = False): + # if this is a unicode string, return its string representation + if isinstance(data, unicode): # noqa: F821 + return data.encode('utf-8') + # if this is a list of values, return list of byteified values + if isinstance(data, list): + return [ _byteify(item, ignore_dicts=True) for item in data ] + # if this is a dictionary, return dictionary of byteified keys and values + # but only if we haven't already byteified it + if isinstance(data, dict) and not ignore_dicts: + return { + _byteify(key, ignore_dicts=True): _byteify(value, ignore_dicts=True) + for key, value in data.items() + } + # if it's anything else, return it in its original form + return data + + +def annotate(desired): + return dict( metadata=dict( annotations={ - LAST_APPLIED_CONFIG_ANNOTATION: json.dumps(definition, separators=(',', ':'), indent=None) + LAST_APPLIED_CONFIG_ANNOTATION: json.dumps(desired, separators=(',', ':'), indent=None, sort_keys=True) } ) ) - try: - actual = resource.get(name=definition['metadata']['name'], namespace=definition['metadata'].get('namespace')) - except NotFoundError: - return None, dict_merge(definition, desired_annotation) - last_applied = actual.metadata.get('annotations',{}).get(LAST_APPLIED_CONFIG_ANNOTATION) + + +def apply_patch(actual, desired): + last_applied = actual['metadata'].get('annotations',{}).get(LAST_APPLIED_CONFIG_ANNOTATION) if last_applied: - last_applied = json.loads(last_applied) - actual_dict = actual.to_dict() - del actual_dict['metadata']['annotations'][LAST_APPLIED_CONFIG_ANNOTATION] - patch = merge(last_applied, definition, actual_dict) + # ensure that last_applied doesn't come back as a dict of unicode key/value pairs + # json.loads can be used if we stop supporting python 2 + last_applied = json_loads_byteified(last_applied) + patch = merge(dict_merge(last_applied, annotate(last_applied)), + dict_merge(desired, annotate(desired)), actual) if patch: - return actual.to_dict(), dict_merge(patch, desired_annotation) + return actual, patch else: - return actual.to_dict(), actual.to_dict() + return actual, actual else: - return actual.to_dict(), dict_merge(definition, desired_annotation) + return actual, dict_merge(desired, annotate(desired)) + + +def apply_object(resource, definition): + try: + actual = resource.get(name=definition['metadata']['name'], namespace=definition['metadata'].get('namespace')) + except NotFoundError: + return None, dict_merge(definition, annotate(definition)) + return apply_patch(actual.to_dict(), definition) def apply(resource, definition): @@ -48,9 +131,9 @@ def apply(resource, definition): # from last_applied to desired. To find it, we compute deletions, which are the deletions from # last_applied to desired, and delta, which is the difference from actual to desired without # deletions, and then apply delta to deletions as a patch, which should be strictly additive. -def merge(last_applied, desired, actual): +def merge(last_applied, desired, actual, position=None): deletions = get_deletions(last_applied, desired) - delta = get_delta(actual, desired) + delta = get_delta(last_applied, actual, desired, position or desired['kind']) return dict_merge(deletions, delta) @@ -70,6 +153,96 @@ def dict_merge(a, b): return result +def list_to_dict(lst, key, position): + result = OrderedDict() + for item in lst: + try: + result[item[key]] = item + except KeyError: + raise ApplyException("Expected key '%s' not found in position %s" % (key, position)) + return result + + +# list_merge applies a strategic merge to a set of lists if the patchMergeKey is known +# each item in the list is compared based on the patchMergeKey - if two values with the +# same patchMergeKey differ, we take the keys that are in last applied, compare the +# actual and desired for those keys, and update if any differ +def list_merge(last_applied, actual, desired, position): + result = list() + if position in STRATEGIC_MERGE_PATCH_KEYS and last_applied: + patch_merge_key = STRATEGIC_MERGE_PATCH_KEYS[position] + last_applied_dict = list_to_dict(last_applied, patch_merge_key, position) + actual_dict = list_to_dict(actual, patch_merge_key, position) + desired_dict = list_to_dict(desired, patch_merge_key, position) + for key in desired_dict: + if key not in actual_dict or key not in last_applied_dict: + result.append(desired_dict[key]) + else: + patch = merge(last_applied_dict[key], desired_dict[key], actual_dict[key], position) + result.append(dict_merge(actual_dict[key], patch)) + for key in actual_dict: + if key not in desired_dict and key not in last_applied_dict: + result.append(actual_dict[key]) + return result + else: + return desired + + +def recursive_list_diff(list1, list2, position=None): + result = (list(), list()) + if position in STRATEGIC_MERGE_PATCH_KEYS: + patch_merge_key = STRATEGIC_MERGE_PATCH_KEYS[position] + dict1 = list_to_dict(list1, patch_merge_key, position) + dict2 = list_to_dict(list2, patch_merge_key, position) + dict1_keys = set(dict1.keys()) + dict2_keys = set(dict2.keys()) + for key in dict1_keys - dict2_keys: + result[0].append(dict1[key]) + for key in dict2_keys - dict1_keys: + result[1].append(dict2[key]) + for key in dict1_keys & dict2_keys: + diff = recursive_diff(dict1[key], dict2[key], position) + if diff: + # reinsert patch merge key to relate changes in other keys to + # a specific list element + diff[0].update({patch_merge_key: dict1[key][patch_merge_key]}) + diff[1].update({patch_merge_key: dict2[key][patch_merge_key]}) + result[0].append(diff[0]) + result[1].append(diff[1]) + if result[0] or result[1]: + return result + elif list1 != list2: + return (list1, list2) + return None + + +def recursive_diff(dict1, dict2, position=None): + if not position: + if 'kind' in dict1 and dict1.get('kind') == dict2.get('kind'): + position = dict1['kind'] + left = dict((k, v) for (k, v) in dict1.items() if k not in dict2) + right = dict((k, v) for (k, v) in dict2.items() if k not in dict1) + for k in (set(dict1.keys()) & set(dict2.keys())): + this_position = "%s.%s" % (position, k) if position is not None else None + if isinstance(dict1[k], dict) and isinstance(dict2[k], dict): + result = recursive_diff(dict1[k], dict2[k], this_position) + if result: + left[k] = result[0] + right[k] = result[1] + elif isinstance(dict1[k], list) and isinstance(dict2[k], list): + result = recursive_list_diff(dict1[k], dict2[k], this_position) + if result: + left[k] = result[0] + right[k] = result[1] + elif dict1[k] != dict2[k]: + left[k] = dict1[k] + right[k] = dict2[k] + if left or right: + return left, right + else: + return None + + def get_deletions(last_applied, desired): patch = {} for k, last_applied_value in last_applied.items(): @@ -83,14 +256,23 @@ def get_deletions(last_applied, desired): return patch -def get_delta(actual, desired): +def get_delta(last_applied, actual, desired, position=None): patch = {} + for k, desired_value in desired.items(): + if position: + this_position = "%s.%s" % (position, k) actual_value = actual.get(k) if actual_value is None: patch[k] = desired_value elif isinstance(desired_value, dict): - p = get_delta(actual_value, desired_value) + p = get_delta(last_applied.get(k, {}), actual_value, desired_value, this_position) if p: patch[k] = p + elif isinstance(desired_value, list): + p = list_merge(last_applied.get(k, []), actual_value, desired_value, this_position) + if p: + patch[k] = [item for item in p if item] + elif actual_value != desired_value: + patch[k] = desired_value return patch diff --git a/openshift/dynamic/client.py b/openshift/dynamic/client.py index e65c15fd..94ffbead 100644 --- a/openshift/dynamic/client.py +++ b/openshift/dynamic/client.py @@ -1,16 +1,13 @@ -import six -import json - -from kubernetes import watch -from kubernetes.client.rest import ApiException +from kubernetes.dynamic.client import meta_request # noqa +from kubernetes.dynamic.client import DynamicClient as K8sDynamicClient from .apply import apply from .discovery import EagerDiscoverer, LazyDiscoverer -from .exceptions import api_exception, KubernetesValidateMissing from .resource import Resource, ResourceList, Subresource, ResourceInstance, ResourceField +from .exceptions import ApplyException try: - import kubernetes_validate + import kubernetes_validate # noqa HAS_KUBERNETES_VALIDATE = True except ImportError: HAS_KUBERNETES_VALIDATE = False @@ -33,103 +30,14 @@ class VersionNotSupportedError(NotImplementedError): ] -def meta_request(func): - """ Handles parsing response structure and translating API Exceptions """ - def inner(self, *args, **kwargs): - serialize_response = kwargs.pop('serialize', True) - serializer = kwargs.pop('serializer', ResourceInstance) - try: - resp = func(self, *args, **kwargs) - except ApiException as e: - raise api_exception(e) - if serialize_response: - try: - if six.PY2: - return serializer(self, json.loads(resp.data)) - return serializer(self, json.loads(resp.data.decode('utf8'))) - except ValueError: - if six.PY2: - return resp.data - return resp.data.decode('utf8') - return resp - - return inner - - -class DynamicClient(object): +class DynamicClient(K8sDynamicClient): """ A kubernetes client that dynamically discovers and interacts with the kubernetes API """ def __init__(self, client, cache_file=None, discoverer=None): - # Setting default here to delay evaluation of LazyDiscoverer class - # until constructor is called discoverer = discoverer or LazyDiscoverer - - self.client = client - self.configuration = client.configuration - self.__discoverer = discoverer(self, cache_file) - - @property - def resources(self): - return self.__discoverer - - @property - def version(self): - return self.__discoverer.version - - 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 serialize_body(self, body): - if hasattr(body, 'to_dict'): - return body.to_dict() - return body or {} - - def get(self, resource, name=None, namespace=None, **kwargs): - path = resource.path(name=name, namespace=namespace) - return self.request('get', path, **kwargs) - - def create(self, resource, body=None, namespace=None, **kwargs): - body = self.serialize_body(body) - if resource.namespaced: - namespace = self.ensure_namespace(resource, namespace, body) - path = resource.path(namespace=namespace) - return self.request('post', path, body=body, **kwargs) - - 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 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) - - def replace(self, resource, body=None, name=None, namespace=None, **kwargs): - body = self.serialize_body(body) - 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) - - def patch(self, resource, body=None, name=None, namespace=None, **kwargs): - body = self.serialize_body(body) - 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) - - content_type = kwargs.pop('content_type', 'application/strategic-merge-patch+json') - path = resource.path(name=name, namespace=namespace) - - return self.request('patch', path, body=body, content_type=content_type, **kwargs) + K8sDynamicClient.__init__(self, client, cache_file=cache_file, discoverer=discoverer) def apply(self, resource, body=None, name=None, namespace=None): body = self.serialize_body(body) @@ -139,136 +47,8 @@ def apply(self, resource, body=None, name=None, namespace=None): raise ValueError("name is required to apply {}.{}".format(resource.group_version, resource.kind)) if resource.namespaced: body['metadata']['namespace'] = self.ensure_namespace(resource, namespace, body) - return apply(resource, body) - - def watch(self, resource, namespace=None, name=None, label_selector=None, field_selector=None, resource_version=None, timeout=None): - """ - Stream events for a resource from the Kubernetes API - - :param resource: The API resource object that will be used to query the API - :param namespace: The namespace to query - :param name: The name of the resource instance to query - :param label_selector: The label selector with which to filter results - :param field_selector: The field selector with which to filter results - :param resource_version: The version with which to filter results. Only events with - a resource_version greater than this value will be returned - :param timeout: The amount of time in seconds to wait before terminating the stream - - :return: Event object with these keys: - 'type': The type of event such as "ADDED", "DELETED", etc. - 'raw_object': a dict representing the watched object. - 'object': A ResourceInstance wrapping raw_object. - - Example: - client = DynamicClient(k8s_client) - v1_pods = client.resources.get(api_version='v1', kind='Pod') - - for e in v1_pods.watch(resource_version=0, namespace=default, timeout=5): - print(e['type']) - print(e['object'].metadata) - """ - watcher = watch.Watch() - for event in watcher.stream( - resource.get, - namespace=namespace, - name=name, - field_selector=field_selector, - label_selector=label_selector, - resource_version=resource_version, - serialize=False, - timeout_seconds=timeout - ): - event['object'] = ResourceInstance(resource, event['object']) - yield event - - @meta_request - def request(self, method, path, body=None, **params): - if not path.startswith('/'): - path = '/' + path - - path_params = params.get('path_params', {}) - query_params = params.get('query_params', []) - if params.get('pretty') is not None: - query_params.append(('pretty', params['pretty'])) - if params.get('_continue') is not None: - query_params.append(('continue', params['_continue'])) - if params.get('include_uninitialized') is not None: - query_params.append(('includeUninitialized', params['include_uninitialized'])) - if params.get('field_selector') is not None: - query_params.append(('fieldSelector', params['field_selector'])) - if params.get('label_selector') is not None: - query_params.append(('labelSelector', params['label_selector'])) - if params.get('limit') is not None: - query_params.append(('limit', params['limit'])) - if params.get('resource_version') is not None: - query_params.append(('resourceVersion', params['resource_version'])) - if params.get('timeout_seconds') is not None: - query_params.append(('timeoutSeconds', params['timeout_seconds'])) - if params.get('watch') is not None: - query_params.append(('watch', params['watch'])) - - header_params = params.get('header_params', {}) - form_params = [] - local_var_files = {} - # HTTP header `Accept` - header_params['Accept'] = self.client.select_header_accept([ - 'application/json', - 'application/yaml', - 'application/vnd.kubernetes.protobuf' - ]) - - # HTTP header `Content-Type` - if params.get('content_type'): - header_params['Content-Type'] = params['content_type'] - else: - header_params['Content-Type'] = self.client.select_header_content_type(['*/*']) - - # Authentication setting - auth_settings = ['BearerToken'] - - return self.client.call_api( - path, - method.upper(), - path_params, - query_params, - header_params, - body=body, - post_params=form_params, - async_req=params.get('async_req'), - files=local_var_files, - auth_settings=auth_settings, - _preload_content=False, - _return_http_data_only=params.get('_return_http_data_only', True) - ) - - def validate(self, definition, version=None, strict=False): - """validate checks a kubernetes resource definition - - Args: - definition (dict): resource definition - version (str): version of kubernetes to validate against - strict (bool): whether unexpected additional properties should be considered errors - - Returns: - warnings (list), errors (list): warnings are missing validations, errors are validation failures - """ - if not HAS_KUBERNETES_VALIDATE: - raise KubernetesValidateMissing() - - errors = list() - warnings = list() try: - if version is None: - try: - version = self.version['kubernetes']['gitVersion'] - except KeyError: - version = kubernetes_validate.latest_version() - kubernetes_validate.validate(definition, version, strict) - except kubernetes_validate.utils.ValidationError as e: - errors.append("resource definition validation error at %s: %s" % ('.'.join([str(item) for item in e.path]), e.message)) # noqa: B306 - except VersionNotSupportedError: - errors.append("Kubernetes version %s is not supported by kubernetes-validate" % version) - except kubernetes_validate.utils.SchemaNotFoundError as e: - warnings.append("Could not find schema for object kind %s with API version %s in Kubernetes version %s (possibly Custom Resource?)" % - (e.kind, e.api_version, e.version)) - return warnings, errors + return apply(resource, body) + except ApplyException as e: + raise ValueError("Could not apply strategic merge to %s/%s: %s" % + (body['kind'], body['metadata']['name'], e)) diff --git a/openshift/dynamic/discovery.py b/openshift/dynamic/discovery.py index 3fe439f3..58fa47fb 100644 --- a/openshift/dynamic/discovery.py +++ b/openshift/dynamic/discovery.py @@ -1,22 +1,31 @@ import os import six -import json import hashlib import tempfile from collections import defaultdict -from abc import abstractmethod, abstractproperty from urllib3.exceptions import ProtocolError, MaxRetryError -from openshift import __version__ -from .exceptions import ResourceNotFoundError, ResourceNotUniqueError, ApiException -from .resource import Resource, ResourceList - +from kubernetes.dynamic.discovery import Discoverer as K8sDiscoverer +from kubernetes.dynamic.discovery import LazyDiscoverer as K8sLazyDiscoverer +from kubernetes.dynamic.discovery import EagerDiscoverer as K8sEagerDiscoverer +from kubernetes.dynamic.discovery import ( # noqa + CacheEncoder, + CacheDecoder, + DISCOVERY_PREFIX, + ResourceGroup, +) +from kubernetes.dynamic.exceptions import ( + ApiException, + ResourceNotFoundError, + ResourceNotUniqueError, + ServiceUnavailableError, +) -DISCOVERY_PREFIX = 'apis' +from .resource import Resource, ResourceList -class Discoverer(object): +class Discoverer(K8sDiscoverer): """ A convenient container for storing discovered API resources. Allows easy searching and retrieval of specific resources. @@ -26,7 +35,7 @@ class Discoverer(object): def __init__(self, client, cache_file): self.client = client - default_cachefile_name = 'osrcp-{0}.json'.format(hashlib.md5(self.__get_default_cache_id()).hexdigest()) + default_cachefile_name = 'osrcp-{0}.json'.format(hashlib.sha1(self.__get_default_cache_id()).hexdigest()) self.__cache_file = cache_file or os.path.join(tempfile.gettempdir(), default_cachefile_name) self.__init_cache() @@ -62,51 +71,6 @@ def __get_user(self): return str(user) return None - def __init_cache(self, refresh=False): - if refresh or not os.path.exists(self.__cache_file): - self._cache = {'library_version': __version__} - refresh = True - else: - try: - with open(self.__cache_file, 'r') as f: - self._cache = json.load(f, cls=CacheDecoder(self.client)) - if self._cache.get('library_version') != __version__: - # Version mismatch, need to refresh cache - self.invalidate_cache() - except Exception: - self.invalidate_cache() - self._load_server_info() - self.discover() - if refresh: - self._write_cache() - - def _write_cache(self): - try: - with open(self.__cache_file, 'w') as f: - json.dump(self._cache, f, cls=CacheEncoder) - except Exception: - # Failing to write the cache shouldn't crash the library - pass - - def invalidate_cache(self): - self.__init_cache(refresh=True) - - @abstractproperty - def api_groups(self): - pass - - @abstractmethod - def search(self, prefix=None, group=None, api_version=None, kind=None, **kwargs): - pass - - @abstractmethod - def discover(self): - pass - - @property - def version(self): - return self.__version - def default_groups(self, request_resources=False): groups = {} groups['api'] = { '': { @@ -124,30 +88,6 @@ def default_groups(self, request_resources=False): }} return groups - def parse_api_groups(self, request_resources=False, update=False): - """ Discovers all API groups present in the cluster """ - if not self._cache.get('resources') or update: - self._cache['resources'] = self._cache.get('resources', {}) - groups_response = self.client.request('GET', '/{}'.format(DISCOVERY_PREFIX)).groups - - groups = self.default_groups(request_resources=request_resources) - - for group in groups_response: - new_group = {} - for version_raw in group['versions']: - version = version_raw['version'] - resource_group = self._cache.get('resources', {}).get(DISCOVERY_PREFIX, {}).get(group['name'], {}).get(version) - preferred = version_raw == group['preferredVersion'] - resources = resource_group.resources if resource_group else {} - if request_resources: - resources = self.get_resources_for_api_version(DISCOVERY_PREFIX, group['name'], version, preferred) - new_group[version] = ResourceGroup(preferred, resources=resources) - groups[DISCOVERY_PREFIX][group['name']] = new_group - self._cache['resources'].update(groups) - self._write_cache() - - return self._cache['resources'] - def _load_server_info(self): def just_json(_, serialized): return serialized @@ -182,12 +122,15 @@ def get_resources_for_api_version(self, prefix, group, version, preferred): subresources = {} path = '/'.join(filter(None, [prefix, group, version])) - resources_response = self.client.request('GET', path).resources or [] + try: + resources_response = self.client.request('GET', path).resources or [] + except ServiceUnavailableError: + 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('/') + resource, name = subresource['name'].split('/', 1) if not subresources.get(resource): subresources[resource] = {} subresources[resource][name] = subresource @@ -245,209 +188,17 @@ def get(self, **kwargs): raise ResourceNotUniqueError('Multiple matches found for {}: {}'.format(kwargs, results)) -class LazyDiscoverer(Discoverer): +class LazyDiscoverer(K8sLazyDiscoverer, Discoverer): """ A convenient container for storing discovered API resources. Allows easy searching and retrieval of specific resources. Resources for the cluster are loaded lazily. """ - def __init__(self, client, cache_file): - Discoverer.__init__(self, client, cache_file) - self.__update_cache = False - - def discover(self): - self.__resources = self.parse_api_groups(request_resources=False) - - def __maybe_write_cache(self): - if self.__update_cache: - self._write_cache() - self.__update_cache = False - - @property - def api_groups(self): - return self.parse_api_groups(request_resources=False, update=True)['apis'].keys() - - def search(self, **kwargs): - results = self.__search(self.__build_search(**kwargs), self.__resources, []) - if not results: - self.invalidate_cache() - results = self.__search(self.__build_search(**kwargs), self.__resources, []) - self.__maybe_write_cache() - return results - - def __search(self, parts, resources, reqParams): - part = parts[0] - if part != '*': - - resourcePart = resources.get(part) - if not resourcePart: - return [] - elif isinstance(resourcePart, ResourceGroup): - if len(reqParams) != 2: - raise ValueError("prefix and group params should be present, have %s" % reqParams) - # Check if we've requested resources for this group - if not resourcePart.resources: - prefix, group, version = reqParams[0], reqParams[1], part - resourcePart.resources = self.get_resources_for_api_version(prefix, - group, part, resourcePart.preferred) - self._cache['resources'][prefix][group][version] = resourcePart - self.__update_cache=True - return self.__search(parts[1:], resourcePart.resources, reqParams) - elif isinstance(resourcePart, dict): - # In this case parts [0] will be a specified prefix, group, version - # as we recurse - return self.__search(parts[1:], resourcePart, reqParams + [part] ) - else: - if parts[1] != '*' and isinstance(parts[1], dict): - for _resource in resourcePart: - for term, value in parts[1].items(): - if getattr(_resource, term) == value: - return [_resource] - - return [] - else: - return resourcePart - else: - matches = [] - for key in resources.keys(): - matches.extend(self.__search([key] + parts[1:], resources, reqParams)) - return matches - - def __build_search(self, prefix=None, group=None, api_version=None, kind=None, **kwargs): - if not group and api_version and '/' in api_version: - group, api_version = api_version.split('/') - items = [prefix, group, api_version, kind, kwargs] - return list(map(lambda x: x or '*', items)) - - def __iter__(self): - for prefix, groups in self.__resources.items(): - for group, versions in groups.items(): - for version, rg in versions.items(): - # Request resources for this groupVersion if we haven't yet - if not rg.resources: - rg.resources = self.get_resources_for_api_version( - prefix, group, version, rg.preferred) - self._cache['resources'][prefix][group][version] = rg - self.__update_cache = True - for _, resource in six.iteritems(rg.resources): - yield resource - self.__maybe_write_cache() - - -class EagerDiscoverer(Discoverer): +class EagerDiscoverer(K8sEagerDiscoverer, Discoverer): """ A convenient container for storing discovered API resources. Allows easy searching and retrieval of specific resources. All resources are discovered for the cluster upon object instantiation. """ - - def update(self, resources): - self.__resources = resources - - def __init__(self, client, cache_file): - Discoverer.__init__(self, client, cache_file) - - def discover(self): - self.__resources = self.parse_api_groups(request_resources=True) - - @property - def api_groups(self): - """ list available api groups """ - return self.parse_api_groups(request_resources=True, update=True)['apis'].keys() - - - 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 - """ - results = self.__search(self.__build_search(**kwargs), self.__resources) - if not results: - self.invalidate_cache() - results = self.__search(self.__build_search(**kwargs), self.__resources) - return results - - def __build_search(self, prefix=None, group=None, api_version=None, kind=None, **kwargs): - if not group and api_version and '/' in api_version: - group, api_version = api_version.split('/') - - items = [prefix, group, api_version, kind, kwargs] - return list(map(lambda x: x or '*', items)) - - def __search(self, parts, resources): - part = parts[0] - resourcePart = resources.get(part) - - if part != '*' and resourcePart: - if isinstance(resourcePart, ResourceGroup): - return self.__search(parts[1:], resourcePart.resources) - elif isinstance(resourcePart, dict): - return self.__search(parts[1:], resourcePart) - else: - if parts[1] != '*' and isinstance(parts[1], dict): - for _resource in resourcePart: - for term, value in parts[1].items(): - if getattr(_resource, term) == value: - return [_resource] - return [] - else: - return resourcePart - elif part == '*': - matches = [] - for key in resources.keys(): - matches.extend(self.__search([key] + parts[1:], resources)) - return matches - return [] - - def __iter__(self): - for _, groups in self.__resources.items(): - for _, versions in groups.items(): - for _, resources in versions.items(): - for _, resource in resources.items(): - yield resource - - -class ResourceGroup(object): - """Helper class for Discoverer container""" - def __init__(self, preferred, resources=None): - self.preferred = preferred - self.resources = resources or {} - - def to_dict(self): - return { - '_type': 'ResourceGroup', - 'preferred': self.preferred, - 'resources': self.resources, - } - - -class CacheEncoder(json.JSONEncoder): - - def default(self, o): - return o.to_dict() - - -class CacheDecoder(json.JSONDecoder): - def __init__(self, client, *args, **kwargs): - self.client = client - json.JSONDecoder.__init__(self, object_hook=self.object_hook, *args, **kwargs) - - def object_hook(self, obj): - if '_type' not in obj: - return obj - _type = obj.pop('_type') - if _type == 'Resource': - return Resource(client=self.client, **obj) - elif _type == 'ResourceList': - return ResourceList(self.client, **obj) - elif _type == 'ResourceGroup': - return ResourceGroup(obj['preferred'], resources=self.object_hook(obj['resources'])) - return obj diff --git a/openshift/dynamic/exceptions.py b/openshift/dynamic/exceptions.py index e16952af..48ddb245 100644 --- a/openshift/dynamic/exceptions.py +++ b/openshift/dynamic/exceptions.py @@ -1,97 +1,5 @@ -import json -import sys -import traceback +from kubernetes.dynamic.exceptions import * # noqa -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 = [str(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) - - def summary(self): - if self.body: - if self.headers and self.headers.get('Content-Type') == 'application/json': - message = json.loads(self.body).get('message') - if message: - return message - - return self.body - else: - return "{} Reason: {}".format(self.status, self.reason) - -class ResourceNotFoundError(Exception): - """ Resource was not found in available APIs """ -class ResourceNotUniqueError(Exception): - """ Parameters given matched multiple API resources """ - -class KubernetesValidateMissing(Exception): - """ kubernetes-validate is not installed """ - -# 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 """ +class ApplyException(Exception): + """ Could not apply patch """ diff --git a/openshift/dynamic/resource.py b/openshift/dynamic/resource.py index 5b0cdec7..63b000b3 100644 --- a/openshift/dynamic/resource.py +++ b/openshift/dynamic/resource.py @@ -1,93 +1,4 @@ -import copy -import yaml -from functools import partial - -from pprint import pformat - - -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, subresources=None, **kwargs): - - if None in (api_version, kind, prefix): - raise ValueError("At least prefix, kind, and api_version must be provided") - - self.prefix = prefix - self.group = group - self.api_version = api_version - self.kind = kind - self.namespaced = namespaced - self.verbs = verbs - self.name = name - self.preferred = preferred - self.client = client - self.singular_name = singularName or (name[:-1] if name else "") - self.short_names = shortNames - self.categories = categories - self.subresources = { - k: Subresource(self, **v) for k, v in (subresources or {}).items() - } - - self.extra_args = kwargs - - def to_dict(self): - return { - '_type': 'Resource', - 'prefix': self.prefix, - 'group': self.group, - 'api_version': self.api_version, - 'kind': self.kind, - 'namespaced': self.namespaced, - 'verbs': self.verbs, - 'name': self.name, - 'preferred': self.preferred, - 'singular_name': self.singular_name, - 'short_names': self.short_names, - 'categories': self.categories, - 'subresources': {k: sr.to_dict() for k, sr in self.subresources.items()}, - 'extra_args': self.extra_args, - } - - @property - def group_version(self): - if self.group: - return '{}/{}'.format(self.group, self.api_version) - return self.api_version - - def __repr__(self): - return '<{}({}/{})>'.format(self.__class__.__name__, self.group_version, self.name) - - @property - def urls(self): - full_prefix = '{}/{}'.format(self.prefix, self.group_version) - resource_name = self.name.lower() - return { - 'base': '/{}/{}'.format(full_prefix, resource_name), - 'namespaced_base': '/{}/namespaces/{{namespace}}/{}'.format(full_prefix, resource_name), - 'full': '/{}/{}/{{name}}'.format(full_prefix, resource_name), - 'namespaced_full': '/{}/namespaces/{{namespace}}/{}/{{name}}'.format(full_prefix, resource_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) +from kubernetes.dynamic.resource import Resource, Subresource, ResourceField # noqa class ResourceList(Resource): @@ -110,92 +21,6 @@ def base_resource(self): return self.__base_resource return None - def _items_to_resources(self, body): - """ Takes a List body and return a dictionary with the following structure: - { - 'api_version': str, - 'kind': str, - 'items': [{ - 'resource': Resource, - 'name': str, - 'namespace': str, - }] - } - """ - if body is None: - raise ValueError("You must provide a body when calling methods on a ResourceList") - - api_version = body['apiVersion'] - kind = body['kind'] - items = body.get('items') - if not items: - raise ValueError('The `items` field in the body must be populated when calling methods on a ResourceList') - - if self.kind != kind: - raise ValueError('Methods on a {} must be called with a body containing the same kind. Receieved {} instead'.format(self.kind, kind)) - - return { - 'api_version': api_version, - 'kind': kind, - 'items': [self._item_to_resource(item) for item in items] - } - - def _item_to_resource(self, item): - metadata = item.get('metadata', {}) - resource = self.base_resource() - if not resource: - api_version = item.get('apiVersion', self.api_version) - kind = item.get('kind', self.base_kind) - resource = self.client.resources.get(api_version=api_version, kind=kind) - return { - 'resource': resource, - 'definition': item, - 'name': metadata.get('name'), - 'namespace': metadata.get('namespace') - } - - def get(self, body, name=None, namespace=None, **kwargs): - if name: - raise ValueError('Operations on ResourceList objects do not support the `name` argument') - resource_list = self._items_to_resources(body) - response = copy.deepcopy(body) - - response['items'] = [ - item['resource'].get(name=item['name'], namespace=item['namespace'] or namespace, **kwargs).to_dict() - for item in resource_list['items'] - ] - return ResourceInstance(self, response) - - def delete(self, body, name=None, namespace=None, **kwargs): - if name: - raise ValueError('Operations on ResourceList objects do not support the `name` argument') - resource_list = self._items_to_resources(body) - response = copy.deepcopy(body) - - response['items'] = [ - item['resource'].delete(name=item['name'], namespace=item['namespace'] or namespace, **kwargs).to_dict() - for item in resource_list['items'] - ] - return ResourceInstance(self, response) - - def verb_mapper(self, verb, body, **kwargs): - resource_list = self._items_to_resources(body) - response = copy.deepcopy(body) - response['items'] = [ - getattr(item['resource'], verb)(body=item['definition'], **kwargs).to_dict() - for item in resource_list['items'] - ] - return ResourceInstance(self, response) - - def create(self, *args, **kwargs): - return self.verb_mapper('create', *args, **kwargs) - - def replace(self, *args, **kwargs): - return self.verb_mapper('replace', *args, **kwargs) - - def patch(self, *args, **kwargs): - return self.verb_mapper('patch', *args, **kwargs) - def apply(self, *args, **kwargs): return self.verb_mapper('apply', *args, **kwargs) @@ -215,54 +40,6 @@ def __getattr__(self, name): return None -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 - - #TODO(fabianvf): Determine proper way to handle differences between resources + subresources - def create(self, body=None, name=None, namespace=None, **kwargs): - name = name or body.get('metadata', {}).get('name') - body = self.parent.client.serialize_body(body) - if self.parent.namespaced: - namespace = self.parent.client.ensure_namespace(self.parent, namespace, body) - path = self.path(name=name, namespace=namespace) - return self.parent.client.request('post', path, body=body, **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) - - def to_dict(self): - return { - 'kind': self.kind, - 'name': self.name, - 'subresource': self.subresource, - 'namespaced': self.namespaced, - 'verbs': self.verbs, - 'extra_args': self.extra_args, - } - - class ResourceInstance(object): """ A parsed instance of an API resource. It exists solely to ease interaction with API objects by allowing attributes to @@ -276,101 +53,12 @@ def __init__(self, client, instance): kind = instance['kind'] if kind.endswith('List') and 'items' in instance: kind = instance['kind'][:-4] - for item in instance['items']: - if 'apiVersion' not in item: - item['apiVersion'] = instance['apiVersion'] - if 'kind' not in item: - item['kind'] = kind + if instance['items']: + for item in instance['items']: + if 'apiVersion' not in item: + item['apiVersion'] = instance['apiVersion'] + if 'kind' not in item: + item['kind'] = kind self.attributes = self.__deserialize(instance) self.__initialised = True - - def __deserialize(self, field): - if isinstance(field, dict): - return ResourceField(**{ - k: self.__deserialize(v) for k, v in field.items() - }) - elif isinstance(field, (list, tuple)): - return [self.__deserialize(item) for item in field] - else: - return field - - def __serialize(self, field): - if isinstance(field, ResourceField): - return { - k: self.__serialize(v) for k, v in field.__dict__.items() - } - elif isinstance(field, (list, tuple)): - return [self.__serialize(item) for item in field] - elif isinstance(field, ResourceInstance): - return field.to_dict() - else: - return field - - def to_dict(self): - return self.__serialize(self.attributes) - - def to_str(self): - return repr(self) - - def __repr__(self): - return "ResourceInstance[{}]:\n {}".format( - self.attributes.kind, - ' '.join(yaml.safe_dump(self.to_dict()).splitlines(True)) - ) - - def __getattr__(self, name): - if not '_ResourceInstance__initialised' in self.__dict__: - return super(ResourceInstance, self).__getattr__(name) - return getattr(self.attributes, name) - - def __setattr__(self, name, value): - if not '_ResourceInstance__initialised' in self.__dict__: - return super(ResourceInstance, self).__setattr__(name, value) - elif name in self.__dict__: - return super(ResourceInstance, self).__setattr__(name, value) - else: - self.attributes[name] = value - - def __getitem__(self, name): - return self.attributes[name] - - def __setitem__(self, name, value): - self.attributes[name] = value - - def __dir__(self): - return dir(type(self)) + list(self.attributes.__dict__.keys()) - - -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) - - def __repr__(self): - return pformat(self.__dict__) - - def __eq__(self, other): - return self.__dict__ == other.__dict__ - - def __getitem__(self, name): - return self.__dict__.get(name) - - # Here resource.items will return items if available or resource.__dict__.items function if not - # resource.get will call resource.__dict__.get after attempting resource.__dict__.get('get') - def __getattr__(self, name): - return self.__dict__.get(name, getattr(self.__dict__, name, None)) - - 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) diff --git a/openshift/helper/hashes.py b/openshift/helper/hashes.py index 4df7dc5b..e331140a 100644 --- a/openshift/helper/hashes.py +++ b/openshift/helper/hashes.py @@ -30,11 +30,11 @@ def generate_hash(resource): resource['name'] = resource.get('metadata', {}).get('name', '') if resource['kind'] == 'ConfigMap': marshalled = marshal(sorted_dict(resource), ['data', 'kind', 'name']) - del(resource['name']) + del (resource['name']) return encode(marshalled) if resource['kind'] == 'Secret': marshalled = marshal(sorted_dict(resource), ['data', 'kind', 'name', 'type']) - del(resource['name']) + del (resource['name']) return encode(marshalled) raise NotImplementedError diff --git a/openshift/helper/userpassauth.py b/openshift/helper/userpassauth.py new file mode 100644 index 00000000..07759f9b --- /dev/null +++ b/openshift/helper/userpassauth.py @@ -0,0 +1,129 @@ +# OpenShift User-Password Login helper module +# This module extends the `kubernetes.client.Configuration` object. +# `OCPLoginConfiguration` uses `username` and `password` params to authenticate +# with the OAuth OpenShift component, after the autentication the `api_key` value is set +# with the Bearer token. +# +# IMPORTANT: the Bearer token is designed to expire, si up to the user to renew the token. +# the valitity (in secods) is saved into the `token['expires_in`]` attribute. +# The value refers to when the token has been created and to not change overtime. + +# Related discussion on GitHub https://github.com/openshift/openshift-restclient-python/issues/249 +# Most part of the code has been taken from the k8s_auth ansible module: +# https://github.com/ansible/ansible/blob/stable-2.9/lib/ansible/modules/clustering/k8s/k8s_auth.py + +import requests +from requests_oauthlib import OAuth2Session +from urllib3.util import make_headers +from urllib.parse import parse_qs, urlencode, urlparse +from kubernetes.client import Configuration as KubeConfig + +class OCPLoginException(Exception): + """The base class for the OCPLogin exceptions""" + +class OCPLoginRequestException(OCPLoginException): + def __init__(self, msg, **kwargs): + self.msg = msg + self.req_info = {} + for k, v in kwargs.items(): + self.req_info['req_' + k] = v + + def __str__(self): + error_msg = self.msg + for k, v in self.req_info.items(): + error_msg += '\t{0}: {1}\n'.format(k, v) + return error_msg + +class OCPLoginConfiguration(KubeConfig): + def __init__(self, host="http://localhost", + api_key=None, api_key_prefix=None, + ocp_username=None, ocp_password=None, + discard_unknown_keys=False, + ): + + self.ocp_username = ocp_username + self.ocp_password = ocp_password + + super(OCPLoginConfiguration, self).__init__(host=host, api_key=None, + api_key_prefix=None, username=None, + password=None, discard_unknown_keys=discard_unknown_keys) + + def get_token(self): + # python-requests takes either a bool or a path to a ca file as the 'verify' param + if self.verify_ssl and self.ssl_ca_cert: + self.con_verify_ca = self.ssl_ca_cert # path + else: + self.con_verify_ca = self.verify_ssl # bool + + self.discover() + self.token = self.login() + self.api_key = {"authorization": "Bearer " + self.token['access_token']} + self.api_key_expires = self.token['expires_in'] + self.api_key_scope = self.token['scope'] + + def discover(self): + url = '{0}/.well-known/oauth-authorization-server'.format(self.host) + ret = requests.get(url, verify=self.con_verify_ca) + + if ret.status_code != 200: + raise OCPLoginRequestException("Couldn't find OpenShift's OAuth API", method='GET', url=url, + reason=ret.reason, status_code=ret.status_code) + + oauth_info = ret.json() + self.openshift_auth_endpoint = oauth_info['authorization_endpoint'] + self.openshift_token_endpoint = oauth_info['token_endpoint'] + + def login(self): + os_oauth = OAuth2Session(client_id='openshift-challenging-client') + authorization_url, state = os_oauth.authorization_url(self.openshift_auth_endpoint, + state="1", code_challenge_method='S256') + auth_headers = make_headers(basic_auth='{0}:{1}'.format(self.ocp_username, self.ocp_password)) + # Request authorization code using basic auth credentials + ret = os_oauth.get( + authorization_url, + headers={'X-Csrf-Token': state, 'authorization': auth_headers.get('authorization')}, + verify=self.con_verify_ca, + allow_redirects=False + ) + + if ret.status_code != 302: + raise OCPLoginRequestException("Authorization failed.", method='GET', url=authorization_url, + reason=ret.reason, status_code=ret.status_code) + + qwargs = {} + for k, v in parse_qs(urlparse(ret.headers['Location']).query).items(): + qwargs[k] = v[0] + qwargs['grant_type'] = 'authorization_code' + + # Using authorization code given to us in the Location header of the previous request, request a token + ret = os_oauth.post( + self.openshift_token_endpoint, + headers={ + 'Accept': 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + # This is just base64 encoded 'openshift-challenging-client:' + 'Authorization': 'Basic b3BlbnNoaWZ0LWNoYWxsZW5naW5nLWNsaWVudDo=' + }, + + data=urlencode(qwargs), + verify=self.con_verify_ca + ) + if ret.status_code != 200: + raise OCPLoginRequestException("Failed to obtain an authorization token.", method='POST', + url=self.openshift_token_endpoint, + reason=ret.reason, status_code=ret.status_code) + return ret.json() + + def logout(self): + url = '{0}/apis/oauth.openshift.io/v1/oauthaccesstokens/{1}'.format(self.host, self.api_key) + headers = { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'Authorization': 'Bearer {0}'.format(self.api_key) + } + json = { + "apiVersion": "oauth.openshift.io/v1", + "kind": "DeleteOptions" + } + requests.delete(url, headers=headers, json=json, verify=self.con_verify_ca) + # Ignore errors, the token will time out eventually anyway diff --git a/python-openshift.spec b/python-openshift.spec index b3e83a9b..76f94435 100644 --- a/python-openshift.spec +++ b/python-openshift.spec @@ -35,7 +35,6 @@ BuildRequires: python-setuptools BuildRequires: git Requires: python2 -Requires: python2-dictdiffer Requires: python2-kubernetes Requires: python2-string_utils Requires: python-requests @@ -55,7 +54,6 @@ BuildRequires: %{py3}-setuptools BuildRequires: git Requires: %{py3} -Requires: %{py3}-dictdiffer Requires: %{py3}-kubernetes Requires: %{py3}-string_utils Requires: %{py3}-requests @@ -92,7 +90,7 @@ Python client for the OpenShift API #the requirements are also done in an non-backwards compatible way %if 0%{?rhel} sed -i -e "s/find_packages(include='openshift.*')/['openshift', 'openshift.dynamic', 'openshift.helper']/g" setup.py -sed -i -e '30s/^/REQUIRES = [\n "dictdiffer",\n "jinja2",\n "kubernetes",\n "setuptools",\n "six",\n "ruamel.yaml",\n "python-string-utils",\n]\n/g' setup.py +sed -i -e '30s/^/REQUIRES = [\n "jinja2",\n "kubernetes",\n "setuptools",\n "six",\n "ruamel.yaml",\n "python-string-utils",\n]\n/g' setup.py sed -i -e "s/extract_requirements('requirements.txt')/REQUIRES/g" setup.py #sed -i -e '14,21d' setup.py %endif diff --git a/requirements.txt b/requirements.txt index a4bf15be..88b960cb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,7 @@ -dictdiffer jinja2 kubernetes python-string-utils ruamel.yaml six +requests +requests-oauthlib diff --git a/setup.py b/setup.py index d2b794c1..0def000f 100644 --- a/setup.py +++ b/setup.py @@ -48,10 +48,6 @@ def extract_requirements(filename): keywords=["Swagger", "OpenAPI", "Kubernetes", "OpenShift"], install_requires=extract_requirements('requirements.txt'), packages=find_packages(include='openshift.*'), - include_package_data=True, - data_files=[ - ('requirements.txt', ['requirements.txt']) - ], long_description='Python client for OpenShift http://openshift.redhat.com/', classifiers=[ "Development Status :: %s" % DEVELOPMENT_STATUS, @@ -66,7 +62,4 @@ def extract_requirements(filename): "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.5", ], - entry_points={ - 'console_scripts': ['openshift-ansible-gen = openshift.ansiblegen.cli:commandline'] - }, ) diff --git a/test-requirements.txt b/test-requirements.txt index 0f7331e6..f817bdfa 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -1,8 +1,4 @@ coverage -docker flake8 pytest -pytest-bdd pytest-cov -PyYAML -dictdiffer diff --git a/test/Dockerfile b/test/Dockerfile new file mode 100644 index 00000000..c872f814 --- /dev/null +++ b/test/Dockerfile @@ -0,0 +1,33 @@ +FROM registry.access.redhat.com/ubi8/ubi + +ENV USER_UID=1001 \ + USER_NAME=openshift-python\ + HOME=/opt/osrcp + +RUN yum install -y \ + glibc-langpack-en \ + git \ + make \ + python3 \ + python3-devel \ + python3-pip \ + python3-setuptools \ + && pip3 install --no-cache-dir --upgrade setuptools pip wheel \ + && yum clean all + +COPY . /opt/osrcp + +WORKDIR /opt/osrcp + + +RUN pip install -e . && \ + pip install -r test-requirements.txt + +RUN echo "${USER_NAME}:x:${USER_UID}:0:${USER_NAME} user:${HOME}:/sbin/nologin" >> /etc/passwd \ + && chown -R "${USER_UID}:0" "${HOME}" \ + && chmod -R ug+rwX "${HOME}" \ + && mkdir /go \ + && chown -R "${USER_UID}:0" /go \ + && chmod -R ug+rwX /go + +USER ${USER_UID} diff --git a/test/functional/conftest.py b/test/functional/conftest.py deleted file mode 100644 index 10c06a85..00000000 --- a/test/functional/conftest.py +++ /dev/null @@ -1,184 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import absolute_import -from __future__ import print_function - -import io -import os -import copy -import time -import socket -import tarfile -import warnings -from contextlib import closing - -import json -import docker -import pytest -import requests - -from urllib3.exceptions import InsecureRequestWarning - -import kubernetes -from openshift.dynamic import DynamicClient - - -if os.path.exists(os.path.join(os.getcwd(), 'KubeObjHelper.log')): - os.remove(os.path.join(os.getcwd(), 'KubeObjHelper.log')) - - - -@pytest.fixture(scope='session') -def port(): - with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s: - s.bind(('', 0)) - return s.getsockname()[1] - - -@pytest.fixture(scope='session') -def openshift_container(request, port, pytestconfig): - capmanager = pytestconfig.pluginmanager.getplugin('capturemanager') - client = docker.from_env() - openshift_version = request.config.getoption('--openshift-version') - if openshift_version is None: - yield None - else: - container = client.containers.run( - 'openshift/origin:{}'.format(openshift_version), - 'start master --listen=0.0.0.0:{}'.format(port), - detach=True, - ports={port: port} - ) - - try: - # Wait for the container to no longer be in the created state before - # continuing - while container.status == u'created': - capmanager.suspend() - print("\nWaiting for container...") - capmanager.resume() - time.sleep(5) - container = client.containers.get(container.id) - - # Disable InsecureRequest warnings because we can't get the cert yet - warnings.simplefilter('ignore', InsecureRequestWarning) - # Wait for the api server to be ready before continuing - for _ in range(10): - try: - requests.head("https://127.0.0.1:{}/healthz/ready".format(port), verify=False) - except requests.RequestException: - pass - time.sleep(1) - warnings.simplefilter('default', InsecureRequestWarning) - - time.sleep(1) - - yield container - finally: - # Always remove the container - container.remove(force=True) - - -@pytest.fixture(scope='session') -def kubeconfig(openshift_container, tmpdir_factory): - # get_archive returns a stream of the tar archive containing the requested - # files/directories, so we need use BytesIO as an intermediate step. - if openshift_container is None: - return None - else: - openshift_container.exec_run('oc login -u test -p test') - tar_stream, _ = openshift_container.get_archive( - '/var/lib/origin/openshift.local.config/master/admin.kubeconfig') - - tar_bytes = io.BytesIO() - for chunk in tar_stream: - tar_bytes.write(chunk) - tar_bytes.seek(0) - - tar_obj = tarfile.open(fileobj=tar_bytes) - kubeconfig_contents = tar_obj.extractfile('admin.kubeconfig').read() - - kubeconfig_file = tmpdir_factory.mktemp('kubeconfig').join('admin.kubeconfig') - kubeconfig_file.write(kubeconfig_contents) - return kubeconfig_file - - -@pytest.fixture(scope='session') -def admin_kubeconfig(openshift_container, tmpdir_factory): - # get_archive returns a stream of the tar archive containing the requested - # files/directories, so we need use BytesIO as an intermediate step. - if openshift_container is None: - return None - else: - openshift_container.exec_run('oc login -u system:admin') - tar_stream, _ = openshift_container.get_archive( - '/var/lib/origin/openshift.local.config/master/admin.kubeconfig') - - tar_bytes = io.BytesIO() - for chunk in tar_stream: - tar_bytes.write(chunk) - tar_bytes.seek(0) - - tar_obj = tarfile.open(fileobj=tar_bytes) - kubeconfig_contents = tar_obj.extractfile('admin.kubeconfig').read() - - kubeconfig_file = tmpdir_factory.mktemp('kubeconfig').join('admin.kubeconfig') - kubeconfig_file.write(kubeconfig_contents) - return kubeconfig_file - - -@pytest.fixture(scope='session') -def obj_compare(): - def compare_func(ansible_helper, k8s_obj, parameters): - """ Assert that an object matches an expected object """ - requested = copy.deepcopy(k8s_obj) - ansible_helper.object_from_params(parameters, obj=requested) - - ansible_helper.log('paramters:') - ansible_helper.log(json.dumps(parameters, indent=4)) - ansible_helper.log('\n\n') - - ansible_helper.log('k8s_obj:') - ansible_helper.log(k8s_obj.to_str()) - ansible_helper.log('\n\n') - - ansible_helper.log('from params:') - ansible_helper.log(requested.to_str()) - ansible_helper.log('\n\n') - - match, diff = ansible_helper.objects_match(k8s_obj, requested) - if not match: - ansible_helper.log('\n\n') - ansible_helper.log('Differences:') - ansible_helper.log(json.dumps(diff, indent=4)) - ansible_helper.log('\n\n') - assert match - - return compare_func - - -@pytest.fixture(scope='class') -def auth(request, kubeconfig, admin_kubeconfig, port): - # needs_admin = request.node.cls.tasks.get('admin') - needs_admin = True - config = admin_kubeconfig if needs_admin else kubeconfig - if config is not None: - return { - 'kubeconfig': str(config), - 'host': 'https://localhost:{}'.format(port), - 'verify_ssl': False - } - - return {} - - -@pytest.fixture -def openshift_version(): - return pytest.config.getoption('--openshift-version') - - -@pytest.fixture(scope='session') -def admin_client(admin_kubeconfig, port): - k8s_client = kubernetes.config.new_client_from_config(str(admin_kubeconfig)) - # k8s_client.configuration.verify_ssl = False - k8s_client.configuration.host = 'https://localhost:{}'.format(port) - return DynamicClient(k8s_client) diff --git a/test/functional/dynamic/conftest.py b/test/functional/dynamic/conftest.py deleted file mode 100644 index 69b8e989..00000000 --- a/test/functional/dynamic/conftest.py +++ /dev/null @@ -1,277 +0,0 @@ -import os -import time -import yaml - -import pytest -from pytest_bdd import ( - given, - then, - when, - parsers -) - -import kubernetes - -from openshift.dynamic import DynamicClient, ResourceList, exceptions - - -def object_contains(obj, subset): - def dict_is_subset(obj, subset): - return all([mapping.get(type(obj[k]), mapping['default'])(obj[k], v) for (k, v) in subset.items()]) - - def list_is_subset(obj, subset): - return all([mapping.get(type(obj[i]), mapping['default'])(obj[i], v) for (i, v) in enumerate(subset)]) - - def values_match(obj, subset): - return obj == subset - - mapping = { - dict: dict_is_subset, - list: list_is_subset, - tuple: list_is_subset, - 'default': values_match - } - - return dict_is_subset(obj, subset) - - -def load_definition(filename): - path = os.path.join(os.path.dirname(__file__), filename) - with open(path, 'r') as f: - return yaml.load(f.read()) - - -@pytest.fixture -def definition(filename): - return load_definition(filename) - - -@pytest.fixture -def context(): - state = {} - return state - - -def ensure_resource(resource, body, namespace): - try: - return resource.create(body=body, namespace=namespace) - except exceptions.ConflictError: - if isinstance(resource, ResourceList): - return resource.get(body=body, namespace=namespace) - return resource.get(name=body['metadata']['name'], namespace=namespace) - except exceptions.InternalServerError as e: - error = yaml.load(e.body) - retry_after_seconds = error['details'].get('retryAfterSeconds') - if not retry_after_seconds: - raise - time.sleep(retry_after_seconds) - return resource.create(body=body, namespace=namespace) - except Exception: - # TODO: Sometimes we hit a timing issue, for now I'll just sleep - time.sleep(3) - return resource.create(body=body, namespace=namespace) - - -@given(parsers.parse('The content of does not exist in ')) -def ensure_resource_not_in_namespace(admin_client, namespace, definition): - resource = admin_client.resources.get(api_version=definition['apiVersion'], kind=definition['kind']) - try: - if isinstance(resource, ResourceList): - resource.delete(body=definition, namespace=namespace) - else: - resource.delete(definition['metadata']['name'], namespace) - except exceptions.NotFoundError: - pass - - -@given(parsers.parse('The content of exists in ')) -def ensure_resource_in_namespace(admin_client, namespace, definition): - resource = admin_client.resources.get(api_version=definition['apiVersion'], kind=definition['kind']) - instance = ensure_resource(resource, definition, namespace) - assert instance is not None - - -@given(parsers.parse('I have created in ')) -def ensure_user_resource_in_namespace(client, namespace, definition): - """I have created in .""" - resource = client.resources.get(api_version=definition['apiVersion'], kind=definition['kind']) - instance = ensure_resource(resource, definition, namespace) - assert instance is not None - - -def ensure_namespace(admin_client, namespace): - v1_projects = admin_client.resources.get(api_version='project.openshift.io/v1', kind="Project") - try: - project = v1_projects.get(name=namespace) - except exceptions.NotFoundError: - project = v1_projects.create({"apiVersion": "project.openshift.io/v1", "kind": "Project", "metadata": {"name": namespace}}) - if project.metadata.DeletionTimestamp: - # Wait up to 5 minutes for project to terminate - for _ in range(300): - try: - project = v1_projects.get(name=namespace) - except exceptions.NotFoundError: - project = v1_projects.create({"apiVersion": "project.openshift.io/v1", "kind": "Project", "metadata": {"name": namespace}}) - return project - return project - - - -@given(parsers.parse('I have {clusterrole} permissions in ')) -def client(clusterrole, namespace, kubeconfig, port, admin_client): - """I have permissions in .""" - needs_admin = namespace == 'all namespaces' - if needs_admin: - return admin_client - - ensure_namespace(admin_client, namespace) - - k8s_client = kubernetes.config.new_client_from_config(str(kubeconfig)) - k8s_client.configuration.host = 'https://localhost:{}'.format(port) - - v1_tkr = admin_client.resources.get(api_version='authentication.k8s.io/v1', kind='TokenReview') - - username = v1_tkr.create({ - "apiVersion": "authentication.k8s.io/v1", - "kind": "TokenReview", - "spec": { - "token": k8s_client.configuration.api_key['authorization'].replace('Bearer ', '') - } - }).status.user.username - - - role_binding = { - "kind": "RoleBinding", - "apiVersion": "rbac.authorization.k8s.io/v1", - "metadata": { - "name": "{}-{}".format(username, clusterrole), - "namespace": namespace, - }, - "roleRef": { - "kind": "ClusterRole", - "name": clusterrole, - "apiGroup": "rbac.authorization.k8s.io", - }, - "subjects": [{ - "kind": "User", - "name": username, - "apiGroup": "rbac.authorization.k8s.io", - }] - } - rbs = admin_client.resources.get(kind='RoleBinding', api_version='rbac.authorization.k8s.io/v1') - try: - rbs.create(role_binding) - except exceptions.ConflictError: - pass - - return DynamicClient(k8s_client) - - -@when(parsers.parse('I {action} with in ')) -def perform_update_action_in_namespace(context, client, action, namespace, definition, update): - - def set_resource_version(r, resource): - if isinstance(resource, ResourceList): - for i, item in enumerate(r['items']): - base_resource = resource._item_to_resource(item)['resource'] - r['items'][i] = set_resource_version(item, base_resource) - return r - try: - r['metadata']['resourceVersion'] = resource.get(r['metadata']['name'], namespace=namespace).metadata.resourceVersion - except exceptions.NotFoundError: - r['metadata']['resourceVersion'] = "0" - return r - - fail = not action.startswith('try to ') - action = action.replace('try to ', '') - resource = client.resources.get(api_version=definition['apiVersion'], kind=definition['kind']) - try: - if action == 'replace': - replace = load_definition(update) - replace = set_resource_version(replace, resource) - context['instance'] = resource.replace(body=replace, namespace=namespace) - elif action == 'patch': - patch = load_definition(update) - context['instance'] = resource.patch(body=patch, namespace=namespace) - else: - fail = True - raise ValueError('Unable to {action} resource!'.format(action)) - except Exception as e: - context['exc'] = e - if fail: - raise - - -@when(parsers.parse('I {action} in ')) -def perform_action_in_namespace(context, client, action, namespace, definition): - fail = not action.startswith('try to ') - action = action.replace('try to ', '') - resource = client.resources.get(api_version=definition['apiVersion'], kind=definition['kind']) - try: - if action == 'create': - context['instance'] = resource.create(body=definition, namespace=namespace) - elif action == 'delete': - if isinstance(resource, ResourceList): - context['instance'] = resource.delete(body=definition, namespace=namespace) - else: - context['instance'] = resource.delete(name=definition['metadata']['name'], namespace=namespace) - elif action == 'get': - if isinstance(resource, ResourceList): - context['instance'] = resource.get(body=definition, namespace=namespace) - else: - context['instance'] = resource.get(name=definition['metadata']['name'], namespace=namespace) - else: - fail = True - raise ValueError('Unable to {action} resource!'.format(action)) - except Exception as e: - context['exc'] = e - if fail: - raise - - -@then(parsers.parse('It throws a {error}')) -def it_throws_an_error(context, error): - """It throws an error""" - assert isinstance(context['exc'], getattr(exceptions, error)) - - -@then('The resources in in should match the content of ') -def resource_should_match_update(admin_client, namespace, update, definition): - update = load_definition(update) - resource = admin_client.resources.get(api_version=definition['apiVersion'], kind=definition['kind']) - if isinstance(resource, ResourceList): - cluster_instance = resource.get(body=definition, namespace=namespace).to_dict() - else: - cluster_instance = resource.get(name=definition['metadata']['name'], namespace=namespace).to_dict() - assert object_contains(cluster_instance, update) - - -@then(parsers.parse('The content of does not exist in ')) -def resource_not_in_namespace(admin_client, namespace, definition): - - def assert_resource_does_not_exist(r, resource, namespace): - if isinstance(resource, ResourceList): - for item in r['items']: - resource = resource._item_to_resource(item)['resource'] - return assert_resource_does_not_exist(item, resource, namespace) - with pytest.raises(exceptions.NotFoundError): - resource.get(r['metadata']['name'], namespace) - - resource = admin_client.resources.get(api_version=definition['apiVersion'], kind=definition['kind']) - assert_resource_does_not_exist(definition, resource, namespace) - - -@then(parsers.parse('The content of exists in ')) -def resource_in_namespace(admin_client, namespace, definition): - - def assert_resource_exists(r, resource, namespace): - if isinstance(resource, ResourceList): - for item in r['items']: - base_resource = resource._item_to_resource(item)['resource'] - return assert_resource_exists(item, base_resource, namespace) - instance = resource.get(r['metadata']['name'], namespace) - assert instance.metadata.name == r['metadata']['name'] - assert instance.metadata.namespace == namespace - - resource = admin_client.resources.get(api_version=definition['apiVersion'], kind=definition['kind']) - assert_resource_exists(definition, resource, namespace) diff --git a/test/functional/dynamic/create.feature b/test/functional/dynamic/create.feature deleted file mode 100644 index ce66bac7..00000000 --- a/test/functional/dynamic/create.feature +++ /dev/null @@ -1,20 +0,0 @@ -Feature: Create - - Examples: - | filename | namespace | - | definitions/v1_Pod_test.yaml | test-create | - | definitions/v1_PodList_test.yaml | test-create2 | - | definitions/v1_List_test.yaml | test-create | - - - Scenario Outline: Create a resource for the first time - Given I have edit permissions in - And The content of does not exist in - When I create in - Then The content of exists in - - Scenario Outline: Create a resource again - Given I have edit permissions in - And I have created in - When I try to create in - Then It throws a ConflictError diff --git a/test/functional/dynamic/definitions/route.openshift.io_v1_RouteList_test.yaml b/test/functional/dynamic/definitions/route.openshift.io_v1_RouteList_test.yaml deleted file mode 100644 index c3502bd6..00000000 --- a/test/functional/dynamic/definitions/route.openshift.io_v1_RouteList_test.yaml +++ /dev/null @@ -1,17 +0,0 @@ - ---- -apiVersion: route.openshift.io/v1 -kind: RouteList -items: -- metadata: - name: test-route - spec: - to: - name: test - kind: Service -- metadata: - name: test-route2 - spec: - to: - name: test2 - kind: Service diff --git a/test/functional/dynamic/definitions/route.openshift.io_v1_RouteList_test_replace.yaml b/test/functional/dynamic/definitions/route.openshift.io_v1_RouteList_test_replace.yaml deleted file mode 100644 index 3c94253f..00000000 --- a/test/functional/dynamic/definitions/route.openshift.io_v1_RouteList_test_replace.yaml +++ /dev/null @@ -1,16 +0,0 @@ ---- -apiVersion: route.openshift.io/v1 -kind: RouteList -items: -- metadata: - name: test-route - spec: - to: - name: test3 - kind: Service -- metadata: - name: test-route2 - spec: - to: - name: test4 - kind: Service diff --git a/test/functional/dynamic/definitions/route.openshift.io_v1_Route_test.yaml b/test/functional/dynamic/definitions/route.openshift.io_v1_Route_test.yaml deleted file mode 100644 index aa1012c9..00000000 --- a/test/functional/dynamic/definitions/route.openshift.io_v1_Route_test.yaml +++ /dev/null @@ -1,9 +0,0 @@ ---- -apiVersion: route.openshift.io/v1 -kind: Route -metadata: - name: test -spec: - to: - name: test - kind: Service diff --git a/test/functional/dynamic/definitions/route.openshift.io_v1_Route_test_replace.yaml b/test/functional/dynamic/definitions/route.openshift.io_v1_Route_test_replace.yaml deleted file mode 100644 index 9e3718c2..00000000 --- a/test/functional/dynamic/definitions/route.openshift.io_v1_Route_test_replace.yaml +++ /dev/null @@ -1,9 +0,0 @@ ---- -apiVersion: route.openshift.io/v1 -kind: Route -metadata: - name: test -spec: - to: - name: test2 - kind: Service diff --git a/test/functional/dynamic/definitions/v1_List_test.yaml b/test/functional/dynamic/definitions/v1_List_test.yaml deleted file mode 100644 index 2798d975..00000000 --- a/test/functional/dynamic/definitions/v1_List_test.yaml +++ /dev/null @@ -1,16 +0,0 @@ ---- -apiVersion: v1 -kind: List -items: -- kind: ConfigMap - apiVersion: v1 - metadata: - name: test-config1 - data: - index: "1" -- kind: ConfigMap - apiVersion: v1 - metadata: - name: test-config2 - data: - index: "2" diff --git a/test/functional/dynamic/definitions/v1_List_test_replace.yaml b/test/functional/dynamic/definitions/v1_List_test_replace.yaml deleted file mode 100644 index cdc32d61..00000000 --- a/test/functional/dynamic/definitions/v1_List_test_replace.yaml +++ /dev/null @@ -1,16 +0,0 @@ ---- -apiVersion: v1 -kind: List -items: -- kind: ConfigMap - apiVersion: v1 - metadata: - name: test-config1 - data: - overwritten: "true" -- kind: ConfigMap - apiVersion: v1 - metadata: - name: test-config2 - data: - overwritten: "true" diff --git a/test/functional/dynamic/definitions/v1_PodList_test.yaml b/test/functional/dynamic/definitions/v1_PodList_test.yaml deleted file mode 100644 index eb72f582..00000000 --- a/test/functional/dynamic/definitions/v1_PodList_test.yaml +++ /dev/null @@ -1,33 +0,0 @@ -apiVersion: v1 -items: -- apiVersion: v1 - kind: Pod - metadata: - name: example-app2 - spec: - containers: - - args: - - "3600" - command: - - sleep - image: busybox:latest - imagePullPolicy: Always - name: example-app - resources: {} - restartPolicy: Always -- apiVersion: v1 - kind: Pod - metadata: - name: example-app - spec: - containers: - - args: - - "3600" - command: - - sleep - image: busybox:latest - imagePullPolicy: Always - name: example-app - resources: {} - restartPolicy: Always -kind: PodList diff --git a/test/functional/dynamic/definitions/v1_PodList_test_patch.yaml b/test/functional/dynamic/definitions/v1_PodList_test_patch.yaml deleted file mode 100644 index e03e5c29..00000000 --- a/test/functional/dynamic/definitions/v1_PodList_test_patch.yaml +++ /dev/null @@ -1,15 +0,0 @@ -apiVersion: v1 -items: -- metadata: - name: example-app2 - spec: - containers: - - image: busybox:1 - name: example-app -- metadata: - name: example-app - spec: - containers: - - image: busybox:2 - name: example-app -kind: PodList diff --git a/test/functional/dynamic/definitions/v1_Pod_test.yaml b/test/functional/dynamic/definitions/v1_Pod_test.yaml deleted file mode 100644 index a3429e21..00000000 --- a/test/functional/dynamic/definitions/v1_Pod_test.yaml +++ /dev/null @@ -1,13 +0,0 @@ -apiVersion: v1 -kind: Pod -metadata: - name: test -spec: - containers: - - image: busybox - command: - - sleep - - "3600" - imagePullPolicy: IfNotPresent - name: busybox - restartPolicy: Always diff --git a/test/functional/dynamic/definitions/v1_Pod_test_patch.yaml b/test/functional/dynamic/definitions/v1_Pod_test_patch.yaml deleted file mode 100644 index 8ed5fd61..00000000 --- a/test/functional/dynamic/definitions/v1_Pod_test_patch.yaml +++ /dev/null @@ -1,6 +0,0 @@ -metadata: - name: test -spec: - containers: - - image: busybox:1 - name: busybox diff --git a/test/functional/dynamic/delete.feature b/test/functional/dynamic/delete.feature deleted file mode 100644 index c3975a02..00000000 --- a/test/functional/dynamic/delete.feature +++ /dev/null @@ -1,19 +0,0 @@ -Feature: Delete - - Examples: - | filename | namespace | - | definitions/v1_Pod_test.yaml | test-delete | - | definitions/v1_PodList_test.yaml | test-delete2 | - | definitions/v1_List_test.yaml | test-delete | - - Scenario Outline: Delete a resource that exists - Given I have edit permissions in - And The content of exists in - When I delete in - Then The content of does not exist in - - Scenario Outline: Delete a resource that does not exist - Given I have edit permissions in - And The content of does not exist in - When I try to delete in - Then It throws a NotFoundError diff --git a/test/functional/dynamic/patch.feature b/test/functional/dynamic/patch.feature deleted file mode 100644 index 8af7f1f0..00000000 --- a/test/functional/dynamic/patch.feature +++ /dev/null @@ -1,19 +0,0 @@ -Feature: Patch - - Examples: - | filename | namespace | update | - | definitions/v1_Pod_test.yaml | test-patch | definitions/v1_Pod_test_patch.yaml | - | definitions/v1_PodList_test.yaml | test-patch2 | definitions/v1_PodList_test_patch.yaml | - | definitions/v1_List_test.yaml | test-patch | definitions/v1_List_test_replace.yaml | - - Scenario Outline: Patch a resource that does not exist - Given I have edit permissions in - And The content of does not exist in - When I try to patch with in - Then It throws a NotFoundError - - Scenario Outline: Patch a resource that exists - Given I have edit permissions in - And I have created in - When I patch with in - Then The resources in in should match the content of diff --git a/test/functional/dynamic/replace.feature b/test/functional/dynamic/replace.feature deleted file mode 100644 index e795842b..00000000 --- a/test/functional/dynamic/replace.feature +++ /dev/null @@ -1,19 +0,0 @@ -Feature: Replace - - Examples: - | filename | namespace | update | - | definitions/route.openshift.io_v1_Route_test.yaml | test-replace | definitions/route.openshift.io_v1_Route_test_replace.yaml | - | definitions/route.openshift.io_v1_RouteList_test.yaml | test-replace2 | definitions/route.openshift.io_v1_RouteList_test_replace.yaml | - | definitions/v1_List_test.yaml | test-replace | definitions/v1_List_test_replace.yaml | - - Scenario Outline: Replace a resource that does not exist - Given I have edit permissions in - And The content of does not exist in - When I try to replace with in - Then It throws a NotFoundError - - Scenario Outline: Replace a resource that exists - Given I have edit permissions in - And I have created in - When I replace with in - Then The resources in in should match the content of diff --git a/test/functional/dynamic/test_create.py b/test/functional/dynamic/test_create.py deleted file mode 100644 index 944a4abe..00000000 --- a/test/functional/dynamic/test_create.py +++ /dev/null @@ -1,18 +0,0 @@ -# coding=utf-8 -"""create feature tests. -All step implementations are in conftest.py -""" - -from pytest_bdd import scenario - - -@scenario('create.feature', 'Create a resource again') -def test_create_a_resource_again(): - """Create a resource again.""" - pass - - -@scenario('create.feature', 'Create a resource for the first time') -def test_create_a_resource_for_the_first_time(): - """Create a resource for the first time.""" - pass diff --git a/test/functional/dynamic/test_delete.py b/test/functional/dynamic/test_delete.py deleted file mode 100644 index 726f4584..00000000 --- a/test/functional/dynamic/test_delete.py +++ /dev/null @@ -1,18 +0,0 @@ -# coding=utf-8 -"""delete feature tests. -All step implementations are in conftest.py -""" - -from pytest_bdd import scenario - - -@scenario('delete.feature', 'Delete a resource that exists') -def test_delete_a_resource_that_exists(): - """Delete a resource that exists""" - pass - - -@scenario('delete.feature', 'Delete a resource that does not exist') -def test_delete_a_resource_that_does_not_exist(): - """Delete a resource that does not exist""" - pass diff --git a/test/functional/dynamic/test_patch.py b/test/functional/dynamic/test_patch.py deleted file mode 100644 index 1e9fa6be..00000000 --- a/test/functional/dynamic/test_patch.py +++ /dev/null @@ -1,18 +0,0 @@ -# coding=utf-8 -"""Patch feature tests. -All step implementations are in conftest.py -""" - -from pytest_bdd import scenario - - -@scenario('patch.feature', 'Patch a resource that does not exist') -def test_patch_a_resource_that_does_not_exist(): - """Patch a resource that does not exist.""" - pass - - -@scenario('patch.feature', 'Patch a resource that exists') -def test_patch_a_resource_that_exists(): - """Patch a resource that exists.""" - pass diff --git a/test/functional/dynamic/test_replace.py b/test/functional/dynamic/test_replace.py deleted file mode 100644 index 1c650a4c..00000000 --- a/test/functional/dynamic/test_replace.py +++ /dev/null @@ -1,18 +0,0 @@ -# coding=utf-8 -"""replace feature tests. -All step implementations are in conftest.py -""" - -from pytest_bdd import scenario - - -@scenario('replace.feature', 'Replace a resource that does not exist') -def test_replace_a_resource_that_does_not_exist(): - """replace a resource that does not exist.""" - pass - - -@scenario('replace.feature', 'Replace a resource that exists') -def test_replace_a_resource_that_exists(): - """replace a resource that exists.""" - pass diff --git a/test/integration/__init__.py b/test/integration/__init__.py new file mode 100644 index 00000000..87860e11 --- /dev/null +++ b/test/integration/__init__.py @@ -0,0 +1,29 @@ +import unittest + +from kubernetes import config +from kubernetes.client import api_client, Configuration + +from openshift.dynamic import DynamicClient + +class RestClientTestCase(unittest.TestCase): + + @classmethod + def setUpClass(cls): + try: + config.load_kube_config() + except config.ConfigException: + config.load_incluster_config() + cls.config = Configuration.get_default_copy() + cls.client = DynamicClient(api_client.ApiClient(configuration=cls.config)) + + def setUp(self): + # Generate a namespace name from the test case name + self.namespace = self.id().split('.')[-1].replace('_', '-') + v1_namespaces = self.client.resources.get(api_version='v1', kind='Namespace') + + self.addCleanup(v1_namespaces.delete, name=self.namespace) + v1_namespaces.create(body=dict( + apiVersion='v1', + kind='Namespace', + metadata=dict(name=self.namespace), + )) diff --git a/test/integration/test_client.py b/test/integration/test_client.py new file mode 100644 index 00000000..04ca1e1b --- /dev/null +++ b/test/integration/test_client.py @@ -0,0 +1,337 @@ +import time +import uuid + +from openshift.dynamic.exceptions import ResourceNotFoundError + +from . import RestClientTestCase + + +def short_uuid(): + id = str(uuid.uuid4()) + return id[-12:] + + +class TestDynamicClient(RestClientTestCase): + + def test_cluster_custom_resources(self): + with self.assertRaises(ResourceNotFoundError): + changeme_api = self.client.resources.get( + api_version='apps.example.com/v1', kind='ClusterChangeMe') + + crd_api = self.client.resources.get( + api_version='apiextensions.k8s.io/v1beta1', + kind='CustomResourceDefinition') + name = 'clusterchangemes.apps.example.com' + crd_manifest = { + 'apiVersion': 'apiextensions.k8s.io/v1beta1', + 'kind': 'CustomResourceDefinition', + 'metadata': { + 'name': name, + }, + 'spec': { + 'group': 'apps.example.com', + 'names': { + 'kind': 'ClusterChangeMe', + 'listKind': 'ClusterChangeMeList', + 'plural': 'clusterchangemes', + 'singular': 'clusterchangeme', + }, + 'scope': 'Cluster', + 'version': 'v1', + 'subresources': { + 'status': {} + } + } + } + resp = crd_api.create(crd_manifest) + + self.assertEqual(name, resp.metadata.name) + self.assertTrue(resp.status) + + resp = crd_api.get( + name=name, + ) + self.assertEqual(name, resp.metadata.name) + self.assertTrue(resp.status) + + try: + changeme_api = self.client.resources.get( + api_version='apps.example.com/v1', kind='ClusterChangeMe') + except ResourceNotFoundError: + # Need to wait a sec for the discovery layer to get updated + time.sleep(2) + changeme_api = self.client.resources.get( + api_version='apps.example.com/v1', kind='ClusterChangeMe') + resp = changeme_api.get() + self.assertEqual(resp.items, []) + changeme_name = 'custom-resource' + short_uuid() + changeme_manifest = { + 'apiVersion': 'apps.example.com/v1', + 'kind': 'ClusterChangeMe', + 'metadata': { + 'name': changeme_name, + }, + 'spec': {} + } + + resp = changeme_api.create(body=changeme_manifest) + self.assertEqual(resp.metadata.name, changeme_name) + + resp = changeme_api.get(name=changeme_name) + self.assertEqual(resp.metadata.name, changeme_name) + + changeme_manifest['spec']['size'] = 3 + resp = changeme_api.patch( + body=changeme_manifest, + content_type='application/merge-patch+json' + ) + self.assertEqual(resp.spec.size, 3) + + resp = changeme_api.get(name=changeme_name) + self.assertEqual(resp.spec.size, 3) + + resp = changeme_api.get() + self.assertEqual(len(resp.items), 1) + + resp = changeme_api.delete( + name=changeme_name, + ) + + resp = changeme_api.get() + self.assertEqual(len(resp.items), 0) + + resp = crd_api.delete( + name=name, + ) + + time.sleep(2) + self.client.resources.invalidate_cache() + with self.assertRaises(ResourceNotFoundError): + changeme_api = self.client.resources.get( + api_version='apps.example.com/v1', kind='ClusterChangeMe') + + def test_namespaced_custom_resources(self): + with self.assertRaises(ResourceNotFoundError): + changeme_api = self.client.resources.get( + api_version='apps.example.com/v1', kind='ChangeMe') + + crd_api = self.client.resources.get( + api_version='apiextensions.k8s.io/v1beta1', + kind='CustomResourceDefinition') + name = 'changemes.apps.example.com' + crd_manifest = { + 'apiVersion': 'apiextensions.k8s.io/v1beta1', + 'kind': 'CustomResourceDefinition', + 'metadata': { + 'name': name, + }, + 'spec': { + 'group': 'apps.example.com', + 'names': { + 'kind': 'ChangeMe', + 'listKind': 'ChangeMeList', + 'plural': 'changemes', + 'singular': 'changeme', + }, + 'scope': 'Namespaced', + 'version': 'v1', + 'subresources': { + 'status': {} + } + } + } + resp = crd_api.create(crd_manifest) + + self.assertEqual(name, resp.metadata.name) + self.assertTrue(resp.status) + + resp = crd_api.get( + name=name, + ) + self.assertEqual(name, resp.metadata.name) + self.assertTrue(resp.status) + + try: + changeme_api = self.client.resources.get( + api_version='apps.example.com/v1', kind='ChangeMe') + except ResourceNotFoundError: + # Need to wait a sec for the discovery layer to get updated + time.sleep(2) + changeme_api = self.client.resources.get( + api_version='apps.example.com/v1', kind='ChangeMe') + resp = changeme_api.get() + self.assertEqual(resp.items, []) + changeme_name = 'custom-resource' + short_uuid() + changeme_manifest = { + 'apiVersion': 'apps.example.com/v1', + 'kind': 'ChangeMe', + 'metadata': { + 'name': changeme_name, + }, + 'spec': {} + } + + resp = changeme_api.create(body=changeme_manifest, namespace=self.namespace) + self.assertEqual(resp.metadata.name, changeme_name) + + resp = changeme_api.get(name=changeme_name, namespace=self.namespace) + self.assertEqual(resp.metadata.name, changeme_name) + + changeme_manifest['spec']['size'] = 3 + resp = changeme_api.patch( + body=changeme_manifest, + namespace=self.namespace, + content_type='application/merge-patch+json' + ) + self.assertEqual(resp.spec.size, 3) + + resp = changeme_api.get(name=changeme_name, namespace=self.namespace) + self.assertEqual(resp.spec.size, 3) + + resp = changeme_api.get(namespace=self.namespace) + self.assertEqual(len(resp.items), 1) + + resp = changeme_api.get() + self.assertEqual(len(resp.items), 1) + + resp = changeme_api.delete( + name=changeme_name, + namespace=self.namespace + ) + + resp = changeme_api.get(namespace=self.namespace) + self.assertEqual(len(resp.items), 0) + + resp = changeme_api.get() + self.assertEqual(len(resp.items), 0) + + resp = crd_api.delete( + name=name, + ) + + time.sleep(2) + self.client.resources.invalidate_cache() + with self.assertRaises(ResourceNotFoundError): + changeme_api = self.client.resources.get( + api_version='apps.example.com/v1', kind='ChangeMe') + + def test_service_apis(self): + api = self.client.resources.get(api_version='v1', kind='Service') + + name = 'frontend-' + short_uuid() + service_manifest = {'apiVersion': 'v1', + 'kind': 'Service', + 'metadata': {'labels': {'name': name}, + 'name': name, + 'resourceversion': 'v1'}, + 'spec': {'ports': [{'name': 'port', + 'port': 80, + 'protocol': 'TCP', + 'targetPort': 80}], + 'selector': {'name': name}}} + + resp = api.create( + body=service_manifest, + namespace=self.namespace + ) + self.assertEqual(name, resp.metadata.name) + self.assertTrue(resp.status) + + resp = api.get( + name=name, + namespace=self.namespace + ) + self.assertEqual(name, resp.metadata.name) + self.assertTrue(resp.status) + + service_manifest['spec']['ports'] = [{'name': 'new', + 'port': 8080, + 'protocol': 'TCP', + 'targetPort': 8080}] + resp = api.patch( + body=service_manifest, + name=name, + namespace=self.namespace + ) + self.assertEqual(2, len(resp.spec.ports)) + self.assertTrue(resp.status) + + resp = api.delete( + name=name, body={}, + namespace=self.namespace + ) + + def test_replication_controller_apis(self): + api = self.client.resources.get( + api_version='v1', kind='ReplicationController') + + name = 'frontend-' + short_uuid() + rc_manifest = { + 'apiVersion': 'v1', + 'kind': 'ReplicationController', + 'metadata': {'labels': {'name': name}, + 'name': name}, + 'spec': {'replicas': 2, + 'selector': {'name': name}, + 'template': {'metadata': { + 'labels': {'name': name}}, + 'spec': {'containers': [{ + 'image': 'nginx', + 'name': 'nginx', + 'ports': [{'containerPort': 80, + 'protocol': 'TCP'}]}]}}}} + + resp = api.create( + body=rc_manifest, namespace=self.namespace) + self.assertEqual(name, resp.metadata.name) + self.assertEqual(2, resp.spec.replicas) + + resp = api.get( + name=name, namespace=self.namespace) + self.assertEqual(name, resp.metadata.name) + self.assertEqual(2, resp.spec.replicas) + + resp = api.delete( + name=name, body={}, namespace=self.namespace) + + def test_configmap_apis(self): + api = self.client.resources.get(api_version='v1', kind='ConfigMap') + + name = 'test-configmap-' + short_uuid() + test_configmap = { + "kind": "ConfigMap", + "apiVersion": "v1", + "metadata": { + "name": name, + }, + "data": { + "config.json": "{\"command\":\"/usr/bin/mysqld_safe\"}", + "frontend.cnf": "[mysqld]\nbind-address = 10.0.0.3\n" + } + } + + resp = api.create( + body=test_configmap, namespace=self.namespace + ) + self.assertEqual(name, resp.metadata.name) + + resp = api.get( + name=name, namespace=self.namespace) + self.assertEqual(name, resp.metadata.name) + + test_configmap['data']['config.json'] = "{}" + resp = api.patch( + name=name, namespace=self.namespace, body=test_configmap) + + resp = api.delete( + name=name, body={}, namespace=self.namespace) + + resp = api.get(namespace=self.namespace, pretty=True) + self.assertEqual([], resp.items) + + def test_node_apis(self): + api = self.client.resources.get(api_version='v1', kind='Node') + + for item in api.get().items: + node = api.get(name=item.metadata.name) + self.assertTrue(len(dict(node.metadata.labels)) > 0) diff --git a/test/integration/test_openshift_apis.py b/test/integration/test_openshift_apis.py new file mode 100644 index 00000000..9b3bbeb5 --- /dev/null +++ b/test/integration/test_openshift_apis.py @@ -0,0 +1,139 @@ +import time +from datetime import datetime + +import requests + +from . import RestClientTestCase + +class TestOpenshiftApis(RestClientTestCase): + + def create_hello_openshift(self): + apps_v1_deployments = self.client.resources.get(api_version='apps/v1', kind='Deployment') + v1_services = self.client.resources.get(api_version='v1', kind='Service') + + name = 'hello-openshift' + + deployment = { + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": { + "name": name, + "namespace": self.namespace + }, + "spec": { + "replicas": 3, + "selector": { + "matchLabels": { + "app": "hello-openshift" + } + }, + "template": { + "metadata": { + "labels": { + "app": "hello-openshift" + } + }, + "spec": { + "containers": [{ + "name": "hello-openshift", + "image": "docker.io/openshift/hello-openshift", + "ports": [{"containerPort": 8080}] + }] + }}}} + service = { + "apiVersion": "v1", + "kind": "Service", + "metadata": { + "name": name, + "namespace": self.namespace + }, + "spec": { + "ports": [{ + "port": 80, + "targetPort": 8080 + }], + "selector": { + "app": "hello-openshift" + } + }} + self.addCleanup(apps_v1_deployments.delete, name=name, namespace=self.namespace) + apps_v1_deployments.create(deployment) + + self.addCleanup(v1_services.delete, name=name, namespace=self.namespace) + v1_services.create(service) + + # Wait 1 minute for deployment to become available + timeout = 60 + start = datetime.now() + while (datetime.now() - start).seconds < timeout: + try: + deployment = apps_v1_deployments.get(name=name, namespace=self.namespace) + if (deployment.status + and deployment.spec.replicas == (deployment.status.replicas or 0) + and deployment.status.readyReplicas == deployment.spec.replicas + and deployment.status.observedGeneration == deployment.metadata.generation + and not deployment.status.unavailableReplicas): + return + time.sleep(1) + except Exception: + time.sleep(1) + + def test_v1_route(self): + self.create_hello_openshift() + + v1_routes = self.client.resources.get(api_version='route.openshift.io/v1', kind='Route') + route = { + "apiVersion": "route.openshift.io/v1", + "kind": "Route", + "metadata": { + "name": "test-route", + "namespace": self.namespace, + }, + "spec": { + "to": { + "kind": "Service", + "name": "hello-openshift", + }, + "port": { + "targetPort": 8080 + }, + "tls": { + "termination": "Edge", + "insecureEdgeTerminationPolicy": "Redirect", + } + } + } + + self.addCleanup(v1_routes.delete, name='test-route', namespace=self.namespace) + created_route = v1_routes.create(route) + + url = created_route.spec.host + timeout = 10 + start = datetime.now() + while (datetime.now() - start).seconds < timeout: + response = requests.get("https://{0}".format(url), verify=False) + if response.status_code == 200: + break + assert response.text == "Hello OpenShift!\n" + + def test_templates(self): + v1_templates = self.client.resources.get(api_version='template.openshift.io/v1', name='templates') + v1_processed_templates = self.client.resources.get(api_version='template.openshift.io/v1', name='processedtemplates') + + nginx_template = v1_templates.get(name='nginx-example', namespace='openshift').to_dict() + nginx_template = self.update_template_param(nginx_template, 'NAMESPACE', self.namespace) + nginx_template = self.update_template_param(nginx_template, 'NAME', 'test123') + + response = v1_processed_templates.create(body=nginx_template, namespace=self.namespace) + + for obj in response.objects: + if obj.metadata.namespace: + assert obj.metadata.namespace == self.namespace + assert obj.metadata.name == 'test123' + + def update_template_param(self, template, k, v): + for i, param in enumerate(template['parameters']): + if param['name'] == k: + template['parameters'][i]['value'] = v + return template + return template diff --git a/test/unit/test_apply.py b/test/unit/test_apply.py index 2415c383..907d08d2 100644 --- a/test/unit/test_apply.py +++ b/test/unit/test_apply.py @@ -1,7 +1,4 @@ -# Test ConfigMapHash and SecretHash equivalents -# tests based on https://github.com/kubernetes/kubernetes/pull/49961 - -from openshift.dynamic.apply import merge +from openshift.dynamic.apply import merge, apply_patch tests = [ dict( @@ -43,6 +40,20 @@ ), expected = dict(data=dict(two=None, three="3")) ), + dict( + last_applied = dict( + kind="ConfigMap", + metadata=dict(name="foo", annotations=dict(this="one", hello="world")), + data=dict(one="1", two="2") + ), + desired = dict( + kind="ConfigMap", + metadata=dict(name="foo"), + data=dict(one="1", three="3") + ), + expected = dict(metadata=dict(annotations=None), data=dict(two=None, three="3")) + ), + dict( last_applied = dict( kind="Service", @@ -59,7 +70,7 @@ metadata=dict(name="foo"), spec=dict(ports=[dict(port=8080, name="http")]) ), - expected = {} + expected = dict(spec=dict(ports=[dict(port=8080, protocol='TCP', name="http")])) ), dict( last_applied = dict( @@ -79,7 +90,121 @@ ), expected = dict(spec=dict(ports=[dict(port=8081, name="http")])) ), - # This last one is based on a real world case where definition was mostly + dict( + last_applied = dict( + kind="Service", + metadata=dict(name="foo"), + spec=dict(ports=[dict(port=8080, name="http")]) + ), + actual = dict( + kind="Service", + metadata=dict(name="foo"), + spec=dict(ports=[dict(port=8080, protocol='TCP', name="http")]) + ), + desired = dict( + kind="Service", + metadata=dict(name="foo"), + spec=dict(ports=[dict(port=8443, name="https"), dict(port=8080, name="http")]) + ), + expected = dict(spec=dict(ports=[dict(port=8443, name="https"), dict(port=8080, name="http", protocol='TCP')])) + ), + dict( + last_applied = dict( + kind="Service", + metadata=dict(name="foo"), + spec=dict(ports=[dict(port=8443, name="https"), dict(port=8080, name="http")]) + ), + actual = dict( + kind="Service", + metadata=dict(name="foo"), + spec=dict(ports=[dict(port=8443, protocol='TCP', name="https"), dict(port=8080, protocol='TCP', name='http')]) + ), + desired = dict( + kind="Service", + metadata=dict(name="foo"), + spec=dict(ports=[dict(port=8080, name="http")]) + ), + expected = dict(spec=dict(ports=[dict(port=8080, name="http", protocol='TCP')])) + ), + dict( + last_applied = dict( + kind="Service", + metadata=dict(name="foo"), + spec=dict(ports=[dict(port=8443, name="https", madeup="xyz"), dict(port=8080, name="http")]) + ), + actual = dict( + kind="Service", + metadata=dict(name="foo"), + spec=dict(ports=[dict(port=8443, protocol='TCP', name="https", madeup="xyz"), dict(port=8080, protocol='TCP', name='http')]) + ), + desired = dict( + kind="Service", + metadata=dict(name="foo"), + spec=dict(ports=[dict(port=8443, name="https")]) + ), + expected = dict(spec=dict(ports=[dict(madeup=None, port=8443, name="https", protocol='TCP')])) + ), + dict( + last_applied = dict( + kind="Pod", + metadata=dict(name="foo"), + spec=dict(containers=[dict(name="busybox", image="busybox", + resources=dict(requests=dict(cpu="100m", memory="100Mi"), limits=dict(cpu="100m", memory="100Mi")))]) + ), + actual = dict( + kind="Pod", + metadata=dict(name="foo"), + spec=dict(containers=[dict(name="busybox", image="busybox", + resources=dict(requests=dict(cpu="100m", memory="100Mi"), limits=dict(cpu="100m", memory="100Mi")))]) + ), + desired = dict( + kind="Pod", + metadata=dict(name="foo"), + spec=dict(containers=[dict(name="busybox", image="busybox", + resources=dict(requests=dict(cpu="50m", memory="50Mi"), limits=dict(memory="50Mi")))]) + ), + expected=dict(spec=dict(containers=[dict(name="busybox", image="busybox", + resources=dict(requests=dict(cpu="50m", memory="50Mi"), limits=dict(cpu=None, memory="50Mi")))])) + ), + dict( + desired = dict(kind='Pod', + spec=dict(containers=[ + dict(name='hello', + volumeMounts=[dict(name="test", mountPath="/test")]) + ], + volumes=[ + dict(name="test", configMap=dict(name="test")), + ])), + last_applied = dict(kind='Pod', + spec=dict(containers=[ + dict(name='hello', + volumeMounts=[dict(name="test", mountPath="/test")]) + ], + volumes=[ + dict(name="test", configMap=dict(name="test")), + ])), + actual = dict(kind='Pod', + spec=dict(containers=[ + dict(name='hello', + volumeMounts=[dict(name="test", mountPath="/test"), + dict(mountPath="/var/run/secrets/kubernetes.io/serviceaccount", name="default-token-xyz")]) + ], + volumes=[ + dict(name="test", configMap=dict(name="test")), + dict(name="default-token-xyz", secret=dict(secretName="default-token-xyz")), + ])), + expected = dict(spec=dict(containers=[ + dict(name='hello', + volumeMounts=[dict(name="test", mountPath="/test"), + dict(mountPath="/var/run/secrets/kubernetes.io/serviceaccount", name="default-token-xyz")]) + ], + volumes=[ + dict(name="test", configMap=dict(name="test")), + dict(name="default-token-xyz", secret=dict(secretName="default-token-xyz")), + ])), + ), + + # This next one is based on a real world case where definition was mostly # str type and everything else was mostly unicode type (don't ask me how) dict( last_applied = { @@ -106,10 +231,176 @@ }, expected = dict() ), - + # apply a Deployment, then scale the Deployment (which doesn't affect last-applied) + # then apply the Deployment again. Should un-scale the Deployment + dict( + last_applied = { + 'kind': u'Deployment', + 'spec': { + 'replicas': 1, + 'template': { + 'spec': { + 'containers': [ + { + 'name': 'this_must_exist', + 'envFrom': [ + { + 'configMapRef': { + 'name': 'config-xyz' + } + }, + { + 'secretRef': { + 'name': 'config-wxy' + } + } + ] + } + ] + } + } + }, + 'metadata': { + 'namespace': 'apply', + 'name': u'apply-deployment' + } + }, + actual = { + 'kind': u'Deployment', + 'spec': { + 'replicas': 0, + 'template': { + 'spec': { + 'containers': [ + { + 'name': 'this_must_exist', + 'envFrom': [ + { + 'configMapRef': { + 'name': 'config-xyz' + } + }, + { + 'secretRef': { + 'name': 'config-wxy' + } + } + ] + } + ] + } + } + }, + 'metadata': { + 'namespace': 'apply', + 'name': u'apply-deployment' + } + }, + desired = { + 'kind': u'Deployment', + 'spec': { + 'replicas': 1, + 'template': { + 'spec': { + 'containers': [ + { + 'name': 'this_must_exist', + 'envFrom': [ + { + 'configMapRef': { + 'name': 'config-abc' + } + } + ] + } + ] + } + } + }, + 'metadata': { + 'namespace': 'apply', + 'name': u'apply-deployment' + } + }, + expected = { + 'spec' : { + 'replicas': 1, + 'template': { + 'spec': { + 'containers': [ + { + 'name': 'this_must_exist', + 'envFrom': [ + { + 'configMapRef': { + 'name': 'config-abc' + } + } + ] + } + ] + } + } + } + } + ), + dict( + last_applied = { + 'kind': 'MadeUp', + 'toplevel': { + 'original': 'entry' + } + }, + actual = { + 'kind': 'MadeUp', + 'toplevel': { + 'original': 'entry', + 'another': { + 'nested': { + 'entry': 'value' + } + } + } + }, + desired = { + 'kind': 'MadeUp', + 'toplevel': { + 'original': 'entry', + 'another': { + 'nested': { + 'entry': 'value' + } + } + } + }, + expected = {} + ) ] def test_merges(): for test in tests: assert(merge(test['last_applied'], test['desired'], test.get('actual', test['last_applied'])) == test['expected']) + + +def test_apply_patch(): + actual = dict( + kind="ConfigMap", + metadata=dict(name="foo", + annotations={'kubectl.kubernetes.io/last-applied-configuration': + '{"data":{"one":"1","two":"2"},"kind":"ConfigMap",' + '"metadata":{"annotations":{"hello":"world","this":"one"},"name":"foo"}}', + 'this': 'one', 'hello': 'world'}), + data=dict(one="1", two="2") + ) + desired = dict( + kind="ConfigMap", + metadata=dict(name="foo"), + data=dict(one="1", three="3") + ) + expected = dict( + metadata=dict( + annotations={'kubectl.kubernetes.io/last-applied-configuration': '{"data":{"one":"1","three":"3"},"kind":"ConfigMap","metadata":{"name":"foo"}}', + 'this': None, 'hello': None}), + data=dict(two=None, three="3")) + assert(apply_patch(actual, desired) == (actual, expected)) diff --git a/test/unit/test_diff.py b/test/unit/test_diff.py new file mode 100644 index 00000000..03b67baa --- /dev/null +++ b/test/unit/test_diff.py @@ -0,0 +1,114 @@ +from openshift.dynamic.apply import recursive_diff + +tests = [ + dict( + before = dict( + kind="Service", + metadata=dict(name="foo"), + spec=dict(ports=[dict(port=8080, name="http")]) + ), + after = dict( + kind="Service", + metadata=dict(name="foo"), + spec=dict(ports=[dict(port=8080, name="http")]) + ), + expected = None + ), + dict( + before = dict( + kind="Service", + metadata=dict(name="foo"), + spec=dict(ports=[dict(port=8080, name="http")]) + ), + after = dict( + kind="Service", + metadata=dict(name="foo"), + spec=dict(ports=[dict(port=8081, name="http")]) + ), + expected = ( + dict(spec=dict(ports=[dict(port=8080, name="http")])), + dict(spec=dict(ports=[dict(port=8081, name="http")])) + ) + ), + dict( + before = dict( + kind="Service", + metadata=dict(name="foo"), + spec=dict(ports=[dict(port=8080, name="http"), dict(port=8081, name="https")]) + ), + after = dict( + kind="Service", + metadata=dict(name="foo"), + spec=dict(ports=[dict(port=8081, name="https"), dict(port=8080, name="http")]) + ), + expected = None + ), + + dict( + before = dict( + kind="Pod", + metadata=dict(name="foo"), + spec=dict(containers=[dict(name="busybox", image="busybox", + env=[dict(name="hello", value="world"), + dict(name="another", value="next")])]) + ), + after = dict( + kind="Pod", + metadata=dict(name="foo"), + spec=dict(containers=[dict(name="busybox", image="busybox", + env=[dict(name="hello", value="everyone")])]) + ), + expected=(dict(spec=dict(containers=[dict(name="busybox", env=[dict(name="another", value="next"), dict(name="hello", value="world")])])), + dict(spec=dict(containers=[dict(name="busybox", env=[dict(name="hello", value="everyone")])]))) + ), + + dict( + before = dict( + kind="Pod", + metadata=dict(name="foo"), + spec=dict(containers=[dict(name="busybox", image="busybox")]) + ), + after = dict( + kind="Service", + metadata=dict(name="foo"), + spec=dict(ports=[dict(port=8081, name="http")]) + ), + expected=(dict(kind='Pod', spec=dict(containers=[dict(image='busybox', name='busybox')])), + dict(kind='Service', spec=dict(ports=[dict(name='http', port=8081)]))) + ), + + dict( + before = dict( + kind="Pod", + metadata=dict(name="foo"), + spec=dict(containers=[dict(name="busybox", image="busybox")]) + ), + after = dict( + # kind="...", + metadata=dict(name="foo"), + spec=dict(ports=[dict(port=8081, name="http")]) + ), + expected=(dict(kind='Pod', spec=dict(containers=[dict(image='busybox', name='busybox')])), + dict(spec=dict(ports=[dict(name='http', port=8081)]))) + ), + + dict( + before = dict( + # kind="...", + metadata=dict(name="foo"), + spec=dict(containers=[dict(name="busybox", image="busybox")]) + ), + after = dict( + kind="Service", + metadata=dict(name="foo"), + spec=dict(ports=[dict(port=8081, name="http")]) + ), + expected=(dict(spec=dict(containers=[dict(image='busybox', name='busybox')])), + dict(kind='Service', spec=dict(ports=[dict(name='http', port=8081)]))) + ), + ] + + +def test_diff(): + for test in tests: + assert(recursive_diff(test['before'], test['after']) == test['expected']) diff --git a/tox.ini b/tox.ini index 3c2fbf6c..d2139f62 100644 --- a/tox.ini +++ b/tox.ini @@ -1,51 +1,27 @@ [tox] envlist = - py{27,35}-{lint} - py{27,35}-openshift{latest,311,310,39,37,36,15,14,13}-unit - py{27,35}-openshift{latest,311,310,39}-dynamic-functional + py39-lint + py39-openshift-unit [testenv] usedevelop = True -setenv = - openshift13: openshift_version=v1.3.3 - openshift14: openshift_version=v1.4.1 - openshift15: openshift_version=v1.5.1 - openshift36: openshift_version=v3.6.0 - openshift37: openshift_version=v3.7.0 - openshift39: openshift_version=v3.9.0 - openshift310: openshift_version=v3.10.0 - openshift311: openshift_version=v3.11.0 - deps = -rrequirements.txt -rtest-requirements.txt - py35-lint: flake8-bugbear + py39-lint: flake8-bugbear whitelist_externals = /bin/bash /bin/git commands = - docs: python setup.py build_sphinx lint: flake8 - unit: pytest test/unit -v -r s --openshift-version={env:openshift_version:latest} - dynamic-functional: pytest test/functional/dynamic -v -r s --openshift-version={env:openshift_version:latest} + unit: pytest test/unit -v -r s [travis:env] TEST_SUITE = lint: lint unit: unit - dynamic-functional: dynamic-functional -OPENSHIFT_VERSION = - latest: openshiftlatest - 3.11: openshift311 - 3.10: openshift310 - 3.9: openshift39 - 3.7: openshift37 - 3.6: openshift36 - 1.5: openshift15 - 1.4: openshift14 - 1.3: openshift13 [flake8] # These are things that the devs don't agree make the code more readable