agent
This commit is contained in:
2
agent/app/core/__init__.py
Normal file
2
agent/app/core/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Core installers and task orchestration."""
|
||||
|
||||
17
agent/app/core/checksum.py
Normal file
17
agent/app/core/checksum.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def sha256_file(file_path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with file_path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def verify_sha256(file_path: Path, expected: str) -> bool:
|
||||
return sha256_file(file_path).lower() == expected.lower()
|
||||
|
||||
50
agent/app/core/command_runner.py
Normal file
50
agent/app/core/command_runner.py
Normal file
@@ -0,0 +1,50 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
|
||||
from app.config import settings
|
||||
from app.storage.repository import Repository
|
||||
|
||||
|
||||
class CommandError(RuntimeError):
|
||||
def __init__(self, command: list[str], returncode: int, stdout: str, stderr: str) -> None:
|
||||
super().__init__(f"Command failed with exit code {returncode}: {' '.join(command)}")
|
||||
self.command = command
|
||||
self.returncode = returncode
|
||||
self.stdout = stdout
|
||||
self.stderr = stderr
|
||||
|
||||
|
||||
class CommandRunner:
|
||||
def __init__(self, repository: Repository, task_id: str | None = None) -> None:
|
||||
self.repository = repository
|
||||
self.task_id = task_id
|
||||
|
||||
def run(self, command: list[str], timeout: int | None = None) -> subprocess.CompletedProcess[str]:
|
||||
if self.task_id:
|
||||
self.repository.add_log(self.task_id, "debug", f"Running command: {' '.join(command)}")
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
command,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout or settings.command_timeout_seconds,
|
||||
)
|
||||
except subprocess.TimeoutExpired as error:
|
||||
if self.task_id:
|
||||
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:
|
||||
for line in result.stdout.splitlines():
|
||||
self.repository.add_log(self.task_id, "debug", line)
|
||||
for line in result.stderr.splitlines():
|
||||
self.repository.add_log(self.task_id, "warning", line)
|
||||
|
||||
if result.returncode != 0:
|
||||
raise CommandError(command, result.returncode, result.stdout, result.stderr)
|
||||
|
||||
return result
|
||||
|
||||
42
agent/app/core/downloader.py
Normal file
42
agent/app/core/downloader.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from urllib.parse import unquote, urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import settings
|
||||
from app.storage.repository import Repository
|
||||
from app.utils.validators import validate_url_host
|
||||
|
||||
|
||||
SAFE_FILE_RE = re.compile(r"[^a-zA-Z0-9._+-]+")
|
||||
|
||||
|
||||
def _safe_file_name(url: str) -> str:
|
||||
parsed = urlparse(url)
|
||||
name = Path(unquote(parsed.path)).name or "package.deb"
|
||||
return SAFE_FILE_RE.sub("-", name).strip("-") or "package.deb"
|
||||
|
||||
|
||||
class Downloader:
|
||||
def __init__(self, repository: Repository, task_id: str) -> None:
|
||||
self.repository = repository
|
||||
self.task_id = task_id
|
||||
|
||||
def download(self, url: str) -> Path:
|
||||
validate_url_host(url, settings.allowed_download_hosts)
|
||||
settings.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
destination = settings.cache_dir / _safe_file_name(url)
|
||||
|
||||
self.repository.add_log(self.task_id, "info", f"Downloading {url}")
|
||||
with httpx.stream("GET", url, follow_redirects=True, timeout=120) as response:
|
||||
response.raise_for_status()
|
||||
with destination.open("wb") as handle:
|
||||
for chunk in response.iter_bytes():
|
||||
handle.write(chunk)
|
||||
|
||||
self.repository.add_log(self.task_id, "info", f"Downloaded to {destination}")
|
||||
return destination
|
||||
|
||||
30
agent/app/core/installer.py
Normal file
30
agent/app/core/installer.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from app.core.command_runner import CommandRunner
|
||||
|
||||
|
||||
class DebInstaller:
|
||||
def __init__(self, command_runner: CommandRunner) -> None:
|
||||
self.command_runner = command_runner
|
||||
|
||||
def install_deb(self, file_path: Path) -> None:
|
||||
self.command_runner.run(["apt", "install", "-y", str(file_path)])
|
||||
|
||||
def remove_package(self, package_name: str, purge: bool = False) -> None:
|
||||
action = "purge" if purge else "remove"
|
||||
self.command_runner.run(["apt", action, "-y", package_name])
|
||||
|
||||
def get_package_version(self, package_name: str) -> str | None:
|
||||
result = self.command_runner.run(["dpkg-query", "-W", "-f=${Version}", package_name])
|
||||
version = result.stdout.strip()
|
||||
return version or None
|
||||
|
||||
def check_package_installed(self, package_name: str) -> bool:
|
||||
try:
|
||||
self.get_package_version(package_name)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
18
agent/app/core/manifest_client.py
Normal file
18
agent/app/core/manifest_client.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from urllib.parse import quote
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
class ManifestClient:
|
||||
def fetch_manifest(self, app_id: str, version: str) -> dict:
|
||||
app_id_part = quote(app_id, safe="")
|
||||
version_part = quote(version, safe="")
|
||||
url = f"{settings.robot_package_base_url}/api/apps/{app_id_part}/versions/{version_part}/manifest"
|
||||
response = httpx.get(url, follow_redirects=True, timeout=30)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
26
agent/app/core/manifest_validator.py
Normal file
26
agent/app/core/manifest_validator.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.config import settings
|
||||
from app.models.schemas import AppManifest, DebComponent
|
||||
from app.utils.validators import validate_url_host
|
||||
|
||||
|
||||
class ManifestValidator:
|
||||
def validate(self, payload: dict) -> dict:
|
||||
manifest = AppManifest.model_validate(payload).model_dump(by_alias=True)
|
||||
components = []
|
||||
for raw_component in manifest["components"]:
|
||||
component_type = raw_component.get("type")
|
||||
if component_type == "deb":
|
||||
component = DebComponent.model_validate(raw_component).model_dump(by_alias=True)
|
||||
validate_url_host(component["downloadUrl"], settings.allowed_download_hosts)
|
||||
components.append(component)
|
||||
elif component_type == "docker" and not settings.allow_docker:
|
||||
raise ValueError("Docker components are not enabled on this Agent")
|
||||
elif component_type == "docker_compose" and not settings.allow_docker_compose:
|
||||
raise ValueError("Docker Compose components are not enabled on this Agent")
|
||||
else:
|
||||
raise ValueError(f"Unsupported component type: {component_type}")
|
||||
manifest["components"] = sorted(components, key=lambda item: item.get("installOrder", 10))
|
||||
return manifest
|
||||
|
||||
40
agent/app/core/service_manager.py
Normal file
40
agent/app/core/service_manager.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.core.command_runner import CommandRunner
|
||||
|
||||
|
||||
class ServiceManager:
|
||||
def __init__(self, command_runner: CommandRunner) -> None:
|
||||
self.command_runner = command_runner
|
||||
|
||||
def enable_service(self, service_name: str) -> None:
|
||||
self.command_runner.run(["systemctl", "enable", service_name])
|
||||
|
||||
def disable_service(self, service_name: str) -> None:
|
||||
self.command_runner.run(["systemctl", "disable", service_name])
|
||||
|
||||
def start_service(self, service_name: str) -> None:
|
||||
self.command_runner.run(["systemctl", "start", service_name])
|
||||
|
||||
def stop_service(self, service_name: str) -> None:
|
||||
self.command_runner.run(["systemctl", "stop", service_name])
|
||||
|
||||
def restart_service(self, service_name: str) -> None:
|
||||
self.command_runner.run(["systemctl", "restart", 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"
|
||||
return {
|
||||
"serviceName": service_name,
|
||||
"active": active,
|
||||
"enabled": enabled,
|
||||
"status": "running" if active else "stopped",
|
||||
}
|
||||
|
||||
def _query(self, command: list[str]) -> str:
|
||||
try:
|
||||
return self.command_runner.run(command).stdout.strip()
|
||||
except Exception:
|
||||
return "unknown"
|
||||
|
||||
248
agent/app/core/task_runner.py
Normal file
248
agent/app/core/task_runner.py
Normal file
@@ -0,0 +1,248 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import traceback
|
||||
from typing import Any
|
||||
|
||||
from app.config import settings
|
||||
from app.core.checksum import verify_sha256
|
||||
from app.core.command_runner import CommandRunner
|
||||
from app.core.downloader import Downloader
|
||||
from app.core.installer import DebInstaller
|
||||
from app.core.manifest_client import ManifestClient
|
||||
from app.core.manifest_validator import ManifestValidator
|
||||
from app.core.service_manager import ServiceManager
|
||||
from app.models.schemas import InstallRequest, RemoveRequest, UpdateRequest
|
||||
from app.storage.repository import Repository, utc_now
|
||||
|
||||
|
||||
class TaskRunner:
|
||||
def __init__(self, repository: Repository) -> None:
|
||||
self.repository = repository
|
||||
self.manifest_client = ManifestClient()
|
||||
self.manifest_validator = ManifestValidator()
|
||||
|
||||
def run_install(self, task_id: str, request: InstallRequest | UpdateRequest, task_type: str = "install") -> None:
|
||||
try:
|
||||
self._mark_started(task_id, f"starting {task_type}")
|
||||
self._require_root_if_available()
|
||||
manifest = self._resolve_manifest(request)
|
||||
self.repository.add_log(task_id, "info", f"Installing {manifest['appId']} {manifest['version']}")
|
||||
self._install_manifest(task_id, manifest)
|
||||
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,
|
||||
)
|
||||
self.repository.update_task(
|
||||
task_id,
|
||||
status="success",
|
||||
progress=100,
|
||||
current_step="completed",
|
||||
finished_at=utc_now(),
|
||||
)
|
||||
self.repository.add_log(task_id, "info", f"Task {task_id} completed")
|
||||
except Exception as error:
|
||||
self._fail_task(task_id, error)
|
||||
|
||||
def run_remove(self, task_id: str, request: RemoveRequest) -> None:
|
||||
try:
|
||||
if not settings.allow_remove:
|
||||
raise ValueError("Remove is disabled on this Agent")
|
||||
if request.purge and not settings.allow_purge:
|
||||
raise ValueError("Purge is disabled on this Agent")
|
||||
self._mark_started(task_id, "starting remove")
|
||||
self._require_root_if_available()
|
||||
|
||||
components = self.repository.list_installed_components(request.app_id)
|
||||
if not components and request.package_name:
|
||||
components = [
|
||||
{
|
||||
"component_id": request.package_name,
|
||||
"type": "deb",
|
||||
"install_order": 10,
|
||||
"package_name": request.package_name,
|
||||
"service_name": request.service_name,
|
||||
}
|
||||
]
|
||||
if not components:
|
||||
raise ValueError("No installed components found for this app")
|
||||
|
||||
command_runner = CommandRunner(self.repository, task_id)
|
||||
installer = DebInstaller(command_runner)
|
||||
services = ServiceManager(command_runner)
|
||||
|
||||
ordered = sorted(components, key=lambda item: item["install_order"], reverse=True)
|
||||
total = len(ordered)
|
||||
for index, component in enumerate(ordered, start=1):
|
||||
progress = int((index - 1) / total * 80) + 10
|
||||
component_id = component["component_id"]
|
||||
self.repository.update_task(
|
||||
task_id,
|
||||
progress=progress,
|
||||
current_step=f"removing {component_id}",
|
||||
current_component_id=component_id,
|
||||
)
|
||||
service_name = component.get("service_name")
|
||||
if service_name:
|
||||
self.repository.add_log(task_id, "info", f"Stopping service {service_name}")
|
||||
services.stop_service(service_name)
|
||||
services.disable_service(service_name)
|
||||
|
||||
package_name = component.get("package_name")
|
||||
if component["type"] == "deb" and package_name:
|
||||
self.repository.add_log(task_id, "info", f"Removing package {package_name}")
|
||||
installer.remove_package(package_name, purge=request.purge)
|
||||
else:
|
||||
raise ValueError(f"Unsupported installed component type: {component['type']}")
|
||||
|
||||
self.repository.delete_installed_app(request.app_id)
|
||||
self.repository.update_task(
|
||||
task_id,
|
||||
status="success",
|
||||
progress=100,
|
||||
current_step="completed",
|
||||
finished_at=utc_now(),
|
||||
)
|
||||
except Exception as error:
|
||||
self._fail_task(task_id, error)
|
||||
|
||||
def _resolve_manifest(self, request: InstallRequest | UpdateRequest) -> dict[str, Any]:
|
||||
if request.download_url:
|
||||
digest = request.sha256 or request.checksum
|
||||
return self.manifest_validator.validate(
|
||||
{
|
||||
"schemaVersion": "1.0",
|
||||
"appId": request.app_id,
|
||||
"appName": request.app_name or request.app_id,
|
||||
"version": request.version,
|
||||
"architecture": "amd64",
|
||||
"components": [
|
||||
{
|
||||
"componentId": request.package_name,
|
||||
"type": "deb",
|
||||
"installOrder": 10,
|
||||
"required": True,
|
||||
"packageName": request.package_name,
|
||||
"version": request.version,
|
||||
"downloadUrl": request.download_url,
|
||||
"sha256": digest,
|
||||
"serviceName": request.service_name,
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
payload = self.manifest_client.fetch_manifest(request.app_id, request.version)
|
||||
return self.manifest_validator.validate(payload)
|
||||
|
||||
def _install_manifest(self, task_id: str, manifest: dict[str, Any]) -> None:
|
||||
components = manifest["components"]
|
||||
for component in components:
|
||||
self.repository.create_task_component(
|
||||
task_id,
|
||||
manifest["appId"],
|
||||
component["componentId"],
|
||||
component["type"],
|
||||
component.get("installOrder", 10),
|
||||
)
|
||||
|
||||
total = len(components)
|
||||
if total == 0:
|
||||
raise ValueError("Manifest has no installable components")
|
||||
|
||||
for index, component in enumerate(components, start=1):
|
||||
base_progress = int((index - 1) / total * 80) + 10
|
||||
component_id = component["componentId"]
|
||||
self.repository.update_task(
|
||||
task_id,
|
||||
progress=base_progress,
|
||||
current_step=f"installing {component_id}",
|
||||
current_component_id=component_id,
|
||||
)
|
||||
self.repository.update_task_component(
|
||||
task_id,
|
||||
component_id,
|
||||
status="running",
|
||||
progress=5,
|
||||
current_step="downloading",
|
||||
started_at=utc_now(),
|
||||
)
|
||||
|
||||
if component["type"] != "deb":
|
||||
raise ValueError(f"Unsupported component type in MVP: {component['type']}")
|
||||
|
||||
self._install_deb_component(task_id, manifest["appId"], component)
|
||||
|
||||
self.repository.update_task_component(
|
||||
task_id,
|
||||
component_id,
|
||||
status="success",
|
||||
progress=100,
|
||||
current_step="completed",
|
||||
finished_at=utc_now(),
|
||||
)
|
||||
|
||||
def _install_deb_component(self, task_id: str, app_id: str, component: dict[str, Any]) -> None:
|
||||
component_id = component["componentId"]
|
||||
downloader = Downloader(self.repository, task_id)
|
||||
command_runner = CommandRunner(self.repository, task_id)
|
||||
installer = DebInstaller(command_runner)
|
||||
services = ServiceManager(command_runner)
|
||||
|
||||
package_path = downloader.download(component["downloadUrl"])
|
||||
self.repository.update_task_component(task_id, component_id, progress=35, current_step="verifying checksum")
|
||||
if not verify_sha256(package_path, component["sha256"]):
|
||||
raise ValueError(f"Checksum mismatch for {component_id}")
|
||||
self.repository.add_log(task_id, "info", f"Checksum verified for {component_id}")
|
||||
|
||||
self.repository.update_task_component(task_id, component_id, progress=60, current_step="installing package")
|
||||
installer.install_deb(package_path)
|
||||
|
||||
self.repository.update_task_component(task_id, component_id, progress=75, current_step="verifying package")
|
||||
installed_version = installer.get_package_version(component["packageName"])
|
||||
self.repository.add_log(
|
||||
task_id,
|
||||
"info",
|
||||
f"Package {component['packageName']} installed with version {installed_version}",
|
||||
)
|
||||
|
||||
service_name = component.get("serviceName")
|
||||
if service_name:
|
||||
self.repository.update_task_component(task_id, component_id, progress=90, current_step="starting service")
|
||||
services.enable_service(service_name)
|
||||
services.start_service(service_name)
|
||||
|
||||
self.repository.upsert_installed_component(app_id, component)
|
||||
|
||||
def _mark_started(self, task_id: str, step: str) -> None:
|
||||
self.repository.update_task(
|
||||
task_id,
|
||||
status="running",
|
||||
progress=5,
|
||||
current_step=step,
|
||||
started_at=utc_now(),
|
||||
)
|
||||
self.repository.add_log(task_id, "info", step)
|
||||
|
||||
def _fail_task(self, task_id: str, error: Exception) -> None:
|
||||
self.repository.update_task(
|
||||
task_id,
|
||||
status="failed",
|
||||
current_step="failed",
|
||||
error_message=str(error),
|
||||
finished_at=utc_now(),
|
||||
)
|
||||
self.repository.add_log(task_id, "error", str(error))
|
||||
self.repository.add_log(task_id, "debug", traceback.format_exc())
|
||||
|
||||
def _require_root_if_available(self) -> None:
|
||||
geteuid = getattr(os, "geteuid", None)
|
||||
if callable(geteuid) and geteuid() != 0:
|
||||
raise PermissionError("Agent must run as root to call apt and systemctl")
|
||||
|
||||
Reference in New Issue
Block a user