laster 0.0.2
This commit is contained in:
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
Package: local-installer-agent
|
||||
Version: 0.1.3
|
||||
Version: 1.0.0
|
||||
Section: utils
|
||||
Priority: optional
|
||||
Architecture: amd64
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
4
web-client/package-lock.json
generated
4
web-client/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 (
|
||||
<tr
|
||||
@@ -700,6 +744,19 @@ function App() {
|
||||
Update
|
||||
</button>
|
||||
)}
|
||||
{app.installed && openUrl && (
|
||||
<a
|
||||
className="btn btn-secondary compact"
|
||||
href={openUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title={`Open ${app.appName}`}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<ExternalLink size={14} aria-hidden="true" />
|
||||
Open App
|
||||
</a>
|
||||
)}
|
||||
{app.installed && (
|
||||
<button
|
||||
className="icon-button danger"
|
||||
@@ -825,6 +882,7 @@ function TaskPanel({ task, onClear, onRefresh }) {
|
||||
const logs = task.logs || [];
|
||||
const visibleLogs = logs.slice(-6);
|
||||
const canClear = TERMINAL_TASK_STATUSES.has(task.status);
|
||||
const canRefresh = !task.localOnly;
|
||||
|
||||
return (
|
||||
<section className="panel task-panel">
|
||||
@@ -835,7 +893,7 @@ function TaskPanel({ task, onClear, onRefresh }) {
|
||||
</div>
|
||||
<div className="panel-actions">
|
||||
<span className={statusBadgeClass(task.status)}>{task.status || 'queued'}</span>
|
||||
<button className="icon-button subtle" type="button" title="Refresh task" onClick={onRefresh}>
|
||||
<button className="icon-button subtle" type="button" title="Refresh task" onClick={onRefresh} disabled={!canRefresh}>
|
||||
<RefreshCcw size={16} aria-hidden="true" />
|
||||
</button>
|
||||
{canClear && (
|
||||
@@ -1001,6 +1059,9 @@ function AppDetailPanel({ app, detail, manifest, status, packageBaseUrl }) {
|
||||
<ShieldCheck size={15} aria-hidden="true" />
|
||||
Components
|
||||
</div>
|
||||
{status.state === 'danger' && (
|
||||
<div className="table-empty compact-empty danger-text">{status.message}</div>
|
||||
)}
|
||||
{(components.length ? components : packages).slice(0, 5).map((item) => (
|
||||
<div className="component-item" key={item.componentId || item.id || item.packageId}>
|
||||
<div>
|
||||
|
||||
@@ -4,6 +4,9 @@ export const DEFAULT_PACKAGE_BASE_URL = normalizeUrl(
|
||||
export const DEFAULT_AGENT_BASE_URL = normalizeUrl(
|
||||
import.meta.env.VITE_AGENT_BASE_URL || 'http://127.0.0.1:5010'
|
||||
);
|
||||
export const DEFAULT_APP_OPEN_URL = normalizeUrl(
|
||||
import.meta.env.VITE_APP_OPEN_URL || 'http://127.0.0.1'
|
||||
);
|
||||
|
||||
export function normalizeUrl(value) {
|
||||
const text = String(value || '').trim();
|
||||
@@ -16,6 +19,30 @@ export function joinUrl(baseUrl, path) {
|
||||
return `${normalizedBaseUrl}${normalizedPath}`;
|
||||
}
|
||||
|
||||
function normalizeOpenUrl(value) {
|
||||
const text = normalizeUrl(value);
|
||||
if (!text) return '';
|
||||
|
||||
try {
|
||||
const parsed = new URL(text);
|
||||
return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? text : '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export function getAppOpenUrl(app) {
|
||||
return normalizeOpenUrl(
|
||||
app?.openUrl
|
||||
|| app?.open_url
|
||||
|| app?.webUrl
|
||||
|| app?.web_url
|
||||
|| app?.homepageUrl
|
||||
|| app?.homepage_url
|
||||
|| app?.homepage
|
||||
) || normalizeOpenUrl(DEFAULT_APP_OPEN_URL);
|
||||
}
|
||||
|
||||
async function requestJson(baseUrl, path, options = {}) {
|
||||
const {
|
||||
timeoutMs = 8000,
|
||||
@@ -50,8 +77,7 @@ async function requestJson(baseUrl, path, options = {}) {
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = payload?.detail || payload?.error || payload || response.statusText;
|
||||
throw new Error(`${response.status} ${formatErrorDetail(detail)}`);
|
||||
throw new Error(`${response.status} ${formatErrorDetail(payload || response.statusText)}`);
|
||||
}
|
||||
|
||||
return payload;
|
||||
@@ -75,7 +101,27 @@ function formatErrorDetail(detail) {
|
||||
|
||||
if (detail && typeof detail === 'object') {
|
||||
const location = Array.isArray(detail.loc) ? detail.loc.join('.') : '';
|
||||
const message = detail.msg || detail.message || detail.detail || detail.error;
|
||||
const messageParts = [
|
||||
detail.msg,
|
||||
detail.message,
|
||||
detail.error,
|
||||
detail.detail
|
||||
]
|
||||
.map((item) => String(item || '').trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (Array.isArray(detail.missingPackageFiles) && detail.missingPackageFiles.length > 0) {
|
||||
const missingFiles = detail.missingPackageFiles
|
||||
.map(formatMissingPackageFile)
|
||||
.filter(Boolean)
|
||||
.join('; ');
|
||||
|
||||
if (missingFiles) {
|
||||
messageParts.push(`Missing package files: ${missingFiles}`);
|
||||
}
|
||||
}
|
||||
|
||||
const message = [...new Set(messageParts)].join('. ');
|
||||
|
||||
if (message) {
|
||||
return location ? `${location}: ${message}` : String(message);
|
||||
@@ -91,6 +137,17 @@ function formatErrorDetail(detail) {
|
||||
return String(detail || 'Request failed');
|
||||
}
|
||||
|
||||
function formatMissingPackageFile(item) {
|
||||
if (!item || typeof item !== 'object') return String(item || '').trim();
|
||||
|
||||
const packageName = String(item.packageName || item.componentId || 'package').trim();
|
||||
const version = String(item.version || '').trim();
|
||||
const downloadUrl = String(item.downloadUrl || '').trim();
|
||||
const label = [packageName, version].filter(Boolean).join(' ');
|
||||
|
||||
return downloadUrl ? `${label} (${downloadUrl})` : label;
|
||||
}
|
||||
|
||||
export async function fetchPackageApps(packageBaseUrl) {
|
||||
const payload = await requestJson(packageBaseUrl, '/api/apps', { timeoutMs: 10000 });
|
||||
return Array.isArray(payload?.apps) ? payload.apps.map(normalizePackageApp) : [];
|
||||
@@ -185,7 +242,16 @@ function normalizePackageApp(app) {
|
||||
appName: String(app.appName || app.app_name || app.name || '').trim(),
|
||||
version: String(app.version || '').trim(),
|
||||
status: String(app.status || 'Released').trim(),
|
||||
packageCount: Number(app.packageCount || app.package_count || 0)
|
||||
packageCount: Number(app.packageCount || app.package_count || 0),
|
||||
openUrl: normalizeOpenUrl(
|
||||
app.openUrl
|
||||
|| app.open_url
|
||||
|| app.webUrl
|
||||
|| app.web_url
|
||||
|| app.homepageUrl
|
||||
|| app.homepage_url
|
||||
|| app.homepage
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -196,7 +262,16 @@ function normalizeInstalledApp(app) {
|
||||
version: String(app.installedVersion || app.version || app.package_version || '').trim(),
|
||||
status: String(app.status || 'installed').trim(),
|
||||
installedAt: app.installedAt || app.installed_at || '',
|
||||
updatedAt: app.updatedAt || app.updated_at || ''
|
||||
updatedAt: app.updatedAt || app.updated_at || '',
|
||||
openUrl: normalizeOpenUrl(
|
||||
app.openUrl
|
||||
|| app.open_url
|
||||
|| app.webUrl
|
||||
|| app.web_url
|
||||
|| app.homepageUrl
|
||||
|| app.homepage_url
|
||||
|| app.homepage
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ WEB_SERVER_CONTAINER_NAME=robot-installer-web-server
|
||||
WEB_SERVER_PORT=3000
|
||||
IMAGE_TAG=1.0.1
|
||||
DOCKER_NETWORK=robot-installer-net
|
||||
WEB_SERVER_UPLOADS_DIR=./uploads
|
||||
SQLSERVER_HOST=172.20.235.176
|
||||
SQLSERVER_PORT=1433
|
||||
SQLSERVER_DATABASE=RobotInstaller
|
||||
|
||||
@@ -12,7 +12,7 @@ ENV PORT=3000
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache su-exec
|
||||
RUN apk add --no-cache bzip2 dpkg su-exec xz zstd
|
||||
|
||||
COPY --from=dependencies /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
@@ -15,7 +15,7 @@ services:
|
||||
ports:
|
||||
- "${WEB_SERVER_PORT:-3000}:3000"
|
||||
volumes:
|
||||
- web_server_uploads:/app/uploads
|
||||
- ${WEB_SERVER_UPLOADS_DIR:-./uploads}:/app/uploads
|
||||
networks:
|
||||
- robot-installer
|
||||
restart: unless-stopped
|
||||
@@ -24,7 +24,3 @@ networks:
|
||||
robot-installer:
|
||||
name: ${DOCKER_NETWORK:-robot-installer-net}
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
web_server_uploads:
|
||||
name: ${WEB_SERVER_UPLOADS_VOLUME:-robot-installer-web-server-uploads}
|
||||
|
||||
4
web-server/package-lock.json
generated
4
web-server/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "robot-installer-web-server",
|
||||
"version": "0.1.0",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "robot-installer-web-server",
|
||||
"version": "0.1.0",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"dotenv": "^17.4.2",
|
||||
"ejs": "^3.1.10",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "robot-installer-web-server",
|
||||
"version": "0.1.0",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Robot Installer package management web server UI",
|
||||
"main": "server.js",
|
||||
|
||||
@@ -1044,6 +1044,8 @@ tbody tr:hover td.action-col {
|
||||
border: 1px solid rgba(226, 232, 240, 0.9);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-lg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: min(86vh, 720px);
|
||||
overflow: hidden;
|
||||
transform: scale(0.97);
|
||||
@@ -1064,6 +1066,7 @@ tbody tr:hover td.action-col {
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid #eef2f7;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
justify-content: space-between;
|
||||
padding: 14px 18px;
|
||||
}
|
||||
@@ -1075,16 +1078,23 @@ tbody tr:hover td.action-col {
|
||||
}
|
||||
|
||||
.modal-form {
|
||||
max-height: calc(86vh - 60px);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 18px;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.88), #ffffff 30%);
|
||||
border-top: 1px solid #eef2f7;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
padding-top: 18px;
|
||||
margin: 18px -18px -18px;
|
||||
padding: 14px 18px 18px;
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.modal-actions .btn {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
require('dotenv').config({ quiet: true });
|
||||
|
||||
const crypto = require('crypto');
|
||||
const { execFile } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const fsp = require('fs/promises');
|
||||
const path = require('path');
|
||||
@@ -15,6 +16,7 @@ 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 agentDebianPackageName = 'local-installer-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';
|
||||
@@ -32,6 +34,7 @@ const agentVersionCollator = new Intl.Collator('en', {
|
||||
numeric: true,
|
||||
sensitivity: 'base'
|
||||
});
|
||||
let didWarnAgentMetadataToolMissing = false;
|
||||
|
||||
app.get('/healthz', (req, res) => {
|
||||
res.status(200).json({ status: 'ok' });
|
||||
@@ -598,13 +601,112 @@ function getAgentPackageDownloadPath(fileName) {
|
||||
return `/packages/agent/${encodeURIComponent(fileName)}`;
|
||||
}
|
||||
|
||||
async function getAgentPackageFromEntry(entry) {
|
||||
const match = /^local-installer-agent_([^/\\]+)_([^/\\]+)\.deb$/.exec(entry.name);
|
||||
function parseAgentPackageFileName(fileName) {
|
||||
const match = /^local-installer-agent_([^/\\]+)_([^/\\]+)\.deb$/.exec(fileName);
|
||||
if (!match) return null;
|
||||
|
||||
const [, version, packageArch] = match;
|
||||
return {
|
||||
version: match[1],
|
||||
arch: match[2]
|
||||
};
|
||||
}
|
||||
|
||||
function parseDebControlOutput(output) {
|
||||
return String(output || '')
|
||||
.split(/\r?\n/)
|
||||
.reduce((metadata, line) => {
|
||||
const match = /^([^:]+):\s*(.*)$/.exec(line);
|
||||
if (!match) return metadata;
|
||||
|
||||
const key = match[1].trim().toLowerCase();
|
||||
const value = match[2].trim();
|
||||
|
||||
if (key === 'package') metadata.package = value;
|
||||
if (key === 'version') metadata.version = value;
|
||||
if (key === 'architecture') metadata.architecture = value;
|
||||
|
||||
return metadata;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function isDebMetadataInspectionUnavailable(error) {
|
||||
const message = String(error?.message || '');
|
||||
|
||||
return error?.code === 'ENOENT'
|
||||
|| /unable to execute decompressing archive/i.test(message)
|
||||
|| /member 'control\.tar' \([^)]+\): No such file or directory/i.test(message);
|
||||
}
|
||||
|
||||
async function readDebPackageMetadata(filePath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(
|
||||
'dpkg-deb',
|
||||
['-f', filePath, 'Package', 'Version', 'Architecture'],
|
||||
{ windowsHide: true, timeout: 10000, maxBuffer: 1024 * 1024 },
|
||||
(error, stdout) => {
|
||||
if (error) {
|
||||
if (isDebMetadataInspectionUnavailable(error)) error.metadataToolMissing = true;
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
const metadata = parseDebControlOutput(stdout);
|
||||
if (!metadata.package || !metadata.version || !metadata.architecture) {
|
||||
reject(new Error('Missing Package, Version, or Architecture in deb control metadata.'));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(metadata);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function readDebPackageMetadataIfAvailable(filePath) {
|
||||
try {
|
||||
return await readDebPackageMetadata(filePath);
|
||||
} catch (error) {
|
||||
if (error.metadataToolMissing) {
|
||||
if (!didWarnAgentMetadataToolMissing) {
|
||||
console.warn('dpkg-deb metadata inspection is not available; using agent package filename metadata.');
|
||||
didWarnAgentMetadataToolMissing = true;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
console.warn(`Cannot inspect agent package metadata for ${path.basename(filePath)}:`, error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function getAgentPackageFromEntry(entry) {
|
||||
const fileNameMetadata = parseAgentPackageFileName(entry.name);
|
||||
if (!fileNameMetadata) return null;
|
||||
|
||||
const filePath = path.join(agentPackageDir, entry.name);
|
||||
const stat = await fsp.stat(filePath);
|
||||
const debMetadata = await readDebPackageMetadataIfAvailable(filePath);
|
||||
|
||||
if (debMetadata?.package && debMetadata.package !== agentDebianPackageName) {
|
||||
console.warn(`Ignoring ${entry.name}: deb package is ${debMetadata.package}, expected ${agentDebianPackageName}.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (debMetadata) {
|
||||
const metadataArch = normalizeAgentArch(debMetadata.architecture);
|
||||
const fileNameArch = normalizeAgentArch(fileNameMetadata.arch);
|
||||
|
||||
if (debMetadata.version !== fileNameMetadata.version || metadataArch !== fileNameArch) {
|
||||
console.warn(
|
||||
`Ignoring ${entry.name}: filename metadata does not match deb metadata ` +
|
||||
`(deb version=${debMetadata.version}, deb arch=${debMetadata.architecture}).`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const version = fileNameMetadata.version;
|
||||
const packageArch = fileNameMetadata.arch;
|
||||
|
||||
return {
|
||||
fileName: entry.name,
|
||||
@@ -824,6 +926,35 @@ async function getArtifactFromUpload(file) {
|
||||
};
|
||||
}
|
||||
|
||||
async function getDebUploadMetadataValidationMessage(file, packageCode, version) {
|
||||
if (!file || path.extname(file.originalname).toLowerCase() !== '.deb') return null;
|
||||
|
||||
let debMetadata = null;
|
||||
try {
|
||||
debMetadata = await readDebPackageMetadata(file.path);
|
||||
} catch (error) {
|
||||
if (error.metadataToolMissing) {
|
||||
console.warn('dpkg-deb metadata inspection is not available; skipping package metadata validation.');
|
||||
return null;
|
||||
}
|
||||
|
||||
return `Khong doc duoc metadata trong file .deb: ${error.message}`;
|
||||
}
|
||||
|
||||
const metadataErrors = [];
|
||||
if (debMetadata.package !== packageCode) {
|
||||
metadataErrors.push(`package=${debMetadata.package}`);
|
||||
}
|
||||
|
||||
if (debMetadata.version !== version) {
|
||||
metadataErrors.push(`version=${debMetadata.version}`);
|
||||
}
|
||||
|
||||
return metadataErrors.length > 0
|
||||
? `File .deb khong khop thong tin package (${metadataErrors.join(', ')}). Package code va version tren web phai khop Package/Version trong file .deb.`
|
||||
: null;
|
||||
}
|
||||
|
||||
async function listMissingManifestPackageFiles(manifest) {
|
||||
const missingPackageFiles = [];
|
||||
|
||||
@@ -849,6 +980,7 @@ async function listMissingManifestPackageFiles(manifest) {
|
||||
componentId: component.componentId,
|
||||
packageName: component.packageName,
|
||||
version: component.version,
|
||||
filePath: packageVersion?.filePath || '',
|
||||
downloadUrl: component.downloadUrl
|
||||
});
|
||||
}
|
||||
@@ -1233,7 +1365,7 @@ app.get('/api/apps/:appCode/versions/:version/manifest', asyncRoute(async (req,
|
||||
if (missingPackageFiles.length > 0) {
|
||||
res.status(409).json({
|
||||
error: 'Package file is missing on the package server',
|
||||
detail: 'Upload the package file to this web-server storage or re-upload the package version.',
|
||||
detail: 'Upload the package file to this web-server storage or re-upload the same package version from the Packages page.',
|
||||
missingPackageFiles
|
||||
});
|
||||
return;
|
||||
@@ -1343,6 +1475,47 @@ app.post('/agent/packages', requireAdmin, agentUpload.single('agentFile'), async
|
||||
return;
|
||||
}
|
||||
|
||||
let debMetadata = null;
|
||||
try {
|
||||
debMetadata = await readDebPackageMetadata(req.file.path);
|
||||
} catch (error) {
|
||||
if (!error.metadataToolMissing) {
|
||||
await removeUploadedFile(req.file);
|
||||
redirectWithNotice(res, '/agent', 'warning', `Khong doc duoc metadata trong file .deb: ${error.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.warn('dpkg-deb metadata inspection is not available; skipping uploaded agent package metadata validation.');
|
||||
}
|
||||
|
||||
if (debMetadata) {
|
||||
const metadataArch = normalizeAgentArch(debMetadata.architecture);
|
||||
const metadataErrors = [];
|
||||
|
||||
if (debMetadata.package !== agentDebianPackageName) {
|
||||
metadataErrors.push(`package=${debMetadata.package}`);
|
||||
}
|
||||
|
||||
if (debMetadata.version !== version) {
|
||||
metadataErrors.push(`version=${debMetadata.version}`);
|
||||
}
|
||||
|
||||
if (metadataArch !== arch) {
|
||||
metadataErrors.push(`arch=${debMetadata.architecture}`);
|
||||
}
|
||||
|
||||
if (metadataErrors.length > 0) {
|
||||
await removeUploadedFile(req.file);
|
||||
redirectWithNotice(
|
||||
res,
|
||||
'/agent',
|
||||
'warning',
|
||||
`File .deb khong khop thong tin upload (${metadataErrors.join(', ')}).`
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const targetFileName = `local-installer-agent_${version}_${arch}.deb`;
|
||||
const targetPath = path.join(agentPackageDir, targetFileName);
|
||||
const isUpdate = fs.existsSync(targetPath);
|
||||
@@ -1488,6 +1661,15 @@ app.post('/packages', upload.single('packageFile'), asyncRoute(async (req, res)
|
||||
return;
|
||||
}
|
||||
|
||||
if (packageType === 'deb') {
|
||||
const metadataMessage = await getDebUploadMetadataValidationMessage(req.file, packageCode, version);
|
||||
if (metadataMessage) {
|
||||
await removeUploadedFile(req.file);
|
||||
redirectWithNotice(res, '/packages', 'warning', metadataMessage);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await repository.createPackageWithVersion({
|
||||
packageCode,
|
||||
packageName,
|
||||
@@ -1516,6 +1698,8 @@ app.post('/packages', upload.single('packageFile'), asyncRoute(async (req, res)
|
||||
}));
|
||||
|
||||
app.post('/package-versions', upload.single('packageFile'), asyncRoute(async (req, res) => {
|
||||
let versionInput = null;
|
||||
|
||||
try {
|
||||
const artifact = await getArtifactFromUpload(req.file);
|
||||
const version = String(req.body.version || '').trim();
|
||||
@@ -1532,7 +1716,23 @@ app.post('/package-versions', upload.single('packageFile'), asyncRoute(async (re
|
||||
return;
|
||||
}
|
||||
|
||||
await repository.addPackageVersion({
|
||||
const packageItem = await repository.getPackageById(req.body.packageId);
|
||||
if (!packageItem) {
|
||||
await removeUploadedFile(req.file);
|
||||
redirectWithNotice(res, '/packages', 'warning', 'Khong tim thay package can cap nhat.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (packageItem.type === 'deb') {
|
||||
const metadataMessage = await getDebUploadMetadataValidationMessage(req.file, packageItem.code, version);
|
||||
if (metadataMessage) {
|
||||
await removeUploadedFile(req.file);
|
||||
redirectWithNotice(res, `/packages/${req.body.packageId}`, 'warning', metadataMessage);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
versionInput = {
|
||||
packageId: req.body.packageId,
|
||||
version,
|
||||
releaseDate: req.body.releaseDate,
|
||||
@@ -1541,12 +1741,33 @@ app.post('/package-versions', upload.single('packageFile'), asyncRoute(async (re
|
||||
fileSizeBytes: artifact.fileSizeBytes,
|
||||
checksum: artifact.checksum,
|
||||
changeLog: req.body.changeLog
|
||||
});
|
||||
};
|
||||
|
||||
await repository.addPackageVersion(versionInput);
|
||||
|
||||
redirectWithNotice(res, `/packages/${req.body.packageId}`, 'success', 'Đã cập nhật version mới và đặt làm latest.');
|
||||
} catch (error) {
|
||||
await removeUploadedFile(req.file);
|
||||
if (error.code === 'DUPLICATE_PACKAGE') {
|
||||
if (versionInput && (versionInput.filePath || versionInput.dockerImage)) {
|
||||
try {
|
||||
const replacedVersionId = await repository.replacePackageVersionArtifact(versionInput);
|
||||
|
||||
if (replacedVersionId) {
|
||||
redirectWithNotice(
|
||||
res,
|
||||
`/packages/${versionInput.packageId}`,
|
||||
'success',
|
||||
'Da re-upload package version hien co va cap nhat artifact.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
} catch (replaceError) {
|
||||
await removeUploadedFile(req.file);
|
||||
throw replaceError;
|
||||
}
|
||||
}
|
||||
|
||||
await removeUploadedFile(req.file);
|
||||
redirectWithNotice(res, `/packages/${req.body.packageId || ''}`, 'warning', 'Version này đã tồn tại trong package.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1096,6 +1096,43 @@ async function addPackageVersion(input) {
|
||||
return versionId;
|
||||
}
|
||||
|
||||
async function replacePackageVersionArtifact(input) {
|
||||
const pool = await getPool();
|
||||
const result = await pool.request()
|
||||
.input('PackageId', sql.UniqueIdentifier, input.packageId)
|
||||
.input('Version', sql.NVarChar(50), input.version)
|
||||
.input('FilePath', sql.NVarChar(1000), input.filePath || null)
|
||||
.input('DockerImage', sql.NVarChar(500), input.dockerImage || null)
|
||||
.input('FileChecksumSha256', sql.Char(64), input.checksum || null)
|
||||
.input('FileSizeBytes', sql.BigInt, input.fileSizeBytes || null)
|
||||
.input('ChangeLog', sql.NVarChar(sql.MAX), input.changeLog || null)
|
||||
.input('ReleaseDate', sql.DateTime2, input.releaseDate ? new Date(input.releaseDate) : new Date())
|
||||
.query(`
|
||||
UPDATE dbo.PackageVersions
|
||||
SET FilePath = @FilePath,
|
||||
DockerImage = @DockerImage,
|
||||
FileChecksumSha256 = @FileChecksumSha256,
|
||||
FileSizeBytes = @FileSizeBytes,
|
||||
ChangeLog = COALESCE(@ChangeLog, ChangeLog),
|
||||
ReleaseDate = @ReleaseDate,
|
||||
UploadedAt = SYSUTCDATETIME(),
|
||||
IsDeprecated = 0
|
||||
OUTPUT inserted.Id
|
||||
WHERE PackageId = @PackageId
|
||||
AND Version = @Version;
|
||||
`);
|
||||
|
||||
const row = result.recordset[0];
|
||||
if (!row) return null;
|
||||
|
||||
const versionId = String(row.Id);
|
||||
await pool.request()
|
||||
.input('PackageVersionId', sql.UniqueIdentifier, versionId)
|
||||
.execute('dbo.SetLatestPackageVersion');
|
||||
|
||||
return versionId;
|
||||
}
|
||||
|
||||
async function deletePackage(packageId) {
|
||||
const pool = await getPool();
|
||||
const transaction = new sql.Transaction(pool);
|
||||
@@ -1369,6 +1406,7 @@ module.exports = {
|
||||
getApplicationById,
|
||||
createPackageWithVersion,
|
||||
addPackageVersion,
|
||||
replacePackageVersionArtifact,
|
||||
deletePackage,
|
||||
setLatestPackageVersion,
|
||||
deletePackageVersion,
|
||||
|
||||
@@ -17,8 +17,9 @@
|
||||
</select>
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>New version</span>
|
||||
<span>Version</span>
|
||||
<input type="text" name="version" placeholder="2.5.0" pattern="[A-Za-z0-9._:+~=-]+" title="Only letters, numbers and . _ : + ~ = - characters." required>
|
||||
<small>Nhap version moi de them, hoac version da co de re-upload artifact.</small>
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>Release date</span>
|
||||
|
||||
Reference in New Issue
Block a user