Merge branch agent/phase-a-dev-runtime
Some checks failed
api-ci-deploy / test-build-deploy (push) Has been cancelled

This commit is contained in:
kcar 2026-05-28 12:37:09 +01:00
commit 550d405935
5 changed files with 108 additions and 10 deletions

View File

@ -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:

View File

@ -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:

View File

@ -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

60
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
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"

View File

@ -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