From 0ca3c0de22b52b748a68aef2020c4e85072efafa Mon Sep 17 00:00:00 2001 From: Saqib Date: Mon, 17 Aug 2026 21:31:53 +0530 Subject: [PATCH 1/4] fix: health check now returns 503 when a dependency is unhealthy health_check previously returned JsonResponse(data) unconditionally, so the ECS container healthcheck (curl -f) and any future smoke gate would false-green a container with a dead DB/ES/Redis/telemetry connection. Also exposes git_sha so a deploy can verify the running code matches what was just pushed. --- api/views/health.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/api/views/health.py b/api/views/health.py index 12d527d9..41491a3e 100644 --- a/api/views/health.py +++ b/api/views/health.py @@ -1,3 +1,4 @@ +import os from typing import Any, Dict import requests @@ -141,6 +142,7 @@ def health_check(request: HttpRequest) -> JsonResponse: data = { "status": "healthy" if overall_status else "unhealthy", "services": status, + "git_sha": os.environ.get("GIT_COMMIT_SHA", "unknown"), } - return JsonResponse(data) + return JsonResponse(data, status=200 if overall_status else 503) From ed4b1161fd38c08e34de3178655eaa91d3db7994 Mon Sep 17 00:00:00 2001 From: Saqib Date: Mon, 17 Aug 2026 21:31:58 +0530 Subject: [PATCH 2/4] test: cover health check status codes and git_sha field --- tests/test_health.py | 99 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 tests/test_health.py diff --git a/tests/test_health.py b/tests/test_health.py new file mode 100644 index 00000000..93a6b28c --- /dev/null +++ b/tests/test_health.py @@ -0,0 +1,99 @@ +"""Tests for the /health/ endpoint's status-code and git_sha behavior.""" + +import os +import unittest +from contextlib import ExitStack +from unittest.mock import MagicMock, patch + +from django.test import Client, override_settings + + +class TestHealthCheck(unittest.TestCase): + """The endpoint must reflect actual dependency health in its status code.""" + + def setUp(self) -> None: + self.client = Client() + + def _mock_healthy_dependencies(self, stack: ExitStack) -> None: + """Patch ES/Redis/telemetry so the happy path doesn't need real services.""" + # tests/test_settings.py's ELASTICSEARCH_DSL omits http_auth (the real + # DataSpace/settings.py value always sets it) — health_check reads it + # unconditionally, so supply it here rather than touching test settings. + stack.enter_context( + override_settings( + ELASTICSEARCH_DSL={ + "default": {"hosts": "localhost:9200", "http_auth": ("user", "pass")} + } + ) + ) + mock_es_instance = MagicMock() + mock_es_instance.ping.return_value = True + stack.enter_context( + patch("api.views.health.Elasticsearch", return_value=mock_es_instance) + ) + # `cache` is Django's lazy DefaultConnectionProxy — patching .set/.get + # as attributes on it gets forwarded to the real backend instead of + # being intercepted, so replace the name binding in the health module + # wholesale instead. + cache_store: dict = {} + mock_cache = MagicMock() + mock_cache.set.side_effect = lambda k, v, timeout=None: cache_store.__setitem__(k, v) + mock_cache.get.side_effect = lambda k: cache_store.get(k) + stack.enter_context(patch("api.views.health.cache", mock_cache)) + mock_get = stack.enter_context(patch("api.views.health.requests.get")) + mock_get.return_value.status_code = 200 + + def test_returns_200_when_all_dependencies_healthy(self) -> None: + with ExitStack() as stack: + self._mock_healthy_dependencies(stack) + response = self.client.get("/health/") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["status"], "healthy") + + def test_returns_503_when_elasticsearch_unhealthy(self) -> None: + with ExitStack() as stack: + self._mock_healthy_dependencies(stack) + mock_es_instance = MagicMock() + mock_es_instance.ping.return_value = False + stack.enter_context( + patch("api.views.health.Elasticsearch", return_value=mock_es_instance) + ) + response = self.client.get("/health/") + + self.assertEqual(response.status_code, 503) + body = response.json() + self.assertEqual(body["status"], "unhealthy") + self.assertEqual(body["services"]["elasticsearch"]["status"], "unhealthy") + + def test_returns_503_when_redis_unhealthy(self) -> None: + with ExitStack() as stack: + self._mock_healthy_dependencies(stack) + mock_cache = MagicMock() + mock_cache.set.side_effect = Exception("down") + stack.enter_context(patch("api.views.health.cache", mock_cache)) + response = self.client.get("/health/") + + self.assertEqual(response.status_code, 503) + self.assertEqual(response.json()["services"]["redis"]["status"], "unhealthy") + + def test_git_sha_defaults_to_unknown(self) -> None: + with ExitStack() as stack: + self._mock_healthy_dependencies(stack) + stack.enter_context(patch.dict(os.environ, {}, clear=False)) + os.environ.pop("GIT_COMMIT_SHA", None) + response = self.client.get("/health/") + + self.assertEqual(response.json()["git_sha"], "unknown") + + def test_git_sha_reflects_env_var(self) -> None: + with ExitStack() as stack: + self._mock_healthy_dependencies(stack) + stack.enter_context(patch.dict(os.environ, {"GIT_COMMIT_SHA": "abc1234"})) + response = self.client.get("/health/") + + self.assertEqual(response.json()["git_sha"], "abc1234") + + +if __name__ == "__main__": + unittest.main() From 029689ae7b68c43ddbacbdf091dfab726f3335d5 Mon Sep 17 00:00:00 2001 From: Saqib Date: Mon, 17 Aug 2026 21:31:58 +0530 Subject: [PATCH 3/4] build: plumb GIT_COMMIT_SHA into the image Follows the same ARG->ENV pattern the container already uses for other build-time config. Feeds health_check's new git_sha field. --- Dockerfile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Dockerfile b/Dockerfile index 05be1d8a..1fb7c77d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,6 @@ FROM python:3.10 +ARG GIT_COMMIT_SHA=unknown +ENV GIT_COMMIT_SHA=${GIT_COMMIT_SHA} ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONUNBUFFERED=1 From 4d821392fe835999bfb9010ccb61c70f22f800b4 Mon Sep 17 00:00:00 2001 From: Saqib Date: Mon, 17 Aug 2026 21:31:58 +0530 Subject: [PATCH 4/4] fix: drop makemigrations from the container boot path Generating migration files at deploy time instead of using committed ones is unsafe under a rolling deployment (briefly 2 tasks live) and means the schema that lands in prod was never reviewed. migrate itself stays here for now; moving it to an explicit one-off step is next. --- docker-entrypoint.sh | 4 ---- 1 file changed, 4 deletions(-) diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 10d7bf4d..96033e99 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -45,10 +45,6 @@ mkdir -p /code/api/migrations chmod -R 777 /code/api/migrations touch /code/api/migrations/__init__.py -# Run makemigrations first to ensure migration files are created -echo "Running makemigrations..." -python manage.py makemigrations --noinput - # Run migrations echo "Running migrations..." python manage.py migrate --noinput