Files
ForgeFlow/tests/unraid-deployment.test.mjs
T

668 lines
21 KiB
JavaScript

import test from "node:test";
import assert from "node:assert/strict";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const {
UnraidDeploymentService,
safeRemoteFolder,
safeRelativeRemoteFile,
parseInspection,
dockerIgnoreHasPath,
checksSummary,
xmlEscape,
bash,
} = require("../src/main/unraid-deployment-service.cjs");
const { fingerprintKey, shellQuote } = require("../src/main/ssh-service.cjs");
test("Unraid remote paths cannot escape appdata project folder", () => {
assert.equal(safeRemoteFolder("lumaops"), "lumaops");
assert.throws(() => safeRemoteFolder("../lumaops"));
assert.equal(
safeRelativeRemoteFile("deploy/docker-compose.yml"),
"deploy/docker-compose.yml",
);
assert.throws(() => safeRelativeRemoteFile("../../etc/passwd"));
});
test("server inspection key-value payload is decoded safely", () => {
const b64 = (value) => Buffer.from(value).toString("base64");
const parsed = parseInspection(
`noise\n__FORGEFLOW_KV__\nexists=true\nrootGit=true\nhead=${"a".repeat(40)}\nbranch=main\nremote=${b64("ssh://git@gitea/Jens/LumaOps.git")}\ntrackedChanges=${b64(" M docker-compose.yml\n")}\ncomposeFiles=${b64("docker-compose.yml\n")}\nnestedGit=${b64("source\n")}\ndockerfile=true\ndockerignoreContent=${b64(".git\ndata/\n")}\nexistingPreservePaths=${b64("data\nlogs\n")}\n`,
);
assert.equal(parsed.rootGit, true);
assert.deepEqual(parsed.composeFiles, ["docker-compose.yml"]);
assert.deepEqual(parsed.nestedGit, ["source"]);
assert.equal(parsed.trackedChanges.length, 1);
assert.match(parsed.dockerignoreContent, /\.git/);
assert.deepEqual(parsed.existingPreservePaths, ["data", "logs"]);
});
test("Docker ignore checks identify exact runtime and Git context exclusions", () => {
const rules = "# build context\n.git\ndata/\nlogs/**\n!logs/keep.txt\n";
assert.equal(dockerIgnoreHasPath(rules, ".git"), true);
assert.equal(dockerIgnoreHasPath(rules, "data"), true);
assert.equal(dockerIgnoreHasPath(rules, "logs"), true);
assert.equal(dockerIgnoreHasPath(rules, "source"), false);
});
test("server inspection detects preserved runtime paths and missing Docker context exclusions", async () => {
const b64 = (value) => Buffer.from(value).toString("base64");
let receivedCommand = "";
const store = {
getDeploymentProfile: () => ({
id: "production",
provider: "ssh-unraid",
serverId: "unraid",
remoteFolder: "lumaops",
preservePaths: ["data", "logs"],
}),
getServer: () => ({
id: "unraid",
name: "Unraid",
basePath: "/mnt/user/appdata",
}),
};
const ssh = {
exec: async (_serverId, command) => {
receivedCommand = command;
return {
stdout: `__FORGEFLOW_KV__\nexists=true\nrootGit=true\nhead=${"a".repeat(40)}\nbranch=main\nremote=${b64("ssh://git@gitea/Jens/LumaOps.git")}\ntrackedChanges=\ncomposeFiles=${b64("docker-compose.yml\n")}\nnestedGit=${b64("source\n")}\ndockerfile=true\ndockerignoreContent=${b64(".git\ndata/\n")}\nexistingPreservePaths=${b64("data\nlogs\n")}\n`,
stderr: "",
exitCode: 0,
};
},
};
const service = new UnraidDeploymentService({
store,
ssh,
git: {},
diagnostics: null,
});
const inspection = await service.inspect({
repository: { fullName: "Jens/LumaOps", name: "LumaOps" },
profileId: "production",
});
assert.match(receivedCommand, /base64 -d \| bash$/);
assert.equal(inspection.remotePath, "/mnt/user/appdata/lumaops");
assert.equal(inspection.dockerignoreGitExcluded, true);
assert.deepEqual(inspection.existingPreservePaths.sort(), ["data", "logs"]);
assert.deepEqual(inspection.dockerContextExclusionsMissing.sort(), [
"logs",
"source",
]);
});
test("preflight summary blocks only failed checks", () => {
const result = checksSummary([
{ id: "a", status: "pass" },
{ id: "b", status: "warning" },
{ id: "c", status: "fail" },
]);
assert.equal(result.ready, false);
assert.deepEqual(result.blocking, ["c"]);
});
test("SSH helpers produce pinned fingerprints and quoted commands", () => {
assert.match(fingerprintKey(Buffer.from("host-key")), /^SHA256:/);
assert.equal(shellQuote("a'b"), "'a'\\''b'");
const wrapped = bash("git fetch origin main");
assert.match(wrapped, /base64 -d \| bash$/);
assert.equal(wrapped.includes("\n"), false);
const encoded = wrapped.match(/printf '%s' '([A-Za-z0-9+/=]+)'/)[1];
const decoded = Buffer.from(encoded, "base64").toString("utf8");
assert.match(decoded, /GIT_TERMINAL_PROMPT=0/);
assert.match(decoded, /BatchMode=yes/);
assert.match(decoded, /git fetch origin main/);
});
test("SSH rollback refuses any SHA other than the exact recorded previous deployment", async () => {
const previousSha = "a".repeat(40);
const store = {
getDeploymentProfile: () => ({
id: "production",
provider: "ssh-unraid",
serverId: "unraid",
remoteFolder: "lumaops",
composeFile: "docker-compose.yml",
branch: "main",
environment: "production",
}),
getServer: () => ({ id: "unraid", basePath: "/mnt/user/appdata" }),
getDeploymentState: () => ({ liveSha: "b".repeat(40), previousSha }),
};
const service = new UnraidDeploymentService({
store,
ssh: {},
git: {},
diagnostics: null,
});
await assert.rejects(
service.rollback({
repository: {
fullName: "Jens/LumaOps",
name: "LumaOps",
localPath: "/tmp/lumaops",
},
profileId: "production",
targetSha: "c".repeat(40),
}),
(error) => error.code === "ROLLBACK_TARGET_NOT_PREVIOUS_SHA",
);
});
test("successful SSH rollback records the formerly live SHA as the new rollback target", async () => {
const previousSha = "a".repeat(40);
const liveSha = "b".repeat(40);
const savedStates = [];
const operations = [];
const store = {
getDeploymentProfile: () => ({
id: "production",
provider: "ssh-unraid",
serverId: "unraid",
remoteFolder: "lumaops",
composeFile: "docker-compose.yml",
branch: "main",
environment: "production",
healthcheckUrl: "",
iconMode: "none",
}),
getServer: () => ({
id: "unraid",
name: "Unraid",
basePath: "/mnt/user/appdata",
}),
getDeploymentState: () => ({ liveSha, previousSha }),
addOperation: async (operation) => {
operations.push(operation);
return operation;
},
saveDeploymentState: async (_profileId, state) => {
savedStates.push(state);
return state;
},
};
const git = { verifyCommitOnRemoteBranch: async () => true };
const ssh = { exec: async () => ({ stdout: "", stderr: "", exitCode: 0 }) };
const service = new UnraidDeploymentService({
store,
ssh,
git,
diagnostics: null,
});
service.inspect = async () => ({
rootGit: true,
trackedChanges: [],
head: liveSha,
});
service.checkHealth = async () => ({
configured: false,
healthy: null,
status: null,
latencyMs: null,
});
const result = await service.rollback({
repository: {
fullName: "Jens/LumaOps",
name: "LumaOps",
localPath: "/tmp/lumaops",
},
profileId: "production",
targetSha: previousSha,
});
assert.equal(result.status, "rolled-back");
assert.equal(savedStates.at(-1).liveSha, previousSha);
assert.equal(savedStates.at(-1).previousSha, liveSha);
assert.equal(operations.at(-1).previousSha, liveSha);
});
test("generated Compose uses a lowercase-safe service while preserving the visible Portfolio container name", () => {
const service = new UnraidDeploymentService({
store: {},
ssh: {},
git: {},
diagnostics: null,
});
const compose = service.generatedCompose(
{ composeService: "Portfolio", hostPort: 5150, containerPort: 80 },
{ name: "Portfolio" },
);
assert.match(compose, / portfolio:/);
assert.match(compose, /image: forgeflow\/portfolio:production/);
assert.match(compose, /container_name: Portfolio/);
});
test("SSH deployment dispatch returns a running operation while the remote build continues in background", async () => {
const sha = "d".repeat(40);
const operations = [];
let resolveRemote;
const store = {
getDeploymentProfile: () => ({
id: "production",
provider: "ssh-unraid",
serverId: "unraid",
remoteFolder: "Portfolio",
cloneUrl: "forgeflow-gitea:Jens/Portfolio.git",
composeFile: "docker-compose.yml",
branch: "main",
environment: "production",
healthcheckUrl: "",
iconMode: "none",
}),
getServer: () => ({
id: "unraid",
name: "Unraid",
basePath: "/mnt/user/appdata",
}),
addOperation: async (operation) => {
operations.push(structuredClone(operation));
return structuredClone(operation);
},
saveDeploymentState: async () => ({}),
};
const ssh = {
exec: async () =>
new Promise((resolve) => {
resolveRemote = resolve;
}),
};
const service = new UnraidDeploymentService({
store,
ssh,
git: {},
diagnostics: null,
});
service.preflight = async () => ({
summary: { ready: true, blocking: [] },
inspection: { head: null },
});
service.checkHealth = async () => ({
configured: false,
healthy: null,
status: null,
latencyMs: null,
});
const operation = await service.deploy({
repository: { fullName: "Jens/Portfolio", name: "Portfolio" },
profileId: "production",
sha,
});
assert.equal(operation.status, "running");
assert.match(operation.logs.join("\n"), /background/i);
resolveRemote({
stdout: "Container Portfolio started\n",
stderr: "",
exitCode: 0,
});
await new Promise((resolve) => setTimeout(resolve, 10));
assert.equal(operations.at(-1).status, "success");
});
test("Unraid preflight verifies repository access before a deployment can start", async () => {
const source = await import("node:fs/promises").then(({ readFile }) =>
readFile(
new URL("../src/main/unraid-deployment-service.cjs", import.meta.url),
"utf8",
),
);
assert.match(source, /server-git-access/);
assert.match(source, /git ls-remote --exit-code/);
assert.match(source, /Unraid → Gitea access/);
});
test("DockerMan metadata uses dockerman labels, a template WebUI and lowercase-safe service/image names", () => {
const service = new UnraidDeploymentService({
store: {},
ssh: {},
git: {},
diagnostics: null,
});
const metadata = service.metadataCompose(
{
composeService: "portfolio",
containerName: "Portfolio",
remoteFolder: "Portfolio",
environment: "production",
hostPort: 5150,
webUiUrl: "http://192.168.10.150:5150/admin",
dockerShell: "/bin/sh",
},
{ name: "Portfolio" },
"file:///boot/config/plugins/dockerMan/images/Portfolio-icon.png",
);
assert.match(metadata, / portfolio:/);
assert.match(metadata, /image: forgeflow\/portfolio:production/);
assert.match(metadata, /container_name: Portfolio/);
assert.match(metadata, /net\.unraid\.docker\.managed.*dockerman/);
assert.match(
metadata,
/net\.unraid\.docker\.webui.*http:\/\/\[IP\]:\[PORT:5150\]\/admin/,
);
assert.match(
metadata,
/net\.unraid\.docker\.icon.*file:\/\/\/boot\/config\/plugins\/dockerMan\/images\/Portfolio-icon\.png/,
);
});
test("DockerMan integration writes a persistent template fallback and invalidates cached metadata", () => {
const service = new UnraidDeploymentService({
store: {},
ssh: {},
git: {},
diagnostics: null,
});
const profile = {
composeService: "portfolio",
containerName: "Portfolio",
remoteFolder: "Portfolio",
environment: "production",
hostPort: 5150,
webUiUrl: "http://192.168.10.150:5150/",
dockerShell: "/bin/sh",
};
const repository = { name: "Portfolio" };
const icon =
"file:///boot/config/plugins/dockerMan/images/Portfolio-icon.png";
const template = service.dockerManTemplate(profile, repository, icon);
const refresh = service.dockerManRefreshScript(profile, repository, icon);
assert.match(template, /<Name>Portfolio<\/Name>/);
assert.match(
template,
/<Repository>forgeflow\/portfolio:production<\/Repository>/,
);
assert.match(template, /<WebUI>http:\/\/\[IP\]:\[PORT:5150\]\/<\/WebUI>/);
assert.match(
template,
/<Icon>file:\/\/\/boot\/config\/plugins\/dockerMan\/images\/Portfolio-icon\.png<\/Icon>/,
);
assert.match(refresh, /templates-user\/my-Portfolio\.xml/);
assert.match(refresh, /dynamix\.docker\.manager\/docker\.json/);
assert.match(
refresh,
/cp '\/boot\/config\/plugins\/dockerMan\/images\/Portfolio-icon\.png'/,
);
assert.doesNotMatch(refresh, /dockerManRefreshScript/);
assert.equal(xmlEscape('A&B<"x">'), "A&amp;B&lt;&quot;x&quot;&gt;");
});
test("built-in ITWorx DockerMan icon is uploaded to persistent Unraid storage", async (t) => {
const { mkdtemp, mkdir, writeFile, rm } = await import("node:fs/promises");
const os = await import("node:os");
const path = await import("node:path");
const sourcePath = await mkdtemp(path.join(os.tmpdir(), "forgeflow-icon-"));
t.after(() => rm(sourcePath, { recursive: true, force: true }));
const asset = path.join(
sourcePath,
"src",
"renderer",
"assets",
"itworx-mark.png",
);
await mkdir(path.dirname(asset), { recursive: true });
await writeFile(asset, Buffer.from([137, 80, 78, 71]));
const uploads = [];
const service = new UnraidDeploymentService({
store: {},
git: {},
diagnostics: null,
sourcePath,
ssh: {
uploadFile: async (...args) => {
uploads.push(args);
return {};
},
},
});
const icon = await service.prepareIcon(
{ iconMode: "builtin", remoteFolder: "Portfolio" },
{ name: "Portfolio" },
{ id: "unraid" },
);
assert.equal(
icon,
"file:///boot/config/plugins/dockerMan/images/Portfolio-icon.png",
);
assert.equal(uploads.length, 1);
assert.equal(uploads[0][0], "unraid");
assert.equal(uploads[0][1], asset);
assert.equal(
uploads[0][2],
"/boot/config/plugins/dockerMan/images/Portfolio-icon.png",
);
});
test("stuck SSH deployment is reconciled to success when exact SHA and container health are live", async () => {
const sha = "f".repeat(40);
const saved = [];
const operation = {
id: "op-1",
type: "deployment",
provider: "ssh-unraid",
action: "deploy",
repository: "Jens/Portfolio",
profileId: "production",
sha,
status: "running",
logs: [],
};
const store = {
getOperation: () => operation,
addOperation: async (next) => {
saved.push(next);
return next;
},
};
const service = new UnraidDeploymentService({
store,
ssh: {},
git: {},
diagnostics: null,
});
service.refreshProfileState = async () => ({
liveSha: sha,
containerRunning: true,
healthy: true,
});
const result = await service.refreshOperation("op-1");
assert.equal(result.status, "success");
assert.match(result.logs.at(-1), /reconciled/i);
assert.equal(saved.at(-1).status, "success");
});
test("DockerMan metadata repair refreshes known Unraid icon caches after container recreation", async () => {
const source = await import("node:fs/promises").then(({ readFile }) =>
readFile(
new URL("../src/main/unraid-deployment-service.cjs", import.meta.url),
"utf8",
),
);
assert.match(source, /\/var\/lib\/docker\/unraid\/images/);
assert.match(source, /dynamix\.docker\.manager\/images/);
assert.match(source, /-icon\.png/);
assert.match(source, /cp \${shellQuote\(localIconPath\)}/);
assert.match(source, /--force-recreate/);
});
test("stuck deployment is cleared as superseded when a different healthy commit is already live", async () => {
const requested = "a".repeat(40);
const live = "b".repeat(40);
const operation = {
id: "op-superseded",
type: "deployment",
provider: "ssh-unraid",
action: "deploy",
repository: "Jens/Portfolio",
profileId: "production",
sha: requested,
status: "running",
logs: [],
};
const saved = [];
const service = new UnraidDeploymentService({
store: {
getOperation: () => operation,
addOperation: async (next) => {
saved.push(next);
return next;
},
},
ssh: {},
git: {},
diagnostics: null,
});
service.refreshProfileState = async () => ({
liveSha: live,
containerRunning: true,
healthy: true,
});
const result = await service.refreshOperation(operation.id);
assert.equal(result.status, "cancelled");
assert.match(result.error, /Superseded/);
assert.equal(saved.at(-1).status, "cancelled");
});
test("existing Unraid deployment discovery derives profile values from Docker, Compose and DockerMan truth", () => {
const {
deriveDetectedProfile,
} = require("../src/main/unraid-deployment-service.cjs");
const result = deriveDetectedProfile({
repository: {
name: "blockpilot-autonomous",
defaultBranch: "main",
sshUrl: "ssh://git@gitea/Jens/blockpilot-autonomous.git",
},
server: { id: "unraid", host: "192.168.10.150" },
remoteFolder: "blockpilot-autonomous",
remotePath: "/mnt/user/appdata/blockpilot-autonomous",
payload: {
head: "a".repeat(40),
branch: "main",
remote: "ssh://git@gitea/Jens/blockpilot-autonomous.git",
composeFiles: ["compose.yml"],
compose: { services: { app: { image: "blockpilot:test" } } },
containers: [
{
Name: "/blockpilot",
State: { Running: true },
Config: {
Image: "blockpilot:test",
Env: ["TOKEN=secret", "MODE=prod"],
Labels: {
"com.docker.compose.service": "app",
"com.docker.compose.project": "blockpilot",
},
},
HostConfig: { RestartPolicy: { Name: "unless-stopped" } },
NetworkSettings: {
Ports: { "8080/tcp": [{ HostIp: "0.0.0.0", HostPort: "1223" }] },
Networks: { bridge: {} },
},
Mounts: [
{
Type: "bind",
Source: "/mnt/user/appdata/blockpilot-autonomous/data",
Destination: "/data",
RW: true,
},
],
},
],
dockerManXml:
"<Container><Name>blockpilot</Name><WebUI>http://[IP]:[PORT:1223]/</WebUI><Icon>https://example.test/icon.png</Icon><Shell>/bin/bash</Shell></Container>",
},
});
assert.equal(result.profile.hostPort, 1223);
assert.equal(result.profile.containerPort, 8080);
assert.equal(result.profile.containerName, "blockpilot");
assert.equal(result.profile.composeService, "app");
assert.equal(result.profile.webUiUrl, "http://[IP]:[PORT:1223]/");
assert.equal(result.profile.iconUrl, "https://example.test/icon.png");
assert.equal(result.profile.dockerShell, "/bin/bash");
assert.deepEqual(result.profile.detectedMetadata.envNames, ["TOKEN", "MODE"]);
assert.ok(result.profile.preservePaths.includes("data"));
assert.equal(result.provenance.hostPort.origin, "docker-inspect");
});
test("a previously failed deployment is corrected when its exact commit is healthy on Unraid", async () => {
const sha = "e".repeat(40);
const failed = {
id: "failed-1",
type: "deployment",
provider: "ssh-unraid",
action: "deploy",
profileId: "production",
sha,
status: "failed",
logs: [],
};
const operations = [failed];
const service = new UnraidDeploymentService({
store: {
data: { operations },
getOperation: (id) => operations.find((item) => item.id === id),
addOperation: async (next) => {
operations.splice(
operations.findIndex((item) => item.id === next.id),
1,
next,
);
return next;
},
},
ssh: {},
git: {},
diagnostics: null,
});
await service.reconcileRecordedOperations("production", {
liveSha: sha,
matchesGitea: true,
containerRunning: true,
healthy: true,
});
assert.equal(operations[0].status, "success");
assert.match(operations[0].logs.at(-1), /reconciled/i);
});
test("a failed deployment is marked superseded when Gitea and Unraid agree on a newer commit", async () => {
const liveSha = "d".repeat(40);
const operations = [
{
id: "failed-2",
type: "deployment",
provider: "ssh-unraid",
profileId: "production",
sha: "c".repeat(40),
status: "failed",
logs: [],
},
];
const service = new UnraidDeploymentService({
store: {
data: { operations },
getOperation: (id) => operations.find((item) => item.id === id),
addOperation: async (next) => {
operations.splice(
operations.findIndex((item) => item.id === next.id),
1,
next,
);
return next;
},
},
ssh: {},
git: {},
diagnostics: null,
});
await service.reconcileRecordedOperations("production", {
liveSha,
matchesGitea: true,
containerRunning: true,
healthy: true,
});
assert.equal(operations[0].status, "cancelled");
assert.match(operations[0].error, /Superseded/);
});