Publish curated ForgeFlow source from 2ed1787c0b52
This commit is contained in:
commit
f60b269686
254 files changed
+46204
No files matched your search
@@ -0,0 +1,524 @@
|
||||
"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,
|
||||
};
|
||||
Reference in new issue
Block a user