"use strict"; const fs = require("node:fs/promises"); const path = require("node:path").posix; const nativePath = require("node:path"); const crypto = require("node:crypto"); const { shellQuote } = require("./ssh-service.cjs"); const { assertFullCommitSha } = require("../shared/validation.cjs"); const { normalizeRemoteUrl } = require("../shared/repository-match.cjs"); function safeRemoteFolder(value) { const text = String(value || "").trim(); if (!/^[a-zA-Z0-9._-]+$/.test(text) || text === "." || text === "..") throw new Error("Remote folder contains unsupported characters."); 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\nexport GIT_TERMINAL_PROMPT=0\nexport GIT_SSH_COMMAND='ssh -o BatchMode=yes'\n${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 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, diagnostics, sourcePath = process.cwd(), onOperationChange = null, }) { this.store = store; this.ssh = ssh; this.git = git; this.diagnostics = diagnostics; this.sourcePath = sourcePath; this.onOperationChange = onOperationChange; } async saveOperation(operation) { const saved = await this.store.addOperation(operation); this.onOperationChange?.({ operations: [saved] }); return saved; } resolve(repository, profileId) { const profile = this.store.getDeploymentProfile( repository.fullName, profileId, ); if (!profile || profile.provider !== "ssh-unraid") throw new Error("The SSH / Unraid deployment profile no longer exists."); const server = this.store.getServer(profile.serverId); if (!server) throw new Error("The deployment server no longer exists."); const remoteFolder = safeRemoteFolder( profile.remoteFolder || repository.name, ); const remotePath = path.join(server.basePath, remoteFolder); if (!remotePath.startsWith(`${server.basePath}/`)) throw new Error( "Remote project path escapes the configured server base path.", ); return { profile, server, remoteFolder, remotePath }; } async discoverExisting({ repository, serverId, remoteFolder = "" }) { const server = this.store.getServer(serverId); if (!server) throw new Error("The deployment server no longer exists."); const folder = safeRemoteFolder(remoteFolder || repository.name); const remotePath = path.join(server.basePath, folder); if (!remotePath.startsWith(`${server.basePath}/`)) throw new Error( "Remote project path escapes the configured server base path.", ); const script = ` root=${shellQuote(remotePath)} test -d "$root" || { echo "Existing server folder not found: $root" >&2; exit 44; } head=$(git -C "$root" rev-parse HEAD 2>/dev/null || true) branch=$(git -C "$root" branch --show-current 2>/dev/null || true) remote=$(git -C "$root" remote get-url origin 2>/dev/null || true) compose_files=$(find "$root" -maxdepth 2 -type f \\( -name 'docker-compose.yml' -o -name 'docker-compose.yaml' -o -name 'compose.yml' -o -name 'compose.yaml' \\) -printf '%P\\n' 2>/dev/null | sort) compose_file=$(printf '%s\\n' "$compose_files" | head -n1) compose_json='{}' container_json='[]' if [ -n "$compose_file" ] && command -v docker >/dev/null 2>&1; then compose_json=$(cd "$root" && docker compose -f "$compose_file" config --format json 2>/dev/null || printf '{}') ids=$(cd "$root" && docker compose -f "$compose_file" ps -aq 2>/dev/null || true) [ -n "$ids" ] && container_json=$(docker inspect $ids 2>/dev/null || printf '[]') fi container_name=$(printf '%s' "$container_json" | sed -n 's/.*"Name"[[:space:]]*:[[:space:]]*"\\/\\([^" ]*\\)".*/\\1/p' | head -n1) dockerman_xml='' if [ -n "$container_name" ] && [ -d /boot/config/plugins/dockerMan/templates-user ]; then template=$(grep -ril "${container_name}" /boot/config/plugins/dockerMan/templates-user 2>/dev/null | head -n1 || true) [ -n "$template" ] && dockerman_xml=$(cat "$template") fi printf '__FORGEFLOW_DISCOVERY__\\n' printf 'head=%s\\n' "$head" printf 'branch=%s\\n' "$branch" printf 'remote=%s\\n' "$(printf '%s' "$remote" | base64 | tr -d '\\r\\n')" printf 'composeFiles=%s\\n' "$(printf '%s\\n' "$compose_files" | base64 | tr -d '\\r\\n')" printf 'compose=%s\\n' "$(printf '%s' "$compose_json" | base64 | tr -d '\\r\\n')" printf 'containers=%s\\n' "$(printf '%s' "$container_json" | base64 | tr -d '\\r\\n')" printf 'dockerManXml=%s\\n' "$(printf '%s' "$dockerman_xml" | base64 | tr -d '\\r\\n')" `; const result = await this.ssh.exec(server.id, bash(script), { timeout: 90_000, maxOutput: 8 * 1024 * 1024, }); const marker = "__FORGEFLOW_DISCOVERY__"; const index = result.stdout.lastIndexOf(marker); if (index < 0) throw new Error("The server did not return deployment discovery data."); const fields = {}; for (const line of result.stdout .slice(index + marker.length) .trim() .split(/\r?\n/)) { const split = line.indexOf("="); if (split > 0) fields[line.slice(0, split)] = line.slice(split + 1); } const payload = { head: fields.head || null, branch: fields.branch || null, remote: fields.remote ? Buffer.from(fields.remote, "base64").toString("utf8") : "", composeFiles: fields.composeFiles ? Buffer.from(fields.composeFiles, "base64") .toString("utf8") .split(/\r?\n/) .filter(Boolean) : [], compose: decodeBase64Json(fields.compose, {}), containers: decodeBase64Json(fields.containers, []), dockerManXml: fields.dockerManXml ? Buffer.from(fields.dockerManXml, "base64").toString("utf8") : "", }; const discovery = deriveDetectedProfile({ repository, server, remoteFolder: folder, remotePath, payload, }); await this.diagnostics?.info("unraid.existing-discovered", { repository: repository.fullName, serverId, remotePath, containers: discovery.runtime.containers, services: discovery.runtime.services, }); return discovery; } async inspect({ repository, profileId }) { const { profile, server, remotePath } = this.resolve(repository, profileId); const preserveProbe = (profile.preservePaths || []) .map( (relativePath) => `if [ -e "$root"/${shellQuote(relativePath)} ]; then printf '%s\\n' ${shellQuote(relativePath)}; fi`, ) .join("\n"); const script = ` root=${shellQuote(remotePath)} exists=false; root_git=false; head=""; branch=""; remote=""; tracked_changes=""; compose_files=""; nested_git=""; dockerfile=false; dockerignore_content=""; existing_preserve_paths="" if [ -d "$root" ]; then exists=true if [ -d "$root/.git" ]; then root_git=true head=$(git -C "$root" rev-parse HEAD 2>/dev/null || true) branch=$(git -C "$root" branch --show-current 2>/dev/null || true) remote=$(git -C "$root" remote get-url origin 2>/dev/null || true) tracked_changes=$(git -C "$root" status --porcelain --untracked-files=no 2>/dev/null | head -n 25 | base64 | tr -d '\\r\\n' || true) fi compose_files=$(find "$root" -maxdepth 2 -type f \\( -name 'docker-compose.yml' -o -name 'docker-compose.yaml' -o -name 'compose.yml' -o -name 'compose.yaml' -o -name 'compose.forgeflow.yml' \\) -printf '%P\\n' 2>/dev/null | sort | base64 | tr -d '\\r\\n' || true) nested_git=$(find "$root" -mindepth 2 -maxdepth 5 -type d -name .git -printf '%h\\n' 2>/dev/null | sed "s#^$root/##" | sort | base64 | tr -d '\\r\\n' || true) [ -f "$root/Dockerfile" ] && dockerfile=true [ -f "$root/.dockerignore" ] && dockerignore_content=$(base64 < "$root/.dockerignore" | tr -d '\\r\\n' || true) existing_preserve_paths=$({ ${preserveProbe || ":"}; } | sort -u | base64 | tr -d '\\r\\n' || true) fi printf '__FORGEFLOW_KV__\\n' printf 'exists=%s\\n' "$exists" printf 'rootGit=%s\\n' "$root_git" printf 'head=%s\\n' "$head" printf 'branch=%s\\n' "$branch" printf 'remote=%s\\n' "$(printf '%s' "$remote" | base64 | tr -d '\\r\\n')" printf 'trackedChanges=%s\\n' "$tracked_changes" printf 'composeFiles=%s\\n' "$compose_files" printf 'nestedGit=%s\\n' "$nested_git" printf 'dockerfile=%s\\n' "$dockerfile" printf 'dockerignoreContent=%s\\n' "$dockerignore_content" printf 'existingPreservePaths=%s\\n' "$existing_preserve_paths" `; const wrapped = bash(script); const result = await this.ssh.exec(server.id, wrapped, { timeout: 60_000 }); const parsed = parseInspection(result.stdout); const contextCandidates = [ ...new Set([ ...(parsed.existingPreservePaths || []), ...(parsed.nestedGit || []), ]), ]; const inspection = { ...parsed, dockerignore: Boolean(parsed.dockerignoreContent), dockerignoreGitExcluded: dockerIgnoreHasPath( parsed.dockerignoreContent, ".git", ), dockerContextExclusionsMissing: parsed.dockerfile ? contextCandidates.filter( (item) => !dockerIgnoreHasPath(parsed.dockerignoreContent, item), ) : [], serverId: server.id, serverName: server.name, remotePath, profileId: profile.id, }; await this.diagnostics?.info("unraid.inspected", { repository: repository.fullName, serverId: server.id, remotePath, exists: inspection.exists, rootGit: inspection.rootGit, head: inspection.head, composeFiles: inspection.composeFiles, nestedGitCount: inspection.nestedGit.length, trackedChangeCount: inspection.trackedChanges.length, dockerContextExclusionsMissing: inspection.dockerContextExclusionsMissing, }); return inspection; } async preflight({ repository, profileId, sha = null }) { const { profile, server, remotePath } = this.resolve(repository, profileId); const targetSha = assertFullCommitSha(sha || repository.localStatus?.head); const checks = []; let inspection = null; if (!repository.localPath) { checks.push({ id: "local-repository", label: "Local repository", status: "fail", detail: "Link or clone the repository locally before deploying.", }); } else { try { const localStatus = await this.git.status(repository.localPath); checks.push({ id: "local-repository", label: "Local repository", status: "pass", detail: localStatus.root, }); checks.push({ id: "local-branch", label: "Allowed branch", status: localStatus.branch.head === profile.branch ? "pass" : "fail", detail: `Current: ${localStatus.branch.head || "detached"}; required: ${profile.branch}.`, }); checks.push({ id: "local-clean", label: "Clean local working tree", status: localStatus.clean ? "pass" : "fail", detail: localStatus.clean ? "No uncommitted changes." : `${localStatus.counts.changed} changed file(s) remain.`, }); checks.push({ id: "local-upstream", label: "Published upstream", status: localStatus.branch.upstream ? "pass" : "fail", detail: localStatus.branch.upstream || "No upstream branch is configured.", }); checks.push({ id: "local-sync", label: "Local and Gitea synchronized", status: !localStatus.branch.ahead && !localStatus.branch.behind ? "pass" : "fail", detail: `${localStatus.branch.ahead || 0} ahead, ${localStatus.branch.behind || 0} behind.`, }); checks.push({ id: "local-target-sha", label: "Selected deployment commit", status: localStatus.head === targetSha ? "pass" : "fail", detail: localStatus.head === targetSha ? targetSha : `Local HEAD is ${localStatus.head || "unknown"}, but deployment requested ${targetSha}.`, }); try { await this.git.verifyCommitOnRemoteBranch( repository.localPath, targetSha, profile.branch, ); checks.push({ id: "remote-target-sha", label: "Exact commit on Gitea branch", status: "pass", detail: `${targetSha.slice(0, 7)} exists on origin/${profile.branch}.`, }); } catch (error) { checks.push({ id: "remote-target-sha", label: "Exact commit on Gitea branch", status: "fail", detail: error.message, }); } const localDeploymentFile = profile.generatedCompose ? nativePath.join(repository.localPath, "Dockerfile") : nativePath.join( repository.localPath, safeRelativeRemoteFile( profile.composeFile || "docker-compose.yml", ), ); const localDeploymentFileExists = Boolean( (await fs.stat(localDeploymentFile).catch(() => null))?.isFile(), ); checks.push({ id: "local-deployment-file", label: profile.generatedCompose ? "Dockerfile in repository" : "Compose file in repository", status: localDeploymentFileExists ? "pass" : "fail", detail: localDeploymentFileExists ? localDeploymentFile : `${localDeploymentFile} was not found in the exact local checkout.`, }); } catch (error) { checks.push({ id: "local-repository", label: "Local repository", status: "fail", detail: error.message, }); } } try { const connection = await this.ssh.test(server.id, { trustOnFirstUse: false, }); checks.push({ id: "ssh", label: "SSH connection", status: "pass", detail: `${server.username}@${server.host}:${server.port}`, }); if (!/docker compose|docker-compose/i.test(connection.output)) { checks.push({ id: "compose-command", label: "Docker Compose", status: "fail", detail: "Docker Compose was not detected on the server.", }); } else checks.push({ id: "compose-command", label: "Docker Compose", status: "pass", detail: "Docker Compose is available.", }); } catch (error) { checks.push({ id: "ssh", label: "SSH connection", status: "fail", detail: error.message, }); } if (!server.hostFingerprint) checks.push({ id: "host-key", label: "Server identity", status: "fail", detail: "Test and trust the SSH host key first.", }); else checks.push({ id: "host-key", label: "Server identity", status: "pass", detail: server.hostFingerprint, }); const cloneUrl = String( profile.cloneUrl || repository.sshUrl || repository.preferredCloneUrl || "", ).trim(); if (!cloneUrl) { checks.push({ id: "server-git-access", label: "Unraid โ†’ Gitea access", status: "fail", detail: "No server-usable Git clone URL is configured.", }); } else { try { const branchRef = `refs/heads/${String(profile.branch || "main")}`; const probe = await this.ssh.exec( server.id, bash( `git ls-remote --exit-code ${shellQuote(cloneUrl)} ${shellQuote(branchRef)}`, ), { timeout: 45_000, maxOutput: 256 * 1024 }, ); const remoteSha = String(probe.stdout || "") .trim() .split(/\s+/)[0] || "reachable"; checks.push({ id: "server-git-access", label: "Unraid โ†’ Gitea access", status: "pass", detail: `${cloneUrl} ยท ${String(remoteSha).slice(0, 7)}`, }); } catch (error) { checks.push({ id: "server-git-access", label: "Unraid โ†’ Gitea access", status: "fail", detail: `Unraid cannot read the repository with the configured clone URL: ${error.message}`, }); } } try { inspection = await this.inspect({ repository, profileId }); if (!inspection.exists) { checks.push({ id: "remote-folder", label: "Remote project folder", status: "pass", detail: `${remotePath} will be created.`, }); } else if (!inspection.rootGit) { checks.push({ id: "remote-folder", label: "Remote project folder", status: "fail", detail: `${remotePath} exists but is not a Git working tree. Adopt or migrate it before deployment.`, }); } else { checks.push({ id: "remote-folder", label: "Remote Git working tree", status: "pass", detail: `${remotePath} at ${String(inspection.head || "").slice(0, 7) || "unknown"}.`, }); } if (inspection.trackedChanges.length) { checks.push({ id: "tracked-changes", label: "Server-side tracked changes", status: "fail", detail: `${inspection.trackedChanges.length} tracked change(s) would be overwritten. Commit, revert or migrate them first.`, }); } else if (inspection.rootGit) checks.push({ id: "tracked-changes", label: "Server-side tracked changes", status: "pass", detail: "No tracked server-only edits detected.", }); if (inspection.rootGit && profile.cloneUrl && inspection.remote) { const expectedRemote = normalizeRemoteUrl(profile.cloneUrl); const currentRemote = normalizeRemoteUrl(inspection.remote); const matches = Boolean( expectedRemote && currentRemote && expectedRemote.host === currentRemote.host && expectedRemote.path === currentRemote.path, ); if (!matches && profile.alignRemote) { checks.push({ id: "origin-url", label: "Server Git origin", status: "warning", detail: `Origin will be aligned from ${inspection.remote} to the configured clone URL before fetch.`, }); } else if (!matches) { checks.push({ id: "origin-url", label: "Server Git origin", status: "fail", detail: `Current origin ${inspection.remote} does not match the configured clone URL. Enable controlled origin alignment or correct the profile.`, }); } else { checks.push({ id: "origin-url", label: "Server Git origin", status: "pass", detail: inspection.remote, }); } } if (inspection.nestedGit.length) { checks.push({ id: "nested-git", label: "Nested Git repositories", status: "warning", detail: `Detected: ${inspection.nestedGit.join(", ")}. ForgeFlow will not delete them automatically.`, }); } if (inspection.dockerfile && !inspection.dockerignore) { checks.push({ id: "dockerignore", label: "Docker build context", status: "warning", detail: "A Dockerfile exists but .dockerignore is missing. Add one in the repository before large builds.", }); } else if (inspection.dockerfile && !inspection.dockerignoreGitExcluded) { checks.push({ id: "dockerignore-git", label: "Git metadata excluded from Docker", status: "warning", detail: ".dockerignore does not explicitly exclude .git.", }); } else if (inspection.dockerfile) { checks.push({ id: "dockerignore-git", label: "Git metadata excluded from Docker", status: "pass", detail: ".git is excluded from the Docker build context.", }); } if (inspection.dockerContextExclusionsMissing.length) { checks.push({ id: "dockerignore-runtime", label: "Runtime data excluded from Docker", status: "warning", detail: `Add these existing runtime or legacy paths to .dockerignore: ${inspection.dockerContextExclusionsMissing.join(", ")}.`, }); } else if ( inspection.dockerfile && inspection.existingPreservePaths.length ) { checks.push({ id: "dockerignore-runtime", label: "Runtime data excluded from Docker", status: "pass", detail: "Detected preserved runtime paths are excluded from the Docker build context.", }); } const composeFile = safeRelativeRemoteFile( profile.composeFile || "docker-compose.yml", ); if ( inspection.exists && !inspection.composeFiles.includes(composeFile) && !profile.generatedCompose ) { checks.push({ id: "compose-file", label: "Compose configuration", status: "fail", detail: `${composeFile} was not found. Select an existing file or enable generated Compose.`, }); } else { checks.push({ id: "compose-file", label: "Compose configuration", status: "pass", detail: profile.generatedCompose ? "ForgeFlow will generate an isolated Compose file." : composeFile, }); } } catch (error) { checks.push({ id: "inspection", label: "Server project inspection", status: "fail", detail: error.message, }); } const iconMode = profile.iconMode || (profile.iconFilePath ? "upload" : profile.iconUrl ? "url" : "builtin"); if (iconMode === "upload") { const iconStat = await fs.stat(profile.iconFilePath).catch(() => null); checks.push({ id: "dockerman-icon-file", label: "DockerMan icon upload", status: iconStat?.isFile() && nativePath.extname(profile.iconFilePath).toLowerCase() === ".png" ? "pass" : "fail", detail: iconStat?.isFile() ? profile.iconFilePath : "The selected local PNG icon file was not found.", }); } else if (iconMode === "builtin") { const builtinIcon = nativePath.join( this.sourcePath, "src", "renderer", "assets", "itworx-mark.png", ); const iconStat = await fs.stat(builtinIcon).catch(() => null); checks.push({ id: "dockerman-icon-builtin", label: "DockerMan icon", status: iconStat?.isFile() ? "pass" : "fail", detail: iconStat?.isFile() ? "Built-in high-contrast ITWorx mark." : "The built-in ITWorx icon asset is missing.", }); } else if (iconMode === "url") checks.push({ id: "dockerman-icon", label: "DockerMan icon", status: profile.iconUrl ? "pass" : "fail", detail: profile.iconUrl || "Icon URL mode requires an HTTPS or HTTP PNG URL.", }); else checks.push({ id: "dockerman-icon", label: "DockerMan icon", status: "warning", detail: "Custom DockerMan icon disabled.", }); const webUiLabel = this.dockerManWebUi(profile); checks.push({ id: "dockerman-webui", label: "DockerMan Web UI action", status: webUiLabel ? "pass" : "warning", detail: webUiLabel || "No Web UI URL or host port is configured.", }); checks.push({ id: "compose-identity", label: "Safe Docker Compose identity", status: "pass", detail: `Internal project/image: ${this.internalSlug(profile, repository)}; visible container: ${profile.containerName || profile.remoteFolder || repository.name}.`, }); checks.push({ id: "exact-sha", label: "Exact deployment commit", status: "pass", detail: targetSha, }); return { provider: "ssh-unraid", repository: repository.fullName, environment: profile.environment, sha: targetSha, server: { id: server.id, name: server.name, host: server.host }, remotePath, inspection, checks, summary: checksSummary(checks), }; } internalSlug(profile, repository) { return ( String( profile.remoteFolder || repository.name || profile.composeService || "app", ) .toLowerCase() .replace(/[^a-z0-9._-]+/g, "-") .replace(/^-+|-+$/g, "") || "app" ); } generatedCompose(profile, repository) { const service = String(profile.composeService || repository.name || "app") .toLowerCase() .replace(/[^a-z0-9._-]/g, "-") || "app"; const containerName = String( profile.containerName || profile.remoteFolder || repository.name || service, ).replace(/[^A-Za-z0-9._-]/g, "-") || service; if (!profile.hostPort || !profile.containerPort) throw new Error( "Host and container ports are required for generated Compose.", ); return ( [ "services:", ` ${service}:`, ` image: forgeflow/${this.internalSlug(profile, repository)}:${String(profile.environment || "production").toLowerCase()}`, " build:", " context: ..", ` container_name: ${containerName}`, " restart: unless-stopped", " ports:", ` - "${profile.hostPort}:${profile.containerPort}"`, ].join("\n") + "\n" ); } dockerManWebUi(profile) { if (profile.hostPort) { let suffix = "/"; try { const parsed = profile.webUiUrl ? new URL(profile.webUiUrl) : null; suffix = parsed ? `${parsed.pathname || "/"}${parsed.search || ""}${parsed.hash || ""}` : "/"; } catch {} if (!suffix.startsWith("/")) suffix = `/${suffix}`; return `http://[IP]:[PORT:${profile.hostPort}]${suffix}`; } return profile.webUiUrl || ""; } dockerManShell(profile) { return String(profile.dockerShell || "/bin/sh") .toLowerCase() .includes("bash") ? "bash" : "sh"; } dockerManTemplatePath(profile, repository) { const containerName = String( profile.containerName || profile.remoteFolder || repository.name || "app", ).replace(/[^A-Za-z0-9._-]/g, "-") || "app"; return `/boot/config/plugins/dockerMan/templates-user/my-${containerName}.xml`; } dockerManTemplate(profile, repository, iconReference = "") { const containerName = String( profile.containerName || profile.remoteFolder || repository.name || "app", ).replace(/[^A-Za-z0-9._-]/g, "-") || "app"; const slug = this.internalSlug(profile, repository); const environment = String(profile.environment || "production") .toLowerCase() .replace(/[^a-z0-9._-]/g, "-") || "production"; const image = `forgeflow/${slug}:${environment}`; const webUi = this.dockerManWebUi(profile); return ( [ '', '', ` ${xmlEscape(containerName)}`, ` ${xmlEscape(image)}`, " ", " bridge", " ", ` ${xmlEscape(this.dockerManShell(profile))}`, " false", " ", " ", " Managed by ForgeFlow through Docker Compose. Use ForgeFlow or the Compose files for configuration changes.", " Tools:", ` ${xmlEscape(webUi)}`, " ", ` ${xmlEscape(iconReference)}`, " ", " ", " ", " ", " ", "", ].join("\n") + "\n" ); } iconCacheRefresh(profile, repository, iconReference = "") { const containerName = String( profile.containerName || profile.remoteFolder || repository.name || "app", ).replace(/[^A-Za-z0-9._-]/g, "-") || "app"; const cacheLoop = `for icon_dir in /var/lib/docker/unraid/images /usr/local/emhttp/state/plugins/dynamix.docker.manager/images /var/local/emhttp/plugins/dynamix.docker.manager/images; do [ -d "$icon_dir" ] || continue; rm -f "$icon_dir/${containerName}-icon.png" "$icon_dir/${containerName}.png"; done`; const invalidateMetadata = `rm -f /usr/local/emhttp/state/plugins/dynamix.docker.manager/docker.json`; const localIconPath = iconReferenceLocalPath(iconReference); if (!localIconPath) return `${cacheLoop}\n${invalidateMetadata}`; return `${cacheLoop} if [ -f ${shellQuote(localIconPath)} ]; then for icon_dir in /var/lib/docker/unraid/images /usr/local/emhttp/state/plugins/dynamix.docker.manager/images /var/local/emhttp/plugins/dynamix.docker.manager/images; do [ -d "$icon_dir" ] || continue; cp ${shellQuote(localIconPath)} "$icon_dir/${containerName}-icon.png"; chmod 0644 "$icon_dir/${containerName}-icon.png"; done; fi ${invalidateMetadata}`; } dockerManRefreshScript(profile, repository, iconReference = "") { const templatePath = this.dockerManTemplatePath(profile, repository); const template = this.dockerManTemplate(profile, repository, iconReference); return `mkdir -p /boot/config/plugins/dockerMan/templates-user cat > ${shellQuote(templatePath)} <<'FORGEFLOW_DOCKERMAN_TEMPLATE' ${template}FORGEFLOW_DOCKERMAN_TEMPLATE chmod 0644 ${shellQuote(templatePath)} ${this.iconCacheRefresh(profile, repository, iconReference)}`; } metadataCompose(profile, repository, iconReference = "") { const service = String(profile.composeService || repository.name || "app") .trim() .toLowerCase() .replace(/[^a-z0-9._-]/g, "-") || "app"; const containerName = String( profile.containerName || profile.remoteFolder || repository.name || service, ).replace(/[^A-Za-z0-9._-]/g, "-") || "app"; const slug = this.internalSlug(profile, repository); const labels = { "net.unraid.docker.managed": "dockerman", "net.unraid.docker.shell": this.dockerManShell(profile), }; const webUiLabel = this.dockerManWebUi(profile); if (webUiLabel) labels["net.unraid.docker.webui"] = webUiLabel; if (iconReference) labels["net.unraid.docker.icon"] = iconReference; return ( [ "services:", ` ${service}:`, ` image: forgeflow/${slug}:${String( profile.environment || "production", ) .toLowerCase() .replace(/[^a-z0-9._-]/g, "-")}`, ` container_name: ${containerName}`, " labels:", ...Object.entries(labels).map( ([key, value]) => ` ${JSON.stringify(key)}: ${JSON.stringify(value)}`, ), ].join("\n") + "\n" ); } async prepareIcon(profile, repository, server) { const mode = profile.iconMode || (profile.iconFilePath ? "upload" : profile.iconUrl ? "url" : "builtin"); if (mode === "none") return ""; if (mode === "url") { if (!profile.iconUrl) throw new Error( "DockerMan icon URL mode is selected, but no icon URL is configured.", ); return profile.iconUrl; } const localIconPath = mode === "builtin" ? nativePath.join( this.sourcePath, "src", "renderer", "assets", "itworx-mark.png", ) : profile.iconFilePath; const stat = await fs.stat(localIconPath).catch(() => null); if (!stat?.isFile()) throw new Error( mode === "builtin" ? "The built-in ITWorx DockerMan icon is missing." : `The selected DockerMan icon file no longer exists: ${localIconPath}`, ); if (nativePath.extname(localIconPath).toLowerCase() !== ".png") throw new Error( "DockerMan icon upload currently accepts PNG files only.", ); const containerName = String( profile.containerName || profile.remoteFolder || repository.name || "app", ).replace(/[^A-Za-z0-9._-]/g, "-") || "app"; const remoteIconPath = `/boot/config/plugins/dockerMan/images/${containerName}-icon.png`; await this.ssh.uploadFile(server.id, localIconPath, remoteIconPath, { mode: 0o644, }); return `file://${remoteIconPath}`; } composeInvocation(profile, repository, composeFile) { const slug = this.internalSlug(profile, repository); return `docker compose -p ${shellQuote(slug)} -f ${shellQuote(composeFile)} -f '.forgeflow/compose.metadata.yml'`; } async checkHealth(url) { if (!url) return { configured: false, healthy: null, status: null, latencyMs: null, }; let last = null; for (let attempt = 1; attempt <= 5; attempt += 1) { const started = Date.now(); try { const response = await fetch(url, { signal: AbortSignal.timeout(8_000), redirect: "manual", }); last = { configured: true, healthy: response.ok, status: response.status, latencyMs: Date.now() - started, }; if (response.ok) return last; } catch (error) { last = { configured: true, healthy: false, status: null, latencyMs: Date.now() - started, error: error.message, }; } if (attempt < 5) await new Promise((resolve) => setTimeout(resolve, 3_000)); } return last; } async deploy({ repository, profileId, sha }) { const targetSha = assertFullCommitSha(sha); const { profile, server, remotePath } = this.resolve(repository, profileId); const preflight = await this.preflight({ repository, profileId, sha: targetSha, }); if (!preflight.summary.ready) { const error = new Error( `SSH deployment preflight failed: ${preflight.summary.blocking.join(", ")}`, ); error.code = "SSH_DEPLOYMENT_PREFLIGHT_FAILED"; throw error; } const requestId = crypto.randomUUID(); const operation = await this.saveOperation({ id: requestId, type: "deployment", action: "deploy", provider: "ssh-unraid", repository: repository.fullName, environment: profile.environment, profileId, serverId: server.id, remotePath, sha: targetSha, shortSha: targetSha.slice(0, 7), status: "running", logs: [ "Preflight passed.", "Unraid can read the Gitea repository.", `Deploying exact commit ${targetSha} in the background.`, ], }); const cloneUrl = String( profile.cloneUrl || repository.sshUrl || repository.preferredCloneUrl || "", ).trim(); const composeFile = profile.generatedCompose ? ".forgeflow/compose.forgeflow.yml" : safeRelativeRemoteFile(profile.composeFile || "docker-compose.yml"); const generated = profile.generatedCompose ? this.generatedCompose(profile, repository) : ""; const iconReference = await this.prepareIcon(profile, repository, server); const metadata = this.metadataCompose(profile, repository, iconReference); const compose = this.composeInvocation(profile, repository, composeFile); const branch = String(profile.branch || "main"); const statusJson = JSON.stringify({ repository: repository.fullName, environment: profile.environment, requested_sha: targetSha, live_sha: targetSha, request_id: requestId, healthy: null, healthcheck_url_configured: Boolean(profile.healthcheckUrl), deployed_at: new Date().toISOString(), }); const script = ` root=${shellQuote(remotePath)} parent=$(dirname "$root") mkdir -p "$parent" if [ ! -d "$root" ]; then git clone --branch ${shellQuote(branch)} --single-branch ${shellQuote(cloneUrl)} "$root" fi test -d "$root/.git" || { echo "Existing folder is not a Git working tree" >&2; exit 32; } ${profile.alignRemote ? `git -C "$root" remote set-url origin ${shellQuote(cloneUrl)}` : ""} changes=$(git -C "$root" status --porcelain --untracked-files=no) test -z "$changes" || { echo "Tracked server-side changes block deployment" >&2; printf '%s\n' "$changes" >&2; exit 33; } git -C "$root" fetch --prune origin ${shellQuote(branch)} git -C "$root" cat-file -e ${shellQuote(`${targetSha}^{commit}`)} git -C "$root" merge-base --is-ancestor ${shellQuote(targetSha)} ${shellQuote(`origin/${branch}`)} previous=$(git -C "$root" rev-parse HEAD 2>/dev/null || true) git -C "$root" checkout -B ${shellQuote(branch)} ${shellQuote(`origin/${branch}`)} git -C "$root" reset --hard ${shellQuote(targetSha)} mkdir -p "$root/.forgeflow" printf '%s' "$previous" > "$root/.forgeflow/previous-sha" printf '%s' ${shellQuote(targetSha)} > "$root/.forgeflow/current-sha" ${profile.generatedCompose ? `cat > "$root/.forgeflow/compose.forgeflow.yml" <<'FORGEFLOW_COMPOSE'\n${generated}FORGEFLOW_COMPOSE` : ""} cat > "$root/.forgeflow/compose.metadata.yml" <<'FORGEFLOW_METADATA' ${metadata}FORGEFLOW_METADATA cd "$root" ${compose} config >/dev/null ${compose} up -d --build --remove-orphans --force-recreate ${this.dockerManRefreshScript(profile, repository, iconReference)} container=${shellQuote(String(profile.containerName || profile.remoteFolder || repository.name))} docker inspect "$container" >/dev/null cat > "$root/.forgeflow/status.json" <<'FORGEFLOW_STATUS' ${statusJson} FORGEFLOW_STATUS `; void (async () => { try { const result = await this.ssh.exec(server.id, bash(script), { timeout: 30 * 60_000, maxOutput: 4 * 1024 * 1024, }); const health = await this.checkHealth(profile.healthcheckUrl); // The remote deployment script already verifies that Docker created the expected // container. Complete the operation before a secondary state inspection so a slow or // failed refresh cannot leave ForgeFlow stuck in deployment mode after a successful run. const effectiveHealthy = health.configured ? health.healthy : true; const finalStatus = effectiveHealthy === false ? "failed" : "success"; const finalLogs = [ ...operation.logs, ...result.stdout.trim().split("\n").filter(Boolean).slice(-60), "Docker Compose deployment completed.", health.configured ? `Healthcheck ${health.healthy ? "passed" : "failed"}${health.status ? ` with HTTP ${health.status}` : ""}.` : "No desktop healthcheck URL configured; the remote container inspection passed.", ]; await this.saveOperation({ ...operation, status: finalStatus, previousSha: preflight.inspection?.head || null, health: { ...health, healthy: effectiveHealthy }, logs: finalLogs, error: effectiveHealthy === false ? "The application healthcheck did not pass after deployment." : null, }); await this.store.saveDeploymentState(profileId, { liveSha: targetSha, previousSha: preflight.inspection?.head || null, healthy: effectiveHealthy, healthStatus: health.status ?? null, healthLatencyMs: health.latencyMs ?? null, requestId, remotePath, provider: "ssh-unraid", containerName: String( profile.containerName || profile.remoteFolder || repository.name, ), containerRunning: true, dockerMan: { webUi: this.dockerManWebUi(profile), icon: iconReference, shell: this.dockerManShell(profile), templateExists: true, configured: Boolean(this.dockerManWebUi(profile) || iconReference), }, webUiUrl: profile.webUiUrl || (profile.hostPort ? `http://${server.host}:${profile.hostPort}/` : null), }); // Reconcile authoritative Unraid/Docker state in the background and preserve the already // completed operation if that follow-up inspection is unavailable. void this.refreshProfileState(repository.fullName, profileId).catch( async (refreshError) => { await this.diagnostics?.warning( "unraid.deployment.post-refresh-failed", { requestId, repository: repository.fullName, serverId: server.id, error: refreshError, }, ); }, ); await this.diagnostics?.info("unraid.deployment.completed", { requestId, repository: repository.fullName, serverId: server.id, remotePath, sha: targetSha, healthy: effectiveHealthy, healthStatus: health.status ?? null, }); } catch (error) { await this.saveOperation({ ...operation, status: "failed", error: error.message, failure: { stage: "SSH / Docker deployment", message: error.message }, logs: [...operation.logs, error.message], }); await this.diagnostics?.error("unraid.deployment.failed", { requestId, repository: repository.fullName, serverId: server.id, remotePath, sha: targetSha, error, }); } })(); return operation; } async rollback({ repository, profileId, targetSha }) { const target = assertFullCommitSha(targetSha); const { profile, server, remotePath } = this.resolve(repository, profileId); const deploymentState = this.store.getDeploymentState(profileId); if ( !deploymentState?.previousSha || deploymentState.previousSha !== target ) { const error = new Error( "Rollback is allowed only to the exact previous SHA reported by ForgeFlow for this deployment profile.", ); error.code = "ROLLBACK_TARGET_NOT_PREVIOUS_SHA"; throw error; } if (!repository.localPath) throw new Error( "A linked local repository is required for rollback verification.", ); await this.git.verifyCommitOnRemoteBranch( repository.localPath, target, profile.branch, ); const inspection = await this.inspect({ repository, profileId }); if (!inspection.rootGit) throw new Error( "The configured server project is not a root Git working tree.", ); if (inspection.trackedChanges.length) throw new Error( "Tracked server-side changes block rollback. Commit, revert or migrate them first.", ); const composeFile = profile.generatedCompose ? ".forgeflow/compose.forgeflow.yml" : safeRelativeRemoteFile(profile.composeFile || "docker-compose.yml"); const iconReference = await this.prepareIcon(profile, repository, server); const metadata = this.metadataCompose(profile, repository, iconReference); const compose = this.composeInvocation(profile, repository, composeFile); const requestId = crypto.randomUUID(); const operation = await this.saveOperation({ id: requestId, type: "deployment", action: "rollback", provider: "ssh-unraid", repository: repository.fullName, environment: profile.environment, profileId, serverId: server.id, remotePath, sha: target, shortSha: target.slice(0, 7), status: "running", logs: [`Rolling back to exact commit ${target}.`], }); const statusJson = JSON.stringify({ repository: repository.fullName, environment: profile.environment, requested_sha: target, live_sha: target, request_id: requestId, healthy: null, healthcheck_url_configured: Boolean(profile.healthcheckUrl), rollback: true, deployed_at: new Date().toISOString(), }); const script = ` root=${shellQuote(remotePath)} test -d "$root/.git" git -C "$root" fetch --prune origin ${shellQuote(profile.branch)} git -C "$root" cat-file -e ${shellQuote(`${target}^{commit}`)} current=$(git -C "$root" rev-parse HEAD) git -C "$root" reset --hard ${shellQuote(target)} cat > "$root/.forgeflow/compose.metadata.yml" <<'FORGEFLOW_METADATA' ${metadata}FORGEFLOW_METADATA cd "$root" ${compose} config >/dev/null ${compose} up -d --build --remove-orphans --force-recreate ${this.dockerManRefreshScript(profile, repository, iconReference)} printf '%s' "$current" > "$root/.forgeflow/previous-sha" printf '%s' ${shellQuote(target)} > "$root/.forgeflow/current-sha" cat > "$root/.forgeflow/status.json" <<'FORGEFLOW_STATUS' ${statusJson} FORGEFLOW_STATUS `; try { const result = await this.ssh.exec(server.id, bash(script), { timeout: 30 * 60_000, maxOutput: 4 * 1024 * 1024, }); const health = await this.checkHealth(profile.healthcheckUrl); const finalStatus = health.healthy === false ? "failed" : "rolled-back"; const completed = await this.saveOperation({ ...operation, status: finalStatus, previousSha: deploymentState.liveSha || inspection.head || null, health, error: health.healthy === false ? "The application healthcheck did not pass after rollback." : null, logs: [ ...operation.logs, ...result.stdout.trim().split("\n").filter(Boolean).slice(-60), "Rollback completed.", health.configured ? `Healthcheck ${health.healthy ? "passed" : "failed"}${health.status ? ` with HTTP ${health.status}` : ""}.` : "No desktop healthcheck URL configured.", ], }); await this.store.saveDeploymentState(profileId, { liveSha: target, previousSha: deploymentState.liveSha || inspection.head || null, healthy: health.healthy, healthStatus: health.status, healthLatencyMs: health.latencyMs, requestId, remotePath, provider: "ssh-unraid", }); if (health.healthy === false) { const error = new Error( "Rollback completed, but the configured healthcheck failed.", ); error.code = "ROLLBACK_HEALTHCHECK_FAILED"; error.operationId = completed.id; throw error; } return completed; } catch (error) { if (error.code !== "ROLLBACK_HEALTHCHECK_FAILED") { await this.saveOperation({ ...operation, status: "failed", error: error.message, logs: [...operation.logs, error.message], }); } throw error; } } async refreshProfileState(fullName, profileId, expectedGiteaSha = null) { const repository = { fullName, name: fullName.split("/").pop() }; const { profile, server, remotePath } = this.resolve(repository, profileId); const containerName = String( profile.containerName || profile.remoteFolder || repository.name, ); const script = ` root=${shellQuote(remotePath)} container=${shellQuote(containerName)} template_path=${shellQuote("/boot/config/plugins/dockerMan/templates-user/my-" + containerName + ".xml")} live=""; previous=""; running=false; docker_health=""; webui=""; icon=""; shell_label=""; template_exists=false [ -f "$template_path" ] && template_exists=true [ -f "$root/.forgeflow/current-sha" ] && live=$(cat "$root/.forgeflow/current-sha") [ -z "$live" ] && [ -d "$root/.git" ] && live=$(git -C "$root" rev-parse HEAD 2>/dev/null || true) [ -f "$root/.forgeflow/previous-sha" ] && previous=$(cat "$root/.forgeflow/previous-sha") if docker inspect "$container" >/dev/null 2>&1; then running=$(docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null || echo false) docker_health=$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{end}}' "$container" 2>/dev/null || true) webui=$(docker inspect -f '{{index .Config.Labels "net.unraid.docker.webui"}}' "$container" 2>/dev/null || true) icon=$(docker inspect -f '{{index .Config.Labels "net.unraid.docker.icon"}}' "$container" 2>/dev/null || true) shell_label=$(docker inspect -f '{{index .Config.Labels "net.unraid.docker.shell"}}' "$container" 2>/dev/null || true) fi printf '__FORGEFLOW_KV__\n' printf 'liveSha=%s\n' "$live" printf 'previousSha=%s\n' "$previous" printf 'containerRunning=%s\n' "$running" printf 'dockerHealth=%s\n' "$docker_health" printf 'webUiLabel=%s\n' "$(printf '%s' "$webui" | base64 | tr -d '\r\n')" printf 'iconLabel=%s\n' "$(printf '%s' "$icon" | base64 | tr -d '\r\n')" printf 'shellLabel=%s\n' "$(printf '%s' "$shell_label" | base64 | tr -d '\r\n')" printf 'templateExists=%s\n' "$template_exists" `; const result = await this.ssh.exec(server.id, bash(script), { timeout: 30_000, }); const marker = result.stdout.lastIndexOf("__FORGEFLOW_KV__"); if (marker < 0) throw new Error( "Unraid state inspection did not return a ForgeFlow marker.", ); const fields = {}; for (const line of result.stdout .slice(marker + "__FORGEFLOW_KV__".length) .trim() .split(/\r?\n/)) { const index = line.indexOf("="); if (index > 0) fields[line.slice(0, index)] = line.slice(index + 1); } const decode = (value) => { try { return value ? Buffer.from(value, "base64").toString("utf8") : ""; } catch { return ""; } }; const health = await this.checkHealth(profile.healthcheckUrl); const dockerHealthy = fields.dockerHealth ? fields.dockerHealth === "healthy" : null; const effectiveHealthy = health.configured ? health.healthy : (dockerHealthy ?? (fields.containerRunning === "true" ? true : false)); return this.store.saveDeploymentState(profile.id, { liveSha: /^[0-9a-f]{40}$/i.test(fields.liveSha || "") ? fields.liveSha : null, previousSha: /^[0-9a-f]{40}$/i.test(fields.previousSha || "") ? fields.previousSha : null, healthy: effectiveHealthy, healthStatus: health.status, healthLatencyMs: health.latencyMs, containerName, containerRunning: fields.containerRunning === "true", dockerHealth: fields.dockerHealth || null, dockerMan: { webUi: decode(fields.webUiLabel), icon: decode(fields.iconLabel), shell: decode(fields.shellLabel), templateExists: fields.templateExists === "true", configured: Boolean( decode(fields.webUiLabel) || decode(fields.iconLabel) || fields.templateExists === "true", ), }, webUiUrl: profile.webUiUrl || (profile.hostPort ? `http://${server.host}:${profile.hostPort}/` : null), remotePath, provider: "ssh-unraid", giteaSha: /^[0-9a-f]{40}$/i.test(String(expectedGiteaSha || "")) ? expectedGiteaSha : null, matchesGitea: /^[0-9a-f]{40}$/i.test(String(expectedGiteaSha || "")) && fields.liveSha === expectedGiteaSha, }); } async applyDockerManMetadata({ repository, profileId }) { const { profile, server, remotePath } = this.resolve(repository, profileId); const composeFile = profile.generatedCompose ? ".forgeflow/compose.forgeflow.yml" : safeRelativeRemoteFile(profile.composeFile || "docker-compose.yml"); const iconReference = await this.prepareIcon(profile, repository, server); const metadata = this.metadataCompose(profile, repository, iconReference); const compose = this.composeInvocation(profile, repository, composeFile); const script = ` root=${shellQuote(remotePath)} test -d "$root/.git" mkdir -p "$root/.forgeflow" cat > "$root/.forgeflow/compose.metadata.yml" <<'FORGEFLOW_METADATA' ${metadata}FORGEFLOW_METADATA cd "$root" ${compose} config >/dev/null ${compose} up -d --build --remove-orphans --force-recreate ${this.dockerManRefreshScript(profile, repository, iconReference)} `; await this.ssh.exec(server.id, bash(script), { timeout: 10 * 60_000, maxOutput: 2 * 1024 * 1024, }); return this.refreshProfileState(repository.fullName, profileId); } async refreshOperation( operationId, { includeTerminal = false, state: suppliedState = null } = {}, ) { const operation = this.store.getOperation(operationId); if (!operation || operation.provider !== "ssh-unraid") return operation; if ( !includeTerminal && ["success", "failed", "cancelled", "rolled-back"].includes( operation.status, ) ) return operation; try { const state = suppliedState || (await this.refreshProfileState( operation.repository, operation.profileId, )); if ( state.liveSha === operation.sha && state.containerRunning && state.healthy !== false ) { return this.saveOperation({ ...operation, status: operation.action === "rollback" ? "rolled-back" : "success", health: { healthy: state.healthy, status: state.healthStatus }, logs: [ ...(operation.logs || []), "Deployment state reconciled from Unraid.", ], }); } if ( /^[0-9a-f]{40}$/i.test(String(state.liveSha || "")) && state.liveSha !== operation.sha && state.containerRunning && state.healthy !== false ) { return this.saveOperation({ ...operation, status: "cancelled", error: `Superseded by live commit ${state.liveSha.slice(0, 7)}.`, health: { healthy: state.healthy, status: state.healthStatus }, logs: [ ...(operation.logs || []), `Operation superseded by live Unraid commit ${state.liveSha}.`, ], }); } const ageMs = Date.now() - new Date(operation.updatedAt || operation.createdAt || 0).getTime(); if (ageMs > 45 * 60_000) { return this.saveOperation({ ...operation, status: "failed", error: "Deployment was interrupted or did not reach the requested commit within 45 minutes.", logs: [ ...(operation.logs || []), "Stale deployment was marked failed during reconciliation.", ], }); } return operation; } catch { return operation; } } async reconcileRecordedOperations(profileId, state) { const operations = this.store.data.operations .filter( (item) => item.profileId === profileId && item.provider === "ssh-unraid", ) .sort( (left, right) => new Date(right.updatedAt || right.createdAt || 0) - new Date(left.updatedAt || left.createdAt || 0), ); const matching = operations.find( (item) => item.sha === state.liveSha && item.status === "failed", ); if (matching && state.containerRunning && state.healthy !== false) { await this.refreshOperation(matching.id, { includeTerminal: true, state, }); } const latestFailed = operations.find((item) => item.status === "failed"); if ( latestFailed && latestFailed.id !== matching?.id && state.matchesGitea && state.containerRunning && state.healthy !== false ) { await this.saveOperation({ ...latestFailed, status: "cancelled", error: `Superseded by Gitea/live commit ${state.liveSha.slice(0, 7)}.`, logs: [ ...(latestFailed.logs || []), `Reconciled: Gitea and Unraid now both report ${state.liveSha}.`, ], }); } return this.store.data.operations .filter((item) => item.profileId === profileId) .slice(0, 10); } async refreshActiveOperations() { const active = this.store.data.operations.filter( (item) => item.provider === "ssh-unraid" && item.type === "deployment" && !["success", "failed", "cancelled", "rolled-back"].includes( item.status, ), ); return Promise.all(active.map((item) => this.refreshOperation(item.id))); } } module.exports = { UnraidDeploymentService, safeRemoteFolder, safeRelativeRemoteFile, parseInspection, dockerIgnoreHasPath, checksSummary, xmlEscape, iconReferenceLocalPath, decodeBase64Json, parseDockerManXml, deriveDetectedProfile, bash, };