This commit is contained in:
2026-05-22 16:47:51 +07:00
parent 190d2418da
commit 582960cc32
39 changed files with 2307 additions and 2 deletions

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
__pycache__/
*.py[cod]
agent/.venv/
agent/build/
web-server/uploads/

7
.vscode/settings.json vendored Normal file
View 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
View 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
View File

@@ -0,0 +1,2 @@
"""Local Installer Agent package."""

View File

@@ -0,0 +1,2 @@
"""API routers for the Local Installer Agent."""

53
agent/app/api/apps.py Normal file
View 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
View 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
View 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
View 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
View 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()

View File

@@ -0,0 +1,2 @@
"""Core installers and task orchestration."""

View 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()

View 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

View 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

View 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

View 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()

View 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

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

View 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
View 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)

View File

@@ -0,0 +1,2 @@
"""Pydantic models for API contracts."""

196
agent/app/models/schemas.py Normal file
View 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)

View File

@@ -0,0 +1,2 @@
"""SQLite storage layer."""

View 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)

View 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=(",", ":"))

View File

@@ -0,0 +1,2 @@
"""Utility helpers."""

View 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

View 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.

View 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

View File

@@ -0,0 +1,6 @@
#!/bin/bash
set -e
systemctl daemon-reload
exit 0

View 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

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

View 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."

View File

@@ -828,7 +828,8 @@ tbody tr:hover td.action-col {
} }
.detail-grid, .detail-grid,
.builder-layout { .builder-layout,
.agent-layout {
display: grid; display: grid;
flex: 1; flex: 1;
gap: 16px; gap: 16px;
@@ -836,6 +837,10 @@ tbody tr:hover td.action-col {
min-height: 0; min-height: 0;
} }
.agent-layout {
grid-template-columns: 380px minmax(0, 1fr);
}
.wide-panel { .wide-panel {
min-width: 0; min-width: 0;
} }
@@ -890,10 +895,35 @@ tbody tr:hover td.action-col {
} }
.builder-layout .panel .form-grid, .builder-layout .panel .form-grid,
.builder-layout .panel .form-stack { .builder-layout .panel .form-stack,
.agent-upload-form .form-grid {
padding: 16px; 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 { .checkbox {
accent-color: var(--primary); accent-color: var(--primary);
height: 16px; height: 16px;
@@ -1360,6 +1390,7 @@ tbody tr:hover td.action-col {
.dashboard-grid, .dashboard-grid,
.detail-grid, .detail-grid,
.builder-layout, .builder-layout,
.agent-layout,
.users-layout { .users-layout {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -1370,6 +1401,7 @@ tbody tr:hover td.action-col {
.detail-grid, .detail-grid,
.builder-layout, .builder-layout,
.agent-layout,
.users-layout { .users-layout {
overflow: auto; overflow: auto;
} }

View File

@@ -14,17 +14,24 @@ const notiflixVersion = require('notiflix/package.json').version;
const app = express(); const app = express();
const port = Number(process.env.PORT || 3000); const port = Number(process.env.PORT || 3000);
const uploadDir = path.join(__dirname, 'uploads', 'packages'); 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 authCookieName = 'robot_installer_session';
const sessionMaxAgeMs = Number(process.env.SESSION_MAX_AGE_MS || 1000 * 60 * 60 * 8); 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 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(uploadDir, { recursive: true });
fs.mkdirSync(agentPackageDir, { recursive: true });
const navItems = [ const navItems = [
{ id: 'dashboard', label: 'Tổng quan', href: '/', icon: 'dashboard' }, { id: 'dashboard', label: 'Tổng quan', href: '/', icon: 'dashboard' },
{ id: 'packages', label: 'Packages', href: '/packages', icon: 'inventory_2' }, { id: 'packages', label: 'Packages', href: '/packages', icon: 'inventory_2' },
{ id: 'applications', label: 'Applications', href: '/applications', icon: 'apps' }, { id: 'applications', label: 'Applications', href: '/applications', icon: 'apps' },
{ id: 'builder', label: 'Đóng gói App', href: '/builder', icon: 'deployed_code' }, { 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 } { 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({ const upload = multer({
storage, storage,
limits: { 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('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views')); app.set('views', path.join(__dirname, 'views'));
app.use(express.urlencoded({ extended: true })); app.use(express.urlencoded({ extended: true }));
app.use(express.static(path.join(__dirname, 'public'))); app.use(express.static(path.join(__dirname, 'public')));
app.use('/vendor/notiflix', express.static(path.join(__dirname, 'node_modules/notiflix/dist'))); 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); app.use(loadCurrentUser);
function helpers() { function helpers() {
@@ -281,6 +337,139 @@ function getBaseUrl(req) {
return `${protocol}://${req.get('host')}`; 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) { async function loadCurrentUser(req, res, next) {
try { try {
const cookies = parseCookies(req.headers.cookie); const cookies = parseCookies(req.headers.cookie);
@@ -652,9 +841,143 @@ app.post('/logout', (req, res) => {
redirectWithNotice(res, '/login', 'success', 'Đã đăng xuất.'); 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(requireAuthenticated);
app.use('/uploads', express.static(path.join(__dirname, 'uploads'))); 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) => { app.post('/profile', asyncRoute(async (req, res) => {
const returnTo = sanitizeReturnTo(req.body.returnTo); const returnTo = sanitizeReturnTo(req.body.returnTo);
const fullName = String(req.body.fullName || '').trim(); const fullName = String(req.body.fullName || '').trim();

View File

@@ -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) { async function getUserById(id) {
const pool = await getPool(); const pool = await getPool();
const result = await pool.request() const result = await pool.request()
@@ -806,6 +816,88 @@ async function getApplicationById(id) {
return application; 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() { async function getStats() {
const pool = await getPool(); const pool = await getPool();
const result = await pool.request().query(` const result = await pool.request().query(`
@@ -1216,6 +1308,7 @@ module.exports = {
getPageData, getPageData,
listPackages, listPackages,
listApplications, listApplications,
getApplicationManifest,
getPackageById, getPackageById,
getApplicationById, getApplicationById,
createPackageWithVersion, createPackageWithVersion,

157
web-server/views/agent.ejs Normal file
View 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_&lt;version&gt;_&lt;arch&gt;.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') %>