762 lines
24 KiB
JavaScript
762 lines
24 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,
|
|
parseServerInventory,
|
|
inventoryContainerMatch,
|
|
remoteIdentity,
|
|
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("server workload inventory links running containers to exact Gitea checkouts", () => {
|
|
const b64 = (value) => Buffer.from(value).toString("base64");
|
|
const inspect = JSON.stringify([
|
|
{
|
|
Name: "/Portfolio",
|
|
State: { Running: true, Health: { Status: "healthy" } },
|
|
Config: {
|
|
Labels: {
|
|
"com.docker.compose.project.working_dir":
|
|
"/mnt/user/appdata/Portfolio",
|
|
},
|
|
},
|
|
Mounts: [],
|
|
},
|
|
]);
|
|
const inventory = parseServerInventory(
|
|
`noise\n__FORGEFLOW_INVENTORY__\nR\t${b64("/mnt/user/appdata/Portfolio")}\t${b64("git@gitea.itworx.tech:Jens/Portfolio.git")}\t${"a".repeat(40)}\t${b64("main")}\nC\t${b64(inspect)}\n`,
|
|
);
|
|
assert.equal(inventory.checkouts.length, 1);
|
|
assert.equal(inventory.containers.length, 1);
|
|
assert.equal(
|
|
remoteIdentity("git@gitea.itworx.tech:Jens/Portfolio.git"),
|
|
remoteIdentity("https://gitea.itworx.tech/Jens/Portfolio"),
|
|
);
|
|
assert.equal(
|
|
inventoryContainerMatch(
|
|
inventory.checkouts[0],
|
|
{ name: "Portfolio" },
|
|
inventory.containers[0],
|
|
),
|
|
100,
|
|
);
|
|
});
|
|
|
|
test("automatic server discovery adopts and verifies a running Gitea deployment", async () => {
|
|
const b64 = (value) => Buffer.from(value).toString("base64");
|
|
const sha = "b".repeat(40);
|
|
const container = {
|
|
Name: "/Portfolio",
|
|
State: { Running: true, Health: { Status: "healthy" } },
|
|
Config: {
|
|
Labels: {
|
|
"com.docker.compose.project.working_dir": "/mnt/user/appdata/Portfolio",
|
|
"com.docker.compose.service": "portfolio",
|
|
},
|
|
},
|
|
Mounts: [],
|
|
NetworkSettings: { Ports: { "3000/tcp": [{ HostPort: "8080" }] } },
|
|
};
|
|
const profiles = [];
|
|
const states = new Map();
|
|
const service = new UnraidDeploymentService({
|
|
store: {
|
|
getServer: () => ({
|
|
id: "unraid",
|
|
name: "Unraid",
|
|
basePath: "/mnt/user/appdata",
|
|
}),
|
|
getDeploymentProfiles: () => profiles,
|
|
saveDeploymentProfile: async (_fullName, profile) => {
|
|
profiles.push(profile);
|
|
return profile;
|
|
},
|
|
saveDeploymentState: async (id, state) => {
|
|
states.set(id, state);
|
|
return state;
|
|
},
|
|
},
|
|
ssh: {
|
|
exec: async () => ({
|
|
stdout: `__FORGEFLOW_INVENTORY__\nR\t${b64("/mnt/user/appdata/Portfolio")}\t${b64("git@gitea.itworx.tech:Jens/Portfolio.git")}\t${sha}\t${b64("main")}\nC\t${b64(JSON.stringify([container]))}\n`,
|
|
}),
|
|
},
|
|
gitea: { getBranch: async () => ({ commit: { id: sha } }) },
|
|
});
|
|
const result = await service.discoverServerWorkloads("unraid", [
|
|
{
|
|
fullName: "Jens/Portfolio",
|
|
name: "Portfolio",
|
|
defaultBranch: "main",
|
|
cloneUrl: "https://gitea.itworx.tech/Jens/Portfolio.git",
|
|
sshUrl: "git@gitea.itworx.tech:Jens/Portfolio.git",
|
|
},
|
|
]);
|
|
assert.equal(result.adopted, 1);
|
|
assert.equal(result.verified, 1);
|
|
assert.equal(profiles[0].containerName, "Portfolio");
|
|
assert.equal(profiles[0].adoptedFromServer, true);
|
|
assert.equal(states.get(profiles[0].id).matchesGitea, true);
|
|
});
|
|
|
|
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&B<"x">");
|
|
});
|
|
|
|
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/);
|
|
});
|