outposts: support json patch for Kubernetes (#6319)
This commit is contained in:
parent
a728dad166
commit
d435a65cfd
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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():
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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():
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -172,6 +172,7 @@ webauthn = "*"
|
|||
wsproto = "*"
|
||||
xmlsec = "*"
|
||||
zxcvbn = "*"
|
||||
jsonpatch = "*"
|
||||
|
||||
[tool.poetry.dev-dependencies]
|
||||
bandit = "*"
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
```
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
Reference in New Issue