This commit is contained in:
2026-05-28 14:26:02 +07:00
parent b0443d5950
commit 991d6f5257
13 changed files with 464 additions and 22 deletions

2
.gitignore vendored
View File

@@ -9,4 +9,4 @@ agent/build/
web-client/dist/ web-client/dist/
web-server/uploads/ web-server/uploads/
docs docs/*

View File

@@ -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`. 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.
## Development ## Development
@@ -74,3 +74,5 @@ For manifest mode, the Agent fetches:
```text ```text
{ROBOT_PACKAGE_BASE_URL}/api/apps/{appId}/versions/{version}/manifest {ROBOT_PACKAGE_BASE_URL}/api/apps/{appId}/versions/{version}/manifest
``` ```
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`.

View File

@@ -13,6 +13,16 @@ def _csv(value: str | None, fallback: list[str]) -> list[str]:
return [item.strip() for item in value.split(",") if item.strip()] return [item.strip() for item in value.split(",") if item.strip()]
def _csv_with_defaults(value: str | None, defaults: list[str]) -> list[str]:
items = _csv(value, [])
seen = set(items)
for item in defaults:
if item not in seen:
items.append(item)
seen.add(item)
return items
def _default_allowed_download_hosts(base_url: str) -> list[str]: def _default_allowed_download_hosts(base_url: str) -> list[str]:
parsed = urlparse(base_url) parsed = urlparse(base_url)
if parsed.hostname: if parsed.hostname:
@@ -37,6 +47,7 @@ class Settings:
allow_purge: bool allow_purge: bool
allow_docker: bool allow_docker: bool
allow_docker_compose: bool allow_docker_compose: bool
auto_install_docker: bool
command_timeout_seconds: int command_timeout_seconds: int
@@ -63,9 +74,9 @@ 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_docker_registries=_csv( allowed_docker_registries=_csv_with_defaults(
os.getenv("ALLOWED_DOCKER_REGISTRIES"), os.getenv("ALLOWED_DOCKER_REGISTRIES"),
["registry.robot.package"], ["registry.robot.package", "docker.io"],
), ),
cache_dir=Path(os.getenv("CACHE_DIR", "/var/cache/local-installer-agent/packages")), cache_dir=Path(os.getenv("CACHE_DIR", "/var/cache/local-installer-agent/packages")),
app_dir=Path(os.getenv("APP_DIR", "/opt/robot-apps")), app_dir=Path(os.getenv("APP_DIR", "/opt/robot-apps")),
@@ -73,8 +84,9 @@ def get_settings() -> Settings:
db_path=Path(os.getenv("DB_PATH", "/var/lib/local-installer-agent/agent.db")), db_path=Path(os.getenv("DB_PATH", "/var/lib/local-installer-agent/agent.db")),
allow_remove=_bool("ALLOW_REMOVE", True), allow_remove=_bool("ALLOW_REMOVE", True),
allow_purge=_bool("ALLOW_PURGE", False), allow_purge=_bool("ALLOW_PURGE", False),
allow_docker=_bool("ALLOW_DOCKER", False), allow_docker=_bool("ALLOW_DOCKER", True),
allow_docker_compose=_bool("ALLOW_DOCKER_COMPOSE", False), allow_docker_compose=_bool("ALLOW_DOCKER_COMPOSE", False),
auto_install_docker=_bool("AUTO_INSTALL_DOCKER", True),
command_timeout_seconds=int(os.getenv("COMMAND_TIMEOUT_SECONDS", "900")), command_timeout_seconds=int(os.getenv("COMMAND_TIMEOUT_SECONDS", "900")),
) )

View File

@@ -0,0 +1,163 @@
from __future__ import annotations
import shutil
from app.core.command_runner import CommandError, CommandRunner
DOCKER_REQUIRED_MESSAGE = "Docker runtime is required"
def image_reference(component: dict) -> str:
image = component["image"]
digest = component.get("digest")
tag = component.get("tag")
if "@" in image:
return image
if digest:
return f"{_image_without_tag(image)}@{digest}"
if _image_has_tag(image) or not tag:
return image
return f"{image}:{tag}"
def _image_has_tag(image: str) -> bool:
last_slash = image.rfind("/")
last_colon = image.rfind(":")
return last_colon > last_slash
def _image_without_tag(image: str) -> str:
if not _image_has_tag(image):
return image
return image[: image.rfind(":")]
class DockerInstaller:
def __init__(self, command_runner: CommandRunner) -> None:
self.command_runner = command_runner
def ensure_runtime(self, auto_install: bool = False) -> None:
if not shutil.which("docker"):
if not auto_install:
raise RuntimeError(DOCKER_REQUIRED_MESSAGE)
self.install_runtime()
try:
self._verify_runtime()
return
except CommandError as error:
self._log("warning", self._runtime_error_message(error))
self._start_runtime()
try:
self._verify_runtime()
except CommandError as error:
raise RuntimeError(self._runtime_error_message(error)) from error
def install_runtime(self) -> None:
if not shutil.which("apt-get"):
raise RuntimeError(
f"{DOCKER_REQUIRED_MESSAGE}. Install Docker Engine on this client or disable Docker packages."
)
self._log("info", "Docker runtime not found; installing docker.io")
try:
self.command_runner.run(["apt-get", "update"], timeout=600)
self.command_runner.run(
["env", "DEBIAN_FRONTEND=noninteractive", "apt-get", "install", "-y", "docker.io"],
timeout=1200,
)
except CommandError as error:
raise RuntimeError(
f"{DOCKER_REQUIRED_MESSAGE}: failed to install docker.io with apt-get: "
f"{self._command_error_detail(error)}"
) from error
self._start_runtime()
def _verify_runtime(self) -> None:
self.command_runner.run(["docker", "version", "--format", "{{.Server.Version}}"])
def _start_runtime(self) -> None:
if shutil.which("systemctl"):
try:
self.command_runner.run(["systemctl", "enable", "--now", "docker"], timeout=120)
return
except CommandError as error:
self._log("warning", f"Could not start Docker with systemctl: {self._command_error_detail(error)}")
if shutil.which("service"):
try:
self.command_runner.run(["service", "docker", "start"], timeout=120)
except CommandError as error:
self._log("warning", f"Could not start Docker service: {self._command_error_detail(error)}")
def _runtime_error_message(self, error: CommandError) -> str:
detail = self._command_error_detail(error)
return f"{DOCKER_REQUIRED_MESSAGE}: {detail}" if detail else DOCKER_REQUIRED_MESSAGE
def _command_error_detail(self, error: CommandError) -> str:
output = (error.stderr or error.stdout or "").strip()
if not output:
return str(error)
return output.splitlines()[-1].strip()
def _log(self, level: str, message: str) -> None:
if self.command_runner.task_id:
self.command_runner.repository.add_log(self.command_runner.task_id, level, message)
def pull_image(self, reference: str) -> None:
self.command_runner.run(["docker", "pull", reference])
def recreate_container(self, app_id: str, component: dict) -> None:
container_name = component["containerName"]
reference = image_reference(component)
self.remove_container(container_name)
command = [
"docker",
"run",
"-d",
"--name",
container_name,
"--restart",
component.get("restartPolicy", "unless-stopped"),
"--label",
f"local-installer-agent.app-id={app_id}",
"--label",
f"local-installer-agent.component-id={component['componentId']}",
]
for name, value in sorted((component.get("env") or {}).items()):
command.extend(["-e", f"{name}={value}"])
for port in component.get("ports") or []:
command.extend(["-p", port])
for volume in component.get("volumes") or []:
command.extend(["-v", volume])
command.append(reference)
self.command_runner.run(command)
def remove_container(self, container_name: str) -> None:
result = self.command_runner.run([
"docker",
"ps",
"-aq",
"--filter",
f"name=^/{container_name}$",
])
if result.stdout.strip():
self.command_runner.run(["docker", "rm", "-f", container_name])
def assert_container_running(self, container_name: str) -> None:
result = self.command_runner.run([
"docker",
"inspect",
"-f",
"{{.State.Running}}",
container_name,
])
if result.stdout.strip().lower() != "true":
raise RuntimeError(f"Docker container is not running: {container_name}")

View File

@@ -1,8 +1,8 @@
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 from app.models.schemas import AppManifest, DebComponent, DockerComponent
from app.utils.validators import validate_url_host from app.utils.validators import validate_docker_registry, validate_url_host
class ManifestValidator: class ManifestValidator:
@@ -15,12 +15,17 @@ 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 == "docker" and not settings.allow_docker: elif component_type == "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")
elif component_type == "docker_compose" and not settings.allow_docker_compose: component = DockerComponent.model_validate(raw_component).model_dump(by_alias=True)
validate_docker_registry(component["image"], settings.allowed_docker_registries)
components.append(component)
elif component_type == "docker_compose":
if not settings.allow_docker_compose:
raise ValueError("Docker Compose components are not enabled on this Agent") raise ValueError("Docker Compose components are not enabled on this Agent")
raise ValueError("Docker Compose components are not implemented on this Agent")
else: else:
raise ValueError(f"Unsupported component type: {component_type}") raise ValueError(f"Unsupported component type: {component_type}")
manifest["components"] = sorted(components, key=lambda item: item.get("installOrder", 10)) manifest["components"] = sorted(components, key=lambda item: item.get("installOrder", 10))
return manifest return manifest

View File

@@ -9,6 +9,7 @@ from app.config import settings
from app.core.checksum import sha256_file 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.installer import DebInstaller from app.core.installer import 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
@@ -99,6 +100,12 @@ class TaskRunner:
if component["type"] == "deb" and package_name: if component["type"] == "deb" 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=request.purge) installer.remove_package(package_name, purge=request.purge)
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}")
docker_installer = DockerInstaller(command_runner)
docker_installer.ensure_runtime(auto_install=settings.auto_install_docker)
docker_installer.remove_container(container_name)
else: else:
raise ValueError(f"Unsupported installed component type: {component['type']}") raise ValueError(f"Unsupported installed component type: {component['type']}")
@@ -171,14 +178,16 @@ class TaskRunner:
component_id, component_id,
status="running", status="running",
progress=5, progress=5,
current_step="downloading", current_step="preparing",
started_at=utc_now(), started_at=utc_now(),
) )
if component["type"] != "deb": if component["type"] == "deb":
raise ValueError(f"Unsupported component type in MVP: {component['type']}")
self._install_deb_component(task_id, manifest["appId"], component) self._install_deb_component(task_id, manifest["appId"], component)
elif component["type"] == "docker":
self._install_docker_component(task_id, manifest["appId"], component)
else:
raise ValueError(f"Unsupported component type: {component['type']}")
self.repository.update_task_component( self.repository.update_task_component(
task_id, task_id,
@@ -196,6 +205,7 @@ class TaskRunner:
installer = DebInstaller(command_runner) installer = DebInstaller(command_runner)
services = ServiceManager(command_runner) services = ServiceManager(command_runner)
self.repository.update_task_component(task_id, component_id, progress=10, current_step="downloading")
package_path = downloader.download(component["downloadUrl"]) package_path = downloader.download(component["downloadUrl"])
self.repository.update_task_component(task_id, component_id, progress=35, current_step="verifying checksum") self.repository.update_task_component(task_id, component_id, progress=35, current_step="verifying checksum")
actual_sha256 = sha256_file(package_path) actual_sha256 = sha256_file(package_path)
@@ -250,6 +260,32 @@ class TaskRunner:
self.repository.upsert_installed_component(app_id, component) self.repository.upsert_installed_component(app_id, 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"]
reference = image_reference(component)
command_runner = CommandRunner(self.repository, task_id)
installer = DockerInstaller(command_runner)
self.repository.update_task_component(task_id, component_id, progress=15, current_step="checking Docker runtime")
installer.ensure_runtime(auto_install=settings.auto_install_docker)
self.repository.update_task_component(task_id, component_id, progress=35, current_step="pulling image")
self.repository.add_log(task_id, "info", f"Pulling Docker image {reference}")
installer.pull_image(reference)
self.repository.update_task_component(task_id, component_id, progress=70, current_step="recreating container")
self.repository.add_log(task_id, "info", f"Recreating Docker container {container_name}")
installer.recreate_container(app_id, component)
self.repository.update_task_component(task_id, component_id, progress=90, current_step="verifying container")
installer.assert_container_running(container_name)
self.repository.add_log(task_id, "info", f"Docker container {container_name} is running")
installed_component = dict(component)
installed_component["image"] = reference
self.repository.upsert_installed_component(app_id, installed_component)
def _mark_started(self, task_id: str, step: str) -> None: def _mark_started(self, task_id: str, step: str) -> None:
self.repository.update_task( self.repository.update_task(
task_id, task_id,
@@ -261,12 +297,24 @@ class TaskRunner:
self.repository.add_log(task_id, "info", step) self.repository.add_log(task_id, "info", step)
def _fail_task(self, task_id: str, error: Exception) -> None: def _fail_task(self, task_id: str, error: Exception) -> None:
task = self.repository.get_task(task_id)
component_id = task.get("current_component_id") if task else None
finished_at = utc_now()
if component_id:
self.repository.update_task_component(
task_id,
component_id,
status="failed",
current_step="failed",
error_message=str(error),
finished_at=finished_at,
)
self.repository.update_task( self.repository.update_task(
task_id, task_id,
status="failed", status="failed",
current_step="failed", current_step="failed",
error_message=str(error), error_message=str(error),
finished_at=utc_now(), finished_at=finished_at,
) )
self.repository.add_log(task_id, "error", str(error)) self.repository.add_log(task_id, "error", str(error))
self.repository.add_log(task_id, "debug", traceback.format_exc()) self.repository.add_log(task_id, "debug", traceback.format_exc())

View File

@@ -6,9 +6,17 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_valida
from app.utils.validators import ( from app.utils.validators import (
validate_app_id, validate_app_id,
validate_container_name,
validate_docker_digest,
validate_docker_image,
validate_docker_tag,
validate_env_name,
validate_package_name, validate_package_name,
validate_port_mapping,
validate_restart_policy,
validate_service_name, validate_service_name,
validate_sha256, validate_sha256,
validate_volume_mapping,
validate_version, validate_version,
) )
@@ -168,6 +176,69 @@ class DebComponent(CamelModel):
return validate_service_name(value) return validate_service_name(value)
class DockerComponent(CamelModel):
component_id: str = Field(alias="componentId")
type: Literal["docker"] = "docker"
install_order: int = Field(default=10, alias="installOrder")
required: bool = True
image: str
tag: str | None = None
digest: str | None = None
container_name: str | None = Field(default=None, alias="containerName")
restart_policy: str = Field(default="unless-stopped", alias="restartPolicy")
ports: list[str] = Field(default_factory=list)
volumes: list[str] = Field(default_factory=list)
env: dict[str, str] = Field(default_factory=dict)
@field_validator("component_id")
@classmethod
def _component_id(cls, value: str) -> str:
return validate_app_id(value)
@field_validator("image")
@classmethod
def _image(cls, value: str) -> str:
return validate_docker_image(value)
@field_validator("tag")
@classmethod
def _tag(cls, value: str | None) -> str | None:
return validate_docker_tag(value)
@field_validator("digest")
@classmethod
def _digest(cls, value: str | None) -> str | None:
return validate_docker_digest(value)
@field_validator("restart_policy")
@classmethod
def _restart_policy(cls, value: str | None) -> str:
return validate_restart_policy(value)
@field_validator("ports")
@classmethod
def _ports(cls, values: list[str]) -> list[str]:
return [validate_port_mapping(value) for value in values]
@field_validator("volumes")
@classmethod
def _volumes(cls, values: list[str]) -> list[str]:
return [validate_volume_mapping(value) for value in values]
@field_validator("env")
@classmethod
def _env(cls, values: dict[str, Any]) -> dict[str, str]:
normalized: dict[str, str] = {}
for key, value in values.items():
normalized[validate_env_name(str(key))] = str(value)
return normalized
@model_validator(mode="after")
def _container_name(self) -> "DockerComponent":
self.container_name = validate_container_name(self.container_name or self.component_id)
return self
class RawComponent(CamelModel): class RawComponent(CamelModel):
component_id: str = Field(alias="componentId") component_id: str = Field(alias="componentId")
type: ComponentType type: ComponentType

View File

@@ -9,6 +9,13 @@ PACKAGE_NAME_RE = re.compile(r"^[a-zA-Z0-9._+-]+$")
SERVICE_NAME_RE = re.compile(r"^[a-zA-Z0-9._@+-]+\.service$") SERVICE_NAME_RE = re.compile(r"^[a-zA-Z0-9._@+-]+\.service$")
VERSION_RE = re.compile(r"^[a-zA-Z0-9._:+~=-]+$") VERSION_RE = re.compile(r"^[a-zA-Z0-9._:+~=-]+$")
SHA256_RE = re.compile(r"^[0-9a-fA-F]{64}$") SHA256_RE = re.compile(r"^[0-9a-fA-F]{64}$")
DOCKER_DIGEST_RE = re.compile(r"^sha256:[0-9a-fA-F]{64}$")
DOCKER_REF_RE = re.compile(r"^[a-z0-9][a-z0-9._:/@+-]{0,254}$")
DOCKER_TAG_RE = re.compile(r"^[a-zA-Z0-9_][a-zA-Z0-9_.-]{0,127}$")
CONTAINER_NAME_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$")
ENV_NAME_RE = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$")
PORT_MAPPING_RE = re.compile(r"^[a-zA-Z0-9_.:-]+(?:/(?:tcp|udp|sctp))?$")
VOLUME_MAPPING_RE = re.compile(r"^[^\s:]+:[^\s:]+(?::(?:ro|rw))?$")
def validate_app_id(value: str) -> str: def validate_app_id(value: str) -> str:
@@ -53,3 +60,78 @@ def validate_url_host(url: str, allowed_hosts: list[str]) -> str:
raise ValueError(f"download host is not allowed: {parsed.hostname}") raise ValueError(f"download host is not allowed: {parsed.hostname}")
return url return url
def validate_docker_image(value: str) -> str:
image = value.strip()
if not image or not DOCKER_REF_RE.fullmatch(image):
raise ValueError("docker image contains invalid characters")
if "//" in image or image.endswith(("/", ":", "@")):
raise ValueError("docker image reference is malformed")
return image
def validate_docker_tag(value: str | None) -> str | None:
if value is None or value == "":
return None
tag = value.strip()
if not DOCKER_TAG_RE.fullmatch(tag):
raise ValueError("docker tag contains invalid characters")
return tag
def validate_docker_digest(value: str | None) -> str | None:
if value is None or value == "":
return None
digest = value.strip()
if not DOCKER_DIGEST_RE.fullmatch(digest):
raise ValueError("docker digest must be sha256:<64 hex characters>")
return digest.lower()
def validate_docker_registry(image: str, allowed_registries: list[str]) -> str:
reference = image.split("@", 1)[0]
parts = reference.split("/", 1)
first_part = parts[0]
has_explicit_registry = len(parts) > 1 and (
"." in first_part or ":" in first_part or first_part == "localhost"
)
registry = first_part if has_explicit_registry else "docker.io"
allowed = {item.strip() for item in allowed_registries if item.strip()}
if "*" not in allowed and registry not in allowed:
raise ValueError(f"docker registry is not allowed: {registry}")
return image
def validate_container_name(value: str) -> str:
name = value.strip()
if not name or not CONTAINER_NAME_RE.fullmatch(name):
raise ValueError("containerName contains invalid characters")
return name
def validate_restart_policy(value: str | None) -> str:
policy = (value or "unless-stopped").strip()
if policy not in {"no", "always", "unless-stopped", "on-failure"}:
raise ValueError("restartPolicy must be one of: no, always, unless-stopped, on-failure")
return policy
def validate_port_mapping(value: str) -> str:
mapping = value.strip()
if not mapping or not PORT_MAPPING_RE.fullmatch(mapping):
raise ValueError("docker port mapping contains invalid characters")
return mapping
def validate_volume_mapping(value: str) -> str:
mapping = value.strip()
if not mapping or not VOLUME_MAPPING_RE.fullmatch(mapping):
raise ValueError("docker volume mapping must be hostPath:containerPath[:ro|rw]")
return mapping
def validate_env_name(value: str) -> str:
name = value.strip()
if not ENV_NAME_RE.fullmatch(name):
raise ValueError("docker env names may contain only letters, numbers, and underscore")
return name

View File

@@ -4,6 +4,41 @@ set -e
mkdir -p /var/lib/local-installer-agent mkdir -p /var/lib/local-installer-agent
mkdir -p /var/log/local-installer-agent mkdir -p /var/log/local-installer-agent
mkdir -p /var/cache/local-installer-agent/packages mkdir -p /var/cache/local-installer-agent/packages
mkdir -p /etc/local-installer-agent
AGENT_ENV="/etc/local-installer-agent/agent.env"
touch "$AGENT_ENV"
set_agent_env() {
KEY="$1"
VALUE="$2"
if grep -q "^$KEY=" "$AGENT_ENV"; then
sed -i "s|^$KEY=.*|$KEY=$VALUE|" "$AGENT_ENV"
else
echo "$KEY=$VALUE" >> "$AGENT_ENV"
fi
}
append_csv_env() {
KEY="$1"
VALUE="$2"
CURRENT="$(grep "^$KEY=" "$AGENT_ENV" | tail -n 1 | cut -d= -f2- || true)"
if [ -z "$CURRENT" ]; then
set_agent_env "$KEY" "$VALUE"
return
fi
case ",$CURRENT," in
*",$VALUE,"*) ;;
*) set_agent_env "$KEY" "$CURRENT,$VALUE" ;;
esac
}
set_agent_env ALLOW_DOCKER true
set_agent_env AUTO_INSTALL_DOCKER true
append_csv_env ALLOWED_DOCKER_REGISTRIES docker.io
cd /opt/local-installer-agent cd /opt/local-installer-agent

View File

@@ -58,15 +58,16 @@ AGENT_PORT=5010
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://localhost:5173 ALLOWED_ORIGINS=https://app.pnkr.cloud,https://package.pnkr.cloud,http://localhost:3000,http://localhost:5173
ALLOWED_DOWNLOAD_HOSTS=package.pnkr.cloud ALLOWED_DOWNLOAD_HOSTS=package.pnkr.cloud
ALLOWED_DOCKER_REGISTRIES=registry.robot.package 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
LOG_DIR=/var/log/local-installer-agent LOG_DIR=/var/log/local-installer-agent
DB_PATH=/var/lib/local-installer-agent/agent.db DB_PATH=/var/lib/local-installer-agent/agent.db
ALLOW_REMOVE=true ALLOW_REMOVE=true
ALLOW_PURGE=false ALLOW_PURGE=false
ALLOW_DOCKER=false ALLOW_DOCKER=true
ALLOW_DOCKER_COMPOSE=false ALLOW_DOCKER_COMPOSE=false
AUTO_INSTALL_DOCKER=true
EOF EOF
dpkg-deb -Z"${DEB_COMPRESSION}" --root-owner-group --build "${BUILD_DIR}" dpkg-deb -Z"${DEB_COMPRESSION}" --root-owner-group --build "${BUILD_DIR}"

View File

@@ -692,7 +692,7 @@ function App() {
const updateBusy = busyAction === `update:${app.appId}` || (rowTaskBusy && rowTask.action === 'update'); const updateBusy = busyAction === `update:${app.appId}` || (rowTaskBusy && rowTask.action === 'update');
const removeBusy = busyAction === `remove:${app.appId}` || (rowTaskBusy && rowTask.action === 'remove'); const removeBusy = busyAction === `remove:${app.appId}` || (rowTaskBusy && rowTask.action === 'remove');
const openUrl = app.installed const openUrl = app.installed
? getAppOpenUrl({ ...app, openUrl: app.installed.openUrl || app.openUrl }) ? getAppOpenUrl({ ...app, openUrl: app.openUrl || app.installed.openUrl })
: ''; : '';
return ( return (

View File

@@ -1415,6 +1415,7 @@ PACKAGE_BASE_URL="${baseUrl}"
AGENT_URL="${agentUrl}?arch=$ARCH" AGENT_URL="${agentUrl}?arch=$ARCH"
AGENT_ENV="/etc/local-installer-agent/agent.env" AGENT_ENV="/etc/local-installer-agent/agent.env"
PACKAGE_HOST="$(printf '%s' "$PACKAGE_BASE_URL" | sed -E 's#^[a-zA-Z][a-zA-Z0-9+.-]*://([^/:]+).*$#\\1#')" PACKAGE_HOST="$(printf '%s' "$PACKAGE_BASE_URL" | sed -E 's#^[a-zA-Z][a-zA-Z0-9+.-]*://([^/:]+).*$#\\1#')"
PACKAGE_REGISTRY="$(printf '%s' "$PACKAGE_BASE_URL" | sed -E 's#^[a-zA-Z][a-zA-Z0-9+.-]*://([^/]+).*$#\\1#')"
TMP_DEB="/tmp/local-installer-agent.deb" TMP_DEB="/tmp/local-installer-agent.deb"
set_agent_env() { set_agent_env() {
@@ -1440,6 +1441,9 @@ touch "$AGENT_ENV"
set_agent_env ROBOT_PACKAGE_BASE_URL "$PACKAGE_BASE_URL" set_agent_env ROBOT_PACKAGE_BASE_URL "$PACKAGE_BASE_URL"
set_agent_env ALLOWED_ORIGINS "${escapeShellDoubleQuoted(agentAllowedOrigins)}" set_agent_env ALLOWED_ORIGINS "${escapeShellDoubleQuoted(agentAllowedOrigins)}"
set_agent_env ALLOWED_DOWNLOAD_HOSTS "$PACKAGE_HOST,localhost,127.0.0.1" set_agent_env ALLOWED_DOWNLOAD_HOSTS "$PACKAGE_HOST,localhost,127.0.0.1"
set_agent_env ALLOWED_DOCKER_REGISTRIES "$PACKAGE_REGISTRY,$PACKAGE_HOST,localhost,127.0.0.1,registry.robot.package,docker.io"
set_agent_env ALLOW_DOCKER true
set_agent_env AUTO_INSTALL_DOCKER true
echo "Starting Local Installer Agent..." echo "Starting Local Installer Agent..."
systemctl enable local-installer-agent systemctl enable local-installer-agent

View File

@@ -372,6 +372,21 @@ function toAbsoluteUrl(baseUrl, filePath) {
return `${normalizedBaseUrl}${normalizedPath}`; return `${normalizedBaseUrl}${normalizedPath}`;
} }
function inferDockerPortsFromOpenUrl(openUrl) {
if (!openUrl) return [];
try {
const parsed = new URL(openUrl);
if (!isLoopbackHost(parsed.hostname) || !parsed.port) {
return [];
}
return [`${parsed.port}:${parsed.port}`];
} catch {
return [];
}
}
async function getUserById(id) { async function getUserById(id) {
const pool = await getPool(); const pool = await getPool();
const result = await pool.request() const result = await pool.request()
@@ -966,6 +981,9 @@ async function getApplicationManifest(appCode, version, baseUrl) {
ORDER BY ap.AddedAt ASC, p.PackageCode ASC; ORDER BY ap.AddedAt ASC, p.PackageCode ASC;
`); `);
const dockerRows = componentResult.recordset.filter((row) => row.PackageType === 'docker');
const inferredDockerPorts = dockerRows.length === 1 ? inferDockerPortsFromOpenUrl(appRow.OpenUrl) : [];
const components = componentResult.recordset.map((row) => { const components = componentResult.recordset.map((row) => {
const installOrder = Number(row.InstallOrder || 10); const installOrder = Number(row.InstallOrder || 10);
@@ -977,7 +995,8 @@ async function getApplicationManifest(appCode, version, baseUrl) {
required: true, required: true,
image: row.DockerImage || '', image: row.DockerImage || '',
tag: row.Version || 'latest', tag: row.Version || 'latest',
containerName: row.PackageCode containerName: row.PackageCode,
ports: inferredDockerPorts
}; };
} }