From c04d0a373a5e6c38e12e6fb7581687fee81f44d9 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Wed, 4 Nov 2020 13:01:38 +0100 Subject: [PATCH] admin: add views for outpost service-connections --- .../admin/templates/administration/base.html | 25 +++- .../outpost_service_connection/list.html | 125 ++++++++++++++++++ passbook/admin/urls.py | 32 ++++- .../views/outposts_service_connections.py | 83 ++++++++++++ ...operty_mapping.py => property_mappings.py} | 0 passbook/admin/views/providers.py | 4 +- passbook/api/v2/urls.py | 10 +- passbook/outposts/api.py | 2 +- passbook/outposts/apps.py | 9 +- passbook/outposts/controllers/kubernetes.py | 2 +- passbook/outposts/forms.py | 38 +++++- .../migrations/0010_service_connection.py | 46 ++++++- passbook/outposts/models.py | 54 +++++++- swagger.yaml | 12 +- 14 files changed, 413 insertions(+), 29 deletions(-) create mode 100644 passbook/admin/templates/administration/outpost_service_connection/list.html create mode 100644 passbook/admin/views/outposts_service_connections.py rename passbook/admin/views/{property_mapping.py => property_mappings.py} (100%) diff --git a/passbook/admin/templates/administration/base.html b/passbook/admin/templates/administration/base.html index ba4ce6e9f..c3fcf966e 100644 --- a/passbook/admin/templates/administration/base.html +++ b/passbook/admin/templates/administration/base.html @@ -46,11 +46,28 @@ {% trans 'Providers' %} -
  • - - {% trans 'Outposts' %} +
  • + {% trans 'Outposts' %} + + + +
    + +
  • +
    +

    + + {% trans 'Outpost Service-Connections' %} +

    +

    {% trans "Outpost Service-Connections define how passbook connects to external platforms to manage and deploy Outposts." %}

    +
    + +
    + +
    +{% endblock %} diff --git a/passbook/admin/urls.py b/passbook/admin/urls.py index e2c13505e..2400b6fe9 100644 --- a/passbook/admin/urls.py +++ b/passbook/admin/urls.py @@ -7,10 +7,11 @@ from passbook.admin.views import ( flows, groups, outposts, + outposts_service_connections, overview, policies, policies_bindings, - property_mapping, + property_mappings, providers, sources, stages, @@ -225,22 +226,22 @@ urlpatterns = [ # Property Mappings path( "property-mappings/", - property_mapping.PropertyMappingListView.as_view(), + property_mappings.PropertyMappingListView.as_view(), name="property-mappings", ), path( "property-mappings/create/", - property_mapping.PropertyMappingCreateView.as_view(), + property_mappings.PropertyMappingCreateView.as_view(), name="property-mapping-create", ), path( "property-mappings//update/", - property_mapping.PropertyMappingUpdateView.as_view(), + property_mappings.PropertyMappingUpdateView.as_view(), name="property-mapping-update", ), path( "property-mappings//delete/", - property_mapping.PropertyMappingDeleteView.as_view(), + property_mappings.PropertyMappingDeleteView.as_view(), name="property-mapping-delete", ), # Users @@ -312,6 +313,27 @@ urlpatterns = [ outposts.OutpostDeleteView.as_view(), name="outpost-delete", ), + # Outpost Service Connections + path( + "outposts/service_connections/", + outposts_service_connections.OutpostServiceConnectionListView.as_view(), + name="outpost-service-connections", + ), + path( + "outposts/service_connections/create/", + outposts_service_connections.OutpostServiceConnectionCreateView.as_view(), + name="outpost-service-connection-create", + ), + path( + "outposts/service_connections//update/", + outposts_service_connections.OutpostServiceConnectionUpdateView.as_view(), + name="outpost-service-connection-update", + ), + path( + "outposts/service_connections//delete/", + outposts_service_connections.OutpostServiceConnectionDeleteView.as_view(), + name="outpost-service-connection-delete", + ), # Tasks path( "tasks/", diff --git a/passbook/admin/views/outposts_service_connections.py b/passbook/admin/views/outposts_service_connections.py new file mode 100644 index 000000000..93fc6c519 --- /dev/null +++ b/passbook/admin/views/outposts_service_connections.py @@ -0,0 +1,83 @@ +"""passbook OutpostServiceConnection administration""" +from django.contrib.auth.mixins import LoginRequiredMixin +from django.contrib.auth.mixins import ( + PermissionRequiredMixin as DjangoPermissionRequiredMixin, +) +from django.contrib.messages.views import SuccessMessageMixin +from django.urls import reverse_lazy +from django.utils.translation import gettext as _ +from guardian.mixins import PermissionListMixin, PermissionRequiredMixin + +from passbook.admin.views.utils import ( + BackSuccessUrlMixin, + DeleteMessageView, + InheritanceCreateView, + InheritanceListView, + InheritanceUpdateView, + SearchListMixin, + UserPaginateListMixin, +) +from passbook.outposts.models import OutpostServiceConnection + + +class OutpostServiceConnectionListView( + LoginRequiredMixin, + PermissionListMixin, + UserPaginateListMixin, + SearchListMixin, + InheritanceListView, +): + """Show list of all outpost-service-connections""" + + model = OutpostServiceConnection + permission_required = "passbook_outposts.add_outpostserviceconnection" + template_name = "administration/outpost_service_connection/list.html" + ordering = "pk" + search_fields = ["pk", "name"] + + +class OutpostServiceConnectionCreateView( + SuccessMessageMixin, + BackSuccessUrlMixin, + LoginRequiredMixin, + DjangoPermissionRequiredMixin, + InheritanceCreateView, +): + """Create new OutpostServiceConnection""" + + model = OutpostServiceConnection + permission_required = "passbook_outposts.add_outpostserviceconnection" + + template_name = "generic/create.html" + success_url = reverse_lazy("passbook_admin:outpost-service-connections") + success_message = _("Successfully created OutpostServiceConnection") + + +class OutpostServiceConnectionUpdateView( + SuccessMessageMixin, + BackSuccessUrlMixin, + LoginRequiredMixin, + PermissionRequiredMixin, + InheritanceUpdateView, +): + """Update outpostserviceconnection""" + + model = OutpostServiceConnection + permission_required = "passbook_outposts.change_outpostserviceconnection" + + template_name = "generic/update.html" + success_url = reverse_lazy("passbook_admin:outpost-service-connections") + success_message = _("Successfully updated OutpostServiceConnection") + + +class OutpostServiceConnectionDeleteView( + LoginRequiredMixin, PermissionRequiredMixin, DeleteMessageView +): + """Delete outpostserviceconnection""" + + model = OutpostServiceConnection + permission_required = "passbook_outposts.delete_outpostserviceconnection" + + template_name = "generic/delete.html" + success_url = reverse_lazy("passbook_admin:outpost-service-connections") + success_message = _("Successfully deleted OutpostServiceConnection") diff --git a/passbook/admin/views/property_mapping.py b/passbook/admin/views/property_mappings.py similarity index 100% rename from passbook/admin/views/property_mapping.py rename to passbook/admin/views/property_mappings.py diff --git a/passbook/admin/views/providers.py b/passbook/admin/views/providers.py index fc85cd098..19584ad44 100644 --- a/passbook/admin/views/providers.py +++ b/passbook/admin/views/providers.py @@ -32,8 +32,8 @@ class ProviderListView( model = Provider permission_required = "passbook_core.add_provider" template_name = "administration/provider/list.html" - ordering = "id" - search_fields = ["id", "name"] + ordering = "pk" + search_fields = ["pk", "name"] class ProviderCreateView( diff --git a/passbook/api/v2/urls.py b/passbook/api/v2/urls.py index cc60675ac..39dbe1cb7 100644 --- a/passbook/api/v2/urls.py +++ b/passbook/api/v2/urls.py @@ -19,7 +19,11 @@ from passbook.core.api.tokens import TokenViewSet from passbook.core.api.users import UserViewSet from passbook.crypto.api import CertificateKeyPairViewSet from passbook.flows.api import FlowStageBindingViewSet, FlowViewSet, StageViewSet -from passbook.outposts.api import OutpostViewSet, DockerServiceConnectionViewSet, KubernetesServiceConnectionViewSet +from passbook.outposts.api import ( + DockerServiceConnectionViewSet, + KubernetesServiceConnectionViewSet, + OutpostViewSet, +) from passbook.policies.api import PolicyBindingViewSet, PolicyViewSet from passbook.policies.dummy.api import DummyPolicyViewSet from passbook.policies.expiry.api import PasswordExpiryPolicyViewSet @@ -67,7 +71,9 @@ router.register("core/tokens", TokenViewSet) router.register("outposts/outposts", OutpostViewSet) router.register("outposts/service_connections/docker", DockerServiceConnectionViewSet) -router.register("outposts/service_connections/kubernetes", KubernetesServiceConnectionViewSet) +router.register( + "outposts/service_connections/kubernetes", KubernetesServiceConnectionViewSet +) router.register("outposts/proxy", ProxyOutpostConfigViewSet) router.register("flows/instances", FlowViewSet) diff --git a/passbook/outposts/api.py b/passbook/outposts/api.py index d55a3258d..e86f4472d 100644 --- a/passbook/outposts/api.py +++ b/passbook/outposts/api.py @@ -49,7 +49,7 @@ class KubernetesServiceConnectionSerializer(ModelSerializer): class Meta: model = KubernetesServiceConnection - fields = ["pk", "name", "local", "config"] + fields = ["pk", "name", "local", "kubeconfig"] class KubernetesServiceConnectionViewSet(ModelViewSet): diff --git a/passbook/outposts/apps.py b/passbook/outposts/apps.py index e8cca5e66..c5934a1d5 100644 --- a/passbook/outposts/apps.py +++ b/passbook/outposts/apps.py @@ -6,6 +6,7 @@ from pathlib import Path from socket import gethostname from urllib.parse import urlparse +import yaml from django.apps import AppConfig from django.db import ProgrammingError from docker.constants import DEFAULT_UNIX_SOCKET @@ -43,19 +44,21 @@ class PassbookOutpostConfig(AppConfig): if not KubernetesServiceConnection.objects.filter(local=True).exists(): LOGGER.debug("Created Service Connection for in-cluster") KubernetesServiceConnection.objects.create( - name="Local Kubernetes Cluster", local=True, config={} + name="Local Kubernetes Cluster", local=True, kubeconfig={} ) # For development, check for the existence of a kubeconfig file kubeconfig_path = expanduser(KUBE_CONFIG_DEFAULT_LOCATION) if Path(kubeconfig_path).exists(): LOGGER.debug("Detected kubeconfig") + kubeconfig_local_name = f"k8s-{gethostname()}" if not KubernetesServiceConnection.objects.filter( - name=gethostname() + name=kubeconfig_local_name ).exists(): LOGGER.debug("Creating kubeconfig Service Connection") with open(kubeconfig_path, "r") as _kubeconfig: KubernetesServiceConnection.objects.create( - name=gethostname(), config=_kubeconfig.read() + name=kubeconfig_local_name, + kubeconfig=yaml.safe_load(_kubeconfig), ) unix_socket_path = urlparse(DEFAULT_UNIX_SOCKET).path socket = Path(unix_socket_path) diff --git a/passbook/outposts/controllers/kubernetes.py b/passbook/outposts/controllers/kubernetes.py index 3ccbde6f5..3740400e7 100644 --- a/passbook/outposts/controllers/kubernetes.py +++ b/passbook/outposts/controllers/kubernetes.py @@ -33,7 +33,7 @@ class KubernetesController(BaseController): if self.connection.local: load_incluster_config() else: - load_kube_config_from_dict(self.connection.config) + load_kube_config_from_dict(self.connection.kubeconfig) except ConfigException: load_kube_config() self.reconcilers = { diff --git a/passbook/outposts/forms.py b/passbook/outposts/forms.py index 572f2d276..a0de27461 100644 --- a/passbook/outposts/forms.py +++ b/passbook/outposts/forms.py @@ -4,7 +4,11 @@ from django import forms from django.utils.translation import gettext_lazy as _ from passbook.admin.fields import CodeMirrorWidget, YAMLField -from passbook.outposts.models import Outpost +from passbook.outposts.models import ( + DockerServiceConnection, + KubernetesServiceConnection, + Outpost, +) from passbook.providers.proxy.models import ProxyProvider @@ -33,3 +37,35 @@ class OutpostForm(forms.ModelForm): "_config": YAMLField, } labels = {"_config": _("Configuration")} + + +class DockerServiceConnectionForm(forms.ModelForm): + """Docker service-connection form""" + + class Meta: + + model = DockerServiceConnection + fields = ["name", "local", "url", "tls"] + widgets = { + "name": forms.TextInput, + } + + +class KubernetesServiceConnectionForm(forms.ModelForm): + """Kubernetes service-connection form""" + + class Meta: + + model = KubernetesServiceConnection + fields = [ + "name", + "local", + "kubeconfig", + ] + widgets = { + "name": forms.TextInput, + "kubeconfig": CodeMirrorWidget, + } + field_classes = { + "kubeconfig": YAMLField, + } diff --git a/passbook/outposts/migrations/0010_service_connection.py b/passbook/outposts/migrations/0010_service_connection.py index d35c96e7a..f627d1846 100644 --- a/passbook/outposts/migrations/0010_service_connection.py +++ b/passbook/outposts/migrations/0010_service_connection.py @@ -7,6 +7,8 @@ from django.apps.registry import Apps from django.db import migrations, models from django.db.backends.base.schema import BaseDatabaseSchemaEditor +import passbook.lib.models + def migrate_to_service_connection(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): db_alias = schema_editor.connection.alias @@ -98,7 +100,7 @@ class Migration(migrations.Migration): to="passbook_outposts.outpostserviceconnection", ), ), - ("config", models.JSONField()), + ("kubeconfig", models.JSONField()), ], bases=("passbook_outposts.outpostserviceconnection",), ), @@ -119,4 +121,46 @@ class Migration(migrations.Migration): model_name="outpost", name="deployment_type", ), + migrations.AlterModelOptions( + name="dockerserviceconnection", + options={ + "verbose_name": "Docker Service-Connection", + "verbose_name_plural": "Docker Service-Connections", + }, + ), + migrations.AlterModelOptions( + name="kubernetesserviceconnection", + options={ + "verbose_name": "Kubernetes Service-Connection", + "verbose_name_plural": "Kubernetes Service-Connections", + }, + ), + migrations.AlterField( + model_name="outpost", + name="service_connection", + field=passbook.lib.models.InheritanceForeignKey( + blank=True, + default=None, + help_text="Select Service-Connection passbook should use to manage this outpost. Leave empty if passbook should not handle the deployment.", + null=True, + on_delete=django.db.models.deletion.SET_DEFAULT, + to="passbook_outposts.outpostserviceconnection", + ), + ), + migrations.AlterModelOptions( + name="outpostserviceconnection", + options={ + "verbose_name": "Outpost Service-Connection", + "verbose_name_plural": "Outpost Service-Connections", + }, + ), + migrations.AlterField( + model_name="kubernetesserviceconnection", + name="kubeconfig", + field=models.JSONField( + default=None, + help_text="Paste your kubeconfig here. passbook will automatically use the currently selected context.", + ), + preserve_default=False, + ), ] diff --git a/passbook/outposts/models.py b/passbook/outposts/models.py index 1ad756ab5..2e47b12b7 100644 --- a/passbook/outposts/models.py +++ b/passbook/outposts/models.py @@ -1,13 +1,14 @@ """Outpost models""" from dataclasses import asdict, dataclass, field from datetime import datetime -from typing import Dict, Iterable, List, Optional, Union +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 guardian.models import UserObjectPermission @@ -86,18 +87,63 @@ class OutpostServiceConnection(models.Model): objects = InheritanceManager() + @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""" + """Service Connection to a Docker endpoint""" url = models.TextField() tls = models.BooleanField() + @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}" + + class Meta: + + verbose_name = _("Docker Service-Connection") + verbose_name_plural = _("Docker Service-Connections") + class KubernetesServiceConnection(OutpostServiceConnection): - """Service Connection to a kubernetes cluster""" + """Service Connection to a Kubernetes cluster""" - config = models.JSONField() + 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}" + + class Meta: + + verbose_name = _("Kubernetes Service-Connection") + verbose_name_plural = _("Kubernetes Service-Connections") class Outpost(models.Model): diff --git a/swagger.yaml b/swagger.yaml index 38f1e700c..b22ad3467 100755 --- a/swagger.yaml +++ b/swagger.yaml @@ -1476,7 +1476,7 @@ paths: parameters: - name: uuid in: path - description: A UUID string identifying this docker service connection. + description: A UUID string identifying this Docker Service-Connection. required: true type: string format: uuid @@ -1603,7 +1603,7 @@ paths: parameters: - name: uuid in: path - description: A UUID string identifying this kubernetes service connection. + description: A UUID string identifying this Kubernetes Service-Connection. required: true type: string format: uuid @@ -6888,7 +6888,7 @@ definitions: description: KubernetesServiceConnection Serializer required: - name - - config + - kubeconfig type: object properties: pk: @@ -6905,8 +6905,10 @@ definitions: description: If enabled, use the local connection. Required Docker socket/Kubernetes Integration type: boolean - config: - title: Config + kubeconfig: + title: Kubeconfig + description: Paste your kubeconfig here. passbook will automatically use the + currently selected context. type: object Policy: description: Policy Serializer