Update
This commit is contained in:
+58
-12
@@ -4,10 +4,10 @@ const fs = require('node:fs/promises');
|
||||
const path = require('node:path');
|
||||
const crypto = require('node:crypto');
|
||||
const { safeStorage } = require('electron');
|
||||
const { assertHttpUrl, assertWorkflowFileName, assertBranchName, assertEnvironmentName, assertCloneRemote, assertRepositoryRelativePaths } = require('../shared/validation.cjs');
|
||||
const { assertHttpUrl, assertWorkflowFileName, assertBranchName, assertEnvironmentName, assertCloneRemote, assertRepositoryRelativePath, assertRepositoryRelativePaths } = require('../shared/validation.cjs');
|
||||
|
||||
const DEFAULT_CONFIG = {
|
||||
schemaVersion: 8,
|
||||
schemaVersion: 9,
|
||||
setupComplete: false,
|
||||
appearance: 'dark',
|
||||
gitea: { baseUrl: '', user: null, encryptedToken: null },
|
||||
@@ -76,7 +76,28 @@ class ConfigStore {
|
||||
: iconFilePath ? 'upload' : iconUrl && !/itworx\.tech\/assets\/itworx-icon\.png/i.test(iconUrl) ? 'url' : 'builtin';
|
||||
const visibleName = String(profile.containerName || profile.remoteFolder || '').trim();
|
||||
const internalService = String(profile.composeService || profile.remoteFolder || 'app').trim().toLowerCase().replace(/[^a-z0-9._-]/g, '-') || 'app';
|
||||
return { ...profile, composeService: internalService, containerName: visibleName || internalService, iconMode };
|
||||
const requestedDeploymentMode = String(profile.deploymentMode || '').trim();
|
||||
const deploymentMode = ['push-bundle', 'server-git', 'monitor-only'].includes(requestedDeploymentMode)
|
||||
? requestedDeploymentMode
|
||||
: 'server-git';
|
||||
const composeFiles = uniqueStrings(profile.composeFiles || [profile.composeFile || 'docker-compose.yml']);
|
||||
const composeServices = uniqueStrings(profile.composeServices || [internalService]).map((value) => value.toLowerCase());
|
||||
return {
|
||||
...profile,
|
||||
deploymentMode,
|
||||
composeFile: composeFiles[0] || 'docker-compose.yml',
|
||||
composeFiles: composeFiles.length ? composeFiles : ['docker-compose.yml'],
|
||||
composeServices,
|
||||
composeProject: String(profile.composeProject || '').trim(),
|
||||
composeWorkingDir: String(profile.composeWorkingDir || '').trim(),
|
||||
composeService: internalService,
|
||||
containerName: visibleName || internalService,
|
||||
iconMode,
|
||||
manageDockerMan: profile.manageDockerMan === true,
|
||||
forceRecreate: profile.forceRecreate === true,
|
||||
removeOrphans: profile.removeOrphans === true,
|
||||
workloadIdentity: profile.workloadIdentity && typeof profile.workloadIdentity === 'object' ? structuredClone(profile.workloadIdentity) : null
|
||||
};
|
||||
})]))
|
||||
: {},
|
||||
deploymentStates: source.deploymentStates && typeof source.deploymentStates === 'object' ? source.deploymentStates : {},
|
||||
@@ -223,10 +244,13 @@ class ConfigStore {
|
||||
|
||||
async deleteServer(serverId) {
|
||||
this.data.servers = this.data.servers.filter((item) => item.id !== serverId);
|
||||
const removedProfileIds = new Set();
|
||||
for (const [key, profiles] of Object.entries(this.data.deploymentProfiles)) {
|
||||
for (const profile of profiles) if (profile.serverId === serverId) removedProfileIds.add(profile.id);
|
||||
this.data.deploymentProfiles[key] = profiles.filter((profile) => profile.serverId !== serverId);
|
||||
if (!this.data.deploymentProfiles[key].length) delete this.data.deploymentProfiles[key];
|
||||
}
|
||||
for (const profileId of removedProfileIds) delete this.data.deploymentStates[profileId];
|
||||
await this.save();
|
||||
}
|
||||
|
||||
@@ -357,21 +381,39 @@ class ConfigStore {
|
||||
inputs: {}
|
||||
};
|
||||
if (provider === 'ssh-unraid') {
|
||||
const remoteFolder = String(profile.remoteFolder || '').trim();
|
||||
if (!remoteFolder || !/^[a-zA-Z0-9._-]+$/.test(remoteFolder)) throw new Error('Remote folder must contain only letters, numbers, dots, underscores and dashes.');
|
||||
const remoteFolder = assertRepositoryRelativePath(String(profile.remoteFolder || '').trim());
|
||||
if (!remoteFolder || remoteFolder === '.' || remoteFolder.split('/').some((part) => !part || part === '.')) throw new Error('Remote folder must be a safe path relative to the configured server base path.');
|
||||
const preservePaths = assertRepositoryRelativePaths(uniqueStrings(profile.preservePaths || ['.env', 'appdata', 'data', 'logs', 'config', 'compose.override.yml']));
|
||||
const composeFiles = assertRepositoryRelativePaths(uniqueStrings(profile.composeFiles || [profile.composeFile || 'docker-compose.yml']));
|
||||
if (!composeFiles.length && profile.generatedCompose !== true) throw new Error('Select at least one Compose file.');
|
||||
const composeService = (() => {
|
||||
const value = String(profile.composeService || profile.composeServices?.[0] || remoteFolder.split('/').pop()).trim().toLowerCase();
|
||||
if (!/^[a-z0-9._-]+$/.test(value)) throw new Error('Compose service must be lowercase and contain only letters, numbers, dots, underscores and dashes.');
|
||||
return value;
|
||||
})();
|
||||
const composeServices = uniqueStrings(profile.composeServices || [composeService]).map((value) => {
|
||||
const normalized = String(value).trim().toLowerCase();
|
||||
if (!/^[a-z0-9._-]+$/.test(normalized)) throw new Error('Compose services must be lowercase and contain only letters, numbers, dots, underscores and dashes.');
|
||||
return normalized;
|
||||
});
|
||||
const composeProject = String(profile.composeProject || '').trim();
|
||||
if (composeProject && !/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(composeProject)) throw new Error('Compose project name contains unsupported characters.');
|
||||
const composeWorkingDir = String(profile.composeWorkingDir || '').trim();
|
||||
if (composeWorkingDir && (!composeWorkingDir.startsWith('/') || /[\r\n\0]/.test(composeWorkingDir))) throw new Error('Compose working directory must be an absolute safe Unix path.');
|
||||
const deploymentMode = ['push-bundle', 'server-git', 'monitor-only'].includes(profile.deploymentMode) ? profile.deploymentMode : 'push-bundle';
|
||||
return {
|
||||
...common,
|
||||
serverId: String(profile.serverId || '').trim(),
|
||||
remoteFolder,
|
||||
composeFile: String(profile.composeFile || 'docker-compose.yml').trim(),
|
||||
composeService: (() => {
|
||||
const value = String(profile.composeService || remoteFolder).trim().toLowerCase();
|
||||
if (!/^[a-z0-9._-]+$/.test(value)) throw new Error('Compose service must be lowercase and contain only letters, numbers, dots, underscores and dashes.');
|
||||
return value;
|
||||
})(),
|
||||
deploymentMode,
|
||||
composeFile: composeFiles[0] || 'docker-compose.yml',
|
||||
composeFiles: composeFiles.length ? composeFiles : ['docker-compose.yml'],
|
||||
composeProject,
|
||||
composeWorkingDir,
|
||||
composeService,
|
||||
composeServices,
|
||||
containerName: (() => {
|
||||
const value = String(profile.containerName || remoteFolder).trim();
|
||||
const value = String(profile.containerName || remoteFolder.split('/').pop()).trim();
|
||||
if (!/^[A-Za-z0-9._-]+$/.test(value)) throw new Error('Container name must contain only letters, numbers, dots, underscores and dashes.');
|
||||
return value;
|
||||
})(),
|
||||
@@ -390,6 +432,10 @@ class ConfigStore {
|
||||
generatedCompose: profile.generatedCompose === true,
|
||||
adoptedFromServer: profile.adoptedFromServer === true,
|
||||
serverSourceOfTruth: profile.serverSourceOfTruth === true,
|
||||
manageDockerMan: profile.manageDockerMan === true,
|
||||
forceRecreate: profile.forceRecreate === true,
|
||||
removeOrphans: profile.removeOrphans === true,
|
||||
workloadIdentity: profile.workloadIdentity && typeof profile.workloadIdentity === 'object' ? structuredClone(profile.workloadIdentity) : null,
|
||||
detectedAt: profile.detectedAt || null,
|
||||
provenance: profile.provenance && typeof profile.provenance === 'object' ? structuredClone(profile.provenance) : {},
|
||||
detectedMetadata: profile.detectedMetadata && typeof profile.detectedMetadata === 'object' ? structuredClone(profile.detectedMetadata) : {},
|
||||
|
||||
@@ -450,6 +450,7 @@ function registerIpc({
|
||||
register(
|
||||
"server:save",
|
||||
async ({ server, password = "", passphrase = "" }) => {
|
||||
await ssh.validateServerConfiguration(server, { password, passphrase });
|
||||
const saved = await store.saveServer(server, { password, passphrase });
|
||||
await diagnostics.info("server.saved", {
|
||||
serverId: saved.id,
|
||||
@@ -1175,6 +1176,17 @@ function registerIpc({
|
||||
},
|
||||
);
|
||||
register("deployment:health", ({ url }) => deployments.checkHealth(url));
|
||||
register("deployment:link-server-workload", async ({ repository, serverId, workloadId, deploymentMode = "push-bundle", remoteFolder = "" }) => {
|
||||
const current = await resolveRepository(repository);
|
||||
const result = await unraid.linkServerWorkload({
|
||||
repository: current,
|
||||
serverId,
|
||||
workloadId,
|
||||
deploymentMode,
|
||||
remoteFolder,
|
||||
});
|
||||
return { ...result, state: store.getPublicState() };
|
||||
});
|
||||
register("deployment:discover-server-workloads", async () => {
|
||||
const repositoryList = await repositories.refresh();
|
||||
const remoteRepositories = repositoryList.filter(
|
||||
@@ -1189,10 +1201,16 @@ function registerIpc({
|
||||
} catch (error) {
|
||||
results.push({
|
||||
serverId: server.id,
|
||||
serverName: server.name,
|
||||
detected: 0,
|
||||
adopted: 0,
|
||||
verified: 0,
|
||||
linked: 0,
|
||||
unmatched: 0,
|
||||
needsReview: 0,
|
||||
capabilities: {},
|
||||
warnings: [],
|
||||
workloads: [],
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
'use strict';
|
||||
|
||||
const crypto = require('node:crypto');
|
||||
const path = require('node:path').posix;
|
||||
const { normalizeRemoteUrl } = require('../shared/repository-match.cjs');
|
||||
|
||||
function decodeBase64(value) {
|
||||
try { return Buffer.from(String(value || ''), 'base64').toString('utf8'); }
|
||||
catch { return ''; }
|
||||
}
|
||||
|
||||
function remoteIdentity(value) {
|
||||
const normalized = normalizeRemoteUrl(value);
|
||||
return normalized ? `${normalized.host}/${normalized.path}` : '';
|
||||
}
|
||||
|
||||
function normalizedName(value) {
|
||||
return String(value || '').toLowerCase().replace(/\.git$/i, '').replace(/[^a-z0-9]/g, '');
|
||||
}
|
||||
|
||||
function safeJson(value, fallback) {
|
||||
try { return JSON.parse(value); }
|
||||
catch { return fallback; }
|
||||
}
|
||||
|
||||
function sanitizeLegacyContainer(container) {
|
||||
const labels = container?.Config?.Labels || {};
|
||||
return {
|
||||
id: container?.Id || '',
|
||||
name: String(container?.Name || '').replace(/^\//, ''),
|
||||
image: container?.Config?.Image || '',
|
||||
imageId: container?.Image || '',
|
||||
running: container?.State?.Running === true,
|
||||
status: container?.State?.Status || '',
|
||||
health: container?.State?.Health?.Status || null,
|
||||
labels: {
|
||||
'com.docker.compose.project': labels['com.docker.compose.project'] || '',
|
||||
'com.docker.compose.project.working_dir': labels['com.docker.compose.project.working_dir'] || '',
|
||||
'com.docker.compose.project.config_files': labels['com.docker.compose.project.config_files'] || '',
|
||||
'com.docker.compose.service': labels['com.docker.compose.service'] || '',
|
||||
'org.opencontainers.image.source': labels['org.opencontainers.image.source'] || '',
|
||||
'org.opencontainers.image.revision': labels['org.opencontainers.image.revision'] || '',
|
||||
'tech.itworx.forgeflow.repository': labels['tech.itworx.forgeflow.repository'] || '',
|
||||
'tech.itworx.forgeflow.commit': labels['tech.itworx.forgeflow.commit'] || '',
|
||||
'tech.itworx.forgeflow.branch': labels['tech.itworx.forgeflow.branch'] || '',
|
||||
'net.unraid.docker.webui': labels['net.unraid.docker.webui'] || '',
|
||||
'net.unraid.docker.icon': labels['net.unraid.docker.icon'] || '',
|
||||
'net.unraid.docker.shell': labels['net.unraid.docker.shell'] || '',
|
||||
'net.unraid.docker.managed': labels['net.unraid.docker.managed'] || '',
|
||||
},
|
||||
ports: container?.NetworkSettings?.Ports || {},
|
||||
mounts: Array.isArray(container?.Mounts) ? container.Mounts : [],
|
||||
networks: container?.NetworkSettings?.Networks || {},
|
||||
restartPolicy: container?.HostConfig?.RestartPolicy?.Name || '',
|
||||
};
|
||||
}
|
||||
|
||||
function parseServerInventory(output) {
|
||||
const marker = '__FORGEFLOW_INVENTORY__';
|
||||
const index = String(output || '').lastIndexOf(marker);
|
||||
if (index < 0) throw new Error('The server did not return a ForgeFlow workload inventory.');
|
||||
const inventory = {
|
||||
capabilities: {},
|
||||
checkouts: [],
|
||||
containers: [],
|
||||
dockerMan: [],
|
||||
warnings: [],
|
||||
};
|
||||
for (const line of String(output).slice(index + marker.length).trim().split(/\r?\n/)) {
|
||||
if (!line) continue;
|
||||
const [kind, ...parts] = line.split('\t');
|
||||
if (kind === 'H') {
|
||||
inventory.capabilities = {
|
||||
docker: parts[0] === 'true',
|
||||
compose: parts[1] === 'true',
|
||||
git: parts[2] === 'true',
|
||||
tar: parts[3] === 'true',
|
||||
checksum: parts[4] === 'true',
|
||||
baseWritable: parts[5] === 'true',
|
||||
composeVersion: decodeBase64(parts[6]),
|
||||
platform: decodeBase64(parts[7]),
|
||||
};
|
||||
} else if (kind === 'R' && parts.length >= 4) {
|
||||
inventory.checkouts.push({
|
||||
root: decodeBase64(parts[0]),
|
||||
remote: decodeBase64(parts[1]),
|
||||
liveSha: parts[2] || '',
|
||||
branch: decodeBase64(parts[3]),
|
||||
});
|
||||
} else if (kind === 'C' && parts[0]) {
|
||||
const parsed = safeJson(decodeBase64(parts[0]), null);
|
||||
if (!parsed) continue;
|
||||
if (Array.isArray(parsed)) {
|
||||
if (parsed[0]) inventory.containers.push(sanitizeLegacyContainer(parsed[0]));
|
||||
} else if (parsed.Config || parsed.State) inventory.containers.push(sanitizeLegacyContainer(parsed));
|
||||
else inventory.containers.push({
|
||||
...parsed,
|
||||
name: String(parsed.name || '').replace(/^\//, ''),
|
||||
labels: parsed.labels && typeof parsed.labels === 'object' ? parsed.labels : {},
|
||||
mounts: Array.isArray(parsed.mounts) ? parsed.mounts : [],
|
||||
ports: parsed.ports && typeof parsed.ports === 'object' ? parsed.ports : {},
|
||||
networks: parsed.networks && typeof parsed.networks === 'object' ? parsed.networks : {},
|
||||
});
|
||||
} else if (kind === 'D' && parts[0]) {
|
||||
inventory.dockerMan.push({
|
||||
name: decodeBase64(parts[0]),
|
||||
templatePath: decodeBase64(parts[1]),
|
||||
webUiUrl: decodeBase64(parts[2]),
|
||||
iconUrl: decodeBase64(parts[3]),
|
||||
shell: decodeBase64(parts[4]),
|
||||
repository: decodeBase64(parts[5]),
|
||||
network: decodeBase64(parts[6]),
|
||||
});
|
||||
} else if (kind === 'W') inventory.warnings.push(decodeBase64(parts[0]));
|
||||
}
|
||||
return inventory;
|
||||
}
|
||||
|
||||
function configFilesFor(container) {
|
||||
return String(container?.labels?.['com.docker.compose.project.config_files'] || '')
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function containerPorts(container) {
|
||||
const ports = [];
|
||||
for (const [containerKey, bindings] of Object.entries(container?.ports || {})) {
|
||||
const [containerPortText, protocol = 'tcp'] = containerKey.split('/');
|
||||
const containerPort = Number(containerPortText) || null;
|
||||
if (Array.isArray(bindings) && bindings.length) {
|
||||
for (const binding of bindings) ports.push({
|
||||
hostIp: binding?.HostIp || '',
|
||||
hostPort: Number(binding?.HostPort) || null,
|
||||
containerPort,
|
||||
protocol,
|
||||
});
|
||||
} else ports.push({ hostIp: '', hostPort: null, containerPort, protocol });
|
||||
}
|
||||
return ports;
|
||||
}
|
||||
|
||||
function safeRelativeToBase(basePath, candidate) {
|
||||
const base = String(basePath || '').replace(/\/+$/, '');
|
||||
const value = String(candidate || '').replace(/\/+$/, '');
|
||||
if (!base || !value || !value.startsWith(`${base}/`)) return '';
|
||||
const relative = value.slice(base.length + 1).replace(/^\/+|\/+$/g, '');
|
||||
if (!relative || relative.split('/').some((part) => !part || part === '.' || part === '..')) return '';
|
||||
return relative;
|
||||
}
|
||||
|
||||
function topLevelRelativeToBase(basePath, candidate) {
|
||||
const relative = safeRelativeToBase(basePath, candidate);
|
||||
return relative ? relative.split('/')[0] : '';
|
||||
}
|
||||
|
||||
function workloadSelector(group) {
|
||||
if (group.composeProject) return {
|
||||
kind: 'compose',
|
||||
composeProject: group.composeProject,
|
||||
workingDir: group.workingDir || '',
|
||||
configFiles: group.configFiles,
|
||||
};
|
||||
const dockerMan = group.dockerMan || null;
|
||||
if (dockerMan?.templatePath) return {
|
||||
kind: 'dockerman-container',
|
||||
templatePath: dockerMan.templatePath,
|
||||
containerName: group.containers[0]?.name || '',
|
||||
};
|
||||
return { kind: 'docker-container', containerName: group.containers[0]?.name || '' };
|
||||
}
|
||||
|
||||
function stableWorkloadId(serverId, selector) {
|
||||
return `workload-${crypto.createHash('sha256').update(`${serverId}:${JSON.stringify(selector)}`).digest('hex').slice(0, 24)}`;
|
||||
}
|
||||
|
||||
function profileMatchesWorkload(profile, workload) {
|
||||
if (!profile || profile.provider !== 'ssh-unraid' || profile.serverId !== workload.serverId) return false;
|
||||
const identity = profile.workloadIdentity || {};
|
||||
if (identity.workloadId && identity.workloadId === workload.workloadId) return true;
|
||||
if (identity.selector && JSON.stringify(identity.selector) === JSON.stringify(workload.selector)) return true;
|
||||
if (profile.composeProject && workload.compose?.project && profile.composeProject === workload.compose.project) {
|
||||
if (!profile.composeWorkingDir || !workload.compose.workingDir || profile.composeWorkingDir === workload.compose.workingDir) return true;
|
||||
}
|
||||
return workload.containers.some((container) => container.name === profile.containerName);
|
||||
}
|
||||
|
||||
function repositoryRemoteMap(repositories) {
|
||||
const map = new Map();
|
||||
for (const repository of repositories || []) {
|
||||
for (const value of [repository.cloneUrl, repository.sshUrl, repository.htmlUrl, repository.preferredCloneUrl]) {
|
||||
const id = remoteIdentity(value);
|
||||
if (id) map.set(id, repository);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function candidateRepositories(workload, repositories, checkouts) {
|
||||
const candidates = new Map();
|
||||
const add = (repository, points, reason, exact = false) => {
|
||||
if (!repository?.fullName) return;
|
||||
const current = candidates.get(repository.fullName) || { repositoryFullName: repository.fullName, repositoryName: repository.name, score: 0, exact: false, reasons: [] };
|
||||
current.score += points;
|
||||
current.exact ||= exact;
|
||||
if (reason && !current.reasons.includes(reason)) current.reasons.push(reason);
|
||||
candidates.set(repository.fullName, current);
|
||||
};
|
||||
const remotes = repositoryRemoteMap(repositories);
|
||||
const exactRemoteHints = new Set();
|
||||
for (const container of workload.containers) {
|
||||
const labels = container.labels || {};
|
||||
for (const value of [labels['tech.itworx.forgeflow.repository'], labels['org.opencontainers.image.source']]) {
|
||||
const id = remoteIdentity(value);
|
||||
if (id) exactRemoteHints.add(id);
|
||||
}
|
||||
}
|
||||
for (const checkout of checkouts || []) {
|
||||
const root = String(checkout.root || '').replace(/\/+$/, '');
|
||||
const matchesPath = root && (root === workload.compose.workingDir || workload.containers.some((container) => (container.mounts || []).some((mount) => {
|
||||
const source = String(mount?.Source || '').replace(/\/+$/, '');
|
||||
return source === root || source.startsWith(`${root}/`);
|
||||
})));
|
||||
if (matchesPath) {
|
||||
const id = remoteIdentity(checkout.remote);
|
||||
if (id) exactRemoteHints.add(id);
|
||||
}
|
||||
}
|
||||
for (const id of exactRemoteHints) {
|
||||
const repository = remotes.get(id);
|
||||
if (repository) add(repository, 100, 'Exact repository provenance from container or server checkout', true);
|
||||
}
|
||||
const names = new Set([
|
||||
workload.compose.project,
|
||||
path.basename(workload.compose.workingDir || ''),
|
||||
...workload.containers.map((container) => container.name),
|
||||
...workload.containers.map((container) => String(container.image || '').split('/').pop()?.split(':')[0]),
|
||||
].filter(Boolean).map(normalizedName));
|
||||
for (const repository of repositories || []) {
|
||||
const repoName = normalizedName(repository.name);
|
||||
if (!repoName || !names.has(repoName)) continue;
|
||||
add(repository, workload.compose.project && normalizedName(workload.compose.project) === repoName ? 35 : 20, 'Name similarity only; manual confirmation required');
|
||||
}
|
||||
return [...candidates.values()].sort((a, b) => b.score - a.score || a.repositoryFullName.localeCompare(b.repositoryFullName)).map((candidate) => ({
|
||||
...candidate,
|
||||
confidence: candidate.exact ? 'exact' : candidate.score >= 35 ? 'strong' : 'weak',
|
||||
}));
|
||||
}
|
||||
|
||||
function buildWorkloadInventory({ inventory, server, repositories = [], profiles = [] }) {
|
||||
const dockerManByName = new Map((inventory.dockerMan || []).map((item) => [item.name, item]));
|
||||
const groups = new Map();
|
||||
for (const container of inventory.containers || []) {
|
||||
const labels = container.labels || {};
|
||||
const composeProject = String(labels['com.docker.compose.project'] || '').trim();
|
||||
const workingDir = String(labels['com.docker.compose.project.working_dir'] || '').replace(/\/+$/, '');
|
||||
const configFiles = configFilesFor(container);
|
||||
const key = composeProject
|
||||
? `compose:${composeProject}:${workingDir}:${configFiles.join('|')}`
|
||||
: `container:${container.name}`;
|
||||
const group = groups.get(key) || {
|
||||
composeProject,
|
||||
workingDir,
|
||||
configFiles,
|
||||
services: [],
|
||||
containers: [],
|
||||
dockerMan: null,
|
||||
};
|
||||
group.containers.push(container);
|
||||
const service = String(labels['com.docker.compose.service'] || '').trim();
|
||||
if (service && !group.services.includes(service)) group.services.push(service);
|
||||
group.dockerMan ||= dockerManByName.get(container.name) || null;
|
||||
groups.set(key, group);
|
||||
}
|
||||
const workloads = [];
|
||||
for (const group of groups.values()) {
|
||||
const selector = workloadSelector(group);
|
||||
const workloadId = stableWorkloadId(server.id, selector);
|
||||
const primary = group.containers.find((item) => item.running) || group.containers[0];
|
||||
const ports = group.containers.flatMap(containerPorts);
|
||||
const mounts = group.containers.flatMap((container) => container.mounts || []);
|
||||
const remoteFolderCandidate = safeRelativeToBase(server.basePath, group.workingDir)
|
||||
|| mounts.map((mount) => topLevelRelativeToBase(server.basePath, mount?.Source)).find(Boolean)
|
||||
|| '';
|
||||
const workload = {
|
||||
workloadId,
|
||||
serverId: server.id,
|
||||
serverName: server.name,
|
||||
kind: selector.kind,
|
||||
selector,
|
||||
displayName: group.composeProject || primary?.name || 'Unnamed workload',
|
||||
compose: {
|
||||
project: group.composeProject,
|
||||
workingDir: group.workingDir,
|
||||
configFiles: group.configFiles,
|
||||
services: group.services,
|
||||
},
|
||||
containers: group.containers.map((container) => ({
|
||||
id: container.id,
|
||||
name: container.name,
|
||||
image: container.image,
|
||||
imageId: container.imageId,
|
||||
running: container.running === true,
|
||||
status: container.status || '',
|
||||
health: container.health || null,
|
||||
service: container.labels?.['com.docker.compose.service'] || '',
|
||||
ports: containerPorts(container),
|
||||
mounts: (container.mounts || []).map((mount) => ({
|
||||
type: mount?.Type || '',
|
||||
source: mount?.Source || '',
|
||||
target: mount?.Destination || '',
|
||||
readOnly: mount?.RW === false,
|
||||
})),
|
||||
networks: Object.keys(container.networks || {}),
|
||||
restartPolicy: container.restartPolicy || '',
|
||||
})),
|
||||
dockerMan: group.dockerMan,
|
||||
metadata: {
|
||||
webUiUrl: primary?.labels?.['net.unraid.docker.webui'] || group.dockerMan?.webUiUrl || '',
|
||||
iconUrl: primary?.labels?.['net.unraid.docker.icon'] || group.dockerMan?.iconUrl || '',
|
||||
shell: primary?.labels?.['net.unraid.docker.shell'] || group.dockerMan?.shell || '/bin/sh',
|
||||
sourceRepository: primary?.labels?.['tech.itworx.forgeflow.repository'] || primary?.labels?.['org.opencontainers.image.source'] || '',
|
||||
liveRevision: primary?.labels?.['tech.itworx.forgeflow.commit'] || primary?.labels?.['org.opencontainers.image.revision'] || '',
|
||||
branch: primary?.labels?.['tech.itworx.forgeflow.branch'] || '',
|
||||
},
|
||||
runtime: {
|
||||
running: group.containers.some((container) => container.running === true),
|
||||
allRunning: group.containers.every((container) => container.running === true),
|
||||
health: group.containers.some((container) => container.health === 'unhealthy')
|
||||
? 'unhealthy'
|
||||
: group.containers.length && group.containers.every((container) => container.health === 'healthy')
|
||||
? 'healthy'
|
||||
: 'unverified',
|
||||
ports,
|
||||
},
|
||||
remoteFolderCandidate,
|
||||
observedAt: new Date().toISOString(),
|
||||
};
|
||||
const matchingCheckout = (inventory.checkouts || []).find((checkout) => {
|
||||
const root = String(checkout.root || '').replace(/\/+$/, '');
|
||||
if (!root) return false;
|
||||
if (root === workload.compose.workingDir) return true;
|
||||
return mounts.some((mount) => {
|
||||
const source = String(mount?.Source || '').replace(/\/+$/, '');
|
||||
return source === root || source.startsWith(`${root}/`);
|
||||
});
|
||||
});
|
||||
if (matchingCheckout) {
|
||||
workload.metadata.sourceRepository ||= matchingCheckout.remote || '';
|
||||
workload.metadata.liveRevision ||= matchingCheckout.liveSha || '';
|
||||
workload.metadata.branch ||= matchingCheckout.branch || '';
|
||||
}
|
||||
workload.candidates = candidateRepositories(workload, repositories, inventory.checkouts || []);
|
||||
const linked = profiles.find((profile) => profileMatchesWorkload(profile, workload));
|
||||
if (linked) {
|
||||
workload.link = {
|
||||
status: 'linked',
|
||||
profileId: linked.id,
|
||||
repositoryFullName: linked.repositoryFullName || linked._repositoryFullName || '',
|
||||
source: linked.workloadIdentity?.linkSource || (linked.adoptedFromServer ? 'automatic' : 'manual'),
|
||||
};
|
||||
workload.status = 'linked';
|
||||
} else if (workload.candidates.length === 1 && workload.candidates[0].exact) workload.status = 'exact-match';
|
||||
else if (workload.candidates.length) workload.status = workload.candidates[1]?.score === workload.candidates[0]?.score ? 'ambiguous' : 'suggested';
|
||||
else workload.status = 'unmatched';
|
||||
workloads.push(workload);
|
||||
}
|
||||
workloads.sort((a, b) => Number(b.runtime.running) - Number(a.runtime.running) || a.displayName.localeCompare(b.displayName));
|
||||
return workloads;
|
||||
}
|
||||
|
||||
function inventoryContainerMatch(checkout, repository, container) {
|
||||
const safe = container?.Config || container?.State ? sanitizeLegacyContainer(container) : container;
|
||||
if (!safe?.running) return 0;
|
||||
const labels = safe.labels || {};
|
||||
const workingDir = String(labels['com.docker.compose.project.working_dir'] || '').replace(/\/$/, '');
|
||||
const source = remoteIdentity(labels['org.opencontainers.image.source'] || labels['tech.itworx.forgeflow.repository'] || '');
|
||||
const mounts = Array.isArray(safe.mounts) ? safe.mounts : [];
|
||||
const root = String(checkout.root || '').replace(/\/$/, '');
|
||||
const name = String(safe.name || '').replace(/^\//, '');
|
||||
const project = String(labels['com.docker.compose.project'] || '');
|
||||
const expectedNames = new Set([repository.name, root.split('/').pop()].filter(Boolean).map(normalizedName));
|
||||
if (workingDir && workingDir === root) return 100;
|
||||
if (mounts.some((mount) => {
|
||||
const mountSource = String(mount.Source || '').replace(/\/$/, '');
|
||||
return mountSource === root || mountSource.startsWith(`${root}/`);
|
||||
})) return 90;
|
||||
if (source && source === remoteIdentity(checkout.remote)) return 85;
|
||||
if (expectedNames.has(normalizedName(project))) return 70;
|
||||
if (expectedNames.has(normalizedName(name))) return 60;
|
||||
return 0;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parseServerInventory,
|
||||
buildWorkloadInventory,
|
||||
inventoryContainerMatch,
|
||||
remoteIdentity,
|
||||
stableWorkloadId,
|
||||
profileMatchesWorkload,
|
||||
sanitizeLegacyContainer,
|
||||
safeRelativeToBase,
|
||||
};
|
||||
+194
-66
@@ -1,11 +1,12 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('node:fs/promises');
|
||||
const fs = require('node:fs');
|
||||
const fsp = require('node:fs/promises');
|
||||
const crypto = require('node:crypto');
|
||||
const path = require('node:path').posix;
|
||||
|
||||
function loadSshClient() {
|
||||
try { return require('ssh2').Client; }
|
||||
function loadSshModule() {
|
||||
try { return require('ssh2'); }
|
||||
catch {
|
||||
const error = new Error('The ssh2 dependency is not installed. Run npm install before configuring SSH deployments.');
|
||||
error.code = 'SSH2_NOT_INSTALLED';
|
||||
@@ -13,6 +14,10 @@ function loadSshClient() {
|
||||
}
|
||||
}
|
||||
|
||||
function loadSshClient() {
|
||||
return loadSshModule().Client;
|
||||
}
|
||||
|
||||
function fingerprintKey(key) {
|
||||
const buffer = Buffer.isBuffer(key) ? key : Buffer.from(key);
|
||||
return `SHA256:${crypto.createHash('sha256').update(buffer).digest('base64').replace(/=+$/, '')}`;
|
||||
@@ -22,12 +27,65 @@ function shellQuote(value) {
|
||||
return `'${String(value ?? '').replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
function parseCapabilityOutput(output) {
|
||||
const marker = '__FORGEFLOW_SERVER_TEST__';
|
||||
const index = String(output || '').lastIndexOf(marker);
|
||||
if (index < 0) return { platform: String(output || '').trim(), docker: false, dockerReady: false, compose: false, git: false, tar: false, checksum: false };
|
||||
const fields = {};
|
||||
for (const line of String(output).slice(index + marker.length).trim().split(/\r?\n/)) {
|
||||
const separator = line.indexOf('=');
|
||||
if (separator > 0) fields[line.slice(0, separator)] = line.slice(separator + 1);
|
||||
}
|
||||
const decode = (value) => {
|
||||
try { return value ? Buffer.from(value, 'base64').toString('utf8') : ''; }
|
||||
catch { return ''; }
|
||||
};
|
||||
return {
|
||||
platform: decode(fields.platform),
|
||||
docker: fields.docker === 'true',
|
||||
dockerReady: fields.dockerReady === 'true',
|
||||
compose: fields.compose === 'true',
|
||||
composeVersion: decode(fields.composeVersion),
|
||||
git: fields.git === 'true',
|
||||
tar: fields.tar === 'true',
|
||||
checksum: fields.checksum === 'true',
|
||||
baseWritable: fields.baseWritable === 'true',
|
||||
};
|
||||
}
|
||||
|
||||
class SshService {
|
||||
constructor({ store, diagnostics }) {
|
||||
this.store = store;
|
||||
this.diagnostics = diagnostics;
|
||||
}
|
||||
|
||||
async validateServerConfiguration(server, secrets = {}) {
|
||||
if (server?.authType !== 'privateKey') return { valid: true, method: 'password' };
|
||||
const privateKeyPath = String(server.privateKeyPath || '').trim();
|
||||
if (!privateKeyPath) throw new Error('Select a private key file.');
|
||||
const stat = await fsp.stat(privateKeyPath).catch(() => null);
|
||||
if (!stat?.isFile()) {
|
||||
const error = new Error(`The SSH private key file was not found: ${privateKeyPath}`);
|
||||
error.code = 'SSH_PRIVATE_KEY_NOT_FOUND';
|
||||
throw error;
|
||||
}
|
||||
const existing = server.id ? this.store.getServer(server.id) : null;
|
||||
const sameKey = existing && String(existing.privateKeyPath || '') === privateKeyPath;
|
||||
const storedPassphrase = sameKey ? this.store.getServerCredentials(existing.id).passphrase : '';
|
||||
const passphrase = Object.prototype.hasOwnProperty.call(secrets, 'passphrase') && String(secrets.passphrase || '')
|
||||
? String(secrets.passphrase)
|
||||
: storedPassphrase;
|
||||
const key = await fsp.readFile(privateKeyPath);
|
||||
const parsed = loadSshModule().utils.parseKey(key, passphrase || undefined);
|
||||
const errorResult = Array.isArray(parsed) ? parsed.find((item) => item instanceof Error) : parsed instanceof Error ? parsed : null;
|
||||
if (errorResult) {
|
||||
const error = new Error(`The selected file is not a usable SSH private key${passphrase ? ' with the supplied passphrase' : ''}: ${errorResult.message}`);
|
||||
error.code = /encrypted|passphrase|decrypt/i.test(errorResult.message) ? 'SSH_PRIVATE_KEY_PASSPHRASE_INVALID' : 'SSH_PRIVATE_KEY_INVALID';
|
||||
throw error;
|
||||
}
|
||||
return { valid: true, method: 'privateKey', encrypted: Boolean(passphrase), privateKeyPath };
|
||||
}
|
||||
|
||||
async connectionOptions(server, { trustOnFirstUse = false } = {}) {
|
||||
const credentials = this.store.getServerCredentials(server.id);
|
||||
let observedFingerprint = null;
|
||||
@@ -41,12 +99,16 @@ class SshService {
|
||||
hostVerifier: (key) => {
|
||||
observedFingerprint = fingerprintKey(key);
|
||||
return trustOnFirstUse || Boolean(server.hostFingerprint && observedFingerprint === server.hostFingerprint);
|
||||
}
|
||||
},
|
||||
};
|
||||
if (server.authType === 'password') {
|
||||
options.password = credentials.password;
|
||||
} else {
|
||||
options.privateKey = await fs.readFile(server.privateKeyPath);
|
||||
if (server.authType === 'password') options.password = credentials.password;
|
||||
else {
|
||||
try { options.privateKey = await fsp.readFile(server.privateKeyPath); }
|
||||
catch (error) {
|
||||
const wrapped = new Error(`Could not read SSH private key ${server.privateKeyPath}: ${error.message}`);
|
||||
wrapped.code = 'SSH_PRIVATE_KEY_READ_FAILED';
|
||||
throw wrapped;
|
||||
}
|
||||
if (credentials.passphrase) options.passphrase = credentials.passphrase;
|
||||
}
|
||||
return { options, getObservedFingerprint: () => observedFingerprint };
|
||||
@@ -70,24 +132,20 @@ class SshService {
|
||||
client.once('ready', async () => {
|
||||
try {
|
||||
const data = await action(client, server, connection.getObservedFingerprint());
|
||||
await this.diagnostics?.debug('ssh.connection.completed', {
|
||||
serverId,
|
||||
host: server.host,
|
||||
durationMs: Date.now() - started
|
||||
});
|
||||
await this.diagnostics?.debug('ssh.connection.completed', { serverId, host: server.host, durationMs: Date.now() - started });
|
||||
finish(resolve, data);
|
||||
} catch (error) { finish(reject, error); }
|
||||
});
|
||||
client.once('error', async (error) => {
|
||||
const wrapped = new Error(`SSH connection failed: ${error.message}`);
|
||||
wrapped.code = error.code || 'SSH_CONNECTION_FAILED';
|
||||
await this.diagnostics?.warning('ssh.connection.failed', {
|
||||
serverId,
|
||||
host: server.host,
|
||||
durationMs: Date.now() - started,
|
||||
code: wrapped.code,
|
||||
message: wrapped.message
|
||||
});
|
||||
const observed = connection.getObservedFingerprint();
|
||||
const mismatch = Boolean(server.hostFingerprint && observed && server.hostFingerprint !== observed);
|
||||
const wrapped = new Error(mismatch
|
||||
? `SSH host identity changed. Expected ${server.hostFingerprint}, but the server presented ${observed}.`
|
||||
: `SSH connection failed: ${error.message}`);
|
||||
wrapped.code = mismatch ? 'SSH_HOST_KEY_MISMATCH' : (error.code || 'SSH_CONNECTION_FAILED');
|
||||
wrapped.expectedFingerprint = mismatch ? server.hostFingerprint : undefined;
|
||||
wrapped.observedFingerprint = mismatch ? observed : undefined;
|
||||
await this.diagnostics?.warning('ssh.connection.failed', { serverId, host: server.host, durationMs: Date.now() - started, code: wrapped.code, message: wrapped.message });
|
||||
finish(reject, wrapped);
|
||||
});
|
||||
client.connect(connection.options);
|
||||
@@ -96,96 +154,166 @@ class SshService {
|
||||
|
||||
execClient(client, command, { timeout = 15 * 60_000, maxOutput = 2 * 1024 * 1024 } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('The SSH command timed out.')), timeout);
|
||||
let completed = false;
|
||||
const timer = setTimeout(() => {
|
||||
if (completed) return;
|
||||
completed = true;
|
||||
reject(new Error('The SSH command timed out.'));
|
||||
}, timeout);
|
||||
client.exec(command, (error, stream) => {
|
||||
if (error) {
|
||||
clearTimeout(timer);
|
||||
completed = true;
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
stream.on('data', (chunk) => { if (stdout.length < maxOutput) stdout += chunk.toString(); });
|
||||
stream.stderr.on('data', (chunk) => { if (stderr.length < maxOutput) stderr += chunk.toString(); });
|
||||
let stdoutBytes = 0;
|
||||
let stderrBytes = 0;
|
||||
let truncated = false;
|
||||
const append = (target, chunk) => {
|
||||
const text = chunk.toString();
|
||||
const bytes = Buffer.byteLength(text);
|
||||
if (target === 'stdout') {
|
||||
if (stdoutBytes + bytes <= maxOutput) stdout += text;
|
||||
else truncated = true;
|
||||
stdoutBytes += bytes;
|
||||
} else {
|
||||
if (stderrBytes + bytes <= maxOutput) stderr += text;
|
||||
else truncated = true;
|
||||
stderrBytes += bytes;
|
||||
}
|
||||
};
|
||||
stream.on('data', (chunk) => append('stdout', chunk));
|
||||
stream.stderr.on('data', (chunk) => append('stderr', chunk));
|
||||
stream.on('close', (code, signal) => {
|
||||
if (completed) return;
|
||||
completed = true;
|
||||
clearTimeout(timer);
|
||||
if (code !== 0) {
|
||||
if (truncated) {
|
||||
const failure = new Error(`Remote command output exceeded the ${maxOutput}-byte safety limit. ForgeFlow refused to use an incomplete result.`);
|
||||
failure.code = 'SSH_OUTPUT_TRUNCATED';
|
||||
failure.stdoutBytes = stdoutBytes;
|
||||
failure.stderrBytes = stderrBytes;
|
||||
reject(failure);
|
||||
} else if (code !== 0) {
|
||||
const failure = new Error(`Remote command failed with exit code ${code}: ${(stderr || stdout).trim().slice(-4000)}`);
|
||||
failure.code = 'SSH_COMMAND_FAILED';
|
||||
failure.exitCode = code;
|
||||
failure.signal = signal;
|
||||
reject(failure);
|
||||
} else resolve({ stdout, stderr, exitCode: code });
|
||||
} else resolve({ stdout, stderr, exitCode: code, truncated: false });
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async uploadBuffer(serverId, remotePath, content, { mode = 0o600 } = {}) {
|
||||
ensureUploadTarget(target) {
|
||||
const normalized = String(target || '').replace(/\\/g, '/');
|
||||
if (!normalized.startsWith('/') || normalized.includes('\0') || normalized.split('/').includes('..')) throw new Error('Remote upload path must be an absolute safe Unix path.');
|
||||
return normalized;
|
||||
}
|
||||
|
||||
async withSftp(serverId, remotePath, action) {
|
||||
const server = this.store.getServer(serverId);
|
||||
if (!server?.hostFingerprint) {
|
||||
const error = new Error('Test and trust the SSH server fingerprint before uploading deployment assets.');
|
||||
error.code = 'SSH_HOST_NOT_TRUSTED';
|
||||
throw error;
|
||||
}
|
||||
const target = String(remotePath || '').replace(/\\/g, '/');
|
||||
if (!target.startsWith('/') || target.includes('\0') || target.split('/').includes('..')) throw new Error('Remote upload path must be an absolute safe Unix path.');
|
||||
const data = Buffer.isBuffer(content) ? content : Buffer.from(content);
|
||||
const target = this.ensureUploadTarget(remotePath);
|
||||
return this.withClient(serverId, (client) => new Promise((resolve, reject) => {
|
||||
client.sftp((sftpError, sftp) => {
|
||||
if (sftpError) { reject(sftpError); return; }
|
||||
const directory = path.dirname(target);
|
||||
const mkdirParts = directory.split('/').filter(Boolean);
|
||||
const parts = path.dirname(target).split('/').filter(Boolean);
|
||||
let current = '';
|
||||
const makeNext = (index) => {
|
||||
if (index >= mkdirParts.length) {
|
||||
const stream = sftp.createWriteStream(target, { mode });
|
||||
stream.once('error', reject);
|
||||
stream.once('close', () => resolve({ remotePath: target, size: data.length }));
|
||||
stream.end(data);
|
||||
const ensureNext = (index) => {
|
||||
if (index >= parts.length) {
|
||||
Promise.resolve(action(sftp, target)).then(resolve, reject);
|
||||
return;
|
||||
}
|
||||
current += `/${mkdirParts[index]}`;
|
||||
const ensureDirectory = () => {
|
||||
sftp.stat(current, (statError, attributes) => {
|
||||
if (!statError) {
|
||||
if (typeof attributes?.isDirectory === 'function' && !attributes.isDirectory()) {
|
||||
reject(new Error(`Remote upload parent exists but is not a directory: ${current}`));
|
||||
return;
|
||||
}
|
||||
makeNext(index + 1);
|
||||
return;
|
||||
}
|
||||
if (![2, 'ENOENT'].includes(statError.code)) { reject(statError); return; }
|
||||
sftp.mkdir(current, { mode: 0o755 }, (mkdirError) => {
|
||||
if (!mkdirError) { makeNext(index + 1); return; }
|
||||
sftp.stat(current, (retryError, retryAttributes) => {
|
||||
if (!retryError && (typeof retryAttributes?.isDirectory !== 'function' || retryAttributes.isDirectory())) makeNext(index + 1);
|
||||
else reject(mkdirError);
|
||||
});
|
||||
current += `/${parts[index]}`;
|
||||
sftp.stat(current, (statError, attributes) => {
|
||||
if (!statError) {
|
||||
if (typeof attributes?.isDirectory === 'function' && !attributes.isDirectory()) { reject(new Error(`Remote upload parent exists but is not a directory: ${current}`)); return; }
|
||||
ensureNext(index + 1);
|
||||
return;
|
||||
}
|
||||
if (![2, 'ENOENT'].includes(statError.code)) { reject(statError); return; }
|
||||
sftp.mkdir(current, { mode: 0o755 }, (mkdirError) => {
|
||||
if (!mkdirError) { ensureNext(index + 1); return; }
|
||||
sftp.stat(current, (retryError, retryAttributes) => {
|
||||
if (!retryError && (typeof retryAttributes?.isDirectory !== 'function' || retryAttributes.isDirectory())) ensureNext(index + 1);
|
||||
else reject(mkdirError);
|
||||
});
|
||||
});
|
||||
};
|
||||
ensureDirectory();
|
||||
});
|
||||
};
|
||||
makeNext(0);
|
||||
ensureNext(0);
|
||||
});
|
||||
}), { trustOnFirstUse: false });
|
||||
}
|
||||
|
||||
async uploadFile(serverId, localPath, remotePath, options = {}) {
|
||||
const data = await fs.readFile(localPath);
|
||||
return this.uploadBuffer(serverId, remotePath, data, options);
|
||||
async uploadBuffer(serverId, remotePath, content, { mode = 0o600 } = {}) {
|
||||
const data = Buffer.isBuffer(content) ? content : Buffer.from(content);
|
||||
return this.withSftp(serverId, remotePath, (sftp, target) => new Promise((resolve, reject) => {
|
||||
const stream = sftp.createWriteStream(target, { mode });
|
||||
stream.once('error', reject);
|
||||
stream.once('close', () => resolve({ remotePath: target, size: data.length }));
|
||||
stream.end(data);
|
||||
}));
|
||||
}
|
||||
|
||||
async uploadFile(serverId, localPath, remotePath, { mode = 0o600, onProgress = null } = {}) {
|
||||
const stat = await fsp.stat(localPath);
|
||||
if (!stat.isFile()) throw new Error(`Local upload source is not a file: ${localPath}`);
|
||||
return this.withSftp(serverId, remotePath, (sftp, target) => new Promise((resolve, reject) => {
|
||||
const options = {
|
||||
mode,
|
||||
step: (totalTransferred, _chunk, total) => onProgress?.({ transferred: totalTransferred, total: total || stat.size }),
|
||||
};
|
||||
sftp.fastPut(localPath, target, options, (error) => {
|
||||
if (error) { reject(error); return; }
|
||||
resolve({ remotePath: target, size: stat.size });
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
async test(serverId, { trustOnFirstUse = true } = {}) {
|
||||
return this.withClient(serverId, async (client, server, fingerprint) => {
|
||||
const result = await this.execClient(client, 'uname -srm && command -v git && (docker compose version || docker-compose version)', { timeout: 30_000 });
|
||||
const script = `
|
||||
platform=$(uname -srm 2>/dev/null || true)
|
||||
docker=false; docker_ready=false; compose=false; compose_version=''; git=false; tar_ok=false; checksum=false; base_writable=false
|
||||
command -v docker >/dev/null 2>&1 && docker=true
|
||||
[ "$docker" = true ] && docker info >/dev/null 2>&1 && docker_ready=true
|
||||
if [ "$docker" = true ]; then
|
||||
if docker compose version >/dev/null 2>&1; then compose=true; compose_version=$(docker compose version 2>/dev/null | head -n1); elif command -v docker-compose >/dev/null 2>&1; then compose=true; compose_version=$(docker-compose version 2>/dev/null | head -n1); fi
|
||||
fi
|
||||
command -v git >/dev/null 2>&1 && git=true
|
||||
command -v tar >/dev/null 2>&1 && tar_ok=true
|
||||
(command -v sha256sum >/dev/null 2>&1 || command -v shasum >/dev/null 2>&1) && checksum=true
|
||||
base=${shellQuote(server.basePath)}
|
||||
if [ -d "$base" ]; then [ -w "$base" ] && base_writable=true; else parent=$(dirname "$base"); [ -d "$parent" ] && [ -w "$parent" ] && base_writable=true; fi
|
||||
printf '__FORGEFLOW_SERVER_TEST__\\n'
|
||||
printf 'platform=%s\\n' "$(printf '%s' "$platform" | base64 | tr -d '\\r\\n')"
|
||||
printf 'docker=%s\\n' "$docker"
|
||||
printf 'dockerReady=%s\\n' "$docker_ready"
|
||||
printf 'compose=%s\\n' "$compose"
|
||||
printf 'composeVersion=%s\\n' "$(printf '%s' "$compose_version" | base64 | tr -d '\\r\\n')"
|
||||
printf 'git=%s\\n' "$git"
|
||||
printf 'tar=%s\\n' "$tar_ok"
|
||||
printf 'checksum=%s\\n' "$checksum"
|
||||
printf 'baseWritable=%s\\n' "$base_writable"
|
||||
`;
|
||||
const result = await this.execClient(client, script, { timeout: 30_000, maxOutput: 256 * 1024 });
|
||||
const capabilities = parseCapabilityOutput(result.stdout);
|
||||
return {
|
||||
connected: true,
|
||||
fingerprint,
|
||||
server: { id: server.id, name: server.name, host: server.host, basePath: server.basePath },
|
||||
output: result.stdout.trim()
|
||||
capabilities,
|
||||
output: [capabilities.platform, capabilities.composeVersion].filter(Boolean).join('\n'),
|
||||
};
|
||||
}, { trustOnFirstUse });
|
||||
}
|
||||
@@ -201,4 +329,4 @@ class SshService {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { SshService, shellQuote, fingerprintKey };
|
||||
module.exports = { SshService, shellQuote, fingerprintKey, parseCapabilityOutput };
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user