'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, };