agent
This commit is contained in:
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
agent/.venv/
|
||||
agent/build/
|
||||
|
||||
web-server/uploads/
|
||||
7
.vscode/settings.json
vendored
Normal file
7
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"python.defaultInterpreterPath": "${workspaceFolder}/agent/.venv/Scripts/python.exe",
|
||||
"python.analysis.extraPaths": [
|
||||
"${workspaceFolder}/agent"
|
||||
],
|
||||
"python.terminal.activateEnvironment": true
|
||||
}
|
||||
76
agent/README.md
Normal file
76
agent/README.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# Local Installer Agent
|
||||
|
||||
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`.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
cd agent
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
uvicorn app.main:app --host 127.0.0.1 --port 5010
|
||||
```
|
||||
|
||||
## Install script URL
|
||||
|
||||
Client machines should keep using the stable installer command:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://robot.package/install-agent.sh | sudo bash
|
||||
```
|
||||
|
||||
The web server resolves `https://robot.package/packages/agent/latest.deb?arch=<dpkg-arch>`
|
||||
to the newest uploaded `local-installer-agent_<version>_<arch>.deb`, so updating the Agent does not require editing the install command.
|
||||
|
||||
Agent packages are uploaded from the web server Admin page at `/agent`. The default storage folder is
|
||||
`web-server/uploads/packages/agent`, and it can be changed with `AGENT_PACKAGE_DIR`.
|
||||
|
||||
## Important API
|
||||
|
||||
```text
|
||||
GET /health
|
||||
GET /system-info
|
||||
GET /apps/installed
|
||||
POST /apps/install
|
||||
POST /apps/update
|
||||
POST /apps/remove
|
||||
GET /tasks/{taskId}
|
||||
GET /tasks/{taskId}/logs
|
||||
GET /tasks/{taskId}/components
|
||||
POST /services/start
|
||||
POST /services/stop
|
||||
POST /services/restart
|
||||
GET /services/{serviceName}/status
|
||||
```
|
||||
|
||||
`POST /apps/install` supports both:
|
||||
|
||||
```json
|
||||
{
|
||||
"appId": "robot-suite",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
```
|
||||
|
||||
and a direct single `.deb` payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"appId": "robot-web-app",
|
||||
"appName": "Robot Web App",
|
||||
"packageName": "robot-web-app",
|
||||
"serviceName": "robot-web-app.service",
|
||||
"version": "1.0.0",
|
||||
"downloadUrl": "https://robot.package/packages/robot-web-app_1.0.0_amd64.deb",
|
||||
"checksum": "sha256_hash_here"
|
||||
}
|
||||
```
|
||||
|
||||
For manifest mode, the Agent fetches:
|
||||
|
||||
```text
|
||||
{ROBOT_PACKAGE_BASE_URL}/api/apps/{appId}/versions/{version}/manifest
|
||||
```
|
||||
2
agent/app/__init__.py
Normal file
2
agent/app/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Local Installer Agent package."""
|
||||
|
||||
2
agent/app/api/__init__.py
Normal file
2
agent/app/api/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""API routers for the Local Installer Agent."""
|
||||
|
||||
53
agent/app/api/apps.py
Normal file
53
agent/app/api/apps.py
Normal file
@@ -0,0 +1,53 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, HTTPException
|
||||
|
||||
from app.core.task_runner import TaskRunner
|
||||
from app.models.schemas import InstallRequest, RemoveRequest, UpdateRequest
|
||||
from app.storage.repository import Repository
|
||||
|
||||
|
||||
router = APIRouter(prefix="/apps", tags=["apps"])
|
||||
repository = Repository()
|
||||
task_runner = TaskRunner(repository)
|
||||
|
||||
|
||||
def _task_id(prefix: str) -> str:
|
||||
return f"task_{prefix}_{uuid.uuid4().hex[:12]}"
|
||||
|
||||
|
||||
@router.get("/installed")
|
||||
def installed_apps() -> list[dict]:
|
||||
return repository.list_installed_apps()
|
||||
|
||||
|
||||
@router.post("/install")
|
||||
def install_app(request: InstallRequest, background_tasks: BackgroundTasks) -> dict[str, str]:
|
||||
if not request.version:
|
||||
raise HTTPException(status_code=400, detail="version is required")
|
||||
task_id = _task_id("install")
|
||||
repository.create_task(task_id, "install", request.app_id, request.app_name)
|
||||
background_tasks.add_task(task_runner.run_install, task_id, request, "install")
|
||||
return {"taskId": task_id, "status": "queued"}
|
||||
|
||||
|
||||
@router.post("/update")
|
||||
def update_app(request: UpdateRequest, background_tasks: BackgroundTasks) -> dict[str, str]:
|
||||
if not request.version:
|
||||
raise HTTPException(status_code=400, detail="version or targetVersion is required")
|
||||
task_id = _task_id("update")
|
||||
repository.create_task(task_id, "update", request.app_id, request.app_name)
|
||||
background_tasks.add_task(task_runner.run_install, task_id, request, "update")
|
||||
return {"taskId": task_id, "status": "queued"}
|
||||
|
||||
|
||||
@router.post("/remove")
|
||||
def remove_app(request: RemoveRequest, background_tasks: BackgroundTasks) -> dict[str, str]:
|
||||
if not request.package_name and not repository.list_installed_components(request.app_id):
|
||||
raise HTTPException(status_code=400, detail="packageName is required when app is not tracked locally")
|
||||
task_id = _task_id("remove")
|
||||
repository.create_task(task_id, "remove", request.app_id, request.app_id)
|
||||
background_tasks.add_task(task_runner.run_remove, task_id, request)
|
||||
return {"taskId": task_id, "status": "queued"}
|
||||
38
agent/app/api/health.py
Normal file
38
agent/app/api/health.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import platform
|
||||
import shutil
|
||||
import socket
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
def health() -> dict[str, str]:
|
||||
return {
|
||||
"status": "online",
|
||||
"agentVersion": settings.agent_version,
|
||||
"hostname": socket.gethostname(),
|
||||
"os": platform.platform(),
|
||||
"architecture": platform.machine(),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/system-info")
|
||||
def system_info() -> dict[str, str]:
|
||||
disk = shutil.disk_usage("/")
|
||||
memory_total = "unknown"
|
||||
return {
|
||||
"hostname": socket.gethostname(),
|
||||
"os": platform.platform(),
|
||||
"kernel": platform.release(),
|
||||
"architecture": platform.machine(),
|
||||
"diskFree": f"{disk.free // (1024 ** 3)}GB",
|
||||
"memoryTotal": memory_total,
|
||||
}
|
||||
|
||||
40
agent/app/api/services.py
Normal file
40
agent/app/api/services.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.core.command_runner import CommandRunner
|
||||
from app.core.service_manager import ServiceManager
|
||||
from app.models.schemas import ServiceRequest
|
||||
from app.storage.repository import Repository
|
||||
|
||||
|
||||
router = APIRouter(prefix="/services", tags=["services"])
|
||||
|
||||
|
||||
def _manager() -> ServiceManager:
|
||||
return ServiceManager(CommandRunner(Repository()))
|
||||
|
||||
|
||||
@router.post("/start")
|
||||
def start_service(request: ServiceRequest) -> dict[str, str]:
|
||||
_manager().start_service(request.service_name)
|
||||
return {"serviceName": request.service_name, "status": "started"}
|
||||
|
||||
|
||||
@router.post("/stop")
|
||||
def stop_service(request: ServiceRequest) -> dict[str, str]:
|
||||
_manager().stop_service(request.service_name)
|
||||
return {"serviceName": request.service_name, "status": "stopped"}
|
||||
|
||||
|
||||
@router.post("/restart")
|
||||
def restart_service(request: ServiceRequest) -> dict[str, str]:
|
||||
_manager().restart_service(request.service_name)
|
||||
return {"serviceName": request.service_name, "status": "restarted"}
|
||||
|
||||
|
||||
@router.get("/{service_name}/status")
|
||||
def service_status(service_name: str) -> dict[str, object]:
|
||||
request = ServiceRequest(serviceName=service_name)
|
||||
return _manager().get_service_status(request.service_name)
|
||||
|
||||
74
agent/app/api/tasks.py
Normal file
74
agent/app/api/tasks.py
Normal file
@@ -0,0 +1,74 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from app.storage.repository import Repository
|
||||
|
||||
|
||||
router = APIRouter(prefix="/tasks", tags=["tasks"])
|
||||
repository = Repository()
|
||||
|
||||
|
||||
def _task_response(row: dict) -> dict:
|
||||
return {
|
||||
"taskId": row["id"],
|
||||
"type": row["type"],
|
||||
"appId": row["app_id"],
|
||||
"appName": row["app_name"],
|
||||
"status": row["status"],
|
||||
"progress": row["progress"],
|
||||
"currentStep": row["current_step"],
|
||||
"currentComponentId": row["current_component_id"],
|
||||
"errorMessage": row["error_message"],
|
||||
"createdAt": row["created_at"],
|
||||
"startedAt": row["started_at"],
|
||||
"finishedAt": row["finished_at"],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{task_id}")
|
||||
def get_task(task_id: str) -> dict:
|
||||
task = repository.get_task(task_id)
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
return _task_response(task)
|
||||
|
||||
|
||||
@router.get("/{task_id}/logs")
|
||||
def get_task_logs(task_id: str) -> dict:
|
||||
if not repository.get_task(task_id):
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
return {
|
||||
"taskId": task_id,
|
||||
"logs": [
|
||||
{
|
||||
"time": item["timestamp"],
|
||||
"level": item["level"],
|
||||
"message": item["message"],
|
||||
}
|
||||
for item in repository.get_task_logs(task_id)
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{task_id}/components")
|
||||
def get_task_components(task_id: str) -> dict:
|
||||
if not repository.get_task(task_id):
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
return {
|
||||
"taskId": task_id,
|
||||
"components": [
|
||||
{
|
||||
"componentId": item["component_id"],
|
||||
"type": item["type"],
|
||||
"status": item["status"],
|
||||
"progress": item["progress"],
|
||||
"currentStep": item["current_step"],
|
||||
"errorMessage": item["error_message"],
|
||||
"startedAt": item["started_at"],
|
||||
"finishedAt": item["finished_at"],
|
||||
}
|
||||
for item in repository.get_task_components(task_id)
|
||||
],
|
||||
}
|
||||
|
||||
83
agent/app/config.py
Normal file
83
agent/app/config.py
Normal file
@@ -0,0 +1,83 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
def _csv(value: str | None, fallback: list[str]) -> list[str]:
|
||||
if not value:
|
||||
return fallback
|
||||
return [item.strip() for item in value.split(",") if item.strip()]
|
||||
|
||||
|
||||
def _default_allowed_download_hosts(base_url: str) -> list[str]:
|
||||
parsed = urlparse(base_url)
|
||||
if parsed.hostname:
|
||||
return [parsed.hostname]
|
||||
return ["robot.package"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Settings:
|
||||
agent_version: str
|
||||
host: str
|
||||
port: int
|
||||
robot_package_base_url: str
|
||||
allowed_origins: list[str]
|
||||
allowed_download_hosts: list[str]
|
||||
allowed_docker_registries: list[str]
|
||||
cache_dir: Path
|
||||
app_dir: Path
|
||||
log_dir: Path
|
||||
db_path: Path
|
||||
allow_remove: bool
|
||||
allow_purge: bool
|
||||
allow_docker: bool
|
||||
allow_docker_compose: bool
|
||||
command_timeout_seconds: int
|
||||
|
||||
|
||||
def _bool(name: str, default: bool) -> bool:
|
||||
value = os.getenv(name)
|
||||
if value is None:
|
||||
return default
|
||||
return value.strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_settings() -> Settings:
|
||||
robot_package_base_url = os.getenv("ROBOT_PACKAGE_BASE_URL", "https://robot.package").rstrip("/")
|
||||
return Settings(
|
||||
agent_version=os.getenv("AGENT_VERSION", "0.1.0"),
|
||||
host=os.getenv("AGENT_HOST", "127.0.0.1"),
|
||||
port=int(os.getenv("AGENT_PORT", "5010")),
|
||||
robot_package_base_url=robot_package_base_url,
|
||||
allowed_origins=_csv(
|
||||
os.getenv("ALLOWED_ORIGINS"),
|
||||
["https://robot.installer", "http://localhost:3000", "http://localhost:5173"],
|
||||
),
|
||||
allowed_download_hosts=_csv(
|
||||
os.getenv("ALLOWED_DOWNLOAD_HOSTS"),
|
||||
_default_allowed_download_hosts(robot_package_base_url),
|
||||
),
|
||||
allowed_docker_registries=_csv(
|
||||
os.getenv("ALLOWED_DOCKER_REGISTRIES"),
|
||||
["registry.robot.package"],
|
||||
),
|
||||
cache_dir=Path(os.getenv("CACHE_DIR", "/var/cache/local-installer-agent/packages")),
|
||||
app_dir=Path(os.getenv("APP_DIR", "/opt/robot-apps")),
|
||||
log_dir=Path(os.getenv("LOG_DIR", "/var/log/local-installer-agent")),
|
||||
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_compose=_bool("ALLOW_DOCKER_COMPOSE", False),
|
||||
command_timeout_seconds=int(os.getenv("COMMAND_TIMEOUT_SECONDS", "900")),
|
||||
)
|
||||
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
2
agent/app/core/__init__.py
Normal file
2
agent/app/core/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Core installers and task orchestration."""
|
||||
|
||||
17
agent/app/core/checksum.py
Normal file
17
agent/app/core/checksum.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def sha256_file(file_path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with file_path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def verify_sha256(file_path: Path, expected: str) -> bool:
|
||||
return sha256_file(file_path).lower() == expected.lower()
|
||||
|
||||
50
agent/app/core/command_runner.py
Normal file
50
agent/app/core/command_runner.py
Normal file
@@ -0,0 +1,50 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
|
||||
from app.config import settings
|
||||
from app.storage.repository import Repository
|
||||
|
||||
|
||||
class CommandError(RuntimeError):
|
||||
def __init__(self, command: list[str], returncode: int, stdout: str, stderr: str) -> None:
|
||||
super().__init__(f"Command failed with exit code {returncode}: {' '.join(command)}")
|
||||
self.command = command
|
||||
self.returncode = returncode
|
||||
self.stdout = stdout
|
||||
self.stderr = stderr
|
||||
|
||||
|
||||
class CommandRunner:
|
||||
def __init__(self, repository: Repository, task_id: str | None = None) -> None:
|
||||
self.repository = repository
|
||||
self.task_id = task_id
|
||||
|
||||
def run(self, command: list[str], timeout: int | None = None) -> subprocess.CompletedProcess[str]:
|
||||
if self.task_id:
|
||||
self.repository.add_log(self.task_id, "debug", f"Running command: {' '.join(command)}")
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
command,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout or settings.command_timeout_seconds,
|
||||
)
|
||||
except subprocess.TimeoutExpired as error:
|
||||
if self.task_id:
|
||||
self.repository.add_log(self.task_id, "error", f"Command timed out: {' '.join(command)}")
|
||||
raise CommandError(command, 124, error.stdout or "", error.stderr or "") from error
|
||||
|
||||
if self.task_id:
|
||||
for line in result.stdout.splitlines():
|
||||
self.repository.add_log(self.task_id, "debug", line)
|
||||
for line in result.stderr.splitlines():
|
||||
self.repository.add_log(self.task_id, "warning", line)
|
||||
|
||||
if result.returncode != 0:
|
||||
raise CommandError(command, result.returncode, result.stdout, result.stderr)
|
||||
|
||||
return result
|
||||
|
||||
42
agent/app/core/downloader.py
Normal file
42
agent/app/core/downloader.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from urllib.parse import unquote, urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import settings
|
||||
from app.storage.repository import Repository
|
||||
from app.utils.validators import validate_url_host
|
||||
|
||||
|
||||
SAFE_FILE_RE = re.compile(r"[^a-zA-Z0-9._+-]+")
|
||||
|
||||
|
||||
def _safe_file_name(url: str) -> str:
|
||||
parsed = urlparse(url)
|
||||
name = Path(unquote(parsed.path)).name or "package.deb"
|
||||
return SAFE_FILE_RE.sub("-", name).strip("-") or "package.deb"
|
||||
|
||||
|
||||
class Downloader:
|
||||
def __init__(self, repository: Repository, task_id: str) -> None:
|
||||
self.repository = repository
|
||||
self.task_id = task_id
|
||||
|
||||
def download(self, url: str) -> Path:
|
||||
validate_url_host(url, settings.allowed_download_hosts)
|
||||
settings.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
destination = settings.cache_dir / _safe_file_name(url)
|
||||
|
||||
self.repository.add_log(self.task_id, "info", f"Downloading {url}")
|
||||
with httpx.stream("GET", url, follow_redirects=True, timeout=120) as response:
|
||||
response.raise_for_status()
|
||||
with destination.open("wb") as handle:
|
||||
for chunk in response.iter_bytes():
|
||||
handle.write(chunk)
|
||||
|
||||
self.repository.add_log(self.task_id, "info", f"Downloaded to {destination}")
|
||||
return destination
|
||||
|
||||
30
agent/app/core/installer.py
Normal file
30
agent/app/core/installer.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from app.core.command_runner import CommandRunner
|
||||
|
||||
|
||||
class DebInstaller:
|
||||
def __init__(self, command_runner: CommandRunner) -> None:
|
||||
self.command_runner = command_runner
|
||||
|
||||
def install_deb(self, file_path: Path) -> None:
|
||||
self.command_runner.run(["apt", "install", "-y", str(file_path)])
|
||||
|
||||
def remove_package(self, package_name: str, purge: bool = False) -> None:
|
||||
action = "purge" if purge else "remove"
|
||||
self.command_runner.run(["apt", action, "-y", package_name])
|
||||
|
||||
def get_package_version(self, package_name: str) -> str | None:
|
||||
result = self.command_runner.run(["dpkg-query", "-W", "-f=${Version}", package_name])
|
||||
version = result.stdout.strip()
|
||||
return version or None
|
||||
|
||||
def check_package_installed(self, package_name: str) -> bool:
|
||||
try:
|
||||
self.get_package_version(package_name)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
18
agent/app/core/manifest_client.py
Normal file
18
agent/app/core/manifest_client.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from urllib.parse import quote
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
class ManifestClient:
|
||||
def fetch_manifest(self, app_id: str, version: str) -> dict:
|
||||
app_id_part = quote(app_id, safe="")
|
||||
version_part = quote(version, safe="")
|
||||
url = f"{settings.robot_package_base_url}/api/apps/{app_id_part}/versions/{version_part}/manifest"
|
||||
response = httpx.get(url, follow_redirects=True, timeout=30)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
26
agent/app/core/manifest_validator.py
Normal file
26
agent/app/core/manifest_validator.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.config import settings
|
||||
from app.models.schemas import AppManifest, DebComponent
|
||||
from app.utils.validators import validate_url_host
|
||||
|
||||
|
||||
class ManifestValidator:
|
||||
def validate(self, payload: dict) -> dict:
|
||||
manifest = AppManifest.model_validate(payload).model_dump(by_alias=True)
|
||||
components = []
|
||||
for raw_component in manifest["components"]:
|
||||
component_type = raw_component.get("type")
|
||||
if component_type == "deb":
|
||||
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")
|
||||
else:
|
||||
raise ValueError(f"Unsupported component type: {component_type}")
|
||||
manifest["components"] = sorted(components, key=lambda item: item.get("installOrder", 10))
|
||||
return manifest
|
||||
|
||||
40
agent/app/core/service_manager.py
Normal file
40
agent/app/core/service_manager.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.core.command_runner import CommandRunner
|
||||
|
||||
|
||||
class ServiceManager:
|
||||
def __init__(self, command_runner: CommandRunner) -> None:
|
||||
self.command_runner = command_runner
|
||||
|
||||
def enable_service(self, service_name: str) -> None:
|
||||
self.command_runner.run(["systemctl", "enable", service_name])
|
||||
|
||||
def disable_service(self, service_name: str) -> None:
|
||||
self.command_runner.run(["systemctl", "disable", service_name])
|
||||
|
||||
def start_service(self, service_name: str) -> None:
|
||||
self.command_runner.run(["systemctl", "start", service_name])
|
||||
|
||||
def stop_service(self, service_name: str) -> None:
|
||||
self.command_runner.run(["systemctl", "stop", service_name])
|
||||
|
||||
def restart_service(self, service_name: str) -> None:
|
||||
self.command_runner.run(["systemctl", "restart", service_name])
|
||||
|
||||
def get_service_status(self, service_name: str) -> dict[str, object]:
|
||||
active = self._query(["systemctl", "is-active", service_name]) == "active"
|
||||
enabled = self._query(["systemctl", "is-enabled", service_name]) == "enabled"
|
||||
return {
|
||||
"serviceName": service_name,
|
||||
"active": active,
|
||||
"enabled": enabled,
|
||||
"status": "running" if active else "stopped",
|
||||
}
|
||||
|
||||
def _query(self, command: list[str]) -> str:
|
||||
try:
|
||||
return self.command_runner.run(command).stdout.strip()
|
||||
except Exception:
|
||||
return "unknown"
|
||||
|
||||
248
agent/app/core/task_runner.py
Normal file
248
agent/app/core/task_runner.py
Normal file
@@ -0,0 +1,248 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import traceback
|
||||
from typing import Any
|
||||
|
||||
from app.config import settings
|
||||
from app.core.checksum import verify_sha256
|
||||
from app.core.command_runner import CommandRunner
|
||||
from app.core.downloader import Downloader
|
||||
from app.core.installer import DebInstaller
|
||||
from app.core.manifest_client import ManifestClient
|
||||
from app.core.manifest_validator import ManifestValidator
|
||||
from app.core.service_manager import ServiceManager
|
||||
from app.models.schemas import InstallRequest, RemoveRequest, UpdateRequest
|
||||
from app.storage.repository import Repository, utc_now
|
||||
|
||||
|
||||
class TaskRunner:
|
||||
def __init__(self, repository: Repository) -> None:
|
||||
self.repository = repository
|
||||
self.manifest_client = ManifestClient()
|
||||
self.manifest_validator = ManifestValidator()
|
||||
|
||||
def run_install(self, task_id: str, request: InstallRequest | UpdateRequest, task_type: str = "install") -> None:
|
||||
try:
|
||||
self._mark_started(task_id, f"starting {task_type}")
|
||||
self._require_root_if_available()
|
||||
manifest = self._resolve_manifest(request)
|
||||
self.repository.add_log(task_id, "info", f"Installing {manifest['appId']} {manifest['version']}")
|
||||
self._install_manifest(task_id, manifest)
|
||||
manifest_hash = hashlib.sha256(
|
||||
self.repository.export_manifest_hash(manifest).encode("utf-8")
|
||||
).hexdigest()
|
||||
self.repository.upsert_installed_app(
|
||||
manifest["appId"],
|
||||
manifest["appName"],
|
||||
manifest["version"],
|
||||
manifest_hash,
|
||||
)
|
||||
self.repository.update_task(
|
||||
task_id,
|
||||
status="success",
|
||||
progress=100,
|
||||
current_step="completed",
|
||||
finished_at=utc_now(),
|
||||
)
|
||||
self.repository.add_log(task_id, "info", f"Task {task_id} completed")
|
||||
except Exception as error:
|
||||
self._fail_task(task_id, error)
|
||||
|
||||
def run_remove(self, task_id: str, request: RemoveRequest) -> None:
|
||||
try:
|
||||
if not settings.allow_remove:
|
||||
raise ValueError("Remove is disabled on this Agent")
|
||||
if request.purge and not settings.allow_purge:
|
||||
raise ValueError("Purge is disabled on this Agent")
|
||||
self._mark_started(task_id, "starting remove")
|
||||
self._require_root_if_available()
|
||||
|
||||
components = self.repository.list_installed_components(request.app_id)
|
||||
if not components and request.package_name:
|
||||
components = [
|
||||
{
|
||||
"component_id": request.package_name,
|
||||
"type": "deb",
|
||||
"install_order": 10,
|
||||
"package_name": request.package_name,
|
||||
"service_name": request.service_name,
|
||||
}
|
||||
]
|
||||
if not components:
|
||||
raise ValueError("No installed components found for this app")
|
||||
|
||||
command_runner = CommandRunner(self.repository, task_id)
|
||||
installer = DebInstaller(command_runner)
|
||||
services = ServiceManager(command_runner)
|
||||
|
||||
ordered = sorted(components, key=lambda item: item["install_order"], reverse=True)
|
||||
total = len(ordered)
|
||||
for index, component in enumerate(ordered, start=1):
|
||||
progress = int((index - 1) / total * 80) + 10
|
||||
component_id = component["component_id"]
|
||||
self.repository.update_task(
|
||||
task_id,
|
||||
progress=progress,
|
||||
current_step=f"removing {component_id}",
|
||||
current_component_id=component_id,
|
||||
)
|
||||
service_name = component.get("service_name")
|
||||
if service_name:
|
||||
self.repository.add_log(task_id, "info", f"Stopping service {service_name}")
|
||||
services.stop_service(service_name)
|
||||
services.disable_service(service_name)
|
||||
|
||||
package_name = component.get("package_name")
|
||||
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)
|
||||
else:
|
||||
raise ValueError(f"Unsupported installed component type: {component['type']}")
|
||||
|
||||
self.repository.delete_installed_app(request.app_id)
|
||||
self.repository.update_task(
|
||||
task_id,
|
||||
status="success",
|
||||
progress=100,
|
||||
current_step="completed",
|
||||
finished_at=utc_now(),
|
||||
)
|
||||
except Exception as error:
|
||||
self._fail_task(task_id, error)
|
||||
|
||||
def _resolve_manifest(self, request: InstallRequest | UpdateRequest) -> dict[str, Any]:
|
||||
if request.download_url:
|
||||
digest = request.sha256 or request.checksum
|
||||
return self.manifest_validator.validate(
|
||||
{
|
||||
"schemaVersion": "1.0",
|
||||
"appId": request.app_id,
|
||||
"appName": request.app_name or request.app_id,
|
||||
"version": request.version,
|
||||
"architecture": "amd64",
|
||||
"components": [
|
||||
{
|
||||
"componentId": request.package_name,
|
||||
"type": "deb",
|
||||
"installOrder": 10,
|
||||
"required": True,
|
||||
"packageName": request.package_name,
|
||||
"version": request.version,
|
||||
"downloadUrl": request.download_url,
|
||||
"sha256": digest,
|
||||
"serviceName": request.service_name,
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
payload = self.manifest_client.fetch_manifest(request.app_id, request.version)
|
||||
return self.manifest_validator.validate(payload)
|
||||
|
||||
def _install_manifest(self, task_id: str, manifest: dict[str, Any]) -> None:
|
||||
components = manifest["components"]
|
||||
for component in components:
|
||||
self.repository.create_task_component(
|
||||
task_id,
|
||||
manifest["appId"],
|
||||
component["componentId"],
|
||||
component["type"],
|
||||
component.get("installOrder", 10),
|
||||
)
|
||||
|
||||
total = len(components)
|
||||
if total == 0:
|
||||
raise ValueError("Manifest has no installable components")
|
||||
|
||||
for index, component in enumerate(components, start=1):
|
||||
base_progress = int((index - 1) / total * 80) + 10
|
||||
component_id = component["componentId"]
|
||||
self.repository.update_task(
|
||||
task_id,
|
||||
progress=base_progress,
|
||||
current_step=f"installing {component_id}",
|
||||
current_component_id=component_id,
|
||||
)
|
||||
self.repository.update_task_component(
|
||||
task_id,
|
||||
component_id,
|
||||
status="running",
|
||||
progress=5,
|
||||
current_step="downloading",
|
||||
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)
|
||||
|
||||
self.repository.update_task_component(
|
||||
task_id,
|
||||
component_id,
|
||||
status="success",
|
||||
progress=100,
|
||||
current_step="completed",
|
||||
finished_at=utc_now(),
|
||||
)
|
||||
|
||||
def _install_deb_component(self, task_id: str, app_id: str, component: dict[str, Any]) -> None:
|
||||
component_id = component["componentId"]
|
||||
downloader = Downloader(self.repository, task_id)
|
||||
command_runner = CommandRunner(self.repository, task_id)
|
||||
installer = DebInstaller(command_runner)
|
||||
services = ServiceManager(command_runner)
|
||||
|
||||
package_path = downloader.download(component["downloadUrl"])
|
||||
self.repository.update_task_component(task_id, component_id, progress=35, current_step="verifying checksum")
|
||||
if not verify_sha256(package_path, component["sha256"]):
|
||||
raise ValueError(f"Checksum mismatch for {component_id}")
|
||||
self.repository.add_log(task_id, "info", f"Checksum verified for {component_id}")
|
||||
|
||||
self.repository.update_task_component(task_id, component_id, progress=60, current_step="installing package")
|
||||
installer.install_deb(package_path)
|
||||
|
||||
self.repository.update_task_component(task_id, component_id, progress=75, current_step="verifying package")
|
||||
installed_version = installer.get_package_version(component["packageName"])
|
||||
self.repository.add_log(
|
||||
task_id,
|
||||
"info",
|
||||
f"Package {component['packageName']} installed with version {installed_version}",
|
||||
)
|
||||
|
||||
service_name = component.get("serviceName")
|
||||
if service_name:
|
||||
self.repository.update_task_component(task_id, component_id, progress=90, current_step="starting service")
|
||||
services.enable_service(service_name)
|
||||
services.start_service(service_name)
|
||||
|
||||
self.repository.upsert_installed_component(app_id, component)
|
||||
|
||||
def _mark_started(self, task_id: str, step: str) -> None:
|
||||
self.repository.update_task(
|
||||
task_id,
|
||||
status="running",
|
||||
progress=5,
|
||||
current_step=step,
|
||||
started_at=utc_now(),
|
||||
)
|
||||
self.repository.add_log(task_id, "info", step)
|
||||
|
||||
def _fail_task(self, task_id: str, error: Exception) -> None:
|
||||
self.repository.update_task(
|
||||
task_id,
|
||||
status="failed",
|
||||
current_step="failed",
|
||||
error_message=str(error),
|
||||
finished_at=utc_now(),
|
||||
)
|
||||
self.repository.add_log(task_id, "error", str(error))
|
||||
self.repository.add_log(task_id, "debug", traceback.format_exc())
|
||||
|
||||
def _require_root_if_available(self) -> None:
|
||||
geteuid = getattr(os, "geteuid", None)
|
||||
if callable(geteuid) and geteuid() != 0:
|
||||
raise PermissionError("Agent must run as root to call apt and systemctl")
|
||||
|
||||
37
agent/app/main.py
Normal file
37
agent/app/main.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.api import apps, health, services, tasks
|
||||
from app.config import settings
|
||||
from app.storage.database import initialize_database
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
initialize_database()
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="Local Installer Agent",
|
||||
version=settings.agent_version,
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.allowed_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(health.router)
|
||||
app.include_router(apps.router)
|
||||
app.include_router(tasks.router)
|
||||
app.include_router(services.router)
|
||||
|
||||
2
agent/app/models/__init__.py
Normal file
2
agent/app/models/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Pydantic models for API contracts."""
|
||||
|
||||
196
agent/app/models/schemas.py
Normal file
196
agent/app/models/schemas.py
Normal file
@@ -0,0 +1,196 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
from app.utils.validators import (
|
||||
validate_app_id,
|
||||
validate_package_name,
|
||||
validate_service_name,
|
||||
validate_sha256,
|
||||
validate_version,
|
||||
)
|
||||
|
||||
|
||||
TaskType = Literal["install", "update", "remove"]
|
||||
TaskStatus = Literal["queued", "running", "success", "failed", "cancelled"]
|
||||
ComponentType = Literal["deb", "docker", "docker_compose"]
|
||||
|
||||
|
||||
class CamelModel(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True, extra="forbid")
|
||||
|
||||
|
||||
class HealthResponse(CamelModel):
|
||||
status: str
|
||||
agent_version: str = Field(alias="agentVersion")
|
||||
hostname: str
|
||||
os: str
|
||||
architecture: str
|
||||
|
||||
|
||||
class SystemInfoResponse(CamelModel):
|
||||
hostname: str
|
||||
os: str
|
||||
kernel: str
|
||||
architecture: str
|
||||
disk_free: str = Field(alias="diskFree")
|
||||
memory_total: str = Field(alias="memoryTotal")
|
||||
|
||||
|
||||
class InstallRequest(CamelModel):
|
||||
app_id: str = Field(alias="appId")
|
||||
version: str | None = None
|
||||
app_name: str | None = Field(default=None, alias="appName")
|
||||
package_name: str | None = Field(default=None, alias="packageName")
|
||||
service_name: str | None = Field(default=None, alias="serviceName")
|
||||
download_url: str | None = Field(default=None, alias="downloadUrl")
|
||||
checksum: str | None = None
|
||||
sha256: str | None = None
|
||||
|
||||
@field_validator("app_id")
|
||||
@classmethod
|
||||
def _app_id(cls, value: str) -> str:
|
||||
return validate_app_id(value)
|
||||
|
||||
@field_validator("version")
|
||||
@classmethod
|
||||
def _version(cls, value: str | None) -> str | None:
|
||||
return validate_version(value) if value else None
|
||||
|
||||
@field_validator("package_name")
|
||||
@classmethod
|
||||
def _package_name(cls, value: str | None) -> str | None:
|
||||
return validate_package_name(value) if value else None
|
||||
|
||||
@field_validator("service_name")
|
||||
@classmethod
|
||||
def _service_name(cls, value: str | None) -> str | None:
|
||||
return validate_service_name(value)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _direct_package_fields(self) -> "InstallRequest":
|
||||
digest = self.sha256 or self.checksum
|
||||
if digest:
|
||||
normalized = validate_sha256(digest)
|
||||
self.sha256 = normalized
|
||||
self.checksum = normalized
|
||||
|
||||
has_direct_package = any([self.download_url, self.package_name, self.checksum, self.sha256])
|
||||
if has_direct_package and not all([self.download_url, self.package_name, self.checksum or self.sha256]):
|
||||
raise ValueError("direct install requires packageName, downloadUrl, and checksum/sha256")
|
||||
return self
|
||||
|
||||
|
||||
class UpdateRequest(InstallRequest):
|
||||
current_version: str | None = Field(default=None, alias="currentVersion")
|
||||
target_version: str | None = Field(default=None, alias="targetVersion")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _normalize_target(self) -> "UpdateRequest":
|
||||
if self.target_version:
|
||||
self.target_version = validate_version(self.target_version)
|
||||
self.version = self.target_version
|
||||
return self
|
||||
|
||||
|
||||
class RemoveRequest(CamelModel):
|
||||
app_id: str = Field(alias="appId")
|
||||
package_name: str | None = Field(default=None, alias="packageName")
|
||||
service_name: str | None = Field(default=None, alias="serviceName")
|
||||
purge: bool = False
|
||||
|
||||
@field_validator("app_id")
|
||||
@classmethod
|
||||
def _app_id(cls, value: str) -> str:
|
||||
return validate_app_id(value)
|
||||
|
||||
@field_validator("package_name")
|
||||
@classmethod
|
||||
def _package_name(cls, value: str | None) -> str | None:
|
||||
return validate_package_name(value) if value else None
|
||||
|
||||
@field_validator("service_name")
|
||||
@classmethod
|
||||
def _service_name(cls, value: str | None) -> str | None:
|
||||
return validate_service_name(value)
|
||||
|
||||
|
||||
class ServiceRequest(CamelModel):
|
||||
service_name: str = Field(alias="serviceName")
|
||||
|
||||
@field_validator("service_name")
|
||||
@classmethod
|
||||
def _service_name(cls, value: str) -> str:
|
||||
return validate_service_name(value) or value
|
||||
|
||||
|
||||
class TaskQueuedResponse(CamelModel):
|
||||
task_id: str = Field(alias="taskId")
|
||||
status: str
|
||||
|
||||
|
||||
class DebComponent(CamelModel):
|
||||
component_id: str = Field(alias="componentId")
|
||||
type: Literal["deb"] = "deb"
|
||||
install_order: int = Field(default=10, alias="installOrder")
|
||||
required: bool = True
|
||||
package_name: str = Field(alias="packageName")
|
||||
version: str
|
||||
download_url: str = Field(alias="downloadUrl")
|
||||
sha256: str
|
||||
service_name: str | None = Field(default=None, alias="serviceName")
|
||||
|
||||
@field_validator("component_id")
|
||||
@classmethod
|
||||
def _component_id(cls, value: str) -> str:
|
||||
return validate_app_id(value)
|
||||
|
||||
@field_validator("package_name")
|
||||
@classmethod
|
||||
def _package_name(cls, value: str) -> str:
|
||||
return validate_package_name(value)
|
||||
|
||||
@field_validator("version")
|
||||
@classmethod
|
||||
def _version(cls, value: str) -> str:
|
||||
return validate_version(value)
|
||||
|
||||
@field_validator("sha256")
|
||||
@classmethod
|
||||
def _sha256(cls, value: str) -> str:
|
||||
return validate_sha256(value)
|
||||
|
||||
@field_validator("service_name")
|
||||
@classmethod
|
||||
def _service_name(cls, value: str | None) -> str | None:
|
||||
return validate_service_name(value)
|
||||
|
||||
|
||||
class RawComponent(CamelModel):
|
||||
component_id: str = Field(alias="componentId")
|
||||
type: ComponentType
|
||||
install_order: int = Field(default=10, alias="installOrder")
|
||||
required: bool = True
|
||||
payload: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class AppManifest(CamelModel):
|
||||
schema_version: str = Field(default="1.0", alias="schemaVersion")
|
||||
app_id: str = Field(alias="appId")
|
||||
app_name: str = Field(alias="appName")
|
||||
version: str
|
||||
architecture: str = "amd64"
|
||||
components: list[dict[str, Any]]
|
||||
signature: str | None = None
|
||||
|
||||
@field_validator("app_id")
|
||||
@classmethod
|
||||
def _app_id(cls, value: str) -> str:
|
||||
return validate_app_id(value)
|
||||
|
||||
@field_validator("version")
|
||||
@classmethod
|
||||
def _version(cls, value: str) -> str:
|
||||
return validate_version(value)
|
||||
2
agent/app/storage/__init__.py
Normal file
2
agent/app/storage/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""SQLite storage layer."""
|
||||
|
||||
102
agent/app/storage/database.py
Normal file
102
agent/app/storage/database.py
Normal file
@@ -0,0 +1,102 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
SCHEMA = """
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL,
|
||||
app_id TEXT,
|
||||
app_name TEXT,
|
||||
status TEXT NOT NULL,
|
||||
progress INTEGER DEFAULT 0,
|
||||
current_step TEXT,
|
||||
current_component_id TEXT,
|
||||
error_message TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
started_at TEXT,
|
||||
finished_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS task_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id TEXT NOT NULL,
|
||||
timestamp TEXT NOT NULL,
|
||||
level TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS installed_apps (
|
||||
app_id TEXT PRIMARY KEY,
|
||||
app_name TEXT NOT NULL,
|
||||
version TEXT NOT NULL,
|
||||
manifest_hash TEXT,
|
||||
status TEXT NOT NULL,
|
||||
installed_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS installed_components (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
app_id TEXT NOT NULL,
|
||||
component_id TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
install_order INTEGER NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
package_name TEXT,
|
||||
package_version TEXT,
|
||||
service_name TEXT,
|
||||
docker_image TEXT,
|
||||
docker_digest TEXT,
|
||||
container_name TEXT,
|
||||
compose_project_name TEXT,
|
||||
installed_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE(app_id, component_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS task_components (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id TEXT NOT NULL,
|
||||
app_id TEXT NOT NULL,
|
||||
component_id TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
install_order INTEGER NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
progress INTEGER DEFAULT 0,
|
||||
current_step TEXT,
|
||||
error_message TEXT,
|
||||
started_at TEXT,
|
||||
finished_at TEXT,
|
||||
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS agent_config (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
def get_connection() -> sqlite3.Connection:
|
||||
connection = sqlite3.connect(settings.db_path, timeout=30)
|
||||
connection.row_factory = sqlite3.Row
|
||||
return connection
|
||||
|
||||
|
||||
def initialize_database() -> None:
|
||||
db_path = Path(settings.db_path)
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
settings.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
settings.log_dir.mkdir(parents=True, exist_ok=True)
|
||||
with get_connection() as connection:
|
||||
connection.executescript(SCHEMA)
|
||||
|
||||
255
agent/app/storage/repository.py
Normal file
255
agent/app/storage/repository.py
Normal file
@@ -0,0 +1,255 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from app.storage.database import get_connection
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
|
||||
|
||||
|
||||
def row_to_dict(row: Any) -> dict[str, Any] | None:
|
||||
if row is None:
|
||||
return None
|
||||
return dict(row)
|
||||
|
||||
|
||||
class Repository:
|
||||
def create_task(self, task_id: str, task_type: str, app_id: str, app_name: str | None) -> None:
|
||||
now = utc_now()
|
||||
with get_connection() as connection:
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO tasks (id, type, app_id, app_name, status, progress, current_step, created_at)
|
||||
VALUES (?, ?, ?, ?, 'queued', 0, 'queued', ?)
|
||||
""",
|
||||
(task_id, task_type, app_id, app_name, now),
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO task_logs (task_id, timestamp, level, message) VALUES (?, ?, 'info', ?)",
|
||||
(task_id, now, f"Task {task_id} queued"),
|
||||
)
|
||||
|
||||
def update_task(
|
||||
self,
|
||||
task_id: str,
|
||||
*,
|
||||
status: str | None = None,
|
||||
progress: int | None = None,
|
||||
current_step: str | None = None,
|
||||
current_component_id: str | None = None,
|
||||
error_message: str | None = None,
|
||||
started_at: str | None = None,
|
||||
finished_at: str | None = None,
|
||||
) -> None:
|
||||
fields: list[str] = []
|
||||
values: list[Any] = []
|
||||
for key, value in {
|
||||
"status": status,
|
||||
"progress": progress,
|
||||
"current_step": current_step,
|
||||
"current_component_id": current_component_id,
|
||||
"error_message": error_message,
|
||||
"started_at": started_at,
|
||||
"finished_at": finished_at,
|
||||
}.items():
|
||||
if value is not None:
|
||||
fields.append(f"{key} = ?")
|
||||
values.append(value)
|
||||
if not fields:
|
||||
return
|
||||
values.append(task_id)
|
||||
with get_connection() as connection:
|
||||
connection.execute(f"UPDATE tasks SET {', '.join(fields)} WHERE id = ?", values)
|
||||
|
||||
def add_log(self, task_id: str, level: str, message: str) -> None:
|
||||
with get_connection() as connection:
|
||||
connection.execute(
|
||||
"INSERT INTO task_logs (task_id, timestamp, level, message) VALUES (?, ?, ?, ?)",
|
||||
(task_id, utc_now(), level, message),
|
||||
)
|
||||
|
||||
def get_task(self, task_id: str) -> dict[str, Any] | None:
|
||||
with get_connection() as connection:
|
||||
row = connection.execute("SELECT * FROM tasks WHERE id = ?", (task_id,)).fetchone()
|
||||
return row_to_dict(row)
|
||||
|
||||
def get_task_logs(self, task_id: str) -> list[dict[str, Any]]:
|
||||
with get_connection() as connection:
|
||||
rows = connection.execute(
|
||||
"""
|
||||
SELECT timestamp, level, message
|
||||
FROM task_logs
|
||||
WHERE task_id = ?
|
||||
ORDER BY id ASC
|
||||
""",
|
||||
(task_id,),
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
def create_task_component(
|
||||
self,
|
||||
task_id: str,
|
||||
app_id: str,
|
||||
component_id: str,
|
||||
component_type: str,
|
||||
install_order: int,
|
||||
) -> None:
|
||||
with get_connection() as connection:
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO task_components (
|
||||
task_id, app_id, component_id, type, install_order, status, progress, current_step
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, 'queued', 0, 'queued')
|
||||
""",
|
||||
(task_id, app_id, component_id, component_type, install_order),
|
||||
)
|
||||
|
||||
def update_task_component(
|
||||
self,
|
||||
task_id: str,
|
||||
component_id: str,
|
||||
*,
|
||||
status: str | None = None,
|
||||
progress: int | None = None,
|
||||
current_step: str | None = None,
|
||||
error_message: str | None = None,
|
||||
started_at: str | None = None,
|
||||
finished_at: str | None = None,
|
||||
) -> None:
|
||||
fields: list[str] = []
|
||||
values: list[Any] = []
|
||||
for key, value in {
|
||||
"status": status,
|
||||
"progress": progress,
|
||||
"current_step": current_step,
|
||||
"error_message": error_message,
|
||||
"started_at": started_at,
|
||||
"finished_at": finished_at,
|
||||
}.items():
|
||||
if value is not None:
|
||||
fields.append(f"{key} = ?")
|
||||
values.append(value)
|
||||
if not fields:
|
||||
return
|
||||
values.extend([task_id, component_id])
|
||||
with get_connection() as connection:
|
||||
connection.execute(
|
||||
f"UPDATE task_components SET {', '.join(fields)} WHERE task_id = ? AND component_id = ?",
|
||||
values,
|
||||
)
|
||||
|
||||
def get_task_components(self, task_id: str) -> list[dict[str, Any]]:
|
||||
with get_connection() as connection:
|
||||
rows = connection.execute(
|
||||
"""
|
||||
SELECT component_id, type, status, progress, current_step, error_message, started_at, finished_at
|
||||
FROM task_components
|
||||
WHERE task_id = ?
|
||||
ORDER BY install_order ASC, id ASC
|
||||
""",
|
||||
(task_id,),
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
def list_installed_apps(self) -> list[dict[str, Any]]:
|
||||
with get_connection() as connection:
|
||||
rows = connection.execute(
|
||||
"""
|
||||
SELECT app_id, app_name, version, manifest_hash, status, installed_at, updated_at
|
||||
FROM installed_apps
|
||||
ORDER BY app_name ASC
|
||||
"""
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
def upsert_installed_app(
|
||||
self,
|
||||
app_id: str,
|
||||
app_name: str,
|
||||
version: str,
|
||||
manifest_hash: str | None,
|
||||
status: str = "installed",
|
||||
) -> None:
|
||||
now = utc_now()
|
||||
with get_connection() as connection:
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO installed_apps (app_id, app_name, version, manifest_hash, status, installed_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(app_id) DO UPDATE SET
|
||||
app_name = excluded.app_name,
|
||||
version = excluded.version,
|
||||
manifest_hash = excluded.manifest_hash,
|
||||
status = excluded.status,
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
(app_id, app_name, version, manifest_hash, status, now, now),
|
||||
)
|
||||
|
||||
def delete_installed_app(self, app_id: str) -> None:
|
||||
with get_connection() as connection:
|
||||
connection.execute("DELETE FROM installed_components WHERE app_id = ?", (app_id,))
|
||||
connection.execute("DELETE FROM installed_apps WHERE app_id = ?", (app_id,))
|
||||
|
||||
def upsert_installed_component(self, app_id: str, component: dict[str, Any]) -> None:
|
||||
now = utc_now()
|
||||
with get_connection() as connection:
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO installed_components (
|
||||
app_id, component_id, type, install_order, status, package_name, package_version,
|
||||
service_name, docker_image, docker_digest, container_name, compose_project_name,
|
||||
installed_at, updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, 'installed', ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(app_id, component_id) DO UPDATE SET
|
||||
type = excluded.type,
|
||||
install_order = excluded.install_order,
|
||||
status = excluded.status,
|
||||
package_name = excluded.package_name,
|
||||
package_version = excluded.package_version,
|
||||
service_name = excluded.service_name,
|
||||
docker_image = excluded.docker_image,
|
||||
docker_digest = excluded.docker_digest,
|
||||
container_name = excluded.container_name,
|
||||
compose_project_name = excluded.compose_project_name,
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
(
|
||||
app_id,
|
||||
component["componentId"],
|
||||
component["type"],
|
||||
component.get("installOrder", 10),
|
||||
component.get("packageName"),
|
||||
component.get("version"),
|
||||
component.get("serviceName"),
|
||||
component.get("image"),
|
||||
component.get("digest"),
|
||||
component.get("containerName"),
|
||||
component.get("projectName"),
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
|
||||
def list_installed_components(self, app_id: str) -> list[dict[str, Any]]:
|
||||
with get_connection() as connection:
|
||||
rows = connection.execute(
|
||||
"""
|
||||
SELECT *
|
||||
FROM installed_components
|
||||
WHERE app_id = ?
|
||||
ORDER BY install_order ASC, id ASC
|
||||
""",
|
||||
(app_id,),
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
def export_manifest_hash(self, manifest: dict[str, Any]) -> str:
|
||||
return json.dumps(manifest, sort_keys=True, separators=(",", ":"))
|
||||
|
||||
2
agent/app/utils/__init__.py
Normal file
2
agent/app/utils/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Utility helpers."""
|
||||
|
||||
55
agent/app/utils/validators.py
Normal file
55
agent/app/utils/validators.py
Normal file
@@ -0,0 +1,55 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
APP_ID_RE = re.compile(r"^[a-zA-Z0-9._+-]+$")
|
||||
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}$")
|
||||
|
||||
|
||||
def validate_app_id(value: str) -> str:
|
||||
if not value or not APP_ID_RE.fullmatch(value):
|
||||
raise ValueError("appId contains invalid characters")
|
||||
return value
|
||||
|
||||
|
||||
def validate_package_name(value: str) -> str:
|
||||
if not value or not PACKAGE_NAME_RE.fullmatch(value):
|
||||
raise ValueError("packageName contains invalid characters")
|
||||
return value
|
||||
|
||||
|
||||
def validate_service_name(value: str | None) -> str | None:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
if not SERVICE_NAME_RE.fullmatch(value):
|
||||
raise ValueError("serviceName must be a systemd .service name")
|
||||
return value
|
||||
|
||||
|
||||
def validate_version(value: str) -> str:
|
||||
if not value or not VERSION_RE.fullmatch(value):
|
||||
raise ValueError("version contains invalid characters")
|
||||
return value
|
||||
|
||||
|
||||
def validate_sha256(value: str) -> str:
|
||||
if not value or not SHA256_RE.fullmatch(value):
|
||||
raise ValueError("sha256/checksum must be a 64 character hex digest")
|
||||
return value.lower()
|
||||
|
||||
|
||||
def validate_url_host(url: str, allowed_hosts: list[str]) -> str:
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in {"http", "https"}:
|
||||
raise ValueError("download URL must use http or https")
|
||||
if not parsed.hostname:
|
||||
raise ValueError("download URL is missing a host")
|
||||
if parsed.hostname not in set(allowed_hosts):
|
||||
raise ValueError(f"download host is not allowed: {parsed.hostname}")
|
||||
return url
|
||||
|
||||
10
agent/packaging/DEBIAN/control
Normal file
10
agent/packaging/DEBIAN/control
Normal file
@@ -0,0 +1,10 @@
|
||||
Package: local-installer-agent
|
||||
Version: 0.1.0
|
||||
Section: utils
|
||||
Priority: optional
|
||||
Architecture: amd64
|
||||
Maintainer: Robot Team <admin@robot.package>
|
||||
Depends: python3, python3-venv, python3-pip, curl
|
||||
Description: Local Installer Agent for robot.installer
|
||||
A local background service that installs, updates, and removes trusted .deb apps
|
||||
from robot.package on the user's Linux machine.
|
||||
21
agent/packaging/DEBIAN/postinst
Normal file
21
agent/packaging/DEBIAN/postinst
Normal file
@@ -0,0 +1,21 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
mkdir -p /var/lib/local-installer-agent
|
||||
mkdir -p /var/log/local-installer-agent
|
||||
mkdir -p /var/cache/local-installer-agent/packages
|
||||
|
||||
cd /opt/local-installer-agent
|
||||
|
||||
if [ ! -d "venv" ]; then
|
||||
python3 -m venv venv
|
||||
fi
|
||||
|
||||
./venv/bin/pip install --upgrade pip
|
||||
./venv/bin/pip install -r requirements.txt
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable local-installer-agent
|
||||
systemctl restart local-installer-agent
|
||||
|
||||
exit 0
|
||||
6
agent/packaging/DEBIAN/postrm
Normal file
6
agent/packaging/DEBIAN/postrm
Normal file
@@ -0,0 +1,6 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
systemctl daemon-reload
|
||||
|
||||
exit 0
|
||||
8
agent/packaging/DEBIAN/prerm
Normal file
8
agent/packaging/DEBIAN/prerm
Normal file
@@ -0,0 +1,8 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
if systemctl is-active --quiet local-installer-agent; then
|
||||
systemctl stop local-installer-agent
|
||||
fi
|
||||
|
||||
exit 0
|
||||
13
agent/packaging/systemd/local-installer-agent.service
Normal file
13
agent/packaging/systemd/local-installer-agent.service
Normal file
@@ -0,0 +1,13 @@
|
||||
[Unit]
|
||||
Description=Local Installer Agent
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
WorkingDirectory=/opt/local-installer-agent
|
||||
ExecStart=/opt/local-installer-agent/venv/bin/python -m uvicorn app.main:app --host 127.0.0.1 --port 5010
|
||||
Restart=always
|
||||
User=root
|
||||
EnvironmentFile=/etc/local-installer-agent/agent.env
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
4
agent/requirements.txt
Normal file
4
agent/requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
fastapi>=0.115,<1.0
|
||||
uvicorn[standard]>=0.30,<1.0
|
||||
pydantic>=2,<3
|
||||
httpx>=0.27,<1.0
|
||||
109
agent/scripts/build-deb.sh
Normal file
109
agent/scripts/build-deb.sh
Normal file
@@ -0,0 +1,109 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
PKG_NAME="local-installer-agent"
|
||||
ARCH="${ARCH:-amd64}"
|
||||
PUBLISH_DIR="${AGENT_PUBLISH_DIR:-../web-server/uploads/packages/agent}"
|
||||
|
||||
if [ -z "${BUILD_ROOT:-}" ]; then
|
||||
if [[ "$(pwd -P)" == /mnt/* ]]; then
|
||||
BUILD_ROOT="/tmp/${PKG_NAME}-build"
|
||||
else
|
||||
BUILD_ROOT="build"
|
||||
fi
|
||||
fi
|
||||
|
||||
next_patch_version() {
|
||||
local latest=""
|
||||
|
||||
if [ -d "${PUBLISH_DIR}" ]; then
|
||||
latest="$(
|
||||
for package_path in "${PUBLISH_DIR}/${PKG_NAME}_"*"_${ARCH}.deb"; do
|
||||
[ -e "${package_path}" ] || continue
|
||||
package_file="$(basename "${package_path}")"
|
||||
package_version="${package_file#${PKG_NAME}_}"
|
||||
package_version="${package_version%_${ARCH}.deb}"
|
||||
printf '%s\n' "${package_version}"
|
||||
done | sort -V | tail -n 1
|
||||
)"
|
||||
fi
|
||||
|
||||
if [ -z "${latest}" ]; then
|
||||
latest="$(
|
||||
sed -nE 's/^Version:[[:space:]]*([^[:space:]]+).*/\1/p' packaging/DEBIAN/control |
|
||||
head -n 1
|
||||
)"
|
||||
fi
|
||||
|
||||
if [[ "${latest}" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
|
||||
printf '%s.%s.%s\n' "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" "$((BASH_REMATCH[3] + 1))"
|
||||
return
|
||||
fi
|
||||
|
||||
printf '0.1.0\n'
|
||||
}
|
||||
|
||||
VERSION="${VERSION:-$(next_patch_version)}"
|
||||
BUILD_DIR="${BUILD_ROOT}/${PKG_NAME}_${VERSION}_${ARCH}"
|
||||
|
||||
rm -rf "${BUILD_ROOT}"
|
||||
|
||||
mkdir -p "${BUILD_DIR}/opt/local-installer-agent"
|
||||
mkdir -p "${BUILD_DIR}/etc/local-installer-agent"
|
||||
mkdir -p "${BUILD_DIR}/etc/systemd/system"
|
||||
mkdir -p "${BUILD_DIR}/DEBIAN"
|
||||
|
||||
cp -r app "${BUILD_DIR}/opt/local-installer-agent/"
|
||||
cp requirements.txt "${BUILD_DIR}/opt/local-installer-agent/"
|
||||
|
||||
find "${BUILD_DIR}/opt/local-installer-agent/app" -type d -name "__pycache__" -prune -exec rm -rf {} +
|
||||
find "${BUILD_DIR}/opt/local-installer-agent/app" -type f \( -name "*.pyc" -o -name "*.pyo" \) -delete
|
||||
find "${BUILD_DIR}/opt/local-installer-agent" -type d -exec chmod 755 {} +
|
||||
find "${BUILD_DIR}/opt/local-installer-agent" -type f -exec chmod 644 {} +
|
||||
|
||||
cp packaging/systemd/local-installer-agent.service \
|
||||
"${BUILD_DIR}/etc/systemd/system/local-installer-agent.service"
|
||||
|
||||
cp packaging/DEBIAN/control "${BUILD_DIR}/DEBIAN/control"
|
||||
cp packaging/DEBIAN/postinst "${BUILD_DIR}/DEBIAN/postinst"
|
||||
cp packaging/DEBIAN/prerm "${BUILD_DIR}/DEBIAN/prerm"
|
||||
cp packaging/DEBIAN/postrm "${BUILD_DIR}/DEBIAN/postrm"
|
||||
|
||||
chmod 755 "${BUILD_DIR}/DEBIAN/postinst"
|
||||
chmod 755 "${BUILD_DIR}/DEBIAN/prerm"
|
||||
chmod 755 "${BUILD_DIR}/DEBIAN/postrm"
|
||||
chmod 644 "${BUILD_DIR}/DEBIAN/control"
|
||||
chmod 644 "${BUILD_DIR}/etc/systemd/system/local-installer-agent.service"
|
||||
|
||||
sed -i -E "s/^Version:.*/Version: ${VERSION}/" "${BUILD_DIR}/DEBIAN/control"
|
||||
sed -i -E "s/^Architecture:.*/Architecture: ${ARCH}/" "${BUILD_DIR}/DEBIAN/control"
|
||||
|
||||
cat > "${BUILD_DIR}/etc/local-installer-agent/agent.env" <<EOF
|
||||
AGENT_VERSION=${VERSION}
|
||||
AGENT_HOST=127.0.0.1
|
||||
AGENT_PORT=5010
|
||||
ROBOT_PACKAGE_BASE_URL=https://robot.package
|
||||
ALLOWED_ORIGINS=https://robot.installer,http://localhost:3000,http://localhost:5173
|
||||
ALLOWED_DOWNLOAD_HOSTS=robot.package
|
||||
ALLOWED_DOCKER_REGISTRIES=registry.robot.package
|
||||
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_COMPOSE=false
|
||||
EOF
|
||||
|
||||
dpkg-deb --root-owner-group --build "${BUILD_DIR}"
|
||||
|
||||
echo "Built package:"
|
||||
echo "${BUILD_DIR}.deb"
|
||||
|
||||
if [ "${PUBLISH:-false}" = "true" ]; then
|
||||
mkdir -p "${PUBLISH_DIR}"
|
||||
cp "${BUILD_DIR}.deb" "${PUBLISH_DIR}/"
|
||||
echo "Published package:"
|
||||
echo "${PUBLISH_DIR}/$(basename "${BUILD_DIR}.deb")"
|
||||
fi
|
||||
23
agent/scripts/install-agent.sh
Normal file
23
agent/scripts/install-agent.sh
Normal file
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
AGENT_BASE_URL="${AGENT_BASE_URL:-https://robot.package}"
|
||||
ARCH="${ARCH:-$(dpkg --print-architecture)}"
|
||||
AGENT_URL="${AGENT_URL:-${AGENT_BASE_URL%/}/packages/agent/latest.deb?arch=${ARCH}}"
|
||||
TMP_DEB="/tmp/local-installer-agent.deb"
|
||||
|
||||
echo "Downloading Local Installer Agent..."
|
||||
curl -fL "$AGENT_URL" -o "$TMP_DEB"
|
||||
|
||||
echo "Installing Local Installer Agent..."
|
||||
apt install -y "$TMP_DEB"
|
||||
|
||||
echo "Starting Local Installer Agent..."
|
||||
systemctl enable local-installer-agent
|
||||
systemctl restart local-installer-agent
|
||||
|
||||
echo "Checking Agent..."
|
||||
curl -fsSL http://127.0.0.1:5010/health
|
||||
|
||||
echo ""
|
||||
echo "Local Installer Agent installed successfully."
|
||||
@@ -828,7 +828,8 @@ tbody tr:hover td.action-col {
|
||||
}
|
||||
|
||||
.detail-grid,
|
||||
.builder-layout {
|
||||
.builder-layout,
|
||||
.agent-layout {
|
||||
display: grid;
|
||||
flex: 1;
|
||||
gap: 16px;
|
||||
@@ -836,6 +837,10 @@ tbody tr:hover td.action-col {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.agent-layout {
|
||||
grid-template-columns: 380px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.wide-panel {
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -890,10 +895,35 @@ tbody tr:hover td.action-col {
|
||||
}
|
||||
|
||||
.builder-layout .panel .form-grid,
|
||||
.builder-layout .panel .form-stack {
|
||||
.builder-layout .panel .form-stack,
|
||||
.agent-upload-form .form-grid {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.agent-upload-form {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.agent-upload-form .modal-actions {
|
||||
padding: 0 16px 16px;
|
||||
}
|
||||
|
||||
.agent-command-list {
|
||||
border-top: 1px solid #eef2f7;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.agent-command-list input {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.agent-table {
|
||||
min-width: 920px;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
accent-color: var(--primary);
|
||||
height: 16px;
|
||||
@@ -1360,6 +1390,7 @@ tbody tr:hover td.action-col {
|
||||
.dashboard-grid,
|
||||
.detail-grid,
|
||||
.builder-layout,
|
||||
.agent-layout,
|
||||
.users-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@@ -1370,6 +1401,7 @@ tbody tr:hover td.action-col {
|
||||
|
||||
.detail-grid,
|
||||
.builder-layout,
|
||||
.agent-layout,
|
||||
.users-layout {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
@@ -14,17 +14,24 @@ const notiflixVersion = require('notiflix/package.json').version;
|
||||
const app = express();
|
||||
const port = Number(process.env.PORT || 3000);
|
||||
const uploadDir = path.join(__dirname, 'uploads', 'packages');
|
||||
const agentPackageDir = path.resolve(process.env.AGENT_PACKAGE_DIR || path.join(uploadDir, 'agent'));
|
||||
const authCookieName = 'robot_installer_session';
|
||||
const sessionMaxAgeMs = Number(process.env.SESSION_MAX_AGE_MS || 1000 * 60 * 60 * 8);
|
||||
const authSecret = process.env.AUTH_SECRET || process.env.SESSION_SECRET || 'robot-installer-dev-secret';
|
||||
const agentVersionCollator = new Intl.Collator('en', {
|
||||
numeric: true,
|
||||
sensitivity: 'base'
|
||||
});
|
||||
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
fs.mkdirSync(agentPackageDir, { recursive: true });
|
||||
|
||||
const navItems = [
|
||||
{ id: 'dashboard', label: 'Tổng quan', href: '/', icon: 'dashboard' },
|
||||
{ id: 'packages', label: 'Packages', href: '/packages', icon: 'inventory_2' },
|
||||
{ id: 'applications', label: 'Applications', href: '/applications', icon: 'apps' },
|
||||
{ id: 'builder', label: 'Đóng gói App', href: '/builder', icon: 'deployed_code' },
|
||||
{ id: 'agent', label: 'Agent', href: '/agent', icon: 'memory', adminOnly: true },
|
||||
{ id: 'users', label: 'Users', href: '/users', icon: 'group', adminOnly: true }
|
||||
];
|
||||
|
||||
@@ -57,6 +64,21 @@ const storage = multer.diskStorage({
|
||||
}
|
||||
});
|
||||
|
||||
const agentStorage = multer.diskStorage({
|
||||
destination: agentPackageDir,
|
||||
filename(req, file, callback) {
|
||||
const safeName = file.originalname
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-zA-Z0-9._-]/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.toLowerCase();
|
||||
const suffix = `${Date.now()}-${crypto.randomBytes(4).toString('hex')}`;
|
||||
|
||||
callback(null, `${suffix}-${safeName}`);
|
||||
}
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: {
|
||||
@@ -64,12 +86,46 @@ const upload = multer({
|
||||
}
|
||||
});
|
||||
|
||||
const agentUpload = multer({
|
||||
storage: agentStorage,
|
||||
limits: {
|
||||
fileSize: Number(process.env.AGENT_MAX_UPLOAD_BYTES || process.env.MAX_UPLOAD_BYTES || 1024 * 1024 * 1024)
|
||||
}
|
||||
});
|
||||
|
||||
app.set('view engine', 'ejs');
|
||||
app.set('views', path.join(__dirname, 'views'));
|
||||
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
app.use('/vendor/notiflix', express.static(path.join(__dirname, 'node_modules/notiflix/dist')));
|
||||
app.get('/packages/agent/latest.deb', asyncRoute(async (req, res) => {
|
||||
const arch = normalizeAgentArch(req.query.arch);
|
||||
const latestPackage = await findLatestAgentPackage(arch);
|
||||
|
||||
if (latestPackage) {
|
||||
res.type('application/vnd.debian.binary-package');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${latestPackage.fileName}"`);
|
||||
res.setHeader('X-Agent-Version', latestPackage.version);
|
||||
res.sendFile(latestPackage.filePath);
|
||||
return;
|
||||
}
|
||||
|
||||
const latestPackageRecord = await findLatestAgentPackageRecord(arch);
|
||||
if (latestPackageRecord) {
|
||||
res.setHeader('X-Agent-Version', latestPackageRecord.version);
|
||||
res.redirect(latestPackageRecord.filePath);
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(404).type('text/plain').send(
|
||||
`No Local Installer Agent package found${arch ? ` for ${arch}` : ''}. ` +
|
||||
'Upload local-installer-agent_<version>_<arch>.deb to web-server/uploads/packages/agent, ' +
|
||||
'or upload it as package code local-installer-agent.'
|
||||
);
|
||||
}));
|
||||
app.use('/uploads/packages', express.static(uploadDir));
|
||||
app.use('/packages/agent', express.static(agentPackageDir));
|
||||
app.use(loadCurrentUser);
|
||||
|
||||
function helpers() {
|
||||
@@ -281,6 +337,139 @@ function getBaseUrl(req) {
|
||||
return `${protocol}://${req.get('host')}`;
|
||||
}
|
||||
|
||||
function normalizeAgentArch(value) {
|
||||
const arch = String(value || '').trim().toLowerCase();
|
||||
return /^[a-z0-9][a-z0-9._-]*$/.test(arch) ? arch : '';
|
||||
}
|
||||
|
||||
function isValidAgentVersion(value) {
|
||||
return /^[a-zA-Z0-9][a-zA-Z0-9._+~=-]*$/.test(String(value || '').trim());
|
||||
}
|
||||
|
||||
function compareAgentPackages(first, second) {
|
||||
const versionCompare = agentVersionCollator.compare(first.version, second.version);
|
||||
|
||||
if (versionCompare !== 0) return versionCompare;
|
||||
return first.mtimeMs - second.mtimeMs;
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
const value = Number(bytes || 0);
|
||||
if (value < 1024) return `${value} B`;
|
||||
|
||||
const units = ['KB', 'MB', 'GB'];
|
||||
let size = value / 1024;
|
||||
let unitIndex = 0;
|
||||
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex += 1;
|
||||
}
|
||||
|
||||
return `${size.toFixed(size >= 10 ? 1 : 2)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
function formatLocalDateTime(value) {
|
||||
return new Intl.DateTimeFormat('vi-VN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
function getAgentPackageDownloadPath(fileName) {
|
||||
return `/packages/agent/${encodeURIComponent(fileName)}`;
|
||||
}
|
||||
|
||||
async function getAgentPackageFromEntry(entry) {
|
||||
const match = /^local-installer-agent_([^/\\]+)_([^/\\]+)\.deb$/.exec(entry.name);
|
||||
if (!match) return null;
|
||||
|
||||
const [, version, packageArch] = match;
|
||||
const filePath = path.join(agentPackageDir, entry.name);
|
||||
const stat = await fsp.stat(filePath);
|
||||
|
||||
return {
|
||||
fileName: entry.name,
|
||||
filePath,
|
||||
version,
|
||||
arch: packageArch,
|
||||
size: stat.size,
|
||||
sizeLabel: formatBytes(stat.size),
|
||||
uploadedAt: formatLocalDateTime(stat.mtime),
|
||||
mtimeMs: stat.mtimeMs,
|
||||
downloadPath: getAgentPackageDownloadPath(entry.name)
|
||||
};
|
||||
}
|
||||
|
||||
async function listAgentPackages(arch = '') {
|
||||
let dirEntries;
|
||||
try {
|
||||
dirEntries = await fsp.readdir(agentPackageDir, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') return null;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const packages = (await Promise.all(
|
||||
dirEntries
|
||||
.filter((entry) => entry.isFile())
|
||||
.map(getAgentPackageFromEntry)
|
||||
))
|
||||
.filter(Boolean)
|
||||
.filter((packageItem) => !arch || packageItem.arch.toLowerCase() === arch);
|
||||
|
||||
const latestByArch = new Map();
|
||||
packages
|
||||
.sort(compareAgentPackages);
|
||||
|
||||
packages.forEach((packageItem) => {
|
||||
latestByArch.set(packageItem.arch.toLowerCase(), packageItem.fileName);
|
||||
});
|
||||
|
||||
return packages
|
||||
.sort((first, second) => compareAgentPackages(second, first))
|
||||
.map((packageItem) => ({
|
||||
...packageItem,
|
||||
isLatestForArch: latestByArch.get(packageItem.arch.toLowerCase()) === packageItem.fileName
|
||||
}));
|
||||
}
|
||||
|
||||
async function findLatestAgentPackage(arch = '') {
|
||||
const packages = await listAgentPackages(arch);
|
||||
const sortedPackages = packages
|
||||
.slice()
|
||||
.sort(compareAgentPackages);
|
||||
|
||||
return sortedPackages[sortedPackages.length - 1] || null;
|
||||
}
|
||||
|
||||
function packageVersionMatchesArch(version, arch) {
|
||||
if (!arch) return true;
|
||||
return String(version.filePath || '').toLowerCase().includes(`_${arch}.deb`);
|
||||
}
|
||||
|
||||
async function findLatestAgentPackageRecord(arch = '') {
|
||||
try {
|
||||
const packageItem = await repository.getPackageById('local-installer-agent');
|
||||
const latestVersion = packageItem?.versions
|
||||
?.filter((version) => version.filePath && packageVersionMatchesArch(version, arch))
|
||||
?.[0];
|
||||
|
||||
if (!latestVersion) return null;
|
||||
|
||||
return {
|
||||
filePath: latestVersion.filePath,
|
||||
version: latestVersion.version
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn('Cannot read local-installer-agent package from database:', error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCurrentUser(req, res, next) {
|
||||
try {
|
||||
const cookies = parseCookies(req.headers.cookie);
|
||||
@@ -652,9 +841,143 @@ app.post('/logout', (req, res) => {
|
||||
redirectWithNotice(res, '/login', 'success', 'Đã đăng xuất.');
|
||||
});
|
||||
|
||||
app.get('/api/apps', asyncRoute(async (req, res) => {
|
||||
const applications = await repository.listApplications();
|
||||
res.json({
|
||||
apps: applications
|
||||
.filter((application) => application.status === 'Released')
|
||||
.map((application) => ({
|
||||
appId: application.code,
|
||||
appName: application.name,
|
||||
version: application.version,
|
||||
status: application.status,
|
||||
packageCount: application.packageCount
|
||||
}))
|
||||
});
|
||||
}));
|
||||
|
||||
app.get('/api/apps/:appCode', asyncRoute(async (req, res) => {
|
||||
const application = await repository.getApplicationById(req.params.appCode);
|
||||
if (!application || application.status !== 'Released') {
|
||||
res.status(404).json({ error: 'Application not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
appId: application.code,
|
||||
appName: application.name,
|
||||
version: application.version,
|
||||
status: application.status,
|
||||
packageCount: application.packageCount,
|
||||
packages: application.packages
|
||||
});
|
||||
}));
|
||||
|
||||
app.get('/api/apps/:appCode/versions/:version/manifest', asyncRoute(async (req, res) => {
|
||||
const manifest = await repository.getApplicationManifest(
|
||||
req.params.appCode,
|
||||
req.params.version,
|
||||
getBaseUrl(req)
|
||||
);
|
||||
|
||||
if (!manifest) {
|
||||
res.status(404).json({ error: 'Application manifest not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json(manifest);
|
||||
}));
|
||||
|
||||
app.get('/install-agent.sh', (req, res) => {
|
||||
const baseUrl = getBaseUrl(req);
|
||||
const agentUrl = `${baseUrl}/packages/agent/latest.deb`;
|
||||
res.type('text/x-shellscript').send(`#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ARCH="$(dpkg --print-architecture)"
|
||||
AGENT_URL="${agentUrl}?arch=$ARCH"
|
||||
TMP_DEB="/tmp/local-installer-agent.deb"
|
||||
|
||||
echo "Downloading Local Installer Agent..."
|
||||
curl -fL "$AGENT_URL" -o "$TMP_DEB"
|
||||
|
||||
echo "Installing Local Installer Agent..."
|
||||
apt install -y "$TMP_DEB"
|
||||
|
||||
echo "Starting Local Installer Agent..."
|
||||
systemctl enable local-installer-agent
|
||||
systemctl restart local-installer-agent
|
||||
|
||||
echo "Checking Agent..."
|
||||
curl -fsSL http://127.0.0.1:5010/health
|
||||
|
||||
echo ""
|
||||
echo "Local Installer Agent installed successfully."
|
||||
`);
|
||||
});
|
||||
|
||||
app.use(requireAuthenticated);
|
||||
app.use('/uploads', express.static(path.join(__dirname, 'uploads')));
|
||||
|
||||
app.get('/agent', requireAdmin, asyncRoute(async (req, res) => {
|
||||
const pageData = await repository.getPageData(req.currentUser);
|
||||
const agentPackages = await listAgentPackages();
|
||||
const preferredArch = normalizeAgentArch(req.query.arch) || 'amd64';
|
||||
const latestAgentPackage = await findLatestAgentPackage(preferredArch);
|
||||
const baseUrl = getBaseUrl(req);
|
||||
|
||||
res.render('agent', viewModel(req, 'agent', 'Agent packages', pageData, {
|
||||
agentPackages,
|
||||
latestAgentPackage,
|
||||
agentPackageDir,
|
||||
preferredArch,
|
||||
installCommand: `curl -fsSL ${baseUrl}/install-agent.sh | sudo bash`,
|
||||
latestAgentUrl: `${baseUrl}/packages/agent/latest.deb?arch=${preferredArch}`
|
||||
}));
|
||||
}));
|
||||
|
||||
app.post('/agent/packages', requireAdmin, agentUpload.single('agentFile'), asyncRoute(async (req, res) => {
|
||||
const version = String(req.body.version || '').trim();
|
||||
const arch = normalizeAgentArch(req.body.arch);
|
||||
|
||||
try {
|
||||
if (!req.file || !version || !arch) {
|
||||
await removeUploadedFile(req.file);
|
||||
redirectWithNotice(res, '/agent', 'warning', 'Vui lòng chọn file .deb, nhập version và architecture.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isValidAgentVersion(version)) {
|
||||
await removeUploadedFile(req.file);
|
||||
redirectWithNotice(res, '/agent', 'warning', 'Version chỉ nên chứa chữ, số, dấu chấm, gạch ngang hoặc gạch dưới.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.extname(req.file.originalname).toLowerCase() !== '.deb') {
|
||||
await removeUploadedFile(req.file);
|
||||
redirectWithNotice(res, '/agent', 'warning', 'Agent package phải là file .deb.');
|
||||
return;
|
||||
}
|
||||
|
||||
const targetFileName = `local-installer-agent_${version}_${arch}.deb`;
|
||||
const targetPath = path.join(agentPackageDir, targetFileName);
|
||||
const isUpdate = fs.existsSync(targetPath);
|
||||
|
||||
await fsp.mkdir(agentPackageDir, { recursive: true });
|
||||
await fsp.rename(req.file.path, targetPath);
|
||||
|
||||
redirectWithNotice(
|
||||
res,
|
||||
'/agent',
|
||||
'success',
|
||||
isUpdate ? 'Đã cập nhật Agent package.' : 'Đã upload Agent package mới.'
|
||||
);
|
||||
} catch (error) {
|
||||
await removeUploadedFile(req.file);
|
||||
throw error;
|
||||
}
|
||||
}));
|
||||
|
||||
app.post('/profile', asyncRoute(async (req, res) => {
|
||||
const returnTo = sanitizeReturnTo(req.body.returnTo);
|
||||
const fullName = String(req.body.fullName || '').trim();
|
||||
|
||||
@@ -262,6 +262,16 @@ function mapApplicationPackageRow(row) {
|
||||
};
|
||||
}
|
||||
|
||||
function toAbsoluteUrl(baseUrl, filePath) {
|
||||
if (!filePath) return '';
|
||||
if (/^https?:\/\//i.test(filePath)) return filePath;
|
||||
|
||||
const normalizedBaseUrl = String(baseUrl || '').replace(/\/+$/, '');
|
||||
const normalizedPath = String(filePath).startsWith('/') ? filePath : `/${filePath}`;
|
||||
|
||||
return `${normalizedBaseUrl}${normalizedPath}`;
|
||||
}
|
||||
|
||||
async function getUserById(id) {
|
||||
const pool = await getPool();
|
||||
const result = await pool.request()
|
||||
@@ -806,6 +816,88 @@ async function getApplicationById(id) {
|
||||
return application;
|
||||
}
|
||||
|
||||
async function getApplicationManifest(appCode, version, baseUrl) {
|
||||
const pool = await getPool();
|
||||
const appResult = await pool.request()
|
||||
.input('AppCode', sql.NVarChar(100), String(appCode || '').trim())
|
||||
.input('AppVersion', sql.NVarChar(50), String(version || '').trim())
|
||||
.query(`
|
||||
SELECT TOP (1) Id, AppCode, AppName, AppVersion
|
||||
FROM dbo.Applications
|
||||
WHERE AppCode = @AppCode
|
||||
AND AppVersion = @AppVersion
|
||||
AND Status = N'Released';
|
||||
`);
|
||||
|
||||
const appRow = appResult.recordset[0];
|
||||
if (!appRow) return null;
|
||||
|
||||
const componentResult = await pool.request()
|
||||
.input('ApplicationId', sql.UniqueIdentifier, appRow.Id)
|
||||
.query(`
|
||||
SELECT
|
||||
ap.Id,
|
||||
p.PackageCode,
|
||||
p.PackageName,
|
||||
p.PackageType,
|
||||
COALESCE(selected_version.Version, latest_version.Version) AS Version,
|
||||
COALESCE(selected_version.FilePath, latest_version.FilePath) AS FilePath,
|
||||
COALESCE(selected_version.DockerImage, latest_version.DockerImage) AS DockerImage,
|
||||
COALESCE(selected_version.FileChecksumSha256, latest_version.FileChecksumSha256) AS FileChecksumSha256,
|
||||
ROW_NUMBER() OVER (ORDER BY ap.AddedAt ASC, p.PackageCode ASC) * 10 AS InstallOrder
|
||||
FROM dbo.ApplicationPackages AS ap
|
||||
INNER JOIN dbo.Packages AS p
|
||||
ON p.Id = ap.PackageId
|
||||
LEFT JOIN dbo.PackageVersions AS selected_version
|
||||
ON selected_version.Id = ap.SelectedVersionId
|
||||
OUTER APPLY (
|
||||
SELECT TOP (1) latest.*
|
||||
FROM dbo.PackageVersions AS latest
|
||||
WHERE latest.PackageId = p.Id
|
||||
AND latest.IsLatest = 1
|
||||
ORDER BY latest.ReleaseDate DESC, latest.UploadedAt DESC
|
||||
) AS latest_version
|
||||
WHERE ap.ApplicationId = @ApplicationId
|
||||
ORDER BY ap.AddedAt ASC, p.PackageCode ASC;
|
||||
`);
|
||||
|
||||
const components = componentResult.recordset.map((row) => {
|
||||
const installOrder = Number(row.InstallOrder || 10);
|
||||
|
||||
if (row.PackageType === 'docker') {
|
||||
return {
|
||||
componentId: row.PackageCode,
|
||||
type: 'docker',
|
||||
installOrder,
|
||||
required: true,
|
||||
image: row.DockerImage || '',
|
||||
tag: row.Version || 'latest',
|
||||
containerName: row.PackageCode
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
componentId: row.PackageCode,
|
||||
type: 'deb',
|
||||
installOrder,
|
||||
required: true,
|
||||
packageName: row.PackageCode,
|
||||
version: row.Version || '',
|
||||
downloadUrl: toAbsoluteUrl(baseUrl, row.FilePath),
|
||||
sha256: row.FileChecksumSha256 || ''
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
schemaVersion: '1.0',
|
||||
appId: appRow.AppCode,
|
||||
appName: appRow.AppName,
|
||||
version: appRow.AppVersion,
|
||||
architecture: 'amd64',
|
||||
components
|
||||
};
|
||||
}
|
||||
|
||||
async function getStats() {
|
||||
const pool = await getPool();
|
||||
const result = await pool.request().query(`
|
||||
@@ -1216,6 +1308,7 @@ module.exports = {
|
||||
getPageData,
|
||||
listPackages,
|
||||
listApplications,
|
||||
getApplicationManifest,
|
||||
getPackageById,
|
||||
getApplicationById,
|
||||
createPackageWithVersion,
|
||||
|
||||
157
web-server/views/agent.ejs
Normal file
157
web-server/views/agent.ejs
Normal file
@@ -0,0 +1,157 @@
|
||||
<%- include('partials/page-start') %>
|
||||
|
||||
<section class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>Agent</h1>
|
||||
<p>Quản lý Local Installer Agent package dùng cho máy client Linux.</p>
|
||||
</div>
|
||||
<div class="page-actions">
|
||||
<span class="badge badge-primary">Admin</span>
|
||||
<span class="badge badge-info"><%= agentPackages.length %> packages</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="agent-layout">
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2>Upload / Update</h2>
|
||||
<% if (latestAgentPackage) { %>
|
||||
<p>Latest <%= preferredArch %>: <strong><%= latestAgentPackage.version %></strong></p>
|
||||
<% } else { %>
|
||||
<p>Chưa có Agent package cho <%= preferredArch %>.</p>
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="agent-upload-form" method="post" action="/agent/packages" enctype="multipart/form-data">
|
||||
<div class="form-grid">
|
||||
<label class="form-field">
|
||||
<span>Version</span>
|
||||
<input type="text" name="version" placeholder="0.1.1" required>
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>Architecture</span>
|
||||
<input type="text" name="arch" value="<%= preferredArch %>" required>
|
||||
</label>
|
||||
<div class="form-field full">
|
||||
<span>Agent .deb</span>
|
||||
<div class="file-dropzone" data-file-dropzone>
|
||||
<input class="file-input" type="file" name="agentFile" accept=".deb" required data-file-input>
|
||||
<div class="file-dropzone-content">
|
||||
<span class="material-symbols-outlined">upload_file</span>
|
||||
<strong>Chọn file Agent package</strong>
|
||||
<small>Server sẽ lưu thành local-installer-agent_<version>_<arch>.deb</small>
|
||||
<button class="btn btn-secondary" type="button" data-file-browse>
|
||||
<span class="material-symbols-outlined">attach_file</span>
|
||||
Chọn file
|
||||
</button>
|
||||
</div>
|
||||
<div class="file-preview" data-file-preview hidden>
|
||||
<span class="material-symbols-outlined">draft</span>
|
||||
<div>
|
||||
<strong data-file-name>Chưa chọn file</strong>
|
||||
<small data-file-meta></small>
|
||||
</div>
|
||||
<button class="icon-button subtle" type="button" title="Bỏ file" aria-label="Bỏ file đã chọn" data-file-clear>
|
||||
<span class="material-symbols-outlined">close</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-primary" type="submit">
|
||||
<span class="material-symbols-outlined">upload</span>
|
||||
Upload / Update
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="agent-command-list">
|
||||
<label class="form-field full">
|
||||
<span>Install command</span>
|
||||
<input class="mono" type="text" value="<%= installCommand %>" readonly>
|
||||
</label>
|
||||
<label class="form-field full">
|
||||
<span>Latest package URL</span>
|
||||
<input class="mono" type="text" value="<%= latestAgentUrl %>" readonly>
|
||||
</label>
|
||||
<label class="form-field full">
|
||||
<span>Storage folder</span>
|
||||
<input class="mono" type="text" value="<%= agentPackageDir %>" readonly>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="table-panel wide-panel">
|
||||
<div class="page-filters inline">
|
||||
<label class="filter-field wide">
|
||||
<span>Search</span>
|
||||
<input type="search" placeholder="Tìm theo version, arch, filename..." data-table-search="agentPackagesTable">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table id="agentPackagesTable" class="data-table agent-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Version</th>
|
||||
<th>Arch</th>
|
||||
<th>File</th>
|
||||
<th>Size</th>
|
||||
<th>Uploaded</th>
|
||||
<th>Status</th>
|
||||
<th class="action-col">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% if (agentPackages.length === 0) { %>
|
||||
<tr>
|
||||
<td colspan="7" class="table-empty">Chưa có Agent package nào.</td>
|
||||
</tr>
|
||||
<% } %>
|
||||
<% agentPackages.forEach((item) => { %>
|
||||
<tr data-search="<%= `${item.version} ${item.arch} ${item.fileName}`.toLowerCase() %>">
|
||||
<td><strong><%= item.version %></strong></td>
|
||||
<td><span class="badge badge-muted"><%= item.arch %></span></td>
|
||||
<td>
|
||||
<span class="table-title"><%= item.fileName %></span>
|
||||
<span class="table-subtitle"><%= item.downloadPath %></span>
|
||||
</td>
|
||||
<td><%= item.sizeLabel %></td>
|
||||
<td><%= item.uploadedAt %></td>
|
||||
<td>
|
||||
<% if (item.isLatestForArch) { %>
|
||||
<span class="badge badge-success">Latest</span>
|
||||
<% } else { %>
|
||||
<span class="badge badge-muted">Stored</span>
|
||||
<% } %>
|
||||
</td>
|
||||
<td class="action-col">
|
||||
<div class="action-group">
|
||||
<a class="icon-button subtle" href="<%= item.downloadPath %>" title="Download" aria-label="Download <%= item.fileName %>">
|
||||
<span class="material-symbols-outlined">download</span>
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<% }) %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="page-pager">
|
||||
<span>Showing 1-<%= agentPackages.length %> of <%= agentPackages.length %></span>
|
||||
<div>
|
||||
<button type="button" disabled>Prev</button>
|
||||
<span>Page 1 / 1</span>
|
||||
<button type="button" disabled>Next</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<%- include('partials/page-end') %>
|
||||
Reference in New Issue
Block a user