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