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