Adding node-labels api

Blueprint: https://review.openstack.org/#/c/583343

1. Added node-labels api for managing node labels in
   kubernetes cluster
2. Added unit test cases
3. Updated documents
4. Resiliency gate script update

Change-Id: Iebd49706b3fdbb3650f2e46c5a7fbd21d236b906
This commit is contained in:
pallav 2018-07-23 20:14:24 +05:30
parent 7c3f05d564
commit ea5de25b1a
15 changed files with 539 additions and 34 deletions

1
.gitignore vendored
View File

@ -10,6 +10,7 @@
/.cache
/.eggs
/.helm-pid
/.pytest_cache
/.tox
/build
/conformance

View File

@ -62,3 +62,33 @@ Responses:
+ 200 OK: Documents were successfully validated
+ 400 Bad Request: Documents were not successfully validated
/v1.0/node-labels/<node_name>
-----------------------------
Update node labels
PUT /v1.0/node-labels/<node_name>
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

View File

@ -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:

View File

@ -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

View File

@ -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

View File

@ -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

136
promenade/kubeclient.py Normal file
View File

@ -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

View File

@ -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'
}]),
]

View File

@ -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)

View File

@ -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
})

View File

@ -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']

View File

@ -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

View File

@ -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()

View File

@ -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}"
}

View File

@ -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