When you change code patterns, conventions, or architecture, update this file in the same commit. AGENTS.md is the single source of truth for project conventions — if it's stale, the next agent will follow wrong patterns.
controller/
rootfs/
api/ # Django application
models/ # Django models (one file per domain)
views/ # DRF ViewSets (one file per domain)
serializers/ # DRF serializers (__init__.py contains all)
migrations/ # Django migrations
tests/ # Unit tests (test_*.py)
settings/ # Django settings (production.py, testing.py)
clients.py # External service API clients
exceptions.py # Custom exception classes
urls.py # URL routing
viewsets.py # Base ViewSet classes
utils.py # Helper functions
tasks.py # Celery tasks
scheduler/ # Kubernetes API client
__init__.py # KubeHTTPClient (base HTTP layer)
resources/ # K8s resource implementations
exceptions.py # KubeException, KubeHTTPException
manage.py # Django management script
requirements.txt # Python dependencies
setup.cfg # flake8 config
Makefile # Build/test commands
- Indentation: 4 spaces (see
.editorconfig) - Line endings: LF
- Line length: 99 characters max (see
setup.cfgflake8) - Complexity: max 12 (flake8)
- Formatter: None configured — follow existing file style exactly
- Quotes: Single quotes preferred, double quotes for docstrings
- Trailing commas: Follow existing file patterns
api/migrations, templates, venv — never lint these.
UuidAuditedModel— UUID primary key +created/updatedtimestamps (most models)AuditedModel— Justcreated/updatedtimestamps
- Primary key (uuid from base class, or custom like
id = models.SlugField) - Business fields
- Foreign keys
- JSON fields
- Meta class last
save(*args, **kwargs)— Override for K8s resource creation. Callsuper().save()first, then K8s ops if creating (self._state.adding).delete(*args, **kwargs)— Override for K8s cleanup. Clean K8s first, thensuper().delete().schedulerproperty — Returnsget_scheduler(metadata={...})with labels/annotations. Use this for all K8s operations.- Helper methods prefixed with
_(private). - Business logic methods as instance methods on the model (NO separate Service classes).
- Module-level
validate_*functions, used in fieldvalidators=[] - Raise
rest_framework.exceptions.ValidationError
class MyModel(UuidAuditedModel):
name = models.CharField(max_length=63)
workspace = models.ForeignKey("Workspace", on_delete=models.CASCADE)
class Meta:
unique_together = [("workspace", "name")]
def __str__(self):
return self.name
@property
def scheduler(self):
labels = {"drycc.cc/workspace": self.workspace.id}
return get_scheduler(metadata={"labels": labels, "annotations": dict(labels)})
def save(self, *args, **kwargs):
creating = self._state.adding
if creating:
# validate, set derived fields
pass
super().save(*args, **kwargs)
if creating:
# K8s operations via self.scheduler
pass
def delete(self, *args, **kwargs):
# K8s cleanup first
try:
self.scheduler.something.delete(...)
except KubeException:
pass
return super().delete(*args, **kwargs)
def update_something(self, params):
# business logic + K8s update
passModelViewSet— Standard CRUDAppFilterViewSet— App-scoped resources (nested under apps)- Views are split into separate files under
api/views/
class MyModelViewSet(ModelViewSet):
lookup_field = 'name' # or 'id'
lookup_value_regex = r'[a-z0-9]([a-z0-9-]*[a-z0-9])?'
serializer_class = serializers.MyModelSerializer
permission_classes = [IsAuthenticated]
def get_queryset(self):
queryset = models.mymodel.MyModel.objects.filter(
workspace__workspacemember__user=self.request.user).distinct()
workspace = self.request.query_params.get('workspace')
if workspace:
workspace_obj = get_object_or_404(
models.workspace.Workspace, id=workspace)
if not workspace_obj.has_member(self.request.user):
raise PermissionDenied(
f"You are not a member of workspace '{workspace_obj.id}'")
queryset = queryset.filter(workspace=workspace_obj)
return queryset
def get_object(self):
return get_object_or_404(self.get_queryset(), name=self.kwargs['name'])
def create(self, request, *args, **kwargs):
# Extract workspace from request.data['workspace']
# Create model instance directly, call instance.save()
# Return serialized response
pass
def update(self, request, *args, **kwargs):
instance = self.get_object()
instance.update_something(request.data)
serializer = self.get_serializer(instance)
return Response(serializer.data)
def destroy(self, request, *args, **kwargs):
instance = self.get_object()
instance.delete()
return Response(status=status.HTTP_204_NO_CONTENT)Workspace management is flat. Resources are NOT nested under /workspaces/{id}/.
Instead, they live at top-level endpoints and filter via ?workspace= query param:
GET /v2/addons?workspace=myworkspace— list filtered by workspacePOST /v2/addonswith{"workspace": "myworkspace", ...}— create in workspace
See AppViewSet.list() for the reference pattern.
All business logic goes on the model itself. The view calls model methods directly.
This is the established pattern — see App.create(), App.delete(), etc.
DryccException("message")— 400 status, business errorsValidationError("message")— 400 status, validation errorsAlreadyExists— 409 statusPermissionDenied("message")— 403 statusget_object_or_404()— 404 status
DO NOT create domain-specific exception classes (e.g., AddonNotFound). Use DryccException with descriptive messages.
All serializers live in api/serializers/__init__.py.
class MyModelSerializer(serializers.ModelSerializer):
workspace = serializers.ReadOnlyField(source='workspace.id')
class Meta:
model = models.mymodel.MyModel
fields = ['uuid', 'name', 'workspace', 'created', 'updated']
read_only_fields = ['uuid', 'created', 'updated']The API uses snake_case for all field names. K8s data stored in JSONFields uses camelCase internally (matching K8s API conventions), but is exposed as snake_case in API responses.
CamelCaseJSONField (in api/serializers/__init__.py, extends JSONFieldSerializer):
- Input (
to_internal_value): converts snake_case keys to camelCase before validation and DB storage. Both snake_case and camelCase input are accepted. - Output (
to_representation): converts camelCase keys to snake_case for API responses. - DB storage: camelCase (Python code and K8s API calls use camelCase unchanged).
- Conversion functions (
camelize,underscoreize, etc.) live inapi/utils.py.
Use CamelCaseJSONField for any JSONField that contains K8s camelCase data:
class RouteSerializer(serializers.ModelSerializer):
rules = CamelCaseJSONField(convert_to_str=False, required=True)
parent_refs = serializers.JSONField(required=True) # single-word keys, no conversion neededFor serializers that read from K8s CRD/API (not Django models), use Serializer
with source + CamelCaseJSONField:
class AddonClassSerializer(serializers.Serializer):
name = serializers.CharField(source='metadata.name', required=False)
plans = CamelCaseJSONField(source='spec.plans', required=False)When to use CamelCaseJSONField (K8s data with camelCase keys inside):
Route.rules- K8s Gateway API (backendRefs,requestRedirect, etc.)Config.healthcheck- K8s probes (livenessProbe,httpGet, etc.)Config.lifecycle- K8s lifecycle (postStart,preStop, etc.)AddonInstance.parameters- user/Helm parameters (may have camelCase keys)PodDetailSerializer.state/last_state- K8s container stateDeploymentDetailSerializer.liveness_probe/readiness_probe/volume_mounts
When NOT to use CamelCaseJSONField (single-word keys or non-K8s data):
Gateway.ports- keys areport,protocol(single words)Route.parent_refs- keys arename,port(single words)Config.values- keys arename,value,ptype(single words)Config.tags- K8s labels (dots/hyphens, not camelCase)AppSettings.autoscale- already snake_case (cpu_percent), HPA code converts
URLs use re_path in api/urls.py. Workspace-managed resources are flat at top-level:
re_path(r'^addons/?$',
views.AddonInstanceViewSet.as_view({'get': 'list', 'post': 'create'}),
name='addon_list'),
re_path(r'^addons/(?P<name>[-_\w]+)/?$',
views.AddonInstanceViewSet.as_view(
{'get': 'retrieve', 'put': 'update', 'patch': 'update', 'delete': 'destroy'}),
name='addon_detail'),Workspace sub-resources (members, invitations) are nested under /workspaces/{id}/:
re_path(r'^workspaces/(?P<id>[-_\w]+)/members/?$',
views.WorkspaceMemberViewSet.as_view({'get': 'list'}),
name='workspace_member_list'),- Item lookup uses
namekwarg (notid) - Trailing
/?for optional slash - Flat resources filter via
?workspace=query param
All resources in scheduler/resources/ extend Resource base class:
class MyResource(Resource):
api_prefix = 'apis' # or 'api' for core v1
api_version = 'group/v1'
short_name = 'myresources' # optional, for KubeHTTPClient mapping
def get(self, namespace, name=None, ignore_exception=True, **kwargs):
url = self.api("/namespaces/{}/myresource/{}", namespace, name)
response = self.http_get(url, params=self.query_params(**kwargs))
if not ignore_exception and self.unhealthy(response.status_code):
raise KubeHTTPException(response, 'get MyResource ' + name)
return response
def create(self, namespace, name, ignore_exception=True, **kwargs):
manifest = kwargs["manifest"]
url = self.api("/namespaces/{}/myresource", namespace)
response = self.http_post(url, json=manifest)
if not ignore_exception and self.unhealthy(response.status_code):
raise KubeHTTPException(response, 'create MyResource "{}"', name)
return responseNamespaced resources:
def get(self, namespace, name=None, ignore_exception=True, **kwargs)
def create(self, namespace, name, ignore_exception=True, **kwargs)
def put(self, namespace, name, ignore_exception=True, **kwargs)
def patch(self, namespace, name, ignore_exception=True, **kwargs)
def delete(self, namespace, name, ignore_exception=True, **kwargs)Cluster-scoped resources (e.g. AddonClass, Node, Namespace) omit namespace:
def get(self, name=None, ignore_exception=True, **kwargs)
def create(self, name, ignore_exception=True, **kwargs)
def delete(self, name, ignore_exception=True, **kwargs)namespace— first positional arg (namespaced resources only)name— positional arg (optional forget)ignore_exception— controls error raising**kwargs— for resource-specific parameters (manifest, kind, etc.)
NEVER create a separate list() method. When name=None, get() returns a list of all resources; when name is provided, it returns a single resource.
def get(self, name=None, ignore_exception=True):
url = self.api("/addonclasses")
args = []
if name is not None:
args.append(name)
url += "/{}"
message = 'get AddonClass "{}"'
else:
message = 'get AddonClasses'
url = self.api(url, *args)
response = self.http_get(url)
if not ignore_exception and self.unhealthy(response.status_code):
raise KubeHTTPException(response, message, *args)
return responseWhen a kwarg is required (e.g. kind for generic resource handlers), validate explicitly and raise KubeException:
kind = kwargs.pop("kind", None)
if kind is None:
raise KubeException("kind is required for AddonResource operations")NEVER put resource-specific params like kind, manifest as positional args. Use **kwargs.
Use kwargs.pop() if the value should NOT be passed to K8s query_params (e.g., kind).
Use kwargs.get() if it's fine to pass through.
Resources are auto-registered and accessible via scheduler_client.<plural_name>:
scheduler.deployments→ Deployment resourcescheduler.addonresources→ AddonResource (generic Crossplane composite resource handler,kindrequired)scheduler.addonclasses→ AddonClass resource
KubeException— General K8s errorKubeHTTPException— HTTP-level K8s error (has.responseattribute)ignore_exception=True/False— Controls whether errors raise or are suppressed
- Manual migration files in
api/migrations/ - Numbered sequentially:
0033_xxx.py - If
makemigrationsfails due to serialization issues (e.g., complex validators), write the migration manually - Dependencies:
('api', '0032_previous')
DryccTestCase— TransactionTestCase with K8s mockingDryccTransactionTestCase— TransactionTestCase variant- Both use
@requests_mock.Mocker(real_http=True, adapter=adapter)decorator
@requests_mock.Mocker(real_http=True, adapter=adapter)
class MyModelTest(DryccTestCase):
fixtures = ['tests.json']
def setUp(self):
self.user = User.objects.get(username='autotest')
self.token = self.get_or_create_token(self.user)
self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
def test_something(self, mock_requests):
# test implementation
passLocal testing: Use the project's .venv at the project root.
cd rootfs
../.venv/bin/python manage.py test --settings=api.settings.testing --noinput api scheduler.testsMakefile targets: (run inside podman container with test image)
make test # style + unit + functional
make test-style # flake8 + shellcheck
make test-unit # Django unit tests- Write tests alongside code. Every new feature, model, view, or serializer must have corresponding test cases in
api/tests/test_<name>.py. - Do not commit code without tests. If you implement a new endpoint, model method, or scheduler resource, add tests that verify the behavior.
- Run tests locally before reporting completion. Use the
.venvPython interpreter from the project root to runapi.scheduler.tests.
# Standard library (alphabetical)
import logging
import re
import uuid
# Third-party
from django.db import models
from rest_framework.exceptions import ValidationError
# Internal - api package
from api.utils import validate_reserved_names, get_scheduler
from api.exceptions import DryccException, AlreadyExists
from scheduler import KubeHTTPException, KubeException
# Internal - relative (models only)
from .base import UuidAuditedModel
# User model
User = get_user_model()
logger = logging.getLogger(__name__)Workspace.namespaceproperty returnsf"{settings.WORKSPACE_NAMESPACE_PREFIX}{self.id}"- Default prefix:
drycc-workspace- - Configurable via
WORKSPACE_NAMESPACE_PREFIXenv var - Use
workspace.namespacefor all K8s namespace references
- Django 5.2.x
- Django REST Framework 3.16.x
- PostgreSQL (via psycopg)
- Celery + Redis
- Kubernetes Python client
- requests + requests-toolbelt
- jsonschema for validation
| Setting | Default | Purpose |
|---|---|---|
WORKFLOW_NAMESPACE |
drycc |
Controller's own namespace |
WORKSPACE_NAMESPACE_PREFIX |
drycc-workspace- |
Workspace namespace prefix |
SCHEDULER_MODULE |
scheduler |
K8s client module |
SCHEDULER_URL |
from env | K8s API URL |
K8S_API_VERIFY_TLS |
true |
TLS verification |
APP_URL_REGEX |
[a-z0-9-]+ |
App name URL pattern |
NAME_REGEX |
[a-z0-9]+(\-[a-z0-9]+)* |
Resource name URL pattern |