1892 lines
66 KiB
JavaScript
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, "&")
|
|
.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 "<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,
|
|
};
|