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

@@ -4,6 +4,9 @@ import uuid
from fastapi import APIRouter, BackgroundTasks, HTTPException
from app.core.command_runner import CommandRunner
from app.core.installer import AptInstaller
from app.core.service_manager import ServiceManager
from app.core.task_runner import TaskRunner
from app.models.schemas import InstallRequest, RemoveRequest, UpdateRequest
from app.storage.repository import Repository
@@ -20,7 +23,56 @@ def _task_id(prefix: str) -> str:
@router.get("/installed")
def installed_apps() -> list[dict]:
return repository.list_installed_apps()
apps = repository.list_installed_apps()
command_runner = CommandRunner(repository)
service_manager = ServiceManager(command_runner)
apt_installer = AptInstaller(command_runner)
for app in apps:
service_checks: list[dict] = []
component_statuses: list[str] = []
for component in repository.list_installed_components(app["app_id"]):
check_status = component.get("service_check_status", "not-checked")
if check_status != "not-checked":
component_statuses.append(check_status)
for check in component.get("service_checks", []):
service_name = str(check.get("serviceName") or "").strip()
if not service_name:
continue
current_check = service_manager.get_service_status(service_name)
if current_check.get("status") == "unknown" and current_check.get("errorMessage"):
current_check = dict(check)
current_check["stale"] = True
elif check.get("readinessType") == "postgresql":
readiness_status = "ready" if apt_installer.is_postgresql_ready() else "failed"
current_check["readinessType"] = "postgresql"
current_check["readinessStatus"] = readiness_status
current_check["healthy"] = bool(current_check.get("healthy")) and readiness_status == "ready"
service_checks.append(
{
**current_check,
"componentId": component["component_id"],
"packageName": component.get("package_name"),
}
)
if service_checks and any(not check.get("healthy") for check in service_checks):
overall_status = "unhealthy"
elif service_checks:
overall_status = "healthy"
elif "unhealthy" in component_statuses:
overall_status = "unhealthy"
elif "checking" in component_statuses:
overall_status = "checking"
elif "healthy" in component_statuses:
overall_status = "healthy"
elif "not-applicable" in component_statuses:
overall_status = "not-applicable"
else:
overall_status = "not-checked"
app["serviceCheckStatus"] = overall_status
app["serviceChecks"] = service_checks
return apps
@router.post("/install")

View File

@@ -65,10 +65,11 @@ def get_task_components(task_id: str) -> dict:
"progress": item["progress"],
"currentStep": item["current_step"],
"errorMessage": item["error_message"],
"serviceCheckStatus": item["service_check_status"],
"serviceChecks": item["service_checks"],
"startedAt": item["started_at"],
"finishedAt": item["finished_at"],
}
for item in repository.get_task_components(task_id)
],
}

View File

@@ -63,7 +63,7 @@ def _bool(name: str, default: bool) -> bool:
def get_settings() -> Settings:
robot_package_base_url = os.getenv("ROBOT_PACKAGE_BASE_URL", "https://package.pnkr.cloud").rstrip("/")
return Settings(
agent_version=os.getenv("AGENT_VERSION", "1.0.0"),
agent_version=os.getenv("AGENT_VERSION", "1.0.3"),
host=os.getenv("AGENT_HOST", "0.0.0.0"),
port=int(os.getenv("AGENT_PORT", "5010")),
robot_package_base_url=robot_package_base_url,

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():

View File

@@ -1,6 +1,8 @@
from __future__ import annotations
import sqlite3
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
from app.config import settings
@@ -55,6 +57,8 @@ CREATE TABLE IF NOT EXISTS installed_components (
package_name TEXT,
package_version TEXT,
service_name TEXT,
service_check_status TEXT DEFAULT 'not-checked',
service_checks_json TEXT,
docker_image TEXT,
docker_digest TEXT,
container_name TEXT,
@@ -75,6 +79,8 @@ CREATE TABLE IF NOT EXISTS task_components (
progress INTEGER DEFAULT 0,
current_step TEXT,
error_message TEXT,
service_check_status TEXT DEFAULT 'not-checked',
service_checks_json TEXT,
started_at TEXT,
finished_at TEXT,
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE
@@ -96,10 +102,18 @@ def _ensure_column(connection: sqlite3.Connection, table: str, column: str, defi
connection.execute(f"ALTER TABLE {table} ADD COLUMN {column} {definition}")
def get_connection() -> sqlite3.Connection:
@contextmanager
def get_connection() -> Iterator[sqlite3.Connection]:
connection = sqlite3.connect(settings.db_path, timeout=30)
connection.row_factory = sqlite3.Row
return connection
try:
yield connection
connection.commit()
except Exception:
connection.rollback()
raise
finally:
connection.close()
def initialize_database() -> None:
@@ -110,3 +124,17 @@ def initialize_database() -> None:
with get_connection() as connection:
connection.executescript(SCHEMA)
_ensure_column(connection, "installed_apps", "open_url", "TEXT")
_ensure_column(
connection,
"installed_components",
"service_check_status",
"TEXT DEFAULT 'not-checked'",
)
_ensure_column(connection, "installed_components", "service_checks_json", "TEXT")
_ensure_column(
connection,
"task_components",
"service_check_status",
"TEXT DEFAULT 'not-checked'",
)
_ensure_column(connection, "task_components", "service_checks_json", "TEXT")

View File

@@ -17,6 +17,29 @@ def row_to_dict(row: Any) -> dict[str, Any] | None:
return dict(row)
def _encode_service_checks(value: list[dict[str, Any]]) -> str:
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
def _decode_service_checks(value: Any) -> list[dict[str, Any]]:
if not value:
return []
try:
decoded = json.loads(str(value))
except (TypeError, ValueError, json.JSONDecodeError):
return []
if not isinstance(decoded, list):
return []
return [item for item in decoded if isinstance(item, dict)]
def _component_row(row: Any) -> dict[str, Any]:
item = dict(row)
item["service_checks"] = _decode_service_checks(item.pop("service_checks_json", None))
item["service_check_status"] = item.get("service_check_status") or "not-checked"
return item
class Repository:
def create_task(self, task_id: str, task_type: str, app_id: str, app_name: str | None) -> None:
now = utc_now()
@@ -118,6 +141,8 @@ class Repository:
progress: int | None = None,
current_step: str | None = None,
error_message: str | None = None,
service_check_status: str | None = None,
service_checks: list[dict[str, Any]] | None = None,
started_at: str | None = None,
finished_at: str | None = None,
) -> None:
@@ -128,6 +153,12 @@ class Repository:
"progress": progress,
"current_step": current_step,
"error_message": error_message,
"service_check_status": service_check_status,
"service_checks_json": (
_encode_service_checks(service_checks)
if service_checks is not None
else None
),
"started_at": started_at,
"finished_at": finished_at,
}.items():
@@ -147,14 +178,15 @@ class Repository:
with get_connection() as connection:
rows = connection.execute(
"""
SELECT component_id, type, status, progress, current_step, error_message, started_at, finished_at
SELECT component_id, type, status, progress, current_step, error_message,
service_check_status, service_checks_json, started_at, finished_at
FROM task_components
WHERE task_id = ?
ORDER BY install_order ASC, id ASC
""",
(task_id,),
).fetchall()
return [dict(row) for row in rows]
return [_component_row(row) for row in rows]
def list_installed_apps(self) -> list[dict[str, Any]]:
with get_connection() as connection:
@@ -205,10 +237,11 @@ class Repository:
"""
INSERT INTO installed_components (
app_id, component_id, type, install_order, status, package_name, package_version,
service_name, docker_image, docker_digest, container_name, compose_project_name,
service_name, service_check_status, service_checks_json,
docker_image, docker_digest, container_name, compose_project_name,
installed_at, updated_at
)
VALUES (?, ?, ?, ?, 'installed', ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, 'installed', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(app_id, component_id) DO UPDATE SET
type = excluded.type,
install_order = excluded.install_order,
@@ -216,6 +249,8 @@ class Repository:
package_name = excluded.package_name,
package_version = excluded.package_version,
service_name = excluded.service_name,
service_check_status = excluded.service_check_status,
service_checks_json = excluded.service_checks_json,
docker_image = excluded.docker_image,
docker_digest = excluded.docker_digest,
container_name = excluded.container_name,
@@ -230,6 +265,8 @@ class Repository:
component.get("packageName"),
component.get("version"),
component.get("serviceName"),
component.get("serviceCheckStatus", "not-checked"),
_encode_service_checks(component.get("serviceChecks", [])),
component.get("image"),
component.get("digest"),
component.get("containerName"),
@@ -250,7 +287,7 @@ class Repository:
""",
(app_id,),
).fetchall()
return [dict(row) for row in rows]
return [_component_row(row) for row in rows]
def export_manifest_hash(self, manifest: dict[str, Any]) -> str:
return json.dumps(manifest, sort_keys=True, separators=(",", ":"))