diff --git a/devenv.py b/devenv.py index a9816cab..4afab900 100755 --- a/devenv.py +++ b/devenv.py @@ -17,13 +17,14 @@ provisioner = Provisioner( id=uuid_provisioner_jenkins, name='My AWS', - engine='kqueen.provisioners.jenkins.JenkinsProvisioner', state='OK', - location='-', - access_id='demo', - access_key='Demo123' + engine='kqueen.engines.jenkins.JenkinsEngine', + parameters={ + 'username': 'demo', + 'password': 'Demo123' + } ) - provisioner.save() + provisioner.save(check_status=False) except: print('Adding aws provisioner failed') @@ -45,13 +46,11 @@ provisioner = Provisioner( id=uuid_provisioner_local, name='Manual', - engine='local', state='OK', - location='-', - access_id='', - access_key='' + engine='local', + parameters={} ) - provisioner.save() + provisioner.save(check_status=False) except: print('Adding aws provisioner failed') diff --git a/docs/kqueen.engines.rst b/docs/kqueen.engines.rst new file mode 100644 index 00000000..7d7a7ff3 --- /dev/null +++ b/docs/kqueen.engines.rst @@ -0,0 +1,22 @@ +Engines +======= + +Submodules +---------- + +JenkinsEngine +------------- + +.. automodule:: kqueen.engines.jenkins + :members: + :undoc-members: + :show-inheritance: + :exclude-members: app + + +Module contents +--------------- + +.. automodule:: kqueen.engines.base + :members: + :undoc-members: diff --git a/docs/kqueen.provisioners.rst b/docs/kqueen.provisioners.rst deleted file mode 100644 index 9765137e..00000000 --- a/docs/kqueen.provisioners.rst +++ /dev/null @@ -1,22 +0,0 @@ -Provisioners -============= - -Submodules ----------- - -JenkinsProvisioner ------------------------------------- - -.. automodule:: kqueen.provisioners.jenkins - :members: - :undoc-members: - :show-inheritance: - - -Module contents ---------------- - -.. automodule:: kqueen.provisioners - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/kqueen.rst b/docs/kqueen.rst index e2afb0fb..3f494d42 100644 --- a/docs/kqueen.rst +++ b/docs/kqueen.rst @@ -1,7 +1,6 @@ kqueen package ============== - Models ------- @@ -58,7 +57,7 @@ Subpackages .. toctree:: kqueen.blueprints - kqueen.provisioners + kqueen.engines kqueen.storages diff --git a/gulpfile.js b/gulpfile.js index 8a099349..9e8b5feb 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -190,6 +190,9 @@ gulp.task('watch', function () { gulp.start('javascript-all'); gulp.start('run-server'); }); + watch('./kqueen/**/**/*.py', function() { + gulp.start('run-server'); + }); }); gulp.task('dev', ['run-server', 'watch']); diff --git a/kqueen/blueprints/ui/forms.py b/kqueen/blueprints/ui/forms.py index 89f92fce..0d91e176 100644 --- a/kqueen/blueprints/ui/forms.py +++ b/kqueen/blueprints/ui/forms.py @@ -3,7 +3,7 @@ from wtforms import PasswordField, SelectField, StringField from wtforms.validators import DataRequired -PROVISIONER_ENGINES = [('kqueen.provisioners.jenkins.JenkinsProvisioner', 'JenkinsProvisioner')] +PROVISIONER_ENGINES = [('kqueen.engines.JenkinsEngine', 'Jenkins')] class LoginForm(FlaskForm): @@ -14,8 +14,8 @@ class LoginForm(FlaskForm): class ProvisionerCreateForm(FlaskForm): name = StringField('Name', validators=[DataRequired()]) engine = SelectField('Engine', choices=PROVISIONER_ENGINES) - access_id = StringField('Access ID', validators=[DataRequired()]) - access_key = PasswordField('Access key', validators=[DataRequired()]) + username = StringField('Username', validators=[DataRequired()]) + password = PasswordField('Password', validators=[DataRequired()]) def _get_provisioners(): diff --git a/kqueen/blueprints/ui/tables.py b/kqueen/blueprints/ui/tables.py index 4c6a87cd..b14c2e99 100644 --- a/kqueen/blueprints/ui/tables.py +++ b/kqueen/blueprints/ui/tables.py @@ -39,7 +39,6 @@ class ProvisionerTable(Table): # Table fields name = Col('Name') engine_name = Col('Engine') - location = Col('Location') state = StatusCol('Status') delete = DeleteCol( 'Delete', diff --git a/kqueen/blueprints/ui/views.py b/kqueen/blueprints/ui/views.py index be5b862c..6476a5c4 100644 --- a/kqueen/blueprints/ui/views.py +++ b/kqueen/blueprints/ui/views.py @@ -4,7 +4,7 @@ from .tables import ProvisionerTable from flask import abort from flask import Blueprint -from flask import current_app +from flask import current_app as app from flask import flash from flask import jsonify from flask import redirect @@ -18,7 +18,6 @@ from uuid import UUID, uuid4 import logging -import time logging.basicConfig(level=logging.DEBUG) logger = logging.getLogger(__name__) @@ -30,13 +29,13 @@ @ui.route('/') @login_required def index(): - username = current_app.config['USERNAME'] + username = app.config['USERNAME'] clusters = [] healthy = 0 for cluster in list(Cluster.list(return_objects=True).values()): data = cluster.get_dict() if data and 'state' in data: - if 'Error' not in data['state']: + if app.config['CLUSTER_ERROR_STATE'] not in data['state']: healthy = healthy + 1 # TODO: teach ORM to get related objects for us @@ -67,9 +66,9 @@ def index(): def login(): error = None if request.method == 'POST': - if request.form['username'] != current_app.config['USERNAME']: + if request.form['username'] != app.config['USERNAME']: error = 'Invalid username' - elif request.form['password'] != current_app.config['PASSWORD']: + elif request.form['password'] != app.config['PASSWORD']: error = 'Invalid password' else: session['logged_in'] = True @@ -107,18 +106,16 @@ def provisioner_create(): provisioner = Provisioner( name=form.name.data, engine=form.engine.data, - state='Not Available', - location='-', - access_id=form.access_id.data, - access_key=form.access_key + state=app.config['PROVISIONER_UNKNOWN_STATE'], + parameters={ + 'username': form.username.data, + 'password': form.password.data + } ) - # Check if provisioner lives - if provisioner.alive(): - provisioner.state = 'OK' provisioner.save() flash('Provisioner %s successfully created.' % provisioner.name, 'success') except Exception as e: - logging.error('Could not create provisioner: %s' % repr(e)) + logger.error('Could not create provisioner: %s' % repr(e)) flash('Could not create provisioner.', 'danger') return redirect('/') return render_template('ui/provisioner_create.html', form=form) @@ -145,7 +142,7 @@ def provisioner_delete(provisioner_id): except NameError: abort(404) except Exception as e: - logging.error(e) + logger.error(e) abort(500) @@ -161,28 +158,28 @@ def cluster_deploy(): cluster = Cluster( id=cluster_id, name=form.name.data, - state='Deploying', + state=app.config['CLUSTER_PROVISIONING_STATE'], provisioner=form.provisioner.data, kubeconfig={}, ) cluster.save() except Exception as e: flash('Could not create cluster %s.' % form.name.data, 'danger') - logging.error('Creating cluster %s failed with following reason: %s' % (form.name.data, repr(e))) + logger.error('Creating cluster %s failed with following reason: %s' % (form.name.data, repr(e))) return redirect('/') # Actually provision cluster - res = False + result = False try: - prv = Provisioner.load(form.provisioner.data).engine_cls() - res = prv.provision(cluster_id) + result, err = cluster.engine.provision() except Exception as e: flash('Could not create cluster %s.' % form.name.data, 'danger') - logging.error('Creating cluster %s failed with following reason: %s' % (form.name.data, repr(e))) + logger.error('Creating cluster %s failed with following reason: %s' % (form.name.data, repr(e))) return redirect('/') - if res: + if result: flash('Provisioning of cluster %s is in progress.' % form.name.data, 'success') else: + logger.error('Creating cluster %s failed with following reason: %s' % (form.name.data, str(err))) flash('Could not create cluster %s.' % form.name.data, 'danger') return redirect('/') return render_template('ui/cluster_deploy.html', form=form) @@ -211,7 +208,7 @@ def cluster_detail(cluster_id): flash('Unable to load cluster', 'danger') status = {} - if obj.get_state() == 'OK': + if obj.get_state() == app.config['CLUSTER_OK_STATE']: try: status = obj.status() except: @@ -235,32 +232,22 @@ def cluster_delete(cluster_id): @login_required def cluster_deployment_status(cluster_id): try: - object_id = UUID(cluster_id, version=5) + object_id = UUID(cluster_id, version=4) except ValueError: - logging.debug('%s not valid UUID' % cluster_id) + logger.debug('%s not valid UUID' % cluster_id) abort(404) # load object try: - obj = Cluster.load(object_id) + cluster = Cluster.load(object_id) except NameError: - logging.debug('Cluster with UUID %s not found' % cluster_id) + logger.debug('Cluster with UUID %s not found' % cluster_id) abort(404) - res = 0 - progress = 1 - result = 'UNKNOWN' try: - prv = obj.get_provisioner() - if prv: - data = prv.engine_cls().get(cluster_id) - result = data['state'] - if data['state'] == 'Deploying': - progress = int((((time.time() * 1000) - data['build_timestamp']) / data['build_estimated_duration']) * 100) - if progress > 99: - progress = 99 - else: - progress = 100 - except: - res = 1 - return jsonify({'response': res, 'progress': progress, 'result': result}) + status = cluster.engine.get_progress() + except Exception as e: + logger.error('Error occured while getting provisioning status for cluster %s: %s' % (cluster_id, repr(e))) + abort(500) + + return jsonify(status) diff --git a/kqueen/config_dev.py b/kqueen/config_dev.py index 3f401564..d5d695cc 100644 --- a/kqueen/config_dev.py +++ b/kqueen/config_dev.py @@ -7,9 +7,22 @@ # App secret SECRET_KEY = 'secret' -# Jenkins provisioner settings +# Cluster statuses +CLUSTER_ERROR_STATE = 'Error' +CLUSTER_OK_STATE = 'OK' +CLUSTER_PROVISIONING_STATE = 'Deploying' +CLUSTER_DEPROVISIONING_STATE = 'Destroying' +CLUSTER_UNKNOWN_STATE = 'Unknown' + +# Provisioner statuses +PROVISIONER_ERROR_STATE = 'Error' +PROVISIONER_OK_STATE = 'OK' +PROVISIONER_UNKNOWN_STATE = 'Not Reachable' + +# Jenkins engine settings JENKINS_API_URL = 'https://ci.mcp.mirantis.net' -JENKINS_JOB_NAME = 'deploy-aws-k8s_ha_calico_sm' -JENKINS_JOB_CTX = {} +JENKINS_PROVISION_JOB_NAME = 'deploy-aws-k8s_ha_calico_sm' +JENKINS_PROVISION_JOB_CTX = {} +JENKINS_ANCHOR_PARAMETER = 'STACK_NAME' JENKINS_USERNAME = None JENKINS_PASSWORD = None diff --git a/kqueen/engines/__init__.py b/kqueen/engines/__init__.py new file mode 100644 index 00000000..2879652b --- /dev/null +++ b/kqueen/engines/__init__.py @@ -0,0 +1,3 @@ +from .jenkins import JenkinsEngine + +__all__ = ['JenkinsEngine'] diff --git a/kqueen/engines/base.py b/kqueen/engines/base.py new file mode 100644 index 00000000..8dffc6ff --- /dev/null +++ b/kqueen/engines/base.py @@ -0,0 +1,166 @@ +class BaseEngine: + """Base Engine object. + + When you initialize the engine through the prepared property :func:`~kqueen.models.Cluster.engine` + on :obj:`kqueen.models.Cluster` model object, all keys in engine object parameters attribute (JSONField) on + :obj:`kqueen.models.Provisioner` object are passed as kwargs. + + Example:: + >>> print my_provisioner.parameters + {'username': 'foo', 'password': 'bar'} + >>> print my_cluster.engine.conn_kw + {'username': 'foo', 'password': 'bar'} + + Credentials passed from parameters attribute to kwargs of MyProvisioner class + used in conn_kw dict for client initialization. + + Args: + cluster (:obj:`kqueen.models.Cluster`): Cluster model object related to + this engine instance. + **kwargs: Keyword arguments specific to Provisioner implementation. + + Attributes: + cluster (:obj:`kqueen.models.Cluster`): Cluster model object related to + this engine instance. + name (str): Name of the engine usable by program. + verbose_name (str): Human readable name of the engine. + """ + name = 'base_engine' + verbose_name = 'Base Engine' + + def __init__(self, cluster, **kwargs): + self.cluster = cluster + + def cluster_list(self): + """Get all clusters available on backend. + + Returns: + list: list of dictionaries. Dictionary format should be:: + + { + 'key': key, # this record should be cached under this key if you choose to cache + 'name': name, # name of the cluster in its respective backend + 'id': id, # id of `kqueen.models.Cluster` object in KQueen database + 'state': state, # cluster.state + 'metadata': { + 'foo': bar # any keys specific for the Provisioner implementation + } + } + """ + raise NotImplementedError + + def cluster_get(self): + """Get single cluster from backend related to this engine instance. + + Although this function doesn't take any arguments, it is expected that + the implementation of the Provisioner gets ``self.cluster`` to provide the + relevant object for which we want to get data from backend. + + Returns: + dict: Dictionary format should be:: + + { + 'key': key, # (str) this record should be cached under this key if you choose to cache + 'name': name, # (str) name of the cluster in its respective backend + 'id': id, # (str or UUID) id of `kqueen.models.Cluster` object in KQueen database + 'state': state, # (str) cluster.state + 'metadata': { + 'foo': bar # any keys specific for the Provisioner implementation + } + } + """ + raise NotImplementedError + + def provision(self): + """Provision the cluster related to this engine instance to backend. + + Although this function doesn't take any arguments, it is expected that + the implementation of the Provisioner gets ``self.cluster`` to provide the + relevant object which we want to provision to backend. + + Returns: + tuple: First item is bool (success/failure), second item is error, can be None:: + + (True, None) # successful provisioning + (False, 'Could not connect to backend') # failed provisioning + """ + raise NotImplementedError + + def deprovision(self): + """Deprovision the cluster related to this engine instance from backend. + + Although this function doesn't take any arguments, it is expected that + the implementation of the Provisioner gets ``self.cluster`` to provide the + relevant object which we want to remove from backend. + + Returns: + tuple: First item is bool (success/failure), second item is error, can be None:: + + (True, None) # successful provisioning + (False, 'Could not connect to backend') # failed provisioning + """ + raise NotImplementedError + + def get_kubeconfig(self): + """Get kubeconfig of the cluster related to this engine from backend. + + Although this function doesn't take any arguments, it is expected that + the implementation of the Provisioner gets ``self.cluster`` to provide the + relevant object which we want to get kubeconfig for. + + Returns: + dict: Dictionary form of kubeconfig (`yaml.load(kubeconfig_file)`) + """ + raise NotImplementedError + + def get_parameter_schema(self): + """Return parameters specific for this Provisioner implementation. + + This method should return parameters specific to the Provisioner implementation, + these are used to generate form for creation of Provisioner object and are stored + in parameters attribute (JSONField) of the `kqueen.models.Provisioner` object. + + Returns: + dict: Dictionary representation of the parameters with hints for form rendering.:: + + { + 'username': { + 'type': 'text', + 'required': True, + 'initial': None + } + 'password': { + 'type': 'password', + 'required': True, + 'initial': None + } + } + """ + raise NotImplementedError + + def get_progress(self): + """Get progress of provisioning if its possible to determine. + + Although this function doesn't take any arguments, it is expected that + the implementation of the Provisioner gets ``self.cluster`` to provide the + relevant object which we want to get provisioning progress for. + + Returns: + dict: Dictionary representation of the provisioning provress.:: + + { + 'response': response, # (int) any number other than 0 means failure to determine progress + 'progress': progress, # (int) provisioning progress in percents + 'result': result # (str) current state of the cluster, i.e. 'Deploying' + } + """ + raise NotImplementedError + + @staticmethod + def engine_status(): + """Check if backend this Provisioner implements is reachable and/or working. + + Returns: + str: Return status of engine, should use statuses from ``app.config`` + """ + raise NotImplementedError diff --git a/kqueen/engines/jenkins.py b/kqueen/engines/jenkins.py new file mode 100644 index 00000000..7098095b --- /dev/null +++ b/kqueen/engines/jenkins.py @@ -0,0 +1,293 @@ +from flask import current_app as app +from kqueen.engines.base import BaseEngine +from kqueen.server import cache + +import jenkins +import logging +import requests +import time +import yaml + +# Ugly patch to make this module importable outside app context to generate docs +if not app: + from kqueen import config_dev + app = type( + 'app', + (object,), + {'config': {k: v for (k, v) in config_dev.__dict__.items() if not k.startswith("__")}} + ) + +logging.basicConfig(level=logging.DEBUG) +logger = logging.getLogger(__name__) + + +STATE_MAP = { + 'ABORTED': app.config['CLUSTER_ERROR_STATE'], + 'FAILURE': app.config['CLUSTER_ERROR_STATE'], + 'NOT_BUILT': app.config['CLUSTER_UNKNOWN_STATE'], + 'SUCCESS': app.config['CLUSTER_OK_STATE'], + 'UNSTABLE': app.config['CLUSTER_UNKNOWN_STATE'] +} + + +class JenkinsEngine(BaseEngine): + name = 'jenkins' + verbose_name = 'Jenkins' + jenkins_url = app.config['JENKINS_API_URL'] + username = app.config['JENKINS_USERNAME'] + password = app.config['JENKINS_PASSWORD'] + provision_job_name = app.config['JENKINS_PROVISION_JOB_NAME'] + anchor_parameter = app.config['JENKINS_ANCHOR_PARAMETER'] + parameter_schema = { + 'username': { + 'type': 'text', + 'required': True, + 'initial': None + }, + 'password': { + 'type': 'password', + 'required': True, + 'initial': None + } + } + + def __init__(self, cluster, **kwargs): + """ + Implementation of :func:`~kqueen.engines.base.BaseEngine.__init__` + """ + # Call parent init to save cluster on self + super(JenkinsEngine, self).__init__(cluster, **kwargs) + # Client initialization + self.username = kwargs.get('username', self.username) + self.password = kwargs.get('password', self.password) + self.client = self._get_client() + # Cache settings + self.cache_timeout = 5 * 60 + + def _get_provision_job_builds(self): + """ + Get builds history of Jenkins job used to provision clusters + + Returns: + dict: More information at :func:`~jenkins.Jenkins.get_job_info` + """ + return self.client.get_job_info(self.provision_job_name, depth=1) + + @classmethod + def engine_status(cls): + """ + Implementation of :func:`~kqueen.engines.base.BaseEngine.engine_status` + """ + conn_kw = { + 'username': app.config['JENKINS_USERNAME'], + 'password': app.config['JENKINS_PASSWORD'] + } + status = app.config['PROVISIONER_UNKNOWN_STATE'] + try: + client = jenkins.Jenkins(app.config['JENKINS_API_URL'], **conn_kw) + version = client.get_version() + if version: + status = app.config['PROVISIONER_OK_STATE'] + except Exception as e: + logger.error('Could not contact JenkinsEngine backend: %s' % repr(e)) + status = app.config['PROVISIONER_ERROR_STATE'] + return status + + def _get_client(self): + """ + Initialize Jenkins client + + Returns: + :obj:`jenkins.Jenkins`: initialized Jenkins client + """ + return jenkins.Jenkins(self.jenkins_url, **{ + 'username': self.username, + 'password': self.password + }) + + def provision(self, **kwargs): + """ + Implementation of :func:`~kqueen.engines.base.BaseEngine.provision` + """ + cluster_id = self.cluster.id + ctx = app.config['JENKINS_PROVISION_JOB_CTX'] + # PATCH THE CTX TO CONTAIN ANCHOR WITH OBJ UUID + ctx['STACK_NAME'] = 'KQUEEN__%s' % str(cluster_id) + try: + self.client.build_job(self.provision_job_name, ctx) + return (True, None) + except Exception as e: + msg = 'Creating cluster %s failed with following reason: %s' % (str(cluster_id), repr(e)) + logger.error(msg) + return (False, msg) + return (None, None) + + def get_kubeconfig(self): + """ + Implementation of :func:`~kqueen.engines.base.BaseEngine.get_kubeconfig` + """ + cluster_external_id = self._get_external_id() + if not cluster_external_id: + return {} + kubeconfig_url = '%s/job/%s/%s/artifact/kubeconfig' % ( + self.jenkins_url, self.provision_job_name, str(cluster_external_id)) + kubeconfig = {} + try: + kubeconfig = yaml.load(requests.get(kubeconfig_url).text) + except Exception as e: + logger.error(repr(e)) + return kubeconfig + + def _get_external_id(self): + """ + Get external ID of cluster, in this case Jenkins job ID. + + First we try to get external_id from related object metadata, if there is no external_id + yet, we need to look it up in build history of our configured provisioning Jenkins job + + Returns: + int: Jenkins job ID + """ + metadata = self.cluster.metadata or {} + external_id = metadata.get('external_id', None) + if external_id: + return external_id + try: + cluster = self._get_by_id() + external_id = cluster['metadata']['external_id'] + # Get fresh data just in case to avoid conflict + metadata = self.cluster.metadata or {} + metadata['external_id'] = external_id + self.cluster.metadata = metadata + self.cluster.save() + return external_id + except: + pass + return external_id + + def _get_by_id(self): + cluster_id = self.cluster.id + _list = self.cluster_list() + cluster = [c for c in _list if c['id'] == cluster_id] + return cluster[0] if cluster else {} + + def _get_by_external_id(self): + cluster_external_id = self._get_external_id() + # Cannot get by external_id if there is no external_id on self.cluster + if not cluster_external_id: + return {} + # Try to get the data from cache + cluster_cache_key = 'cluster-{}-{}'.format(self.name, cluster_external_id) + cluster = cache.get(cluster_cache_key) + if cluster: + return cluster + # Get build info for the given job ID (external_id) + build = self.client.get_build_info(self.provision_job_name, int(cluster_external_id)) + cluster = self._get_cluster_from_build(build) + return cluster or {} + + def cluster_get(self): + """ + Implementation of :func:`~kqueen.engines.base.BaseEngine.cluster_get` + + First we try to get cluster by external_id, because its much more efficient in this + implementation. If its not possible yet, we return from the slower method + """ + cluster = self._get_by_external_id() + if cluster: + return cluster + return self._get_by_id() + + def _get_cluster_from_build(self, build): + cluster_cache_key = 'cluster-{}-{}'.format(self.name, build['number']) + cluster = cache.get(cluster_cache_key) + + if cluster is None: + logger.debug('Build {} missing in cache'.format(cluster_cache_key)) + + # Prepare build parameters + _parameters = [d for d in build.get('actions', []) if d.get('parameters', [])] + parameters = _parameters[0].get('parameters', []) if _parameters else [] + + # Try to determine stack name on backend + stack_name = '' + if build['result'] in ['SUCCESS'] and build.get('description'): + stack_name = build['description'].split(' ')[0] + + # Try to determine cluster_id + _cluster_id = [p.get('value', '') for p in parameters + if p.get('name', '') == 'STACK_NAME' and p.get('value', '').startswith('KQUEEN')] + cluster_id = _cluster_id[0].split('__')[1] if _cluster_id else None + + # Try to determine cluster state + if build['result']: + try: + state = STATE_MAP[build['result']] + except KeyError: + logger.warning('%s is not valid cluster state' % str(build['result'])) + state = app.config['CLUSTER_UNKNOWN_STATE'] + else: + state = app.config['CLUSTER_PROVISIONING_STATE'] + + cluster = { + 'key': cluster_cache_key, + 'name': stack_name, + 'id': cluster_id, + 'state': state, + 'metadata': { + 'external_id': build['number'], + 'build_timestamp': build['timestamp'], + 'build_estimated_duration': build['estimatedDuration'] + } + } + + if cluster['state'] != app.config['CLUSTER_PROVISIONING_STATE']: + cache.set(cluster_cache_key, cluster, timeout=self.cache_timeout) + + return cluster + + def cluster_list(self): + """ + Implementation of :func:`~kqueen.engines.base.BaseEngine.cluster_list` + """ + job = self._get_provision_job_builds() + clusters = [] + + for build in job['builds']: + logger.debug('Reading build {}'.format(build)) + cluster = self._get_cluster_from_build(build) + clusters.append(cluster) + + return clusters + + def get_progress(self): + """ + Implementation of :func:`~kqueen.engines.base.BaseEngine.get_progress` + """ + response = 0 + progress = 1 + result = app.config['CLUSTER_UNKNOWN_STATE'] + try: + cluster = self.cluster_get() + result = cluster['state'] + if cluster['state'] == app.config['CLUSTER_PROVISIONING_STATE']: + # Determine approximate percentage of progress, it is based on estimation + # from Jenkins, so it can get above 99 percent without being done, so there + # is patch to hold it on 99 untill its actually done + now = time.time() * 1000 + start = cluster['metadata']['build_timestamp'] + estimate = cluster['metadata']['build_estimated_duration'] + progress = int(((now - start) / estimate) * 100) + if progress > 99: + progress = 99 + else: + progress = 100 + except: + response = 1 + return {'response': response, 'progress': progress, 'result': result} + + def get_parameter_schema(self): + """ + Implementation of :func:`~kqueen.engines.base.BaseEngine.get_parameter_schema` + """ + return self.parameter_schema diff --git a/kqueen/engines/test_jenkins.py b/kqueen/engines/test_jenkins.py new file mode 100644 index 00000000..b0c7d6ba --- /dev/null +++ b/kqueen/engines/test_jenkins.py @@ -0,0 +1,14 @@ +from .jenkins import JenkinsEngine + + +class TestJenkinsInit: + def setup(self): + pass + + def test_init_jenkins(self, cluster): + JenkinsEngine(cluster) + + def test_init_jenkins_with_kwargs(self, cluster): + engine = JenkinsEngine(cluster, username='foo', password='bar') + assert engine.username == 'foo' + assert engine.password == 'bar' diff --git a/kqueen/models.py b/kqueen/models.py index 5a1eb801..7cdaace4 100644 --- a/kqueen/models.py +++ b/kqueen/models.py @@ -1,10 +1,11 @@ from importlib import import_module +from flask import current_app as app + from kqueen.kubeapi import KubernetesAPI from kqueen.storages.etcd import IdField from kqueen.storages.etcd import JSONField from kqueen.storages.etcd import Model from kqueen.storages.etcd import ModelMeta -from kqueen.storages.etcd import SecretField from kqueen.storages.etcd import StringField import logging @@ -19,60 +20,49 @@ class Cluster(Model, metaclass=ModelMeta): id = IdField() - external_id = StringField() name = StringField() provisioner = StringField() state = StringField() kubeconfig = JSONField() + metadata = JSONField() def get_state(self): - if self.state != 'Deploying': + if self.state != app.config['CLUSTER_PROVISIONING_STATE']: return self.state try: - prv = self.get_provisioner().engine_cls() - c_data = prv.get(str(self.id)) - if c_data['state'] == 'Deploying': + cluster = self.engine.cluster_get() + if cluster['state'] == app.config['CLUSTER_PROVISIONING_STATE']: return self.state - self.state = 'OK' if c_data['state'] == 'SUCCESS' else 'Error' + self.state = cluster['state'] self.save() - return self.state except: pass return self.state def get_provisioner(self): try: - prv = Provisioner.load(self.provisioner) + provisioner = Provisioner.load(self.provisioner) except: - prv = None - return prv + provisioner = None + return provisioner - def get_external_id(self): - if self.external_id: - return self.external_id - try: - prv = self.get_provisioner().engine_cls() - c_data = prv.get(str(self.id)) - external_id = c_data['build_number'] - self.external_id = external_id - self.save() - return external_id - except: - pass + @property + def engine(self): + provisioner = self.get_provisioner() + if provisioner: + _class = provisioner.get_engine_cls() + if _class: + parameters = provisioner.parameters or {} + return _class(self, **parameters) return None def get_kubeconfig(self): if self.kubeconfig: return self.kubeconfig - if self.get_external_id(): - try: - kubeconfig = self.get_provisioner().engine_cls().get_kubeconfig(self.external_id) - self.kubeconfig = kubeconfig - self.save() - return kubeconfig - except: - pass - return {} + kubeconfig = self.engine.get_kubeconfig() + self.kubeconfig = kubeconfig + self.save() + return kubeconfig def status(self): """Return information about Kubernetes cluster""" @@ -99,14 +89,9 @@ class Provisioner(Model, metaclass=ModelMeta): name = StringField() engine = StringField() state = StringField() - # TODO: Do not hardcode AWS specific params, just create JSONField with - # params then pass params field as argument to class saved in type field - access_id = StringField() - access_key = SecretField() - location = StringField() + parameters = JSONField() - @property - def engine_cls(self): + def get_engine_cls(self): """Return engine class""" try: module_path = '.'.join(self.engine.split('.')[:-1]) @@ -119,8 +104,19 @@ def engine_cls(self): @property def engine_name(self): - return self.engine_cls.__name__ if self.engine_cls else self.engine + return getattr(self.get_engine_cls(), 'verbose_name', self.engine) + + def engine_status(self, save=True): + state = app.config['PROVISIONER_UNKNOWN_STATE'] + klass = self.get_engine_cls() + if klass: + state = klass.engine_status() + if save: + self.state = state + self.save() + return state - def alive(self): - """Test availability of provisioner and return bool""" - return True + def save(self, check_status=True): + if check_status: + self.state = self.engine_status(save=False) + return super(Provisioner, self).save() diff --git a/kqueen/provisioners/__init__.py b/kqueen/provisioners/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/kqueen/provisioners/jenkins.py b/kqueen/provisioners/jenkins.py deleted file mode 100644 index 1f8a6819..00000000 --- a/kqueen/provisioners/jenkins.py +++ /dev/null @@ -1,124 +0,0 @@ -import jenkins -import logging -import requests -import yaml - -from flask import current_app as app -from werkzeug.contrib.cache import SimpleCache - -logging.basicConfig(level=logging.DEBUG) -logger = logging.getLogger(__name__) - -cache = SimpleCache() - - -class JenkinsProvisioner(): - def __init__(self, *args, **kwargs): - # configuration - self.jenkins_url = kwargs.get('jenkins_url', app.config['JENKINS_API_URL']) - conn_kw = { - 'username': kwargs.get('username', app.config['JENKINS_USERNAME']), - 'password': kwargs.get('password', app.config['JENKINS_PASSWORD']) - } - self.client = jenkins.Jenkins(self.jenkins_url, **conn_kw) - - self.provisioner = 'jenkins' - self.cache_timeout = 5 * 60 - self.job_name = kwargs.get('job_name', app.config['JENKINS_JOB_NAME']) - - def get_job(self): - return self.client.get_job_info(self.job_name, depth=1) - - def provision(self, obj_id, **kwargs): - # TODO: write extension for Python Jenkins so we can catch headers and return - # queue item ID in jenkins.build_job response which we can immediatelly save - # on Cluster object and keep working with it, so we can avoid using anchor to - # distinguish which builds were triggered by this app - # More informations here: https://issues.jenkins-ci.org/browse/JENKINS-12827 - ctx = kwargs.get('job_ctx', app.config['JENKINS_JOB_CTX']) - # PATCH THE CTX TO CONTAIN ANCHOR WITH OBJ UUID - ctx['STACK_NAME'] = 'KQUEEN__%s' % str(obj_id) - try: - self.client.build_job(self.job_name, ctx) - return True - except Exception as e: - logging.error('Creating cluster %s failed with following reason: %s' % (obj_id, repr(e))) - return False - - def get_kubeconfig(self, job_number): - kubeconfig_url = '%s/job/%s/%s/artifact/kubeconfig' % ( - self.jenkins_url, self.job_name, str(job_number)) - kubeconfig = {} - try: - kubeconfig = yaml.load(requests.get(kubeconfig_url).text) - except Exception as e: - logger.error(repr(e)) - return kubeconfig - - def get(self, obj_id): - _list = self.list() - for key, value in _list.items(): - if obj_id == value['obj_id']: - return value - return {} - - def list(self): - job = self.get_job() - clusters = {} - - for build in job['builds']: - logger.debug('Reading build {}'.format(build)) - - cluster_id = 'cluster-{}-{}'.format(self.provisioner, build['number']) - clusters[cluster_id] = cache.get(cluster_id) - - if clusters[cluster_id] is None: - logger.debug('Build {} missing in cache'.format(cluster_id)) - _parameters = [d for d in build.get('actions', []) if d.get('parameters', [])] - parameters = _parameters[0].get('parameters', []) if _parameters else [] - - stack_name = '' - if build['result'] in ['SUCCESS'] and build.get('description'): - stack_name = build['description'].split(' ')[0] - # LOOKUP STACKS WITH OUR ANCHOR AND DON'T THINK ABOUT IT TOO MUCH - _obj_id = [p.get('value', '') for p in parameters if p.get('name', '') == 'STACK_NAME' and p.get('value', '').startswith('KQUEEN')] - obj_id = _obj_id[0].split('__')[1] if _obj_id else None - - clusters[cluster_id] = { - 'name': stack_name, - 'artifacts': [], - 'obj_id': obj_id, - 'build_number': build['number'], - 'build_timestamp': build['timestamp'], - 'build_estimated_duration': build['estimatedDuration'], - 'state': build['result'] if build['result'] else 'Deploying' - } - - # parse artifacts - # if build_info.get('artifacts'): - # for a in build_info['artifacts']: - # clusters[cluster_id]['artifacts'].append(a['relativePath']) - - # read outputs and kubeconfig - # for f in ['outputs.json', 'kubeconfig']: - # url = '{}/{}/{}'.format( - # build_info['url'], - # 'artifact', - # f - # ) - # logger.debug('Downloading ' + url) - # response = requests.get(url) - # if response.status_code == 200: - # if f.endswith('.json'): - # content = response.json() - # elif f.endswith('.yml') or f == 'kubeconfig': - # content = yaml.load(response.text) - # else: - # content = response.text - - # clusters[cluster_id]['config'][f] = content - if clusters[cluster_id]['state'] != 'Deploying': - cache.set(cluster_id, clusters[cluster_id], timeout=self.cache_timeout) - # ONLY GET 5 LATEST BUILDS - - return clusters diff --git a/kqueen/server.py b/kqueen/server.py index 2b7e7d38..8dbe84ac 100644 --- a/kqueen/server.py +++ b/kqueen/server.py @@ -4,6 +4,7 @@ from kqueen.blueprints.api.views import api from kqueen.blueprints.ui.views import ui from kqueen.serializers import CustomJSONEncoder +from werkzeug.contrib.cache import SimpleCache import logging import os @@ -11,6 +12,8 @@ logging.basicConfig(level=logging.DEBUG) logger = logging.getLogger(__name__) +cache = SimpleCache() + def create_app(): app = Flask(__name__, static_folder='./asset/static')