laster 0.0.2

This commit is contained in:
2026-05-26 15:43:56 +07:00
parent e2c4881bb7
commit 8ceb1bb1df
24 changed files with 583 additions and 40 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
Package: local-installer-agent
Version: 0.1.3
Version: 1.0.0
Section: utils
Priority: optional
Architecture: amd64

View File

@@ -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}"

View File

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

View File

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

View File

@@ -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
```

View File

@@ -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}

View File

@@ -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",

View File

@@ -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.",

View File

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

View File

@@ -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
)
};
}

View File

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

View File

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

View File

@@ -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}

View File

@@ -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",

View File

@@ -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",

View File

@@ -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 {

View File

@@ -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;
}

View File

@@ -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,

View File

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