From bd193fb7cb586a808448e1e58d2d6cb34068b0b8 Mon Sep 17 00:00:00 2001 From: Adam Tengler Date: Fri, 19 Jan 2018 19:27:40 +0100 Subject: [PATCH 1/6] HelmWrapper POC --- kqueen/helm.py | 102 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 kqueen/helm.py diff --git a/kqueen/helm.py b/kqueen/helm.py new file mode 100644 index 00000000..b0e10832 --- /dev/null +++ b/kqueen/helm.py @@ -0,0 +1,102 @@ +from tempfile import mkstemp + +import json +import os +import re +import six +import subprocess + + +class HelmMissingDependency(Exception): + pass + + +class HelmCallError(Exception): + pass + + +class HelmWrapper: + def __init__(self, kubeconfig): + # check for helm binary + self.kubepath = '' + try: + self._call('which helm') + except HelmCallError: + raise HelmMissingDependency('Helm binary is required to use HelmWrapper.') + if not isinstance(kubeconfig, dict): + raise TypeError('Kubeconfig must be dictionary.') + self.kubehandle, self.kubepath = mkstemp(prefix='khelm-') + with open(self.kubepath, 'w') as outfile: + json.dump(kubeconfig, outfile) + + def __del__(self): + try: + os.close(self.kubehandle) + os.remove(self.kubepath) + except Exception: + pass + + def _call(self, cmd): + if isinstance(cmd, six.string_types): + cmd = cmd.split() + env = os.environ.copy() + env['KUBECONFIG'] = self.kubepath + res = subprocess.run(cmd, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + if res.returncode != 0: + raise HelmCallError(res.stderr.decode('utf-8')) + return res.stdout.decode('utf-8') + + def _no_parse(self, response): + return {'raw': response} + + def _parse_helm_horizontal_list(self, response, keys): + parsed = [] + lines = response.splitlines() + if lines: + del lines[0] + for line in lines: + parsed_line = re.split(r'\t+', line) + clean_line = [ch.rstrip() for ch in parsed_line] + item = dict(zip(keys, clean_line)) + parsed.append(item) + return parsed + + def delete(self, release): + raw = self._call('helm delete {}'.format(release)) + return self._no_parse(raw) + + def get(self, release): + raw = self._call('helm get {}'.format(release)) + return self._no_parse(raw) + + def _parse_history(self, response): + keys = ['revision', 'updated', 'status', 'chart', 'description'] + return self._parse_helm_horizontal_list(response, keys) + + def history(self, release): + raw = self._call('helm history {}'.format(release)) + return self._parse_history(raw) + + def init(self): + raw = self._call('helm init') + return self._no_parse(raw) + + def install(self, chart): + raw = self._call('helm install {}'.format(chart)) + return self._no_parse(raw) + + def _parse_list(self, response): + keys = ['name', 'revision', 'updated', 'status', 'chart', 'namespace'] + return self._parse_helm_horizontal_list(response, keys) + + def list(self): + raw = self._call('helm list') + return self._parse_list(raw) + + def repo_update(self): + raw = self._call('helm repo update') + return self._no_parse(raw) + + def reset(self): + raw = self._call('helm reset') + return self._no_parse(raw) From a4ed98a20795afb435e31ee23d2bf3880a56c8e0 Mon Sep 17 00:00:00 2001 From: Adam Tengler Date: Tue, 23 Jan 2018 20:32:53 +0100 Subject: [PATCH 2/6] Helm views --- kqueen/blueprints/api/views.py | 38 ++++++++++++++++++++++++++++++++++ kqueen/helm.py | 4 ++++ 2 files changed, 42 insertions(+) diff --git a/kqueen/blueprints/api/views.py b/kqueen/blueprints/api/views.py index 1dd91d9c..40961bcc 100644 --- a/kqueen/blueprints/api/views.py +++ b/kqueen/blueprints/api/views.py @@ -13,6 +13,7 @@ from flask_jwt import jwt_required from importlib import import_module from kqueen.auth import encrypt_password +from kqueen.helm import HelmWrapper from kqueen.models import Cluster from kqueen.models import Organization from kqueen.models import Provisioner @@ -398,3 +399,40 @@ def swagger_json(): abort(500) return jsonify(data) + + +# Helm + +@api.route('/clusters//helm/install', methods=['POST']) +@jwt_required() +def helm_install(pk): + obj = get_object(Cluster, pk, current_identity) + + data = request.json + if not isinstance(data, dict) or (isinstance(data, dict) and 'name' not in data): + abort(400) + + helm = HelmWrapper(obj.kubeconfig) + return jsonify(helm.install(data['name'])) + + +@api.route('/clusters//helm/delete', methods=['POST']) +@jwt_required() +def helm_delete(pk): + obj = get_object(Cluster, pk, current_identity) + + data = request.json + if not isinstance(data, dict) or (isinstance(data, dict) and 'name' not in data): + abort(400) + + helm = HelmWrapper(obj.kubeconfig) + return jsonify(helm.delete(data['name'])) + + +@api.route('/clusters//helm/list', methods=['GET']) +@jwt_required() +def helm_list(pk): + obj = get_object(Cluster, pk, current_identity) + + helm = HelmWrapper(obj.kubeconfig) + return jsonify(helm.list()) diff --git a/kqueen/helm.py b/kqueen/helm.py index b0e10832..7add2b49 100644 --- a/kqueen/helm.py +++ b/kqueen/helm.py @@ -100,3 +100,7 @@ def repo_update(self): def reset(self): raw = self._call('helm reset') return self._no_parse(raw) + + def version(self): + raw = self._call('helm version') + return self._no_parse(raw) From 5b1e4e8a35f2e9fcdb7b6f83186eebc433b204a3 Mon Sep 17 00:00:00 2001 From: Adam Tengler Date: Thu, 25 Jan 2018 00:03:17 +0100 Subject: [PATCH 3/6] Implement additional params for helm install --- kqueen/helm.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/kqueen/helm.py b/kqueen/helm.py index 7add2b49..5b2c414d 100644 --- a/kqueen/helm.py +++ b/kqueen/helm.py @@ -81,8 +81,22 @@ def init(self): raw = self._call('helm init') return self._no_parse(raw) - def install(self, chart): - raw = self._call('helm install {}'.format(chart)) + def install(self, chart, release_name=None, overrides=None): + cmd = 'helm install' + if release_name: + cmd = cmd + ' --name={}'.format(release_name) + if overrides and isinstance(overrides, dict): + ovrdhandle, ovrdpath = mkstemp(prefix='khelm-ovrd-') + with open(ovrdpath, 'w') as outfile: + json.dump(overrides, outfile) + cmd = cmd + ' -f {}'.format(ovrdpath) + cmd = cmd + ' {}'.format(chart) + raw = self._call(cmd) + try: + os.close(ovrdhandle) + os.remove(ovrdpath) + except Exception: + pass return self._no_parse(raw) def _parse_list(self, response): From 7bb3d8e3c4ab3f06d70cbf53bf1058cfdbaae5c4 Mon Sep 17 00:00:00 2001 From: Adam Tengler Date: Thu, 25 Jan 2018 00:04:11 +0100 Subject: [PATCH 4/6] Additional views --- kqueen/blueprints/api/views.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/kqueen/blueprints/api/views.py b/kqueen/blueprints/api/views.py index 40961bcc..445d87e5 100644 --- a/kqueen/blueprints/api/views.py +++ b/kqueen/blueprints/api/views.py @@ -403,17 +403,25 @@ def swagger_json(): # Helm +@api.route('/clusters//helm/init', methods=['GET']) +@jwt_required() +def helm_init(pk): + obj = get_object(Cluster, pk, current_identity) + helm = HelmWrapper(obj.kubeconfig) + return jsonify(helm.init()) + + @api.route('/clusters//helm/install', methods=['POST']) @jwt_required() def helm_install(pk): obj = get_object(Cluster, pk, current_identity) data = request.json - if not isinstance(data, dict) or (isinstance(data, dict) and 'name' not in data): + if not isinstance(data, dict): abort(400) helm = HelmWrapper(obj.kubeconfig) - return jsonify(helm.install(data['name'])) + return jsonify(helm.install(**data)) @api.route('/clusters//helm/delete', methods=['POST']) From 6baaedf6a5f58dffec23ab46f2ad8466b3ac1fa5 Mon Sep 17 00:00:00 2001 From: Adam Tengler Date: Thu, 25 Jan 2018 01:06:22 +0100 Subject: [PATCH 5/6] Helm catalog --- kqueen/blueprints/api/views.py | 9 ++++++ kqueen/helm.py | 59 ++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/kqueen/blueprints/api/views.py b/kqueen/blueprints/api/views.py index 445d87e5..9ed8c9ff 100644 --- a/kqueen/blueprints/api/views.py +++ b/kqueen/blueprints/api/views.py @@ -444,3 +444,12 @@ def helm_list(pk): helm = HelmWrapper(obj.kubeconfig) return jsonify(helm.list()) + + +@api.route('/clusters//helm/catalog', methods=['GET']) +@jwt_required() +def helm_catalog(pk): + obj = get_object(Cluster, pk, current_identity) + + helm = HelmWrapper(obj.kubeconfig) + return jsonify(helm.catalog()) diff --git a/kqueen/helm.py b/kqueen/helm.py index 5b2c414d..0251d081 100644 --- a/kqueen/helm.py +++ b/kqueen/helm.py @@ -1,10 +1,13 @@ from tempfile import mkstemp +import asyncio +import concurrent.futures import json import os import re import six import subprocess +import yaml class HelmMissingDependency(Exception): @@ -61,6 +64,31 @@ def _parse_helm_horizontal_list(self, response, keys): parsed.append(item) return parsed + async def _get_catalog(self, loop, chart_names): + results = [] + with concurrent.futures.ThreadPoolExecutor(max_workers=8) as executor: + futures = [ + loop.run_in_executor( + executor, + self.inspect, + cname + ) + for cname in chart_names + ] + for result in await asyncio.gather(*futures): + results.append(result) + return results + + def catalog(self): + charts = self.search() + chart_names = [c['name'] for c in charts] + try: + loop = asyncio.get_event_loop() + except RuntimeError: + loop = asyncio.SelectorEventLoop() + charts = loop.run_until_complete(self._get_catalog(loop, chart_names)) + return charts + def delete(self, release): raw = self._call('helm delete {}'.format(release)) return self._no_parse(raw) @@ -81,6 +109,26 @@ def init(self): raw = self._call('helm init') return self._no_parse(raw) + def _parse_inspect(self, response): + _chart = response.split('\n---')[0] + _values = response.split('\n---')[1] + try: + chart = yaml.load(_chart) + except Exception: + chart = {} + try: + values = yaml.load(_values) + except Exception: + values = {} + return { + 'chart': chart, + 'values': values + } + + def inspect(self, chart): + raw = self._call('helm inspect {}'.format(chart)) + return self._parse_inspect(raw) + def install(self, chart, release_name=None, overrides=None): cmd = 'helm install' if release_name: @@ -115,6 +163,17 @@ def reset(self): raw = self._call('helm reset') return self._no_parse(raw) + def _parse_search(self, response): + keys = ['name', 'version', 'description'] + return self._parse_helm_horizontal_list(response, keys) + + def search(self, chart=None): + cmd = 'helm search' + if chart: + cmd = cmd + ' {}'.format(chart) + raw = self._call(cmd) + return self._parse_search(raw) + def version(self): raw = self._call('helm version') return self._no_parse(raw) From 9d097ab1de722a85a6d0013287be65ca6e9fae4b Mon Sep 17 00:00:00 2001 From: Adam Tengler Date: Fri, 26 Jan 2018 19:29:49 +0100 Subject: [PATCH 6/6] Add Helm inspect view --- kqueen/blueprints/api/views.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/kqueen/blueprints/api/views.py b/kqueen/blueprints/api/views.py index 9ed8c9ff..9e6fc19a 100644 --- a/kqueen/blueprints/api/views.py +++ b/kqueen/blueprints/api/views.py @@ -411,6 +411,14 @@ def helm_init(pk): return jsonify(helm.init()) +@api.route('/clusters//helm/inspect/', methods=['GET']) +@jwt_required() +def helm_inspect(pk, chart): + obj = get_object(Cluster, pk, current_identity) + helm = HelmWrapper(obj.kubeconfig) + return jsonify(helm.inspect(chart)) + + @api.route('/clusters//helm/install', methods=['POST']) @jwt_required() def helm_install(pk):