"""Outpost models""" from dataclasses import asdict, dataclass, field from datetime import datetime from typing import Dict, Iterable, List, Optional, Type, Union from uuid import uuid4 from dacite import from_dict from django.core.cache import cache from django.db import models, transaction from django.db.models.base import Model from django.forms.models import ModelForm from django.http import HttpRequest from django.utils.translation import gettext_lazy as _ from docker.client import DockerClient from docker.errors import DockerException from guardian.models import UserObjectPermission from guardian.shortcuts import assign_perm from kubernetes.client import VersionApi, VersionInfo from kubernetes.client.api_client import ApiClient from kubernetes.client.configuration import Configuration from kubernetes.client.exceptions import OpenApiException from kubernetes.config.config_exception import ConfigException from kubernetes.config.incluster_config import load_incluster_config from kubernetes.config.kube_config import load_kube_config_from_dict from model_utils.managers import InheritanceManager from packaging.version import LegacyVersion, Version, parse from structlog import get_logger from urllib3.exceptions import HTTPError from passbook import __version__ from passbook.core.models import Provider, Token, TokenIntents, User from passbook.crypto.models import CertificateKeyPair from passbook.lib.config import CONFIG from passbook.lib.models import InheritanceForeignKey from passbook.lib.sentry import SentryIgnoredException from passbook.lib.utils.template import render_to_string from passbook.outposts.docker_tls import DockerInlineTLS OUR_VERSION = parse(__version__) OUTPOST_HELLO_INTERVAL = 10 LOGGER = get_logger() class ServiceConnectionInvalid(SentryIgnoredException): """"Exception raised when a Service Connection has invalid parameters""" @dataclass class OutpostConfig: """Configuration an outpost uses to configure it self""" passbook_host: str passbook_host_insecure: bool = False log_level: str = CONFIG.y("log_level") error_reporting_enabled: bool = CONFIG.y_bool("error_reporting.enabled") error_reporting_environment: str = CONFIG.y( "error_reporting.environment", "customer" ) kubernetes_replicas: int = field(default=1) kubernetes_namespace: str = field(default="default") kubernetes_ingress_annotations: Dict[str, str] = field(default_factory=dict) kubernetes_ingress_secret_name: str = field(default="passbook-outpost") class OutpostModel(Model): """Base model for providers that need more objects than just themselves""" def get_required_objects(self) -> Iterable[models.Model]: """Return a list of all required objects""" return [self] class Meta: abstract = True class OutpostType(models.TextChoices): """Outpost types, currently only the reverse proxy is available""" PROXY = "proxy" def default_outpost_config(): """Get default outpost config""" return asdict(OutpostConfig(passbook_host="")) @dataclass class OutpostServiceConnectionState: """State of an Outpost Service Connection""" version: str healthy: bool class OutpostServiceConnection(models.Model): """Connection details for an Outpost Controller, like Docker or Kubernetes""" uuid = models.UUIDField(default=uuid4, editable=False, primary_key=True) name = models.TextField() local = models.BooleanField( default=False, help_text=_( ( "If enabled, use the local connection. Required Docker " "socket/Kubernetes Integration" ) ), ) objects = InheritanceManager() @property def state(self) -> OutpostServiceConnectionState: """Get state of service connection""" state_key = f"outpost_service_connection_{self.pk.hex}" state = cache.get(state_key, None) if not state: state = self._get_state() cache.set(state_key, state, timeout=0) return state def _get_state(self) -> OutpostServiceConnectionState: raise NotImplementedError @property def form(self) -> Type[ModelForm]: """Return Form class used to edit this object""" raise NotImplementedError class Meta: verbose_name = _("Outpost Service-Connection") verbose_name_plural = _("Outpost Service-Connections") class DockerServiceConnection(OutpostServiceConnection): """Service Connection to a Docker endpoint""" url = models.TextField() tls_verification = models.ForeignKey( CertificateKeyPair, null=True, blank=True, default=None, related_name="+", on_delete=models.SET_DEFAULT, help_text=_( ( "CA which the endpoint's Certificate is verified against. " "Can be left empty for no validation." ) ), ) tls_authentication = models.ForeignKey( CertificateKeyPair, null=True, blank=True, default=None, related_name="+", on_delete=models.SET_DEFAULT, help_text=_( "Certificate/Key used for authentication. Can be left empty for no authentication." ), ) @property def form(self) -> Type[ModelForm]: from passbook.outposts.forms import DockerServiceConnectionForm return DockerServiceConnectionForm def __str__(self) -> str: return f"Docker Service-Connection {self.name}" def client(self) -> DockerClient: """Get DockerClient""" try: client = None if self.local: client = DockerClient.from_env() else: client = DockerClient( base_url=self.url, tls=DockerInlineTLS( verification_kp=self.tls_verification, authentication_kp=self.tls_authentication, ).write(), ) client.containers.list() except DockerException as exc: LOGGER.error(exc) raise ServiceConnectionInvalid from exc return client def _get_state(self) -> OutpostServiceConnectionState: try: client = self.client() return OutpostServiceConnectionState( version=client.info()["ServerVersion"], healthy=True ) except ServiceConnectionInvalid: return OutpostServiceConnectionState(version="", healthy=False) class Meta: verbose_name = _("Docker Service-Connection") verbose_name_plural = _("Docker Service-Connections") class KubernetesServiceConnection(OutpostServiceConnection): """Service Connection to a Kubernetes cluster""" kubeconfig = models.JSONField( help_text=_( ( "Paste your kubeconfig here. passbook will automatically use " "the currently selected context." ) ) ) @property def form(self) -> Type[ModelForm]: from passbook.outposts.forms import KubernetesServiceConnectionForm return KubernetesServiceConnectionForm def __str__(self) -> str: return f"Kubernetes Service-Connection {self.name}" def _get_state(self) -> OutpostServiceConnectionState: try: client = self.client() api_instance = VersionApi(client) version: VersionInfo = api_instance.get_code() return OutpostServiceConnectionState( version=version.git_version, healthy=True ) except (OpenApiException, HTTPError): return OutpostServiceConnectionState(version="", healthy=False) def client(self) -> ApiClient: """Get Kubernetes client configured from kubeconfig""" config = Configuration() try: if self.local: load_incluster_config(client_configuration=config) else: load_kube_config_from_dict(self.kubeconfig, client_configuration=config) return ApiClient(config) except ConfigException as exc: raise ServiceConnectionInvalid from exc class Meta: verbose_name = _("Kubernetes Service-Connection") verbose_name_plural = _("Kubernetes Service-Connections") class Outpost(models.Model): """Outpost instance which manages a service user and token""" uuid = models.UUIDField(default=uuid4, editable=False, primary_key=True) name = models.TextField() type = models.TextField(choices=OutpostType.choices, default=OutpostType.PROXY) service_connection = InheritanceForeignKey( OutpostServiceConnection, default=None, null=True, blank=True, help_text=_( ( "Select Service-Connection passbook should use to manage this outpost. " "Leave empty if passbook should not handle the deployment." ) ), on_delete=models.SET_DEFAULT, ) _config = models.JSONField(default=default_outpost_config) providers = models.ManyToManyField(Provider) @property def config(self) -> OutpostConfig: """Load config as OutpostConfig object""" return from_dict(OutpostConfig, self._config) @config.setter def config(self, value): """Dump config into json""" self._config = asdict(value) @property def state_cache_prefix(self) -> str: """Key by which the outposts status is saved""" return f"outpost_{self.uuid.hex}_state" @property def state(self) -> List["OutpostState"]: """Get outpost's health status""" return OutpostState.for_outpost(self) @property def user_identifier(self): """Username for service user""" return f"pb-outpost-{self.uuid.hex}" @property def user(self) -> User: """Get/create user with access to all required objects""" users = User.objects.filter(username=self.user_identifier) if not users.exists(): user: User = User.objects.create(username=self.user_identifier) user.set_unusable_password() user.save() else: user = users.first() # To ensure the user only has the correct permissions, we delete all of them and re-add # the ones the user needs with transaction.atomic(): UserObjectPermission.objects.filter(user=user).delete() for model in self.get_required_objects(): code_name = f"{model._meta.app_label}.view_{model._meta.model_name}" assign_perm(code_name, user, model) return user @property def token_identifier(self) -> str: """Get Token identifier""" return f"pb-outpost-{self.pk}-api" @property def token(self) -> Token: """Get/create token for auto-generated user""" token = Token.filter_not_expired(user=self.user, intent=TokenIntents.INTENT_API) if token.exists(): return token.first() return Token.objects.create( user=self.user, identifier=self.token_identifier, intent=TokenIntents.INTENT_API, description=f"Autogenerated by passbook for Outpost {self.name}", expiring=False, ) def get_required_objects(self) -> Iterable[models.Model]: """Get an iterator of all objects the user needs read access to""" objects = [self] for provider in ( Provider.objects.filter(outpost=self).select_related().select_subclasses() ): if isinstance(provider, OutpostModel): objects.extend(provider.get_required_objects()) else: objects.append(provider) return objects def html_deployment_view(self, request: HttpRequest) -> Optional[str]: """return template and context modal to view token and other config info""" return render_to_string( "outposts/deployment_modal.html", {"outpost": self, "full_url": request.build_absolute_uri("/")}, ) def __str__(self) -> str: return f"Outpost {self.name}" @dataclass class OutpostState: """Outpost instance state, last_seen and version""" uid: str last_seen: Optional[datetime] = field(default=None) version: Optional[str] = field(default=None) version_should: Union[Version, LegacyVersion] = field(default=OUR_VERSION) _outpost: Optional[Outpost] = field(default=None) @property def version_outdated(self) -> bool: """Check if outpost version matches our version""" if not self.version: return False return parse(self.version) < OUR_VERSION @staticmethod def for_outpost(outpost: Outpost) -> List["OutpostState"]: """Get all states for an outpost""" keys = cache.keys(f"{outpost.state_cache_prefix}_*") states = [] for key in keys: channel = key.replace(f"{outpost.state_cache_prefix}_", "") states.append(OutpostState.for_channel(outpost, channel)) return states @staticmethod def for_channel(outpost: Outpost, channel: str) -> "OutpostState": """Get state for a single channel""" key = f"{outpost.state_cache_prefix}_{channel}" default_data = {"uid": channel} data = cache.get(key, default_data) if isinstance(data, str): cache.delete(key) data = default_data state = from_dict(OutpostState, data) state.uid = channel # pylint: disable=protected-access state._outpost = outpost return state def save(self, timeout=OUTPOST_HELLO_INTERVAL): """Save current state to cache""" full_key = f"{self._outpost.state_cache_prefix}_{self.uid}" return cache.set(full_key, asdict(self), timeout=timeout) def delete(self): """Manually delete from cache, used on channel disconnect""" full_key = f"{self._outpost.state_cache_prefix}_{self.uid}" cache.delete(full_key)