update check active

This commit is contained in:
2026-07-20 16:08:36 +07:00
parent 4a159cad71
commit db1ce68800
17 changed files with 1230 additions and 73 deletions

View File

@@ -51,6 +51,7 @@ class CommandRunner:
command: list[str],
timeout: int | None = None,
env: Mapping[str, str] | None = None,
log_output: bool = True,
) -> subprocess.CompletedProcess[str]:
if self.task_id:
self.repository.add_log(self.task_id, "debug", f"Running command: {' '.join(command)}")
@@ -74,7 +75,7 @@ class CommandRunner:
self.repository.add_log(self.task_id, "error", f"Command timed out: {' '.join(command)}")
raise CommandError(command, 124, error.stdout or "", error.stderr or "") from error
if self.task_id:
if self.task_id and log_output:
for line in result.stdout.splitlines():
self.repository.add_log(self.task_id, "debug", line)
for line in result.stderr.splitlines():

View File

@@ -1,7 +1,7 @@
from __future__ import annotations
import time
from pathlib import Path
from pathlib import Path, PurePosixPath
from app.core.command_runner import CommandError, CommandRunner
@@ -125,6 +125,38 @@ class AptInstaller(DebInstaller):
env=APT_NONINTERACTIVE_ENV,
)
def discover_service_units(self, package_name: str) -> list[str]:
"""Return concrete systemd service units shipped by an installed package.
The package name is already constrained by the Agent APT allowlist. It is
still passed as a single argv item and no shell expansion is performed.
Template units are omitted because they cannot be meaningfully checked
without an instance name.
"""
try:
result = self.command_runner.run(
["dpkg-query", "-L", package_name],
timeout=30,
log_output=False,
)
except CommandError:
return []
service_names: set[str] = set()
for raw_path in result.stdout.splitlines():
path = raw_path.strip()
if not path.endswith(".service"):
continue
if "/systemd/system/" not in path:
continue
service_name = PurePosixPath(path).name
if "@" in service_name:
continue
service_names.add(service_name)
return sorted(service_names)
def wait_for_postgresql(
self,
attempts: int = 6,
@@ -141,3 +173,10 @@ class AptInstaller(DebInstaller):
time.sleep(delay_seconds)
raise RuntimeError("PostgreSQL did not become ready after installation") from last_error
def is_postgresql_ready(self) -> bool:
try:
self.command_runner.run(["pg_isready", "--timeout=5"], timeout=10)
return True
except Exception:
return False

View File

@@ -1,5 +1,8 @@
from __future__ import annotations
from datetime import datetime, timezone
from app.core.command_runner import CommandError
from app.core.command_runner import CommandRunner
@@ -31,17 +34,60 @@ class ServiceManager:
raise RuntimeError(f"Service is not active: {service_name}")
def get_service_status(self, service_name: str) -> dict[str, object]:
active = self._query(["systemctl", "is-active", service_name]) == "active"
enabled = self._query(["systemctl", "is-enabled", service_name]) == "enabled"
command = [
"systemctl",
"show",
service_name,
"--no-pager",
"--property=LoadState",
"--property=ActiveState",
"--property=SubState",
"--property=UnitFileState",
]
error_message: str | None = None
try:
output = self.command_runner.run(command, timeout=5).stdout
except CommandError as error:
output = error.stdout
error_message = str(error)
except Exception as error:
output = ""
error_message = str(error)
properties = self._parse_properties(output)
load_state = properties.get("LoadState", "unknown") or "unknown"
active_state = properties.get("ActiveState", "unknown") or "unknown"
sub_state = properties.get("SubState", "unknown") or "unknown"
unit_file_state = properties.get("UnitFileState", "unknown") or "unknown"
active = load_state == "loaded" and active_state == "active"
enabled = unit_file_state in {"enabled", "enabled-runtime"}
if load_state == "not-found":
status = "not-found"
elif active_state != "unknown":
status = active_state
else:
status = "unknown"
return {
"serviceName": service_name,
"loadState": load_state,
"activeState": active_state,
"subState": sub_state,
"unitFileState": unit_file_state,
"active": active,
"enabled": enabled,
"status": "running" if active else "stopped",
"healthy": active,
"status": status,
"checkedAt": datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z"),
"errorMessage": error_message,
}
def _query(self, command: list[str]) -> str:
try:
return self.command_runner.run(command).stdout.strip()
except Exception:
return "unknown"
@staticmethod
def _parse_properties(output: str) -> dict[str, str]:
properties: dict[str, str] = {}
for line in output.splitlines():
key, separator, value = line.partition("=")
if separator and key:
properties[key.strip()] = value.strip()
return properties

View File

@@ -18,6 +18,18 @@ from app.models.schemas import InstallRequest, RemoveRequest, UpdateRequest
from app.storage.repository import Repository, utc_now
APT_SERVICE_POLICIES: dict[str, dict[str, Any]] = {
"postgresql": {
"managedServices": ["postgresql.service"],
"readiness": "postgresql",
},
}
class InstalledComponentVerificationError(RuntimeError):
"""The package changed on disk, but its required runtime verification failed."""
class TaskRunner:
def __init__(self, repository: Repository) -> None:
self.repository = repository
@@ -25,6 +37,7 @@ class TaskRunner:
self.manifest_validator = ManifestValidator()
def run_install(self, task_id: str, request: InstallRequest | UpdateRequest, task_type: str = "install") -> None:
manifest: dict[str, Any] | None = None
try:
self._mark_started(task_id, f"starting {task_type}")
self._require_root_if_available()
@@ -49,6 +62,9 @@ class TaskRunner:
finished_at=utc_now(),
)
self.repository.add_log(task_id, "info", f"Task {task_id} completed")
except InstalledComponentVerificationError as error:
self._track_attention_install(task_id, manifest)
self._fail_task(task_id, error)
except Exception as error:
self._fail_task(task_id, error)
@@ -332,30 +348,121 @@ class TaskRunner:
installed_component = dict(component)
installed_component["version"] = installed_version
if package_name == "postgresql":
service_name = "postgresql.service"
policy = APT_SERVICE_POLICIES.get(package_name, {})
managed_services = list(policy.get("managedServices", []))
discovered_services = installer.discover_service_units(package_name)
service_names = list(dict.fromkeys([*managed_services, *discovered_services]))
self.repository.update_task_component(
task_id,
component_id,
progress=80,
current_step="checking package services",
service_check_status="checking",
service_checks=[],
)
verification_errors: list[str] = []
if managed_services:
self.repository.update_task_component(
task_id,
component_id,
progress=85,
current_step="starting PostgreSQL service",
current_step="starting managed package services",
)
services.enable_service(service_name)
services.start_service(service_name)
services.assert_service_active(service_name)
for service_name in managed_services:
try:
services.enable_service(service_name)
services.start_service(service_name)
except Exception as error:
verification_errors.append(f"Could not start {service_name}: {error}")
readiness_status: str | None = None
if policy.get("readiness") == "postgresql":
self.repository.update_task_component(
task_id,
component_id,
progress=95,
progress=90,
current_step="checking PostgreSQL readiness",
)
installer.wait_for_postgresql()
self.repository.add_log(task_id, "info", "PostgreSQL service is active and accepting connections")
installed_component["serviceName"] = service_name
try:
installer.wait_for_postgresql()
readiness_status = "ready"
except Exception as error:
readiness_status = "failed"
verification_errors.append(f"PostgreSQL is not accepting connections: {error}")
service_checks: list[dict[str, Any]] = []
self.repository.update_task_component(
task_id,
component_id,
progress=95,
current_step="reading service status",
)
for service_name in service_names:
check = services.get_service_status(service_name)
if service_name in managed_services and readiness_status is not None:
check["readinessType"] = str(policy["readiness"])
check["readinessStatus"] = readiness_status
check["healthy"] = bool(check.get("healthy")) and readiness_status == "ready"
service_checks.append(check)
if check.get("healthy"):
self.repository.add_log(
task_id,
"info",
f"Service {service_name} is active ({check.get('subState', 'unknown')})",
)
else:
self.repository.add_log(
task_id,
"warning",
f"Service {service_name} is not healthy "
f"(load={check.get('loadState', 'unknown')}, "
f"active={check.get('activeState', 'unknown')}, "
f"sub={check.get('subState', 'unknown')})",
)
if not service_names:
service_check_status = "not-applicable"
self.repository.add_log(
task_id,
"info",
f"APT package {package_name} does not expose a concrete systemd service unit",
)
elif all(bool(check.get("healthy")) for check in service_checks):
service_check_status = "healthy"
else:
service_check_status = "unhealthy"
self.repository.update_task_component(
task_id,
component_id,
progress=98,
current_step="service checks completed",
service_check_status=service_check_status,
service_checks=service_checks,
)
installed_component["serviceCheckStatus"] = service_check_status
installed_component["serviceChecks"] = service_checks
if managed_services:
installed_component["serviceName"] = managed_services[0]
self.repository.upsert_installed_component(app_id, installed_component)
unhealthy_managed_services = [
str(check.get("serviceName"))
for check in service_checks
if check.get("serviceName") in managed_services and not check.get("healthy")
]
if unhealthy_managed_services:
verification_errors.append(
f"Required service is not healthy: {', '.join(unhealthy_managed_services)}"
)
if verification_errors:
raise InstalledComponentVerificationError(
"; ".join(dict.fromkeys(verification_errors))
)
def _install_docker_component(self, task_id: str, app_id: str, component: dict[str, Any]) -> None:
component_id = component["componentId"]
container_name = component["containerName"]
@@ -426,6 +533,33 @@ class TaskRunner:
except Exception as error:
self.repository.add_log(task_id, "warning", f"Could not {action}: {error}")
def _track_attention_install(self, task_id: str, manifest: dict[str, Any] | None) -> None:
if not manifest:
return
try:
manifest_hash = hashlib.sha256(
self.repository.export_manifest_hash(manifest).encode("utf-8")
).hexdigest()
self.repository.upsert_installed_app(
manifest["appId"],
manifest["appName"],
manifest["version"],
manifest_hash,
manifest.get("openUrl"),
status="attention",
)
self.repository.add_log(
task_id,
"warning",
"The package was installed but its service needs attention; it remains available for status and removal",
)
except Exception as tracking_error:
self.repository.add_log(
task_id,
"warning",
f"Could not persist installed package attention state: {tracking_error}",
)
def _clean_cached_package_files(self, task_id: str, *identifiers: str | None) -> None:
cache_dir = settings.cache_dir
if not cache_dir.exists() or not cache_dir.is_dir():