Django and AWS: Keeping the Cloud Out of Reach of Application Code and Tests

Smarter is a Django platform whose apps never call the AWS SDK or kubectl themselves. Every DNS record, TLS certificate, Kubernetes manifest and email goes through one service layer, smarter.apps.infrastructure, and every real backend in that layer refuses to run while Django’s test runner is running. This post describes that layer, the guard inside it, the in-memory cloud that the tests use instead, and what the arrangement does and doesn’t protect against.

The reason it exists is mundane. Smarter’s development containers are not sandboxes. The smarter-app and Celery worker containers carry a kubeconfig for a real EKS cluster and AWS credentials that can create and delete Route 53 hosted zones, ACM certificates and EKS managed node groups, because that is what the platform does for a living. It deploys each chatbot with its own DNS record, ingress and certificate. A developer, or an AI coding agent, who runs manage.py test in those containers is one unmocked call away from real, billable infrastructure. The project’s infrastructure-safety skill records the case that made this concrete. An unguarded test once created two real EKS node groups, one of them a GPU instance.

One door to the cloud

Application code reaches its infrastructure through a single object, infrastructure, whose properties each return a service.

from smarter.apps.infrastructure.services import infrastructure

zone, created = infrastructure.dns.get_or_create_zone("example.com")
certificate_id, _ = infrastructure.certificates.get_or_create_certificate("example.com")
infrastructure.kubernetes.apply_manifest(manifest)
infrastructure.email.send_email(subject="Hello", body="...", to="user@example.com")

That excerpt is the module docstring of services/__init__.py. Behind each property is an abstract service class (DNSService, CertificateService, KubernetesService, EmailService) and a cloud provider that implements the cloud-specific ones. The AWS provider is the one that imports boto3 and talks to Route 53 and ACM. Kubernetes is implemented once, with kubectl, and the provider contributes only the kubeconfig and a bearer token.

Three properties of the facade matter for safety.

First, services are resolved when they are used, not when the module is imported. Importing the platform never reaches the network, and a test can swap the provider before anything asks for it.

Second, provider errors never leak upward. Each service wraps its SDK calls in an operation() context manager that re-raises any foreign exception as the service’s own error class and sends an infrastructure_operation_failed signal. Application code catches SmarterInfrastructureError, never a botocore exception, so there is no reason for it to import botocore.

Third, there is one place to read. The question “where can this codebase change our AWS account?” has a short answer, smarter/smarter/apps/infrastructure/providers/, plus the EKS node-group backend in llmhost, which borrows the AWS provider’s session rather than creating its own. The project’s rule for contributors is that a new cloud is a new provider, never SDK calls in an app.

One exception is worth naming so the claim isn’t overstated. The settings layer, which loads before Django, uses boto3 directly for two read-only checks, namely whether credentials resolve (sts get-caller-identity) and, when enabled, the list of AWS regions. Neither can create anything, but they are outside the facade and outside its guard.

The guard at the door

The guard is a few lines in services/base.py.

def refuse_in_unit_tests(what: str, allow_in_tests: bool = False) -> None:
    if running_unit_tests() and not allow_in_tests:
        raise InfrastructureConfigurationError(
            f"Refusing to reach {what} from the unit tests. Install a fake, e.g. with "
            "smarter.apps.infrastructure.providers.configure_provider(), or pass allow_in_tests=True."
        )

running_unit_tests() is deliberately crude. It returns "test" in sys.argv, which is true exactly when the process is manage.py test. There is no setting to forget and no environment variable to leak into production.

The guard sits at the last step before each real backend, not at the top of each feature.

  • The AWS provider calls it in require_live(), which runs before the boto3 session is created and before the EKS helper is built. Its ready property catches the refusal and answers False, so code that checks readiness first simply sees an unavailable cloud.
  • The kubectl service calls it at the top of _kubectl(), the one method that spawns the kubectl process.
  • The SMTP email service reports itself not ready in the tests, so mail is logged as not sent rather than delivered.
  • The EKS node-group backend in llmhost raises its own error before it asks for a session, with a comment that says why, “the platform’s AWS credentials are real, and node groups cost money.”
  • The LLM provider proxy refuses to build a real transport in the tests, so a test can’t send a billable request to OpenAI or Anthropic through it by accident.

Placing the guard at the backend rather than in each caller is the point. Feature code, tasks and management commands don’t need to remember anything. A new code path that reaches Route 53 hits the same require_live() as every old one.

Feature code, Celery tasks and management commands reach the cloud through the infrastructure facade (.dns, .certificates, .kubernetes, .email). Each real backend has a guard at its last step, namely require_live() in the AWS provider, the top of _kubectl() in the kubectl service, SMTP email not ready in tests, and, outside the facade, the llmhost EKS node-group backend and the LLM provider proxy. In manage.py test every guard refuses, and tests use InMemoryProvider, configure_kubernetes() and configure_email().

A fake cloud that behaves like one

A guard that only raises would leave the tests with nothing to run against. The other half is providers/memory.py, an InMemoryProvider whose DNS zones, records and certificates live in dictionaries and behave like a cloud’s. It is not a stub that returns success. Its certificate service issues a certificate only once the certificate’s validation record exists in the zone, the way ACM’s DNS validation works.

def _validated(self, certificate: Certificate) -> bool:
    zone = self.dns.get_zone(certificate.domain_name)
    if zone is None:
        return False
    return all(
        self.dns.get_record(zone.id, record.name, record.type) is not None
        for record in certificate.validation_records
    )

A test that forgets to create the validation record sees a certificate stuck in PENDING_VALIDATION, just as it would in AWS. The in-memory services send the same signals as the real ones, so the tests exercise the platform’s receivers and ledger too.

Installing it is two lines, and the infrastructure app’s test base class does it for every test.

self.provider = InMemoryProvider()
configure_provider(lambda: self.provider)
self.addCleanup(configure_provider, None)

Kubernetes and email have matching hooks, configure_kubernetes() and configure_email(). Tests of a single task can also patch the facade where it is used, for example patch("smarter.apps.llmclient.tasks.verify_custom_domain.infrastructure"), which keeps the test focused on the task’s own decisions.

Real infrastructure, on request

Some behavior can only be checked against the real thing. Does a deployed chatbot actually get a certificate from cert-manager, and does its DNS record resolve? Those tests exist, tagged infrastructure, and Smarter’s test runner in lib/unittest/runner.py excludes the tag unless it is asked for.

self.skipping_infrastructure = INFRASTRUCTURE not in tags and not include_infrastructure()
if self.skipping_infrastructure:
    exclude_tags.add(INFRASTRUCTURE)

Running them takes an explicit --tag infrastructure or SMARTER_TEST_INFRASTRUCTURE=true, and the suites themselves construct their services with allow_in_tests=True. The opt-in is visible in two places, on the command line and in the test code. A plain manage.py test prints a line saying the infrastructure tests were skipped and how to run them.

Knowing what costs money

The facade also gives the platform one place to notice spending. billable_resources() in services/kubernetes.py reads a manifest before kubectl apply and picks out what will provision a cloud resource of its own.

if kind in BILLABLE_KUBERNETES_KINDS:
    retval.append((kind, name))
elif kind == "statefulset" and spec.get("volumeClaimTemplates"):
    retval.append((kind, name))
elif kind == "service" and spec.get("type") == "LoadBalancer":
    retval.append((kind, name))

Persistent volume claims, stateful sets with volume templates and LoadBalancer services each send a billable_resource_creating signal before the apply and billable_resource_created after it. Today the receivers log a warning and record the resource in an InfrastructureResource ledger, and a Celery Beat task reconciles that ledger with what the cluster actually holds. The signal fires before the resource exists, which is the hook for a future policy that stops it. For now it is an audit trail, not a brake.

What it doesn’t cover

The guard checks the process it runs in. Celery is not eager in Smarter’s tests, so a test that calls .delay() on a task sends it to the worker containers, which are not running manage.py test and hold the same credentials. The rule there is procedural. Tests call task functions directly and patch .delay() and .apply_async() on anything the code under test would queue. That is a convention, enforced by review and by the project’s agent skills, not by code.

Inside the smarter-app container running manage.py test, a test that calls a task function directly stays in the same process, where the guard refuses and the fake answers. A test that calls task.delay() queues the task for the Celery worker containers, which are not running manage.py test and hold the same real credentials for Route 53, ACM and EKS. The rule, a convention, is to call task functions directly and patch .delay() and .apply_async().

It is also a net, not a permission boundary. It stops honest mistakes in tests. It does nothing about what someone with a shell in those containers can do with the credentials, and it does not make broad development credentials a good idea. Narrower IAM permissions for development would shrink the blast radius further, independently of anything in Python.

Is it worth it?

As a security control against attackers, I’d put it at about a 6 out of 10. It doesn’t stop an intrusion. What it does is concentrate every cloud-mutating call into one small, reviewable package, so an audit of “what can this app do to our AWS account” reads one directory instead of the whole codebase, and it removes the most likely way a development environment damages production, which is a test that quietly does the real thing. Against that failure, which in this project has already cost money once, it is close to a 10.

The ongoing burden is small. A new backend needs one call to refuse_in_unit_tests() and, if tests are to exercise it, an in-memory counterpart. The in-memory provider has to keep up with the real one’s behavior, which is the only part that takes thought, because a fake that is more forgiving than AWS hides bugs.

The cost is nothing at runtime and saves money in development, with no stray hosted zones, certificates or node groups, and no accidental LLM charges from the proxy. There is no user-facing effect. For stability, the facade’s error translation means a provider SDK upgrade changes exception types in one package, not in every caller, and the lazy resolution means a missing cloud never stops the platform from importing.

I would adopt it in any Django project whose developers hold credentials for something that costs money or matters, which is most of them. The pattern doesn’t need Smarter’s code. It needs one facade, one guard at the last step before each backend, a fake that behaves like the real thing, and a test tag for the few tests that must touch the cloud.

Takeaways

  • Put every cloud SDK call behind one service layer, and translate SDK exceptions into your own at that boundary.
  • Guard the backend, not the callers. Refuse at the last step before a real call, so new code paths inherit the guard.
  • Make the fake behave like the cloud, including its waiting states, or the tests will pass against a world that doesn’t exist.
  • Tag the tests that need real infrastructure and skip them by default, with the opt-in visible on the command line.
  • Remember what the guard can’t see, such as task queues, other processes, and the credentials themselves.

Closing

None of this is novel, and that is the appeal. A facade, a three-line guard and a well-behaved fake turn “be careful when you run the tests” from a rule people must remember into a property of the code.