From 39cb9a0981e5503eb2edbdf9549f94cccc39fdc2 Mon Sep 17 00:00:00 2001 From: abikouo <79859644+abikouo@users.noreply.github.com> Date: Tue, 30 Nov 2021 16:39:37 +0100 Subject: [PATCH] openshift admin prune deployments (#129) * openshift admin prune deployments * update test * update --- .../tasks/openshift_adm_prune_deployments.yml | 269 ++++++++++++++++++ molecule/default/verify.yml | 1 + .../openshift_adm_prune_deployments.py | 145 ++++++++++ .../openshift_adm_prune_deployments.py | 100 +++++++ 4 files changed, 515 insertions(+) create mode 100644 molecule/default/tasks/openshift_adm_prune_deployments.yml create mode 100644 plugins/module_utils/openshift_adm_prune_deployments.py create mode 100644 plugins/modules/openshift_adm_prune_deployments.py diff --git a/molecule/default/tasks/openshift_adm_prune_deployments.yml b/molecule/default/tasks/openshift_adm_prune_deployments.yml new file mode 100644 index 0000000..baa0241 --- /dev/null +++ b/molecule/default/tasks/openshift_adm_prune_deployments.yml @@ -0,0 +1,269 @@ +- name: Prune deployments + block: + - set_fact: + dc_name: "hello" + deployment_ns: "prune-deployments" + deployment_ns_2: "prune-deployments-2" + + + - name: Ensure namespace + community.okd.k8s: + kind: Namespace + name: '{{ deployment_ns }}' + + - name: Create deployment config + community.okd.k8s: + namespace: '{{ deployment_ns }}' + definition: + kind: DeploymentConfig + apiVersion: apps.openshift.io/v1 + metadata: + name: '{{ dc_name }}' + spec: + replicas: 1 + selector: + name: '{{ dc_name }}' + template: + metadata: + labels: + name: '{{ dc_name }}' + spec: + containers: + - name: hello-openshift + imagePullPolicy: IfNotPresent + image: python:3.7-alpine + command: [ "/bin/sh", "-c", "while true;do date;sleep 2s; done"] + wait: yes + + - name: prune deployments (no candidate DeploymentConfig) + community.okd.openshift_adm_prune_deployments: + namespace: "{{ deployment_ns }}" + register: test_prune + + - assert: + that: + - test_prune is not changed + - test_prune.replication_controllers | length == 0 + + - name: Update DeploymentConfig - set replicas to 0 + community.okd.k8s: + namespace: "{{ deployment_ns }}" + definition: + kind: DeploymentConfig + apiVersion: "apps.openshift.io/v1" + metadata: + name: "{{ dc_name }}" + spec: + replicas: 0 + selector: + name: "{{ dc_name }}" + template: + metadata: + labels: + name: "{{ dc_name }}" + spec: + containers: + - name: hello-openshift + imagePullPolicy: IfNotPresent + image: python:3.7-alpine + command: [ "/bin/sh", "-c", "while true;do date;sleep 2s; done"] + wait: yes + + - name: Wait for ReplicationController candidate for pruning + kubernetes.core.k8s_info: + kind: ReplicationController + namespace: "{{ deployment_ns }}" + register: result + retries: 10 + delay: 30 + until: + - result.resources.0.metadata.annotations["openshift.io/deployment.phase"] in ("Failed", "Complete") + + - name: Prune deployments - should delete 1 ReplicationController + community.okd.openshift_adm_prune_deployments: + namespace: "{{ deployment_ns }}" + check_mode: yes + register: test_prune + + - name: Read ReplicationController + kubernetes.core.k8s_info: + kind: ReplicationController + namespace: "{{ deployment_ns }}" + register: replications + + - name: Assert that Replication controller was not deleted + assert: + that: + - replications.resources | length == 1 + - 'replications.resources.0.metadata.name is match("{{ dc_name }}-*")' + + - name: Assure that candidate ReplicationController was found for pruning + assert: + that: + - test_prune is changed + - test_prune.replication_controllers | length == 1 + - test_prune.replication_controllers.0.metadata.name == replications.resources.0.metadata.name + - test_prune.replication_controllers.0.metadata.namespace == replications.resources.0.metadata.namespace + + - name: Prune deployments - keep younger than 45min (check_mode) + community.okd.openshift_adm_prune_deployments: + keep_younger_than: 45 + namespace: "{{ deployment_ns }}" + check_mode: true + register: keep_younger + + - name: assert no candidate was found + assert: + that: + - keep_younger is not changed + - keep_younger.replication_controllers == [] + + - name: Ensure second namespace is created + community.okd.k8s: + kind: Namespace + name: '{{ deployment_ns_2 }}' + + - name: Create deployment config from 2nd namespace + community.okd.k8s: + namespace: '{{ deployment_ns_2 }}' + definition: + kind: DeploymentConfig + apiVersion: apps.openshift.io/v1 + metadata: + name: '{{ dc_name }}2' + spec: + replicas: 1 + selector: + name: '{{ dc_name }}2' + template: + metadata: + labels: + name: '{{ dc_name }}2' + spec: + containers: + - name: hello-openshift + imagePullPolicy: IfNotPresent + image: python:3.7-alpine + command: [ "/bin/sh", "-c", "while true;do date;sleep 2s; done"] + wait: yes + + - name: Stop deployment config - replicas = 0 + community.okd.k8s: + namespace: '{{ deployment_ns_2 }}' + definition: + kind: DeploymentConfig + apiVersion: apps.openshift.io/v1 + metadata: + name: '{{ dc_name }}2' + spec: + replicas: 0 + selector: + name: '{{ dc_name }}2' + template: + metadata: + labels: + name: '{{ dc_name }}2' + spec: + containers: + - name: hello-openshift + imagePullPolicy: IfNotPresent + image: python:3.7-alpine + command: [ "/bin/sh", "-c", "while true;do date;sleep 2s; done"] + wait: yes + + - name: Wait for ReplicationController candidate for pruning + kubernetes.core.k8s_info: + kind: ReplicationController + namespace: "{{ deployment_ns_2 }}" + register: result + retries: 10 + delay: 30 + until: + - result.resources.0.metadata.annotations["openshift.io/deployment.phase"] in ("Failed", "Complete") + + # Prune from one namespace should not have any effect on others namespaces + - name: Prune deployments from 2nd namespace + community.okd.openshift_adm_prune_deployments: + namespace: "{{ deployment_ns_2 }}" + check_mode: yes + register: test_prune + + - name: Assure that candidate ReplicationController was found for pruning + assert: + that: + - test_prune is changed + - test_prune.replication_controllers | length == 1 + - "test_prune.replication_controllers.0.metadata.namespace == deployment_ns_2" + + # Prune without namespace option + - name: Prune from all namespace should update more deployments + community.okd.openshift_adm_prune_deployments: + check_mode: yes + register: no_namespace_prune + + - name: Assure multiple ReplicationController were found for pruning + assert: + that: + - no_namespace_prune is changed + - no_namespace_prune.replication_controllers | length == 2 + + # Execute Prune from 2nd namespace + - name: Read ReplicationController before Prune operation + kubernetes.core.k8s_info: + kind: ReplicationController + namespace: "{{ deployment_ns_2 }}" + register: replications + + - assert: + that: + - replications.resources | length == 1 + + - name: Prune DeploymentConfig from 2nd namespace + community.okd.openshift_adm_prune_deployments: + namespace: "{{ deployment_ns_2 }}" + register: _prune + + - name: Assert DeploymentConfig was deleted + assert: + that: + - _prune is changed + - _prune.replication_controllers | length == 1 + - _prune.replication_controllers.0.details.name == replications.resources.0.metadata.name + + # Execute Prune without namespace option + - name: Read ReplicationController before Prune operation + kubernetes.core.k8s_info: + kind: ReplicationController + namespace: "{{ deployment_ns }}" + register: replications + + - assert: + that: + - replications.resources | length == 1 + + - name: Prune from all namespace should update more deployments + community.okd.openshift_adm_prune_deployments: + register: _prune + + - name: Assure multiple ReplicationController were found for pruning + assert: + that: + - _prune is changed + - _prune.replication_controllers | length > 0 + + always: + - name: Delete 1st namespace + community.okd.k8s: + state: absent + kind: Namespace + name: "{{ deployment_ns }}" + ignore_errors: yes + when: deployment_ns is defined + + - name: Delete 2nd namespace + community.okd.k8s: + state: absent + kind: Namespace + name: "{{ deployment_ns_2 }}" + ignore_errors: yes + when: deployment_ns_2 is defined \ No newline at end of file diff --git a/molecule/default/verify.yml b/molecule/default/verify.yml index 7fcc95b..8ff0329 100644 --- a/molecule/default/verify.yml +++ b/molecule/default/verify.yml @@ -63,6 +63,7 @@ - import_tasks: tasks/openshift_auth.yml - import_tasks: tasks/openshift_adm_prune_auth_clusterroles.yml - import_tasks: tasks/openshift_adm_prune_auth_roles.yml + - import_tasks: tasks/openshift_adm_prune_deployments.yml - import_tasks: tasks/openshift_route.yml - block: - name: Create namespace diff --git a/plugins/module_utils/openshift_adm_prune_deployments.py b/plugins/module_utils/openshift_adm_prune_deployments.py new file mode 100644 index 0000000..8e2cfda --- /dev/null +++ b/plugins/module_utils/openshift_adm_prune_deployments.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +from datetime import datetime, timezone +import traceback + +from ansible.module_utils._text import to_native + +try: + from ansible_collections.kubernetes.core.plugins.module_utils.common import ( + K8sAnsibleMixin, + get_api_client, + ) + HAS_KUBERNETES_COLLECTION = True +except ImportError as e: + HAS_KUBERNETES_COLLECTION = False + k8s_collection_import_exception = e + K8S_COLLECTION_ERROR = traceback.format_exc() + +try: + from kubernetes import client + from kubernetes.dynamic.exceptions import DynamicApiError +except ImportError as e: + pass + + +def get_deploymentconfig_for_replicationcontroller(replica_controller): + # DeploymentConfigAnnotation is an annotation name used to correlate a deployment with the + # DeploymentConfig on which the deployment is based. + # This is set on replication controller pod template by deployer controller. + DeploymentConfigAnnotation = "openshift.io/deployment-config.name" + try: + deploymentconfig_name = replica_controller['metadata']['annotations'].get(DeploymentConfigAnnotation) + if deploymentconfig_name is None or deploymentconfig_name == "": + return None + return deploymentconfig_name + except Exception: + return None + + +class OpenShiftAdmPruneDeployment(K8sAnsibleMixin): + def __init__(self, module): + self.module = module + self.fail_json = self.module.fail_json + self.exit_json = self.module.exit_json + + if not HAS_KUBERNETES_COLLECTION: + self.module.fail_json( + msg="The kubernetes.core collection must be installed", + exception=K8S_COLLECTION_ERROR, + error=to_native(k8s_collection_import_exception), + ) + + super(OpenShiftAdmPruneDeployment, self).__init__(self.module) + + self.params = self.module.params + self.check_mode = self.module.check_mode + self.client = get_api_client(self.module) + + def filter_replication_controller(self, replicacontrollers): + def _deployment(obj): + return get_deploymentconfig_for_replicationcontroller(obj) is not None + + def _zeroReplicaSize(obj): + return obj['spec']['replicas'] == 0 and obj['status']['replicas'] == 0 + + def _complete_failed(obj): + DeploymentStatusAnnotation = "openshift.io/deployment.phase" + try: + # validate that replication controller status is either 'Complete' or 'Failed' + deployment_phase = obj['metadata']['annotations'].get(DeploymentStatusAnnotation) + return deployment_phase in ('Failed', 'Complete') + except Exception: + return False + + def _younger(obj): + creation_timestamp = datetime.strptime(obj['metadata']['creationTimestamp'], '%Y-%m-%dT%H:%M:%SZ') + now = datetime.now(timezone.utc).replace(tzinfo=None) + age = (now - creation_timestamp).seconds / 60 + return age > self.params['keep_younger_than'] + + def _orphan(obj): + try: + # verify if the deploymentconfig associated to the replication controller is still existing + deploymentconfig_name = get_deploymentconfig_for_replicationcontroller(obj) + params = dict( + kind="DeploymentConfig", + api_version="apps.openshift.io/v1", + name=deploymentconfig_name, + namespace=obj["metadata"]["name"], + ) + exists = self.kubernetes_facts(**params) + return not (exists.get['api_found'] and len(exists['resources']) > 0) + except Exception: + return False + + predicates = [_deployment, _zeroReplicaSize, _complete_failed] + if self.params['orphans']: + predicates.append(_orphan) + if self.params['keep_younger_than']: + predicates.append(_younger) + + results = replicacontrollers.copy() + for pred in predicates: + results = filter(pred, results) + return list(results) + + def execute(self): + # list replicationcontroller candidate for pruning + kind = 'ReplicationController' + api_version = 'v1' + resource = self.find_resource(kind=kind, api_version=api_version, fail=True) + + # Get ReplicationController + params = dict( + kind=kind, + api_version="v1", + namespace=self.params.get("namespace"), + ) + candidates = self.kubernetes_facts(**params) + candidates = self.filter_replication_controller(candidates["resources"]) + + if len(candidates) == 0: + self.exit_json(changed=False, replication_controllers=[]) + + changed = True + delete_options = client.V1DeleteOptions(propagation_policy='Background') + replication_controllers = [] + for replica in candidates: + try: + result = replica + if not self.check_mode: + name = replica["metadata"]["name"] + namespace = replica["metadata"]["namespace"] + result = resource.delete(name=name, namespace=namespace, body=delete_options).to_dict() + replication_controllers.append(result) + except DynamicApiError as exc: + msg = "Failed to delete ReplicationController {namespace}/{name} due to: {msg}".format(namespace=namespace, name=name, msg=exc.body) + self.fail_json(msg=msg) + except Exception as e: + msg = "Failed to delete ReplicationController {namespace}/{name} due to: {msg}".format(namespace=namespace, name=name, msg=to_native(e)) + self.fail_json(msg=msg) + self.exit_json(changed=changed, replication_controllers=replication_controllers) diff --git a/plugins/modules/openshift_adm_prune_deployments.py b/plugins/modules/openshift_adm_prune_deployments.py new file mode 100644 index 0000000..94621ca --- /dev/null +++ b/plugins/modules/openshift_adm_prune_deployments.py @@ -0,0 +1,100 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2021, Red Hat +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +DOCUMENTATION = r''' + +module: openshift_adm_prune_deployments + +short_description: Remove old completed and failed deployment configs + +version_added: "2.2.0" + +author: + - Aubin Bikouo (@abikouo) + +description: + - This module allow administrators to remove old completed and failed deployment configs. + - Analogous to C(oc adm prune deployments). + +extends_documentation_fragment: + - kubernetes.core.k8s_auth_options + +options: + namespace: + description: + - Use to specify namespace for deployments to be deleted. + type: str + keep_younger_than: + description: + - Specify the minimum age (in minutes) of a deployment for it to be considered a candidate for pruning. + type: int + orphans: + description: + - If C(true), prune all deployments where the associated DeploymentConfig no longer exists, + the status is complete or failed, and the replica size is C(0). + type: bool + default: False + +requirements: + - python >= 3.6 + - kubernetes >= 12.0.0 +''' + +EXAMPLES = r''' +- name: Prune Deployments from testing namespace + community.okd.openshift_adm_prune_deployments: + namespace: testing + +- name: Prune orphans deployments, keep younger than 2hours + community.okd.openshift_adm_prune_deployments: + orphans: True + keep_younger_than: 120 +''' + + +RETURN = r''' +replication_controllers: + type: list + description: list of replication controllers candidate for pruning. + returned: always +''' + +import copy + +from ansible.module_utils.basic import AnsibleModule +try: + from ansible_collections.kubernetes.core.plugins.module_utils.args_common import AUTH_ARG_SPEC +except ImportError as e: + pass + + +def argument_spec(): + args = copy.deepcopy(AUTH_ARG_SPEC) + args.update( + dict( + namespace=dict(type='str',), + keep_younger_than=dict(type='int',), + orphans=dict(type='bool', default=False), + ) + ) + return args + + +def main(): + module = AnsibleModule(argument_spec=argument_spec(), supports_check_mode=True) + + from ansible_collections.community.okd.plugins.module_utils.openshift_adm_prune_deployments import ( + OpenShiftAdmPruneDeployment) + + adm_prune_deployments = OpenShiftAdmPruneDeployment(module) + adm_prune_deployments.execute() + + +if __name__ == '__main__': + main()