Add and test control plane configuration #21

Merged
rixx merged 14 commits from 12-control-planes into main 2025-03-24 10:29:25 +00:00
19 changed files with 524 additions and 17 deletions

View file

@ -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.

View file

@ -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 (doesnt
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.

View file

@ -50,7 +50,6 @@ erDiagram
ControlPlane {
string name
string description
string k8s_api_endpoint
json api_credentials
}

View file

@ -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",

View file

@ -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)

72
src/servala/core/forms.py Normal file
View file

@ -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)

View file

View file

@ -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")
)

View file

@ -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"
),
),
]

View file

@ -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",
),
),
]

View file

@ -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):
"""

View file

@ -0,0 +1,12 @@
{% extends "admin/change_form.html" %}
{% load i18n admin_urls %}
{% block submit_buttons_bottom %}
{{ block.super }}
{% if original %}
<div class="submit-row">
<input type="submit"
value="{% translate 'Save and Test Connection' %}"
name="_save_and_test" />
</div>
{% endif %}
{% endblock %}

View file

@ -14,6 +14,7 @@
{% provider_login_url provider process=process scope=scope auth_params=auth_params as href %}
<form method="post" action="{{ href }}">
{% csrf_token %}
{{ redirect_field }}
<button href="{{ href }}"
class="btn btn-warning btn-lg icon icon-left"
title="{{ provider.name }}">
@ -39,7 +40,7 @@
style="max-width: 400px">
{% url 'account_login' as form_action %}
{% translate "Sign In" as form_submit_label %}
{% include "includes/form.html" %}
{% include "includes/form.html" with extra_field=redirect_field %}
</div>
</div>
{% endblock card_content %}

View file

@ -5,6 +5,7 @@
{% include "includes/form_errors.html" %}
{% csrf_token %}
{{ form }}
{% if extra_field %}{{ extra_field }}{% endif %}
<div class="col-sm-12 d-flex justify-content-end">
<button class="btn btn-primary me-1 mb-1" type="submit">
{% if form_submit_label %}

View file

@ -21,6 +21,10 @@ SECRET_KEY = os.environ.get("SERVALA_SECRET_KEY")
if previous_secret_key := os.environ.get("SERVALA_PREVIOUS_SECRET_KEY"):
SECRET_KEY_FALLBACKS = [previous_secret_key]
SALT_KEY = os.environ.get("SERVALA_SALT_KEY")
if previous_salt := os.environ.get("SERVALA_PREVIOUS_SALT_KEY"):
SALT_KEY = [SALT_KEY, previous_salt]
BASE_DIR = Path(__file__).resolve().parent.parent
ALLOWED_HOSTS = []

View file

@ -1,12 +1,18 @@
import pytest
from servala.core.models import Organization, OrganizationMembership, User, OrganizationOrigin
from servala.core.models import (
Organization,
OrganizationMembership,
OrganizationOrigin,
User,
)
@pytest.fixture
def origin():
return OrganizationOrigin.objects.create(name="TESTORIGIN")
@pytest.fixture
def organization(origin):
return Organization.objects.create(name="Test Org", origin=origin)
@ -16,9 +22,11 @@ def organization(origin):
def other_organization(origin):
return Organization.objects.create(name="Test Org Alternate", origin=origin)
@pytest.fixture
def org_owner(organization):
user = User.objects.create(email="user@example.org", password="example")
OrganizationMembership.objects.create(organization=organization, user=user, role="owner")
OrganizationMembership.objects.create(
organization=organization, user=user, role="owner"
)
return user

View file

@ -1,10 +1,13 @@
import pytest
@pytest.mark.parametrize("url,redirect", (
("/", "/accounts/login/?next=/"),
("/accounts/profile/", "/accounts/login/?next=/accounts/profile/")
))
@pytest.mark.parametrize(
"url,redirect",
(
("/", "/accounts/login/?next=/"),
("/accounts/profile/", "/accounts/login/?next=/accounts/profile/"),
),
)
def test_root_view_redirects_valid_urls(client, url, redirect):
response = client.get(url)
assert response.status_code == 302
@ -33,7 +36,9 @@ def test_user_cannot_see_other_organization(client, org_owner, other_organizatio
@pytest.mark.django_db
def test_organization_linked_in_sidebar(client, org_owner, organization, other_organization):
def test_organization_linked_in_sidebar(
client, org_owner, organization, other_organization
):
client.force_login(org_owner)
response = client.get("/", follow=True)
assert response.status_code == 200

147
uv.lock generated
View file

@ -77,6 +77,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/09/71/54e999902aed72baf26bca0d50781b01838251a462612966e9fc4891eadd/black-25.1.0-py3-none-any.whl", hash = "sha256:95e8176dae143ba9097f351d174fdaf0ccd29efb414b362ae3fd72bf0f710717", size = 207646 },
]
[[package]]
name = "cachetools"
version = "5.5.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6c/81/3747dad6b14fa2cf53fcf10548cf5aea6913e96fab41a3c198676f8948a5/cachetools-5.5.2.tar.gz", hash = "sha256:1a661caa9175d26759571b2e19580f9d6393969e5dfca11fdb1f947a23e640d4", size = 28380 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/72/76/20fa66124dbe6be5cafeb312ece67de6b61dd91a0247d1ea13db4ebb33c2/cachetools-5.5.2-py3-none-any.whl", hash = "sha256:d26a22bcc62eb95c3beabd9f1ee5e820d3d2704fe2967cbe350e20c8ffcd3f0a", size = 10080 },
]
[[package]]
name = "certifi"
version = "2025.1.31"
@ -287,6 +296,19 @@ dependencies = [
]
sdist = { url = "https://files.pythonhosted.org/packages/66/f8/b58f84c29bcbca3798939279a98e2423e6e53a38c29e3fed7700ff3d6984/django_allauth-65.5.0.tar.gz", hash = "sha256:1a564fd2f5413054559078c2b7146796b517c1e7a38c6312e9de7c9bb708325d", size = 1624216 }
[[package]]
name = "django-fernet-encrypted-fields"
version = "0.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
{ name = "django" },
]
sdist = { url = "https://files.pythonhosted.org/packages/70/b8/b6725f1207693ba9e76223abf87eb9e8de5114cccad8ddd1bce29a195273/django-fernet-encrypted-fields-0.3.0.tar.gz", hash = "sha256:38031bdaf1724a6e885ee137cc66a2bd7dc3726c438e189ea7e44799ec0ba9b3", size = 4021 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/75/8a/2c5d88cd540d83ceaa1cb3191ed35dfed0caacc6fe2ff5fe74c9ecc7776f/django_fernet_encrypted_fields-0.3.0-py3-none-any.whl", hash = "sha256:a17cca5bf3638ee44674e64f30792d5960b1d4d4b291ec478c27515fc4860612", size = 5400 },
]
[[package]]
name = "django-scopes"
version = "2.0.0"
@ -339,6 +361,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/4b/67/f7aeea9be6fb3bd984487af8d0d80225a0b1e5f6f7126e3332d349fb13fe/djlint-1.36.4-py3-none-any.whl", hash = "sha256:e9699b8ac3057a6ed04fb90835b89bee954ed1959c01541ce4f8f729c938afdd", size = 52290 },
]
[[package]]
name = "durationpy"
version = "0.9"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/31/e9/f49c4e7fccb77fa5c43c2480e09a857a78b41e7331a75e128ed5df45c56b/durationpy-0.9.tar.gz", hash = "sha256:fd3feb0a69a0057d582ef643c355c40d2fa1c942191f914d12203b1a01ac722a", size = 3186 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4c/a3/ac312faeceffd2d8f86bc6dcb5c401188ba5a01bc88e69bed97578a0dfcd/durationpy-0.9-py3-none-any.whl", hash = "sha256:e65359a7af5cedad07fb77a2dd3f390f8eb0b74cb845589fa6c057086834dd38", size = 3461 },
]
[[package]]
name = "editorconfig"
version = "0.17.0"
@ -386,6 +417,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5f/1d/635e86f9f3a96b7ea9e9f19b5efe17a987e765c39ca496e4a893bb999112/flake8_pyproject-1.2.3-py3-none-any.whl", hash = "sha256:6249fe53545205af5e76837644dc80b4c10037e73a0e5db87ff562d75fb5bd4a", size = 4756 },
]
[[package]]
name = "google-auth"
version = "2.38.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cachetools" },
{ name = "pyasn1-modules" },
{ name = "rsa" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c6/eb/d504ba1daf190af6b204a9d4714d457462b486043744901a6eeea711f913/google_auth-2.38.0.tar.gz", hash = "sha256:8285113607d3b80a3f1543b75962447ba8a09fe85783432a784fdeef6ac094c4", size = 270866 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/47/603554949a37bca5b7f894d51896a9c534b9eab808e2520a748e081669d0/google_auth-2.38.0-py2.py3-none-any.whl", hash = "sha256:e7dae6694313f434a2727bf2906f27ad259bae090d7aa896590d86feec3d9d4a", size = 210770 },
]
[[package]]
name = "idna"
version = "3.10"
@ -435,6 +480,28 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/aa/42/797895b952b682c3dafe23b1834507ee7f02f4d6299b65aaa61425763278/json5-0.10.0-py3-none-any.whl", hash = "sha256:19b23410220a7271e8377f81ba8aacba2fdd56947fbb137ee5977cbe1f5e8dfa", size = 34049 },
]
[[package]]
name = "kubernetes"
version = "32.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "durationpy" },
{ name = "google-auth" },
{ name = "oauthlib" },
{ name = "python-dateutil" },
{ name = "pyyaml" },
{ name = "requests" },
{ name = "requests-oauthlib" },
{ name = "six" },
{ name = "urllib3" },
{ name = "websocket-client" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b7/e8/0598f0e8b4af37cd9b10d8b87386cf3173cb8045d834ab5f6ec347a758b3/kubernetes-32.0.1.tar.gz", hash = "sha256:42f43d49abd437ada79a79a16bd48a604d3471a117a8347e87db693f2ba0ba28", size = 946691 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/08/10/9f8af3e6f569685ce3af7faab51c8dd9d93b9c38eba339ca31c746119447/kubernetes-32.0.1-py2.py3-none-any.whl", hash = "sha256:35282ab8493b938b08ab5526c7ce66588232df00ef5e1dbe88a419107dc10998", size = 1988070 },
]
[[package]]
name = "mccabe"
version = "0.7.0"
@ -453,6 +520,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/e2/5d3f6ada4297caebe1a2add3b126fe800c96f56dbe5d1988a2cbe0b267aa/mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", size = 4695 },
]
[[package]]
name = "oauthlib"
version = "3.2.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6d/fa/fbf4001037904031639e6bfbfc02badfc7e12f137a8afa254df6c4c8a670/oauthlib-3.2.2.tar.gz", hash = "sha256:9859c40929662bec5d64f34d01c99e093149682a3f38915dc0655d5a633dd918", size = 177352 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/80/cab10959dc1faead58dc8384a781dfbf93cb4d33d50988f7a69f1b7c9bbe/oauthlib-3.2.2-py3-none-any.whl", hash = "sha256:8139f29aac13e25d502680e9e19963e83f16838d48a0d71c287fe40e7067fbca", size = 151688 },
]
[[package]]
name = "packaging"
version = "24.2"
@ -558,6 +634,27 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/08/50/d13ea0a054189ae1bc21af1d85b6f8bb9bbc5572991055d70ad9006fe2d6/psycopg2_binary-2.9.10-cp313-cp313-win_amd64.whl", hash = "sha256:27422aa5f11fbcd9b18da48373eb67081243662f9b46e6fd07c3eb46e4535142", size = 2569224 },
]
[[package]]
name = "pyasn1"
version = "0.6.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ba/e9/01f1a64245b89f039897cb0130016d79f77d52669aae6ee7b159a6c4c018/pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034", size = 145322 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629", size = 83135 },
]
[[package]]
name = "pyasn1-modules"
version = "0.4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyasn1" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1d/67/6afbf0d507f73c32d21084a79946bfcfca5fbc62a72057e9c23797a737c9/pyasn1_modules-0.4.1.tar.gz", hash = "sha256:c28e2dbf9c06ad61c71a075c7e0f9fd0f1b0bb2d2ad4377f240d33ac2ab60a7c", size = 310028 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/77/89/bc88a6711935ba795a679ea6ebee07e128050d6382eaa35a0a47c8032bdc/pyasn1_modules-0.4.1-py3-none-any.whl", hash = "sha256:49bfa96b45a292b711e986f222502c1c9a5e1f4e568fc30e2574a6c7d07838fd", size = 181537 },
]
[[package]]
name = "pycodestyle"
version = "2.12.1"
@ -634,6 +731,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/58/4c/a4fe18205926216e1aebe1f125cba5bce444f91b6e4de4f49fa87e322775/pytest_django-4.10.0-py3-none-any.whl", hash = "sha256:57c74ef3aa9d89cae5a5d73fbb69a720a62673ade7ff13b9491872409a3f5918", size = 23975 },
]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 },
]
[[package]]
name = "pyyaml"
version = "6.0.2"
@ -713,6 +822,31 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 },
]
[[package]]
name = "requests-oauthlib"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "oauthlib" },
{ name = "requests" },
]
sdist = { url = "https://files.pythonhosted.org/packages/42/f2/05f29bc3913aea15eb670be136045bf5c5bbf4b99ecb839da9b422bb2c85/requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9", size = 55650 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179 },
]
[[package]]
name = "rsa"
version = "4.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyasn1" },
]
sdist = { url = "https://files.pythonhosted.org/packages/aa/65/7d973b89c4d2351d7fb232c2e452547ddfa243e93131e7cfa766da627b52/rsa-4.9.tar.gz", hash = "sha256:e38464a49c6c85d7f1351b0126661487a7e0a14a50f1675ec50eb34d4f20ef21", size = 29711 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/49/97/fa78e3d2f65c02c8e1268b9aba606569fe97f6c8f7c2d74394553347c145/rsa-4.9-py3-none-any.whl", hash = "sha256:90260d9058e514786967344d0ef75fa8727eed8a7d2e43ce9f4bcf1b536174f7", size = 34315 },
]
[[package]]
name = "rules"
version = "3.5"
@ -731,8 +865,10 @@ dependencies = [
{ name = "cryptography" },
{ name = "django" },
{ name = "django-allauth" },
{ name = "django-fernet-encrypted-fields" },
{ name = "django-scopes" },
{ name = "django-template-partials" },
{ name = "kubernetes" },
{ name = "pillow" },
{ name = "psycopg2-binary" },
{ name = "pyjwt" },
@ -761,8 +897,10 @@ requires-dist = [
{ name = "cryptography", specifier = ">=44.0.2" },
{ name = "django", specifier = "==5.2b1" },
{ name = "django-allauth", specifier = ">=65.5.0" },
{ name = "django-fernet-encrypted-fields", specifier = ">=0.3.0" },
{ name = "django-scopes", specifier = ">=2.0.0" },
{ name = "django-template-partials", specifier = ">=24.4" },
{ name = "kubernetes", specifier = ">=32.0.1" },
{ name = "pillow", specifier = ">=11.1.0" },
{ name = "psycopg2-binary", specifier = ">=2.9.10" },
{ name = "pyjwt", specifier = ">=2.10.1" },
@ -841,3 +979,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/65/c3/cc163cadf40a03d23
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/0c/e8a418c9bc9349e7869e88a5b439cf39c4f6f8942da858000944c94a8f01/urlman-2.0.2-py2.py3-none-any.whl", hash = "sha256:2505bf310be424ffa6f4965a6f643ce32dc6194f61a3c5989f2f56453c614814", size = 8028 },
]
[[package]]
name = "websocket-client"
version = "1.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e6/30/fba0d96b4b5fbf5948ed3f4681f7da2f9f64512e1d303f94b4cc174c24a5/websocket_client-1.8.0.tar.gz", hash = "sha256:3239df9f44da632f96012472805d40a23281a991027ce11d2f45a6f24ac4c3da", size = 54648 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/84/44687a29792a70e111c5c477230a72c4b957d88d16141199bf9acb7537a3/websocket_client-1.8.0-py3-none-any.whl", hash = "sha256:17b44cc997f5c498e809b22cdf2d9c7a9e71c02c8cc2b6c56e7c2d1239bfa526", size = 58826 },
]