Files
ForgeFlow/tests/unraid-deployment.test.mjs
T
NuklearRabbit dec3b79793
Managed validation / full (pull_request) Successful in 27s
hygiene: prepare ForgeFlow for public release
2026-09-02 23:37:30 +02:00

1542 lines
58 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("inventory refresh preserves a repository deployment root above its Compose working directory", () => {
const service = new UnraidDeploymentService({});
const existing = {
id: "profile", provider: "ssh-unraid", serverId: "server", environment: "production",
branch: "main", deploymentMode: "server-git", remoteFolder: "App/source",
composeWorkingDir: "/mnt/user/appdata/App/source/ops", composeProject: "app",
composeFiles: ["ops/compose.yml"], composeServices: ["web"], containerName: "app-web",
workloadIdentity: { linkSource: "automatic-compose" }, preservePaths: [".env"],
};
const workload = {
workloadId: "workload", kind: "compose-project", displayName: "app",
remoteFolderCandidate: "App/source/ops", selector: { kind: "compose-project", composeProject: "app" },
compose: {
project: "app", workingDir: "/mnt/user/appdata/App/source/ops",
configFiles: ["/mnt/user/appdata/App/source/ops/compose.yml"], services: ["web"],
},
containers: [{ name: "app-web", running: true, service: "web", mounts: [], ports: [] }],
metadata: {}, dockerMan: null,
};
const refreshed = service.refreshedProfileFromWorkload(
{ fullName: "Owner/App", name: "App", defaultBranch: "main", sshUrl: "git@gitea.test:Owner/App.git" },
{ id: "server", name: "Server", basePath: "/mnt/user/appdata" },
workload,
existing,
);
assert.equal(refreshed.remoteFolder, "App/source");
assert.equal(refreshed.composeWorkingDir, "/mnt/user/appdata/App/source/ops");
assert.deepEqual(refreshed.composeFiles, ["ops/compose.yml"]);
});
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 prefers the linked checkout origin over stale detected SSH endpoints", () => {
const service = new UnraidDeploymentService({});
const repository = {
fullName: "Jens/Portfolio",
localStatus: { remoteUrl: "git@gitea.itworx.tech:Jens/Portfolio.git" },
sshUrl: "ssh://git@192.168.56.10:222/Jens/Portfolio.git",
preferredCloneUrl: "ssh://git@192.168.56.10:222/Jens/Portfolio.git",
};
const profile = { cloneUrl: "ssh://git@192.168.56.10:222/Jens/Portfolio.git" };
assert.equal(service.serverGitRemote(repository, profile), "git@gitea.itworx.tech:Jens/Portfolio.git");
assert.deepEqual(service.serverGitHost(repository, profile), { host: "gitea.itworx.tech", port: 22 });
});
test("write-access inspection parses the remote permission report through the access module", async () => {
const encoded = (value) => Buffer.from(value).toString("base64");
const profile = { id: "profile", provider: "ssh-unraid", serverId: "server", remoteFolder: "App", composeFiles: ["compose.yml"] };
const service = new UnraidDeploymentService({
store: {
getDeploymentProfile: () => profile,
getServer: () => ({ id: "server", basePath: "/mnt/user/appdata" }),
},
ssh: { exec: async () => ({ stdout: `__FORGEFLOW_PERMISSIONS__\nI\t${encoded("deploy")}\t1000\t1000\t${encoded("users")}\tfalse\tfalse\nP\t${encoded("project-root")}\t${encoded("Project folder")}\t${encoded("/mnt/user/appdata/App")}\tdirectory\ttrue\ttrue\ttrue\ttrue\ttrue\ttrue\t${encoded("deploy")}\t${encoded("users")}\t2775\t${encoded("/mnt/user/appdata/App")}\t${encoded("Read/write probe passed.")}\n` }) },
});
const report = await service.inspectWriteAccess({ repository: { fullName: "Owner/App" }, profileId: "profile" });
assert.equal(report.ready, true);
assert.equal(report.identity.user, "deploy");
assert.equal(report.targets[0].effectiveWritable, true);
});
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 remains deploy-ready when only live runtime evidence is incomplete", async () => {
const sha = "c".repeat(40);
const profile = {
id: "profile-runtime-incomplete", 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: () => ({ containerRunning: true, healthy: null }),
},
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: null });
const report = await service.verifyServerGitProfile({ repository: { fullName: "Jens/Portfolio", sshUrl: "git@gitea.test:Jens/Portfolio.git" }, profileId: profile.id });
assert.equal(report.deployReady, true);
assert.equal(report.ready, true);
assert.equal(report.readiness, "Deploy-ready; runtime verification incomplete");
assert.deepEqual(report.deploymentBlockers, []);
});
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("low-level inventory scan is read-only and user discovery auto-links exact provenance", 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 readOnlyDiscovery = await service.scanServerInventory("unraid", repositories);
assert.equal(readOnlyDiscovery.adopted, 0);
assert.equal(readOnlyDiscovery.verified, 0);
assert.equal(profiles.length, 0);
assert.equal(states.size, 0);
const discovery = await service.discoverServerWorkloads("unraid", repositories);
assert.equal(discovery.adopted, 1);
assert.equal(discovery.verified, 1);
assert.equal(profiles[0].containerName, "Portfolio");
assert.equal(profiles[0].adoptedFromServer, true);
assert.equal(states.get(profiles[0].id).matchesGitea, true);
assert.deepEqual(discovery.refreshedProfileIds, [profiles[0].id]);
});
test("a stale deployment link cannot block adoption of its running replacement", async () => {
const repository = {
fullName: "Jens/DevRunBook",
name: "DevRunBook",
defaultBranch: "main",
sshUrl: "git@gitea.test:Jens/DevRunBook.git",
};
const stale = {
workloadId: "old-devrunbook",
status: "stale",
classification: { type: "stale-link" },
runtime: { running: false, health: "missing" },
link: { profileId: "old-profile", repositoryFullName: repository.fullName },
candidates: [{ repositoryFullName: repository.fullName, score: 100, exact: true }],
};
const replacement = {
workloadId: "devrunbook-runtime",
serverId: "unraid",
displayName: "DevRunBook",
status: "suggested",
classification: { type: "active-application" },
runtime: { running: true, health: "healthy" },
link: null,
candidates: [{
repositoryFullName: repository.fullName,
score: 85,
exact: false,
identityExact: true,
}],
compose: {
project: "devrunbook",
workingDir: "/mnt/user/appdata/DevRunBook",
configFiles: ["/mnt/user/appdata/DevRunBook/compose.yml"],
services: ["app"],
},
containers: [{ name: "DevRunBook", running: true, mounts: [], ports: [] }],
metadata: { branch: "main" },
remoteFolderCandidate: "DevRunBook",
};
const saved = [];
const service = new UnraidDeploymentService({
store: {
data: {
deploymentProfiles: {
[repository.fullName]: [{
id: "old-profile",
provider: "ssh-unraid",
serverId: "unraid",
workloadIdentity: { workloadId: stale.workloadId, linkSource: "automatic" },
}],
},
},
createRecoverySnapshot: async () => ({}),
saveDeploymentProfile: async (_fullName, profile) => {
saved.push(profile);
return profile;
},
saveDeploymentState: async () => ({}),
},
});
service.collectServerInventory = async () => ({
server: { id: "unraid", name: "Unraid", basePath: "/mnt/user/appdata" },
inventory: { capabilities: {}, warnings: [] },
workloads: [stale, replacement],
});
const plan = service.reconciliationPlan(
{ id: "unraid" },
[stale, replacement],
[repository],
{ autoLink: true },
);
assert.deepEqual(plan.additions.map((item) => item.workloadId), [replacement.workloadId]);
const result = await service.scanServerInventory("unraid", [repository], { autoLink: true });
assert.equal(result.adopted, 1);
assert.equal(replacement.link.repositoryFullName, repository.fullName);
assert.equal(saved.length, 1);
});
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 batches Docker inspect and retains a disappearing-container fallback", async () => {
const source = await unraidSource();
assert.match(source, /docker inspect "\\\$\{container_ids\[@\]\}"/);
assert.match(source, /for container_id in "\\\$\{container_ids\[@\]\}"/);
});
test("server inventory avoids a second Compose process for static image definitions", async () => {
const source = await unraidSource();
assert.match(source, /has_override=false/);
assert.match(source, /\[ -z "\$images" \].*config --images/);
});
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("inventory provenance alone never claims Gitea commit parity", async () => {
let savedState = null;
const service = new UnraidDeploymentService({
store: { getDeploymentState: () => ({}), saveDeploymentState: async (_id, state) => { savedState = state; return state; } },
});
await service.saveWorkloadState(
{ id: "profile", remoteFolder: "app", cloneUrl: "git@gitea.test:Owner/App.git" },
{
workloadId: "workload", observedAt: new Date().toISOString(),
metadata: { sourceRepository: "git@gitea.test:Owner/App.git", liveRevision: "a".repeat(40) },
runtime: { running: true, health: "healthy" }, containers: [{ name: "app", running: true, health: "healthy" }], compose: {},
},
{ basePath: "/mnt/user/appdata" },
);
assert.equal(savedState.matchesGitea, false);
assert.equal(savedState.giteaSha, null);
});
test("a stopped workload cannot become healthy through a reused healthcheck port", async () => {
let savedState = null;
const service = new UnraidDeploymentService({
store: { getDeploymentState: () => ({}), saveDeploymentState: async (_id, state) => { savedState = state; return state; } },
});
await service.saveWorkloadState(
{ id: "profile", remoteFolder: "app", healthcheckUrl: "http://server.test/health" },
{ workloadId: "workload", metadata: {}, runtime: { running: false, health: "unverified" }, containers: [{ name: "app", running: false }], compose: {} },
{ basePath: "/mnt/user/appdata" },
{ health: { configured: true, healthy: true, status: 200 } },
);
assert.equal(savedState.containerRunning, false);
assert.equal(savedState.healthy, false);
assert.equal(savedState.runtimeVerification, "stopped");
});
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.56.10: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.56.10: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&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");
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.56.10" },
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.match(script, /docker inspect "\$container_id"/);
assert.doesNotMatch(script, /docker inspect --format/);
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.56.10",
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")));
});
test("preflight explains monitor-only and degraded server evidence without hiding warnings", async () => {
const sha = "d".repeat(40);
const profile = {
id: "observed", provider: "ssh-unraid", environment: "production", branch: "main", serverId: "unraid",
remoteFolder: "Observed", deploymentMode: "monitor-only", composeFiles: ["compose.yml"],
composeService: "app", iconMode: "url", iconUrl: "", preservePaths: ["data"]
};
const service = new UnraidDeploymentService({
store: {
getDeploymentProfile: () => profile,
getServer: () => ({ id: "unraid", name: "Unraid", host: "server", port: 22, username: "deploy", basePath: "/mnt/apps", hostFingerprint: "" })
},
git: {},
ssh: { test: async () => { throw new Error("host unavailable"); } },
gitea: {},
sourcePath: process.cwd()
});
service.inspectWriteAccess = async () => { throw new Error("permission probe failed"); };
service.inspect = async () => ({
exists: true, rootGit: true, head: "e".repeat(40), trackedChanges: ["compose.yml"], nestedGit: ["vendor/repo"],
dockerfile: true, dockerignore: false, dockerignoreGitExcluded: false, dockerContextExclusionsMissing: ["data"],
existingPreservePaths: ["data"], composeFiles: []
});
const result = await service.preflight({ repository: { fullName: "Jens/Observed", name: "Observed", localPath: null }, profileId: profile.id, sha });
const byId = (id) => result.checks.find((check) => check.id === id);
assert.equal(byId("deployment-mode").status, "fail");
assert.equal(byId("local-repository").status, "fail");
assert.equal(byId("ssh").status, "fail");
assert.equal(byId("host-key").status, "fail");
assert.equal(byId("project-write-access").status, "fail");
assert.equal(byId("tracked-changes").status, "warning");
assert.equal(byId("nested-git").status, "warning");
assert.equal(byId("dockerignore").status, "warning");
assert.equal(byId("dockerignore-runtime").status, "warning");
assert.equal(byId("dockerman-icon").status, "fail");
assert.equal(byId("dockerman-webui").status, "warning");
assert.equal(result.summary.ready, false);
});
test("server-pull preflight resolves Gitea SHA and reports every degraded capability and write target", async () => {
const sha = "f".repeat(40);
const profile = {
id: "server-pull", provider: "ssh-unraid", environment: "staging", branch: "release", serverId: "unraid", remoteFolder: "App",
deploymentMode: "server-git", composeFiles: ["compose.yml", "compose.prod.yml"], composeService: "web",
iconMode: "none", preservePaths: []
};
const service = new UnraidDeploymentService({
store: {
getDeploymentProfile: () => profile,
getServer: () => ({ id: "unraid", name: "Unraid", host: "server", port: 22, username: "deploy", basePath: "/mnt/apps", hostFingerprint: "SHA256:trusted" })
},
git: { status: async () => ({ root: "/local", clean: false, counts: { changed: 3 }, branch: { head: "main" } }) },
ssh: { test: async () => ({ capabilities: { docker: true, dockerReady: false, compose: false, git: false, tar: true, checksum: false, baseWritable: false } }) },
gitea: {
getBranch: async () => ({ commit: { sha } }),
repositoryFileExists: async ({ filePath, ref }) => ref === sha && filePath === "compose.yml",
},
sourcePath: process.cwd()
});
service.probeServerGitAccess = async () => ({ ready: false, error: "deploy key missing", remoteSha: null });
service.inspectWriteAccess = async () => ({
ready: false, identity: { user: "deploy" }, blocking: [{ path: "/mnt/apps/App" }],
targets: [{ id: "root", label: "Project root", path: "/mnt/apps/App", required: true, effectiveWritable: false, owner: "root", group: "root", mode: "0755", detail: "not writable" }]
});
service.inspect = async () => ({ exists: false, rootGit: false, trackedChanges: [], nestedGit: [], dockerfile: false, dockerignore: false, dockerignoreGitExcluded: false, dockerContextExclusionsMissing: [], existingPreservePaths: [], composeFiles: [] });
const result = await service.preflight({ repository: { fullName: "Jens/App", name: "App", localPath: "/local" }, profileId: profile.id });
const byId = (id) => result.checks.find((check) => check.id === id);
assert.equal(result.sha, sha);
assert.equal(byId("local-branch").status, "warning");
assert.equal(byId("local-clean").status, "warning");
assert.equal(byId("gitea-deployment-files").status, "fail");
assert.match(byId("gitea-deployment-files").detail, /compose\.prod\.yml/);
assert.equal(byId("docker-runtime").status, "fail");
assert.match(byId("docker-runtime").detail, /cannot query/i);
assert.equal(byId("compose-command").status, "fail");
assert.equal(byId("bundle-tools").status, "fail");
assert.equal(byId("server-base-writable").status, "fail");
assert.equal(byId("server-git-access").repairAction, "configure-server-git-access");
assert.equal(byId("write-path:root").status, "fail");
assert.equal(byId("remote-folder").status, "pass");
assert.equal(byId("dockerman-icon").status, "warning");
});