From 7b7a6884cf18a47124cdf70a829a4bc15787dcfa Mon Sep 17 00:00:00 2001 From: tphan025 Date: Tue, 17 Mar 2026 15:08:16 +0100 Subject: [PATCH 01/71] Implement request API --- .gitignore | 2 + .../haproxy_route_policy/asgi.py | 2 +- .../haproxy_route_policy/settings.py | 72 +++++----- .../haproxy_route_policy/test_settings.py | 13 ++ .../haproxy_route_policy/urls.py | 8 +- haproxy-route-policy/policy/__init__.py | 2 + haproxy-route-policy/policy/apps.py | 12 ++ haproxy-route-policy/policy/db_models.py | 85 +++++++++++ .../policy/migrations/0001_initial.py | 28 ++++ .../policy/migrations/__init__.py | 0 haproxy-route-policy/policy/tests/__init__.py | 2 + .../policy/tests/test_models.py | 64 +++++++++ .../policy/tests/test_views.py | 136 ++++++++++++++++++ haproxy-route-policy/policy/urls.py | 21 +++ haproxy-route-policy/policy/views.py | 64 +++++++++ 15 files changed, 474 insertions(+), 37 deletions(-) create mode 100644 haproxy-route-policy/haproxy_route_policy/test_settings.py create mode 100644 haproxy-route-policy/policy/__init__.py create mode 100644 haproxy-route-policy/policy/apps.py create mode 100644 haproxy-route-policy/policy/db_models.py create mode 100644 haproxy-route-policy/policy/migrations/0001_initial.py create mode 100644 haproxy-route-policy/policy/migrations/__init__.py create mode 100644 haproxy-route-policy/policy/tests/__init__.py create mode 100644 haproxy-route-policy/policy/tests/test_models.py create mode 100644 haproxy-route-policy/policy/tests/test_views.py create mode 100644 haproxy-route-policy/policy/urls.py create mode 100644 haproxy-route-policy/policy/views.py diff --git a/.gitignore b/.gitignore index 1fba8d8f1..6d2a4e023 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,5 @@ __pycache__/ terraform/**/.terraform* terraform/**/.tfvars terraform/**/*.tfstate* +haproxy-route-policy/db.sqlite3 + diff --git a/haproxy-route-policy/haproxy_route_policy/asgi.py b/haproxy-route-policy/haproxy_route_policy/asgi.py index db644c77f..a449fdbd2 100644 --- a/haproxy-route-policy/haproxy_route_policy/asgi.py +++ b/haproxy-route-policy/haproxy_route_policy/asgi.py @@ -14,6 +14,6 @@ from django.core.asgi import get_asgi_application -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'haproxy_route_policy.settings') +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "haproxy_route_policy.settings") application = get_asgi_application() diff --git a/haproxy-route-policy/haproxy_route_policy/settings.py b/haproxy-route-policy/haproxy_route_policy/settings.py index 009893d1e..be22e37d2 100644 --- a/haproxy-route-policy/haproxy_route_policy/settings.py +++ b/haproxy-route-policy/haproxy_route_policy/settings.py @@ -23,7 +23,7 @@ # See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = 'django-insecure-8^cu^zn%=))7@yooq*_w2yz&cs@=)&5g*^72)l)ye6bdyzm3+%' +SECRET_KEY = "django-insecure-8^cu^zn%=))7@yooq*_w2yz&cs@=)&5g*^72)l)ye6bdyzm3+%" # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True @@ -34,51 +34,55 @@ # Application definition INSTALLED_APPS = [ - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "rest_framework", + "policy.apps.PolicyConfig", ] +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + MIDDLEWARE = [ - 'django.middleware.security.SecurityMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.middleware.common.CommonMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware', + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", ] -ROOT_URLCONF = 'haproxy_route_policy.urls' +ROOT_URLCONF = "haproxy_route_policy.urls" TEMPLATES = [ { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", ], }, }, ] -WSGI_APPLICATION = 'haproxy_route_policy.wsgi.application' +WSGI_APPLICATION = "haproxy_route_policy.wsgi.application" # Database # https://docs.djangoproject.com/en/6.0/ref/settings/#databases DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': BASE_DIR / 'db.sqlite3', + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "db.sqlite3", } } @@ -88,16 +92,16 @@ AUTH_PASSWORD_VALIDATORS = [ { - 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", }, { - 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", }, { - 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", }, { - 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", }, ] @@ -105,9 +109,9 @@ # Internationalization # https://docs.djangoproject.com/en/6.0/topics/i18n/ -LANGUAGE_CODE = 'en-us' +LANGUAGE_CODE = "en-us" -TIME_ZONE = 'UTC' +TIME_ZONE = "UTC" USE_I18N = True @@ -117,4 +121,4 @@ # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/6.0/howto/static-files/ -STATIC_URL = 'static/' +STATIC_URL = "static/" diff --git a/haproxy-route-policy/haproxy_route_policy/test_settings.py b/haproxy-route-policy/haproxy_route_policy/test_settings.py new file mode 100644 index 000000000..28e02860b --- /dev/null +++ b/haproxy-route-policy/haproxy_route_policy/test_settings.py @@ -0,0 +1,13 @@ +# Copyright 2026 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Django settings for running tests with SQLite.""" + +from haproxy_route_policy.settings import * # noqa: F401, F403 + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": ":memory:", + } +} diff --git a/haproxy-route-policy/haproxy_route_policy/urls.py b/haproxy-route-policy/haproxy_route_policy/urls.py index 90851e82c..0fb11c0d9 100644 --- a/haproxy-route-policy/haproxy_route_policy/urls.py +++ b/haproxy-route-policy/haproxy_route_policy/urls.py @@ -17,9 +17,13 @@ 1. Import the include() function: from django.urls import include, path 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ + from django.contrib import admin -from django.urls import path +from django.urls import include, path + +from policy import urls as policy_urls urlpatterns = [ - path('admin/', admin.site.urls), + path("admin/", admin.site.urls), + path("", include(policy_urls)), ] diff --git a/haproxy-route-policy/policy/__init__.py b/haproxy-route-policy/policy/__init__.py new file mode 100644 index 000000000..fa89e9d7f --- /dev/null +++ b/haproxy-route-policy/policy/__init__.py @@ -0,0 +1,2 @@ +# Copyright 2026 Canonical Ltd. +# See LICENSE file for licensing details. diff --git a/haproxy-route-policy/policy/apps.py b/haproxy-route-policy/policy/apps.py new file mode 100644 index 000000000..ccc707240 --- /dev/null +++ b/haproxy-route-policy/policy/apps.py @@ -0,0 +1,12 @@ +# Copyright 2026 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Django app configuration for the policy app.""" + +from django.apps import AppConfig + + +class PolicyConfig(AppConfig): + """Configuration for the policy Django app.""" + + name = "policy" diff --git a/haproxy-route-policy/policy/db_models.py b/haproxy-route-policy/policy/db_models.py new file mode 100644 index 000000000..e72551429 --- /dev/null +++ b/haproxy-route-policy/policy/db_models.py @@ -0,0 +1,85 @@ +# Copyright 2026 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Database models for the haproxy-route-policy application.""" + +from datetime import datetime +import typing +from django.db import models +from validators import domain +from django.core.exceptions import ValidationError + +REQUEST_STATUS_PENDING = "pending" +REQUEST_STATUS_ACCEPTED = "accepted" +REQUEST_STATUS_REJECTED = "rejected" + +REQUEST_STATUSES = [ + REQUEST_STATUS_PENDING, + REQUEST_STATUS_ACCEPTED, + REQUEST_STATUS_REJECTED, +] + +REQUEST_STATUS_CHOICES = [(status, status) for status in REQUEST_STATUSES] + + +def validate_hostname_acls(value: typing.Any): + """Validate that the value is a list of valid hostnames.""" + if not isinstance(value, list): + raise ValidationError("hostname_acls must be a list.") + if invalid_hostnames := [ + hostname for hostname in typing.cast(list, value) if not domain(hostname) + ]: + raise ValidationError(f"Invalid hostnames: {', '.join(invalid_hostnames)}") + + +class BackendRequest(models.Model): + """A backend request submitted via the haproxy-route relation. + + Attrs: + id: Auto-incrementing primary key. + relation_id: The Juju relation ID this request originated from. + hostname_acls: Hostnames requested for routing. + backend_name: The name of the backend in the HAProxy config. + paths: URL paths requested for routing. + port: The port exposed on the frontend. + status: Current approval status (pending, accepted, rejected). + created_at: Timestamp when the request was created. + updated_at: Timestamp when the request was last updated. + """ + + id = models.BigAutoField(primary_key=True) + relation_id = models.IntegerField() + hostname_acls = models.JSONField(default=list, validators=[validate_hostname_acls]) + backend_name = models.TextField() + paths = models.JSONField(default=list) + port = models.IntegerField(null=True) + status = models.TextField( + choices=REQUEST_STATUS_CHOICES, + default=REQUEST_STATUS_PENDING, + db_index=True, + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def to_dict(self) -> dict: + """Serialize to a JSON-compatible dict.""" + return { + "id": self.id, + "relation_id": self.relation_id, + "hostname_acls": self.hostname_acls, + "backend_name": self.backend_name, + "paths": self.paths, + "port": self.port, + "status": self.status, + "created_at": typing.cast(datetime, self.created_at).isoformat() + if self.created_at + else None, + "updated_at": typing.cast(datetime, self.updated_at).isoformat() + if self.updated_at + else None, + } + + @classmethod + def required_fields(cls): + """Return a list of fields required for creating a BackendRequest.""" + return ["relation_id", "backend_name", "port"] diff --git a/haproxy-route-policy/policy/migrations/0001_initial.py b/haproxy-route-policy/policy/migrations/0001_initial.py new file mode 100644 index 000000000..316fa7703 --- /dev/null +++ b/haproxy-route-policy/policy/migrations/0001_initial.py @@ -0,0 +1,28 @@ +# Generated by Django 6.0.3 on 2026-03-16 15:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='BackendRequest', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('relation_id', models.IntegerField()), + ('hostname_acls', models.JSONField(default=list)), + ('backend_name', models.TextField()), + ('paths', models.JSONField(default=list)), + ('port', models.IntegerField(null=True)), + ('status', models.TextField(choices=[('pending', 'pending'), ('accepted', 'accepted'), ('rejected', 'rejected')], db_index=True, default='pending')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + ), + ] diff --git a/haproxy-route-policy/policy/migrations/__init__.py b/haproxy-route-policy/policy/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/haproxy-route-policy/policy/tests/__init__.py b/haproxy-route-policy/policy/tests/__init__.py new file mode 100644 index 000000000..fa89e9d7f --- /dev/null +++ b/haproxy-route-policy/policy/tests/__init__.py @@ -0,0 +1,2 @@ +# Copyright 2026 Canonical Ltd. +# See LICENSE file for licensing details. diff --git a/haproxy-route-policy/policy/tests/test_models.py b/haproxy-route-policy/policy/tests/test_models.py new file mode 100644 index 000000000..087a1ae0d --- /dev/null +++ b/haproxy-route-policy/policy/tests/test_models.py @@ -0,0 +1,64 @@ +# Copyright 2026 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Unit tests for the BackendRequest model.""" + +from django.test import TestCase + +from policy import db_models + + +class TestBackendRequestModel(TestCase): + """Tests for BackendRequest model creation and serialisation.""" + + def test_create_with_defaults(self): + """Test creating a request with minimal required fields.""" + request = db_models.BackendRequest.objects.create( + relation_id=1, + backend_name="my-backend", + ) + self.assertEqual(request.relation_id, 1) + self.assertEqual(request.backend_name, "my-backend") + self.assertEqual(request.hostname_acls, []) + self.assertEqual(request.paths, []) + self.assertIsNone(request.port) + self.assertEqual(request.status, db_models.REQUEST_STATUS_PENDING) + self.assertIsNotNone(request.created_at) + self.assertIsNotNone(request.updated_at) + + def test_create_with_all_fields(self): + """Test creating a request with all fields specified.""" + request = db_models.BackendRequest.objects.create( + relation_id=5, + hostname_acls=["example.com", "app.example.com"], + backend_name="web-backend", + paths=["/api", "/health"], + port=8080, + status=db_models.REQUEST_STATUS_ACCEPTED, + ) + self.assertEqual(request.relation_id, 5) + self.assertEqual(request.hostname_acls, ["example.com", "app.example.com"]) + self.assertEqual(request.backend_name, "web-backend") + self.assertEqual(request.paths, ["/api", "/health"]) + self.assertEqual(request.port, 8080) + self.assertEqual(request.status, db_models.REQUEST_STATUS_ACCEPTED) + + def test_to_jsonable(self): + """Test serialisation to a JSON-compatible dict.""" + request = db_models.BackendRequest.objects.create( + relation_id=2, + hostname_acls=["host.example.com"], + backend_name="backend-a", + paths=["/v1"], + port=443, + ) + data = request.to_dict() + self.assertEqual(data["id"], request.pk) + self.assertEqual(data["relation_id"], 2) + self.assertEqual(data["hostname_acls"], ["host.example.com"]) + self.assertEqual(data["backend_name"], "backend-a") + self.assertEqual(data["paths"], ["/v1"]) + self.assertEqual(data["port"], 443) + self.assertEqual(data["status"], db_models.REQUEST_STATUS_PENDING) + self.assertIn("created_at", data) + self.assertIn("updated_at", data) diff --git a/haproxy-route-policy/policy/tests/test_views.py b/haproxy-route-policy/policy/tests/test_views.py new file mode 100644 index 000000000..b2875d35f --- /dev/null +++ b/haproxy-route-policy/policy/tests/test_views.py @@ -0,0 +1,136 @@ +# Copyright 2026 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Integration tests for the policy REST API views.""" + +from django.test import TestCase +from rest_framework.test import APIClient + +from policy import db_models + + +class TestListCreateRequestsView(TestCase): + """Tests for GET /api/v1/requests and POST /api/v1/requests.""" + + def setUp(self): + """Set up the API client.""" + self.client = APIClient() + + def test_list_empty(self): + """GET returns an empty list when no requests exist.""" + response = self.client.get("/api/v1/requests") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), []) + + def test_list_returns_all(self): + """GET returns all requests.""" + db_models.BackendRequest.objects.create(relation_id=1, backend_name="a") + db_models.BackendRequest.objects.create(relation_id=2, backend_name="b") + response = self.client.get("/api/v1/requests") + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual(len(data), 2) + self.assertEqual(data[0]["backend_name"], "a") + self.assertEqual(data[1]["backend_name"], "b") + + def test_list_filter_by_status(self): + """GET with ?status= filters results.""" + db_models.BackendRequest.objects.create( + relation_id=1, backend_name="a", status=db_models.REQUEST_STATUS_PENDING + ) + db_models.BackendRequest.objects.create( + relation_id=2, backend_name="b", status=db_models.REQUEST_STATUS_ACCEPTED + ) + response = self.client.get("/api/v1/requests?status=accepted") + data = response.json() + self.assertEqual(len(data), 1) + self.assertEqual(data[0]["backend_name"], "b") + + def test_bulk_create(self): + """POST creates multiple requests and returns them.""" + payload = [ + { + "relation_id": 1, + "hostname_acls": ["example.com"], + "backend_name": "backend-1", + "paths": ["/api"], + "port": 80, + }, + { + "relation_id": 2, + "backend_name": "backend-2", + }, + ] + response = self.client.post("/api/v1/requests", data=payload, format="json") + self.assertEqual(response.status_code, 201) + data = response.json() + self.assertEqual(len(data), 2) + self.assertEqual(data[0]["backend_name"], "backend-1") + self.assertEqual(data[0]["status"], "pending") + self.assertEqual(data[0]["hostname_acls"], ["example.com"]) + self.assertEqual(data[1]["backend_name"], "backend-2") + self.assertEqual(data[1]["hostname_acls"], []) + self.assertEqual(data[1]["paths"], []) + self.assertIsNone(data[1]["port"]) + self.assertEqual(db_models.BackendRequest.objects.count(), 2) + + def test_bulk_create_all_set_to_pending(self): + """POST always sets status to pending regardless of input.""" + payload = [ + { + "relation_id": 1, + "backend_name": "test", + "status": "accepted", + }, + ] + response = self.client.post("/api/v1/requests", data=payload, format="json") + self.assertEqual(response.status_code, 201) + self.assertEqual(response.json()[0]["status"], "pending") + + def test_bulk_create_rejects_non_list(self): + """POST returns 400 when the body is not a list.""" + response = self.client.post( + "/api/v1/requests", + data={"relation_id": 1, "backend_name": "x"}, + format="json", + ) + self.assertEqual(response.status_code, 400) + + +class TestRequestDetailView(TestCase): + """Tests for GET /api/v1/requests/ and DELETE /api/v1/requests/.""" + + def setUp(self): + """Set up the API client and a sample request.""" + self.client = APIClient() + self.backend_request = db_models.BackendRequest.objects.create( + relation_id=10, + hostname_acls=["host.test"], + backend_name="detail-backend", + port=443, + ) + + def test_get_existing(self): + """GET returns the request matching the given ID.""" + response = self.client.get(f"/api/v1/requests/{self.backend_request.pk}") + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual(data["id"], self.backend_request.pk) + self.assertEqual(data["backend_name"], "detail-backend") + + def test_get_not_found(self): + """GET returns 404 for a non-existent ID.""" + response = self.client.get("/api/v1/requests/99999") + self.assertEqual(response.status_code, 404) + + def test_delete_existing(self): + """DELETE removes the request and returns 204.""" + pk = self.backend_request.pk + response = self.client.delete(f"/api/v1/requests/{pk}") + self.assertEqual(response.status_code, 204) + self.assertFalse(db_models.BackendRequest.objects.filter(pk=pk).exists()) + + def test_delete_nonexistent(self): + """DELETE on a non-existent ID still returns 204 (idempotent).""" + response = self.client.delete("/api/v1/requests/99999") + self.assertEqual(response.status_code, 204) diff --git a/haproxy-route-policy/policy/urls.py b/haproxy-route-policy/policy/urls.py new file mode 100644 index 000000000..f1580fabb --- /dev/null +++ b/haproxy-route-policy/policy/urls.py @@ -0,0 +1,21 @@ +# Copyright 2026 Canonical Ltd. +# See LICENSE file for licensing details. + +"""URL configuration for the policy app.""" + +from django.urls import path + +from policy import views + +urlpatterns = [ + path( + "api/v1/requests", + views.ListCreateRequestsView.as_view(), + name="api-requests", + ), + path( + "api/v1/requests/", + views.RequestDetailView.as_view(), + name="api-request-detail", + ), +] diff --git a/haproxy-route-policy/policy/views.py b/haproxy-route-policy/policy/views.py new file mode 100644 index 000000000..ebee67bcd --- /dev/null +++ b/haproxy-route-policy/policy/views.py @@ -0,0 +1,64 @@ +# Copyright 2026 Canonical Ltd. +# See LICENSE file for licensing details. + +"""REST API views for backend requests.""" + +from django.http import HttpResponse, HttpResponseNotFound, HttpResponseBadRequest, JsonResponse +from rest_framework.views import APIView +from django.core.exceptions import ValidationError +from .db_models import BackendRequest, REQUEST_STATUS_PENDING + + +class ListCreateRequestsView(APIView): + """View for listing and bulk-creating backend requests.""" + + def get(self, request): + """List all requests, optionally filtered by status.""" + status = request.GET.get("status") + queryset = BackendRequest.objects.all() + if status: + queryset = queryset.filter(status=status) + return JsonResponse([r.to_dict() for r in queryset.order_by("id")], safe=False) + + def post(self, request): + """Bulk create backend requests. + + All new requests are set to 'pending' (evaluation logic is deferred). + """ + if not isinstance(request.data, list): + return JsonResponse( + {"error": "Expected a list of request objects."}, status=400 + ) + + created = [] + try: + for item in request.data: + backend_request = BackendRequest.objects.create( + relation_id=item.get("relation_id"), + hostname_acls=item.get("hostname_acls", []), + backend_name=item.get("backend_name"), + paths=item.get("paths", []), + port=item.get("port"), + status=REQUEST_STATUS_PENDING, + ) + created.append(backend_request.to_dict()) + except ValidationError as e: + return HttpResponseBadRequest({"error": str(e)}, status=400) + return JsonResponse(created, safe=False, status=201) + + +class RequestDetailView(APIView): + """View for getting or deleting a single backend request.""" + + def get(self, request, pk): + """Get a request by ID.""" + try: + backend_request = BackendRequest.objects.get(pk=pk) + except BackendRequest.DoesNotExist: + return HttpResponseNotFound() + return JsonResponse(backend_request.to_dict()) + + def delete(self, request, pk): + """Delete a request by ID.""" + BackendRequest.objects.filter(pk=pk).delete() + return HttpResponse(status=204) From 00a3ea709a67a30ff032c974bd4c8c89dc38c755 Mon Sep 17 00:00:00 2001 From: tphan025 Date: Tue, 17 Mar 2026 19:01:53 +0100 Subject: [PATCH 02/71] update model validation before save and add unit tests --- .github/workflows/test.yaml | 13 +++++++++++++ haproxy-route-policy/policy/db_models.py | 13 ++++++++----- haproxy-route-policy/policy/views.py | 16 +++++++++++++--- 3 files changed, 34 insertions(+), 8 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 7f6390e4f..ac13eacff 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -22,3 +22,16 @@ jobs: self-hosted-runner-image: "noble" working-directory: ${{ matrix.charm.working-directory }} with-uv: true + + haproxy-route-policy: + name: HAProxy-route Policy App Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6.0.1 + - uses: actions/setup-python@v6 + with: + python-version: '3.x' + - working-directory: ./haproxy-route-policy + run: | + pip install -r requirements.txt + python3 ./manage.py test --settings=haproxy_route_policy.test_settings diff --git a/haproxy-route-policy/policy/db_models.py b/haproxy-route-policy/policy/db_models.py index e72551429..01c6ff19f 100644 --- a/haproxy-route-policy/policy/db_models.py +++ b/haproxy-route-policy/policy/db_models.py @@ -8,6 +8,9 @@ from django.db import models from validators import domain from django.core.exceptions import ValidationError +import logging + +logger = logging.getLogger(__name__) REQUEST_STATUS_PENDING = "pending" REQUEST_STATUS_ACCEPTED = "accepted" @@ -24,6 +27,7 @@ def validate_hostname_acls(value: typing.Any): """Validate that the value is a list of valid hostnames.""" + logger.info("Validating hostname_acls: %s", value) if not isinstance(value, list): raise ValidationError("hostname_acls must be a list.") if invalid_hostnames := [ @@ -41,7 +45,6 @@ class BackendRequest(models.Model): hostname_acls: Hostnames requested for routing. backend_name: The name of the backend in the HAProxy config. paths: URL paths requested for routing. - port: The port exposed on the frontend. status: Current approval status (pending, accepted, rejected). created_at: Timestamp when the request was created. updated_at: Timestamp when the request was last updated. @@ -49,10 +52,11 @@ class BackendRequest(models.Model): id = models.BigAutoField(primary_key=True) relation_id = models.IntegerField() - hostname_acls = models.JSONField(default=list, validators=[validate_hostname_acls]) + hostname_acls = models.JSONField( + default=list, validators=[validate_hostname_acls], blank=True + ) backend_name = models.TextField() - paths = models.JSONField(default=list) - port = models.IntegerField(null=True) + paths = models.JSONField(default=list, blank=True) status = models.TextField( choices=REQUEST_STATUS_CHOICES, default=REQUEST_STATUS_PENDING, @@ -69,7 +73,6 @@ def to_dict(self) -> dict: "hostname_acls": self.hostname_acls, "backend_name": self.backend_name, "paths": self.paths, - "port": self.port, "status": self.status, "created_at": typing.cast(datetime, self.created_at).isoformat() if self.created_at diff --git a/haproxy-route-policy/policy/views.py b/haproxy-route-policy/policy/views.py index ebee67bcd..fb92139cf 100644 --- a/haproxy-route-policy/policy/views.py +++ b/haproxy-route-policy/policy/views.py @@ -3,10 +3,16 @@ """REST API views for backend requests.""" -from django.http import HttpResponse, HttpResponseNotFound, HttpResponseBadRequest, JsonResponse +from django.http import ( + HttpResponse, + HttpResponseNotFound, + HttpResponseBadRequest, + JsonResponse, +) from rest_framework.views import APIView from django.core.exceptions import ValidationError from .db_models import BackendRequest, REQUEST_STATUS_PENDING +from django.db.utils import IntegrityError class ListCreateRequestsView(APIView): @@ -33,7 +39,7 @@ def post(self, request): created = [] try: for item in request.data: - backend_request = BackendRequest.objects.create( + backend_request = BackendRequest( relation_id=item.get("relation_id"), hostname_acls=item.get("hostname_acls", []), backend_name=item.get("backend_name"), @@ -41,9 +47,13 @@ def post(self, request): port=item.get("port"), status=REQUEST_STATUS_PENDING, ) + backend_request.full_clean() + backend_request.save() created.append(backend_request.to_dict()) except ValidationError as e: - return HttpResponseBadRequest({"error": str(e)}, status=400) + return HttpResponseBadRequest(str(e), status=400) + except IntegrityError: + return HttpResponseBadRequest("Invalid request data.", status=400) return JsonResponse(created, safe=False, status=201) From bc11a1f80b2774744f838d741f22afc3e011733b Mon Sep 17 00:00:00 2001 From: tphan025 Date: Tue, 17 Mar 2026 19:15:28 +0100 Subject: [PATCH 03/71] use environment variables for secret key --- haproxy-route-policy/haproxy_route_policy/settings.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/haproxy-route-policy/haproxy_route_policy/settings.py b/haproxy-route-policy/haproxy_route_policy/settings.py index be22e37d2..f628c2208 100644 --- a/haproxy-route-policy/haproxy_route_policy/settings.py +++ b/haproxy-route-policy/haproxy_route_policy/settings.py @@ -14,6 +14,7 @@ """ from pathlib import Path +import os # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent @@ -23,10 +24,10 @@ # See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = "django-insecure-8^cu^zn%=))7@yooq*_w2yz&cs@=)&5g*^72)l)ye6bdyzm3+%" +SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY") # SECURITY WARNING: don't run with debug turned on in production! -DEBUG = True +DEBUG = os.environ.get("DJANGO_DEBUG", "True") == "True" ALLOWED_HOSTS = [] From 6e98d08bff7ed9cb1a0dbb8d2b5dc8ba152d41b6 Mon Sep 17 00:00:00 2001 From: tphan025 Date: Tue, 17 Mar 2026 19:16:19 +0100 Subject: [PATCH 04/71] ruff format --- .../haproxy_route_policy/wsgi.py | 2 +- haproxy-route-policy/manage.py | 5 ++- .../policy/migrations/0001_initial.py | 43 +++++++++++++------ 3 files changed, 34 insertions(+), 16 deletions(-) diff --git a/haproxy-route-policy/haproxy_route_policy/wsgi.py b/haproxy-route-policy/haproxy_route_policy/wsgi.py index b81d09010..9616b9a61 100644 --- a/haproxy-route-policy/haproxy_route_policy/wsgi.py +++ b/haproxy-route-policy/haproxy_route_policy/wsgi.py @@ -14,6 +14,6 @@ from django.core.wsgi import get_wsgi_application -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'haproxy_route_policy.settings') +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "haproxy_route_policy.settings") application = get_wsgi_application() diff --git a/haproxy-route-policy/manage.py b/haproxy-route-policy/manage.py index 36fbcd667..55b10427a 100755 --- a/haproxy-route-policy/manage.py +++ b/haproxy-route-policy/manage.py @@ -4,13 +4,14 @@ # See LICENSE file for licensing details. """Django's command-line utility for administrative tasks.""" + import os import sys def main(): """Run administrative tasks.""" - os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'haproxy_route_policy.settings') + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "haproxy_route_policy.settings") try: from django.core.management import execute_from_command_line except ImportError as exc: @@ -22,5 +23,5 @@ def main(): execute_from_command_line(sys.argv) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/haproxy-route-policy/policy/migrations/0001_initial.py b/haproxy-route-policy/policy/migrations/0001_initial.py index 316fa7703..3c892bc55 100644 --- a/haproxy-route-policy/policy/migrations/0001_initial.py +++ b/haproxy-route-policy/policy/migrations/0001_initial.py @@ -4,25 +4,42 @@ class Migration(migrations.Migration): - initial = True - dependencies = [ - ] + dependencies = [] operations = [ migrations.CreateModel( - name='BackendRequest', + name="BackendRequest", fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('relation_id', models.IntegerField()), - ('hostname_acls', models.JSONField(default=list)), - ('backend_name', models.TextField()), - ('paths', models.JSONField(default=list)), - ('port', models.IntegerField(null=True)), - ('status', models.TextField(choices=[('pending', 'pending'), ('accepted', 'accepted'), ('rejected', 'rejected')], db_index=True, default='pending')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("relation_id", models.IntegerField()), + ("hostname_acls", models.JSONField(default=list)), + ("backend_name", models.TextField()), + ("paths", models.JSONField(default=list)), + ("port", models.IntegerField(null=True)), + ( + "status", + models.TextField( + choices=[ + ("pending", "pending"), + ("accepted", "accepted"), + ("rejected", "rejected"), + ], + db_index=True, + default="pending", + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), ], ), ] From f073454b1f5cf0a2ef29a21116d45ec7156bf608 Mon Sep 17 00:00:00 2001 From: tphan025 Date: Tue, 17 Mar 2026 19:18:27 +0100 Subject: [PATCH 05/71] add secret key for testing --- haproxy-route-policy/haproxy_route_policy/test_settings.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/haproxy-route-policy/haproxy_route_policy/test_settings.py b/haproxy-route-policy/haproxy_route_policy/test_settings.py index 28e02860b..dc1db7f24 100644 --- a/haproxy-route-policy/haproxy_route_policy/test_settings.py +++ b/haproxy-route-policy/haproxy_route_policy/test_settings.py @@ -5,6 +5,9 @@ from haproxy_route_policy.settings import * # noqa: F401, F403 +# Mock secret key for testing. +SECRET_KEY = "test-secret-key" + DATABASES = { "default": { "ENGINE": "django.db.backends.sqlite3", From 4842e4ace41f92e66b08eee62e373221e34a3691 Mon Sep 17 00:00:00 2001 From: tphan025 Date: Tue, 17 Mar 2026 19:20:40 +0100 Subject: [PATCH 06/71] remove port attribute from test --- haproxy-route-policy/policy/migrations/0001_initial.py | 1 - haproxy-route-policy/policy/tests/test_models.py | 5 ----- haproxy-route-policy/policy/tests/test_views.py | 3 --- haproxy-route-policy/policy/views.py | 1 - 4 files changed, 10 deletions(-) diff --git a/haproxy-route-policy/policy/migrations/0001_initial.py b/haproxy-route-policy/policy/migrations/0001_initial.py index 3c892bc55..82c6bdf2f 100644 --- a/haproxy-route-policy/policy/migrations/0001_initial.py +++ b/haproxy-route-policy/policy/migrations/0001_initial.py @@ -25,7 +25,6 @@ class Migration(migrations.Migration): ("hostname_acls", models.JSONField(default=list)), ("backend_name", models.TextField()), ("paths", models.JSONField(default=list)), - ("port", models.IntegerField(null=True)), ( "status", models.TextField( diff --git a/haproxy-route-policy/policy/tests/test_models.py b/haproxy-route-policy/policy/tests/test_models.py index 087a1ae0d..a1e475fdb 100644 --- a/haproxy-route-policy/policy/tests/test_models.py +++ b/haproxy-route-policy/policy/tests/test_models.py @@ -21,7 +21,6 @@ def test_create_with_defaults(self): self.assertEqual(request.backend_name, "my-backend") self.assertEqual(request.hostname_acls, []) self.assertEqual(request.paths, []) - self.assertIsNone(request.port) self.assertEqual(request.status, db_models.REQUEST_STATUS_PENDING) self.assertIsNotNone(request.created_at) self.assertIsNotNone(request.updated_at) @@ -33,14 +32,12 @@ def test_create_with_all_fields(self): hostname_acls=["example.com", "app.example.com"], backend_name="web-backend", paths=["/api", "/health"], - port=8080, status=db_models.REQUEST_STATUS_ACCEPTED, ) self.assertEqual(request.relation_id, 5) self.assertEqual(request.hostname_acls, ["example.com", "app.example.com"]) self.assertEqual(request.backend_name, "web-backend") self.assertEqual(request.paths, ["/api", "/health"]) - self.assertEqual(request.port, 8080) self.assertEqual(request.status, db_models.REQUEST_STATUS_ACCEPTED) def test_to_jsonable(self): @@ -50,7 +47,6 @@ def test_to_jsonable(self): hostname_acls=["host.example.com"], backend_name="backend-a", paths=["/v1"], - port=443, ) data = request.to_dict() self.assertEqual(data["id"], request.pk) @@ -58,7 +54,6 @@ def test_to_jsonable(self): self.assertEqual(data["hostname_acls"], ["host.example.com"]) self.assertEqual(data["backend_name"], "backend-a") self.assertEqual(data["paths"], ["/v1"]) - self.assertEqual(data["port"], 443) self.assertEqual(data["status"], db_models.REQUEST_STATUS_PENDING) self.assertIn("created_at", data) self.assertIn("updated_at", data) diff --git a/haproxy-route-policy/policy/tests/test_views.py b/haproxy-route-policy/policy/tests/test_views.py index b2875d35f..ad58deeae 100644 --- a/haproxy-route-policy/policy/tests/test_views.py +++ b/haproxy-route-policy/policy/tests/test_views.py @@ -54,7 +54,6 @@ def test_bulk_create(self): "hostname_acls": ["example.com"], "backend_name": "backend-1", "paths": ["/api"], - "port": 80, }, { "relation_id": 2, @@ -71,7 +70,6 @@ def test_bulk_create(self): self.assertEqual(data[1]["backend_name"], "backend-2") self.assertEqual(data[1]["hostname_acls"], []) self.assertEqual(data[1]["paths"], []) - self.assertIsNone(data[1]["port"]) self.assertEqual(db_models.BackendRequest.objects.count(), 2) def test_bulk_create_all_set_to_pending(self): @@ -107,7 +105,6 @@ def setUp(self): relation_id=10, hostname_acls=["host.test"], backend_name="detail-backend", - port=443, ) def test_get_existing(self): diff --git a/haproxy-route-policy/policy/views.py b/haproxy-route-policy/policy/views.py index fb92139cf..548d20517 100644 --- a/haproxy-route-policy/policy/views.py +++ b/haproxy-route-policy/policy/views.py @@ -44,7 +44,6 @@ def post(self, request): hostname_acls=item.get("hostname_acls", []), backend_name=item.get("backend_name"), paths=item.get("paths", []), - port=item.get("port"), status=REQUEST_STATUS_PENDING, ) backend_request.full_clean() From 5b667c048e76428948a182868b41451eb0c2f626 Mon Sep 17 00:00:00 2001 From: tphan025 Date: Tue, 17 Mar 2026 19:58:30 +0100 Subject: [PATCH 07/71] add requirements.txt for testing --- haproxy-route-policy/requirements.txt | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 haproxy-route-policy/requirements.txt diff --git a/haproxy-route-policy/requirements.txt b/haproxy-route-policy/requirements.txt new file mode 100644 index 000000000..df9f7bbe8 --- /dev/null +++ b/haproxy-route-policy/requirements.txt @@ -0,0 +1,3 @@ +Django==6.0.3 +djangorestframework==3.16.1 +validators==0.35.0 From e719d3335717799bb3871c1e9058acc74e8eedd0 Mon Sep 17 00:00:00 2001 From: tphan025 Date: Tue, 17 Mar 2026 20:05:51 +0100 Subject: [PATCH 08/71] reintroduce port field --- haproxy-route-policy/policy/db_models.py | 3 ++ .../policy/migrations/0001_initial.py | 1 + .../policy/tests/test_models.py | 8 ++++-- .../policy/tests/test_views.py | 28 +++++++++++++++---- haproxy-route-policy/policy/views.py | 1 + 5 files changed, 34 insertions(+), 7 deletions(-) diff --git a/haproxy-route-policy/policy/db_models.py b/haproxy-route-policy/policy/db_models.py index 01c6ff19f..119519bca 100644 --- a/haproxy-route-policy/policy/db_models.py +++ b/haproxy-route-policy/policy/db_models.py @@ -45,6 +45,7 @@ class BackendRequest(models.Model): hostname_acls: Hostnames requested for routing. backend_name: The name of the backend in the HAProxy config. paths: URL paths requested for routing. + port: The frontend port that should be opened by HAProxy. status: Current approval status (pending, accepted, rejected). created_at: Timestamp when the request was created. updated_at: Timestamp when the request was last updated. @@ -57,6 +58,7 @@ class BackendRequest(models.Model): ) backend_name = models.TextField() paths = models.JSONField(default=list, blank=True) + port = models.IntegerField() status = models.TextField( choices=REQUEST_STATUS_CHOICES, default=REQUEST_STATUS_PENDING, @@ -73,6 +75,7 @@ def to_dict(self) -> dict: "hostname_acls": self.hostname_acls, "backend_name": self.backend_name, "paths": self.paths, + "port": self.port, "status": self.status, "created_at": typing.cast(datetime, self.created_at).isoformat() if self.created_at diff --git a/haproxy-route-policy/policy/migrations/0001_initial.py b/haproxy-route-policy/policy/migrations/0001_initial.py index 82c6bdf2f..403c762f5 100644 --- a/haproxy-route-policy/policy/migrations/0001_initial.py +++ b/haproxy-route-policy/policy/migrations/0001_initial.py @@ -25,6 +25,7 @@ class Migration(migrations.Migration): ("hostname_acls", models.JSONField(default=list)), ("backend_name", models.TextField()), ("paths", models.JSONField(default=list)), + ("port", models.IntegerField()), ( "status", models.TextField( diff --git a/haproxy-route-policy/policy/tests/test_models.py b/haproxy-route-policy/policy/tests/test_models.py index a1e475fdb..7d3233715 100644 --- a/haproxy-route-policy/policy/tests/test_models.py +++ b/haproxy-route-policy/policy/tests/test_models.py @@ -14,14 +14,14 @@ class TestBackendRequestModel(TestCase): def test_create_with_defaults(self): """Test creating a request with minimal required fields.""" request = db_models.BackendRequest.objects.create( - relation_id=1, - backend_name="my-backend", + relation_id=1, backend_name="my-backend", port=443 ) self.assertEqual(request.relation_id, 1) self.assertEqual(request.backend_name, "my-backend") self.assertEqual(request.hostname_acls, []) self.assertEqual(request.paths, []) self.assertEqual(request.status, db_models.REQUEST_STATUS_PENDING) + self.assertEqual(request.port, 443) self.assertIsNotNone(request.created_at) self.assertIsNotNone(request.updated_at) @@ -32,12 +32,14 @@ def test_create_with_all_fields(self): hostname_acls=["example.com", "app.example.com"], backend_name="web-backend", paths=["/api", "/health"], + port=443, status=db_models.REQUEST_STATUS_ACCEPTED, ) self.assertEqual(request.relation_id, 5) self.assertEqual(request.hostname_acls, ["example.com", "app.example.com"]) self.assertEqual(request.backend_name, "web-backend") self.assertEqual(request.paths, ["/api", "/health"]) + self.assertEqual(request.port, 443) self.assertEqual(request.status, db_models.REQUEST_STATUS_ACCEPTED) def test_to_jsonable(self): @@ -47,6 +49,7 @@ def test_to_jsonable(self): hostname_acls=["host.example.com"], backend_name="backend-a", paths=["/v1"], + port=443, ) data = request.to_dict() self.assertEqual(data["id"], request.pk) @@ -54,6 +57,7 @@ def test_to_jsonable(self): self.assertEqual(data["hostname_acls"], ["host.example.com"]) self.assertEqual(data["backend_name"], "backend-a") self.assertEqual(data["paths"], ["/v1"]) + self.assertEqual(data["port"], 443) self.assertEqual(data["status"], db_models.REQUEST_STATUS_PENDING) self.assertIn("created_at", data) self.assertIn("updated_at", data) diff --git a/haproxy-route-policy/policy/tests/test_views.py b/haproxy-route-policy/policy/tests/test_views.py index ad58deeae..e230a6d08 100644 --- a/haproxy-route-policy/policy/tests/test_views.py +++ b/haproxy-route-policy/policy/tests/test_views.py @@ -24,8 +24,12 @@ def test_list_empty(self): def test_list_returns_all(self): """GET returns all requests.""" - db_models.BackendRequest.objects.create(relation_id=1, backend_name="a") - db_models.BackendRequest.objects.create(relation_id=2, backend_name="b") + db_models.BackendRequest.objects.create( + relation_id=1, backend_name="a", port=443 + ) + db_models.BackendRequest.objects.create( + relation_id=2, backend_name="b", port=443 + ) response = self.client.get("/api/v1/requests") self.assertEqual(response.status_code, 200) data = response.json() @@ -36,10 +40,16 @@ def test_list_returns_all(self): def test_list_filter_by_status(self): """GET with ?status= filters results.""" db_models.BackendRequest.objects.create( - relation_id=1, backend_name="a", status=db_models.REQUEST_STATUS_PENDING + relation_id=1, + backend_name="a", + status=db_models.REQUEST_STATUS_PENDING, + port=443, ) db_models.BackendRequest.objects.create( - relation_id=2, backend_name="b", status=db_models.REQUEST_STATUS_ACCEPTED + relation_id=2, + backend_name="b", + status=db_models.REQUEST_STATUS_ACCEPTED, + port=443, ) response = self.client.get("/api/v1/requests?status=accepted") data = response.json() @@ -54,10 +64,12 @@ def test_bulk_create(self): "hostname_acls": ["example.com"], "backend_name": "backend-1", "paths": ["/api"], + "port": 443, }, { "relation_id": 2, "backend_name": "backend-2", + "port": 443, }, ] response = self.client.post("/api/v1/requests", data=payload, format="json") @@ -67,9 +79,12 @@ def test_bulk_create(self): self.assertEqual(data[0]["backend_name"], "backend-1") self.assertEqual(data[0]["status"], "pending") self.assertEqual(data[0]["hostname_acls"], ["example.com"]) + self.assertEqual(data[0]["paths"], second=["/api"]) + self.assertEqual(data[0]["port"], 443) self.assertEqual(data[1]["backend_name"], "backend-2") self.assertEqual(data[1]["hostname_acls"], []) self.assertEqual(data[1]["paths"], []) + self.assertEqual(data[1]["port"], 443) self.assertEqual(db_models.BackendRequest.objects.count(), 2) def test_bulk_create_all_set_to_pending(self): @@ -79,17 +94,19 @@ def test_bulk_create_all_set_to_pending(self): "relation_id": 1, "backend_name": "test", "status": "accepted", + "port": 443, }, ] response = self.client.post("/api/v1/requests", data=payload, format="json") self.assertEqual(response.status_code, 201) self.assertEqual(response.json()[0]["status"], "pending") + self.assertEqual(response.json()[0]["port"], 443) def test_bulk_create_rejects_non_list(self): """POST returns 400 when the body is not a list.""" response = self.client.post( "/api/v1/requests", - data={"relation_id": 1, "backend_name": "x"}, + data={"relation_id": 1, "backend_name": "x", "port": 443}, format="json", ) self.assertEqual(response.status_code, 400) @@ -105,6 +122,7 @@ def setUp(self): relation_id=10, hostname_acls=["host.test"], backend_name="detail-backend", + port=443, ) def test_get_existing(self): diff --git a/haproxy-route-policy/policy/views.py b/haproxy-route-policy/policy/views.py index 548d20517..fb92139cf 100644 --- a/haproxy-route-policy/policy/views.py +++ b/haproxy-route-policy/policy/views.py @@ -44,6 +44,7 @@ def post(self, request): hostname_acls=item.get("hostname_acls", []), backend_name=item.get("backend_name"), paths=item.get("paths", []), + port=item.get("port"), status=REQUEST_STATUS_PENDING, ) backend_request.full_clean() From 597eb6672c38cd48e3cb2d16b66243c1c4ab8b54 Mon Sep 17 00:00:00 2001 From: tphan025 Date: Tue, 17 Mar 2026 20:16:30 +0100 Subject: [PATCH 09/71] Add change artifact --- docs/release-notes/artifacts/pr0399.yaml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 docs/release-notes/artifacts/pr0399.yaml diff --git a/docs/release-notes/artifacts/pr0399.yaml b/docs/release-notes/artifacts/pr0399.yaml new file mode 100644 index 000000000..42dbc4b55 --- /dev/null +++ b/docs/release-notes/artifacts/pr0399.yaml @@ -0,0 +1,20 @@ +version_schema: 2 + +changes: + - title: Added requests management REST API for haproxy-route-policy app + author: tphan025 + type: minor + description: > + Added the policy Django app with a BackendRequest model and REST API endpoints + for managing backend requests. Implemented GET /api/v1/requests (list with optional + status filter), POST /api/v1/requests (bulk create with all requests set to pending), + GET /api/v1/requests/ (retrieve by ID), and DELETE /api/v1/requests/ + (idempotent delete). Included hostname validation, test settings with in-memory + SQLite, and unit and integration tests for models and views. + urls: + pr: + - https://github.com/canonical/haproxy-operator/pull/399 + related_doc: + related_issue: + visibility: public + highlight: false From 6f51301a31fb5736642c2913e290a5c16e213ce8 Mon Sep 17 00:00:00 2001 From: tphan025 Date: Tue, 17 Mar 2026 20:32:11 +0100 Subject: [PATCH 10/71] run lint with uv --- .github/workflows/test.yaml | 18 +- haproxy-route-policy/.python-version | 1 + haproxy-route-policy/README.md | 0 haproxy-route-policy/policy/db_models.py | 18 +- haproxy-route-policy/policy/views.py | 4 +- haproxy-route-policy/pyproject.toml | 22 ++ haproxy-route-policy/requirements.txt | 3 - haproxy-route-policy/tox.toml | 56 ++++ haproxy-route-policy/uv.lock | 339 +++++++++++++++++++++++ 9 files changed, 439 insertions(+), 22 deletions(-) create mode 100644 haproxy-route-policy/.python-version create mode 100644 haproxy-route-policy/README.md create mode 100644 haproxy-route-policy/pyproject.toml delete mode 100644 haproxy-route-policy/requirements.txt create mode 100644 haproxy-route-policy/tox.toml create mode 100644 haproxy-route-policy/uv.lock diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index ac13eacff..1185d12da 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -14,6 +14,8 @@ jobs: working-directory: ./haproxy-spoe-auth-operator - name: haproxy-ddos-protection-configurator working-directory: ./haproxy-ddos-protection-configurator + - name: haproxy-route-policy + working-directory: ./haproxy-route-policy name: Unit tests for ${{ matrix.charm.name }} uses: canonical/operator-workflows/.github/workflows/test.yaml@main secrets: inherit @@ -27,11 +29,11 @@ jobs: name: HAProxy-route Policy App Tests runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6.0.1 - - uses: actions/setup-python@v6 - with: - python-version: '3.x' - - working-directory: ./haproxy-route-policy - run: | - pip install -r requirements.txt - python3 ./manage.py test --settings=haproxy_route_policy.test_settings + - uses: actions/checkout@v6.0.1 + - uses: actions/setup-python@v6 + with: + python-version: "3.x" + - working-directory: ./haproxy-route-policy + run: | + pip install -r requirements.txt + python3 ./manage.py test --settings=haproxy_route_policy.test_settings diff --git a/haproxy-route-policy/.python-version b/haproxy-route-policy/.python-version new file mode 100644 index 000000000..e4fba2183 --- /dev/null +++ b/haproxy-route-policy/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/haproxy-route-policy/README.md b/haproxy-route-policy/README.md new file mode 100644 index 000000000..e69de29bb diff --git a/haproxy-route-policy/policy/db_models.py b/haproxy-route-policy/policy/db_models.py index 119519bca..81b0921bd 100644 --- a/haproxy-route-policy/policy/db_models.py +++ b/haproxy-route-policy/policy/db_models.py @@ -51,21 +51,21 @@ class BackendRequest(models.Model): updated_at: Timestamp when the request was last updated. """ - id = models.BigAutoField(primary_key=True) - relation_id = models.IntegerField() - hostname_acls = models.JSONField( + id: models.BigAutoField = models.BigAutoField(primary_key=True) + relation_id: models.IntegerField = models.IntegerField() + hostname_acls: models.JSONField = models.JSONField( default=list, validators=[validate_hostname_acls], blank=True ) - backend_name = models.TextField() - paths = models.JSONField(default=list, blank=True) - port = models.IntegerField() - status = models.TextField( + backend_name: models.TextField = models.TextField() + paths: models.JSONField = models.JSONField(default=list, blank=True) + port: models.IntegerField = models.IntegerField() + status: models.TextField = models.TextField( choices=REQUEST_STATUS_CHOICES, default=REQUEST_STATUS_PENDING, db_index=True, ) - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) + created_at: models.DateTimeField = models.DateTimeField(auto_now_add=True) + updated_at: models.DateTimeField = models.DateTimeField(auto_now=True) def to_dict(self) -> dict: """Serialize to a JSON-compatible dict.""" diff --git a/haproxy-route-policy/policy/views.py b/haproxy-route-policy/policy/views.py index fb92139cf..f44b88c19 100644 --- a/haproxy-route-policy/policy/views.py +++ b/haproxy-route-policy/policy/views.py @@ -51,9 +51,9 @@ def post(self, request): backend_request.save() created.append(backend_request.to_dict()) except ValidationError as e: - return HttpResponseBadRequest(str(e), status=400) + return HttpResponseBadRequest(bytes(str(e), encoding="utf-8"), status=400) except IntegrityError: - return HttpResponseBadRequest("Invalid request data.", status=400) + return HttpResponseBadRequest(b"Invalid request data.", status=400) return JsonResponse(created, safe=False, status=201) diff --git a/haproxy-route-policy/pyproject.toml b/haproxy-route-policy/pyproject.toml new file mode 100644 index 000000000..bf5b72e0d --- /dev/null +++ b/haproxy-route-policy/pyproject.toml @@ -0,0 +1,22 @@ +[project] +name = "haproxy-route-policy" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "django>=6.0.3", + "djangorestframework>=3.16.1", + "validators>=0.35.0", +] + +[dependency-groups] +lint = [ + "codespell>=2.4.2", + "django-stubs>=6.0.0", + "django-types>=0.23.0", + "djangorestframework-stubs>=3.16.8", + "djangorestframework-types>=0.9.0", + "mypy>=1.19.1", + "ruff>=0.15.6", +] diff --git a/haproxy-route-policy/requirements.txt b/haproxy-route-policy/requirements.txt deleted file mode 100644 index df9f7bbe8..000000000 --- a/haproxy-route-policy/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -Django==6.0.3 -djangorestframework==3.16.1 -validators==0.35.0 diff --git a/haproxy-route-policy/tox.toml b/haproxy-route-policy/tox.toml new file mode 100644 index 000000000..819c5fbe6 --- /dev/null +++ b/haproxy-route-policy/tox.toml @@ -0,0 +1,56 @@ +# Copyright 2025 Canonical Ltd. +# See LICENSE file for licensing details. + +skipsdist = true +skip_missing_interpreters = true +requires = ["tox>=4.21"] +no_package = true + +[env_run_base] +passenv = ["PYTHONPATH"] +runner = "uv-venv-lock-runner" + +[env_run_base.setenv] +PYTHONPATH = "{toxinidir}:{[vars]src_path}" +PYTHONBREAKPOINT = "ipdb.set_trace" +PY_COLORS = "1" + +[env.lint] +description = "Check code against coding style standards" +commands = [ + [ + "codespell", + "{toxinidir}", + ], + [ + "ruff", + "format", + "--check", + "--diff", + { replace = "ref", of = [ + "vars", + "all_path", + ], extend = true }, + ], + [ + "ruff", + "check", + { replace = "ref", of = [ + "vars", + "all_path", + ], extend = true }, + ], + [ + "mypy", + { replace = "ref", of = [ + "vars", + "all_path", + ], extend = true }, + ], +] +dependency_groups = ["lint"] + +[vars] +src_path = "{toxinidir}/policy/" +tst_path = "{toxinidir}/policy/tests" +all_path = ["{toxinidir}/policy/"] diff --git a/haproxy-route-policy/uv.lock b/haproxy-route-policy/uv.lock new file mode 100644 index 000000000..17c0cdf21 --- /dev/null +++ b/haproxy-route-policy/uv.lock @@ -0,0 +1,339 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "asgiref" +version = "3.11.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/63/40/f03da1264ae8f7cfdbf9146542e5e7e8100a4c66ab48e791df9a03d3f6c0/asgiref-3.11.1.tar.gz", hash = "sha256:5f184dc43b7e763efe848065441eac62229c9f7b0475f41f80e207a114eda4ce", size = 38550, upload-time = "2026-02-03T13:30:14.33Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/0a/a72d10ed65068e115044937873362e6e32fab1b7dce0046aeb224682c989/asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133", size = 24345, upload-time = "2026-02-03T13:30:13.039Z" }, +] + +[[package]] +name = "codespell" +version = "2.4.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/9d/1d0903dff693160f893ca6abcabad545088e7a2ee0a6deae7c24e958be69/codespell-2.4.2.tar.gz", hash = "sha256:3c33be9ae34543807f088aeb4832dfad8cb2dae38da61cac0a7045dd376cfdf3", size = 352058, upload-time = "2026-03-05T18:10:42.936Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/a1/52fa05533e95fe45bcc09bcf8a503874b1c08f221a4e35608017e0938f55/codespell-2.4.2-py3-none-any.whl", hash = "sha256:97e0c1060cf46bd1d5db89a936c98db8c2b804e1fdd4b5c645e82a1ec6b1f886", size = 353715, upload-time = "2026-03-05T18:10:41.398Z" }, +] + +[[package]] +name = "django" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asgiref" }, + { name = "sqlparse" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/80/e1/894115c6bd70e2c8b66b0c40a3c367d83a5a48c034a4d904d31b62f7c53a/django-6.0.3.tar.gz", hash = "sha256:90be765ee756af8a6cbd6693e56452404b5ad15294f4d5e40c0a55a0f4870fe1", size = 10872701, upload-time = "2026-03-03T13:55:15.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/b1/23f2556967c45e34d3d3cf032eb1bd3ef925ee458667fb99052a0b3ea3a6/django-6.0.3-py3-none-any.whl", hash = "sha256:2e5974441491ddb34c3f13d5e7a9f97b07ba03bf70234c0a9c68b79bbb235bc3", size = 8358527, upload-time = "2026-03-03T13:55:10.552Z" }, +] + +[[package]] +name = "django-stubs" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, + { name = "django-stubs-ext" }, + { name = "types-pyyaml" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/1a/24f0bdd54fccbd1ea0a72bfef2cadd2f53a2138d319c603f6519346b93fb/django_stubs-6.0.0.tar.gz", hash = "sha256:14e7c667d2de73dbaf91ae43d117f923639107d8d3e84a2257ebc101861f18ed", size = 272752, upload-time = "2026-03-17T00:25:12.195Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/a2/b0e8d16fa33e07aa096eacb12d032d4561ce0e7e21248b4d34e943daf8d9/django_stubs-6.0.0-py3-none-any.whl", hash = "sha256:747baa97fb9a5c1892ef93bf881d06b4f211f719da18e4ddfd1e74bbad71e752", size = 535559, upload-time = "2026-03-17T00:25:10.335Z" }, +] + +[[package]] +name = "django-stubs-ext" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e5/39/d9f4ae9506458bfb77bbcd45bf21cd5eb460026acac6727af456a9deabec/django_stubs_ext-6.0.0.tar.gz", hash = "sha256:fb860210b496e75ae751cadee02a3449d5a7599de68c8db9df40c84e559d9298", size = 6686, upload-time = "2026-03-17T00:24:33.688Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/63/f727323c6e422ad72846558d8464955448dfcac3268419929332fc6e31a5/django_stubs_ext-6.0.0-py3-none-any.whl", hash = "sha256:6f8c29e0dd5111fd36aa72519446c8a21c3e419e48c5d7dc7f418c8eec9c43ae", size = 10168, upload-time = "2026-03-17T00:24:32.289Z" }, +] + +[[package]] +name = "django-types" +version = "0.23.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "types-psycopg2" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/73/9a/5c652bbc8694489782c415d7d6fa0782219e401aba25f4d1df2b95c3a34c/django_types-0.23.0.tar.gz", hash = "sha256:f97fb746166fb15a5f40e470a1fd7a58226349aac9e0a9cb8ae81deb14d94fd0", size = 208369, upload-time = "2026-02-04T00:36:23.33Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/de/471afcd92022642544f7866dd5620bc85f04e4312d5e29d7f2960f31f010/django_types-0.23.0-py3-none-any.whl", hash = "sha256:0727b13ae810c4b1f14eeac9872834ac928c99dc76584ea7c23afc4461e049dd", size = 379397, upload-time = "2026-02-04T00:36:21.783Z" }, +] + +[[package]] +name = "djangorestframework" +version = "3.16.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/95/5376fe618646fde6899b3cdc85fd959716bb67542e273a76a80d9f326f27/djangorestframework-3.16.1.tar.gz", hash = "sha256:166809528b1aced0a17dc66c24492af18049f2c9420dbd0be29422029cfc3ff7", size = 1089735, upload-time = "2025-08-06T17:50:53.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/ce/bf8b9d3f415be4ac5588545b5fcdbbb841977db1c1d923f7568eeabe1689/djangorestframework-3.16.1-py3-none-any.whl", hash = "sha256:33a59f47fb9c85ede792cbf88bde71893bcda0667bc573f784649521f1102cec", size = 1080442, upload-time = "2025-08-06T17:50:50.667Z" }, +] + +[[package]] +name = "djangorestframework-stubs" +version = "3.16.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django-stubs" }, + { name = "types-pyyaml" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/d5/87166a827833eb39703856ef957ca0fb4e9d15285331251186a2e738c20c/djangorestframework_stubs-3.16.8.tar.gz", hash = "sha256:f6d464b54fa2f929610e957446c04e6ac29558265418e0a2d9f653a4cdd410b5", size = 32312, upload-time = "2026-02-03T22:35:53.182Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/e9/d9c363b08d07d975c21793fe821b2020dfd3627ac4ce19c5c12df94ce9d0/djangorestframework_stubs-3.16.8-py3-none-any.whl", hash = "sha256:c5bf61def0f330a071dd5f470f05710189d06c467b3f3e186b32c5a23d4952fb", size = 56517, upload-time = "2026-02-03T22:35:50.67Z" }, +] + +[[package]] +name = "djangorestframework-types" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/5d/1a21a5fd10ad9980dcb934b8221934dee2b6b97af5edc58cb169558c0831/djangorestframework_types-0.9.0.tar.gz", hash = "sha256:aa6b27fbdab5ff4ab1dfa5376f3b6ec45713ce48dbcdd4226bf3e1410f0deaca", size = 32521, upload-time = "2024-10-10T00:42:04.01Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/5f/d908ce938356b209d4d27a7fb159ab9100b8814396a69c0204bb66e38703/djangorestframework_types-0.9.0-py3-none-any.whl", hash = "sha256:5e4258fe43774d0a3d018780170bd702bf615407fe244453ea5ec6e6676b98c4", size = 54947, upload-time = "2024-10-10T00:42:02.311Z" }, +] + +[[package]] +name = "haproxy-route-policy" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "django" }, + { name = "djangorestframework" }, + { name = "validators" }, +] + +[package.dev-dependencies] +lint = [ + { name = "codespell" }, + { name = "django-stubs" }, + { name = "django-types" }, + { name = "djangorestframework-stubs" }, + { name = "djangorestframework-types" }, + { name = "mypy" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "django", specifier = ">=6.0.3" }, + { name = "djangorestframework", specifier = ">=3.16.1" }, + { name = "validators", specifier = ">=0.35.0" }, +] + +[package.metadata.requires-dev] +lint = [ + { name = "codespell", specifier = ">=2.4.2" }, + { name = "django-stubs", specifier = ">=6.0.0" }, + { name = "django-types", specifier = ">=0.23.0" }, + { name = "djangorestframework-stubs", specifier = ">=3.16.8" }, + { name = "djangorestframework-types", specifier = ">=0.9.0" }, + { name = "mypy", specifier = ">=1.19.1" }, + { name = "ruff", specifier = ">=0.15.6" }, +] + +[[package]] +name = "librt" +version = "0.8.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/56/9c/b4b0c54d84da4a94b37bd44151e46d5e583c9534c7e02250b961b1b6d8a8/librt-0.8.1.tar.gz", hash = "sha256:be46a14693955b3bd96014ccbdb8339ee8c9346fbe11c1b78901b55125f14c73", size = 177471, upload-time = "2026-02-17T16:13:06.101Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/21/d39b0a87ac52fc98f621fb6f8060efb017a767ebbbac2f99fbcbc9ddc0d7/librt-0.8.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a28f2612ab566b17f3698b0da021ff9960610301607c9a5e8eaca62f5e1c350a", size = 66516, upload-time = "2026-02-17T16:11:41.604Z" }, + { url = "https://files.pythonhosted.org/packages/69/f1/46375e71441c43e8ae335905e069f1c54febee63a146278bcee8782c84fd/librt-0.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:60a78b694c9aee2a0f1aaeaa7d101cf713e92e8423a941d2897f4fa37908dab9", size = 68634, upload-time = "2026-02-17T16:11:43.268Z" }, + { url = "https://files.pythonhosted.org/packages/0a/33/c510de7f93bf1fa19e13423a606d8189a02624a800710f6e6a0a0f0784b3/librt-0.8.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:758509ea3f1eba2a57558e7e98f4659d0ea7670bff49673b0dde18a3c7e6c0eb", size = 198941, upload-time = "2026-02-17T16:11:44.28Z" }, + { url = "https://files.pythonhosted.org/packages/dd/36/e725903416409a533d92398e88ce665476f275081d0d7d42f9c4951999e5/librt-0.8.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:039b9f2c506bd0ab0f8725aa5ba339c6f0cd19d3b514b50d134789809c24285d", size = 209991, upload-time = "2026-02-17T16:11:45.462Z" }, + { url = "https://files.pythonhosted.org/packages/30/7a/8d908a152e1875c9f8eac96c97a480df425e657cdb47854b9efaa4998889/librt-0.8.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bb54f1205a3a6ab41a6fd71dfcdcbd278670d3a90ca502a30d9da583105b6f7", size = 224476, upload-time = "2026-02-17T16:11:46.542Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b8/a22c34f2c485b8903a06f3fe3315341fe6876ef3599792344669db98fcff/librt-0.8.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:05bd41cdee35b0c59c259f870f6da532a2c5ca57db95b5f23689fcb5c9e42440", size = 217518, upload-time = "2026-02-17T16:11:47.746Z" }, + { url = "https://files.pythonhosted.org/packages/79/6f/5c6fea00357e4f82ba44f81dbfb027921f1ab10e320d4a64e1c408d035d9/librt-0.8.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adfab487facf03f0d0857b8710cf82d0704a309d8ffc33b03d9302b4c64e91a9", size = 225116, upload-time = "2026-02-17T16:11:49.298Z" }, + { url = "https://files.pythonhosted.org/packages/f2/a0/95ced4e7b1267fe1e2720a111685bcddf0e781f7e9e0ce59d751c44dcfe5/librt-0.8.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:153188fe98a72f206042be10a2c6026139852805215ed9539186312d50a8e972", size = 217751, upload-time = "2026-02-17T16:11:50.49Z" }, + { url = "https://files.pythonhosted.org/packages/93/c2/0517281cb4d4101c27ab59472924e67f55e375bc46bedae94ac6dc6e1902/librt-0.8.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dd3c41254ee98604b08bd5b3af5bf0a89740d4ee0711de95b65166bf44091921", size = 218378, upload-time = "2026-02-17T16:11:51.783Z" }, + { url = "https://files.pythonhosted.org/packages/43/e8/37b3ac108e8976888e559a7b227d0ceac03c384cfd3e7a1c2ee248dbae79/librt-0.8.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e0d138c7ae532908cbb342162b2611dbd4d90c941cd25ab82084aaf71d2c0bd0", size = 241199, upload-time = "2026-02-17T16:11:53.561Z" }, + { url = "https://files.pythonhosted.org/packages/4b/5b/35812d041c53967fedf551a39399271bbe4257e681236a2cf1a69c8e7fa1/librt-0.8.1-cp312-cp312-win32.whl", hash = "sha256:43353b943613c5d9c49a25aaffdba46f888ec354e71e3529a00cca3f04d66a7a", size = 54917, upload-time = "2026-02-17T16:11:54.758Z" }, + { url = "https://files.pythonhosted.org/packages/de/d1/fa5d5331b862b9775aaf2a100f5ef86854e5d4407f71bddf102f4421e034/librt-0.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:ff8baf1f8d3f4b6b7257fcb75a501f2a5499d0dda57645baa09d4d0d34b19444", size = 62017, upload-time = "2026-02-17T16:11:55.748Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7c/c614252f9acda59b01a66e2ddfd243ed1c7e1deab0293332dfbccf862808/librt-0.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f2ae3725904f7377e11cc37722d5d401e8b3d5851fb9273d7f4fe04f6b3d37d", size = 52441, upload-time = "2026-02-17T16:11:56.801Z" }, + { url = "https://files.pythonhosted.org/packages/c5/3c/f614c8e4eaac7cbf2bbdf9528790b21d89e277ee20d57dc6e559c626105f/librt-0.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7e6bad1cd94f6764e1e21950542f818a09316645337fd5ab9a7acc45d99a8f35", size = 66529, upload-time = "2026-02-17T16:11:57.809Z" }, + { url = "https://files.pythonhosted.org/packages/ab/96/5836544a45100ae411eda07d29e3d99448e5258b6e9c8059deb92945f5c2/librt-0.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cf450f498c30af55551ba4f66b9123b7185362ec8b625a773b3d39aa1a717583", size = 68669, upload-time = "2026-02-17T16:11:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/06/53/f0b992b57af6d5531bf4677d75c44f095f2366a1741fb695ee462ae04b05/librt-0.8.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:eca45e982fa074090057132e30585a7e8674e9e885d402eae85633e9f449ce6c", size = 199279, upload-time = "2026-02-17T16:11:59.862Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ad/4848cc16e268d14280d8168aee4f31cea92bbd2b79ce33d3e166f2b4e4fc/librt-0.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c3811485fccfda840861905b8c70bba5ec094e02825598bb9d4ca3936857a04", size = 210288, upload-time = "2026-02-17T16:12:00.954Z" }, + { url = "https://files.pythonhosted.org/packages/52/05/27fdc2e95de26273d83b96742d8d3b7345f2ea2bdbd2405cc504644f2096/librt-0.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e4af413908f77294605e28cfd98063f54b2c790561383971d2f52d113d9c363", size = 224809, upload-time = "2026-02-17T16:12:02.108Z" }, + { url = "https://files.pythonhosted.org/packages/7a/d0/78200a45ba3240cb042bc597d6f2accba9193a2c57d0356268cbbe2d0925/librt-0.8.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5212a5bd7fae98dae95710032902edcd2ec4dc994e883294f75c857b83f9aba0", size = 218075, upload-time = "2026-02-17T16:12:03.631Z" }, + { url = "https://files.pythonhosted.org/packages/af/72/a210839fa74c90474897124c064ffca07f8d4b347b6574d309686aae7ca6/librt-0.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e692aa2d1d604e6ca12d35e51fdc36f4cda6345e28e36374579f7ef3611b3012", size = 225486, upload-time = "2026-02-17T16:12:04.725Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c1/a03cc63722339ddbf087485f253493e2b013039f5b707e8e6016141130fa/librt-0.8.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4be2a5c926b9770c9e08e717f05737a269b9d0ebc5d2f0060f0fe3fe9ce47acb", size = 218219, upload-time = "2026-02-17T16:12:05.828Z" }, + { url = "https://files.pythonhosted.org/packages/58/f5/fff6108af0acf941c6f274a946aea0e484bd10cd2dc37610287ce49388c5/librt-0.8.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fd1a720332ea335ceb544cf0a03f81df92abd4bb887679fd1e460976b0e6214b", size = 218750, upload-time = "2026-02-17T16:12:07.09Z" }, + { url = "https://files.pythonhosted.org/packages/71/67/5a387bfef30ec1e4b4f30562c8586566faf87e47d696768c19feb49e3646/librt-0.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2af9e01e0ef80d95ae3c720be101227edae5f2fe7e3dc63d8857fadfc5a1d", size = 241624, upload-time = "2026-02-17T16:12:08.43Z" }, + { url = "https://files.pythonhosted.org/packages/d4/be/24f8502db11d405232ac1162eb98069ca49c3306c1d75c6ccc61d9af8789/librt-0.8.1-cp313-cp313-win32.whl", hash = "sha256:086a32dbb71336627e78cc1d6ee305a68d038ef7d4c39aaff41ae8c9aa46e91a", size = 54969, upload-time = "2026-02-17T16:12:09.633Z" }, + { url = "https://files.pythonhosted.org/packages/5c/73/c9fdf6cb2a529c1a092ce769a12d88c8cca991194dfe641b6af12fa964d2/librt-0.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:e11769a1dbda4da7b00a76cfffa67aa47cfa66921d2724539eee4b9ede780b79", size = 62000, upload-time = "2026-02-17T16:12:10.632Z" }, + { url = "https://files.pythonhosted.org/packages/d3/97/68f80ca3ac4924f250cdfa6e20142a803e5e50fca96ef5148c52ee8c10ea/librt-0.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:924817ab3141aca17893386ee13261f1d100d1ef410d70afe4389f2359fea4f0", size = 52495, upload-time = "2026-02-17T16:12:11.633Z" }, + { url = "https://files.pythonhosted.org/packages/c9/6a/907ef6800f7bca71b525a05f1839b21f708c09043b1c6aa77b6b827b3996/librt-0.8.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6cfa7fe54fd4d1f47130017351a959fe5804bda7a0bc7e07a2cdbc3fdd28d34f", size = 66081, upload-time = "2026-02-17T16:12:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/1b/18/25e991cd5640c9fb0f8d91b18797b29066b792f17bf8493da183bf5caabe/librt-0.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:228c2409c079f8c11fb2e5d7b277077f694cb93443eb760e00b3b83cb8b3176c", size = 68309, upload-time = "2026-02-17T16:12:13.756Z" }, + { url = "https://files.pythonhosted.org/packages/a4/36/46820d03f058cfb5a9de5940640ba03165ed8aded69e0733c417bb04df34/librt-0.8.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7aae78ab5e3206181780e56912d1b9bb9f90a7249ce12f0e8bf531d0462dd0fc", size = 196804, upload-time = "2026-02-17T16:12:14.818Z" }, + { url = "https://files.pythonhosted.org/packages/59/18/5dd0d3b87b8ff9c061849fbdb347758d1f724b9a82241aa908e0ec54ccd0/librt-0.8.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:172d57ec04346b047ca6af181e1ea4858086c80bdf455f61994c4aa6fc3f866c", size = 206907, upload-time = "2026-02-17T16:12:16.513Z" }, + { url = "https://files.pythonhosted.org/packages/d1/96/ef04902aad1424fd7299b62d1890e803e6ab4018c3044dca5922319c4b97/librt-0.8.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b1977c4ea97ce5eb7755a78fae68d87e4102e4aaf54985e8b56806849cc06a3", size = 221217, upload-time = "2026-02-17T16:12:17.906Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ff/7e01f2dda84a8f5d280637a2e5827210a8acca9a567a54507ef1c75b342d/librt-0.8.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10c42e1f6fd06733ef65ae7bebce2872bcafd8d6e6b0a08fe0a05a23b044fb14", size = 214622, upload-time = "2026-02-17T16:12:19.108Z" }, + { url = "https://files.pythonhosted.org/packages/1e/8c/5b093d08a13946034fed57619742f790faf77058558b14ca36a6e331161e/librt-0.8.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4c8dfa264b9193c4ee19113c985c95f876fae5e51f731494fc4e0cf594990ba7", size = 221987, upload-time = "2026-02-17T16:12:20.331Z" }, + { url = "https://files.pythonhosted.org/packages/d3/cc/86b0b3b151d40920ad45a94ce0171dec1aebba8a9d72bb3fa00c73ab25dd/librt-0.8.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:01170b6729a438f0dedc4a26ed342e3dc4f02d1000b4b19f980e1877f0c297e6", size = 215132, upload-time = "2026-02-17T16:12:21.54Z" }, + { url = "https://files.pythonhosted.org/packages/fc/be/8588164a46edf1e69858d952654e216a9a91174688eeefb9efbb38a9c799/librt-0.8.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7b02679a0d783bdae30d443025b94465d8c3dc512f32f5b5031f93f57ac32071", size = 215195, upload-time = "2026-02-17T16:12:23.073Z" }, + { url = "https://files.pythonhosted.org/packages/f5/f2/0b9279bea735c734d69344ecfe056c1ba211694a72df10f568745c899c76/librt-0.8.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:190b109bb69592a3401fe1ffdea41a2e73370ace2ffdc4a0e8e2b39cdea81b78", size = 237946, upload-time = "2026-02-17T16:12:24.275Z" }, + { url = "https://files.pythonhosted.org/packages/e9/cc/5f2a34fbc8aeb35314a3641f9956fa9051a947424652fad9882be7a97949/librt-0.8.1-cp314-cp314-win32.whl", hash = "sha256:e70a57ecf89a0f64c24e37f38d3fe217a58169d2fe6ed6d70554964042474023", size = 50689, upload-time = "2026-02-17T16:12:25.766Z" }, + { url = "https://files.pythonhosted.org/packages/a0/76/cd4d010ab2147339ca2b93e959c3686e964edc6de66ddacc935c325883d7/librt-0.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:7e2f3edca35664499fbb36e4770650c4bd4a08abc1f4458eab9df4ec56389730", size = 57875, upload-time = "2026-02-17T16:12:27.465Z" }, + { url = "https://files.pythonhosted.org/packages/84/0f/2143cb3c3ca48bd3379dcd11817163ca50781927c4537345d608b5045998/librt-0.8.1-cp314-cp314-win_arm64.whl", hash = "sha256:0d2f82168e55ddefd27c01c654ce52379c0750ddc31ee86b4b266bcf4d65f2a3", size = 48058, upload-time = "2026-02-17T16:12:28.556Z" }, + { url = "https://files.pythonhosted.org/packages/d2/0e/9b23a87e37baf00311c3efe6b48d6b6c168c29902dfc3f04c338372fd7db/librt-0.8.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c74a2da57a094bd48d03fa5d196da83d2815678385d2978657499063709abe1", size = 68313, upload-time = "2026-02-17T16:12:29.659Z" }, + { url = "https://files.pythonhosted.org/packages/db/9a/859c41e5a4f1c84200a7d2b92f586aa27133c8243b6cac9926f6e54d01b9/librt-0.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a355d99c4c0d8e5b770313b8b247411ed40949ca44e33e46a4789b9293a907ee", size = 70994, upload-time = "2026-02-17T16:12:31.516Z" }, + { url = "https://files.pythonhosted.org/packages/4c/28/10605366ee599ed34223ac2bf66404c6fb59399f47108215d16d5ad751a8/librt-0.8.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2eb345e8b33fb748227409c9f1233d4df354d6e54091f0e8fc53acdb2ffedeb7", size = 220770, upload-time = "2026-02-17T16:12:33.294Z" }, + { url = "https://files.pythonhosted.org/packages/af/8d/16ed8fd452dafae9c48d17a6bc1ee3e818fd40ef718d149a8eff2c9f4ea2/librt-0.8.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9be2f15e53ce4e83cc08adc29b26fb5978db62ef2a366fbdf716c8a6c8901040", size = 235409, upload-time = "2026-02-17T16:12:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/89/1b/7bdf3e49349c134b25db816e4a3db6b94a47ac69d7d46b1e682c2c4949be/librt-0.8.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:785ae29c1f5c6e7c2cde2c7c0e148147f4503da3abc5d44d482068da5322fd9e", size = 246473, upload-time = "2026-02-17T16:12:36.656Z" }, + { url = "https://files.pythonhosted.org/packages/4e/8a/91fab8e4fd2a24930a17188c7af5380eb27b203d72101c9cc000dbdfd95a/librt-0.8.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d3a7da44baf692f0c6aeb5b2a09c5e6fc7a703bca9ffa337ddd2e2da53f7732", size = 238866, upload-time = "2026-02-17T16:12:37.849Z" }, + { url = "https://files.pythonhosted.org/packages/b9/e0/c45a098843fc7c07e18a7f8a24ca8496aecbf7bdcd54980c6ca1aaa79a8e/librt-0.8.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5fc48998000cbc39ec0d5311312dda93ecf92b39aaf184c5e817d5d440b29624", size = 250248, upload-time = "2026-02-17T16:12:39.445Z" }, + { url = "https://files.pythonhosted.org/packages/82/30/07627de23036640c952cce0c1fe78972e77d7d2f8fd54fa5ef4554ff4a56/librt-0.8.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e96baa6820280077a78244b2e06e416480ed859bbd8e5d641cf5742919d8beb4", size = 240629, upload-time = "2026-02-17T16:12:40.889Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c1/55bfe1ee3542eba055616f9098eaf6eddb966efb0ca0f44eaa4aba327307/librt-0.8.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31362dbfe297b23590530007062c32c6f6176f6099646bb2c95ab1b00a57c382", size = 239615, upload-time = "2026-02-17T16:12:42.446Z" }, + { url = "https://files.pythonhosted.org/packages/2b/39/191d3d28abc26c9099b19852e6c99f7f6d400b82fa5a4e80291bd3803e19/librt-0.8.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc3656283d11540ab0ea01978378e73e10002145117055e03722417aeab30994", size = 263001, upload-time = "2026-02-17T16:12:43.627Z" }, + { url = "https://files.pythonhosted.org/packages/b9/eb/7697f60fbe7042ab4e88f4ee6af496b7f222fffb0a4e3593ef1f29f81652/librt-0.8.1-cp314-cp314t-win32.whl", hash = "sha256:738f08021b3142c2918c03692608baed43bc51144c29e35807682f8070ee2a3a", size = 51328, upload-time = "2026-02-17T16:12:45.148Z" }, + { url = "https://files.pythonhosted.org/packages/7c/72/34bf2eb7a15414a23e5e70ecb9440c1d3179f393d9349338a91e2781c0fb/librt-0.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:89815a22daf9c51884fb5dbe4f1ef65ee6a146e0b6a8df05f753e2e4a9359bf4", size = 58722, upload-time = "2026-02-17T16:12:46.85Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c8/d148e041732d631fc76036f8b30fae4e77b027a1e95b7a84bb522481a940/librt-0.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:bf512a71a23504ed08103a13c941f763db13fb11177beb3d9244c98c29fb4a61", size = 48755, upload-time = "2026-02-17T16:12:47.943Z" }, +] + +[[package]] +name = "mypy" +version = "1.19.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053, upload-time = "2025-12-15T05:03:46.622Z" }, + { url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134, upload-time = "2025-12-15T05:03:24.367Z" }, + { url = "https://files.pythonhosted.org/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2", size = 12731616, upload-time = "2025-12-15T05:02:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847, upload-time = "2025-12-15T05:03:39.633Z" }, + { url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976, upload-time = "2025-12-15T05:03:08.786Z" }, + { url = "https://files.pythonhosted.org/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13", size = 10118104, upload-time = "2025-12-15T05:03:10.834Z" }, + { url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z" }, + { url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581, upload-time = "2025-12-15T05:03:20.087Z" }, + { url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" }, + { url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" }, + { url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510, upload-time = "2025-12-15T05:02:58.438Z" }, + { url = "https://files.pythonhosted.org/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1", size = 13199744, upload-time = "2025-12-15T05:03:30.823Z" }, + { url = "https://files.pythonhosted.org/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718", size = 12215815, upload-time = "2025-12-15T05:02:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/0a/c6/bdd60774a0dbfb05122e3e925f2e9e846c009e479dcec4821dad881f5b52/mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b", size = 12740047, upload-time = "2025-12-15T05:03:33.168Z" }, + { url = "https://files.pythonhosted.org/packages/32/2a/66ba933fe6c76bd40d1fe916a83f04fed253152f451a877520b3c4a5e41e/mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045", size = 13601998, upload-time = "2025-12-15T05:03:13.056Z" }, + { url = "https://files.pythonhosted.org/packages/e3/da/5055c63e377c5c2418760411fd6a63ee2b96cf95397259038756c042574f/mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957", size = 13807476, upload-time = "2025-12-15T05:03:17.977Z" }, + { url = "https://files.pythonhosted.org/packages/cd/09/4ebd873390a063176f06b0dbf1f7783dd87bd120eae7727fa4ae4179b685/mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f", size = 10281872, upload-time = "2025-12-15T05:03:05.549Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "pathspec" +version = "1.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/51/df/f8629c19c5318601d3121e230f74cbee7a3732339c52b21daa2b82ef9c7d/ruff-0.15.6.tar.gz", hash = "sha256:8394c7bb153a4e3811a4ecdacd4a8e6a4fa8097028119160dffecdcdf9b56ae4", size = 4597916, upload-time = "2026-03-12T23:05:47.51Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/2f/4e03a7e5ce99b517e98d3b4951f411de2b0fa8348d39cf446671adcce9a2/ruff-0.15.6-py3-none-linux_armv6l.whl", hash = "sha256:7c98c3b16407b2cf3d0f2b80c80187384bc92c6774d85fefa913ecd941256fff", size = 10508953, upload-time = "2026-03-12T23:05:17.246Z" }, + { url = "https://files.pythonhosted.org/packages/70/60/55bcdc3e9f80bcf39edf0cd272da6fa511a3d94d5a0dd9e0adf76ceebdb4/ruff-0.15.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ee7dcfaad8b282a284df4aa6ddc2741b3f4a18b0555d626805555a820ea181c3", size = 10942257, upload-time = "2026-03-12T23:05:23.076Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f9/005c29bd1726c0f492bfa215e95154cf480574140cb5f867c797c18c790b/ruff-0.15.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3bd9967851a25f038fc8b9ae88a7fbd1b609f30349231dffaa37b6804923c4bb", size = 10322683, upload-time = "2026-03-12T23:05:33.738Z" }, + { url = "https://files.pythonhosted.org/packages/5f/74/2f861f5fd7cbb2146bddb5501450300ce41562da36d21868c69b7a828169/ruff-0.15.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13f4594b04e42cd24a41da653886b04d2ff87adbf57497ed4f728b0e8a4866f8", size = 10660986, upload-time = "2026-03-12T23:05:53.245Z" }, + { url = "https://files.pythonhosted.org/packages/c1/a1/309f2364a424eccb763cdafc49df843c282609f47fe53aa83f38272389e0/ruff-0.15.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e2ed8aea2f3fe57886d3f00ea5b8aae5bf68d5e195f487f037a955ff9fbaac9e", size = 10332177, upload-time = "2026-03-12T23:05:56.145Z" }, + { url = "https://files.pythonhosted.org/packages/30/41/7ebf1d32658b4bab20f8ac80972fb19cd4e2c6b78552be263a680edc55ac/ruff-0.15.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:70789d3e7830b848b548aae96766431c0dc01a6c78c13381f423bf7076c66d15", size = 11170783, upload-time = "2026-03-12T23:06:01.742Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/6d488f6adca047df82cd62c304638bcb00821c36bd4881cfca221561fdfc/ruff-0.15.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:542aaf1de3154cea088ced5a819ce872611256ffe2498e750bbae5247a8114e9", size = 12044201, upload-time = "2026-03-12T23:05:28.697Z" }, + { url = "https://files.pythonhosted.org/packages/71/68/e6f125df4af7e6d0b498f8d373274794bc5156b324e8ab4bf5c1b4fc0ec7/ruff-0.15.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c22e6f02c16cfac3888aa636e9eba857254d15bbacc9906c9689fdecb1953ab", size = 11421561, upload-time = "2026-03-12T23:05:31.236Z" }, + { url = "https://files.pythonhosted.org/packages/f1/9f/f85ef5fd01a52e0b472b26dc1b4bd228b8f6f0435975442ffa4741278703/ruff-0.15.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98893c4c0aadc8e448cfa315bd0cc343a5323d740fe5f28ef8a3f9e21b381f7e", size = 11310928, upload-time = "2026-03-12T23:05:45.288Z" }, + { url = "https://files.pythonhosted.org/packages/8c/26/b75f8c421f5654304b89471ed384ae8c7f42b4dff58fa6ce1626d7f2b59a/ruff-0.15.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:70d263770d234912374493e8cc1e7385c5d49376e41dfa51c5c3453169dc581c", size = 11235186, upload-time = "2026-03-12T23:05:50.677Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d4/d5a6d065962ff7a68a86c9b4f5500f7d101a0792078de636526c0edd40da/ruff-0.15.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:55a1ad63c5a6e54b1f21b7514dfadc0c7fb40093fa22e95143cf3f64ebdcd512", size = 10635231, upload-time = "2026-03-12T23:05:37.044Z" }, + { url = "https://files.pythonhosted.org/packages/d6/56/7c3acf3d50910375349016cf33de24be021532042afbed87942858992491/ruff-0.15.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8dc473ba093c5ec238bb1e7429ee676dca24643c471e11fbaa8a857925b061c0", size = 10340357, upload-time = "2026-03-12T23:06:04.748Z" }, + { url = "https://files.pythonhosted.org/packages/06/54/6faa39e9c1033ff6a3b6e76b5df536931cd30caf64988e112bbf91ef5ce5/ruff-0.15.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:85b042377c2a5561131767974617006f99f7e13c63c111b998f29fc1e58a4cfb", size = 10860583, upload-time = "2026-03-12T23:05:58.978Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/509a201b843b4dfb0b32acdedf68d951d3377988cae43949ba4c4133a96a/ruff-0.15.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:cef49e30bc5a86a6a92098a7fbf6e467a234d90b63305d6f3ec01225a9d092e0", size = 11410976, upload-time = "2026-03-12T23:05:39.955Z" }, + { url = "https://files.pythonhosted.org/packages/6c/25/3fc9114abf979a41673ce877c08016f8e660ad6cf508c3957f537d2e9fa9/ruff-0.15.6-py3-none-win32.whl", hash = "sha256:bbf67d39832404812a2d23020dda68fee7f18ce15654e96fb1d3ad21a5fe436c", size = 10616872, upload-time = "2026-03-12T23:05:42.451Z" }, + { url = "https://files.pythonhosted.org/packages/89/7a/09ece68445ceac348df06e08bf75db72d0e8427765b96c9c0ffabc1be1d9/ruff-0.15.6-py3-none-win_amd64.whl", hash = "sha256:aee25bc84c2f1007ecb5037dff75cef00414fdf17c23f07dc13e577883dca406", size = 11787271, upload-time = "2026-03-12T23:05:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/7f/d0/578c47dd68152ddddddf31cd7fc67dc30b7cdf639a86275fda821b0d9d98/ruff-0.15.6-py3-none-win_arm64.whl", hash = "sha256:c34de3dd0b0ba203be50ae70f5910b17188556630e2178fd7d79fc030eb0d837", size = 11060497, upload-time = "2026-03-12T23:05:25.968Z" }, +] + +[[package]] +name = "sqlparse" +version = "0.5.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/90/76/437d71068094df0726366574cf3432a4ed754217b436eb7429415cf2d480/sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e", size = 120815, upload-time = "2025-12-19T07:17:45.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" }, +] + +[[package]] +name = "types-psycopg2" +version = "2.9.21.20260223" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/55/1f/4daff0ce5e8e191844e65aaa793ed1b9cb40027dc2700906ecf2b6bcc0ed/types_psycopg2-2.9.21.20260223.tar.gz", hash = "sha256:78ed70de2e56bc6b5c26c8c1da8e9af54e49fdc3c94d1504609f3519e2b84f02", size = 27090, upload-time = "2026-02-23T04:11:18.177Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/e7/c566df58410bc0728348b514e718f0b38fa0d248b5c10599a11494ba25d2/types_psycopg2-2.9.21.20260223-py3-none-any.whl", hash = "sha256:c6228ade72d813b0624f4c03feeb89471950ac27cd0506b5debed6f053086bc8", size = 24919, upload-time = "2026-02-23T04:11:17.214Z" }, +] + +[[package]] +name = "types-pyyaml" +version = "6.0.12.20250915" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/69/3c51b36d04da19b92f9e815be12753125bd8bc247ba0470a982e6979e71c/types_pyyaml-6.0.12.20250915.tar.gz", hash = "sha256:0f8b54a528c303f0e6f7165687dd33fafa81c807fcac23f632b63aa624ced1d3", size = 17522, upload-time = "2025-09-15T03:01:00.728Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/e0/1eed384f02555dde685fff1a1ac805c1c7dcb6dd019c916fe659b1c1f9ec/types_pyyaml-6.0.12.20250915-py3-none-any.whl", hash = "sha256:e7d4d9e064e89a3b3cae120b4990cd370874d2bf12fa5f46c97018dd5d3c9ab6", size = 20338, upload-time = "2025-09-15T03:00:59.218Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "tzdata" +version = "2025.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, +] + +[[package]] +name = "validators" +version = "0.35.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/66/a435d9ae49850b2f071f7ebd8119dd4e84872b01630d6736761e6e7fd847/validators-0.35.0.tar.gz", hash = "sha256:992d6c48a4e77c81f1b4daba10d16c3a9bb0dbb79b3a19ea847ff0928e70497a", size = 73399, upload-time = "2025-05-01T05:42:06.7Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/6e/3e955517e22cbdd565f2f8b2e73d52528b14b8bcfdb04f62466b071de847/validators-0.35.0-py3-none-any.whl", hash = "sha256:e8c947097eae7892cb3d26868d637f79f47b4a0554bc6b80065dfe5aac3705dd", size = 44712, upload-time = "2025-05-01T05:42:04.203Z" }, +] From 7f245eab052ef17f1ba724e16019c5f64d056190 Mon Sep 17 00:00:00 2001 From: tphan025 Date: Tue, 17 Mar 2026 20:36:22 +0100 Subject: [PATCH 11/71] add unit testing --- haproxy-route-policy/tox.toml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/haproxy-route-policy/tox.toml b/haproxy-route-policy/tox.toml index 819c5fbe6..d86392900 100644 --- a/haproxy-route-policy/tox.toml +++ b/haproxy-route-policy/tox.toml @@ -15,6 +15,20 @@ PYTHONPATH = "{toxinidir}:{[vars]src_path}" PYTHONBREAKPOINT = "ipdb.set_trace" PY_COLORS = "1" +[env.unit] +description = "Run unit tests" +commands = [ + [ + "uv", + "run", + "manage.py", + "test", + "policy", + "--settings=haproxy_route_policy.test_settings", + "-v2", + ], +] + [env.lint] description = "Check code against coding style standards" commands = [ From 6254485a4fc7fffb3aa7ffc2d4c66af60307b574 Mon Sep 17 00:00:00 2001 From: tphan025 Date: Tue, 17 Mar 2026 20:36:36 +0100 Subject: [PATCH 12/71] remove custom test --- .github/workflows/test.yaml | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 1185d12da..23e5cc81d 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -24,16 +24,3 @@ jobs: self-hosted-runner-image: "noble" working-directory: ${{ matrix.charm.working-directory }} with-uv: true - - haproxy-route-policy: - name: HAProxy-route Policy App Tests - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6.0.1 - - uses: actions/setup-python@v6 - with: - python-version: "3.x" - - working-directory: ./haproxy-route-policy - run: | - pip install -r requirements.txt - python3 ./manage.py test --settings=haproxy_route_policy.test_settings From ad50aa35b48ea8cbe3a30bb3ae1b63dbcaaf2f29 Mon Sep 17 00:00:00 2001 From: tphan025 Date: Tue, 17 Mar 2026 21:21:55 +0100 Subject: [PATCH 13/71] update migration --- .../policy/migrations/0001_initial.py | 46 ++++++------------- 1 file changed, 15 insertions(+), 31 deletions(-) diff --git a/haproxy-route-policy/policy/migrations/0001_initial.py b/haproxy-route-policy/policy/migrations/0001_initial.py index 403c762f5..10d4f2d61 100644 --- a/haproxy-route-policy/policy/migrations/0001_initial.py +++ b/haproxy-route-policy/policy/migrations/0001_initial.py @@ -1,45 +1,29 @@ -# Generated by Django 6.0.3 on 2026-03-16 15:53 +# Generated by Django 6.0.3 on 2026-03-17 20:21 +import policy.db_models from django.db import migrations, models class Migration(migrations.Migration): + initial = True - dependencies = [] + dependencies = [ + ] operations = [ migrations.CreateModel( - name="BackendRequest", + name='BackendRequest', fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("relation_id", models.IntegerField()), - ("hostname_acls", models.JSONField(default=list)), - ("backend_name", models.TextField()), - ("paths", models.JSONField(default=list)), - ("port", models.IntegerField()), - ( - "status", - models.TextField( - choices=[ - ("pending", "pending"), - ("accepted", "accepted"), - ("rejected", "rejected"), - ], - db_index=True, - default="pending", - ), - ), - ("created_at", models.DateTimeField(auto_now_add=True)), - ("updated_at", models.DateTimeField(auto_now=True)), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('relation_id', models.IntegerField()), + ('hostname_acls', models.JSONField(blank=True, default=list, validators=[policy.db_models.validate_hostname_acls])), + ('backend_name', models.TextField()), + ('paths', models.JSONField(blank=True, default=list)), + ('port', models.IntegerField()), + ('status', models.TextField(choices=[('pending', 'pending'), ('accepted', 'accepted'), ('rejected', 'rejected')], db_index=True, default='pending')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), ], ), ] From 3640143b13f2d354a0b6674d7abcad5479fd958a Mon Sep 17 00:00:00 2001 From: Phan Trung Thanh Date: Wed, 18 Mar 2026 09:33:15 +0100 Subject: [PATCH 14/71] Wrap creation under `transaction.atomic` Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- haproxy-route-policy/policy/views.py | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/haproxy-route-policy/policy/views.py b/haproxy-route-policy/policy/views.py index f44b88c19..fc2733b87 100644 --- a/haproxy-route-policy/policy/views.py +++ b/haproxy-route-policy/policy/views.py @@ -13,6 +13,7 @@ from django.core.exceptions import ValidationError from .db_models import BackendRequest, REQUEST_STATUS_PENDING from django.db.utils import IntegrityError +from django.db import transaction class ListCreateRequestsView(APIView): @@ -38,18 +39,19 @@ def post(self, request): created = [] try: - for item in request.data: - backend_request = BackendRequest( - relation_id=item.get("relation_id"), - hostname_acls=item.get("hostname_acls", []), - backend_name=item.get("backend_name"), - paths=item.get("paths", []), - port=item.get("port"), - status=REQUEST_STATUS_PENDING, - ) - backend_request.full_clean() - backend_request.save() - created.append(backend_request.to_dict()) + with transaction.atomic(): + for item in request.data: + backend_request = BackendRequest( + relation_id=item.get("relation_id"), + hostname_acls=item.get("hostname_acls", []), + backend_name=item.get("backend_name"), + paths=item.get("paths", []), + port=item.get("port"), + status=REQUEST_STATUS_PENDING, + ) + backend_request.full_clean() + backend_request.save() + created.append(backend_request.to_dict()) except ValidationError as e: return HttpResponseBadRequest(bytes(str(e), encoding="utf-8"), status=400) except IntegrityError: From adbf18ff6017a471d198b3ce4c78f03773350e9d Mon Sep 17 00:00:00 2001 From: Phan Trung Thanh Date: Wed, 18 Mar 2026 09:34:32 +0100 Subject: [PATCH 15/71] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- haproxy-route-policy/policy/db_models.py | 1 - 1 file changed, 1 deletion(-) diff --git a/haproxy-route-policy/policy/db_models.py b/haproxy-route-policy/policy/db_models.py index 81b0921bd..660c16d11 100644 --- a/haproxy-route-policy/policy/db_models.py +++ b/haproxy-route-policy/policy/db_models.py @@ -27,7 +27,6 @@ def validate_hostname_acls(value: typing.Any): """Validate that the value is a list of valid hostnames.""" - logger.info("Validating hostname_acls: %s", value) if not isinstance(value, list): raise ValidationError("hostname_acls must be a list.") if invalid_hostnames := [ From daef5d312caa1afd1f362dd0ed1d97d79e7c0d31 Mon Sep 17 00:00:00 2001 From: tphan025 Date: Wed, 18 Mar 2026 09:35:40 +0100 Subject: [PATCH 16/71] remove unused code --- haproxy-route-policy/policy/db_models.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/haproxy-route-policy/policy/db_models.py b/haproxy-route-policy/policy/db_models.py index 660c16d11..e523453f9 100644 --- a/haproxy-route-policy/policy/db_models.py +++ b/haproxy-route-policy/policy/db_models.py @@ -8,9 +8,6 @@ from django.db import models from validators import domain from django.core.exceptions import ValidationError -import logging - -logger = logging.getLogger(__name__) REQUEST_STATUS_PENDING = "pending" REQUEST_STATUS_ACCEPTED = "accepted" @@ -83,8 +80,3 @@ def to_dict(self) -> dict: if self.updated_at else None, } - - @classmethod - def required_fields(cls): - """Return a list of fields required for creating a BackendRequest.""" - return ["relation_id", "backend_name", "port"] From 78e5dc075d4b8ccd61aee9a1b44b4ddd01f4da6e Mon Sep 17 00:00:00 2001 From: tphan025 Date: Wed, 18 Mar 2026 09:39:26 +0100 Subject: [PATCH 17/71] minor fixes to settings --- haproxy-route-policy/haproxy_route_policy/settings.py | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/haproxy-route-policy/haproxy_route_policy/settings.py b/haproxy-route-policy/haproxy_route_policy/settings.py index f628c2208..df4f9c1a7 100644 --- a/haproxy-route-policy/haproxy_route_policy/settings.py +++ b/haproxy-route-policy/haproxy_route_policy/settings.py @@ -18,16 +18,8 @@ # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent - - -# Quick-start development settings - unsuitable for production -# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/ - -# SECURITY WARNING: keep the secret key used in production secret! SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY") - -# SECURITY WARNING: don't run with debug turned on in production! -DEBUG = os.environ.get("DJANGO_DEBUG", "True") == "True" +DEBUG = os.environ.get("DJANGO_DEBUG", "").lower() == "true" ALLOWED_HOSTS = [] From cd93076cd2fa50db51323c9ff3d3dcb928a6f470 Mon Sep 17 00:00:00 2001 From: Phan Trung Thanh Date: Wed, 18 Mar 2026 09:39:50 +0100 Subject: [PATCH 18/71] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- haproxy-route-policy/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/haproxy-route-policy/pyproject.toml b/haproxy-route-policy/pyproject.toml index bf5b72e0d..1ef141aee 100644 --- a/haproxy-route-policy/pyproject.toml +++ b/haproxy-route-policy/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "haproxy-route-policy" version = "0.1.0" -description = "Add your description here" +description = "Django REST API for managing HAProxy routing policies." readme = "README.md" requires-python = ">=3.12" dependencies = [ From f93e9cb0ad1e5b8f5bd401b0932c5e93e0ca6b37 Mon Sep 17 00:00:00 2001 From: tphan025 Date: Wed, 18 Mar 2026 15:29:17 +0100 Subject: [PATCH 19/71] use django serializer --- .../policy/migrations/0001_initial.py | 42 +++++++++++++------ haproxy-route-policy/policy/serializers.py | 26 ++++++++++++ .../policy/tests/test_views.py | 15 ------- haproxy-route-policy/policy/views.py | 20 ++++----- 4 files changed, 63 insertions(+), 40 deletions(-) create mode 100644 haproxy-route-policy/policy/serializers.py diff --git a/haproxy-route-policy/policy/migrations/0001_initial.py b/haproxy-route-policy/policy/migrations/0001_initial.py index 10d4f2d61..26cc6998e 100644 --- a/haproxy-route-policy/policy/migrations/0001_initial.py +++ b/haproxy-route-policy/policy/migrations/0001_initial.py @@ -5,25 +5,41 @@ class Migration(migrations.Migration): - initial = True - dependencies = [ - ] + dependencies = [] operations = [ migrations.CreateModel( - name='BackendRequest', + name="BackendRequest", fields=[ - ('id', models.BigAutoField(primary_key=True, serialize=False)), - ('relation_id', models.IntegerField()), - ('hostname_acls', models.JSONField(blank=True, default=list, validators=[policy.db_models.validate_hostname_acls])), - ('backend_name', models.TextField()), - ('paths', models.JSONField(blank=True, default=list)), - ('port', models.IntegerField()), - ('status', models.TextField(choices=[('pending', 'pending'), ('accepted', 'accepted'), ('rejected', 'rejected')], db_index=True, default='pending')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), + ("id", models.BigAutoField(primary_key=True, serialize=False)), + ("relation_id", models.IntegerField()), + ( + "hostname_acls", + models.JSONField( + blank=True, + default=list, + validators=[policy.db_models.validate_hostname_acls], + ), + ), + ("backend_name", models.TextField()), + ("paths", models.JSONField(blank=True, default=list)), + ("port", models.IntegerField()), + ( + "status", + models.TextField( + choices=[ + ("pending", "pending"), + ("accepted", "accepted"), + ("rejected", "rejected"), + ], + db_index=True, + default="pending", + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), ], ), ] diff --git a/haproxy-route-policy/policy/serializers.py b/haproxy-route-policy/policy/serializers.py new file mode 100644 index 000000000..6130dc563 --- /dev/null +++ b/haproxy-route-policy/policy/serializers.py @@ -0,0 +1,26 @@ +from rest_framework import serializers +from policy.db_models import ( + BackendRequest, +) + + +class BackendRequestSerializer(serializers.ModelSerializer): + class Meta: # pyright: ignore[reportIncompatibleVariableOverride] + model = BackendRequest + fields = [ + "id", + "relation_id", + "hostname_acls", + "backend_name", + "paths", + "port", + "status", + "created_at", + "updated_at", + ] + + def create(self, validated_data): + """ + Create and return a new `BackendRequest` instance, given the validated data. + """ + return BackendRequest.objects.create(**validated_data) diff --git a/haproxy-route-policy/policy/tests/test_views.py b/haproxy-route-policy/policy/tests/test_views.py index e230a6d08..049893645 100644 --- a/haproxy-route-policy/policy/tests/test_views.py +++ b/haproxy-route-policy/policy/tests/test_views.py @@ -87,21 +87,6 @@ def test_bulk_create(self): self.assertEqual(data[1]["port"], 443) self.assertEqual(db_models.BackendRequest.objects.count(), 2) - def test_bulk_create_all_set_to_pending(self): - """POST always sets status to pending regardless of input.""" - payload = [ - { - "relation_id": 1, - "backend_name": "test", - "status": "accepted", - "port": 443, - }, - ] - response = self.client.post("/api/v1/requests", data=payload, format="json") - self.assertEqual(response.status_code, 201) - self.assertEqual(response.json()[0]["status"], "pending") - self.assertEqual(response.json()[0]["port"], 443) - def test_bulk_create_rejects_non_list(self): """POST returns 400 when the body is not a list.""" response = self.client.post( diff --git a/haproxy-route-policy/policy/views.py b/haproxy-route-policy/policy/views.py index fc2733b87..8c1449a7a 100644 --- a/haproxy-route-policy/policy/views.py +++ b/haproxy-route-policy/policy/views.py @@ -11,9 +11,10 @@ ) from rest_framework.views import APIView from django.core.exceptions import ValidationError -from .db_models import BackendRequest, REQUEST_STATUS_PENDING +from .db_models import BackendRequest from django.db.utils import IntegrityError from django.db import transaction +from policy import serializers class ListCreateRequestsView(APIView): @@ -40,18 +41,13 @@ def post(self, request): created = [] try: with transaction.atomic(): - for item in request.data: - backend_request = BackendRequest( - relation_id=item.get("relation_id"), - hostname_acls=item.get("hostname_acls", []), - backend_name=item.get("backend_name"), - paths=item.get("paths", []), - port=item.get("port"), - status=REQUEST_STATUS_PENDING, + for backend_request in request.data: + serializer = serializers.BackendRequestSerializer( + data=backend_request ) - backend_request.full_clean() - backend_request.save() - created.append(backend_request.to_dict()) + if serializer.is_valid(raise_exception=True): + serializer.save() + created.append(serializer.data) except ValidationError as e: return HttpResponseBadRequest(bytes(str(e), encoding="utf-8"), status=400) except IntegrityError: From 4a92e212de1a1e8e8190a47a515613b881f46660 Mon Sep 17 00:00:00 2001 From: tphan025 Date: Wed, 18 Mar 2026 15:30:30 +0100 Subject: [PATCH 20/71] update gitignore --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 6d2a4e023..453102298 100644 --- a/.gitignore +++ b/.gitignore @@ -30,4 +30,4 @@ terraform/**/.terraform* terraform/**/.tfvars terraform/**/*.tfstate* haproxy-route-policy/db.sqlite3 - +haproxy-route-policy/.python-version From 22e4aefb1db8fa647e304b3e2231668ad2a40221 Mon Sep 17 00:00:00 2001 From: tphan025 Date: Thu, 19 Mar 2026 01:18:24 +0100 Subject: [PATCH 21/71] update view to use django rest --- haproxy-route-policy/policy/db_models.py | 19 -------- haproxy-route-policy/policy/serializers.py | 18 +------ .../policy/tests/test_models.py | 20 -------- haproxy-route-policy/policy/views.py | 48 +++++++++++-------- 4 files changed, 28 insertions(+), 77 deletions(-) diff --git a/haproxy-route-policy/policy/db_models.py b/haproxy-route-policy/policy/db_models.py index e523453f9..4a670cfe4 100644 --- a/haproxy-route-policy/policy/db_models.py +++ b/haproxy-route-policy/policy/db_models.py @@ -3,7 +3,6 @@ """Database models for the haproxy-route-policy application.""" -from datetime import datetime import typing from django.db import models from validators import domain @@ -62,21 +61,3 @@ class BackendRequest(models.Model): ) created_at: models.DateTimeField = models.DateTimeField(auto_now_add=True) updated_at: models.DateTimeField = models.DateTimeField(auto_now=True) - - def to_dict(self) -> dict: - """Serialize to a JSON-compatible dict.""" - return { - "id": self.id, - "relation_id": self.relation_id, - "hostname_acls": self.hostname_acls, - "backend_name": self.backend_name, - "paths": self.paths, - "port": self.port, - "status": self.status, - "created_at": typing.cast(datetime, self.created_at).isoformat() - if self.created_at - else None, - "updated_at": typing.cast(datetime, self.updated_at).isoformat() - if self.updated_at - else None, - } diff --git a/haproxy-route-policy/policy/serializers.py b/haproxy-route-policy/policy/serializers.py index 6130dc563..56a1549f7 100644 --- a/haproxy-route-policy/policy/serializers.py +++ b/haproxy-route-policy/policy/serializers.py @@ -7,20 +7,4 @@ class BackendRequestSerializer(serializers.ModelSerializer): class Meta: # pyright: ignore[reportIncompatibleVariableOverride] model = BackendRequest - fields = [ - "id", - "relation_id", - "hostname_acls", - "backend_name", - "paths", - "port", - "status", - "created_at", - "updated_at", - ] - - def create(self, validated_data): - """ - Create and return a new `BackendRequest` instance, given the validated data. - """ - return BackendRequest.objects.create(**validated_data) + fields = "__all__" diff --git a/haproxy-route-policy/policy/tests/test_models.py b/haproxy-route-policy/policy/tests/test_models.py index 7d3233715..e9981db1d 100644 --- a/haproxy-route-policy/policy/tests/test_models.py +++ b/haproxy-route-policy/policy/tests/test_models.py @@ -41,23 +41,3 @@ def test_create_with_all_fields(self): self.assertEqual(request.paths, ["/api", "/health"]) self.assertEqual(request.port, 443) self.assertEqual(request.status, db_models.REQUEST_STATUS_ACCEPTED) - - def test_to_jsonable(self): - """Test serialisation to a JSON-compatible dict.""" - request = db_models.BackendRequest.objects.create( - relation_id=2, - hostname_acls=["host.example.com"], - backend_name="backend-a", - paths=["/v1"], - port=443, - ) - data = request.to_dict() - self.assertEqual(data["id"], request.pk) - self.assertEqual(data["relation_id"], 2) - self.assertEqual(data["hostname_acls"], ["host.example.com"]) - self.assertEqual(data["backend_name"], "backend-a") - self.assertEqual(data["paths"], ["/v1"]) - self.assertEqual(data["port"], 443) - self.assertEqual(data["status"], db_models.REQUEST_STATUS_PENDING) - self.assertIn("created_at", data) - self.assertIn("updated_at", data) diff --git a/haproxy-route-policy/policy/views.py b/haproxy-route-policy/policy/views.py index 8c1449a7a..8a5a7d564 100644 --- a/haproxy-route-policy/policy/views.py +++ b/haproxy-route-policy/policy/views.py @@ -3,15 +3,16 @@ """REST API views for backend requests.""" -from django.http import ( - HttpResponse, - HttpResponseNotFound, - HttpResponseBadRequest, - JsonResponse, -) +from policy.db_models import BackendRequest from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework.status import ( + HTTP_201_CREATED, + HTTP_400_BAD_REQUEST, + HTTP_404_NOT_FOUND, + HTTP_204_NO_CONTENT, +) from django.core.exceptions import ValidationError -from .db_models import BackendRequest from django.db.utils import IntegrityError from django.db import transaction from policy import serializers @@ -22,11 +23,12 @@ class ListCreateRequestsView(APIView): def get(self, request): """List all requests, optionally filtered by status.""" - status = request.GET.get("status") - queryset = BackendRequest.objects.all() - if status: - queryset = queryset.filter(status=status) - return JsonResponse([r.to_dict() for r in queryset.order_by("id")], safe=False) + filter = ( + {"status": request.GET.get("status")} if request.GET.get("status") else {} + ) + queryset = BackendRequest.objects.all().filter(**filter) + serializer = serializers.BackendRequestSerializer(queryset, many=True) + return Response(serializer.data) def post(self, request): """Bulk create backend requests. @@ -34,8 +36,9 @@ def post(self, request): All new requests are set to 'pending' (evaluation logic is deferred). """ if not isinstance(request.data, list): - return JsonResponse( - {"error": "Expected a list of request objects."}, status=400 + return Response( + {"error": "Expected a list of request objects."}, + status=HTTP_400_BAD_REQUEST, ) created = [] @@ -49,24 +52,27 @@ def post(self, request): serializer.save() created.append(serializer.data) except ValidationError as e: - return HttpResponseBadRequest(bytes(str(e), encoding="utf-8"), status=400) + return Response({"error": str(e)}, status=HTTP_400_BAD_REQUEST) except IntegrityError: - return HttpResponseBadRequest(b"Invalid request data.", status=400) - return JsonResponse(created, safe=False, status=201) + return Response( + {"error": "Invalid request data."}, status=HTTP_400_BAD_REQUEST + ) + return Response(created, status=HTTP_201_CREATED) class RequestDetailView(APIView): """View for getting or deleting a single backend request.""" - def get(self, request, pk): + def get(self, _request, pk): """Get a request by ID.""" try: backend_request = BackendRequest.objects.get(pk=pk) + serializer = serializers.BackendRequestSerializer(backend_request) except BackendRequest.DoesNotExist: - return HttpResponseNotFound() - return JsonResponse(backend_request.to_dict()) + return Response(status=HTTP_404_NOT_FOUND) + return Response(serializer.data) def delete(self, request, pk): """Delete a request by ID.""" BackendRequest.objects.filter(pk=pk).delete() - return HttpResponse(status=204) + return Response(status=HTTP_204_NO_CONTENT) From 266e20e217032673c6cf374f53ca0619dd60439c Mon Sep 17 00:00:00 2001 From: tphan025 Date: Thu, 19 Mar 2026 02:01:05 +0100 Subject: [PATCH 22/71] remove python-version --- .gitignore | 1 - haproxy-route-policy/.python-version | 1 - 2 files changed, 2 deletions(-) delete mode 100644 haproxy-route-policy/.python-version diff --git a/.gitignore b/.gitignore index 453102298..906e8929b 100644 --- a/.gitignore +++ b/.gitignore @@ -30,4 +30,3 @@ terraform/**/.terraform* terraform/**/.tfvars terraform/**/*.tfstate* haproxy-route-policy/db.sqlite3 -haproxy-route-policy/.python-version diff --git a/haproxy-route-policy/.python-version b/haproxy-route-policy/.python-version deleted file mode 100644 index e4fba2183..000000000 --- a/haproxy-route-policy/.python-version +++ /dev/null @@ -1 +0,0 @@ -3.12 From d5db748e408157ef404f981ee9ed6965d2894bb3 Mon Sep 17 00:00:00 2001 From: tphan025 Date: Thu, 19 Mar 2026 02:01:33 +0100 Subject: [PATCH 23/71] update gitignore --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 906e8929b..c08ae9c7e 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,5 @@ terraform/**/.terraform* terraform/**/.tfvars terraform/**/*.tfstate* haproxy-route-policy/db.sqlite3 +haproxy-route-policy/.python-version + From 34ca372633a7f3690fe9c6c49733b9a09a0038dd Mon Sep 17 00:00:00 2001 From: tphan025 Date: Thu, 19 Mar 2026 02:05:58 +0100 Subject: [PATCH 24/71] add missing license headers --- haproxy-route-policy/policy/migrations/__init__.py | 2 ++ haproxy-route-policy/policy/serializers.py | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/haproxy-route-policy/policy/migrations/__init__.py b/haproxy-route-policy/policy/migrations/__init__.py index e69de29bb..fa89e9d7f 100644 --- a/haproxy-route-policy/policy/migrations/__init__.py +++ b/haproxy-route-policy/policy/migrations/__init__.py @@ -0,0 +1,2 @@ +# Copyright 2026 Canonical Ltd. +# See LICENSE file for licensing details. diff --git a/haproxy-route-policy/policy/serializers.py b/haproxy-route-policy/policy/serializers.py index 56a1549f7..27139efb3 100644 --- a/haproxy-route-policy/policy/serializers.py +++ b/haproxy-route-policy/policy/serializers.py @@ -1,3 +1,8 @@ +# Copyright 2026 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Serializers for the haproxy-route-policy application.""" + from rest_framework import serializers from policy.db_models import ( BackendRequest, From 3ea5d0ec27da01d6391dcb859b3cf3f3d29d8f11 Mon Sep 17 00:00:00 2001 From: tphan025 Date: Tue, 17 Mar 2026 21:20:56 +0100 Subject: [PATCH 25/71] Add rules engine --- haproxy-route-policy/policy/db_models.py | 65 ++++++ ...r_backendrequest_hostname_acls_and_more.py | 43 ++++ .../policy/tests/test_models.py | 133 ++++++++++++- .../policy/tests/test_views.py | 187 +++++++++++++++++- haproxy-route-policy/policy/urls.py | 10 + haproxy-route-policy/policy/views.py | 83 +++++++- 6 files changed, 517 insertions(+), 4 deletions(-) create mode 100644 haproxy-route-policy/policy/migrations/0002_rule_alter_backendrequest_hostname_acls_and_more.py diff --git a/haproxy-route-policy/policy/db_models.py b/haproxy-route-policy/policy/db_models.py index 4a670cfe4..d5919af42 100644 --- a/haproxy-route-policy/policy/db_models.py +++ b/haproxy-route-policy/policy/db_models.py @@ -4,6 +4,7 @@ """Database models for the haproxy-route-policy application.""" import typing +import uuid from django.db import models from validators import domain from django.core.exceptions import ValidationError @@ -20,6 +21,24 @@ REQUEST_STATUS_CHOICES = [(status, status) for status in REQUEST_STATUSES] +RULE_ACTION_ALLOW = "allow" +RULE_ACTION_DENY = "deny" + +RULE_ACTIONS = [ + RULE_ACTION_ALLOW, + RULE_ACTION_DENY, +] + +RULE_ACTION_CHOICES = [(action, action) for action in RULE_ACTIONS] + +RULE_KIND_HOSTNAME_AND_PATH_MATCH = "hostname_and_path_match" + +RULE_KINDS = [ + RULE_KIND_HOSTNAME_AND_PATH_MATCH, +] + +RULE_KIND_CHOICES = [(kind, kind) for kind in RULE_KINDS] + def validate_hostname_acls(value: typing.Any): """Validate that the value is a list of valid hostnames.""" @@ -61,3 +80,49 @@ class BackendRequest(models.Model): ) created_at: models.DateTimeField = models.DateTimeField(auto_now_add=True) updated_at: models.DateTimeField = models.DateTimeField(auto_now=True) + + +class Rule(models.Model): + """A rule used to evaluate backend requests. + + Rules are matched against incoming backend requests to automatically + accept or deny them. Rules have a priority and an action (allow/deny). + + Attrs: + id: UUID primary key. + kind: The type of rule (e.g. hostname_and_path_match, match_request_id). + value: The rule value, structure depends on kind. + action: Whether the rule allows or denies matching requests. + priority: Rule priority (higher = evaluated first, deny wins on tie). + comment: Optional human-readable comment. + created_at: Timestamp when the rule was created. + updated_at: Timestamp when the rule was last updated. + """ + + id: models.UUIDField = models.UUIDField( + primary_key=True, default=uuid.uuid4, editable=False + ) + kind: models.TextField = models.TextField(choices=RULE_KIND_CHOICES) + value: models.JSONField = models.JSONField() + action: models.TextField = models.TextField(choices=RULE_ACTION_CHOICES) + priority: models.IntegerField = models.IntegerField(default=0, blank=True) + comment: models.TextField = models.TextField(default="", blank=True) + created_at: models.DateTimeField = models.DateTimeField(auto_now_add=True) + updated_at: models.DateTimeField = models.DateTimeField(auto_now=True) + + def to_dict(self) -> dict: + """Serialize to a JSON-compatible dict.""" + return { + "id": str(self.id), + "kind": self.kind, + "value": self.value, + "action": self.action, + "priority": self.priority, + "comment": self.comment, + "created_at": typing.cast(datetime, self.created_at).isoformat() + if self.created_at + else None, + "updated_at": typing.cast(datetime, self.updated_at).isoformat() + if self.updated_at + else None, + } diff --git a/haproxy-route-policy/policy/migrations/0002_rule_alter_backendrequest_hostname_acls_and_more.py b/haproxy-route-policy/policy/migrations/0002_rule_alter_backendrequest_hostname_acls_and_more.py new file mode 100644 index 000000000..92ff9a4a1 --- /dev/null +++ b/haproxy-route-policy/policy/migrations/0002_rule_alter_backendrequest_hostname_acls_and_more.py @@ -0,0 +1,43 @@ +# Generated by Django 6.0.3 on 2026-03-17 20:05 + +import policy.db_models +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('policy', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Rule', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('kind', models.TextField(choices=[('hostname_and_path_match', 'hostname_and_path_match'), ('match_request_id', 'match_request_id')])), + ('value', models.JSONField()), + ('action', models.TextField(choices=[('allow', 'allow'), ('deny', 'deny')])), + ('priority', models.IntegerField(default=0)), + ('comment', models.TextField(blank=True, default='')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + ), + migrations.AlterField( + model_name='backendrequest', + name='hostname_acls', + field=models.JSONField(blank=True, default=list, validators=[policy.db_models.validate_hostname_acls]), + ), + migrations.AlterField( + model_name='backendrequest', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='backendrequest', + name='paths', + field=models.JSONField(blank=True, default=list), + ), + ] diff --git a/haproxy-route-policy/policy/tests/test_models.py b/haproxy-route-policy/policy/tests/test_models.py index e9981db1d..708e66554 100644 --- a/haproxy-route-policy/policy/tests/test_models.py +++ b/haproxy-route-policy/policy/tests/test_models.py @@ -1,9 +1,10 @@ # Copyright 2026 Canonical Ltd. # See LICENSE file for licensing details. -"""Unit tests for the BackendRequest model.""" +"""Unit tests for the BackendRequest and Rule models.""" from django.test import TestCase +from django.core.exceptions import ValidationError from policy import db_models @@ -41,3 +42,133 @@ def test_create_with_all_fields(self): self.assertEqual(request.paths, ["/api", "/health"]) self.assertEqual(request.port, 443) self.assertEqual(request.status, db_models.REQUEST_STATUS_ACCEPTED) + + +class TestRuleModel(TestCase): + """Tests for Rule model creation, serialisation, and validation.""" + + def test_create_hostname_and_path_match_rule(self): + """Test creating a hostname_and_path_match rule with valid data.""" + rule = db_models.Rule( + kind=db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, + value={"hostnames": ["example.com"], "paths": ["/api"]}, + action=db_models.RULE_ACTION_DENY, + priority=1, + comment="Deny example.com/api", + ) + rule.full_clean() + rule.save() + + self.assertIsNotNone(rule.id) + self.assertEqual(rule.kind, db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH) + self.assertEqual(rule.value, {"hostnames": ["example.com"], "paths": ["/api"]}) + self.assertEqual(rule.action, db_models.RULE_ACTION_DENY) + self.assertEqual(rule.priority, 1) + self.assertEqual(rule.comment, "Deny example.com/api") + self.assertIsNotNone(rule.created_at) + self.assertIsNotNone(rule.updated_at) + + def test_create_rule_defaults(self): + """Test that default values are set correctly.""" + rule = db_models.Rule( + kind=db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, + value={"hostnames": ["test.com"], "paths": []}, + action=db_models.RULE_ACTION_ALLOW, + ) + rule.full_clean() + rule.save() + + self.assertEqual(rule.priority, 0) + self.assertEqual(rule.comment, "") + + def test_to_dict(self): + """Test serialisation to a JSON-compatible dict.""" + rule = db_models.Rule( + kind=db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, + value={"hostnames": ["example.com"], "paths": []}, + action=db_models.RULE_ACTION_DENY, + priority=5, + comment="Test rule", + ) + rule.full_clean() + rule.save() + + data = rule.to_dict() + self.assertEqual(data["id"], str(rule.id)) + self.assertEqual(data["kind"], db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH) + self.assertEqual(data["value"], {"hostnames": ["example.com"], "paths": []}) + self.assertEqual(data["action"], db_models.RULE_ACTION_DENY) + self.assertEqual(data["priority"], 5) + self.assertEqual(data["comment"], "Test rule") + self.assertIn("created_at", data) + self.assertIn("updated_at", data) + + def test_validate_hostname_and_path_match_requires_dict(self): + """Test that hostname_and_path_match rules require a dict value.""" + rule = db_models.Rule( + kind=db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, + value="not-a-dict", + action=db_models.RULE_ACTION_DENY, + ) + with self.assertRaises(ValidationError): + rule.full_clean() + + def test_validate_hostname_and_path_match_requires_hostnames_key(self): + """Test that hostname_and_path_match rules require 'hostnames' key.""" + rule = db_models.Rule( + kind=db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, + value={"paths": []}, + action=db_models.RULE_ACTION_DENY, + ) + with self.assertRaises(ValidationError): + rule.full_clean() + + def test_validate_hostname_and_path_match_requires_paths_key(self): + """Test that hostname_and_path_match rules require 'paths' key.""" + rule = db_models.Rule( + kind=db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, + value={"hostnames": ["example.com"]}, + action=db_models.RULE_ACTION_DENY, + ) + with self.assertRaises(ValidationError): + rule.full_clean() + + def test_validate_hostname_and_path_match_hostnames_must_be_list(self): + """Test that 'hostnames' must be a list.""" + rule = db_models.Rule( + kind=db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, + value={"hostnames": "example.com", "paths": []}, + action=db_models.RULE_ACTION_DENY, + ) + with self.assertRaises(ValidationError): + rule.full_clean() + + def test_validate_hostname_and_path_match_paths_must_be_list(self): + """Test that 'paths' must be a list.""" + rule = db_models.Rule( + kind=db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, + value={"hostnames": ["example.com"], "paths": "/api"}, + action=db_models.RULE_ACTION_DENY, + ) + with self.assertRaises(ValidationError): + rule.full_clean() + + def test_validate_rule_value_rejects_list(self): + """Test that the value field rejects list types.""" + rule = db_models.Rule( + kind=db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, + value=["invalid"], + action=db_models.RULE_ACTION_DENY, + ) + with self.assertRaises(ValidationError): + rule.full_clean() + + def test_invalid_kind_rejected(self): + """Test that an invalid kind value is rejected.""" + rule = db_models.Rule( + kind="invalid_kind", + value=1, + action=db_models.RULE_ACTION_ALLOW, + ) + with self.assertRaises(ValidationError): + rule.full_clean() diff --git a/haproxy-route-policy/policy/tests/test_views.py b/haproxy-route-policy/policy/tests/test_views.py index 049893645..340b8651f 100644 --- a/haproxy-route-policy/policy/tests/test_views.py +++ b/haproxy-route-policy/policy/tests/test_views.py @@ -3,6 +3,8 @@ """Integration tests for the policy REST API views.""" +import uuid + from django.test import TestCase from rest_framework.test import APIClient @@ -79,7 +81,7 @@ def test_bulk_create(self): self.assertEqual(data[0]["backend_name"], "backend-1") self.assertEqual(data[0]["status"], "pending") self.assertEqual(data[0]["hostname_acls"], ["example.com"]) - self.assertEqual(data[0]["paths"], second=["/api"]) + self.assertEqual(data[0]["paths"], ["/api"]) self.assertEqual(data[0]["port"], 443) self.assertEqual(data[1]["backend_name"], "backend-2") self.assertEqual(data[1]["hostname_acls"], []) @@ -134,3 +136,186 @@ def test_delete_nonexistent(self): """DELETE on a non-existent ID still returns 204 (idempotent).""" response = self.client.delete("/api/v1/requests/99999") self.assertEqual(response.status_code, 204) + + +class TestListCreateRulesView(TestCase): + """Tests for GET /api/v1/rules and POST /api/v1/rules.""" + + def setUp(self): + """Set up the API client.""" + self.client = APIClient() + + def test_list_empty(self): + """GET returns an empty list when no rules exist.""" + response = self.client.get("/api/v1/rules") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), []) + + def test_list_returns_all_ordered_by_priority(self): + """GET returns all rules ordered by descending priority.""" + rule_low = db_models.Rule( + kind=db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, + value={"hostnames": ["example.com"], "paths": ["/api"]}, + action=db_models.RULE_ACTION_ALLOW, + priority=0, + ) + rule_low.full_clean() + rule_low.save() + rule_high = db_models.Rule( + kind=db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, + value={"hostnames": ["example.org"], "paths": ["/admin"]}, + action=db_models.RULE_ACTION_DENY, + priority=10, + ) + rule_high.full_clean() + rule_high.save() + + response = self.client.get("/api/v1/rules") + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual(len(data), 2) + # Higher priority should come first + self.assertEqual(data[0]["priority"], 10) + self.assertEqual(data[1]["priority"], 0) + + def test_create_hostname_and_path_match_rule(self): + """POST creates a hostname_and_path_match rule.""" + payload = { + "kind": db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, + "value": {"hostnames": ["example.com"], "paths": ["/api"]}, + "action": db_models.RULE_ACTION_DENY, + "priority": 5, + "comment": "Block example.com/api", + } + response = self.client.post("/api/v1/rules", data=payload, format="json") + self.assertEqual(response.status_code, 201) + data = response.json() + self.assertEqual(data["kind"], db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH) + self.assertEqual( + data["value"], {"hostnames": ["example.com"], "paths": ["/api"]} + ) + self.assertEqual(data["action"], db_models.RULE_ACTION_DENY) + self.assertEqual(data["priority"], 5) + self.assertEqual(data["comment"], "Block example.com/api") + self.assertIn("id", data) + self.assertIn("created_at", data) + self.assertEqual(db_models.Rule.objects.count(), 1) + + def test_create_rule_with_defaults(self): + """POST creates a rule with default priority and comment.""" + payload = { + "kind": db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, + "value": {"hostnames": ["example.com"], "paths": ["/api"]}, + "action": db_models.RULE_ACTION_DENY, + } + response = self.client.post("/api/v1/rules", data=payload, format="json") + self.assertEqual(response.status_code, 201) + data = response.json() + self.assertEqual(data["priority"], 0) + self.assertEqual(data["comment"], "") + + def test_create_rule_missing_required_fields(self): + """POST returns 400 when required fields are missing.""" + payload = {"kind": db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH} + response = self.client.post("/api/v1/rules", data=payload, format="json") + self.assertEqual(response.status_code, 400) + + def test_create_rule_invalid_kind(self): + """POST returns 400 when kind is invalid.""" + payload = { + "kind": "invalid_kind", + "value": 1, + "action": db_models.RULE_ACTION_ALLOW, + } + response = self.client.post("/api/v1/rules", data=payload, format="json") + self.assertEqual(response.status_code, 400) + + def test_create_rule_invalid_value_for_kind(self): + """POST returns 400 when value doesn't match kind requirements.""" + payload = { + "kind": db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, + "value": "not-a-dict", + "action": db_models.RULE_ACTION_DENY, + } + response = self.client.post("/api/v1/rules", data=payload, format="json") + self.assertEqual(response.status_code, 400) + + def test_create_rule_rejects_non_dict(self): + """POST returns 400 when the body is not a JSON object.""" + response = self.client.post( + "/api/v1/rules", data=[{"kind": "test"}], format="json" + ) + self.assertEqual(response.status_code, 400) + + +class TestRuleDetailView(TestCase): + """Tests for GET, PUT, DELETE /api/v1/rules/.""" + + def setUp(self): + """Set up the API client and a sample rule.""" + self.client = APIClient() + self.rule = db_models.Rule( + kind=db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, + value={"hostnames": ["example.com"], "paths": ["/api"]}, + action=db_models.RULE_ACTION_DENY, + priority=1, + comment="Test rule", + ) + self.rule.full_clean() + self.rule.save() + + def test_get_existing(self): + """GET returns the rule matching the given ID.""" + response = self.client.get(f"/api/v1/rules/{self.rule.pk}") + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual(data["id"], str(self.rule.pk)) + self.assertEqual(data["kind"], db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH) + + def test_get_not_found(self): + """GET returns 404 for a non-existent rule ID.""" + fake_id = uuid.uuid4() + response = self.client.get(f"/api/v1/rules/{fake_id}") + self.assertEqual(response.status_code, 404) + + def test_update_rule(self): + """PUT updates the rule fields.""" + payload = { + "priority": 10, + "comment": "Updated comment", + "action": db_models.RULE_ACTION_ALLOW, + } + response = self.client.put( + f"/api/v1/rules/{self.rule.pk}", data=payload, format="json" + ) + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual(data["priority"], 10) + self.assertEqual(data["comment"], "Updated comment") + self.assertEqual(data["action"], db_models.RULE_ACTION_ALLOW) + # Unchanged fields remain the same + self.assertEqual(data["kind"], db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH) + self.assertEqual( + data["value"], {"hostnames": ["example.com"], "paths": ["/api"]} + ) + + def test_update_nonexistent(self): + """PUT returns 404 for a non-existent rule ID.""" + fake_id = uuid.uuid4() + response = self.client.put( + f"/api/v1/rules/{fake_id}", data={"priority": 5}, format="json" + ) + self.assertEqual(response.status_code, 404) + + def test_delete_existing(self): + """DELETE removes the rule and returns 204.""" + pk = self.rule.pk + response = self.client.delete(f"/api/v1/rules/{pk}") + self.assertEqual(response.status_code, 204) + self.assertFalse(db_models.Rule.objects.filter(pk=pk).exists()) + + def test_delete_nonexistent(self): + """DELETE on a non-existent rule ID still returns 204 (idempotent).""" + fake_id = uuid.uuid4() + response = self.client.delete(f"/api/v1/rules/{fake_id}") + self.assertEqual(response.status_code, 204) diff --git a/haproxy-route-policy/policy/urls.py b/haproxy-route-policy/policy/urls.py index f1580fabb..9cb4e203c 100644 --- a/haproxy-route-policy/policy/urls.py +++ b/haproxy-route-policy/policy/urls.py @@ -18,4 +18,14 @@ views.RequestDetailView.as_view(), name="api-request-detail", ), + path( + "api/v1/rules", + views.ListCreateRulesView.as_view(), + name="api-rules", + ), + path( + "api/v1/rules/", + views.RuleDetailView.as_view(), + name="api-rule-detail", + ), ] diff --git a/haproxy-route-policy/policy/views.py b/haproxy-route-policy/policy/views.py index 8a5a7d564..a08a996e3 100644 --- a/haproxy-route-policy/policy/views.py +++ b/haproxy-route-policy/policy/views.py @@ -1,9 +1,9 @@ # Copyright 2026 Canonical Ltd. # See LICENSE file for licensing details. -"""REST API views for backend requests.""" +"""REST API views for backend requests and rules.""" -from policy.db_models import BackendRequest +from policy.db_models import BackendRequest, Rule from rest_framework.views import APIView from rest_framework.response import Response from rest_framework.status import ( @@ -76,3 +76,82 @@ def delete(self, request, pk): """Delete a request by ID.""" BackendRequest.objects.filter(pk=pk).delete() return Response(status=HTTP_204_NO_CONTENT) + + +class ListCreateRulesView(APIView): + """View for listing and creating rules.""" + + def get(self, request): + """List all rules.""" + queryset = Rule.objects.all().order_by("-priority", "created_at") + serializer = serializers.RuleSerializer(queryset, many=True) + return Response(serializer.data) + + def post(self, request): + """Create a new rule.""" + data = request.data + if not isinstance(data, dict): + return Response( + {"error": "Expected a JSON object."}, status=HTTP_400_BAD_REQUEST + ) + + try: + serializer = serializers.RuleSerializer(data=data) + if serializer.is_valid(raise_exception=True): + serializer.save() + except IntegrityError: + return Response( + {"error": "Invalid rule data."}, status=HTTP_400_BAD_REQUEST + ) + + return Response(serializer.data, status=HTTP_201_CREATED) + + +class RuleDetailView(APIView): + """View for getting, updating, or deleting a single rule.""" + + def get(self, request, pk): + """Get a rule by ID.""" + try: + rule = Rule.objects.get(pk=pk) + except (Rule.DoesNotExist, ValueError): + return Response(status=HTTP_404_NOT_FOUND) + return Response(rule.to_dict()) + + def put(self, request, pk): + """Update a rule by ID.""" + try: + rule = Rule.objects.get(pk=pk) + serializer = serializers.RuleSerializer(rule) + except (Rule.DoesNotExist, ValueError): + return Response(status=HTTP_404_NOT_FOUND) + + data = request.data + if not isinstance(data, dict): + return Response( + {"error": "Expected a JSON object."}, status=HTTP_400_BAD_REQUEST + ) + # Update fields if provided + if "kind" in data: + rule.kind = data["kind"] + if "value" in data: + rule.value = data["value"] + if "action" in data: + rule.action = data["action"] + if "priority" in data: + rule.priority = data["priority"] + if "comment" in data: + rule.comment = data["comment"] + + try: + rule.full_clean() + rule.save() + except ValidationError as e: + return Response({"error": str(e)}, status=HTTP_400_BAD_REQUEST) + + return Response(serializer.data) + + def delete(self, request, pk): + """Delete a rule by ID.""" + Rule.objects.filter(pk=pk).delete() + return Response(status=HTTP_204_NO_CONTENT) From 16833e434a0c0c25f71fc65e24f2d7ac266e7c3d Mon Sep 17 00:00:00 2001 From: tphan025 Date: Tue, 17 Mar 2026 21:22:45 +0100 Subject: [PATCH 26/71] update migration --- ...alter_backendrequest_hostname_acls_and_more.py | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/haproxy-route-policy/policy/migrations/0002_rule_alter_backendrequest_hostname_acls_and_more.py b/haproxy-route-policy/policy/migrations/0002_rule_alter_backendrequest_hostname_acls_and_more.py index 92ff9a4a1..d6f1a1444 100644 --- a/haproxy-route-policy/policy/migrations/0002_rule_alter_backendrequest_hostname_acls_and_more.py +++ b/haproxy-route-policy/policy/migrations/0002_rule_alter_backendrequest_hostname_acls_and_more.py @@ -25,19 +25,4 @@ class Migration(migrations.Migration): ('updated_at', models.DateTimeField(auto_now=True)), ], ), - migrations.AlterField( - model_name='backendrequest', - name='hostname_acls', - field=models.JSONField(blank=True, default=list, validators=[policy.db_models.validate_hostname_acls]), - ), - migrations.AlterField( - model_name='backendrequest', - name='id', - field=models.BigAutoField(primary_key=True, serialize=False), - ), - migrations.AlterField( - model_name='backendrequest', - name='paths', - field=models.JSONField(blank=True, default=list), - ), ] From efe9beb2b3f8aa33122643d9ca005129dcd1f35f Mon Sep 17 00:00:00 2001 From: tphan025 Date: Tue, 17 Mar 2026 21:25:09 +0100 Subject: [PATCH 27/71] update view --- haproxy-route-policy/policy/views.py | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/haproxy-route-policy/policy/views.py b/haproxy-route-policy/policy/views.py index a08a996e3..6c599cb82 100644 --- a/haproxy-route-policy/policy/views.py +++ b/haproxy-route-policy/policy/views.py @@ -132,22 +132,25 @@ def put(self, request, pk): {"error": "Expected a JSON object."}, status=HTTP_400_BAD_REQUEST ) # Update fields if provided - if "kind" in data: - rule.kind = data["kind"] - if "value" in data: - rule.value = data["value"] - if "action" in data: - rule.action = data["action"] - if "priority" in data: - rule.priority = data["priority"] - if "comment" in data: - rule.comment = data["comment"] - + if kind := data.get("kind"): + rule.kind = kind + if value := data.get("value"): + rule.value = value + if action := data.get("action"): + rule.action = action + if priority := data.get("priority"): + rule.priority = priority + if comment := data.get("comment"): + rule.comment = comment try: rule.full_clean() rule.save() except ValidationError as e: return Response({"error": str(e)}, status=HTTP_400_BAD_REQUEST) + except IntegrityError: + return Response( + {"error": "Invalid rule data."}, status=HTTP_400_BAD_REQUEST + ) return Response(serializer.data) From 593518ebfcecba6864deb2a868b72e3b9ec11d98 Mon Sep 17 00:00:00 2001 From: tphan025 Date: Tue, 17 Mar 2026 21:26:21 +0100 Subject: [PATCH 28/71] fix lint --- ...r_backendrequest_hostname_acls_and_more.py | 41 +++++++++++++------ 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/haproxy-route-policy/policy/migrations/0002_rule_alter_backendrequest_hostname_acls_and_more.py b/haproxy-route-policy/policy/migrations/0002_rule_alter_backendrequest_hostname_acls_and_more.py index d6f1a1444..84e403e52 100644 --- a/haproxy-route-policy/policy/migrations/0002_rule_alter_backendrequest_hostname_acls_and_more.py +++ b/haproxy-route-policy/policy/migrations/0002_rule_alter_backendrequest_hostname_acls_and_more.py @@ -1,28 +1,45 @@ # Generated by Django 6.0.3 on 2026-03-17 20:05 -import policy.db_models import uuid from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('policy', '0001_initial'), + ("policy", "0001_initial"), ] operations = [ migrations.CreateModel( - name='Rule', + name="Rule", fields=[ - ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), - ('kind', models.TextField(choices=[('hostname_and_path_match', 'hostname_and_path_match'), ('match_request_id', 'match_request_id')])), - ('value', models.JSONField()), - ('action', models.TextField(choices=[('allow', 'allow'), ('deny', 'deny')])), - ('priority', models.IntegerField(default=0)), - ('comment', models.TextField(blank=True, default='')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "kind", + models.TextField( + choices=[ + ("hostname_and_path_match", "hostname_and_path_match"), + ("match_request_id", "match_request_id"), + ] + ), + ), + ("value", models.JSONField()), + ( + "action", + models.TextField(choices=[("allow", "allow"), ("deny", "deny")]), + ), + ("priority", models.IntegerField(default=0)), + ("comment", models.TextField(blank=True, default="")), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), ], ), ] From f23afe1f1433cc65b8db81343a343be0edb2cd7e Mon Sep 17 00:00:00 2001 From: tphan025 Date: Tue, 17 Mar 2026 21:29:16 +0100 Subject: [PATCH 29/71] remove extra tests --- .../policy/tests/test_models.py | 60 ------------------- .../policy/tests/test_views.py | 6 -- 2 files changed, 66 deletions(-) diff --git a/haproxy-route-policy/policy/tests/test_models.py b/haproxy-route-policy/policy/tests/test_models.py index 708e66554..736262c4d 100644 --- a/haproxy-route-policy/policy/tests/test_models.py +++ b/haproxy-route-policy/policy/tests/test_models.py @@ -103,66 +103,6 @@ def test_to_dict(self): self.assertIn("created_at", data) self.assertIn("updated_at", data) - def test_validate_hostname_and_path_match_requires_dict(self): - """Test that hostname_and_path_match rules require a dict value.""" - rule = db_models.Rule( - kind=db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, - value="not-a-dict", - action=db_models.RULE_ACTION_DENY, - ) - with self.assertRaises(ValidationError): - rule.full_clean() - - def test_validate_hostname_and_path_match_requires_hostnames_key(self): - """Test that hostname_and_path_match rules require 'hostnames' key.""" - rule = db_models.Rule( - kind=db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, - value={"paths": []}, - action=db_models.RULE_ACTION_DENY, - ) - with self.assertRaises(ValidationError): - rule.full_clean() - - def test_validate_hostname_and_path_match_requires_paths_key(self): - """Test that hostname_and_path_match rules require 'paths' key.""" - rule = db_models.Rule( - kind=db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, - value={"hostnames": ["example.com"]}, - action=db_models.RULE_ACTION_DENY, - ) - with self.assertRaises(ValidationError): - rule.full_clean() - - def test_validate_hostname_and_path_match_hostnames_must_be_list(self): - """Test that 'hostnames' must be a list.""" - rule = db_models.Rule( - kind=db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, - value={"hostnames": "example.com", "paths": []}, - action=db_models.RULE_ACTION_DENY, - ) - with self.assertRaises(ValidationError): - rule.full_clean() - - def test_validate_hostname_and_path_match_paths_must_be_list(self): - """Test that 'paths' must be a list.""" - rule = db_models.Rule( - kind=db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, - value={"hostnames": ["example.com"], "paths": "/api"}, - action=db_models.RULE_ACTION_DENY, - ) - with self.assertRaises(ValidationError): - rule.full_clean() - - def test_validate_rule_value_rejects_list(self): - """Test that the value field rejects list types.""" - rule = db_models.Rule( - kind=db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, - value=["invalid"], - action=db_models.RULE_ACTION_DENY, - ) - with self.assertRaises(ValidationError): - rule.full_clean() - def test_invalid_kind_rejected(self): """Test that an invalid kind value is rejected.""" rule = db_models.Rule( diff --git a/haproxy-route-policy/policy/tests/test_views.py b/haproxy-route-policy/policy/tests/test_views.py index 340b8651f..54962f4a7 100644 --- a/haproxy-route-policy/policy/tests/test_views.py +++ b/haproxy-route-policy/policy/tests/test_views.py @@ -214,12 +214,6 @@ def test_create_rule_with_defaults(self): self.assertEqual(data["priority"], 0) self.assertEqual(data["comment"], "") - def test_create_rule_missing_required_fields(self): - """POST returns 400 when required fields are missing.""" - payload = {"kind": db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH} - response = self.client.post("/api/v1/rules", data=payload, format="json") - self.assertEqual(response.status_code, 400) - def test_create_rule_invalid_kind(self): """POST returns 400 when kind is invalid.""" payload = { From 6955b9243ec53eb0a494648b3be2a76ae72d9160 Mon Sep 17 00:00:00 2001 From: tphan025 Date: Wed, 18 Mar 2026 14:46:33 +0100 Subject: [PATCH 30/71] add validation and update tests --- haproxy-route-policy/policy/db_models.py | 25 +++ .../policy/tests/test_models.py | 154 ++++++++++++++++++ 2 files changed, 179 insertions(+) diff --git a/haproxy-route-policy/policy/db_models.py b/haproxy-route-policy/policy/db_models.py index d5919af42..ae35815b6 100644 --- a/haproxy-route-policy/policy/db_models.py +++ b/haproxy-route-policy/policy/db_models.py @@ -82,6 +82,11 @@ class BackendRequest(models.Model): updated_at: models.DateTimeField = models.DateTimeField(auto_now=True) +def is_valid_path(value: typing.Any): + """Validate that the value is a list of valid URL paths.""" + return not isinstance(value, str) or not value.startswith("/") + + class Rule(models.Model): """A rule used to evaluate backend requests. @@ -126,3 +131,23 @@ def to_dict(self) -> dict: if self.updated_at else None, } + + def clean(self) -> None: + """Custom validation logic for the Rule model.""" + if self.kind == RULE_KIND_HOSTNAME_AND_PATH_MATCH: + if not isinstance(self.value, dict): + raise ValidationError("The value field must be a JSON object.") + + if hostnames := self.value.get("hostnames"): + if invalid_hostnames := [ + hostname for hostname in hostnames if not domain(hostname) + ]: + raise ValidationError( + f"Invalid hostname(s) in rule: {', '.join(invalid_hostnames)}" + ) + + if paths := self.value.get("paths"): + if invalid_paths := [path for path in paths if is_valid_path(path)]: + raise ValidationError( + f"Invalid path(s) in rule: {', '.join([str(path) for path in invalid_paths])}" + ) diff --git a/haproxy-route-policy/policy/tests/test_models.py b/haproxy-route-policy/policy/tests/test_models.py index 736262c4d..eaff743b4 100644 --- a/haproxy-route-policy/policy/tests/test_models.py +++ b/haproxy-route-policy/policy/tests/test_models.py @@ -112,3 +112,157 @@ def test_invalid_kind_rejected(self): ) with self.assertRaises(ValidationError): rule.full_clean() + + def test_invalid_action_rejected(self): + """Test that an invalid action value is rejected.""" + rule = db_models.Rule( + kind=db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, + value={"hostnames": ["example.com"], "paths": []}, + action="invalid_action", + ) + with self.assertRaises(ValidationError): + rule.full_clean() + + def test_hostname_and_path_match_value_must_be_dict(self): + """Test that hostname_and_path_match rules require a dict value.""" + rule = db_models.Rule( + kind=db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, + value="not-a-dict", + action=db_models.RULE_ACTION_DENY, + ) + with self.assertRaises(ValidationError) as ctx: + rule.full_clean() + self.assertIn("value field must be a JSON object", str(ctx.exception)) + + def test_hostname_and_path_match_value_list_rejected(self): + """Test that hostname_and_path_match rules reject a list value.""" + rule = db_models.Rule( + kind=db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, + value=["not", "a", "dict"], + action=db_models.RULE_ACTION_DENY, + ) + with self.assertRaises(ValidationError) as ctx: + rule.full_clean() + self.assertIn("value field must be a JSON object", str(ctx.exception)) + + def test_hostname_and_path_match_value_int_rejected(self): + """Test that hostname_and_path_match rules reject an integer value.""" + rule = db_models.Rule( + kind=db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, + value=42, + action=db_models.RULE_ACTION_DENY, + ) + with self.assertRaises(ValidationError) as ctx: + rule.full_clean() + self.assertIn("value field must be a JSON object", str(ctx.exception)) + + def test_hostname_and_path_match_invalid_hostname(self): + """Test that invalid hostnames are rejected in hostname_and_path_match rules.""" + rule = db_models.Rule( + kind=db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, + value={"hostnames": ["not a valid hostname!!!"], "paths": []}, + action=db_models.RULE_ACTION_DENY, + ) + with self.assertRaises(ValidationError) as ctx: + rule.full_clean() + self.assertIn("Invalid hostname", str(ctx.exception)) + + def test_hostname_and_path_match_multiple_invalid_hostnames(self): + """Test that multiple invalid hostnames are reported.""" + rule = db_models.Rule( + kind=db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, + value={"hostnames": ["valid.com", "bad host", "also bad!"], "paths": []}, + action=db_models.RULE_ACTION_DENY, + ) + with self.assertRaises(ValidationError) as ctx: + rule.full_clean() + msg = str(ctx.exception) + self.assertIn("bad host", msg) + self.assertIn("also bad!", msg) + + def test_hostname_and_path_match_valid_hostnames_accepted(self): + """Test that valid hostnames pass validation.""" + rule = db_models.Rule( + kind=db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, + value={"hostnames": ["example.com", "sub.example.org"], "paths": []}, + action=db_models.RULE_ACTION_ALLOW, + ) + rule.full_clean() # Should not raise + + def test_hostname_and_path_match_empty_hostnames_accepted(self): + """Test that an empty hostnames list passes validation.""" + rule = db_models.Rule( + kind=db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, + value={"hostnames": [], "paths": []}, + action=db_models.RULE_ACTION_ALLOW, + ) + rule.full_clean() # Should not raise + + def test_hostname_and_path_match_invalid_path_not_starting_with_slash(self): + """Test that paths not starting with / are rejected.""" + rule = db_models.Rule( + kind=db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, + value={"hostnames": ["example.com"], "paths": ["api/v1"]}, + action=db_models.RULE_ACTION_DENY, + ) + with self.assertRaises(ValidationError) as ctx: + rule.full_clean() + self.assertIn("Invalid path", str(ctx.exception)) + + def test_hostname_and_path_match_invalid_path_non_string(self): + """Test that non-string paths are rejected.""" + rule = db_models.Rule( + kind=db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, + value={"hostnames": ["example.com"], "paths": [123]}, + action=db_models.RULE_ACTION_DENY, + ) + with self.assertRaises(ValidationError) as ctx: + rule.full_clean() + self.assertIn("Invalid path", str(ctx.exception)) + + def test_hostname_and_path_match_valid_paths_accepted(self): + """Test that valid paths starting with / pass validation.""" + rule = db_models.Rule( + kind=db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, + value={"hostnames": ["example.com"], "paths": ["/api", "/health"]}, + action=db_models.RULE_ACTION_ALLOW, + ) + rule.full_clean() # Should not raise + + def test_hostname_and_path_match_empty_paths_accepted(self): + """Test that an empty paths list passes validation.""" + rule = db_models.Rule( + kind=db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, + value={"hostnames": ["example.com"], "paths": []}, + action=db_models.RULE_ACTION_ALLOW, + ) + rule.full_clean() # Should not raise + + def test_hostname_and_path_match_multiple_invalid_paths(self): + """Test that multiple invalid paths are reported.""" + rule = db_models.Rule( + kind=db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, + value={"hostnames": [], "paths": ["no-slash", "also-bad"]}, + action=db_models.RULE_ACTION_DENY, + ) + with self.assertRaises(ValidationError) as ctx: + rule.full_clean() + msg = str(ctx.exception) + self.assertIn("no-slash", msg) + self.assertIn("also-bad", msg) + + def test_hostname_and_path_match_both_valid_hostnames_and_paths(self): + """Test that a rule with both valid hostnames and paths passes.""" + rule = db_models.Rule( + kind=db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, + value={ + "hostnames": ["example.com", "app.example.com"], + "paths": ["/api", "/v1/health"], + }, + action=db_models.RULE_ACTION_DENY, + priority=3, + comment="Block specific routes", + ) + rule.full_clean() + rule.save() + self.assertIsNotNone(rule.id) From ff23fa0b27d4cce029cde52b443263444d489e88 Mon Sep 17 00:00:00 2001 From: tphan025 Date: Wed, 18 Mar 2026 16:46:37 +0100 Subject: [PATCH 31/71] update view --- haproxy-route-policy/policy/serializers.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/haproxy-route-policy/policy/serializers.py b/haproxy-route-policy/policy/serializers.py index 27139efb3..a031064c9 100644 --- a/haproxy-route-policy/policy/serializers.py +++ b/haproxy-route-policy/policy/serializers.py @@ -6,6 +6,7 @@ from rest_framework import serializers from policy.db_models import ( BackendRequest, + Rule, ) @@ -13,3 +14,9 @@ class BackendRequestSerializer(serializers.ModelSerializer): class Meta: # pyright: ignore[reportIncompatibleVariableOverride] model = BackendRequest fields = "__all__" + + +class RuleSerializer(serializers.ModelSerializer): + class Meta: # pyright: ignore[reportIncompatibleVariableOverride] + model = Rule + fields = "__all__" From 314a687ce9aaf3241731d692d3643c87f24841de Mon Sep 17 00:00:00 2001 From: tphan025 Date: Thu, 19 Mar 2026 01:24:49 +0100 Subject: [PATCH 32/71] remove to_dict --- haproxy-route-policy/policy/db_models.py | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/haproxy-route-policy/policy/db_models.py b/haproxy-route-policy/policy/db_models.py index ae35815b6..17789744e 100644 --- a/haproxy-route-policy/policy/db_models.py +++ b/haproxy-route-policy/policy/db_models.py @@ -115,23 +115,6 @@ class Rule(models.Model): created_at: models.DateTimeField = models.DateTimeField(auto_now_add=True) updated_at: models.DateTimeField = models.DateTimeField(auto_now=True) - def to_dict(self) -> dict: - """Serialize to a JSON-compatible dict.""" - return { - "id": str(self.id), - "kind": self.kind, - "value": self.value, - "action": self.action, - "priority": self.priority, - "comment": self.comment, - "created_at": typing.cast(datetime, self.created_at).isoformat() - if self.created_at - else None, - "updated_at": typing.cast(datetime, self.updated_at).isoformat() - if self.updated_at - else None, - } - def clean(self) -> None: """Custom validation logic for the Rule model.""" if self.kind == RULE_KIND_HOSTNAME_AND_PATH_MATCH: From a10a0321b1c39adcde4b41e236cc70aa06c17e03 Mon Sep 17 00:00:00 2001 From: tphan025 Date: Thu, 19 Mar 2026 01:25:41 +0100 Subject: [PATCH 33/71] use serializer for get --- haproxy-route-policy/policy/views.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/haproxy-route-policy/policy/views.py b/haproxy-route-policy/policy/views.py index 6c599cb82..0d186e368 100644 --- a/haproxy-route-policy/policy/views.py +++ b/haproxy-route-policy/policy/views.py @@ -114,9 +114,10 @@ def get(self, request, pk): """Get a rule by ID.""" try: rule = Rule.objects.get(pk=pk) + serializer = serializers.RuleSerializer(rule) except (Rule.DoesNotExist, ValueError): return Response(status=HTTP_404_NOT_FOUND) - return Response(rule.to_dict()) + return Response(serializer.data) def put(self, request, pk): """Update a rule by ID.""" From c4b297f757e4b8c31c29d97acc8179255bd120b2 Mon Sep 17 00:00:00 2001 From: tphan025 Date: Thu, 19 Mar 2026 01:32:44 +0100 Subject: [PATCH 34/71] use serializer --- haproxy-route-policy/policy/views.py | 76 ++++++++-------------------- 1 file changed, 21 insertions(+), 55 deletions(-) diff --git a/haproxy-route-policy/policy/views.py b/haproxy-route-policy/policy/views.py index 0d186e368..55fc49260 100644 --- a/haproxy-route-policy/policy/views.py +++ b/haproxy-route-policy/policy/views.py @@ -12,6 +12,7 @@ HTTP_404_NOT_FOUND, HTTP_204_NO_CONTENT, ) +from django.http import Http404 from django.core.exceptions import ValidationError from django.db.utils import IntegrityError from django.db import transaction @@ -89,73 +90,38 @@ def get(self, request): def post(self, request): """Create a new rule.""" - data = request.data - if not isinstance(data, dict): - return Response( - {"error": "Expected a JSON object."}, status=HTTP_400_BAD_REQUEST - ) - - try: - serializer = serializers.RuleSerializer(data=data) - if serializer.is_valid(raise_exception=True): - serializer.save() - except IntegrityError: - return Response( - {"error": "Invalid rule data."}, status=HTTP_400_BAD_REQUEST - ) - - return Response(serializer.data, status=HTTP_201_CREATED) + serializer = serializers.RuleSerializer(data=request.data) + if serializer.is_valid(raise_exception=True): + serializer.save() + return Response(serializer.data, status=HTTP_201_CREATED) + return Response(serializer.errors, status=HTTP_400_BAD_REQUEST) class RuleDetailView(APIView): """View for getting, updating, or deleting a single rule.""" + def get_object(self, pk): + try: + return Rule.objects.get(pk=pk) + except Rule.DoesNotExist: + raise Http404 + def get(self, request, pk): """Get a rule by ID.""" - try: - rule = Rule.objects.get(pk=pk) - serializer = serializers.RuleSerializer(rule) - except (Rule.DoesNotExist, ValueError): - return Response(status=HTTP_404_NOT_FOUND) + rule = self.get_object(pk) + serializer = serializers.RuleSerializer(rule) return Response(serializer.data) def put(self, request, pk): """Update a rule by ID.""" - try: - rule = Rule.objects.get(pk=pk) - serializer = serializers.RuleSerializer(rule) - except (Rule.DoesNotExist, ValueError): - return Response(status=HTTP_404_NOT_FOUND) - - data = request.data - if not isinstance(data, dict): - return Response( - {"error": "Expected a JSON object."}, status=HTTP_400_BAD_REQUEST - ) - # Update fields if provided - if kind := data.get("kind"): - rule.kind = kind - if value := data.get("value"): - rule.value = value - if action := data.get("action"): - rule.action = action - if priority := data.get("priority"): - rule.priority = priority - if comment := data.get("comment"): - rule.comment = comment - try: - rule.full_clean() - rule.save() - except ValidationError as e: - return Response({"error": str(e)}, status=HTTP_400_BAD_REQUEST) - except IntegrityError: - return Response( - {"error": "Invalid rule data."}, status=HTTP_400_BAD_REQUEST - ) - - return Response(serializer.data) + rule = self.get_object(pk) + serializer = serializers.RuleSerializer(rule, data=request.data) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data) + return Response(serializer.errors, status=HTTP_400_BAD_REQUEST) def delete(self, request, pk): """Delete a rule by ID.""" - Rule.objects.filter(pk=pk).delete() + Rule.objects.get(pk=pk).delete() return Response(status=HTTP_204_NO_CONTENT) From 4f09fbc2b8ad7589f1fc14c84d2a4f1b1971d5ae Mon Sep 17 00:00:00 2001 From: tphan025 Date: Thu, 19 Mar 2026 01:33:06 +0100 Subject: [PATCH 35/71] remove unused tests --- .../policy/tests/test_models.py | 22 ------------------- 1 file changed, 22 deletions(-) diff --git a/haproxy-route-policy/policy/tests/test_models.py b/haproxy-route-policy/policy/tests/test_models.py index eaff743b4..7130982bd 100644 --- a/haproxy-route-policy/policy/tests/test_models.py +++ b/haproxy-route-policy/policy/tests/test_models.py @@ -81,28 +81,6 @@ def test_create_rule_defaults(self): self.assertEqual(rule.priority, 0) self.assertEqual(rule.comment, "") - def test_to_dict(self): - """Test serialisation to a JSON-compatible dict.""" - rule = db_models.Rule( - kind=db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, - value={"hostnames": ["example.com"], "paths": []}, - action=db_models.RULE_ACTION_DENY, - priority=5, - comment="Test rule", - ) - rule.full_clean() - rule.save() - - data = rule.to_dict() - self.assertEqual(data["id"], str(rule.id)) - self.assertEqual(data["kind"], db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH) - self.assertEqual(data["value"], {"hostnames": ["example.com"], "paths": []}) - self.assertEqual(data["action"], db_models.RULE_ACTION_DENY) - self.assertEqual(data["priority"], 5) - self.assertEqual(data["comment"], "Test rule") - self.assertIn("created_at", data) - self.assertIn("updated_at", data) - def test_invalid_kind_rejected(self): """Test that an invalid kind value is rejected.""" rule = db_models.Rule( From ad1c42c26624bb9b923e42d1a12a3b754fa707ba Mon Sep 17 00:00:00 2001 From: tphan025 Date: Thu, 19 Mar 2026 01:36:18 +0100 Subject: [PATCH 36/71] use filter for delete query --- haproxy-route-policy/policy/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/haproxy-route-policy/policy/views.py b/haproxy-route-policy/policy/views.py index 55fc49260..de6e3c890 100644 --- a/haproxy-route-policy/policy/views.py +++ b/haproxy-route-policy/policy/views.py @@ -123,5 +123,5 @@ def put(self, request, pk): def delete(self, request, pk): """Delete a rule by ID.""" - Rule.objects.get(pk=pk).delete() + Rule.objects.filter(pk=pk).delete() return Response(status=HTTP_204_NO_CONTENT) From fb9e14324a8f8c72dc47a8a017ae41d84eced960 Mon Sep 17 00:00:00 2001 From: tphan025 Date: Thu, 19 Mar 2026 01:58:54 +0100 Subject: [PATCH 37/71] update tests and move validation to serializer class --- haproxy-route-policy/policy/db_models.py | 25 -- haproxy-route-policy/policy/serializers.py | 35 ++- .../policy/tests/test_models.py | 287 ++++++++++-------- haproxy-route-policy/policy/views.py | 2 +- 4 files changed, 198 insertions(+), 151 deletions(-) diff --git a/haproxy-route-policy/policy/db_models.py b/haproxy-route-policy/policy/db_models.py index 17789744e..ba96854cc 100644 --- a/haproxy-route-policy/policy/db_models.py +++ b/haproxy-route-policy/policy/db_models.py @@ -82,11 +82,6 @@ class BackendRequest(models.Model): updated_at: models.DateTimeField = models.DateTimeField(auto_now=True) -def is_valid_path(value: typing.Any): - """Validate that the value is a list of valid URL paths.""" - return not isinstance(value, str) or not value.startswith("/") - - class Rule(models.Model): """A rule used to evaluate backend requests. @@ -114,23 +109,3 @@ class Rule(models.Model): comment: models.TextField = models.TextField(default="", blank=True) created_at: models.DateTimeField = models.DateTimeField(auto_now_add=True) updated_at: models.DateTimeField = models.DateTimeField(auto_now=True) - - def clean(self) -> None: - """Custom validation logic for the Rule model.""" - if self.kind == RULE_KIND_HOSTNAME_AND_PATH_MATCH: - if not isinstance(self.value, dict): - raise ValidationError("The value field must be a JSON object.") - - if hostnames := self.value.get("hostnames"): - if invalid_hostnames := [ - hostname for hostname in hostnames if not domain(hostname) - ]: - raise ValidationError( - f"Invalid hostname(s) in rule: {', '.join(invalid_hostnames)}" - ) - - if paths := self.value.get("paths"): - if invalid_paths := [path for path in paths if is_valid_path(path)]: - raise ValidationError( - f"Invalid path(s) in rule: {', '.join([str(path) for path in invalid_paths])}" - ) diff --git a/haproxy-route-policy/policy/serializers.py b/haproxy-route-policy/policy/serializers.py index a031064c9..590b2d223 100644 --- a/haproxy-route-policy/policy/serializers.py +++ b/haproxy-route-policy/policy/serializers.py @@ -4,10 +4,14 @@ """Serializers for the haproxy-route-policy application.""" from rest_framework import serializers -from policy.db_models import ( - BackendRequest, - Rule, -) +from policy.db_models import BackendRequest, Rule, RULE_KIND_HOSTNAME_AND_PATH_MATCH +import typing +from validators import domain + + +def is_valid_path(value: typing.Any): + """Validate that the value is a list of valid URL paths.""" + return not isinstance(value, str) or not value.startswith("/") class BackendRequestSerializer(serializers.ModelSerializer): @@ -20,3 +24,26 @@ class RuleSerializer(serializers.ModelSerializer): class Meta: # pyright: ignore[reportIncompatibleVariableOverride] model = Rule fields = "__all__" + + def validate(self, attrs): + """Custom validation logic for the Rule model.""" + if attrs.get("kind") == RULE_KIND_HOSTNAME_AND_PATH_MATCH: + if not isinstance(attrs.get("value"), dict): + raise serializers.ValidationError( + "The value field must be a JSON object." + ) + + if hostnames := typing.cast(dict, attrs.get("value")).get("hostnames"): + if invalid_hostnames := [ + hostname for hostname in hostnames if not domain(hostname) + ]: + raise serializers.ValidationError( + f"Invalid hostname(s) in rule: {', '.join(invalid_hostnames)}" + ) + + if paths := typing.cast(dict, attrs.get("value")).get("paths"): + if invalid_paths := [path for path in paths if is_valid_path(path)]: + raise serializers.ValidationError( + f"Invalid path(s) in rule: {', '.join([str(path) for path in invalid_paths])}" + ) + return attrs diff --git a/haproxy-route-policy/policy/tests/test_models.py b/haproxy-route-policy/policy/tests/test_models.py index 7130982bd..e73181f5c 100644 --- a/haproxy-route-policy/policy/tests/test_models.py +++ b/haproxy-route-policy/policy/tests/test_models.py @@ -4,13 +4,12 @@ """Unit tests for the BackendRequest and Rule models.""" from django.test import TestCase -from django.core.exceptions import ValidationError -from policy import db_models +from policy import db_models, serializers class TestBackendRequestModel(TestCase): - """Tests for BackendRequest model creation and serialisation.""" + """Tests for BackendRequest model creation and serialization.""" def test_create_with_defaults(self): """Test creating a request with minimal required fields.""" @@ -49,15 +48,17 @@ class TestRuleModel(TestCase): def test_create_hostname_and_path_match_rule(self): """Test creating a hostname_and_path_match rule with valid data.""" - rule = db_models.Rule( - kind=db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, - value={"hostnames": ["example.com"], "paths": ["/api"]}, - action=db_models.RULE_ACTION_DENY, - priority=1, - comment="Deny example.com/api", + serializer = serializers.RuleSerializer( + data={ + "kind": db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, + "value": {"hostnames": ["example.com"], "paths": ["/api"]}, + "action": db_models.RULE_ACTION_DENY, + "priority": 1, + "comment": "Deny example.com/api", + } ) - rule.full_clean() - rule.save() + self.assertTrue(serializer.is_valid(), serializer.errors) + rule = serializer.save() self.assertIsNotNone(rule.id) self.assertEqual(rule.kind, db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH) @@ -70,177 +71,221 @@ def test_create_hostname_and_path_match_rule(self): def test_create_rule_defaults(self): """Test that default values are set correctly.""" - rule = db_models.Rule( - kind=db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, - value={"hostnames": ["test.com"], "paths": []}, - action=db_models.RULE_ACTION_ALLOW, + serializer = serializers.RuleSerializer( + data={ + "kind": db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, + "value": {"hostnames": ["test.com"], "paths": []}, + "action": db_models.RULE_ACTION_ALLOW, + } ) - rule.full_clean() - rule.save() + self.assertTrue(serializer.is_valid(), serializer.errors) + rule = serializer.save() self.assertEqual(rule.priority, 0) self.assertEqual(rule.comment, "") def test_invalid_kind_rejected(self): """Test that an invalid kind value is rejected.""" - rule = db_models.Rule( - kind="invalid_kind", - value=1, - action=db_models.RULE_ACTION_ALLOW, + serializer = serializers.RuleSerializer( + data={ + "kind": "invalid_kind", + "value": 1, + "action": db_models.RULE_ACTION_ALLOW, + } ) - with self.assertRaises(ValidationError): - rule.full_clean() + self.assertFalse(serializer.is_valid()) + self.assertIn("kind", serializer.errors) def test_invalid_action_rejected(self): """Test that an invalid action value is rejected.""" - rule = db_models.Rule( - kind=db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, - value={"hostnames": ["example.com"], "paths": []}, - action="invalid_action", + serializer = serializers.RuleSerializer( + data={ + "kind": db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, + "value": {"hostnames": ["example.com"], "paths": []}, + "action": "invalid_action", + } ) - with self.assertRaises(ValidationError): - rule.full_clean() + self.assertFalse(serializer.is_valid()) + self.assertIn("action", serializer.errors) def test_hostname_and_path_match_value_must_be_dict(self): """Test that hostname_and_path_match rules require a dict value.""" - rule = db_models.Rule( - kind=db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, - value="not-a-dict", - action=db_models.RULE_ACTION_DENY, + serializer = serializers.RuleSerializer( + data={ + "kind": db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, + "value": "not-a-dict", + "action": db_models.RULE_ACTION_DENY, + } + ) + self.assertFalse(serializer.is_valid()) + self.assertIn("non_field_errors", serializer.errors) + self.assertIn( + "value field must be a JSON object", + str(serializer.errors["non_field_errors"]), ) - with self.assertRaises(ValidationError) as ctx: - rule.full_clean() - self.assertIn("value field must be a JSON object", str(ctx.exception)) def test_hostname_and_path_match_value_list_rejected(self): """Test that hostname_and_path_match rules reject a list value.""" - rule = db_models.Rule( - kind=db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, - value=["not", "a", "dict"], - action=db_models.RULE_ACTION_DENY, + serializer = serializers.RuleSerializer( + data={ + "kind": db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, + "value": ["not", "a", "dict"], + "action": db_models.RULE_ACTION_DENY, + } + ) + self.assertFalse(serializer.is_valid()) + self.assertIn("non_field_errors", serializer.errors) + self.assertIn( + "value field must be a JSON object", + str(serializer.errors["non_field_errors"]), ) - with self.assertRaises(ValidationError) as ctx: - rule.full_clean() - self.assertIn("value field must be a JSON object", str(ctx.exception)) def test_hostname_and_path_match_value_int_rejected(self): """Test that hostname_and_path_match rules reject an integer value.""" - rule = db_models.Rule( - kind=db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, - value=42, - action=db_models.RULE_ACTION_DENY, + serializer = serializers.RuleSerializer( + data={ + "kind": db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, + "value": 42, + "action": db_models.RULE_ACTION_DENY, + } + ) + self.assertFalse(serializer.is_valid()) + self.assertIn("non_field_errors", serializer.errors) + self.assertIn( + "value field must be a JSON object", + str(serializer.errors["non_field_errors"]), ) - with self.assertRaises(ValidationError) as ctx: - rule.full_clean() - self.assertIn("value field must be a JSON object", str(ctx.exception)) def test_hostname_and_path_match_invalid_hostname(self): """Test that invalid hostnames are rejected in hostname_and_path_match rules.""" - rule = db_models.Rule( - kind=db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, - value={"hostnames": ["not a valid hostname!!!"], "paths": []}, - action=db_models.RULE_ACTION_DENY, + serializer = serializers.RuleSerializer( + data={ + "kind": db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, + "value": {"hostnames": ["not a valid hostname!!!"], "paths": []}, + "action": db_models.RULE_ACTION_DENY, + } ) - with self.assertRaises(ValidationError) as ctx: - rule.full_clean() - self.assertIn("Invalid hostname", str(ctx.exception)) + self.assertFalse(serializer.is_valid()) + self.assertIn("Invalid hostname", str(serializer.errors)) def test_hostname_and_path_match_multiple_invalid_hostnames(self): """Test that multiple invalid hostnames are reported.""" - rule = db_models.Rule( - kind=db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, - value={"hostnames": ["valid.com", "bad host", "also bad!"], "paths": []}, - action=db_models.RULE_ACTION_DENY, + serializer = serializers.RuleSerializer( + data={ + "kind": db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, + "value": { + "hostnames": ["valid.com", "bad host", "also bad!"], + "paths": [], + }, + "action": db_models.RULE_ACTION_DENY, + } ) - with self.assertRaises(ValidationError) as ctx: - rule.full_clean() - msg = str(ctx.exception) - self.assertIn("bad host", msg) - self.assertIn("also bad!", msg) + self.assertFalse(serializer.is_valid()) + errors_str = str(serializer.errors) + self.assertIn("bad host", errors_str) + self.assertIn("also bad!", errors_str) def test_hostname_and_path_match_valid_hostnames_accepted(self): """Test that valid hostnames pass validation.""" - rule = db_models.Rule( - kind=db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, - value={"hostnames": ["example.com", "sub.example.org"], "paths": []}, - action=db_models.RULE_ACTION_ALLOW, + serializer = serializers.RuleSerializer( + data={ + "kind": db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, + "value": { + "hostnames": ["example.com", "sub.example.org"], + "paths": [], + }, + "action": db_models.RULE_ACTION_ALLOW, + } ) - rule.full_clean() # Should not raise + self.assertTrue(serializer.is_valid(), serializer.errors) def test_hostname_and_path_match_empty_hostnames_accepted(self): """Test that an empty hostnames list passes validation.""" - rule = db_models.Rule( - kind=db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, - value={"hostnames": [], "paths": []}, - action=db_models.RULE_ACTION_ALLOW, + serializer = serializers.RuleSerializer( + data={ + "kind": db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, + "value": {"hostnames": [], "paths": []}, + "action": db_models.RULE_ACTION_ALLOW, + } ) - rule.full_clean() # Should not raise + self.assertTrue(serializer.is_valid(), serializer.errors) def test_hostname_and_path_match_invalid_path_not_starting_with_slash(self): """Test that paths not starting with / are rejected.""" - rule = db_models.Rule( - kind=db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, - value={"hostnames": ["example.com"], "paths": ["api/v1"]}, - action=db_models.RULE_ACTION_DENY, + serializer = serializers.RuleSerializer( + data={ + "kind": db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, + "value": {"hostnames": ["example.com"], "paths": ["api/v1"]}, + "action": db_models.RULE_ACTION_DENY, + } ) - with self.assertRaises(ValidationError) as ctx: - rule.full_clean() - self.assertIn("Invalid path", str(ctx.exception)) + self.assertFalse(serializer.is_valid()) + self.assertIn("Invalid path", str(serializer.errors)) def test_hostname_and_path_match_invalid_path_non_string(self): """Test that non-string paths are rejected.""" - rule = db_models.Rule( - kind=db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, - value={"hostnames": ["example.com"], "paths": [123]}, - action=db_models.RULE_ACTION_DENY, + serializer = serializers.RuleSerializer( + data={ + "kind": db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, + "value": {"hostnames": ["example.com"], "paths": [123]}, + "action": db_models.RULE_ACTION_DENY, + } ) - with self.assertRaises(ValidationError) as ctx: - rule.full_clean() - self.assertIn("Invalid path", str(ctx.exception)) + self.assertFalse(serializer.is_valid()) def test_hostname_and_path_match_valid_paths_accepted(self): """Test that valid paths starting with / pass validation.""" - rule = db_models.Rule( - kind=db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, - value={"hostnames": ["example.com"], "paths": ["/api", "/health"]}, - action=db_models.RULE_ACTION_ALLOW, + serializer = serializers.RuleSerializer( + data={ + "kind": db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, + "value": { + "hostnames": ["example.com"], + "paths": ["/api", "/health"], + }, + "action": db_models.RULE_ACTION_ALLOW, + } ) - rule.full_clean() # Should not raise + self.assertTrue(serializer.is_valid(), serializer.errors) def test_hostname_and_path_match_empty_paths_accepted(self): """Test that an empty paths list passes validation.""" - rule = db_models.Rule( - kind=db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, - value={"hostnames": ["example.com"], "paths": []}, - action=db_models.RULE_ACTION_ALLOW, + serializer = serializers.RuleSerializer( + data={ + "kind": db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, + "value": {"hostnames": ["example.com"], "paths": []}, + "action": db_models.RULE_ACTION_ALLOW, + } ) - rule.full_clean() # Should not raise + self.assertTrue(serializer.is_valid(), serializer.errors) def test_hostname_and_path_match_multiple_invalid_paths(self): """Test that multiple invalid paths are reported.""" - rule = db_models.Rule( - kind=db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, - value={"hostnames": [], "paths": ["no-slash", "also-bad"]}, - action=db_models.RULE_ACTION_DENY, + serializer = serializers.RuleSerializer( + data={ + "kind": db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, + "value": {"hostnames": [], "paths": ["no-slash", "also-bad"]}, + "action": db_models.RULE_ACTION_DENY, + } ) - with self.assertRaises(ValidationError) as ctx: - rule.full_clean() - msg = str(ctx.exception) - self.assertIn("no-slash", msg) - self.assertIn("also-bad", msg) + self.assertFalse(serializer.is_valid()) + errors_str = str(serializer.errors) + self.assertIn("no-slash", errors_str) + self.assertIn("also-bad", errors_str) def test_hostname_and_path_match_both_valid_hostnames_and_paths(self): """Test that a rule with both valid hostnames and paths passes.""" - rule = db_models.Rule( - kind=db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, - value={ - "hostnames": ["example.com", "app.example.com"], - "paths": ["/api", "/v1/health"], - }, - action=db_models.RULE_ACTION_DENY, - priority=3, - comment="Block specific routes", - ) - rule.full_clean() - rule.save() + serializer = serializers.RuleSerializer( + data={ + "kind": db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, + "value": { + "hostnames": ["example.com", "app.example.com"], + "paths": ["/api", "/v1/health"], + }, + "action": db_models.RULE_ACTION_DENY, + "priority": 3, + "comment": "Block specific routes", + } + ) + self.assertTrue(serializer.is_valid(), serializer.errors) + rule = serializer.save() self.assertIsNotNone(rule.id) diff --git a/haproxy-route-policy/policy/views.py b/haproxy-route-policy/policy/views.py index de6e3c890..03d73a79f 100644 --- a/haproxy-route-policy/policy/views.py +++ b/haproxy-route-policy/policy/views.py @@ -115,7 +115,7 @@ def get(self, request, pk): def put(self, request, pk): """Update a rule by ID.""" rule = self.get_object(pk) - serializer = serializers.RuleSerializer(rule, data=request.data) + serializer = serializers.RuleSerializer(rule, data=request.data, partial=True) if serializer.is_valid(): serializer.save() return Response(serializer.data) From b05c12a041ddc6086e71ff985be3c1c94e829804 Mon Sep 17 00:00:00 2001 From: Phan Trung Thanh Date: Thu, 19 Mar 2026 12:10:59 +0100 Subject: [PATCH 38/71] Apply suggestion from @github-actions[bot] Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .../0002_rule_alter_backendrequest_hostname_acls_and_more.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/haproxy-route-policy/policy/migrations/0002_rule_alter_backendrequest_hostname_acls_and_more.py b/haproxy-route-policy/policy/migrations/0002_rule_alter_backendrequest_hostname_acls_and_more.py index 84e403e52..ebbc42eeb 100644 --- a/haproxy-route-policy/policy/migrations/0002_rule_alter_backendrequest_hostname_acls_and_more.py +++ b/haproxy-route-policy/policy/migrations/0002_rule_alter_backendrequest_hostname_acls_and_more.py @@ -1,3 +1,6 @@ +# Copyright 2026 Canonical Ltd. +# See LICENSE file for licensing details. + # Generated by Django 6.0.3 on 2026-03-17 20:05 import uuid From 10a2708834d797cfbe036bfdfc33f26fe0c7eed2 Mon Sep 17 00:00:00 2001 From: Phan Trung Thanh Date: Thu, 19 Mar 2026 12:15:57 +0100 Subject: [PATCH 39/71] Update haproxy-route-policy/policy/migrations/0001_initial.py Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- haproxy-route-policy/policy/migrations/0001_initial.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/haproxy-route-policy/policy/migrations/0001_initial.py b/haproxy-route-policy/policy/migrations/0001_initial.py index 26cc6998e..1b55c35b4 100644 --- a/haproxy-route-policy/policy/migrations/0001_initial.py +++ b/haproxy-route-policy/policy/migrations/0001_initial.py @@ -1,3 +1,6 @@ +# Copyright 2026 Canonical Ltd. +# See LICENSE file for licensing details. + # Generated by Django 6.0.3 on 2026-03-17 20:21 import policy.db_models From 571e469f58a3e9a97e5c7cf513b811bc498a39f6 Mon Sep 17 00:00:00 2001 From: tphan025 Date: Thu, 19 Mar 2026 12:44:30 +0100 Subject: [PATCH 40/71] Revert "Update haproxy-route-policy/policy/migrations/0001_initial.py" This reverts commit 10a2708834d797cfbe036bfdfc33f26fe0c7eed2. --- haproxy-route-policy/policy/migrations/0001_initial.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/haproxy-route-policy/policy/migrations/0001_initial.py b/haproxy-route-policy/policy/migrations/0001_initial.py index 1b55c35b4..26cc6998e 100644 --- a/haproxy-route-policy/policy/migrations/0001_initial.py +++ b/haproxy-route-policy/policy/migrations/0001_initial.py @@ -1,6 +1,3 @@ -# Copyright 2026 Canonical Ltd. -# See LICENSE file for licensing details. - # Generated by Django 6.0.3 on 2026-03-17 20:21 import policy.db_models From a22beddbf6b9d3c2befa3fc9f79b7f8ab34720a3 Mon Sep 17 00:00:00 2001 From: tphan025 Date: Thu, 19 Mar 2026 12:45:46 +0100 Subject: [PATCH 41/71] ignore migration files for license header --- .licenserc.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.licenserc.yaml b/.licenserc.yaml index 6a24006db..58e40ca11 100644 --- a/.licenserc.yaml +++ b/.licenserc.yaml @@ -38,4 +38,5 @@ header: - '.readthedocs.yaml' - 'docs/**' - '.lycheeignore' + - 'haproxy-route-policy/policy/migrations/*.py' comment: on-failure From 7d3d20b6da69dd1f87360c6da38b57f554d3f0b4 Mon Sep 17 00:00:00 2001 From: tphan025 Date: Thu, 19 Mar 2026 12:47:15 +0100 Subject: [PATCH 42/71] remove license header from generated files --- .../0002_rule_alter_backendrequest_hostname_acls_and_more.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/haproxy-route-policy/policy/migrations/0002_rule_alter_backendrequest_hostname_acls_and_more.py b/haproxy-route-policy/policy/migrations/0002_rule_alter_backendrequest_hostname_acls_and_more.py index ebbc42eeb..84e403e52 100644 --- a/haproxy-route-policy/policy/migrations/0002_rule_alter_backendrequest_hostname_acls_and_more.py +++ b/haproxy-route-policy/policy/migrations/0002_rule_alter_backendrequest_hostname_acls_and_more.py @@ -1,6 +1,3 @@ -# Copyright 2026 Canonical Ltd. -# See LICENSE file for licensing details. - # Generated by Django 6.0.3 on 2026-03-17 20:05 import uuid From 00c094e8384f693f4db39d192574561923630644 Mon Sep 17 00:00:00 2001 From: tphan025 Date: Thu, 19 Mar 2026 13:45:08 +0100 Subject: [PATCH 43/71] add change artifact --- docs/release-notes/artifacts/pr0400.yaml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 docs/release-notes/artifacts/pr0400.yaml diff --git a/docs/release-notes/artifacts/pr0400.yaml b/docs/release-notes/artifacts/pr0400.yaml new file mode 100644 index 000000000..67c834051 --- /dev/null +++ b/docs/release-notes/artifacts/pr0400.yaml @@ -0,0 +1,21 @@ +version_schema: 2 + +changes: + - title: Added rules management REST API for haproxy-route-policy app + author: tphan025 + type: minor + description: > + Added the Rule model with UUID primary key and fields for kind, value, action, + priority, and comment. Implemented REST API endpoints for rules: GET /api/v1/rules + (list ordered by descending priority), POST /api/v1/rules (create with validation), + GET /api/v1/rules/ (retrieve by ID), PUT /api/v1/rules/ (partial update), + and DELETE /api/v1/rules/ (idempotent delete). Added RuleSerializer with + custom validation for hostname_and_path_match rules including hostname and path + checks. Included unit and integration tests for the Rule model and API views. + urls: + pr: + - https://github.com/canonical/haproxy-operator/pull/400 + related_doc: + related_issue: + visibility: public + highlight: false From cd7c49ac92d88a02dc25f87582fd96243a12f65f Mon Sep 17 00:00:00 2001 From: tphan025 Date: Thu, 19 Mar 2026 13:48:48 +0100 Subject: [PATCH 44/71] add envlist to tox commands --- haproxy-route-policy/tox.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/haproxy-route-policy/tox.toml b/haproxy-route-policy/tox.toml index d86392900..26924a975 100644 --- a/haproxy-route-policy/tox.toml +++ b/haproxy-route-policy/tox.toml @@ -5,6 +5,7 @@ skipsdist = true skip_missing_interpreters = true requires = ["tox>=4.21"] no_package = true +envlist = [ "lint", "unit", "static", "coverage-report" ] [env_run_base] passenv = ["PYTHONPATH"] From 503a3134246fb92550f02ade8446e66373edc593 Mon Sep 17 00:00:00 2001 From: tphan025 Date: Thu, 19 Mar 2026 13:49:03 +0100 Subject: [PATCH 45/71] update envlist --- haproxy-route-policy/tox.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/haproxy-route-policy/tox.toml b/haproxy-route-policy/tox.toml index 26924a975..d08f465fc 100644 --- a/haproxy-route-policy/tox.toml +++ b/haproxy-route-policy/tox.toml @@ -5,7 +5,7 @@ skipsdist = true skip_missing_interpreters = true requires = ["tox>=4.21"] no_package = true -envlist = [ "lint", "unit", "static", "coverage-report" ] +envlist = [ "lint", "unit"] [env_run_base] passenv = ["PYTHONPATH"] From a2d1ea2a19c83f23e7c4fd8a70d2977db61fde42 Mon Sep 17 00:00:00 2001 From: tphan025 Date: Thu, 19 Mar 2026 14:24:40 +0100 Subject: [PATCH 46/71] convert pk to uuid for requests --- haproxy-route-policy/policy/db_models.py | 7 +++++-- .../policy/migrations/0001_initial.py | 13 +++++++++++-- haproxy-route-policy/policy/tests/test_views.py | 8 ++++---- haproxy-route-policy/policy/urls.py | 2 +- 4 files changed, 21 insertions(+), 9 deletions(-) diff --git a/haproxy-route-policy/policy/db_models.py b/haproxy-route-policy/policy/db_models.py index 4a670cfe4..f8c631de8 100644 --- a/haproxy-route-policy/policy/db_models.py +++ b/haproxy-route-policy/policy/db_models.py @@ -7,6 +7,7 @@ from django.db import models from validators import domain from django.core.exceptions import ValidationError +import uuid REQUEST_STATUS_PENDING = "pending" REQUEST_STATUS_ACCEPTED = "accepted" @@ -35,7 +36,7 @@ class BackendRequest(models.Model): """A backend request submitted via the haproxy-route relation. Attrs: - id: Auto-incrementing primary key. + id: Request UUID. relation_id: The Juju relation ID this request originated from. hostname_acls: Hostnames requested for routing. backend_name: The name of the backend in the HAProxy config. @@ -46,7 +47,9 @@ class BackendRequest(models.Model): updated_at: Timestamp when the request was last updated. """ - id: models.BigAutoField = models.BigAutoField(primary_key=True) + id: models.UUIDField = models.UUIDField( + primary_key=True, default=uuid.uuid4, editable=False + ) relation_id: models.IntegerField = models.IntegerField() hostname_acls: models.JSONField = models.JSONField( default=list, validators=[validate_hostname_acls], blank=True diff --git a/haproxy-route-policy/policy/migrations/0001_initial.py b/haproxy-route-policy/policy/migrations/0001_initial.py index 26cc6998e..7d7a16bcd 100644 --- a/haproxy-route-policy/policy/migrations/0001_initial.py +++ b/haproxy-route-policy/policy/migrations/0001_initial.py @@ -1,6 +1,7 @@ -# Generated by Django 6.0.3 on 2026-03-17 20:21 +# Generated by Django 6.0.3 on 2026-03-19 12:58 import policy.db_models +import uuid from django.db import migrations, models @@ -13,7 +14,15 @@ class Migration(migrations.Migration): migrations.CreateModel( name="BackendRequest", fields=[ - ("id", models.BigAutoField(primary_key=True, serialize=False)), + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), ("relation_id", models.IntegerField()), ( "hostname_acls", diff --git a/haproxy-route-policy/policy/tests/test_views.py b/haproxy-route-policy/policy/tests/test_views.py index 049893645..ad3f3f994 100644 --- a/haproxy-route-policy/policy/tests/test_views.py +++ b/haproxy-route-policy/policy/tests/test_views.py @@ -5,7 +5,7 @@ from django.test import TestCase from rest_framework.test import APIClient - +import uuid from policy import db_models @@ -115,12 +115,12 @@ def test_get_existing(self): response = self.client.get(f"/api/v1/requests/{self.backend_request.pk}") self.assertEqual(response.status_code, 200) data = response.json() - self.assertEqual(data["id"], self.backend_request.pk) + self.assertEqual(data["id"], str(self.backend_request.pk)) self.assertEqual(data["backend_name"], "detail-backend") def test_get_not_found(self): """GET returns 404 for a non-existent ID.""" - response = self.client.get("/api/v1/requests/99999") + response = self.client.get(f"/api/v1/requests/{uuid.uuid4()}") self.assertEqual(response.status_code, 404) def test_delete_existing(self): @@ -132,5 +132,5 @@ def test_delete_existing(self): def test_delete_nonexistent(self): """DELETE on a non-existent ID still returns 204 (idempotent).""" - response = self.client.delete("/api/v1/requests/99999") + response = self.client.delete(f"/api/v1/requests/{uuid.uuid4()}") self.assertEqual(response.status_code, 204) diff --git a/haproxy-route-policy/policy/urls.py b/haproxy-route-policy/policy/urls.py index f1580fabb..6dc9f6e13 100644 --- a/haproxy-route-policy/policy/urls.py +++ b/haproxy-route-policy/policy/urls.py @@ -14,7 +14,7 @@ name="api-requests", ), path( - "api/v1/requests/", + "api/v1/requests/", views.RequestDetailView.as_view(), name="api-request-detail", ), From 988ba70b247a3e358f91052cca2d998f447416aa Mon Sep 17 00:00:00 2001 From: tphan025 Date: Thu, 19 Mar 2026 18:09:22 +0100 Subject: [PATCH 47/71] Add guard against mal-formed uuid and parameter. Add logging configs, Add middleware to guard against db connection errors --- .../haproxy_route_policy/settings.py | 39 ++++++++++++++++++- haproxy-route-policy/policy/middleware.py | 38 ++++++++++++++++++ .../policy/tests/test_views.py | 2 +- haproxy-route-policy/policy/views.py | 36 +++++++++++++---- haproxy-route-policy/pyproject.toml | 1 + haproxy-route-policy/uv.lock | 11 ++++++ 6 files changed, 117 insertions(+), 10 deletions(-) create mode 100644 haproxy-route-policy/policy/middleware.py diff --git a/haproxy-route-policy/haproxy_route_policy/settings.py b/haproxy-route-policy/haproxy_route_policy/settings.py index df4f9c1a7..7c3d2c341 100644 --- a/haproxy-route-policy/haproxy_route_policy/settings.py +++ b/haproxy-route-policy/haproxy_route_policy/settings.py @@ -15,14 +15,14 @@ from pathlib import Path import os +import json # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY") DEBUG = os.environ.get("DJANGO_DEBUG", "").lower() == "true" -ALLOWED_HOSTS = [] - +ALLOWED_HOSTS = json.loads(os.getenv("DJANGO_ALLOWED_HOSTS", "[]")) # Application definition @@ -47,6 +47,8 @@ "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", + "whitenoise.middleware.WhiteNoiseMiddleware", + "policy.middleware.DatabaseErrorMiddleware", ] ROOT_URLCONF = "haproxy_route_policy.urls" @@ -115,3 +117,36 @@ # https://docs.djangoproject.com/en/6.0/howto/static-files/ STATIC_URL = "static/" +STATIC_ROOT = Path(BASE_DIR, "static/") + +# Default primary key field type +# https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +env_log_level = os.getenv("DJANGO_LOG_LEVEL", "INFO").upper() +if env_log_level not in ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]: + env_log_level = "INFO" + +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "handlers": { + "console": { + "class": "logging.StreamHandler", + }, + }, + "root": { + "handlers": ["console"], + "level": "WARNING", + }, + "loggers": { + "django": { + "handlers": ["console"], + "level": env_log_level, + "propagate": False, + }, + }, +} + +DATA_UPLOAD_MAX_MEMORY_SIZE = 32 * 1024 * 1024 diff --git a/haproxy-route-policy/policy/middleware.py b/haproxy-route-policy/policy/middleware.py new file mode 100644 index 000000000..cdd6fe22b --- /dev/null +++ b/haproxy-route-policy/policy/middleware.py @@ -0,0 +1,38 @@ +# Copyright 2026 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Middleware for handling database connection errors.""" + +import logging + +from django.db import OperationalError, DatabaseError +from django.http import JsonResponse +from rest_framework.status import HTTP_500_INTERNAL_SERVER_ERROR + +logger = logging.getLogger(__name__) + + +class DatabaseErrorMiddleware: + """Catch database connection errors and return a generic 503 response. + + This prevents the application's stack trace from being exposed to the client + when the database is unreachable or encounters a connection-level error. + """ + + def __init__(self, get_response): + """Initialize the middleware.""" + self.get_response = get_response + + def __call__(self, request): + """Process the request.""" + return self.get_response(request) + + def process_exception(self, _request, exception): + """Handle database errors raised during view processing.""" + if isinstance(exception, (OperationalError, DatabaseError)): + logger.error("Database error: %s", exception) + return JsonResponse( + {"error": "A database error occurred. Please try again later."}, + status=HTTP_500_INTERNAL_SERVER_ERROR, + ) + return None diff --git a/haproxy-route-policy/policy/tests/test_views.py b/haproxy-route-policy/policy/tests/test_views.py index ad3f3f994..fdf2e809b 100644 --- a/haproxy-route-policy/policy/tests/test_views.py +++ b/haproxy-route-policy/policy/tests/test_views.py @@ -79,7 +79,7 @@ def test_bulk_create(self): self.assertEqual(data[0]["backend_name"], "backend-1") self.assertEqual(data[0]["status"], "pending") self.assertEqual(data[0]["hostname_acls"], ["example.com"]) - self.assertEqual(data[0]["paths"], second=["/api"]) + self.assertEqual(data[0]["paths"], ["/api"]) self.assertEqual(data[0]["port"], 443) self.assertEqual(data[1]["backend_name"], "backend-2") self.assertEqual(data[1]["hostname_acls"], []) diff --git a/haproxy-route-policy/policy/views.py b/haproxy-route-policy/policy/views.py index 8a5a7d564..5319eee79 100644 --- a/haproxy-route-policy/policy/views.py +++ b/haproxy-route-policy/policy/views.py @@ -3,6 +3,9 @@ """REST API views for backend requests.""" +import uuid +from typing import Any +from venv import logger from policy.db_models import BackendRequest from rest_framework.views import APIView from rest_framework.response import Response @@ -16,6 +19,7 @@ from django.db.utils import IntegrityError from django.db import transaction from policy import serializers +from .db_models import REQUEST_STATUSES class ListCreateRequestsView(APIView): @@ -23,10 +27,13 @@ class ListCreateRequestsView(APIView): def get(self, request): """List all requests, optionally filtered by status.""" - filter = ( - {"status": request.GET.get("status")} if request.GET.get("status") else {} - ) - queryset = BackendRequest.objects.all().filter(**filter) + status = request.GET.get("status") + if status and status not in REQUEST_STATUSES: + return Response( + {"error": "Invalid status filter."}, status=HTTP_400_BAD_REQUEST + ) + filters = {"status": status} if status else {} + queryset = BackendRequest.objects.all().filter(**filters) serializer = serializers.BackendRequestSerializer(queryset, many=True) return Response(serializer.data) @@ -66,13 +73,28 @@ class RequestDetailView(APIView): def get(self, _request, pk): """Get a request by ID.""" try: - backend_request = BackendRequest.objects.get(pk=pk) + backend_request = BackendRequest.objects.get(pk=uuid_primary_key(pk)) serializer = serializers.BackendRequestSerializer(backend_request) except BackendRequest.DoesNotExist: return Response(status=HTTP_404_NOT_FOUND) + except (ValueError, AttributeError): + return Response( + {"error": "Invalid request ID."}, status=HTTP_400_BAD_REQUEST + ) return Response(serializer.data) - def delete(self, request, pk): + def delete(self, _request, pk): """Delete a request by ID.""" - BackendRequest.objects.filter(pk=pk).delete() + try: + BackendRequest.objects.filter(pk=uuid_primary_key(pk)).delete() + except (AttributeError, ValueError): + logger.warning(f"Attempted to delete request with invalid UUID: {pk}") return Response(status=HTTP_204_NO_CONTENT) + + +def uuid_primary_key(pk: Any) -> str: + """Validate that the passed request parameter is a valid UUID string. + + The calling methods are responsible for catching ValueError and AttributeError. + """ + return str(uuid.UUID(str(pk), version=4)) diff --git a/haproxy-route-policy/pyproject.toml b/haproxy-route-policy/pyproject.toml index 1ef141aee..8664dfd08 100644 --- a/haproxy-route-policy/pyproject.toml +++ b/haproxy-route-policy/pyproject.toml @@ -8,6 +8,7 @@ dependencies = [ "django>=6.0.3", "djangorestframework>=3.16.1", "validators>=0.35.0", + "whitenoise>=6.12.0", ] [dependency-groups] diff --git a/haproxy-route-policy/uv.lock b/haproxy-route-policy/uv.lock index 17c0cdf21..218acc61c 100644 --- a/haproxy-route-policy/uv.lock +++ b/haproxy-route-policy/uv.lock @@ -117,6 +117,7 @@ dependencies = [ { name = "django" }, { name = "djangorestframework" }, { name = "validators" }, + { name = "whitenoise" }, ] [package.dev-dependencies] @@ -135,6 +136,7 @@ requires-dist = [ { name = "django", specifier = ">=6.0.3" }, { name = "djangorestframework", specifier = ">=3.16.1" }, { name = "validators", specifier = ">=0.35.0" }, + { name = "whitenoise", specifier = ">=6.12.0" }, ] [package.metadata.requires-dev] @@ -337,3 +339,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/53/66/a435d9ae49850b2f0 wheels = [ { url = "https://files.pythonhosted.org/packages/fa/6e/3e955517e22cbdd565f2f8b2e73d52528b14b8bcfdb04f62466b071de847/validators-0.35.0-py3-none-any.whl", hash = "sha256:e8c947097eae7892cb3d26868d637f79f47b4a0554bc6b80065dfe5aac3705dd", size = 44712, upload-time = "2025-05-01T05:42:04.203Z" }, ] + +[[package]] +name = "whitenoise" +version = "6.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cb/2a/55b3f3a4ec326cd077c1c3defeee656b9298372a69229134d930151acd01/whitenoise-6.12.0.tar.gz", hash = "sha256:f723ebb76a112e98816ff80fcea0a6c9b8ecde835f8ddda25df7a30a3c2db6ad", size = 26841, upload-time = "2026-02-27T00:05:42.028Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/eb/d5583a11486211f3ebd4b385545ae787f32363d453c19fffd81106c9c138/whitenoise-6.12.0-py3-none-any.whl", hash = "sha256:fc5e8c572e33ebf24795b47b6a7da8da3c00cff2349f5b04c02f28d0cc5a3cc2", size = 20302, upload-time = "2026-02-27T00:05:40.086Z" }, +] From 9a246aaee01892b92486ac1ed462f1cf183c2ecb Mon Sep 17 00:00:00 2001 From: tphan025 Date: Thu, 19 Mar 2026 18:43:00 +0100 Subject: [PATCH 48/71] add validators for port and paths --- haproxy-route-policy/policy/db_models.py | 26 ++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/haproxy-route-policy/policy/db_models.py b/haproxy-route-policy/policy/db_models.py index f8c631de8..f72afd8d8 100644 --- a/haproxy-route-policy/policy/db_models.py +++ b/haproxy-route-policy/policy/db_models.py @@ -32,6 +32,26 @@ def validate_hostname_acls(value: typing.Any): raise ValidationError(f"Invalid hostnames: {', '.join(invalid_hostnames)}") +def validate_port(value: typing.Any): + """Validate that the value is a valid TCP port number.""" + if not isinstance(value, int) or not (1 <= value <= 65535): + raise ValidationError("port must be an integer between 1 and 65535.") + + +def validate_paths(value: typing.Any): + """Validate that the value is a list of valid URL paths.""" + if not isinstance(value, list): + raise ValidationError("paths must be a list.") + if invalid_paths := [ + path + for path in typing.cast(list, value) + if not isinstance(path, str) or not path.startswith("/") + ]: + raise ValidationError( + f"Invalid paths: {', '.join(str(path) for path in invalid_paths)}" + ) + + class BackendRequest(models.Model): """A backend request submitted via the haproxy-route relation. @@ -55,8 +75,10 @@ class BackendRequest(models.Model): default=list, validators=[validate_hostname_acls], blank=True ) backend_name: models.TextField = models.TextField() - paths: models.JSONField = models.JSONField(default=list, blank=True) - port: models.IntegerField = models.IntegerField() + paths: models.JSONField = models.JSONField( + default=list, validators=[validate_paths], blank=True + ) + port: models.IntegerField = models.IntegerField(validators=[validate_port]) status: models.TextField = models.TextField( choices=REQUEST_STATUS_CHOICES, default=REQUEST_STATUS_PENDING, From 35a286f29553d00454eb833f7c2d54beb0029842 Mon Sep 17 00:00:00 2001 From: tphan025 Date: Thu, 19 Mar 2026 21:04:28 +0100 Subject: [PATCH 49/71] add tests for validators --- .../policy/tests/test_models.py | 62 +++++++++++++++++ .../policy/tests/test_views.py | 67 +++++++++++++++++++ 2 files changed, 129 insertions(+) diff --git a/haproxy-route-policy/policy/tests/test_models.py b/haproxy-route-policy/policy/tests/test_models.py index e9981db1d..68ff38818 100644 --- a/haproxy-route-policy/policy/tests/test_models.py +++ b/haproxy-route-policy/policy/tests/test_models.py @@ -3,6 +3,7 @@ """Unit tests for the BackendRequest model.""" +from django.core.exceptions import ValidationError from django.test import TestCase from policy import db_models @@ -41,3 +42,64 @@ def test_create_with_all_fields(self): self.assertEqual(request.paths, ["/api", "/health"]) self.assertEqual(request.port, 443) self.assertEqual(request.status, db_models.REQUEST_STATUS_ACCEPTED) + + +class TestValidatePort(TestCase): + """Tests for the validate_port validator.""" + + def test_valid_ports(self): + """Valid TCP port numbers should not raise.""" + valid_ports = [1, 80, 443, 8080, 65535] + for port in valid_ports: + with self.subTest(port=port): + db_models.validate_port(port) + + def test_invalid_ports(self): + """Out-of-range and wrong-type values should raise ValidationError.""" + invalid_ports = [ + (0, "below minimum"), + (-1, "negative"), + (65536, "above maximum"), + (100000, "way above maximum"), + ("443", "string"), + (44.3, "float"), + (None, "None"), + ] + for value, label in invalid_ports: + with self.subTest(value=value, label=label): + with self.assertRaises(ValidationError): + db_models.validate_port(value) + + +class TestValidatePaths(TestCase): + """Tests for the validate_paths validator.""" + + def test_valid_paths(self): + """Valid path lists should not raise.""" + valid_cases = [ + ([], "empty list"), + (["/"], "root path"), + (["/api"], "single path"), + (["/api", "/health", "/status"], "multiple paths"), + (["/api/v1/requests"], "nested path"), + ] + for paths, label in valid_cases: + with self.subTest(paths=paths, label=label): + db_models.validate_paths(paths) + + def test_invalid_paths(self): + """Invalid path values should raise ValidationError.""" + invalid_cases = [ + ("not-a-list", "string instead of list"), + (None, "None"), + (123, "integer"), + (["no-leading-slash"], "missing leading slash"), + (["api/v1"], "relative path"), + ([123], "non-string element"), + ([None], "None element"), + (["/valid", "invalid"], "mixed valid and invalid"), + ] + for value, label in invalid_cases: + with self.subTest(value=value, label=label): + with self.assertRaises(ValidationError): + db_models.validate_paths(value) diff --git a/haproxy-route-policy/policy/tests/test_views.py b/haproxy-route-policy/policy/tests/test_views.py index fdf2e809b..3d6b580d7 100644 --- a/haproxy-route-policy/policy/tests/test_views.py +++ b/haproxy-route-policy/policy/tests/test_views.py @@ -134,3 +134,70 @@ def test_delete_nonexistent(self): """DELETE on a non-existent ID still returns 204 (idempotent).""" response = self.client.delete(f"/api/v1/requests/{uuid.uuid4()}") self.assertEqual(response.status_code, 204) + + +class TestStatusFilterSanitization(TestCase): + """Tests for status query parameter validation on GET /api/v1/requests.""" + + def setUp(self): + """Set up the API client.""" + self.client = APIClient() + + def test_valid_status_filters(self): + """Valid status values should return 200.""" + valid_statuses = ["pending", "accepted", "rejected"] + for status in valid_statuses: + with self.subTest(status=status): + response = self.client.get(f"/api/v1/requests?status={status}") + self.assertEqual(response.status_code, 200) + + def test_invalid_status_filters(self): + """Invalid status values should return 400.""" + invalid_statuses = [ + "invalid", + "PENDING", + "Accepted", + "unknown", + "' OR 1=1 --", + "", + "pending; DROP TABLE", + ] + for status in invalid_statuses: + with self.subTest(status=status): + response = self.client.get(f"/api/v1/requests?status={status}") + self.assertEqual(response.status_code, 400) + self.assertIn("error", response.json()) + + +class TestPkValidation(TestCase): + """Tests for pk (UUID) validation on GET/DELETE /api/v1/requests/.""" + + def setUp(self): + """Set up the API client.""" + self.client = APIClient() + + def test_get_invalid_pk_returns_400(self): + """GET with an invalid UUID pk should return 400.""" + invalid_pks = [ + "not-a-uuid", + "12345", + "' OR 1=1 --", + " ", + ] + for pk in invalid_pks: + with self.subTest(pk=pk): + response = self.client.get(f"/api/v1/requests/{pk}") + self.assertEqual(response.status_code, 400) + self.assertIn("error", response.json()) + + def test_delete_invalid_pk_returns_204(self): + """DELETE with an invalid UUID pk should still return 204 (idempotent).""" + invalid_pks = [ + "not-a-uuid", + "12345", + "' OR 1=1 --", + ] + for pk in invalid_pks: + with self.subTest(pk=pk): + response = self.client.delete(f"/api/v1/requests/{pk}") + self.assertEqual(response.status_code, 204) From a42d4c3163c2cab98529e954bf9e3926f004c0b5 Mon Sep 17 00:00:00 2001 From: tphan025 Date: Thu, 19 Mar 2026 21:08:09 +0100 Subject: [PATCH 50/71] add note for migration --- haproxy-route-policy/policy/db_models.py | 1 + 1 file changed, 1 insertion(+) diff --git a/haproxy-route-policy/policy/db_models.py b/haproxy-route-policy/policy/db_models.py index f72afd8d8..c2aa1fd0d 100644 --- a/haproxy-route-policy/policy/db_models.py +++ b/haproxy-route-policy/policy/db_models.py @@ -13,6 +13,7 @@ REQUEST_STATUS_ACCEPTED = "accepted" REQUEST_STATUS_REJECTED = "rejected" +# Note: changing these values will require a data migration to update the database schema. REQUEST_STATUSES = [ REQUEST_STATUS_PENDING, REQUEST_STATUS_ACCEPTED, From ee86d7d8f364daa7903fef7f6cd02d59221e9f0e Mon Sep 17 00:00:00 2001 From: tphan025 Date: Thu, 19 Mar 2026 21:11:58 +0100 Subject: [PATCH 51/71] ruff format --- haproxy-route-policy/policy/tests/test_views.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/haproxy-route-policy/policy/tests/test_views.py b/haproxy-route-policy/policy/tests/test_views.py index 95daba705..1ac1127e5 100644 --- a/haproxy-route-policy/policy/tests/test_views.py +++ b/haproxy-route-policy/policy/tests/test_views.py @@ -314,11 +314,14 @@ def test_delete_nonexistent(self): response = self.client.delete(f"/api/v1/rules/{fake_id}") self.assertEqual(response.status_code, 204) + class TestStatusFilterSanitization(TestCase): """Tests for status query parameter validation on GET /api/v1/requests.""" + def setUp(self): """Set up the API client.""" self.client = APIClient() + def test_valid_status_filters(self): """Valid status values should return 200.""" valid_statuses = ["pending", "accepted", "rejected"] From 9101175c88934ad888ff70a50df9271c8cff8dfb Mon Sep 17 00:00:00 2001 From: tphan025 Date: Thu, 19 Mar 2026 21:12:23 +0100 Subject: [PATCH 52/71] remove unused imports --- haproxy-route-policy/policy/db_models.py | 1 - haproxy-route-policy/policy/tests/test_views.py | 1 - 2 files changed, 2 deletions(-) diff --git a/haproxy-route-policy/policy/db_models.py b/haproxy-route-policy/policy/db_models.py index 1a6d05b59..caf23e64c 100644 --- a/haproxy-route-policy/policy/db_models.py +++ b/haproxy-route-policy/policy/db_models.py @@ -8,7 +8,6 @@ from django.db import models from validators import domain from django.core.exceptions import ValidationError -import uuid REQUEST_STATUS_PENDING = "pending" REQUEST_STATUS_ACCEPTED = "accepted" diff --git a/haproxy-route-policy/policy/tests/test_views.py b/haproxy-route-policy/policy/tests/test_views.py index 1ac1127e5..48efab024 100644 --- a/haproxy-route-policy/policy/tests/test_views.py +++ b/haproxy-route-policy/policy/tests/test_views.py @@ -7,7 +7,6 @@ from django.test import TestCase from rest_framework.test import APIClient -import uuid from policy import db_models From 69872ecdd08537de16e974e4394447fed5b16940 Mon Sep 17 00:00:00 2001 From: tphan025 Date: Fri, 20 Mar 2026 13:53:43 +0100 Subject: [PATCH 53/71] add static tests --- haproxy-route-policy/pyproject.toml | 3 + haproxy-route-policy/tox.toml | 6 ++ haproxy-route-policy/uv.lock | 126 ++++++++++++++++++++++++++++ 3 files changed, 135 insertions(+) diff --git a/haproxy-route-policy/pyproject.toml b/haproxy-route-policy/pyproject.toml index 8664dfd08..3eab72619 100644 --- a/haproxy-route-policy/pyproject.toml +++ b/haproxy-route-policy/pyproject.toml @@ -21,3 +21,6 @@ lint = [ "mypy>=1.19.1", "ruff>=0.15.6", ] +static = [ + "bandit[toml]>=1.9.4", +] diff --git a/haproxy-route-policy/tox.toml b/haproxy-route-policy/tox.toml index d08f465fc..15b8d1214 100644 --- a/haproxy-route-policy/tox.toml +++ b/haproxy-route-policy/tox.toml @@ -65,6 +65,12 @@ commands = [ ] dependency_groups = ["lint"] + +[env.static] +description = "Run static analysis tests" +commands = [ [ "bandit", "-c", "{toxinidir}/pyproject.toml", "-r", "{[vars]src_path}", "{[vars]tst_path}" ] ] +dependency_groups = [ "static" ] + [vars] src_path = "{toxinidir}/policy/" tst_path = "{toxinidir}/policy/tests" diff --git a/haproxy-route-policy/uv.lock b/haproxy-route-policy/uv.lock index 218acc61c..5e705cf7f 100644 --- a/haproxy-route-policy/uv.lock +++ b/haproxy-route-policy/uv.lock @@ -11,6 +11,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5c/0a/a72d10ed65068e115044937873362e6e32fab1b7dce0046aeb224682c989/asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133", size = 24345, upload-time = "2026-02-03T13:30:13.039Z" }, ] +[[package]] +name = "bandit" +version = "1.9.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "pyyaml" }, + { name = "rich" }, + { name = "stevedore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/c3/0cb80dfe0f3076e5da7e4c5ad8e57bac6ac357ff4a6406205501cade4965/bandit-1.9.4.tar.gz", hash = "sha256:b589e5de2afe70bd4d53fa0c1da6199f4085af666fde00e8a034f152a52cd628", size = 4242677, upload-time = "2026-02-25T06:44:15.503Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/a4/a26d5b25671d27e03afb5401a0be5899d94ff8fab6a698b1ac5be3ec29ef/bandit-1.9.4-py3-none-any.whl", hash = "sha256:f89ffa663767f5a0585ea075f01020207e966a9c0f2b9ef56a57c7963a3f6f8e", size = 134741, upload-time = "2026-02-25T06:44:13.694Z" }, +] + [[package]] name = "codespell" version = "2.4.2" @@ -20,6 +35,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/42/a1/52fa05533e95fe45bcc09bcf8a503874b1c08f221a4e35608017e0938f55/codespell-2.4.2-py3-none-any.whl", hash = "sha256:97e0c1060cf46bd1d5db89a936c98db8c2b804e1fdd4b5c645e82a1ec6b1f886", size = 353715, upload-time = "2026-03-05T18:10:41.398Z" }, ] +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + [[package]] name = "django" version = "6.0.3" @@ -130,6 +154,9 @@ lint = [ { name = "mypy" }, { name = "ruff" }, ] +static = [ + { name = "bandit" }, +] [package.metadata] requires-dist = [ @@ -149,6 +176,7 @@ lint = [ { name = "mypy", specifier = ">=1.19.1" }, { name = "ruff", specifier = ">=0.15.6" }, ] +static = [{ name = "bandit", extras = ["toml"], specifier = ">=1.9.4" }] [[package]] name = "librt" @@ -210,6 +238,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b2/c8/d148e041732d631fc76036f8b30fae4e77b027a1e95b7a84bb522481a940/librt-0.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:bf512a71a23504ed08103a13c941f763db13fb11177beb3d9244c98c29fb4a61", size = 48755, upload-time = "2026-02-17T16:12:47.943Z" }, ] +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + [[package]] name = "mypy" version = "1.19.1" @@ -261,6 +310,74 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, ] +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "rich" +version = "14.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, +] + [[package]] name = "ruff" version = "0.15.6" @@ -295,6 +412,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" }, ] +[[package]] +name = "stevedore" +version = "5.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6d/90764092216fa560f6587f83bb70113a8ba510ba436c6476a2b47359057c/stevedore-5.7.0.tar.gz", hash = "sha256:31dd6fe6b3cbe921e21dcefabc9a5f1cf848cf538a1f27543721b8ca09948aa3", size = 516200, upload-time = "2026-02-20T13:27:06.765Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/06/36d260a695f383345ab5bbc3fd447249594ae2fa8dfd19c533d5ae23f46b/stevedore-5.7.0-py3-none-any.whl", hash = "sha256:fd25efbb32f1abb4c9e502f385f0018632baac11f9ee5d1b70f88cc5e22ad4ed", size = 54483, upload-time = "2026-02-20T13:27:05.561Z" }, +] + [[package]] name = "types-psycopg2" version = "2.9.21.20260223" From b700245b13a9908d7f4f7f3879d3542f2d993902 Mon Sep 17 00:00:00 2001 From: tphan025 Date: Fri, 20 Mar 2026 16:47:25 +0100 Subject: [PATCH 54/71] guard rules API against pk --- haproxy-route-policy/policy/views.py | 35 ++++++++++++++++++++-------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/haproxy-route-policy/policy/views.py b/haproxy-route-policy/policy/views.py index 9011f7ca0..2e32255e3 100644 --- a/haproxy-route-policy/policy/views.py +++ b/haproxy-route-policy/policy/views.py @@ -114,7 +114,7 @@ def post(self, request): class RuleDetailView(APIView): """View for getting, updating, or deleting a single rule.""" - def get_object(self, pk): + def get_object(self, pk: str): try: return Rule.objects.get(pk=pk) except Rule.DoesNotExist: @@ -122,22 +122,37 @@ def get_object(self, pk): def get(self, request, pk): """Get a rule by ID.""" - rule = self.get_object(pk) - serializer = serializers.RuleSerializer(rule) - return Response(serializer.data) + try: + rule = self.get_object(uuid_primary_key(pk)) + serializer = serializers.RuleSerializer(rule) + return Response(data=serializer.data) + except (ValueError, AttributeError): + return Response( + {"error": "Invalid request ID."}, status=HTTP_400_BAD_REQUEST + ) def put(self, request, pk): """Update a rule by ID.""" - rule = self.get_object(pk) - serializer = serializers.RuleSerializer(rule, data=request.data, partial=True) - if serializer.is_valid(): - serializer.save() - return Response(serializer.data) + try: + rule = self.get_object(uuid_primary_key(pk)) + serializer = serializers.RuleSerializer( + rule, data=request.data, partial=True + ) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data) + except (ValueError, AttributeError): + return Response( + {"error": "Invalid request ID."}, status=HTTP_400_BAD_REQUEST + ) return Response(serializer.errors, status=HTTP_400_BAD_REQUEST) def delete(self, request, pk): """Delete a rule by ID.""" - Rule.objects.filter(pk=pk).delete() + try: + Rule.objects.filter(pk=uuid_primary_key(pk)).delete() + except (AttributeError, ValueError): + logger.warning(f"Attempted to delete request with invalid UUID: {pk}") return Response(status=HTTP_204_NO_CONTENT) From e3d7215e5c895987885093346ada410cdbb20ab4 Mon Sep 17 00:00:00 2001 From: tphan025 Date: Mon, 23 Mar 2026 16:40:23 +0100 Subject: [PATCH 55/71] update view, middle wares and tests --- haproxy-route-policy/policy/middleware.py | 16 +++-- .../policy/tests/test_views.py | 30 ++++---- haproxy-route-policy/policy/urls.py | 2 +- haproxy-route-policy/policy/views.py | 71 +++++-------------- 4 files changed, 48 insertions(+), 71 deletions(-) diff --git a/haproxy-route-policy/policy/middleware.py b/haproxy-route-policy/policy/middleware.py index cdd6fe22b..ee01870a3 100644 --- a/haproxy-route-policy/policy/middleware.py +++ b/haproxy-route-policy/policy/middleware.py @@ -12,12 +12,8 @@ logger = logging.getLogger(__name__) -class DatabaseErrorMiddleware: - """Catch database connection errors and return a generic 503 response. - - This prevents the application's stack trace from being exposed to the client - when the database is unreachable or encounters a connection-level error. - """ +class BaseMiddleware: + """Base middleware class to provide common structure for all middleware.""" def __init__(self, get_response): """Initialize the middleware.""" @@ -27,6 +23,14 @@ def __call__(self, request): """Process the request.""" return self.get_response(request) + +class DatabaseErrorMiddleware(BaseMiddleware): + """Catch database connection errors and return a generic 503 response. + + This prevents the application's stack trace from being exposed to the client + when the database is unreachable or encounters a connection-level error. + """ + def process_exception(self, _request, exception): """Handle database errors raised during view processing.""" if isinstance(exception, (OperationalError, DatabaseError)): diff --git a/haproxy-route-policy/policy/tests/test_views.py b/haproxy-route-policy/policy/tests/test_views.py index 48efab024..eed387276 100644 --- a/haproxy-route-policy/policy/tests/test_views.py +++ b/haproxy-route-policy/policy/tests/test_views.py @@ -354,28 +354,34 @@ def setUp(self): """Set up the API client.""" self.client = APIClient() - def test_get_invalid_pk_returns_400(self): - """GET with an invalid UUID pk should return 400.""" + def test_invalid_pk_returns_404(self): + """GET and DELETE with an invalid UUID pk should return 404.""" invalid_pks = [ "not-a-uuid", "12345", "' OR 1=1 --", " ", ] + # GET requests with invalid PKs for pk in invalid_pks: with self.subTest(pk=pk): response = self.client.get(f"/api/v1/requests/{pk}") - self.assertEqual(response.status_code, 400) - self.assertIn("error", response.json()) + self.assertEqual(response.status_code, 404) - def test_delete_invalid_pk_returns_204(self): - """DELETE with an invalid UUID pk should still return 204 (idempotent).""" - invalid_pks = [ - "not-a-uuid", - "12345", - "' OR 1=1 --", - ] + # GET rules with invalid PKs + for pk in invalid_pks: + with self.subTest(pk=pk): + response = self.client.get(f"/api/v1/rules/{pk}") + self.assertEqual(response.status_code, 404) + + # DELETE requests with invalid PKs for pk in invalid_pks: with self.subTest(pk=pk): response = self.client.delete(f"/api/v1/requests/{pk}") - self.assertEqual(response.status_code, 204) + self.assertEqual(response.status_code, 404) + + # DELETE rules with invalid PKs + for pk in invalid_pks: + with self.subTest(pk=pk): + response = self.client.delete(f"/api/v1/rules/{pk}") + self.assertEqual(response.status_code, 404) diff --git a/haproxy-route-policy/policy/urls.py b/haproxy-route-policy/policy/urls.py index 95c4da997..3229cb919 100644 --- a/haproxy-route-policy/policy/urls.py +++ b/haproxy-route-policy/policy/urls.py @@ -14,7 +14,7 @@ name="api-requests", ), path( - "api/v1/requests/", + "api/v1/requests/", views.RequestDetailView.as_view(), name="api-request-detail", ), diff --git a/haproxy-route-policy/policy/views.py b/haproxy-route-policy/policy/views.py index 2e32255e3..9de7b8df1 100644 --- a/haproxy-route-policy/policy/views.py +++ b/haproxy-route-policy/policy/views.py @@ -4,15 +4,13 @@ """REST API views for backend requests and rules.""" from policy.db_models import BackendRequest, Rule -import uuid -from typing import Any +from typing import Type from venv import logger from rest_framework.views import APIView from rest_framework.response import Response from rest_framework.status import ( HTTP_201_CREATED, HTTP_400_BAD_REQUEST, - HTTP_404_NOT_FOUND, HTTP_204_NO_CONTENT, ) from django.http import Http404 @@ -73,23 +71,13 @@ class RequestDetailView(APIView): def get(self, _request, pk): """Get a request by ID.""" - try: - backend_request = BackendRequest.objects.get(pk=uuid_primary_key(pk)) - serializer = serializers.BackendRequestSerializer(backend_request) - except BackendRequest.DoesNotExist: - return Response(status=HTTP_404_NOT_FOUND) - except (ValueError, AttributeError): - return Response( - {"error": "Invalid request ID."}, status=HTTP_400_BAD_REQUEST - ) + backend_request = get_object(BackendRequest, pk) + serializer = serializers.BackendRequestSerializer(backend_request) return Response(serializer.data) def delete(self, _request, pk): """Delete a request by ID.""" - try: - BackendRequest.objects.filter(pk=uuid_primary_key(pk)).delete() - except (AttributeError, ValueError): - logger.warning(f"Attempted to delete request with invalid UUID: {pk}") + BackendRequest.objects.filter(pk=pk).delete() return Response(status=HTTP_204_NO_CONTENT) @@ -114,51 +102,30 @@ def post(self, request): class RuleDetailView(APIView): """View for getting, updating, or deleting a single rule.""" - def get_object(self, pk: str): - try: - return Rule.objects.get(pk=pk) - except Rule.DoesNotExist: - raise Http404 - def get(self, request, pk): """Get a rule by ID.""" - try: - rule = self.get_object(uuid_primary_key(pk)) - serializer = serializers.RuleSerializer(rule) - return Response(data=serializer.data) - except (ValueError, AttributeError): - return Response( - {"error": "Invalid request ID."}, status=HTTP_400_BAD_REQUEST - ) + rule = get_object(Rule, pk) + serializer = serializers.RuleSerializer(rule) + return Response(data=serializer.data) def put(self, request, pk): """Update a rule by ID.""" - try: - rule = self.get_object(uuid_primary_key(pk)) - serializer = serializers.RuleSerializer( - rule, data=request.data, partial=True - ) - if serializer.is_valid(): - serializer.save() - return Response(serializer.data) - except (ValueError, AttributeError): - return Response( - {"error": "Invalid request ID."}, status=HTTP_400_BAD_REQUEST - ) + rule = get_object(Rule, pk) + serializer = serializers.RuleSerializer(rule, data=request.data, partial=True) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data) return Response(serializer.errors, status=HTTP_400_BAD_REQUEST) def delete(self, request, pk): """Delete a rule by ID.""" - try: - Rule.objects.filter(pk=uuid_primary_key(pk)).delete() - except (AttributeError, ValueError): - logger.warning(f"Attempted to delete request with invalid UUID: {pk}") + Rule.objects.filter(pk=pk).delete() return Response(status=HTTP_204_NO_CONTENT) -def uuid_primary_key(pk: Any) -> str: - """Validate that the passed request parameter is a valid UUID string. - - The calling methods are responsible for catching ValueError and AttributeError. - """ - return str(uuid.UUID(str(pk), version=4)) +def get_object(object_class: Type[Rule] | Type[BackendRequest], pk: str): + try: + logger.info(f"Fetching object with ID: {pk}") + return object_class.objects.get(pk=pk) + except object_class.DoesNotExist: + raise Http404 From 6c1db2730b5d1efd2339523923cd345c7cb59c15 Mon Sep 17 00:00:00 2001 From: tphan025 Date: Mon, 23 Mar 2026 22:40:46 +0100 Subject: [PATCH 56/71] refactor tests by parametrizing --- .../policy/tests/test_models.py | 403 ++++++++---------- 1 file changed, 188 insertions(+), 215 deletions(-) diff --git a/haproxy-route-policy/policy/tests/test_models.py b/haproxy-route-policy/policy/tests/test_models.py index 6c86f13b9..78d02767c 100644 --- a/haproxy-route-policy/policy/tests/test_models.py +++ b/haproxy-route-policy/policy/tests/test_models.py @@ -47,29 +47,6 @@ def test_create_with_all_fields(self): class TestRuleModel(TestCase): """Tests for Rule model creation, serialization, and validation.""" - def test_create_hostname_and_path_match_rule(self): - """Test creating a hostname_and_path_match rule with valid data.""" - serializer = serializers.RuleSerializer( - data={ - "kind": db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, - "value": {"hostnames": ["example.com"], "paths": ["/api"]}, - "action": db_models.RULE_ACTION_DENY, - "priority": 1, - "comment": "Deny example.com/api", - } - ) - self.assertTrue(serializer.is_valid(), serializer.errors) - rule = serializer.save() - - self.assertIsNotNone(rule.id) - self.assertEqual(rule.kind, db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH) - self.assertEqual(rule.value, {"hostnames": ["example.com"], "paths": ["/api"]}) - self.assertEqual(rule.action, db_models.RULE_ACTION_DENY) - self.assertEqual(rule.priority, 1) - self.assertEqual(rule.comment, "Deny example.com/api") - self.assertIsNotNone(rule.created_at) - self.assertIsNotNone(rule.updated_at) - def test_create_rule_defaults(self): """Test that default values are set correctly.""" serializer = serializers.RuleSerializer( @@ -85,211 +62,207 @@ def test_create_rule_defaults(self): self.assertEqual(rule.priority, 0) self.assertEqual(rule.comment, "") - def test_invalid_kind_rejected(self): - """Test that an invalid kind value is rejected.""" - serializer = serializers.RuleSerializer( - data={ - "kind": "invalid_kind", - "value": 1, - "action": db_models.RULE_ACTION_ALLOW, - } - ) - self.assertFalse(serializer.is_valid()) - self.assertIn("kind", serializer.errors) - - def test_invalid_action_rejected(self): - """Test that an invalid action value is rejected.""" - serializer = serializers.RuleSerializer( - data={ - "kind": db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, - "value": {"hostnames": ["example.com"], "paths": []}, - "action": "invalid_action", - } - ) - self.assertFalse(serializer.is_valid()) - self.assertIn("action", serializer.errors) - - def test_hostname_and_path_match_value_must_be_dict(self): - """Test that hostname_and_path_match rules require a dict value.""" - serializer = serializers.RuleSerializer( - data={ - "kind": db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, - "value": "not-a-dict", - "action": db_models.RULE_ACTION_DENY, - } - ) - self.assertFalse(serializer.is_valid()) - self.assertIn("non_field_errors", serializer.errors) - self.assertIn( - "value field must be a JSON object", - str(serializer.errors["non_field_errors"]), - ) - - def test_hostname_and_path_match_value_list_rejected(self): - """Test that hostname_and_path_match rules reject a list value.""" - serializer = serializers.RuleSerializer( - data={ - "kind": db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, - "value": ["not", "a", "dict"], - "action": db_models.RULE_ACTION_DENY, - } - ) - self.assertFalse(serializer.is_valid()) - self.assertIn("non_field_errors", serializer.errors) - self.assertIn( - "value field must be a JSON object", - str(serializer.errors["non_field_errors"]), - ) - - def test_hostname_and_path_match_value_int_rejected(self): - """Test that hostname_and_path_match rules reject an integer value.""" + def test_create_hostname_and_path_match_rule(self): + """Test creating a hostname_and_path_match rule with valid data.""" serializer = serializers.RuleSerializer( data={ "kind": db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, - "value": 42, + "value": {"hostnames": ["example.com"], "paths": ["/api"]}, "action": db_models.RULE_ACTION_DENY, + "priority": 1, + "comment": "Deny example.com/api", } ) - self.assertFalse(serializer.is_valid()) - self.assertIn("non_field_errors", serializer.errors) - self.assertIn( - "value field must be a JSON object", - str(serializer.errors["non_field_errors"]), - ) + self.assertTrue(serializer.is_valid(), serializer.errors) + rule = serializer.save() - def test_hostname_and_path_match_invalid_hostname(self): - """Test that invalid hostnames are rejected in hostname_and_path_match rules.""" - serializer = serializers.RuleSerializer( - data={ - "kind": db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, - "value": {"hostnames": ["not a valid hostname!!!"], "paths": []}, - "action": db_models.RULE_ACTION_DENY, - } - ) - self.assertFalse(serializer.is_valid()) - self.assertIn("Invalid hostname", str(serializer.errors)) + self.assertIsNotNone(rule.id) + self.assertEqual(rule.kind, db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH) + self.assertEqual(rule.value, {"hostnames": ["example.com"], "paths": ["/api"]}) + self.assertEqual(rule.action, db_models.RULE_ACTION_DENY) + self.assertEqual(rule.priority, 1) + self.assertEqual(rule.comment, "Deny example.com/api") + self.assertIsNotNone(rule.created_at) + self.assertIsNotNone(rule.updated_at) - def test_hostname_and_path_match_multiple_invalid_hostnames(self): - """Test that multiple invalid hostnames are reported.""" - serializer = serializers.RuleSerializer( - data={ - "kind": db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, - "value": { - "hostnames": ["valid.com", "bad host", "also bad!"], - "paths": [], + def test_valid_rule_data_accepted(self): + """Valid rule data should pass serializer validation.""" + valid_cases = [ + ( + "valid hostnames", + { + "kind": db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, + "value": { + "hostnames": ["example.com", "sub.example.org"], + "paths": [], + }, + "action": db_models.RULE_ACTION_ALLOW, }, - "action": db_models.RULE_ACTION_DENY, - } - ) - self.assertFalse(serializer.is_valid()) - errors_str = str(serializer.errors) - self.assertIn("bad host", errors_str) - self.assertIn("also bad!", errors_str) - - def test_hostname_and_path_match_valid_hostnames_accepted(self): - """Test that valid hostnames pass validation.""" - serializer = serializers.RuleSerializer( - data={ - "kind": db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, - "value": { - "hostnames": ["example.com", "sub.example.org"], - "paths": [], + ), + ( + "empty hostnames", + { + "kind": db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, + "value": {"hostnames": [], "paths": []}, + "action": db_models.RULE_ACTION_ALLOW, }, - "action": db_models.RULE_ACTION_ALLOW, - } - ) - self.assertTrue(serializer.is_valid(), serializer.errors) - - def test_hostname_and_path_match_empty_hostnames_accepted(self): - """Test that an empty hostnames list passes validation.""" - serializer = serializers.RuleSerializer( - data={ - "kind": db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, - "value": {"hostnames": [], "paths": []}, - "action": db_models.RULE_ACTION_ALLOW, - } - ) - self.assertTrue(serializer.is_valid(), serializer.errors) - - def test_hostname_and_path_match_invalid_path_not_starting_with_slash(self): - """Test that paths not starting with / are rejected.""" - serializer = serializers.RuleSerializer( - data={ - "kind": db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, - "value": {"hostnames": ["example.com"], "paths": ["api/v1"]}, - "action": db_models.RULE_ACTION_DENY, - } - ) - self.assertFalse(serializer.is_valid()) - self.assertIn("Invalid path", str(serializer.errors)) - - def test_hostname_and_path_match_invalid_path_non_string(self): - """Test that non-string paths are rejected.""" - serializer = serializers.RuleSerializer( - data={ - "kind": db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, - "value": {"hostnames": ["example.com"], "paths": [123]}, - "action": db_models.RULE_ACTION_DENY, - } - ) - self.assertFalse(serializer.is_valid()) - - def test_hostname_and_path_match_valid_paths_accepted(self): - """Test that valid paths starting with / pass validation.""" - serializer = serializers.RuleSerializer( - data={ - "kind": db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, - "value": { - "hostnames": ["example.com"], - "paths": ["/api", "/health"], + ), + ( + "valid paths", + { + "kind": db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, + "value": { + "hostnames": ["example.com"], + "paths": ["/api", "/health"], + }, + "action": db_models.RULE_ACTION_ALLOW, }, - "action": db_models.RULE_ACTION_ALLOW, - } - ) - self.assertTrue(serializer.is_valid(), serializer.errors) - - def test_hostname_and_path_match_empty_paths_accepted(self): - """Test that an empty paths list passes validation.""" - serializer = serializers.RuleSerializer( - data={ - "kind": db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, - "value": {"hostnames": ["example.com"], "paths": []}, - "action": db_models.RULE_ACTION_ALLOW, - } - ) - self.assertTrue(serializer.is_valid(), serializer.errors) - - def test_hostname_and_path_match_multiple_invalid_paths(self): - """Test that multiple invalid paths are reported.""" - serializer = serializers.RuleSerializer( - data={ - "kind": db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, - "value": {"hostnames": [], "paths": ["no-slash", "also-bad"]}, - "action": db_models.RULE_ACTION_DENY, - } - ) - self.assertFalse(serializer.is_valid()) - errors_str = str(serializer.errors) - self.assertIn("no-slash", errors_str) - self.assertIn("also-bad", errors_str) + ), + ( + "empty paths", + { + "kind": db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, + "value": {"hostnames": ["example.com"], "paths": []}, + "action": db_models.RULE_ACTION_ALLOW, + }, + ), + ( + "both valid hostnames and paths", + { + "kind": db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, + "value": { + "hostnames": ["example.com", "app.example.com"], + "paths": ["/api", "/v1/health"], + }, + "action": db_models.RULE_ACTION_DENY, + "priority": 3, + "comment": "Block specific routes", + }, + ), + ] + for label, data in valid_cases: + with self.subTest(label=label): + serializer = serializers.RuleSerializer(data=data) + self.assertTrue(serializer.is_valid(), serializer.errors) - def test_hostname_and_path_match_both_valid_hostnames_and_paths(self): - """Test that a rule with both valid hostnames and paths passes.""" - serializer = serializers.RuleSerializer( - data={ - "kind": db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, - "value": { - "hostnames": ["example.com", "app.example.com"], - "paths": ["/api", "/v1/health"], + def test_invalid_rule_data_rejected(self): + """Invalid rule data should fail serializer validation.""" + invalid_cases = [ + ( + "invalid kind", + { + "kind": "invalid_kind", + "value": 1, + "action": db_models.RULE_ACTION_ALLOW, }, - "action": db_models.RULE_ACTION_DENY, - "priority": 3, - "comment": "Block specific routes", - } - ) - self.assertTrue(serializer.is_valid(), serializer.errors) - rule = serializer.save() - self.assertIsNotNone(rule.id) + {"field": "kind"}, + ), + ( + "invalid action", + { + "kind": db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, + "value": {"hostnames": ["example.com"], "paths": []}, + "action": "invalid_action", + }, + {"field": "action"}, + ), + ( + "value must be dict — string given", + { + "kind": db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, + "value": "not-a-dict", + "action": db_models.RULE_ACTION_DENY, + }, + { + "field": "non_field_errors", + "message": "value field must be a JSON object", + }, + ), + ( + "value must be dict — list given", + { + "kind": db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, + "value": ["not", "a", "dict"], + "action": db_models.RULE_ACTION_DENY, + }, + { + "field": "non_field_errors", + "message": "value field must be a JSON object", + }, + ), + ( + "value must be dict — int given", + { + "kind": db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, + "value": 42, + "action": db_models.RULE_ACTION_DENY, + }, + { + "field": "non_field_errors", + "message": "value field must be a JSON object", + }, + ), + ( + "invalid hostname", + { + "kind": db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, + "value": {"hostnames": ["not a valid hostname!!!"], "paths": []}, + "action": db_models.RULE_ACTION_DENY, + }, + {"message": "Invalid hostname"}, + ), + ( + "multiple invalid hostnames", + { + "kind": db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, + "value": { + "hostnames": ["valid.com", "bad host", "also bad!"], + "paths": [], + }, + "action": db_models.RULE_ACTION_DENY, + }, + {"message_contains": ["bad host", "also bad!"]}, + ), + ( + "path without leading slash", + { + "kind": db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, + "value": {"hostnames": ["example.com"], "paths": ["api/v1"]}, + "action": db_models.RULE_ACTION_DENY, + }, + {"message": "Invalid path"}, + ), + ( + "non-string path", + { + "kind": db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, + "value": {"hostnames": ["example.com"], "paths": [123]}, + "action": db_models.RULE_ACTION_DENY, + }, + {}, + ), + ( + "multiple invalid paths", + { + "kind": db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, + "value": {"hostnames": [], "paths": ["no-slash", "also-bad"]}, + "action": db_models.RULE_ACTION_DENY, + }, + {"message_contains": ["no-slash", "also-bad"]}, + ), + ] + for label, data, checks in invalid_cases: + with self.subTest(label=label): + serializer = serializers.RuleSerializer(data=data) + self.assertFalse(serializer.is_valid()) + errors_str = str(serializer.errors) + if "field" in checks: + self.assertIn(checks["field"], serializer.errors) + if "message" in checks: + self.assertIn(checks["message"], errors_str) + if "message_contains" in checks: + for fragment in checks["message_contains"]: + self.assertIn(fragment, errors_str) class TestValidatePort(TestCase): From ea6afe4ec2589e847bbd74fff3d7ba0b9808e25e Mon Sep 17 00:00:00 2001 From: tphan025 Date: Mon, 23 Mar 2026 22:44:13 +0100 Subject: [PATCH 57/71] group tests by parameterizing --- .../policy/tests/test_views.py | 56 ++++++++++--------- 1 file changed, 30 insertions(+), 26 deletions(-) diff --git a/haproxy-route-policy/policy/tests/test_views.py b/haproxy-route-policy/policy/tests/test_views.py index eed387276..ab9fd8326 100644 --- a/haproxy-route-policy/policy/tests/test_views.py +++ b/haproxy-route-policy/policy/tests/test_views.py @@ -213,32 +213,36 @@ def test_create_rule_with_defaults(self): self.assertEqual(data["priority"], 0) self.assertEqual(data["comment"], "") - def test_create_rule_invalid_kind(self): - """POST returns 400 when kind is invalid.""" - payload = { - "kind": "invalid_kind", - "value": 1, - "action": db_models.RULE_ACTION_ALLOW, - } - response = self.client.post("/api/v1/rules", data=payload, format="json") - self.assertEqual(response.status_code, 400) - - def test_create_rule_invalid_value_for_kind(self): - """POST returns 400 when value doesn't match kind requirements.""" - payload = { - "kind": db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, - "value": "not-a-dict", - "action": db_models.RULE_ACTION_DENY, - } - response = self.client.post("/api/v1/rules", data=payload, format="json") - self.assertEqual(response.status_code, 400) - - def test_create_rule_rejects_non_dict(self): - """POST returns 400 when the body is not a JSON object.""" - response = self.client.post( - "/api/v1/rules", data=[{"kind": "test"}], format="json" - ) - self.assertEqual(response.status_code, 400) + def test_create_rule_invalid_payload(self): + """POST returns 400 for invalid rule payloads.""" + invalid_payloads = [ + ( + "invalid kind", + { + "kind": "invalid_kind", + "value": 1, + "action": db_models.RULE_ACTION_ALLOW, + }, + ), + ( + "value doesn't match kind", + { + "kind": db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, + "value": "not-a-dict", + "action": db_models.RULE_ACTION_DENY, + }, + ), + ( + "body is not a JSON object", + [{"kind": "test"}], + ), + ] + for label, payload in invalid_payloads: + with self.subTest(label=label): + response = self.client.post( + "/api/v1/rules", data=payload, format="json" + ) + self.assertEqual(response.status_code, 400) class TestRuleDetailView(TestCase): From 7ec072cccf429108cb71ffbcb90e1585a7fe56d9 Mon Sep 17 00:00:00 2001 From: tphan025 Date: Mon, 23 Mar 2026 22:55:22 +0100 Subject: [PATCH 58/71] refactor Rule model to rename attribute from "value" to "parameters" --- haproxy-route-policy/policy/db_models.py | 4 +- ...le_alter_backendrequest_paths_and_more.py} | 24 ++++++--- haproxy-route-policy/policy/serializers.py | 8 +-- .../policy/tests/test_models.py | 53 ++++++++++--------- .../policy/tests/test_views.py | 20 +++---- 5 files changed, 62 insertions(+), 47 deletions(-) rename haproxy-route-policy/policy/migrations/{0002_rule_alter_backendrequest_hostname_acls_and_more.py => 0002_rule_alter_backendrequest_paths_and_more.py} (59%) diff --git a/haproxy-route-policy/policy/db_models.py b/haproxy-route-policy/policy/db_models.py index caf23e64c..7eb294f6d 100644 --- a/haproxy-route-policy/policy/db_models.py +++ b/haproxy-route-policy/policy/db_models.py @@ -116,7 +116,7 @@ class Rule(models.Model): Attrs: id: UUID primary key. kind: The type of rule (e.g. hostname_and_path_match, match_request_id). - value: The rule value, structure depends on kind. + parameters: The rule parameters, structure depends on kind. action: Whether the rule allows or denies matching requests. priority: Rule priority (higher = evaluated first, deny wins on tie). comment: Optional human-readable comment. @@ -128,7 +128,7 @@ class Rule(models.Model): primary_key=True, default=uuid.uuid4, editable=False ) kind: models.TextField = models.TextField(choices=RULE_KIND_CHOICES) - value: models.JSONField = models.JSONField() + parameters: models.JSONField = models.JSONField() action: models.TextField = models.TextField(choices=RULE_ACTION_CHOICES) priority: models.IntegerField = models.IntegerField(default=0, blank=True) comment: models.TextField = models.TextField(default="", blank=True) diff --git a/haproxy-route-policy/policy/migrations/0002_rule_alter_backendrequest_hostname_acls_and_more.py b/haproxy-route-policy/policy/migrations/0002_rule_alter_backendrequest_paths_and_more.py similarity index 59% rename from haproxy-route-policy/policy/migrations/0002_rule_alter_backendrequest_hostname_acls_and_more.py rename to haproxy-route-policy/policy/migrations/0002_rule_alter_backendrequest_paths_and_more.py index 84e403e52..0a7b61e4f 100644 --- a/haproxy-route-policy/policy/migrations/0002_rule_alter_backendrequest_hostname_acls_and_more.py +++ b/haproxy-route-policy/policy/migrations/0002_rule_alter_backendrequest_paths_and_more.py @@ -1,5 +1,6 @@ -# Generated by Django 6.0.3 on 2026-03-17 20:05 +# Generated by Django 6.0.3 on 2026-03-23 21:53 +import policy.db_models import uuid from django.db import migrations, models @@ -25,21 +26,30 @@ class Migration(migrations.Migration): ( "kind", models.TextField( - choices=[ - ("hostname_and_path_match", "hostname_and_path_match"), - ("match_request_id", "match_request_id"), - ] + choices=[("hostname_and_path_match", "hostname_and_path_match")] ), ), - ("value", models.JSONField()), + ("parameters", models.JSONField()), ( "action", models.TextField(choices=[("allow", "allow"), ("deny", "deny")]), ), - ("priority", models.IntegerField(default=0)), + ("priority", models.IntegerField(blank=True, default=0)), ("comment", models.TextField(blank=True, default="")), ("created_at", models.DateTimeField(auto_now_add=True)), ("updated_at", models.DateTimeField(auto_now=True)), ], ), + migrations.AlterField( + model_name="backendrequest", + name="paths", + field=models.JSONField( + blank=True, default=list, validators=[policy.db_models.validate_paths] + ), + ), + migrations.AlterField( + model_name="backendrequest", + name="port", + field=models.IntegerField(validators=[policy.db_models.validate_port]), + ), ] diff --git a/haproxy-route-policy/policy/serializers.py b/haproxy-route-policy/policy/serializers.py index 590b2d223..a71d371a7 100644 --- a/haproxy-route-policy/policy/serializers.py +++ b/haproxy-route-policy/policy/serializers.py @@ -28,12 +28,12 @@ class Meta: # pyright: ignore[reportIncompatibleVariableOverride] def validate(self, attrs): """Custom validation logic for the Rule model.""" if attrs.get("kind") == RULE_KIND_HOSTNAME_AND_PATH_MATCH: - if not isinstance(attrs.get("value"), dict): + if not isinstance(attrs.get("parameters"), dict): raise serializers.ValidationError( - "The value field must be a JSON object." + "The parameters field must be a JSON object." ) - if hostnames := typing.cast(dict, attrs.get("value")).get("hostnames"): + if hostnames := typing.cast(dict, attrs.get("parameters")).get("hostnames"): if invalid_hostnames := [ hostname for hostname in hostnames if not domain(hostname) ]: @@ -41,7 +41,7 @@ def validate(self, attrs): f"Invalid hostname(s) in rule: {', '.join(invalid_hostnames)}" ) - if paths := typing.cast(dict, attrs.get("value")).get("paths"): + if paths := typing.cast(dict, attrs.get("parameters")).get("paths"): if invalid_paths := [path for path in paths if is_valid_path(path)]: raise serializers.ValidationError( f"Invalid path(s) in rule: {', '.join([str(path) for path in invalid_paths])}" diff --git a/haproxy-route-policy/policy/tests/test_models.py b/haproxy-route-policy/policy/tests/test_models.py index 78d02767c..0233a3401 100644 --- a/haproxy-route-policy/policy/tests/test_models.py +++ b/haproxy-route-policy/policy/tests/test_models.py @@ -52,7 +52,7 @@ def test_create_rule_defaults(self): serializer = serializers.RuleSerializer( data={ "kind": db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, - "value": {"hostnames": ["test.com"], "paths": []}, + "parameters": {"hostnames": ["test.com"], "paths": []}, "action": db_models.RULE_ACTION_ALLOW, } ) @@ -67,7 +67,7 @@ def test_create_hostname_and_path_match_rule(self): serializer = serializers.RuleSerializer( data={ "kind": db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, - "value": {"hostnames": ["example.com"], "paths": ["/api"]}, + "parameters": {"hostnames": ["example.com"], "paths": ["/api"]}, "action": db_models.RULE_ACTION_DENY, "priority": 1, "comment": "Deny example.com/api", @@ -78,7 +78,9 @@ def test_create_hostname_and_path_match_rule(self): self.assertIsNotNone(rule.id) self.assertEqual(rule.kind, db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH) - self.assertEqual(rule.value, {"hostnames": ["example.com"], "paths": ["/api"]}) + self.assertEqual( + rule.parameters, {"hostnames": ["example.com"], "paths": ["/api"]} + ) self.assertEqual(rule.action, db_models.RULE_ACTION_DENY) self.assertEqual(rule.priority, 1) self.assertEqual(rule.comment, "Deny example.com/api") @@ -92,7 +94,7 @@ def test_valid_rule_data_accepted(self): "valid hostnames", { "kind": db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, - "value": { + "parameters": { "hostnames": ["example.com", "sub.example.org"], "paths": [], }, @@ -103,7 +105,7 @@ def test_valid_rule_data_accepted(self): "empty hostnames", { "kind": db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, - "value": {"hostnames": [], "paths": []}, + "parameters": {"hostnames": [], "paths": []}, "action": db_models.RULE_ACTION_ALLOW, }, ), @@ -111,7 +113,7 @@ def test_valid_rule_data_accepted(self): "valid paths", { "kind": db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, - "value": { + "parameters": { "hostnames": ["example.com"], "paths": ["/api", "/health"], }, @@ -122,7 +124,7 @@ def test_valid_rule_data_accepted(self): "empty paths", { "kind": db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, - "value": {"hostnames": ["example.com"], "paths": []}, + "parameters": {"hostnames": ["example.com"], "paths": []}, "action": db_models.RULE_ACTION_ALLOW, }, ), @@ -130,7 +132,7 @@ def test_valid_rule_data_accepted(self): "both valid hostnames and paths", { "kind": db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, - "value": { + "parameters": { "hostnames": ["example.com", "app.example.com"], "paths": ["/api", "/v1/health"], }, @@ -152,7 +154,7 @@ def test_invalid_rule_data_rejected(self): "invalid kind", { "kind": "invalid_kind", - "value": 1, + "parameters": 1, "action": db_models.RULE_ACTION_ALLOW, }, {"field": "kind"}, @@ -161,52 +163,55 @@ def test_invalid_rule_data_rejected(self): "invalid action", { "kind": db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, - "value": {"hostnames": ["example.com"], "paths": []}, + "parameters": {"hostnames": ["example.com"], "paths": []}, "action": "invalid_action", }, {"field": "action"}, ), ( - "value must be dict — string given", + "parameters must be dict — string given", { "kind": db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, - "value": "not-a-dict", + "parameters": "not-a-dict", "action": db_models.RULE_ACTION_DENY, }, { "field": "non_field_errors", - "message": "value field must be a JSON object", + "message": "parameters field must be a JSON object", }, ), ( - "value must be dict — list given", + "parameters must be dict — list given", { "kind": db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, - "value": ["not", "a", "dict"], + "parameters": ["not", "a", "dict"], "action": db_models.RULE_ACTION_DENY, }, { "field": "non_field_errors", - "message": "value field must be a JSON object", + "message": "parameters field must be a JSON object", }, ), ( - "value must be dict — int given", + "parameters must be dict — int given", { "kind": db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, - "value": 42, + "parameters": 42, "action": db_models.RULE_ACTION_DENY, }, { "field": "non_field_errors", - "message": "value field must be a JSON object", + "message": "parameters field must be a JSON object", }, ), ( "invalid hostname", { "kind": db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, - "value": {"hostnames": ["not a valid hostname!!!"], "paths": []}, + "parameters": { + "hostnames": ["not a valid hostname!!!"], + "paths": [], + }, "action": db_models.RULE_ACTION_DENY, }, {"message": "Invalid hostname"}, @@ -215,7 +220,7 @@ def test_invalid_rule_data_rejected(self): "multiple invalid hostnames", { "kind": db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, - "value": { + "parameters": { "hostnames": ["valid.com", "bad host", "also bad!"], "paths": [], }, @@ -227,7 +232,7 @@ def test_invalid_rule_data_rejected(self): "path without leading slash", { "kind": db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, - "value": {"hostnames": ["example.com"], "paths": ["api/v1"]}, + "parameters": {"hostnames": ["example.com"], "paths": ["api/v1"]}, "action": db_models.RULE_ACTION_DENY, }, {"message": "Invalid path"}, @@ -236,7 +241,7 @@ def test_invalid_rule_data_rejected(self): "non-string path", { "kind": db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, - "value": {"hostnames": ["example.com"], "paths": [123]}, + "parameters": {"hostnames": ["example.com"], "paths": [123]}, "action": db_models.RULE_ACTION_DENY, }, {}, @@ -245,7 +250,7 @@ def test_invalid_rule_data_rejected(self): "multiple invalid paths", { "kind": db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, - "value": {"hostnames": [], "paths": ["no-slash", "also-bad"]}, + "parameters": {"hostnames": [], "paths": ["no-slash", "also-bad"]}, "action": db_models.RULE_ACTION_DENY, }, {"message_contains": ["no-slash", "also-bad"]}, diff --git a/haproxy-route-policy/policy/tests/test_views.py b/haproxy-route-policy/policy/tests/test_views.py index ab9fd8326..0042bc7d8 100644 --- a/haproxy-route-policy/policy/tests/test_views.py +++ b/haproxy-route-policy/policy/tests/test_views.py @@ -154,7 +154,7 @@ def test_list_returns_all_ordered_by_priority(self): """GET returns all rules ordered by descending priority.""" rule_low = db_models.Rule( kind=db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, - value={"hostnames": ["example.com"], "paths": ["/api"]}, + parameters={"hostnames": ["example.com"], "paths": ["/api"]}, action=db_models.RULE_ACTION_ALLOW, priority=0, ) @@ -162,7 +162,7 @@ def test_list_returns_all_ordered_by_priority(self): rule_low.save() rule_high = db_models.Rule( kind=db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, - value={"hostnames": ["example.org"], "paths": ["/admin"]}, + parameters={"hostnames": ["example.org"], "paths": ["/admin"]}, action=db_models.RULE_ACTION_DENY, priority=10, ) @@ -181,7 +181,7 @@ def test_create_hostname_and_path_match_rule(self): """POST creates a hostname_and_path_match rule.""" payload = { "kind": db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, - "value": {"hostnames": ["example.com"], "paths": ["/api"]}, + "parameters": {"hostnames": ["example.com"], "paths": ["/api"]}, "action": db_models.RULE_ACTION_DENY, "priority": 5, "comment": "Block example.com/api", @@ -191,7 +191,7 @@ def test_create_hostname_and_path_match_rule(self): data = response.json() self.assertEqual(data["kind"], db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH) self.assertEqual( - data["value"], {"hostnames": ["example.com"], "paths": ["/api"]} + data["parameters"], {"hostnames": ["example.com"], "paths": ["/api"]} ) self.assertEqual(data["action"], db_models.RULE_ACTION_DENY) self.assertEqual(data["priority"], 5) @@ -204,7 +204,7 @@ def test_create_rule_with_defaults(self): """POST creates a rule with default priority and comment.""" payload = { "kind": db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, - "value": {"hostnames": ["example.com"], "paths": ["/api"]}, + "parameters": {"hostnames": ["example.com"], "paths": ["/api"]}, "action": db_models.RULE_ACTION_DENY, } response = self.client.post("/api/v1/rules", data=payload, format="json") @@ -220,15 +220,15 @@ def test_create_rule_invalid_payload(self): "invalid kind", { "kind": "invalid_kind", - "value": 1, + "parameters": 1, "action": db_models.RULE_ACTION_ALLOW, }, ), ( - "value doesn't match kind", + "parameters doesn't match kind", { "kind": db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, - "value": "not-a-dict", + "parameters": "not-a-dict", "action": db_models.RULE_ACTION_DENY, }, ), @@ -253,7 +253,7 @@ def setUp(self): self.client = APIClient() self.rule = db_models.Rule( kind=db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, - value={"hostnames": ["example.com"], "paths": ["/api"]}, + parameters={"hostnames": ["example.com"], "paths": ["/api"]}, action=db_models.RULE_ACTION_DENY, priority=1, comment="Test rule", @@ -293,7 +293,7 @@ def test_update_rule(self): # Unchanged fields remain the same self.assertEqual(data["kind"], db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH) self.assertEqual( - data["value"], {"hostnames": ["example.com"], "paths": ["/api"]} + data["parameters"], {"hostnames": ["example.com"], "paths": ["/api"]} ) def test_update_nonexistent(self): From 9a3d61a85525ccedc63ebe2c021628add50ca14e Mon Sep 17 00:00:00 2001 From: tphan025 Date: Mon, 23 Mar 2026 22:56:58 +0100 Subject: [PATCH 59/71] update test name --- haproxy-route-policy/policy/tests/test_models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/haproxy-route-policy/policy/tests/test_models.py b/haproxy-route-policy/policy/tests/test_models.py index 0233a3401..16ec291f5 100644 --- a/haproxy-route-policy/policy/tests/test_models.py +++ b/haproxy-route-policy/policy/tests/test_models.py @@ -47,7 +47,7 @@ def test_create_with_all_fields(self): class TestRuleModel(TestCase): """Tests for Rule model creation, serialization, and validation.""" - def test_create_rule_defaults(self): + def test_create_rule_set_default_priority_and_comment(self): """Test that default values are set correctly.""" serializer = serializers.RuleSerializer( data={ From 6b75a0a012bdac4949d3f49d62bd403a286fdc9f Mon Sep 17 00:00:00 2001 From: tphan025 Date: Mon, 23 Mar 2026 22:57:37 +0100 Subject: [PATCH 60/71] update naming --- haproxy-route-policy/policy/tests/test_views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/haproxy-route-policy/policy/tests/test_views.py b/haproxy-route-policy/policy/tests/test_views.py index 0042bc7d8..7db88c79f 100644 --- a/haproxy-route-policy/policy/tests/test_views.py +++ b/haproxy-route-policy/policy/tests/test_views.py @@ -200,7 +200,7 @@ def test_create_hostname_and_path_match_rule(self): self.assertIn("created_at", data) self.assertEqual(db_models.Rule.objects.count(), 1) - def test_create_rule_with_defaults(self): + def test_create_rule_set_default_priority_and_comment(self): """POST creates a rule with default priority and comment.""" payload = { "kind": db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, From 5e949077665c71a253c044f8239a8a2fa9a973c2 Mon Sep 17 00:00:00 2001 From: tphan025 Date: Wed, 25 Mar 2026 11:27:08 +0100 Subject: [PATCH 61/71] Add coverage-report as part of unit test suite --- haproxy-route-policy/pyproject.toml | 6 ++ haproxy-route-policy/tox.toml | 8 ++- haproxy-route-policy/uv.lock | 92 +++++++++++++++++++++++++++++ 3 files changed, 105 insertions(+), 1 deletion(-) diff --git a/haproxy-route-policy/pyproject.toml b/haproxy-route-policy/pyproject.toml index 3eab72619..e1443b556 100644 --- a/haproxy-route-policy/pyproject.toml +++ b/haproxy-route-policy/pyproject.toml @@ -12,6 +12,9 @@ dependencies = [ ] [dependency-groups] +coverage-report = [ + "coverage[toml]>=7.13.5", +] lint = [ "codespell>=2.4.2", "django-stubs>=6.0.0", @@ -24,3 +27,6 @@ lint = [ static = [ "bandit[toml]>=1.9.4", ] +unit = [ + "coverage[toml]>=7.13.5", +] diff --git a/haproxy-route-policy/tox.toml b/haproxy-route-policy/tox.toml index 15b8d1214..327c78402 100644 --- a/haproxy-route-policy/tox.toml +++ b/haproxy-route-policy/tox.toml @@ -20,7 +20,7 @@ PY_COLORS = "1" description = "Run unit tests" commands = [ [ - "uv", + "coverage", "run", "manage.py", "test", @@ -29,6 +29,7 @@ commands = [ "-v2", ], ] +dependency_groups = ["unit"] [env.lint] description = "Check code against coding style standards" @@ -75,3 +76,8 @@ dependency_groups = [ "static" ] src_path = "{toxinidir}/policy/" tst_path = "{toxinidir}/policy/tests" all_path = ["{toxinidir}/policy/"] + +[env.coverage-report] +description = "Create test coverage report" +commands = [ [ "coverage", "report" ] ] +dependency_groups = [ "coverage-report" ] diff --git a/haproxy-route-policy/uv.lock b/haproxy-route-policy/uv.lock index 5e705cf7f..8caecae5a 100644 --- a/haproxy-route-policy/uv.lock +++ b/haproxy-route-policy/uv.lock @@ -44,6 +44,90 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "coverage" +version = "7.13.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" }, + { url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" }, + { url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" }, + { url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" }, + { url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270, upload-time = "2026-03-17T10:30:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538, upload-time = "2026-03-17T10:30:50.77Z" }, + { url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821, upload-time = "2026-03-17T10:30:52.5Z" }, + { url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191, upload-time = "2026-03-17T10:30:54.543Z" }, + { url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337, upload-time = "2026-03-17T10:30:56.663Z" }, + { url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404, upload-time = "2026-03-17T10:30:58.427Z" }, + { url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903, upload-time = "2026-03-17T10:31:00.093Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780, upload-time = "2026-03-17T10:31:01.916Z" }, + { url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093, upload-time = "2026-03-17T10:31:03.642Z" }, + { url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900, upload-time = "2026-03-17T10:31:05.651Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515, upload-time = "2026-03-17T10:31:07.293Z" }, + { url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" }, + { url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" }, + { url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" }, + { url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" }, + { url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" }, + { url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" }, + { url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" }, + { url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" }, + { url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" }, + { url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" }, + { url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" }, + { url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" }, + { url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" }, + { url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" }, + { url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" }, + { url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" }, + { url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" }, + { url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" }, + { url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" }, + { url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" }, + { url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" }, + { url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" }, + { url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" }, + { url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" }, + { url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" }, + { url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" }, + { url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" }, + { url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" }, + { url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" }, + { url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" }, + { url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" }, + { url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" }, + { url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" }, + { url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" }, + { url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" }, + { url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" }, + { url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" }, + { url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" }, + { url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" }, + { url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" }, + { url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" }, + { url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" }, + { url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" }, + { url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" }, +] + [[package]] name = "django" version = "6.0.3" @@ -145,6 +229,9 @@ dependencies = [ ] [package.dev-dependencies] +coverage-report = [ + { name = "coverage" }, +] lint = [ { name = "codespell" }, { name = "django-stubs" }, @@ -157,6 +244,9 @@ lint = [ static = [ { name = "bandit" }, ] +unit = [ + { name = "coverage" }, +] [package.metadata] requires-dist = [ @@ -167,6 +257,7 @@ requires-dist = [ ] [package.metadata.requires-dev] +coverage-report = [{ name = "coverage", extras = ["toml"], specifier = ">=7.13.5" }] lint = [ { name = "codespell", specifier = ">=2.4.2" }, { name = "django-stubs", specifier = ">=6.0.0" }, @@ -177,6 +268,7 @@ lint = [ { name = "ruff", specifier = ">=0.15.6" }, ] static = [{ name = "bandit", extras = ["toml"], specifier = ">=1.9.4" }] +unit = [{ name = "coverage", extras = ["toml"], specifier = ">=7.13.5" }] [[package]] name = "librt" From 1910db567be9779ee8f145046739a4e0f7451c22 Mon Sep 17 00:00:00 2001 From: tphan025 Date: Wed, 25 Mar 2026 13:29:57 +0100 Subject: [PATCH 62/71] update env list --- haproxy-route-policy/tox.toml | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/haproxy-route-policy/tox.toml b/haproxy-route-policy/tox.toml index 327c78402..40fa551cc 100644 --- a/haproxy-route-policy/tox.toml +++ b/haproxy-route-policy/tox.toml @@ -5,7 +5,7 @@ skipsdist = true skip_missing_interpreters = true requires = ["tox>=4.21"] no_package = true -envlist = [ "lint", "unit"] +envlist = ["lint", "unit", "static", "coverage-report"] [env_run_base] passenv = ["PYTHONPATH"] @@ -69,8 +69,17 @@ dependency_groups = ["lint"] [env.static] description = "Run static analysis tests" -commands = [ [ "bandit", "-c", "{toxinidir}/pyproject.toml", "-r", "{[vars]src_path}", "{[vars]tst_path}" ] ] -dependency_groups = [ "static" ] +commands = [ + [ + "bandit", + "-c", + "{toxinidir}/pyproject.toml", + "-r", + "{[vars]src_path}", + "{[vars]tst_path}", + ], +] +dependency_groups = ["static"] [vars] src_path = "{toxinidir}/policy/" @@ -79,5 +88,5 @@ all_path = ["{toxinidir}/policy/"] [env.coverage-report] description = "Create test coverage report" -commands = [ [ "coverage", "report" ] ] -dependency_groups = [ "coverage-report" ] +commands = [["coverage", "report"]] +dependency_groups = ["coverage-report"] From 6a3b4b74a397ae3f50efec3d1a9e78f6212521a4 Mon Sep 17 00:00:00 2001 From: tphan025 Date: Thu, 19 Mar 2026 03:02:44 +0100 Subject: [PATCH 63/71] implement rule evaluation --- haproxy-route-policy/policy/rule_engine.py | 116 +++++++ .../policy/tests/test_rule_engine.py | 325 ++++++++++++++++++ .../policy/tests/test_views.py | 93 +++++ haproxy-route-policy/policy/views.py | 14 +- 4 files changed, 545 insertions(+), 3 deletions(-) create mode 100644 haproxy-route-policy/policy/rule_engine.py create mode 100644 haproxy-route-policy/policy/tests/test_rule_engine.py diff --git a/haproxy-route-policy/policy/rule_engine.py b/haproxy-route-policy/policy/rule_engine.py new file mode 100644 index 000000000..5d9d38499 --- /dev/null +++ b/haproxy-route-policy/policy/rule_engine.py @@ -0,0 +1,116 @@ +# Copyright 2026 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Rule matching engine for evaluating backend requests against rules. + +Rules are evaluated following these principles: + P1: Rules are grouped by priority and evaluated starting from the highest + priority group. + P2: Within the same priority group, "deny" rules take precedence over + "allow" rules. + +If no rules match a request, its status remains "pending". +""" + +import logging +from itertools import groupby +from policy.db_models import ( + BackendRequest, + Rule, + RULE_ACTION_ALLOW, + RULE_ACTION_DENY, + RULE_KIND_HOSTNAME_AND_PATH_MATCH, + REQUEST_STATUS_ACCEPTED, + REQUEST_STATUS_REJECTED, + REQUEST_STATUS_PENDING, +) + +logger = logging.getLogger(__name__) + + +def _hostname_and_path_match(rule: Rule, request: BackendRequest) -> bool: + """Check if a hostname_and_path_match rule matches a backend request. + + A rule matches if: + 1. Any of the rule's `hostnames` appear in the request's `hostname_acls` + if `hostnames` is not empty. + 2. Any of the rule's `paths` appear in the request's `paths` + if `paths` is not empty.. + + Args: + rule: The rule to check. + request: The backend request to evaluate. + + Returns: + True if the rule matches the request, False otherwise. + """ + rule_hostnames: list = rule.value.get("hostnames", []) + rule_paths: list = rule.value.get("paths", []) + + hostname_matched = set(request.hostname_acls).intersection(set(rule_hostnames)) + path_matched = set(request.paths).intersection(set(rule_paths)) + if not rule_hostnames and not rule_paths: + return False + if not rule_hostnames: + return bool(path_matched) + if not rule_paths: + return bool(hostname_matched) + return bool(hostname_matched) and bool(path_matched) + + +def evaluate_request(request: BackendRequest) -> str: + """Evaluate a backend request against all rules and return the resulting status. + + Rules are fetched from the database, ordered by descending priority. + They are grouped by priority level and evaluated from highest to lowest. + + Within the same priority group: + - If any "deny" rule matches, the request is rejected. + - If any "allow" rule matches (and no deny matched), the request is accepted. + - If no rules match at this priority level, move to the next group. + + If no rules match at any priority level, the request stays "pending". + + Args: + request: The backend request to evaluate. + + Returns: + The resulting status string: "accepted", "rejected", or "pending". + """ + rules = Rule.objects.all().order_by("-priority") + + for _priority, group in groupby(rules, key=lambda rule: rule.priority): + allow_matched = False + deny_matched = False + + for rule in group: + if not _matches(rule, request): + continue + + if rule.action == RULE_ACTION_DENY: + deny_matched = True + elif rule.action == RULE_ACTION_ALLOW: + allow_matched = True + + # P2: deny rules have priority over allow rules within the same priority level + if deny_matched: + return REQUEST_STATUS_REJECTED + if allow_matched: + return REQUEST_STATUS_ACCEPTED + + return REQUEST_STATUS_PENDING + + +def _matches(rule: Rule, request: BackendRequest) -> bool: + """Dispatch matching logic based on the rule kind. + + Args: + rule: The rule to evaluate. + request: The backend request to evaluate against. + + Returns: + True if the rule matches the request. + """ + if rule.kind == RULE_KIND_HOSTNAME_AND_PATH_MATCH: + return _hostname_and_path_match(rule, request) + return False diff --git a/haproxy-route-policy/policy/tests/test_rule_engine.py b/haproxy-route-policy/policy/tests/test_rule_engine.py new file mode 100644 index 000000000..edf3d11ed --- /dev/null +++ b/haproxy-route-policy/policy/tests/test_rule_engine.py @@ -0,0 +1,325 @@ +# Copyright 2026 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Unit tests for the rule matching engine.""" + +from django.test import TestCase + +from policy import db_models +from policy.rule_engine import evaluate_request, _hostname_and_path_match + + +class TestHostnameAndPathMatch(TestCase): + """Tests for the _hostname_and_path_match matching function.""" + + def _make_request(self, hostname_acls=None, paths=None): + """Create and save a BackendRequest with the given hostnames and paths.""" + return db_models.BackendRequest.objects.create( + relation_id=1, + backend_name="test-backend", + hostname_acls=hostname_acls or [], + paths=paths or [], + port=443, + ) + + def _make_rule(self, hostnames=None, paths=None, action="deny", priority=0): + """Create and save a Rule with hostname_and_path_match kind.""" + rule = db_models.Rule( + kind=db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, + value={"hostnames": hostnames or [], "paths": paths or []}, + action=action, + priority=priority, + ) + rule.save() + return rule + + def test_exact_hostname_match(self): + """Rule matches when hostnames overlap exactly.""" + rule = self._make_rule(hostnames=["example.com"]) + request = self._make_request(hostname_acls=["example.com"]) + self.assertTrue(_hostname_and_path_match(rule, request)) + + def test_hostname_no_overlap(self): + """Rule does not match when hostnames don't overlap.""" + rule = self._make_rule(hostnames=["example.com"]) + request = self._make_request(hostname_acls=["other.com"]) + self.assertFalse(_hostname_and_path_match(rule, request)) + + def test_hostname_partial_overlap(self): + """Rule matches when at least one hostname overlaps.""" + rule = self._make_rule(hostnames=["example.com", "other.com"]) + request = self._make_request(hostname_acls=["example.com", "third.com"]) + self.assertTrue(_hostname_and_path_match(rule, request)) + + def test_empty_rule_hostnames_no_match(self): + """Rule with empty hostnames never matches.""" + rule = self._make_rule(hostnames=[]) + request = self._make_request(hostname_acls=["example.com"]) + self.assertFalse(_hostname_and_path_match(rule, request)) + + def test_empty_request_hostnames_no_match(self): + """Request with empty hostname_acls doesn't match a hostname rule.""" + rule = self._make_rule(hostnames=["example.com"]) + request = self._make_request(hostname_acls=[]) + self.assertFalse(_hostname_and_path_match(rule, request)) + + def test_empty_rule_paths_matches_all_paths(self): + """Rule with empty paths list matches any request paths (wildcard).""" + rule = self._make_rule(hostnames=["example.com"], paths=[]) + request = self._make_request( + hostname_acls=["example.com"], paths=["/api", "/health"] + ) + self.assertTrue(_hostname_and_path_match(rule, request)) + + def test_empty_rule_paths_matches_empty_request_paths(self): + """Rule with empty paths matches requests with no paths.""" + rule = self._make_rule(hostnames=["example.com"], paths=[]) + request = self._make_request(hostname_acls=["example.com"], paths=[]) + self.assertTrue(_hostname_and_path_match(rule, request)) + + def test_path_overlap(self): + """Rule matches when paths overlap.""" + rule = self._make_rule(hostnames=["example.com"], paths=["/api"]) + request = self._make_request( + hostname_acls=["example.com"], paths=["/api", "/health"] + ) + self.assertTrue(_hostname_and_path_match(rule, request)) + + def test_path_no_overlap(self): + """Rule does not match when paths don't overlap.""" + rule = self._make_rule(hostnames=["example.com"], paths=["/admin"]) + request = self._make_request( + hostname_acls=["example.com"], paths=["/api", "/health"] + ) + self.assertFalse(_hostname_and_path_match(rule, request)) + + def test_rule_paths_set_but_request_paths_empty(self): + """Rule with specific paths does not match request with no paths.""" + rule = self._make_rule(hostnames=["example.com"], paths=["/api"]) + request = self._make_request(hostname_acls=["example.com"], paths=[]) + self.assertFalse(_hostname_and_path_match(rule, request)) + + def test_hostname_match_but_path_mismatch(self): + """Rule doesn't match when hostnames match but paths don't.""" + rule = self._make_rule(hostnames=["example.com"], paths=["/admin"]) + request = self._make_request( + hostname_acls=["example.com"], paths=["/api"] + ) + self.assertFalse(_hostname_and_path_match(rule, request)) + + def test_multiple_hostnames_and_paths(self): + """Rule matches with multiple hostnames and paths that overlap.""" + rule = self._make_rule( + hostnames=["example.com", "other.com"], + paths=["/api", "/v2"], + ) + request = self._make_request( + hostname_acls=["other.com"], paths=["/v2", "/health"] + ) + self.assertTrue(_hostname_and_path_match(rule, request)) + + +class TestEvaluateRequest(TestCase): + """Tests for the evaluate_request function.""" + + def _make_request(self, hostname_acls=None, paths=None): + """Create and save a BackendRequest.""" + return db_models.BackendRequest.objects.create( + relation_id=1, + backend_name="test-backend", + hostname_acls=hostname_acls or [], + paths=paths or [], + port=443, + ) + + def _make_rule(self, hostnames=None, paths=None, action="deny", priority=0): + """Create and save a hostname_and_path_match Rule.""" + rule = db_models.Rule( + kind=db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, + value={"hostnames": hostnames or [], "paths": paths or []}, + action=action, + priority=priority, + ) + rule.save() + return rule + + def test_no_rules_returns_pending(self): + """Request stays pending when no rules exist.""" + request = self._make_request(hostname_acls=["example.com"]) + self.assertEqual(evaluate_request(request), db_models.REQUEST_STATUS_PENDING) + + def test_no_matching_rules_returns_pending(self): + """Request stays pending when no rules match.""" + self._make_rule(hostnames=["other.com"], action=db_models.RULE_ACTION_DENY) + request = self._make_request(hostname_acls=["example.com"]) + self.assertEqual(evaluate_request(request), db_models.REQUEST_STATUS_PENDING) + + def test_single_allow_rule_accepts(self): + """Request is accepted when a single allow rule matches.""" + self._make_rule( + hostnames=["example.com"], action=db_models.RULE_ACTION_ALLOW + ) + request = self._make_request(hostname_acls=["example.com"]) + self.assertEqual(evaluate_request(request), db_models.REQUEST_STATUS_ACCEPTED) + + def test_single_deny_rule_rejects(self): + """Request is rejected when a single deny rule matches.""" + self._make_rule( + hostnames=["example.com"], action=db_models.RULE_ACTION_DENY + ) + request = self._make_request(hostname_acls=["example.com"]) + self.assertEqual(evaluate_request(request), db_models.REQUEST_STATUS_REJECTED) + + def test_deny_wins_over_allow_at_same_priority(self): + """Deny rule takes precedence over allow rule at the same priority.""" + self._make_rule( + hostnames=["example.com"], + action=db_models.RULE_ACTION_ALLOW, + priority=0, + ) + self._make_rule( + hostnames=["example.com"], + action=db_models.RULE_ACTION_DENY, + priority=0, + ) + request = self._make_request(hostname_acls=["example.com"]) + self.assertEqual(evaluate_request(request), db_models.REQUEST_STATUS_REJECTED) + + def test_higher_priority_evaluated_first(self): + """Higher priority rules are evaluated before lower priority ones.""" + # Priority 1: allow example.com/client + self._make_rule( + hostnames=["example.com"], + paths=["/client"], + action=db_models.RULE_ACTION_ALLOW, + priority=1, + ) + # Priority 0: deny example.com (all paths) + self._make_rule( + hostnames=["example.com"], + action=db_models.RULE_ACTION_DENY, + priority=0, + ) + request = self._make_request( + hostname_acls=["example.com"], paths=["/client"] + ) + self.assertEqual(evaluate_request(request), db_models.REQUEST_STATUS_ACCEPTED) + + def test_spec_example_client_allowed(self): + """Spec example: request for example.com/client is allowed. + + Rules: + Rule 1: deny example.com (all paths), priority=0 + Rule 2: allow example.com /api, priority=0 + Rule 3: allow example.com /client, priority=1 + """ + # Rule 1 + self._make_rule( + hostnames=["example.com"], + paths=[], + action=db_models.RULE_ACTION_DENY, + priority=0, + ) + # Rule 2 + self._make_rule( + hostnames=["example.com"], + paths=["/api"], + action=db_models.RULE_ACTION_ALLOW, + priority=0, + ) + # Rule 3 + self._make_rule( + hostnames=["example.com"], + paths=["/client"], + action=db_models.RULE_ACTION_ALLOW, + priority=1, + ) + request = self._make_request( + hostname_acls=["example.com"], paths=["/client"] + ) + self.assertEqual(evaluate_request(request), db_models.REQUEST_STATUS_ACCEPTED) + + def test_spec_example_api_denied(self): + """Spec example: request for example.com/api is denied. + + Rules: + Rule 1: deny example.com (all paths), priority=0 + Rule 2: allow example.com /api, priority=0 + Rule 3: allow example.com /client, priority=1 + """ + # Rule 1 + self._make_rule( + hostnames=["example.com"], + paths=[], + action=db_models.RULE_ACTION_DENY, + priority=0, + ) + # Rule 2 + self._make_rule( + hostnames=["example.com"], + paths=["/api"], + action=db_models.RULE_ACTION_ALLOW, + priority=0, + ) + # Rule 3 + self._make_rule( + hostnames=["example.com"], + paths=["/client"], + action=db_models.RULE_ACTION_ALLOW, + priority=1, + ) + request = self._make_request( + hostname_acls=["example.com"], paths=["/api"] + ) + self.assertEqual(evaluate_request(request), db_models.REQUEST_STATUS_REJECTED) + + def test_lower_priority_not_reached_if_higher_matches(self): + """If a higher priority group matches, lower priority groups are skipped.""" + # Priority 5: allow example.com + self._make_rule( + hostnames=["example.com"], + action=db_models.RULE_ACTION_ALLOW, + priority=5, + ) + # Priority 0: deny example.com + self._make_rule( + hostnames=["example.com"], + action=db_models.RULE_ACTION_DENY, + priority=0, + ) + request = self._make_request(hostname_acls=["example.com"]) + self.assertEqual(evaluate_request(request), db_models.REQUEST_STATUS_ACCEPTED) + + def test_only_matching_rules_affect_outcome(self): + """Non-matching rules at the same priority don't affect the result.""" + # Deny other.com at priority 0 + self._make_rule( + hostnames=["other.com"], + action=db_models.RULE_ACTION_DENY, + priority=0, + ) + # Allow example.com at priority 0 + self._make_rule( + hostnames=["example.com"], + action=db_models.RULE_ACTION_ALLOW, + priority=0, + ) + request = self._make_request(hostname_acls=["example.com"]) + self.assertEqual(evaluate_request(request), db_models.REQUEST_STATUS_ACCEPTED) + + def test_multiple_priority_groups_fallthrough(self): + """If highest priority group has no match, fall through to next.""" + # Priority 10: deny other.com (doesn't match) + self._make_rule( + hostnames=["other.com"], + action=db_models.RULE_ACTION_DENY, + priority=10, + ) + # Priority 0: allow example.com + self._make_rule( + hostnames=["example.com"], + action=db_models.RULE_ACTION_ALLOW, + priority=0, + ) + request = self._make_request(hostname_acls=["example.com"]) + self.assertEqual(evaluate_request(request), db_models.REQUEST_STATUS_ACCEPTED) diff --git a/haproxy-route-policy/policy/tests/test_views.py b/haproxy-route-policy/policy/tests/test_views.py index 7db88c79f..b2dbdb93e 100644 --- a/haproxy-route-policy/policy/tests/test_views.py +++ b/haproxy-route-policy/policy/tests/test_views.py @@ -88,6 +88,99 @@ def test_bulk_create(self): self.assertEqual(data[1]["port"], 443) self.assertEqual(db_models.BackendRequest.objects.count(), 2) + def test_bulk_create_evaluates_rules_on_creation(self): + """POST evaluates rules and sets status accordingly.""" + # Create a deny rule for example.com + db_models.Rule( + kind=db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, + value={"hostnames": ["example.com"], "paths": []}, + action=db_models.RULE_ACTION_DENY, + ).save() + payload = [ + { + "relation_id": 1, + "hostname_acls": ["example.com"], + "backend_name": "backend-1", + "paths": ["/api"], + "port": 443, + }, + ] + response = self.client.post("/api/v1/requests", data=payload, format="json") + self.assertEqual(response.status_code, 201) + data = response.json() + self.assertEqual(data[0]["status"], db_models.REQUEST_STATUS_REJECTED) + # Verify DB is updated too + self.assertEqual( + db_models.BackendRequest.objects.get(pk=data[0]["id"]).status, + db_models.REQUEST_STATUS_REJECTED, + ) + + def test_bulk_create_accepted_by_allow_rule(self): + """POST sets status to accepted when an allow rule matches.""" + db_models.Rule( + kind=db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, + value={"hostnames": ["example.com"], "paths": []}, + action=db_models.RULE_ACTION_ALLOW, + ).save() + payload = [ + { + "relation_id": 1, + "hostname_acls": ["example.com"], + "backend_name": "backend-1", + "port": 443, + }, + ] + response = self.client.post("/api/v1/requests", data=payload, format="json") + self.assertEqual(response.status_code, 201) + self.assertEqual(response.json()[0]["status"], db_models.REQUEST_STATUS_ACCEPTED) + + def test_bulk_create_pending_when_no_rules_match(self): + """POST leaves status as pending when no rules match.""" + # Rule for other.com, request for example.com + db_models.Rule( + kind=db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, + value={"hostnames": ["other.com"], "paths": []}, + action=db_models.RULE_ACTION_DENY, + ).save() + payload = [ + { + "relation_id": 1, + "hostname_acls": ["example.com"], + "backend_name": "backend-1", + "port": 443, + }, + ] + response = self.client.post("/api/v1/requests", data=payload, format="json") + self.assertEqual(response.status_code, 201) + self.assertEqual(response.json()[0]["status"], db_models.REQUEST_STATUS_PENDING) + + def test_bulk_create_mixed_statuses(self): + """POST evaluates each request independently against rules.""" + db_models.Rule( + kind=db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, + value={"hostnames": ["example.com"], "paths": []}, + action=db_models.RULE_ACTION_DENY, + ).save() + payload = [ + { + "relation_id": 1, + "hostname_acls": ["example.com"], + "backend_name": "backend-1", + "port": 443, + }, + { + "relation_id": 2, + "hostname_acls": ["other.com"], + "backend_name": "backend-2", + "port": 443, + }, + ] + response = self.client.post("/api/v1/requests", data=payload, format="json") + self.assertEqual(response.status_code, 201) + data = response.json() + self.assertEqual(data[0]["status"], db_models.REQUEST_STATUS_REJECTED) + self.assertEqual(data[1]["status"], db_models.REQUEST_STATUS_PENDING) + def test_bulk_create_rejects_non_list(self): """POST returns 400 when the body is not a list.""" response = self.client.post( diff --git a/haproxy-route-policy/policy/views.py b/haproxy-route-policy/policy/views.py index 9de7b8df1..f617e4f28 100644 --- a/haproxy-route-policy/policy/views.py +++ b/haproxy-route-policy/policy/views.py @@ -19,6 +19,7 @@ from django.db import transaction from policy import serializers from .db_models import REQUEST_STATUSES +from policy.rule_engine import evaluate_request class ListCreateRequestsView(APIView): @@ -39,7 +40,9 @@ def get(self, request): def post(self, request): """Bulk create backend requests. - All new requests are set to 'pending' (evaluation logic is deferred). + Each new request is evaluated against existing rules immediately. + If a matching rule is found, the request status is set accordingly. + If no rules match, the request stays as 'pending'. """ if not isinstance(request.data, list): return Response( @@ -55,8 +58,13 @@ def post(self, request): data=backend_request ) if serializer.is_valid(raise_exception=True): - serializer.save() - created.append(serializer.data) + instance = BackendRequest(**serializer.validated_data) + # Evaluate rules and update status + instance.status = evaluate_request(instance) + instance.save() + created.append( + serializers.BackendRequestSerializer(instance).data + ) except ValidationError as e: return Response({"error": str(e)}, status=HTTP_400_BAD_REQUEST) except IntegrityError: From c564054a2cf2a43a982ca560f85d4f0f5b7d53ff Mon Sep 17 00:00:00 2001 From: tphan025 Date: Thu, 19 Mar 2026 13:53:48 +0100 Subject: [PATCH 64/71] add change artifact --- docs/release-notes/artifacts/pr0401.yaml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 docs/release-notes/artifacts/pr0401.yaml diff --git a/docs/release-notes/artifacts/pr0401.yaml b/docs/release-notes/artifacts/pr0401.yaml new file mode 100644 index 000000000..490a4332b --- /dev/null +++ b/docs/release-notes/artifacts/pr0401.yaml @@ -0,0 +1,20 @@ +version_schema: 2 + +changes: + - title: Added rule matching engine and request evaluation on creation + author: tphan025 + type: minor + description: > + Added a rule matching engine that evaluates backend requests against rules + ordered by descending priority. Within the same priority group, deny rules + take precedence over allow rules. Integrated the engine into the bulk create + endpoint so that each new request is evaluated immediately and its status is + set to accepted, rejected, or pending accordingly. Included unit tests for the + matching logic and integration tests for rule evaluation during request creation. + urls: + pr: + - https://github.com/canonical/haproxy-operator/pull/401 + related_doc: + related_issue: + visibility: public + highlight: false From 92e6810dd151b9ba7897e3286411ffc617e8597a Mon Sep 17 00:00:00 2001 From: tphan025 Date: Thu, 19 Mar 2026 13:55:27 +0100 Subject: [PATCH 65/71] update imports --- haproxy-route-policy/policy/views.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/haproxy-route-policy/policy/views.py b/haproxy-route-policy/policy/views.py index f617e4f28..64ccfed9d 100644 --- a/haproxy-route-policy/policy/views.py +++ b/haproxy-route-policy/policy/views.py @@ -20,6 +20,7 @@ from policy import serializers from .db_models import REQUEST_STATUSES from policy.rule_engine import evaluate_request +from .serializers import BackendRequestSerializer, RuleSerializer class ListCreateRequestsView(APIView): @@ -54,17 +55,13 @@ def post(self, request): try: with transaction.atomic(): for backend_request in request.data: - serializer = serializers.BackendRequestSerializer( - data=backend_request - ) + serializer = BackendRequestSerializer(data=backend_request) if serializer.is_valid(raise_exception=True): instance = BackendRequest(**serializer.validated_data) # Evaluate rules and update status instance.status = evaluate_request(instance) instance.save() - created.append( - serializers.BackendRequestSerializer(instance).data - ) + created.append(BackendRequestSerializer(instance).data) except ValidationError as e: return Response({"error": str(e)}, status=HTTP_400_BAD_REQUEST) except IntegrityError: @@ -95,12 +92,12 @@ class ListCreateRulesView(APIView): def get(self, request): """List all rules.""" queryset = Rule.objects.all().order_by("-priority", "created_at") - serializer = serializers.RuleSerializer(queryset, many=True) + serializer = RuleSerializer(queryset, many=True) return Response(serializer.data) def post(self, request): """Create a new rule.""" - serializer = serializers.RuleSerializer(data=request.data) + serializer = RuleSerializer(data=request.data) if serializer.is_valid(raise_exception=True): serializer.save() return Response(serializer.data, status=HTTP_201_CREATED) From ed16de976fa6da51ce118c9681fb5a4d8c32e756 Mon Sep 17 00:00:00 2001 From: tphan025 Date: Mon, 23 Mar 2026 23:05:47 +0100 Subject: [PATCH 66/71] update naming --- haproxy-route-policy/policy/rule_engine.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/haproxy-route-policy/policy/rule_engine.py b/haproxy-route-policy/policy/rule_engine.py index 5d9d38499..1305de417 100644 --- a/haproxy-route-policy/policy/rule_engine.py +++ b/haproxy-route-policy/policy/rule_engine.py @@ -44,8 +44,8 @@ def _hostname_and_path_match(rule: Rule, request: BackendRequest) -> bool: Returns: True if the rule matches the request, False otherwise. """ - rule_hostnames: list = rule.value.get("hostnames", []) - rule_paths: list = rule.value.get("paths", []) + rule_hostnames: list = rule.parameters.get("hostnames", []) + rule_paths: list = rule.parameters.get("paths", []) hostname_matched = set(request.hostname_acls).intersection(set(rule_hostnames)) path_matched = set(request.paths).intersection(set(rule_paths)) From d3b5e6d10c10aa2ae4adf796da00bfc58b8c58d8 Mon Sep 17 00:00:00 2001 From: tphan025 Date: Wed, 25 Mar 2026 11:24:54 +0100 Subject: [PATCH 67/71] update rules matching logic --- haproxy-route-policy/policy/db_models.py | 2 +- .../policy/migrations/0001_initial.py | 16 +++++- .../policy/migrations/0002_rule.py | 27 +++++++++ ...ule_alter_backendrequest_paths_and_more.py | 55 ------------------- haproxy-route-policy/policy/rule_engine.py | 4 +- haproxy-route-policy/policy/views.py | 18 +++++- 6 files changed, 58 insertions(+), 64 deletions(-) create mode 100644 haproxy-route-policy/policy/migrations/0002_rule.py delete mode 100644 haproxy-route-policy/policy/migrations/0002_rule_alter_backendrequest_paths_and_more.py diff --git a/haproxy-route-policy/policy/db_models.py b/haproxy-route-policy/policy/db_models.py index 7eb294f6d..03b178c39 100644 --- a/haproxy-route-policy/policy/db_models.py +++ b/haproxy-route-policy/policy/db_models.py @@ -93,7 +93,7 @@ class BackendRequest(models.Model): hostname_acls: models.JSONField = models.JSONField( default=list, validators=[validate_hostname_acls], blank=True ) - backend_name: models.TextField = models.TextField() + backend_name: models.TextField = models.TextField(unique=True) paths: models.JSONField = models.JSONField( default=list, validators=[validate_paths], blank=True ) diff --git a/haproxy-route-policy/policy/migrations/0001_initial.py b/haproxy-route-policy/policy/migrations/0001_initial.py index 7d7a16bcd..17c86d4be 100644 --- a/haproxy-route-policy/policy/migrations/0001_initial.py +++ b/haproxy-route-policy/policy/migrations/0001_initial.py @@ -32,9 +32,19 @@ class Migration(migrations.Migration): validators=[policy.db_models.validate_hostname_acls], ), ), - ("backend_name", models.TextField()), - ("paths", models.JSONField(blank=True, default=list)), - ("port", models.IntegerField()), + ("backend_name", models.TextField(unique=True)), + ( + "paths", + models.JSONField( + blank=True, + default=list, + validators=[policy.db_models.validate_paths], + ), + ), + ( + "port", + models.IntegerField(validators=[policy.db_models.validate_port]), + ), ( "status", models.TextField( diff --git a/haproxy-route-policy/policy/migrations/0002_rule.py b/haproxy-route-policy/policy/migrations/0002_rule.py new file mode 100644 index 000000000..b428ddbaa --- /dev/null +++ b/haproxy-route-policy/policy/migrations/0002_rule.py @@ -0,0 +1,27 @@ +# Generated by Django 6.0.3 on 2026-03-24 14:42 + +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('policy', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Rule', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('kind', models.TextField(choices=[('hostname_and_path_match', 'hostname_and_path_match')])), + ('parameters', models.JSONField()), + ('action', models.TextField(choices=[('allow', 'allow'), ('deny', 'deny')])), + ('priority', models.IntegerField(blank=True, default=0)), + ('comment', models.TextField(blank=True, default='')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + ), + ] diff --git a/haproxy-route-policy/policy/migrations/0002_rule_alter_backendrequest_paths_and_more.py b/haproxy-route-policy/policy/migrations/0002_rule_alter_backendrequest_paths_and_more.py deleted file mode 100644 index 0a7b61e4f..000000000 --- a/haproxy-route-policy/policy/migrations/0002_rule_alter_backendrequest_paths_and_more.py +++ /dev/null @@ -1,55 +0,0 @@ -# Generated by Django 6.0.3 on 2026-03-23 21:53 - -import policy.db_models -import uuid -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("policy", "0001_initial"), - ] - - operations = [ - migrations.CreateModel( - name="Rule", - fields=[ - ( - "id", - models.UUIDField( - default=uuid.uuid4, - editable=False, - primary_key=True, - serialize=False, - ), - ), - ( - "kind", - models.TextField( - choices=[("hostname_and_path_match", "hostname_and_path_match")] - ), - ), - ("parameters", models.JSONField()), - ( - "action", - models.TextField(choices=[("allow", "allow"), ("deny", "deny")]), - ), - ("priority", models.IntegerField(blank=True, default=0)), - ("comment", models.TextField(blank=True, default="")), - ("created_at", models.DateTimeField(auto_now_add=True)), - ("updated_at", models.DateTimeField(auto_now=True)), - ], - ), - migrations.AlterField( - model_name="backendrequest", - name="paths", - field=models.JSONField( - blank=True, default=list, validators=[policy.db_models.validate_paths] - ), - ), - migrations.AlterField( - model_name="backendrequest", - name="port", - field=models.IntegerField(validators=[policy.db_models.validate_port]), - ), - ] diff --git a/haproxy-route-policy/policy/rule_engine.py b/haproxy-route-policy/policy/rule_engine.py index 1305de417..467d2a577 100644 --- a/haproxy-route-policy/policy/rule_engine.py +++ b/haproxy-route-policy/policy/rule_engine.py @@ -47,8 +47,8 @@ def _hostname_and_path_match(rule: Rule, request: BackendRequest) -> bool: rule_hostnames: list = rule.parameters.get("hostnames", []) rule_paths: list = rule.parameters.get("paths", []) - hostname_matched = set(request.hostname_acls).intersection(set(rule_hostnames)) - path_matched = set(request.paths).intersection(set(rule_paths)) + hostname_matched = set(request.hostname_acls).issubset(rule_hostnames) + path_matched = set(request.paths).issubset(rule_paths) if not rule_hostnames and not rule_paths: return False if not rule_hostnames: diff --git a/haproxy-route-policy/policy/views.py b/haproxy-route-policy/policy/views.py index 64ccfed9d..2f36a8ec8 100644 --- a/haproxy-route-policy/policy/views.py +++ b/haproxy-route-policy/policy/views.py @@ -26,6 +26,13 @@ class ListCreateRequestsView(APIView): """View for listing and bulk-creating backend requests.""" + def get_request_by_backend_name(self, backend_name: str) -> BackendRequest | None: + """Get a backend request by its backend name.""" + try: + return BackendRequest.objects.get(backend_name=backend_name) + except BackendRequest.DoesNotExist: + return None + def get(self, request): """List all requests, optionally filtered by status.""" status = request.GET.get("status") @@ -55,7 +62,11 @@ def post(self, request): try: with transaction.atomic(): for backend_request in request.data: - serializer = BackendRequestSerializer(data=backend_request) + # Get the request with the same backend_name if it exists and update it, otherwise create a new one + req = self.get_request_by_backend_name( + backend_request.get("backend_name") + ) + serializer = BackendRequestSerializer(req, data=backend_request) if serializer.is_valid(raise_exception=True): instance = BackendRequest(**serializer.validated_data) # Evaluate rules and update status @@ -64,9 +75,10 @@ def post(self, request): created.append(BackendRequestSerializer(instance).data) except ValidationError as e: return Response({"error": str(e)}, status=HTTP_400_BAD_REQUEST) - except IntegrityError: + except IntegrityError as e: return Response( - {"error": "Invalid request data."}, status=HTTP_400_BAD_REQUEST + {"error": f"Invalid request data: {str(e)}"}, + status=HTTP_400_BAD_REQUEST, ) return Response(created, status=HTTP_201_CREATED) From ec6d312b158b5b617bf036cc88dbcf5f837841dd Mon Sep 17 00:00:00 2001 From: tphan025 Date: Wed, 25 Mar 2026 13:29:31 +0100 Subject: [PATCH 68/71] update tests --- haproxy-route-policy/policy/rule_engine.py | 22 +++++++++++++------ .../policy/tests/test_rule_engine.py | 4 ++-- .../policy/tests/test_views.py | 8 +++---- 3 files changed, 21 insertions(+), 13 deletions(-) diff --git a/haproxy-route-policy/policy/rule_engine.py b/haproxy-route-policy/policy/rule_engine.py index 467d2a577..7856fbbbf 100644 --- a/haproxy-route-policy/policy/rule_engine.py +++ b/haproxy-route-policy/policy/rule_engine.py @@ -47,15 +47,23 @@ def _hostname_and_path_match(rule: Rule, request: BackendRequest) -> bool: rule_hostnames: list = rule.parameters.get("hostnames", []) rule_paths: list = rule.parameters.get("paths", []) - hostname_matched = set(request.hostname_acls).issubset(rule_hostnames) - path_matched = set(request.paths).issubset(rule_paths) - if not rule_hostnames and not rule_paths: - return False + # A rule with no hostnames can never match. if not rule_hostnames: - return bool(path_matched) + return False + + # At least one rule hostname must appear in the request's hostname_acls. + hostname_matched = bool( + set(rule_hostnames).intersection(request.hostname_acls) + ) + if not hostname_matched: + return False + + # Empty rule paths means "match all paths" (wildcard). if not rule_paths: - return bool(hostname_matched) - return bool(hostname_matched) and bool(path_matched) + return True + + # At least one rule path must appear in the request's paths. + return bool(set(rule_paths).intersection(request.paths)) def evaluate_request(request: BackendRequest) -> str: diff --git a/haproxy-route-policy/policy/tests/test_rule_engine.py b/haproxy-route-policy/policy/tests/test_rule_engine.py index edf3d11ed..b484a00bf 100644 --- a/haproxy-route-policy/policy/tests/test_rule_engine.py +++ b/haproxy-route-policy/policy/tests/test_rule_engine.py @@ -26,7 +26,7 @@ def _make_rule(self, hostnames=None, paths=None, action="deny", priority=0): """Create and save a Rule with hostname_and_path_match kind.""" rule = db_models.Rule( kind=db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, - value={"hostnames": hostnames or [], "paths": paths or []}, + parameters={"hostnames": hostnames or [], "paths": paths or []}, action=action, priority=priority, ) @@ -136,7 +136,7 @@ def _make_rule(self, hostnames=None, paths=None, action="deny", priority=0): """Create and save a hostname_and_path_match Rule.""" rule = db_models.Rule( kind=db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, - value={"hostnames": hostnames or [], "paths": paths or []}, + parameters={"hostnames": hostnames or [], "paths": paths or []}, action=action, priority=priority, ) diff --git a/haproxy-route-policy/policy/tests/test_views.py b/haproxy-route-policy/policy/tests/test_views.py index b2dbdb93e..a346e4b7b 100644 --- a/haproxy-route-policy/policy/tests/test_views.py +++ b/haproxy-route-policy/policy/tests/test_views.py @@ -93,7 +93,7 @@ def test_bulk_create_evaluates_rules_on_creation(self): # Create a deny rule for example.com db_models.Rule( kind=db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, - value={"hostnames": ["example.com"], "paths": []}, + parameters={"hostnames": ["example.com"], "paths": []}, action=db_models.RULE_ACTION_DENY, ).save() payload = [ @@ -119,7 +119,7 @@ def test_bulk_create_accepted_by_allow_rule(self): """POST sets status to accepted when an allow rule matches.""" db_models.Rule( kind=db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, - value={"hostnames": ["example.com"], "paths": []}, + parameters={"hostnames": ["example.com"], "paths": []}, action=db_models.RULE_ACTION_ALLOW, ).save() payload = [ @@ -139,7 +139,7 @@ def test_bulk_create_pending_when_no_rules_match(self): # Rule for other.com, request for example.com db_models.Rule( kind=db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, - value={"hostnames": ["other.com"], "paths": []}, + parameters={"hostnames": ["other.com"], "paths": []}, action=db_models.RULE_ACTION_DENY, ).save() payload = [ @@ -158,7 +158,7 @@ def test_bulk_create_mixed_statuses(self): """POST evaluates each request independently against rules.""" db_models.Rule( kind=db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, - value={"hostnames": ["example.com"], "paths": []}, + parameters={"hostnames": ["example.com"], "paths": []}, action=db_models.RULE_ACTION_DENY, ).save() payload = [ From 93ce0f4553f925b37b2666fde82d6471357b45bd Mon Sep 17 00:00:00 2001 From: tphan025 Date: Thu, 26 Mar 2026 13:29:40 +0100 Subject: [PATCH 69/71] save request using serializer with the correct instace --- haproxy-route-policy/policy/views.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/haproxy-route-policy/policy/views.py b/haproxy-route-policy/policy/views.py index 2f36a8ec8..b4483518c 100644 --- a/haproxy-route-policy/policy/views.py +++ b/haproxy-route-policy/policy/views.py @@ -5,7 +5,6 @@ from policy.db_models import BackendRequest, Rule from typing import Type -from venv import logger from rest_framework.views import APIView from rest_framework.response import Response from rest_framework.status import ( @@ -68,11 +67,13 @@ def post(self, request): ) serializer = BackendRequestSerializer(req, data=backend_request) if serializer.is_valid(raise_exception=True): - instance = BackendRequest(**serializer.validated_data) # Evaluate rules and update status - instance.status = evaluate_request(instance) - instance.save() - created.append(BackendRequestSerializer(instance).data) + serializer.save( + status=evaluate_request( + BackendRequest(**serializer.validated_data) + ) + ) + created.append(serializer.data) except ValidationError as e: return Response({"error": str(e)}, status=HTTP_400_BAD_REQUEST) except IntegrityError as e: @@ -142,7 +143,6 @@ def delete(self, request, pk): def get_object(object_class: Type[Rule] | Type[BackendRequest], pk: str): try: - logger.info(f"Fetching object with ID: {pk}") return object_class.objects.get(pk=pk) except object_class.DoesNotExist: raise Http404 From 3e3a0a960acea8bb47733afd7f56d1990532885b Mon Sep 17 00:00:00 2001 From: tphan025 Date: Thu, 26 Mar 2026 15:13:07 +0100 Subject: [PATCH 70/71] group tests --- .../policy/tests/test_views.py | 175 +++++++++--------- 1 file changed, 86 insertions(+), 89 deletions(-) diff --git a/haproxy-route-policy/policy/tests/test_views.py b/haproxy-route-policy/policy/tests/test_views.py index a346e4b7b..f71a499ca 100644 --- a/haproxy-route-policy/policy/tests/test_views.py +++ b/haproxy-route-policy/policy/tests/test_views.py @@ -88,98 +88,95 @@ def test_bulk_create(self): self.assertEqual(data[1]["port"], 443) self.assertEqual(db_models.BackendRequest.objects.count(), 2) - def test_bulk_create_evaluates_rules_on_creation(self): - """POST evaluates rules and sets status accordingly.""" - # Create a deny rule for example.com - db_models.Rule( - kind=db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, - parameters={"hostnames": ["example.com"], "paths": []}, - action=db_models.RULE_ACTION_DENY, - ).save() - payload = [ - { - "relation_id": 1, - "hostname_acls": ["example.com"], - "backend_name": "backend-1", - "paths": ["/api"], - "port": 443, - }, - ] - response = self.client.post("/api/v1/requests", data=payload, format="json") - self.assertEqual(response.status_code, 201) - data = response.json() - self.assertEqual(data[0]["status"], db_models.REQUEST_STATUS_REJECTED) - # Verify DB is updated too - self.assertEqual( - db_models.BackendRequest.objects.get(pk=data[0]["id"]).status, - db_models.REQUEST_STATUS_REJECTED, - ) - - def test_bulk_create_accepted_by_allow_rule(self): - """POST sets status to accepted when an allow rule matches.""" - db_models.Rule( - kind=db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, - parameters={"hostnames": ["example.com"], "paths": []}, - action=db_models.RULE_ACTION_ALLOW, - ).save() - payload = [ - { - "relation_id": 1, - "hostname_acls": ["example.com"], - "backend_name": "backend-1", - "port": 443, - }, + def test_evaluate_requests(self): + """POST evaluates rules and sets status accordingly for each request.""" + cases = [ + ( + "denied by matching deny rule", + {"hostnames": ["example.com"], "paths": []}, + db_models.RULE_ACTION_DENY, + [ + { + "relation_id": 1, + "hostname_acls": ["example.com"], + "backend_name": "backend-1", + "paths": ["/api"], + "port": 443, + }, + ], + [db_models.REQUEST_STATUS_REJECTED], + ), + ( + "accepted by matching allow rule", + {"hostnames": ["example.com"], "paths": []}, + db_models.RULE_ACTION_ALLOW, + [ + { + "relation_id": 1, + "hostname_acls": ["example.com"], + "backend_name": "backend-1", + "port": 443, + }, + ], + [db_models.REQUEST_STATUS_ACCEPTED], + ), + ( + "pending when no rules match", + {"hostnames": ["other.com"], "paths": []}, + db_models.RULE_ACTION_DENY, + [ + { + "relation_id": 1, + "hostname_acls": ["example.com"], + "backend_name": "backend-1", + "port": 443, + }, + ], + [db_models.REQUEST_STATUS_PENDING], + ), + ( + "mixed statuses per request", + {"hostnames": ["example.com"], "paths": []}, + db_models.RULE_ACTION_DENY, + [ + { + "relation_id": 1, + "hostname_acls": ["example.com"], + "backend_name": "backend-1", + "port": 443, + }, + { + "relation_id": 2, + "hostname_acls": ["other.com"], + "backend_name": "backend-2", + "port": 443, + }, + ], + [ + db_models.REQUEST_STATUS_REJECTED, + db_models.REQUEST_STATUS_PENDING, + ], + ), ] - response = self.client.post("/api/v1/requests", data=payload, format="json") - self.assertEqual(response.status_code, 201) - self.assertEqual(response.json()[0]["status"], db_models.REQUEST_STATUS_ACCEPTED) + for label, rule_params, rule_action, payload, expected_statuses in cases: + with self.subTest(label=label): + # Clean slate for each sub-test + db_models.Rule.objects.all().delete() + db_models.BackendRequest.objects.all().delete() - def test_bulk_create_pending_when_no_rules_match(self): - """POST leaves status as pending when no rules match.""" - # Rule for other.com, request for example.com - db_models.Rule( - kind=db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, - parameters={"hostnames": ["other.com"], "paths": []}, - action=db_models.RULE_ACTION_DENY, - ).save() - payload = [ - { - "relation_id": 1, - "hostname_acls": ["example.com"], - "backend_name": "backend-1", - "port": 443, - }, - ] - response = self.client.post("/api/v1/requests", data=payload, format="json") - self.assertEqual(response.status_code, 201) - self.assertEqual(response.json()[0]["status"], db_models.REQUEST_STATUS_PENDING) + db_models.Rule( + kind=db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, + parameters=rule_params, + action=rule_action, + ).save() - def test_bulk_create_mixed_statuses(self): - """POST evaluates each request independently against rules.""" - db_models.Rule( - kind=db_models.RULE_KIND_HOSTNAME_AND_PATH_MATCH, - parameters={"hostnames": ["example.com"], "paths": []}, - action=db_models.RULE_ACTION_DENY, - ).save() - payload = [ - { - "relation_id": 1, - "hostname_acls": ["example.com"], - "backend_name": "backend-1", - "port": 443, - }, - { - "relation_id": 2, - "hostname_acls": ["other.com"], - "backend_name": "backend-2", - "port": 443, - }, - ] - response = self.client.post("/api/v1/requests", data=payload, format="json") - self.assertEqual(response.status_code, 201) - data = response.json() - self.assertEqual(data[0]["status"], db_models.REQUEST_STATUS_REJECTED) - self.assertEqual(data[1]["status"], db_models.REQUEST_STATUS_PENDING) + response = self.client.post( + "/api/v1/requests", data=payload, format="json" + ) + self.assertEqual(response.status_code, 201) + data = response.json() + actual_statuses = [r["status"] for r in data] + self.assertEqual(actual_statuses, expected_statuses) def test_bulk_create_rejects_non_list(self): """POST returns 400 when the body is not a list.""" From 60cc53ffd34f90875ef7c9344176b150eb59f1bc Mon Sep 17 00:00:00 2001 From: tphan025 Date: Thu, 26 Mar 2026 15:16:47 +0100 Subject: [PATCH 71/71] update formatting --- .../policy/migrations/0002_rule.py | 37 +++++++++++++------ haproxy-route-policy/policy/rule_engine.py | 4 +- .../policy/tests/test_rule_engine.py | 24 +++--------- 3 files changed, 33 insertions(+), 32 deletions(-) diff --git a/haproxy-route-policy/policy/migrations/0002_rule.py b/haproxy-route-policy/policy/migrations/0002_rule.py index b428ddbaa..4f3fa0ae6 100644 --- a/haproxy-route-policy/policy/migrations/0002_rule.py +++ b/haproxy-route-policy/policy/migrations/0002_rule.py @@ -5,23 +5,38 @@ class Migration(migrations.Migration): - dependencies = [ - ('policy', '0001_initial'), + ("policy", "0001_initial"), ] operations = [ migrations.CreateModel( - name='Rule', + name="Rule", fields=[ - ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), - ('kind', models.TextField(choices=[('hostname_and_path_match', 'hostname_and_path_match')])), - ('parameters', models.JSONField()), - ('action', models.TextField(choices=[('allow', 'allow'), ('deny', 'deny')])), - ('priority', models.IntegerField(blank=True, default=0)), - ('comment', models.TextField(blank=True, default='')), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + "kind", + models.TextField( + choices=[("hostname_and_path_match", "hostname_and_path_match")] + ), + ), + ("parameters", models.JSONField()), + ( + "action", + models.TextField(choices=[("allow", "allow"), ("deny", "deny")]), + ), + ("priority", models.IntegerField(blank=True, default=0)), + ("comment", models.TextField(blank=True, default="")), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), ], ), ] diff --git a/haproxy-route-policy/policy/rule_engine.py b/haproxy-route-policy/policy/rule_engine.py index 7856fbbbf..c59a27535 100644 --- a/haproxy-route-policy/policy/rule_engine.py +++ b/haproxy-route-policy/policy/rule_engine.py @@ -52,9 +52,7 @@ def _hostname_and_path_match(rule: Rule, request: BackendRequest) -> bool: return False # At least one rule hostname must appear in the request's hostname_acls. - hostname_matched = bool( - set(rule_hostnames).intersection(request.hostname_acls) - ) + hostname_matched = bool(set(rule_hostnames).intersection(request.hostname_acls)) if not hostname_matched: return False diff --git a/haproxy-route-policy/policy/tests/test_rule_engine.py b/haproxy-route-policy/policy/tests/test_rule_engine.py index b484a00bf..03666b170 100644 --- a/haproxy-route-policy/policy/tests/test_rule_engine.py +++ b/haproxy-route-policy/policy/tests/test_rule_engine.py @@ -102,9 +102,7 @@ def test_rule_paths_set_but_request_paths_empty(self): def test_hostname_match_but_path_mismatch(self): """Rule doesn't match when hostnames match but paths don't.""" rule = self._make_rule(hostnames=["example.com"], paths=["/admin"]) - request = self._make_request( - hostname_acls=["example.com"], paths=["/api"] - ) + request = self._make_request(hostname_acls=["example.com"], paths=["/api"]) self.assertFalse(_hostname_and_path_match(rule, request)) def test_multiple_hostnames_and_paths(self): @@ -156,17 +154,13 @@ def test_no_matching_rules_returns_pending(self): def test_single_allow_rule_accepts(self): """Request is accepted when a single allow rule matches.""" - self._make_rule( - hostnames=["example.com"], action=db_models.RULE_ACTION_ALLOW - ) + self._make_rule(hostnames=["example.com"], action=db_models.RULE_ACTION_ALLOW) request = self._make_request(hostname_acls=["example.com"]) self.assertEqual(evaluate_request(request), db_models.REQUEST_STATUS_ACCEPTED) def test_single_deny_rule_rejects(self): """Request is rejected when a single deny rule matches.""" - self._make_rule( - hostnames=["example.com"], action=db_models.RULE_ACTION_DENY - ) + self._make_rule(hostnames=["example.com"], action=db_models.RULE_ACTION_DENY) request = self._make_request(hostname_acls=["example.com"]) self.assertEqual(evaluate_request(request), db_models.REQUEST_STATUS_REJECTED) @@ -200,9 +194,7 @@ def test_higher_priority_evaluated_first(self): action=db_models.RULE_ACTION_DENY, priority=0, ) - request = self._make_request( - hostname_acls=["example.com"], paths=["/client"] - ) + request = self._make_request(hostname_acls=["example.com"], paths=["/client"]) self.assertEqual(evaluate_request(request), db_models.REQUEST_STATUS_ACCEPTED) def test_spec_example_client_allowed(self): @@ -234,9 +226,7 @@ def test_spec_example_client_allowed(self): action=db_models.RULE_ACTION_ALLOW, priority=1, ) - request = self._make_request( - hostname_acls=["example.com"], paths=["/client"] - ) + request = self._make_request(hostname_acls=["example.com"], paths=["/client"]) self.assertEqual(evaluate_request(request), db_models.REQUEST_STATUS_ACCEPTED) def test_spec_example_api_denied(self): @@ -268,9 +258,7 @@ def test_spec_example_api_denied(self): action=db_models.RULE_ACTION_ALLOW, priority=1, ) - request = self._make_request( - hostname_acls=["example.com"], paths=["/api"] - ) + request = self._make_request(hostname_acls=["example.com"], paths=["/api"]) self.assertEqual(evaluate_request(request), db_models.REQUEST_STATUS_REJECTED) def test_lower_priority_not_reached_if_higher_matches(self):