578 lines
27 KiB
JavaScript
578 lines
27 KiB
JavaScript
'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: [],
|
|
composeProjects: [],
|
|
composeDefinitions: [],
|
|
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)) {
|
|
for (const item of parsed) if (item) inventory.containers.push(sanitizeLegacyContainer(item));
|
|
} 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 === 'P' && parts[0]) {
|
|
const parsed = safeJson(decodeBase64(parts[0]), []);
|
|
const projects = Array.isArray(parsed) ? parsed : parsed ? [parsed] : [];
|
|
for (const project of projects) {
|
|
const name = String(project?.Name || project?.name || '').trim();
|
|
if (!name) continue;
|
|
const rawFiles = project?.ConfigFiles || project?.configFiles || project?.config_files || [];
|
|
const configFiles = (Array.isArray(rawFiles) ? rawFiles : String(rawFiles || '').split(','))
|
|
.map((item) => String(item || '').trim())
|
|
.filter(Boolean);
|
|
inventory.composeProjects.push({
|
|
name,
|
|
status: String(project?.Status || project?.status || ''),
|
|
configFiles,
|
|
});
|
|
}
|
|
} else if (kind === 'Y' && parts[0]) {
|
|
inventory.composeDefinitions.push({
|
|
workingDir: decodeBase64(parts[0]).replace(/\/+$/, ''),
|
|
configFiles: decodeBase64(parts[1]).split(/\r?\n/).map((item) => item.trim()).filter(Boolean),
|
|
projectName: decodeBase64(parts[2]).trim(),
|
|
services: decodeBase64(parts[3]).split(/\r?\n/).map((item) => item.trim()).filter(Boolean),
|
|
images: decodeBase64(parts[4]).split(/\r?\n/).map((item) => item.trim()).filter(Boolean),
|
|
valid: parts[5] === 'true',
|
|
error: decodeBase64(parts[6]).trim(),
|
|
});
|
|
} 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 canonicalServerAppdataPath(basePath, candidate) {
|
|
const value = String(candidate || '').replace(/\\/g, '/').replace(/\/+$/, '');
|
|
if (!value) return '';
|
|
const bases = [...new Set([
|
|
String(basePath || '').replace(/\/+$/, ''),
|
|
'/mnt/user/appdata',
|
|
'/mnt/cache/appdata',
|
|
].filter(Boolean))];
|
|
for (const base of bases) {
|
|
const relative = safeRelativeToBase(base, value);
|
|
if (relative) return `${String(basePath || base).replace(/\/+$/, '')}/${relative}`;
|
|
if (value === base) return String(basePath || base).replace(/\/+$/, '');
|
|
}
|
|
const diskMatch = value.match(/^\/mnt\/disk\d+\/appdata\/(.+)$/i);
|
|
if (diskMatch) return `${String(basePath || '/mnt/user/appdata').replace(/\/+$/, '')}/${diskMatch[1]}`;
|
|
return value;
|
|
}
|
|
|
|
function isDeploymentBackupPath(value) {
|
|
const segments = String(value || '').replace(/\\/g, '/').split('/').filter(Boolean);
|
|
return segments.some((segment) =>
|
|
/^source-pre-[0-9a-f]{7,64}$/i.test(segment)
|
|
|| /^forgeflow-(backup|staging|rollback)(?:[-_.].*)?$/i.test(segment)
|
|
|| ['.forgeflow', 'releases', 'backups', 'staging', 'incoming', '_audit_quarantine', 'devrunbook-validation'].includes(segment.toLowerCase()),
|
|
);
|
|
}
|
|
|
|
function deploymentRootCandidate(relativePath) {
|
|
const segments = String(relativePath || '').replace(/\\/g, '/').split('/').filter(Boolean);
|
|
const forgeFlowIndex = segments.indexOf('.forgeflow');
|
|
if (forgeFlowIndex > 0) return segments.slice(0, forgeFlowIndex).join('/');
|
|
const releasesIndex = segments.indexOf('releases');
|
|
if (releasesIndex > 0 && segments.length > releasesIndex + 1) return segments.slice(0, releasesIndex).join('/');
|
|
const backupIndex = segments.findIndex((segment) => /^source-pre-[0-9a-f]{7,64}$/i.test(segment));
|
|
if (backupIndex > 0) return segments.slice(0, backupIndex).join('/');
|
|
return segments.join('/');
|
|
}
|
|
|
|
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;
|
|
}
|
|
if (profile.remoteFolder && workload.remoteFolderCandidate && profile.remoteFolder === workload.remoteFolderCandidate) 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, identityExact = false) => {
|
|
if (!repository?.fullName) return;
|
|
const current = candidates.get(repository.fullName) || { repositoryFullName: repository.fullName, repositoryName: repository.name, score: 0, exact: false, identityExact: false, reasons: [] };
|
|
current.score += points;
|
|
current.exact ||= exact;
|
|
current.identityExact ||= identityExact;
|
|
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 composeProjectName = normalizedName(workload.compose.project);
|
|
const composeFolderName = normalizedName(path.basename(workload.compose.workingDir || ''));
|
|
const deploymentFolderName = normalizedName(String(workload.remoteFolderCandidate || '').split('/')[0]);
|
|
const serviceNames = new Set((workload.compose.services || []).map(normalizedName).filter(Boolean));
|
|
const containerNames = new Set(workload.containers.map((container) => normalizedName(container.name)).filter(Boolean));
|
|
const imageNames = new Set([
|
|
...workload.containers.map((container) => String(container.image || '').split('/').pop()?.split(':')[0]),
|
|
...(workload.metadata?.images || []).map((image) => String(image || '').split('/').pop()?.split(':')[0]),
|
|
].map(normalizedName).filter(Boolean));
|
|
for (const repository of repositories || []) {
|
|
const repoName = normalizedName(repository.name);
|
|
if (!repoName) continue;
|
|
if (composeProjectName && composeProjectName === repoName) add(repository, 55, 'Compose project name matches repository', false, true);
|
|
if (deploymentFolderName && deploymentFolderName === repoName) add(repository, 70, 'Top-level appdata folder exactly matches repository', false, true);
|
|
if (composeFolderName && composeFolderName === repoName) add(repository, 50, 'Compose file folder matches repository');
|
|
if (serviceNames.has(repoName)) add(repository, 25, 'Compose service name matches repository');
|
|
if (containerNames.has(repoName)) add(repository, 70, 'Container name exactly matches repository', false, true);
|
|
if (imageNames.has(repoName)) add(repository, 20, 'Container image name matches repository');
|
|
}
|
|
return [...candidates.values()].sort((a, b) => b.score - a.score || a.repositoryFullName.localeCompare(b.repositoryFullName)).map((candidate) => ({
|
|
...candidate,
|
|
reasons: candidate.exact
|
|
? candidate.reasons
|
|
: [...candidate.reasons, 'Manual confirmation is reduced to one click; Compose identity and paths are prefilled from the server.'],
|
|
confidence: candidate.exact ? 'exact' : candidate.score >= 35 ? 'strong' : 'weak',
|
|
}));
|
|
}
|
|
|
|
function buildWorkloadInventory({ inventory, server, repositories = [], profiles = [] }) {
|
|
const dockerManByName = new Map((inventory.dockerMan || []).map((item) => [String(item.name || '').toLowerCase(), 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 = canonicalServerAppdataPath(server.basePath, labels['com.docker.compose.project.working_dir']);
|
|
const configFiles = [...new Set(configFilesFor(container).map((file) => canonicalServerAppdataPath(server.basePath, file)))];
|
|
const key = composeProject
|
|
? `compose:${composeProject}:${workingDir}:${configFiles.join('|')}`
|
|
: `container:${container.name}`;
|
|
const group = groups.get(key) || {
|
|
composeProject,
|
|
workingDir,
|
|
configFiles,
|
|
services: [],
|
|
images: [],
|
|
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(String(container.name || '').toLowerCase()) || null;
|
|
groups.set(key, group);
|
|
}
|
|
for (const project of inventory.composeProjects || []) {
|
|
const configFiles = [...new Set((project.configFiles || []).filter(Boolean).map((file) => canonicalServerAppdataPath(server.basePath, file)))];
|
|
const workingDir = configFiles.length ? canonicalServerAppdataPath(server.basePath, path.dirname(configFiles[0])) : '';
|
|
const key = `compose:${project.name}:${workingDir}:${configFiles.join('|')}`;
|
|
if (groups.has(key)) continue;
|
|
const existingByProject = [...groups.values()].find((group) => group.composeProject === project.name);
|
|
if (existingByProject) {
|
|
if (!existingByProject.configFiles.length && configFiles.length) existingByProject.configFiles = configFiles;
|
|
if (!existingByProject.workingDir && workingDir) existingByProject.workingDir = workingDir;
|
|
continue;
|
|
}
|
|
groups.set(key, {
|
|
composeProject: project.name,
|
|
workingDir,
|
|
configFiles,
|
|
services: [],
|
|
images: [],
|
|
containers: [],
|
|
dockerMan: dockerManByName.get(String(project.name || '').toLowerCase()) || null,
|
|
composeStatus: project.status || '',
|
|
});
|
|
}
|
|
for (const definition of inventory.composeDefinitions || []) {
|
|
const configFiles = [...new Set((definition.configFiles || []).filter(Boolean).map((file) => canonicalServerAppdataPath(server.basePath, file)))];
|
|
const workingDir = canonicalServerAppdataPath(server.basePath, definition.workingDir || (configFiles[0] ? path.dirname(configFiles[0]) : ''));
|
|
if (isDeploymentBackupPath(workingDir) || configFiles.some(isDeploymentBackupPath)) continue;
|
|
const projectName = String(definition.projectName || path.basename(workingDir || '')).trim();
|
|
const existing = [...groups.values()].find((group) => {
|
|
if (workingDir && group.workingDir && group.workingDir === workingDir) return true;
|
|
if (configFiles.length && (group.configFiles || []).some((file) => configFiles.includes(file))) return true;
|
|
return Boolean(projectName && group.composeProject === projectName && (!workingDir || !group.workingDir));
|
|
});
|
|
if (existing) {
|
|
existing.composeProject ||= projectName;
|
|
existing.workingDir ||= workingDir;
|
|
existing.configFiles = [...new Set([...(existing.configFiles || []), ...configFiles])];
|
|
existing.services = [...new Set([...(existing.services || []), ...(definition.services || [])])];
|
|
existing.images = [...new Set([...(existing.images || []), ...(definition.images || [])])];
|
|
existing.composeDefinitionValid = definition.valid;
|
|
existing.composeDefinitionError = definition.error || '';
|
|
existing.composeSource = 'server-compose-file';
|
|
continue;
|
|
}
|
|
const key = `compose-file:${projectName}:${workingDir}:${configFiles.join('|')}`;
|
|
groups.set(key, {
|
|
composeProject: projectName,
|
|
workingDir,
|
|
configFiles,
|
|
services: [...new Set(definition.services || [])],
|
|
images: [...new Set(definition.images || [])],
|
|
containers: [],
|
|
dockerMan: dockerManByName.get(projectName.toLowerCase()) || null,
|
|
composeStatus: '',
|
|
composeDefinitionValid: definition.valid,
|
|
composeDefinitionError: definition.error || '',
|
|
composeSource: 'server-compose-file',
|
|
});
|
|
}
|
|
const containerNames = new Set((inventory.containers || []).map((container) => String(container.name || '').toLowerCase()));
|
|
for (const dockerMan of inventory.dockerMan || []) {
|
|
const normalized = String(dockerMan.name || '').toLowerCase();
|
|
if (!normalized || containerNames.has(normalized)) continue;
|
|
const key = `container:${dockerMan.name}`;
|
|
if (groups.has(key)) continue;
|
|
groups.set(key, {
|
|
composeProject: '',
|
|
workingDir: '',
|
|
configFiles: [],
|
|
services: [],
|
|
images: dockerMan.repository ? [dockerMan.repository] : [],
|
|
containers: [{
|
|
id: '',
|
|
name: dockerMan.name,
|
|
image: dockerMan.repository || '',
|
|
imageId: '',
|
|
running: false,
|
|
status: 'template-only',
|
|
health: null,
|
|
labels: {},
|
|
ports: {},
|
|
mounts: [],
|
|
networks: dockerMan.network ? { [dockerMan.network]: {} } : {},
|
|
restartPolicy: '',
|
|
}],
|
|
dockerMan,
|
|
});
|
|
}
|
|
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 = deploymentRootCandidate(safeRelativeToBase(server.basePath, canonicalServerAppdataPath(server.basePath, group.workingDir)))
|
|
|| mounts.map((mount) => topLevelRelativeToBase(server.basePath, canonicalServerAppdataPath(server.basePath, mount?.Source))).find(Boolean)
|
|
|| '';
|
|
const workload = {
|
|
workloadId,
|
|
serverId: server.id,
|
|
serverName: server.name,
|
|
kind: selector.kind,
|
|
selector,
|
|
displayName: group.composeProject || primary?.name || group.dockerMan?.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'] || '',
|
|
composeStatus: group.composeStatus || '',
|
|
images: [...new Set(group.images || [])],
|
|
composeSource: group.composeSource || (group.configFiles?.length ? 'docker-compose-runtime' : ''),
|
|
composeDefinitionValid: group.composeDefinitionValid !== false,
|
|
composeDefinitionError: group.composeDefinitionError || '',
|
|
},
|
|
runtime: {
|
|
running: group.containers.some((container) => container.running === true),
|
|
allRunning: group.containers.length > 0 && 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,
|
|
canonicalServerAppdataPath,
|
|
deploymentRootCandidate,
|
|
};
|