Add ability to install helm charts

This patch adds ability to install helm charts and to add helm
repos. It was decided to take python code from salt-formula-helm
instead of using pyhelm module since it uses grpc and requires a
tunnel to tiller since tiller is not exposed and operates with its
client using portforward api.

Change-Id: I992abcf7eb035342a01dca838438ae7e2115fda0
Related-PROD: PROD-28361
diff --git a/_states/k8s_helm_release.py b/_states/k8s_helm_release.py
new file mode 100644
index 0000000..9977551
--- /dev/null
+++ b/_states/k8s_helm_release.py
@@ -0,0 +1,196 @@
+import difflib
+import os
+import logging
+
+from salt.exceptions import CommandExecutionError
+from salt.serializers import yaml
+
+LOG = logging.getLogger(__name__)
+
+def _get_values_from_file(values_file=None):
+    if values_file:
+        try:
+            with open(values_file) as values_stream:
+                values = yaml.deserialize(values_stream)
+            return values
+        except e:
+            raise CommandExecutionError("encountered error reading from values "
+                                        "file (%s): %s" % (values_file, e))
+    return None
+
+def _get_yaml_diff(new_yaml=None, old_yaml=None):
+    if not new_yaml and not old_yaml:
+        return None
+
+    old_str = yaml.serialize(old_yaml, default_flow_style=False)
+    new_str = yaml.serialize(new_yaml, default_flow_style=False)
+    return difflib.unified_diff(old_str.split('\n'), new_str.split('\n'))
+
+def _failure(name, message, changes={}):
+    return {
+        'name': name,
+        'changes': changes,
+        'result': False,
+        'comment': message,
+    }
+
+def present(name, chart_name, namespace, version=None, values_file=None,
+            tiller_namespace='kube-system', **kwargs):
+    '''
+    Ensure that a release with the supplied name is in the desired state in the
+    Tiller installation. This state will handle change detection to determine
+    whether an installation or update needs to be made.
+
+    In the event the namespace to which a release is installed changes, the
+    state will first delete and purge the release and the re-install it into
+    the new namespace, since Helm does not support updating a release into a
+    new namespace.
+
+    name
+        The name of the release to ensure is present
+
+    chart_name
+        The name of the chart to install, including the repository name as
+        applicable (such as `stable/mysql`)
+
+    namespace
+        The namespace to which the release should be (re-)installed
+
+    version
+        The version of the chart to install. Defaults to the latest version
+
+    values_file
+        The path to the a values file containing all the chart values that
+        should be applied to the release. Note that this should not be passed
+        if there are not chart value overrides required.
+
+    '''
+    kwargs['tiller_namespace'] = tiller_namespace
+    if __opts__['test'] == True:
+        return {
+            'result': None,
+            'name': name,
+            'comment': 'Helm chart "{0}" will be installed'.format(chart_name),
+            'changes': {
+            'chart_name': chart_name,
+            'namespace': namespace,
+          }
+        }
+    old_release = __salt__['k8s_helm.get_release'](name, **kwargs)
+    if not old_release:
+        try:
+            result = __salt__['k8s_helm.release_create'](
+                name, chart_name, namespace, version, values_file, **kwargs
+            )
+            return {
+                'name': name,
+                'changes': {
+                'name': name,
+                'chart_name': chart_name,
+                'namespace': namespace,
+                'version': version,
+                'values': _get_values_from_file(values_file),
+                'stdout': result.get('stdout')
+              },
+              'result': True,
+              'comment': ('Release "%s" was created' % name +
+                          '\nExecuted command: %s' % result['cmd'])
+            }
+        except CommandExecutionError as e:
+            msg = (("Failed to create new release: %s" % e.error) +
+                   "\nExecuted command: %s" % e.cmd)
+            return _failure(name, msg)
+
+    changes = {}
+    warnings = []
+    if old_release.get('chart') != chart_name.split("/")[1]:
+        changes['chart'] = { 'old': old_release['chart'], 'new': chart_name }
+
+    if old_release.get('version') != version:
+        changes['version'] = { 'old': old_release['version'], 'new': version }
+
+    if old_release.get('namespace') != namespace:
+        changes['namespace'] = { 'old': old_release['namespace'], 'new': namespace }
+
+    if (not values_file and old_release.get("values") or
+        not old_release.get("values") and values_file):
+        changes['values'] = { 'old': old_release['values'], 'new': values_file }
+
+    values = _get_values_from_file(values_file)
+    diff = _get_yaml_diff(values, old_release.get('values'))
+
+    if diff:
+        diff_string = '\n'.join(diff)
+        if diff_string:
+            changes['values'] = diff_string
+
+    if not changes:
+        return {
+            'name': name,
+          'result': True,
+          'changes': {},
+          'comment': 'Release "{}" is already in the desired state'.format(name)
+        }
+
+    module_fn = 'k8s_helm.release_upgrade'
+    if changes.get("namespace"):
+        LOG.debug("purging old release (%s) due to namespace change" % name)
+        try:
+            result = __salt__['k8s_helm.release_delete'](name, **kwargs)
+        except CommandExecutionError as e:
+            msg = ("Failed to delete release for namespace change: %s" % e.error +
+                   "\nExecuted command: %s" % e.cmd)
+            return _failure(name, msg, changes)
+
+        module_fn = 'k8s_helm.release_create'
+        warnings.append('Release (%s) was replaced due to namespace change' % name)
+
+    try:
+        result = __salt__[module_fn](
+            name, chart_name, namespace, version, values_file, **kwargs
+        )
+        changes.update({ 'stdout': result.get('stdout') })
+        ret = {
+            'name': name,
+          'changes': changes,
+          'result': True,
+          'comment': 'Release "%s" was updated\nExecuted command: %s' % (name, result['cmd'])
+        }
+        if warnings:
+            ret['warnings'] = warnings
+
+        return ret
+    except CommandExecutionError as e:
+        msg = ("Failed to delete release for namespace change: %s" % e.error +
+               "\nExecuted command: %s" % e.cmd)
+        return _failure(name, msg, changes)
+
+
+def absent(name, tiller_namespace='kube-system', **kwargs):
+    '''
+    Ensure that any release with the supplied release name is absent from the
+    tiller installation.
+
+    name
+        The name of the release to ensure is absent
+    '''
+    kwargs['tiller_namespace'] = tiller_namespace
+    exists = __salt__['k8s_helm.release_exists'](name, **kwargs)
+    if not exists:
+        return {
+            'name': name,
+            'changes': {},
+            'result': True,
+            'comment': 'Release "%s" doesn\'t exist' % name
+        }
+    try:
+        result = __salt__['k8s_helm.release_delete'](name, **kwargs)
+        return {
+            'name': name,
+          'changes': { name: 'DELETED', 'stdout': result['stdout'] },
+          'result': True,
+          'comment': 'Release "%s" was deleted\nExecuted command: %s' % (name, result['cmd'])
+        }
+    except CommandExecutionError as e:
+        return _failure(e.cmd, e.error)
+
diff --git a/_states/k8s_helm_repos.py b/_states/k8s_helm_repos.py
new file mode 100644
index 0000000..f192e4b
--- /dev/null
+++ b/_states/k8s_helm_repos.py
@@ -0,0 +1,118 @@
+import re
+
+from salt.exceptions import CommandExecutionError
+
+def managed(name, present={}, absent=[], exclusive=False, helm_home=None):
+    '''
+    Ensure the supplied repositories are available to the helm client. If the
+    `exclusive` flag is set to a truthy value, any extra repositories in the
+    helm client will be removed.
+
+    name
+        The name of the state
+
+    present
+        A dict of repository names to urls to ensure are registered with the
+        Helm client
+
+    absent
+        A list of repository names to ensure are unregistered from the Helm client
+
+    exclusive
+        A boolean flag indicating whether the state should ensure only the
+        supplied repositories are availabe to the target minion.
+
+    helm_home
+        An optional path to the Helm home directory
+    '''
+
+    ret = {'name': name,
+           'changes': {},
+           'result': True,
+           'comment': ''}
+
+    if __opts__['test'] == True:
+        ret['result'] = None
+        if len(present) == 0:
+            ret['comment'] = 'Helm repository "{0}" will be added'.format(absent)
+        else:
+            ret['comment'] = 'Helm repository "{0}" will be added'.format(present.keys())
+        ret['changes']['repository'] = present.items()
+        return ret
+
+    try:
+        result = __salt__['k8s_helm.manage_repos'](
+            present=present,
+          absent=absent,
+          exclusive=exclusive,
+          helm_home=helm_home
+        )
+
+        if result['failed']:
+            ret['comment'] = 'Failed to add or remove some repositories'
+            ret['changes'] = result
+            ret['result'] = False
+            return ret
+
+        if result['added'] or result['removed']:
+            ret['comment'] = 'Repositories were added or removed'
+            ret['changes'] = result
+            return ret
+
+        ret['comment'] = ("Repositories were in the desired state: "
+                         "%s" % [name for (name, url) in present.iteritems()])
+        return ret
+    except CommandExecutionError as e:
+        ret['result'] = False
+        ret['comment'] = "Failed to add some repositories: %s" % e
+        return ret
+
+def updated(name, helm_home=None):
+    '''
+    Ensure the local Helm repository cache is up to date with each of the
+    helm client's configured remote chart repositories. Because the `helm repo
+    update` command doesn't indicate whether any changes were made to the local
+    cache, this will only indicate change if the Helm client failed to retrieve
+    an update from one or more of the repositories, regardless of whether an
+    update was made to the local Helm chart repository cache.
+
+    name
+        The name of the state
+
+    helm_home
+        An optional path to the Helm home directory
+    '''
+    ret = {'name': name,
+           'changes': {},
+           'result': True,
+           'comment': 'Successfully synced repositories: ' }
+
+    if __opts__['test'] == True:
+        ret['result'] = None
+        ret['comment'] = 'Repositories will be updated'
+        return ret
+    try:
+        result = __salt__['k8s_helm.update_repos'](helm_home=helm_home)
+        cmd_str = "\nExecuted command: %s" % result['cmd']
+
+        success_repos = re.findall(
+            r'Successfully got an update from the \"([^\"]+)\"', result['stdout'])
+        failed_repos = re.findall(
+            r'Unable to get an update from the \"([^\"]+)\"', result['stdout'])
+
+        if failed_repos and len(failed_repos) > 0:
+            ret['result'] = False
+            ret['changes']['succeeded'] = success_repos
+            ret['changes']['failed'] = failed_repos
+            ret['comment'] = 'Failed to sync against some repositories' + cmd_str
+        else:
+            ret['comment'] += "%s" % success_repos + cmd_str
+
+    except CommandExecutionError as e:
+        ret['name'] = e.cmd
+        ret['result'] = False
+        ret['comment'] = ("Failed to update repos: %s" % e.error +
+                          "\nExecuted command: %s" % e.cmd)
+        return ret
+
+    return ret