diff --git a/README.md b/README.md index 965e98ef..2e5cd645 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,8 @@ Click on the name of a plugin or module to view that content's documentation: - [k8s_info](https://docs.ansible.com/ansible/latest/modules/k8s_info_module.html) - [k8s_scale](https://docs.ansible.com/ansible/latest/modules/k8s_scale_module.html) - [k8s_service](https://docs.ansible.com/ansible/latest/modules/k8s_service_module.html) + - [helm_cli](https://docs.ansible.com/ansible/latest/modules/helm_cli_module.html) + - [helm_cli_info](https://docs.ansible.com/ansible/latest/modules/helm_cli_info_module.html) ## Installation and Usage diff --git a/molecule/default/converge.yml b/molecule/default/converge.yml index b4402410..ebac0811 100644 --- a/molecule/default/converge.yml +++ b/molecule/default/converge.yml @@ -2,7 +2,6 @@ - name: Converge hosts: localhost connection: local - gather_facts: false collections: - community.kubernetes @@ -27,3 +26,6 @@ - include_tasks: tasks/full.yml - include_tasks: tasks/exec.yml - include_tasks: tasks/log.yml + + roles: + - helm diff --git a/molecule/default/roles/helm/defaults/main.yml b/molecule/default/roles/helm/defaults/main.yml new file mode 100644 index 00000000..b5a2a31f --- /dev/null +++ b/molecule/default/roles/helm/defaults/main.yml @@ -0,0 +1,15 @@ +--- +helm_archive_name: "helm-{{ helm_version }}-{{ ansible_system | lower }}-amd64.tar.gz" +helm_binary: "/tmp/helm/{{ ansible_system | lower }}-amd64/helm" +helm_namespace: helm + +tiller_namespace: tiller +tiller_cluster_role: cluster-admin + +chart_test: "nginx-ingress" +chart_test_version: 1.32.0 +chart_test_version_upgrade: 1.33.0 +chart_test_repo: "https://kubernetes-charts.storage.googleapis.com" +chart_test_git_repo: "http://github.com/helm/charts.git" +chart_test_values: + revisionHistoryLimit: 0 diff --git a/molecule/default/roles/helm/tasks/install.yml b/molecule/default/roles/helm/tasks/install.yml new file mode 100644 index 00000000..8030aac7 --- /dev/null +++ b/molecule/default/roles/helm/tasks/install.yml @@ -0,0 +1,11 @@ +--- +- name: Init Helm folders + file: + path: /tmp/helm/ + state: directory + +- name: Unarchive Helm binary + unarchive: + src: 'https://get.helm.sh/{{ helm_archive_name }}' + dest: /tmp/helm/ + remote_src: yes diff --git a/molecule/default/roles/helm/tasks/main.yml b/molecule/default/roles/helm/tasks/main.yml new file mode 100644 index 00000000..13c936a2 --- /dev/null +++ b/molecule/default/roles/helm/tasks/main.yml @@ -0,0 +1,8 @@ +--- +- name: Run tests + include_tasks: run_test.yml + loop_control: + loop_var: helm_version + with_items: + - "v2.16.0" + - "v3.1.2" diff --git a/molecule/default/roles/helm/tasks/run_test.yml b/molecule/default/roles/helm/tasks/run_test.yml new file mode 100644 index 00000000..179663b0 --- /dev/null +++ b/molecule/default/roles/helm/tasks/run_test.yml @@ -0,0 +1,43 @@ +--- +- name: Ensure helm is not install + file: + path: "{{ item }}" + state: absent + with_items: + - "/tmp/helm" + +- name: Check failed if helm is not install + include_tasks: test_helm_not_installed.yml + +- name: "Install {{ helm_version }}" + include_tasks: install.yml + +- name: Check if failed when tiller is not installed + include_tasks: test_missing_tiller.yml + when: "helm_version.startswith('v2')" + +- name: Install tiller + include_tasks: tiller.yml + when: "helm_version.startswith('v2')" + +- name: Deploy charts + include_tasks: "tests_chart/{{ test_chart_type }}.yml" + loop_control: + loop_var: test_chart_type + with_items: + - from_local_path + - from_repository + - from_url + +- name: Clean tiller + include_tasks: tiller.yml + vars: + status: absent + when: "helm_version.startswith('v2')" + +- name: Clean helm install + file: + path: "{{ item }}" + state: absent + with_items: + - "/tmp/helm/" diff --git a/molecule/default/roles/helm/tasks/test_helm_not_installed.yml b/molecule/default/roles/helm/tasks/test_helm_not_installed.yml new file mode 100644 index 00000000..6cda87d7 --- /dev/null +++ b/molecule/default/roles/helm/tasks/test_helm_not_installed.yml @@ -0,0 +1,14 @@ +--- +- name: Failed test when helm is not install + helm_cli: + binary_path: "{{ helm_binary}}_fake" + name: test + chart_ref: "{{ chart_test }}" + ignore_errors: yes + register: helm_missing_binary + +- name: Assert that helm is not installed + assert: + that: + - helm_missing_binary is failed + - "'No such file or directory' in helm_missing_binary.msg" diff --git a/molecule/default/roles/helm/tasks/test_missing_tiller.yml b/molecule/default/roles/helm/tasks/test_missing_tiller.yml new file mode 100644 index 00000000..ce1e6b85 --- /dev/null +++ b/molecule/default/roles/helm/tasks/test_missing_tiller.yml @@ -0,0 +1,15 @@ +--- +- name: Failed test when tiller is missing + helm_cli: + binary_path: "{{ helm_binary}}" + name: test + chart_ref: "{{ chart_test }}" + tiller_namespace: helm + ignore_errors: yes + register: missing_tiller + +- name: Assert that tiller is missing + assert: + that: + - missing_tiller is failed + - "'could not find tiller' in missing_tiller.msg" diff --git a/molecule/default/roles/helm/tasks/tests_chart.yml b/molecule/default/roles/helm/tasks/tests_chart.yml new file mode 100644 index 00000000..e196b3f0 --- /dev/null +++ b/molecule/default/roles/helm/tasks/tests_chart.yml @@ -0,0 +1,185 @@ +--- +- name: Create helm namespace + k8s: + api_version: v1 + kind: Namespace + name: "{{ helm_namespace }}" + wait: true + +- name: "Install {{ chart_test }} from {{ source }}" + helm_cli: + binary_path: "{{ helm_binary}}" + name: test + chart_ref: "{{ chart_source }}" + chart_version: "{{ chart_source_version | default(omit)}}" + namespace: "{{ helm_namespace }}" + tiller_namespace: "{{ tiller_namespace }}" + register: install + +- name: "Assert that {{ chart_test }} chart is installed from {{ source }}" + assert: + that: + - install is changed + - install.status.chart == "{{ chart_test }}-{{ chart_test_version }}" + - install.status.status | lower == 'deployed' + +- name: Check idempotency + helm_cli: + binary_path: "{{ helm_binary}}" + name: test + chart_ref: "{{ chart_source }}" + chart_version: "{{ chart_source_version | default(omit)}}" + namespace: "{{ helm_namespace }}" + tiller_namespace: "{{ tiller_namespace }}" + register: install + +- name: Assert idempotency + assert: + that: + - install is not changed + - install.status.chart == "{{ chart_test }}-{{ chart_test_version }}" + - install.status.status | lower == 'deployed' + +- name: "Add vars to {{ chart_test }} from {{ source }}" + helm_cli: + binary_path: "{{ helm_binary}}" + name: test + chart_ref: "{{ chart_source }}" + chart_version: "{{ chart_source_version | default(omit)}}" + namespace: "{{ helm_namespace }}" + tiller_namespace: "{{ tiller_namespace }}" + values: "{{ chart_test_values }}" + register: install + +- name: "Assert that {{ chart_test }} chart is upgraded with new var from {{ source }}" + assert: + that: + - install is changed + - install.status.status | lower == 'deployed' + - install.status.chart == "{{ chart_test }}-{{ chart_test_version }}" + - "install.status['values'].revisionHistoryLimit == 0" + +- name: Check idempotency after add vars + helm_cli: + binary_path: "{{ helm_binary}}" + name: test + chart_ref: "{{ chart_source }}" + chart_version: "{{ chart_source_version | default(omit)}}" + namespace: "{{ helm_namespace }}" + tiller_namespace: "{{ tiller_namespace }}" + values: "{{ chart_test_values }}" + register: install + +- name: Assert idempotency after add vars + assert: + that: + - install is not changed + - install.status.status | lower == 'deployed' + - install.status.chart == "{{ chart_test }}-{{ chart_test_version }}" + - "install.status['values'].revisionHistoryLimit == 0" + +- name: "Remove Vars to {{ chart_test }} from {{ source }}" + helm_cli: + binary_path: "{{ helm_binary}}" + name: test + chart_ref: "{{ chart_source }}" + chart_version: "{{ chart_source_version | default(omit)}}" + namespace: "{{ helm_namespace }}" + tiller_namespace: "{{ tiller_namespace }}" + register: install + +- name: "Assert that {{ chart_test }} chart is upgraded with new var from {{ source }}" + assert: + that: + - install is changed + - install.status.status | lower == 'deployed' + - install.status.chart == "{{ chart_test }}-{{ chart_test_version }}" + - install.status['values'] == {} + +- name: Check idempotency after remove vars + helm_cli: + binary_path: "{{ helm_binary}}" + name: test + chart_ref: "{{ chart_source }}" + chart_version: "{{ chart_source_version | default(omit)}}" + namespace: "{{ helm_namespace }}" + tiller_namespace: "{{ tiller_namespace }}" + register: install + +- name: Assert idempotency after remove vars + assert: + that: + - install is not changed + - install.status.status | lower == 'deployed' + - install.status.chart == "{{ chart_test }}-{{ chart_test_version }}" + - install.status['values'] == {} + +- name: "Upgrade {{ chart_test }} from {{ source }}" + helm_cli: + binary_path: "{{ helm_binary}}" + name: test + chart_ref: "{{ chart_source_upgrade | default(chart_source) }}" + chart_version: "{{ chart_source_version_upgrade | default(omit)}}" + namespace: "{{ helm_namespace }}" + tiller_namespace: "{{ tiller_namespace }}" + register: install + +- name: "Assert that {{ chart_test }} chart is upgraded with new version from {{ source }}" + assert: + that: + - install is changed + - install.status.status | lower == 'deployed' + - install.status.chart == "{{ chart_test }}-{{ chart_test_version_upgrade }}" + +- name: Check idempotency after upgrade + helm_cli: + binary_path: "{{ helm_binary}}" + name: test + chart_ref: "{{ chart_source_upgrade | default(chart_source) }}" + chart_version: "{{ chart_source_version_upgrade | default(omit)}}" + namespace: "{{ helm_namespace }}" + tiller_namespace: "{{ tiller_namespace }}" + register: install + +- name: Assert idempotency after upgrade + assert: + that: + - install is not changed + - install.status.status | lower == 'deployed' + - install.status.chart == "{{ chart_test }}-{{ chart_test_version_upgrade }}" + +- name: "Remove {{ chart_test }} from {{ source }}" + helm_cli: + binary_path: "{{ helm_binary}}" + state: absent + name: test + namespace: "{{ helm_namespace }}" + tiller_namespace: "{{ tiller_namespace }}" + register: install + +- name: "Assert that {{ chart_test }} chart is remove from {{ source }}" + assert: + that: + - install is changed + +- name: Check idempotency after remove + helm_cli: + binary_path: "{{ helm_binary}}" + state: absent + name: test + namespace: "{{ helm_namespace }}" + tiller_namespace: "{{ tiller_namespace }}" + register: install + +- name: Assert idempotency + assert: + that: + - install is not changed + +- name: Remove helm namespace + k8s: + api_version: v1 + kind: Namespace + name: "{{ helm_namespace }}" + state: absent + wait: true diff --git a/molecule/default/roles/helm/tasks/tests_chart/from_local_path.yml b/molecule/default/roles/helm/tasks/tests_chart/from_local_path.yml new file mode 100644 index 00000000..8b874718 --- /dev/null +++ b/molecule/default/roles/helm/tasks/tests_chart/from_local_path.yml @@ -0,0 +1,27 @@ +--- +- name: Git clone stable repo + git: + repo: "{{ chart_test_git_repo }}" + dest: /tmp/helm_test_repo + version: 631eb8413f6728962439488f48d7d6fbb954a6db + +- name: Git clone stable repo upgrade + git: + repo: "{{ chart_test_git_repo }}" + dest: /tmp/helm_test_repo_upgrade + version: d37b5025ffc8be49699898369fbb59661e2a8ffb + +- name: Install Chart from local path + include_tasks: "../tests_chart.yml" + vars: + source: local_path + chart_source: "/tmp/helm_test_repo/stable/{{ chart_test }}/" + chart_source_upgrade: "/tmp/helm_test_repo_upgrade/stable/{{ chart_test }}/" + +- name: Remove clone repos + file: + path: "{{ item }}" + state: absent + with_items: + - /tmp/helm_test_repo + - /tmp/helm_test_repo_upgrade diff --git a/molecule/default/roles/helm/tasks/tests_chart/from_repository.yml b/molecule/default/roles/helm/tasks/tests_chart/from_repository.yml new file mode 100644 index 00000000..73755167 --- /dev/null +++ b/molecule/default/roles/helm/tasks/tests_chart/from_repository.yml @@ -0,0 +1,11 @@ +--- +- name: Add chart repo + shell: "helm repo add stable {{ chart_test_repo }}" + +- name: Install Chart from repository + include_tasks: "../tests_chart.yml" + vars: + source: repository + chart_source: "stable/{{ chart_test }}" + chart_source_version: "{{ chart_test_version }}" + chart_source_version_upgrade: "{{ chart_test_version_upgrade }}" diff --git a/molecule/default/roles/helm/tasks/tests_chart/from_url.yml b/molecule/default/roles/helm/tasks/tests_chart/from_url.yml new file mode 100644 index 00000000..fd3f66c3 --- /dev/null +++ b/molecule/default/roles/helm/tasks/tests_chart/from_url.yml @@ -0,0 +1,7 @@ +--- +- name: Install Chart from URL + include_tasks: "../tests_chart.yml" + vars: + source: url + chart_source: "{{ chart_test_repo }}/{{ chart_test }}-{{ chart_test_version }}.tgz" + chart_source_upgrade: "{{ chart_test_repo }}/{{ chart_test }}-{{ chart_test_version_upgrade }}.tgz" diff --git a/molecule/default/roles/helm/tasks/tiller.yml b/molecule/default/roles/helm/tasks/tiller.yml new file mode 100644 index 00000000..fc7a6991 --- /dev/null +++ b/molecule/default/roles/helm/tasks/tiller.yml @@ -0,0 +1,22 @@ +--- +- name: Tiller namespace + k8s: + api_version: v1 + kind: Namespace + name: "{{ tiller_namespace }}" + state: "{{ status | default('present') }}" + wait: true + +- name: Tiller requirements + k8s: + state: "{{ status | default('present') }}" + resource_definition: "{{ lookup('template', item) | from_yaml }}" + wait: true + with_fileglob: + - ../templates/*.yml + +- name: deploy tiller + shell: "{{ helm_binary }} init --wait --service-account tiller --tiller-namespace {{ tiller_namespace }}" + environment: + ignore_errors: yes + when: "status | default('present') == 'present'" diff --git a/molecule/default/roles/helm/templates/tiller-clusterRoleBinding.yml b/molecule/default/roles/helm/templates/tiller-clusterRoleBinding.yml new file mode 100644 index 00000000..4ac25c80 --- /dev/null +++ b/molecule/default/roles/helm/templates/tiller-clusterRoleBinding.yml @@ -0,0 +1,13 @@ +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: tiller +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: "{{ tiller_cluster_role }}" +subjects: + - kind: ServiceAccount + name: tiller + namespace: "{{ tiller_namespace }}" diff --git a/molecule/default/roles/helm/templates/tiller-sa.yml b/molecule/default/roles/helm/templates/tiller-sa.yml new file mode 100644 index 00000000..7adbd3c9 --- /dev/null +++ b/molecule/default/roles/helm/templates/tiller-sa.yml @@ -0,0 +1,6 @@ +--- +kind: ServiceAccount +apiVersion: v1 +metadata: + name: tiller + namespace: "{{ tiller_namespace }}" diff --git a/plugins/modules/helm_cli.py b/plugins/modules/helm_cli.py new file mode 100644 index 00000000..be077c59 --- /dev/null +++ b/plugins/modules/helm_cli.py @@ -0,0 +1,594 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: Ansible Project +# 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 + +ANSIBLE_METADATA = {'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'community'} + +DOCUMENTATION = ''' +--- +module: helm_cli +short_description: Manages Kubernetes packages with the Helm package manager +description: + - Install, upgrade, delete packages with the Helm package manager. +version_added: "2.10" +author: + - Lucas Boisserie (@LucasBoisserie) + - Matthieu Diehr (@d-matt) +requirements: + - "helm (https://github.com/helm/helm/releases)" + - "yaml (https://pypi.org/project/PyYAML/)" +options: + binary_path: + description: + - The path of a helm binary to use. + required: false + type: path + chart_ref: + description: + - chart_reference on chart repository + - path to a packaged chart + - path to an unpacked chart directory + - absolute URL + - Required when I(release_state) is set to C(present) + required: false + type: path + chart_repo_url: + description: + - Chart repository URL where to locate the requested chart + required: false + type: str + chart_repo_username: + description: + - Chart repository username where to locate the requested chart + - Required if I(chart_repo_password) is specified + required: false + type: str + chart_repo_password: + description: + - Chart repository password where to locate the requested chart + - Required if I(chart_repo_username) is specified + required: false + type: str + chart_version: + description: + - Chart version to install. If this is not specified, the latest version is installed + required: false + type: str + release_name: + description: + - Release name to manage + required: true + type: str + aliases: [ name ] + release_namespace: + description: + - Kubernetes namespace where the chart should be installed + - Can't be changed with helm 2 + default: "default" + required: false + type: str + aliases: [ namespace ] + release_state: + choices: ['present', 'absent'] + description: + - Desirated state of release + required: false + default: present + aliases: [ state ] + type: str + release_values: + description: + - Value to pass to chart + required: false + default: {} + aliases: [ values ] + type: dict + tiller_host: + description: + - Address of Tiller + - Ignored when is helm 3 + type: str + tiller_namespace: + description: + - Namespace of Tiller + - Ignored when is helm 3 + default: "kube-system" + type: str + update_repo_cache: + description: + - Run C(helm repo update) before the operation. Can be run as part of the package installation or as a separate step. + default: false + type: bool + +#Helm options + disable_hook: + description: + - Helm option to disable hook on install/upgrade/delete + default: False + type: bool + force: + description: + - Helm option to force reinstall, ignore on new install + default: False + type: bool + kube_context: + description: + - Helm option to specify which kubeconfig context to use + type: str + kubeconfig_path: + description: + - Helm option to specify kubeconfig path to use + type: path + aliases: [ kubeconfig ] + purge: + description: + - Remove the release from the store and make its name free for later use + default: True + type: bool + wait: + description: + - Wait until all Pods, PVCs, Services, and minimum number of Pods of a Deployment are in a ready state before marking the release as successful + default: False + type: bool + wait_timeout: + description: + - Timeout when wait option is enabled (helm2 is a number of seconds, helm3 is a duration) + type: str +''' + +EXAMPLES = ''' +# With Helm 2 +- name: Deploy grafana with params version + helm_cli: + name: test + chart_ref: stable/grafana + chart_version: 3.3.8 + tiller_namespace: helm + values: + replicas: 2 + +- name: Load Value from template + helm_cli: + name: test + chart_ref: stable/grafana + tiller_namespace: helm + values: "{{ lookup('template', 'somefile.yaml') | from_yaml }}" + +- name: Remove test release and waiting suppression ending + helm_cli: + name: test + state: absent + tiller_namespace: helm + wait: true + +# With Helm 3 +- name: Create helm namespace HELM 3 doesn't create it automatically + k8s: + api_version: v1 + kind: Namespace + name: "monitoring" + wait: true + +- name: Deploy latest version of Grafana chart inside monitoring namespace + helm_cli: + name: test + chart_ref: stable/grafana + release_namespace: monitoring + values: + replicas: 2 + +''' + +RETURN = """ +status: + type: complex + description: A dictionary of status output + returned: on success Creation/Upgrade/Already deploy + contains: + appversion: + type: str + returned: always + description: Version of app deployed + chart: + type: str + returned: always + description: Chart name and chart version + name: + type: str + returned: always + description: Name of the release + namespace: + type: str + returned: always + description: Namespace where the release is deployed + revision: + type: str + returned: always + description: Number of time where the release has been updated + status: + type: str + returned: always + description: Status of release (can be DEPLOYED, FAILED, ...) + updated: + type: str + returned: always + description: The Date of last update + values: + type: str + returned: always + description: Dict of Values used to deploy +stdout: + type: str + description: Full `helm` command stdout, in case you want to display it or examine the event log + returned: always + sample: '' +stderr: + type: str + description: Full `helm` command stderr, in case you want to display it or examine the event log + returned: always + sample: '' +command: + type: str + description: Full `helm` command built by this module, in case you want to re-run the command outside the module or debug a problem. + returned: always + sample: helm upgrade ... +""" + +try: + import yaml + HAS_YAML = True +except ImportError: + HAS_YAML = False + +from ansible.module_utils.basic import AnsibleModule + +module = None +is_helm_2 = True + + +# get Helm Version +def get_helm_client_version(command): + is_helm_2_local = True + version_command = command + " version --client --short" + rc, out, err = module.run_command(version_command) + + if not out.startswith('Client: v2'): + is_helm_2_local = False + # Fallback on Helm 3 + version_command = command + " version --short" + rc, out, err = module.run_command(version_command) + + if rc != 0: + module.fail_json( + msg="Failure when executing Helm command. Exited {0}.\nstdout: {1}\nstderr: {2}".format(rc, out, err), + command=version_command + ) + + elif rc != 0: + module.fail_json( + msg="Failure when executing Helm command. Exited {0}.\nstdout: {1}\nstderr: {2}".format(rc, out, err), + command=version_command + ) + + return is_helm_2_local + + +# Get Values from deployed release +def get_values(command, release_name, release_namespace): + get_command = command + " get values --output=yaml " + release_name + + if not is_helm_2: + get_command += " --namespace=" + release_namespace + + rc, out, err = module.run_command(get_command) + + if rc != 0: + module.fail_json( + msg="Failure when executing Helm command. Exited {0}.\nstdout: {1}\nstderr: {2}".format(rc, out, err), + command=get_command + ) + + # Helm 3 return "null" string when no values are set + if not is_helm_2 and out.rstrip("\n") == "null": + return yaml.safe_load('{}') + else: + return yaml.safe_load(out) + + +# Get Release from all deployed releases +def get_release(state, release_name, release_namespace): + if state is not None: + if is_helm_2: + for release in state['Releases']: + # release = {k.lower(): v for k, v in release.items()} # NOT WORKING wit python 2.6 + release_lower = dict() + for k, v in release.items(): + release_lower[k.lower()] = v + release = release_lower + if release['name'] == release_name and release['namespace'] == release_namespace: + return release + else: + for release in state: + if release['name'] == release_name and release['namespace'] == release_namespace: + return release + return None + + +# Get Release state from deployed release +def get_release_status(command, release_name, release_namespace): + list_command = command + " list --output=yaml " + + if not is_helm_2: + list_command += " --namespace=" + release_namespace + list_command += " --filter " + + list_command += release_name + + rc, out, err = module.run_command(list_command) + + if rc != 0: + module.fail_json( + msg="Failure when executing Helm command. Exited {0}.\nstdout: {1}\nstderr: {2}".format(rc, out, err), + command=list_command + ) + + release = get_release(yaml.safe_load(out), release_name, release_namespace) + + if release is None: # not install + return None + + release['values'] = get_values(command, release_name, release_namespace) + + return release + + +# Run Repo update +def run_repo_update(command): + repo_update_command = command + " repo update" + + rc, out, err = module.run_command(repo_update_command) + if rc != 0: + module.fail_json( + msg="Failure when executing Helm command. Exited {0}.\nstdout: {1}\nstderr: {2}".format(rc, out, err), + command=repo_update_command + ) + + +# Get chart info +def fetch_chart_info(command, chart_ref): + if is_helm_2: + inspect_command = command + " inspect chart " + chart_ref + else: + inspect_command = command + " show chart " + chart_ref + + rc, out, err = module.run_command(inspect_command) + if rc != 0: + module.fail_json( + msg="Failure when executing Helm command. Exited {0}.\nstdout: {1}\nstderr: {2}".format(rc, out, err), + command=inspect_command + ) + + return yaml.safe_load(out) + + +# Install/upgrade/rollback release chart +def deploy(command, release_name, release_namespace, release_values, chart_name, wait, wait_timeout, disable_hook, force): + deploy_command = command + " upgrade -i" # install/upgrade + + # Always reset values to keep release_values equal to values released + deploy_command += " --reset-values" + + if wait: + deploy_command += " --wait" + if wait_timeout is not None: + deploy_command += " --timeout " + wait_timeout + + if force: + deploy_command += " --force" + + if disable_hook: + deploy_command += " --no-hooks" + + if release_values != {}: + try: + import tempfile + except ImportError: + module.fail_json(msg="Could not import the tempfile python module. Please install `tempfile` package.") + + fd, path = tempfile.mkstemp(suffix='.yml') + with open(path, 'w') as yaml_file: + yaml.dump(release_values, yaml_file, default_flow_style=False) + deploy_command += " -f=" + path + + deploy_command += " --namespace=" + release_namespace + deploy_command += " " + release_name + deploy_command += " " + chart_name + + return deploy_command + + +# Delete release chart for helm2 +def delete_2(command, release_name, purge, disable_hook): + delete_command = command + " delete" + + if purge: + delete_command += " --purge" + + if disable_hook: + delete_command += " --no-hooks" + + delete_command += " " + release_name + + return delete_command + + +# Delete release chart for helm3 +def delete_3(command, release_name, release_namespace, purge, disable_hook): + delete_command = command + " uninstall --namespace=" + release_namespace + + if not purge: + delete_command += " --keep-history" + + if disable_hook: + delete_command += " --no-hooks" + + delete_command += " " + release_name + + return delete_command + + +def main(): + global module, is_helm_2 + module = AnsibleModule( + argument_spec=dict( + binary_path=dict(type='path'), + chart_ref=dict(type='path'), + chart_repo_url=dict(type='str'), + chart_repo_username=dict(type='str'), + chart_repo_password=dict(type='str', no_log=True), + chart_version=dict(type='str'), + release_name=dict(type='str', required=True, aliases=['name']), + release_namespace=dict(type='str', default='default', aliases=['namespace']), + release_state=dict(default='present', choices=['present', 'absent'], aliases=['state']), + release_values=dict(type='dict', default={}, aliases=['values']), + tiller_host=dict(type='str'), + tiller_namespace=dict(type='str', default='kube-system'), + update_repo_cache=dict(type='bool', default=False), + + # Helm options + disable_hook=dict(type='bool', default=False), + force=dict(type='bool', default=False), + kube_context=dict(type='str'), + kubeconfig_path=dict(type='path', aliases=['kubeconfig']), + purge=dict(type='bool', default=True), + wait=dict(type='bool', default=False), + wait_timeout=dict(type='str'), + ), + required_if=[ + ('release_state', 'present', ['release_name', 'chart_ref']), + ('release_state', 'absent', ['release_name']) + + ], + required_together=[ + ['chart_repo_username', 'chart_repo_password'] + ], + supports_check_mode=True, + ) + + if not HAS_YAML: + module.fail_json(msg="Could not import the yaml python module. Please install `yaml` package.") + changed = False + + bin_path = module.params.get('binary_path') + chart_ref = module.params.get('chart_ref') + chart_repo_url = module.params.get('chart_repo_url') + chart_repo_username = module.params.get('chart_repo_username') + chart_repo_password = module.params.get('chart_repo_password') + chart_version = module.params.get('chart_version') + release_name = module.params.get('release_name') + release_namespace = module.params.get('release_namespace') + release_state = module.params.get('release_state') + release_values = module.params.get('release_values') + tiller_host = module.params.get('tiller_host') + tiller_namespace = module.params.get('tiller_namespace') + update_repo_cache = module.params.get('update_repo_cache') + + # Helm options + disable_hook = module.params.get('disable_hook') + force = module.params.get('force') + kube_context = module.params.get('kube_context') + kubeconfig_path = module.params.get('kubeconfig_path') + purge = module.params.get('purge') + wait = module.params.get('wait') + wait_timeout = module.params.get('wait_timeout') + + if bin_path is not None: + helm_cmd_common = bin_path + else: + helm_cmd_common = module.get_bin_path('helm', required=True) + + is_helm_2 = get_helm_client_version(helm_cmd_common) + + # Helm 2 need tiller, Helm 3 and higher doesn't + if is_helm_2: + if tiller_host is not None: + helm_cmd_common += " --host=" + tiller_namespace + else: + helm_cmd_common += " --tiller-namespace=" + tiller_namespace + + if kube_context is not None: + helm_cmd_common += " --kube-context " + kube_context + + if kubeconfig_path is not None: + helm_cmd_common += " --kubeconfig " + kubeconfig_path + + if update_repo_cache: + run_repo_update(helm_cmd_common) + + # Get real/deployed release status + release_status = get_release_status(helm_cmd_common, release_name, release_namespace) + + # keep helm_cmd_common for get_release_status in module_exit_json + helm_cmd = helm_cmd_common + if release_state == "absent" and release_status is not None: + if is_helm_2: + helm_cmd = delete_2(helm_cmd, release_name, purge, disable_hook) + else: + helm_cmd = delete_3(helm_cmd, release_name, release_namespace, purge, disable_hook) + changed = True + elif release_state == "present": + + if chart_version is not None: + helm_cmd += " --version=" + chart_version + + if chart_repo_url is not None: + helm_cmd += " --repo=" + chart_repo_url + if chart_repo_username is not None and chart_repo_password is not None: + helm_cmd += " --username=" + chart_repo_username + helm_cmd += " --password=" + chart_repo_password + + # Fetch chart info to have real version and real name for chart_ref from archive, folder or url + chart_info = fetch_chart_info(helm_cmd, chart_ref) + + if release_status is None: # Not installed + helm_cmd = deploy(helm_cmd, release_name, release_namespace, release_values, chart_ref, wait, wait_timeout, + disable_hook, False) + changed = True + + elif is_helm_2 and release_namespace != release_status['namespace']: + module.fail_json( + msg="With helm2, Target Namespace can't be changed on deployed chart ! Need to destroy the release " + "and recreate it " + ) + + elif force or release_values != release_status['values'] \ + or (chart_info['name'] + '-' + chart_info['version']) != release_status["chart"]: + helm_cmd = deploy(helm_cmd, release_name, release_namespace, release_values, chart_ref, wait, wait_timeout, + disable_hook, force) + changed = True + + if module.check_mode: + module.exit_json(changed=changed) + elif not changed: + module.exit_json(changed=False, status=release_status) + + rc, out, err = module.run_command(helm_cmd) + + if rc != 0: + module.fail_json( + msg="Failure when executing Helm command. Exited {0}.\nstdout: {1}\nstderr: {2}".format(rc, out, err), + command=helm_cmd + ) + + module.exit_json(changed=changed, stdout=out, stderr=err, + status=get_release_status(helm_cmd_common, release_name, release_namespace), command=helm_cmd) + + +if __name__ == '__main__': + main() diff --git a/plugins/modules/helm_cli_info.py b/plugins/modules/helm_cli_info.py new file mode 100644 index 00000000..85389aa9 --- /dev/null +++ b/plugins/modules/helm_cli_info.py @@ -0,0 +1,290 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: Ansible Project +# 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 + +ANSIBLE_METADATA = {'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'community'} + +DOCUMENTATION = ''' +--- +module: helm_cli_info +short_description: Get informations from Helm package deployed inside the cluster +description: + - Get informations (values, states, ...) from Helm package deployed inside the cluster +version_added: "2.10" +author: + - Lucas Boisserie (@LucasBoisserie) +requirements: + - "helm (https://github.com/helm/helm/releases)" + - "yaml (https://pypi.org/project/PyYAML/)" +options: + binary_path: + description: + - The path of a helm binary to use. + required: false + type: path + release_name: + description: + - Release name to manage + required: true + type: str + aliases: [ name ] + release_namespace: + description: + - Kubernetes namespace where the chart should be installed + - Can't be changed with helm 2 + default: "default" + required: false + type: str + aliases: [ namespace ] + tiller_host: + description: + - Address of Tiller + - Ignored when is helm 3 + type: str + tiller_namespace: + description: + - Namespace of Tiller + - Ignored when is helm 3 + default: "kube-system" + type: str + +#Helm options + kube_context: + description: + - Helm option to specify which kubeconfig context to use + type: str + kubeconfig_path: + description: + - Helm option to specify kubeconfig path to use + type: path + aliases: [ kubeconfig ] +''' + +EXAMPLES = ''' +# With Helm 2 +- name: Get grafana deployment + helm_cli_info: + name: test + tiller_namespace: helm + +# With Helm 3 +- name: Deploy latest version of Grafana chart inside monitoring namespace + helm_cli_info: + name: test + release_namespace: monitoring +''' + +RETURN = """ +status: + type: complex + description: A dictionary of status output + returned: only when release exists + contains: + appversion: + type: str + returned: always + description: Version of app deployed + chart: + type: str + returned: always + description: Chart name and chart version + name: + type: str + returned: always + description: Name of the release + namespace: + type: str + returned: always + description: Namespace where the release is deployed + revision: + type: str + returned: always + description: Number of time where the release has been updated + status: + type: str + returned: always + description: Status of release (can be DEPLOYED, FAILED, ...) + updated: + type: str + returned: always + description: The Date of last update + values: + type: str + returned: always + description: Dict of Values used to deploy +""" + +try: + import yaml + HAS_YAML = True +except ImportError: + HAS_YAML = False + +from ansible.module_utils.basic import AnsibleModule + +module = None +is_helm_2 = True + + +# get Helm Version +def get_helm_client_version(command): + is_helm_2_local = True + version_command = command + " version --client --short" + rc, out, err = module.run_command(version_command) + + if not out.startswith('Client: v2'): + is_helm_2_local = False + # Fallback on Helm 3 + version_command = command + " version --short" + rc, out, err = module.run_command(version_command) + + if rc != 0: + module.fail_json( + msg="Failure when executing Helm command. Exited {0}.\nstdout: {1}\nstderr: {2}".format(rc, out, err), + command=version_command + ) + + elif rc != 0: + module.fail_json( + msg="Failure when executing Helm command. Exited {0}.\nstdout: {1}\nstderr: {2}".format(rc, out, err), + command=version_command + ) + + return is_helm_2_local + + +# Get Values from deployed release +def get_values(command, release_name, release_namespace): + get_command = command + " get values --output=yaml " + release_name + + if not is_helm_2: + get_command += " --namespace=" + release_namespace + + rc, out, err = module.run_command(get_command) + + if rc != 0: + module.fail_json( + msg="Failure when executing Helm command. Exited {0}.\nstdout: {1}\nstderr: {2}".format(rc, out, err), + command=get_command + ) + + # Helm 3 return "null" string when no values are set + if not is_helm_2 and out.rstrip("\n") == "null": + return yaml.safe_load('{}') + else: + return yaml.safe_load(out) + + +# Get Release from all deployed releases +def get_release(state, release_name, release_namespace): + if state is not None: + if is_helm_2: + for release in state['Releases']: + # release = {k.lower(): v for k, v in release.items()} # NOT WORKING wit python 2.6 + release_lower = dict() + for k, v in release.items(): + release_lower[k.lower()] = v + release = release_lower + if release['name'] == release_name and release['namespace'] == release_namespace: + return release + else: + for release in state: + if release['name'] == release_name and release['namespace'] == release_namespace: + return release + return None + + +# Get Release state from deployed release +def get_release_status(command, release_name, release_namespace): + list_command = command + " list --output=yaml " + + if not is_helm_2: + list_command += " --namespace=" + release_namespace + list_command += " --filter " + + list_command += release_name + + rc, out, err = module.run_command(list_command) + + if rc != 0: + module.fail_json( + msg="Failure when executing Helm command. Exited {0}.\nstdout: {1}\nstderr: {2}".format(rc, out, err), + command=list_command + ) + + release = get_release(yaml.safe_load(out), release_name, release_namespace) + + if release is None: # not install + return None + + release['values'] = get_values(command, release_name, release_namespace) + + return release + + +def main(): + global module, is_helm_2 + + module = AnsibleModule( + argument_spec=dict( + binary_path=dict(type='path'), + release_name=dict(type='str', required=True, aliases=['name']), + release_namespace=dict(type='str', default='default', aliases=['namespace']), + tiller_host=dict(type='str'), + tiller_namespace=dict(type='str', default='kube-system'), + + # Helm options + kube_context=dict(type='str'), + kubeconfig_path=dict(type='path', aliases=['kubeconfig']), + ), + supports_check_mode=True, + ) + if not HAS_YAML: + module.fail_json(msg="Could not import the yaml python module. Please install `yaml` package.") + + bin_path = module.params.get('binary_path') + release_name = module.params.get('release_name') + release_namespace = module.params.get('release_namespace') + tiller_host = module.params.get('tiller_host') + tiller_namespace = module.params.get('tiller_namespace') + + # Helm options + kube_context = module.params.get('kube_context') + kubeconfig_path = module.params.get('kubeconfig_path') + + if bin_path is not None: + helm_cmd_common = bin_path + else: + helm_cmd_common = module.get_bin_path('helm', required=True) + + is_helm_2 = get_helm_client_version(helm_cmd_common) + + # Helm 2 need tiller, Helm 3 and higher doesn't + if is_helm_2: + if tiller_host is not None: + helm_cmd_common += " --host=" + tiller_namespace + else: + helm_cmd_common += " --tiller-namespace=" + tiller_namespace + + if kube_context is not None: + helm_cmd_common += " --kube-context " + kube_context + + if kubeconfig_path is not None: + helm_cmd_common += " --kubeconfig " + kubeconfig_path + + # Get real/deployed release status + release_status = get_release_status(helm_cmd_common, release_name, release_namespace) + if release_status is not None: + module.exit_json(changed=False, status=release_status) + else: + module.exit_json(changed=False) + + +if __name__ == '__main__': + main()