Files
ForgeFlow/src/main/server-inventory.cjs
T
2026-07-27 23:15:35 +02:00

404 lines
18 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: [],
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,
};