kuryr-kubernetes/kuryr_kubernetes/k8s_client.py
Roman Dobosz de6a06c6f7 Adapt selfLink calculation for any k8s objects.
Also, selfLink occurrences in unit tests has been removed (besides
those, which actually make sense), and documentation.

Implements: blueprint selflink
Change-Id: Ib0bcc9f5cb6c4cdc27c3393dcb3f665b21cb64ac
2021-01-12 15:59:22 +01:00

448 lines
18 KiB
Python

# Copyright (c) 2016 Mirantis, Inc.
# All 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 contextlib
import functools
import itertools
import os
import ssl
import time
from urllib import parse
import urllib3
from oslo_log import log as logging
from oslo_serialization import jsonutils
import requests
from requests import adapters
from kuryr.lib._i18n import _
from kuryr_kubernetes import config
from kuryr_kubernetes import constants
from kuryr_kubernetes import exceptions as exc
from kuryr_kubernetes import utils
CONF = config.CONF
LOG = logging.getLogger(__name__)
class K8sClient(object):
# REVISIT(ivc): replace with python-k8sclient if it could be extended
# with 'WATCH' support
def __init__(self, base_url):
self._base_url = base_url
cert_file = config.CONF.kubernetes.ssl_client_crt_file
key_file = config.CONF.kubernetes.ssl_client_key_file
ca_crt_file = config.CONF.kubernetes.ssl_ca_crt_file
self.verify_server = config.CONF.kubernetes.ssl_verify_server_crt
token_file = config.CONF.kubernetes.token_file
self.token = None
self.cert = (None, None)
# Setting higher numbers regarding connection pools as we're running
# with max of 1000 green threads.
self.session = requests.Session()
prefix = '%s://' % parse.urlparse(base_url).scheme
self.session.mount(prefix, adapters.HTTPAdapter(pool_maxsize=1000))
if token_file:
if os.path.exists(token_file):
with open(token_file, 'r') as f:
self.token = f.readline().rstrip('\n')
else:
raise RuntimeError(
_("Unable to find token_file : %s") % token_file)
else:
if cert_file and not os.path.exists(cert_file):
raise RuntimeError(
_("Unable to find ssl cert_file : %s") % cert_file)
if key_file and not os.path.exists(key_file):
raise RuntimeError(
_("Unable to find ssl key_file : %s") % key_file)
self.cert = (cert_file, key_file)
if self.verify_server:
if not ca_crt_file:
raise RuntimeError(
_("ssl_ca_crt_file cannot be None"))
elif not os.path.exists(ca_crt_file):
raise RuntimeError(
_("Unable to find ca cert_file : %s") % ca_crt_file)
else:
self.verify_server = ca_crt_file
# Let's setup defaults for our Session.
self.session.cert = self.cert
self.session.verify = self.verify_server
if self.token:
self.session.headers['Authorization'] = f'Bearer {self.token}'
# NOTE(dulek): Seems like this is the only way to set is globally.
self.session.request = functools.partial(
self.session.request, timeout=(
CONF.kubernetes.watch_connection_timeout,
CONF.kubernetes.watch_read_timeout))
def _raise_from_response(self, response):
if response.status_code == requests.codes.not_found:
raise exc.K8sResourceNotFound(response.text)
if response.status_code == requests.codes.conflict:
raise exc.K8sConflict(response.text)
if response.status_code == requests.codes.forbidden:
if 'because it is being terminated' in response.json()['message']:
raise exc.K8sNamespaceTerminating(response.text)
raise exc.K8sForbidden(response.text)
if response.status_code == requests.codes.unprocessable_entity:
# NOTE(gryf): on k8s API code 422 is also Forbidden, but specified
# to FieldValueForbidden. Perhaps there are other usages for
# throwing unprocessable entity errors in different cases.
if ('FieldValueForbidden' in response.text and
'Forbidden' in response.json()['message']):
raise exc.K8sFieldValueForbidden(response.text)
raise exc.K8sUnprocessableEntity(response.text)
if not response.ok:
raise exc.K8sClientException(response.text)
def get(self, path, json=True, headers=None):
LOG.debug("Get %(path)s", {'path': path})
url = self._base_url + path
response = self.session.get(url, headers=headers)
self._raise_from_response(response)
if json:
result = response.json()
kind = result['kind']
api_version = result.get('apiVersion')
if not api_version:
api_version = utils.get_api_ver(path)
# Strip List from e.g. PodList. For some reason `.items` of a list
# returned from API doesn't have `kind` set.
# NOTE(gryf): Also, for the sake of calculating selfLink
# equivalent, we need to have both: kind and apiVersion, while the
# latter is not present on items list for core resources, while
# for custom resources there are both kind and apiVersion..
if kind.endswith('List'):
kind = kind[:-4]
# NOTE(gryf): In case we get null/None for items from the API,
# we need to convert it to the empty list, otherwise it might
# be propagated to the consumers of this method and sent back
# to the Kubernetes as is, and fail as a result.
if result['items'] is None:
result['items'] = []
for item in result['items']:
if not item.get('kind'):
item['kind'] = kind
if not item.get('apiVersion'):
item['apiVersion'] = api_version
if not result.get('apiVersion'):
result['apiVersion'] = api_version
else:
result = response.text
return result
def _get_url_and_header(self, path, content_type):
url = self._base_url + path
header = {'Content-Type': content_type,
'Accept': 'application/json'}
return url, header
def patch(self, field, path, data):
LOG.debug("Patch %(path)s: %(data)s", {'path': path, 'data': data})
content_type = 'application/merge-patch+json'
url, header = self._get_url_and_header(path, content_type)
response = self.session.patch(url, json={field: data}, headers=header)
self._raise_from_response(response)
return response.json().get('status')
def patch_crd(self, field, path, data, action='replace'):
content_type = 'application/json-patch+json'
url, header = self._get_url_and_header(path, content_type)
if action == 'remove':
data = [{'op': action,
'path': f'/{field}/{data}'}]
else:
if data:
data = [{'op': action,
'path': f'/{field}/{crd_field}',
'value': value}
for crd_field, value in data.items()]
else:
data = [{'op': action,
'path': f'/{field}',
'value': data}]
LOG.debug("Patch %(path)s: %(data)s", {
'path': path, 'data': data})
response = self.session.patch(url, data=jsonutils.dumps(data),
headers=header)
self._raise_from_response(response)
return response.json().get('status')
def patch_node_annotations(self, node, annotation_name, value):
content_type = 'application/json-patch+json'
path = '{}/nodes/{}/'.format(constants.K8S_API_BASE, node)
value = jsonutils.dumps(value)
url, header = self._get_url_and_header(path, content_type)
data = [{'op': 'add',
'path': '/metadata/annotations/{}'.format(annotation_name),
'value': value}]
response = self.session.patch(url, data=jsonutils.dumps(data),
headers=header)
self._raise_from_response(response)
return response.json().get('status')
def remove_node_annotations(self, node, annotation_name):
content_type = 'application/json-patch+json'
path = '{}/nodes/{}/'.format(constants.K8S_API_BASE, node)
url, header = self._get_url_and_header(path, content_type)
data = [{'op': 'remove',
'path': '/metadata/annotations/{}'.format(annotation_name)}]
response = self.session.patch(url, data=jsonutils.dumps(data),
headers=header)
self._raise_from_response(response)
return response.json().get('status')
def _jsonpatch_escape(self, value):
value = value.replace('~', '~0')
value = value.replace('/', '~1')
return value
def remove_annotations(self, path, annotation_name):
LOG.debug("Remove annotations %(path)s: %(name)s",
{'path': path, 'name': annotation_name})
content_type = 'application/json-patch+json'
url, header = self._get_url_and_header(path, content_type)
annotation_name = self._jsonpatch_escape(annotation_name)
data = [{'op': 'remove',
'path': f'/metadata/annotations/{annotation_name}'}]
response = self.session.patch(url, data=jsonutils.dumps(data),
headers=header)
if response.ok:
return response.json().get('status')
raise exc.K8sClientException(response.text)
def post(self, path, body):
LOG.debug("Post %(path)s: %(body)s", {'path': path, 'body': body})
url = self._base_url + path
header = {'Content-Type': 'application/json'}
response = self.session.post(url, json=body, headers=header)
self._raise_from_response(response)
return response.json()
def delete(self, path):
LOG.debug("Delete %(path)s", {'path': path})
url = self._base_url + path
header = {'Content-Type': 'application/json'}
response = self.session.delete(url, headers=header)
self._raise_from_response(response)
return response.json()
# TODO(dulek): add_finalizer() and remove_finalizer() have some code
# duplication, but I don't see a nice way to avoid it.
def add_finalizer(self, obj, finalizer):
if finalizer in obj['metadata'].get('finalizers', []):
return True
path = utils.get_res_link(obj)
LOG.debug(f"Add finalizer {finalizer} to {path}")
url, headers = self._get_url_and_header(
path, 'application/merge-patch+json')
for i in range(3): # Let's make sure it's not infinite loop
finalizers = obj['metadata'].get('finalizers', []).copy()
finalizers.append(finalizer)
data = {
'metadata': {
'finalizers': finalizers,
'resourceVersion': obj['metadata']['resourceVersion'],
},
}
response = self.session.patch(url, json=data, headers=headers)
if response.ok:
return True
try:
self._raise_from_response(response)
except (exc.K8sFieldValueForbidden, exc.K8sResourceNotFound):
# Object is being deleting or gone. Return.
return False
except exc.K8sConflict:
try:
obj = self.get(path)
except exc.K8sResourceNotFound:
# Object got removed before finalizer was set
return False
if finalizer in obj['metadata'].get('finalizers', []):
# Finalizer is there, return.
return True
# If after 3 iterations there's still conflict, just raise.
self._raise_from_response(response)
def remove_finalizer(self, obj, finalizer):
path = utils.get_res_link(obj)
LOG.debug(f"Remove finalizer {finalizer} from {path}")
url, headers = self._get_url_and_header(
path, 'application/merge-patch+json')
for i in range(3): # Let's make sure it's not infinite loop
finalizers = obj['metadata'].get('finalizers', []).copy()
try:
finalizers.remove(finalizer)
except ValueError:
# Finalizer is not there, return.
return True
data = {
'metadata': {
'finalizers': finalizers,
'resourceVersion': obj['metadata']['resourceVersion'],
},
}
response = self.session.patch(url, json=data, headers=headers)
if response.ok:
return True
try:
try:
self._raise_from_response(response)
except exc.K8sConflict:
obj = self.get(path)
except (exc.K8sFieldValueForbidden, exc.K8sResourceNotFound):
# Object is being deleted or gone already, stop.
return False
# If after 3 iterations there's still conflict, just raise.
self._raise_from_response(response)
def get_loadbalancer_crd(self, obj):
name = obj['metadata']['name']
namespace = obj['metadata']['namespace']
try:
crd = self.get('{}/{}/kuryrloadbalancers/{}'.format(
constants.K8S_API_CRD_NAMESPACES, namespace,
name))
except exc.K8sResourceNotFound:
return None
except exc.K8sClientException:
LOG.exception("Kubernetes Client Exception.")
raise
return crd
def annotate(self, path, annotations, resource_version=None):
"""Pushes a resource annotation to the K8s API resource
The annotate operation is made with a PATCH HTTP request of kind:
application/merge-patch+json as described in:
https://github.com/kubernetes/community/blob/master/contributors/devel/sig-architecture/api-conventions.md#patch-operations # noqa
"""
LOG.debug("Annotate %(path)s: %(names)s", {
'path': path, 'names': list(annotations)})
content_type = 'application/merge-patch+json'
url, header = self._get_url_and_header(path, content_type)
while itertools.count(1):
metadata = {"annotations": annotations}
if resource_version:
metadata['resourceVersion'] = resource_version
data = jsonutils.dumps({"metadata": metadata}, sort_keys=True)
response = self.session.patch(url, data=data, headers=header)
if response.ok:
return response.json()['metadata'].get('annotations', {})
if response.status_code == requests.codes.conflict:
resource = self.get(path)
new_version = resource['metadata']['resourceVersion']
retrieved_annotations = resource['metadata'].get(
'annotations', {})
for k, v in annotations.items():
if v != retrieved_annotations.get(k):
break
else:
LOG.debug("Annotations for %(path)s already present: "
"%(names)s", {'path': path,
'names': retrieved_annotations})
return retrieved_annotations
# Retry patching with updated resourceVersion
resource_version = new_version
continue
LOG.error("Exception response, headers: %(headers)s, "
"content: %(content)s, text: %(text)s"
% {'headers': response.headers,
'content': response.content, 'text': response.text})
self._raise_from_response(response)
def watch(self, path):
url = self._base_url + path
resource_version = None
attempt = 0
while True:
try:
params = {'watch': 'true'}
if resource_version:
params['resourceVersion'] = resource_version
with contextlib.closing(
self.session.get(
url, params=params, stream=True)) as response:
if not response.ok:
raise exc.K8sClientException(response.text)
attempt = 0
for line in response.iter_lines():
line = line.decode('utf-8').strip()
if line:
line_dict = jsonutils.loads(line)
yield line_dict
# Saving the resourceVersion in case of a restart.
# At this point it's safely passed to handler.
m = line_dict.get('object', {}).get('metadata', {})
resource_version = m.get('resourceVersion', None)
except (requests.ReadTimeout, requests.ConnectionError,
ssl.SSLError, requests.exceptions.ChunkedEncodingError,
urllib3.exceptions.SSLError):
t = utils.exponential_backoff(attempt)
log = LOG.debug
if attempt > 0:
# Only make it a warning if it's happening again, no need
# to inform about all the read timeouts.
log = LOG.warning
log('Connection error when watching %s. Retrying in %ds with '
'resourceVersion=%s', path, t,
params.get('resourceVersion'))
time.sleep(t)
attempt += 1