346 lines
10 KiB
Python
346 lines
10 KiB
Python
import kubernetes
|
|
from django.core.exceptions import ValidationError
|
|
from django.db import models
|
|
from django.utils.translation import gettext_lazy as _
|
|
from encrypted_fields.fields import EncryptedJSONField
|
|
from kubernetes import config
|
|
from kubernetes.client.rest import ApiException
|
|
|
|
|
|
class ServiceCategory(models.Model):
|
|
"""
|
|
Categories for services, e.g. "Databases", "Storage", "Compute".
|
|
"""
|
|
|
|
name = models.CharField(max_length=100, verbose_name=_("Name"))
|
|
description = models.TextField(blank=True, verbose_name=_("Description"))
|
|
logo = models.ImageField(
|
|
upload_to="public/service_categories",
|
|
blank=True,
|
|
null=True,
|
|
verbose_name=_("Logo"),
|
|
)
|
|
|
|
parent = models.ForeignKey(
|
|
to="self",
|
|
on_delete=models.CASCADE,
|
|
related_name="children",
|
|
blank=True,
|
|
null=True,
|
|
verbose_name=_("Parent"),
|
|
)
|
|
|
|
class Meta:
|
|
verbose_name = _("Service category")
|
|
verbose_name_plural = _("Service categories")
|
|
|
|
def __str__(self):
|
|
return self.name
|
|
|
|
|
|
class Service(models.Model):
|
|
"""
|
|
A service that can be offered, e.g. "PostgreSQL", "MinIO", "Kubernetes".
|
|
"""
|
|
|
|
name = models.CharField(max_length=100, verbose_name=_("Name"))
|
|
slug = models.SlugField(max_length=100, verbose_name=_("URL slug"), unique=True)
|
|
category = models.ForeignKey(
|
|
to="ServiceCategory",
|
|
on_delete=models.PROTECT,
|
|
related_name="services",
|
|
verbose_name=_("Category"),
|
|
)
|
|
description = models.TextField(blank=True, verbose_name=_("Description"))
|
|
logo = models.ImageField(
|
|
upload_to="public/services", blank=True, null=True, verbose_name=_("Logo")
|
|
)
|
|
# TODO schema
|
|
external_links = models.JSONField(
|
|
null=True, blank=True, verbose_name=_("External links")
|
|
)
|
|
|
|
class Meta:
|
|
verbose_name = _("Service")
|
|
verbose_name_plural = _("Services")
|
|
|
|
def __str__(self):
|
|
return self.name
|
|
|
|
|
|
def validate_dict(data, required_fields=None, allow_empty=True):
|
|
if not data:
|
|
if allow_empty:
|
|
return
|
|
raise ValidationError(_("Data may not be empty!"))
|
|
|
|
missing_fields = required_fields - set(data)
|
|
if missing_fields:
|
|
raise ValidationError(
|
|
_("Missing required fields in API credentials: %(fields)s"),
|
|
params={"fields": ", ".join(missing_fields)},
|
|
)
|
|
|
|
|
|
def validate_api_credentials(value):
|
|
required_fields = ("certificate-authority-data", "server", "token")
|
|
return validate_dict(value, required_fields)
|
|
|
|
|
|
class ControlPlane(models.Model):
|
|
name = models.CharField(max_length=100, verbose_name=_("Name"))
|
|
description = models.TextField(blank=True, verbose_name=_("Description"))
|
|
# Either contains the fields "certificate_authority_data", "server" and "token", or is empty
|
|
api_credentials = EncryptedJSONField(
|
|
verbose_name=_("API credentials"),
|
|
help_text="Required fields: certificate-authority-data, server (URL), token",
|
|
validators=[validate_api_credentials],
|
|
)
|
|
|
|
cloud_provider = models.ForeignKey(
|
|
to="CloudProvider",
|
|
on_delete=models.PROTECT,
|
|
related_name="control_planes",
|
|
verbose_name=_("Cloud provider"),
|
|
)
|
|
|
|
class Meta:
|
|
verbose_name = _("Control plane")
|
|
verbose_name_plural = _("Control planes")
|
|
|
|
def __str__(self):
|
|
return self.name
|
|
|
|
@property
|
|
def kubernetes_config(self):
|
|
conf = kubernetes.client.Configuration()
|
|
user_name = "servala-user"
|
|
|
|
config_dict = {
|
|
"apiVersion": "v1",
|
|
"clusters": [
|
|
{
|
|
"cluster": {
|
|
"certificate-authority-data": self.api_credentials[
|
|
"certificate-authority-data"
|
|
],
|
|
"server": self.api_credentials["server"],
|
|
},
|
|
"name": self.name,
|
|
},
|
|
],
|
|
"contexts": [
|
|
{
|
|
"context": {
|
|
"cluster": self.name,
|
|
"namespace": "default",
|
|
"user": user_name,
|
|
},
|
|
"name": self.name,
|
|
}
|
|
],
|
|
"current-context": self.name,
|
|
"kind": "Config",
|
|
"preferences": {},
|
|
"users": [
|
|
{
|
|
"name": user_name,
|
|
"user": {"token": self.api_credentials["token"]},
|
|
}
|
|
],
|
|
}
|
|
config.load_kube_config_from_dict(
|
|
config_dict=config_dict,
|
|
client_configuration=conf,
|
|
)
|
|
return conf
|
|
|
|
def get_kubernetes_client(self):
|
|
return kubernetes.client.ApiClient(self.kubernetes_config)
|
|
|
|
def test_connection(self):
|
|
if not self.api_credentials:
|
|
return False, _("No API credentials provided")
|
|
|
|
try:
|
|
v1 = kubernetes.client.CoreV1Api(self.get_kubernetes_client())
|
|
namespace_count = len(v1.list_namespace().items)
|
|
|
|
return True, _(
|
|
"Successfully connected to Kubernetes API. Found {} namespaces."
|
|
).format(namespace_count)
|
|
except ApiException as e:
|
|
return False, _("API error: {}").format(str(e))
|
|
except Exception as e:
|
|
return False, _("Connection error: {}").format(str(e))
|
|
|
|
|
|
class CloudProvider(models.Model):
|
|
"""
|
|
A cloud provider, e.g. "Exoscale".
|
|
"""
|
|
|
|
name = models.CharField(max_length=100, verbose_name=_("Name"))
|
|
description = models.TextField(blank=True, verbose_name=_("Description"))
|
|
logo = models.ImageField(
|
|
upload_to="public/service_providers",
|
|
blank=True,
|
|
null=True,
|
|
verbose_name=_("Logo"),
|
|
)
|
|
external_links = models.JSONField(
|
|
null=True, blank=True, verbose_name=_("External links")
|
|
)
|
|
|
|
class Meta:
|
|
verbose_name = _("Cloud provider")
|
|
verbose_name_plural = _("Cloud providers")
|
|
|
|
def __str__(self):
|
|
return self.name
|
|
|
|
|
|
class Plan(models.Model):
|
|
"""
|
|
Each service offering can have multiple plans, e.g. for different tiers.
|
|
"""
|
|
|
|
name = models.CharField(max_length=100, verbose_name=_("Name"))
|
|
description = models.TextField(blank=True, verbose_name=_("Description"))
|
|
# TODO schema
|
|
features = models.JSONField(verbose_name=_("Features"), null=True, blank=True)
|
|
# TODO schema
|
|
pricing = models.JSONField(verbose_name=_("Pricing"), null=True, blank=True)
|
|
term = models.PositiveIntegerField(
|
|
verbose_name=_("Term"), help_text=_("Term in months")
|
|
)
|
|
service_offering = models.ForeignKey(
|
|
to="ServiceOffering",
|
|
on_delete=models.PROTECT,
|
|
related_name="plans",
|
|
verbose_name=_("Service offering"),
|
|
)
|
|
|
|
class Meta:
|
|
verbose_name = _("Plan")
|
|
verbose_name_plural = _("Plans")
|
|
|
|
def __str__(self):
|
|
return self.name
|
|
|
|
|
|
def validate_api_definition(value):
|
|
required_fields = ("group", "version", "kind")
|
|
return validate_dict(value, required_fields)
|
|
|
|
|
|
class ServiceDefinition(models.Model):
|
|
"""
|
|
Configuration/service implementation: contains information on which
|
|
CompositeResourceDefinition (aka XRD) implements a service on a ControlPlane.
|
|
|
|
Is required in order to query the OpenAPI spec for dynamic form generation.
|
|
"""
|
|
|
|
name = models.CharField(max_length=100, verbose_name=_("Name"))
|
|
description = models.TextField(blank=True, verbose_name=_("Description"))
|
|
api_definition = models.JSONField(
|
|
verbose_name=_("API Definition"),
|
|
help_text=_("Contains group, version, and kind information"),
|
|
null=True,
|
|
blank=True,
|
|
)
|
|
service = models.ForeignKey(
|
|
to="Service",
|
|
on_delete=models.CASCADE,
|
|
related_name="service_definitions",
|
|
verbose_name=_("Service"),
|
|
)
|
|
|
|
@property
|
|
def control_planes(self):
|
|
return ControlPlane.objects.filter(
|
|
offering_connections__service_definition=self
|
|
).distinct()
|
|
|
|
@property
|
|
def service_offerings(self):
|
|
return ServiceOffering.objects.filter(
|
|
control_plane_connections__service_definition=self
|
|
).distinct()
|
|
|
|
class Meta:
|
|
verbose_name = _("Service definition")
|
|
verbose_name_plural = _("Service definitions")
|
|
|
|
def __str__(self):
|
|
return self.name
|
|
|
|
|
|
class ServiceOfferingControlPlane(models.Model):
|
|
"""
|
|
Each combination of ServiceOffering and ControlPlane can have a different
|
|
ServiceDefinition, which is here modeled as the "through" model.
|
|
"""
|
|
|
|
service_offering = models.ForeignKey(
|
|
to="ServiceOffering",
|
|
on_delete=models.CASCADE,
|
|
related_name="control_plane_connections",
|
|
verbose_name=_("Service offering"),
|
|
)
|
|
control_plane = models.ForeignKey(
|
|
to="ControlPlane",
|
|
on_delete=models.CASCADE,
|
|
related_name="offering_connections",
|
|
verbose_name=_("Control plane"),
|
|
)
|
|
service_definition = models.ForeignKey(
|
|
to="ServiceDefinition",
|
|
on_delete=models.PROTECT,
|
|
related_name="offering_control_planes",
|
|
verbose_name=_("Service definition"),
|
|
)
|
|
|
|
class Meta:
|
|
verbose_name = _("Service offering control plane connection")
|
|
verbose_name_plural = _("Service offering control planes connections")
|
|
unique_together = [("service_offering", "control_plane")]
|
|
|
|
def __str__(self):
|
|
return f"{self.service_offering} on {self.control_plane} with {self.service_definition}"
|
|
|
|
|
|
class ServiceOffering(models.Model):
|
|
"""
|
|
A service offering, e.g. "PostgreSQL on AWS", "MinIO on GCP".
|
|
"""
|
|
|
|
service = models.ForeignKey(
|
|
to="Service",
|
|
on_delete=models.PROTECT,
|
|
related_name="offerings",
|
|
verbose_name=_("Service"),
|
|
)
|
|
provider = models.ForeignKey(
|
|
to="CloudProvider",
|
|
on_delete=models.PROTECT,
|
|
related_name="offerings",
|
|
verbose_name=_("Provider"),
|
|
)
|
|
control_planes = models.ManyToManyField(
|
|
to="ControlPlane",
|
|
through="ServiceOfferingControlPlane",
|
|
related_name="offerings",
|
|
verbose_name=_("Control planes"),
|
|
)
|
|
description = models.TextField(blank=True, verbose_name=_("Description"))
|
|
|
|
class Meta:
|
|
verbose_name = _("Service offering")
|
|
verbose_name_plural = _("Service offerings")
|
|
|
|
def __str__(self):
|
|
return _("{service_name} at {provider_name}").format(
|
|
service_name=self.service.name, provider_name=self.provider.name
|
|
)
|