diff --git a/molecule/default/converge.yml b/molecule/default/converge.yml index 944aefa6..f6dcb454 100644 --- a/molecule/default/converge.yml +++ b/molecule/default/converge.yml @@ -29,6 +29,7 @@ - include_tasks: tasks/log.yml - include_tasks: tasks/cluster_info.yml - include_tasks: tasks/access_review.yml + - include_tasks: tasks/rollback.yml roles: - helm diff --git a/molecule/default/tasks/rollback.yml b/molecule/default/tasks/rollback.yml new file mode 100644 index 00000000..743ff53c --- /dev/null +++ b/molecule/default/tasks/rollback.yml @@ -0,0 +1,217 @@ +--- +- block: + - name: Set variables + set_fact: + namespace: "testingrollback" + + - name: Create a namespace + k8s: + name: "{{ namespace }}" + kind: Namespace + api_version: v1 + apply: no + register: output + + - name: show output + debug: + var: output + + - name: Create a deployment + k8s: + state: present + wait: yes + inline: &deploy + apiVersion: apps/v1 + kind: Deployment + metadata: + name: nginx-deploy + labels: + app: nginx + namespace: "{{ namespace }}" + spec: + replicas: 3 + selector: + matchLabels: + app: nginx + template: + metadata: + labels: + app: nginx + spec: + containers: + - name: nginx + image: nginx:1.17 + ports: + - containerPort: 80 + register: output + + - name: Show output + debug: + var: output + + - name: Crash the existing deployment + k8s: + state: present + wait: yes + definition: + apiVersion: apps/v1 + kind: Deployment + metadata: + name: nginx-deploy + labels: + app: nginx + namespace: "{{ namespace }}" + spec: + replicas: 3 + selector: + matchLabels: + app: nginx + template: + metadata: + labels: + app: nginx + spec: + containers: + - name: nginx + image: nginx:1.0.23449928384992872784 + ports: + - containerPort: 80 + ignore_errors: yes + register: output + + - name: Rolling Back the crashed deployment + k8s_rollback: + api_version: apps/v1 + kind: Deployment + name: nginx-deploy + namespace: "{{ namespace }}" + when: output.failed + register: output + + - name: Show output + debug: + var: output + + - name: Create a DaemonSet + k8s: + state: present + wait: yes + definition: + apiVersion: apps/v1 + kind: DaemonSet + metadata: + name: fluentd-elasticsearch + namespace: "{{ namespace }}" + labels: + k8s-app: fluentd-logging + spec: + selector: + matchLabels: + name: fluentd-elasticsearch + template: + metadata: + labels: + name: fluentd-elasticsearch + spec: + tolerations: + - key: node-role.kubernetes.io/master + effect: NoSchedule + containers: + - name: fluentd-elasticsearch + image: quay.io/fluentd_elasticsearch/fluentd:v2.5.2 + resources: + limits: + memory: 200Mi + requests: + cpu: 100m + memory: 200Mi + volumeMounts: + - name: varlog + mountPath: /var/log + - name: varlibdockercontainers + mountPath: /var/lib/docker/containers + readOnly: true + terminationGracePeriodSeconds: 30 + volumes: + - name: varlog + hostPath: + path: /var/log + - name: varlibdockercontainers + hostPath: + path: /var/lib/docker/containers + register: output + + - name: Show output + debug: + var: output + + - name: Crash the existing DaemonSet + k8s: + state: present + wait: yes + definition: + apiVersion: apps/v1 + kind: DaemonSet + metadata: + name: fluentd-elasticsearch + namespace: "{{ namespace }}" + labels: + k8s-app: fluentd-logging + spec: + selector: + matchLabels: + name: fluentd-elasticsearch + template: + metadata: + labels: + name: fluentd-elasticsearch + spec: + tolerations: + - key: node-role.kubernetes.io/master + effect: NoSchedule + containers: + - name: fluentd-elasticsearch + image: quay.io/fluentd_elasticsearch/fluentd:v2734894949 + resources: + limits: + memory: 200Mi + requests: + cpu: 100m + memory: 200Mi + volumeMounts: + - name: varlog + mountPath: /var/log + - name: varlibdockercontainers + mountPath: /var/lib/docker/containers + readOnly: true + terminationGracePeriodSeconds: 30 + volumes: + - name: varlog + hostPath: + path: /var/log + - name: varlibdockercontainers + hostPath: + path: /var/lib/docker/containers + ignore_errors: yes + register: output + + - name: Rolling Back the crashed DaemonSet + k8s_rollback: + api_version: apps/v1 + kind: DaemonSet + name: fluentd-elasticsearch + namespace: "{{ namespace }}" + when: output.failed + register: output + + - name: Show output + debug: + var: output + + always: + - name: Delete {{ namespace }} namespace + k8s: + name: "{{ namespace }}" + kind: Namespace + api_version: v1 + state: absent diff --git a/plugins/modules/k8s_rollback.py b/plugins/modules/k8s_rollback.py new file mode 100644 index 00000000..86d5af7c --- /dev/null +++ b/plugins/modules/k8s_rollback.py @@ -0,0 +1,224 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2020, Julien Huon <@julienhuon> Institut National de l'Audiovisuel +# 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: k8s_rollback +short_description: Rollback Kubernetes (K8S) Deployments and DaemonSets +version_added: "1.0.0" +author: + - "Julien Huon (@julienhuon)" +description: + - Use the OpenShift Python client to perform the Rollback. + - Authenticate using either a config file, certificates, password or token. + - Similar to the kubectl rollout undo command. +options: + label_selectors: + description: List of label selectors to use to filter results. + type: list + elements: str + field_selectors: + description: List of field selectors to use to filter results. + type: list + elements: str +extends_documentation_fragment: + - community.kubernetes.k8s_auth_options + - community.kubernetes.k8s_name_options +requirements: + - "python >= 2.7" + - "openshift >= 0.6" + - "PyYAML >= 3.11" +''' + +EXAMPLES = r''' +- name: Rollback a failed deployment + community.kubernetes.k8s_rollback: + api_version: apps/v1 + kind: Deployment + name: web + namespace: testing +''' + +RETURN = r''' +rollback_info: + description: + - The rollbacked object. + returned: success + type: complex + contains: + api_version: + description: The versioned schema of this representation of an object. + returned: success + type: str + code: + description: The HTTP Code of the response + returned: success + type: str + kind: + description: Status + returned: success + type: str + metadata: + description: + - Standard object metadata. + - Includes name, namespace, annotations, labels, etc. + returned: success + type: dict + status: + description: Current status details for the object. + returned: success + type: dict +''' + +import copy + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.community.kubernetes.plugins.module_utils.common import ( + K8sAnsibleMixin, AUTH_ARG_SPEC, NAME_ARG_SPEC) + + +class KubernetesRollbackModule(K8sAnsibleMixin): + + def __init__(self): + module = AnsibleModule( + argument_spec=self.argspec, + supports_check_mode=True, + ) + self.module = module + self.params = self.module.params + self.fail_json = self.module.fail_json + self.fail = self.module.fail_json + self.exit_json = self.module.exit_json + super(KubernetesRollbackModule, self).__init__() + + self.kind = self.params['kind'] + self.api_version = self.params['api_version'] + self.name = self.params['name'] + self.namespace = self.params['namespace'] + self.managed_resource = {} + + if self.kind == "DaemonSet": + self.managed_resource['kind'] = "ControllerRevision" + self.managed_resource['api_version'] = "apps/v1" + elif self.kind == "Deployment": + self.managed_resource['kind'] = "ReplicaSet" + self.managed_resource['api_version'] = "apps/v1" + else: + self.fail(msg="Cannot perform rollback on resource of kind {0}".format(self.kind)) + + def execute_module(self): + results = [] + self.client = self.get_api_client() + + resources = self.kubernetes_facts(self.kind, + self.api_version, + self.name, + self.namespace, + self.params['label_selectors'], + self.params['field_selectors']) + + for resource in resources['resources']: + result = self.perform_action(resource) + results.append(result) + + self.exit_json(**{ + 'changed': True, + 'rollback_info': results + }) + + def perform_action(self, resource): + if self.kind == "DaemonSet": + current_revision = resource['metadata']['generation'] + elif self.kind == "Deployment": + current_revision = resource['metadata']['annotations']['deployment.kubernetes.io/revision'] + + managed_resources = self.kubernetes_facts(self.managed_resource['kind'], + self.managed_resource['api_version'], + '', + self.namespace, + resource['spec'] + ['selector'] + ['matchLabels'], + '') + + prev_managed_resource = get_previous_revision(managed_resources['resources'], + current_revision) + + if self.kind == "Deployment": + del prev_managed_resource['spec']['template']['metadata']['labels']['pod-template-hash'] + + resource_patch = [{ + "op": "replace", + "path": "/spec/template", + "value": prev_managed_resource['spec']['template'] + }, { + "op": "replace", + "path": "/metadata/annotations", + "value": { + "deployment.kubernetes.io/revision": prev_managed_resource['metadata']['annotations']['deployment.kubernetes.io/revision'] + } + }] + + api_target = 'deployments' + content_type = 'application/json-patch+json' + elif self.kind == "DaemonSet": + resource_patch = prev_managed_resource["data"] + + api_target = 'daemonsets' + content_type = 'application/strategic-merge-patch+json' + + rollback = self.client.request("PATCH", + "/apis/{0}/namespaces/{1}/{2}/{3}" + .format(self.api_version, + self.namespace, + api_target, + self.name), + body=resource_patch, + content_type=content_type) + + result = {'changed': True} + result['method'] = 'patch' + result['body'] = resource_patch + result['resources'] = rollback.to_dict() + return result + + @property + def argspec(self): + args = copy.deepcopy(AUTH_ARG_SPEC) + args.update(NAME_ARG_SPEC) + args.update( + dict( + label_selectors=dict(type='list', elements='str', default=[]), + field_selectors=dict(type='list', elements='str', default=[]), + ) + ) + return args + + +def get_previous_revision(all_resources, current_revision): + for resource in all_resources: + if resource['kind'] == 'ReplicaSet': + if int(resource['metadata'] + ['annotations'] + ['deployment.kubernetes.io/revision']) == int(current_revision) - 1: + return resource + elif resource['kind'] == 'ControllerRevision': + if int(resource['metadata'] + ['annotations'] + ['deprecated.daemonset.template.generation']) == int(current_revision) - 1: + return resource + return None + + +def main(): + KubernetesRollbackModule().execute_module() + + +if __name__ == '__main__': + main()