update agent

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -25,6 +25,11 @@ class ServiceManager:
def reset_failed(self, service_name: str) -> None:
self.command_runner.run(["systemctl", "reset-failed", service_name])
def assert_service_active(self, service_name: str) -> None:
result = self.command_runner.run(["systemctl", "is-active", service_name])
if result.stdout.strip() != "active":
raise RuntimeError(f"Service is not active: {service_name}")
def get_service_status(self, service_name: str) -> dict[str, object]:
active = self._query(["systemctl", "is-active", service_name]) == "active"
enabled = self._query(["systemctl", "is-enabled", service_name]) == "enabled"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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)
);

View File

@@ -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) {

View File

@@ -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);

View File

@@ -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>

View File

@@ -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">