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"""
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

View File

@ -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,

View File

@ -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():

View File

@ -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

View File

@ -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)

View File

@ -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:

View File

@ -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):

View File

@ -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():

View File

@ -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

View File

@ -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(

View File

@ -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())

27
poetry.lock generated
View File

@ -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"

View File

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

View File

@ -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):

View File

@ -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:

View File

@ -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
```

View File

@ -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