From e0bedce049f77bf469760e11e16ef661e2441c97 Mon Sep 17 00:00:00 2001 From: Clement Tee Date: Thu, 9 Oct 2025 16:48:28 +0800 Subject: [PATCH 1/9] Refactor `k8s_get_tls_secret` and `get_ca_cert` to common util --- app/conftest.py | 12 +++ app/handlers/handlers_services.py | 38 +-------- app/handlers/tests/test_handlers_services.py | 90 ++------------------ app/tests/test_utils_k8s.py | 53 ++++++++++++ app/utils_k8s.py | 39 +++++++++ 5 files changed, 115 insertions(+), 117 deletions(-) diff --git a/app/conftest.py b/app/conftest.py index cd0dcf85..da6156e0 100644 --- a/app/conftest.py +++ b/app/conftest.py @@ -1,8 +1,11 @@ from unittest.mock import MagicMock, patch +import kubernetes import pytest import responses +from app.api.tests.factories import BASE64_OF_VALID_CA_CERT + @pytest.fixture def mocked_responses(): @@ -38,3 +41,12 @@ def k8s_apps_client_mock(): with patch("kubernetes.client.AppsV1Api") as k8sclient_mock: k8sclient_mock.return_value = client_mock yield client_mock + + +@pytest.fixture +def k8s_tls_secret_mock(): + return kubernetes.client.V1Secret( + type="kubernetes.io/tls", + metadata=kubernetes.client.V1ObjectMeta(name="gateway-tls"), + data={"ca.crt": BASE64_OF_VALID_CA_CERT}, + ) diff --git a/app/handlers/handlers_services.py b/app/handlers/handlers_services.py index 310da962..1aff88ba 100644 --- a/app/handlers/handlers_services.py +++ b/app/handlers/handlers_services.py @@ -1,4 +1,3 @@ -import base64 from collections.abc import Callable from enum import StrEnum from typing import cast @@ -8,7 +7,8 @@ from kopf import Body, Status from app.crds import ResourceType -from app.utils import to_bool, validate_pem_x509_certificate +from app.utils import to_bool +from app.utils_k8s import get_ca_cert, k8s_get_tls_secret def k8s_get_twingate_resource( @@ -25,40 +25,6 @@ def k8s_get_twingate_resource( raise -def k8s_get_tls_secret(namespace: str, name: str) -> kubernetes.client.V1Secret | None: - try: - return kubernetes.client.CoreV1Api().read_namespaced_secret( - name=name, namespace=namespace - ) - except kubernetes.client.exceptions.ApiException as ex: - if ex.status == 404: - return None - - raise - - -def get_ca_cert(tls_secret: kubernetes.client.V1Secret) -> str: - tls_secret_name = tls_secret.metadata.name - if tls_secret.type != "kubernetes.io/tls": - raise kopf.PermanentError( - f"Kubernetes Secret object: {tls_secret_name} type is invalid." - ) - - if not (ca_cert := tls_secret.data.get("ca.crt")): - raise kopf.PermanentError( - f"Kubernetes Secret object: {tls_secret_name} is missing ca.crt." - ) - - try: - validate_pem_x509_certificate(base64.b64decode(ca_cert).decode()) - except ValueError as ex: - raise kopf.PermanentError( - f"Kubernetes Secret object: {tls_secret_name} ca.crt is invalid." - ) from ex - - return ca_cert - - ALLOWED_EXTRA_ANNOTATIONS: list[tuple[str, Callable]] = [ ("name", str), ("alias", str), diff --git a/app/handlers/tests/test_handlers_services.py b/app/handlers/tests/test_handlers_services.py index 57898631..46b3c628 100644 --- a/app/handlers/tests/test_handlers_services.py +++ b/app/handlers/tests/test_handlers_services.py @@ -11,11 +11,11 @@ from app.handlers.handlers_services import ( ALLOWED_EXTRA_ANNOTATIONS, TLS_OBJECT_ANNOTATION, - k8s_get_tls_secret, k8s_get_twingate_resource, service_to_twingate_resource, twingate_service_create, ) +from app.utils_k8s import get_ca_cert # Ignore the fact we use _cogs here @@ -114,15 +114,6 @@ def example_load_balancer_gateway_service_body(): return kopf.Body(yaml.safe_load(yaml_str)) -@pytest.fixture -def k8s_tls_secret_mock(): - return kubernetes.client.V1Secret( - type="kubernetes.io/tls", - metadata=kubernetes.client.V1ObjectMeta(name="gateway-tls"), - data={"ca.crt": BASE64_OF_VALID_CA_CERT}, - ) - - @pytest.fixture def k8s_customobjects_client_mock(): client_mock = MagicMock() @@ -191,9 +182,14 @@ def test_kubernetes_resource_type_annotation( namespace = "custom-namespace" k8s_core_client_mock.read_namespaced_secret.return_value = k8s_tls_secret_mock - result = service_to_twingate_resource( - example_cluster_ip_gateway_service_body, namespace - ) + with patch( + "app.handlers.handlers_services.get_ca_cert", wraps=get_ca_cert + ) as get_ca_cert_mock: + result = service_to_twingate_resource( + example_cluster_ip_gateway_service_body, namespace + ) + + get_ca_cert_mock.assert_called_once_with(k8s_tls_secret_mock) k8s_core_client_mock.read_namespaced_secret.assert_called_once_with( namespace=namespace, name=tls_object_name ) @@ -248,59 +244,6 @@ def test_kubernetes_resource_type_annotation_without_k8s_secret_object( example_cluster_ip_gateway_service_body, "default" ) - def test_kubernetes_resource_type_annotation_with_invalid_secret_type( - self, - example_cluster_ip_gateway_service_body, - k8s_core_client_mock, - k8s_tls_secret_mock, - ): - k8s_tls_secret_mock.type = "kubernetes.io/token" - k8s_core_client_mock.read_namespaced_secret.return_value = k8s_tls_secret_mock - - with pytest.raises( - kopf.PermanentError, - match=r"Kubernetes Secret object: gateway-tls type is invalid.", - ): - service_to_twingate_resource( - example_cluster_ip_gateway_service_body, "default" - ) - - def test_kubernetes_resource_type_annotation_without_ca_cert( - self, - example_cluster_ip_gateway_service_body, - k8s_core_client_mock, - k8s_tls_secret_mock, - ): - k8s_tls_secret_mock.data = {} - k8s_core_client_mock.read_namespaced_secret.return_value = k8s_tls_secret_mock - - with pytest.raises( - kopf.PermanentError, - match=r"Kubernetes Secret object: gateway-tls is missing ca.crt.", - ): - service_to_twingate_resource( - example_cluster_ip_gateway_service_body, "default" - ) - - def test_kubernetes_resource_type_annotation_with_invalid_ca_cert( - self, - example_cluster_ip_gateway_service_body, - k8s_core_client_mock, - k8s_tls_secret_mock, - ): - k8s_tls_secret_mock.data["ca.crt"] = ( - "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tIE1JSUZmakNDQTJhZ0F3SUJBZ0lVQk50IC0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0=" - ) - k8s_core_client_mock.read_namespaced_secret.return_value = k8s_tls_secret_mock - - with pytest.raises( - kopf.PermanentError, - match=r"Kubernetes Secret object: gateway-tls ca.crt is invalid.", - ): - service_to_twingate_resource( - example_cluster_ip_gateway_service_body, "default" - ) - @pytest.mark.parametrize( ("status", "expected"), [ @@ -406,21 +349,6 @@ def test_reraises_non_404_exceptions(self, k8s_customobjects_client_mock): k8s_get_twingate_resource("default", "test") -class TestK8sGetTLSSecret: - def test_handles_404_returns_none(self, k8s_core_client_mock): - k8s_core_client_mock.read_namespaced_secret.side_effect = ( - kubernetes.client.exceptions.ApiException(status=404) - ) - assert k8s_get_tls_secret("default", "test") is None - - def test_reraises_non_404_exceptions(self, k8s_core_client_mock): - k8s_core_client_mock.read_namespaced_secret.side_effect = ( - kubernetes.client.exceptions.ApiException(status=500) - ) - with pytest.raises(kubernetes.client.exceptions.ApiException): - k8s_get_tls_secret("default", "test") - - class TestTwingateServiceCreate: def test_create_service_triggers_creation_of_twingate_resource( self, example_service_body, kopf_handler_runner, k8s_customobjects_client_mock diff --git a/app/tests/test_utils_k8s.py b/app/tests/test_utils_k8s.py index 683676ac..9d700fc1 100644 --- a/app/tests/test_utils_k8s.py +++ b/app/tests/test_utils_k8s.py @@ -1,8 +1,12 @@ +import kopf import kubernetes import pytest +from app.api.tests.factories import BASE64_OF_VALID_CA_CERT from app.utils_k8s import ( + get_ca_cert, k8s_delete_pod, + k8s_get_tls_secret, k8s_read_namespaced_deployment, k8s_read_namespaced_pod, ) @@ -56,3 +60,52 @@ def test_reraises_non_404_exceptions(self, k8s_apps_client_mock): ) with pytest.raises(kubernetes.client.exceptions.ApiException): k8s_read_namespaced_deployment("default", "test") + + +class TestK8sGetTLSSecret: + def test_handles_404_returns_none(self, k8s_core_client_mock): + k8s_core_client_mock.read_namespaced_secret.side_effect = ( + kubernetes.client.exceptions.ApiException(status=404) + ) + assert k8s_get_tls_secret("default", "test") is None + + def test_reraises_non_404_exceptions(self, k8s_core_client_mock): + k8s_core_client_mock.read_namespaced_secret.side_effect = ( + kubernetes.client.exceptions.ApiException(status=500) + ) + with pytest.raises(kubernetes.client.exceptions.ApiException): + k8s_get_tls_secret("default", "test") + + +class TestGetCACert: + def test_get_ca_cert(self, k8s_tls_secret_mock): + assert get_ca_cert(k8s_tls_secret_mock) == BASE64_OF_VALID_CA_CERT + + def test_get_ca_cert_with_invalid_secret_type(self, k8s_tls_secret_mock): + k8s_tls_secret_mock.type = "kubernetes.io/token" + + with pytest.raises( + kopf.PermanentError, + match=r"Kubernetes Secret object: gateway-tls type is invalid.", + ): + get_ca_cert(k8s_tls_secret_mock) + + def test_get_ca_cert_with_missing_ca_cert(self, k8s_tls_secret_mock): + k8s_tls_secret_mock.data = {} + + with pytest.raises( + kopf.PermanentError, + match=r"Kubernetes Secret object: gateway-tls is missing ca.crt.", + ): + get_ca_cert(k8s_tls_secret_mock) + + def test_get_ca_cert_with_invalid_ca_cert(self, k8s_tls_secret_mock): + k8s_tls_secret_mock.data["ca.crt"] = ( + "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tIE1JSUZmakNDQTJhZ0F3SUJBZ0lVQk50IC0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0=" + ) + + with pytest.raises( + kopf.PermanentError, + match=r"Kubernetes Secret object: gateway-tls ca.crt is invalid.", + ): + get_ca_cert(k8s_tls_secret_mock) diff --git a/app/utils_k8s.py b/app/utils_k8s.py index d229fc96..242e6039 100644 --- a/app/utils_k8s.py +++ b/app/utils_k8s.py @@ -1,5 +1,10 @@ +import base64 + +import kopf import kubernetes +from app.utils import validate_pem_x509_certificate + def k8s_read_namespaced_pod( namespace: str, name: str, kapi: kubernetes.client.CoreV1Api | None = None @@ -40,3 +45,37 @@ def k8s_read_namespaced_deployment( if ex.status == 404: return None raise + + +def k8s_get_tls_secret(namespace: str, name: str) -> kubernetes.client.V1Secret | None: + try: + return kubernetes.client.CoreV1Api().read_namespaced_secret( + name=name, namespace=namespace + ) + except kubernetes.client.exceptions.ApiException as ex: + if ex.status == 404: + return None + + raise + + +def get_ca_cert(tls_secret: kubernetes.client.V1Secret) -> str: + tls_secret_name = tls_secret.metadata.name + if tls_secret.type != "kubernetes.io/tls": + raise kopf.PermanentError( + f"Kubernetes Secret object: {tls_secret_name} type is invalid." + ) + + if not (ca_cert := tls_secret.data.get("ca.crt")): + raise kopf.PermanentError( + f"Kubernetes Secret object: {tls_secret_name} is missing ca.crt." + ) + + try: + validate_pem_x509_certificate(base64.b64decode(ca_cert).decode()) + except ValueError as ex: + raise kopf.PermanentError( + f"Kubernetes Secret object: {tls_secret_name} ca.crt is invalid." + ) from ex + + return ca_cert From 618107a2db3db2b3ceab247544ae56d1dffe5eb3 Mon Sep 17 00:00:00 2001 From: Clement Tee Date: Thu, 9 Oct 2025 16:50:05 +0800 Subject: [PATCH 2/9] Support `certificateAuthorityCertSecretRef` to `TwingateResource` CRD --- app/crds.py | 25 +++- app/tests/test_crds_resource.py | 51 +++++++- .../crds/twingate.com.twingateresources.yaml | 16 ++- tests_integration/test_crds_resource.py | 76 +++++++++++ tests_integration/test_resource_flows.py | 120 ++++++++++++++++++ tests_integration/utils.py | 6 + 6 files changed, 289 insertions(+), 5 deletions(-) diff --git a/app/crds.py b/app/crds.py index 768b01ba..18e97490 100644 --- a/app/crds.py +++ b/app/crds.py @@ -1,9 +1,11 @@ +import base64 import logging from collections.abc import MutableMapping from datetime import datetime from enum import Enum, StrEnum from typing import Annotated, Any, cast +import kopf import kubernetes.client import pendulum from croniter import croniter @@ -21,6 +23,7 @@ from semantic_version import NpmSpec from app.settings import get_settings +from app.utils_k8s import get_ca_cert, k8s_get_tls_secret from app.version_policy_providers import get_provider K8sObject = MutableMapping[Any, Any] @@ -122,8 +125,19 @@ class ResourceProxy(BaseModel): address: str certificate_authority_cert: Annotated[ - Base64Str, AfterValidator(lambda v: v.strip()) - ] + Base64Str | None, AfterValidator(lambda v: v.strip() if v is not None else None) + ] = None + certificate_authority_cert_secret_ref: _KubernetesObjectRef | None = None + + def get_certificate_authority_cert(self) -> str | None: + if secret_ref := self.certificate_authority_cert_secret_ref: + tls_secret = k8s_get_tls_secret(secret_ref.namespace, secret_ref.name) + if not tls_secret: + return None + + return base64.b64decode(get_ca_cert(tls_secret)).decode() + + return self.certificate_authority_cert class ResourceType(StrEnum): @@ -190,9 +204,14 @@ def to_graphql_arguments( } case ResourceType.KUBERNETES: resource_proxy = cast(ResourceProxy, self.proxy) + ca_cert = resource_proxy.get_certificate_authority_cert() + if ca_cert is None: + raise kopf.PermanentError( + "Certificate authority cert is not found for Kubernetes Resource type" + ) graphql_args |= { "proxy_address": resource_proxy.address, - "certificate_authority_cert": resource_proxy.certificate_authority_cert, + "certificate_authority_cert": ca_cert, } return graphql_args diff --git a/app/tests/test_crds_resource.py b/app/tests/test_crds_resource.py index 72b16906..f9de6693 100644 --- a/app/tests/test_crds_resource.py +++ b/app/tests/test_crds_resource.py @@ -1,9 +1,18 @@ import base64 +from unittest.mock import patch +import kopf import pytest from app.api.tests.factories import BASE64_OF_VALID_CA_CERT, VALID_CA_CERT -from app.crds import ResourceSpec, ResourceType, TwingateResourceCRD +from app.crds import ( + ResourceProxy, + ResourceSpec, + ResourceType, + TwingateResourceCRD, + _KubernetesObjectRef, +) +from app.utils_k8s import get_ca_cert @pytest.fixture @@ -275,6 +284,31 @@ def test_resourceprotocol_ports_validation(): ) +def test_resource_proxy_get_certificate_authority_cert_without_secret_ref(): + proxy = ResourceProxy( + address="proxy.default.cluster.local", + certificate_authority_cert=BASE64_OF_VALID_CA_CERT, + certificate_authority_cert_secret_ref=None, + ) + + assert proxy.get_certificate_authority_cert() == VALID_CA_CERT + + +def test_resource_proxy_get_certificate_authority_cert_with_secret_ref( + k8s_core_client_mock, k8s_tls_secret_mock +): + proxy = ResourceProxy( + address="proxy.default.cluster.local", + certificate_authority_cert_secret_ref=_KubernetesObjectRef(name="gateway-tls"), + certificate_authority_cert=None, + ) + k8s_core_client_mock.read_namespaced_secret.return_value = k8s_tls_secret_mock + + with patch("app.crds.get_ca_cert", wraps=get_ca_cert) as get_ca_cert_mock: + assert proxy.get_certificate_authority_cert() == VALID_CA_CERT + get_ca_cert_mock.assert_called_once_with(k8s_tls_secret_mock) + + def test_network_resource_spec_to_graphql_arguments(sample_network_resource_object): resource_spec = ResourceSpec( **sample_network_resource_object["spec"], @@ -330,6 +364,21 @@ def test_kubernetes_resource_spec_to_graphql_arguments( } +def test_kubernetes_resource_spec_to_graphql_arguments_when_certificate_authority_cert_not_found( + sample_kubernetes_resource_object, +): + sample_kubernetes_resource_object["spec"]["proxy"]["certificate_authority_cert"] = ( + None + ) + resource_spec = ResourceSpec(**sample_kubernetes_resource_object["spec"]) + + with pytest.raises( + kopf.PermanentError, + match="Certificate authority cert is not found for Kubernetes Resource type", + ): + resource_spec.to_graphql_arguments(labels={"key": "value"}) + + def test_resource_spec_to_graphql_arguments_when_sync_labels_disabled( sample_network_resource_object, ): diff --git a/deploy/twingate-operator/crds/twingate.com.twingateresources.yaml b/deploy/twingate-operator/crds/twingate.com.twingateresources.yaml index cdf62eee..36bf2bb6 100644 --- a/deploy/twingate-operator/crds/twingate.com.twingateresources.yaml +++ b/deploy/twingate-operator/crds/twingate.com.twingateresources.yaml @@ -138,7 +138,10 @@ spec: message: "Resource type is immutable" proxy: type: object - required: ["address", "certificateAuthorityCert"] + required: ["address"] + oneOf: + - required: ["certificateAuthorityCertSecretRef"] + - required: ["certificateAuthorityCert"] properties: address: type: string @@ -146,6 +149,17 @@ spec: certificateAuthorityCert: type: string description: "Base64-encoded certificate of the Certificate Authority issuing the proxy's TLS certificate." + certificateAuthorityCertSecretRef: + type: object + required: ["name"] + properties: + name: + type: string + description: "Name of the Secret object containing the certificate of the Certificate Authority issuing the proxy's TLS certificate." + namespace: + type: string + default: default + description: "Namespace of the Secret object." status: type: object x-kubernetes-preserve-unknown-fields: true diff --git a/tests_integration/test_crds_resource.py b/tests_integration/test_crds_resource.py index 4c6b4142..aad6b368 100644 --- a/tests_integration/test_crds_resource.py +++ b/tests_integration/test_crds_resource.py @@ -453,3 +453,79 @@ def test_kubernetes_resource_cannot_have_browser_shortcut(unique_resource_name): "isBrowserShortcutEnabled cannot be set to true for Kubernetes Resource" in stderr ) + + +def test_kubernetes_resource_proxy_object_must_have_address(unique_resource_name): + with pytest.raises(subprocess.CalledProcessError) as ex: + kubectl_create( + f""" + apiVersion: twingate.com/v1beta + kind: TwingateResource + metadata: + name: {unique_resource_name} + spec: + name: My K8S Resource + address: "foo.default.cluster.local" + type: Kubernetes + proxy: + certificateAuthorityCert: "base64-encoded-cert" + """ + ) + + stderr = ex.value.stderr.decode() + assert "spec.proxy.address: Required value" in stderr + + +def test_kubernetes_resource_proxy_object_has_both_ca_cert_string_and_secret_ref( + unique_resource_name, +): + with pytest.raises(subprocess.CalledProcessError) as ex: + kubectl_create( + f""" + apiVersion: twingate.com/v1beta + kind: TwingateResource + metadata: + name: {unique_resource_name} + spec: + name: My K8S Resource + address: "foo.default.cluster.local" + type: Kubernetes + proxy: + address: "my-proxy.default.cluster.local" + certificateAuthorityCert: "base64-encoded-cert" + certificateAuthorityCertSecretRef: + name: "tls-secret" + """ + ) + + stderr = ex.value.stderr.decode() + assert ( + '"spec.proxy" must validate one and only one schema (oneOf). Found 2 valid alternatives' + in stderr + ) + + +def test_kubernetes_resource_proxy_object_missing_either_ca_cert_string_or_secret_ref( + unique_resource_name, +): + with pytest.raises(subprocess.CalledProcessError) as ex: + kubectl_create( + f""" + apiVersion: twingate.com/v1beta + kind: TwingateResource + metadata: + name: {unique_resource_name} + spec: + name: My K8S Resource + address: "foo.default.cluster.local" + type: Kubernetes + proxy: + address: "my-proxy.default.cluster.local" + """ + ) + + stderr = ex.value.stderr.decode() + assert ( + '"spec.proxy" must validate one and only one schema (oneOf). Found none valid' + in stderr + ) diff --git a/tests_integration/test_resource_flows.py b/tests_integration/test_resource_flows.py index 669d00d2..e5e36707 100644 --- a/tests_integration/test_resource_flows.py +++ b/tests_integration/test_resource_flows.py @@ -5,6 +5,7 @@ from kopf.testing import KopfRunner from tests_integration.utils import ( + assert_log_message_contains, assert_log_message_starts_with, kubectl_apply, kubectl_create, @@ -97,6 +98,125 @@ def test_resource_flows(kopf_settings, kopf_runner_args, unique_resource_name): # fmt: on +def test_kubernetes_resource_flows( + kopf_settings, kopf_runner_args, unique_resource_name +): + secret_name = "kubernetes-access-gateway-tls" # noqa: S105 + OBJ = f""" + apiVersion: twingate.com/v1beta + kind: TwingateResource + metadata: + name: {unique_resource_name} + spec: + name: My K8S Resource + address: kubernetes.default.svc.cluster.local + type: Kubernetes + proxy: + address: kubernetes-access-gateway.default.svc.cluster.local:443 + certificateAuthorityCert: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUZmekNDQTJlZ0F3SUJBZ0lWQUxvT0pBb1NQMW04MUJRM0RBalJIY1lYckxSOE1BMEdDU3FHU0liM0RRRUJDd1VBTUhjeEN6QUpCZ05WQkFZVEFsVlRNUXN3Q1FZRFZRUUlFd0pEVHpFUU1BNEdBMVVFQnhNSFFtOTFiR1JsY2pFU01CQUdBMVVFQ2hNSlNuVnRjRU5zYjNWa01Sa3dGd1lEVlFRTEV4QktkVzF3UTJ4dmRXUlRRVTFNU1dSUU1Sb3dHQVlEVlFRREV4RktkVzF3UTJ4dmRXUlRRVTFNVlhObGNqQWVGdzB5TVRFeE1qa3dNVEF5TVRSYUZ3MHlOakV4TWprd01UQXlNVFJhTUhjeEN6QUpCZ05WQkFZVEFsVlRNUXN3Q1FZRFZRUUlFd0pEVHpFUU1BNEdBMVVFQnhNSFFtOTFiR1JsY2pFU01CQUdBMVVFQ2hNSlNuVnRjRU5zYjNWa01Sa3dGd1lEVlFRTEV4QktkVzF3UTJ4dmRXUlRRVTFNU1dSUU1Sb3dHQVlEVlFRREV4RktkVzF3UTJ4dmRXUlRRVTFNVlhObGNqQ0NBaUl3RFFZSktvWklodmNOQVFFQkJRQURnZ0lQQURDQ0Fnb0NnZ0lCQUxjNktKT0czTm0wMnZIZnZvYVdrcjBzUjk0SE9Wd2lLNzlqZHhQNHNhQ2k1aEw3RmoyRW5FbXo3M0JIL0J4QkZRL3VIY1JqTU85dUxuNldSY1QyUDhXRE10eVV1QlNJVUw0bEx4b1RPbTAvMzdxcllZQUhmYllKdVBXQWJ2SXhuZTJOczBpWFlGa2dIU1o2RHVkWjM3U1NkWG5QQnVSNmNhZXltYm92ckNIUGJFVGIzU3BnY1ZNdXV1RzFYaENUTjBsWi94cnBCMUc4SHFMMzd4VkNtSkF6bUJtVWdZcHU5K3pIMXVCUHdVb1dhOFRIZWxYcnAyQ1VaM210d28wdUtuZnlYSmNKeUM1ckp2MFJMbzRvSlJldFUzbWlURjcvdHJjWE1oWEdzb3NNL1UvYTVzbjc5RWgzdngrQkpDRGRySnRlNXowV0NDUitGY0xZdEU5aXdlV3BJS2g5ODc0NnJVb1M0ck1IcFVhZTBOczZlU3BVK093SW1NdzZvVUNITzgreDFna2NWQkcydGZEMG12N1RJZFc1aWI2TTlMOVQ2M0wxNXFla2U5QVBQY3BHMHZHNUl4ZUdiQ2xSY2pFNHVzaVRnK2lLOCtBQ1Q3aDJodFNjU0dsUHNJM0RibG45RDRMWFJLTkhDY3lCY3BWT0hJMDZaMEQwaEs3eWNscGl1SUxTSGFUVENQbDM4eHdVTkZsSkRxWGpVdnpMeE0xc1d6ZWJ0NEl0M2c4ODZNa1M0bDB3WmdhWUh4bWNtQ2RsSnZ5UHFWOHR4Z1FaWUJZM2pUN0VqZ1BGb3g0a0xNVktBK2pBemY5c0hUaDd6UW5PZ1JFMzJyaGoyTlVBSzNoQmJIdjFhT2VVbGh4U0xEbGU3WDZsWEd4eEhDdkEzbDFOcG1vNUExT1poTUJGQWdNQkFBR2pBakFBTUEwR0NTcUdTSWIzRFFFQkN3VUFBNElDQVFDVUlvcDJUU1FKenNSaGd3T0dZa2JwQWJsU2prTlE1VEJaZnJyWm9GWU9NQTBqaTYycWxXRDNDNU9VYVdRYkJydkcvOEx2Q09YbTRtUG1wMWUwSmVsaTZEWkJJbjJVbzduZTI5VitpdHZnQi9kdTYrcGtySXIwZWdBYmtKZmtTK2YzbFFqZXBqRmFraVFxSzNZTEp0WEpVckt2d2pXa2RnVG1XcjhTMVA5TFg0ZkU0UmxyOWkrcGc2TlZzcFNEZXptREhnN2piZ2NxMXRLOGczcmFEcEFNNExreUdKSENTRTB0V21ORHc2UUtSYi9ldjZmQmR6MVVWVFhhV1pvQTIycldjZk1IMzVZd2NDUDVvWHBpa0lTaStKbUc2SG9qQnM0bGpwYlpGWWNSUnU5UC9pMG12cGRKUXRQUlJudk5DNXY1RXdQdWt0RTFXaTZxa3A2OE43aitRTGw4anlhWExuNkdIRTZDamdnRTRZQjh2ZXFjZUxhRERZdXR4UmpUNzdMaEVTeFdONlhSQnpoTWNPckhGcE5KUUkxVmxhbEFCVzJZanBKSVB2bytpV2xBWlp4MjBrMitHRkpWTml3ZTBYemR5cWwxZUdNeENrS3BkNXdCZXpKZUJVdXJNUSt0cWQrMWRHMTBmRUJMM2dpa0JHbFpMU1d1czNwRnhTaXdZemhTU29BcUs5ekY3VCtBNjc0cDdFUUI5Zms4VjRadFI2T28yMFI0TldPWDRWcnFzekZjWWFOSnJwS3VCOEZhRHZVcWNFMmFRK3ZrWVhmaTBGYWRMRm1jN1dlTWVQSU12a2ZpbnI5cUVnWWMreXE1WGEwV0hiM1hlKzF5N2wwVEt5dUtIZEJnSEdKVUJBYkFFeWF1NFVjSUJqbjFnWDJZTlEvTjFUUnZxSWJrY1E9PQotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0t + protocols: + allowIcmp: false + tcp: + policy: RESTRICTED + ports: + - end: 443 + start: 443 + udp: + policy: RESTRICTED + ports: [] + """ + + OBJ_UPDATED = f""" + apiVersion: twingate.com/v1beta + kind: TwingateResource + metadata: + name: {unique_resource_name} + spec: + name: My K8S Resource + address: kubernetes.default.svc.cluster.local + type: Kubernetes + proxy: + address: kubernetes-access-gateway.default.svc.cluster.local:443 + certificateAuthorityCertSecretRef: + name: {secret_name} + protocols: + allowIcmp: false + tcp: + policy: RESTRICTED + ports: + - end: 443 + start: 443 + udp: + policy: RESTRICTED + ports: [] + """ + + SECRET_OBJ = f""" + apiVersion: v1 + kind: Secret + metadata: + name: {secret_name} + namespace: default + type: kubernetes.io/tls + data: + ca.crt: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSURKakNDQWc2Z0F3SUJBZ0lRQ3VzQW56OWxEcGcxY3d5TmhlU045VEFOQmdrcWhraUc5dzBCQVFzRkFEQXQKTVNzd0tRWURWUVFERXlKc2IyTmhiQzFyZFdKbGNtNWxkR1Z6TFdGalkyVnpjeTFuWVhSbGQyRjVMV05oTUI0WApEVEkxTVRBd09ERTFNekkwTjFvWERUSTJNREV3TmpFMU16STBOMW93TFRFck1Da0dBMVVFQXhNaWJHOWpZV3d0CmEzVmlaWEp1WlhSbGN5MWhZMk5sYzNNdFoyRjBaWGRoZVMxallUQ0NBU0l3RFFZSktvWklodmNOQVFFQkJRQUQKZ2dFUEFEQ0NBUW9DZ2dFQkFPdlNGUUdNejZtRnhYMFVDcXNkTWZMMUthUHUrR1Jpa0xkRDJMaUM4N1dpK3V3dQpyOXFpK1I3MU53VFd4cWFSeHZlcE5zVzBhZEYrdjhnd0c3Nm5KanU2S3dvNVV3M3EwSWg3WWp4cXFsN0taeGJlCkNMM0JYSzhtdW9Kbk5yUmt6MzJDTFNYajZUUXNZclNGcUZabW5OSS9ma2hRT3ZoWG85SldtaGxuYXY2WCtSRGUKYWdqc29Ed2VkV2J2eXZuZHpUd1ZodVJCR0VDelhFU0dSQXkyR1VrNXoxeTY1ZjNNUDdOVit1MFowdk53MEtSawpRcmNTVDA1V0t5RWZYZUpDOHM1czZZVm9zZE1xRnRzZ1drTzg0N01OR3ZYc01yY3RTN1hNUkdNeTRwVUl6VEI1CnRyK3JhNENkZTYwZFpNNHNJODMvVmh6bnU5enhidUFGTGRVNkdGTUNBd0VBQWFOQ01FQXdEZ1lEVlIwUEFRSC8KQkFRREFnS2tNQThHQTFVZEV3RUIvd1FGTUFNQkFmOHdIUVlEVlIwT0JCWUVGSWVsd3dsWGNnUy9rakZZY2hqQwpZWU1zNFFDb01BMEdDU3FHU0liM0RRRUJDd1VBQTRJQkFRQUk2V3JiUzA2TEdxRDl4VDdTbnhYZjl6YlQrRGhqCjZDaFhOb3NSOEJKNnhEL3YwU3NEV3ZuNkdIeFY0ZEd6YXJwVTdROUpLM0d2NlJhcmJ6M2orU2syN2I2MURmNmsKRGM1QUQ3N1hRSVovOTExUm4rcFk3c3lGaG91dVpjdFNJQXRLOTVhVnNGeTNuWkk3UFU2c01sWjNPRG5iWEpORgpMQkYwemYxYVIrdTk4Y2ZFWEIxWFJneWVJajNTdUNiQVZSNjFZY0h5NEZTNmdRMzhkR2FkalFnNlN4QWZyUlpaCkR5dEoyL3YzdmJCNFFiYVdZOHNOTDBxRVpjUGQ1eHZVTldQOVZibnZlVW1OZXBhbllabXJGV3dGMlE3V1dnZWgKVUp6dDV2ZzFENVRUcnE0eDZ1aUVML3lDZXFjaU8vSFJISTdwRk13WnlFWTYySnNONE5CejkybWYKLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQo= + tls.crt: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUR1VENDQXFHZ0F3SUJBZ0lSQU16QkJzcHV0Smg2bHE5TklEOHhmUmt3RFFZSktvWklodmNOQVFFTEJRQXcKTFRFck1Da0dBMVVFQXhNaWJHOWpZV3d0YTNWaVpYSnVaWFJsY3kxaFkyTmxjM010WjJGMFpYZGhlUzFqWVRBZQpGdzB5TlRFd01Ea3dOVEUwTXpsYUZ3MHlOVEV3TURrd05qRTBNemxhTUVJeEVUQVBCZ05WQkFvVENGUjNhVzVuCllYUmxNUzB3S3dZRFZRUURFeVJyZFdKbGNtNWxkR1Z6TG1SbFptRjFiSFF1YzNaakxtTnNkWE4wWlhJdWJHOWoKWVd3d2dnRWlNQTBHQ1NxR1NJYjNEUUVCQVFVQUE0SUJEd0F3Z2dFS0FvSUJBUURlTFUxVjhtODV4eStTTlY1WApMa3M0N1hvNnZXOTlvaVBPcTJtS0dScDZhUlFkcVl1U3lDdmlSS1NjRkFlSElwY2lqY1diR0NFWUZ4Ull0VjNsCnplZHM2SEkxUzUvcGYwbG82b2k5dDRCaWFId0svbjdYZW96QitlT1JRSmgzM2Z3NnBGby9Nenc4WndUcXBPMjEKcFo0U1RyaHlnbzZZaENHR0l2Y3FlSE9TcUtkTHZMRzltSktCdFJVcUZpRmZ1T2orcHRKYmhiWmVaRldKdzVCYgpKWEpWZit6M29CeUF2NExjRkhPdGJjbTVpUmM2TTZyT1JNN1BSczFuOWpwTmxZOHdPODdmRGRWK282eEJmemxZCjFKYzZsdmxIRDdib0NwVWFidFpSaGdnem5NYitnTzJQZ1l3MnhLR1BCbXYrRVBEUkEwKy91VU45djIrT0FJUS8KS0dpbEFnTUJBQUdqZ2I0d2dic3dEZ1lEVlIwUEFRSC9CQVFEQWdXZ01Bd0dBMVVkRXdFQi93UUNNQUF3SHdZRApWUjBqQkJnd0ZvQVVoNlhEQ1ZkeUJMK1NNVmh5R01KaGd5emhBS2d3ZWdZRFZSMFJCSE13Y1lJS2EzVmlaWEp1ClpYUmxjNElTYTNWaVpYSnVaWFJsY3k1a1pXWmhkV3gwZ2hacmRXSmxjbTVsZEdWekxtUmxabUYxYkhRdWMzWmoKZ2lScmRXSmxjbTVsZEdWekxtUmxabUYxYkhRdWMzWmpMbU5zZFhOMFpYSXViRzlqWVd5Q0VXeHZZMkZzTFdOcwpkWE4wWlhJdWFXNTBNQTBHQ1NxR1NJYjNEUUVCQ3dVQUE0SUJBUUJrOFhuUkQ5QURHOGxqK09WOURiWGtWbFBaClYwVFY4TFRBR1FhbnhpVGQ5VFFDOTNmRVZFSkJXV2RPNGNBdENVc0p3UjFSN0Rqam1nNGs5UmRHc0g5ZXlwVTQKSmRRSlV6QzlYTXJDNTQ1ZXNaVkh2V1ZhaWZUWWwwTyt4cHlJM0ZhTWQyZloxT1Vydk5mMmVFckdsTk80ZzRwOQpMTGlMaDR6eThpVVZmbktkaFRSTUlVNHcvSUlHSTV1S0ZKTHZKZzkwRDdXMnVSNkl6Sm5QRCt4ZFRkT3JkdnlrCmlFbncwc2lTYnlxaWVZSk40MjBFOVJBRDZRMTRiQ1gwUi85amRWNit6N2wvRGZicVp5TkFlVEROMkxlQ2Vwc0oKYlZ1R2RiQmEybkpobDBPVzNVNEdVVS8xMnBXUitBUkRXaUVoMEJLOEZySkVPcjh0dmd4eWxkZ3BWWWc4Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K + tls.key: LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlFcEFJQkFBS0NBUUVBM2kxTlZmSnZPY2N2a2pWZVZ5NUxPTzE2T3IxdmZhSWp6cXRwaWhrYWVta1VIYW1MCmtzZ3I0a1NrbkJRSGh5S1hJbzNGbXhnaEdCY1VXTFZkNWMzbmJPaHlOVXVmNlg5SmFPcUl2YmVBWW1oOEN2NSsKMTNxTXdmbmprVUNZZDkzOE9xUmFQek04UEdjRTZxVHR0YVdlRWs2NGNvS09tSVFoaGlMM0tuaHprcWluUzd5eAp2WmlTZ2JVVktoWWhYN2pvL3FiU1c0VzJYbVJWaWNPUVd5VnlWWC9zOTZBY2dMK0MzQlJ6clczSnVZa1hPak9xCnprVE96MGJOWi9ZNlRaV1BNRHZPM3czVmZxT3NRWDg1V05TWE9wYjVSdysyNkFxVkdtN1dVWVlJTTV6Ry9vRHQKajRHTU5zU2hqd1pyL2hEdzBRTlB2N2xEZmI5dmpnQ0VQeWhvcFFJREFRQUJBb0lCQUNEYldrQ0hwZU5KamNOMQptUW9Ua3BSTXFuTGRhUXVQV3ZSSmJVWTdDQ3RxTnN0Y000UDFqbWZiOXV3T0dqN2w0cXY5ZzJlNFhjeU9QVGdSCk9sMnQ0YmU5ZUlaaE5MajNWZ2ZxQjJibktGbGxVbExkNkN3OXQydElaVnNwem1LTHRhMkdlTUkzOVlTSlI3VGIKeHp2QnptcXVzYUJkcG5EdnVYVjQza3l0bTRub21LcXp4aW12NXNIcE5yakJiWWExeVFLUjNPR1VUb3JsdSttdQp3cW03enRraDdXbFBoNENFcVFibytSZmJ6dUNKd2pWMXRiSDhpQTFmdDRkckd0dC9rRmhnKytZa3BmSi9sb1hXCkxodlVwdTdDL0Z3enVpWFA5V2VpMkVjbnd3b3lPNFVBcTk5VWFNN1BFUWlzZG5QQzNUZ0tlN2pEeE1NaTVZZXQKNEpubS9kRUNnWUVBNzlNR3VSVDR3VVlpdmhLYXRWdFdhWkNtQjZSckRia1hITGl4Y2lPV3MxZTVtN201aU1VVQphZWlMaGU3bGFZNm1qRTF1UGVLbEh6OVB3bkUxTWM2a1lZdmc1QzYvZUpsaE0wbGMraU9kWUlHN3hCVzFRWkY0CjU5WkFEVWQxeVFva0lnQlBJNDJyRjQ1RHhFa1lOWU81ZmFBdW5ZYmQ0Ni9ZSVY4M1BtUEdRbWNDZ1lFQTdTbVEKY1BYTmpjY0pMelZGeTRaYmlEQ0t5bW03NGorTXBWYkNJUE56ZU0vbWVZUWl4MHFDS3lLYWtxWC9SUnhrdmQ3dQp3akQ2ejd5T29mZHNqaTl0SFY5dSs5UGxxRC9lMEU3QWFhQ3kzSjFWNVR2cW5Ud1prWmg4K0wzOXpnOXlYWmRJClJUTXJ0d2VZYmV1cGo0bldocWozcklsZy9JU3h1SjAxNE1CenpSTUNnWUJRMjRWWXdZbGRJSmgySFMrc0ZhOTgKeUJneVcyejhvM3IzWkEzdnZiQUJwNEljenZHTysyTjJrY0Q0MXlMaUJBYURKMWdUNVdabXNxSGhuT21pY1ZsYQp5aDU0MElvZHp4akdnZVduTUhyUEh1NS9uaElPbVUxNlhQSWJpQXhlUzkwQzJiZlU5TjdLZ2x5MndTNDRYTUVkCmFmUk5pRHNubVJIMXJuU2h4R0lENFFLQmdRREF6NWJuejE3alVock1iNUlqeWtMbU1SalZRU3NINE1TV3N6YzIKbE5hZk5ON2FraXU0UElJaFVZdTdpQXRHQTdSL2pSd3RjcWFtZDFTNnB5NXhWbXR1Z3VUM0JhbmpwTEdnUnpZMQphZm1nVktXOXJYMnJnVzRFS2FZSWtHWWt2ZmdyME05bnV4ZGlRV0dTbEJLUmFPMnBJdnZoSVB0aHNQdlA3TGdkCjFqa1BVd0tCZ1FESVNRZGlUWW1HTkdaS2dSa2hoOFZndm1WZUJVdy82bEJzNnVzMEMzM2NkYWpJbGtYZ3lEOEMKZHJ0a2QvUXdBeDBCcmliZWxCUzl2VVhNOFZIUlJwZzFqVy9yTHpKTzhBc2g0Q1ZCSG55eXFnb2hwdC9iV0RXQQo4bkhRdHViY2JpOGE3OFhuM3krOXRKVnMyWllGYjNzOWRGNCt2SHYveDk4c0dSRityOTg3MWc9PQotLS0tLUVORCBSU0EgUFJJVkFURSBLRVktLS0tLQo= + """ + + CA_CERT = r"-----BEGIN CERTIFICATE-----\nMIIDJjCCAg6gAwIBAgIQCusAnz9lDpg1cwyNheSN9TANBgkqhkiG9w0BAQsFADAt\nMSswKQYDVQQDEyJsb2NhbC1rdWJlcm5ldGVzLWFjY2Vzcy1nYXRld2F5LWNhMB4X\nDTI1MTAwODE1MzI0N1oXDTI2MDEwNjE1MzI0N1owLTErMCkGA1UEAxMibG9jYWwt\na3ViZXJuZXRlcy1hY2Nlc3MtZ2F0ZXdheS1jYTCCASIwDQYJKoZIhvcNAQEBBQAD\nggEPADCCAQoCggEBAOvSFQGMz6mFxX0UCqsdMfL1KaPu+GRikLdD2LiC87Wi+uwu\nr9qi+R71NwTWxqaRxvepNsW0adF+v8gwG76nJju6Kwo5Uw3q0Ih7Yjxqql7KZxbe\nCL3BXK8muoJnNrRkz32CLSXj6TQsYrSFqFZmnNI/fkhQOvhXo9JWmhlnav6X+RDe\nagjsoDwedWbvyvndzTwVhuRBGECzXESGRAy2GUk5z1y65f3MP7NV+u0Z0vNw0KRk\nQrcST05WKyEfXeJC8s5s6YVosdMqFtsgWkO847MNGvXsMrctS7XMRGMy4pUIzTB5\ntr+ra4Cde60dZM4sI83/Vhznu9zxbuAFLdU6GFMCAwEAAaNCMEAwDgYDVR0PAQH/\nBAQDAgKkMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFIelwwlXcgS/kjFYchjC\nYYMs4QCoMA0GCSqGSIb3DQEBCwUAA4IBAQAI6WrbS06LGqD9xT7SnxXf9zbT+Dhj\n6ChXNosR8BJ6xD/v0SsDWvn6GHxV4dGzarpU7Q9JK3Gv6Rarbz3j+Sk27b61Df6k\nDc5AD77XQIZ/911Rn+pY7syFhouuZctSIAtK95aVsFy3nZI7PU6sMlZ3ODnbXJNF\nLBF0zf1aR+u98cfEXB1XRgyeIj3SuCbAVR61YcHy4FS6gQ38dGadjQg6SxAfrRZZ\nDytJ2/v3vbB4QbaWY8sNL0qEZcPd5xvUNWP9VbnveUmNepanYZmrFWwF2Q7WWgeh\nUJzt5vg1D5TTrq4x6uiEL/yCeqciO/HRHI7pFMwZyEY62JsN4NBz92mf\n-----END CERTIFICATE-----" + + # fmt: off + with KopfRunner(kopf_runner_args, settings=kopf_settings) as runner: + kubectl_create(SECRET_OBJ) + kubectl_wait_to_exist("Secret", secret_name) + + kubectl_apply(OBJ) + created_object = kubectl_wait_object_handler_success("tgr", unique_resource_name, "twingate_resource_create") + + # Update the CA cert + kubectl_apply(OBJ_UPDATED) + updated_object = kubectl_wait_object_handler_success("tgr", unique_resource_name, "twingate_resource_update") + assert "certificateAuthorityCertSecretRef" in updated_object["spec"]["proxy"] + assert updated_object["spec"]["proxy"]["certificateAuthorityCertSecretRef"]["name"] == secret_name + + kubectl_delete_wait("tgr", unique_resource_name) + kubectl_delete_wait("Secret", secret_name) + + # fmt: on + + # Ensure that the operator did not die on start, or during the operation. + assert runner.exception is None + assert runner.exit_code == 0 + + logs = load_stdout(runner.stdout) + + # fmt: off + + assert "twingate_resource_create" in created_object["status"], f"status not updated: {created_object['status']}" + + twingate_id = created_object["status"]["twingate_resource_create"]["twingate_id"] + + expected_object = {"apiVersion": "twingate.com/v1beta", "kind": "TwingateResource", "name": unique_resource_name, "uid": ANY, "namespace": "default"} + + # Create + assert {"message": "Handler 'twingate_resource_create' succeeded.", "timestamp": ANY, "taskName": ANY, "object": expected_object, "severity": "info"} in logs + assert twingate_id + + # Update + assert {"message": f"Updating resource {twingate_id}", "timestamp": ANY, "taskName": ANY, "object": expected_object, "severity": "info"} in logs + assert_log_message_contains(logs, CA_CERT) + + # Delete + assert {"message": "Twingate API Result: {'resourceDelete': {'ok': True, 'error': None}}", "timestamp": ANY, "taskName": ANY, "object": expected_object, "severity": "info"} in logs + assert {"message": "Handler 'twingate_resource_delete' succeeded.", "timestamp": ANY, "taskName": ANY, "object": expected_object, "severity": "info"} in logs + + # Shutdown + assert {"message": "Activity 'shutdown' succeeded.", "timestamp": ANY, "taskName": ANY, "severity": "info"} in logs + + # fmt: on + + def test_resource_created_before_operator_runs(run_kopf, unique_resource_name): OBJ = f""" apiVersion: twingate.com/v1beta diff --git a/tests_integration/utils.py b/tests_integration/utils.py index 231f0298..80873884 100644 --- a/tests_integration/utils.py +++ b/tests_integration/utils.py @@ -19,6 +19,12 @@ def assert_log_message_starts_with(logs, message): ) +def assert_log_message_contains(logs, message): + assert any(message in log["message"] for log in logs), ( + f"Could not find log message containing '{message}'" + ) + + def kubectl(command: str, input: str | None = None) -> subprocess.CompletedProcess: return subprocess.run( f"{KUBECTL_COMMAND} {command}", From fe84bfc572d109c4f3ccac074381f5de178f5b91 Mon Sep 17 00:00:00 2001 From: Clement Tee Date: Thu, 9 Oct 2025 17:46:38 +0800 Subject: [PATCH 3/9] Update test --- tests_integration/test_resource_flows.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests_integration/test_resource_flows.py b/tests_integration/test_resource_flows.py index e5e36707..99263cb6 100644 --- a/tests_integration/test_resource_flows.py +++ b/tests_integration/test_resource_flows.py @@ -164,6 +164,7 @@ def test_kubernetes_resource_flows( tls.key: LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlFcEFJQkFBS0NBUUVBM2kxTlZmSnZPY2N2a2pWZVZ5NUxPTzE2T3IxdmZhSWp6cXRwaWhrYWVta1VIYW1MCmtzZ3I0a1NrbkJRSGh5S1hJbzNGbXhnaEdCY1VXTFZkNWMzbmJPaHlOVXVmNlg5SmFPcUl2YmVBWW1oOEN2NSsKMTNxTXdmbmprVUNZZDkzOE9xUmFQek04UEdjRTZxVHR0YVdlRWs2NGNvS09tSVFoaGlMM0tuaHprcWluUzd5eAp2WmlTZ2JVVktoWWhYN2pvL3FiU1c0VzJYbVJWaWNPUVd5VnlWWC9zOTZBY2dMK0MzQlJ6clczSnVZa1hPak9xCnprVE96MGJOWi9ZNlRaV1BNRHZPM3czVmZxT3NRWDg1V05TWE9wYjVSdysyNkFxVkdtN1dVWVlJTTV6Ry9vRHQKajRHTU5zU2hqd1pyL2hEdzBRTlB2N2xEZmI5dmpnQ0VQeWhvcFFJREFRQUJBb0lCQUNEYldrQ0hwZU5KamNOMQptUW9Ua3BSTXFuTGRhUXVQV3ZSSmJVWTdDQ3RxTnN0Y000UDFqbWZiOXV3T0dqN2w0cXY5ZzJlNFhjeU9QVGdSCk9sMnQ0YmU5ZUlaaE5MajNWZ2ZxQjJibktGbGxVbExkNkN3OXQydElaVnNwem1LTHRhMkdlTUkzOVlTSlI3VGIKeHp2QnptcXVzYUJkcG5EdnVYVjQza3l0bTRub21LcXp4aW12NXNIcE5yakJiWWExeVFLUjNPR1VUb3JsdSttdQp3cW03enRraDdXbFBoNENFcVFibytSZmJ6dUNKd2pWMXRiSDhpQTFmdDRkckd0dC9rRmhnKytZa3BmSi9sb1hXCkxodlVwdTdDL0Z3enVpWFA5V2VpMkVjbnd3b3lPNFVBcTk5VWFNN1BFUWlzZG5QQzNUZ0tlN2pEeE1NaTVZZXQKNEpubS9kRUNnWUVBNzlNR3VSVDR3VVlpdmhLYXRWdFdhWkNtQjZSckRia1hITGl4Y2lPV3MxZTVtN201aU1VVQphZWlMaGU3bGFZNm1qRTF1UGVLbEh6OVB3bkUxTWM2a1lZdmc1QzYvZUpsaE0wbGMraU9kWUlHN3hCVzFRWkY0CjU5WkFEVWQxeVFva0lnQlBJNDJyRjQ1RHhFa1lOWU81ZmFBdW5ZYmQ0Ni9ZSVY4M1BtUEdRbWNDZ1lFQTdTbVEKY1BYTmpjY0pMelZGeTRaYmlEQ0t5bW03NGorTXBWYkNJUE56ZU0vbWVZUWl4MHFDS3lLYWtxWC9SUnhrdmQ3dQp3akQ2ejd5T29mZHNqaTl0SFY5dSs5UGxxRC9lMEU3QWFhQ3kzSjFWNVR2cW5Ud1prWmg4K0wzOXpnOXlYWmRJClJUTXJ0d2VZYmV1cGo0bldocWozcklsZy9JU3h1SjAxNE1CenpSTUNnWUJRMjRWWXdZbGRJSmgySFMrc0ZhOTgKeUJneVcyejhvM3IzWkEzdnZiQUJwNEljenZHTysyTjJrY0Q0MXlMaUJBYURKMWdUNVdabXNxSGhuT21pY1ZsYQp5aDU0MElvZHp4akdnZVduTUhyUEh1NS9uaElPbVUxNlhQSWJpQXhlUzkwQzJiZlU5TjdLZ2x5MndTNDRYTUVkCmFmUk5pRHNubVJIMXJuU2h4R0lENFFLQmdRREF6NWJuejE3alVock1iNUlqeWtMbU1SalZRU3NINE1TV3N6YzIKbE5hZk5ON2FraXU0UElJaFVZdTdpQXRHQTdSL2pSd3RjcWFtZDFTNnB5NXhWbXR1Z3VUM0JhbmpwTEdnUnpZMQphZm1nVktXOXJYMnJnVzRFS2FZSWtHWWt2ZmdyME05bnV4ZGlRV0dTbEJLUmFPMnBJdnZoSVB0aHNQdlA3TGdkCjFqa1BVd0tCZ1FESVNRZGlUWW1HTkdaS2dSa2hoOFZndm1WZUJVdy82bEJzNnVzMEMzM2NkYWpJbGtYZ3lEOEMKZHJ0a2QvUXdBeDBCcmliZWxCUzl2VVhNOFZIUlJwZzFqVy9yTHpKTzhBc2g0Q1ZCSG55eXFnb2hwdC9iV0RXQQo4bkhRdHViY2JpOGE3OFhuM3krOXRKVnMyWllGYjNzOWRGNCt2SHYveDk4c0dSRityOTg3MWc9PQotLS0tLUVORCBSU0EgUFJJVkFURSBLRVktLS0tLQo= """ + # The CA cert stored in the K8s TLS secret object above CA_CERT = r"-----BEGIN CERTIFICATE-----\nMIIDJjCCAg6gAwIBAgIQCusAnz9lDpg1cwyNheSN9TANBgkqhkiG9w0BAQsFADAt\nMSswKQYDVQQDEyJsb2NhbC1rdWJlcm5ldGVzLWFjY2Vzcy1nYXRld2F5LWNhMB4X\nDTI1MTAwODE1MzI0N1oXDTI2MDEwNjE1MzI0N1owLTErMCkGA1UEAxMibG9jYWwt\na3ViZXJuZXRlcy1hY2Nlc3MtZ2F0ZXdheS1jYTCCASIwDQYJKoZIhvcNAQEBBQAD\nggEPADCCAQoCggEBAOvSFQGMz6mFxX0UCqsdMfL1KaPu+GRikLdD2LiC87Wi+uwu\nr9qi+R71NwTWxqaRxvepNsW0adF+v8gwG76nJju6Kwo5Uw3q0Ih7Yjxqql7KZxbe\nCL3BXK8muoJnNrRkz32CLSXj6TQsYrSFqFZmnNI/fkhQOvhXo9JWmhlnav6X+RDe\nagjsoDwedWbvyvndzTwVhuRBGECzXESGRAy2GUk5z1y65f3MP7NV+u0Z0vNw0KRk\nQrcST05WKyEfXeJC8s5s6YVosdMqFtsgWkO847MNGvXsMrctS7XMRGMy4pUIzTB5\ntr+ra4Cde60dZM4sI83/Vhznu9zxbuAFLdU6GFMCAwEAAaNCMEAwDgYDVR0PAQH/\nBAQDAgKkMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFIelwwlXcgS/kjFYchjC\nYYMs4QCoMA0GCSqGSIb3DQEBCwUAA4IBAQAI6WrbS06LGqD9xT7SnxXf9zbT+Dhj\n6ChXNosR8BJ6xD/v0SsDWvn6GHxV4dGzarpU7Q9JK3Gv6Rarbz3j+Sk27b61Df6k\nDc5AD77XQIZ/911Rn+pY7syFhouuZctSIAtK95aVsFy3nZI7PU6sMlZ3ODnbXJNF\nLBF0zf1aR+u98cfEXB1XRgyeIj3SuCbAVR61YcHy4FS6gQ38dGadjQg6SxAfrRZZ\nDytJ2/v3vbB4QbaWY8sNL0qEZcPd5xvUNWP9VbnveUmNepanYZmrFWwF2Q7WWgeh\nUJzt5vg1D5TTrq4x6uiEL/yCeqciO/HRHI7pFMwZyEY62JsN4NBz92mf\n-----END CERTIFICATE-----" # fmt: off From 9ea0ddb631bfcf5a3af2d33c2dccfb2b40eadba9 Mon Sep 17 00:00:00 2001 From: Clement Tee Date: Fri, 10 Oct 2025 10:53:51 +0800 Subject: [PATCH 4/9] Update `k8s_get_tls_secret` to `k8s_get_secret` --- app/crds.py | 4 ++-- app/handlers/handlers_services.py | 4 ++-- app/tests/test_utils_k8s.py | 6 +++--- app/utils_k8s.py | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/app/crds.py b/app/crds.py index 18e97490..4268e801 100644 --- a/app/crds.py +++ b/app/crds.py @@ -23,7 +23,7 @@ from semantic_version import NpmSpec from app.settings import get_settings -from app.utils_k8s import get_ca_cert, k8s_get_tls_secret +from app.utils_k8s import get_ca_cert, k8s_get_secret from app.version_policy_providers import get_provider K8sObject = MutableMapping[Any, Any] @@ -131,7 +131,7 @@ class ResourceProxy(BaseModel): def get_certificate_authority_cert(self) -> str | None: if secret_ref := self.certificate_authority_cert_secret_ref: - tls_secret = k8s_get_tls_secret(secret_ref.namespace, secret_ref.name) + tls_secret = k8s_get_secret(secret_ref.namespace, secret_ref.name) if not tls_secret: return None diff --git a/app/handlers/handlers_services.py b/app/handlers/handlers_services.py index 1aff88ba..ea7892e4 100644 --- a/app/handlers/handlers_services.py +++ b/app/handlers/handlers_services.py @@ -8,7 +8,7 @@ from app.crds import ResourceType from app.utils import to_bool -from app.utils_k8s import get_ca_cert, k8s_get_tls_secret +from app.utils_k8s import get_ca_cert, k8s_get_secret def k8s_get_twingate_resource( @@ -93,7 +93,7 @@ def service_to_twingate_resource(service_body: Body, namespace: str) -> dict: f"{TLS_OBJECT_ANNOTATION} annotation is not provided." ) - if not (tls_secret := k8s_get_tls_secret(namespace, tls_secret_name)): + if not (tls_secret := k8s_get_secret(namespace, tls_secret_name)): raise kopf.PermanentError( f"Kubernetes Secret object: {tls_secret_name} is missing." ) diff --git a/app/tests/test_utils_k8s.py b/app/tests/test_utils_k8s.py index 9d700fc1..d1f37947 100644 --- a/app/tests/test_utils_k8s.py +++ b/app/tests/test_utils_k8s.py @@ -6,7 +6,7 @@ from app.utils_k8s import ( get_ca_cert, k8s_delete_pod, - k8s_get_tls_secret, + k8s_get_secret, k8s_read_namespaced_deployment, k8s_read_namespaced_pod, ) @@ -67,14 +67,14 @@ def test_handles_404_returns_none(self, k8s_core_client_mock): k8s_core_client_mock.read_namespaced_secret.side_effect = ( kubernetes.client.exceptions.ApiException(status=404) ) - assert k8s_get_tls_secret("default", "test") is None + assert k8s_get_secret("default", "test") is None def test_reraises_non_404_exceptions(self, k8s_core_client_mock): k8s_core_client_mock.read_namespaced_secret.side_effect = ( kubernetes.client.exceptions.ApiException(status=500) ) with pytest.raises(kubernetes.client.exceptions.ApiException): - k8s_get_tls_secret("default", "test") + k8s_get_secret("default", "test") class TestGetCACert: diff --git a/app/utils_k8s.py b/app/utils_k8s.py index 242e6039..24b0db3f 100644 --- a/app/utils_k8s.py +++ b/app/utils_k8s.py @@ -47,7 +47,7 @@ def k8s_read_namespaced_deployment( raise -def k8s_get_tls_secret(namespace: str, name: str) -> kubernetes.client.V1Secret | None: +def k8s_get_secret(namespace: str, name: str) -> kubernetes.client.V1Secret | None: try: return kubernetes.client.CoreV1Api().read_namespaced_secret( name=name, namespace=namespace From 66ae0e4e887064c400245cf81a93870e76fff637 Mon Sep 17 00:00:00 2001 From: Clement Tee Date: Fri, 10 Oct 2025 14:25:59 +0800 Subject: [PATCH 5/9] Remove tls in secret name variable --- app/conftest.py | 2 +- app/handlers/tests/test_handlers_services.py | 10 ++++---- app/tests/test_crds_resource.py | 6 ++--- app/tests/test_utils_k8s.py | 25 +++++++------------- app/utils_k8s.py | 11 +++------ 5 files changed, 20 insertions(+), 34 deletions(-) diff --git a/app/conftest.py b/app/conftest.py index da6156e0..6e27212e 100644 --- a/app/conftest.py +++ b/app/conftest.py @@ -44,7 +44,7 @@ def k8s_apps_client_mock(): @pytest.fixture -def k8s_tls_secret_mock(): +def k8s_secret_mock(): return kubernetes.client.V1Secret( type="kubernetes.io/tls", metadata=kubernetes.client.V1ObjectMeta(name="gateway-tls"), diff --git a/app/handlers/tests/test_handlers_services.py b/app/handlers/tests/test_handlers_services.py index 46b3c628..ca2d5979 100644 --- a/app/handlers/tests/test_handlers_services.py +++ b/app/handlers/tests/test_handlers_services.py @@ -176,11 +176,11 @@ def test_kubernetes_resource_type_annotation( self, example_cluster_ip_gateway_service_body, k8s_core_client_mock, - k8s_tls_secret_mock, + k8s_secret_mock, ): tls_object_name = "gateway-tls" namespace = "custom-namespace" - k8s_core_client_mock.read_namespaced_secret.return_value = k8s_tls_secret_mock + k8s_core_client_mock.read_namespaced_secret.return_value = k8s_secret_mock with patch( "app.handlers.handlers_services.get_ca_cert", wraps=get_ca_cert @@ -189,7 +189,7 @@ def test_kubernetes_resource_type_annotation( example_cluster_ip_gateway_service_body, namespace ) - get_ca_cert_mock.assert_called_once_with(k8s_tls_secret_mock) + get_ca_cert_mock.assert_called_once_with(k8s_secret_mock) k8s_core_client_mock.read_namespaced_secret.assert_called_once_with( namespace=namespace, name=tls_object_name ) @@ -258,13 +258,13 @@ def test_kubernetes_resource_with_load_balancer_service_type( self, example_load_balancer_gateway_service_body, k8s_core_client_mock, - k8s_tls_secret_mock, + k8s_secret_mock, status, expected, ): tls_object_name = "gateway-tls" namespace = "default" - k8s_core_client_mock.read_namespaced_secret.return_value = k8s_tls_secret_mock + k8s_core_client_mock.read_namespaced_secret.return_value = k8s_secret_mock with patch( "kopf._cogs.structs.bodies.Body.status", diff --git a/app/tests/test_crds_resource.py b/app/tests/test_crds_resource.py index f9de6693..2e3c5ed2 100644 --- a/app/tests/test_crds_resource.py +++ b/app/tests/test_crds_resource.py @@ -295,18 +295,18 @@ def test_resource_proxy_get_certificate_authority_cert_without_secret_ref(): def test_resource_proxy_get_certificate_authority_cert_with_secret_ref( - k8s_core_client_mock, k8s_tls_secret_mock + k8s_core_client_mock, k8s_secret_mock ): proxy = ResourceProxy( address="proxy.default.cluster.local", certificate_authority_cert_secret_ref=_KubernetesObjectRef(name="gateway-tls"), certificate_authority_cert=None, ) - k8s_core_client_mock.read_namespaced_secret.return_value = k8s_tls_secret_mock + k8s_core_client_mock.read_namespaced_secret.return_value = k8s_secret_mock with patch("app.crds.get_ca_cert", wraps=get_ca_cert) as get_ca_cert_mock: assert proxy.get_certificate_authority_cert() == VALID_CA_CERT - get_ca_cert_mock.assert_called_once_with(k8s_tls_secret_mock) + get_ca_cert_mock.assert_called_once_with(k8s_secret_mock) def test_network_resource_spec_to_graphql_arguments(sample_network_resource_object): diff --git a/app/tests/test_utils_k8s.py b/app/tests/test_utils_k8s.py index d1f37947..2e2edaf8 100644 --- a/app/tests/test_utils_k8s.py +++ b/app/tests/test_utils_k8s.py @@ -78,29 +78,20 @@ def test_reraises_non_404_exceptions(self, k8s_core_client_mock): class TestGetCACert: - def test_get_ca_cert(self, k8s_tls_secret_mock): - assert get_ca_cert(k8s_tls_secret_mock) == BASE64_OF_VALID_CA_CERT + def test_get_ca_cert(self, k8s_secret_mock): + assert get_ca_cert(k8s_secret_mock) == BASE64_OF_VALID_CA_CERT - def test_get_ca_cert_with_invalid_secret_type(self, k8s_tls_secret_mock): - k8s_tls_secret_mock.type = "kubernetes.io/token" - - with pytest.raises( - kopf.PermanentError, - match=r"Kubernetes Secret object: gateway-tls type is invalid.", - ): - get_ca_cert(k8s_tls_secret_mock) - - def test_get_ca_cert_with_missing_ca_cert(self, k8s_tls_secret_mock): - k8s_tls_secret_mock.data = {} + def test_get_ca_cert_with_missing_ca_cert(self, k8s_secret_mock): + k8s_secret_mock.data = {} with pytest.raises( kopf.PermanentError, match=r"Kubernetes Secret object: gateway-tls is missing ca.crt.", ): - get_ca_cert(k8s_tls_secret_mock) + get_ca_cert(k8s_secret_mock) - def test_get_ca_cert_with_invalid_ca_cert(self, k8s_tls_secret_mock): - k8s_tls_secret_mock.data["ca.crt"] = ( + def test_get_ca_cert_with_invalid_ca_cert(self, k8s_secret_mock): + k8s_secret_mock.data["ca.crt"] = ( "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tIE1JSUZmakNDQTJhZ0F3SUJBZ0lVQk50IC0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0=" ) @@ -108,4 +99,4 @@ def test_get_ca_cert_with_invalid_ca_cert(self, k8s_tls_secret_mock): kopf.PermanentError, match=r"Kubernetes Secret object: gateway-tls ca.crt is invalid.", ): - get_ca_cert(k8s_tls_secret_mock) + get_ca_cert(k8s_secret_mock) diff --git a/app/utils_k8s.py b/app/utils_k8s.py index 24b0db3f..296dee00 100644 --- a/app/utils_k8s.py +++ b/app/utils_k8s.py @@ -60,22 +60,17 @@ def k8s_get_secret(namespace: str, name: str) -> kubernetes.client.V1Secret | No def get_ca_cert(tls_secret: kubernetes.client.V1Secret) -> str: - tls_secret_name = tls_secret.metadata.name - if tls_secret.type != "kubernetes.io/tls": - raise kopf.PermanentError( - f"Kubernetes Secret object: {tls_secret_name} type is invalid." - ) - + secret_name = tls_secret.metadata.name if not (ca_cert := tls_secret.data.get("ca.crt")): raise kopf.PermanentError( - f"Kubernetes Secret object: {tls_secret_name} is missing ca.crt." + f"Kubernetes Secret object: {secret_name} is missing ca.crt." ) try: validate_pem_x509_certificate(base64.b64decode(ca_cert).decode()) except ValueError as ex: raise kopf.PermanentError( - f"Kubernetes Secret object: {tls_secret_name} ca.crt is invalid." + f"Kubernetes Secret object: {secret_name} ca.crt is invalid." ) from ex return ca_cert From cffd976feb8668e1285b73c1db9fb71affaf8b8e Mon Sep 17 00:00:00 2001 From: Clement Tee Date: Fri, 10 Oct 2025 15:31:49 +0800 Subject: [PATCH 6/9] Update `tls_secret_name` to `secret_name` --- app/crds.py | 6 +++--- app/handlers/handlers_services.py | 8 ++++---- app/utils_k8s.py | 6 +++--- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/app/crds.py b/app/crds.py index 4268e801..74960a0d 100644 --- a/app/crds.py +++ b/app/crds.py @@ -131,11 +131,11 @@ class ResourceProxy(BaseModel): def get_certificate_authority_cert(self) -> str | None: if secret_ref := self.certificate_authority_cert_secret_ref: - tls_secret = k8s_get_secret(secret_ref.namespace, secret_ref.name) - if not tls_secret: + secret = k8s_get_secret(secret_ref.namespace, secret_ref.name) + if not secret: return None - return base64.b64decode(get_ca_cert(tls_secret)).decode() + return base64.b64decode(get_ca_cert(secret)).decode() return self.certificate_authority_cert diff --git a/app/handlers/handlers_services.py b/app/handlers/handlers_services.py index ea7892e4..6d5e7951 100644 --- a/app/handlers/handlers_services.py +++ b/app/handlers/handlers_services.py @@ -88,14 +88,14 @@ def service_to_twingate_resource(service_body: Body, namespace: str) -> dict: result["spec"][key] = convert_f(value) if result["spec"].get("type") == ResourceType.KUBERNETES: - if not (tls_secret_name := meta.annotations.get(TLS_OBJECT_ANNOTATION)): + if not (secret_name := meta.annotations.get(TLS_OBJECT_ANNOTATION)): raise kopf.PermanentError( f"{TLS_OBJECT_ANNOTATION} annotation is not provided." ) - if not (tls_secret := k8s_get_secret(namespace, tls_secret_name)): + if not (secret := k8s_get_secret(namespace, secret_name)): raise kopf.PermanentError( - f"Kubernetes Secret object: {tls_secret_name} is missing." + f"Kubernetes Secret object: {secret_name} is missing." ) result["spec"] |= { @@ -106,7 +106,7 @@ def service_to_twingate_resource(service_body: Body, namespace: str) -> dict: if spec["type"] == ServiceType.LOAD_BALANCER else f"{service_name}.{namespace}.svc.cluster.local" ), - "certificateAuthorityCert": get_ca_cert(tls_secret), + "certificateAuthorityCert": get_ca_cert(secret), }, } diff --git a/app/utils_k8s.py b/app/utils_k8s.py index 296dee00..5935cb1a 100644 --- a/app/utils_k8s.py +++ b/app/utils_k8s.py @@ -59,9 +59,9 @@ def k8s_get_secret(namespace: str, name: str) -> kubernetes.client.V1Secret | No raise -def get_ca_cert(tls_secret: kubernetes.client.V1Secret) -> str: - secret_name = tls_secret.metadata.name - if not (ca_cert := tls_secret.data.get("ca.crt")): +def get_ca_cert(secret: kubernetes.client.V1Secret) -> str: + secret_name = secret.metadata.name + if not (ca_cert := secret.data.get("ca.crt")): raise kopf.PermanentError( f"Kubernetes Secret object: {secret_name} is missing ca.crt." ) From bc8586940a10714589f87890be6027c2d0ffb811 Mon Sep 17 00:00:00 2001 From: Clement Tee Date: Mon, 20 Oct 2025 15:41:32 +0800 Subject: [PATCH 7/9] Address PR review --- app/crds.py | 31 +++++++++++++++++++++++----- app/tests/test_crds_resource.py | 34 +++++++++++++++++++++++++++++-- app/tests/test_utils_k8s.py | 36 ++++----------------------------- app/utils_k8s.py | 32 ++++++----------------------- 4 files changed, 68 insertions(+), 65 deletions(-) diff --git a/app/crds.py b/app/crds.py index 74960a0d..edd20d81 100644 --- a/app/crds.py +++ b/app/crds.py @@ -23,7 +23,8 @@ from semantic_version import NpmSpec from app.settings import get_settings -from app.utils_k8s import get_ca_cert, k8s_get_secret +from app.utils import validate_pem_x509_certificate +from app.utils_k8s import k8s_read_namespaced_secret from app.version_policy_providers import get_provider K8sObject = MutableMapping[Any, Any] @@ -131,14 +132,34 @@ class ResourceProxy(BaseModel): def get_certificate_authority_cert(self) -> str | None: if secret_ref := self.certificate_authority_cert_secret_ref: - secret = k8s_get_secret(secret_ref.namespace, secret_ref.name) - if not secret: - return None + if secret := k8s_read_namespaced_secret( + secret_ref.namespace, secret_ref.name + ): + return self.read_certificate_authority_cert_from_secret(secret) - return base64.b64decode(get_ca_cert(secret)).decode() + return None return self.certificate_authority_cert + @staticmethod + def read_certificate_authority_cert_from_secret( + secret: kubernetes.client.V1Secret, + ) -> str: + secret_name = secret.metadata.name + if not (ca_cert := secret.data.get("ca.crt")): + raise kopf.PermanentError( + f"Kubernetes Secret object: {secret_name} is missing ca.crt." + ) + + try: + ca_cert = base64.b64decode(ca_cert).decode() + validate_pem_x509_certificate(ca_cert) + return ca_cert + except ValueError as ex: + raise kopf.PermanentError( + f"Kubernetes Secret object: {secret_name} ca.crt is invalid." + ) from ex + class ResourceType(StrEnum): NETWORK = "Network" diff --git a/app/tests/test_crds_resource.py b/app/tests/test_crds_resource.py index 2e3c5ed2..4b2b70a2 100644 --- a/app/tests/test_crds_resource.py +++ b/app/tests/test_crds_resource.py @@ -12,7 +12,6 @@ TwingateResourceCRD, _KubernetesObjectRef, ) -from app.utils_k8s import get_ca_cert @pytest.fixture @@ -304,7 +303,10 @@ def test_resource_proxy_get_certificate_authority_cert_with_secret_ref( ) k8s_core_client_mock.read_namespaced_secret.return_value = k8s_secret_mock - with patch("app.crds.get_ca_cert", wraps=get_ca_cert) as get_ca_cert_mock: + with patch( + "app.crds.ResourceProxy.read_certificate_authority_cert_from_secret", + wraps=proxy.read_certificate_authority_cert_from_secret, + ) as get_ca_cert_mock: assert proxy.get_certificate_authority_cert() == VALID_CA_CERT get_ca_cert_mock.assert_called_once_with(k8s_secret_mock) @@ -410,3 +412,31 @@ def test_resource_proxy_certificate_authority_cert_should_trim_whitespace( ) assert resource_spec.proxy.certificate_authority_cert == VALID_CA_CERT + + +class TestResourceProxyReadCACertFromSecret: + def test_get_ca_cert_from_secret(self, k8s_secret_mock): + assert ( + ResourceProxy.read_certificate_authority_cert_from_secret(k8s_secret_mock) + == VALID_CA_CERT + ) + + def test_get_ca_cert_from_secret_with_missing_ca_cert(self, k8s_secret_mock): + k8s_secret_mock.data = {} + + with pytest.raises( + kopf.PermanentError, + match=r"Kubernetes Secret object: gateway-tls is missing ca.crt.", + ): + ResourceProxy.read_certificate_authority_cert_from_secret(k8s_secret_mock) + + def test_get_ca_cert_from_secret_with_invalid_ca_cert(self, k8s_secret_mock): + k8s_secret_mock.data["ca.crt"] = ( + "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tIE1JSUZmakNDQTJhZ0F3SUJBZ0lVQk50IC0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0=" + ) + + with pytest.raises( + kopf.PermanentError, + match=r"Kubernetes Secret object: gateway-tls ca.crt is invalid.", + ): + ResourceProxy.read_certificate_authority_cert_from_secret(k8s_secret_mock) diff --git a/app/tests/test_utils_k8s.py b/app/tests/test_utils_k8s.py index 2e2edaf8..ba84a463 100644 --- a/app/tests/test_utils_k8s.py +++ b/app/tests/test_utils_k8s.py @@ -1,14 +1,11 @@ -import kopf import kubernetes import pytest -from app.api.tests.factories import BASE64_OF_VALID_CA_CERT from app.utils_k8s import ( - get_ca_cert, k8s_delete_pod, - k8s_get_secret, k8s_read_namespaced_deployment, k8s_read_namespaced_pod, + k8s_read_namespaced_secret, ) @@ -62,41 +59,16 @@ def test_reraises_non_404_exceptions(self, k8s_apps_client_mock): k8s_read_namespaced_deployment("default", "test") -class TestK8sGetTLSSecret: +class TestReadNamespacedSecret: def test_handles_404_returns_none(self, k8s_core_client_mock): k8s_core_client_mock.read_namespaced_secret.side_effect = ( kubernetes.client.exceptions.ApiException(status=404) ) - assert k8s_get_secret("default", "test") is None + assert k8s_read_namespaced_secret("default", "test") is None def test_reraises_non_404_exceptions(self, k8s_core_client_mock): k8s_core_client_mock.read_namespaced_secret.side_effect = ( kubernetes.client.exceptions.ApiException(status=500) ) with pytest.raises(kubernetes.client.exceptions.ApiException): - k8s_get_secret("default", "test") - - -class TestGetCACert: - def test_get_ca_cert(self, k8s_secret_mock): - assert get_ca_cert(k8s_secret_mock) == BASE64_OF_VALID_CA_CERT - - def test_get_ca_cert_with_missing_ca_cert(self, k8s_secret_mock): - k8s_secret_mock.data = {} - - with pytest.raises( - kopf.PermanentError, - match=r"Kubernetes Secret object: gateway-tls is missing ca.crt.", - ): - get_ca_cert(k8s_secret_mock) - - def test_get_ca_cert_with_invalid_ca_cert(self, k8s_secret_mock): - k8s_secret_mock.data["ca.crt"] = ( - "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tIE1JSUZmakNDQTJhZ0F3SUJBZ0lVQk50IC0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0=" - ) - - with pytest.raises( - kopf.PermanentError, - match=r"Kubernetes Secret object: gateway-tls ca.crt is invalid.", - ): - get_ca_cert(k8s_secret_mock) + k8s_read_namespaced_secret("default", "test") diff --git a/app/utils_k8s.py b/app/utils_k8s.py index 5935cb1a..a46025ba 100644 --- a/app/utils_k8s.py +++ b/app/utils_k8s.py @@ -1,10 +1,5 @@ -import base64 - -import kopf import kubernetes -from app.utils import validate_pem_x509_certificate - def k8s_read_namespaced_pod( namespace: str, name: str, kapi: kubernetes.client.CoreV1Api | None = None @@ -47,30 +42,15 @@ def k8s_read_namespaced_deployment( raise -def k8s_get_secret(namespace: str, name: str) -> kubernetes.client.V1Secret | None: +def k8s_read_namespaced_secret( + namespace: str, name: str, kapi: kubernetes.client.CoreV1Api | None = None +) -> kubernetes.client.V1Secret | None: try: - return kubernetes.client.CoreV1Api().read_namespaced_secret( - name=name, namespace=namespace - ) + kapi = kapi or kubernetes.client.CoreV1Api() + + return kapi.read_namespaced_secret(name=name, namespace=namespace) except kubernetes.client.exceptions.ApiException as ex: if ex.status == 404: return None raise - - -def get_ca_cert(secret: kubernetes.client.V1Secret) -> str: - secret_name = secret.metadata.name - if not (ca_cert := secret.data.get("ca.crt")): - raise kopf.PermanentError( - f"Kubernetes Secret object: {secret_name} is missing ca.crt." - ) - - try: - validate_pem_x509_certificate(base64.b64decode(ca_cert).decode()) - except ValueError as ex: - raise kopf.PermanentError( - f"Kubernetes Secret object: {secret_name} ca.crt is invalid." - ) from ex - - return ca_cert From 9b33d729bf8a6f26c1b0adf8c3fec88bfddc9a5f Mon Sep 17 00:00:00 2001 From: Clement Tee Date: Mon, 20 Oct 2025 15:55:31 +0800 Subject: [PATCH 8/9] Update service handler --- app/handlers/handlers_services.py | 13 +++++++++---- app/handlers/tests/test_handlers_services.py | 10 +++++----- app/tests/test_crds_resource.py | 10 +++++----- 3 files changed, 19 insertions(+), 14 deletions(-) diff --git a/app/handlers/handlers_services.py b/app/handlers/handlers_services.py index 6d5e7951..0f397cb3 100644 --- a/app/handlers/handlers_services.py +++ b/app/handlers/handlers_services.py @@ -1,3 +1,4 @@ +import base64 from collections.abc import Callable from enum import StrEnum from typing import cast @@ -6,9 +7,9 @@ import kubernetes from kopf import Body, Status -from app.crds import ResourceType +from app.crds import ResourceProxy, ResourceType from app.utils import to_bool -from app.utils_k8s import get_ca_cert, k8s_get_secret +from app.utils_k8s import k8s_read_namespaced_secret def k8s_get_twingate_resource( @@ -93,7 +94,7 @@ def service_to_twingate_resource(service_body: Body, namespace: str) -> dict: f"{TLS_OBJECT_ANNOTATION} annotation is not provided." ) - if not (secret := k8s_get_secret(namespace, secret_name)): + if not (secret := k8s_read_namespaced_secret(namespace, secret_name)): raise kopf.PermanentError( f"Kubernetes Secret object: {secret_name} is missing." ) @@ -106,7 +107,11 @@ def service_to_twingate_resource(service_body: Body, namespace: str) -> dict: if spec["type"] == ServiceType.LOAD_BALANCER else f"{service_name}.{namespace}.svc.cluster.local" ), - "certificateAuthorityCert": get_ca_cert(secret), + "certificateAuthorityCert": base64.b64encode( + ResourceProxy.read_certificate_authority_cert_from_secret( + secret + ).encode() + ).decode(), }, } diff --git a/app/handlers/tests/test_handlers_services.py b/app/handlers/tests/test_handlers_services.py index ca2d5979..2ec5d942 100644 --- a/app/handlers/tests/test_handlers_services.py +++ b/app/handlers/tests/test_handlers_services.py @@ -7,7 +7,7 @@ from kopf._core.intents.causes import Reason from app.api.tests.factories import BASE64_OF_VALID_CA_CERT -from app.crds import ResourceType +from app.crds import ResourceProxy, ResourceType from app.handlers.handlers_services import ( ALLOWED_EXTRA_ANNOTATIONS, TLS_OBJECT_ANNOTATION, @@ -15,7 +15,6 @@ service_to_twingate_resource, twingate_service_create, ) -from app.utils_k8s import get_ca_cert # Ignore the fact we use _cogs here @@ -183,13 +182,14 @@ def test_kubernetes_resource_type_annotation( k8s_core_client_mock.read_namespaced_secret.return_value = k8s_secret_mock with patch( - "app.handlers.handlers_services.get_ca_cert", wraps=get_ca_cert - ) as get_ca_cert_mock: + "app.handlers.handlers_services.ResourceProxy.read_certificate_authority_cert_from_secret", + wraps=ResourceProxy.read_certificate_authority_cert_from_secret, + ) as read_ca_cert_mock: result = service_to_twingate_resource( example_cluster_ip_gateway_service_body, namespace ) - get_ca_cert_mock.assert_called_once_with(k8s_secret_mock) + read_ca_cert_mock.assert_called_once_with(k8s_secret_mock) k8s_core_client_mock.read_namespaced_secret.assert_called_once_with( namespace=namespace, name=tls_object_name ) diff --git a/app/tests/test_crds_resource.py b/app/tests/test_crds_resource.py index 4b2b70a2..31b996d0 100644 --- a/app/tests/test_crds_resource.py +++ b/app/tests/test_crds_resource.py @@ -306,9 +306,9 @@ def test_resource_proxy_get_certificate_authority_cert_with_secret_ref( with patch( "app.crds.ResourceProxy.read_certificate_authority_cert_from_secret", wraps=proxy.read_certificate_authority_cert_from_secret, - ) as get_ca_cert_mock: + ) as read_ca_cert_mock: assert proxy.get_certificate_authority_cert() == VALID_CA_CERT - get_ca_cert_mock.assert_called_once_with(k8s_secret_mock) + read_ca_cert_mock.assert_called_once_with(k8s_secret_mock) def test_network_resource_spec_to_graphql_arguments(sample_network_resource_object): @@ -415,13 +415,13 @@ def test_resource_proxy_certificate_authority_cert_should_trim_whitespace( class TestResourceProxyReadCACertFromSecret: - def test_get_ca_cert_from_secret(self, k8s_secret_mock): + def test_read_ca_cert_from_secret(self, k8s_secret_mock): assert ( ResourceProxy.read_certificate_authority_cert_from_secret(k8s_secret_mock) == VALID_CA_CERT ) - def test_get_ca_cert_from_secret_with_missing_ca_cert(self, k8s_secret_mock): + def test_read_ca_cert_from_secret_with_missing_ca_cert(self, k8s_secret_mock): k8s_secret_mock.data = {} with pytest.raises( @@ -430,7 +430,7 @@ def test_get_ca_cert_from_secret_with_missing_ca_cert(self, k8s_secret_mock): ): ResourceProxy.read_certificate_authority_cert_from_secret(k8s_secret_mock) - def test_get_ca_cert_from_secret_with_invalid_ca_cert(self, k8s_secret_mock): + def test_read_ca_cert_from_secret_with_invalid_ca_cert(self, k8s_secret_mock): k8s_secret_mock.data["ca.crt"] = ( "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tIE1JSUZmakNDQTJhZ0F3SUJBZ0lVQk50IC0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0=" ) From e31b36ef870cbae66e33560b9130e2e6df9c89a2 Mon Sep 17 00:00:00 2001 From: Clement Tee Date: Tue, 21 Oct 2025 10:54:54 +0800 Subject: [PATCH 9/9] Update min connector version --- tests_integration/test_connector_flows.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests_integration/test_connector_flows.py b/tests_integration/test_connector_flows.py index 2acc9e41..b471f5ea 100644 --- a/tests_integration/test_connector_flows.py +++ b/tests_integration/test_connector_flows.py @@ -146,7 +146,7 @@ def test_connector_flows_deployment_gone_while_operator_down( name: {connector_name} hasStatusNotificationsEnabled: false image: - tag: "1.63.0" + tag: "1.77.0" """ wait_for_deployment = functools.partial(