aegis-stack 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of aegis-stack might be problematic. Click here for more details.
- aegis/__init__.py +5 -0
- aegis/__main__.py +374 -0
- aegis/core/CLAUDE.md +365 -0
- aegis/core/__init__.py +6 -0
- aegis/core/components.py +115 -0
- aegis/core/dependency_resolver.py +119 -0
- aegis/core/template_generator.py +163 -0
- aegis/templates/CLAUDE.md +306 -0
- aegis/templates/cookiecutter-aegis-project/cookiecutter.json +27 -0
- aegis/templates/cookiecutter-aegis-project/hooks/post_gen_project.py +172 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/.dockerignore +71 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/.env.example.j2 +70 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/.gitignore +127 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/Dockerfile +53 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/Makefile +211 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/README.md.j2 +196 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/__init__.py +5 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/cli/__init__.py +6 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/cli/health.py +321 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/cli/load_test.py +638 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/cli/main.py +41 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/backend/__init__.py +0 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/backend/api/__init__.py +0 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/backend/api/health.py +134 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/backend/api/models.py.j2 +247 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/backend/api/routing.py.j2 +14 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/backend/api/tasks.py.j2 +596 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/backend/hooks.py +133 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/backend/main.py +16 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/backend/middleware/__init__.py +1 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/backend/middleware/cors.py +20 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/backend/shutdown/__init__.py +1 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/backend/shutdown/cleanup.py +14 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/backend/startup/__init__.py +1 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/backend/startup/component_health.py.j2 +190 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/frontend/__init__.py +0 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/frontend/core/__init__.py +1 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/frontend/core/theme.py +46 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/frontend/main.py +687 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/scheduler/__init__.py +1 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/scheduler/main.py +138 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/worker/CLAUDE.md +213 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/worker/__init__.py +6 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/worker/constants.py.j2 +30 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/worker/pools.py +78 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/worker/queues/__init__.py +1 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/worker/queues/load_test.py +48 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/worker/queues/media.py +41 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/worker/queues/system.py +36 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/worker/registry.py +139 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/worker/tasks/__init__.py +119 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/worker/tasks/load_tasks.py +526 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/worker/tasks/simple_system_tasks.py +32 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/components/worker/tasks/system_tasks.py +279 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/core/config.py.j2 +119 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/core/constants.py +60 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/core/db.py +67 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/core/log.py +85 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/entrypoints/__init__.py +1 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/entrypoints/webserver.py +40 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/entrypoints/{% if cookiecutter.include_scheduler == /"yes/" %}scheduler.py{% endif %}" +21 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/integrations/__init__.py +0 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/integrations/main.py +61 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/py.typed +0 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/services/__init__.py +1 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/services/load_test.py +661 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/services/load_test_models.py +269 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/services/shared/__init__.py +15 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/services/shared/models.py +26 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/services/system/__init__.py +52 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/services/system/alerts.py +94 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/services/system/health.py.j2 +1105 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/services/system/models.py +169 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/app/services/system/ui.py +52 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/docker-compose.yml.j2 +195 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/docs/api.md +191 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/docs/components/scheduler.md +414 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/docs/development.md +215 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/docs/health.md +240 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/docs/javascripts/mermaid-config.js +62 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/docs/stylesheets/mermaid.css +95 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/mkdocs.yml.j2 +62 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/pyproject.toml.j2 +156 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/scripts/entrypoint.sh +87 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/scripts/entrypoint.sh.j2 +104 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/scripts/gen_docs.py +16 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/tests/api/__init__.py +1 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/tests/api/test_health_endpoints.py.j2 +239 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/tests/components/test_scheduler.py +76 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/tests/conftest.py.j2 +81 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/tests/services/__init__.py +1 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/tests/services/test_component_integration.py.j2 +376 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/tests/services/test_health_logic.py.j2 +633 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/tests/services/test_load_test_models.py +665 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/tests/services/test_load_test_service.py +602 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/tests/services/test_system_service.py +96 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/tests/services/test_worker_health_registration.py.j2 +224 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/tests/test_core.py +50 -0
- aegis/templates/cookiecutter-aegis-project/{{cookiecutter.project_slug}}/uv.lock +1673 -0
- aegis_stack-0.1.0.dist-info/METADATA +114 -0
- aegis_stack-0.1.0.dist-info/RECORD +103 -0
- aegis_stack-0.1.0.dist-info/WHEEL +4 -0
- aegis_stack-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,633 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for core health logic and component status propagation.
|
|
3
|
+
|
|
4
|
+
These tests focus on the pure logic of health checking, warning propagation,
|
|
5
|
+
and component hierarchy without external dependencies like Redis or system metrics.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from datetime import UTC, datetime
|
|
9
|
+
from unittest.mock import AsyncMock, MagicMock, patch
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
from app.services.system import ComponentStatus, ComponentStatusType
|
|
15
|
+
from app.services.system.health import (
|
|
16
|
+
get_system_status,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TestHealthUtilityFunctions:
|
|
22
|
+
"""Test utility functions used in health checks."""
|
|
23
|
+
|
|
24
|
+
def test_format_bytes(self) -> None:
|
|
25
|
+
"""Test format_bytes utility function."""
|
|
26
|
+
from app.services.system.health import format_bytes
|
|
27
|
+
|
|
28
|
+
# Test various byte sizes
|
|
29
|
+
assert format_bytes(0) == "0 B"
|
|
30
|
+
assert format_bytes(512) == "512 B"
|
|
31
|
+
assert format_bytes(1024) == "1.0 KB"
|
|
32
|
+
assert format_bytes(1536) == "1.5 KB"
|
|
33
|
+
assert format_bytes(2048) == "2.0 KB"
|
|
34
|
+
assert format_bytes(1048576) == "1.0 MB"
|
|
35
|
+
assert format_bytes(1572864) == "1.5 MB"
|
|
36
|
+
assert format_bytes(1073741824) == "1.0 GB"
|
|
37
|
+
assert format_bytes(1099511627776) == "1.0 TB"
|
|
38
|
+
|
|
39
|
+
# Test edge cases
|
|
40
|
+
assert format_bytes(1) == "1 B"
|
|
41
|
+
assert format_bytes(1023) == "1023 B"
|
|
42
|
+
assert format_bytes(8192) == "8.0 KB"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class TestComponentStatusPropagation:
|
|
46
|
+
"""Test warning status propagation through component hierarchies."""
|
|
47
|
+
|
|
48
|
+
def test_component_status_creation_with_warning(self) -> None:
|
|
49
|
+
"""Test creating ComponentStatus with warning status."""
|
|
50
|
+
status = ComponentStatus(
|
|
51
|
+
name="test_component",
|
|
52
|
+
status=ComponentStatusType.WARNING,
|
|
53
|
+
message="Has warnings but still healthy",
|
|
54
|
+
response_time_ms=100.0,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
assert status.name == "test_component"
|
|
58
|
+
assert status.healthy is True
|
|
59
|
+
assert status.status == ComponentStatusType.WARNING
|
|
60
|
+
assert status.message == "Has warnings but still healthy"
|
|
61
|
+
|
|
62
|
+
def test_component_status_defaults_to_healthy(self) -> None:
|
|
63
|
+
"""Test that ComponentStatus defaults to HEALTHY status."""
|
|
64
|
+
status = ComponentStatus(
|
|
65
|
+
name="test_component",
|
|
66
|
+
message="All good",
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
assert status.status == ComponentStatusType.HEALTHY
|
|
70
|
+
|
|
71
|
+
def test_unhealthy_component_with_unhealthy_status(self) -> None:
|
|
72
|
+
"""Test that unhealthy components get UNHEALTHY status."""
|
|
73
|
+
status = ComponentStatus(
|
|
74
|
+
name="test_component",
|
|
75
|
+
status=ComponentStatusType.UNHEALTHY,
|
|
76
|
+
message="Something is broken",
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
assert status.healthy is False
|
|
80
|
+
assert status.status == ComponentStatusType.UNHEALTHY
|
|
81
|
+
|
|
82
|
+
def test_sub_component_hierarchy(self) -> None:
|
|
83
|
+
"""Test component with sub-components for hierarchy testing."""
|
|
84
|
+
# Create sub-components with different statuses
|
|
85
|
+
sub_component_healthy = ComponentStatus(
|
|
86
|
+
name="sub_healthy",
|
|
87
|
+
status=ComponentStatusType.HEALTHY,
|
|
88
|
+
message="Sub-component is healthy",
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
sub_component_warning = ComponentStatus(
|
|
92
|
+
name="sub_warning",
|
|
93
|
+
status=ComponentStatusType.WARNING,
|
|
94
|
+
message="Sub-component has warnings",
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
# Create parent component
|
|
98
|
+
parent_component = ComponentStatus(
|
|
99
|
+
name="parent",
|
|
100
|
+
status=ComponentStatusType.WARNING, # Should propagate from sub-components
|
|
101
|
+
message="Parent has sub-component warnings",
|
|
102
|
+
sub_components={
|
|
103
|
+
"sub_healthy": sub_component_healthy,
|
|
104
|
+
"sub_warning": sub_component_warning,
|
|
105
|
+
}
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
assert parent_component.healthy is True
|
|
109
|
+
assert parent_component.status == ComponentStatusType.WARNING
|
|
110
|
+
assert len(parent_component.sub_components) == 2
|
|
111
|
+
assert (
|
|
112
|
+
parent_component.sub_components["sub_warning"].status
|
|
113
|
+
== ComponentStatusType.WARNING
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class TestSystemStatusWarningPropagation:
|
|
118
|
+
"""Test warning propagation in real system status scenarios."""
|
|
119
|
+
|
|
120
|
+
@pytest.mark.asyncio
|
|
121
|
+
async def test_system_status_with_mixed_component_health(self) -> None:
|
|
122
|
+
"""Test system status calculation with components having different states."""
|
|
123
|
+
|
|
124
|
+
# Mock the health check registry to have controlled components
|
|
125
|
+
mock_healthy_component = AsyncMock(return_value=ComponentStatus(
|
|
126
|
+
name="healthy_service",
|
|
127
|
+
status=ComponentStatusType.HEALTHY,
|
|
128
|
+
message="Service is running well",
|
|
129
|
+
))
|
|
130
|
+
|
|
131
|
+
mock_warning_component = AsyncMock(return_value=ComponentStatus(
|
|
132
|
+
name="warning_service",
|
|
133
|
+
status=ComponentStatusType.WARNING,
|
|
134
|
+
message="Service has warnings",
|
|
135
|
+
))
|
|
136
|
+
|
|
137
|
+
mock_unhealthy_component = AsyncMock(return_value=ComponentStatus(
|
|
138
|
+
name="unhealthy_service",
|
|
139
|
+
status=ComponentStatusType.UNHEALTHY,
|
|
140
|
+
message="Service is down",
|
|
141
|
+
))
|
|
142
|
+
|
|
143
|
+
# Mock the system metrics to avoid actual system calls
|
|
144
|
+
mock_system_metrics = {
|
|
145
|
+
"memory": ComponentStatus(
|
|
146
|
+
name="memory",
|
|
147
|
+
status=ComponentStatusType.HEALTHY,
|
|
148
|
+
message="Memory usage: 50%",
|
|
149
|
+
),
|
|
150
|
+
"cpu": ComponentStatus(
|
|
151
|
+
name="cpu",
|
|
152
|
+
status=ComponentStatusType.HEALTHY,
|
|
153
|
+
message="CPU usage: 10%",
|
|
154
|
+
),
|
|
155
|
+
"disk": ComponentStatus(
|
|
156
|
+
name="disk",
|
|
157
|
+
status=ComponentStatusType.HEALTHY,
|
|
158
|
+
message="Disk usage: 30%",
|
|
159
|
+
),
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
with patch('app.services.system.health._health_checks', {
|
|
163
|
+
'healthy_service': mock_healthy_component,
|
|
164
|
+
'warning_service': mock_warning_component,
|
|
165
|
+
'unhealthy_service': mock_unhealthy_component,
|
|
166
|
+
}), patch(
|
|
167
|
+
'app.services.system.health._get_cached_system_metrics',
|
|
168
|
+
return_value=mock_system_metrics
|
|
169
|
+
), patch(
|
|
170
|
+
'app.services.system.health._get_system_info',
|
|
171
|
+
return_value={"test": "info"}
|
|
172
|
+
):
|
|
173
|
+
|
|
174
|
+
system_status = await get_system_status()
|
|
175
|
+
|
|
176
|
+
# System should be unhealthy due to unhealthy_service
|
|
177
|
+
assert system_status.overall_healthy is False
|
|
178
|
+
|
|
179
|
+
# Check that components are present in aegis structure
|
|
180
|
+
assert "aegis" in system_status.components
|
|
181
|
+
aegis_component = system_status.components["aegis"]
|
|
182
|
+
|
|
183
|
+
# Check that our test components are included
|
|
184
|
+
assert "healthy_service" in aegis_component.sub_components
|
|
185
|
+
assert "warning_service" in aegis_component.sub_components
|
|
186
|
+
assert "unhealthy_service" in aegis_component.sub_components
|
|
187
|
+
# System metrics grouped under backend
|
|
188
|
+
assert "backend" in aegis_component.sub_components
|
|
189
|
+
|
|
190
|
+
# Verify component statuses
|
|
191
|
+
assert (
|
|
192
|
+
aegis_component.sub_components["healthy_service"].status
|
|
193
|
+
== ComponentStatusType.HEALTHY
|
|
194
|
+
)
|
|
195
|
+
assert (
|
|
196
|
+
aegis_component.sub_components["warning_service"].status
|
|
197
|
+
== ComponentStatusType.WARNING
|
|
198
|
+
)
|
|
199
|
+
assert (
|
|
200
|
+
aegis_component.sub_components["unhealthy_service"].status
|
|
201
|
+
== ComponentStatusType.UNHEALTHY
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
@pytest.mark.asyncio
|
|
205
|
+
async def test_system_status_with_only_warnings_stays_healthy(self) -> None:
|
|
206
|
+
"""Test that system with only warnings remains overall healthy."""
|
|
207
|
+
|
|
208
|
+
mock_warning_component = AsyncMock(return_value=ComponentStatus(
|
|
209
|
+
name="warning_service",
|
|
210
|
+
status=ComponentStatusType.WARNING,
|
|
211
|
+
message="Service has warnings but functional",
|
|
212
|
+
))
|
|
213
|
+
|
|
214
|
+
mock_system_metrics = {
|
|
215
|
+
"memory": ComponentStatus(
|
|
216
|
+
name="memory",
|
|
217
|
+
status=ComponentStatusType.HEALTHY,
|
|
218
|
+
message="Memory usage: 50%",
|
|
219
|
+
),
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
with patch('app.services.system.health._health_checks', {
|
|
223
|
+
'warning_service': mock_warning_component,
|
|
224
|
+
}), patch(
|
|
225
|
+
'app.services.system.health._get_cached_system_metrics',
|
|
226
|
+
return_value=mock_system_metrics
|
|
227
|
+
), patch(
|
|
228
|
+
'app.services.system.health._get_system_info',
|
|
229
|
+
return_value={"test": "info"}
|
|
230
|
+
):
|
|
231
|
+
|
|
232
|
+
system_status = await get_system_status()
|
|
233
|
+
|
|
234
|
+
# System should remain healthy since warnings don't affect overall health
|
|
235
|
+
assert system_status.overall_healthy is True
|
|
236
|
+
|
|
237
|
+
# But aegis component should propagate warning status
|
|
238
|
+
aegis_component = system_status.components["aegis"]
|
|
239
|
+
assert aegis_component.status == ComponentStatusType.WARNING
|
|
240
|
+
assert aegis_component.healthy is True
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class TestWorkerHealthLogic:
|
|
244
|
+
"""Test the specific worker health check logic and warning propagation."""
|
|
245
|
+
|
|
246
|
+
def test_queue_status_determination_logic(self) -> None:
|
|
247
|
+
"""Test the logic for determining queue component status."""
|
|
248
|
+
|
|
249
|
+
# Test case 1: Worker with no functions should be WARNING but healthy
|
|
250
|
+
def check_empty_worker_status(
|
|
251
|
+
queue_type: str,
|
|
252
|
+
has_functions: bool,
|
|
253
|
+
worker_alive: bool,
|
|
254
|
+
failure_rate: float,
|
|
255
|
+
) -> tuple[bool, ComponentStatusType]:
|
|
256
|
+
"""Simulate the queue status logic from worker health check."""
|
|
257
|
+
if not has_functions:
|
|
258
|
+
queue_healthy = True # Empty workers don't affect overall health
|
|
259
|
+
queue_status = ComponentStatusType.WARNING # But show as warning
|
|
260
|
+
else:
|
|
261
|
+
queue_healthy = worker_alive and failure_rate < 25
|
|
262
|
+
queue_status = (
|
|
263
|
+
ComponentStatusType.HEALTHY
|
|
264
|
+
if queue_healthy
|
|
265
|
+
else ComponentStatusType.UNHEALTHY
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
return queue_healthy, queue_status
|
|
269
|
+
|
|
270
|
+
# Empty worker (media/system queues)
|
|
271
|
+
healthy, status = check_empty_worker_status("media", False, False, 100)
|
|
272
|
+
assert healthy is True # Doesn't affect system health
|
|
273
|
+
assert status == ComponentStatusType.WARNING # But shows warning
|
|
274
|
+
|
|
275
|
+
# Active worker with good performance
|
|
276
|
+
healthy, status = check_empty_worker_status("load_test", True, True, 5)
|
|
277
|
+
assert healthy is True
|
|
278
|
+
assert status == ComponentStatusType.HEALTHY
|
|
279
|
+
|
|
280
|
+
# Active worker with high failure rate
|
|
281
|
+
healthy, status = check_empty_worker_status("load_test", True, True, 50)
|
|
282
|
+
assert healthy is False
|
|
283
|
+
assert status == ComponentStatusType.UNHEALTHY
|
|
284
|
+
|
|
285
|
+
# Active worker that's offline
|
|
286
|
+
healthy, status = check_empty_worker_status("load_test", True, False, 0)
|
|
287
|
+
assert healthy is False
|
|
288
|
+
assert status == ComponentStatusType.UNHEALTHY
|
|
289
|
+
|
|
290
|
+
def test_warning_propagation_to_parent_components(self) -> None:
|
|
291
|
+
"""Test warning propagation from queue -> queues -> worker."""
|
|
292
|
+
|
|
293
|
+
# Simulate the propagation logic used in worker health check
|
|
294
|
+
def check_warning_propagation(
|
|
295
|
+
sub_components: dict[str, ComponentStatus]
|
|
296
|
+
) -> ComponentStatusType:
|
|
297
|
+
"""Simulate queues component status determination."""
|
|
298
|
+
queues_healthy = all(
|
|
299
|
+
queue.healthy for queue in sub_components.values()
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
has_warnings = any(
|
|
303
|
+
queue.status == ComponentStatusType.WARNING
|
|
304
|
+
for queue in sub_components.values()
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
if has_warnings and queues_healthy:
|
|
308
|
+
return ComponentStatusType.WARNING
|
|
309
|
+
elif queues_healthy:
|
|
310
|
+
return ComponentStatusType.HEALTHY
|
|
311
|
+
else:
|
|
312
|
+
return ComponentStatusType.UNHEALTHY
|
|
313
|
+
|
|
314
|
+
# Test case: Some queues have warnings, all are healthy
|
|
315
|
+
sub_components = {
|
|
316
|
+
"media": ComponentStatus(
|
|
317
|
+
name="media",
|
|
318
|
+
status=ComponentStatusType.WARNING,
|
|
319
|
+
message="No tasks configured",
|
|
320
|
+
),
|
|
321
|
+
"system": ComponentStatus(
|
|
322
|
+
name="system",
|
|
323
|
+
status=ComponentStatusType.WARNING,
|
|
324
|
+
message="No tasks configured",
|
|
325
|
+
),
|
|
326
|
+
"load_test": ComponentStatus(
|
|
327
|
+
name="load_test",
|
|
328
|
+
status=ComponentStatusType.HEALTHY,
|
|
329
|
+
message="Active with completed tasks",
|
|
330
|
+
),
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
queues_status = check_warning_propagation(sub_components)
|
|
334
|
+
assert queues_status == ComponentStatusType.WARNING
|
|
335
|
+
|
|
336
|
+
# Test case: All components healthy
|
|
337
|
+
for component in sub_components.values():
|
|
338
|
+
component.status = ComponentStatusType.HEALTHY
|
|
339
|
+
|
|
340
|
+
queues_status = check_warning_propagation(sub_components)
|
|
341
|
+
assert queues_status == ComponentStatusType.HEALTHY
|
|
342
|
+
|
|
343
|
+
# Test case: One component unhealthy
|
|
344
|
+
sub_components["load_test"].status = ComponentStatusType.UNHEALTHY
|
|
345
|
+
|
|
346
|
+
queues_status = check_warning_propagation(sub_components)
|
|
347
|
+
assert queues_status == ComponentStatusType.UNHEALTHY
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
class TestComponentMetadata:
|
|
351
|
+
"""Test component metadata handling and serialization."""
|
|
352
|
+
|
|
353
|
+
def test_component_status_with_complex_metadata(self) -> None:
|
|
354
|
+
"""Test ComponentStatus with complex metadata for different component types."""
|
|
355
|
+
|
|
356
|
+
# Worker component metadata
|
|
357
|
+
worker_metadata = {
|
|
358
|
+
"total_queued": 5,
|
|
359
|
+
"total_completed": 1000,
|
|
360
|
+
"total_failed": 50,
|
|
361
|
+
"overall_failure_rate_percent": 4.8,
|
|
362
|
+
"redis_url": "redis://localhost:6379",
|
|
363
|
+
"queue_configuration": {
|
|
364
|
+
"load_test": {
|
|
365
|
+
"description": "Load testing tasks",
|
|
366
|
+
"max_jobs": 50,
|
|
367
|
+
"timeout_seconds": 300,
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
worker_status = ComponentStatus(
|
|
373
|
+
name="worker",
|
|
374
|
+
status=ComponentStatusType.WARNING,
|
|
375
|
+
message="arq worker infrastructure: 1/3 workers active",
|
|
376
|
+
metadata=worker_metadata,
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
# Verify metadata is preserved
|
|
380
|
+
assert worker_status.metadata["total_completed"] == 1000
|
|
381
|
+
assert worker_status.metadata["overall_failure_rate_percent"] == 4.8
|
|
382
|
+
assert "queue_configuration" in worker_status.metadata
|
|
383
|
+
|
|
384
|
+
# Cache component metadata
|
|
385
|
+
cache_metadata = {
|
|
386
|
+
"implementation": "redis",
|
|
387
|
+
"version": "7.0.0",
|
|
388
|
+
"connected_clients": 2,
|
|
389
|
+
"used_memory_human": "1.5M",
|
|
390
|
+
"uptime_in_seconds": 3600,
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
cache_status = ComponentStatus(
|
|
394
|
+
name="cache",
|
|
395
|
+
status=ComponentStatusType.HEALTHY,
|
|
396
|
+
message="Redis cache connection successful",
|
|
397
|
+
metadata=cache_metadata,
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
assert cache_status.metadata["implementation"] == "redis"
|
|
401
|
+
assert cache_status.metadata["uptime_in_seconds"] == 3600
|
|
402
|
+
|
|
403
|
+
def test_component_status_serialization(self) -> None:
|
|
404
|
+
"""Test that ComponentStatus can be properly serialized (for API responses)."""
|
|
405
|
+
|
|
406
|
+
status = ComponentStatus(
|
|
407
|
+
name="test_component",
|
|
408
|
+
status=ComponentStatusType.WARNING,
|
|
409
|
+
message="Component with warning",
|
|
410
|
+
response_time_ms=123.45,
|
|
411
|
+
metadata={"key": "value", "number": 42},
|
|
412
|
+
sub_components={
|
|
413
|
+
"sub1": ComponentStatus(
|
|
414
|
+
name="sub1",
|
|
415
|
+
status=ComponentStatusType.HEALTHY,
|
|
416
|
+
message="Sub-component OK",
|
|
417
|
+
)
|
|
418
|
+
}
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
# Convert to dict (simulates JSON serialization)
|
|
422
|
+
status_dict = status.model_dump()
|
|
423
|
+
|
|
424
|
+
# Verify structure
|
|
425
|
+
assert status_dict["name"] == "test_component"
|
|
426
|
+
assert status_dict["healthy"] is True
|
|
427
|
+
assert status_dict["status"] == "warning"
|
|
428
|
+
assert status_dict["message"] == "Component with warning"
|
|
429
|
+
assert status_dict["response_time_ms"] == 123.45
|
|
430
|
+
assert status_dict["metadata"]["key"] == "value"
|
|
431
|
+
assert "sub1" in status_dict["sub_components"]
|
|
432
|
+
assert status_dict["sub_components"]["sub1"]["status"] == "healthy"
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
{% if cookiecutter.include_database == "yes" %}
|
|
436
|
+
class TestDatabaseHealthCheck:
|
|
437
|
+
"""Test database health check functionality."""
|
|
438
|
+
|
|
439
|
+
@pytest.mark.asyncio
|
|
440
|
+
async def test_database_health_check_success(self, db_session) -> None:
|
|
441
|
+
"""Test successful database health check with mocked database."""
|
|
442
|
+
from app.services.system.health import check_database_health
|
|
443
|
+
|
|
444
|
+
# Mock everything to simulate successful database connection with
|
|
445
|
+
# enhanced metadata
|
|
446
|
+
with patch('app.services.system.health.settings') as mock_settings, \
|
|
447
|
+
patch('pathlib.Path.exists', return_value=True), \
|
|
448
|
+
patch('pathlib.Path.stat') as mock_stat, \
|
|
449
|
+
patch('app.core.db.db_session') as mock_db_session, \
|
|
450
|
+
patch('app.core.db.engine') as mock_engine, \
|
|
451
|
+
patch('app.services.system.health.sqlite3') as mock_sqlite3:
|
|
452
|
+
|
|
453
|
+
mock_settings.DATABASE_URL = "sqlite:///./data/test.db"
|
|
454
|
+
mock_settings.DATABASE_ENGINE_ECHO = False
|
|
455
|
+
|
|
456
|
+
# Mock SQLite version
|
|
457
|
+
mock_sqlite3.sqlite_version = "3.43.2"
|
|
458
|
+
|
|
459
|
+
# Mock file size
|
|
460
|
+
mock_stat_result = MagicMock()
|
|
461
|
+
mock_stat_result.st_size = 8192
|
|
462
|
+
mock_stat.return_value = mock_stat_result
|
|
463
|
+
|
|
464
|
+
# Mock engine pool
|
|
465
|
+
mock_engine.pool.size.return_value = 5
|
|
466
|
+
|
|
467
|
+
# Mock successful db_session with PRAGMA queries
|
|
468
|
+
mock_session = MagicMock()
|
|
469
|
+
|
|
470
|
+
# Mock PRAGMA query results
|
|
471
|
+
def mock_execute(query):
|
|
472
|
+
query_str = str(query).lower()
|
|
473
|
+
if "pragma foreign_keys" in query_str:
|
|
474
|
+
result = MagicMock()
|
|
475
|
+
result.fetchone.return_value = [1] # foreign_keys = ON
|
|
476
|
+
return result
|
|
477
|
+
elif "pragma journal_mode" in query_str:
|
|
478
|
+
result = MagicMock()
|
|
479
|
+
result.fetchone.return_value = ["delete"] # journal_mode = delete
|
|
480
|
+
return result
|
|
481
|
+
elif "pragma cache_size" in query_str:
|
|
482
|
+
result = MagicMock()
|
|
483
|
+
result.fetchone.return_value = [2000] # cache_size = 2000
|
|
484
|
+
return result
|
|
485
|
+
else:
|
|
486
|
+
# For "SELECT 1" query
|
|
487
|
+
return None
|
|
488
|
+
|
|
489
|
+
mock_session.execute.side_effect = mock_execute
|
|
490
|
+
mock_db_session.return_value.__enter__ = MagicMock(
|
|
491
|
+
return_value=mock_session
|
|
492
|
+
)
|
|
493
|
+
mock_db_session.return_value.__exit__ = MagicMock(return_value=None)
|
|
494
|
+
|
|
495
|
+
result = await check_database_health()
|
|
496
|
+
|
|
497
|
+
# Test basic health status
|
|
498
|
+
assert result.name == "database"
|
|
499
|
+
assert result.status == ComponentStatusType.HEALTHY
|
|
500
|
+
assert result.message == "Database connection successful"
|
|
501
|
+
|
|
502
|
+
# Test existing metadata fields
|
|
503
|
+
assert result.metadata["implementation"] == "sqlite"
|
|
504
|
+
assert result.metadata["database_exists"] is True
|
|
505
|
+
assert result.metadata["engine_echo"] is False
|
|
506
|
+
assert result.metadata["url"] == "sqlite:///./data/test.db"
|
|
507
|
+
|
|
508
|
+
# Test enhanced metadata fields
|
|
509
|
+
assert result.metadata["version"] == "3.43.2"
|
|
510
|
+
assert result.metadata["file_size_bytes"] == 8192
|
|
511
|
+
assert result.metadata["file_size_human"] == "8.0 KB"
|
|
512
|
+
assert result.metadata["connection_pool_size"] == 5
|
|
513
|
+
|
|
514
|
+
# Test PRAGMA settings
|
|
515
|
+
assert "pragma_settings" in result.metadata
|
|
516
|
+
pragma_settings = result.metadata["pragma_settings"]
|
|
517
|
+
assert pragma_settings["foreign_keys"] is True
|
|
518
|
+
assert pragma_settings["journal_mode"] == "delete"
|
|
519
|
+
assert pragma_settings["cache_size"] == 2000
|
|
520
|
+
assert result.metadata["wal_enabled"] is False
|
|
521
|
+
|
|
522
|
+
@pytest.mark.asyncio
|
|
523
|
+
async def test_database_health_check_import_error(self) -> None:
|
|
524
|
+
"""Test database health check when db module not available."""
|
|
525
|
+
from app.services.system.health import check_database_health
|
|
526
|
+
|
|
527
|
+
# Mock ImportError when trying to import db_session from app.core.db
|
|
528
|
+
import builtins
|
|
529
|
+
real_import = builtins.__import__
|
|
530
|
+
|
|
531
|
+
def mock_import(name, *args, **kwargs):
|
|
532
|
+
if name == 'app.core.db':
|
|
533
|
+
raise ImportError("No db module")
|
|
534
|
+
return real_import(name, *args, **kwargs)
|
|
535
|
+
|
|
536
|
+
with patch('builtins.__import__', side_effect=mock_import):
|
|
537
|
+
result = await check_database_health()
|
|
538
|
+
|
|
539
|
+
assert result.name == "database"
|
|
540
|
+
assert result.status == ComponentStatusType.UNHEALTHY
|
|
541
|
+
assert result.message == "Database module not available"
|
|
542
|
+
assert result.metadata["error"] == (
|
|
543
|
+
"Database module not imported or configured"
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
@pytest.mark.asyncio
|
|
547
|
+
async def test_database_health_check_missing_file(self) -> None:
|
|
548
|
+
"""Test database health check when database file doesn't exist."""
|
|
549
|
+
from app.services.system.health import check_database_health
|
|
550
|
+
|
|
551
|
+
# Patch settings.DATABASE_URL to point to non-existent file
|
|
552
|
+
with patch('app.services.system.health.settings') as mock_settings:
|
|
553
|
+
mock_settings.DATABASE_URL = "sqlite:///./nonexistent/test.db"
|
|
554
|
+
|
|
555
|
+
result = await check_database_health()
|
|
556
|
+
|
|
557
|
+
assert result.name == "database"
|
|
558
|
+
assert result.status == ComponentStatusType.WARNING
|
|
559
|
+
assert "Database not initialized" in result.message
|
|
560
|
+
assert result.metadata["database_exists"] is False
|
|
561
|
+
assert "nonexistent/test.db" in result.metadata["expected_path"]
|
|
562
|
+
|
|
563
|
+
@pytest.mark.asyncio
|
|
564
|
+
async def test_database_health_check_connection_failure(self) -> None:
|
|
565
|
+
"""Test database health check when connection fails due to permissions."""
|
|
566
|
+
from app.services.system.health import check_database_health
|
|
567
|
+
|
|
568
|
+
# Mock settings and Path.exists to simulate file exists but connection fails
|
|
569
|
+
with patch('app.services.system.health.settings') as mock_settings, \
|
|
570
|
+
patch('pathlib.Path.exists', return_value=True), \
|
|
571
|
+
patch('app.core.db.db_session') as mock_db_session:
|
|
572
|
+
|
|
573
|
+
# File exists but connection fails
|
|
574
|
+
mock_settings.DATABASE_URL = "sqlite:///./data/test.db"
|
|
575
|
+
|
|
576
|
+
# Mock db_session to simulate connection error
|
|
577
|
+
mock_session = MagicMock()
|
|
578
|
+
mock_session.execute.side_effect = Exception("unable to open database file")
|
|
579
|
+
mock_db_session.return_value.__enter__ = MagicMock(
|
|
580
|
+
return_value=mock_session
|
|
581
|
+
)
|
|
582
|
+
mock_db_session.return_value.__exit__ = MagicMock(return_value=None)
|
|
583
|
+
|
|
584
|
+
result = await check_database_health()
|
|
585
|
+
|
|
586
|
+
assert result.name == "database"
|
|
587
|
+
assert result.status == ComponentStatusType.WARNING
|
|
588
|
+
assert result.message == "Database file not accessible"
|
|
589
|
+
assert "unable to open database file" in result.metadata["error"]
|
|
590
|
+
|
|
591
|
+
def test_database_status_metadata_structure(self) -> None:
|
|
592
|
+
"""Test that database health check includes proper metadata."""
|
|
593
|
+
from app.services.system.models import ComponentStatus, ComponentStatusType
|
|
594
|
+
|
|
595
|
+
# Test successful database component metadata with enhanced fields
|
|
596
|
+
database_metadata = {
|
|
597
|
+
"implementation": "sqlite",
|
|
598
|
+
"url": "sqlite:///:memory:",
|
|
599
|
+
"database_exists": True,
|
|
600
|
+
"engine_echo": False,
|
|
601
|
+
"version": "3.43.2",
|
|
602
|
+
"file_size_bytes": 8192,
|
|
603
|
+
"file_size_human": "8.0 KB",
|
|
604
|
+
"connection_pool_size": 1,
|
|
605
|
+
"pragma_settings": {
|
|
606
|
+
"foreign_keys": True,
|
|
607
|
+
"journal_mode": "delete",
|
|
608
|
+
"cache_size": 2000
|
|
609
|
+
},
|
|
610
|
+
"wal_enabled": False,
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
database_status = ComponentStatus(
|
|
614
|
+
name="database",
|
|
615
|
+
status=ComponentStatusType.HEALTHY,
|
|
616
|
+
message="Database connection successful",
|
|
617
|
+
metadata=database_metadata,
|
|
618
|
+
)
|
|
619
|
+
|
|
620
|
+
# Test existing fields
|
|
621
|
+
assert database_status.metadata["implementation"] == "sqlite"
|
|
622
|
+
assert "sqlite://" in database_status.metadata["url"]
|
|
623
|
+
assert database_status.metadata["database_exists"] is True
|
|
624
|
+
assert database_status.metadata["engine_echo"] is False
|
|
625
|
+
|
|
626
|
+
# Test enhanced metadata fields
|
|
627
|
+
assert database_status.metadata["version"] == "3.43.2"
|
|
628
|
+
assert database_status.metadata["file_size_bytes"] == 8192
|
|
629
|
+
assert database_status.metadata["file_size_human"] == "8.0 KB"
|
|
630
|
+
assert database_status.metadata["connection_pool_size"] == 1
|
|
631
|
+
assert isinstance(database_status.metadata["pragma_settings"], dict)
|
|
632
|
+
assert database_status.metadata["wal_enabled"] is False
|
|
633
|
+
{% endif %}
|