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
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()
tls: authentik.outposts.docker_tls.DockerInlineTLS | None
ssh: authentik.outposts.docker_ssh.DockerInlineSSH | None
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
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
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