laster 0.0.2
This commit is contained in:
@@ -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