diff --git a/README.md b/README.md index 44f6f4a0..356b91ed 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,9 @@ 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](https://docs.ansible.com/ansible/latest/modules/helm_module.html) + - [helm_info](https://docs.ansible.com/ansible/latest/modules/helm_info_module.html) + - [helm_repository](https://docs.ansible.com/ansible/latest/modules/helm_repository_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..458775de --- /dev/null +++ b/molecule/default/roles/helm/tasks/main.yml @@ -0,0 +1,7 @@ +--- +- name: Run tests + include_tasks: run_test.yml + loop_control: + loop_var: helm_version + with_items: + - "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..42c54d09 --- /dev/null +++ b/molecule/default/roles/helm/tasks/run_test.yml @@ -0,0 +1,32 @@ +--- +- name: Ensure helm is not installed + file: + path: "{{ item }}" + state: absent + with_items: + - "/tmp/helm" + +- name: Check failed if helm is not installed + include_tasks: test_helm_not_installed.yml + +- name: "Install {{ helm_version }}" + include_tasks: install.yml + +- name: tests_repository + include_tasks: tests_repository.yml + +- 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 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..0832dcb0 --- /dev/null +++ b/molecule/default/roles/helm/tasks/test_helm_not_installed.yml @@ -0,0 +1,15 @@ +--- +- name: Failed test when helm is not installed + helm: + binary_path: "{{ helm_binary}}_fake" + name: test + chart_ref: "{{ chart_test }}" + namespace: "{{ helm_namespace }}" + 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/tests_chart.yml b/molecule/default/roles/helm/tasks/tests_chart.yml new file mode 100644 index 00000000..eb6d518f --- /dev/null +++ b/molecule/default/roles/helm/tasks/tests_chart.yml @@ -0,0 +1,201 @@ +--- +- name: Create helm namespace + k8s: + api_version: v1 + kind: Namespace + name: "{{ helm_namespace }}" + wait: true + +- name: Check helm_info empty + helm_info: + binary_path: "{{ helm_binary }}" + name: test + namespace: "{{ helm_namespace }}" + register: empty_info + +- name: "Assert that no charts are installed with helm_info" + assert: + that: + - empty_info.status is undefined + +- name: "Install {{ chart_test }} from {{ source }}" + helm: + binary_path: "{{ helm_binary }}" + name: test + chart_ref: "{{ chart_source }}" + chart_version: "{{ chart_source_version | default(omit) }}" + namespace: "{{ helm_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 helm_info content + helm_info: + binary_path: "{{ helm_binary }}" + name: test + namespace: "{{ helm_namespace }}" + register: content_info + +- name: "Assert that {{ chart_test }} is installed from {{ source }} with helm_info" + assert: + that: + - content_info.status.chart == "{{ chart_test }}-{{ chart_test_version }}" + - content_info.status.status | lower == 'deployed' + +- name: Check idempotency + helm: + binary_path: "{{ helm_binary }}" + name: test + chart_ref: "{{ chart_source }}" + chart_version: "{{ chart_source_version | default(omit) }}" + namespace: "{{ helm_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: + binary_path: "{{ helm_binary }}" + name: test + chart_ref: "{{ chart_source }}" + chart_version: "{{ chart_source_version | default(omit) }}" + namespace: "{{ helm_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 adding vars + helm: + binary_path: "{{ helm_binary }}" + name: test + chart_ref: "{{ chart_source }}" + chart_version: "{{ chart_source_version | default(omit) }}" + namespace: "{{ helm_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: + binary_path: "{{ helm_binary }}" + name: test + chart_ref: "{{ chart_source }}" + chart_version: "{{ chart_source_version | default(omit) }}" + namespace: "{{ helm_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 removing vars + helm: + binary_path: "{{ helm_binary }}" + name: test + chart_ref: "{{ chart_source }}" + chart_version: "{{ chart_source_version | default(omit) }}" + namespace: "{{ helm_namespace }}" + register: install + +- name: Assert idempotency after removing 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: + 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 }}" + 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: + 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 }}" + 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: + binary_path: "{{ helm_binary }}" + state: absent + name: test + namespace: "{{ helm_namespace }}" + register: install + +- name: "Assert that {{ chart_test }} chart is removed from {{ source }}" + assert: + that: + - install is changed + +- name: Check idempotency after remove + helm: + binary_path: "{{ helm_binary }}" + state: absent + name: test + namespace: "{{ helm_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 + wait_timeout: 180 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..067b216b --- /dev/null +++ b/molecule/default/roles/helm/tasks/tests_chart/from_repository.yml @@ -0,0 +1,19 @@ +--- +- name: Add chart repo + helm_repository: + name: test_helm + repo_url: "{{ chart_test_repo }}" + +- name: Install Chart from repository + include_tasks: "../tests_chart.yml" + vars: + source: repository + chart_source: "test_helm/{{ chart_test }}" + chart_source_version: "{{ chart_test_version }}" + chart_source_version_upgrade: "{{ chart_test_version_upgrade }}" + +- name: Add chart repo + helm_repository: + name: test_helm + repo_url: "{{ chart_test_repo }}" + state: absent 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/tests_repository.yml b/molecule/default/roles/helm/tasks/tests_repository.yml new file mode 100644 index 00000000..9d274819 --- /dev/null +++ b/molecule/default/roles/helm/tasks/tests_repository.yml @@ -0,0 +1,61 @@ +--- +- name: "Ensure test_helm_repo doesn't exist" + helm_repository: + name: test_helm_repo + state: absent + +- name: Add test_helm_repo chart repository + helm_repository: + name: test_helm_repo + repo_url: "{{ chart_test_repo }}" + register: repository + +- name: Assert that test_helm_repo repository is added + assert: + that: + - repository is changed + +- name: Check idempotency + helm_repository: + name: test_helm_repo + repo_url: "{{ chart_test_repo }}" + register: repository + +- name: Assert idempotency + assert: + that: + - repository is not changed + +- name: Failed to add repository with the same name + helm_repository: + name: test_helm_repo + repo_url: "https://other-charts.url" + register: repository_errors + ignore_errors: yes + +- name: Assert that adding repository with the same name failed + assert: + that: + - repository_errors is failed + +- name: Remove test_helm_repo chart repository + helm_repository: + name: test_helm_repo + state: absent + register: repository + +- name: Assert that test_helm_repo repository is removed + assert: + that: + - repository is changed + +- name: Check idempotency after remove + helm_repository: + name: test_helm_repo + state: absent + register: repository + +- name: Assert idempotency + assert: + that: + - repository is not changed diff --git a/molecule/default/tasks/waiter.yml b/molecule/default/tasks/waiter.yml index 1269c2e9..98ee6416 100644 --- a/molecule/default/tasks/waiter.yml +++ b/molecule/default/tasks/waiter.yml @@ -49,7 +49,7 @@ app: "{{ k8s_pod_name }}" template: "{{ k8s_pod_template }}" wait: yes - wait_sleep: 3 + wait_sleep: 5 wait_timeout: 180 vars: k8s_pod_name: wait-ds diff --git a/plugins/modules/helm.py b/plugins/modules/helm.py new file mode 100644 index 00000000..2cdd9672 --- /dev/null +++ b/plugins/modules/helm.py @@ -0,0 +1,491 @@ +#!/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 + +short_description: Manages Kubernetes packages with the Helm package manager + +author: + - Lucas Boisserie (@LucasBoisserie) + - Matthieu Diehr (@d-matt) + +requirements: + - "helm (https://github.com/helm/helm/releases)" + - "yaml (https://pypi.org/project/PyYAML/)" + +description: + - Install, upgrade, delete packages with the Helm package manager. + +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_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. + required: true + 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 + 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 = ''' +- name: Create helm namespace as HELM 3 doesn't create it automatically + k8s: + api_version: v1 + kind: Namespace + name: "monitoring" + wait: true + +# From repository +- name: Add stable chart repo + helm_repository: + name: stable + repo_url: "https://kubernetes-charts.storage.googleapis.com" + +- name: Deploy latest version of Grafana chart inside monitoring namespace with values + helm: + name: test + chart_ref: stable/grafana + release_namespace: monitoring + values: + replicas: 2 + +- name: Deploy Grafana chart on 5.0.12 with values loaded from template + helm: + name: test + chart_ref: stable/grafana + chart_version: 5.0.12 + values: "{{ lookup('template', 'somefile.yaml') | from_yaml }}" + +- name: Remove test release and waiting suppression ending + helm: + name: test + state: absent + wait: true + +# From git +- name: Git clone stable repo on HEAD + git: + repo: "http://github.com/helm/charts.git" + dest: /tmp/helm_repo + +- name: Deploy Grafana chart from local path + helm: + name: test + chart_ref: /tmp/helm_repo/stable/grafana + release_namespace: monitoring + +# From url +- name: Deploy Grafana chart on 5.0.12 from url + helm: + name: test + chart_ref: "https://kubernetes-charts.storage.googleapis.com/grafana-5.0.12.tgz" + release_namespace: monitoring +''' + +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 ... +""" + +import traceback + +try: + import yaml + IMP_YAML = True +except ImportError: + IMP_YAML_ERR = traceback.format_exc() + IMP_YAML = False + +from ansible.module_utils.basic import AnsibleModule, missing_required_lib + +module = None + + +# Get Values from deployed release +def get_values(command, release_name): + get_command = command + " get values --output=yaml " + release_name + + 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 out.rstrip("\n") == "null": + return {} + else: + return yaml.safe_load(out) + + +# Get Release from all deployed releases +def get_release(state, release_name): + if state is not None: + for release in state: + if release['name'] == release_name: + return release + return None + + +# Get Release state from deployed release +def get_release_status(command, release_name): + list_command = command + " list --output=yaml --filter " + 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) + + if release is None: # not install + return None + + release['values'] = get_values(command, release_name) + + 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): + 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_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=missing_required_lib("tempfile"), exception=traceback.format_exc()) + + 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 += " " + release_name + " " + chart_name + + return deploy_command + + +# Delete release chart +def delete(command, release_name, purge, disable_hook): + delete_command = command + " uninstall " + + 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 + module = AnsibleModule( + argument_spec=dict( + binary_path=dict(type='path'), + chart_ref=dict(type='path'), + chart_repo_url=dict(type='str'), + chart_version=dict(type='str'), + release_name=dict(type='str', required=True, aliases=['name']), + release_namespace=dict(type='str', required=True, aliases=['namespace']), + release_state=dict(default='present', choices=['present', 'absent'], aliases=['state']), + release_values=dict(type='dict', default={}, aliases=['values']), + 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']) + ], + supports_check_mode=True, + ) + + if not IMP_YAML: + module.fail_json(msg=missing_required_lib("yaml"), exception=IMP_YAML_ERR) + + 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_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') + 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) + + 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) + + helm_cmd_common += " --namespace=" + release_namespace + + # Get real/deployed release status + release_status = get_release_status(helm_cmd_common, release_name) + + # 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: + helm_cmd = delete(helm_cmd, release_name, 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 + + # 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_values, chart_ref, wait, wait_timeout, + disable_hook, False) + changed = True + + 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_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), command=helm_cmd) + + +if __name__ == '__main__': + main() diff --git a/plugins/modules/helm_info.py b/plugins/modules/helm_info.py new file mode 100644 index 00000000..5d773c21 --- /dev/null +++ b/plugins/modules/helm_info.py @@ -0,0 +1,220 @@ +#!/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_info + +short_description: Get informations from Helm package deployed inside the cluster + +author: + - Lucas Boisserie (@LucasBoisserie) + +requirements: + - "helm (https://github.com/helm/helm/releases)" + - "yaml (https://pypi.org/project/PyYAML/)" + +description: + - Get informations (values, states, ...) from Helm package deployed inside the cluster + +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. + required: true + type: str + aliases: [ namespace ] + +#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 = ''' +- name: Deploy latest version of Grafana chart inside monitoring namespace + helm_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 +""" + +import traceback + +try: + import yaml + IMP_YAML = True +except ImportError: + IMP_YAML_ERR = traceback.format_exc() + IMP_YAML = False + +from ansible.module_utils.basic import AnsibleModule, missing_required_lib + +module = None + + +# Get Values from deployed release +def get_values(command, release_name): + get_command = command + " get values --output=yaml " + release_name + + 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 out.rstrip("\n") == "null": + return {} + else: + return yaml.safe_load(out) + + +# Get Release from all deployed releases +def get_release(state, release_name): + if state is not None: + for release in state: + if release['name'] == release_name: + return release + return None + + +# Get Release state from deployed release +def get_release_status(command, release_name): + list_command = command + " list --output=yaml --filter " + 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) + + if release is None: # not install + return None + + release['values'] = get_values(command, release_name) + + return release + + +def main(): + global module + + module = AnsibleModule( + argument_spec=dict( + binary_path=dict(type='path'), + release_name=dict(type='str', required=True, aliases=['name']), + release_namespace=dict(type='str', required=True, aliases=['namespace']), + + # Helm options + kube_context=dict(type='str'), + kubeconfig_path=dict(type='path', aliases=['kubeconfig']), + ), + supports_check_mode=True, + ) + + if not IMP_YAML: + module.fail_json(msg=missing_required_lib("yaml"), exception=IMP_YAML_ERR) + + bin_path = module.params.get('binary_path') + release_name = module.params.get('release_name') + release_namespace = module.params.get('release_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) + + 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 + + helm_cmd_common += " --namespace=" + release_namespace + + release_status = get_release_status(helm_cmd_common, release_name) + + if release_status is not None: + module.exit_json(changed=False, status=release_status) + else: + module.exit_json(changed=False) + + +if __name__ == '__main__': + main() diff --git a/plugins/modules/helm_repository.py b/plugins/modules/helm_repository.py new file mode 100644 index 00000000..908c0418 --- /dev/null +++ b/plugins/modules/helm_repository.py @@ -0,0 +1,208 @@ +#!/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_repository + +short_description: Add and remove Helm repository + +author: + - Lucas Boisserie (@LucasBoisserie) + +requirements: + - "helm (https://github.com/helm/helm/releases)" + - "yaml (https://pypi.org/project/PyYAML/)" + +description: + - Manage Helm repositories + +options: + binary_path: + description: + - The path of a helm binary to use. + required: false + type: path + repo_name: + description: + - Chart repository name. + required: true + type: str + aliases: [ name ] + repo_url: + description: + - Chart repository url + type: str + aliases: [ url ] + repo_username: + description: + - Chart repository username for repository with basic auth. + - Required if chart_repo_password is specified. + required: false + type: str + aliases: [ username ] + repo_password: + description: + - Chart repository password for repository with basic auth. + - Required if chart_repo_username is specified. + required: false + type: str + aliases: [ password ] + repo_state: + choices: ['present', 'absent'] + description: + - Desirated state of repositoriy. + required: false + default: present + aliases: [ state ] + type: str +''' + +EXAMPLES = ''' +- name: Add default repository + helm_repository: + name: stable + repo_url: https://kubernetes-charts.storage.googleapis.com +''' + +RETURN = ''' # ''' + +import traceback + +try: + import yaml + IMP_YAML = True +except ImportError: + IMP_YAML_ERR = traceback.format_exc() + IMP_YAML = False + +from ansible.module_utils.basic import AnsibleModule, missing_required_lib + +module = None + + +# Get repository from all repositories added +def get_repository(state, repo_name): + if state is not None: + for repository in state: + if repository['name'] == repo_name: + return repository + return None + + +# Get repository status +def get_repository_status(command, repository_name): + list_command = command + " repo list --output=yaml" + + rc, out, err = module.run_command(list_command) + + # no repo => rc=1 and 'no repositories to show' in output + if rc == 1 and "no repositories to show" in err: + return None + elif rc != 0: + module.fail_json( + msg="Failure when executing Helm command. Exited {0}.\nstdout: {1}\nstderr: {2}".format(rc, out, err), + command=list_command + ) + + return get_repository(yaml.safe_load(out), repository_name) + + +# Install repository +def install_repository(command, repository_name, repository_url, repository_username, repository_password): + install_command = command + " repo add " + repository_name + " " + repository_url + + if repository_username is not None and repository_password is not None: + install_command += " --username=" + repository_username + install_command += " --password=" + repository_password + + return install_command + + +# Delete repository +def delete_repository(command, repository_name): + remove_command = command + " repo rm " + repository_name + + return remove_command + + +def main(): + global module + + module = AnsibleModule( + argument_spec=dict( + binary_path=dict(type='path'), + repo_name=dict(type='str', aliases=['name'], required=True), + repo_url=dict(type='str', aliases=['url']), + repo_username=dict(type='str', aliases=['username']), + repo_password=dict(type='str', aliases=['password'], no_log=True), + repo_state=dict(default='present', choices=['present', 'absent'], aliases=['state']), + ), + required_together=[ + ['repo_username', 'repo_password'] + ], + required_if=[ + ('repo_state', 'present', ['repo_url']), + ], + supports_check_mode=True, + ) + + if not IMP_YAML: + module.fail_json(msg=missing_required_lib("yaml"), exception=IMP_YAML_ERR) + + changed = False + + bin_path = module.params.get('binary_path') + repo_name = module.params.get('repo_name') + repo_url = module.params.get('repo_url') + repo_username = module.params.get('repo_username') + repo_password = module.params.get('repo_password') + repo_state = module.params.get('repo_state') + + if bin_path is not None: + helm_cmd = bin_path + else: + helm_cmd = module.get_bin_path('helm', required=True) + + repository_status = get_repository_status(helm_cmd, repo_name) + + if repo_state == "absent" and repository_status is not None: + helm_cmd = delete_repository(helm_cmd, repo_name) + changed = True + elif repo_state == "present": + if repository_status is None: + helm_cmd = install_repository(helm_cmd, repo_name, repo_url, repo_username, repo_password) + changed = True + elif repository_status['url'] != repo_url: + module.fail_json(msg="Repository already have a repository named {0}".format(repo_name)) + + if module.check_mode: + module.exit_json(changed=changed) + elif not changed: + module.exit_json(changed=False, repo_name=repo_name, repo_url=repo_url) + + rc, out, err = module.run_command(helm_cmd) + + if repo_password is not None: + helm_cmd = helm_cmd.replace(repo_password, '******') + + 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, command=helm_cmd) + + +if __name__ == '__main__': + main()