authentik.outposts.controllers.docker

Docker controller

  1"""Docker controller"""
  2
  3from time import sleep
  4from urllib.parse import urlparse
  5
  6from django.conf import settings
  7from django.utils.text import slugify
  8from docker import DockerClient as UpstreamDockerClient
  9from docker.errors import DockerException, NotFound
 10from docker.models.containers import Container
 11from docker.utils.utils import kwargs_from_env
 12from paramiko.ssh_exception import SSHException
 13from structlog.stdlib import get_logger
 14from yaml import safe_dump
 15
 16from authentik import authentik_version
 17from authentik.outposts.apps import MANAGED_OUTPOST
 18from authentik.outposts.controllers.base import BaseClient, BaseController, ControllerException
 19from authentik.outposts.docker_ssh import DockerInlineSSH, SSHManagedExternallyException
 20from authentik.outposts.docker_tls import DockerInlineTLS
 21from authentik.outposts.models import (
 22    DockerServiceConnection,
 23    Outpost,
 24    OutpostServiceConnectionState,
 25    ServiceConnectionInvalid,
 26)
 27
 28DOCKER_MAX_ATTEMPTS = 10
 29
 30
 31class DockerClient(UpstreamDockerClient, BaseClient):
 32    """Custom docker client, which can handle TLS and SSH from a database."""
 33
 34    tls: DockerInlineTLS | None
 35    ssh: DockerInlineSSH | None
 36
 37    def __init__(self, connection: DockerServiceConnection):
 38        self.tls = None
 39        self.ssh = None
 40        self.logger = get_logger()
 41        if connection.local:
 42            # Same result as DockerClient.from_env
 43            super().__init__(**kwargs_from_env())
 44        else:
 45            parsed_url = urlparse(connection.url)
 46            tls_config = False
 47            if parsed_url.scheme == "ssh":
 48                try:
 49                    self.ssh = DockerInlineSSH(parsed_url.hostname, connection.tls_authentication)
 50                    self.ssh.write()
 51                except SSHManagedExternallyException as exc:
 52                    # SSH config is managed externally
 53                    self.logger.info(f"SSH Managed externally: {exc}")
 54            else:
 55                self.tls = DockerInlineTLS(
 56                    verification_kp=connection.tls_verification,
 57                    authentication_kp=connection.tls_authentication,
 58                )
 59                tls_config = self.tls.write()
 60            try:
 61                super().__init__(
 62                    base_url=connection.url,
 63                    tls=tls_config,
 64                )
 65            except SSHException as exc:
 66                if self.ssh:
 67                    self.ssh.cleanup()
 68                raise ServiceConnectionInvalid(exc) from exc
 69        # Ensure the client actually works
 70        self.containers.list()
 71
 72    def fetch_state(self) -> OutpostServiceConnectionState:
 73        try:
 74            return OutpostServiceConnectionState(version=self.info()["ServerVersion"], healthy=True)
 75        except ServiceConnectionInvalid, DockerException:
 76            return OutpostServiceConnectionState(version="", healthy=False)
 77
 78    def __exit__(self, exc_type, exc_value, traceback):
 79        if self.tls:
 80            self.logger.debug("Cleaning up TLS")
 81            self.tls.cleanup()
 82        if self.ssh:
 83            self.logger.debug("Cleaning up SSH")
 84            self.ssh.cleanup()
 85
 86
 87class DockerController(BaseController):
 88    """Docker controller"""
 89
 90    client: DockerClient
 91
 92    container: Container
 93    connection: DockerServiceConnection
 94
 95    def __init__(self, outpost: Outpost, connection: DockerServiceConnection) -> None:
 96        super().__init__(outpost, connection)
 97        if outpost.managed == MANAGED_OUTPOST:
 98            return
 99        try:
100            self.client = DockerClient(connection)
101        except DockerException as exc:
102            self.logger.warning(exc)
103            raise ControllerException from exc
104
105    @property
106    def name(self) -> str:
107        """Get the name of the object this reconciler manages"""
108        return (
109            self.outpost.config.object_naming_template
110            % {
111                "name": slugify(self.outpost.name),
112                "uuid": self.outpost.uuid.hex,
113            }
114        ).lower()
115
116    def _get_labels(self) -> dict[str, str]:
117        labels = {
118            "io.goauthentik.outpost-uuid": self.outpost.pk.hex,
119        }
120        if self.outpost.config.docker_labels:
121            labels.update(self.outpost.config.docker_labels)
122        return labels
123
124    def _get_env(self) -> dict[str, str]:
125        return {
126            "AUTHENTIK_HOST": self.outpost.config.authentik_host.lower(),
127            "AUTHENTIK_INSECURE": str(self.outpost.config.authentik_host_insecure).lower(),
128            "AUTHENTIK_TOKEN": self.outpost.token.key,
129            "AUTHENTIK_HOST_BROWSER": self.outpost.config.authentik_host_browser,
130        }
131
132    def _comp_env(self, container: Container) -> bool:
133        """Check if container's env is equal to what we would set. Return true if container needs
134        to be rebuilt."""
135        should_be = self._get_env()
136        container_env = container.attrs.get("Config", {}).get("Env", [])
137        for key, expected_value in should_be.items():
138            entry = f"{key.upper()}={expected_value}"
139            if entry not in container_env:
140                return True
141        return False
142
143    def _comp_labels(self, container: Container) -> bool:
144        """Check if container's labels is equal to what we would set. Return true if container needs
145        to be rebuilt."""
146        should_be = self._get_labels()
147        for key, expected_value in should_be.items():
148            if key not in container.labels:
149                return True
150            if container.labels[key] != expected_value:
151                return True
152        return False
153
154    def _comp_ports(self, container: Container) -> bool:
155        """Check that the container has the correct ports exposed. Return true if container needs
156        to be rebuilt."""
157        # with TEST enabled, we use host-network
158        if settings.TEST:
159            return False
160        # When the container isn't running, the API doesn't report any port mappings
161        if container.status != "running":
162            return False
163        # {'3389/tcp': [
164        #   {'HostIp': '0.0.0.0', 'HostPort': '389'},
165        #   {'HostIp': '::', 'HostPort': '389'}
166        # ]}
167        # If no ports are mapped (either mapping disabled, or host network)
168        if not container.ports or not self.outpost.config.docker_map_ports:
169            return False
170        for port in self.deployment_ports:
171            key = f"{port.inner_port or port.port}/{port.protocol.lower()}"
172            if not container.ports.get(key, None):
173                return True
174            host_matching = False
175            for host_port in container.ports[key]:
176                host_matching = host_port.get("HostPort") == str(port.port)
177            if not host_matching:
178                return True
179        return False
180
181    def try_pull_image(self):
182        """Try to pull the image needed for this outpost based on the CONFIG
183        `outposts.container_image_base`, but fall back to known-good images"""
184        image = self.get_container_image()
185        try:
186            self.client.images.pull(image)
187        except DockerException:  # pragma: no cover
188            image = f"ghcr.io/goauthentik/{self.outpost.type}:{authentik_version()}"
189            self.client.images.pull(image)
190        return image
191
192    def _get_container(self) -> tuple[Container, bool]:
193        try:
194            return self.client.containers.get(self.name), False
195        except NotFound:
196            self.logger.info("(Re-)creating container...")
197            image_name = self.try_pull_image()
198            container_args = {
199                "image": image_name,
200                "name": self.name,
201                "detach": True,
202                "environment": self._get_env(),
203                "labels": self._get_labels(),
204                "restart_policy": {"Name": "unless-stopped"},
205                "network": self.outpost.config.docker_network,
206                "healthcheck": {
207                    "test": ["CMD", f"/{self.outpost.type}", "healthcheck"],
208                    "interval": 5 * 1_000 * 1_000_000,
209                    "retries": 20,
210                    "start_period": 3 * 1_000 * 1_000_000,
211                },
212            }
213            if self.outpost.config.docker_map_ports:
214                container_args["ports"] = {
215                    f"{port.inner_port or port.port}/{port.protocol.lower()}": str(port.port)
216                    for port in self.deployment_ports
217                }
218            if settings.TEST:
219                del container_args["ports"]
220                del container_args["network"]
221                container_args["network_mode"] = "host"
222            return (
223                self.client.containers.create(**container_args),
224                True,
225            )
226
227    def _migrate_container_name(self):  # pragma: no cover
228        """Migrate 2021.9 to 2021.10+"""
229        old_name = f"authentik-proxy-{self.outpost.uuid.hex}"
230        try:
231            old_container: Container = self.client.containers.get(old_name)
232            old_container.kill()
233            old_container.remove()
234        except NotFound:
235            return
236
237    def up(self, depth=1):
238        if self.outpost.managed == MANAGED_OUTPOST:
239            return None
240        if depth >= DOCKER_MAX_ATTEMPTS:
241            raise ControllerException("Giving up since we exceeded recursion limit.")
242        self._migrate_container_name()
243        try:
244            container, has_been_created = self._get_container()
245            if has_been_created:
246                container.start()
247                return None
248            # Check if the container is out of date, delete it and retry
249            if len(container.image.tags) > 0:
250                should_image = self.try_pull_image()
251                if should_image not in container.image.tags:  # pragma: no cover
252                    self.logger.info(
253                        "Container has mismatched image, re-creating...",
254                        has=container.image.tags,
255                        should=should_image,
256                    )
257                    self.down()
258                    return self.up(depth + 1)
259            # Check container's ports
260            if self._comp_ports(container):
261                self.logger.info("Container has mis-matched ports, re-creating...")
262                self.down()
263                return self.up(depth + 1)
264            # Check that container values match our values
265            if self._comp_env(container):
266                self.logger.info("Container has outdated config, re-creating...")
267                self.down()
268                return self.up(depth + 1)
269            # Check that container values match our values
270            if self._comp_labels(container):
271                self.logger.info("Container has outdated labels, re-creating...")
272                self.down()
273                return self.up(depth + 1)
274            if (
275                container.attrs.get("HostConfig", {})
276                .get("RestartPolicy", {})
277                .get("Name", "")
278                .lower()
279                != "unless-stopped"
280            ):
281                self.logger.info("Container has mis-matched restart policy, re-creating...")
282                self.down()
283                return self.up(depth + 1)
284            # Check that container is healthy
285            if container.status == "running" and container.attrs.get("State", {}).get(
286                "Health", {}
287            ).get("Status", "") not in ["healthy", "starting"]:
288                # At this point we know the config is correct, but the container isn't healthy,
289                # so we just restart it with the same config
290                if has_been_created:
291                    # Since we've just created the container, give it some time to start.
292                    # If its still not up by then, restart it
293                    self.logger.info("Container is unhealthy and new, giving it time to boot.")
294                    sleep(60)
295                self.logger.info("Container is unhealthy, restarting...")
296                container.restart()
297                return None
298            # Check that container is running
299            if container.status != "running":
300                self.logger.info("Container is not running, restarting...")
301                container.start()
302                return None
303            self.logger.info("Container is running")
304            return None
305        except DockerException as exc:
306            raise ControllerException(str(exc)) from exc
307
308    def down(self):
309        if self.outpost.managed == MANAGED_OUTPOST:
310            return
311        try:
312            container, _ = self._get_container()
313            if container.status == "running":
314                self.logger.info("Stopping container.")
315                container.kill()
316            self.logger.info("Removing container.")
317            container.remove(force=True)
318        except DockerException as exc:
319            raise ControllerException(str(exc)) from exc
320
321    def get_static_deployment(self) -> str:
322        """Generate docker-compose yaml for proxy, version 3.5"""
323        ports = [
324            f"{port.port}:{port.inner_port or port.port}/{port.protocol.lower()}"
325            for port in self.deployment_ports
326        ]
327        image_name = self.get_container_image()
328        compose = {
329            "version": "3.5",
330            "services": {
331                f"authentik_{self.outpost.type}": {
332                    "image": image_name,
333                    "ports": ports,
334                    "environment": {
335                        "AUTHENTIK_HOST": self.outpost.config.authentik_host,
336                        "AUTHENTIK_INSECURE": str(self.outpost.config.authentik_host_insecure),
337                        "AUTHENTIK_TOKEN": self.outpost.token.key,
338                        "AUTHENTIK_HOST_BROWSER": self.outpost.config.authentik_host_browser,
339                    },
340                    "labels": self._get_labels(),
341                }
342            },
343        }
344        return safe_dump(compose, default_flow_style=False)
DOCKER_MAX_ATTEMPTS = 10
class DockerClient(docker.client.DockerClient, authentik.outposts.controllers.base.BaseClient):
32class DockerClient(UpstreamDockerClient, BaseClient):
33    """Custom docker client, which can handle TLS and SSH from a database."""
34
35    tls: DockerInlineTLS | None
36    ssh: DockerInlineSSH | None
37
38    def __init__(self, connection: DockerServiceConnection):
39        self.tls = None
40        self.ssh = None
41        self.logger = get_logger()
42        if connection.local:
43            # Same result as DockerClient.from_env
44            super().__init__(**kwargs_from_env())
45        else:
46            parsed_url = urlparse(connection.url)
47            tls_config = False
48            if parsed_url.scheme == "ssh":
49                try:
50                    self.ssh = DockerInlineSSH(parsed_url.hostname, connection.tls_authentication)
51                    self.ssh.write()
52                except SSHManagedExternallyException as exc:
53                    # SSH config is managed externally
54                    self.logger.info(f"SSH Managed externally: {exc}")
55            else:
56                self.tls = DockerInlineTLS(
57                    verification_kp=connection.tls_verification,
58                    authentication_kp=connection.tls_authentication,
59                )
60                tls_config = self.tls.write()
61            try:
62                super().__init__(
63                    base_url=connection.url,
64                    tls=tls_config,
65                )
66            except SSHException as exc:
67                if self.ssh:
68                    self.ssh.cleanup()
69                raise ServiceConnectionInvalid(exc) from exc
70        # Ensure the client actually works
71        self.containers.list()
72
73    def fetch_state(self) -> OutpostServiceConnectionState:
74        try:
75            return OutpostServiceConnectionState(version=self.info()["ServerVersion"], healthy=True)
76        except ServiceConnectionInvalid, DockerException:
77            return OutpostServiceConnectionState(version="", healthy=False)
78
79    def __exit__(self, exc_type, exc_value, traceback):
80        if self.tls:
81            self.logger.debug("Cleaning up TLS")
82            self.tls.cleanup()
83        if self.ssh:
84            self.logger.debug("Cleaning up SSH")
85            self.ssh.cleanup()

Custom docker client, which can handle TLS and SSH from a database.

DockerClient(connection: authentik.outposts.models.DockerServiceConnection)
38    def __init__(self, connection: DockerServiceConnection):
39        self.tls = None
40        self.ssh = None
41        self.logger = get_logger()
42        if connection.local:
43            # Same result as DockerClient.from_env
44            super().__init__(**kwargs_from_env())
45        else:
46            parsed_url = urlparse(connection.url)
47            tls_config = False
48            if parsed_url.scheme == "ssh":
49                try:
50                    self.ssh = DockerInlineSSH(parsed_url.hostname, connection.tls_authentication)
51                    self.ssh.write()
52                except SSHManagedExternallyException as exc:
53                    # SSH config is managed externally
54                    self.logger.info(f"SSH Managed externally: {exc}")
55            else:
56                self.tls = DockerInlineTLS(
57                    verification_kp=connection.tls_verification,
58                    authentication_kp=connection.tls_authentication,
59                )
60                tls_config = self.tls.write()
61            try:
62                super().__init__(
63                    base_url=connection.url,
64                    tls=tls_config,
65                )
66            except SSHException as exc:
67                if self.ssh:
68                    self.ssh.cleanup()
69                raise ServiceConnectionInvalid(exc) from exc
70        # Ensure the client actually works
71        self.containers.list()
logger
def fetch_state(self) -> authentik.outposts.models.OutpostServiceConnectionState:
73    def fetch_state(self) -> OutpostServiceConnectionState:
74        try:
75            return OutpostServiceConnectionState(version=self.info()["ServerVersion"], healthy=True)
76        except ServiceConnectionInvalid, DockerException:
77            return OutpostServiceConnectionState(version="", healthy=False)

Get state, version info

class DockerController(authentik.outposts.controllers.base.BaseController):
 88class DockerController(BaseController):
 89    """Docker controller"""
 90
 91    client: DockerClient
 92
 93    container: Container
 94    connection: DockerServiceConnection
 95
 96    def __init__(self, outpost: Outpost, connection: DockerServiceConnection) -> None:
 97        super().__init__(outpost, connection)
 98        if outpost.managed == MANAGED_OUTPOST:
 99            return
100        try:
101            self.client = DockerClient(connection)
102        except DockerException as exc:
103            self.logger.warning(exc)
104            raise ControllerException from exc
105
106    @property
107    def name(self) -> str:
108        """Get the name of the object this reconciler manages"""
109        return (
110            self.outpost.config.object_naming_template
111            % {
112                "name": slugify(self.outpost.name),
113                "uuid": self.outpost.uuid.hex,
114            }
115        ).lower()
116
117    def _get_labels(self) -> dict[str, str]:
118        labels = {
119            "io.goauthentik.outpost-uuid": self.outpost.pk.hex,
120        }
121        if self.outpost.config.docker_labels:
122            labels.update(self.outpost.config.docker_labels)
123        return labels
124
125    def _get_env(self) -> dict[str, str]:
126        return {
127            "AUTHENTIK_HOST": self.outpost.config.authentik_host.lower(),
128            "AUTHENTIK_INSECURE": str(self.outpost.config.authentik_host_insecure).lower(),
129            "AUTHENTIK_TOKEN": self.outpost.token.key,
130            "AUTHENTIK_HOST_BROWSER": self.outpost.config.authentik_host_browser,
131        }
132
133    def _comp_env(self, container: Container) -> bool:
134        """Check if container's env is equal to what we would set. Return true if container needs
135        to be rebuilt."""
136        should_be = self._get_env()
137        container_env = container.attrs.get("Config", {}).get("Env", [])
138        for key, expected_value in should_be.items():
139            entry = f"{key.upper()}={expected_value}"
140            if entry not in container_env:
141                return True
142        return False
143
144    def _comp_labels(self, container: Container) -> bool:
145        """Check if container's labels is equal to what we would set. Return true if container needs
146        to be rebuilt."""
147        should_be = self._get_labels()
148        for key, expected_value in should_be.items():
149            if key not in container.labels:
150                return True
151            if container.labels[key] != expected_value:
152                return True
153        return False
154
155    def _comp_ports(self, container: Container) -> bool:
156        """Check that the container has the correct ports exposed. Return true if container needs
157        to be rebuilt."""
158        # with TEST enabled, we use host-network
159        if settings.TEST:
160            return False
161        # When the container isn't running, the API doesn't report any port mappings
162        if container.status != "running":
163            return False
164        # {'3389/tcp': [
165        #   {'HostIp': '0.0.0.0', 'HostPort': '389'},
166        #   {'HostIp': '::', 'HostPort': '389'}
167        # ]}
168        # If no ports are mapped (either mapping disabled, or host network)
169        if not container.ports or not self.outpost.config.docker_map_ports:
170            return False
171        for port in self.deployment_ports:
172            key = f"{port.inner_port or port.port}/{port.protocol.lower()}"
173            if not container.ports.get(key, None):
174                return True
175            host_matching = False
176            for host_port in container.ports[key]:
177                host_matching = host_port.get("HostPort") == str(port.port)
178            if not host_matching:
179                return True
180        return False
181
182    def try_pull_image(self):
183        """Try to pull the image needed for this outpost based on the CONFIG
184        `outposts.container_image_base`, but fall back to known-good images"""
185        image = self.get_container_image()
186        try:
187            self.client.images.pull(image)
188        except DockerException:  # pragma: no cover
189            image = f"ghcr.io/goauthentik/{self.outpost.type}:{authentik_version()}"
190            self.client.images.pull(image)
191        return image
192
193    def _get_container(self) -> tuple[Container, bool]:
194        try:
195            return self.client.containers.get(self.name), False
196        except NotFound:
197            self.logger.info("(Re-)creating container...")
198            image_name = self.try_pull_image()
199            container_args = {
200                "image": image_name,
201                "name": self.name,
202                "detach": True,
203                "environment": self._get_env(),
204                "labels": self._get_labels(),
205                "restart_policy": {"Name": "unless-stopped"},
206                "network": self.outpost.config.docker_network,
207                "healthcheck": {
208                    "test": ["CMD", f"/{self.outpost.type}", "healthcheck"],
209                    "interval": 5 * 1_000 * 1_000_000,
210                    "retries": 20,
211                    "start_period": 3 * 1_000 * 1_000_000,
212                },
213            }
214            if self.outpost.config.docker_map_ports:
215                container_args["ports"] = {
216                    f"{port.inner_port or port.port}/{port.protocol.lower()}": str(port.port)
217                    for port in self.deployment_ports
218                }
219            if settings.TEST:
220                del container_args["ports"]
221                del container_args["network"]
222                container_args["network_mode"] = "host"
223            return (
224                self.client.containers.create(**container_args),
225                True,
226            )
227
228    def _migrate_container_name(self):  # pragma: no cover
229        """Migrate 2021.9 to 2021.10+"""
230        old_name = f"authentik-proxy-{self.outpost.uuid.hex}"
231        try:
232            old_container: Container = self.client.containers.get(old_name)
233            old_container.kill()
234            old_container.remove()
235        except NotFound:
236            return
237
238    def up(self, depth=1):
239        if self.outpost.managed == MANAGED_OUTPOST:
240            return None
241        if depth >= DOCKER_MAX_ATTEMPTS:
242            raise ControllerException("Giving up since we exceeded recursion limit.")
243        self._migrate_container_name()
244        try:
245            container, has_been_created = self._get_container()
246            if has_been_created:
247                container.start()
248                return None
249            # Check if the container is out of date, delete it and retry
250            if len(container.image.tags) > 0:
251                should_image = self.try_pull_image()
252                if should_image not in container.image.tags:  # pragma: no cover
253                    self.logger.info(
254                        "Container has mismatched image, re-creating...",
255                        has=container.image.tags,
256                        should=should_image,
257                    )
258                    self.down()
259                    return self.up(depth + 1)
260            # Check container's ports
261            if self._comp_ports(container):
262                self.logger.info("Container has mis-matched ports, re-creating...")
263                self.down()
264                return self.up(depth + 1)
265            # Check that container values match our values
266            if self._comp_env(container):
267                self.logger.info("Container has outdated config, re-creating...")
268                self.down()
269                return self.up(depth + 1)
270            # Check that container values match our values
271            if self._comp_labels(container):
272                self.logger.info("Container has outdated labels, re-creating...")
273                self.down()
274                return self.up(depth + 1)
275            if (
276                container.attrs.get("HostConfig", {})
277                .get("RestartPolicy", {})
278                .get("Name", "")
279                .lower()
280                != "unless-stopped"
281            ):
282                self.logger.info("Container has mis-matched restart policy, re-creating...")
283                self.down()
284                return self.up(depth + 1)
285            # Check that container is healthy
286            if container.status == "running" and container.attrs.get("State", {}).get(
287                "Health", {}
288            ).get("Status", "") not in ["healthy", "starting"]:
289                # At this point we know the config is correct, but the container isn't healthy,
290                # so we just restart it with the same config
291                if has_been_created:
292                    # Since we've just created the container, give it some time to start.
293                    # If its still not up by then, restart it
294                    self.logger.info("Container is unhealthy and new, giving it time to boot.")
295                    sleep(60)
296                self.logger.info("Container is unhealthy, restarting...")
297                container.restart()
298                return None
299            # Check that container is running
300            if container.status != "running":
301                self.logger.info("Container is not running, restarting...")
302                container.start()
303                return None
304            self.logger.info("Container is running")
305            return None
306        except DockerException as exc:
307            raise ControllerException(str(exc)) from exc
308
309    def down(self):
310        if self.outpost.managed == MANAGED_OUTPOST:
311            return
312        try:
313            container, _ = self._get_container()
314            if container.status == "running":
315                self.logger.info("Stopping container.")
316                container.kill()
317            self.logger.info("Removing container.")
318            container.remove(force=True)
319        except DockerException as exc:
320            raise ControllerException(str(exc)) from exc
321
322    def get_static_deployment(self) -> str:
323        """Generate docker-compose yaml for proxy, version 3.5"""
324        ports = [
325            f"{port.port}:{port.inner_port or port.port}/{port.protocol.lower()}"
326            for port in self.deployment_ports
327        ]
328        image_name = self.get_container_image()
329        compose = {
330            "version": "3.5",
331            "services": {
332                f"authentik_{self.outpost.type}": {
333                    "image": image_name,
334                    "ports": ports,
335                    "environment": {
336                        "AUTHENTIK_HOST": self.outpost.config.authentik_host,
337                        "AUTHENTIK_INSECURE": str(self.outpost.config.authentik_host_insecure),
338                        "AUTHENTIK_TOKEN": self.outpost.token.key,
339                        "AUTHENTIK_HOST_BROWSER": self.outpost.config.authentik_host_browser,
340                    },
341                    "labels": self._get_labels(),
342                }
343            },
344        }
345        return safe_dump(compose, default_flow_style=False)

Docker controller

DockerController( outpost: authentik.outposts.models.Outpost, connection: authentik.outposts.models.DockerServiceConnection)
 96    def __init__(self, outpost: Outpost, connection: DockerServiceConnection) -> None:
 97        super().__init__(outpost, connection)
 98        if outpost.managed == MANAGED_OUTPOST:
 99            return
100        try:
101            self.client = DockerClient(connection)
102        except DockerException as exc:
103            self.logger.warning(exc)
104            raise ControllerException from exc
client: DockerClient
container: docker.models.containers.Container
name: str
106    @property
107    def name(self) -> str:
108        """Get the name of the object this reconciler manages"""
109        return (
110            self.outpost.config.object_naming_template
111            % {
112                "name": slugify(self.outpost.name),
113                "uuid": self.outpost.uuid.hex,
114            }
115        ).lower()

Get the name of the object this reconciler manages

def try_pull_image(self):
182    def try_pull_image(self):
183        """Try to pull the image needed for this outpost based on the CONFIG
184        `outposts.container_image_base`, but fall back to known-good images"""
185        image = self.get_container_image()
186        try:
187            self.client.images.pull(image)
188        except DockerException:  # pragma: no cover
189            image = f"ghcr.io/goauthentik/{self.outpost.type}:{authentik_version()}"
190            self.client.images.pull(image)
191        return image

Try to pull the image needed for this outpost based on the CONFIG outposts.container_image_base, but fall back to known-good images

def up(self, depth=1):
238    def up(self, depth=1):
239        if self.outpost.managed == MANAGED_OUTPOST:
240            return None
241        if depth >= DOCKER_MAX_ATTEMPTS:
242            raise ControllerException("Giving up since we exceeded recursion limit.")
243        self._migrate_container_name()
244        try:
245            container, has_been_created = self._get_container()
246            if has_been_created:
247                container.start()
248                return None
249            # Check if the container is out of date, delete it and retry
250            if len(container.image.tags) > 0:
251                should_image = self.try_pull_image()
252                if should_image not in container.image.tags:  # pragma: no cover
253                    self.logger.info(
254                        "Container has mismatched image, re-creating...",
255                        has=container.image.tags,
256                        should=should_image,
257                    )
258                    self.down()
259                    return self.up(depth + 1)
260            # Check container's ports
261            if self._comp_ports(container):
262                self.logger.info("Container has mis-matched ports, re-creating...")
263                self.down()
264                return self.up(depth + 1)
265            # Check that container values match our values
266            if self._comp_env(container):
267                self.logger.info("Container has outdated config, re-creating...")
268                self.down()
269                return self.up(depth + 1)
270            # Check that container values match our values
271            if self._comp_labels(container):
272                self.logger.info("Container has outdated labels, re-creating...")
273                self.down()
274                return self.up(depth + 1)
275            if (
276                container.attrs.get("HostConfig", {})
277                .get("RestartPolicy", {})
278                .get("Name", "")
279                .lower()
280                != "unless-stopped"
281            ):
282                self.logger.info("Container has mis-matched restart policy, re-creating...")
283                self.down()
284                return self.up(depth + 1)
285            # Check that container is healthy
286            if container.status == "running" and container.attrs.get("State", {}).get(
287                "Health", {}
288            ).get("Status", "") not in ["healthy", "starting"]:
289                # At this point we know the config is correct, but the container isn't healthy,
290                # so we just restart it with the same config
291                if has_been_created:
292                    # Since we've just created the container, give it some time to start.
293                    # If its still not up by then, restart it
294                    self.logger.info("Container is unhealthy and new, giving it time to boot.")
295                    sleep(60)
296                self.logger.info("Container is unhealthy, restarting...")
297                container.restart()
298                return None
299            # Check that container is running
300            if container.status != "running":
301                self.logger.info("Container is not running, restarting...")
302                container.start()
303                return None
304            self.logger.info("Container is running")
305            return None
306        except DockerException as exc:
307            raise ControllerException(str(exc)) from exc

Called by scheduled task to reconcile deployment/service/etc

def down(self):
309    def down(self):
310        if self.outpost.managed == MANAGED_OUTPOST:
311            return
312        try:
313            container, _ = self._get_container()
314            if container.status == "running":
315                self.logger.info("Stopping container.")
316                container.kill()
317            self.logger.info("Removing container.")
318            container.remove(force=True)
319        except DockerException as exc:
320            raise ControllerException(str(exc)) from exc

Handler to delete everything we've created

def get_static_deployment(self) -> str:
322    def get_static_deployment(self) -> str:
323        """Generate docker-compose yaml for proxy, version 3.5"""
324        ports = [
325            f"{port.port}:{port.inner_port or port.port}/{port.protocol.lower()}"
326            for port in self.deployment_ports
327        ]
328        image_name = self.get_container_image()
329        compose = {
330            "version": "3.5",
331            "services": {
332                f"authentik_{self.outpost.type}": {
333                    "image": image_name,
334                    "ports": ports,
335                    "environment": {
336                        "AUTHENTIK_HOST": self.outpost.config.authentik_host,
337                        "AUTHENTIK_INSECURE": str(self.outpost.config.authentik_host_insecure),
338                        "AUTHENTIK_TOKEN": self.outpost.token.key,
339                        "AUTHENTIK_HOST_BROWSER": self.outpost.config.authentik_host_browser,
340                    },
341                    "labels": self._get_labels(),
342                }
343            },
344        }
345        return safe_dump(compose, default_flow_style=False)

Generate docker-compose yaml for proxy, version 3.5