"use strict"; const fs = require("node:fs/promises"); const fileSystem = require("node:fs"); const path = require("node:path").posix; const nativePath = require("node:path"); const crypto = require("node:crypto"); const os = require("node:os"); const { shellQuote } = require("./ssh-service.cjs"); const { assertFullCommitSha } = require("../shared/validation.cjs"); const { run } = require("./process-runner.cjs"); const { parseServerInventory: parseWorkloadInventory, buildWorkloadInventory, inventoryContainerMatch: matchInventoryContainer, remoteIdentity: inventoryRemoteIdentity, } = require("./server-inventory.cjs"); const { classifyInventory } = require("./inventory-classifier.cjs"); const { createUnraidInventoryMethods } = require("./unraid-inventory-methods.cjs"); const { createUnraidAccessMethods } = require("./unraid-access-methods.cjs"); const { createUnraidPreflightMethods } = require("./unraid-preflight-methods.cjs"); const { createUnraidRuntimeMethods } = require("./unraid-runtime-methods.cjs"); const { createUnraidDeploymentMethods } = require("./unraid-deployment-methods.cjs"); const { createUnraidStateMethods } = require("./unraid-state-methods.cjs"); function safeRemoteFolder(value) { const text = String(value || "").trim().replace(/\\/g, "/").replace(/^\.\//, ""); if ( !text || path.isAbsolute(text) || text.split("/").some((part) => !part || part === "." || part === ".." || !/^[a-zA-Z0-9._-]+$/.test(part)) ) throw new Error("Remote folder must be a safe path below the configured server base path."); return text; } function safeRelativeRemoteFile(value, fallback = "") { const text = String(value || fallback) .trim() .replace(/\\/g, "/"); if ( !text || text.startsWith("/") || text.split("/").some((part) => !part || part === "." || part === "..") ) { throw new Error("Remote file path must remain inside the project folder."); } return text; } function bash(command) { const script = `set -euo pipefail export GIT_TERMINAL_PROMPT=0 export GIT_SSH_COMMAND='ssh -o BatchMode=yes' forgeflow_compose() { if docker compose version >/dev/null 2>&1; then docker compose "$@"; elif command -v docker-compose >/dev/null 2>&1; then docker-compose "$@"; else echo "Docker Compose is not available on the server." >&2; return 127; fi } ${command}`; const payload = Buffer.from(script, "utf8").toString("base64"); return `printf '%s' ${shellQuote(payload)} | base64 -d | bash`; } function parseInspection(text) { const jsonMarker = "__FORGEFLOW_JSON__"; const jsonIndex = text.lastIndexOf(jsonMarker); if (jsonIndex >= 0) return JSON.parse(text.slice(jsonIndex + jsonMarker.length).trim()); const kvMarker = "__FORGEFLOW_KV__"; const kvIndex = text.lastIndexOf(kvMarker); if (kvIndex < 0) throw new Error("The server inspection did not return a ForgeFlow result."); const fields = {}; for (const line of text .slice(kvIndex + kvMarker.length) .trim() .split(/\r?\n/)) { const separator = line.indexOf("="); if (separator > 0) fields[line.slice(0, separator)] = line.slice(separator + 1); } const decodeLines = (value) => { try { return value ? Buffer.from(value, "base64") .toString("utf8") .split(/\r?\n/) .filter(Boolean) : []; } catch { return []; } }; const decodeText = (value) => { try { return value ? Buffer.from(value, "base64").toString("utf8") : ""; } catch { return ""; } }; return { exists: fields.exists === "true", rootGit: fields.rootGit === "true", head: fields.head || null, branch: fields.branch || null, remote: fields.remote ? Buffer.from(fields.remote, "base64").toString("utf8") : null, trackedChanges: decodeLines(fields.trackedChanges), composeFiles: decodeLines(fields.composeFiles), nestedGit: decodeLines(fields.nestedGit), dockerfile: fields.dockerfile === "true", dockerignoreContent: decodeText(fields.dockerignoreContent), existingPreservePaths: decodeLines(fields.existingPreservePaths), }; } function dockerIgnoreHasPath(content, value) { const target = String(value || "") .replace(/\\/g, "/") .replace(/^\.\//, "") .replace(/^\//, "") .replace(/\/$/, ""); if (!target) return false; return String(content || "") .split(/\r?\n/) .some((line) => { let rule = line.trim(); if (!rule || rule.startsWith("#") || rule.startsWith("!")) return false; rule = rule.replace(/^\.\//, "").replace(/^\//, "").replace(/\/$/, ""); return ( rule === target || rule === `${target}/**` || rule === `${target}/**/*` ); }); } function checksSummary(checks) { const counts = { pass: checks.filter((item) => item.status === "pass").length, warning: checks.filter((item) => item.status === "warning").length, fail: checks.filter((item) => item.status === "fail").length, }; return { ready: counts.fail === 0, counts, blocking: checks .filter((item) => item.status === "fail") .map((item) => item.id), }; } function xmlEscape(value) { return String(value ?? "") .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function decodeBase64Json(value, fallback) { try { return value ? JSON.parse(Buffer.from(value, "base64").toString("utf8")) : fallback; } catch { return fallback; } } function parseDockerManXml(xml) { const text = String(xml || ""); const tag = (name) => { const match = text.match( new RegExp(`<${name}>([\\s\\S]*?)<\\/${name}>`, "i"), ); return match ? match[1] .replace(/&/g, "&") .replace(/</g, "<") .replace(/>/g, ">") .trim() : ""; }; return { name: tag("Name"), webUiUrl: tag("WebUI"), iconUrl: tag("Icon"), shell: tag("Shell"), }; } function parsePermissionInspection(text) { const marker = "__FORGEFLOW_PERMISSIONS__"; const index = String(text || "").lastIndexOf(marker); if (index < 0) throw new Error("The server permission check did not return a ForgeFlow marker."); const decode = (value) => { try { return value ? Buffer.from(value, "base64").toString("utf8") : ""; } catch { return ""; } }; const result = { identity: { user: "", uid: null, gid: null, groups: [], hasAcl: false, canElevate: false }, targets: [], }; for (const line of String(text) .slice(index + marker.length) .trim() .split(/\r?\n/)) { const parts = line.split("\t"); if (parts[0] === "I") { result.identity = { user: decode(parts[1]), uid: Number(parts[2]), gid: Number(parts[3]), groups: decode(parts[4]).split(/\s+/).filter(Boolean), hasAcl: parts[5] === "true", canElevate: parts[6] === "true", }; } else if (parts[0] === "P") { result.targets.push({ id: decode(parts[1]), label: decode(parts[2]), path: decode(parts[3]), kind: parts[4] || "directory", required: parts[5] === "true", exists: parts[6] === "true", readable: parts[7] === "true", writable: parts[8] === "true", parentWritable: parts[9] === "true", effectiveWritable: parts[10] === "true", owner: decode(parts[11]), group: decode(parts[12]), mode: parts[13] || "", nearestWritableAncestor: decode(parts[14]), detail: decode(parts[15]), }); } } result.blocking = result.targets.filter( (target) => target.required && !target.effectiveWritable, ); result.ready = result.blocking.length === 0; result.repairable = result.blocking.some((target) => target.id !== "server-base"); return result; } function deriveDetectedProfile({ repository, server, remoteFolder, remotePath, payload, }) { const compose = payload.compose || {}; const services = compose.services && typeof compose.services === "object" ? compose.services : {}; const inspections = Array.isArray(payload.containers) ? payload.containers : []; const primaryContainer = inspections.find((item) => item?.State?.Running) || inspections[0] || null; const labels = primaryContainer?.Config?.Labels || {}; const serviceName = labels["com.docker.compose.service"] || Object.keys(services)[0] || remoteFolder; const service = services[serviceName] || {}; const containerName = String( primaryContainer?.Name || service.container_name || serviceName, ).replace(/^\//, ""); const ports = []; for (const [containerKey, bindings] of Object.entries( primaryContainer?.NetworkSettings?.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 }); } const primaryPort = ports.find((item) => item.hostPort) || ports[0] || {}; const mounts = (primaryContainer?.Mounts || []).map((item) => ({ type: item.Type, source: item.Source, target: item.Destination, readOnly: item.RW === false, })); const networks = Object.keys( primaryContainer?.NetworkSettings?.Networks || {}, ); const envNames = (primaryContainer?.Config?.Env || []) .map((item) => String(item).split("=")[0]) .filter(Boolean); const dockerMan = parseDockerManXml(payload.dockerManXml || ""); const webUiUrl = dockerMan.webUiUrl || labels["net.unraid.docker.webui"] || ""; const iconUrl = dockerMan.iconUrl || labels["net.unraid.docker.icon"] || ""; const shell = dockerMan.shell || labels["net.unraid.docker.shell"] || "/bin/sh"; const preservePaths = [ ...new Set([ ".env", "appdata", "data", "logs", "config", "compose.override.yml", ...mounts .filter((item) => String(item.source || "").startsWith(`${remotePath}/`), ) .map( (item) => String(item.source) .slice(remotePath.length + 1) .split("/")[0], ) .filter(Boolean), ]), ]; const source = (value, origin, confidence = "confirmed") => ({ value, origin, confidence, detectedAt: new Date().toISOString(), overridden: false, }); const composeFiles = payload.composeFiles || []; const composeFile = composeFiles[0] || labels["com.docker.compose.project.config_files"] ?.split(",")[0] ?.replace(`${remotePath}/`, "") || "docker-compose.yml"; return { profile: { name: "Production", environment: "production", provider: "ssh-unraid", branch: payload.branch || repository.defaultBranch || "main", serverId: server.id, remoteFolder, cloneUrl: payload.remote || repository.sshUrl || "", alignRemote: false, generatedCompose: false, composeFile, composeService: serviceName, containerName, hostPort: primaryPort.hostPort || null, containerPort: primaryPort.containerPort || null, webUiUrl, iconMode: /^https?:\/\//i.test(iconUrl) ? "url" : "none", iconUrl: /^https?:\/\//i.test(iconUrl) ? iconUrl : "", serverIconReference: iconUrl, iconFilePath: "", dockerShell: ["/bin/bash", "/bin/sh"].includes(shell) ? shell : "/bin/sh", healthcheckUrl: "", preservePaths, confirmationRequired: true, adoptedFromServer: true, serverSourceOfTruth: true, detectedAt: new Date().toISOString(), detectedMetadata: { head: payload.head || null, composeProject: labels["com.docker.compose.project"] || "", composeFiles, services: Object.keys(services), ports, mounts, networks, envNames, restartPolicy: primaryContainer?.HostConfig?.RestartPolicy?.Name || "", healthcheck: primaryContainer?.Config?.Healthcheck || null, image: primaryContainer?.Config?.Image || service.image || "", dockerMan, }, }, provenance: { remoteFolder: source(remoteFolder, "server-path"), cloneUrl: source(payload.remote || "", "git-origin"), branch: source(payload.branch || "", "git"), composeFile: source(composeFile, "docker-compose"), composeService: source(serviceName, "docker-labels"), containerName: source(containerName, "docker-inspect"), hostPort: source(primaryPort.hostPort || null, "docker-inspect"), containerPort: source( primaryPort.containerPort || null, "docker-inspect", ), webUiUrl: source( webUiUrl, dockerMan.webUiUrl ? "unraid-dockerman" : "docker-labels", ), iconUrl: source( iconUrl, dockerMan.iconUrl ? "unraid-dockerman" : "docker-labels", ), dockerShell: source( shell, dockerMan.shell ? "unraid-dockerman" : "docker-labels", ), }, runtime: { remotePath, containerRunning: Boolean(primaryContainer?.State?.Running), containers: inspections.length, services: Object.keys(services).length, ports, mounts, networks, envNames, }, }; } function iconReferenceLocalPath(iconReference) { const value = String(iconReference || "").trim(); if (value.startsWith("file:///")) return `/${value.slice("file:///".length)}`; if (value.startsWith("/")) return value; return ""; } class UnraidDeploymentService { constructor({ store, ssh, git, gitea, diagnostics, sourcePath = process.cwd(), onOperationChange = null, }) { this.store = store; this.ssh = ssh; this.git = git; this.gitea = gitea; this.diagnostics = diagnostics; this.sourcePath = sourcePath; this.onOperationChange = onOperationChange; } } const stateMethods = createUnraidStateMethods({ path, bash, shellQuote, inventoryRemoteIdentity }); for (const name of Object.getOwnPropertyNames(stateMethods)) { if (name !== "constructor") Object.defineProperty(UnraidDeploymentService.prototype, name, Object.getOwnPropertyDescriptor(stateMethods, name)); } const deploymentMethods = createUnraidDeploymentMethods({ path, crypto, bash, shellQuote, assertFullCommitSha, nativePath, fs, }); for (const name of Object.getOwnPropertyNames(deploymentMethods)) { if (name !== "constructor") Object.defineProperty(UnraidDeploymentService.prototype, name, Object.getOwnPropertyDescriptor(deploymentMethods, name)); } const runtimeMethods = createUnraidRuntimeMethods({ safeRelativeRemoteFile, xmlEscape, nativePath, fileSystem, fs, crypto, os, run, bash, shellQuote, iconReferenceLocalPath, }); for (const name of Object.getOwnPropertyNames(runtimeMethods)) { if (name !== "constructor") Object.defineProperty(UnraidDeploymentService.prototype, name, Object.getOwnPropertyDescriptor(runtimeMethods, name)); } const preflightMethods = createUnraidPreflightMethods({ safeRemoteFolder, path, bash, parseInspection, dockerIgnoreHasPath, checksSummary, inventoryRemoteIdentity, deriveDetectedProfile, decodeBase64Json, shellQuote, assertFullCommitSha, nativePath, safeRelativeRemoteFile, fs, }); for (const name of Object.getOwnPropertyNames(preflightMethods)) { if (name !== "constructor") Object.defineProperty(UnraidDeploymentService.prototype, name, Object.getOwnPropertyDescriptor(preflightMethods, name)); } const accessMethods = createUnraidAccessMethods({ shellQuote, path, bash, inventoryRemoteIdentity, checksSummary, crypto, parsePermissionInspection, safeRelativeRemoteFile }); for (const name of Object.getOwnPropertyNames(accessMethods)) { if (name !== "constructor") Object.defineProperty(UnraidDeploymentService.prototype, name, Object.getOwnPropertyDescriptor(accessMethods, name)); } const inventoryMethods = createUnraidInventoryMethods({ shellQuote, path, parseWorkloadInventory, buildWorkloadInventory, classifyInventory, inventoryRemoteIdentity, deriveDetectedProfile, crypto, matchInventoryContainer, safeRemoteFolder, bash, }); for (const name of Object.getOwnPropertyNames(inventoryMethods)) { if (name !== "constructor") Object.defineProperty(UnraidDeploymentService.prototype, name, Object.getOwnPropertyDescriptor(inventoryMethods, name)); } module.exports = { UnraidDeploymentService, safeRemoteFolder, safeRelativeRemoteFile, parseInspection, dockerIgnoreHasPath, checksSummary, xmlEscape, iconReferenceLocalPath, decodeBase64Json, parseDockerManXml, parsePermissionInspection, parseServerInventory: parseWorkloadInventory, inventoryContainerMatch: matchInventoryContainer, remoteIdentity: inventoryRemoteIdentity, deriveDetectedProfile, bash, };