diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index c3b82e1..832b1de 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -25,6 +25,13 @@ services: - .env.dev environment: - REDIS_HOST=redis-dev + - REDIS_DB_DEV=0 + - BACKEND_DEV_MODE=true + - 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: @@ -45,6 +52,13 @@ services: - .env.dev environment: - REDIS_HOST=redis-dev + - REDIS_DB_DEV=0 + - 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/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/main.py b/main.py index 3834e82..a877544 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"} } } @@ -45,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" @@ -67,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" @@ -77,8 +124,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 @@ -96,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" diff --git a/tests/test_dev_stack.py b/tests/test_dev_stack.py index ce68b43..c056990 100644 --- a/tests/test_dev_stack.py +++ b/tests/test_dev_stack.py @@ -38,8 +38,21 @@ 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 def test_supabase_dev_seed_core_counts(): @@ -51,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