1245 lines
43 KiB
JavaScript
1245 lines
43 KiB
JavaScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { createRequire } from "node:module";
|
|
import { readFile } from "node:fs/promises";
|
|
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");
|
|
const { buildWorkloadInventory, deploymentRootCandidate } = require("../src/main/server-inventory.cjs");
|
|
|
|
async function unraidSource() {
|
|
const files = ["unraid-deployment-service.cjs", "unraid-access-methods.cjs", "unraid-preflight-methods.cjs", "unraid-runtime-methods.cjs", "unraid-deployment-methods.cjs", "unraid-inventory-methods.cjs"];
|
|
return (await Promise.all(files.map((file) => readFile(new URL(`../src/main/${file}`, import.meta.url), "utf8")))).join("\n");
|
|
}
|
|
|
|
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 release directories resolve to the stable deployment root", () => {
|
|
assert.equal(deploymentRootCandidate("infrabinder/releases/f8b0dd8"), "infrabinder");
|
|
assert.equal(deploymentRootCandidate("portfolio/.forgeflow/releases/abc/source"), "portfolio");
|
|
assert.equal(deploymentRootCandidate("ludarium/source/deploy"), "ludarium/source/deploy");
|
|
});
|
|
|
|
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 pull provisions a pinned repository-scoped key and records access metadata", async () => {
|
|
const publicKey = `ssh-ed25519 ${Buffer.from("server-public-key").toString("base64")} forgeflow:test`;
|
|
const profile = {
|
|
id: "profile-1", provider: "ssh-unraid", serverId: "unraid", remoteFolder: "portfolio",
|
|
branch: "main", deploymentMode: "server-git", composeFiles: ["compose.yml"], composeServices: ["portfolio"],
|
|
};
|
|
let saved = null;
|
|
let deployKeyRequest = null;
|
|
let calls = 0;
|
|
const service = new UnraidDeploymentService({
|
|
store: {
|
|
getDeploymentProfile: () => profile,
|
|
getServer: () => ({ id: "unraid", name: "Unraid", basePath: "/mnt/user/appdata" }),
|
|
saveDeploymentProfile: async (_fullName, value) => { saved = value; return value; },
|
|
},
|
|
ssh: { exec: async () => {
|
|
calls += 1;
|
|
if (calls === 1) return { stdout: `__FORGEFLOW_DEPLOY_KEY__\npublicKey=${Buffer.from(publicKey).toString("base64")}\nfingerprint=SHA256:key\nhostFingerprint=SHA256:host\n` };
|
|
return { stdout: `${"a".repeat(40)}\trefs/heads/main\n` };
|
|
} },
|
|
gitea: { ensureReadOnlyDeployKey: async (request) => { deployKeyRequest = request; return { id: 17, created: true }; } },
|
|
});
|
|
const result = await service.configureServerGitAccess({
|
|
repository: { fullName: "Jens/Portfolio", sshUrl: "git@gitea.example.test:Jens/Portfolio.git" },
|
|
profileId: profile.id,
|
|
});
|
|
assert.equal(deployKeyRequest.owner, "Jens");
|
|
assert.equal(deployKeyRequest.repo, "Portfolio");
|
|
assert.equal(saved.deploymentMode, "server-git");
|
|
assert.equal(saved.serverGitAccess.hostFingerprint, "SHA256:host");
|
|
assert.equal(result.remoteSha, "a".repeat(40));
|
|
});
|
|
|
|
test("server pull verification proves a repository-scoped read-only key and exact commit parity", async () => {
|
|
const sha = "c".repeat(40);
|
|
const profile = {
|
|
id: "profile-verify", provider: "ssh-unraid", serverId: "unraid", remoteFolder: "portfolio",
|
|
environment: "production", branch: "main", deploymentMode: "server-git", composeFiles: ["compose.yml"],
|
|
serverGitAccess: { deployKeyId: 17, keyFingerprint: "SHA256:key", hostFingerprint: "SHA256:host" },
|
|
};
|
|
const service = new UnraidDeploymentService({
|
|
store: {
|
|
getDeploymentProfile: () => profile,
|
|
getServer: () => ({ id: "unraid", name: "Unraid", basePath: "/mnt/user/appdata" }),
|
|
getDeploymentState: () => ({ liveSha: sha, containerRunning: true, healthy: true }),
|
|
},
|
|
ssh: { exec: async () => ({ stdout: `__FORGEFLOW_SERVER_GIT_PROBE__\nremoteSha=${sha}\nkeyFingerprint=SHA256:key\nhostFingerprint=SHA256:host\n` }) },
|
|
gitea: {
|
|
getBranch: async () => ({ commit: { id: sha } }),
|
|
listDeployKeys: async () => [{ id: 17, read_only: true }],
|
|
},
|
|
});
|
|
service.inspect = async () => ({ exists: true, composeFiles: ["compose.yml"], head: sha });
|
|
const report = await service.verifyServerGitProfile({ repository: { fullName: "Jens/Portfolio", sshUrl: "git@gitea.test:Jens/Portfolio.git" }, profileId: profile.id });
|
|
assert.equal(report.readiness, "Ready");
|
|
assert.equal(report.ready, true);
|
|
assert.equal(report.checks.find((check) => check.id === "deploy-key-scope").status, "pass");
|
|
});
|
|
|
|
test("server pull verification blocks a writable Gitea deploy key", async () => {
|
|
const sha = "d".repeat(40);
|
|
const profile = {
|
|
id: "profile-writable", provider: "ssh-unraid", serverId: "unraid", remoteFolder: "portfolio",
|
|
environment: "production", branch: "main", deploymentMode: "server-git", composeFiles: ["compose.yml"],
|
|
serverGitAccess: { deployKeyId: 18, keyFingerprint: "SHA256:key", hostFingerprint: "SHA256:host" },
|
|
};
|
|
const service = new UnraidDeploymentService({
|
|
store: {
|
|
getDeploymentProfile: () => profile,
|
|
getServer: () => ({ id: "unraid", name: "Unraid", basePath: "/mnt/user/appdata" }),
|
|
getDeploymentState: () => ({ liveSha: sha, containerRunning: true, healthy: true }),
|
|
},
|
|
ssh: { exec: async () => ({ stdout: `__FORGEFLOW_SERVER_GIT_PROBE__\nremoteSha=${sha}\nkeyFingerprint=SHA256:key\nhostFingerprint=SHA256:host\n` }) },
|
|
gitea: { getBranch: async () => ({ commit: { id: sha } }), listDeployKeys: async () => [{ id: 18, read_only: false }] },
|
|
});
|
|
service.inspect = async () => ({ exists: true, composeFiles: ["compose.yml"], head: sha });
|
|
const report = await service.verifyServerGitProfile({ repository: { fullName: "Jens/Portfolio", sshUrl: "git@gitea.test:Jens/Portfolio.git" }, profileId: profile.id });
|
|
assert.equal(report.readiness, "Access failed");
|
|
assert.equal(report.ready, false);
|
|
});
|
|
|
|
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("server discovery is read-only and explicit reconciliation adopts a verified 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 repositories = [
|
|
{
|
|
fullName: "Jens/Portfolio",
|
|
name: "Portfolio",
|
|
defaultBranch: "main",
|
|
cloneUrl: "https://gitea.itworx.tech/Jens/Portfolio.git",
|
|
sshUrl: "git@gitea.itworx.tech:Jens/Portfolio.git",
|
|
},
|
|
];
|
|
const discovery = await service.discoverServerWorkloads("unraid", repositories);
|
|
assert.equal(discovery.adopted, 0);
|
|
assert.equal(discovery.verified, 0);
|
|
assert.equal(profiles.length, 0);
|
|
assert.equal(states.size, 0);
|
|
|
|
const preview = await service.planServerInventoryReconciliation("unraid", repositories, { autoLink: true });
|
|
assert.equal(preview.plan.summary.additions, 1);
|
|
const result = await service.reconcileServerInventory("unraid", repositories, { autoLink: true, expectedPlanId: preview.plan.id });
|
|
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("server inventory includes stopped DockerMan containers without Git and keeps name matches manual", () => {
|
|
const workloads = buildWorkloadInventory({
|
|
inventory: {
|
|
checkouts: [],
|
|
dockerMan: [
|
|
{
|
|
name: "omniroute",
|
|
templatePath: "/boot/config/plugins/dockerMan/templates-user/my-omniroute.xml",
|
|
webUiUrl: "http://[IP]:[PORT:20128]/",
|
|
iconUrl: "",
|
|
shell: "sh",
|
|
repository: "diegosouzapw/omniroute:latest",
|
|
network: "bridge",
|
|
},
|
|
],
|
|
containers: [
|
|
{
|
|
id: "container-1",
|
|
name: "omniroute",
|
|
image: "ghcr.io/diegosouzapw/omniroute:latest",
|
|
imageId: "sha256:image",
|
|
running: false,
|
|
status: "exited",
|
|
health: null,
|
|
labels: {},
|
|
ports: { "3000/tcp": [{ HostPort: "20128", HostIp: "0.0.0.0" }] },
|
|
mounts: [
|
|
{
|
|
Type: "bind",
|
|
Source: "/mnt/user/appdata/OmniRoute/config",
|
|
Destination: "/app/config",
|
|
RW: true,
|
|
},
|
|
],
|
|
networks: { bridge: {} },
|
|
restartPolicy: "unless-stopped",
|
|
},
|
|
],
|
|
warnings: [],
|
|
capabilities: { docker: true, compose: true },
|
|
},
|
|
server: {
|
|
id: "unraid",
|
|
name: "Unraid",
|
|
basePath: "/mnt/user/appdata",
|
|
},
|
|
repositories: [
|
|
{
|
|
fullName: "Jens/OmniRoute",
|
|
name: "OmniRoute",
|
|
cloneUrl: "https://gitea.itworx.tech/Jens/OmniRoute.git",
|
|
},
|
|
],
|
|
profiles: [],
|
|
});
|
|
assert.equal(workloads.length, 1);
|
|
assert.equal(workloads[0].runtime.running, false);
|
|
assert.equal(workloads[0].kind, "dockerman-container");
|
|
assert.equal(workloads[0].remoteFolderCandidate, "OmniRoute");
|
|
assert.equal(workloads[0].status, "suggested");
|
|
assert.equal(workloads[0].candidates[0].exact, false);
|
|
assert.match(workloads[0].candidates[0].reasons.join(" "), /manual confirmation/i);
|
|
});
|
|
|
|
test("server inventory groups multi-service Compose projects and preserves their identity", () => {
|
|
const baseContainer = {
|
|
image: "example/app:latest",
|
|
imageId: "sha256:image",
|
|
running: true,
|
|
status: "running",
|
|
health: null,
|
|
ports: {},
|
|
mounts: [],
|
|
networks: { appnet: {} },
|
|
restartPolicy: "unless-stopped",
|
|
};
|
|
const labels = {
|
|
"com.docker.compose.project": "forgeflow",
|
|
"com.docker.compose.project.working_dir": "/mnt/user/appdata/ForgeFlow",
|
|
"com.docker.compose.project.config_files": "/mnt/user/appdata/ForgeFlow/compose.yml,/mnt/user/appdata/ForgeFlow/compose.prod.yml",
|
|
};
|
|
const workloads = buildWorkloadInventory({
|
|
inventory: {
|
|
checkouts: [],
|
|
dockerMan: [],
|
|
warnings: [],
|
|
capabilities: {},
|
|
containers: [
|
|
{
|
|
...baseContainer,
|
|
id: "web",
|
|
name: "forgeflow-web-1",
|
|
labels: { ...labels, "com.docker.compose.service": "web" },
|
|
},
|
|
{
|
|
...baseContainer,
|
|
id: "worker",
|
|
name: "forgeflow-worker-1",
|
|
labels: { ...labels, "com.docker.compose.service": "worker" },
|
|
},
|
|
],
|
|
},
|
|
server: { id: "unraid", name: "Unraid", basePath: "/mnt/user/appdata" },
|
|
repositories: [],
|
|
profiles: [],
|
|
});
|
|
assert.equal(workloads.length, 1);
|
|
assert.deepEqual(workloads[0].compose.services.sort(), ["web", "worker"]);
|
|
assert.deepEqual(workloads[0].compose.configFiles, [
|
|
"/mnt/user/appdata/ForgeFlow/compose.yml",
|
|
"/mnt/user/appdata/ForgeFlow/compose.prod.yml",
|
|
]);
|
|
assert.equal(workloads[0].compose.project, "forgeflow");
|
|
assert.equal(workloads[0].remoteFolderCandidate, "ForgeFlow");
|
|
});
|
|
|
|
|
|
|
|
test("manual workload linking does not claim Gitea parity for unrelated provenance", async () => {
|
|
let savedState = null;
|
|
const service = new UnraidDeploymentService({
|
|
store: {
|
|
saveDeploymentState: async (_id, state) => {
|
|
savedState = state;
|
|
return state;
|
|
},
|
|
},
|
|
ssh: {},
|
|
git: {},
|
|
diagnostics: null,
|
|
});
|
|
await service.saveWorkloadState(
|
|
{
|
|
id: "profile",
|
|
containerName: "app",
|
|
remoteFolder: "app",
|
|
cloneUrl: "https://gitea.itworx.tech/Jens/Expected.git",
|
|
},
|
|
{
|
|
workloadId: "workload",
|
|
observedAt: new Date().toISOString(),
|
|
metadata: {
|
|
sourceRepository: "https://gitea.itworx.tech/Jens/Other.git",
|
|
liveRevision: "a".repeat(40),
|
|
},
|
|
runtime: { running: true, health: "healthy" },
|
|
containers: [{ name: "app", running: true, health: "healthy" }],
|
|
compose: { project: "app" },
|
|
},
|
|
{ basePath: "/mnt/user/appdata" },
|
|
);
|
|
assert.equal(savedState.liveSha, "a".repeat(40));
|
|
assert.equal(savedState.matchesGitea, false);
|
|
assert.equal(savedState.giteaSha, null);
|
|
});
|
|
|
|
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, /forgeflow_compose\(\)/);
|
|
assert.match(decoded, /docker compose "\$@"/);
|
|
assert.match(decoded, /docker-compose "\$@"/);
|
|
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,
|
|
});
|
|
service.executePushBundle = async () => ({ stdout: "rollback activated", stderr: "", exitCode: 0 });
|
|
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,
|
|
});
|
|
service.executePushBundle = async () => new Promise((resolve) => { resolveRemote = resolve; });
|
|
|
|
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 unraidSource();
|
|
assert.match(source, /server-git-access/);
|
|
assert.match(source, /git ls-remote --exit-code/);
|
|
assert.match(source, /Unraid .* Gitea read 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.doesNotMatch(metadata, /image: forgeflow\/portfolio:production/);
|
|
assert.doesNotMatch(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",
|
|
manageDockerMan: true,
|
|
generatedCompose: true,
|
|
};
|
|
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("adopted DockerMan templates are never rewritten", () => {
|
|
const service = new UnraidDeploymentService({
|
|
store: {},
|
|
ssh: {},
|
|
git: {},
|
|
diagnostics: null,
|
|
});
|
|
const refresh = service.dockerManRefreshScript(
|
|
{
|
|
composeService: "omniroute",
|
|
containerName: "omniroute",
|
|
remoteFolder: "OmniRoute",
|
|
environment: "production",
|
|
manageDockerMan: true,
|
|
generatedCompose: false,
|
|
adoptedFromServer: true,
|
|
},
|
|
{ name: "OmniRoute" },
|
|
"",
|
|
);
|
|
assert.match(refresh, /left the existing DockerMan template unchanged/);
|
|
assert.doesNotMatch(refresh, /templates-user/);
|
|
});
|
|
|
|
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 unraidSource();
|
|
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, /never adds destructive recreation or orphan-removal flags/);
|
|
});
|
|
|
|
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/);
|
|
});
|
|
|
|
test("linked Compose deployments retain the existing project, files and service set", () => {
|
|
const service = new UnraidDeploymentService({ store: {}, ssh: {}, git: {} });
|
|
const repository = { name: "OmniRoute", fullName: "Jens/OmniRoute" };
|
|
const profile = {
|
|
composeProject: "omniroute-production",
|
|
composeFiles: ["compose.yml", "compose.unraid.yml"],
|
|
composeServices: ["api", "worker"],
|
|
generatedCompose: false,
|
|
};
|
|
const invocation = service.composeInvocation(profile, repository);
|
|
assert.match(invocation, /-p 'omniroute-production'/);
|
|
assert.ok(invocation.indexOf("-f 'compose.yml'") < invocation.indexOf("-f 'compose.unraid.yml'"));
|
|
assert.doesNotMatch(invocation, /compose\.metadata\.yml/);
|
|
assert.deepEqual(service.deploymentServices(profile, repository), ["api", "worker"]);
|
|
});
|
|
|
|
test("inventory scan uses only configured roots and reports partial find failures", () => {
|
|
const service = new UnraidDeploymentService({ store: {}, ssh: {}, git: {} });
|
|
const script = service.inventoryScript({
|
|
basePath: "/mnt/user/appdata",
|
|
scanRoots: ["/mnt/user/appdata", "/mnt/cache/custom apps"],
|
|
scanExcludes: ["archive-*", "scratch"],
|
|
});
|
|
assert.match(script, /add_scan_root '\/mnt\/user\/appdata'/);
|
|
assert.match(script, /add_scan_root '\/mnt\/cache\/custom apps'/);
|
|
assert.match(script, /-name 'archive-\*'/);
|
|
assert.match(script, /-name 'scratch'/);
|
|
assert.match(script, /Inventory scan partially failed/);
|
|
assert.match(script, /2>"\$scan_error" \|\| true/);
|
|
assert.doesNotMatch(script, /add_scan_root \/mnt\/cache\/appdata/);
|
|
});
|
|
|
|
test("push bundle activation validates Compose and services before promoting current SHA", () => {
|
|
const service = new UnraidDeploymentService({ store: {}, ssh: {}, git: {} });
|
|
const repository = { name: "OmniRoute", fullName: "Jens/OmniRoute" };
|
|
const profile = {
|
|
environment: "production",
|
|
deploymentMode: "push-bundle",
|
|
composeProject: "omniroute",
|
|
composeFiles: ["compose.yml", "compose.unraid.yml"],
|
|
composeServices: ["api", "worker"],
|
|
preservePaths: ["data", "config"],
|
|
generatedCompose: false,
|
|
adoptedFromServer: true,
|
|
manageDockerMan: false,
|
|
};
|
|
const script = service.pushBundleScript({
|
|
repository,
|
|
profile,
|
|
remotePath: "/mnt/user/appdata/OmniRoute",
|
|
targetSha: "a".repeat(40),
|
|
requestId: "request-1",
|
|
remotePart: "/mnt/user/appdata/.forgeflow/incoming/request-1.tar.part",
|
|
digest: "b".repeat(64),
|
|
metadata: "services:\n api:\n labels: {}\n worker:\n labels: {}\n",
|
|
generated: "",
|
|
iconReference: "",
|
|
});
|
|
const configIndex = script.indexOf("config >/dev/null");
|
|
const buildIndex = script.indexOf("build");
|
|
const upIndex = script.indexOf("up -d --no-build", buildIndex);
|
|
const serviceCheckIndex = script.indexOf("Compose service $service did not create a container");
|
|
const promoteIndex = script.indexOf('current-sha.pending');
|
|
assert.ok(configIndex >= 0 && configIndex < buildIndex);
|
|
assert.ok(buildIndex < upIndex);
|
|
assert.ok(upIndex < serviceCheckIndex);
|
|
assert.ok(serviceCheckIndex < promoteIndex);
|
|
assert.match(script, /mmin \+120/);
|
|
assert.match(script, /grep -E '\(\^\/\|\(\^\|\/\)\\\.\\\.\(\/\|\$\)\)'/);
|
|
assert.match(script, /kill -0 "\$lock_pid"/);
|
|
assert.match(script, /is_preserved "\$rel" && continue/);
|
|
assert.doesNotMatch(script, /git clone|git -C "\$root" fetch/);
|
|
assert.match(script, /ForgeFlow left the existing DockerMan template unchanged/);
|
|
});
|
|
|
|
test("generated push-bundle command passes Bash syntax validation", { skip: process.platform === "win32" }, async () => {
|
|
const { spawnSync } = await import("node:child_process");
|
|
const service = new UnraidDeploymentService({ store: {}, ssh: {}, git: {} });
|
|
const repository = { name: "Demo", fullName: "Jens/Demo" };
|
|
const profile = {
|
|
environment: "production",
|
|
deploymentMode: "push-bundle",
|
|
composeProject: "demo",
|
|
composeFiles: ["compose.yml"],
|
|
composeServices: ["app"],
|
|
preservePaths: ["data"],
|
|
generatedCompose: false,
|
|
adoptedFromServer: true,
|
|
manageDockerMan: false,
|
|
};
|
|
const generated = service.pushBundleScript({
|
|
repository,
|
|
profile,
|
|
remotePath: "/mnt/user/appdata/Demo",
|
|
targetSha: "d".repeat(40),
|
|
requestId: "syntax-test",
|
|
remotePart: "/mnt/user/appdata/Demo/.forgeflow/incoming/syntax-test.tar.part",
|
|
digest: "e".repeat(64),
|
|
metadata: "services:\n app:\n labels: {}\n",
|
|
generated: "",
|
|
iconReference: "",
|
|
});
|
|
const wrapped = bash(generated);
|
|
const encoded = wrapped.match(/printf '%s' '([A-Za-z0-9+/=]+)'/)[1];
|
|
const script = Buffer.from(encoded, "base64").toString("utf8");
|
|
const result = spawnSync("bash", ["-n"], { input: script, encoding: "utf8" });
|
|
assert.equal(result.status, 0, result.stderr || result.stdout);
|
|
});
|
|
|
|
test("push bundle preflight does not require Git or Gitea credentials on Unraid", async (context) => {
|
|
const { mkdtemp, writeFile, rm } = await import("node:fs/promises");
|
|
const { tmpdir } = await import("node:os");
|
|
const { join } = await import("node:path");
|
|
const localPath = await mkdtemp(join(tmpdir(), "forgeflow-push-preflight-"));
|
|
context.after(() => rm(localPath, { recursive: true, force: true }));
|
|
await writeFile(join(localPath, "compose.yml"), "services:\n app:\n image: example/app:latest\n");
|
|
const sha = "c".repeat(40);
|
|
const profile = {
|
|
id: "production",
|
|
name: "Production",
|
|
environment: "production",
|
|
provider: "ssh-unraid",
|
|
branch: "main",
|
|
serverId: "unraid",
|
|
remoteFolder: "OmniRoute",
|
|
deploymentMode: "push-bundle",
|
|
composeFile: "compose.yml",
|
|
composeFiles: ["compose.yml"],
|
|
composeService: "app",
|
|
composeServices: ["app"],
|
|
iconMode: "none",
|
|
generatedCompose: false,
|
|
preservePaths: ["data"],
|
|
};
|
|
let remoteGitProbeCount = 0;
|
|
const service = new UnraidDeploymentService({
|
|
store: {
|
|
getDeploymentProfile: () => profile,
|
|
getServer: () => ({
|
|
id: "unraid",
|
|
name: "Unraid",
|
|
host: "192.168.10.150",
|
|
port: 22,
|
|
username: "root",
|
|
basePath: "/mnt/user/appdata",
|
|
hostFingerprint: "SHA256:test",
|
|
}),
|
|
},
|
|
git: {
|
|
status: async () => ({
|
|
root: localPath,
|
|
head: sha,
|
|
clean: true,
|
|
counts: { changed: 0 },
|
|
branch: { head: "main", upstream: "origin/main", ahead: 0, behind: 0 },
|
|
}),
|
|
verifyCommitOnRemoteBranch: async () => true,
|
|
},
|
|
ssh: {
|
|
test: async () => ({
|
|
capabilities: {
|
|
docker: true,
|
|
dockerReady: true,
|
|
compose: true,
|
|
composeVersion: "Docker Compose version v2",
|
|
git: false,
|
|
tar: true,
|
|
checksum: true,
|
|
baseWritable: true,
|
|
},
|
|
}),
|
|
exec: async (_serverId, command) => {
|
|
if (String(command).includes("git ls-remote")) remoteGitProbeCount += 1;
|
|
const encodedFiles = Buffer.from("").toString("base64");
|
|
return {
|
|
stdout: `__FORGEFLOW_KV__\nexists=false\nrootGit=false\nhead=\nbranch=\nremote=\ntrackedChanges=\ncomposeFiles=${encodedFiles}\nnestedGit=\ndockerfile=false\ndockerignoreContent=\nexistingPreservePaths=\n`,
|
|
};
|
|
},
|
|
},
|
|
sourcePath: new URL("..", import.meta.url).pathname,
|
|
});
|
|
service.inspectWriteAccess = async () => ({ ready: true, blocking: [], targets: [], identity: { user: "root" } });
|
|
const result = await service.preflight({
|
|
repository: {
|
|
fullName: "Jens/OmniRoute",
|
|
name: "OmniRoute",
|
|
localPath,
|
|
localStatus: { head: sha },
|
|
},
|
|
profileId: "production",
|
|
sha,
|
|
});
|
|
assert.equal(remoteGitProbeCount, 0);
|
|
assert.equal(result.checks.find((item) => item.id === "transfer-path")?.status, "pass");
|
|
assert.match(result.checks.find((item) => item.id === "transfer-path")?.detail || "", /No Gitea credential/);
|
|
assert.equal(result.checks.some((item) => item.id === "server-git-command"), false);
|
|
assert.equal(result.summary.ready, true, JSON.stringify(result.checks.filter((item) => item.status === "fail")));
|
|
});
|