Enhance host status tracking by parsing Ansible PLAY RECAP to update host reachability and last_seen timestamps after health-check playbook executions, add inventory group resolution to host API responses, and trigger automatic data refresh in dashboard after task completion to reflect updated host health indicators
Tests / Backend Tests (Python) (3.10) (push) Has been cancelled
Tests / Backend Tests (Python) (3.11) (push) Has been cancelled
Tests / Backend Tests (Python) (3.12) (push) Has been cancelled
Tests / Frontend Tests (JS) (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
Tests / All Tests Passed (push) Has been cancelled

This commit is contained in:
2025-12-22 10:43:17 -05:00
parent 6c51fb5c75
commit 46823eb42d
15 changed files with 1161 additions and 8 deletions
+37
View File
@@ -596,6 +596,43 @@ class TestHostsFallback:
assert isinstance(response.json(), list)
class TestHostsInventoryGroupsMerge:
"""Tests pour la fusion des groupes depuis l'inventaire Ansible."""
async def test_list_hosts_merges_inventory_role_groups(
self, client: AsyncClient, db_session, host_factory
):
"""Les groups incluent les role_* depuis l'inventaire."""
await host_factory.create(
db_session,
name="mergegroups.local",
ip_address="10.10.10.10",
ansible_group="env_prod",
)
from app.schemas.host_api import AnsibleInventoryHost
with patch("app.routes.hosts.ansible_service") as mock_ansible:
mock_ansible.get_hosts_from_inventory.return_value = [
AnsibleInventoryHost(
name="mergegroups.local",
ansible_host="10.10.10.10",
group="env_prod",
groups=["env_prod", "role_sbc"],
vars={},
)
]
response = await client.get("/api/hosts")
assert response.status_code == 200
hosts = response.json()
our_host = next((h for h in hosts if h["name"] == "mergegroups.local"), None)
assert our_host is not None
assert "env_prod" in our_host["groups"]
assert "role_sbc" in our_host["groups"]
class TestHostToResponse:
"""Tests pour la fonction _host_to_response."""
+353
View File
@@ -11,6 +11,7 @@ Couvre:
import pytest
from unittest.mock import patch, AsyncMock, MagicMock
from httpx import AsyncClient
from datetime import datetime
pytestmark = pytest.mark.unit
@@ -172,6 +173,358 @@ class TestExecuteTask:
assert response.status_code in [200, 400, 422]
class TestHostHealthStatusUpdate:
"""Tests du lien health-check -> MAJ status/last_seen host en base."""
async def test_health_check_updates_host_in_db(
self,
async_engine,
db_session,
host_factory,
mock_ws_manager,
mock_notification_service,
):
# Créer un host en base, avec un nom qui correspondra au target
host = await host_factory.create(
db_session,
name="test-host-1.local",
ip_address="192.168.1.10",
status="unknown",
reachable=False,
last_seen=None,
)
# Exécuter le runner directement (le endpoint /api/tasks lance un asyncio.create_task)
from app.routes import tasks as tasks_routes
# Forcer le runner à utiliser le même engine in-memory que le reste des tests
from sqlalchemy.ext.asyncio import async_sessionmaker, AsyncSession
test_session_maker = async_sessionmaker(
async_engine,
class_=AsyncSession,
expire_on_commit=False,
autoflush=False,
)
with patch.object(tasks_routes.ansible_service, "execute_playbook", new=AsyncMock(return_value={
"success": True,
"return_code": 0,
"stdout": "ok",
"stderr": "",
})), patch.object(tasks_routes, "async_session_maker", test_session_maker):
await tasks_routes._execute_task_playbook(
task_id="task-health-1",
task_name="Vérification de santé",
playbook="health-check.yml",
target=host.name,
extra_vars=None,
check_mode=False,
)
# Recharger depuis la DB et vérifier MAJ
from app.crud.host import HostRepository
async with test_session_maker() as verify_session:
repo = HostRepository(verify_session)
updated = await repo.get(host.id)
assert updated is not None
assert updated.status == "online"
assert updated.reachable is True
assert updated.last_seen is not None
assert isinstance(updated.last_seen, datetime)
async def test_health_check_all_updates_each_host_from_recap(
self,
async_engine,
db_session,
host_factory,
mock_ws_manager,
mock_notification_service,
):
host1 = await host_factory.create(
db_session,
name="host1",
ip_address="192.168.1.11",
status="unknown",
reachable=False,
last_seen=None,
)
host2 = await host_factory.create(
db_session,
name="host2",
ip_address="192.168.1.12",
status="unknown",
reachable=False,
last_seen=None,
)
from app.routes import tasks as tasks_routes
from sqlalchemy.ext.asyncio import async_sessionmaker, AsyncSession
test_session_maker = async_sessionmaker(
async_engine,
class_=AsyncSession,
expire_on_commit=False,
autoflush=False,
)
stdout = (
"PLAY RECAP\n"
"host1 : ok=1 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0\n"
"host2 : ok=0 changed=0 unreachable=1 failed=0 skipped=0 rescued=0 ignored=0\n"
)
with patch.object(tasks_routes.ansible_service, "execute_playbook", new=AsyncMock(return_value={
"success": False,
"return_code": 2,
"stdout": stdout,
"stderr": "",
})), patch.object(tasks_routes, "async_session_maker", test_session_maker):
await tasks_routes._execute_task_playbook(
task_id="task-health-all-1",
task_name="Vérification de santé",
playbook="health-check.yml",
target="all",
extra_vars=None,
check_mode=False,
)
from app.crud.host import HostRepository
async with test_session_maker() as verify_session:
repo = HostRepository(verify_session)
updated1 = await repo.get(host1.id)
updated2 = await repo.get(host2.id)
assert updated1 is not None
assert updated1.status == "online"
assert updated1.reachable is True
assert updated1.last_seen is not None
assert updated2 is not None
assert updated2.status == "offline"
assert updated2.reachable is False
assert updated2.last_seen is not None
class TestAnsibleExecuteHealthCheckStatusUpdate:
async def test_ansible_execute_health_check_updates_hosts_for_role_group(
self,
client: AsyncClient,
db_session,
host_factory,
):
host1 = await host_factory.create(
db_session,
name="orangepi.pc.home",
ip_address="10.10.0.11",
status="unknown",
reachable=False,
last_seen=None,
)
host2 = await host_factory.create(
db_session,
name="raspi.4gb.home",
ip_address="10.10.0.12",
status="unknown",
reachable=False,
last_seen=None,
)
from app.schemas.host_api import AnsibleInventoryHost
inv_hosts = [
AnsibleInventoryHost(name="orangepi", ansible_host=host1.ip_address, group="env_homelab", groups=["role_sbc", "env_homelab"], vars={}),
AnsibleInventoryHost(name="raspi", ansible_host=host2.ip_address, group="env_homelab", groups=["role_sbc", "env_homelab"], vars={}),
]
stdout = (
"PLAY RECAP\n"
"orangepi : ok=1 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0\n"
"raspi : ok=0 changed=0 unreachable=1 failed=0 skipped=0 rescued=0 ignored=0\n"
)
with patch("app.routes.ansible.ansible_service.execute_playbook", new=AsyncMock(return_value={
"success": False,
"return_code": 2,
"stdout": stdout,
"stderr": "",
"execution_time": 1.0,
})), patch("app.routes.ansible.ansible_service.get_hosts_from_inventory", new=MagicMock(return_value=inv_hosts)):
resp = await client.post(
"/api/ansible/execute",
json={
"playbook": "health-check.yml",
"target": "role_sbc",
"check_mode": False,
"verbose": True,
},
)
assert resp.status_code == 200
# Verify host statuses are updated via DB session
from app.crud.host import HostRepository
repo = HostRepository(db_session)
updated1 = await repo.get(host1.id)
updated2 = await repo.get(host2.id)
assert updated1 is not None and updated1.status == "online" and updated1.reachable is True and updated1.last_seen is not None
assert updated2 is not None and updated2.status == "offline" and updated2.reachable is False and updated2.last_seen is not None
async def test_health_check_group_updates_each_host_from_recap(
self,
async_engine,
db_session,
host_factory,
mock_ws_manager,
mock_notification_service,
):
host1 = await host_factory.create(
db_session,
name="group-host-1",
ip_address="10.0.0.1",
status="unknown",
reachable=False,
last_seen=None,
)
host2 = await host_factory.create(
db_session,
name="group-host-2",
ip_address="10.0.0.2",
status="unknown",
reachable=False,
last_seen=None,
)
from app.routes import tasks as tasks_routes
from sqlalchemy.ext.asyncio import async_sessionmaker, AsyncSession
test_session_maker = async_sessionmaker(
async_engine,
class_=AsyncSession,
expire_on_commit=False,
autoflush=False,
)
stdout = (
"PLAY RECAP\n"
"group-host-1 : ok=1 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0\n"
"group-host-2 : ok=1 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0\n"
)
with patch.object(tasks_routes.ansible_service, "execute_playbook", new=AsyncMock(return_value={
"success": True,
"return_code": 0,
"stdout": stdout,
"stderr": "",
})), patch.object(tasks_routes, "async_session_maker", test_session_maker):
await tasks_routes._execute_task_playbook(
task_id="task-health-group-1",
task_name="Vérification de santé",
playbook="health-check.yml",
target="env_test",
extra_vars=None,
check_mode=False,
)
from app.crud.host import HostRepository
async with test_session_maker() as verify_session:
repo = HostRepository(verify_session)
updated1 = await repo.get(host1.id)
updated2 = await repo.get(host2.id)
assert updated1 is not None and updated1.status == "online" and updated1.reachable is True and updated1.last_seen is not None
assert updated2 is not None and updated2.status == "online" and updated2.reachable is True and updated2.last_seen is not None
async def test_health_check_group_updates_hosts_when_recap_uses_inventory_alias(
self,
async_engine,
db_session,
host_factory,
mock_ws_manager,
mock_notification_service,
):
# DB stores hosts by their FQDN/IP, but Ansible recap may print the inventory alias.
host1 = await host_factory.create(
db_session,
name="sbc-01.local",
ip_address="10.42.0.11",
status="unknown",
reachable=False,
last_seen=None,
)
host2 = await host_factory.create(
db_session,
name="sbc-02.local",
ip_address="10.42.0.12",
status="unknown",
reachable=False,
last_seen=None,
)
from app.routes import tasks as tasks_routes
from sqlalchemy.ext.asyncio import async_sessionmaker, AsyncSession
from app.schemas.host_api import AnsibleInventoryHost
test_session_maker = async_sessionmaker(
async_engine,
class_=AsyncSession,
expire_on_commit=False,
autoflush=False,
)
# Inventory aliases for the role group "role_sbc"
inv_hosts = [
AnsibleInventoryHost(name="sbc-01", ansible_host=host1.ip_address, group="env_test", groups=["role_sbc", "env_test"], vars={}),
AnsibleInventoryHost(name="sbc-02", ansible_host=host2.ip_address, group="env_test", groups=["role_sbc", "env_test"], vars={}),
]
stdout = (
"PLAY RECAP\n"
"sbc-01 : ok=1 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0\n"
"sbc-02 : ok=0 changed=0 unreachable=1 failed=0 skipped=0 rescued=0 ignored=0\n"
)
with patch.object(tasks_routes.ansible_service, "execute_playbook", new=AsyncMock(return_value={
"success": False,
"return_code": 2,
"stdout": stdout,
"stderr": "",
})), patch.object(tasks_routes.ansible_service, "get_hosts_from_inventory", new=MagicMock(return_value=inv_hosts)), patch.object(
tasks_routes,
"async_session_maker",
test_session_maker,
):
await tasks_routes._execute_task_playbook(
task_id="task-health-role-alias-1",
task_name="Vérification de santé",
playbook="health-check.yml",
target="role_sbc",
extra_vars=None,
check_mode=False,
)
from app.crud.host import HostRepository
async with test_session_maker() as verify_session:
repo = HostRepository(verify_session)
updated1 = await repo.get(host1.id)
updated2 = await repo.get(host2.id)
assert updated1 is not None
assert updated1.status == "online"
assert updated1.reachable is True
assert updated1.last_seen is not None
assert updated2 is not None
assert updated2.status == "offline"
assert updated2.reachable is False
assert updated2.last_seen is not None
class TestGetTaskById:
"""Tests pour GET /api/tasks/{task_id}."""