feat: expose API runtime identity in health

This commit is contained in:
kcar 2026-05-28 11:32:04 +01:00
parent 7fede4d082
commit 310e273aa5
4 changed files with 85 additions and 3 deletions

View File

@ -30,6 +30,8 @@ services:
- APP_ENV=development - APP_ENV=development
- ENVIRONMENT=development - ENVIRONMENT=development
- START_MODE=dev - START_MODE=dev
- CC_COMPOSE_PROJECT=api-dev
- CC_COMPOSE_SERVICE=backend-dev
- RUN_INIT=false - RUN_INIT=false
- INIT_MODE=infra - INIT_MODE=infra
ports: ports:
@ -54,6 +56,9 @@ services:
- BACKEND_DEV_MODE=true - BACKEND_DEV_MODE=true
- APP_ENV=development - APP_ENV=development
- ENVIRONMENT=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 - API_HEALTH_URL=http://192.168.0.64:18000/health
depends_on: depends_on:
redis-dev: redis-dev:

View File

@ -46,6 +46,11 @@ services:
- .env - .env
environment: environment:
- REDIS_HOST=redis - 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 - RUN_INIT=${RUN_INIT:-false} # Set to 'true' to run init on startup
- INIT_MODE=${INIT_MODE:-infra} # Which init tasks to run - INIT_MODE=${INIT_MODE:-infra} # Which init tasks to run
ports: ports:

51
main.py
View File

@ -10,6 +10,7 @@ logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH
from fastapi import FastAPI, HTTPException from fastapi import FastAPI, HTTPException
import uvicorn import uvicorn
import requests import requests
from urllib.parse import urlparse
from typing import Dict, Any, Optional from typing import Dict, Any, Optional
from modules.database.tools.neo4j_driver_tools import get_driver from modules.database.tools.neo4j_driver_tools import get_driver
@ -22,15 +23,59 @@ from modules.queue_system import ServiceType
app = FastAPI() app = FastAPI()
setup_cors(app) 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 # Health check endpoint
@app.get("/health") @app.get("/health")
async def health_check() -> Dict[str, Any]: async def health_check() -> Dict[str, Any]:
"""Health check endpoint that verifies all service dependencies""" """Health check endpoint that verifies all service dependencies"""
runtime_identity = _runtime_identity()
health_status = { health_status = {
"status": "healthy", "status": "healthy",
"runtime": runtime_identity,
"services": { "services": {
"neo4j": {"status": "healthy", "message": "Connected"}, "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"} "redis": {"status": "healthy", "message": "Connected"}
} }
} }
@ -77,8 +122,8 @@ async def health_check() -> Dict[str, Any]:
# Check Redis using new Redis manager # Check Redis using new Redis manager
from modules.redis_manager import get_redis_manager from modules.redis_manager import get_redis_manager
# Determine environment # Determine environment from explicit startup/runtime identity.
environment = 'dev' if os.getenv('BACKEND_DEV_MODE', 'true').lower() == 'true' else 'prod' environment = runtime_identity["api_runtime_role"]
redis_manager = get_redis_manager(environment) redis_manager = get_redis_manager(environment)
# Get comprehensive health check # Get comprehensive health check

View File

@ -38,7 +38,18 @@ def test_dev_api_health_endpoint_is_healthy():
assert response.status_code == 200 assert response.status_code == 200
payload = response.json() payload = response.json()
assert payload['status'] == 'healthy' 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']['status'] == 'healthy'
assert payload['services']['supabase']['url_host'] == '192.168.0.94'
assert payload['services']['redis']['status'] == 'healthy' assert payload['services']['redis']['status'] == 'healthy'
assert payload['services']['redis']['environment'] == 'dev' assert payload['services']['redis']['environment'] == 'dev'
assert payload['services']['redis']['database'] == 0 assert payload['services']['redis']['database'] == 0
@ -53,3 +64,19 @@ def test_supabase_dev_seed_core_counts():
def test_supabase_dev_seed_timetable_counts(): def test_supabase_dev_seed_timetable_counts():
assert _rest_count('classes') == 17 assert _rest_count('classes') == 17
assert _rest_count('taught_lessons') == 1462 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