diff --git a/authentik/outposts/controllers/base.py b/authentik/outposts/controllers/base.py index 57b9cf68b..bf30a4a7c 100644 --- a/authentik/outposts/controllers/base.py +++ b/authentik/outposts/controllers/base.py @@ -1,5 +1,5 @@ """Base Controller""" -from typing import Dict, List +from dataclasses import dataclass from structlog import get_logger from structlog.testing import capture_logs @@ -7,15 +7,26 @@ from structlog.testing import capture_logs from authentik.lib.sentry import SentryIgnoredException from authentik.outposts.models import Outpost, OutpostServiceConnection +FIELD_MANAGER = "goauthentik.io" + class ControllerException(SentryIgnoredException): """Exception raised when anything fails during controller run""" +@dataclass +class DeploymentPort: + """Info about deployment's single port.""" + + port: int + name: str + protocol: str + + class BaseController: """Base Outpost deployment controller""" - deployment_ports: Dict[str, int] + deployment_ports: list[DeploymentPort] outpost: Outpost connection: OutpostServiceConnection @@ -24,14 +35,14 @@ class BaseController: self.outpost = outpost self.connection = connection self.logger = get_logger() - self.deployment_ports = {} + self.deployment_ports = [] # pylint: disable=invalid-name def up(self): """Called by scheduled task to reconcile deployment/service/etc""" raise NotImplementedError - def up_with_logs(self) -> List[str]: + def up_with_logs(self) -> list[str]: """Call .up() but capture all log output and return it.""" with capture_logs() as logs: self.up() diff --git a/authentik/outposts/controllers/docker.py b/authentik/outposts/controllers/docker.py index f8c625f41..77c46e431 100644 --- a/authentik/outposts/controllers/docker.py +++ b/authentik/outposts/controllers/docker.py @@ -68,7 +68,10 @@ class DockerController(BaseController): "image": image_name, "name": f"authentik-proxy-{self.outpost.uuid.hex}", "detach": True, - "ports": {x: x for _, x in self.deployment_ports.items()}, + "ports": { + f"{port.port}/{port.protocol.lower()}": port.port + for port in self.deployment_ports + }, "environment": self._get_env(), "labels": self._get_labels(), } @@ -139,7 +142,10 @@ class DockerController(BaseController): def get_static_deployment(self) -> str: """Generate docker-compose yaml for proxy, version 3.5""" - ports = [f"{x}:{x}" for _, x in self.deployment_ports.items()] + ports = [ + f"{port.port}:{port.port}/{port.protocol.lower()}" + for port in self.deployment_ports + ] image_prefix = CONFIG.y("outposts.docker_image_base") compose = { "version": "3.5", diff --git a/authentik/outposts/controllers/k8s/base.py b/authentik/outposts/controllers/k8s/base.py index 0fbf5588a..d2f895380 100644 --- a/authentik/outposts/controllers/k8s/base.py +++ b/authentik/outposts/controllers/k8s/base.py @@ -93,7 +93,8 @@ class KubernetesObjectReconciler(Generic[T]): def reconcile(self, current: T, reference: T): """Check what operations should be done, should be raised as ReconcileTrigger""" - raise NotImplementedError + if current.metadata.annotations != reference.metadata.annotations: + raise NeedsUpdate() def create(self, reference: T): """API Wrapper to create object""" diff --git a/authentik/outposts/controllers/k8s/deployment.py b/authentik/outposts/controllers/k8s/deployment.py index 981cade84..94cb3ac68 100644 --- a/authentik/outposts/controllers/k8s/deployment.py +++ b/authentik/outposts/controllers/k8s/deployment.py @@ -18,6 +18,7 @@ from kubernetes.client import ( from authentik import __version__ from authentik.lib.config import CONFIG +from authentik.outposts.controllers.base import FIELD_MANAGER from authentik.outposts.controllers.k8s.base import ( KubernetesObjectReconciler, NeedsUpdate, @@ -43,6 +44,7 @@ class DeploymentReconciler(KubernetesObjectReconciler[V1Deployment]): return f"authentik-outpost-{self.controller.outpost.uuid.hex}" def reconcile(self, current: V1Deployment, reference: V1Deployment): + super().reconcile(current, reference) if current.spec.replicas != reference.spec.replicas: raise NeedsUpdate() if ( @@ -63,8 +65,14 @@ class DeploymentReconciler(KubernetesObjectReconciler[V1Deployment]): """Get deployment object for outpost""" # Generate V1ContainerPort objects container_ports = [] - for port_name, port in self.controller.deployment_ports.items(): - container_ports.append(V1ContainerPort(container_port=port, name=port_name)) + for port in self.controller.deployment_ports: + container_ports.append( + V1ContainerPort( + container_port=port.port, + name=port.name, + protocol=port.protocol.upper(), + ) + ) meta = self.get_object_meta(name=self.name) secret_name = f"authentik-outpost-{self.controller.outpost.uuid.hex}-api" image_prefix = CONFIG.y("outposts.docker_image_base") @@ -118,7 +126,9 @@ class DeploymentReconciler(KubernetesObjectReconciler[V1Deployment]): ) def create(self, reference: V1Deployment): - return self.api.create_namespaced_deployment(self.namespace, reference) + return self.api.create_namespaced_deployment( + self.namespace, reference, field_manager=FIELD_MANAGER + ) def delete(self, reference: V1Deployment): return self.api.delete_namespaced_deployment( diff --git a/authentik/outposts/controllers/k8s/secret.py b/authentik/outposts/controllers/k8s/secret.py index 1d65b4f78..d00a81d4f 100644 --- a/authentik/outposts/controllers/k8s/secret.py +++ b/authentik/outposts/controllers/k8s/secret.py @@ -4,6 +4,7 @@ from typing import TYPE_CHECKING from kubernetes.client import CoreV1Api, V1Secret +from authentik.outposts.controllers.base import FIELD_MANAGER from authentik.outposts.controllers.k8s.base import ( KubernetesObjectReconciler, NeedsUpdate, @@ -30,6 +31,7 @@ class SecretReconciler(KubernetesObjectReconciler[V1Secret]): return f"authentik-outpost-{self.controller.outpost.uuid.hex}-api" def reconcile(self, current: V1Secret, reference: V1Secret): + super().reconcile(current, reference) for key in reference.data.keys(): if current.data[key] != reference.data[key]: raise NeedsUpdate() @@ -51,7 +53,9 @@ class SecretReconciler(KubernetesObjectReconciler[V1Secret]): ) def create(self, reference: V1Secret): - return self.api.create_namespaced_secret(self.namespace, reference) + return self.api.create_namespaced_secret( + self.namespace, reference, field_manager=FIELD_MANAGER + ) def delete(self, reference: V1Secret): return self.api.delete_namespaced_secret( diff --git a/authentik/outposts/controllers/k8s/service.py b/authentik/outposts/controllers/k8s/service.py index b710832f1..1f03d22bb 100644 --- a/authentik/outposts/controllers/k8s/service.py +++ b/authentik/outposts/controllers/k8s/service.py @@ -3,6 +3,7 @@ from typing import TYPE_CHECKING from kubernetes.client import CoreV1Api, V1Service, V1ServicePort, V1ServiceSpec +from authentik.outposts.controllers.base import FIELD_MANAGER from authentik.outposts.controllers.k8s.base import ( KubernetesObjectReconciler, NeedsUpdate, @@ -25,6 +26,7 @@ class ServiceReconciler(KubernetesObjectReconciler[V1Service]): return f"authentik-outpost-{self.controller.outpost.uuid.hex}" def reconcile(self, current: V1Service, reference: V1Service): + super().reconcile(current, reference) if len(current.spec.ports) != len(reference.spec.ports): raise NeedsUpdate() for port in reference.spec.ports: @@ -35,8 +37,15 @@ class ServiceReconciler(KubernetesObjectReconciler[V1Service]): """Get deployment object for outpost""" meta = self.get_object_meta(name=self.name) ports = [] - for port_name, port in self.controller.deployment_ports.items(): - ports.append(V1ServicePort(name=port_name, port=port)) + for port in self.controller.deployment_ports: + ports.append( + V1ServicePort( + name=port.name, + port=port.port, + protocol=port.protocol.upper(), + target_port=port.port, + ) + ) selector_labels = DeploymentReconciler(self.controller).get_pod_meta() return V1Service( metadata=meta, @@ -44,7 +53,9 @@ class ServiceReconciler(KubernetesObjectReconciler[V1Service]): ) def create(self, reference: V1Service): - return self.api.create_namespaced_service(self.namespace, reference) + return self.api.create_namespaced_service( + self.namespace, reference, field_manager=FIELD_MANAGER + ) def delete(self, reference: V1Service): return self.api.delete_namespaced_service( diff --git a/authentik/providers/proxy/controllers/docker.py b/authentik/providers/proxy/controllers/docker.py index 920c76b2a..e823696ac 100644 --- a/authentik/providers/proxy/controllers/docker.py +++ b/authentik/providers/proxy/controllers/docker.py @@ -2,6 +2,7 @@ from typing import Dict from urllib.parse import urlparse +from authentik.outposts.controllers.base import DeploymentPort from authentik.outposts.controllers.docker import DockerController from authentik.outposts.models import DockerServiceConnection, Outpost from authentik.providers.proxy.models import ProxyProvider @@ -12,10 +13,10 @@ class ProxyDockerController(DockerController): def __init__(self, outpost: Outpost, connection: DockerServiceConnection): super().__init__(outpost, connection) - self.deployment_ports = { - "http": 4180, - "https": 4443, - } + self.deployment_ports = [ + DeploymentPort(4180, "http", "tcp"), + DeploymentPort(4443, "https", "tcp"), + ] def _get_labels(self) -> Dict[str, str]: hosts = [] diff --git a/authentik/providers/proxy/controllers/k8s/ingress.py b/authentik/providers/proxy/controllers/k8s/ingress.py index 1b02a54bc..5a0c4c2e7 100644 --- a/authentik/providers/proxy/controllers/k8s/ingress.py +++ b/authentik/providers/proxy/controllers/k8s/ingress.py @@ -15,6 +15,7 @@ from kubernetes.client.models.networking_v1beta1_ingress_rule import ( NetworkingV1beta1IngressRule, ) +from authentik.outposts.controllers.base import FIELD_MANAGER from authentik.outposts.controllers.k8s.base import ( KubernetesObjectReconciler, NeedsUpdate, @@ -39,6 +40,7 @@ class IngressReconciler(KubernetesObjectReconciler[NetworkingV1beta1Ingress]): def reconcile( self, current: NetworkingV1beta1Ingress, reference: NetworkingV1beta1Ingress ): + super().reconcile(current, reference) # Create a list of all expected host and tls hosts expected_hosts = [] expected_hosts_tls = [] @@ -104,7 +106,7 @@ class IngressReconciler(KubernetesObjectReconciler[NetworkingV1beta1Ingress]): NetworkingV1beta1HTTPIngressPath( backend=NetworkingV1beta1IngressBackend( service_name=self.name, - service_port=self.controller.deployment_ports["http"], + service_port="http", ), path="/", ) @@ -124,7 +126,9 @@ class IngressReconciler(KubernetesObjectReconciler[NetworkingV1beta1Ingress]): ) def create(self, reference: NetworkingV1beta1Ingress): - return self.api.create_namespaced_ingress(self.namespace, reference) + return self.api.create_namespaced_ingress( + self.namespace, reference, field_manager=FIELD_MANAGER + ) def delete(self, reference: NetworkingV1beta1Ingress): return self.api.delete_namespaced_ingress( diff --git a/authentik/providers/proxy/controllers/kubernetes.py b/authentik/providers/proxy/controllers/kubernetes.py index 9cee34ae8..3fcc55919 100644 --- a/authentik/providers/proxy/controllers/kubernetes.py +++ b/authentik/providers/proxy/controllers/kubernetes.py @@ -1,4 +1,5 @@ """Proxy Provider Kubernetes Contoller""" +from authentik.outposts.controllers.base import DeploymentPort from authentik.outposts.controllers.kubernetes import KubernetesController from authentik.outposts.models import KubernetesServiceConnection, Outpost from authentik.providers.proxy.controllers.k8s.ingress import IngressReconciler @@ -9,9 +10,9 @@ class ProxyKubernetesController(KubernetesController): def __init__(self, outpost: Outpost, connection: KubernetesServiceConnection): super().__init__(outpost, connection) - self.deployment_ports = { - "http": 4180, - "https": 4443, - } + self.deployment_ports = [ + DeploymentPort(4180, "http", "tcp"), + DeploymentPort(4443, "https", "tcp"), + ] self.reconcilers["ingress"] = IngressReconciler self.reconcile_order.append("ingress")