wip: rename to authentik (#361)

* root: initial rename

* web: rename custom element prefix

* root: rename external functions with pb_ prefix

* root: fix formatting

* root: replace domain with goauthentik.io

* proxy: update path

* root: rename remaining prefixes

* flows: rename file extension

* root: pbadmin -> akadmin

* docs: fix image filenames

* lifecycle: ignore migration files

* ci: copy default config from current source before loading last tagged

* *: new sentry dsn

* tests: fix missing python3.9-dev package

* root: add additional migrations for service accounts created by outposts

* core: mark system-created service accounts with attribute

* policies/expression: fix pb_ replacement not working

* web: fix last linting errors, add lit-analyse

* policies/expressions: fix lint errors

* web: fix sidebar display on screens where not all items fit

* proxy: attempt to fix proxy pipeline

* proxy: use go env GOPATH to get gopath

* lib: fix user_default naming inconsistency

* docs: add upgrade docs

* docs: update screenshots to use authentik

* admin: fix create button on empty-state of outpost

* web: fix modal submit not refreshing SiteShell and Table

* web: fix height of app-card and height of generic icon

* web: fix rendering of subtext

* admin: fix version check error not being caught

* web: fix worker count not being shown

* docs: update screenshots

* root: new icon

* web: fix lint error

* admin: fix linting error

* root: migrate coverage config to pyproject
This commit is contained in:
Jens L
2020-12-05 22:08:42 +01:00
committed by GitHub
parent 810a7ab50b
commit 1cfe1aff13
989 changed files with 6425 additions and 4412 deletions

View File

@ -0,0 +1,46 @@
"""Base Controller"""
from typing import Dict, List
from structlog import get_logger
from structlog.testing import capture_logs
from authentik.lib.sentry import SentryIgnoredException
from authentik.outposts.models import Outpost, OutpostServiceConnection
class ControllerException(SentryIgnoredException):
"""Exception raised when anything fails during controller run"""
class BaseController:
"""Base Outpost deployment controller"""
deployment_ports: Dict[str, int]
outpost: Outpost
connection: OutpostServiceConnection
def __init__(self, outpost: Outpost, connection: OutpostServiceConnection):
self.outpost = outpost
self.connection = connection
self.logger = get_logger()
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]:
"""Call .up() but capture all log output and return it."""
with capture_logs() as logs:
self.up()
return [x["event"] for x in logs]
def down(self):
"""Handler to delete everything we've created"""
raise NotImplementedError
def get_static_deployment(self) -> str:
"""Return a static deployment configuration"""
raise NotImplementedError

View File

@ -0,0 +1,160 @@
"""Docker controller"""
from time import sleep
from typing import Dict, Tuple
from django.conf import settings
from docker import DockerClient
from docker.errors import DockerException, NotFound
from docker.models.containers import Container
from yaml import safe_dump
from authentik import __version__
from authentik.lib.config import CONFIG
from authentik.outposts.controllers.base import BaseController, ControllerException
from authentik.outposts.models import (
DockerServiceConnection,
Outpost,
ServiceConnectionInvalid,
)
class DockerController(BaseController):
"""Docker controller"""
client: DockerClient
container: Container
connection: DockerServiceConnection
def __init__(self, outpost: Outpost, connection: DockerServiceConnection) -> None:
super().__init__(outpost, connection)
try:
self.client = connection.client()
except ServiceConnectionInvalid as exc:
raise ControllerException from exc
def _get_labels(self) -> Dict[str, str]:
return {}
def _get_env(self) -> Dict[str, str]:
return {
"AUTHENTIK_HOST": self.outpost.config.authentik_host,
"AUTHENTIK_INSECURE": str(self.outpost.config.authentik_host_insecure),
"AUTHENTIK_TOKEN": self.outpost.token.key,
}
def _comp_env(self, container: Container) -> bool:
"""Check if container's env is equal to what we would set. Return true if container needs
to be rebuilt."""
should_be = self._get_env()
container_env = container.attrs.get("Config", {}).get("Env", {})
for key, expected_value in should_be.items():
if key not in container_env:
continue
if container_env[key] != expected_value:
return True
return False
def _get_container(self) -> Tuple[Container, bool]:
container_name = f"authentik-proxy-{self.outpost.uuid.hex}"
try:
return self.client.containers.get(container_name), False
except NotFound:
self.logger.info("Container does not exist, creating")
image_prefix = CONFIG.y("outposts.docker_image_base")
image_name = f"{image_prefix}-{self.outpost.type}:{__version__}"
self.client.images.pull(image_name)
container_args = {
"image": image_name,
"name": f"authentik-proxy-{self.outpost.uuid.hex}",
"detach": True,
"ports": {x: x for _, x in self.deployment_ports.items()},
"environment": self._get_env(),
"labels": self._get_labels(),
}
if settings.TEST:
del container_args["ports"]
container_args["network_mode"] = "host"
return (
self.client.containers.create(**container_args),
True,
)
def up(self):
try:
container, has_been_created = self._get_container()
# Check if the container is out of date, delete it and retry
if len(container.image.tags) > 0:
tag: str = container.image.tags[0]
_, _, version = tag.partition(":")
if version != __version__:
self.logger.info(
"Container has mismatched version, re-creating...",
has=version,
should=__version__,
)
container.kill()
container.remove(force=True)
return self.up()
# Check that container values match our values
if self._comp_env(container):
self.logger.info("Container has outdated config, re-creating...")
container.kill()
container.remove(force=True)
return self.up()
# Check that container is healthy
if (
container.status == "running"
and container.attrs.get("State", {}).get("Health", {}).get("Status", "")
!= "healthy"
):
# At this point we know the config is correct, but the container isn't healthy,
# so we just restart it with the same config
if has_been_created:
# Since we've just created the container, give it some time to start.
# If its still not up by then, restart it
self.logger.info(
"Container is unhealthy and new, giving it time to boot."
)
sleep(60)
self.logger.info("Container is unhealthy, restarting...")
container.restart()
return None
# Check that container is running
if container.status != "running":
self.logger.info("Container is not running, restarting...")
container.start()
return None
return None
except DockerException as exc:
raise ControllerException from exc
def down(self):
try:
container, _ = self._get_container()
container.kill()
container.remove()
except DockerException as exc:
raise ControllerException from exc
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()]
image_prefix = CONFIG.y("outposts.docker_image_base")
compose = {
"version": "3.5",
"services": {
f"authentik_{self.outpost.type}": {
"image": f"{image_prefix}-{self.outpost.type}:{__version__}",
"ports": ports,
"environment": {
"AUTHENTIK_HOST": self.outpost.config.authentik_host,
"AUTHENTIK_INSECURE": str(
self.outpost.config.authentik_host_insecure
),
"AUTHENTIK_TOKEN": self.outpost.token.key,
},
}
},
}
return safe_dump(compose, default_flow_style=False)

View File

@ -0,0 +1,126 @@
"""Base Kubernetes Reconciler"""
from typing import TYPE_CHECKING, Generic, TypeVar
from kubernetes.client import V1ObjectMeta
from kubernetes.client.rest import ApiException
from structlog import get_logger
from authentik import __version__
from authentik.lib.sentry import SentryIgnoredException
if TYPE_CHECKING:
from authentik.outposts.controllers.kubernetes import KubernetesController
# pylint: disable=invalid-name
T = TypeVar("T")
class ReconcileTrigger(SentryIgnoredException):
"""Base trigger raised by child classes to notify us"""
class NeedsRecreate(ReconcileTrigger):
"""Exception to trigger a complete recreate of the Kubernetes Object"""
class NeedsUpdate(ReconcileTrigger):
"""Exception to trigger an update to the Kubernetes Object"""
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()
@property
def name(self) -> str:
"""Get the name of the object this reconciler manages"""
raise NotImplementedError
def up(self):
"""Create object if it doesn't exist, update if needed or recreate if needed."""
current = None
reference = self.get_reference_object()
try:
try:
current = self.retrieve()
except ApiException as exc:
if exc.status == 404:
self.logger.debug("Failed to get current, triggering recreate")
raise NeedsRecreate from exc
self.logger.debug("Other unhandled error", exc=exc)
raise exc
else:
self.logger.debug("Got current, running reconcile")
self.reconcile(current, reference)
except NeedsRecreate:
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("Created")
self.create(reference)
except NeedsUpdate:
self.logger.debug("Updating")
self.update(current, reference)
else:
self.logger.debug("Nothing to do...")
def down(self):
"""Delete object if found"""
try:
current = self.retrieve()
self.delete(current)
self.logger.debug("Removing")
except ApiException as exc:
if exc.status == 404:
self.logger.debug("Failed to get current, assuming non-existant")
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"""
raise NotImplementedError
def create(self, reference: T):
"""API Wrapper to create object"""
raise NotImplementedError
def retrieve(self) -> T:
"""API Wrapper to retrive 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/name": f"authentik-{self.controller.outpost.type.lower()}",
"app.kubernetes.io/instance": self.controller.outpost.name,
"app.kubernetes.io/version": __version__,
"app.kubernetes.io/managed-by": "goauthentik.io",
"goauthentik.io/outpost-uuid": self.controller.outpost.uuid.hex,
},
**kwargs,
)

View File

@ -0,0 +1,134 @@
"""Kubernetes Deployment Reconciler"""
from typing import TYPE_CHECKING, Dict
from kubernetes.client import (
AppsV1Api,
V1Container,
V1ContainerPort,
V1Deployment,
V1DeploymentSpec,
V1EnvVar,
V1EnvVarSource,
V1LabelSelector,
V1ObjectMeta,
V1PodSpec,
V1PodTemplateSpec,
V1SecretKeySelector,
)
from authentik import __version__
from authentik.lib.config import CONFIG
from authentik.outposts.controllers.k8s.base import (
KubernetesObjectReconciler,
NeedsUpdate,
)
from authentik.outposts.models import Outpost
if TYPE_CHECKING:
from authentik.outposts.controllers.kubernetes import KubernetesController
class DeploymentReconciler(KubernetesObjectReconciler[V1Deployment]):
"""Kubernetes Deployment Reconciler"""
outpost: Outpost
def __init__(self, controller: "KubernetesController") -> None:
super().__init__(controller)
self.api = AppsV1Api(controller.client)
self.outpost = self.controller.outpost
@property
def name(self) -> str:
return f"authentik-outpost-{self.controller.outpost.uuid.hex}"
def reconcile(self, current: V1Deployment, reference: V1Deployment):
if current.spec.replicas != reference.spec.replicas:
raise NeedsUpdate()
if (
current.spec.template.spec.containers[0].image
!= reference.spec.template.spec.containers[0].image
):
raise NeedsUpdate()
def get_pod_meta(self) -> Dict[str, str]:
"""Get common object metadata"""
return {
"app.kubernetes.io/name": "authentik-outpost",
"app.kubernetes.io/managed-by": "goauthentik.io",
"goauthentik.io/outpost-uuid": self.controller.outpost.uuid.hex,
}
def get_reference_object(self) -> 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))
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")
return V1Deployment(
metadata=meta,
spec=V1DeploymentSpec(
replicas=self.outpost.config.kubernetes_replicas,
selector=V1LabelSelector(match_labels=self.get_pod_meta()),
template=V1PodTemplateSpec(
metadata=V1ObjectMeta(labels=self.get_pod_meta()),
spec=V1PodSpec(
containers=[
V1Container(
name=str(self.outpost.type),
image=f"{image_prefix}-{self.outpost.type}:{__version__}",
ports=container_ports,
env=[
V1EnvVar(
name="AUTHENTIK_HOST",
value_from=V1EnvVarSource(
secret_key_ref=V1SecretKeySelector(
name=secret_name,
key="authentik_host",
)
),
),
V1EnvVar(
name="AUTHENTIK_TOKEN",
value_from=V1EnvVarSource(
secret_key_ref=V1SecretKeySelector(
name=secret_name,
key="token",
)
),
),
V1EnvVar(
name="AUTHENTIK_INSECURE",
value_from=V1EnvVarSource(
secret_key_ref=V1SecretKeySelector(
name=secret_name,
key="authentik_host_insecure",
)
),
),
],
)
]
),
),
),
)
def create(self, reference: V1Deployment):
return self.api.create_namespaced_deployment(self.namespace, reference)
def delete(self, reference: V1Deployment):
return self.api.delete_namespaced_deployment(
reference.metadata.name, self.namespace
)
def retrieve(self) -> V1Deployment:
return self.api.read_namespaced_deployment(self.name, self.namespace)
def update(self, current: V1Deployment, reference: V1Deployment):
return self.api.patch_namespaced_deployment(
current.metadata.name, self.namespace, reference
)

View File

@ -0,0 +1,67 @@
"""Kubernetes Secret Reconciler"""
from base64 import b64encode
from typing import TYPE_CHECKING
from kubernetes.client import CoreV1Api, V1Secret
from authentik.outposts.controllers.k8s.base import (
KubernetesObjectReconciler,
NeedsUpdate,
)
if TYPE_CHECKING:
from authentik.outposts.controllers.kubernetes import KubernetesController
def b64string(source: str) -> str:
"""Base64 Encode string"""
return b64encode(source.encode()).decode("utf-8")
class SecretReconciler(KubernetesObjectReconciler[V1Secret]):
"""Kubernetes Secret Reconciler"""
def __init__(self, controller: "KubernetesController") -> None:
super().__init__(controller)
self.api = CoreV1Api(controller.client)
@property
def name(self) -> str:
return f"authentik-outpost-{self.controller.outpost.uuid.hex}-api"
def reconcile(self, current: V1Secret, reference: V1Secret):
for key in reference.data.keys():
if current.data[key] != reference.data[key]:
raise NeedsUpdate()
def get_reference_object(self) -> V1Secret:
"""Get deployment object for outpost"""
meta = self.get_object_meta(name=self.name)
return V1Secret(
metadata=meta,
data={
"authentik_host": b64string(
self.controller.outpost.config.authentik_host
),
"authentik_host_insecure": b64string(
str(self.controller.outpost.config.authentik_host_insecure)
),
"token": b64string(self.controller.outpost.token.token_uuid.hex),
},
)
def create(self, reference: V1Secret):
return self.api.create_namespaced_secret(self.namespace, reference)
def delete(self, reference: V1Secret):
return self.api.delete_namespaced_secret(
reference.metadata.name, self.namespace
)
def retrieve(self) -> V1Secret:
return self.api.read_namespaced_secret(self.name, self.namespace)
def update(self, current: V1Secret, reference: V1Secret):
return self.api.patch_namespaced_secret(
current.metadata.name, self.namespace, reference
)

View File

@ -0,0 +1,60 @@
"""Kubernetes Service Reconciler"""
from typing import TYPE_CHECKING
from kubernetes.client import CoreV1Api, V1Service, V1ServicePort, V1ServiceSpec
from authentik.outposts.controllers.k8s.base import (
KubernetesObjectReconciler,
NeedsUpdate,
)
from authentik.outposts.controllers.k8s.deployment import DeploymentReconciler
if TYPE_CHECKING:
from authentik.outposts.controllers.kubernetes import KubernetesController
class ServiceReconciler(KubernetesObjectReconciler[V1Service]):
"""Kubernetes Service Reconciler"""
def __init__(self, controller: "KubernetesController") -> None:
super().__init__(controller)
self.api = CoreV1Api(controller.client)
@property
def name(self) -> str:
return f"authentik-outpost-{self.controller.outpost.uuid.hex}"
def reconcile(self, current: V1Service, reference: V1Service):
if len(current.spec.ports) != len(reference.spec.ports):
raise NeedsUpdate()
for port in reference.spec.ports:
if port not in current.spec.ports:
raise NeedsUpdate()
def get_reference_object(self) -> 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))
selector_labels = DeploymentReconciler(self.controller).get_pod_meta()
return V1Service(
metadata=meta,
spec=V1ServiceSpec(ports=ports, selector=selector_labels, type="ClusterIP"),
)
def create(self, reference: V1Service):
return self.api.create_namespaced_service(self.namespace, reference)
def delete(self, reference: V1Service):
return self.api.delete_namespaced_service(
reference.metadata.name, self.namespace
)
def retrieve(self) -> V1Service:
return self.api.read_namespaced_service(self.name, self.namespace)
def update(self, current: V1Service, reference: V1Service):
return self.api.patch_namespaced_service(
current.metadata.name, self.namespace, reference
)

View File

@ -0,0 +1,81 @@
"""Kubernetes deployment controller"""
from io import StringIO
from typing import Dict, List, Type
from kubernetes.client import OpenApiException
from kubernetes.client.api_client import ApiClient
from structlog.testing import capture_logs
from yaml import dump_all
from authentik.outposts.controllers.base import BaseController, ControllerException
from authentik.outposts.controllers.k8s.base import KubernetesObjectReconciler
from authentik.outposts.controllers.k8s.deployment import DeploymentReconciler
from authentik.outposts.controllers.k8s.secret import SecretReconciler
from authentik.outposts.controllers.k8s.service import ServiceReconciler
from authentik.outposts.models import KubernetesServiceConnection, Outpost
class KubernetesController(BaseController):
"""Manage deployment of outpost in kubernetes"""
reconcilers: Dict[str, Type[KubernetesObjectReconciler]]
reconcile_order: List[str]
client: ApiClient
connection: KubernetesServiceConnection
def __init__(
self, outpost: Outpost, connection: KubernetesServiceConnection
) -> None:
super().__init__(outpost, connection)
self.client = connection.client()
self.reconcilers = {
"secret": SecretReconciler,
"deployment": DeploymentReconciler,
"service": ServiceReconciler,
}
self.reconcile_order = ["secret", "deployment", "service"]
def up(self):
try:
for reconcile_key in self.reconcile_order:
reconciler = self.reconcilers[reconcile_key](self)
reconciler.up()
except OpenApiException as exc:
raise ControllerException from exc
def up_with_logs(self) -> List[str]:
try:
all_logs = []
for reconcile_key in self.reconcile_order:
with capture_logs() as logs:
reconciler = self.reconcilers[reconcile_key](self)
reconciler.up()
all_logs += [f"{reconcile_key.title()}: {x['event']}" for x in logs]
return all_logs
except OpenApiException as exc:
raise ControllerException from exc
def down(self):
try:
for reconcile_key in self.reconcile_order:
reconciler = self.reconcilers[reconcile_key](self)
reconciler.down()
except OpenApiException as exc:
raise ControllerException from exc
def get_static_deployment(self) -> str:
documents = []
for reconcile_key in self.reconcile_order:
reconciler = self.reconcilers[reconcile_key](self)
documents.append(reconciler.get_reference_object().to_dict())
with StringIO() as _str:
dump_all(
documents,
stream=_str,
default_flow_style=False,
)
return _str.getvalue()