From 991d6f52571e991fd30be7056a7e3b6b473d9e52 Mon Sep 17 00:00:00 2001 From: DungTT Date: Thu, 28 May 2026 14:26:02 +0700 Subject: [PATCH] done fix --- .gitignore | 2 +- agent/README.md | 4 +- agent/app/config.py | 18 ++- agent/app/core/docker_installer.py | 163 +++++++++++++++++++++++++++ agent/app/core/manifest_validator.py | 19 ++-- agent/app/core/task_runner.py | 60 +++++++++- agent/app/models/schemas.py | 71 ++++++++++++ agent/app/utils/validators.py | 82 ++++++++++++++ agent/packaging/DEBIAN/postinst | 35 ++++++ agent/scripts/build-deb.sh | 5 +- web-client/src/main.jsx | 2 +- web-server/server.js | 4 + web-server/src/repository.js | 21 +++- 13 files changed, 464 insertions(+), 22 deletions(-) create mode 100644 agent/app/core/docker_installer.py diff --git a/.gitignore b/.gitignore index bc2d472..ad3bd67 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,4 @@ agent/build/ web-client/dist/ web-server/uploads/ -docs \ No newline at end of file +docs/* diff --git a/agent/README.md b/agent/README.md index a04d0d4..65ceca4 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`. +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 @@ -74,3 +74,5 @@ For manifest mode, the Agent fetches: ```text {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`. diff --git a/agent/app/config.py b/agent/app/config.py index 9507058..f2ee9a6 100644 --- a/agent/app/config.py +++ b/agent/app/config.py @@ -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()] +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]: parsed = urlparse(base_url) if parsed.hostname: @@ -37,6 +47,7 @@ class Settings: allow_purge: bool allow_docker: bool allow_docker_compose: bool + auto_install_docker: bool command_timeout_seconds: int @@ -63,9 +74,9 @@ def get_settings() -> Settings: os.getenv("ALLOWED_DOWNLOAD_HOSTS"), _default_allowed_download_hosts(robot_package_base_url), ), - allowed_docker_registries=_csv( + allowed_docker_registries=_csv_with_defaults( 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")), 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")), allow_remove=_bool("ALLOW_REMOVE", True), 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), + auto_install_docker=_bool("AUTO_INSTALL_DOCKER", True), command_timeout_seconds=int(os.getenv("COMMAND_TIMEOUT_SECONDS", "900")), ) diff --git a/agent/app/core/docker_installer.py b/agent/app/core/docker_installer.py new file mode 100644 index 0000000..4251f65 --- /dev/null +++ b/agent/app/core/docker_installer.py @@ -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}") diff --git a/agent/app/core/manifest_validator.py b/agent/app/core/manifest_validator.py index 310af2c..04aeab0 100644 --- a/agent/app/core/manifest_validator.py +++ b/agent/app/core/manifest_validator.py @@ -1,8 +1,8 @@ from __future__ import annotations from app.config import settings -from app.models.schemas import AppManifest, DebComponent -from app.utils.validators import validate_url_host +from app.models.schemas import AppManifest, DebComponent, DockerComponent +from app.utils.validators import validate_docker_registry, validate_url_host class ManifestValidator: @@ -15,12 +15,17 @@ 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 == "docker" and not settings.allow_docker: - raise ValueError("Docker components are not enabled on this Agent") - elif component_type == "docker_compose" and not settings.allow_docker_compose: - raise ValueError("Docker Compose components are not enabled on this Agent") + elif component_type == "docker": + if not settings.allow_docker: + raise ValueError("Docker components are not enabled on this Agent") + 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 implemented on this Agent") else: raise ValueError(f"Unsupported component type: {component_type}") manifest["components"] = sorted(components, key=lambda item: item.get("installOrder", 10)) return manifest - diff --git a/agent/app/core/task_runner.py b/agent/app/core/task_runner.py index 50c7cba..0218e98 100644 --- a/agent/app/core/task_runner.py +++ b/agent/app/core/task_runner.py @@ -9,6 +9,7 @@ from app.config import settings 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.manifest_client import ManifestClient from app.core.manifest_validator import ManifestValidator @@ -99,6 +100,12 @@ class TaskRunner: if component["type"] == "deb" and package_name: self.repository.add_log(task_id, "info", f"Removing package {package_name}") 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: raise ValueError(f"Unsupported installed component type: {component['type']}") @@ -171,14 +178,16 @@ class TaskRunner: component_id, status="running", progress=5, - current_step="downloading", + current_step="preparing", started_at=utc_now(), ) - if component["type"] != "deb": - raise ValueError(f"Unsupported component type in MVP: {component['type']}") - - self._install_deb_component(task_id, manifest["appId"], component) + if component["type"] == "deb": + 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( task_id, @@ -196,6 +205,7 @@ class TaskRunner: installer = DebInstaller(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"]) self.repository.update_task_component(task_id, component_id, progress=35, current_step="verifying checksum") actual_sha256 = sha256_file(package_path) @@ -250,6 +260,32 @@ class TaskRunner: 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: self.repository.update_task( task_id, @@ -261,12 +297,24 @@ class TaskRunner: self.repository.add_log(task_id, "info", step) 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( task_id, status="failed", current_step="failed", 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, "debug", traceback.format_exc()) diff --git a/agent/app/models/schemas.py b/agent/app/models/schemas.py index 3c6150f..190f077 100644 --- a/agent/app/models/schemas.py +++ b/agent/app/models/schemas.py @@ -6,9 +6,17 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_valida from app.utils.validators import ( validate_app_id, + validate_container_name, + validate_docker_digest, + validate_docker_image, + validate_docker_tag, + validate_env_name, validate_package_name, + validate_port_mapping, + validate_restart_policy, validate_service_name, validate_sha256, + validate_volume_mapping, validate_version, ) @@ -168,6 +176,69 @@ class DebComponent(CamelModel): 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): component_id: str = Field(alias="componentId") type: ComponentType diff --git a/agent/app/utils/validators.py b/agent/app/utils/validators.py index ab5e847..58194aa 100644 --- a/agent/app/utils/validators.py +++ b/agent/app/utils/validators.py @@ -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$") VERSION_RE = re.compile(r"^[a-zA-Z0-9._:+~=-]+$") 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: @@ -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}") 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 diff --git a/agent/packaging/DEBIAN/postinst b/agent/packaging/DEBIAN/postinst index b4482ea..5f0d956 100644 --- a/agent/packaging/DEBIAN/postinst +++ b/agent/packaging/DEBIAN/postinst @@ -4,6 +4,41 @@ set -e mkdir -p /var/lib/local-installer-agent mkdir -p /var/log/local-installer-agent 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 diff --git a/agent/scripts/build-deb.sh b/agent/scripts/build-deb.sh index 6b3405d..6daa0c8 100644 --- a/agent/scripts/build-deb.sh +++ b/agent/scripts/build-deb.sh @@ -58,15 +58,16 @@ AGENT_PORT=5010 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_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 APP_DIR=/opt/robot-apps LOG_DIR=/var/log/local-installer-agent DB_PATH=/var/lib/local-installer-agent/agent.db ALLOW_REMOVE=true ALLOW_PURGE=false -ALLOW_DOCKER=false +ALLOW_DOCKER=true ALLOW_DOCKER_COMPOSE=false +AUTO_INSTALL_DOCKER=true EOF dpkg-deb -Z"${DEB_COMPRESSION}" --root-owner-group --build "${BUILD_DIR}" diff --git a/web-client/src/main.jsx b/web-client/src/main.jsx index b82ba4a..37f161d 100644 --- a/web-client/src/main.jsx +++ b/web-client/src/main.jsx @@ -692,7 +692,7 @@ function App() { const updateBusy = busyAction === `update:${app.appId}` || (rowTaskBusy && rowTask.action === 'update'); const removeBusy = busyAction === `remove:${app.appId}` || (rowTaskBusy && rowTask.action === 'remove'); const openUrl = app.installed - ? getAppOpenUrl({ ...app, openUrl: app.installed.openUrl || app.openUrl }) + ? getAppOpenUrl({ ...app, openUrl: app.openUrl || app.installed.openUrl }) : ''; return ( diff --git a/web-server/server.js b/web-server/server.js index 464e16b..0fae90c 100644 --- a/web-server/server.js +++ b/web-server/server.js @@ -1415,6 +1415,7 @@ PACKAGE_BASE_URL="${baseUrl}" AGENT_URL="${agentUrl}?arch=$ARCH" 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_REGISTRY="$(printf '%s' "$PACKAGE_BASE_URL" | sed -E 's#^[a-zA-Z][a-zA-Z0-9+.-]*://([^/]+).*$#\\1#')" TMP_DEB="/tmp/local-installer-agent.deb" set_agent_env() { @@ -1440,6 +1441,9 @@ touch "$AGENT_ENV" set_agent_env ROBOT_PACKAGE_BASE_URL "$PACKAGE_BASE_URL" set_agent_env ALLOWED_ORIGINS "${escapeShellDoubleQuoted(agentAllowedOrigins)}" 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..." systemctl enable local-installer-agent diff --git a/web-server/src/repository.js b/web-server/src/repository.js index f07ea74..dc4ee6c 100644 --- a/web-server/src/repository.js +++ b/web-server/src/repository.js @@ -372,6 +372,21 @@ function toAbsoluteUrl(baseUrl, filePath) { 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) { const pool = await getPool(); const result = await pool.request() @@ -966,6 +981,9 @@ async function getApplicationManifest(appCode, version, baseUrl) { 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 installOrder = Number(row.InstallOrder || 10); @@ -977,7 +995,8 @@ async function getApplicationManifest(appCode, version, baseUrl) { required: true, image: row.DockerImage || '', tag: row.Version || 'latest', - containerName: row.PackageCode + containerName: row.PackageCode, + ports: inferredDockerPorts }; }