Release ForgeFlow 0.8.3 premium deployment UX
This commit is contained in:
+539
-175
@@ -1,247 +1,484 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRequire } from 'node:module';
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createRequire } from "node:module";
|
||||
const require = createRequire(import.meta.url);
|
||||
const { UnraidDeploymentService, safeRemoteFolder, safeRelativeRemoteFile, parseInspection, dockerIgnoreHasPath, checksSummary, xmlEscape, bash } = require('../src/main/unraid-deployment-service.cjs');
|
||||
const { fingerprintKey, shellQuote } = require('../src/main/ssh-service.cjs');
|
||||
const {
|
||||
UnraidDeploymentService,
|
||||
safeRemoteFolder,
|
||||
safeRelativeRemoteFile,
|
||||
parseInspection,
|
||||
dockerIgnoreHasPath,
|
||||
checksSummary,
|
||||
xmlEscape,
|
||||
bash,
|
||||
} = require("../src/main/unraid-deployment-service.cjs");
|
||||
const { fingerprintKey, shellQuote } = require("../src/main/ssh-service.cjs");
|
||||
|
||||
test('Unraid remote paths cannot escape appdata project folder', () => {
|
||||
assert.equal(safeRemoteFolder('lumaops'), 'lumaops');
|
||||
assert.throws(() => safeRemoteFolder('../lumaops'));
|
||||
assert.equal(safeRelativeRemoteFile('deploy/docker-compose.yml'), 'deploy/docker-compose.yml');
|
||||
assert.throws(() => safeRelativeRemoteFile('../../etc/passwd'));
|
||||
test("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`);
|
||||
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.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']);
|
||||
assert.deepEqual(parsed.existingPreservePaths, ["data", "logs"]);
|
||||
});
|
||||
|
||||
test('Docker ignore checks identify exact runtime and Git context exclusions', () => {
|
||||
const rules = '# build context\n.git\ndata/\nlogs/**\n!logs/keep.txt\n';
|
||||
assert.equal(dockerIgnoreHasPath(rules, '.git'), true);
|
||||
assert.equal(dockerIgnoreHasPath(rules, 'data'), true);
|
||||
assert.equal(dockerIgnoreHasPath(rules, 'logs'), true);
|
||||
assert.equal(dockerIgnoreHasPath(rules, 'source'), false);
|
||||
test("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 = '';
|
||||
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']
|
||||
id: "production",
|
||||
provider: "ssh-unraid",
|
||||
serverId: "unraid",
|
||||
remoteFolder: "lumaops",
|
||||
preservePaths: ["data", "logs"],
|
||||
}),
|
||||
getServer: () => ({
|
||||
id: 'unraid',
|
||||
name: 'Unraid',
|
||||
basePath: '/mnt/user/appdata'
|
||||
})
|
||||
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
|
||||
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' });
|
||||
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.remotePath, "/mnt/user/appdata/lumaops");
|
||||
assert.equal(inspection.dockerignoreGitExcluded, true);
|
||||
assert.deepEqual(inspection.existingPreservePaths.sort(), ['data', 'logs']);
|
||||
assert.deepEqual(inspection.dockerContextExclusionsMissing.sort(), ['logs', 'source']);
|
||||
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' }]);
|
||||
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']);
|
||||
assert.deepEqual(result.blocking, ["c"]);
|
||||
});
|
||||
|
||||
test('SSH helpers produce pinned fingerprints and quoted commands', () => {
|
||||
assert.match(fingerprintKey(Buffer.from('host-key')), /^SHA256:/);
|
||||
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');
|
||||
const wrapped = bash("git fetch origin main");
|
||||
assert.match(wrapped, /base64 -d \| bash$/);
|
||||
assert.equal(wrapped.includes('\n'), false);
|
||||
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');
|
||||
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);
|
||||
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 })
|
||||
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 });
|
||||
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'
|
||||
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);
|
||||
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' }),
|
||||
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; }
|
||||
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');
|
||||
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' });
|
||||
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);
|
||||
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 () => ({})
|
||||
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 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);
|
||||
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 });
|
||||
resolveRemote({
|
||||
stdout: "Container Portfolio started\n",
|
||||
stderr: "",
|
||||
exitCode: 0,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
assert.equal(operations.at(-1).status, 'success');
|
||||
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'));
|
||||
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');
|
||||
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/);
|
||||
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 });
|
||||
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'
|
||||
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 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,
|
||||
/<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(
|
||||
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.match(
|
||||
refresh,
|
||||
/cp '\/boot\/config\/plugins\/dockerMan\/images\/Portfolio-icon\.png'/,
|
||||
);
|
||||
assert.doesNotMatch(refresh, /dockerManRefreshScript/);
|
||||
assert.equal(xmlEscape('A&B<"x">'), 'A&B<"x">');
|
||||
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-'));
|
||||
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');
|
||||
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 {}; } }
|
||||
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');
|
||||
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][0], "unraid");
|
||||
assert.equal(uploads[0][1], asset);
|
||||
assert.equal(uploads[0][2], '/boot/config/plugins/dockerMan/images/Portfolio-icon.png');
|
||||
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);
|
||||
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 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; }
|
||||
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');
|
||||
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');
|
||||
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'));
|
||||
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/);
|
||||
@@ -249,55 +486,182 @@ test('DockerMan metadata repair refreshes known Unraid icon caches after contain
|
||||
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: [] };
|
||||
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
|
||||
store: {
|
||||
getOperation: () => operation,
|
||||
addOperation: async (next) => {
|
||||
saved.push(next);
|
||||
return next;
|
||||
},
|
||||
},
|
||||
ssh: {},
|
||||
git: {},
|
||||
diagnostics: null,
|
||||
});
|
||||
service.refreshProfileState = async () => ({
|
||||
liveSha: live,
|
||||
containerRunning: true,
|
||||
healthy: true,
|
||||
});
|
||||
service.refreshProfileState = async () => ({ liveSha: live, containerRunning: true, healthy: true });
|
||||
const result = await service.refreshOperation(operation.id);
|
||||
assert.equal(result.status, 'cancelled');
|
||||
assert.equal(result.status, "cancelled");
|
||||
assert.match(result.error, /Superseded/);
|
||||
assert.equal(saved.at(-1).status, 'cancelled');
|
||||
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');
|
||||
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',
|
||||
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>'
|
||||
}
|
||||
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');
|
||||
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/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user