diff --git a/.env.example b/.env.example index 940a6d0..829b56e 100644 --- a/.env.example +++ b/.env.example @@ -4,10 +4,17 @@ # When the environment is "development", DEBUG is set to True. SERVALA_ENVIRONMENT='development' -# Set PREVIOUS_SECRET_KEY when rotating to a new secret key in order to not expire all sessions +# Set SERVALA_PREVIOUS_SECRET_KEY when rotating to a new secret key in order to not expire all sessions and to remain able to read encrypted fields! +# In order to retire the previous key, run the ``reencrypt_fields`` command. Once you drop the previous secret key from +# the rotation, all sessions that still rely on that key will be invalidated (i.e., users will have to log in again). # SERVALA_PREVIOUS_SECRET_KEY='' SERVALA_SECRET_KEY='django-insecure-8sl^1&1f-$3%w7cf)q(rcvi4jo(#s3ug-@be0ooc2ioep*&%7@' +# Set SERVALA_PREVIOUS_SALT_KEY when rotating to a new salt in order to remain able to read encrypted fields! +# In order to retire the previous key, run the ``reencrypt_fields`` command. +# SERVALA_PREVIOUS_SALT_KEY='' +SERVALA_SALT_KEY='eed6UaCi3euZojai5Iequ8ochookun1o' + # Set the allowed hosts as comma-separated list. # Use a leading dot to match a domain and all subdomains. # Leave or unset in the development environment in order to accept localhost names. diff --git a/README.md b/README.md index 8959c00..a30699a 100644 --- a/README.md +++ b/README.md @@ -91,3 +91,20 @@ See `.forgejo/workflows/build-deploy-staging.yaml` for the actual workflow. Deployment files are in the `deployment/kustomize` folder and makes use of [Kustomize](https://kustomize.io/) to account for differences between the deployment stages. Stages are configured with overlays in `deployment/kustomize/overlays/$environment`. +## Maintenance and management commands + +You can interface with the Django server and project by running commands like this: + +```bash +uv run --env-file=.env src/manage.py COMMAND +``` + +Useful commands: + +- ``migrate``: Make sure database migrations are applied. +- ``showmigrations``: Show current database migrations status. Good for debugging. +- ``runserver``: Run development server +- ``clearsessions``: Clear away expired user sessions. Recommended to run regularly, e.g. weekly or monthly (doesn’t + need to be frequent, but otherwise, the database is going to bloat eventually) +- ``reencrypt_fields``: Run after you changed your ``SERVALA_SECRET_KEY`` or ``SERVALA_SALT_KEY`` in order to use the + new keys, and be able to retire the previous ones. diff --git a/docs/modules/ROOT/pages/database-diagram.adoc b/docs/modules/ROOT/pages/database-diagram.adoc index 7a37839..a5254e3 100644 --- a/docs/modules/ROOT/pages/database-diagram.adoc +++ b/docs/modules/ROOT/pages/database-diagram.adoc @@ -50,7 +50,6 @@ erDiagram ControlPlane { string name string description - string k8s_api_endpoint json api_credentials } diff --git a/pyproject.toml b/pyproject.toml index bf02fbb..71f4522 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,8 +9,10 @@ dependencies = [ "cryptography>=44.0.2", "django==5.2b1", "django-allauth>=65.5.0", + "django-fernet-encrypted-fields>=0.3.0", "django-scopes>=2.0.0", "django-template-partials>=24.4", + "kubernetes>=32.0.1", "pillow>=11.1.0", "psycopg2-binary>=2.9.10", "pyjwt>=2.10.1", diff --git a/src/servala/core/admin.py b/src/servala/core/admin.py index 2ecdb69..d718fe0 100644 --- a/src/servala/core/admin.py +++ b/src/servala/core/admin.py @@ -1,6 +1,7 @@ -from django.contrib import admin +from django.contrib import admin, messages from django.utils.translation import gettext_lazy as _ +from servala.core.forms import ControlPlaneAdminForm from servala.core.models import ( BillingEntity, CloudProvider, @@ -110,10 +111,55 @@ class CloudProviderAdmin(admin.ModelAdmin): @admin.register(ControlPlane) class ControlPlaneAdmin(admin.ModelAdmin): - list_display = ("name", "cloud_provider", "k8s_api_endpoint") + form = ControlPlaneAdminForm + list_display = ("name", "cloud_provider") list_filter = ("cloud_provider",) - search_fields = ("name", "description", "k8s_api_endpoint") + search_fields = ("name", "description") autocomplete_fields = ("cloud_provider",) + actions = ["test_kubernetes_connection"] + + fieldsets = ( + ( + None, + {"fields": ("name", "description", "cloud_provider")}, + ), + ( + _("API Credentials"), + { + "fields": ("certificate_authority_data", "server", "token"), + "description": _( + "All three credential fields must be provided together or left empty." + ), + }, + ), + ) + + def get_exclude(self, request, obj=None): + # Exclude the original api_credentials field as we're using our custom fields + return ["api_credentials"] + + def response_change(self, request, obj): + result = super().response_change(request, obj) + if "_save_and_test" in request.POST: + success, message = obj.test_connection() + if success: + messages.success(request, message) + else: + messages.warning(request, message) + return result + + def test_kubernetes_connection(self, request, queryset): + """Admin action to test Kubernetes connection for selected control planes""" + for control_plane in queryset: + success, message = control_plane.test_connection() + message = f"{control_plane.name}: {message}" + + if success: + messages.success(request, message) + else: + messages.warning(request, message) + + test_kubernetes_connection.short_description = _("Test Kubernetes connection") @admin.register(Plan) diff --git a/src/servala/core/forms.py b/src/servala/core/forms.py new file mode 100644 index 0000000..d7740ae --- /dev/null +++ b/src/servala/core/forms.py @@ -0,0 +1,72 @@ +from django import forms +from django.utils.translation import gettext_lazy as _ + +from servala.core.models import ControlPlane + + +class ControlPlaneAdminForm(forms.ModelForm): + certificate_authority_data = forms.CharField( + widget=forms.Textarea, + required=False, + help_text=_("Certificate authority data for the Kubernetes API"), + ) + server = forms.URLField( + required=False, + help_text=_("Server URL for the Kubernetes API"), + ) + token = forms.CharField( + widget=forms.Textarea, + required=False, + help_text=_("Authentication token for the Kubernetes API"), + ) + + class Meta: + model = ControlPlane + fields = "__all__" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # If we have existing api_credentials, populate the individual fields + if self.instance.pk and self.instance.api_credentials: + creds = self.instance.api_credentials + self.fields["certificate_authority_data"].initial = creds.get( + "certificate-authority-data", "" + ) + self.fields["server"].initial = creds.get("server", "") + self.fields["token"].initial = creds.get("token", "") + + def clean(self): + cleaned_data = super().clean() + + ca_data = cleaned_data.get("certificate_authority_data") + server = cleaned_data.get("server") + token = cleaned_data.get("token") + + if ca_data and server and token: + cleaned_data["api_credentials"] = { + "certificate-authority-data": ca_data, + "server": server, + "token": token, + } + else: + if not (ca_data or server or token): + cleaned_data["api_credentials"] = {} + else: + # Some fields are filled but not all - validation will fail at model level, + # as model field validators are also called by the API. + # We still create the JSON with whatever we have so the model validator can run. + credentials = {} + if ca_data: + credentials["certificate-authority-data"] = ca_data + if server: + credentials["server"] = server + if token: + credentials["token"] = token + cleaned_data["api_credentials"] = credentials + + return cleaned_data + + def save(self, *args, **kwargs): + self.instance.api_credentials = self.cleaned_data["api_credentials"] + return super().save(*args, **kwargs) diff --git a/src/servala/core/management/__init__.py b/src/servala/core/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/servala/core/management/commands/__init__.py b/src/servala/core/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/servala/core/management/commands/reencrypt_fields.py b/src/servala/core/management/commands/reencrypt_fields.py new file mode 100644 index 0000000..787e18b --- /dev/null +++ b/src/servala/core/management/commands/reencrypt_fields.py @@ -0,0 +1,21 @@ +from django.core.management.base import BaseCommand +from django.db import transaction + +from servala.core.models.service import ControlPlane + + +class Command(BaseCommand): + help = "Re-encrypts all encrypted fields. Use when rotating SECRET_KEY/SALT" + + def handle(self, *args, **options): + self.stdout.write("Starting re-encryption of ControlPlane objects...") + + count = 0 + with transaction.atomic(): + for control_plane in ControlPlane.objects.all(): + control_plane.save() + count += 1 + + self.stdout.write( + self.style.SUCCESS(f"Re-encrypted {count} ControlPlane objects") + ) diff --git a/src/servala/core/migrations/0004_encrypt_api_credentials.py b/src/servala/core/migrations/0004_encrypt_api_credentials.py new file mode 100644 index 0000000..9ca4acf --- /dev/null +++ b/src/servala/core/migrations/0004_encrypt_api_credentials.py @@ -0,0 +1,46 @@ +# Generated by Django 5.2b1 on 2025-03-24 06:33 + +import encrypted_fields.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("auth", "0012_alter_user_first_name_max_length"), + ("core", "0003_billing_entity_nullable"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="groups", + field=models.ManyToManyField( + blank=True, + help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.", + related_name="user_set", + related_query_name="user", + to="auth.group", + verbose_name="groups", + ), + ), + migrations.AddField( + model_name="user", + name="user_permissions", + field=models.ManyToManyField( + blank=True, + help_text="Specific permissions for this user.", + related_name="user_set", + related_query_name="user", + to="auth.permission", + verbose_name="user permissions", + ), + ), + migrations.AlterField( + model_name="controlplane", + name="api_credentials", + field=encrypted_fields.fields.EncryptedJSONField( + verbose_name="API credentials" + ), + ), + ] diff --git a/src/servala/core/migrations/0005_remove_controlplane_k8s_api_endpoint.py b/src/servala/core/migrations/0005_remove_controlplane_k8s_api_endpoint.py new file mode 100644 index 0000000..b704b0d --- /dev/null +++ b/src/servala/core/migrations/0005_remove_controlplane_k8s_api_endpoint.py @@ -0,0 +1,29 @@ +# Generated by Django 5.2b1 on 2025-03-24 10:27 + +import encrypted_fields.fields +from django.db import migrations + +import servala.core.models.service + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0004_encrypt_api_credentials"), + ] + + operations = [ + migrations.RemoveField( + model_name="controlplane", + name="k8s_api_endpoint", + ), + migrations.AlterField( + model_name="controlplane", + name="api_credentials", + field=encrypted_fields.fields.EncryptedJSONField( + help_text="Required fields: certificate-authority-data, server (URL), token", + validators=[servala.core.models.service.validate_api_credentials], + verbose_name="API credentials", + ), + ), + ] diff --git a/src/servala/core/models/service.py b/src/servala/core/models/service.py index acf366b..78815da 100644 --- a/src/servala/core/models/service.py +++ b/src/servala/core/models/service.py @@ -1,5 +1,10 @@ +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): @@ -62,12 +67,34 @@ class Service(models.Model): return self.name +def validate_api_credentials(value): + """ + Validates that api_credentials either contains all required fields or is empty. + """ + # If empty dict, that's valid + if not value: + return + + # Check for required fields + required_fields = ("certificate-authority-data", "server", "token") + missing_fields = required_fields - set(value) + + if missing_fields: + raise ValidationError( + _("Missing required fields in API credentials: %(fields)s"), + params={"fields": ", ".join(missing_fields)}, + ) + + class ControlPlane(models.Model): name = models.CharField(max_length=100, verbose_name=_("Name")) description = models.TextField(blank=True, verbose_name=_("Description")) - k8s_api_endpoint = models.URLField(verbose_name=_("Kubernetes API endpoint")) - # TODO: schema - api_credentials = models.JSONField(verbose_name=_("API credentials")) + # 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", @@ -83,6 +110,69 @@ class ControlPlane(models.Model): 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): """ diff --git a/src/servala/core/templates/admin/core/controlplane/change_form.html b/src/servala/core/templates/admin/core/controlplane/change_form.html new file mode 100644 index 0000000..87ba8dd --- /dev/null +++ b/src/servala/core/templates/admin/core/controlplane/change_form.html @@ -0,0 +1,12 @@ +{% extends "admin/change_form.html" %} +{% load i18n admin_urls %} +{% block submit_buttons_bottom %} + {{ block.super }} + {% if original %} +