Files
ForgeFlow/src/main/unraid-deployment-service.cjs
T

1892 lines
66 KiB
JavaScript

"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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
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(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/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 "<Name>${container_name}</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 (
[
'<?xml version="1.0"?>',
'<Container version="2">',
` <Name>${xmlEscape(containerName)}</Name>`,
` <Repository>${xmlEscape(image)}</Repository>`,
" <Registry/>",
" <Network>bridge</Network>",
" <MyIP/>",
` <Shell>${xmlEscape(this.dockerManShell(profile))}</Shell>`,
" <Privileged>false</Privileged>",
" <Support/>",
" <Project/>",
" <Overview>Managed by ForgeFlow through Docker Compose. Use ForgeFlow or the Compose files for configuration changes.</Overview>",
" <Category>Tools:</Category>",
` <WebUI>${xmlEscape(webUi)}</WebUI>`,
" <TemplateURL/>",
` <Icon>${xmlEscape(iconReference)}</Icon>`,
" <ExtraParams/>",
" <PostArgs/>",
" <CPUset/>",
" <DonateText/>",
" <DonateLink/>",
"</Container>",
].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,
};