diff --git a/shipyard_client/__init__.py b/shipyard_client/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/shipyard_client/api_client/__init__.py b/shipyard_client/api_client/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/shipyard_client/api_client/base_client.py b/shipyard_client/api_client/base_client.py new file mode 100644 index 00000000..f17e42c4 --- /dev/null +++ b/shipyard_client/api_client/base_client.py @@ -0,0 +1,86 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import logging +import requests + +from .client_error import ClientError + + +class BaseClient: + def __init__(self, context): + self.logger = logging.Logger('api_client') + self.context = context + + def log_message(self, level, msg): + """ Logs a message with context, and extra populated. """ + self.logger.log(level, msg) + + def debug(self, msg): + """ Debug logger for resources, incorporating context. """ + self.log_message(logging.DEBUG, msg) + + def info(self, ctx, msg): + """ Info logger for resources, incorporating context. """ + self.log_message(logging.INFO, msg) + + def warn(self, msg): + """ Warn logger for resources, incorporating context. """ + self.log_message(logging.WARN, msg) + + def error(self, msg): + """ Error logger for resources, incorporating context. """ + self.log_message(logging.ERROR, msg) + + def post_resp(self, + url, + query_params=None, + data=None, + content_type='application/x-yaml'): + """ Thin wrapper of requests post """ + if not query_params: + query_params = {} + if not data: + data = {} + try: + headers = { + 'X-Context-Marker': self.context.context_marker, + 'content-type': content_type, + 'X-Auth-Token': self.context.get_token() + } + self.debug('Post request url: ' + url) + self.debug('Query Params: ' + str(query_params)) + # This could use keystoneauth1 session, but that library handles + # responses strangely (wraps all 400/500 in a keystone exception) + return requests.post( + url, data=data, params=query_params, headers=headers) + except requests.exceptions.RequestException as e: + self.error(str(e)) + raise ClientError(str(e)) + + def get_resp(self, url, query_params=None): + """ Thin wrapper of requests get """ + if not query_params: + query_params = {} + try: + headers = { + 'X-Context-Marker': self.context.context_marker, + 'X-Auth-Token': self.context.get_token() + } + self.debug('url: ' + url) + self.debug('Query Params: ' + str(query_params)) + return requests.get(url, params=query_params, headers=headers) + except requests.exceptions.RequestException as e: + self.error(str(e)) + raise ClientError(str(e)) diff --git a/shipyard_client/api_client/client_error.py b/shipyard_client/api_client/client_error.py new file mode 100644 index 00000000..b46076f0 --- /dev/null +++ b/shipyard_client/api_client/client_error.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +class ClientError(Exception): + pass diff --git a/shipyard_client/api_client/shipyard_api_client.py b/shipyard_client/api_client/shipyard_api_client.py new file mode 100644 index 00000000..354e8980 --- /dev/null +++ b/shipyard_client/api_client/shipyard_api_client.py @@ -0,0 +1,191 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import enum +import json + +from .base_client import BaseClient + + +class ApiPaths(enum.Enum): + """ Enumeration of api paths. + + This implementation assumes that the endpoint for shipyard + includes api/v1.0, so it is not repeated here. + """ + _BASE_URL = '{}/' + POST_GET_CONFIG = _BASE_URL + 'configdocs/{}' + GET_RENDERED = _BASE_URL + 'renderedconfigdocs' + COMMIT_CONFIG = _BASE_URL + 'commitconfigdocs' + POST_GET_ACTIONS = _BASE_URL + 'actions' + GET_ACTION_DETAIL = _BASE_URL + 'actions/{}' + GET_VALIDATION_DETAIL = _BASE_URL + 'actions/{}/validationdetails/{}' + GET_STEP_DETAIL = _BASE_URL + 'actions/{}/steps/{}' + POST_CONTROL_ACTION = _BASE_URL + 'actions/{}/control/{}' + GET_WORKFLOWS = _BASE_URL + 'workflows' + GET_DAG_DETAIL = _BASE_URL + 'workflows/{}' + + +class ShipyardClient(BaseClient): + """ + A client for shipyard API + :param context: shipyardclient_context, context object + """ + + def __init__(self, context): + super().__init__(context) + self.shipyard_url = context.shipyard_endpoint + + def post_configdocs(self, + collection_id=None, + buffer_mode='rejectoncontents', + document_data=None): + """ + Ingests a collection of documents + :param str collection_id: identifies a collection of docs.Bucket_id + :param str buffermode: append|replace|rejectOnContents + :param str document_data: data in a format understood by Deckhand(YAML) + :returns: diff from last committed revision to new revision + :rtype: Response object + """ + query_params = {"buffermode": buffer_mode} + url = ApiPaths.POST_GET_CONFIG.value.format(self.shipyard_url, + collection_id) + return self.post_resp(url, query_params, document_data) + + def get_configdocs(self, collection_id=None, version='buffer'): + """ + Get the collection of documents from deckhand specified by + collection id + :param collection_id: String, bucket_id in deckhand + :param version: String, committed|buffer + :rtype: Response object + """ + query_params = {"version": version} + url = ApiPaths.POST_GET_CONFIG.value.format(self.shipyard_url, + collection_id) + return self.get_resp(url, query_params) + + def get_rendereddocs(self, version='buffer'): + """ + :param str version: committed|buffer + :returns: full set of configdocs in their rendered form. + :rtype: Response object + """ + query_params = {"version": version} + url = ApiPaths.GET_RENDERED.value.format(self.shipyard_url) + return self.get_resp(url, query_params) + + def commit_configdocs(self, force=False): + """ + :param force: boolean, True|False + :returns: dictionary, validations from UCP components + :rtype: Response object + """ + query_params = {"force": force} + url = ApiPaths.COMMIT_CONFIG.value.format(self.shipyard_url) + return self.post_resp(url, query_params) + + def get_actions(self): + """ + A list of actions that have been executed through shipyard's action API + :returns: lists all actions + :rtype: Response object + """ + url = ApiPaths.POST_GET_ACTIONS.value.format(self.shipyard_url) + return self.get_resp(url) + + def post_actions(self, name=None, parameters=None): + """ + Creates an action in the system. This will cause some action to start. + :param str name: name of supported action to invoke + :param dict parameters: parameters to use for trigger invocation + :returns: action entity created successfully + :rtype: Response object + """ + action_data = {"name": name, "parameters": parameters} + url = ApiPaths.POST_GET_ACTIONS.value.format(self.shipyard_url) + return self.post_resp(url, + data=json.dumps(action_data), + content_type='application/json') + + def get_action_detail(self, action_id=None): + """ + Used to get details about an action + :param str action_id: Unique ID for a particular action + :returns: information describing the action + :rtype: Response object + """ + url = ApiPaths.GET_ACTION_DETAIL.value.format(self.shipyard_url, + action_id) + return self.get_resp(url) + + def get_validation_detail(self, action_id=None, validation_id=None): + """ + Allows for drilldown to validation detailed info. + :param str action_id: Unique action id + :param str validation_id: id of the validation + :returns: validation details about action + :rtype: Response object + """ + url = ApiPaths.GET_VALIDATION_DETAIL.value.format( + self.shipyard_url, action_id, validation_id) + return self.get_resp(url) + + def get_step_detail(self, action_id=None, step_id=None): + """ + Allow for drilldown to step information + :param str action_id: Unique action id + :param str step_id: step id + :returns: details for a step by id for the given action by Id + :rtype: Response object + """ + url = ApiPaths.GET_STEP_DETAIL.value.format(self.shipyard_url, + action_id, step_id) + return self.get_resp(url) + + def post_control_action(self, action_id=None, control_verb=None): + """ + Allows for issuing DAG controls against an action. + :param str action_id: Unique action id + :param str control_verb: control action to be taken against an activity + :returns: containing the status of the action fail or success + :rtype: Response object + """ + url = ApiPaths.POST_CONTROL_ACTION.value.format( + self.shipyard_url, action_id, control_verb) + return self.post_resp(url) + + def get_workflows(self, since=None): + """ + Queries airflow for DAGs that are running or have run + (successfully or unsuccessfully) + :param str since: iso8601 date optional + :returns: DAGS running or that have run + :rtype: Response object + """ + query_params = {'since': since} + url = ApiPaths.GET_WORKFLOWS.value.format(self.shipyard_url) + return self.get_resp(url, query_params) + + def get_dag_detail(self, workflow_id=None): + """ + details of a particular scheduled DAG's output + :param str workflow_id: unique id for a DAG + :returns: details of a DAGs output + :rtype: Response object + """ + url = ApiPaths.GET_DAG_DETAIL.value.format(self.shipyard_url, + workflow_id) + return self.get_resp(url) diff --git a/shipyard_client/api_client/shipyardclient_context.py b/shipyard_client/api_client/shipyardclient_context.py new file mode 100644 index 00000000..1fa07703 --- /dev/null +++ b/shipyard_client/api_client/shipyardclient_context.py @@ -0,0 +1,73 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import logging + +from keystoneauth1 import session +from keystoneauth1.identity import v3 +from keystoneauth1.exceptions.auth import AuthorizationFailure +from keystoneauth1.exceptions.catalog import EndpointNotFound +from .client_error import ClientError + +LOG = logging.getLogger(__name__) + + +class ShipyardClientContext: + def __init__(self, keystone_auth, context_marker, debug=False): + """ + shipyard context object + :param bool debug: true, or false + :param str context_marker: + :param dict keystone_auth: auth_url, password, project_domain_name, + project_name, username, user_domain_name + """ + self.debug = debug + self.keystone_auth = keystone_auth + # the service type will for now just be shipyard will change later + self.service_type = 'shipyard' + self.shipyard_endpoint = self.get_endpoint() + self.set_debug() + self.context_marker = context_marker + + def set_debug(self): + if self.debug: + LOG.setLevel(logging.DEBUG) + + def get_token(self): + """ + Returns the simple token string for a token acquired from keystone + """ + return self._get_ks_session().get_auth_headers().get('X-Auth-Token') + + def _get_ks_session(self): + LOG.debug('Accessing keystone for keystone session') + try: + auth = v3.Password(**self.keystone_auth) + return session.Session(auth=auth) + except AuthorizationFailure as e: + LOG.error('Could not authorize against keystone: %s', str(e)) + raise ClientError(str(e)) + + def get_endpoint(self): + """ + Wraps calls to keystone for lookup with overrides from configuration + """ + LOG.debug('Accessing keystone for %s endpoint', self.service_type) + try: + return self._get_ks_session().get_endpoint( + interface='public', service_type=self.service_type) + except EndpointNotFound as e: + LOG.error('Could not find a public interface for %s', + self.service_type) + raise ClientError(str(e)) diff --git a/shipyard_client/setup.cfg b/shipyard_client/setup.cfg new file mode 100644 index 00000000..ab01613c --- /dev/null +++ b/shipyard_client/setup.cfg @@ -0,0 +1,18 @@ +[metadata] +name = shipyard client +summary = Shipyard client library and CLI +description-file = README.md + +author = undercloud team +home-page = https://github.com/att-comdev/shipyard/shipyard_client +classifier = + Intended Audience :: Information Technology + Intended Audience :: System Administrators + License :: OSI Approved :: Apache Software License + Operating System :: POSIX :: Linux + Programming Language :: Python + Programming Language :: Python :: 3 + Programming Language :: Python :: 3.5 + +[build_sphinx] +warning-is-error = True diff --git a/shipyard_client/tests/__init__.py b/shipyard_client/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/shipyard_client/tests/unit/__init__.py b/shipyard_client/tests/unit/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/shipyard_client/tests/unit/apiclient_test/__init__.py b/shipyard_client/tests/unit/apiclient_test/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/shipyard_client/tests/unit/apiclient_test/test_shipyard_api_client.py b/shipyard_client/tests/unit/apiclient_test/test_shipyard_api_client.py new file mode 100644 index 00000000..aeee2886 --- /dev/null +++ b/shipyard_client/tests/unit/apiclient_test/test_shipyard_api_client.py @@ -0,0 +1,205 @@ +# -*- coding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import mock +import json + +from shipyard_client.api_client.shipyard_api_client import ShipyardClient +from shipyard_client.api_client.base_client import BaseClient + + +class TemporaryContext: + def __init__(self): + self.debug = True + self.keystone_Auth = {} + self.token = 'abcdefgh' + self.service_type = 'http://shipyard' + self.shipyard_endpoint = 'http://shipyard/api/v1.0' + self.context_marker = '123456' + + +def replace_post_rep(self, url, query_params={}, data={}, content_type=''): + """ + replaces call to shipyard client + :returns: dict with url and parameters + """ + return {'url': url, 'params': query_params, 'data': data} + + +def replace_get_resp(self, url, query_params={}, json=False): + """ + replaces call to shipyard client + :returns: dict with url and parameters + """ + return {'url': url, 'params': query_params} + + +def replace_base_constructor(self, context): + pass + + +def get_api_client(): + """ + get a instance of shipyard client + :returns: shipyard client with no context object + """ + context = TemporaryContext() + return ShipyardClient(context) + + +@mock.patch.object(BaseClient, '__init__', replace_base_constructor) +@mock.patch.object(BaseClient, 'post_resp', replace_post_rep) +@mock.patch.object(BaseClient, 'get_resp', replace_get_resp) +def test_post_config_docs(*args): + shipyard_client = get_api_client() + buffermode = 'rejectoncontents' + result = shipyard_client.post_configdocs('ABC', buffer_mode=buffermode) + params = result['params'] + assert result['url'] == '{}/configdocs/ABC'.format( + shipyard_client.shipyard_url) + assert params['buffermode'] == buffermode + + +@mock.patch.object(BaseClient, '__init__', replace_base_constructor) +@mock.patch.object(BaseClient, 'post_resp', replace_post_rep) +@mock.patch.object(BaseClient, 'get_resp', replace_get_resp) +def test_get_config_docs(*args): + shipyard_client = get_api_client() + version = 'buffer' + result = shipyard_client.get_configdocs('ABC', version=version) + params = result['params'] + assert result['url'] == '{}/configdocs/ABC'.format( + shipyard_client.shipyard_url) + assert params['version'] == version + + +@mock.patch.object(BaseClient, '__init__', replace_base_constructor) +@mock.patch.object(BaseClient, 'post_resp', replace_post_rep) +@mock.patch.object(BaseClient, 'get_resp', replace_get_resp) +def test_rendered_config_docs(*args): + shipyard_client = get_api_client() + version = 'buffer' + result = shipyard_client.get_rendereddocs(version=version) + params = result['params'] + assert result['url'] == '{}/renderedconfigdocs'.format( + shipyard_client.shipyard_url) + assert params['version'] == version + + +@mock.patch.object(BaseClient, '__init__', replace_base_constructor) +@mock.patch.object(BaseClient, 'post_resp', replace_post_rep) +@mock.patch.object(BaseClient, 'get_resp', replace_get_resp) +def test_commit_configs(*args): + shipyard_client = get_api_client() + force_mode = True + result = shipyard_client.commit_configdocs(force_mode) + params = result['params'] + assert result['url'] == '{}/commitconfigdocs'.format( + shipyard_client.shipyard_url) + assert params['force'] == force_mode + + +@mock.patch.object(BaseClient, '__init__', replace_base_constructor) +@mock.patch.object(BaseClient, 'post_resp', replace_post_rep) +@mock.patch.object(BaseClient, 'get_resp', replace_get_resp) +def test_get_actions(*args): + shipyard_client = get_api_client() + result = shipyard_client.get_actions() + assert result['url'] == '{}/actions'.format( + shipyard_client.shipyard_url) + + +@mock.patch.object(BaseClient, '__init__', replace_base_constructor) +@mock.patch.object(BaseClient, 'post_resp', replace_post_rep) +@mock.patch.object(BaseClient, 'get_resp', replace_get_resp) +def test_post_actions(*args): + shipyard_client = get_api_client() + name = 'good action' + parameters = {'hello': 'world'} + result = shipyard_client.post_actions(name, parameters) + data = json.loads(result['data']) + assert result['url'] == '{}/actions'.format( + shipyard_client.shipyard_url) + assert data['name'] == name + assert data['parameters']['hello'] == 'world' + + +@mock.patch.object(BaseClient, '__init__', replace_base_constructor) +@mock.patch.object(BaseClient, 'post_resp', replace_post_rep) +@mock.patch.object(BaseClient, 'get_resp', replace_get_resp) +def test_action_details(*args): + shipyard_client = get_api_client() + action_id = 'GoodAction' + result = shipyard_client.get_action_detail(action_id) + assert result['url'] == '{}/actions/{}'.format( + shipyard_client.shipyard_url, action_id) + + +@mock.patch.object(BaseClient, '__init__', replace_base_constructor) +@mock.patch.object(BaseClient, 'post_resp', replace_post_rep) +@mock.patch.object(BaseClient, 'get_resp', replace_get_resp) +def test_get_val_details(*args): + shipyard_client = get_api_client() + action_id = 'GoodAction' + validation_id = 'Validation' + result = shipyard_client.get_validation_detail(action_id, validation_id) + assert result[ + 'url'] == '{}/actions/{}/validationdetails/{}'.format( + shipyard_client.shipyard_url, action_id, validation_id) + + +@mock.patch.object(BaseClient, '__init__', replace_base_constructor) +@mock.patch.object(BaseClient, 'post_resp', replace_post_rep) +@mock.patch.object(BaseClient, 'get_resp', replace_get_resp) +def test_get_step_details(*args): + shipyard_client = get_api_client() + action_id = 'GoodAction' + step_id = 'TestStep' + result = shipyard_client.get_step_detail(action_id, step_id) + assert result['url'] == '{}/actions/{}/steps/{}'.format( + shipyard_client.shipyard_url, action_id, step_id) + + +@mock.patch.object(BaseClient, '__init__', replace_base_constructor) +@mock.patch.object(BaseClient, 'post_resp', replace_post_rep) +@mock.patch.object(BaseClient, 'get_resp', replace_get_resp) +def test_post_control(*args): + shipyard_client = get_api_client() + action_id = 'GoodAction' + control_verb = 'Control' + result = shipyard_client.post_control_action(action_id, control_verb) + assert result['url'] == '{}/actions/{}/control/{}'.format( + shipyard_client.shipyard_url, action_id, control_verb) + + +@mock.patch.object(BaseClient, '__init__', replace_base_constructor) +@mock.patch.object(BaseClient, 'post_resp', replace_post_rep) +@mock.patch.object(BaseClient, 'get_resp', replace_get_resp) +def test_get_workflows(*args): + shipyard_client = get_api_client() + since_mode = 'TestSince' + result = shipyard_client.get_workflows(since_mode) + assert result['url'] == '{}/workflows'.format( + shipyard_client.shipyard_url, since_mode) + + +@mock.patch.object(BaseClient, '__init__', replace_base_constructor) +@mock.patch.object(BaseClient, 'post_resp', replace_post_rep) +@mock.patch.object(BaseClient, 'get_resp', replace_get_resp) +def test_get_dag_details(*args): + shipyard_client = get_api_client() + workflow_id = 'TestWorkflow' + result = shipyard_client.get_dag_detail(workflow_id) + assert result['url'] == '{}/workflows/{}'.format( + shipyard_client.shipyard_url, workflow_id)