diff --git a/.github/workflows/e2e-master.yaml b/.github/workflows/e2e-master.yaml index 4dcc5eeab8..588d369cd0 100644 --- a/.github/workflows/e2e-master.yaml +++ b/.github/workflows/e2e-master.yaml @@ -19,7 +19,7 @@ jobs: with: submodules: true - name: Create Kind Cluster - uses: helm/kind-action@ef37e7f390d99f746eb8b610417061a60e82a6cc + uses: helm/kind-action@06c1ae10762d3b9c1644e7fe69596ae519e015a2 with: cluster_name: kubernetes-python-e2e-master-${{ matrix.python-version }} # The kind version to be used to spin the cluster up diff --git a/.github/workflows/e2e-release-35.0.yaml b/.github/workflows/e2e-release-35.0.yaml index 7bd163e608..730710b2f2 100644 --- a/.github/workflows/e2e-release-35.0.yaml +++ b/.github/workflows/e2e-release-35.0.yaml @@ -19,7 +19,7 @@ jobs: with: submodules: true - name: Create Kind Cluster - uses: helm/kind-action@ef37e7f390d99f746eb8b610417061a60e82a6cc + uses: helm/kind-action@06c1ae10762d3b9c1644e7fe69596ae519e015a2 with: cluster_name: kubernetes-python-e2e-release-35.0-${{ matrix.python-version }} # The kind version to be used to spin the cluster up diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index f133c182e9..692d488ff8 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -45,7 +45,7 @@ jobs: - name: Upload coverage to Codecov if: "matrix.use_coverage" - uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f + uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 with: fail_ci_if_error: false verbose: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 25be22e4f2..b12502e080 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +# v37.0.0b1 + +Kubernetes API Version: v1.37.0 + + # v37.0.0a1 Kubernetes API Version: v1.37.0 diff --git a/README.md b/README.md index e4b71d0a6c..03f0988cd6 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,7 @@ supported versions of Kubernetes clusters. - [client 34.y.z](https://pypi.org/project/kubernetes/34.1.0/): Kubernetes 1.33 or below (+-), Kubernetes 1.34 (✓), Kubernetes 1.35 or above (+-) - [client 35.y.z](https://pypi.org/project/kubernetes/35.0.0/): Kubernetes 1.34 or below (+-), Kubernetes 1.35 (✓), Kubernetes 1.36 or above (+-) - [client 36.y.z](https://pypi.org/project/kubernetes/36.0.3/): Kubernetes 1.35 or below (+-), Kubernetes 1.36 (✓), Kubernetes 1.37 or above (+-) -- [client 37.y.z](https://pypi.org/project/kubernetes/37.0.0/): Kubernetes 1.36 or below (+-), Kubernetes 1.37 (✓), Kubernetes 1.38 or above (+-) +- [client 37.y.z](https://pypi.org/project/kubernetes/37.0.0b1/): Kubernetes 1.36 or below (+-), Kubernetes 1.37 (✓), Kubernetes 1.38 or above (+-) > See [here](#homogenizing-the-kubernetes-python-client-versions) for an explanation of why there is no v13-v16 release. diff --git a/doc/source/kubernetes.utils.keepalive.rst b/doc/source/kubernetes.utils.keepalive.rst new file mode 100644 index 0000000000..b00e7d84f7 --- /dev/null +++ b/doc/source/kubernetes.utils.keepalive.rst @@ -0,0 +1,7 @@ +kubernetes.utils.keepalive module +================================= + +.. automodule:: kubernetes.utils.keepalive + :members: + :show-inheritance: + :undoc-members: diff --git a/doc/source/kubernetes.utils.keepalive_test.rst b/doc/source/kubernetes.utils.keepalive_test.rst new file mode 100644 index 0000000000..58fe418ede --- /dev/null +++ b/doc/source/kubernetes.utils.keepalive_test.rst @@ -0,0 +1,7 @@ +kubernetes.utils.keepalive\_test module +======================================= + +.. automodule:: kubernetes.utils.keepalive_test + :members: + :show-inheritance: + :undoc-members: diff --git a/doc/source/kubernetes.utils.rst b/doc/source/kubernetes.utils.rst index ffcb94d255..ea24ab079c 100644 --- a/doc/source/kubernetes.utils.rst +++ b/doc/source/kubernetes.utils.rst @@ -9,6 +9,8 @@ Submodules kubernetes.utils.create_from_yaml kubernetes.utils.duration + kubernetes.utils.keepalive + kubernetes.utils.keepalive_test kubernetes.utils.metrics kubernetes.utils.quantity kubernetes.utils.retry diff --git a/kubernetes/README.md b/kubernetes/README.md index 2f9e603a68..7254a43d69 100644 --- a/kubernetes/README.md +++ b/kubernetes/README.md @@ -4,7 +4,7 @@ No description provided (generated by Openapi Generator https://github.com/opena This Python package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project: - API version: release-1.37 -- Package version: 37.0.0a1 +- Package version: 37.0.0b1 - Generator version: 7.25.0 - Build package: org.openapitools.codegen.languages.PythonClientCodegen diff --git a/kubernetes/__init__.py b/kubernetes/__init__.py index 85f1252ce3..18d57b9faf 100644 --- a/kubernetes/__init__.py +++ b/kubernetes/__init__.py @@ -14,7 +14,7 @@ __project__ = 'kubernetes' # The version is auto-updated. Please do not edit. -__version__ = "37.0.0a1" +__version__ = "37.0.0b1" from . import client from . import config diff --git a/kubernetes/aio/README.md b/kubernetes/aio/README.md index 4ff138f178..fdf8e069d8 100644 --- a/kubernetes/aio/README.md +++ b/kubernetes/aio/README.md @@ -4,7 +4,7 @@ No description provided (generated by Openapi Generator https://github.com/opena This Python package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project: - API version: release-1.37 -- Package version: 37.0.0a1 +- Package version: 37.0.0b1 - Generator version: 7.25.0 - Build package: org.openapitools.codegen.languages.PythonClientCodegen diff --git a/kubernetes/aio/__init__.py b/kubernetes/aio/__init__.py index c1415fbed9..702c62c04d 100644 --- a/kubernetes/aio/__init__.py +++ b/kubernetes/aio/__init__.py @@ -14,13 +14,14 @@ __project__ = "kubernetes_aio" # The version is auto-updated. Please do not edit. -__version__ = "37.0.0a1" +__version__ = "37.0.0b1" import kubernetes.aio.client as client import kubernetes.aio.config as config import kubernetes.aio.dynamic as dynamic +import kubernetes.aio.leaderelection as leaderelection import kubernetes.aio.stream as stream import kubernetes.aio.utils as utils import kubernetes.aio.watch as watch -__all__ = ["client", "config", "dynamic", "stream", "utils", "watch"] +__all__ = ["client", "config", "dynamic", "leaderelection", "stream", "utils", "watch"] diff --git a/kubernetes/aio/client/__init__.py b/kubernetes/aio/client/__init__.py index f48967fd11..14ba8c3e83 100644 --- a/kubernetes/aio/client/__init__.py +++ b/kubernetes/aio/client/__init__.py @@ -14,7 +14,7 @@ """ # noqa: E501 -__version__ = "37.0.0a1" +__version__ = "37.0.0b1" # Define package exports __all__ = [ diff --git a/kubernetes/aio/client/api_client.py b/kubernetes/aio/client/api_client.py index 73b5e241b9..80b3db2803 100644 --- a/kubernetes/aio/client/api_client.py +++ b/kubernetes/aio/client/api_client.py @@ -122,7 +122,7 @@ def __init__( self.default_headers[header_name] = header_value self.cookie = cookie # Set default User-Agent. - self.user_agent = 'OpenAPI-Generator/37.0.0a1/python' + self.user_agent = 'OpenAPI-Generator/37.0.0b1/python' self.client_side_validation = configuration.client_side_validation async def __aenter__(self): diff --git a/kubernetes/aio/client/configuration.py b/kubernetes/aio/client/configuration.py index 06f0ce3212..6562e03619 100644 --- a/kubernetes/aio/client/configuration.py +++ b/kubernetes/aio/client/configuration.py @@ -612,7 +612,7 @@ def to_debug_report(self) -> str: "OS: {env}\n"\ "Python Version: {pyversion}\n"\ "Version of the API: release-1.37\n"\ - "SDK Package Version: 37.0.0a1".\ + "SDK Package Version: 37.0.0b1".\ format(env=sys.platform, pyversion=sys.version) def get_host_settings(self) -> List[HostSetting]: diff --git a/kubernetes/aio/leaderelection/leaderelection.py b/kubernetes/aio/leaderelection/leaderelection.py index 1289692b92..fb8938bd6f 100644 --- a/kubernetes/aio/leaderelection/leaderelection.py +++ b/kubernetes/aio/leaderelection/leaderelection.py @@ -145,11 +145,22 @@ async def try_acquire_or_renew(self) -> bool: # A lock is not created with that name, try to create one if not lock_status: - assert ( + # The error body comes straight from the API server, but anything + # sitting in front of it (ingress, load balancer, proxy) can answer + # with an HTML page, an empty payload or some other non-JSON body. + # Only a clean 404 means the lock is absent and may be created; + # everything else is retried on the next period instead of taking + # the whole leader election down. + error_code = None + if ( isinstance(old_election_record, ApiException) and old_election_record.body is not None - ) - if json.loads(old_election_record.body)["code"] != HTTPStatus.NOT_FOUND: + ): + try: + error_code = json.loads(old_election_record.body)["code"] + except (ValueError, TypeError, KeyError, AttributeError): + error_code = None + if error_code != HTTPStatus.NOT_FOUND: logger.error( "Error retrieving resource lock %s as %s", self.election_config.lock.name, diff --git a/kubernetes/aio/leaderelection/leaderelection_test.py b/kubernetes/aio/leaderelection/leaderelection_test.py index 3d1e35dc0e..c1e3656227 100644 --- a/kubernetes/aio/leaderelection/leaderelection_test.py +++ b/kubernetes/aio/leaderelection/leaderelection_test.py @@ -365,5 +365,38 @@ async def update( self.lock.release() + def test_acquire_survives_non_json_error_body(self): + """A proxy answering with an HTML error page must not kill the elector.""" + + class GatewayErrorLock: + def __init__(self): + self.name = "lock" + self.namespace = "ns" + self.identity = "candidate" + + async def get(self, name, namespace): + return False, ApiException( + status=502, + reason="Bad Gateway", + body="502 Bad Gateway", + ) + + async def create(self, name, namespace, election_record): + return False + + config = electionconfig.Config( + lock=GatewayErrorLock(), + lease_duration=4, + renew_deadline=3, + retry_period=1, + onstarted_leading=lambda: None, + onstopped_leading=lambda: None, + ) + + elector = leaderelection.LeaderElection(config) + result = asyncio.run(elector.try_acquire_or_renew()) + self.assertFalse(result) + + if __name__ == "__main__": unittest.main() diff --git a/kubernetes/aio/leaderelection/resourcelock/configmaplock.py b/kubernetes/aio/leaderelection/resourcelock/configmaplock.py index 53a46c09bf..aa4c4862da 100644 --- a/kubernetes/aio/leaderelection/resourcelock/configmaplock.py +++ b/kubernetes/aio/leaderelection/resourcelock/configmaplock.py @@ -80,9 +80,26 @@ async def get( self.configmap_reference = api_response return True, None - lock_record = self.get_lock_object( - json.loads(annotations[self.leader_electionrecord_annotationkey]) - ) + # A corrupted annotation must not take the elector down: treat it + # like a missing one so the next update rewrites a clean record. + try: + annotation_record = json.loads( + annotations[self.leader_electionrecord_annotationkey] + ) + except ValueError: + logger.warning( + "Leader election annotation on ConfigMap %s/%s is not valid " + "JSON; treating the lock as unheld", + name, + namespace, + ) + api_response.metadata.annotations = { + self.leader_electionrecord_annotationkey: "" + } + self.configmap_reference = api_response + return True, None + + lock_record = self.get_lock_object(annotation_record) self.configmap_reference = api_response return True, lock_record diff --git a/kubernetes/client/__init__.py b/kubernetes/client/__init__.py index e5464b24c9..96bbeb31bc 100644 --- a/kubernetes/client/__init__.py +++ b/kubernetes/client/__init__.py @@ -14,7 +14,7 @@ """ # noqa: E501 -__version__ = "37.0.0a1" +__version__ = "37.0.0b1" # Define package exports __all__ = [ diff --git a/kubernetes/client/api_client.py b/kubernetes/client/api_client.py index 2c0a5e1336..77a75b9f6e 100644 --- a/kubernetes/client/api_client.py +++ b/kubernetes/client/api_client.py @@ -129,7 +129,7 @@ def __init__( self.pool_threads = pool_threads self._pool_lock = Lock() # Set default User-Agent. - self.user_agent = 'OpenAPI-Generator/37.0.0a1/python' + self.user_agent = 'OpenAPI-Generator/37.0.0b1/python' self.client_side_validation = configuration.client_side_validation def close(self): diff --git a/kubernetes/client/configuration.py b/kubernetes/client/configuration.py index ecb0de48b5..731b382440 100644 --- a/kubernetes/client/configuration.py +++ b/kubernetes/client/configuration.py @@ -387,6 +387,15 @@ def __init__( self.socket_options = socket_options """Options to pass down to the underlying urllib3 socket """ + self.keep_alive = False + """Enable TCP keepalive on the underlying urllib3 sockets. + + Long lived requests such as watches are otherwise dropped + silently by an idle proxy or load balancer. When enabled, the + client asks the kernel for the same keepalive timings client-go + uses. Ignored if ``socket_options`` is set, which takes + precedence. + """ self.datetime_format = datetime_format """datetime format @@ -604,7 +613,7 @@ def to_debug_report(self) -> str: "OS: {env}\n"\ "Python Version: {pyversion}\n"\ "Version of the API: release-1.37\n"\ - "SDK Package Version: 37.0.0a1".\ + "SDK Package Version: 37.0.0b1".\ format(env=sys.platform, pyversion=sys.version) def get_host_settings(self) -> List[HostSetting]: diff --git a/kubernetes/client/rest.py b/kubernetes/client/rest.py index 78d79207e7..1a1015d75e 100644 --- a/kubernetes/client/rest.py +++ b/kubernetes/client/rest.py @@ -27,6 +27,7 @@ on_retry_after_error, retry_after_backoff, ) +from kubernetes.utils.keepalive import tcp_keepalive_socket_options from kubernetes.client.exceptions import ApiException, ApiValueError SUPPORTED_SOCKS_PROXIES = {"socks5", "socks5h", "socks4", "socks4a"} @@ -160,6 +161,8 @@ def __init__(self, configuration) -> None: if configuration.socket_options is not None: pool_args['socket_options'] = configuration.socket_options + elif getattr(configuration, 'keep_alive', False): + pool_args['socket_options'] = tcp_keepalive_socket_options() if configuration.connection_pool_maxsize is not None: pool_args['maxsize'] = configuration.connection_pool_maxsize diff --git a/kubernetes/dynamic/client.py b/kubernetes/dynamic/client.py index 6e62a96c3a..05f6bdb81c 100644 --- a/kubernetes/dynamic/client.py +++ b/kubernetes/dynamic/client.py @@ -158,7 +158,7 @@ def server_side_apply(self, resource, body=None, name=None, namespace=None, forc return self.request('patch', path, body=body, force_conflicts=force_conflicts, **kwargs) - def watch(self, resource, namespace=None, name=None, label_selector=None, field_selector=None, resource_version=None, timeout=None, watcher=None, allow_watch_bookmarks=None): + def watch(self, resource, namespace=None, name=None, label_selector=None, field_selector=None, resource_version=None, timeout=None, watcher=None, allow_watch_bookmarks=None, send_initial_events=None, resource_version_match=None): """ Stream events for a resource from the Kubernetes API @@ -172,6 +172,10 @@ def watch(self, resource, namespace=None, name=None, label_selector=None, field_ :param timeout: The amount of time in seconds to wait before terminating the stream :param watcher: The Watcher object that will be used to stream the resource :param allow_watch_bookmarks: Ask the API server to send BOOKMARK events + :param send_initial_events: Ask the API server to begin the stream with synthetic events + for the current state, followed by a BOOKMARK event + :param resource_version_match: How resource_version is matched, e.g. "NotOlderThan". + Required by the API server when send_initial_events is set :return: Event object with these keys: 'type': The type of event such as "ADDED", "DELETED", etc. @@ -204,6 +208,8 @@ def watch(self, resource, namespace=None, name=None, label_selector=None, field_ serialize=False, timeout_seconds=timeout, allow_watch_bookmarks=allow_watch_bookmarks, + send_initial_events=send_initial_events, + resource_version_match=resource_version_match, ): event['object'] = ResourceInstance(resource, event['object']) yield event @@ -229,6 +235,8 @@ def request(self, method, path, body=None, **params): query_params.append(('limit', params['limit'])) if params.get('resource_version') is not None: query_params.append(('resourceVersion', params['resource_version'])) + if params.get('resource_version_match') is not None: + query_params.append(('resourceVersionMatch', params['resource_version_match'])) if params.get('timeout_seconds') is not None: query_params.append(('timeoutSeconds', params['timeout_seconds'])) if params.get('watch') is not None: @@ -247,6 +255,8 @@ def request(self, method, path, body=None, **params): query_params.append(('force', params['force_conflicts'])) if params.get('allow_watch_bookmarks') is not None: query_params.append(('allowWatchBookmarks', params['allow_watch_bookmarks'])) + if params.get('send_initial_events') is not None: + query_params.append(('sendInitialEvents', params['send_initial_events'])) header_params = params.get('header_params', {}) form_params = [] diff --git a/kubernetes/dynamic/client_test.py b/kubernetes/dynamic/client_test.py index a67a2e2e3f..b424b78e9f 100644 --- a/kubernetes/dynamic/client_test.py +++ b/kubernetes/dynamic/client_test.py @@ -208,6 +208,77 @@ def log_message(self, format, *args): target.server_close() proxy.server_close() + def test_watch_forwards_send_initial_events(self): + class FakeWatcher: + def __init__(self): + self.kwargs = None + + def stream(self, func, **kwargs): + self.kwargs = kwargs + return iter(()) + + class FakeResource: + def get(self, **kwargs): + pass + + dynamic = DynamicClient.__new__(DynamicClient) + watcher = FakeWatcher() + + list(dynamic.watch( + FakeResource(), + namespace='default', + watcher=watcher, + send_initial_events=True, + resource_version_match='NotOlderThan', + )) + + self.assertEqual(True, watcher.kwargs['send_initial_events']) + self.assertEqual('NotOlderThan', watcher.kwargs['resource_version_match']) + + def test_request_builds_send_initial_events_query_params(self): + class Handler(BaseHTTPRequestHandler): + def do_GET(self): + self.server.request_path = self.path + body = json.dumps({'kind': 'APIResourceList'}).encode() + self.send_response(200) + self.send_header('Content-Type', 'application/json') + self.send_header('Content-Length', str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, format, *args): + pass + + server = ThreadingHTTPServer(('127.0.0.1', 0), Handler) + thread = threading.Thread(target=server.serve_forever) + thread.start() + try: + dynamic = DynamicClient.__new__(DynamicClient) + dynamic.client = ApiClient(Configuration( + host=f'http://127.0.0.1:{server.server_port}', + proxy='', + no_proxy='', + )) + + dynamic.request( + 'get', + '/apis', + resource_version='0', + resource_version_match='NotOlderThan', + send_initial_events=True, + serializer=lambda _, data: data, + ) + + self.assertEqual( + '/apis?resourceVersion=0&resourceVersionMatch=NotOlderThan' + '&sendInitialEvents=true', + server.request_path, + ) + finally: + server.shutdown() + thread.join() + server.server_close() + if __name__ == '__main__': unittest.main() diff --git a/kubernetes/leaderelection/leaderelection.py b/kubernetes/leaderelection/leaderelection.py index fc72a1d95e..951511f513 100644 --- a/kubernetes/leaderelection/leaderelection.py +++ b/kubernetes/leaderelection/leaderelection.py @@ -130,8 +130,17 @@ def try_acquire_or_renew(self): # A lock is not created with that name, try to create one if not lock_status: - if json.loads(old_election_record.body)[ - 'code'] != HTTPStatus.NOT_FOUND: + # The error body comes straight from the API server, but anything + # sitting in front of it (ingress, load balancer, proxy) can answer + # with an HTML page, an empty payload or some other non-JSON body. + # Only a clean 404 means the lock is absent and may be created; + # everything else is retried on the next period instead of taking + # the whole leader election down. + try: + error_code = json.loads(old_election_record.body)['code'] + except (ValueError, TypeError, KeyError, AttributeError): + error_code = None + if error_code != HTTPStatus.NOT_FOUND: logger.info( "Error retrieving resource lock {} as {}".format( self.election_config.lock.name, diff --git a/kubernetes/leaderelection/leaderelection_test.py b/kubernetes/leaderelection/leaderelection_test.py index ad9c7e7d1e..ba12b49222 100644 --- a/kubernetes/leaderelection/leaderelection_test.py +++ b/kubernetes/leaderelection/leaderelection_test.py @@ -321,5 +321,76 @@ def update(self, name, namespace, updated_record): self.lock.release() + def test_acquire_survives_non_json_error_body(self): + """A proxy answering with an HTML error page must not kill the elector.""" + class GatewayErrorLock: + def __init__(self): + self.name = "lock" + self.namespace = "ns" + self.identity = "candidate" + + def get(self, name, namespace): + return False, ApiException( + status=502, reason="Bad Gateway", + body="502 Bad Gateway") + + config = electionconfig.Config( + lock=GatewayErrorLock(), lease_duration=4, renew_deadline=3, + retry_period=1, onstarted_leading=lambda: None, + onstopped_leading=lambda: None) + + result = leaderelection.LeaderElection(config).try_acquire_or_renew() + self.assertFalse(result) + + def test_acquire_survives_empty_error_body(self): + class EmptyErrorLock: + def __init__(self): + self.name = "lock" + self.namespace = "ns" + self.identity = "candidate" + + def get(self, name, namespace): + return False, ApiException(status=500, reason="Server Error", + body=None) + + config = electionconfig.Config( + lock=EmptyErrorLock(), lease_duration=4, renew_deadline=3, + retry_period=1, onstarted_leading=lambda: None, + onstopped_leading=lambda: None) + + result = leaderelection.LeaderElection(config).try_acquire_or_renew() + self.assertFalse(result) + + def test_acquire_still_creates_on_clean_404(self): + class NotFoundLock: + def __init__(self): + self.name = "lock" + self.namespace = "ns" + self.identity = "candidate" + self.created = False + + def get(self, name, namespace): + if self.created: + return True, LeaderElectionRecord( + "candidate", "4", "now", "now") + return False, ApiException( + status=404, reason="Not Found", + body=json.dumps({'code': 404})) + + def create(self, name, namespace, election_record): + self.created = True + return True + + def update(self, name, namespace, updated_record): + return True + + config = electionconfig.Config( + lock=NotFoundLock(), lease_duration=4, renew_deadline=3, + retry_period=1, onstarted_leading=lambda: None, + onstopped_leading=lambda: None) + + self.assertTrue(leaderelection.LeaderElection(config).try_acquire_or_renew()) + + if __name__ == '__main__': unittest.main() diff --git a/kubernetes/leaderelection/resourcelock/configmaplock.py b/kubernetes/leaderelection/resourcelock/configmaplock.py index c2f1e1cc67..bb694a9b93 100644 --- a/kubernetes/leaderelection/resourcelock/configmaplock.py +++ b/kubernetes/leaderelection/resourcelock/configmaplock.py @@ -64,7 +64,20 @@ def get(self, name, namespace): self.configmap_reference = api_response return True, None - lock_record = self.get_lock_object(json.loads(annotations[self.leader_electionrecord_annotationkey])) + # A corrupted annotation must not take the elector down: treat it + # like a missing one so the next update rewrites a clean record. + try: + annotation_record = json.loads( + annotations[self.leader_electionrecord_annotationkey]) + except ValueError: + logger.warning( + "Leader election annotation on ConfigMap {}/{} is not valid " + "JSON; treating the lock as unheld".format(name, namespace)) + api_response.metadata.annotations = {self.leader_electionrecord_annotationkey: ''} + self.configmap_reference = api_response + return True, None + + lock_record = self.get_lock_object(annotation_record) self.configmap_reference = api_response return True, lock_record diff --git a/kubernetes/utils/__init__.py b/kubernetes/utils/__init__.py index 681123a57c..4b8c07f312 100644 --- a/kubernetes/utils/__init__.py +++ b/kubernetes/utils/__init__.py @@ -25,3 +25,4 @@ on_retry_after_error, retry_after_backoff, retry_after_max_retries, retry_on_conflict, retry_after_seconds) +from .keepalive import tcp_keepalive_socket_options diff --git a/kubernetes/utils/keepalive.py b/kubernetes/utils/keepalive.py new file mode 100644 index 0000000000..0e54a43ea6 --- /dev/null +++ b/kubernetes/utils/keepalive.py @@ -0,0 +1,74 @@ +# Copyright 2026 The Kubernetes Authors. +# +# 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 socket +from typing import List, Tuple + +import urllib3 + + +# client-go dials the API server with a 30 second keepalive: +# https://github.com/kubernetes/client-go/blob/master/transport/cache.go +# Go folds that single duration into the idle time and leaves the probe +# interval and count at its own defaults, 15 seconds and 9 probes: +# https://github.com/golang/go/blob/master/src/net/tcpsock.go +# https://github.com/golang/go/blob/master/src/net/dial.go +DEFAULT_IDLE = 30 +DEFAULT_INTERVAL = 15 +DEFAULT_COUNT = 9 + +SocketOptions = List[Tuple[int, int, int]] + + +def tcp_keepalive_socket_options( + idle: int = DEFAULT_IDLE, + interval: int = DEFAULT_INTERVAL, + count: int = DEFAULT_COUNT, +) -> SocketOptions: + """Build urllib3 socket options that enable TCP keepalive. + + The defaults match what client-go asks the kernel for, so an idle + watch is probed after ``idle`` seconds and dropped after ``count`` + unanswered probes ``interval`` seconds apart. + + The returned list starts from ``urllib3``'s own default socket + options, which disable Nagle's algorithm. urllib3 replaces its + defaults with whatever list it is given rather than merging, so + building on them keeps that behaviour. + + Options the platform does not define are left out: macOS spells the + idle time ``TCP_KEEPALIVE``, and Windows only grew the idle and + interval options in Windows 10 1709. + """ + + for name, value in (('idle', idle), ('interval', interval), + ('count', count)): + if value < 1: + raise ValueError('%s must be at least 1' % name) + + options = list(urllib3.connection.HTTPConnection.default_socket_options) + options.append((socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)) + + if hasattr(socket, 'TCP_KEEPIDLE'): + options.append((socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, idle)) + elif hasattr(socket, 'TCP_KEEPALIVE'): + options.append((socket.IPPROTO_TCP, socket.TCP_KEEPALIVE, idle)) + + if hasattr(socket, 'TCP_KEEPINTVL'): + options.append((socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, interval)) + + if hasattr(socket, 'TCP_KEEPCNT'): + options.append((socket.IPPROTO_TCP, socket.TCP_KEEPCNT, count)) + + return options diff --git a/kubernetes/utils/keepalive_test.py b/kubernetes/utils/keepalive_test.py new file mode 100644 index 0000000000..005808fb83 --- /dev/null +++ b/kubernetes/utils/keepalive_test.py @@ -0,0 +1,140 @@ +# Copyright 2026 The Kubernetes Authors. +# +# 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 socket +import types +import unittest +from unittest import mock + +import urllib3 + +from kubernetes.client import Configuration +from kubernetes.client.rest import RESTClientObject +from kubernetes.utils import keepalive +from kubernetes.utils.keepalive import tcp_keepalive_socket_options + + +def fake_socket_module(**names): + """A stand-in for the socket module exposing only the given names.""" + + defaults = { + 'SOL_SOCKET': socket.SOL_SOCKET, + 'SO_KEEPALIVE': socket.SO_KEEPALIVE, + 'IPPROTO_TCP': socket.IPPROTO_TCP, + } + defaults.update(names) + return types.SimpleNamespace(**defaults) + + +class TestTcpKeepaliveSocketOptions(unittest.TestCase): + + def test_defaults_match_client_go(self): + options = tcp_keepalive_socket_options() + + self.assertIn((socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1), options) + self.assertIn( + (socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 30), options) + self.assertIn( + (socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 15), options) + self.assertIn((socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 9), options) + + def test_keeps_the_urllib3_defaults(self): + options = tcp_keepalive_socket_options() + + defaults = urllib3.connection.HTTPConnection.default_socket_options + for default in defaults: + self.assertIn(default, options) + + def test_custom_timings(self): + options = tcp_keepalive_socket_options(idle=5, interval=2, count=3) + + self.assertIn((socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 5), options) + self.assertIn((socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 2), options) + self.assertIn((socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 3), options) + + def test_timings_must_be_positive(self): + for kwargs in ({'idle': 0}, {'interval': 0}, {'count': 0}): + with self.assertRaises(ValueError): + tcp_keepalive_socket_options(**kwargs) + + def test_options_are_setsockopt_triples(self): + for option in tcp_keepalive_socket_options(): + self.assertEqual(3, len(option)) + for item in option: + self.assertIsInstance(item, int) + + def test_options_apply_to_a_socket(self): + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + for level, name, value in tcp_keepalive_socket_options(): + sock.setsockopt(level, name, value) + + self.assertEqual( + 1, sock.getsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE)) + self.assertEqual( + 30, sock.getsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE)) + + def test_falls_back_to_tcp_keepalive_on_macos(self): + macos = fake_socket_module( + TCP_KEEPALIVE=0x10, + TCP_KEEPINTVL=socket.TCP_KEEPINTVL, + TCP_KEEPCNT=socket.TCP_KEEPCNT, + ) + with mock.patch.object(keepalive, 'socket', macos): + options = tcp_keepalive_socket_options() + + self.assertIn((socket.IPPROTO_TCP, 0x10, 30), options) + + def test_skips_options_the_platform_lacks(self): + with mock.patch.object(keepalive, 'socket', fake_socket_module()): + options = tcp_keepalive_socket_options() + + self.assertIn((socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1), options) + defaults = urllib3.connection.HTTPConnection.default_socket_options + self.assertEqual(len(defaults) + 1, len(options)) + + +class TestConfigurationKeepAlive(unittest.TestCase): + + def pool_socket_options(self, configuration): + rest_client = RESTClientObject(configuration) + return rest_client.pool_manager.connection_pool_kw.get( + 'socket_options') + + def test_off_by_default(self): + configuration = Configuration() + + self.assertFalse(configuration.keep_alive) + self.assertIsNone(self.pool_socket_options(configuration)) + + def test_enabled(self): + configuration = Configuration() + configuration.keep_alive = True + + self.assertEqual( + tcp_keepalive_socket_options(), + self.pool_socket_options(configuration)) + + def test_socket_options_win(self): + configuration = Configuration() + configuration.keep_alive = True + configuration.socket_options = [ + (socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)] + + self.assertEqual( + configuration.socket_options, + self.pool_socket_options(configuration)) + + +if __name__ == "__main__": + unittest.main() diff --git a/requirements-asyncio.txt b/requirements-asyncio.txt index 245285704c..75a435fdf5 100644 --- a/requirements-asyncio.txt +++ b/requirements-asyncio.txt @@ -1,6 +1,6 @@ python-dateutil>=2.8.2 # BSD PyYAML>=6.0.3 # MIT -urllib3>=2.7.0 # MIT +urllib3>=2.8.0 # MIT aiohttp>=3.14.3,<4.0.0 # Apache-2.0 aiohttp-retry>=2.9.1 # MIT pydantic>=2.13.5 # MIT diff --git a/requirements.txt b/requirements.txt index 5b4d4f465b..b30649eed7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,5 +7,5 @@ websocket-client>=0.32.0,!=0.40.0,!=0.41.*,!=0.42.* # LGPLv2+ requests # Apache-2.0 requests-oauthlib # ISC typing-extensions>=4.16.0 # PSF -urllib3>=2.7.0,<3.0.0 # MIT +urllib3>=2.8.0,<3.0.0 # MIT durationpy>=0.11 # MIT diff --git a/scripts/constants.py b/scripts/constants.py index ab62395509..9fcd48f885 100644 --- a/scripts/constants.py +++ b/scripts/constants.py @@ -18,13 +18,13 @@ KUBERNETES_BRANCH = "release-1.37" # client version for packaging and releasing. -CLIENT_VERSION = "37.0.0a1" +CLIENT_VERSION = "37.0.0b1" # Name of the release package PACKAGE_NAME = "kubernetes" # Stage of development, mainly used in setup.py's classifiers. -DEVELOPMENT_STATUS = "3 - Alpha" +DEVELOPMENT_STATUS = "4 - Beta" # If called directly, return the constant value given diff --git a/scripts/keepalive_patch.diff b/scripts/keepalive_patch.diff new file mode 100644 index 0000000000..1583fde05c --- /dev/null +++ b/scripts/keepalive_patch.diff @@ -0,0 +1,39 @@ +diff --git a/kubernetes/client/configuration.py b/kubernetes/client/configuration.py +--- a/kubernetes/client/configuration.py ++++ b/kubernetes/client/configuration.py +@@ -387,6 +387,15 @@ + self.socket_options = socket_options + """Options to pass down to the underlying urllib3 socket + """ ++ self.keep_alive = False ++ """Enable TCP keepalive on the underlying urllib3 sockets. ++ ++ Long lived requests such as watches are otherwise dropped ++ silently by an idle proxy or load balancer. When enabled, the ++ client asks the kernel for the same keepalive timings client-go ++ uses. Ignored if ``socket_options`` is set, which takes ++ precedence. ++ """ + + self.datetime_format = datetime_format + """datetime format +diff --git a/kubernetes/client/rest.py b/kubernetes/client/rest.py +--- a/kubernetes/client/rest.py ++++ b/kubernetes/client/rest.py +@@ -27,6 +27,7 @@ + on_retry_after_error, + retry_after_backoff, + ) ++from kubernetes.utils.keepalive import tcp_keepalive_socket_options + from kubernetes.client.exceptions import ApiException, ApiValueError + + SUPPORTED_SOCKS_PROXIES = {"socks5", "socks5h", "socks4", "socks4a"} +@@ -160,6 +161,8 @@ + + if configuration.socket_options is not None: + pool_args['socket_options'] = configuration.socket_options ++ elif getattr(configuration, 'keep_alive', False): ++ pool_args['socket_options'] = tcp_keepalive_socket_options() + + if configuration.connection_pool_maxsize is not None: + pool_args['maxsize'] = configuration.connection_pool_maxsize diff --git a/scripts/update-client.sh b/scripts/update-client.sh index e128e8454c..102db9a1b7 100755 --- a/scripts/update-client.sh +++ b/scripts/update-client.sh @@ -65,6 +65,9 @@ git apply "${SCRIPT_ROOT}/rest_client_patch.diff" echo ">>> restoring Kubernetes client-go retry integration..." git apply "${SCRIPT_ROOT}/client_go_retry_patch.diff" +echo ">>> restoring Kubernetes TCP keepalive option..." +git apply "${SCRIPT_ROOT}/keepalive_patch.diff" + echo ">>> updating version information..." sed -i'' "s/^CLIENT_VERSION = .*/CLIENT_VERSION = \\\"${CLIENT_VERSION}\\\"/" "${SCRIPT_ROOT}/../setup.py" sed -i'' "s/^__version__ = .*/__version__ = \\\"${CLIENT_VERSION}\\\"/" "${CLIENT_ROOT}/__init__.py" diff --git a/setup-asyncio.py b/setup-asyncio.py index 2e221af18f..a5de317346 100644 --- a/setup-asyncio.py +++ b/setup-asyncio.py @@ -16,9 +16,9 @@ # Do not edit these constants. They will be updated automatically # by scripts/update-client-asyncio.sh. -CLIENT_VERSION = "37.0.0a1" +CLIENT_VERSION = "37.0.0b1" PACKAGE_NAME = "kubernetes.aio" -DEVELOPMENT_STATUS = "3 - Alpha" +DEVELOPMENT_STATUS = "4 - Beta" # To install the library, run the following # diff --git a/setup-release.py b/setup-release.py index 9ebf622c4c..8050d218f9 100644 --- a/setup-release.py +++ b/setup-release.py @@ -16,9 +16,9 @@ # Do not edit these constants. They will be updated automatically # by scripts/update-client.sh. -CLIENT_VERSION = "37.0.0a1" +CLIENT_VERSION = "37.0.0b1" PACKAGE_NAME = "kubernetes" -DEVELOPMENT_STATUS = "3 - Alpha" +DEVELOPMENT_STATUS = "4 - Beta" # To install the library, run the following # @@ -74,11 +74,16 @@ 'kubernetes.leaderelection.resourcelock', 'kubernetes.informer', 'kubernetes.aio', - 'kubernetes.aio.config', - 'kubernetes.aio.utils', 'kubernetes.aio.client', 'kubernetes.aio.client.api', - 'kubernetes.aio.client.models' + 'kubernetes.aio.client.models', + 'kubernetes.aio.config', + 'kubernetes.aio.dynamic', + 'kubernetes.aio.leaderelection', + 'kubernetes.aio.leaderelection.resourcelock', + 'kubernetes.aio.stream', + 'kubernetes.aio.utils', + 'kubernetes.aio.watch', ], package_data={ 'kubernetes': ['py.typed'], diff --git a/setup.py b/setup.py index edf7244c47..6561541574 100644 --- a/setup.py +++ b/setup.py @@ -16,9 +16,9 @@ # Do not edit these constants. They will be updated automatically # by scripts/update-client.sh. -CLIENT_VERSION = "37.0.0a1" +CLIENT_VERSION = "37.0.0b1" PACKAGE_NAME = "kubernetes" -DEVELOPMENT_STATUS = "3 - Alpha" +DEVELOPMENT_STATUS = "4 - Beta" # To install the library, run the following # diff --git a/test-requirements.txt b/test-requirements.txt index d3c93b1f73..ca23638022 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -1,4 +1,4 @@ -coverage>=7.16.0 +coverage>=7.16.1 nose>=1.3.7 pytest pytest-cov