Update
This commit is contained in:
@@ -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&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");
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user