update check active
This commit is contained in:
@@ -5,9 +5,11 @@ import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.core.command_runner import CommandError
|
||||
from app.core.installer import APT_NONINTERACTIVE_ENV, AptInstaller
|
||||
from app.core.manifest_validator import ManifestValidator
|
||||
from app.core.task_runner import TaskRunner
|
||||
from app.core.service_manager import ServiceManager
|
||||
from app.core.task_runner import InstalledComponentVerificationError, TaskRunner
|
||||
|
||||
|
||||
def apt_manifest(component: dict | None = None) -> dict:
|
||||
@@ -37,6 +39,7 @@ class FakeCommandRunner:
|
||||
command: list[str],
|
||||
timeout: int | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
log_output: bool = True,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
self.calls.append((command, timeout, env))
|
||||
return subprocess.CompletedProcess(command, 0, stdout="accepting connections\n", stderr="")
|
||||
@@ -89,6 +92,7 @@ class AptInstallerTests(unittest.TestCase):
|
||||
installer.update_package_index()
|
||||
installer.install_package("postgresql")
|
||||
installer.wait_for_postgresql(attempts=1, delay_seconds=0)
|
||||
self.assertTrue(installer.is_postgresql_ready())
|
||||
|
||||
self.assertEqual(runner.calls[0][0], ["apt-get", "update"])
|
||||
self.assertEqual(
|
||||
@@ -108,6 +112,78 @@ class AptInstallerTests(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(runner.calls[1][2], APT_NONINTERACTIVE_ENV)
|
||||
self.assertEqual(runner.calls[2][0], ["pg_isready", "--timeout=5"])
|
||||
self.assertEqual(runner.calls[3][0], ["pg_isready", "--timeout=5"])
|
||||
|
||||
def test_discovers_concrete_service_units_without_shell_commands(self) -> None:
|
||||
class PackageFileRunner(FakeCommandRunner):
|
||||
def run(
|
||||
self,
|
||||
command: list[str],
|
||||
timeout: int | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
log_output: bool = True,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
self.calls.append((command, timeout, env))
|
||||
return subprocess.CompletedProcess(
|
||||
command,
|
||||
0,
|
||||
stdout=(
|
||||
"/usr/lib/systemd/system/example.service\n"
|
||||
"/lib/systemd/system/example-worker.service\n"
|
||||
"/lib/systemd/system/example@.service\n"
|
||||
"/usr/share/doc/example/README\n"
|
||||
),
|
||||
stderr="",
|
||||
)
|
||||
|
||||
runner = PackageFileRunner()
|
||||
services = AptInstaller(runner).discover_service_units("example")
|
||||
|
||||
self.assertEqual(services, ["example-worker.service", "example.service"])
|
||||
self.assertEqual(runner.calls[0][0], ["dpkg-query", "-L", "example"])
|
||||
|
||||
|
||||
class ServiceManagerTests(unittest.TestCase):
|
||||
def test_reports_full_active_service_state(self) -> None:
|
||||
class StatusRunner:
|
||||
def run(self, command: list[str], timeout: int | None = None) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.CompletedProcess(
|
||||
command,
|
||||
0,
|
||||
stdout=(
|
||||
"LoadState=loaded\n"
|
||||
"ActiveState=active\n"
|
||||
"SubState=running\n"
|
||||
"UnitFileState=enabled\n"
|
||||
),
|
||||
stderr="",
|
||||
)
|
||||
|
||||
status = ServiceManager(StatusRunner()).get_service_status("example.service")
|
||||
|
||||
self.assertTrue(status["active"])
|
||||
self.assertTrue(status["enabled"])
|
||||
self.assertTrue(status["healthy"])
|
||||
self.assertEqual(status["status"], "active")
|
||||
self.assertEqual(status["subState"], "running")
|
||||
|
||||
def test_preserves_inactive_state_from_nonzero_systemctl_result(self) -> None:
|
||||
class InactiveRunner:
|
||||
def run(self, command: list[str], timeout: int | None = None) -> subprocess.CompletedProcess[str]:
|
||||
stdout = (
|
||||
"LoadState=loaded\n"
|
||||
"ActiveState=inactive\n"
|
||||
"SubState=dead\n"
|
||||
"UnitFileState=disabled\n"
|
||||
)
|
||||
raise CommandError(command, 3, stdout, "")
|
||||
|
||||
status = ServiceManager(InactiveRunner()).get_service_status("example.service")
|
||||
|
||||
self.assertFalse(status["active"])
|
||||
self.assertFalse(status["healthy"])
|
||||
self.assertEqual(status["activeState"], "inactive")
|
||||
self.assertEqual(status["unitFileState"], "disabled")
|
||||
|
||||
|
||||
class FakeRepository:
|
||||
@@ -126,6 +202,42 @@ class FakeRepository:
|
||||
self.installed_component = component
|
||||
|
||||
|
||||
class FakeRunRepository:
|
||||
def __init__(self) -> None:
|
||||
self.task = {"current_component_id": "postgresql"}
|
||||
self.task_updates: list[dict] = []
|
||||
self.logs: list[tuple[str, str]] = []
|
||||
self.installed_app_updates: list[dict] = []
|
||||
|
||||
def update_task(self, task_id: str, **fields: object) -> None:
|
||||
self.task_updates.append(dict(fields))
|
||||
|
||||
def update_task_component(self, task_id: str, component_id: str, **fields: object) -> None:
|
||||
return None
|
||||
|
||||
def add_log(self, task_id: str, level: str, message: str) -> None:
|
||||
self.logs.append((level, message))
|
||||
|
||||
def get_task(self, task_id: str) -> dict:
|
||||
return self.task
|
||||
|
||||
def export_manifest_hash(self, manifest: dict) -> str:
|
||||
return "manifest"
|
||||
|
||||
def upsert_installed_app(
|
||||
self,
|
||||
app_id: str,
|
||||
app_name: str,
|
||||
version: str,
|
||||
manifest_hash: str,
|
||||
open_url: str | None,
|
||||
status: str = "installed",
|
||||
) -> None:
|
||||
self.installed_app_updates.append(
|
||||
{"app_id": app_id, "version": version, "status": status}
|
||||
)
|
||||
|
||||
|
||||
class FakeAptInstaller:
|
||||
actions: list[str] = []
|
||||
|
||||
@@ -145,6 +257,10 @@ class FakeAptInstaller:
|
||||
def wait_for_postgresql(self) -> None:
|
||||
self.actions.append("pg_isready")
|
||||
|
||||
def discover_service_units(self, package_name: str) -> list[str]:
|
||||
self.actions.append(f"discover:{package_name}")
|
||||
return []
|
||||
|
||||
|
||||
class FakeServiceManager:
|
||||
actions: list[str] = []
|
||||
@@ -161,8 +277,61 @@ class FakeServiceManager:
|
||||
def assert_service_active(self, service_name: str) -> None:
|
||||
self.actions.append(f"active:{service_name}")
|
||||
|
||||
def get_service_status(self, service_name: str) -> dict[str, object]:
|
||||
self.actions.append(f"status:{service_name}")
|
||||
return {
|
||||
"serviceName": service_name,
|
||||
"loadState": "loaded",
|
||||
"activeState": "active",
|
||||
"subState": "running",
|
||||
"unitFileState": "enabled",
|
||||
"active": True,
|
||||
"enabled": True,
|
||||
"healthy": True,
|
||||
"status": "active",
|
||||
"checkedAt": "2026-07-20T00:00:00Z",
|
||||
"errorMessage": None,
|
||||
}
|
||||
|
||||
|
||||
class AptTaskRunnerTests(unittest.TestCase):
|
||||
def test_only_post_install_verification_failure_marks_app_attention(self) -> None:
|
||||
manifest = apt_manifest()
|
||||
repository = FakeRunRepository()
|
||||
runner = TaskRunner(repository)
|
||||
|
||||
with (
|
||||
patch.object(runner, "_require_root_if_available"),
|
||||
patch.object(runner, "_resolve_manifest", return_value=manifest),
|
||||
patch.object(
|
||||
runner,
|
||||
"_install_manifest",
|
||||
side_effect=InstalledComponentVerificationError("service unhealthy"),
|
||||
),
|
||||
):
|
||||
runner.run_install("task-attention", SimpleNamespace(), "install")
|
||||
|
||||
self.assertEqual(
|
||||
repository.installed_app_updates,
|
||||
[{"app_id": "postgresql", "version": "16", "status": "attention"}],
|
||||
)
|
||||
self.assertEqual(repository.task_updates[-1]["status"], "failed")
|
||||
|
||||
def test_early_install_failure_does_not_overwrite_installed_app_version(self) -> None:
|
||||
manifest = apt_manifest()
|
||||
repository = FakeRunRepository()
|
||||
runner = TaskRunner(repository)
|
||||
|
||||
with (
|
||||
patch.object(runner, "_require_root_if_available"),
|
||||
patch.object(runner, "_resolve_manifest", return_value=manifest),
|
||||
patch.object(runner, "_install_manifest", side_effect=RuntimeError("apt update failed")),
|
||||
):
|
||||
runner.run_install("task-early-failure", SimpleNamespace(), "update")
|
||||
|
||||
self.assertEqual(repository.installed_app_updates, [])
|
||||
self.assertEqual(repository.task_updates[-1]["status"], "failed")
|
||||
|
||||
def test_postgresql_install_verifies_service_and_readiness(self) -> None:
|
||||
repository = FakeRepository()
|
||||
FakeAptInstaller.actions = []
|
||||
@@ -186,19 +355,101 @@ class AptTaskRunnerTests(unittest.TestCase):
|
||||
|
||||
self.assertEqual(
|
||||
FakeAptInstaller.actions,
|
||||
["update", "install:postgresql", "version:postgresql", "pg_isready"],
|
||||
[
|
||||
"update",
|
||||
"install:postgresql",
|
||||
"version:postgresql",
|
||||
"discover:postgresql",
|
||||
"pg_isready",
|
||||
],
|
||||
)
|
||||
self.assertEqual(
|
||||
FakeServiceManager.actions,
|
||||
[
|
||||
"enable:postgresql.service",
|
||||
"start:postgresql.service",
|
||||
"active:postgresql.service",
|
||||
"status:postgresql.service",
|
||||
],
|
||||
)
|
||||
self.assertEqual(repository.installed_component["version"], "16+257build1")
|
||||
self.assertEqual(repository.installed_component["serviceName"], "postgresql.service")
|
||||
self.assertEqual(repository.component_updates[-1]["current_step"], "checking PostgreSQL readiness")
|
||||
self.assertEqual(repository.installed_component["serviceCheckStatus"], "healthy")
|
||||
self.assertEqual(repository.installed_component["serviceChecks"][0]["readinessType"], "postgresql")
|
||||
self.assertEqual(repository.installed_component["serviceChecks"][0]["readinessStatus"], "ready")
|
||||
self.assertEqual(repository.component_updates[-1]["service_check_status"], "healthy")
|
||||
self.assertEqual(repository.component_updates[-1]["current_step"], "service checks completed")
|
||||
|
||||
def test_generic_apt_service_is_checked_but_not_started_automatically(self) -> None:
|
||||
class GenericAptInstaller(FakeAptInstaller):
|
||||
def discover_service_units(self, package_name: str) -> list[str]:
|
||||
self.actions.append(f"discover:{package_name}")
|
||||
return ["example.service"]
|
||||
|
||||
class InactiveServiceManager(FakeServiceManager):
|
||||
def get_service_status(self, service_name: str) -> dict[str, object]:
|
||||
self.actions.append(f"status:{service_name}")
|
||||
return {
|
||||
"serviceName": service_name,
|
||||
"loadState": "loaded",
|
||||
"activeState": "inactive",
|
||||
"subState": "dead",
|
||||
"unitFileState": "disabled",
|
||||
"active": False,
|
||||
"enabled": False,
|
||||
"healthy": False,
|
||||
"status": "inactive",
|
||||
"checkedAt": "2026-07-20T00:00:00Z",
|
||||
"errorMessage": None,
|
||||
}
|
||||
|
||||
repository = FakeRepository()
|
||||
FakeAptInstaller.actions = []
|
||||
FakeServiceManager.actions = []
|
||||
|
||||
with (
|
||||
patch("app.core.task_runner.CommandRunner", return_value=object()),
|
||||
patch("app.core.task_runner.AptInstaller", GenericAptInstaller),
|
||||
patch("app.core.task_runner.ServiceManager", InactiveServiceManager),
|
||||
):
|
||||
TaskRunner(repository)._install_apt_component(
|
||||
"task-2",
|
||||
"example-app",
|
||||
{
|
||||
"componentId": "example",
|
||||
"type": "apt",
|
||||
"packageName": "example",
|
||||
"version": "1",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertNotIn("start:example.service", FakeServiceManager.actions)
|
||||
self.assertNotIn("enable:example.service", FakeServiceManager.actions)
|
||||
self.assertIn("status:example.service", FakeServiceManager.actions)
|
||||
self.assertEqual(repository.installed_component["serviceCheckStatus"], "unhealthy")
|
||||
|
||||
def test_apt_package_without_service_is_marked_not_applicable(self) -> None:
|
||||
repository = FakeRepository()
|
||||
FakeAptInstaller.actions = []
|
||||
FakeServiceManager.actions = []
|
||||
|
||||
with (
|
||||
patch("app.core.task_runner.CommandRunner", return_value=object()),
|
||||
patch("app.core.task_runner.AptInstaller", FakeAptInstaller),
|
||||
patch("app.core.task_runner.ServiceManager", FakeServiceManager),
|
||||
):
|
||||
TaskRunner(repository)._install_apt_component(
|
||||
"task-3",
|
||||
"utility-app",
|
||||
{
|
||||
"componentId": "utility",
|
||||
"type": "apt",
|
||||
"packageName": "utility",
|
||||
"version": "1",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(repository.installed_component["serviceCheckStatus"], "not-applicable")
|
||||
self.assertEqual(repository.installed_component["serviceChecks"], [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
194
agent/tests/test_service_check_persistence.py
Normal file
194
agent/tests/test_service_check_persistence.py
Normal file
@@ -0,0 +1,194 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import unittest
|
||||
from contextlib import closing
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.api import apps as apps_api
|
||||
from app.api import tasks as tasks_api
|
||||
from app.storage.database import initialize_database
|
||||
from app.storage.repository import Repository
|
||||
|
||||
|
||||
SERVICE_CHECK = {
|
||||
"serviceName": "postgresql.service",
|
||||
"loadState": "loaded",
|
||||
"activeState": "active",
|
||||
"subState": "running",
|
||||
"unitFileState": "enabled",
|
||||
"active": True,
|
||||
"enabled": True,
|
||||
"healthy": True,
|
||||
"status": "active",
|
||||
"readinessStatus": "ready",
|
||||
"checkedAt": "2026-07-20T00:00:00Z",
|
||||
"errorMessage": None,
|
||||
}
|
||||
|
||||
|
||||
class ServiceCheckPersistenceTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temp_dir = tempfile.TemporaryDirectory()
|
||||
root = Path(self.temp_dir.name)
|
||||
fake_settings = SimpleNamespace(
|
||||
db_path=root / "agent.db",
|
||||
cache_dir=root / "cache",
|
||||
log_dir=root / "logs",
|
||||
)
|
||||
self.settings_patch = patch("app.storage.database.settings", fake_settings)
|
||||
self.settings_patch.start()
|
||||
initialize_database()
|
||||
self.db_path = fake_settings.db_path
|
||||
self.repository = Repository()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.settings_patch.stop()
|
||||
self.temp_dir.cleanup()
|
||||
|
||||
def test_task_component_api_round_trips_structured_service_checks(self) -> None:
|
||||
self.repository.create_task("task-1", "install", "postgresql", "PostgreSQL")
|
||||
self.repository.create_task_component("task-1", "postgresql", "postgresql", "apt", 10)
|
||||
self.repository.update_task_component(
|
||||
"task-1",
|
||||
"postgresql",
|
||||
service_check_status="healthy",
|
||||
service_checks=[SERVICE_CHECK],
|
||||
)
|
||||
|
||||
with patch.object(tasks_api, "repository", self.repository):
|
||||
response = tasks_api.get_task_components("task-1")
|
||||
|
||||
component = response["components"][0]
|
||||
self.assertEqual(component["serviceCheckStatus"], "healthy")
|
||||
self.assertEqual(component["serviceChecks"], [SERVICE_CHECK])
|
||||
|
||||
def test_initialize_database_migrates_existing_component_tables(self) -> None:
|
||||
with closing(sqlite3.connect(self.db_path)) as connection:
|
||||
connection.execute("DROP TABLE task_components")
|
||||
connection.execute("DROP TABLE installed_components")
|
||||
connection.executescript(
|
||||
"""
|
||||
CREATE TABLE installed_components (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
app_id TEXT NOT NULL,
|
||||
component_id TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
install_order INTEGER NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
package_name TEXT,
|
||||
package_version TEXT,
|
||||
service_name TEXT,
|
||||
docker_image TEXT,
|
||||
docker_digest TEXT,
|
||||
container_name TEXT,
|
||||
compose_project_name TEXT,
|
||||
installed_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE(app_id, component_id)
|
||||
);
|
||||
CREATE TABLE task_components (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id TEXT NOT NULL,
|
||||
app_id TEXT NOT NULL,
|
||||
component_id TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
install_order INTEGER NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
progress INTEGER DEFAULT 0,
|
||||
current_step TEXT,
|
||||
error_message TEXT,
|
||||
started_at TEXT,
|
||||
finished_at TEXT
|
||||
);
|
||||
"""
|
||||
)
|
||||
connection.commit()
|
||||
|
||||
initialize_database()
|
||||
|
||||
with closing(sqlite3.connect(self.db_path)) as connection:
|
||||
installed_columns = {
|
||||
row[1] for row in connection.execute("PRAGMA table_info(installed_components)")
|
||||
}
|
||||
task_columns = {
|
||||
row[1] for row in connection.execute("PRAGMA table_info(task_components)")
|
||||
}
|
||||
self.assertTrue({"service_check_status", "service_checks_json"} <= installed_columns)
|
||||
self.assertTrue({"service_check_status", "service_checks_json"} <= task_columns)
|
||||
|
||||
def test_installed_apps_include_persisted_service_health(self) -> None:
|
||||
self.repository.upsert_installed_app(
|
||||
"postgresql",
|
||||
"PostgreSQL",
|
||||
"16",
|
||||
"manifest-hash",
|
||||
)
|
||||
self.repository.upsert_installed_component(
|
||||
"postgresql",
|
||||
{
|
||||
"componentId": "postgresql",
|
||||
"type": "apt",
|
||||
"packageName": "postgresql",
|
||||
"version": "16+257build1",
|
||||
"serviceName": "postgresql.service",
|
||||
"serviceCheckStatus": "healthy",
|
||||
"serviceChecks": [SERVICE_CHECK],
|
||||
},
|
||||
)
|
||||
|
||||
manager = SimpleNamespace(get_service_status=lambda _name: dict(SERVICE_CHECK))
|
||||
with (
|
||||
patch.object(apps_api, "repository", self.repository),
|
||||
patch.object(apps_api, "ServiceManager", return_value=manager),
|
||||
):
|
||||
response = apps_api.installed_apps()
|
||||
|
||||
self.assertEqual(response[0]["serviceCheckStatus"], "healthy")
|
||||
self.assertEqual(response[0]["serviceChecks"][0]["serviceName"], "postgresql.service")
|
||||
self.assertEqual(response[0]["serviceChecks"][0]["componentId"], "postgresql")
|
||||
|
||||
def test_installed_apps_refresh_postgresql_readiness(self) -> None:
|
||||
stored_check = {
|
||||
**SERVICE_CHECK,
|
||||
"readinessType": "postgresql",
|
||||
"readinessStatus": "ready",
|
||||
}
|
||||
self.repository.upsert_installed_app(
|
||||
"postgresql",
|
||||
"PostgreSQL",
|
||||
"16",
|
||||
"manifest-hash",
|
||||
)
|
||||
self.repository.upsert_installed_component(
|
||||
"postgresql",
|
||||
{
|
||||
"componentId": "postgresql",
|
||||
"type": "apt",
|
||||
"packageName": "postgresql",
|
||||
"version": "16+257build1",
|
||||
"serviceName": "postgresql.service",
|
||||
"serviceCheckStatus": "healthy",
|
||||
"serviceChecks": [stored_check],
|
||||
},
|
||||
)
|
||||
service_manager = SimpleNamespace(get_service_status=lambda _name: dict(SERVICE_CHECK))
|
||||
apt_installer = SimpleNamespace(is_postgresql_ready=lambda: False)
|
||||
|
||||
with (
|
||||
patch.object(apps_api, "repository", self.repository),
|
||||
patch.object(apps_api, "ServiceManager", return_value=service_manager),
|
||||
patch.object(apps_api, "AptInstaller", return_value=apt_installer),
|
||||
):
|
||||
response = apps_api.installed_apps()
|
||||
|
||||
self.assertEqual(response[0]["serviceCheckStatus"], "unhealthy")
|
||||
self.assertEqual(response[0]["serviceChecks"][0]["readinessStatus"], "failed")
|
||||
self.assertFalse(response[0]["serviceChecks"][0]["healthy"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user