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/_modules/k8s_helm.py b/_modules/k8s_helm.py
new file mode 100644
index 0000000..81660d1
--- /dev/null
+++ b/_modules/k8s_helm.py
@@ -0,0 +1,381 @@
+import logging
+import re
+
+from salt.serializers import yaml
+from salt.exceptions import CommandExecutionError
+
+LOG = logging.getLogger(__name__)
+
+class HelmExecutionError(CommandExecutionError):
+ def __init__(self, cmd, error):
+ self.cmd = cmd
+ self.error = error
+
+def _helm_cmd(*args, **kwargs):
+ if kwargs.get('tiller_host'):
+ addtl_args = ('--host', kwargs['tiller_host'])
+ elif kwargs.get('tiller_namespace'):
+ addtl_args = ('--tiller-namespace', kwargs['tiller_namespace'])
+ else:
+ addtl_args = ()
+
+ if kwargs.get('helm_home'):
+ addtl_args = addtl_args + ('--home', kwargs['helm_home'])
+
+ env = {}
+ if kwargs.get('kube_config'):
+ env['KUBECONFIG'] = kwargs['kube_config']
+ if kwargs.get('gce_service_token'):
+ env['GOOGLE_APPLICATION_CREDENTIALS'] = \
+ kwargs['gce_service_token']
+ return {
+ 'cmd': ('helm',) + args + addtl_args,
+ 'env': env,
+ }
+
+def _cmd_and_result(*args, **kwargs):
+ cmd = _helm_cmd(*args, **kwargs)
+ env_string = "".join(['%s="%s" ' % (k, v) for (k, v) in cmd.get('env', {}).items()])
+ cmd_string = env_string + " ".join(cmd['cmd'])
+ result = None
+ try:
+ result = __salt__['cmd.run_all'](**cmd)
+ if result['retcode'] != 0:
+ raise CommandExecutionError(result['stderr'])
+ return {
+ 'cmd': cmd_string,
+ 'stdout': result['stdout'],
+ 'stderr': result['stderr']
+ }
+ except CommandExecutionError as e:
+ raise HelmExecutionError(cmd_string, e)
+
+
+def _parse_release(output):
+ result = {}
+ chart_match = re.search(r'CHART\: ([^0-9]+)-([^\s]+)', output)
+ if chart_match:
+ result['chart'] = chart_match.group(1)
+ result['version'] = chart_match.group(2)
+
+ user_values_match = re.search(r"(?<=USER-SUPPLIED VALUES\:\n)(\n*.+)+?(?=\n*COMPUTED VALUES\:)", output, re.MULTILINE)
+ if user_values_match:
+ result['values'] = yaml.deserialize(user_values_match.group(0))
+
+ computed_values_match = re.search(r"(?<=COMPUTED VALUES\:\n)(\n*.+)+?(?=\n*HOOKS\:)", output, re.MULTILINE)
+ if computed_values_match:
+ result['computed_values'] = yaml.deserialize(computed_values_match.group(0))
+
+ manifest_match = re.search(r"(?<=MANIFEST\:\n)(\n*(?!Release \".+\" has been upgraded).*)+", output, re.MULTILINE)
+ if manifest_match:
+ result['manifest'] = manifest_match.group(0)
+
+ namespace_match = re.search(r"(?<=NAMESPACE\: )(.*)", output)
+ if namespace_match:
+ result['namespace'] = namespace_match.group(0)
+
+ return result
+
+def _parse_repo(repo_string = None):
+ split_string = repo_string.split('\t')
+ return {
+ "name": split_string[0].strip(),
+ "url": split_string[1].strip()
+ }
+
+
+def _get_release_namespace(name, tiller_namespace="kube-system", **kwargs):
+ cmd = _helm_cmd("list", name, **kwargs)
+ result = __salt__['cmd.run_stdout'](**cmd)
+ if not result or len(result.split("\n")) < 2:
+ return None
+
+ return result.split("\n")[1].split("\t")[6]
+
+def list_repos(**kwargs):
+ '''
+ Get the result of running `helm repo list` on the target minion, formatted
+ as a list of dicts with two keys:
+
+ * name: the name with which the repository is registered
+ * url: the url registered for the repository
+ '''
+ cmd = _helm_cmd('repo', 'list', **kwargs)
+ result = __salt__['cmd.run_stdout'](**cmd)
+ if result is None:
+ return result
+
+ result = result.split("\n")
+ result.pop(0)
+ return {
+ repo['name']: repo['url'] for repo in [_parse_repo(line) for line in result]
+ }
+
+def add_repo(name, url, **kwargs):
+ '''
+ Register the repository located at the supplied url with the supplied name.
+ Note that re-using an existing name will overwrite the repository url for
+ that registered repository to point to the supplied url.
+
+ name
+ The name with which to register the repository with the Helm client.
+
+ url
+ The url for the chart repository.
+ '''
+ return _cmd_and_result('repo', 'add', name, url, **kwargs)
+
+def remove_repo(name, **kwargs):
+ '''
+ Remove the repository from the Helm client registered with the supplied
+ name.
+
+ name
+ The name (as registered with the Helm client) for the repository to remove
+ '''
+ return _cmd_and_result('repo', 'remove', name, **kwargs)
+
+def manage_repos(present={}, absent=[], exclusive=False, **kwargs):
+ '''
+ Manage the repositories registered with the Helm client's local cache.
+
+ *ensuring repositories are present*
+ Repositories that should be present in the helm client can be supplied via
+ the `present` dict parameter; each key in the dict is a release name, and the
+ value is the repository url that should be registered.
+
+ *ensuring repositories are absent*
+ Repository names supplied via the `absent` parameter must be a string. If the
+ `exclusive` flag is set to True, the `absent` parameter will be ignored, even
+ if it has been supplied.
+
+ This function returns a dict with the following keys:
+
+ * already_present: a listing of supplied repository definitions to add that
+ are already registered with the Helm client
+
+ * added: a list of repositories that are newly registered with the Helm
+ client. Each item in the list is a dict with the following keys:
+ * name: the repo name
+ * url: the repo url
+ * stdout: the output from the `helm repo add` command call for the repo
+
+ * already_absent: any repository name supplied via the `absent` parameter
+ that was already not registered with the Helm client
+
+ * removed: the result of attempting to remove any repositories
+
+ * failed: a list of repositores that were unable to be added. Each item in
+ the list is a dict with the following keys:
+ * type: the text "removal" or "addition", as appropriate
+ * name: the repo name
+ * url: the repo url (if appropriate)
+ * error: the output from add or remove command attempted for the
+ repository
+
+ present
+ The dict of repositories that should be registered with the Helm client.
+ Each dict key is the name with which the repository url (the corresponding
+ value) should be registered with the Helm client.
+
+ absent
+ The list of repositories to ensure are not registered with the Helm client.
+ Each entry in the list must be the (string) name of the repository.
+
+ exclusive
+ A flag indicating whether only the supplied repos should be available in
+ the target minion's Helm client. If configured to true, the `absent`
+ parameter will be ignored and only the repositories configured via the
+ `present` parameter will be registered with the Helm client. Defaults to
+ False.
+ '''
+ existing_repos = list_repos(**kwargs)
+ result = {
+ "already_present": [],
+ "added": [],
+ "already_absent": [],
+ "removed": [],
+ "failed": []
+ }
+
+ for name, url in present.iteritems():
+ if not name or not url:
+ raise CommandExecutionError(('Supplied repo to add must have a name (%s) '
+ 'and url (%s)' % (name, url)))
+
+ if name in existing_repos and existing_repos[name] == url:
+ result['already_present'].append({ "name": name, "url": url })
+ continue
+
+ try:
+ result['added'].append({
+ 'name': name,
+ 'url': url,
+ 'stdout': add_repo(name, url, **kwargs)['stdout']
+ })
+ existing_repos = {
+ n: u for (n, u) in existing_repos.iteritems() if name != n
+ }
+ except CommandExecutionError as e:
+ result['failed'].append({
+ "type": "addition",
+ "name": name,
+ 'url': url,
+ 'error': '%s' % e
+ })
+
+ #
+ # Handle removal of repositories configured to be absent (or not configured
+ # to be present if the `exclusive` flag is set)
+ #
+ existing_names = [name for (name, url) in existing_repos.iteritems()]
+ if exclusive:
+ present['stable'] = "exclude"
+ absent = [name for name in existing_names if not name in present]
+
+ for name in absent:
+ if not name or not isinstance(name, str):
+ raise CommandExecutionError(('Supplied repo name to be absent must be a '
+ 'string: %s' % name))
+
+ if name not in existing_names:
+ result['already_absent'].append(name)
+ continue
+
+ try:
+ result['removed'].append({
+ 'name': name,
+ 'stdout': remove_repo(name, **kwargs) ['stdout']
+ })
+ except CommandExecutionError as e:
+ result['failed'].append({
+ "type": "removal", "name": name, "error": '%s' % e
+ })
+
+ return result
+
+def update_repos(**kwargs):
+ '''
+ Ensures the local helm repository cache for each repository is up to date.
+ Proxies the `helm repo update` command.
+ '''
+ return _cmd_and_result('repo', 'update', **kwargs)
+
+def get_release(name, tiller_namespace="kube-system", **kwargs):
+ '''
+ Get the parsed release metadata from calling `helm get {{ release }}` for the
+ supplied release name, or None if no release is found. The following keys may
+ or may not be in the returned dict:
+
+ * chart
+ * version
+ * values
+ * computed_values
+ * manifest
+ * namespace
+ '''
+ kwargs['tiller_namespace'] = tiller_namespace
+ cmd = _helm_cmd('get', name, **kwargs)
+ result = __salt__['cmd.run_stdout'](**cmd)
+ if not result:
+ return None
+
+ release = _parse_release(result)
+
+ #
+ # `helm get {{ release }}` doesn't currently (2.6.2) return the namespace, so
+ # separately retrieve it if it's not available
+ #
+ if not 'namespace' in release:
+ release['namespace'] = _get_release_namespace(name, **kwargs)
+ return release
+
+def release_exists(name, tiller_namespace="kube-system", **kwargs):
+ '''
+ Determine whether a release exists in the cluster with the supplied name
+ '''
+ kwargs['tiller_namespace'] = tiller_namespace
+ return get_release(name, **kwargs) is not None
+
+def release_create(name, chart_name, namespace='default',
+ version=None, values_file=None,
+ tiller_namespace='kube-system', **kwargs):
+ '''
+ Install a release. There must not be a release with the supplied name
+ already installed to the Kubernetes cluster.
+
+ Note that if a release already exists with the specified name, you'll need
+ to use the release_upgrade function instead; unless the release is in a
+ different namespace, in which case you'll need to delete and purge the
+ existing release (using release_delete) and *then* use this function to
+ install a new release to the desired namespace.
+ '''
+ args = []
+ if version is not None:
+ args += ['--version', version]
+ if values_file is not None:
+ args += ['--values', values_file]
+ return _cmd_and_result(
+ 'install', chart_name,
+ '--namespace', namespace,
+ '--name', name,
+ *args, **kwargs
+ )
+
+def release_delete(name, tiller_namespace='kube-system', **kwargs):
+ '''
+ Delete and purge any release found with the supplied name.
+ '''
+ kwargs['tiller_namespace'] = tiller_namespace
+ return _cmd_and_result('delete', '--purge', name, **kwargs)
+
+
+def release_upgrade(name, chart_name, namespace='default',
+ version=None, values_file=None,
+ tiller_namespace='kube-system', **kwargs):
+ '''
+ Upgrade an existing release. There must be a release with the supplied name
+ already installed to the Kubernetes cluster.
+
+ If attempting to change the namespace for the release, this function will
+ fail; you will need to first delete and purge the release and then use the
+ release_create function to create a new release in the desired namespace.
+ '''
+ kwargs['tiller_namespace'] = tiller_namespace
+ args = []
+ if version is not None:
+ args += ['--version', version]
+ if values_file is not None:
+ args += ['--values', values_file]
+ return _cmd_and_result(
+ 'upgrade', name, chart_name,
+ '--namespace', namespace,
+ *args, **kwargs
+ )
+
+def install_chart_dependencies(chart_path, **kwargs):
+ '''
+ Install the chart dependencies for the chart definition located at the
+ specified chart_path.
+
+ chart_path
+ The path to the chart for which to install dependencies
+ '''
+ return _cmd_and_result('dependency', 'build', chart_path, **kwargs)
+
+def package(path, destination = None, **kwargs):
+ '''
+ Package a chart definition, optionally to a specific destination. Proxies the
+ `helm package` command on the target minion
+
+ path
+ The path to the chart definition to package.
+
+ destination : None
+ An optional alternative destination folder.
+ '''
+ args = []
+ if destination:
+ args += ["-d", destination]
+
+ return _cmd_and_result('package', path, *args, **kwargs)