From 8ceb1bb1dfc8ad57557543a5c8a81bea478adcb0 Mon Sep 17 00:00:00 2001 From: DungTT Date: Tue, 26 May 2026 15:43:56 +0700 Subject: [PATCH] laster 0.0.2 --- agent/app/config.py | 3 +- agent/app/core/downloader.py | 36 ++- agent/app/core/installer.py | 40 ++- agent/app/core/manifest_client.py | 40 ++- agent/app/core/task_runner.py | 25 ++ agent/packaging/DEBIAN/control | 2 +- agent/scripts/build-deb.sh | 9 +- web-client/.env.example | 1 + web-client/Dockerfile | 2 + web-client/README.md | 1 + web-client/docker-compose.yml | 1 + web-client/package-lock.json | 4 +- web-client/package.json | 2 +- web-client/src/main.jsx | 67 ++++- web-client/src/services/api.js | 85 ++++++- web-server/.env.example | 1 + web-server/Dockerfile | 2 +- web-server/docker-compose.yml | 6 +- web-server/package-lock.json | 4 +- web-server/package.json | 2 +- web-server/public/css/styles.css | 14 +- web-server/server.js | 235 +++++++++++++++++- web-server/src/repository.js | 38 +++ .../views/partials/update-package-modal.ejs | 3 +- 24 files changed, 583 insertions(+), 40 deletions(-) diff --git a/agent/app/config.py b/agent/app/config.py index 36bd8c2..cb2a192 100644 --- a/agent/app/config.py +++ b/agent/app/config.py @@ -51,7 +51,7 @@ def _bool(name: str, default: bool) -> bool: 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"), + agent_version=os.getenv("AGENT_VERSION", "1.0.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, @@ -80,4 +80,3 @@ def get_settings() -> Settings: settings = get_settings() - diff --git a/agent/app/core/downloader.py b/agent/app/core/downloader.py index 6793ca9..8db9c0b 100644 --- a/agent/app/core/downloader.py +++ b/agent/app/core/downloader.py @@ -1,5 +1,6 @@ from __future__ import annotations +from email.message import Message import re from pathlib import Path from urllib.parse import unquote, urlparse @@ -14,10 +15,39 @@ 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: +def _sanitize_file_name(value: str, fallback: str = "package.deb") -> str: + name = SAFE_FILE_RE.sub("-", value).strip("-.") + return name or fallback + + +def _safe_url_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" + if name == "download": + parts = [part for part in parsed.path.split("/") if part] + name = "-".join(parts[-3:]) if len(parts) >= 3 else name + return _sanitize_file_name(name) + + +def _content_disposition_file_name(value: str) -> str: + if not value: + return "" + + message = Message() + message["content-disposition"] = value + return _sanitize_file_name(message.get_filename() or "", fallback="") + + +def _response_file_name(url: str, response: httpx.Response) -> str: + name = _content_disposition_file_name(response.headers.get("content-disposition", "")) + if not name: + name = _safe_url_file_name(str(response.url) or url) + + content_type = response.headers.get("content-type", "").split(";", 1)[0].strip().lower() + if content_type == "application/vnd.debian.binary-package" and not name.lower().endswith(".deb"): + name = f"{name}.deb" + + return name class Downloader: @@ -28,12 +58,12 @@ class Downloader: 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() self._validate_response(url, response) + destination = settings.cache_dir / _response_file_name(url, response) with destination.open("wb") as handle: for chunk in response.iter_bytes(): handle.write(chunk) diff --git a/agent/app/core/installer.py b/agent/app/core/installer.py index 132b4e9..410dcea 100644 --- a/agent/app/core/installer.py +++ b/agent/app/core/installer.py @@ -5,10 +5,49 @@ from pathlib import Path from app.core.command_runner import CommandRunner +def _parse_deb_control_output(output: str) -> dict[str, str]: + metadata: dict[str, str] = {} + + for line in output.splitlines(): + key, separator, value = line.partition(":") + if not separator: + continue + + normalized_key = key.strip().lower() + if normalized_key in {"package", "version", "architecture"}: + metadata[normalized_key] = value.strip() + + return metadata + + class DebInstaller: def __init__(self, command_runner: CommandRunner) -> None: self.command_runner = command_runner + def get_deb_metadata(self, file_path: Path) -> dict[str, str]: + result = self.command_runner.run([ + "dpkg-deb", + "-f", + str(file_path), + "Package", + "Version", + "Architecture", + ]) + metadata = _parse_deb_control_output(result.stdout) + missing_fields = [ + field + for field in ("package", "version", "architecture") + if not metadata.get(field) + ] + + if missing_fields: + raise ValueError( + "Downloaded .deb is missing metadata fields: " + f"{', '.join(missing_fields)}" + ) + + return metadata + def install_deb(self, file_path: Path) -> None: self.command_runner.run(["apt", "install", "-y", str(file_path)]) @@ -27,4 +66,3 @@ class DebInstaller: return True except Exception: return False - diff --git a/agent/app/core/manifest_client.py b/agent/app/core/manifest_client.py index 93a12ba..53a27c6 100644 --- a/agent/app/core/manifest_client.py +++ b/agent/app/core/manifest_client.py @@ -13,6 +13,44 @@ class ManifestClient: 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() + if response.is_error: + raise RuntimeError(_format_manifest_error(response)) return response.json() + +def _format_manifest_error(response: httpx.Response) -> str: + base_message = f"Manifest request failed with HTTP {response.status_code}: {response.url}" + + try: + payload = response.json() + except ValueError: + detail = response.text.strip() + return f"{base_message}. {detail}" if detail else base_message + + if not isinstance(payload, dict): + return base_message + + message_parts = [] + error = str(payload.get("error") or "").strip() + detail = str(payload.get("detail") or "").strip() + + if error: + message_parts.append(error) + if detail: + message_parts.append(detail) + + missing_files = payload.get("missingPackageFiles") + if isinstance(missing_files, list) and missing_files: + descriptions = [] + for item in missing_files: + if not isinstance(item, dict): + continue + + package_name = str(item.get("packageName") or item.get("componentId") or "package").strip() + version = str(item.get("version") or "").strip() + descriptions.append(f"{package_name} {version}".strip()) + + if descriptions: + message_parts.append(f"Missing package files: {', '.join(descriptions)}") + + return f"{base_message}. {' '.join(message_parts)}" if message_parts else base_message diff --git a/agent/app/core/task_runner.py b/agent/app/core/task_runner.py index 15028e6..6bef26c 100644 --- a/agent/app/core/task_runner.py +++ b/agent/app/core/task_runner.py @@ -205,6 +205,31 @@ class TaskRunner: ) self.repository.add_log(task_id, "info", f"Checksum verified for {component_id}") + self.repository.update_task_component(task_id, component_id, progress=50, current_step="validating package metadata") + deb_metadata = installer.get_deb_metadata(package_path) + expected_package_name = component["packageName"] + actual_package_name = deb_metadata["package"] + if actual_package_name != expected_package_name: + raise ValueError( + f"Package metadata mismatch for {component_id}: manifest packageName is " + f"{expected_package_name}, but .deb Package is {actual_package_name}. " + f"Create or update the package in the web server with Package code {actual_package_name}." + ) + + expected_version = component.get("version") or "" + actual_version = deb_metadata["version"] + if expected_version and actual_version != expected_version: + raise ValueError( + f"Package metadata mismatch for {component_id}: manifest version is " + f"{expected_version}, but .deb Version is {actual_version}." + ) + + self.repository.add_log( + task_id, + "info", + f"Package metadata verified for {actual_package_name} {actual_version}", + ) + self.repository.update_task_component(task_id, component_id, progress=60, current_step="installing package") installer.install_deb(package_path) diff --git a/agent/packaging/DEBIAN/control b/agent/packaging/DEBIAN/control index cb0b739..629baf4 100644 --- a/agent/packaging/DEBIAN/control +++ b/agent/packaging/DEBIAN/control @@ -1,5 +1,5 @@ Package: local-installer-agent -Version: 0.1.3 +Version: 1.0.0 Section: utils Priority: optional Architecture: amd64 diff --git a/agent/scripts/build-deb.sh b/agent/scripts/build-deb.sh index bd1103f..9924406 100644 --- a/agent/scripts/build-deb.sh +++ b/agent/scripts/build-deb.sh @@ -1,8 +1,9 @@ #!/usr/bin/env bash set -euo pipefail -VERSION="${VERSION:-0.1.3}" +VERSION="${VERSION:-1.0.0}" ARCH="${ARCH:-amd64}" +DEB_COMPRESSION="${DEB_COMPRESSION:-gzip}" PKG_NAME="local-installer-agent" BUILD_ROOT="${BUILD_ROOT:-build}" BUILD_DIR="${BUILD_ROOT}/${PKG_NAME}_${VERSION}_${ARCH}" @@ -28,6 +29,10 @@ 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__" -o -type f \( -name "*.pyc" -o -name "*.pyo" \) \) \ + -exec rm -rf {} + + cp packaging/systemd/local-installer-agent.service \ "${BUILD_DIR}/etc/systemd/system/local-installer-agent.service" @@ -64,7 +69,7 @@ ALLOW_DOCKER=false ALLOW_DOCKER_COMPOSE=false EOF -dpkg-deb --root-owner-group --build "${BUILD_DIR}" +dpkg-deb -Z"${DEB_COMPRESSION}" --root-owner-group --build "${BUILD_DIR}" echo "Built package:" echo "${OUTPUT_PACKAGE}" diff --git a/web-client/.env.example b/web-client/.env.example index dafcb55..3cfd77f 100644 --- a/web-client/.env.example +++ b/web-client/.env.example @@ -8,3 +8,4 @@ PACKAGE_PROXY_TARGET=http://robot-installer-web-server:3000 VITE_PACKAGE_BASE_URL= VITE_AGENT_BASE_URL=http://127.0.0.1:5010 +VITE_APP_OPEN_URL=http://127.0.0.1 diff --git a/web-client/Dockerfile b/web-client/Dockerfile index e5902c7..0d004c8 100644 --- a/web-client/Dockerfile +++ b/web-client/Dockerfile @@ -9,9 +9,11 @@ COPY . . ARG VITE_PACKAGE_BASE_URL= ARG VITE_AGENT_BASE_URL=http://127.0.0.1:5010 +ARG VITE_APP_OPEN_URL=http://127.0.0.1 RUN VITE_PACKAGE_BASE_URL="${VITE_PACKAGE_BASE_URL}" \ VITE_AGENT_BASE_URL="${VITE_AGENT_BASE_URL}" \ + VITE_APP_OPEN_URL="${VITE_APP_OPEN_URL}" \ npm run build FROM nginx:1.27-alpine AS runtime diff --git a/web-client/README.md b/web-client/README.md index 5d14935..aa62c17 100644 --- a/web-client/README.md +++ b/web-client/README.md @@ -21,6 +21,7 @@ Có thể đổi trong UI hoặc qua `.env`: ```env VITE_PACKAGE_BASE_URL= VITE_AGENT_BASE_URL=http://127.0.0.1:5010 +VITE_APP_OPEN_URL=http://127.0.0.1 PACKAGE_PROXY_TARGET=http://localhost:3000 ``` diff --git a/web-client/docker-compose.yml b/web-client/docker-compose.yml index 493cb9f..0d4d8d6 100644 --- a/web-client/docker-compose.yml +++ b/web-client/docker-compose.yml @@ -6,6 +6,7 @@ services: args: VITE_PACKAGE_BASE_URL: ${VITE_PACKAGE_BASE_URL:-} VITE_AGENT_BASE_URL: ${VITE_AGENT_BASE_URL:-http://127.0.0.1:5010} + VITE_APP_OPEN_URL: ${VITE_APP_OPEN_URL:-http://127.0.0.1} container_name: ${WEB_CLIENT_CONTAINER_NAME:-robot-installer-web-client} environment: PACKAGE_PROXY_TARGET: ${PACKAGE_PROXY_TARGET:-http://robot-installer-web-server:3000} diff --git a/web-client/package-lock.json b/web-client/package-lock.json index 7b48d54..e3161e8 100644 --- a/web-client/package-lock.json +++ b/web-client/package-lock.json @@ -1,12 +1,12 @@ { "name": "robot-installer-web-client", - "version": "0.1.0", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "robot-installer-web-client", - "version": "0.1.0", + "version": "1.0.0", "dependencies": { "@vitejs/plugin-react": "^5.0.0", "lucide-react": "^0.468.0", diff --git a/web-client/package.json b/web-client/package.json index 53500c3..45eba9f 100644 --- a/web-client/package.json +++ b/web-client/package.json @@ -1,6 +1,6 @@ { "name": "robot-installer-web-client", - "version": "0.1.0", + "version": "1.0.0", "private": true, "type": "module", "description": "Public web client for installing Robot applications through the Local Installer Agent.", diff --git a/web-client/src/main.jsx b/web-client/src/main.jsx index eacd93a..b82ba4a 100644 --- a/web-client/src/main.jsx +++ b/web-client/src/main.jsx @@ -37,6 +37,7 @@ import { fetchTaskComponents, fetchTaskLogs, fetchTaskStatus, + getAppOpenUrl, joinUrl, normalizeUrl, queueInstall, @@ -353,6 +354,34 @@ function App() { }); }, []); + const startPreflightFailedTask = useCallback((action, app, message) => { + const failedAt = new Date().toISOString(); + const taskId = `preflight_${Date.now()}`; + + setActiveTask({ + taskId, + action, + appId: app.appId, + appName: app.appName, + status: 'failed', + progress: 5, + currentStep: 'package preflight failed', + errorMessage: message, + logs: [ + { + time: failedAt, + level: 'error', + message + } + ], + components: [], + pollError: '', + localOnly: true, + queuedAt: failedAt, + finishedAt: failedAt + }); + }, []); + const runAppAction = useCallback(async (action, app) => { if (!agentHealth) { notify('warning', 'Agent local đang offline. Cài Agent rồi bấm Retry.'); @@ -368,8 +397,14 @@ function App() { try { let queuedTask; if (action === 'install') { + const manifest = await fetchApplicationManifest(packageBaseUrl, app.appId, app.version); + setSelectedManifest(manifest); + setDetailStatus({ state: 'success', message: 'Manifest ready' }); queuedTask = await queueInstall(agentBaseUrl, app); } else if (action === 'update') { + const manifest = await fetchApplicationManifest(packageBaseUrl, app.appId, app.version); + setSelectedManifest(manifest); + setDetailStatus({ state: 'success', message: 'Manifest ready' }); queuedTask = await queueUpdate(agentBaseUrl, app, app.installed); } else { queuedTask = await queueRemove(agentBaseUrl, app); @@ -378,11 +413,17 @@ function App() { startTask(queuedTask, action, app); notify('success', `Đã queue task ${queuedTask.taskId}`); } catch (error) { - notify('failure', getErrorMessage(error)); + const message = getErrorMessage(error); + if (action === 'install' || action === 'update') { + setSelectedManifest(null); + setDetailStatus({ state: 'danger', message }); + startPreflightFailedTask(action, app, message); + } + notify('failure', message); } finally { setBusyAction(''); } - }, [agentBaseUrl, agentHealth, notify, startTask]); + }, [agentBaseUrl, agentHealth, notify, packageBaseUrl, startPreflightFailedTask, startTask]); const applySettings = useCallback(() => { const nextSettings = { @@ -650,6 +691,9 @@ function App() { const installBusy = busyAction === `install:${app.appId}` || (rowTaskBusy && rowTask.action === 'install'); const updateBusy = busyAction === `update:${app.appId}` || (rowTaskBusy && rowTask.action === 'update'); const removeBusy = busyAction === `remove:${app.appId}` || (rowTaskBusy && rowTask.action === 'remove'); + const openUrl = app.installed + ? getAppOpenUrl({ ...app, openUrl: app.installed.openUrl || app.openUrl }) + : ''; return ( )} + {app.installed && openUrl && ( + event.stopPropagation()} + > + + )} {app.installed && ( {canClear && ( @@ -1001,6 +1059,9 @@ function AppDetailPanel({ app, detail, manifest, status, packageBaseUrl }) {