Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions mod_health/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Health check module for deployment verification."""
114 changes: 114 additions & 0 deletions mod_health/controllers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
"""Health check endpoints for deployment verification and monitoring."""

from datetime import datetime
from typing import Any, Dict, Tuple

from flask import Blueprint, current_app, jsonify

mod_health = Blueprint('health', __name__)


def check_database() -> Dict[str, Any]:
"""
Check database connectivity.

:return: Dictionary with status and optional error message
:rtype: Dict[str, Any]
"""
try:
from database import create_session
db = create_session(current_app.config['DATABASE_URI'])
db.execute('SELECT 1')
# remove() returns the scoped session's connection to the pool
db.remove()
return {'status': 'ok'}
except Exception:
current_app.logger.exception('Health check database connection failed')
return {'status': 'error', 'message': 'Database connection failed'}


def check_config() -> Dict[str, Any]:
"""
Check that required configuration is loaded.

:return: Dictionary with status and optional error message
:rtype: Dict[str, Any]
"""
required_keys = [
'DATABASE_URI',
'GITHUB_TOKEN',
'GITHUB_OWNER',
'GITHUB_REPOSITORY',
]

missing = [key for key in required_keys if not current_app.config.get(key)]

if missing:
return {'status': 'error', 'message': f'Missing config keys: {missing}'}
return {'status': 'ok'}


@mod_health.route('/health')
def health_check() -> Tuple[Any, int]:
"""
Health check endpoint for deployment verification.

Returns 200 if all critical checks pass, 503 if any fail.
Used by deployment pipeline to verify successful deployment.

:return: JSON response with health status and HTTP status code
:rtype: Tuple[Any, int]
"""
check_results: Dict[str, Dict[str, Any]] = {}
all_healthy = True

# Check 1: Database connectivity
db_check = check_database()
check_results['database'] = db_check
if db_check['status'] != 'ok':
all_healthy = False

# Check 2: Configuration loaded
config_check = check_config()
check_results['config'] = config_check
if config_check['status'] != 'ok':
all_healthy = False

checks: Dict[str, Any] = {
'status': 'healthy' if all_healthy else 'unhealthy',
'timestamp': datetime.utcnow().isoformat() + 'Z',
'checks': check_results
}

return jsonify(checks), 200 if all_healthy else 503


@mod_health.route('/health/live')
def liveness_check() -> Tuple[Any, int]:
"""
Liveness check endpoint.

Minimal check, just returns 200 if Flask is responding.
Useful for load balancers and container orchestration.

:return: JSON response with alive status
:rtype: Tuple[Any, int]
"""
return jsonify({
'status': 'alive',
'timestamp': datetime.utcnow().isoformat() + 'Z'
}), 200


@mod_health.route('/health/ready')
def readiness_check() -> Tuple[Any, int]:
"""
Readiness check endpoint.

Same as health check but can be extended for more checks.
Useful for Kubernetes readiness probes.

:return: JSON response with readiness status
:rtype: Tuple[Any, int]
"""
return health_check()
2 changes: 2 additions & 0 deletions run.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
from mod_auth.controllers import mod_auth
from mod_ci.controllers import mod_ci
from mod_customized.controllers import mod_customized
from mod_health.controllers import mod_health
from mod_home.controllers import mod_home
from mod_regression.controllers import mod_regression
from mod_sample.controllers import mod_sample
Expand Down Expand Up @@ -271,3 +272,4 @@ def teardown(exception: Optional[Exception]):
app.register_blueprint(mod_test, url_prefix="/test")
app.register_blueprint(mod_ci)
app.register_blueprint(mod_customized, url_prefix='/custom')
app.register_blueprint(mod_health)
1 change: 1 addition & 0 deletions tests/test_health/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Contains tests for mod_health."""
132 changes: 132 additions & 0 deletions tests/test_health/test_controllers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
"""Tests for the health check endpoints."""

import json
from unittest import mock

from tests.base import BaseTestCase


class TestHealthEndpoints(BaseTestCase):
"""Test health check endpoints."""

@mock.patch('mod_health.controllers.check_config')
@mock.patch('mod_health.controllers.check_database')
def test_health_endpoint_returns_200_when_healthy(self, mock_db, mock_config):
"""Test that /health returns 200 when all checks pass."""
mock_db.return_value = {'status': 'ok'}
mock_config.return_value = {'status': 'ok'}

response = self.app.test_client().get('/health')
self.assertEqual(response.status_code, 200)

data = json.loads(response.data)
self.assertEqual(data['status'], 'healthy')
self.assertIn('timestamp', data)
self.assertIn('checks', data)
self.assertEqual(data['checks']['database']['status'], 'ok')
self.assertEqual(data['checks']['config']['status'], 'ok')

@mock.patch('mod_health.controllers.check_config')
@mock.patch('mod_health.controllers.check_database')
def test_health_endpoint_returns_503_when_database_fails(self, mock_db, mock_config):
"""Test that /health returns 503 when database check fails."""
mock_db.return_value = {'status': 'error', 'message': 'Connection failed'}
mock_config.return_value = {'status': 'ok'}

response = self.app.test_client().get('/health')
self.assertEqual(response.status_code, 503)

data = json.loads(response.data)
self.assertEqual(data['status'], 'unhealthy')
self.assertEqual(data['checks']['database']['status'], 'error')

@mock.patch('mod_health.controllers.check_config')
@mock.patch('mod_health.controllers.check_database')
def test_health_endpoint_returns_503_when_config_fails(self, mock_db, mock_config):
"""Test that /health returns 503 when config check fails."""
mock_db.return_value = {'status': 'ok'}
mock_config.return_value = {'status': 'error', 'message': 'Missing keys'}

response = self.app.test_client().get('/health')
self.assertEqual(response.status_code, 503)

data = json.loads(response.data)
self.assertEqual(data['status'], 'unhealthy')
self.assertEqual(data['checks']['config']['status'], 'error')

def test_liveness_endpoint_returns_200(self):
"""Test that /health/live always returns 200."""
response = self.app.test_client().get('/health/live')
self.assertEqual(response.status_code, 200)

data = json.loads(response.data)
self.assertEqual(data['status'], 'alive')
self.assertIn('timestamp', data)

@mock.patch('mod_health.controllers.check_config')
@mock.patch('mod_health.controllers.check_database')
def test_readiness_endpoint_returns_200_when_healthy(self, mock_db, mock_config):
"""Test that /health/ready returns 200 when healthy."""
mock_db.return_value = {'status': 'ok'}
mock_config.return_value = {'status': 'ok'}

response = self.app.test_client().get('/health/ready')
self.assertEqual(response.status_code, 200)

data = json.loads(response.data)
self.assertEqual(data['status'], 'healthy')

@mock.patch('mod_health.controllers.check_config')
@mock.patch('mod_health.controllers.check_database')
def test_readiness_endpoint_returns_503_when_unhealthy(self, mock_db, mock_config):
"""Test that /health/ready returns 503 when unhealthy."""
mock_db.return_value = {'status': 'error', 'message': 'Connection failed'}
mock_config.return_value = {'status': 'ok'}

response = self.app.test_client().get('/health/ready')
self.assertEqual(response.status_code, 503)

data = json.loads(response.data)
self.assertEqual(data['status'], 'unhealthy')


class TestHealthCheckFunctions(BaseTestCase):
"""Test individual health check functions."""

def test_check_database_success(self):
"""Test check_database returns ok when database is accessible."""
from mod_health.controllers import check_database
with self.app.app_context():
result = check_database()
self.assertEqual(result['status'], 'ok')

def test_check_database_failure(self):
"""Test check_database returns error when database fails."""
from mod_health.controllers import check_database
with self.app.app_context():
# Mock at the source module where it's imported from
with mock.patch('database.create_session') as mock_session:
mock_session.side_effect = Exception('Connection refused')
result = check_database()
self.assertEqual(result['status'], 'error')
# Generic message returned (actual exception logged server-side)
self.assertEqual(result['message'], 'Database connection failed')

def test_check_config_success(self):
"""Test check_config returns ok when config is complete."""
from mod_health.controllers import check_config
with self.app.app_context():
# Set required config values for test
self.app.config['GITHUB_TOKEN'] = 'test_token'
result = check_config()
self.assertEqual(result['status'], 'ok')

def test_check_config_missing_keys(self):
"""Test check_config returns error when keys are missing."""
from mod_health.controllers import check_config
with self.app.app_context():
# Ensure GITHUB_TOKEN is empty to trigger error
self.app.config['GITHUB_TOKEN'] = ''
result = check_config()
self.assertEqual(result['status'], 'error')
self.assertIn('GITHUB_TOKEN', result['message'])