From d435a65cfdf18e0622f0cac3877c88053b2c7b81 Mon Sep 17 00:00:00 2001 From: ChandonPierre <80500072+ChandonPierre@users.noreply.github.com> Date: Fri, 21 Jul 2023 20:29:28 -0400 Subject: [PATCH] outposts: support json patch for Kubernetes (#6319) --- authentik/outposts/controllers/k8s/base.py | 47 ++++++++++++++++++- .../outposts/controllers/k8s/deployment.py | 4 ++ authentik/outposts/controllers/k8s/secret.py | 4 ++ authentik/outposts/controllers/k8s/service.py | 4 ++ .../controllers/k8s/service_monitor.py | 4 ++ authentik/outposts/controllers/kubernetes.py | 17 +++++-- authentik/outposts/models.py | 3 +- .../proxy/controllers/k8s/ingress.py | 4 ++ .../proxy/controllers/k8s/traefik.py | 4 ++ .../proxy/controllers/k8s/traefik_3.py | 4 ++ .../providers/proxy/controllers/kubernetes.py | 10 ++-- poetry.lock | 27 ++++++++++- pyproject.toml | 1 + tests/integration/test_outpost_kubernetes.py | 25 ++++++++++ .../setup/full-dev-environment.md | 2 +- website/docs/outposts/_config.md | 14 ++++++ .../docs/outposts/integrations/kubernetes.md | 5 +- 17 files changed, 162 insertions(+), 17 deletions(-) diff --git a/authentik/outposts/controllers/k8s/base.py b/authentik/outposts/controllers/k8s/base.py index 58f91eb67..c07a1fded 100644 --- a/authentik/outposts/controllers/k8s/base.py +++ b/authentik/outposts/controllers/k8s/base.py @@ -1,16 +1,20 @@ """Base Kubernetes Reconciler""" +from json import dumps from typing import TYPE_CHECKING, Generic, Optional, TypeVar from django.utils.text import slugify -from kubernetes.client import V1ObjectMeta +from jsonpatch import JsonPatchConflict, JsonPatchException, JsonPatchTestFailed, apply_patch +from kubernetes.client import ApiClient, V1ObjectMeta from kubernetes.client.exceptions import ApiException, OpenApiException from kubernetes.client.models.v1_deployment import V1Deployment from kubernetes.client.models.v1_pod import V1Pod +from requests import Response from structlog.stdlib import get_logger from urllib3.exceptions import HTTPError from authentik import __version__ from authentik.outposts.apps import MANAGED_OUTPOST +from authentik.outposts.controllers.base import ControllerException from authentik.outposts.controllers.k8s.triggers import NeedsRecreate, NeedsUpdate if TYPE_CHECKING: @@ -34,11 +38,23 @@ class KubernetesObjectReconciler(Generic[T]): self.namespace = controller.outpost.config.kubernetes_namespace self.logger = get_logger().bind(type=self.__class__.__name__) + def get_patch(self): + """Get any patches that apply to this CRD""" + patches = self.controller.outpost.config.kubernetes_json_patches + if not patches: + return None + return patches.get(self.name, None) + @property def is_embedded(self) -> bool: """Return true if the current outpost is embedded""" return self.controller.outpost.managed == MANAGED_OUTPOST + @staticmethod + def reconciler_name() -> str: + """A name this reconciler is identified by in the configuration""" + raise NotImplementedError + @property def noop(self) -> bool: """Return true if this object should not be created/updated/deleted in this cluster""" @@ -55,6 +71,23 @@ class KubernetesObjectReconciler(Generic[T]): } ).lower() + def get_patched_reference_object(self) -> T: + """Get patched reference object""" + reference = self.get_reference_object() + patch = self.get_patch() + v1deploy_json = ApiClient().sanitize_for_serialization(reference) + try: + if patch is not None: + ref_v1deploy = apply_patch(v1deploy_json, patch) + else: + ref_v1deploy = v1deploy_json + except (JsonPatchException, JsonPatchConflict, JsonPatchTestFailed) as exc: + raise ControllerException(f"JSON Patch failed: {exc}") from exc + mock_response = Response() + mock_response.data = dumps(ref_v1deploy) + + return ApiClient().deserialize(mock_response, reference.__class__.__name__) + # pylint: disable=invalid-name def up(self): """Create object if it doesn't exist, update if needed or recreate if needed.""" @@ -62,7 +95,7 @@ class KubernetesObjectReconciler(Generic[T]): if self.noop: self.logger.debug("Object is noop") return - reference = self.get_reference_object() + reference = self.get_patched_reference_object() try: try: current = self.retrieve() @@ -129,6 +162,16 @@ class KubernetesObjectReconciler(Generic[T]): if current.metadata.labels != reference.metadata.labels: raise NeedsUpdate() + patch = self.get_patch() + if patch is not None: + current_json = ApiClient().sanitize_for_serialization(current) + + try: + if apply_patch(current_json, patch) != current_json: + raise NeedsUpdate() + except (JsonPatchException, JsonPatchConflict, JsonPatchTestFailed) as exc: + raise ControllerException(f"JSON Patch failed: {exc}") from exc + def create(self, reference: T): """API Wrapper to create object""" raise NotImplementedError diff --git a/authentik/outposts/controllers/k8s/deployment.py b/authentik/outposts/controllers/k8s/deployment.py index 96d8a227f..4aa10e7f7 100644 --- a/authentik/outposts/controllers/k8s/deployment.py +++ b/authentik/outposts/controllers/k8s/deployment.py @@ -43,6 +43,10 @@ class DeploymentReconciler(KubernetesObjectReconciler[V1Deployment]): self.api = AppsV1Api(controller.client) self.outpost = self.controller.outpost + @staticmethod + def reconciler_name() -> str: + return "deployment" + def reconcile(self, current: V1Deployment, reference: V1Deployment): compare_ports( current.spec.template.spec.containers[0].ports, diff --git a/authentik/outposts/controllers/k8s/secret.py b/authentik/outposts/controllers/k8s/secret.py index fc8dc8296..8a2293404 100644 --- a/authentik/outposts/controllers/k8s/secret.py +++ b/authentik/outposts/controllers/k8s/secret.py @@ -24,6 +24,10 @@ class SecretReconciler(KubernetesObjectReconciler[V1Secret]): super().__init__(controller) self.api = CoreV1Api(controller.client) + @staticmethod + def reconciler_name() -> str: + return "secret" + def reconcile(self, current: V1Secret, reference: V1Secret): super().reconcile(current, reference) for key in reference.data.keys(): diff --git a/authentik/outposts/controllers/k8s/service.py b/authentik/outposts/controllers/k8s/service.py index dc32d291a..374e94274 100644 --- a/authentik/outposts/controllers/k8s/service.py +++ b/authentik/outposts/controllers/k8s/service.py @@ -20,6 +20,10 @@ class ServiceReconciler(KubernetesObjectReconciler[V1Service]): super().__init__(controller) self.api = CoreV1Api(controller.client) + @staticmethod + def reconciler_name() -> str: + return "service" + def reconcile(self, current: V1Service, reference: V1Service): compare_ports(current.spec.ports, reference.spec.ports) # run the base reconcile last, as that will probably raise NeedsUpdate diff --git a/authentik/outposts/controllers/k8s/service_monitor.py b/authentik/outposts/controllers/k8s/service_monitor.py index 56abccb5d..4e58c119a 100644 --- a/authentik/outposts/controllers/k8s/service_monitor.py +++ b/authentik/outposts/controllers/k8s/service_monitor.py @@ -71,6 +71,10 @@ class PrometheusServiceMonitorReconciler(KubernetesObjectReconciler[PrometheusSe self.api_ex = ApiextensionsV1Api(controller.client) self.api = CustomObjectsApi(controller.client) + @staticmethod + def reconciler_name() -> str: + return "prometheus servicemonitor" + @property def noop(self) -> bool: return (not self._crd_exists()) or (self.is_embedded) diff --git a/authentik/outposts/controllers/kubernetes.py b/authentik/outposts/controllers/kubernetes.py index 31a0db4ba..e3a943e2c 100644 --- a/authentik/outposts/controllers/kubernetes.py +++ b/authentik/outposts/controllers/kubernetes.py @@ -64,12 +64,19 @@ class KubernetesController(BaseController): super().__init__(outpost, connection) self.client = KubernetesClient(connection) self.reconcilers = { - "secret": SecretReconciler, - "deployment": DeploymentReconciler, - "service": ServiceReconciler, - "prometheus servicemonitor": PrometheusServiceMonitorReconciler, + SecretReconciler.reconciler_name(): SecretReconciler, + DeploymentReconciler.reconciler_name(): DeploymentReconciler, + ServiceReconciler.reconciler_name(): ServiceReconciler, + PrometheusServiceMonitorReconciler.reconciler_name(): ( + PrometheusServiceMonitorReconciler + ), } - self.reconcile_order = ["secret", "deployment", "service", "prometheus servicemonitor"] + self.reconcile_order = [ + SecretReconciler.reconciler_name(), + DeploymentReconciler.reconciler_name(), + ServiceReconciler.reconciler_name(), + PrometheusServiceMonitorReconciler.reconciler_name(), + ] def up(self): try: diff --git a/authentik/outposts/models.py b/authentik/outposts/models.py index ac05962c2..20ffa10a0 100644 --- a/authentik/outposts/models.py +++ b/authentik/outposts/models.py @@ -1,7 +1,7 @@ """Outpost models""" from dataclasses import asdict, dataclass, field from datetime import datetime -from typing import Iterable, Optional +from typing import Any, Iterable, Optional from uuid import uuid4 from dacite.core import from_dict @@ -75,6 +75,7 @@ class OutpostConfig: kubernetes_service_type: str = field(default="ClusterIP") kubernetes_disabled_components: list[str] = field(default_factory=list) kubernetes_image_pull_secrets: list[str] = field(default_factory=list) + kubernetes_json_patches: Optional[dict[str, list[dict[str, Any]]]] = field(default=None) class OutpostModel(Model): diff --git a/authentik/providers/proxy/controllers/k8s/ingress.py b/authentik/providers/proxy/controllers/k8s/ingress.py index b641b6d75..e0cc54dba 100644 --- a/authentik/providers/proxy/controllers/k8s/ingress.py +++ b/authentik/providers/proxy/controllers/k8s/ingress.py @@ -31,6 +31,10 @@ class IngressReconciler(KubernetesObjectReconciler[V1Ingress]): super().__init__(controller) self.api = NetworkingV1Api(controller.client) + @staticmethod + def reconciler_name() -> str: + return "ingress" + def _check_annotations(self, reference: V1Ingress): """Check that all annotations *we* set are correct""" for key, value in self.get_ingress_annotations().items(): diff --git a/authentik/providers/proxy/controllers/k8s/traefik.py b/authentik/providers/proxy/controllers/k8s/traefik.py index f1bd625df..f6229abc6 100644 --- a/authentik/providers/proxy/controllers/k8s/traefik.py +++ b/authentik/providers/proxy/controllers/k8s/traefik.py @@ -17,6 +17,10 @@ class TraefikMiddlewareReconciler(KubernetesObjectReconciler): if not self.reconciler.crd_exists(): self.reconciler = Traefik2MiddlewareReconciler(controller) + @staticmethod + def reconciler_name() -> str: + return "traefik middleware" + @property def noop(self) -> bool: return self.reconciler.noop diff --git a/authentik/providers/proxy/controllers/k8s/traefik_3.py b/authentik/providers/proxy/controllers/k8s/traefik_3.py index 5b70ca1bb..c6c5a6c4c 100644 --- a/authentik/providers/proxy/controllers/k8s/traefik_3.py +++ b/authentik/providers/proxy/controllers/k8s/traefik_3.py @@ -67,6 +67,10 @@ class Traefik3MiddlewareReconciler(KubernetesObjectReconciler[TraefikMiddleware] self.crd_version = "v1alpha1" self.crd_plural = "middlewares" + @staticmethod + def reconciler_name() -> str: + return "traefik middleware" + @property def noop(self) -> bool: if not ProxyProvider.objects.filter( diff --git a/authentik/providers/proxy/controllers/kubernetes.py b/authentik/providers/proxy/controllers/kubernetes.py index 3ed04555f..22d9f4083 100644 --- a/authentik/providers/proxy/controllers/kubernetes.py +++ b/authentik/providers/proxy/controllers/kubernetes.py @@ -16,7 +16,9 @@ class ProxyKubernetesController(KubernetesController): DeploymentPort(9300, "http-metrics", "tcp"), DeploymentPort(9443, "https", "tcp"), ] - self.reconcilers["ingress"] = IngressReconciler - self.reconcilers["traefik middleware"] = TraefikMiddlewareReconciler - self.reconcile_order.append("ingress") - self.reconcile_order.append("traefik middleware") + self.reconcilers[IngressReconciler.reconciler_name()] = IngressReconciler + self.reconcilers[ + TraefikMiddlewareReconciler.reconciler_name() + ] = TraefikMiddlewareReconciler + self.reconcile_order.append(IngressReconciler.reconciler_name()) + self.reconcile_order.append(TraefikMiddlewareReconciler.reconciler_name()) diff --git a/poetry.lock b/poetry.lock index 16cbaaef3..116e98ab5 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1809,6 +1809,31 @@ pipfile-deprecated-finder = ["pip-shims (>=0.5.2)", "pipreqs", "requirementslib" plugins = ["setuptools"] requirements-deprecated-finder = ["pip-api", "pipreqs"] +[[package]] +name = "jsonpatch" +version = "1.33" +description = "Apply JSON-Patches (RFC 6902)" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*, !=3.6.*" +files = [ + {file = "jsonpatch-1.33-py2.py3-none-any.whl", hash = "sha256:0ae28c0cd062bbd8b8ecc26d7d164fbbea9652a1a3693f3b956c1eae5145dade"}, + {file = "jsonpatch-1.33.tar.gz", hash = "sha256:9fcd4009c41e6d12348b4a0ff2563ba56a2923a7dfee731d004e212e1ee5030c"}, +] + +[package.dependencies] +jsonpointer = ">=1.9" + +[[package]] +name = "jsonpointer" +version = "2.4" +description = "Identify specific nodes in a JSON document (RFC 6901)" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*, !=3.6.*" +files = [ + {file = "jsonpointer-2.4-py2.py3-none-any.whl", hash = "sha256:15d51bba20eea3165644553647711d150376234112651b4f1811022aecad7d7a"}, + {file = "jsonpointer-2.4.tar.gz", hash = "sha256:585cee82b70211fa9e6043b7bb89db6e1aa49524340dde8ad6b63206ea689d88"}, +] + [[package]] name = "jsonschema" version = "4.17.3" @@ -4186,4 +4211,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "06466753c4ce0063905809123b1e2bb444034d84acdd108dcb20a9f92ce12fa6" +content-hash = "ab00edcd235c1c92dad9a91ace11d50df4564297193683cca7aa2b207ca27be6" diff --git a/pyproject.toml b/pyproject.toml index 716da3b89..6cd144cf2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -172,6 +172,7 @@ webauthn = "*" wsproto = "*" xmlsec = "*" zxcvbn = "*" +jsonpatch = "*" [tool.poetry.dev-dependencies] bandit = "*" diff --git a/tests/integration/test_outpost_kubernetes.py b/tests/integration/test_outpost_kubernetes.py index ec03e8301..ac5e38bca 100644 --- a/tests/integration/test_outpost_kubernetes.py +++ b/tests/integration/test_outpost_kubernetes.py @@ -35,6 +35,19 @@ class OutpostKubernetesTests(TestCase): service_connection=self.service_connection, ) self.outpost.providers.add(self.provider) + self.outpost.config.kubernetes_json_patches = { + "deployment": [ + { + "op": "add", + "path": "/spec/template/spec/containers/0/resources", + "value": { + "requests": {"cpu": "2000m", "memory": "2000Mi"}, + "limits": {"cpu": "4000m", "memory": "8000Mi"}, + }, + } + ] + } + self.outpost.providers.add(self.provider) self.outpost.save() def test_deployment_reconciler(self): @@ -46,6 +59,18 @@ class OutpostKubernetesTests(TestCase): config = self.outpost.config config.kubernetes_replicas = 3 + config.kubernetes_json_patches = { + "deployment": [ + { + "op": "add", + "path": "/spec/template/spec/containers/0/resources", + "value": { + "requests": {"cpu": "1000m", "memory": "2000Mi"}, + "limits": {"cpu": "2000m", "memory": "4000Mi"}, + }, + } + ] + } self.outpost.config = config with self.assertRaises(NeedsUpdate): diff --git a/website/developer-docs/setup/full-dev-environment.md b/website/developer-docs/setup/full-dev-environment.md index fed638df4..baf9eeff1 100644 --- a/website/developer-docs/setup/full-dev-environment.md +++ b/website/developer-docs/setup/full-dev-environment.md @@ -27,7 +27,7 @@ Depending on your platform, some native dependencies might be required. On macOS ::: :::info -As long as [this issue](https://github.com/xmlsec/python-xmlsec/issues/252) about `libxmlsec-1.3.0` is open, a workaround is required to install a compatible version of `libxmlsec1` with brew, see [this comment](https://github.com/xmlsec/python-xmlsec/issues/254#issuecomment-1511135314). +As long as [this issue](https://github.com/xmlsec/python-xmlsec/issues/252) about `libxmlsec-1.3.0` is open, a workaround is required to install a compatible version of `libxmlsec1` with brew, see [this comment](https://github.com/xmlsec/python-xmlsec/issues/254#issuecomment-1612005910). ::: First, you need to create an isolated Python environment. To create the environment and install dependencies, run the following commands in the same directory as your authentik git repository: diff --git a/website/docs/outposts/_config.md b/website/docs/outposts/_config.md index de4ab7d51..f1b3e8792 100644 --- a/website/docs/outposts/_config.md +++ b/website/docs/outposts/_config.md @@ -64,4 +64,18 @@ kubernetes_image_pull_secrets: [] # (Available with 2022.11.0+) # Applies to: proxy outposts kubernetes_ingress_class_name: null +# Optionally apply an RFC 6902 compliant patch to the Kubernetes objects. This value expects +# a mapping of a key which can be any of the values from `kubernetes_disabled_components`, +# which configures which component the patches are applied to. For example: +# deployment: +# - op: add +# path: "/spec/template/spec/containers/0/resources" +# value: +# requests: +# cpu: 2000m +# memory: 2000Mi +# limits: +# cpu: 4000m +# memory: 8000Mi +kubernetes_json_patches: null ``` diff --git a/website/docs/outposts/integrations/kubernetes.md b/website/docs/outposts/integrations/kubernetes.md index fc7047e19..4c2bf274c 100644 --- a/website/docs/outposts/integrations/kubernetes.md +++ b/website/docs/outposts/integrations/kubernetes.md @@ -32,9 +32,8 @@ The following outpost settings are used: - 'prometheus servicemonitor' - 'ingress' - 'traefik middleware' -- `kubernetes_image_pull_secrets`: If the above docker image is in a private repository, use these secrets to pull. - - NOTE: The secret must be created manually in the namespace first. +- `kubernetes_image_pull_secrets`: If the above docker image is in a private repository, use these secrets to pull. (NOTE: The secret must be created manually in the namespace first.) +- `kubernetes_json_patches`: Applies an RFC 6902 compliant JSON patch to the Kubernetes objects. ## Permissions