From c79e1bed483b4667f7bc2cef2654afbd904c1ecb Mon Sep 17 00:00:00 2001 From: Aleksandr Seleznev Date: Thu, 27 Oct 2022 18:54:22 +0300 Subject: [PATCH] Replace Deprecation Checker with Warning response header support More info: https://kubernetes.io/blog/2020/09/03/warnings/ --- k8s_handle/__init__.py | 12 -- k8s_handle/exceptions.py | 4 + k8s_handle/k8s/adapters.py | 11 +- k8s_handle/k8s/api_clients.py | 154 ++++++++++++++++++ .../k8s/availability_checker/__init__.py | 4 - .../k8s/availability_checker/checker.py | 30 ---- k8s_handle/k8s/availability_checker/mocks.py | 3 - .../availability_checker/resource_getters.py | 71 -------- .../test_availability_checker.py | 39 ----- .../test_resource_getters.py | 43 ----- k8s_handle/k8s/deprecation_checker.py | 151 ----------------- k8s_handle/k8s/provisioner.py | 6 +- k8s_handle/k8s/test_api_clients.py | 74 +++++++++ k8s_handle/k8s/test_deprecation_checker.py | 102 ------------ k8s_handle/k8s/test_warning_handler.py | 37 +++++ k8s_handle/k8s/warning_handler.py | 36 ++++ 16 files changed, 316 insertions(+), 461 deletions(-) create mode 100644 k8s_handle/k8s/api_clients.py delete mode 100644 k8s_handle/k8s/availability_checker/__init__.py delete mode 100644 k8s_handle/k8s/availability_checker/checker.py delete mode 100644 k8s_handle/k8s/availability_checker/mocks.py delete mode 100644 k8s_handle/k8s/availability_checker/resource_getters.py delete mode 100644 k8s_handle/k8s/availability_checker/test_availability_checker.py delete mode 100644 k8s_handle/k8s/availability_checker/test_resource_getters.py delete mode 100644 k8s_handle/k8s/deprecation_checker.py create mode 100644 k8s_handle/k8s/test_api_clients.py delete mode 100644 k8s_handle/k8s/test_deprecation_checker.py create mode 100644 k8s_handle/k8s/test_warning_handler.py create mode 100644 k8s_handle/k8s/warning_handler.py diff --git a/k8s_handle/__init__.py b/k8s_handle/__init__.py index 33a0ca3..d99a845 100644 --- a/k8s_handle/__init__.py +++ b/k8s_handle/__init__.py @@ -11,10 +11,8 @@ from k8s_handle import templating from k8s_handle.exceptions import ProvisioningError, ResourceNotAvailableError from k8s_handle.filesystem import InvalidYamlError -from k8s_handle.k8s.deprecation_checker import ApiDeprecationChecker from k8s_handle.k8s.provisioner import Provisioner from k8s_handle.k8s.diff import Diff -from k8s_handle.k8s.availability_checker import ResourceAvailabilityChecker, make_resource_getters_list COMMAND_DEPLOY = 'deploy' COMMAND_DIFF = 'diff' @@ -109,16 +107,6 @@ def _handler_provision(command, resources, priority_evaluator, use_kubeconfig, s log.info("Default namespace is not set. " "This may lead to provisioning error, if namespace is not set for each resource.") - try: - deprecation_checker = ApiDeprecationChecker(client.VersionApi().get_code().git_version[1:]) - available_checker = ResourceAvailabilityChecker(make_resource_getters_list()) - - for resource in resources: - deprecation_checker.run(resource) - available_checker.run(resource) - except client.exceptions.ApiException: - log.warning("Error while getting API version, deprecation check will be skipped.") - if command == COMMAND_DIFF: executor = Diff() else: diff --git a/k8s_handle/exceptions.py b/k8s_handle/exceptions.py index 123334a..5ccdff9 100644 --- a/k8s_handle/exceptions.py +++ b/k8s_handle/exceptions.py @@ -6,6 +6,10 @@ class ResourceNotAvailableError(Exception): pass +class InvalidWarningHeader(Exception): + pass + + class InvalidYamlError(Exception): pass diff --git a/k8s_handle/k8s/adapters.py b/k8s_handle/k8s/adapters.py index c5091a2..1d546c2 100644 --- a/k8s_handle/k8s/adapters.py +++ b/k8s_handle/k8s/adapters.py @@ -8,6 +8,7 @@ from k8s_handle.exceptions import ProvisioningError from k8s_handle.transforms import add_indent, split_str_by_capital_letters from .api_extensions import ResourcesAPI +from .api_clients import ApiClientWithWarningHandler from .mocks import K8sClientMock log = logging.getLogger(__name__) @@ -48,7 +49,9 @@ def __init__(self, spec): self.namespace = spec.get('metadata', {}).get('namespace', "") or settings.K8S_NAMESPACE @staticmethod - def get_instance(spec, api_custom_objects=None, api_resources=None): + def get_instance(spec, api_custom_objects=None, api_resources=None, warning_handler=None): + api_client = ApiClientWithWarningHandler(warning_handler=warning_handler) + # due to https://github.com/kubernetes-client/python/issues/387 if spec.get('kind') in Adapter.kinds_builtin: if spec.get('apiVersion') == 'test/test': @@ -59,10 +62,10 @@ def get_instance(spec, api_custom_objects=None, api_resources=None): if not api: return None - return AdapterBuiltinKind(spec, api()) + return AdapterBuiltinKind(spec, api(api_client=api_client)) - api_custom_objects = api_custom_objects or client.CustomObjectsApi() - api_resources = api_resources or ResourcesAPI() + api_custom_objects = api_custom_objects or client.CustomObjectsApi(api_client=api_client) + api_resources = api_resources or ResourcesAPI(api_client=api_client) return AdapterCustomKind(spec, api_custom_objects, api_resources) diff --git a/k8s_handle/k8s/api_clients.py b/k8s_handle/k8s/api_clients.py new file mode 100644 index 0000000..99a4133 --- /dev/null +++ b/k8s_handle/k8s/api_clients.py @@ -0,0 +1,154 @@ +import re +import logging + +from kubernetes.client.api_client import ApiClient + +from k8s_handle.exceptions import InvalidWarningHeader + +log = logging.getLogger(__name__) + + +class ApiClientWithWarningHandler(ApiClient): + def __init__(self, *args, **kwargs): + self.warning_handler = kwargs.pop("warning_handler", None) + + ApiClient.__init__(self, *args, **kwargs) + + def request(self, *args, **kwargs): + response_data = ApiClient.request(self, *args, **kwargs) + + if self.warning_handler is not None: + headers = response_data.getheaders() + + if "Warning" in headers: + self._handle_warnings([headers["Warning"]], self.warning_handler) + + return response_data + + @staticmethod + def _handle_warnings(headers, handler): + try: + warnings = ApiClientWithWarningHandler._parse_warning_headers(headers) + except InvalidWarningHeader as e: + log.debug("Warning headers: {}".format(headers)) + log.error(e) + return + + for warning in warnings: + handler.handle_warning_header(*warning) + + @staticmethod + def _parse_warning_headers(headers): + """ + Based on `ParseWarningHeaders()` from k8s.io/apimachinery/pkg/util/net package. + """ + results = [] + + for header in headers: + while len(header) > 0: + result, remainder = ApiClientWithWarningHandler._parse_warning_header(header) + results += [result] + header = remainder + + return results + + @staticmethod + def _parse_warning_header(header): + """ + Based on `ParseWarningHeader()` from k8s.io/apimachinery/pkg/util/net package, + but with much more permissive validation rules. + """ + + parts = header.split(" ", maxsplit=2) + if len(parts) != 3: + raise InvalidWarningHeader("Invalid warning header: fewer than 3 segments") + + (code, agent, textDateRemainder) = (parts[0], parts[1], parts[2]) + + # verify code format + codeMatcher = re.compile("^[0-9]{3}$") + if not codeMatcher.match(code): + raise InvalidWarningHeader("Invalid warning header: code segment is not 3 digits") + + code = int(code) + + # verify agent presence + if len(agent) == 0: + raise InvalidWarningHeader("Invalid warning header: empty agent segment") + + # verify textDateRemainder presence + if len(textDateRemainder) == 0: + raise InvalidWarningHeader("Invalid warning header: empty text segment") + + # extract text + text, dateAndRemainder = ApiClientWithWarningHandler._parse_quoted_string(textDateRemainder) + + result = (code, agent, text) + remainder = "" + + if len(dateAndRemainder) > 0: + if dateAndRemainder[0] == '"': + # consume date + foundEndQuote = False + for i in range(1, len(dateAndRemainder)): + if dateAndRemainder[i] == '"': + foundEndQuote = True + remainder = dateAndRemainder[i+1:].strip() + break + + if not foundEndQuote: + raise InvalidWarningHeader("Invalid warning header: unterminated date segment") + else: + remainder = dateAndRemainder + + if len(remainder) > 0: + if remainder[0] == ',': + # consume comma if present + remainder = remainder[1:].strip() + else: + raise InvalidWarningHeader("Invalid warning header: unexpected token after warn-date") + + return result, remainder + + @staticmethod + def _parse_quoted_string(quotedString): + """ + Based on `parseQuotedString()` from k8s.io/apimachinery/pkg/util/net package. + """ + + if len(quotedString) == 0: + raise InvalidWarningHeader("Invalid warning header: invalid quoted string: 0-length") + + if quotedString[0] != '"': + raise InvalidWarningHeader("Invalid warning header: invalid quoted string: missing initial quote") + + quotedString = quotedString[1:] + remainder = "" + escaping = False + closedQuote = False + result = "" + + for i in range(0, len(quotedString)): + b = quotedString[i] + if b == '"': + if escaping: + result += b + escaping = False + else: + closedQuote = True + remainder = quotedString[i+1:].strip() + break + elif b == '\\': + if escaping: + result += b + escaping = False + else: + escaping = True + else: + result += b + escaping = False + + if not closedQuote: + raise InvalidWarningHeader("Invalid warning header: invalid quoted string: missing closing quote") + + return (result, remainder) diff --git a/k8s_handle/k8s/availability_checker/__init__.py b/k8s_handle/k8s/availability_checker/__init__.py deleted file mode 100644 index 4183de8..0000000 --- a/k8s_handle/k8s/availability_checker/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from .checker import ResourceAvailabilityChecker -from .resource_getters import make_resource_getters_list - -__all__ = ['ResourceAvailabilityChecker', 'make_resource_getters_list'] diff --git a/k8s_handle/k8s/availability_checker/checker.py b/k8s_handle/k8s/availability_checker/checker.py deleted file mode 100644 index 4096882..0000000 --- a/k8s_handle/k8s/availability_checker/checker.py +++ /dev/null @@ -1,30 +0,0 @@ -from typing import List - -from k8s_handle.templating import get_template_contexts -from k8s_handle.exceptions import ResourceNotAvailableError - -from .resource_getters import AbstractResourceGetter - - -class ResourceAvailabilityChecker(object): - - def __init__(self, resources_getters: List[AbstractResourceGetter]): - self.resources = resources_getters - self.versions = {} - - def _is_available_kind(self, api_group: str, kind: str) -> bool: - kinds = [] - for api in self.resources: - if api.is_processable_version(api_group): - kinds = api.get_resources_by_version(api_group) - - return kind in kinds - - def run(self, file_path: str): - for template_body in get_template_contexts(file_path): - if not self._is_available_kind(template_body.get('apiVersion'), template_body.get('kind')): - raise ResourceNotAvailableError( - "The resource with kind {} is not supported with version {}. File: {}".format( - template_body.get('kind'), template_body.get('apiVersion'), file_path - ) - ) diff --git a/k8s_handle/k8s/availability_checker/mocks.py b/k8s_handle/k8s/availability_checker/mocks.py deleted file mode 100644 index 4d72552..0000000 --- a/k8s_handle/k8s/availability_checker/mocks.py +++ /dev/null @@ -1,3 +0,0 @@ -class MockResource(object): - def __init__(self, kind): - self.kind = kind diff --git a/k8s_handle/k8s/availability_checker/resource_getters.py b/k8s_handle/k8s/availability_checker/resource_getters.py deleted file mode 100644 index da03f4f..0000000 --- a/k8s_handle/k8s/availability_checker/resource_getters.py +++ /dev/null @@ -1,71 +0,0 @@ -import re -from typing import List, Set - -from k8s_handle.k8s.api_extensions import ResourcesAPI, CoreResourcesAPI - -core_pattern = re.compile(r'^([^/]+)$') -regular_pattern = re.compile(r'^([^/]+)/([^/]+)$') - - -class AbstractResourceGetter(object): - def is_processable_version(self, api_group: str) -> bool: - raise NotImplementedError - - def get_resources_by_version(self, api_group: str) -> Set[str]: - raise NotImplementedError - - -class CoreResourceGetter(AbstractResourceGetter): - - def __init__(self, resource_api: CoreResourcesAPI): - self.api = resource_api - self.versions = {} - - def is_processable_version(self, api_group: str) -> bool: - return core_pattern.match(api_group) is not None - - def get_resources_by_version(self, api_group: str) -> Set[str]: - if api_group in self.versions: - return self.versions[api_group] - - resp = self.api.list_api_resources(api_group) - - if not resp: - return set() - - kinds = {r.kind for r in resp.resources} - self.versions[api_group] = kinds - - return kinds - - -class RegularResourceGetter(AbstractResourceGetter): - - def __init__(self, resource_api: ResourcesAPI): - self.api = resource_api - self.versions = {} - - def is_processable_version(self, api_group: str) -> bool: - return regular_pattern.match(api_group) is not None - - def get_resources_by_version(self, api_group: str) -> Set[str]: - if api_group in self.versions: - return self.versions[api_group] - - group, version = regular_pattern.findall(api_group).pop() - resp = self.api.list_api_resource_arbitrary(group, version) - - if not resp: - return set() - - kinds = {r.kind for r in resp.resources} - self.versions[api_group] = kinds - - return kinds - - -def make_resource_getters_list() -> List[AbstractResourceGetter]: - return [ - CoreResourceGetter(CoreResourcesAPI()), - RegularResourceGetter(ResourcesAPI()) - ] diff --git a/k8s_handle/k8s/availability_checker/test_availability_checker.py b/k8s_handle/k8s/availability_checker/test_availability_checker.py deleted file mode 100644 index 399487d..0000000 --- a/k8s_handle/k8s/availability_checker/test_availability_checker.py +++ /dev/null @@ -1,39 +0,0 @@ -from unittest import TestCase - -from .checker import ResourceAvailabilityChecker -from .resource_getters import CoreResourceGetter, RegularResourceGetter -from .mocks import MockResource - -from k8s_handle.k8s.mocks import ResourcesAPIMock -from k8s_handle.exceptions import ResourceNotAvailableError - - -class TestAvailabilityChecker(TestCase): - - def setUp(self): - core_getter = CoreResourceGetter( - ResourcesAPIMock(group_version="v1", resources=[MockResource("Pod"), MockResource("CronJob")]) - ) - - regular_getter = RegularResourceGetter( - ResourcesAPIMock(group_version="app/v1", resources=[MockResource("Deployment"), MockResource("Service")]) - ) - - self.checker = ResourceAvailabilityChecker([core_getter, regular_getter]) - - def test_is_available_kind(self): - self.assertTrue(self.checker._is_available_kind("v1", "Pod")) - self.assertTrue(self.checker._is_available_kind("app/v1", "Service")) - self.assertFalse(self.checker._is_available_kind("v1", "Deployment")) - self.assertFalse(self.checker._is_available_kind("app/v1", "CronJob")) - - def test_run_with_valid_version(self): - self.checker.run('k8s_handle/k8s/fixtures/valid_version.yaml') - - def test_run_with_invalid_version(self): - with self.assertRaises(ResourceNotAvailableError): - self.checker.run('k8s_handle/k8s/fixtures/invalid_version.yaml') - - def test_run_with_unsupported_version(self): - with self.assertRaises(ResourceNotAvailableError): - self.checker.run('k8s_handle/k8s/fixtures/unsupported_version.yaml') diff --git a/k8s_handle/k8s/availability_checker/test_resource_getters.py b/k8s_handle/k8s/availability_checker/test_resource_getters.py deleted file mode 100644 index db3e68b..0000000 --- a/k8s_handle/k8s/availability_checker/test_resource_getters.py +++ /dev/null @@ -1,43 +0,0 @@ -from unittest import TestCase - -from k8s_handle.k8s.mocks import ResourcesAPIMock - -from .resource_getters import CoreResourceGetter, RegularResourceGetter -from .mocks import MockResource - - -class TestCoreResourceGetter(TestCase): - - def setUp(self): - self.getter = CoreResourceGetter( - ResourcesAPIMock(group_version="v1", resources=[MockResource("Pod"), MockResource("CronJob")]) - ) - - def test_is_processable_version(self): - self.assertTrue(self.getter.is_processable_version("v1")) - self.assertFalse(self.getter.is_processable_version("app/v1")) - self.assertFalse(self.getter.is_processable_version("/")) - self.assertFalse(self.getter.is_processable_version("")) - - def test_get_resources_by_version(self): - self.assertSetEqual({"Pod", "CronJob"}, self.getter.get_resources_by_version("v1")) - self.assertSetEqual(set(), self.getter.get_resources_by_version("v2")) - - -class TestRegularResourceGetter(TestCase): - - def setUp(self): - self.getter = RegularResourceGetter( - ResourcesAPIMock(group_version="app/v1", resources=[MockResource("Pod"), MockResource("CronJob")]) - ) - - def test_is_processable_version(self): - self.assertFalse(self.getter.is_processable_version("v1")) - self.assertTrue(self.getter.is_processable_version("app/betav1")) - self.assertTrue(self.getter.is_processable_version("app/v1")) - self.assertFalse(self.getter.is_processable_version("/")) - self.assertFalse(self.getter.is_processable_version("")) - - def test_get_resources_by_version(self): - self.assertSetEqual({"Pod", "CronJob"}, self.getter.get_resources_by_version("app/v1")) - self.assertSetEqual(set(), self.getter.get_resources_by_version("app/betav1")) diff --git a/k8s_handle/k8s/deprecation_checker.py b/k8s_handle/k8s/deprecation_checker.py deleted file mode 100644 index c192ce4..0000000 --- a/k8s_handle/k8s/deprecation_checker.py +++ /dev/null @@ -1,151 +0,0 @@ -import logging - -import semver - -from k8s_handle.templating import get_template_contexts - -log = logging.getLogger(__name__) - - -class ApiDeprecationChecker: - def __init__(self, server_version): - self.server_version = server_version - self.deprecated_versions = { - "extensions/v1beta1": { - "Deployment": { - "since": "1.8.0", - "until": "1.16.0", - }, - "DaemonSet": { - "since": "1.8.0", - "until": "1.16.0", - }, - "ReplicaSet": { - "since": "1.8.0", - "until": "1.16.0", - }, - "NetworkPolicy": { - "since": "1.9.0", - "until": "1.16.0", - }, - "PodSecurityPolicy": { - "since": "1.10.0", - "until": "1.16.0", - }, - "Ingress": { - "since": "1.14.0", - "until": "1.20.0", - }, - }, - "apps/v1beta1": { - "Deployment": { - "since": "1.9.0", - "until": "1.16.0", - }, - "DaemonSet": { - "since": "1.9.0", - "until": "1.16.0", - }, - "ReplicaSet": { - "since": "1.9.0", - "until": "1.16.0", - }, - "StatefulSet": { - "since": "1.9.0", - "until": "1.16.0", - }, - }, - "apps/v1beta2": { - "Deployment": { - "since": "1.9.0", - "until": "1.16.0", - }, - "DaemonSet": { - "since": "1.9.0", - "until": "1.16.0", - }, - "ReplicaSet": { - "since": "1.9.0", - "until": "1.16.0", - }, - "StatefulSet": { - "since": "1.9.0", - "until": "1.16.0", - }, - }, - "scheduling.k8s.io/v1alpha1": { - "PriorityClass": { - "since": "1.14.0", - "until": "1.17.0", - }, - }, - "scheduling.k8s.io/v1beta1": { - "PriorityClass": { - "since": "1.14.0", - "until": "1.17.0", - }, - }, - "apiextensions.k8s.io/v1beta1": { - "CustomResourceDefinition": { - "since": "1.16.0", - "until": "1.19.0", - }, - }, - "admissionregistration.k8s.io/v1beta1": { - "MutatingWebhookConfiguration": { - "since": "1.16.0", - "until": "1.19.0", - }, - "ValidatingWebhookConfiguration": { - "since": "1.16.0", - "until": "1.19.0", - }, - }, - } - - def _is_server_version_greater(self, checked_version): - return True if semver.compare(self.server_version, checked_version) >= 0 else False - - def _is_deprecated(self, api_version, kind): - message = """ - ▄▄ - ████ - ██▀▀██ - ███ ███ Version {api_version} - ████▄▄████ for resource type {kind} - █████ █████ is {status} since {k8s_version} - ██████████████ - """ - if api_version not in self.deprecated_versions: - return False - - if kind not in self.deprecated_versions[api_version]: - return False - - if self.deprecated_versions[api_version][kind]["until"]: - if self._is_server_version_greater(self.deprecated_versions[api_version][kind]["until"]): - log.warning(message.format( - api_version=api_version, - kind=kind, - status="unsupported", - k8s_version=self.deprecated_versions[api_version][kind]["until"], - )) - return True - - if self._is_server_version_greater(self.deprecated_versions[api_version][kind]["since"]): - log.warning(message.format( - api_version=api_version, - kind=kind, - status="deprecated", - k8s_version=self.deprecated_versions[api_version][kind]["since"], - )) - return True - - return False - - def run(self, file_path): - for template_body in get_template_contexts(file_path): - self._is_deprecated( - template_body.get('apiVersion'), - template_body.get('kind'), - ) diff --git a/k8s_handle/k8s/provisioner.py b/k8s_handle/k8s/provisioner.py index c361c72..391ff61 100644 --- a/k8s_handle/k8s/provisioner.py +++ b/k8s_handle/k8s/provisioner.py @@ -9,6 +9,7 @@ from k8s_handle.templating import get_template_contexts from k8s_handle.transforms import split_str_by_capital_letters from .adapters import Adapter +from .warning_handler import WarningHandler log = logging.getLogger(__name__) @@ -18,6 +19,7 @@ def __init__(self, command, sync_mode, show_logs): self.command = command self.sync_mode = False if show_logs else sync_mode self.show_logs = show_logs + self._warning_handler = WarningHandler() @staticmethod def _replicas_count_are_equal(replicas): @@ -74,7 +76,7 @@ def _deploy_all(self, file_path): self._deploy(template_body, file_path) def _deploy(self, template_body, file_path): - kube_client = Adapter.get_instance(template_body) + kube_client = Adapter.get_instance(template_body, warning_handler=self._warning_handler) if not kube_client: raise RuntimeError( @@ -172,7 +174,7 @@ def _destroy_all(self, file_path): self._destroy(template_body, file_path) def _destroy(self, template_body, file_path): - kube_client = Adapter.get_instance(template_body) + kube_client = Adapter.get_instance(template_body, warning_handler=self._warning_handler) if not kube_client: raise RuntimeError( diff --git a/k8s_handle/k8s/test_api_clients.py b/k8s_handle/k8s/test_api_clients.py new file mode 100644 index 0000000..f8bcc0f --- /dev/null +++ b/k8s_handle/k8s/test_api_clients.py @@ -0,0 +1,74 @@ +import types +import unittest +from unittest.mock import Mock +from unittest.mock import call +from unittest.mock import patch + +from urllib3 import HTTPResponse +from kubernetes.client.rest import RESTResponse + +from k8s_handle.exceptions import InvalidWarningHeader +from .api_clients import ApiClientWithWarningHandler + + +class TestApiClientWithWarningHandler(unittest.TestCase): + def setUp(self): + self.warning_handler = types.SimpleNamespace() + self.warning_handler.handle_warning_header = Mock() + self.api_client = ApiClientWithWarningHandler(warning_handler=self.warning_handler) + + @patch('kubernetes.client.api_client.ApiClient.request') + def _test_request(self, headers, mocked_request): + mocked_request.return_value = RESTResponse(HTTPResponse(headers=headers)) + self.api_client.request() + return self.api_client.warning_handler.handle_warning_header + + def test_request(self): + handler = self._test_request([ + ('Warning', '299 - "warning 1"'), + ]) + handler.assert_called_with(299, '-', 'warning 1') + + def test_request_with_multiple_headers(self): + handler = self._test_request([ + ('Warning', '299 - "warning 1"'), + ('Warning', '299 - "warning 2", 299 - "warning 3"'), + ]) + handler.assert_has_calls([ + call(299, '-', 'warning 1'), + call(299, '-', 'warning 2'), + call(299, '-', 'warning 3'), + ]) + + def test_request_without_header(self): + headers = [] + self._test_request(headers).assert_not_called() + + def test_request_with_invalid_headers(self): + with self.assertLogs("k8s_handle.k8s.api_clients", level="ERROR"): + self._test_request([ + ('Warning', 'invalid'), + ]) + + def test_parse_warning_headers(self): + self.assertEqual( + self.api_client._parse_warning_headers( + ['299 - "warning 1"'], + ), + [(299, '-', 'warning 1')], + ) + + def test_parse_warning_headers_with_invalid_header(self): + with self.assertRaisesRegex(InvalidWarningHeader, "Invalid warning header: fewer than 3 segments"): + self.api_client._parse_warning_headers(['invalid']) + + def test_parse_warning_headers_with_invalid_code(self): + with self.assertRaisesRegex(InvalidWarningHeader, "Invalid warning header: code segment is not 3 digits"): + self.api_client._parse_warning_headers(['1000 - "warning 3"']) + + def test_parse_warning_headers_with_unquoted_text(self): + with self.assertRaisesRegex( + InvalidWarningHeader, + "Invalid warning header: invalid quoted string: missing closing quote" + ): + self.api_client._parse_warning_headers(['299 - "warning unquoted']) diff --git a/k8s_handle/k8s/test_deprecation_checker.py b/k8s_handle/k8s/test_deprecation_checker.py deleted file mode 100644 index a0a8520..0000000 --- a/k8s_handle/k8s/test_deprecation_checker.py +++ /dev/null @@ -1,102 +0,0 @@ -import unittest - -from k8s_handle.k8s.deprecation_checker import ApiDeprecationChecker - - -class TestApiDeprecationChecker(unittest.TestCase): - - def test_version_not_in_list(self): - checker = ApiDeprecationChecker("1.9.7") - checker.deprecated_versions = { - "test/v1": { - "Deployment": { - "since": "1.8.0", - "until": "1.10.0", - }, - } - } - self.assertFalse(checker._is_deprecated("test/v2", "Deployment")) - - def test_kind_not_in_list(self): - checker = ApiDeprecationChecker("1.9.7") - checker.deprecated_versions = { - "test/v1": { - "Deployment": { - "since": "1.8.0", - "until": "1.10.0", - }, - } - } - self.assertFalse(checker._is_deprecated("test/v1", "StatefulSet")) - - def test_version_not_deprecated_yet(self): - checker = ApiDeprecationChecker("1.7.9") - checker.deprecated_versions = { - "test/v1": { - "Deployment": { - "since": "1.8.0", - "until": "1.10.0", - }, - } - } - self.assertFalse(checker._is_deprecated("test/v1", "Deployment")) - - def test_version_is_deprecated_equal(self): - checker = ApiDeprecationChecker("1.8.0") - checker.deprecated_versions = { - "test/v1": { - "Deployment": { - "since": "1.8.0", - "until": "1.10.0", - }, - } - } - self.assertTrue(checker._is_deprecated("test/v1", "Deployment")) - - def test_version_is_deprecated(self): - checker = ApiDeprecationChecker("1.9.9") - checker.deprecated_versions = { - "test/v1": { - "Deployment": { - "since": "1.8.0", - "until": "1.10.0", - }, - } - } - self.assertTrue(checker._is_deprecated("test/v1", "Deployment")) - - def test_version_is_unsupported_equal(self): - checker = ApiDeprecationChecker("1.10.0") - checker.deprecated_versions = { - "test/v1": { - "Deployment": { - "since": "1.8.0", - "until": "1.10.0", - }, - } - } - self.assertTrue(checker._is_deprecated("test/v1", "Deployment")) - - def test_version_is_unsupported(self): - checker = ApiDeprecationChecker("1.10.6") - checker.deprecated_versions = { - "test/v1": { - "Deployment": { - "since": "1.8.0", - "until": "1.10.0", - }, - } - } - self.assertTrue(checker._is_deprecated("test/v1", "Deployment")) - - def test_version_no_until(self): - checker = ApiDeprecationChecker("1.10.6") - checker.deprecated_versions = { - "test/v1": { - "Deployment": { - "since": "1.8.0", - "until": "", - }, - } - } - self.assertTrue(checker._is_deprecated("test/v1", "Deployment")) diff --git a/k8s_handle/k8s/test_warning_handler.py b/k8s_handle/k8s/test_warning_handler.py new file mode 100644 index 0000000..0967c55 --- /dev/null +++ b/k8s_handle/k8s/test_warning_handler.py @@ -0,0 +1,37 @@ +import unittest + +from .warning_handler import WarningHandler + + +class TestWarningHandler(unittest.TestCase): + def setUp(self): + self.handler = WarningHandler() + + def test_handle_warning_header(self): + with self.assertLogs("k8s_handle.k8s.warning_handler", level="WARNING") as cm: + self.handler.handle_warning_header(299, "-", "warning") + + self.assertEqual(cm.output, ['WARNING:k8s_handle.k8s.warning_handler:\x1b[33;1m\n' + ' ▄▄\n' + ' ████\n' + ' ██▀▀██\n' + ' ███ ███ warning\n' + ' ████▄▄████\n' + ' █████ █████\n' + ' ██████████████\n' + '\x1b[0m']) + + def test_handle_warning_header_with_unexpected_code(self): + with self.assertNoLogs("k8s_handle.k8s.warning_handler", level="WARNING"): + self.handler.handle_warning_header(0, "-", "warning") + + def test_handle_warning_header_with_empty_message(self): + with self.assertNoLogs("k8s_handle.k8s.warning_handler", level="WARNING"): + self.handler.handle_warning_header(299, "-", "") + + def test_handle_warning_header_with_duplicate_messages(self): + with self.assertLogs("k8s_handle.k8s.warning_handler", level="WARNING"): + self.handler.handle_warning_header(299, "-", "warning") + + with self.assertNoLogs("k8s_handle.k8s.warning_handler", level="WARNING"): + self.handler.handle_warning_header(299, "-", "warning") diff --git a/k8s_handle/k8s/warning_handler.py b/k8s_handle/k8s/warning_handler.py new file mode 100644 index 0000000..b759df2 --- /dev/null +++ b/k8s_handle/k8s/warning_handler.py @@ -0,0 +1,36 @@ +import logging + +log = logging.getLogger(__name__) + +YELLOW_COLOR = "\u001b[33;1m" +RESET_COLOR = "\u001b[0m" + +WARNING_TEMPLATE = """ + ▄▄ + ████ + ██▀▀██ + ███ ███ {text} + ████▄▄████ + █████ █████ + ██████████████ +""" + + +class WarningHandler(): + def __init__(self): + self.written = [] + + def handle_warning_header(self, code, agent, text): + if code != 299 or len(text) == 0: + return + + if text in self.written: + return + + log.warning(self._yellow(WARNING_TEMPLATE.format(text=text))) + + self.written += [text] + + @staticmethod + def _yellow(str): + return f"{YELLOW_COLOR}{str}{RESET_COLOR}"