This commit is contained in:
NuklearRabbit
2026-07-27 23:15:35 +02:00
parent 03c2608a35
commit f990c26014
18 changed files with 2439 additions and 938 deletions
+372 -2
View File
@@ -16,6 +16,7 @@ const {
bash,
} = require("../src/main/unraid-deployment-service.cjs");
const { fingerprintKey, shellQuote } = require("../src/main/ssh-service.cjs");
const { buildWorkloadInventory } = require("../src/main/server-inventory.cjs");
test("Unraid remote paths cannot escape appdata project folder", () => {
assert.equal(safeRemoteFolder("lumaops"), "lumaops");
@@ -131,6 +132,164 @@ test("automatic server discovery adopts and verifies a running Gitea deployment"
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);
@@ -207,6 +366,9 @@ test("SSH helpers produce pinned fingerprints and quoted commands", () => {
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/);
});
@@ -428,8 +590,8 @@ test("DockerMan metadata uses dockerman labels, a template WebUI and lowercase-s
"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.doesNotMatch(metadata, /image: forgeflow\/portfolio:production/);
assert.doesNotMatch(metadata, /container_name: Portfolio/);
assert.match(metadata, /net\.unraid\.docker\.managed.*dockerman/);
assert.match(
metadata,
@@ -456,6 +618,8 @@ test("DockerMan integration writes a persistent template fallback and invalidate
hostPort: 5150,
webUiUrl: "http://192.168.10.150:5150/",
dockerShell: "/bin/sh",
manageDockerMan: true,
generatedCompose: true,
};
const repository = { name: "Portfolio" };
const icon =
@@ -482,6 +646,32 @@ test("DockerMan integration writes a persistent template fallback and invalidate
assert.equal(xmlEscape('A&B<"x">'), "A&amp;B&lt;&quot;x&quot;&gt;");
});
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");
@@ -759,3 +949,183 @@ test("a failed deployment is marked superseded when Gitea and Unraid agree on a
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.ok(invocation.indexOf("-f 'compose.unraid.yml'") < invocation.indexOf("-f '.forgeflow\/compose.metadata.yml'"));
assert.deepEqual(service.deploymentServices(profile, repository), ["api", "worker"]);
});
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 upIndex = script.indexOf("up -d --build");
const serviceCheckIndex = script.indexOf("Compose service $service did not create a container");
const promoteIndex = script.indexOf('current-sha.pending');
assert.ok(configIndex >= 0 && configIndex < 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,
});
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 === "server-git-access")?.status, "pass");
assert.match(result.checks.find((item) => item.id === "server-git-access")?.detail || "", /Not required/);
assert.equal(result.checks.some((item) => item.id === "server-git-command"), false);
assert.equal(result.summary.ready, true);
});