outposts: support json patch for Kubernetes (#6319)

This commit is contained in:
ChandonPierre 2023-07-21 20:29:28 -04:00 committed by GitHub
parent a728dad166
commit d435a65cfd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 162 additions and 17 deletions

View File

@ -1,16 +1,20 @@
"""Base Kubernetes Reconciler""" """Base Kubernetes Reconciler"""
from json import dumps
from typing import TYPE_CHECKING, Generic, Optional, TypeVar from typing import TYPE_CHECKING, Generic, Optional, TypeVar
from django.utils.text import slugify 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.exceptions import ApiException, OpenApiException
from kubernetes.client.models.v1_deployment import V1Deployment from kubernetes.client.models.v1_deployment import V1Deployment
from kubernetes.client.models.v1_pod import V1Pod from kubernetes.client.models.v1_pod import V1Pod
from requests import Response
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from urllib3.exceptions import HTTPError from urllib3.exceptions import HTTPError
from authentik import __version__ from authentik import __version__
from authentik.outposts.apps import MANAGED_OUTPOST from authentik.outposts.apps import MANAGED_OUTPOST
from authentik.outposts.controllers.base import ControllerException
from authentik.outposts.controllers.k8s.triggers import NeedsRecreate, NeedsUpdate from authentik.outposts.controllers.k8s.triggers import NeedsRecreate, NeedsUpdate
if TYPE_CHECKING: if TYPE_CHECKING:
@ -34,11 +38,23 @@ class KubernetesObjectReconciler(Generic[T]):
self.namespace = controller.outpost.config.kubernetes_namespace self.namespace = controller.outpost.config.kubernetes_namespace
self.logger = get_logger().bind(type=self.__class__.__name__) 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 @property
def is_embedded(self) -> bool: def is_embedded(self) -> bool:
"""Return true if the current outpost is embedded""" """Return true if the current outpost is embedded"""
return self.controller.outpost.managed == MANAGED_OUTPOST 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 @property
def noop(self) -> bool: def noop(self) -> bool:
"""Return true if this object should not be created/updated/deleted in this cluster""" """Return true if this object should not be created/updated/deleted in this cluster"""
@ -55,6 +71,23 @@ class KubernetesObjectReconciler(Generic[T]):
} }
).lower() ).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 # pylint: disable=invalid-name
def up(self): def up(self):
"""Create object if it doesn't exist, update if needed or recreate if needed.""" """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: if self.noop:
self.logger.debug("Object is noop") self.logger.debug("Object is noop")
return return
reference = self.get_reference_object() reference = self.get_patched_reference_object()
try: try:
try: try:
current = self.retrieve() current = self.retrieve()
@ -129,6 +162,16 @@ class KubernetesObjectReconciler(Generic[T]):
if current.metadata.labels != reference.metadata.labels: if current.metadata.labels != reference.metadata.labels:
raise NeedsUpdate() 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): def create(self, reference: T):
"""API Wrapper to create object""" """API Wrapper to create object"""
raise NotImplementedError raise NotImplementedError

View File

@ -43,6 +43,10 @@ class DeploymentReconciler(KubernetesObjectReconciler[V1Deployment]):
self.api = AppsV1Api(controller.client) self.api = AppsV1Api(controller.client)
self.outpost = self.controller.outpost self.outpost = self.controller.outpost
@staticmethod
def reconciler_name() -> str:
return "deployment"
def reconcile(self, current: V1Deployment, reference: V1Deployment): def reconcile(self, current: V1Deployment, reference: V1Deployment):
compare_ports( compare_ports(
current.spec.template.spec.containers[0].ports, current.spec.template.spec.containers[0].ports,

View File

@ -24,6 +24,10 @@ class SecretReconciler(KubernetesObjectReconciler[V1Secret]):
super().__init__(controller) super().__init__(controller)
self.api = CoreV1Api(controller.client) self.api = CoreV1Api(controller.client)
@staticmethod
def reconciler_name() -> str:
return "secret"
def reconcile(self, current: V1Secret, reference: V1Secret): def reconcile(self, current: V1Secret, reference: V1Secret):
super().reconcile(current, reference) super().reconcile(current, reference)
for key in reference.data.keys(): for key in reference.data.keys():

View File

@ -20,6 +20,10 @@ class ServiceReconciler(KubernetesObjectReconciler[V1Service]):
super().__init__(controller) super().__init__(controller)
self.api = CoreV1Api(controller.client) self.api = CoreV1Api(controller.client)
@staticmethod
def reconciler_name() -> str:
return "service"
def reconcile(self, current: V1Service, reference: V1Service): def reconcile(self, current: V1Service, reference: V1Service):
compare_ports(current.spec.ports, reference.spec.ports) compare_ports(current.spec.ports, reference.spec.ports)
# run the base reconcile last, as that will probably raise NeedsUpdate # run the base reconcile last, as that will probably raise NeedsUpdate

View File

@ -71,6 +71,10 @@ class PrometheusServiceMonitorReconciler(KubernetesObjectReconciler[PrometheusSe
self.api_ex = ApiextensionsV1Api(controller.client) self.api_ex = ApiextensionsV1Api(controller.client)
self.api = CustomObjectsApi(controller.client) self.api = CustomObjectsApi(controller.client)
@staticmethod
def reconciler_name() -> str:
return "prometheus servicemonitor"
@property @property
def noop(self) -> bool: def noop(self) -> bool:
return (not self._crd_exists()) or (self.is_embedded) return (not self._crd_exists()) or (self.is_embedded)

View File

@ -64,12 +64,19 @@ class KubernetesController(BaseController):
super().__init__(outpost, connection) super().__init__(outpost, connection)
self.client = KubernetesClient(connection) self.client = KubernetesClient(connection)
self.reconcilers = { self.reconcilers = {
"secret": SecretReconciler, SecretReconciler.reconciler_name(): SecretReconciler,
"deployment": DeploymentReconciler, DeploymentReconciler.reconciler_name(): DeploymentReconciler,
"service": ServiceReconciler, ServiceReconciler.reconciler_name(): ServiceReconciler,
"prometheus servicemonitor": PrometheusServiceMonitorReconciler, 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): def up(self):
try: try:

View File

@ -1,7 +1,7 @@
"""Outpost models""" """Outpost models"""
from dataclasses import asdict, dataclass, field from dataclasses import asdict, dataclass, field
from datetime import datetime from datetime import datetime
from typing import Iterable, Optional from typing import Any, Iterable, Optional
from uuid import uuid4 from uuid import uuid4
from dacite.core import from_dict from dacite.core import from_dict
@ -75,6 +75,7 @@ class OutpostConfig:
kubernetes_service_type: str = field(default="ClusterIP") kubernetes_service_type: str = field(default="ClusterIP")
kubernetes_disabled_components: list[str] = field(default_factory=list) kubernetes_disabled_components: list[str] = field(default_factory=list)
kubernetes_image_pull_secrets: 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): class OutpostModel(Model):

View File

@ -31,6 +31,10 @@ class IngressReconciler(KubernetesObjectReconciler[V1Ingress]):
super().__init__(controller) super().__init__(controller)
self.api = NetworkingV1Api(controller.client) self.api = NetworkingV1Api(controller.client)
@staticmethod
def reconciler_name() -> str:
return "ingress"
def _check_annotations(self, reference: V1Ingress): def _check_annotations(self, reference: V1Ingress):
"""Check that all annotations *we* set are correct""" """Check that all annotations *we* set are correct"""
for key, value in self.get_ingress_annotations().items(): for key, value in self.get_ingress_annotations().items():

View File

@ -17,6 +17,10 @@ class TraefikMiddlewareReconciler(KubernetesObjectReconciler):
if not self.reconciler.crd_exists(): if not self.reconciler.crd_exists():
self.reconciler = Traefik2MiddlewareReconciler(controller) self.reconciler = Traefik2MiddlewareReconciler(controller)
@staticmethod
def reconciler_name() -> str:
return "traefik middleware"
@property @property
def noop(self) -> bool: def noop(self) -> bool:
return self.reconciler.noop return self.reconciler.noop

View File

@ -67,6 +67,10 @@ class Traefik3MiddlewareReconciler(KubernetesObjectReconciler[TraefikMiddleware]
self.crd_version = "v1alpha1" self.crd_version = "v1alpha1"
self.crd_plural = "middlewares" self.crd_plural = "middlewares"
@staticmethod
def reconciler_name() -> str:
return "traefik middleware"
@property @property
def noop(self) -> bool: def noop(self) -> bool:
if not ProxyProvider.objects.filter( if not ProxyProvider.objects.filter(

View File

@ -16,7 +16,9 @@ class ProxyKubernetesController(KubernetesController):
DeploymentPort(9300, "http-metrics", "tcp"), DeploymentPort(9300, "http-metrics", "tcp"),
DeploymentPort(9443, "https", "tcp"), DeploymentPort(9443, "https", "tcp"),
] ]
self.reconcilers["ingress"] = IngressReconciler self.reconcilers[IngressReconciler.reconciler_name()] = IngressReconciler
self.reconcilers["traefik middleware"] = TraefikMiddlewareReconciler self.reconcilers[
self.reconcile_order.append("ingress") TraefikMiddlewareReconciler.reconciler_name()
self.reconcile_order.append("traefik middleware") ] = TraefikMiddlewareReconciler
self.reconcile_order.append(IngressReconciler.reconciler_name())
self.reconcile_order.append(TraefikMiddlewareReconciler.reconciler_name())

27
poetry.lock generated
View File

@ -1809,6 +1809,31 @@ pipfile-deprecated-finder = ["pip-shims (>=0.5.2)", "pipreqs", "requirementslib"
plugins = ["setuptools"] plugins = ["setuptools"]
requirements-deprecated-finder = ["pip-api", "pipreqs"] 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]] [[package]]
name = "jsonschema" name = "jsonschema"
version = "4.17.3" version = "4.17.3"
@ -4186,4 +4211,4 @@ files = [
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = "^3.11" python-versions = "^3.11"
content-hash = "06466753c4ce0063905809123b1e2bb444034d84acdd108dcb20a9f92ce12fa6" content-hash = "ab00edcd235c1c92dad9a91ace11d50df4564297193683cca7aa2b207ca27be6"

View File

@ -172,6 +172,7 @@ webauthn = "*"
wsproto = "*" wsproto = "*"
xmlsec = "*" xmlsec = "*"
zxcvbn = "*" zxcvbn = "*"
jsonpatch = "*"
[tool.poetry.dev-dependencies] [tool.poetry.dev-dependencies]
bandit = "*" bandit = "*"

View File

@ -35,6 +35,19 @@ class OutpostKubernetesTests(TestCase):
service_connection=self.service_connection, service_connection=self.service_connection,
) )
self.outpost.providers.add(self.provider) 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() self.outpost.save()
def test_deployment_reconciler(self): def test_deployment_reconciler(self):
@ -46,6 +59,18 @@ class OutpostKubernetesTests(TestCase):
config = self.outpost.config config = self.outpost.config
config.kubernetes_replicas = 3 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 self.outpost.config = config
with self.assertRaises(NeedsUpdate): with self.assertRaises(NeedsUpdate):

View File

@ -27,7 +27,7 @@ Depending on your platform, some native dependencies might be required. On macOS
::: :::
:::info :::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: 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:

View File

@ -64,4 +64,18 @@ kubernetes_image_pull_secrets: []
# (Available with 2022.11.0+) # (Available with 2022.11.0+)
# Applies to: proxy outposts # Applies to: proxy outposts
kubernetes_ingress_class_name: null 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
``` ```

View File

@ -32,9 +32,8 @@ The following outpost settings are used:
- 'prometheus servicemonitor' - 'prometheus servicemonitor'
- 'ingress' - 'ingress'
- 'traefik middleware' - 'traefik middleware'
- `kubernetes_image_pull_secrets`: If the above docker image is in a private repository, use these secrets to pull. - `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.
NOTE: The secret must be created manually in the namespace first.
## Permissions ## Permissions