diff --git a/.gitignore b/.gitignore index 3263f78f..ac468f41 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ /.cache /.eggs /.helm-pid +/.pytest_cache /.tox /build /conformance diff --git a/doc/source/api.rst b/doc/source/api.rst index 3d17918f..1b39fa8d 100644 --- a/doc/source/api.rst +++ b/doc/source/api.rst @@ -62,3 +62,33 @@ Responses: + 200 OK: Documents were successfully validated + 400 Bad Request: Documents were not successfully validated + + +/v1.0/node-labels/ +----------------------------- + +Update node labels + +PUT /v1.0/node-labels/ + +Updates node labels eg: adding new labels, overriding existing +labels and deleting labels from a node. + +Message Body: + +dict of labels + +.. code-block:: json + + {"label-a": "value1", "label-b": "value2", "label-c": "value3"} + +Responses: + ++ 200 OK: Labels successfully updated ++ 400 Bad Request: Bad input format ++ 401 Unauthorized: Unauthenticated access ++ 403 Forbidden: Unauthorized access ++ 404 Not Found: Bad URL or Node not found ++ 500 Internal Server Error: Server error encountered ++ 502 Bad Gateway: Kubernetes Config Error ++ 503 Service Unavailable: Failed to interact with Kubernetes API diff --git a/doc/source/exceptions.rst b/doc/source/exceptions.rst index 56b98331..29a9f1c7 100644 --- a/doc/source/exceptions.rst +++ b/doc/source/exceptions.rst @@ -39,3 +39,18 @@ Promenade Exceptions :members: :show-inheritance: :undoc-members: + * - KubernetesConfigException + - .. autoexception:: promenade.exceptions.KubernetesConfigException + :members: + :show-inheritance: + :undoc-members: + * - KubernetesApiError + - .. autoexception:: promenade.exceptions.KubernetesApiError + :members: + :show-inheritance: + :undoc-members: + * - NodeNotFoundException + - .. autoexception:: promenade.exceptions.NodeNotFoundException + :members: + :show-inheritance: + :undoc-members: diff --git a/promenade/control/api.py b/promenade/control/api.py index 6e8c2b25..75fb0db6 100644 --- a/promenade/control/api.py +++ b/promenade/control/api.py @@ -19,6 +19,7 @@ from promenade.control.health_api import HealthResource from promenade.control.join_scripts import JoinScriptsResource from promenade.control.middleware import (AuthMiddleware, ContextMiddleware, LoggingMiddleware) +from promenade.control.node_labels import NodeLabelsResource from promenade.control.validatedesign import ValidateDesignResource from promenade import exceptions as exc from promenade import logging @@ -41,6 +42,7 @@ def start_api(): ('/health', HealthResource()), ('/join-scripts', JoinScriptsResource()), ('/validatedesign', ValidateDesignResource()), + ('/node-labels/{node_name}', NodeLabelsResource()), ] # Set up the 1.0 routes diff --git a/promenade/control/node_labels.py b/promenade/control/node_labels.py new file mode 100644 index 00000000..55f23cf9 --- /dev/null +++ b/promenade/control/node_labels.py @@ -0,0 +1,44 @@ +# Copyright 2018 AT&T Intellectual Property. All other rights reserved. +# +# 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 falcon + +from promenade.control.base import BaseResource +from promenade.kubeclient import KubeClient +from promenade import exceptions +from promenade import logging +from promenade import policy + +LOG = logging.getLogger(__name__) + + +class NodeLabelsResource(BaseResource): + """Class for Node Labels Manage API""" + + @policy.ApiEnforcer('kubernetes_provisioner:update_node_labels') + def on_put(self, req, resp, node_name=None): + json_data = self.req_json(req) + if node_name is None: + LOG.error("Invalid format error: Missing input: node_name") + raise exceptions.InvalidFormatError( + description="Missing input: node_name") + if json_data is None: + LOG.error("Invalid format error: Missing input: labels dict") + raise exceptions.InvalidFormatError( + description="Missing input: labels dict") + kubeclient = KubeClient() + response = kubeclient.update_node_labels(node_name, json_data) + + resp.body = response + resp.status = falcon.HTTP_200 diff --git a/promenade/exceptions.py b/promenade/exceptions.py index 8e94af2f..2a18cbb2 100644 --- a/promenade/exceptions.py +++ b/promenade/exceptions.py @@ -295,6 +295,14 @@ class InvalidFormatError(PromenadeException): title = 'Invalid Input Error' status = falcon.HTTP_400 + def __init__(self, title="", description=""): + if not title: + title = self.title + if not description: + description = self.title + super(InvalidFormatError, self).__init__( + title, description, status=self.status) + class ValidationException(PromenadeException): """ @@ -320,6 +328,21 @@ class TemplateRenderException(PromenadeException): status = falcon.HTTP_500 +class KubernetesConfigException(PromenadeException): + title = 'Kubernetes Config Error' + status = falcon.HTTP_502 + + +class KubernetesApiError(PromenadeException): + title = 'Kubernetes API Error' + status = falcon.HTTP_503 + + +class NodeNotFoundException(KubernetesApiError): + title = 'Node not found' + status = falcon.HTTP_404 + + def massage_error_list(error_list, placeholder_description): """ Returns a best-effort attempt to make a nice error list diff --git a/promenade/kubeclient.py b/promenade/kubeclient.py new file mode 100644 index 00000000..875cba1b --- /dev/null +++ b/promenade/kubeclient.py @@ -0,0 +1,136 @@ +# Copyright 2018 AT&T Intellectual Property. All other rights reserved. +# +# 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 falcon +import kubernetes +from kubernetes.client.rest import ApiException +from urllib3.exceptions import MaxRetryError + +from promenade import logging +from promenade.exceptions import KubernetesApiError +from promenade.exceptions import KubernetesConfigException +from promenade.exceptions import NodeNotFoundException +from promenade.utils.success_message import SuccessMessage + +LOG = logging.getLogger(__name__) + + +class KubeClient(object): + """ + Class for Kubernetes APIs client + """ + + def __init__(self): + """ Set Kubernetes APIs connection """ + try: + LOG.info('Loading in-cluster Kubernetes configuration.') + kubernetes.config.load_incluster_config() + except kubernetes.config.config_exception.ConfigException: + LOG.debug('Failed to load in-cluster configuration') + try: + LOG.info('Loading out-of-cluster Kubernetes configuration.') + kubernetes.config.load_kube_config() + except FileNotFoundError: + LOG.exception( + 'FileNotFoundError: Failed to load Kubernetes config file.' + ) + raise KubernetesConfigException + self.client = kubernetes.client.CoreV1Api() + + def update_node_labels(self, node_name, input_labels): + """ + Updating node labels + + Args: + node_name(str): node for which updating labels + input_labels(dict): input labels dict + Returns: + SuccessMessage(dict): API success response + """ + resp_body_succ = SuccessMessage('Update node labels', falcon.HTTP_200) + + try: + existing_labels = self.get_node_labels(node_name) + update_labels = _get_update_labels(existing_labels, input_labels) + # If there is a change + if bool(update_labels): + body = {"metadata": {"labels": update_labels}} + self.client.patch_node(node_name, body) + return resp_body_succ.get_output_json() + except (ApiException, MaxRetryError) as e: + LOG.exception( + "An exception occurred during node labels update: " + str(e)) + raise KubernetesApiError + + def get_node_labels(self, node_name): + """ + Get existing registered node labels + + Args: + node_name(str): node of which getting labels + Returns: + dict: labels dict + """ + try: + response = self.client.read_node(node_name) + if response is not None: + return response.metadata.labels + else: + return {} + except (ApiException, MaxRetryError) as e: + LOG.exception( + "An exception occurred in fetching node labels: " + str(e)) + if hasattr(e, 'status') and str(e.status) == "404": + raise NodeNotFoundException + else: + raise KubernetesApiError + + +def _get_update_labels(existing_labels, input_labels): + """ + Helper function to add new labels, delete labels, override + existing labels + + Args: + existing_labels(dict): Existing node labels + input_labels(dict): Input/Req. labels + Returns: + update_labels(dict): Node labels to be updated + or + input_labels(dict): Node labels to be updated + """ + update_labels = {} + + # no existing labels found + if not existing_labels: + # filter delete label request since there is no labels set on a node + update_labels.update( + {k: v + for k, v in input_labels.items() if v is not None}) + return update_labels + + # new labels or overriding labels + update_labels.update({ + k: v + for k, v in input_labels.items() + if k not in existing_labels or v != existing_labels[k] + }) + + # deleted labels + update_labels.update({ + k: None + for k in existing_labels.keys() + if k not in input_labels and "kubernetes.io" not in k + }) + return update_labels diff --git a/promenade/policy.py b/promenade/policy.py index cbf47ec5..a178d6fa 100644 --- a/promenade/policy.py +++ b/promenade/policy.py @@ -41,6 +41,12 @@ POLICIES = [ 'path': '/api/v1.0/validatedesign', 'method': 'POST' }]), + op.DocumentedRuleDefault('kubernetes_provisioner:update_node_labels', + 'role:admin', 'Update Node Labels', + [{ + 'path': '/api/v1.0/node-labels/{node_name}', + 'method': 'PUT' + }]), ] diff --git a/promenade/utils/message.py b/promenade/utils/message.py new file mode 100644 index 00000000..11737cc1 --- /dev/null +++ b/promenade/utils/message.py @@ -0,0 +1,37 @@ +# Copyright 2018 AT&T Intellectual Property. All other rights reserved. +# +# 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 json + + +class Message(object): + """Message base class""" + + def __init__(self): + self.error_count = 0 + self.details = {'errorCount': 0, 'messageList': []} + self.output = { + 'kind': 'Status', + 'apiVersion': 'v1.0', + 'metadata': {}, + 'details': self.details + } + + def get_output_json(self): + """Returns message as JSON. + + :returns: Message formatted in JSON. + :rtype: json + """ + return json.dumps(self.output, indent=2) diff --git a/promenade/utils/success_message.py b/promenade/utils/success_message.py new file mode 100644 index 00000000..d873d1b2 --- /dev/null +++ b/promenade/utils/success_message.py @@ -0,0 +1,32 @@ +# Copyright 2018 AT&T Intellectual Property. All other rights reserved. +# +# 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 falcon + +from promenade.utils.message import Message + + +class SuccessMessage(Message): + """SuccessMessage per UCP convention: + https://airshipit.readthedocs.io/en/latest/api-conventions.html#status-responses + """ + + def __init__(self, reason='', code=falcon.HTTP_200): + super(SuccessMessage, self).__init__() + self.output.update({ + 'status': 'Success', + 'message': '', + 'reason': reason, + 'code': code + }) diff --git a/promenade/utils/validation_message.py b/promenade/utils/validation_message.py index 24c4010b..faf686ac 100644 --- a/promenade/utils/validation_message.py +++ b/promenade/utils/validation_message.py @@ -15,8 +15,10 @@ import falcon import json +from promenade.utils.message import Message -class ValidationMessage(object): + +class ValidationMessage(Message): """ ValidationMessage per UCP convention: https://github.com/att-comdev/ucp-integration/blob/master/docs/source/api-conventions.rst#output-structure # noqa @@ -34,15 +36,8 @@ class ValidationMessage(object): """ def __init__(self): - self.error_count = 0 - self.details = {'errorCount': 0, 'messageList': []} - self.output = { - 'kind': 'Status', - 'apiVersion': 'v1.0', - 'metadata': {}, - 'reason': 'Validation', - 'details': self.details, - } + super(ValidationMessage, self).__init__() + self.output.update({'reason': 'Validation'}) def add_error_message(self, msg, @@ -80,14 +75,6 @@ class ValidationMessage(object): self.output['status'] = 'Success' return self.output - def get_output_json(self): - """ Return ValidationMessage message as JSON. - - :returns: The ValidationMessage formatted in JSON, for logging. - :rtype: json - """ - return json.dumps(self.output, indent=2) - def update_response(self, resp): output = self.get_output() resp.status = output['code'] diff --git a/tests/unit/api/test_kubeclient.py b/tests/unit/api/test_kubeclient.py new file mode 100644 index 00000000..0da08840 --- /dev/null +++ b/tests/unit/api/test_kubeclient.py @@ -0,0 +1,101 @@ +# Copyright 2018 AT&T Intellectual Property. All other rights reserved. +# +# 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 pytest + +from promenade import kubeclient + +TEST_DATA = [( + 'Multi-facet update', + { + "label-a": "value1", + "label-b": "value2", + "label-c": "value3", + }, + { + "label-a": "value1", + "label-c": "value4", + "label-d": "value99", + }, + { + "label-b": None, + "label-c": "value4", + "label-d": "value99", + }, +), ( + 'Add labels when none exist', + None, + { + "label-a": "value1", + "label-b": "value2", + "label-c": "value3", + }, + { + "label-a": "value1", + "label-b": "value2", + "label-c": "value3", + }, +), ( + 'No updates', + { + "label-a": "value1", + "label-b": "value2", + "label-c": "value3", + }, + { + "label-a": "value1", + "label-b": "value2", + "label-c": "value3", + }, + {}, +), ( + 'Delete labels', + { + "label-a": "value1", + "label-b": "value2", + "label-c": "value3", + }, + {}, + { + "label-a": None, + "label-b": None, + "label-c": None, + }, +), ( + 'Delete labels when none', + None, + {}, + {}, +), ( + 'Avoid kubernetes.io labels Deletion', + { + "label-a": "value1", + "label-b": "value2", + "kubernetes.io/hostname": "ubutubox", + }, + { + "label-a": "value99", + }, + { + "label-a": "value99", + "label-b": None, + }, +)] + + +@pytest.mark.parametrize('description,existing_lbl,input_lbl,expected', + TEST_DATA) +def test_get_update_labels(description, existing_lbl, input_lbl, expected): + applied = kubeclient._get_update_labels(existing_lbl, input_lbl) + assert applied == expected diff --git a/tests/unit/api/test_update_labels.py b/tests/unit/api/test_update_labels.py new file mode 100644 index 00000000..070b38fe --- /dev/null +++ b/tests/unit/api/test_update_labels.py @@ -0,0 +1,88 @@ +# Copyright 2018 AT&T Intellectual Property. All other rights reserved. +# +# 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 falcon +import json +import pytest + +from falcon import testing +from promenade import promenade +from promenade.utils.success_message import SuccessMessage +from unittest import mock + + +@pytest.fixture() +def client(): + return testing.TestClient(promenade.start_promenade(disable='keystone')) + + +@pytest.fixture() +def req_header(): + return { + 'Content-Type': 'application/json', + 'X-IDENTITY-STATUS': 'Confirmed', + 'X-USER-NAME': 'Test', + 'X-ROLES': 'admin' + } + + +@pytest.fixture() +def req_body(): + return json.dumps({ + "label-a": "value1", + "label-c": "value4", + "label-d": "value99" + }) + + +@mock.patch('promenade.kubeclient.KubeClient.update_node_labels') +@mock.patch('promenade.kubeclient.KubeClient.__init__') +def test_node_labels_pass(mock_kubeclient, mock_update_node_labels, client, + req_header, req_body): + """ + Function to test node labels pass test case + + Args: + mock_kubeclient: mock KubeClient object + mock_update_node_labels: mock update_node_labels object + client: Promenode APIs test client + req_header: API request header + req_body: API request body + """ + mock_kubeclient.return_value = None + mock_update_node_labels.return_value = _mock_update_node_labels() + response = client.simulate_put( + '/api/v1.0/node-labels/ubuntubox', headers=req_header, body=req_body) + assert response.status == falcon.HTTP_200 + assert response.json["status"] == "Success" + + +def test_node_labels_missing_inputs(client, req_header, req_body): + """ + Function to test node labels missing inputs + + Args: + client: Promenode APIs test client + req_header: API request header + req_body: API request body + """ + response = client.simulate_post( + '/api/v1.0/node-labels', headers=req_header, body=req_body) + assert response.status == falcon.HTTP_404 + + +def _mock_update_node_labels(): + """Mock update_node_labels function""" + resp_body_succ = SuccessMessage('Update node labels') + return resp_body_succ.get_output_json() diff --git a/tools/g2/lib/promenade.sh b/tools/g2/lib/promenade.sh index 4dc480a9..4b152297 100644 --- a/tools/g2/lib/promenade.sh +++ b/tools/g2/lib/promenade.sh @@ -61,3 +61,8 @@ promenade_health_check() { sleep 10 done } + +promenade_put_labels_url() { + NODE_NAME=${1} + echo "${PROMENADE_BASE_URL}/api/v1.0/node-labels/${NODE_NAME}" +} diff --git a/tools/g2/stages/move-master.sh b/tools/g2/stages/move-master.sh index b122d0f6..01b437c6 100755 --- a/tools/g2/stages/move-master.sh +++ b/tools/g2/stages/move-master.sh @@ -1,32 +1,30 @@ #!/usr/bin/env bash -set -e +set -eu source "${GATE_UTILS}" -log Adding labels to node n0 -kubectl_cmd n1 label node n0 \ - calico-etcd=enabled \ - kubernetes-apiserver=enabled \ - kubernetes-controller-manager=enabled \ - kubernetes-etcd=enabled \ - kubernetes-scheduler=enabled +VIA="n1" -# XXX Need to wait +CURL_ARGS=("--fail" "--max-time" "300" "--retry" "16" "--retry-delay" "15") + +log Adding labels to node n0 +JSON="{\"calico-etcd\": \"enabled\", \"coredns\": \"enabled\", \"kubernetes-apiserver\": \"enabled\", \"kubernetes-controller-manager\": \"enabled\", \"kubernetes-etcd\": \"enabled\", \"kubernetes-scheduler\": \"enabled\", \"ucp-control-plane\": \"enabled\"}" + +ssh_cmd "${VIA}" curl -v "${CURL_ARGS[@]}" -X PUT -H "Content-Type: application/json" -d "${JSON}" "$(promenade_put_labels_url n0)" + +# Need to wait sleep 60 validate_etcd_membership kubernetes n1 n0 n1 n2 n3 validate_etcd_membership calico n1 n0 n1 n2 n3 log Removing labels from node n2 -kubectl_cmd n1 label node n2 \ - calico-etcd- \ - kubernetes-apiserver- \ - kubernetes-controller-manager- \ - kubernetes-etcd- \ - kubernetes-scheduler- +JSON="{\"coredns\": \"enabled\", \"ucp-control-plane\": \"enabled\"}" -# XXX Need to wait +ssh_cmd "${VIA}" curl -v "${CURL_ARGS[@]}" -X PUT -H "Content-Type: application/json" -d "${JSON}" "$(promenade_put_labels_url n2)" + +# Need to wait sleep 60 validate_cluster n1