
* wip * wip[skip ci] * add some tests Signed-off-by: Jens Langhammer <jens@goauthentik.io> --------- Signed-off-by: Jens Langhammer <jens@goauthentik.io> Co-authored-by: Jens Langhammer <jens@goauthentik.io>
227 lines
8.4 KiB
Python
227 lines
8.4 KiB
Python
"""Base Kubernetes Reconciler"""
|
|
|
|
import re
|
|
from dataclasses import asdict
|
|
from json import dumps
|
|
from typing import TYPE_CHECKING, Generic, TypeVar
|
|
|
|
from dacite.core import from_dict
|
|
from django.http import HttpResponseNotFound
|
|
from django.utils.text import slugify
|
|
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:
|
|
from authentik.outposts.controllers.kubernetes import KubernetesController
|
|
|
|
T = TypeVar("T", V1Pod, V1Deployment)
|
|
|
|
|
|
def get_version() -> str:
|
|
"""Wrapper for __version__ to make testing easier"""
|
|
return __version__
|
|
|
|
|
|
class KubernetesObjectReconciler(Generic[T]):
|
|
"""Base Kubernetes Reconciler, handles the basic logic."""
|
|
|
|
controller: "KubernetesController"
|
|
|
|
def __init__(self, controller: "KubernetesController"):
|
|
self.controller = controller
|
|
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.reconciler_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"""
|
|
return False
|
|
|
|
@property
|
|
def name(self) -> str:
|
|
"""Get the name of the object this reconciler manages"""
|
|
|
|
base_name = (
|
|
self.controller.outpost.config.object_naming_template
|
|
% {
|
|
"name": slugify(self.controller.outpost.name),
|
|
"uuid": self.controller.outpost.uuid.hex,
|
|
}
|
|
).lower()
|
|
|
|
formatted = slugify(base_name)
|
|
formatted = re.sub(r"[^a-z0-9-]", "-", formatted)
|
|
formatted = re.sub(r"-+", "-", formatted)
|
|
formatted = formatted[:63]
|
|
|
|
if not formatted:
|
|
formatted = f"outpost-{self.controller.outpost.uuid.hex}"[:63]
|
|
|
|
return formatted
|
|
|
|
def get_patched_reference_object(self) -> T:
|
|
"""Get patched reference object"""
|
|
reference = self.get_reference_object()
|
|
patch = self.get_patch()
|
|
try:
|
|
json = ApiClient().sanitize_for_serialization(reference)
|
|
# Custom objects will not be known to the clients openapi types
|
|
except AttributeError:
|
|
json = asdict(reference)
|
|
try:
|
|
ref = json
|
|
if patch is not None:
|
|
ref = apply_patch(json, patch)
|
|
except (JsonPatchException, JsonPatchConflict, JsonPatchTestFailed) as exc:
|
|
raise ControllerException(f"JSON Patch failed: {exc}") from exc
|
|
mock_response = Response()
|
|
mock_response.data = dumps(ref)
|
|
|
|
try:
|
|
result = ApiClient().deserialize(mock_response, reference.__class__.__name__)
|
|
# Custom objects will not be known to the clients openapi types
|
|
except AttributeError:
|
|
result = from_dict(reference.__class__, data=ref)
|
|
|
|
return result
|
|
|
|
def up(self):
|
|
"""Create object if it doesn't exist, update if needed or recreate if needed."""
|
|
current = None
|
|
if self.noop:
|
|
self.logger.debug("Object is noop")
|
|
return
|
|
reference = self.get_patched_reference_object()
|
|
try:
|
|
try:
|
|
current = self.retrieve()
|
|
except (OpenApiException, HTTPError) as exc:
|
|
if isinstance(exc, ApiException) and exc.status == HttpResponseNotFound.status_code:
|
|
self.logger.debug("Failed to get current, triggering recreate")
|
|
raise NeedsRecreate from exc
|
|
self.logger.debug("Other unhandled error", exc=exc)
|
|
raise exc
|
|
self.reconcile(current, reference)
|
|
except NeedsUpdate:
|
|
try:
|
|
self.update(current, reference)
|
|
self.logger.debug("Updating")
|
|
except (OpenApiException, HTTPError) as exc:
|
|
if isinstance(exc, ApiException) and exc.status == 422: # noqa: PLR2004
|
|
self.logger.debug("Failed to update current, triggering re-create")
|
|
self._recreate(current=current, reference=reference)
|
|
return
|
|
self.logger.debug("Other unhandled error", exc=exc)
|
|
raise exc
|
|
except NeedsRecreate:
|
|
self._recreate(current=current, reference=reference)
|
|
else:
|
|
self.logger.debug("Object is up-to-date.")
|
|
|
|
def _recreate(self, reference: T, current: T | None = None):
|
|
"""Recreate object"""
|
|
self.logger.debug("Recreate requested")
|
|
if current:
|
|
self.logger.debug("Deleted old")
|
|
self.delete(current)
|
|
else:
|
|
self.logger.debug("No old found, creating")
|
|
self.logger.debug("Creating")
|
|
self.create(reference)
|
|
|
|
def down(self):
|
|
"""Delete object if found"""
|
|
if self.noop:
|
|
self.logger.debug("Object is noop")
|
|
return
|
|
try:
|
|
current = self.retrieve()
|
|
self.delete(current)
|
|
self.logger.debug("Removing")
|
|
except (OpenApiException, HTTPError) as exc:
|
|
if isinstance(exc, ApiException) and exc.status == HttpResponseNotFound.status_code:
|
|
self.logger.debug("Failed to get current, assuming non-existent")
|
|
return
|
|
self.logger.debug("Other unhandled error", exc=exc)
|
|
raise exc
|
|
|
|
def get_reference_object(self) -> T:
|
|
"""Return object as it should be"""
|
|
raise NotImplementedError
|
|
|
|
def reconcile(self, current: T, reference: T):
|
|
"""Check what operations should be done, should be raised as
|
|
ReconcileTrigger"""
|
|
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
|
|
|
|
def retrieve(self) -> T:
|
|
"""API Wrapper to retrieve object"""
|
|
raise NotImplementedError
|
|
|
|
def delete(self, reference: T):
|
|
"""API Wrapper to delete object"""
|
|
raise NotImplementedError
|
|
|
|
def update(self, current: T, reference: T):
|
|
"""API Wrapper to update object"""
|
|
raise NotImplementedError
|
|
|
|
def get_object_meta(self, **kwargs) -> V1ObjectMeta:
|
|
"""Get common object metadata"""
|
|
return V1ObjectMeta(
|
|
namespace=self.namespace,
|
|
labels={
|
|
"app.kubernetes.io/instance": slugify(self.controller.outpost.name),
|
|
"app.kubernetes.io/managed-by": "goauthentik.io",
|
|
"app.kubernetes.io/name": f"authentik-{self.controller.outpost.type.lower()}",
|
|
"app.kubernetes.io/version": get_version().replace("+", "-"),
|
|
"goauthentik.io/outpost-name": slugify(self.controller.outpost.name),
|
|
"goauthentik.io/outpost-type": str(self.controller.outpost.type),
|
|
"goauthentik.io/outpost-uuid": self.controller.outpost.uuid.hex,
|
|
},
|
|
**kwargs,
|
|
)
|