mirror of
https://github.com/ansible-collections/kubernetes.core.git
synced 2026-03-26 21:33:02 +00:00
* Cleanup gha * test by removing matrix excludes * Rename sanity tests * trigger integration tests * Fix ansible-lint workflow * Fix concurrency * Add ansible-lint config * Add ansible-lint config * Fix integration and lint issues * integration wf * fix yamllint issues * fix yamllint issues * update readme and add ignore-2.16.txt * fix ansible-doc * Add version * Use /dev/random to generate random data The GHA environment has difficultly generating entropy. Trying to read from /dev/urandom just blocks forever. We don't care if the random data is cryptographically secure; it's just garbage data for the test. Read from /dev/random, instead. This is only used during the k8s_copy test target. This also removes the custom test module that was being used to generate the files. It's not worth maintaining this for two task that can be replaced with some simple command/shell tasks. * Fix saniry errors * test github_action fix * Address review comments * Remove default types * review comments * isort fixes * remove tags * Add setuptools to venv * Test gh changes * update changelog * update ignore-2.16 * Fix indentation in inventory plugin example * Update .github/workflows/integration-tests.yaml * Update integration-tests.yaml --------- Co-authored-by: Mike Graves <mgraves@redhat.com> Co-authored-by: Bikouo Aubin <79859644+abikouo@users.noreply.github.com>
546 lines
19 KiB
Python
546 lines
19 KiB
Python
# Copyright: (c) 2021, Red Hat | Ansible
|
|
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
|
|
|
import copy
|
|
from typing import Any, Dict, List, Optional, Tuple
|
|
|
|
from ansible.module_utils.common.dict_transformations import dict_merge
|
|
from ansible_collections.kubernetes.core.plugins.module_utils.hashes import (
|
|
generate_hash,
|
|
)
|
|
from ansible_collections.kubernetes.core.plugins.module_utils.k8s.core import requires
|
|
from ansible_collections.kubernetes.core.plugins.module_utils.k8s.exceptions import (
|
|
CoreException,
|
|
)
|
|
from ansible_collections.kubernetes.core.plugins.module_utils.k8s.waiter import (
|
|
Waiter,
|
|
exists,
|
|
get_waiter,
|
|
resource_absent,
|
|
)
|
|
|
|
try:
|
|
from kubernetes.dynamic.exceptions import (
|
|
BadRequestError,
|
|
ConflictError,
|
|
ForbiddenError,
|
|
MethodNotAllowedError,
|
|
NotFoundError,
|
|
ResourceNotFoundError,
|
|
ResourceNotUniqueError,
|
|
)
|
|
except ImportError:
|
|
# Handled in module setup
|
|
pass
|
|
|
|
try:
|
|
from kubernetes.dynamic.resource import Resource, ResourceInstance
|
|
except ImportError:
|
|
# These are defined only for the sake of Ansible's checked import requirement
|
|
Resource = Any # type: ignore
|
|
ResourceInstance = Any # type: ignore
|
|
|
|
try:
|
|
from ansible_collections.kubernetes.core.plugins.module_utils.apply import (
|
|
apply_object,
|
|
)
|
|
except ImportError:
|
|
# Handled in module setup
|
|
pass
|
|
|
|
try:
|
|
from ansible_collections.kubernetes.core.plugins.module_utils.apply import (
|
|
recursive_diff,
|
|
)
|
|
except ImportError:
|
|
from ansible.module_utils.common.dict_transformations import recursive_diff
|
|
|
|
|
|
try:
|
|
from ansible_collections.kubernetes.core.plugins.module_utils.common import (
|
|
_encode_stringdata,
|
|
)
|
|
except ImportError:
|
|
# Handled in module setup
|
|
pass
|
|
|
|
|
|
class K8sService:
|
|
"""A Service class for K8S modules.
|
|
This class has the primary purpose is to perform work on the cluster (e.g., create, apply, replace, update, delete).
|
|
"""
|
|
|
|
def __init__(self, client, module) -> None:
|
|
self.client = client
|
|
self.module = module
|
|
|
|
@property
|
|
def _client_side_dry_run(self):
|
|
return self.module.check_mode and not self.client.dry_run
|
|
|
|
def find_resource(
|
|
self, kind: str, api_version: str, fail: bool = False
|
|
) -> Optional[Resource]:
|
|
try:
|
|
return self.client.resource(kind, api_version)
|
|
except (ResourceNotFoundError, ResourceNotUniqueError):
|
|
if fail:
|
|
raise CoreException(
|
|
"Failed to find exact match for %s.%s by [kind, name, singularName, shortNames]"
|
|
% (api_version, kind)
|
|
)
|
|
|
|
def wait(
|
|
self, resource: Resource, instance: Dict
|
|
) -> Tuple[bool, Optional[Dict], int]:
|
|
wait_sleep = self.module.params.get("wait_sleep")
|
|
wait_timeout = self.module.params.get("wait_timeout")
|
|
wait_condition = None
|
|
if self.module.params.get("wait_condition") and self.module.params[
|
|
"wait_condition"
|
|
].get("type"):
|
|
wait_condition = self.module.params["wait_condition"]
|
|
state = "present"
|
|
if self.module.params.get("state") == "absent":
|
|
state = "absent"
|
|
label_selectors = self.module.params.get("label_selectors")
|
|
|
|
waiter = get_waiter(
|
|
self.client, resource, condition=wait_condition, state=state
|
|
)
|
|
return waiter.wait(
|
|
timeout=wait_timeout,
|
|
sleep=wait_sleep,
|
|
name=instance["metadata"].get("name"),
|
|
namespace=instance["metadata"].get("namespace"),
|
|
label_selectors=label_selectors,
|
|
)
|
|
|
|
def create_project_request(self, definition: Dict) -> Dict:
|
|
definition["kind"] = "ProjectRequest"
|
|
results = {"changed": False, "result": {}}
|
|
resource = self.find_resource(
|
|
"ProjectRequest", definition["apiVersion"], fail=True
|
|
)
|
|
if not self.module.check_mode:
|
|
try:
|
|
k8s_obj = self.client.create(resource, definition)
|
|
results["result"] = k8s_obj.to_dict()
|
|
except Exception as e:
|
|
reason = e.body if hasattr(e, "body") else e
|
|
msg = "Failed to create object: {0}".format(reason)
|
|
raise CoreException(msg) from e
|
|
|
|
results["changed"] = True
|
|
|
|
return results
|
|
|
|
def patch_resource(
|
|
self,
|
|
resource: Resource,
|
|
definition: Dict,
|
|
name: str,
|
|
namespace: str,
|
|
merge_type: str = None,
|
|
) -> Dict:
|
|
if merge_type == "json":
|
|
self.module.deprecate(
|
|
msg="json as a merge_type value is deprecated. Please use the k8s_json_patch module instead.",
|
|
version="3.0.0",
|
|
collection_name="kubernetes.core",
|
|
)
|
|
try:
|
|
params = dict(name=name, namespace=namespace)
|
|
if merge_type:
|
|
params["content_type"] = "application/{0}-patch+json".format(merge_type)
|
|
return self.client.patch(resource, definition, **params).to_dict()
|
|
except Exception as e:
|
|
reason = e.body if hasattr(e, "body") else e
|
|
msg = "Failed to patch object: {0}".format(reason)
|
|
raise CoreException(msg) from e
|
|
|
|
def retrieve(self, resource: Resource, definition: Dict) -> ResourceInstance:
|
|
state = self.module.params.get("state", None)
|
|
append_hash = self.module.params.get("append_hash", False)
|
|
name = definition["metadata"].get("name")
|
|
generate_name = definition["metadata"].get("generateName")
|
|
namespace = definition["metadata"].get("namespace")
|
|
label_selectors = self.module.params.get("label_selectors")
|
|
existing: ResourceInstance = None
|
|
|
|
try:
|
|
# ignore append_hash for resources other than ConfigMap and Secret
|
|
if append_hash and definition["kind"] in ["ConfigMap", "Secret"]:
|
|
if name:
|
|
name = "%s-%s" % (name, generate_hash(definition))
|
|
definition["metadata"]["name"] = name
|
|
elif generate_name:
|
|
definition["metadata"]["generateName"] = "%s-%s" % (
|
|
generate_name,
|
|
generate_hash(definition),
|
|
)
|
|
params = {}
|
|
if name:
|
|
params["name"] = name
|
|
if namespace:
|
|
params["namespace"] = namespace
|
|
if label_selectors:
|
|
params["label_selector"] = ",".join(label_selectors)
|
|
if "name" in params or "label_selector" in params:
|
|
existing = self.client.get(resource, **params)
|
|
except (NotFoundError, MethodNotAllowedError):
|
|
pass
|
|
except ForbiddenError as e:
|
|
if (
|
|
definition["kind"] in ["Project", "ProjectRequest"]
|
|
and state != "absent"
|
|
):
|
|
return self.create_project_request(definition)
|
|
reason = e.body if hasattr(e, "body") else e
|
|
msg = "Failed to retrieve requested object: {0}".format(reason)
|
|
raise CoreException(msg) from e
|
|
except Exception as e:
|
|
reason = e.body if hasattr(e, "body") else e
|
|
msg = "Failed to retrieve requested object: {0}".format(reason)
|
|
raise CoreException(msg) from e
|
|
|
|
return existing
|
|
|
|
def retrieve_all(
|
|
self, resource: Resource, namespace: str, label_selectors: List[str] = None
|
|
) -> List[Dict]:
|
|
definitions: List[ResourceInstance] = []
|
|
|
|
try:
|
|
params = dict(namespace=namespace)
|
|
if label_selectors:
|
|
params["label_selector"] = ",".join(label_selectors)
|
|
resource_list = self.client.get(resource, **params)
|
|
for item in resource_list.items:
|
|
existing = self.client.get(
|
|
resource, name=item.metadata.name, namespace=namespace
|
|
)
|
|
definitions.append(existing.to_dict())
|
|
except (NotFoundError, MethodNotAllowedError):
|
|
pass
|
|
except Exception as e:
|
|
reason = e.body if hasattr(e, "body") else e
|
|
msg = "Failed to retrieve requested object: {0}".format(reason)
|
|
raise CoreException(msg) from e
|
|
|
|
return definitions
|
|
|
|
def find(
|
|
self,
|
|
kind: str,
|
|
api_version: str,
|
|
name: str = None,
|
|
namespace: Optional[str] = None,
|
|
label_selectors: Optional[List[str]] = None,
|
|
field_selectors: Optional[List[str]] = None,
|
|
wait: Optional[bool] = False,
|
|
wait_sleep: Optional[int] = 5,
|
|
wait_timeout: Optional[int] = 120,
|
|
state: Optional[str] = "present",
|
|
condition: Optional[Dict] = None,
|
|
hidden_fields: Optional[List] = None,
|
|
) -> Dict:
|
|
resource = self.find_resource(kind, api_version)
|
|
api_found = bool(resource)
|
|
if not api_found:
|
|
return dict(
|
|
resources=[],
|
|
msg='Failed to find API for resource with apiVersion "{0}" and kind "{1}"'.format(
|
|
api_version, kind
|
|
),
|
|
api_found=False,
|
|
)
|
|
|
|
if not label_selectors:
|
|
label_selectors = []
|
|
if not field_selectors:
|
|
field_selectors = []
|
|
|
|
result = {"resources": [], "api_found": True}
|
|
|
|
# With a timeout of 0 the waiter will do a single check and return, effectively not waiting.
|
|
if not wait:
|
|
wait_timeout = 0
|
|
|
|
if state == "present":
|
|
predicate = exists
|
|
else:
|
|
predicate = resource_absent
|
|
|
|
waiter = Waiter(self.client, resource, predicate)
|
|
|
|
# This is an initial check to get the resource or resources that we then need to wait on individually.
|
|
try:
|
|
success, resources, duration = waiter.wait(
|
|
timeout=wait_timeout,
|
|
sleep=wait_sleep,
|
|
name=name,
|
|
namespace=namespace,
|
|
label_selectors=label_selectors,
|
|
field_selectors=field_selectors,
|
|
)
|
|
except BadRequestError:
|
|
return result
|
|
except CoreException as e:
|
|
raise e
|
|
except Exception as e:
|
|
raise CoreException(
|
|
"Exception '{0}' raised while trying to get resource using (name={1}, namespace={2}, label_selectors={3}, field_selectors={4})".format(
|
|
e, name, namespace, label_selectors, field_selectors
|
|
)
|
|
)
|
|
|
|
# There is either no result or there is a List resource with no items
|
|
if (
|
|
not resources
|
|
or resources["kind"].endswith("List")
|
|
and not resources.get("items")
|
|
):
|
|
return result
|
|
|
|
instances = resources.get("items") or [resources]
|
|
|
|
if not wait:
|
|
result["resources"] = [
|
|
hide_fields(instance, hidden_fields) for instance in instances
|
|
]
|
|
return result
|
|
|
|
# Now wait for the specified state of any resource instances we have found.
|
|
waiter = get_waiter(self.client, resource, state=state, condition=condition)
|
|
for instance in instances:
|
|
name = instance["metadata"].get("name")
|
|
namespace = instance["metadata"].get("namespace")
|
|
success, res, duration = waiter.wait(
|
|
timeout=wait_timeout,
|
|
sleep=wait_sleep,
|
|
name=name,
|
|
namespace=namespace,
|
|
)
|
|
if not success:
|
|
raise CoreException(
|
|
"Failed to gather information about %s(s) even"
|
|
" after waiting for %s seconds" % (res.get("kind"), duration)
|
|
)
|
|
result["resources"].append(hide_fields(res, hidden_fields))
|
|
return result
|
|
|
|
def create(self, resource: Resource, definition: Dict) -> Dict:
|
|
namespace = definition["metadata"].get("namespace")
|
|
name = definition["metadata"].get("name")
|
|
|
|
if self._client_side_dry_run:
|
|
k8s_obj = _encode_stringdata(definition)
|
|
else:
|
|
try:
|
|
k8s_obj = self.client.create(
|
|
resource, definition, namespace=namespace
|
|
).to_dict()
|
|
except ConflictError:
|
|
# Some resources, like ProjectRequests, can't be created multiple times,
|
|
# because the resources that they create don't match their kind
|
|
# In this case we'll mark it as unchanged and warn the user
|
|
self.module.warn(
|
|
"{0} was not found, but creating it returned a 409 Conflict error. This can happen \
|
|
if the resource you are creating does not directly create a resource of the same kind.".format(
|
|
name
|
|
)
|
|
)
|
|
return dict()
|
|
except Exception as e:
|
|
reason = e.body if hasattr(e, "body") else e
|
|
msg = "Failed to create object: {0}".format(reason)
|
|
raise CoreException(msg) from e
|
|
return k8s_obj
|
|
|
|
def apply(
|
|
self,
|
|
resource: Resource,
|
|
definition: Dict,
|
|
existing: Optional[ResourceInstance] = None,
|
|
) -> Dict:
|
|
namespace = definition["metadata"].get("namespace")
|
|
|
|
server_side_apply = self.module.params.get("server_side_apply")
|
|
if server_side_apply:
|
|
requires("kubernetes", "19.15.0", reason="to use server side apply")
|
|
if self._client_side_dry_run:
|
|
ignored, patch = apply_object(resource, _encode_stringdata(definition))
|
|
if existing:
|
|
k8s_obj = dict_merge(existing.to_dict(), patch)
|
|
else:
|
|
k8s_obj = patch
|
|
else:
|
|
try:
|
|
params = {}
|
|
if server_side_apply:
|
|
params["server_side"] = True
|
|
params.update(server_side_apply)
|
|
k8s_obj = self.client.apply(
|
|
resource, definition, namespace=namespace, **params
|
|
).to_dict()
|
|
except Exception as e:
|
|
reason = e.body if hasattr(e, "body") else e
|
|
msg = "Failed to apply object: {0}".format(reason)
|
|
raise CoreException(msg) from e
|
|
return k8s_obj
|
|
|
|
def replace(
|
|
self,
|
|
resource: Resource,
|
|
definition: Dict,
|
|
existing: ResourceInstance,
|
|
) -> Dict:
|
|
append_hash = self.module.params.get("append_hash", False)
|
|
name = definition["metadata"].get("name")
|
|
namespace = definition["metadata"].get("namespace")
|
|
|
|
if self._client_side_dry_run:
|
|
k8s_obj = _encode_stringdata(definition)
|
|
else:
|
|
try:
|
|
k8s_obj = self.client.replace(
|
|
resource,
|
|
definition,
|
|
name=name,
|
|
namespace=namespace,
|
|
append_hash=append_hash,
|
|
).to_dict()
|
|
except Exception as e:
|
|
reason = e.body if hasattr(e, "body") else e
|
|
msg = "Failed to replace object: {0}".format(reason)
|
|
raise CoreException(msg) from e
|
|
return k8s_obj
|
|
|
|
def update(
|
|
self, resource: Resource, definition: Dict, existing: ResourceInstance
|
|
) -> Dict:
|
|
name = definition["metadata"].get("name")
|
|
namespace = definition["metadata"].get("namespace")
|
|
|
|
if self._client_side_dry_run:
|
|
k8s_obj = dict_merge(existing.to_dict(), _encode_stringdata(definition))
|
|
else:
|
|
exception = None
|
|
for merge_type in self.module.params.get("merge_type") or [
|
|
"strategic-merge",
|
|
"merge",
|
|
]:
|
|
try:
|
|
k8s_obj = self.patch_resource(
|
|
resource,
|
|
definition,
|
|
name,
|
|
namespace,
|
|
merge_type=merge_type,
|
|
)
|
|
exception = None
|
|
except CoreException as e:
|
|
exception = e
|
|
continue
|
|
break
|
|
if exception:
|
|
raise exception
|
|
return k8s_obj
|
|
|
|
def delete(
|
|
self,
|
|
resource: Resource,
|
|
definition: Dict,
|
|
existing: Optional[ResourceInstance] = None,
|
|
) -> Dict:
|
|
delete_options = self.module.params.get("delete_options")
|
|
label_selectors = self.module.params.get("label_selectors")
|
|
name = definition["metadata"].get("name")
|
|
namespace = definition["metadata"].get("namespace")
|
|
params = {}
|
|
|
|
if not exists(existing):
|
|
return {}
|
|
|
|
# Delete the object
|
|
if self._client_side_dry_run:
|
|
return {}
|
|
|
|
if name:
|
|
params["name"] = name
|
|
|
|
if namespace:
|
|
params["namespace"] = namespace
|
|
|
|
if label_selectors:
|
|
params["label_selector"] = ",".join(label_selectors)
|
|
|
|
if delete_options:
|
|
body = {
|
|
"apiVersion": "v1",
|
|
"kind": "DeleteOptions",
|
|
}
|
|
body.update(delete_options)
|
|
params["body"] = body
|
|
|
|
try:
|
|
k8s_obj = self.client.delete(resource, **params).to_dict()
|
|
except Exception as e:
|
|
reason = e.body if hasattr(e, "body") else e
|
|
msg = "Failed to delete object: {0}".format(reason)
|
|
raise CoreException(msg) from e
|
|
return k8s_obj
|
|
|
|
|
|
def diff_objects(
|
|
existing: Dict, new: Dict, hidden_fields: Optional[list] = None
|
|
) -> Tuple[bool, Dict]:
|
|
result = {}
|
|
diff = recursive_diff(existing, new)
|
|
if not diff:
|
|
return True, result
|
|
|
|
result["before"] = diff[0]
|
|
result["after"] = diff[1]
|
|
|
|
if list(result["after"].keys()) != ["metadata"] or list(
|
|
result["before"].keys()
|
|
) != ["metadata"]:
|
|
return False, result
|
|
|
|
# If only metadata.generation and metadata.resourceVersion changed, ignore it
|
|
ignored_keys = set(["generation", "resourceVersion"])
|
|
|
|
if not set(result["after"]["metadata"].keys()).issubset(ignored_keys):
|
|
return False, result
|
|
if not set(result["before"]["metadata"].keys()).issubset(ignored_keys):
|
|
return False, result
|
|
|
|
result["before"] = hide_fields(result["before"], hidden_fields)
|
|
result["after"] = hide_fields(result["after"], hidden_fields)
|
|
|
|
return True, result
|
|
|
|
|
|
def hide_fields(definition: dict, hidden_fields: Optional[list]) -> dict:
|
|
if not hidden_fields:
|
|
return definition
|
|
result = copy.deepcopy(definition)
|
|
for hidden_field in hidden_fields:
|
|
result = hide_field(result, hidden_field)
|
|
return result
|
|
|
|
|
|
# hide_field is not hugely sophisticated and designed to cope
|
|
# with e.g. status or metadata.managedFields rather than e.g.
|
|
# spec.template.spec.containers[0].env[3].value
|
|
def hide_field(definition: dict, hidden_field: str) -> dict:
|
|
split = hidden_field.split(".", 1)
|
|
if split[0] in definition:
|
|
if len(split) == 2:
|
|
definition[split[0]] = hide_field(definition[split[0]], split[1])
|
|
else:
|
|
del definition[split[0]]
|
|
return definition
|