diff --git a/agent/README.md b/agent/README.md index 65ceca4..cfdea3f 100644 --- a/agent/README.md +++ b/agent/README.md @@ -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. diff --git a/agent/app/config.py b/agent/app/config.py index 4bdda7a..bc49744 100644 --- a/agent/app/config.py +++ b/agent/app/config.py @@ -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"], diff --git a/agent/app/core/installer.py b/agent/app/core/installer.py index 190f7d2..248a807 100644 --- a/agent/app/core/installer.py +++ b/agent/app/core/installer.py @@ -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 diff --git a/agent/app/core/manifest_validator.py b/agent/app/core/manifest_validator.py index 04aeab0..48ab642 100644 --- a/agent/app/core/manifest_validator.py +++ b/agent/app/core/manifest_validator.py @@ -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") diff --git a/agent/app/core/service_manager.py b/agent/app/core/service_manager.py index 5a3d2ad..0318b56 100644 --- a/agent/app/core/service_manager.py +++ b/agent/app/core/service_manager.py @@ -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" diff --git a/agent/app/core/task_runner.py b/agent/app/core/task_runner.py index d7378a7..6c12c2f 100644 --- a/agent/app/core/task_runner.py +++ b/agent/app/core/task_runner.py @@ -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"] diff --git a/agent/app/models/schemas.py b/agent/app/models/schemas.py index 190f077..1aaa874 100644 --- a/agent/app/models/schemas.py +++ b/agent/app/models/schemas.py @@ -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" diff --git a/agent/packaging/DEBIAN/control b/agent/packaging/DEBIAN/control index 629baf4..5fdad06 100644 --- a/agent/packaging/DEBIAN/control +++ b/agent/packaging/DEBIAN/control @@ -6,5 +6,5 @@ Architecture: amd64 Maintainer: Robot Team 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. diff --git a/agent/scripts/build-deb.sh b/agent/scripts/build-deb.sh index e100519..ad21008 100644 --- a/agent/scripts/build-deb.sh +++ b/agent/scripts/build-deb.sh @@ -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 diff --git a/agent/tests/test_apt_components.py b/agent/tests/test_apt_components.py new file mode 100644 index 0000000..79d7267 --- /dev/null +++ b/agent/tests/test_apt_components.py @@ -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() diff --git a/web-server/database/02_schema.sql b/web-server/database/02_schema.sql index 397443d..faa6f76 100644 --- a/web-server/database/02_schema.sql +++ b/web-server/database/02_schema.sql @@ -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) ); diff --git a/web-server/server.js b/web-server/server.js index 6c84041..3c7220c 100644 --- a/web-server/server.js +++ b/web-server/server.js @@ -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) { diff --git a/web-server/src/repository.js b/web-server/src/repository.js index cf1dda4..7423168 100644 --- a/web-server/src/repository.js +++ b/web-server/src/repository.js @@ -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); diff --git a/web-server/views/packages.ejs b/web-server/views/packages.ejs index 0029803..de33e0b 100644 --- a/web-server/views/packages.ejs +++ b/web-server/views/packages.ejs @@ -24,6 +24,7 @@ diff --git a/web-server/views/partials/package-modal.ejs b/web-server/views/partials/package-modal.ejs index 8642e6b..ab44be1 100644 --- a/web-server/views/partials/package-modal.ejs +++ b/web-server/views/partials/package-modal.ejs @@ -16,6 +16,7 @@ Package type @@ -60,6 +61,9 @@ +
+ APT packages only store metadata and do not need an uploaded file. Package code must be an Agent-allowlisted APT name, for example postgresql. +