done fix
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -9,4 +9,4 @@ agent/build/
|
|||||||
web-client/dist/
|
web-client/dist/
|
||||||
|
|
||||||
web-server/uploads/
|
web-server/uploads/
|
||||||
docs
|
docs/*
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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")),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
163
agent/app/core/docker_installer.py
Normal file
163
agent/app/core/docker_installer.py
Normal 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}")
|
||||||
@@ -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":
|
||||||
raise ValueError("Docker components are not enabled on this Agent")
|
if not settings.allow_docker:
|
||||||
elif component_type == "docker_compose" and not settings.allow_docker_compose:
|
raise ValueError("Docker components are not enabled on this Agent")
|
||||||
raise ValueError("Docker Compose 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:
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
elif component["type"] == "docker":
|
||||||
self._install_deb_component(task_id, manifest["appId"], component)
|
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())
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user