From 7fede4d0824b27b1cabffc74ae07b33045f629a6 Mon Sep 17 00:00:00 2001 From: kcar Date: Thu, 28 May 2026 10:15:33 +0100 Subject: [PATCH 1/3] fix: run API dev stack in dev mode --- docker-compose.dev.yml | 9 +++++++++ docker-entrypoint.sh | 10 ++++++---- tests/test_dev_stack.py | 2 ++ 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index c3b82e1..11c1426 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -25,6 +25,11 @@ services: - .env.dev environment: - REDIS_HOST=redis-dev + - REDIS_DB_DEV=0 + - BACKEND_DEV_MODE=true + - APP_ENV=development + - ENVIRONMENT=development + - START_MODE=dev - RUN_INIT=false - INIT_MODE=infra ports: @@ -45,6 +50,10 @@ services: - .env.dev environment: - REDIS_HOST=redis-dev + - REDIS_DB_DEV=0 + - BACKEND_DEV_MODE=true + - APP_ENV=development + - ENVIRONMENT=development - API_HEALTH_URL=http://192.168.0.64:18000/health depends_on: redis-dev: diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 57c4676..bf0af46 100755 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -98,11 +98,13 @@ if [ "$RUN_INIT" = "true" ]; then fi fi -# Start the production server (unless init-only mode) +# Start the server (unless init-only mode). Default remains production, but +# development Compose can set START_MODE=dev so cc-api-dev reports and uses +# development Redis/config instead of silently booting as prod. +START_MODE="${START_MODE:-prod}" if [ "$1" != "init-only" ] && [ -z "$INIT_ONLY" ]; then - print_status "Starting production server..." - exec ./start.sh prod + print_status "Starting ${START_MODE} server..." + exec ./start.sh "$START_MODE" else print_status "Init-only mode - not starting server" fi - diff --git a/tests/test_dev_stack.py b/tests/test_dev_stack.py index ce68b43..4e0d3d2 100644 --- a/tests/test_dev_stack.py +++ b/tests/test_dev_stack.py @@ -40,6 +40,8 @@ def test_dev_api_health_endpoint_is_healthy(): assert payload['status'] == 'healthy' assert payload['services']['supabase']['status'] == 'healthy' assert payload['services']['redis']['status'] == 'healthy' + assert payload['services']['redis']['environment'] == 'dev' + assert payload['services']['redis']['database'] == 0 def test_supabase_dev_seed_core_counts(): From 310e273aa5b8fc7ece7d215099f63466d1b17284 Mon Sep 17 00:00:00 2001 From: kcar Date: Thu, 28 May 2026 11:32:04 +0100 Subject: [PATCH 2/3] feat: expose API runtime identity in health --- docker-compose.dev.yml | 5 ++++ docker-compose.yml | 5 ++++ main.py | 51 ++++++++++++++++++++++++++++++++++++++--- tests/test_dev_stack.py | 27 ++++++++++++++++++++++ 4 files changed, 85 insertions(+), 3 deletions(-) diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 11c1426..832b1de 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -30,6 +30,8 @@ services: - APP_ENV=development - ENVIRONMENT=development - START_MODE=dev + - CC_COMPOSE_PROJECT=api-dev + - CC_COMPOSE_SERVICE=backend-dev - RUN_INIT=false - INIT_MODE=infra ports: @@ -54,6 +56,9 @@ services: - BACKEND_DEV_MODE=true - APP_ENV=development - ENVIRONMENT=development + - START_MODE=dev + - CC_COMPOSE_PROJECT=api-dev + - CC_COMPOSE_SERVICE=backend-test - API_HEALTH_URL=http://192.168.0.64:18000/health depends_on: redis-dev: diff --git a/docker-compose.yml b/docker-compose.yml index e7c45df..e378a32 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -46,6 +46,11 @@ services: - .env environment: - REDIS_HOST=redis + - START_MODE=prod + - APP_ENV=production + - ENVIRONMENT=production + - CC_COMPOSE_PROJECT=api + - CC_COMPOSE_SERVICE=backend - RUN_INIT=${RUN_INIT:-false} # Set to 'true' to run init on startup - INIT_MODE=${INIT_MODE:-infra} # Which init tasks to run ports: diff --git a/main.py b/main.py index 3834e82..7dd9e35 100644 --- a/main.py +++ b/main.py @@ -10,6 +10,7 @@ logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH from fastapi import FastAPI, HTTPException import uvicorn import requests +from urllib.parse import urlparse from typing import Dict, Any, Optional from modules.database.tools.neo4j_driver_tools import get_driver @@ -22,15 +23,59 @@ from modules.queue_system import ServiceType app = FastAPI() setup_cors(app) +def _truthy_env(name: str) -> bool: + return os.getenv(name, "").lower() in {"1", "true", "yes", "on"} + + +def _runtime_environment() -> str: + """Return the API runtime role used for dev/prod backing-service selection.""" + start_mode = os.getenv("START_MODE", "prod").lower() + if start_mode == "dev" or _truthy_env("BACKEND_DEV_MODE"): + return "dev" + return "prod" + + +def _url_host(url: Optional[str]) -> Optional[str]: + if not url: + return None + parsed = urlparse(url) + return parsed.hostname + + +def _runtime_identity() -> Dict[str, Any]: + """Non-secret runtime identity for agents and smoke tests. + + This intentionally exposes only modes, labels, and URL hosts. It must not + include API keys, passwords, bearer tokens, or full URLs that may embed + credentials. + """ + return { + "api_runtime_role": _runtime_environment(), + "start_mode": os.getenv("START_MODE", "prod"), + "app_environment": os.getenv("APP_ENV"), + "environment": os.getenv("ENVIRONMENT"), + "backend_dev_mode": _truthy_env("BACKEND_DEV_MODE"), + "compose_project": os.getenv("COMPOSE_PROJECT_NAME") or os.getenv("CC_COMPOSE_PROJECT"), + "compose_service": os.getenv("COMPOSE_SERVICE") or os.getenv("CC_COMPOSE_SERVICE"), + "supabase_url_host": _url_host(os.getenv("SUPABASE_URL")), + } + + # Health check endpoint @app.get("/health") async def health_check() -> Dict[str, Any]: """Health check endpoint that verifies all service dependencies""" + runtime_identity = _runtime_identity() health_status = { "status": "healthy", + "runtime": runtime_identity, "services": { "neo4j": {"status": "healthy", "message": "Connected"}, - "supabase": {"status": "healthy", "message": "Connected"}, + "supabase": { + "status": "healthy", + "message": "Connected", + "url_host": runtime_identity["supabase_url_host"], + }, "redis": {"status": "healthy", "message": "Connected"} } } @@ -77,8 +122,8 @@ async def health_check() -> Dict[str, Any]: # Check Redis using new Redis manager from modules.redis_manager import get_redis_manager - # Determine environment - environment = 'dev' if os.getenv('BACKEND_DEV_MODE', 'true').lower() == 'true' else 'prod' + # Determine environment from explicit startup/runtime identity. + environment = runtime_identity["api_runtime_role"] redis_manager = get_redis_manager(environment) # Get comprehensive health check diff --git a/tests/test_dev_stack.py b/tests/test_dev_stack.py index 4e0d3d2..c056990 100644 --- a/tests/test_dev_stack.py +++ b/tests/test_dev_stack.py @@ -38,7 +38,18 @@ def test_dev_api_health_endpoint_is_healthy(): assert response.status_code == 200 payload = response.json() assert payload['status'] == 'healthy' + + runtime = payload['runtime'] + assert runtime['api_runtime_role'] == 'dev' + assert runtime['start_mode'] == 'dev' + assert runtime['app_environment'] == 'development' + assert runtime['environment'] == 'development' + assert runtime['backend_dev_mode'] is True + assert runtime['compose_project'] == 'api-dev' + assert runtime['supabase_url_host'] == '192.168.0.94' + assert payload['services']['supabase']['status'] == 'healthy' + assert payload['services']['supabase']['url_host'] == '192.168.0.94' assert payload['services']['redis']['status'] == 'healthy' assert payload['services']['redis']['environment'] == 'dev' assert payload['services']['redis']['database'] == 0 @@ -53,3 +64,19 @@ def test_supabase_dev_seed_core_counts(): def test_supabase_dev_seed_timetable_counts(): assert _rest_count('classes') == 17 assert _rest_count('taught_lessons') == 1462 + + +def test_runtime_identity_does_not_expose_secret_values(): + health_url = os.getenv('API_HEALTH_URL', 'http://192.168.0.64:18000/health') + response = requests.get(health_url, timeout=15) + assert response.status_code == 200 + payload_text = response.text + for secret_name in ('SERVICE_ROLE_KEY', 'ANON_KEY', 'SUPABASE_JWT_SECRET', 'REDIS_PASSWORD'): + secret_value = os.getenv(secret_name) + if secret_value: + assert secret_value not in payload_text + + runtime = response.json()['runtime'] + assert 'supabase_url' not in runtime + assert 'service_role_key' not in runtime + assert 'anon_key' not in runtime From df40ddc2860548c9d296769dea324e1f06190b5a Mon Sep 17 00:00:00 2001 From: kcar Date: Thu, 28 May 2026 11:43:59 +0100 Subject: [PATCH 3/3] fix: keep health errors non-sensitive --- main.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/main.py b/main.py index 7dd9e35..a877544 100644 --- a/main.py +++ b/main.py @@ -90,9 +90,10 @@ async def health_check() -> Dict[str, Any]: } health_status["status"] = "unhealthy" except Exception as e: + logger.warning(f"Neo4j health check failed: {e}") health_status["services"]["neo4j"] = { "status": "unhealthy", - "message": f"Error checking Neo4j: {str(e)}" + "message": "Error checking Neo4j" } health_status["status"] = "unhealthy" @@ -112,9 +113,10 @@ async def health_check() -> Dict[str, Any]: } health_status["status"] = "unhealthy" except Exception as e: + logger.warning(f"Supabase health check failed: {e}") health_status["services"]["supabase"] = { "status": "unhealthy", - "message": f"Error checking Supabase Auth API: {str(e)}" + "message": "Error checking Supabase Auth API" } health_status["status"] = "unhealthy" @@ -141,9 +143,10 @@ async def health_check() -> Dict[str, Any]: health_status["status"] = "unhealthy" except Exception as e: + logger.warning(f"Redis health check failed: {e}") health_status["services"]["redis"] = { "status": "unhealthy", - "message": f"Error checking Redis: {str(e)}" + "message": "Error checking Redis" } health_status["status"] = "unhealthy"