Merge pull request 'Add and test control plane configuration' (#21) from 12-control-planes into main
All checks were successful
All checks were successful
Reviewed-on: https://servala-2nkgm.app.codey.ch/servala/servala-portal/pulls/21
This commit is contained in:
commit
124764bee3
19 changed files with 524 additions and 17 deletions
|
@ -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.
|
||||
|
|
17
README.md
17
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.
|
||||
|
|
|
@ -50,7 +50,6 @@ erDiagram
|
|||
ControlPlane {
|
||||
string name
|
||||
string description
|
||||
string k8s_api_endpoint
|
||||
json api_credentials
|
||||
}
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
72
src/servala/core/forms.py
Normal 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)
|
0
src/servala/core/management/__init__.py
Normal file
0
src/servala/core/management/__init__.py
Normal file
0
src/servala/core/management/commands/__init__.py
Normal file
0
src/servala/core/management/commands/__init__.py
Normal file
21
src/servala/core/management/commands/reencrypt_fields.py
Normal file
21
src/servala/core/management/commands/reencrypt_fields.py
Normal 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")
|
||||
)
|
46
src/servala/core/migrations/0004_encrypt_api_credentials.py
Normal file
46
src/servala/core/migrations/0004_encrypt_api_credentials.py
Normal 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"
|
||||
),
|
||||
),
|
||||
]
|
|
@ -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",
|
||||
),
|
||||
),
|
||||
]
|
|
@ -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):
|
||||
"""
|
||||
|
|
|
@ -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 %}
|
|
@ -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 %}
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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 = []
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -1,10 +1,13 @@
|
|||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.parametrize("url,redirect", (
|
||||
@pytest.mark.parametrize(
|
||||
"url,redirect",
|
||||
(
|
||||
("/", "/accounts/login/?next=/"),
|
||||
("/accounts/profile/", "/accounts/login/?next=/accounts/profile/")
|
||||
))
|
||||
("/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
147
uv.lock
generated
|
@ -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 },
|
||||
]
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue