update agent

This commit is contained in:
2026-07-17 15:41:05 +07:00
parent 13bea66473
commit 476c41cf08
15 changed files with 467 additions and 20 deletions

View File

@@ -2,7 +2,7 @@
FastAPI service that runs on each Linux client and listens on `127.0.0.1:5010`.
It accepts install, update, remove, task, log, installed-app, and service-control requests from `robot.installer`. It stores state in local SQLite and installs trusted `.deb` components downloaded from `robot.package`, plus Docker image components from allowed registries when Docker support is enabled.
It accepts install, update, remove, task, log, installed-app, and service-control requests from `robot.installer`. It stores state in local SQLite and installs trusted `.deb` components downloaded from `robot.package`, allowlisted Ubuntu APT packages, plus Docker image components from allowed registries when Docker support is enabled.
## Development
@@ -76,3 +76,16 @@ For manifest mode, the Agent fetches:
```
Docker image components require Docker Engine on the client machine. By default `AUTO_INSTALL_DOCKER=true`, so the agent will install the trusted distro package `docker.io` with `apt-get` when a Docker app is installed and Docker is missing. Set `AUTO_INSTALL_DOCKER=false` if Docker must be provisioned by your own fleet policy. The agent validates `image` against `ALLOWED_DOCKER_REGISTRIES`, then runs a managed container using the manifest fields `containerName`, `restartPolicy`, `ports`, `volumes`, and `env`.
APT components use a fixed manifest contract and never accept shell commands:
```json
{
"componentId": "postgresql",
"type": "apt",
"packageName": "postgresql",
"version": "16"
}
```
The package name must be present in `ALLOWED_APT_PACKAGES` (default: `postgresql`). For PostgreSQL, the Agent refreshes the APT index, installs the distro package non-interactively, verifies `postgresql.service`, and waits for `pg_isready` before completing the task.

View File

@@ -38,6 +38,7 @@ class Settings:
robot_package_base_url: str
allowed_origins: list[str]
allowed_download_hosts: list[str]
allowed_apt_packages: list[str]
allowed_docker_registries: list[str]
cache_dir: Path
app_dir: Path
@@ -83,6 +84,10 @@ def get_settings() -> Settings:
os.getenv("ALLOWED_DOWNLOAD_HOSTS"),
_default_allowed_download_hosts(robot_package_base_url),
),
allowed_apt_packages=_csv(
os.getenv("ALLOWED_APT_PACKAGES"),
["postgresql"],
),
allowed_docker_registries=_csv_with_defaults(
os.getenv("ALLOWED_DOCKER_REGISTRIES"),
["registry.robot.package", "docker.io"],

View File

@@ -1,8 +1,9 @@
from __future__ import annotations
import time
from pathlib import Path
from app.core.command_runner import CommandRunner
from app.core.command_runner import CommandError, CommandRunner
APT_DPKG_OPTIONS = [
@@ -101,3 +102,42 @@ class DebInstaller:
return True
except Exception:
return False
class AptInstaller(DebInstaller):
def update_package_index(self) -> None:
self.command_runner.run(
["apt-get", "update"],
timeout=600,
env=APT_NONINTERACTIVE_ENV,
)
def install_package(self, package_name: str) -> None:
self.command_runner.run(
[
"apt-get",
*APT_DPKG_OPTIONS,
"install",
"--yes",
package_name,
],
timeout=1200,
env=APT_NONINTERACTIVE_ENV,
)
def wait_for_postgresql(
self,
attempts: int = 6,
delay_seconds: float = 2.0,
) -> None:
last_error: CommandError | None = None
for attempt in range(1, attempts + 1):
try:
self.command_runner.run(["pg_isready", "--timeout=5"], timeout=10)
return
except CommandError as error:
last_error = error
if attempt < attempts:
time.sleep(delay_seconds)
raise RuntimeError("PostgreSQL did not become ready after installation") from last_error

View File

@@ -1,7 +1,7 @@
from __future__ import annotations
from app.config import settings
from app.models.schemas import AppManifest, DebComponent, DockerComponent
from app.models.schemas import AptComponent, AppManifest, DebComponent, DockerComponent
from app.utils.validators import validate_docker_registry, validate_url_host
@@ -15,6 +15,14 @@ class ManifestValidator:
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 == "apt":
component = AptComponent.model_validate(raw_component).model_dump(by_alias=True)
allowed_packages = set(settings.allowed_apt_packages)
if component["packageName"] not in allowed_packages:
raise ValueError(
f"APT package is not allowed: {component['packageName']}"
)
components.append(component)
elif component_type == "docker":
if not settings.allow_docker:
raise ValueError("Docker components are not enabled on this Agent")

View File

@@ -25,6 +25,11 @@ class ServiceManager:
def reset_failed(self, service_name: str) -> None:
self.command_runner.run(["systemctl", "reset-failed", service_name])
def assert_service_active(self, service_name: str) -> None:
result = self.command_runner.run(["systemctl", "is-active", service_name])
if result.stdout.strip() != "active":
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"

View File

@@ -10,7 +10,7 @@ from app.core.checksum import sha256_file
from app.core.command_runner import CommandRunner
from app.core.downloader import Downloader
from app.core.docker_installer import DockerInstaller, image_reference
from app.core.installer import DebInstaller
from app.core.installer import AptInstaller, DebInstaller
from app.core.manifest_client import ManifestClient
from app.core.manifest_validator import ManifestValidator
from app.core.service_manager import ServiceManager
@@ -86,7 +86,7 @@ class TaskRunner:
ordered = sorted(components, key=lambda item: item["install_order"], reverse=True)
total = len(ordered)
removed_deb_package = False
removed_apt_package = False
for index, component in enumerate(ordered, start=1):
progress = int((index - 1) / total * 80) + 10
component_id = component["component_id"]
@@ -103,13 +103,14 @@ class TaskRunner:
self._best_effort(task_id, f"disable service {service_name}", lambda: services.disable_service(service_name))
package_name = component.get("package_name")
if component["type"] == "deb" and package_name:
if component["type"] in {"deb", "apt"} and package_name:
self.repository.add_log(task_id, "info", f"Removing package {package_name}")
installer.remove_package(package_name, purge=effective_purge)
self._clean_cached_package_files(task_id, package_name, component_id)
if component["type"] == "deb":
self._clean_cached_package_files(task_id, package_name, component_id)
if service_name:
self._best_effort(task_id, f"reset failed state for {service_name}", lambda: services.reset_failed(service_name))
removed_deb_package = True
removed_apt_package = True
elif component["type"] == "docker":
container_name = component.get("container_name") or component_id
self.repository.add_log(task_id, "info", f"Removing Docker container {container_name}")
@@ -127,7 +128,7 @@ class TaskRunner:
else:
raise ValueError(f"Unsupported installed component type: {component['type']}")
if removed_deb_package:
if removed_apt_package:
self.repository.update_task(task_id, progress=92, current_step="cleaning package leftovers")
self._best_effort(
task_id,
@@ -210,6 +211,8 @@ class TaskRunner:
if component["type"] == "deb":
self._install_deb_component(task_id, manifest["appId"], component)
elif component["type"] == "apt":
self._install_apt_component(task_id, manifest["appId"], component)
elif component["type"] == "docker":
self._install_docker_component(task_id, manifest["appId"], component)
else:
@@ -286,6 +289,73 @@ class TaskRunner:
self.repository.upsert_installed_component(app_id, component)
def _install_apt_component(self, task_id: str, app_id: str, component: dict[str, Any]) -> None:
component_id = component["componentId"]
package_name = component["packageName"]
command_runner = CommandRunner(self.repository, task_id)
installer = AptInstaller(command_runner)
services = ServiceManager(command_runner)
self.repository.update_task_component(
task_id,
component_id,
progress=10,
current_step="refreshing APT package index",
)
self.repository.add_log(task_id, "info", "Refreshing APT package index")
installer.update_package_index()
self.repository.update_task_component(
task_id,
component_id,
progress=35,
current_step=f"installing APT package {package_name}",
)
self.repository.add_log(task_id, "info", f"Installing trusted APT package {package_name}")
installer.install_package(package_name)
self.repository.update_task_component(
task_id,
component_id,
progress=70,
current_step="verifying installed package",
)
installed_version = installer.get_package_version(package_name)
if not installed_version:
raise RuntimeError(f"APT package was not installed: {package_name}")
self.repository.add_log(
task_id,
"info",
f"APT package {package_name} installed with version {installed_version}",
)
installed_component = dict(component)
installed_component["version"] = installed_version
if package_name == "postgresql":
service_name = "postgresql.service"
self.repository.update_task_component(
task_id,
component_id,
progress=85,
current_step="starting PostgreSQL service",
)
services.enable_service(service_name)
services.start_service(service_name)
services.assert_service_active(service_name)
self.repository.update_task_component(
task_id,
component_id,
progress=95,
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
self.repository.upsert_installed_component(app_id, installed_component)
def _install_docker_component(self, task_id: str, app_id: str, component: dict[str, Any]) -> None:
component_id = component["componentId"]
container_name = component["containerName"]

View File

@@ -23,7 +23,7 @@ from app.utils.validators import (
TaskType = Literal["install", "update", "remove"]
TaskStatus = Literal["queued", "running", "success", "failed", "cancelled"]
ComponentType = Literal["deb", "docker", "docker_compose"]
ComponentType = Literal["deb", "apt", "docker", "docker_compose"]
class CamelModel(BaseModel):
@@ -176,6 +176,30 @@ class DebComponent(CamelModel):
return validate_service_name(value)
class AptComponent(CamelModel):
component_id: str = Field(alias="componentId")
type: Literal["apt"] = "apt"
install_order: int = Field(default=10, alias="installOrder")
required: bool = True
package_name: str = Field(alias="packageName")
version: str | None = None
@field_validator("component_id")
@classmethod
def _component_id(cls, value: str) -> str:
return validate_app_id(value)
@field_validator("package_name")
@classmethod
def _package_name(cls, value: str) -> str:
return validate_package_name(value)
@field_validator("version")
@classmethod
def _version(cls, value: str | None) -> str | None:
return validate_version(value) if value else None
class DockerComponent(CamelModel):
component_id: str = Field(alias="componentId")
type: Literal["docker"] = "docker"

View File

@@ -6,5 +6,5 @@ Architecture: amd64
Maintainer: Robot Team <admin@robot.package>
Depends: python3, python3-venv, python3-pip, curl
Description: Local Installer Agent for robot.installer
A local background service that installs, updates, and removes trusted .deb apps
from robot.package on the user's Linux machine.
A local background service that installs, updates, and removes trusted .deb,
allowlisted APT, and Docker apps on the user's Linux machine.

View File

@@ -60,6 +60,7 @@ AGENT_PORT=${AGENT_PORT}
ROBOT_PACKAGE_BASE_URL=https://package.pnkr.cloud
ALLOWED_ORIGINS=https://app.pnkr.cloud,https://package.pnkr.cloud,http://localhost:3000,http://127.0.0.1:3000,http://localhost:5173,http://127.0.0.1:5173,http://localhost:8080,http://127.0.0.1:8080
ALLOWED_DOWNLOAD_HOSTS=package.pnkr.cloud
ALLOWED_APT_PACKAGES=postgresql
ALLOWED_DOCKER_REGISTRIES=registry.robot.package,docker.io
CACHE_DIR=/var/cache/local-installer-agent/packages
APP_DIR=/opt/robot-apps

View File

@@ -0,0 +1,205 @@
from __future__ import annotations
import subprocess
import unittest
from types import SimpleNamespace
from unittest.mock import patch
from app.core.installer import APT_NONINTERACTIVE_ENV, AptInstaller
from app.core.manifest_validator import ManifestValidator
from app.core.task_runner import TaskRunner
def apt_manifest(component: dict | None = None) -> dict:
return {
"schemaVersion": "1.0",
"appId": "postgresql",
"appName": "PostgreSQL",
"version": "16",
"components": [
component
or {
"componentId": "postgresql",
"type": "apt",
"packageName": "postgresql",
"version": "16",
}
],
}
class FakeCommandRunner:
def __init__(self) -> None:
self.calls: list[tuple[list[str], int | None, dict[str, str] | None]] = []
def run(
self,
command: list[str],
timeout: int | None = None,
env: dict[str, str] | None = None,
) -> subprocess.CompletedProcess[str]:
self.calls.append((command, timeout, env))
return subprocess.CompletedProcess(command, 0, stdout="accepting connections\n", stderr="")
class AptManifestTests(unittest.TestCase):
def test_postgresql_is_accepted_when_allowlisted(self) -> None:
validator_settings = SimpleNamespace(allowed_apt_packages=["postgresql"])
with patch("app.core.manifest_validator.settings", validator_settings):
manifest = ManifestValidator().validate(apt_manifest())
self.assertEqual(manifest["components"][0]["type"], "apt")
self.assertEqual(manifest["components"][0]["packageName"], "postgresql")
def test_non_allowlisted_package_is_rejected(self) -> None:
validator_settings = SimpleNamespace(allowed_apt_packages=["postgresql"])
with patch("app.core.manifest_validator.settings", validator_settings):
with self.assertRaisesRegex(ValueError, "APT package is not allowed"):
ManifestValidator().validate(
apt_manifest(
{
"componentId": "curl",
"type": "apt",
"packageName": "curl",
}
)
)
def test_manifest_cannot_supply_a_shell_command(self) -> None:
validator_settings = SimpleNamespace(allowed_apt_packages=["postgresql"])
with patch("app.core.manifest_validator.settings", validator_settings):
with self.assertRaises(ValueError):
ManifestValidator().validate(
apt_manifest(
{
"componentId": "postgresql",
"type": "apt",
"packageName": "postgresql",
"command": "rm -rf /",
}
)
)
class AptInstallerTests(unittest.TestCase):
def test_apt_commands_are_fixed_argument_lists(self) -> None:
runner = FakeCommandRunner()
installer = AptInstaller(runner)
installer.update_package_index()
installer.install_package("postgresql")
installer.wait_for_postgresql(attempts=1, delay_seconds=0)
self.assertEqual(runner.calls[0][0], ["apt-get", "update"])
self.assertEqual(
runner.calls[1][0],
[
"apt-get",
"-o",
"Dpkg::Use-Pty=0",
"-o",
"Dpkg::Options::=--force-confdef",
"-o",
"Dpkg::Options::=--force-confold",
"install",
"--yes",
"postgresql",
],
)
self.assertEqual(runner.calls[1][2], APT_NONINTERACTIVE_ENV)
self.assertEqual(runner.calls[2][0], ["pg_isready", "--timeout=5"])
class FakeRepository:
def __init__(self) -> None:
self.component_updates: list[dict] = []
self.logs: list[tuple[str, str]] = []
self.installed_component: dict | None = None
def update_task_component(self, task_id: str, component_id: str, **fields: object) -> None:
self.component_updates.append(dict(fields))
def add_log(self, task_id: str, level: str, message: str) -> None:
self.logs.append((level, message))
def upsert_installed_component(self, app_id: str, component: dict) -> None:
self.installed_component = component
class FakeAptInstaller:
actions: list[str] = []
def __init__(self, command_runner: object) -> None:
self.command_runner = command_runner
def update_package_index(self) -> None:
self.actions.append("update")
def install_package(self, package_name: str) -> None:
self.actions.append(f"install:{package_name}")
def get_package_version(self, package_name: str) -> str:
self.actions.append(f"version:{package_name}")
return "16+257build1"
def wait_for_postgresql(self) -> None:
self.actions.append("pg_isready")
class FakeServiceManager:
actions: list[str] = []
def __init__(self, command_runner: object) -> None:
self.command_runner = command_runner
def enable_service(self, service_name: str) -> None:
self.actions.append(f"enable:{service_name}")
def start_service(self, service_name: str) -> None:
self.actions.append(f"start:{service_name}")
def assert_service_active(self, service_name: str) -> None:
self.actions.append(f"active:{service_name}")
class AptTaskRunnerTests(unittest.TestCase):
def test_postgresql_install_verifies_service_and_readiness(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-1",
"postgresql-app",
{
"componentId": "postgresql",
"type": "apt",
"packageName": "postgresql",
"version": "16",
},
)
self.assertEqual(
FakeAptInstaller.actions,
["update", "install:postgresql", "version:postgresql", "pg_isready"],
)
self.assertEqual(
FakeServiceManager.actions,
[
"enable:postgresql.service",
"start:postgresql.service",
"active: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")
if __name__ == "__main__":
unittest.main()