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`.
|
||||
|
||||
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.
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
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 FK_Packages_CreatedByUser
|
||||
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_PackageName_NotBlank CHECK (LEN(LTRIM(RTRIM(PackageName))) > 0)
|
||||
);
|
||||
|
||||
@@ -204,10 +204,14 @@ function helpers() {
|
||||
return statusClassMap[status] || 'badge-muted';
|
||||
},
|
||||
packageTypeLabel(type) {
|
||||
return type === 'docker' ? 'Docker' : '.deb';
|
||||
if (type === 'docker') return 'Docker';
|
||||
if (type === 'apt') return 'APT';
|
||||
return '.deb';
|
||||
},
|
||||
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) {
|
||||
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) {
|
||||
@@ -1694,8 +1699,13 @@ app.get('/packages/export.csv', asyncRoute(async (req, res) => {
|
||||
|
||||
app.post('/packages', upload.single('packageFile'), asyncRoute(async (req, res) => {
|
||||
try {
|
||||
const artifact = await getArtifactFromUpload(req.file);
|
||||
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 packageName = String(req.body.packageName || '').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
|
||||
});
|
||||
|
||||
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) {
|
||||
await removeUploadedFile(req.file);
|
||||
if (error.code === 'DUPLICATE_PACKAGE') {
|
||||
@@ -1758,7 +1775,6 @@ app.post('/package-versions', upload.single('packageFile'), asyncRoute(async (re
|
||||
let versionInput = null;
|
||||
|
||||
try {
|
||||
const artifact = await getArtifactFromUpload(req.file);
|
||||
const version = String(req.body.version || '').trim();
|
||||
|
||||
if (!req.body.packageId || !version) {
|
||||
@@ -1780,6 +1796,13 @@ app.post('/package-versions', upload.single('packageFile'), asyncRoute(async (re
|
||||
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') {
|
||||
const metadataMessage = await getDebUploadMetadataValidationMessage(req.file, packageItem.code, version);
|
||||
if (metadataMessage) {
|
||||
|
||||
@@ -10,6 +10,7 @@ const EMAIL_CONFIRMATION_EXPIRES_MS = Number(process.env.EMAIL_CONFIRMATION_EXPI
|
||||
|
||||
let emailConfirmationSchemaPromise;
|
||||
let applicationOpenUrlSchemaPromise;
|
||||
let packageTypeSchemaPromise;
|
||||
|
||||
function padDatePart(value) {
|
||||
return String(value).padStart(2, '0');
|
||||
@@ -326,6 +327,37 @@ LEFT JOIN dbo.PackageVersions AS pv
|
||||
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) {
|
||||
return isActive ? 'Active' : 'Archived';
|
||||
}
|
||||
@@ -850,6 +882,7 @@ async function deleteUser(userId) {
|
||||
}
|
||||
|
||||
async function listPackages() {
|
||||
await ensurePackageTypeSchema();
|
||||
const pool = await getPool();
|
||||
const result = await pool.request().query(`
|
||||
SELECT *
|
||||
@@ -996,7 +1029,10 @@ async function getApplicationById(id) {
|
||||
}
|
||||
|
||||
async function getApplicationManifest(appCode, version, baseUrl) {
|
||||
await ensureApplicationOpenUrlSchema();
|
||||
await Promise.all([
|
||||
ensureApplicationOpenUrlSchema(),
|
||||
ensurePackageTypeSchema()
|
||||
]);
|
||||
const pool = await getPool();
|
||||
const appResult = await pool.request()
|
||||
.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 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') {
|
||||
return {
|
||||
componentId: row.PackageCode,
|
||||
@@ -1167,6 +1214,7 @@ async function getPackageVersionDownload(packageVersionId) {
|
||||
}
|
||||
|
||||
async function createPackageWithVersion(input) {
|
||||
await ensurePackageTypeSchema();
|
||||
const pool = await getPool();
|
||||
const transaction = new sql.Transaction(pool);
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
<select data-filter-select data-filter-column="type" data-filter-table="packagesTable">
|
||||
<option value="">Tất cả</option>
|
||||
<option value="deb">.deb</option>
|
||||
<option value="apt">APT</option>
|
||||
<option value="docker">Docker</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
<span>Package type</span>
|
||||
<select name="packageType">
|
||||
<option value="deb">.deb</option>
|
||||
<option value="apt">APT (Ubuntu repository)</option>
|
||||
<option value="docker">Docker</option>
|
||||
</select>
|
||||
</label>
|
||||
@@ -60,6 +61,9 @@
|
||||
</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">
|
||||
<span>Docker image/tag</span>
|
||||
<input type="text" name="dockerImage" placeholder="registry.local/robot/fleet-agent:1.9.0">
|
||||
|
||||
Reference in New Issue
Block a user