diff --git a/.env.example b/.env.example index 829b56e..940a6d0 100644 --- a/.env.example +++ b/.env.example @@ -4,17 +4,10 @@ # When the environment is "development", DEBUG is set to True. SERVALA_ENVIRONMENT='development' -# 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). +# Set PREVIOUS_SECRET_KEY when rotating to a new secret key in order to not expire all sessions # 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 a30699a..8959c00 100644 --- a/README.md +++ b/README.md @@ -91,20 +91,3 @@ 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 a5254e3..7a37839 100644 --- a/docs/modules/ROOT/pages/database-diagram.adoc +++ b/docs/modules/ROOT/pages/database-diagram.adoc @@ -50,6 +50,7 @@ erDiagram ControlPlane { string name string description + string k8s_api_endpoint json api_credentials } diff --git a/pyproject.toml b/pyproject.toml index 71f4522..bf02fbb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,10 +9,8 @@ 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 d718fe0..2ecdb69 100644 --- a/src/servala/core/admin.py +++ b/src/servala/core/admin.py @@ -1,7 +1,6 @@ -from django.contrib import admin, messages +from django.contrib import admin from django.utils.translation import gettext_lazy as _ -from servala.core.forms import ControlPlaneAdminForm from servala.core.models import ( BillingEntity, CloudProvider, @@ -111,55 +110,10 @@ class CloudProviderAdmin(admin.ModelAdmin): @admin.register(ControlPlane) class ControlPlaneAdmin(admin.ModelAdmin): - form = ControlPlaneAdminForm - list_display = ("name", "cloud_provider") + list_display = ("name", "cloud_provider", "k8s_api_endpoint") list_filter = ("cloud_provider",) - search_fields = ("name", "description") + search_fields = ("name", "description", "k8s_api_endpoint") 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 deleted file mode 100644 index d7740ae..0000000 --- a/src/servala/core/forms.py +++ /dev/null @@ -1,72 +0,0 @@ -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 deleted file mode 100644 index e69de29..0000000 diff --git a/src/servala/core/management/commands/__init__.py b/src/servala/core/management/commands/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/servala/core/management/commands/reencrypt_fields.py b/src/servala/core/management/commands/reencrypt_fields.py deleted file mode 100644 index 787e18b..0000000 --- a/src/servala/core/management/commands/reencrypt_fields.py +++ /dev/null @@ -1,21 +0,0 @@ -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 deleted file mode 100644 index 9ca4acf..0000000 --- a/src/servala/core/migrations/0004_encrypt_api_credentials.py +++ /dev/null @@ -1,46 +0,0 @@ -# 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 deleted file mode 100644 index b704b0d..0000000 --- a/src/servala/core/migrations/0005_remove_controlplane_k8s_api_endpoint.py +++ /dev/null @@ -1,29 +0,0 @@ -# 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 78815da..acf366b 100644 --- a/src/servala/core/models/service.py +++ b/src/servala/core/models/service.py @@ -1,10 +1,5 @@ -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): @@ -67,34 +62,12 @@ 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")) - # 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], - ) + k8s_api_endpoint = models.URLField(verbose_name=_("Kubernetes API endpoint")) + # TODO: schema + api_credentials = models.JSONField(verbose_name=_("API credentials")) cloud_provider = models.ForeignKey( to="CloudProvider", @@ -110,69 +83,6 @@ 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 deleted file mode 100644 index 87ba8dd..0000000 --- a/src/servala/core/templates/admin/core/controlplane/change_form.html +++ /dev/null @@ -1,12 +0,0 @@ -{% extends "admin/change_form.html" %} -{% load i18n admin_urls %} -{% block submit_buttons_bottom %} - {{ block.super }} - {% if original %} -