update agent
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
FastAPI service that runs on each Linux client and listens on `127.0.0.1:5010`.
|
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
|
## 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`.
|
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.
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ class Settings:
|
|||||||
robot_package_base_url: str
|
robot_package_base_url: str
|
||||||
allowed_origins: list[str]
|
allowed_origins: list[str]
|
||||||
allowed_download_hosts: list[str]
|
allowed_download_hosts: list[str]
|
||||||
|
allowed_apt_packages: list[str]
|
||||||
allowed_docker_registries: list[str]
|
allowed_docker_registries: list[str]
|
||||||
cache_dir: Path
|
cache_dir: Path
|
||||||
app_dir: Path
|
app_dir: Path
|
||||||
@@ -83,6 +84,10 @@ def get_settings() -> Settings:
|
|||||||
os.getenv("ALLOWED_DOWNLOAD_HOSTS"),
|
os.getenv("ALLOWED_DOWNLOAD_HOSTS"),
|
||||||
_default_allowed_download_hosts(robot_package_base_url),
|
_default_allowed_download_hosts(robot_package_base_url),
|
||||||
),
|
),
|
||||||
|
allowed_apt_packages=_csv(
|
||||||
|
os.getenv("ALLOWED_APT_PACKAGES"),
|
||||||
|
["postgresql"],
|
||||||
|
),
|
||||||
allowed_docker_registries=_csv_with_defaults(
|
allowed_docker_registries=_csv_with_defaults(
|
||||||
os.getenv("ALLOWED_DOCKER_REGISTRIES"),
|
os.getenv("ALLOWED_DOCKER_REGISTRIES"),
|
||||||
["registry.robot.package", "docker.io"],
|
["registry.robot.package", "docker.io"],
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from app.core.command_runner import CommandRunner
|
from app.core.command_runner import CommandError, CommandRunner
|
||||||
|
|
||||||
|
|
||||||
APT_DPKG_OPTIONS = [
|
APT_DPKG_OPTIONS = [
|
||||||
@@ -101,3 +102,42 @@ class DebInstaller:
|
|||||||
return True
|
return True
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
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
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from app.config import settings
|
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
|
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)
|
component = DebComponent.model_validate(raw_component).model_dump(by_alias=True)
|
||||||
validate_url_host(component["downloadUrl"], settings.allowed_download_hosts)
|
validate_url_host(component["downloadUrl"], settings.allowed_download_hosts)
|
||||||
components.append(component)
|
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":
|
elif component_type == "docker":
|
||||||
if not settings.allow_docker:
|
if not settings.allow_docker:
|
||||||
raise ValueError("Docker components are not enabled on this Agent")
|
raise ValueError("Docker components are not enabled on this Agent")
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ class ServiceManager:
|
|||||||
def reset_failed(self, service_name: str) -> None:
|
def reset_failed(self, service_name: str) -> None:
|
||||||
self.command_runner.run(["systemctl", "reset-failed", service_name])
|
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]:
|
def get_service_status(self, service_name: str) -> dict[str, object]:
|
||||||
active = self._query(["systemctl", "is-active", service_name]) == "active"
|
active = self._query(["systemctl", "is-active", service_name]) == "active"
|
||||||
enabled = self._query(["systemctl", "is-enabled", service_name]) == "enabled"
|
enabled = self._query(["systemctl", "is-enabled", service_name]) == "enabled"
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from app.core.checksum import sha256_file
|
|||||||
from app.core.command_runner import CommandRunner
|
from app.core.command_runner import CommandRunner
|
||||||
from app.core.downloader import Downloader
|
from app.core.downloader import Downloader
|
||||||
from app.core.docker_installer import DockerInstaller, image_reference
|
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_client import ManifestClient
|
||||||
from app.core.manifest_validator import ManifestValidator
|
from app.core.manifest_validator import ManifestValidator
|
||||||
from app.core.service_manager import ServiceManager
|
from app.core.service_manager import ServiceManager
|
||||||
@@ -86,7 +86,7 @@ class TaskRunner:
|
|||||||
|
|
||||||
ordered = sorted(components, key=lambda item: item["install_order"], reverse=True)
|
ordered = sorted(components, key=lambda item: item["install_order"], reverse=True)
|
||||||
total = len(ordered)
|
total = len(ordered)
|
||||||
removed_deb_package = False
|
removed_apt_package = False
|
||||||
for index, component in enumerate(ordered, start=1):
|
for index, component in enumerate(ordered, start=1):
|
||||||
progress = int((index - 1) / total * 80) + 10
|
progress = int((index - 1) / total * 80) + 10
|
||||||
component_id = component["component_id"]
|
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))
|
self._best_effort(task_id, f"disable service {service_name}", lambda: services.disable_service(service_name))
|
||||||
|
|
||||||
package_name = component.get("package_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}")
|
self.repository.add_log(task_id, "info", f"Removing package {package_name}")
|
||||||
installer.remove_package(package_name, purge=effective_purge)
|
installer.remove_package(package_name, purge=effective_purge)
|
||||||
|
if component["type"] == "deb":
|
||||||
self._clean_cached_package_files(task_id, package_name, component_id)
|
self._clean_cached_package_files(task_id, package_name, component_id)
|
||||||
if service_name:
|
if service_name:
|
||||||
self._best_effort(task_id, f"reset failed state for {service_name}", lambda: services.reset_failed(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":
|
elif component["type"] == "docker":
|
||||||
container_name = component.get("container_name") or component_id
|
container_name = component.get("container_name") or component_id
|
||||||
self.repository.add_log(task_id, "info", f"Removing Docker container {container_name}")
|
self.repository.add_log(task_id, "info", f"Removing Docker container {container_name}")
|
||||||
@@ -127,7 +128,7 @@ class TaskRunner:
|
|||||||
else:
|
else:
|
||||||
raise ValueError(f"Unsupported installed component type: {component['type']}")
|
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.repository.update_task(task_id, progress=92, current_step="cleaning package leftovers")
|
||||||
self._best_effort(
|
self._best_effort(
|
||||||
task_id,
|
task_id,
|
||||||
@@ -210,6 +211,8 @@ class TaskRunner:
|
|||||||
|
|
||||||
if component["type"] == "deb":
|
if component["type"] == "deb":
|
||||||
self._install_deb_component(task_id, manifest["appId"], component)
|
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":
|
elif component["type"] == "docker":
|
||||||
self._install_docker_component(task_id, manifest["appId"], component)
|
self._install_docker_component(task_id, manifest["appId"], component)
|
||||||
else:
|
else:
|
||||||
@@ -286,6 +289,73 @@ class TaskRunner:
|
|||||||
|
|
||||||
self.repository.upsert_installed_component(app_id, component)
|
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:
|
def _install_docker_component(self, task_id: str, app_id: str, component: dict[str, Any]) -> None:
|
||||||
component_id = component["componentId"]
|
component_id = component["componentId"]
|
||||||
container_name = component["containerName"]
|
container_name = component["containerName"]
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ from app.utils.validators import (
|
|||||||
|
|
||||||
TaskType = Literal["install", "update", "remove"]
|
TaskType = Literal["install", "update", "remove"]
|
||||||
TaskStatus = Literal["queued", "running", "success", "failed", "cancelled"]
|
TaskStatus = Literal["queued", "running", "success", "failed", "cancelled"]
|
||||||
ComponentType = Literal["deb", "docker", "docker_compose"]
|
ComponentType = Literal["deb", "apt", "docker", "docker_compose"]
|
||||||
|
|
||||||
|
|
||||||
class CamelModel(BaseModel):
|
class CamelModel(BaseModel):
|
||||||
@@ -176,6 +176,30 @@ class DebComponent(CamelModel):
|
|||||||
return validate_service_name(value)
|
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):
|
class DockerComponent(CamelModel):
|
||||||
component_id: str = Field(alias="componentId")
|
component_id: str = Field(alias="componentId")
|
||||||
type: Literal["docker"] = "docker"
|
type: Literal["docker"] = "docker"
|
||||||
|
|||||||
@@ -6,5 +6,5 @@ Architecture: amd64
|
|||||||
Maintainer: Robot Team <admin@robot.package>
|
Maintainer: Robot Team <admin@robot.package>
|
||||||
Depends: python3, python3-venv, python3-pip, curl
|
Depends: python3, python3-venv, python3-pip, curl
|
||||||
Description: Local Installer Agent for robot.installer
|
Description: Local Installer Agent for robot.installer
|
||||||
A local background service that installs, updates, and removes trusted .deb apps
|
A local background service that installs, updates, and removes trusted .deb,
|
||||||
from robot.package on the user's Linux machine.
|
allowlisted APT, and Docker apps on the user's Linux machine.
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ AGENT_PORT=${AGENT_PORT}
|
|||||||
ROBOT_PACKAGE_BASE_URL=https://package.pnkr.cloud
|
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_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_DOWNLOAD_HOSTS=package.pnkr.cloud
|
||||||
|
ALLOWED_APT_PACKAGES=postgresql
|
||||||
ALLOWED_DOCKER_REGISTRIES=registry.robot.package,docker.io
|
ALLOWED_DOCKER_REGISTRIES=registry.robot.package,docker.io
|
||||||
CACHE_DIR=/var/cache/local-installer-agent/packages
|
CACHE_DIR=/var/cache/local-installer-agent/packages
|
||||||
APP_DIR=/opt/robot-apps
|
APP_DIR=/opt/robot-apps
|
||||||
|
|||||||
205
agent/tests/test_apt_components.py
Normal file
205
agent/tests/test_apt_components.py
Normal 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()
|
||||||
@@ -71,7 +71,7 @@ CREATE TABLE dbo.Packages
|
|||||||
CONSTRAINT DF_Packages_IsActive DEFAULT 1,
|
CONSTRAINT DF_Packages_IsActive DEFAULT 1,
|
||||||
CONSTRAINT FK_Packages_CreatedByUser
|
CONSTRAINT FK_Packages_CreatedByUser
|
||||||
FOREIGN KEY (CreatedByUserId) REFERENCES dbo.Users(Id),
|
FOREIGN KEY (CreatedByUserId) REFERENCES dbo.Users(Id),
|
||||||
CONSTRAINT CK_Packages_PackageType CHECK (PackageType IN (N'deb', N'docker')),
|
CONSTRAINT CK_Packages_PackageType CHECK (PackageType IN (N'deb', N'apt', N'docker')),
|
||||||
CONSTRAINT CK_Packages_PackageCode_NotBlank CHECK (LEN(LTRIM(RTRIM(PackageCode))) > 0),
|
CONSTRAINT CK_Packages_PackageCode_NotBlank CHECK (LEN(LTRIM(RTRIM(PackageCode))) > 0),
|
||||||
CONSTRAINT CK_Packages_PackageName_NotBlank CHECK (LEN(LTRIM(RTRIM(PackageName))) > 0)
|
CONSTRAINT CK_Packages_PackageName_NotBlank CHECK (LEN(LTRIM(RTRIM(PackageName))) > 0)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -204,10 +204,14 @@ function helpers() {
|
|||||||
return statusClassMap[status] || 'badge-muted';
|
return statusClassMap[status] || 'badge-muted';
|
||||||
},
|
},
|
||||||
packageTypeLabel(type) {
|
packageTypeLabel(type) {
|
||||||
return type === 'docker' ? 'Docker' : '.deb';
|
if (type === 'docker') return 'Docker';
|
||||||
|
if (type === 'apt') return 'APT';
|
||||||
|
return '.deb';
|
||||||
},
|
},
|
||||||
packageTypeClass(type) {
|
packageTypeClass(type) {
|
||||||
return type === 'docker' ? 'badge-info' : 'badge-primary';
|
if (type === 'docker') return 'badge-info';
|
||||||
|
if (type === 'apt') return 'badge-warning';
|
||||||
|
return 'badge-primary';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -848,7 +852,8 @@ function requireAdmin(req, res, next) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function normalizePackageType(value) {
|
function normalizePackageType(value) {
|
||||||
return String(value || 'deb').toLowerCase() === 'docker' ? 'docker' : 'deb';
|
const packageType = String(value || 'deb').toLowerCase();
|
||||||
|
return ['deb', 'apt', 'docker'].includes(packageType) ? packageType : 'deb';
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeApplicationStatus(value) {
|
function normalizeApplicationStatus(value) {
|
||||||
@@ -1694,8 +1699,13 @@ app.get('/packages/export.csv', asyncRoute(async (req, res) => {
|
|||||||
|
|
||||||
app.post('/packages', upload.single('packageFile'), asyncRoute(async (req, res) => {
|
app.post('/packages', upload.single('packageFile'), asyncRoute(async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const artifact = await getArtifactFromUpload(req.file);
|
|
||||||
const packageType = normalizePackageType(req.body.packageType);
|
const packageType = normalizePackageType(req.body.packageType);
|
||||||
|
if (packageType === 'apt' && req.file) {
|
||||||
|
await removeUploadedFile(req.file);
|
||||||
|
}
|
||||||
|
const artifact = packageType === 'apt'
|
||||||
|
? { filePath: null, checksum: null, fileSizeBytes: null }
|
||||||
|
: await getArtifactFromUpload(req.file);
|
||||||
const packageCode = String(req.body.packageCode || '').trim();
|
const packageCode = String(req.body.packageCode || '').trim();
|
||||||
const packageName = String(req.body.packageName || '').trim();
|
const packageName = String(req.body.packageName || '').trim();
|
||||||
const version = String(req.body.version || '').trim();
|
const version = String(req.body.version || '').trim();
|
||||||
@@ -1742,7 +1752,14 @@ app.post('/packages', upload.single('packageFile'), asyncRoute(async (req, res)
|
|||||||
createdByUserId: req.currentUser.id
|
createdByUserId: req.currentUser.id
|
||||||
});
|
});
|
||||||
|
|
||||||
redirectWithNotice(res, '/packages', 'success', 'Đã upload package và lưu vào database.');
|
redirectWithNotice(
|
||||||
|
res,
|
||||||
|
'/packages',
|
||||||
|
'success',
|
||||||
|
packageType === 'apt'
|
||||||
|
? 'Đã tạo APT package metadata; không cần upload artifact.'
|
||||||
|
: 'Đã upload package và lưu vào database.'
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await removeUploadedFile(req.file);
|
await removeUploadedFile(req.file);
|
||||||
if (error.code === 'DUPLICATE_PACKAGE') {
|
if (error.code === 'DUPLICATE_PACKAGE') {
|
||||||
@@ -1758,7 +1775,6 @@ app.post('/package-versions', upload.single('packageFile'), asyncRoute(async (re
|
|||||||
let versionInput = null;
|
let versionInput = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const artifact = await getArtifactFromUpload(req.file);
|
|
||||||
const version = String(req.body.version || '').trim();
|
const version = String(req.body.version || '').trim();
|
||||||
|
|
||||||
if (!req.body.packageId || !version) {
|
if (!req.body.packageId || !version) {
|
||||||
@@ -1780,6 +1796,13 @@ app.post('/package-versions', upload.single('packageFile'), asyncRoute(async (re
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (packageItem.type === 'apt' && req.file) {
|
||||||
|
await removeUploadedFile(req.file);
|
||||||
|
}
|
||||||
|
const artifact = packageItem.type === 'apt'
|
||||||
|
? { filePath: null, checksum: null, fileSizeBytes: null }
|
||||||
|
: await getArtifactFromUpload(req.file);
|
||||||
|
|
||||||
if (packageItem.type === 'deb') {
|
if (packageItem.type === 'deb') {
|
||||||
const metadataMessage = await getDebUploadMetadataValidationMessage(req.file, packageItem.code, version);
|
const metadataMessage = await getDebUploadMetadataValidationMessage(req.file, packageItem.code, version);
|
||||||
if (metadataMessage) {
|
if (metadataMessage) {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const EMAIL_CONFIRMATION_EXPIRES_MS = Number(process.env.EMAIL_CONFIRMATION_EXPI
|
|||||||
|
|
||||||
let emailConfirmationSchemaPromise;
|
let emailConfirmationSchemaPromise;
|
||||||
let applicationOpenUrlSchemaPromise;
|
let applicationOpenUrlSchemaPromise;
|
||||||
|
let packageTypeSchemaPromise;
|
||||||
|
|
||||||
function padDatePart(value) {
|
function padDatePart(value) {
|
||||||
return String(value).padStart(2, '0');
|
return String(value).padStart(2, '0');
|
||||||
@@ -326,6 +327,37 @@ LEFT JOIN dbo.PackageVersions AS pv
|
|||||||
return applicationOpenUrlSchemaPromise;
|
return applicationOpenUrlSchemaPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function ensurePackageTypeSchema() {
|
||||||
|
if (!packageTypeSchemaPromise) {
|
||||||
|
packageTypeSchemaPromise = getPool().then((pool) => pool.request().query(`
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM sys.check_constraints
|
||||||
|
WHERE name = N'CK_Packages_PackageType'
|
||||||
|
AND parent_object_id = OBJECT_ID(N'dbo.Packages')
|
||||||
|
AND definition NOT LIKE N'%apt%'
|
||||||
|
)
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.Packages DROP CONSTRAINT CK_Packages_PackageType;
|
||||||
|
END;
|
||||||
|
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM sys.check_constraints
|
||||||
|
WHERE name = N'CK_Packages_PackageType'
|
||||||
|
AND parent_object_id = OBJECT_ID(N'dbo.Packages')
|
||||||
|
)
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE dbo.Packages WITH CHECK
|
||||||
|
ADD CONSTRAINT CK_Packages_PackageType
|
||||||
|
CHECK (PackageType IN (N'deb', N'apt', N'docker'));
|
||||||
|
END;
|
||||||
|
`));
|
||||||
|
}
|
||||||
|
|
||||||
|
return packageTypeSchemaPromise;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizePackageStatus(isActive) {
|
function normalizePackageStatus(isActive) {
|
||||||
return isActive ? 'Active' : 'Archived';
|
return isActive ? 'Active' : 'Archived';
|
||||||
}
|
}
|
||||||
@@ -850,6 +882,7 @@ async function deleteUser(userId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function listPackages() {
|
async function listPackages() {
|
||||||
|
await ensurePackageTypeSchema();
|
||||||
const pool = await getPool();
|
const pool = await getPool();
|
||||||
const result = await pool.request().query(`
|
const result = await pool.request().query(`
|
||||||
SELECT *
|
SELECT *
|
||||||
@@ -996,7 +1029,10 @@ async function getApplicationById(id) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getApplicationManifest(appCode, version, baseUrl) {
|
async function getApplicationManifest(appCode, version, baseUrl) {
|
||||||
await ensureApplicationOpenUrlSchema();
|
await Promise.all([
|
||||||
|
ensureApplicationOpenUrlSchema(),
|
||||||
|
ensurePackageTypeSchema()
|
||||||
|
]);
|
||||||
const pool = await getPool();
|
const pool = await getPool();
|
||||||
const appResult = await pool.request()
|
const appResult = await pool.request()
|
||||||
.input('AppCode', sql.NVarChar(100), String(appCode || '').trim())
|
.input('AppCode', sql.NVarChar(100), String(appCode || '').trim())
|
||||||
@@ -1048,6 +1084,17 @@ async function getApplicationManifest(appCode, version, baseUrl) {
|
|||||||
const components = componentResult.recordset.map((row) => {
|
const components = componentResult.recordset.map((row) => {
|
||||||
const installOrder = Number(row.InstallOrder || 10);
|
const installOrder = Number(row.InstallOrder || 10);
|
||||||
|
|
||||||
|
if (row.PackageType === 'apt') {
|
||||||
|
return {
|
||||||
|
componentId: row.PackageCode,
|
||||||
|
type: 'apt',
|
||||||
|
installOrder,
|
||||||
|
required: true,
|
||||||
|
packageName: row.PackageCode,
|
||||||
|
version: row.Version || undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (row.PackageType === 'docker') {
|
if (row.PackageType === 'docker') {
|
||||||
return {
|
return {
|
||||||
componentId: row.PackageCode,
|
componentId: row.PackageCode,
|
||||||
@@ -1167,6 +1214,7 @@ async function getPackageVersionDownload(packageVersionId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function createPackageWithVersion(input) {
|
async function createPackageWithVersion(input) {
|
||||||
|
await ensurePackageTypeSchema();
|
||||||
const pool = await getPool();
|
const pool = await getPool();
|
||||||
const transaction = new sql.Transaction(pool);
|
const transaction = new sql.Transaction(pool);
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
<select data-filter-select data-filter-column="type" data-filter-table="packagesTable">
|
<select data-filter-select data-filter-column="type" data-filter-table="packagesTable">
|
||||||
<option value="">Tất cả</option>
|
<option value="">Tất cả</option>
|
||||||
<option value="deb">.deb</option>
|
<option value="deb">.deb</option>
|
||||||
|
<option value="apt">APT</option>
|
||||||
<option value="docker">Docker</option>
|
<option value="docker">Docker</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
<span>Package type</span>
|
<span>Package type</span>
|
||||||
<select name="packageType">
|
<select name="packageType">
|
||||||
<option value="deb">.deb</option>
|
<option value="deb">.deb</option>
|
||||||
|
<option value="apt">APT (Ubuntu repository)</option>
|
||||||
<option value="docker">Docker</option>
|
<option value="docker">Docker</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
@@ -60,6 +61,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-field full">
|
||||||
|
<small>APT packages only store metadata and do not need an uploaded file. Package code must be an Agent-allowlisted APT name, for example <code>postgresql</code>.</small>
|
||||||
|
</div>
|
||||||
<label class="form-field full">
|
<label class="form-field full">
|
||||||
<span>Docker image/tag</span>
|
<span>Docker image/tag</span>
|
||||||
<input type="text" name="dockerImage" placeholder="registry.local/robot/fleet-agent:1.9.0">
|
<input type="text" name="dockerImage" placeholder="registry.local/robot/fleet-agent:1.9.0">
|
||||||
|
|||||||
Reference in New Issue
Block a user