diff --git a/kubernetes/e2e_test/test_utils.py b/kubernetes/e2e_test/test_utils.py index cc80e6f205..6579423c8b 100644 --- a/kubernetes/e2e_test/test_utils.py +++ b/kubernetes/e2e_test/test_utils.py @@ -71,6 +71,48 @@ def test_create_apps_deployment_from_yaml(self): except ApiException: continue + def test_create_apps_deployment_from_yaml_with_apply_is_idempotent(self): + """ + Should be able to create an apps/v1 deployment. + """ + k8s_client = client.api_client.ApiClient(configuration=self.config) + try: + utils.create_from_yaml( + k8s_client, self.path_prefix + "apps-deployment.yaml") + app_api = client.AppsV1Api(k8s_client) + dep = app_api.read_namespaced_deployment(name="nginx-app", + namespace="default") + self.assertIsNotNone(dep) + self.assertEqual("nginx-app", dep.metadata.name) + self.assertEqual("nginx:1.15.4", dep.spec.template.spec.containers[0].image) + self.assertEqual(80, dep.spec.template.spec.containers[0].ports[0].container_port) + self.assertEqual("nginx", dep.spec.template.spec.containers[0].name) + self.assertEqual("nginx", dep.spec.template.metadata.labels["app"]) + self.assertEqual(3, dep.spec.replicas) + + utils.create_from_yaml( + k8s_client, self.path_prefix + "apps-deployment.yaml", apply=True) + dep = app_api.read_namespaced_deployment(name="nginx-app", + namespace="default") + self.assertIsNotNone(dep) + self.assertEqual("nginx-app", dep.metadata.name) + self.assertEqual("nginx:1.15.4", dep.spec.template.spec.containers[0].image) + self.assertEqual(80, dep.spec.template.spec.containers[0].ports[0].container_port) + self.assertEqual("nginx", dep.spec.template.spec.containers[0].name) + self.assertEqual("nginx", dep.spec.template.metadata.labels["app"]) + self.assertEqual(3, dep.spec.replicas) + except Exception as e: + self.fail(e) + finally: + while True: + try: + app_api.delete_namespaced_deployment( + name="nginx-app", namespace="default", + body={}) + break + except ApiException: + continue + def test_create_apps_deployment_from_yaml_object(self): """ Should be able to pass YAML objects directly to helper function. diff --git a/kubernetes/utils/create_from_yaml.py b/kubernetes/utils/create_from_yaml.py index 459c291ef3..562c0edb7e 100644 --- a/kubernetes/utils/create_from_yaml.py +++ b/kubernetes/utils/create_from_yaml.py @@ -1,4 +1,4 @@ -# Copyright 2018 The Kubernetes Authors. +# Copyright 2019 The Kubernetes Authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -13,23 +13,20 @@ # limitations under the License. -import re import os +import re import yaml - from kubernetes import client +from kubernetes.dynamic.client import DynamicClient -UPPER_FOLLOWED_BY_LOWER_RE = re.compile('(.)([A-Z][a-z]+)') -LOWER_OR_NUM_FOLLOWED_BY_UPPER_RE = re.compile('([a-z0-9])([A-Z])') +UPPER_FOLLOWED_BY_LOWER_RE = re.compile("(.)([A-Z][a-z]+)") +LOWER_OR_NUM_FOLLOWED_BY_UPPER_RE = re.compile("([a-z0-9])([A-Z])") def create_from_directory( - k8s_client, - yaml_dir=None, - verbose=False, - namespace="default", - **kwargs): + k8s_client, yaml_dir=None, verbose=False, namespace="default", apply=False, **kwargs +): """ Perform an action from files from a directory. Pass True for verbose to print confirmation information. @@ -44,6 +41,7 @@ def create_from_directory( the resource creation will fail. If the API object in the yaml file already contains a namespace definition this parameter has no effect. + apply: bool. If True, use server-side apply for creating resources. Available parameters for creating : :param async_req bool @@ -65,27 +63,31 @@ def create_from_directory( """ if not yaml_dir: - raise ValueError( - '`yaml_dir` argument must be provided') + raise ValueError("`yaml_dir` argument must be provided") elif not os.path.isdir(yaml_dir): - raise ValueError( - '`yaml_dir` argument must be a path to directory') + raise ValueError("`yaml_dir` argument must be a path to directory") - files = [os.path.join(yaml_dir, i) for i in os.listdir(yaml_dir) - if os.path.isfile(os.path.join(yaml_dir, i))] + files = [ + os.path.join(yaml_dir, i) + for i in os.listdir(yaml_dir) + if os.path.isfile(os.path.join(yaml_dir, i)) + ] if not files: - raise ValueError( - '`yaml_dir` contains no files') + raise ValueError("`yaml_dir` contains no files") failures = [] k8s_objects_all = [] for file in files: try: - k8s_objects = create_from_yaml(k8s_client, file, - verbose=verbose, - namespace=namespace, - **kwargs) + k8s_objects = create_from_yaml( + k8s_client, + file, + verbose=verbose, + namespace=namespace, + apply=apply, + **kwargs, + ) k8s_objects_all.append(k8s_objects) except FailToCreateError as failure: failures.extend(failure.api_exceptions) @@ -95,12 +97,14 @@ def create_from_directory( def create_from_yaml( - k8s_client, - yaml_file=None, - yaml_objects=None, - verbose=False, - namespace="default", - **kwargs): + k8s_client, + yaml_file=None, + yaml_objects=None, + verbose=False, + namespace="default", + apply=False, + **kwargs, +): """ Perform an action from a yaml file. Pass True for verbose to print confirmation information. @@ -116,6 +120,7 @@ def create_from_yaml( the resource creation will fail. If the API object in the yaml file already contains a namespace definition this parameter has no effect. + apply: bool. If True, use server-side apply for creating resources. Available parameters for creating : :param async_req bool @@ -136,16 +141,21 @@ def create_from_yaml( instances for each object that failed to create. """ - def create_with(objects): + def create_with(objects, apply=apply): failures = [] k8s_objects = [] for yml_document in objects: if yml_document is None: continue try: - created = create_from_dict(k8s_client, yml_document, verbose, - namespace=namespace, - **kwargs) + created = create_from_dict( + k8s_client, + yml_document, + verbose, + namespace=namespace, + apply=apply, + **kwargs, + ) k8s_objects.append(created) except FailToCreateError as failure: failures.extend(failure.api_exceptions) @@ -164,14 +174,16 @@ class Loader(yaml.loader.SafeLoader): elif yaml_file: with open(os.path.abspath(yaml_file)) as f: yml_document_all = yaml.load_all(f, Loader=Loader) - return create_with(yml_document_all) + return create_with(yml_document_all, apply) else: raise ValueError( - 'One of `yaml_file` or `yaml_objects` arguments must be provided') + "One of `yaml_file` or `yaml_objects` arguments must be provided" + ) -def create_from_dict(k8s_client, data, verbose=False, namespace='default', - **kwargs): +def create_from_dict( + k8s_client, data, verbose=False, namespace="default", apply=False, **kwargs +): """ Perform an action from a dictionary containing valid kubernetes API object (i.e. List, Service, etc). @@ -186,6 +198,7 @@ def create_from_dict(k8s_client, data, verbose=False, namespace='default', the resource creation will fail. If the API object in the yaml file already contains a namespace definition this parameter has no effect. + apply: bool. If True, use server-side apply for creating resources. Returns: The created kubernetes API objects. @@ -210,8 +223,13 @@ def create_from_dict(k8s_client, data, verbose=False, namespace='default', yml_object["kind"] = kind try: created = create_from_yaml_single_item( - k8s_client, yml_object, verbose, namespace=namespace, - **kwargs) + k8s_client, + yml_object, + verbose, + namespace=namespace, + apply=apply, + **kwargs, + ) k8s_objects.append(created) except client.rest.ApiException as api_exception: api_exceptions.append(api_exception) @@ -219,7 +237,8 @@ def create_from_dict(k8s_client, data, verbose=False, namespace='default', # This is a single object. Call the single item method try: created = create_from_yaml_single_item( - k8s_client, data, verbose, namespace=namespace, **kwargs) + k8s_client, data, verbose, namespace=namespace, apply=apply, **kwargs + ) k8s_objects.append(created) except client.rest.ApiException as api_exception: api_exceptions.append(api_exception) @@ -232,7 +251,23 @@ def create_from_dict(k8s_client, data, verbose=False, namespace='default', def create_from_yaml_single_item( - k8s_client, yml_object, verbose=False, **kwargs): + k8s_client, yml_object, verbose=False, apply=False, **kwargs +): + + kind = yml_object["kind"] + if apply is True: + apply_client = DynamicClient(k8s_client).resources.get( + api_version=yml_object["apiVersion"], kind=kind + ) + resp = apply_client.server_side_apply( + body=yml_object, field_manager="python-client", **kwargs + ) + if verbose: + msg = "{0} created.".format(kind) + if hasattr(resp, "status"): + msg += " status='{0}'".format(str(resp.status)) + print(msg) + return resp group, _, version = yml_object["apiVersion"].partition("/") if version == "": version = group @@ -242,29 +277,30 @@ def create_from_yaml_single_item( group = "".join(group.rsplit(".k8s.io", 1)) # convert group name from DNS subdomain format to # python class name convention - group = "".join(word.capitalize() for word in group.split('.')) + group = "".join(word.capitalize() for word in group.split(".")) fcn_to_call = "{0}{1}Api".format(group, version.capitalize()) k8s_api = getattr(client, fcn_to_call)(k8s_client) # Replace CamelCased action_type into snake_case - kind = yml_object["kind"] - kind = UPPER_FOLLOWED_BY_LOWER_RE.sub(r'\1_\2', kind) - kind = LOWER_OR_NUM_FOLLOWED_BY_UPPER_RE.sub(r'\1_\2', kind).lower() + kind = UPPER_FOLLOWED_BY_LOWER_RE.sub(r"\1_\2", kind) + kind = LOWER_OR_NUM_FOLLOWED_BY_UPPER_RE.sub(r"\1_\2", kind).lower() # Expect the user to create namespaced objects more often if hasattr(k8s_api, "create_namespaced_{0}".format(kind)): # Decide which namespace we are going to put the object in, # if any if "namespace" in yml_object["metadata"]: namespace = yml_object["metadata"]["namespace"] - kwargs['namespace'] = namespace + kwargs["namespace"] = namespace resp = getattr(k8s_api, "create_namespaced_{0}".format(kind))( - body=yml_object, **kwargs) + body=yml_object, **kwargs + ) else: - kwargs.pop('namespace', None) + kwargs.pop("namespace", None) resp = getattr(k8s_api, "create_{0}".format(kind))( - body=yml_object, **kwargs) + body=yml_object, **kwargs + ) if verbose: msg = "{0} created.".format(kind) - if hasattr(resp, 'status'): + if hasattr(resp, "status"): msg += " status='{0}'".format(str(resp.status)) print(msg) return resp @@ -283,5 +319,6 @@ def __str__(self): msg = "" for api_exception in self.api_exceptions: msg += "Error from server ({0}): {1}".format( - api_exception.reason, api_exception.body) + api_exception.reason, api_exception.body + ) return msg