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