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, 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"); test("Unraid remote paths cannot escape appdata project folder", () => { assert.equal(safeRemoteFolder("lumaops"), "lumaops"); assert.throws(() => safeRemoteFolder("../lumaops")); assert.equal( safeRelativeRemoteFile("deploy/docker-compose.yml"), "deploy/docker-compose.yml", ); assert.throws(() => safeRelativeRemoteFile("../../etc/passwd")); }); test("server release directories resolve to the stable deployment root", () => { assert.equal(deploymentRootCandidate("infrabinder/releases/f8b0dd8"), "infrabinder"); assert.equal(deploymentRootCandidate("portfolio/.forgeflow/releases/abc/source"), "portfolio"); assert.equal(deploymentRootCandidate("ludarium/source/deploy"), "ludarium/source/deploy"); }); test("server inspection key-value payload is decoded safely", () => { const b64 = (value) => Buffer.from(value).toString("base64"); const parsed = parseInspection( `noise\n__FORGEFLOW_KV__\nexists=true\nrootGit=true\nhead=${"a".repeat(40)}\nbranch=main\nremote=${b64("ssh://git@gitea/Jens/LumaOps.git")}\ntrackedChanges=${b64(" M docker-compose.yml\n")}\ncomposeFiles=${b64("docker-compose.yml\n")}\nnestedGit=${b64("source\n")}\ndockerfile=true\ndockerignoreContent=${b64(".git\ndata/\n")}\nexistingPreservePaths=${b64("data\nlogs\n")}\n`, ); assert.equal(parsed.rootGit, true); assert.deepEqual(parsed.composeFiles, ["docker-compose.yml"]); assert.deepEqual(parsed.nestedGit, ["source"]); assert.equal(parsed.trackedChanges.length, 1); assert.match(parsed.dockerignoreContent, /\.git/); assert.deepEqual(parsed.existingPreservePaths, ["data", "logs"]); }); test("server pull provisions a pinned repository-scoped key and records access metadata", async () => { const publicKey = `ssh-ed25519 ${Buffer.from("server-public-key").toString("base64")} forgeflow:test`; const profile = { id: "profile-1", provider: "ssh-unraid", serverId: "unraid", remoteFolder: "portfolio", branch: "main", deploymentMode: "server-git", composeFiles: ["compose.yml"], composeServices: ["portfolio"], }; let saved = null; let deployKeyRequest = null; let calls = 0; const service = new UnraidDeploymentService({ store: { getDeploymentProfile: () => profile, getServer: () => ({ id: "unraid", name: "Unraid", basePath: "/mnt/user/appdata" }), saveDeploymentProfile: async (_fullName, value) => { saved = value; return value; }, }, ssh: { exec: async () => { calls += 1; if (calls === 1) return { stdout: `__FORGEFLOW_DEPLOY_KEY__\npublicKey=${Buffer.from(publicKey).toString("base64")}\nfingerprint=SHA256:key\nhostFingerprint=SHA256:host\n` }; return { stdout: `${"a".repeat(40)}\trefs/heads/main\n` }; } }, gitea: { ensureReadOnlyDeployKey: async (request) => { deployKeyRequest = request; return { id: 17, created: true }; } }, }); const result = await service.configureServerGitAccess({ repository: { fullName: "Jens/Portfolio", sshUrl: "git@gitea.example.test:Jens/Portfolio.git" }, profileId: profile.id, }); assert.equal(deployKeyRequest.owner, "Jens"); assert.equal(deployKeyRequest.repo, "Portfolio"); assert.equal(saved.deploymentMode, "server-git"); assert.equal(saved.serverGitAccess.hostFingerprint, "SHA256:host"); assert.equal(result.remoteSha, "a".repeat(40)); }); test("server workload inventory links running containers to exact Gitea checkouts", () => { const b64 = (value) => Buffer.from(value).toString("base64"); const inspect = JSON.stringify([ { Name: "/Portfolio", State: { Running: true, Health: { Status: "healthy" } }, Config: { Labels: { "com.docker.compose.project.working_dir": "/mnt/user/appdata/Portfolio", }, }, Mounts: [], }, ]); const inventory = parseServerInventory( `noise\n__FORGEFLOW_INVENTORY__\nR\t${b64("/mnt/user/appdata/Portfolio")}\t${b64("git@gitea.itworx.tech:Jens/Portfolio.git")}\t${"a".repeat(40)}\t${b64("main")}\nC\t${b64(inspect)}\n`, ); assert.equal(inventory.checkouts.length, 1); assert.equal(inventory.containers.length, 1); assert.equal( remoteIdentity("git@gitea.itworx.tech:Jens/Portfolio.git"), remoteIdentity("https://gitea.itworx.tech/Jens/Portfolio"), ); assert.equal( inventoryContainerMatch( inventory.checkouts[0], { name: "Portfolio" }, inventory.containers[0], ), 100, ); }); test("server discovery is read-only and explicit reconciliation adopts a verified deployment", async () => { const b64 = (value) => Buffer.from(value).toString("base64"); const sha = "b".repeat(40); const container = { Name: "/Portfolio", State: { Running: true, Health: { Status: "healthy" } }, Config: { Labels: { "com.docker.compose.project.working_dir": "/mnt/user/appdata/Portfolio", "com.docker.compose.service": "portfolio", }, }, Mounts: [], NetworkSettings: { Ports: { "3000/tcp": [{ HostPort: "8080" }] } }, }; const profiles = []; const states = new Map(); const service = new UnraidDeploymentService({ store: { getServer: () => ({ id: "unraid", name: "Unraid", basePath: "/mnt/user/appdata", }), getDeploymentProfiles: () => profiles, saveDeploymentProfile: async (_fullName, profile) => { profiles.push(profile); return profile; }, saveDeploymentState: async (id, state) => { states.set(id, state); return state; }, }, ssh: { exec: async () => ({ stdout: `__FORGEFLOW_INVENTORY__\nR\t${b64("/mnt/user/appdata/Portfolio")}\t${b64("git@gitea.itworx.tech:Jens/Portfolio.git")}\t${sha}\t${b64("main")}\nC\t${b64(JSON.stringify([container]))}\n`, }), }, gitea: { getBranch: async () => ({ commit: { id: sha } }) }, }); const repositories = [ { fullName: "Jens/Portfolio", name: "Portfolio", defaultBranch: "main", cloneUrl: "https://gitea.itworx.tech/Jens/Portfolio.git", sshUrl: "git@gitea.itworx.tech:Jens/Portfolio.git", }, ]; const discovery = await service.discoverServerWorkloads("unraid", repositories); assert.equal(discovery.adopted, 0); assert.equal(discovery.verified, 0); assert.equal(profiles.length, 0); assert.equal(states.size, 0); const preview = await service.planServerInventoryReconciliation("unraid", repositories, { autoLink: true }); assert.equal(preview.plan.summary.additions, 1); const result = await service.reconcileServerInventory("unraid", repositories, { autoLink: true, expectedPlanId: preview.plan.id }); assert.equal(result.adopted, 1); assert.equal(result.verified, 1); assert.equal(profiles[0].containerName, "Portfolio"); assert.equal(profiles[0].adoptedFromServer, true); assert.equal(states.get(profiles[0].id).matchesGitea, true); }); test("server inventory includes stopped DockerMan containers without Git and keeps name matches manual", () => { const workloads = buildWorkloadInventory({ inventory: { checkouts: [], dockerMan: [ { name: "omniroute", templatePath: "/boot/config/plugins/dockerMan/templates-user/my-omniroute.xml", webUiUrl: "http://[IP]:[PORT:20128]/", iconUrl: "", shell: "sh", repository: "diegosouzapw/omniroute:latest", network: "bridge", }, ], containers: [ { id: "container-1", name: "omniroute", image: "ghcr.io/diegosouzapw/omniroute:latest", imageId: "sha256:image", running: false, status: "exited", health: null, labels: {}, ports: { "3000/tcp": [{ HostPort: "20128", HostIp: "0.0.0.0" }] }, mounts: [ { Type: "bind", Source: "/mnt/user/appdata/OmniRoute/config", Destination: "/app/config", RW: true, }, ], networks: { bridge: {} }, restartPolicy: "unless-stopped", }, ], warnings: [], capabilities: { docker: true, compose: true }, }, server: { id: "unraid", name: "Unraid", basePath: "/mnt/user/appdata", }, repositories: [ { fullName: "Jens/OmniRoute", name: "OmniRoute", cloneUrl: "https://gitea.itworx.tech/Jens/OmniRoute.git", }, ], profiles: [], }); assert.equal(workloads.length, 1); assert.equal(workloads[0].runtime.running, false); assert.equal(workloads[0].kind, "dockerman-container"); assert.equal(workloads[0].remoteFolderCandidate, "OmniRoute"); assert.equal(workloads[0].status, "suggested"); assert.equal(workloads[0].candidates[0].exact, false); assert.match(workloads[0].candidates[0].reasons.join(" "), /manual confirmation/i); }); test("server inventory groups multi-service Compose projects and preserves their identity", () => { const baseContainer = { image: "example/app:latest", imageId: "sha256:image", running: true, status: "running", health: null, ports: {}, mounts: [], networks: { appnet: {} }, restartPolicy: "unless-stopped", }; const labels = { "com.docker.compose.project": "forgeflow", "com.docker.compose.project.working_dir": "/mnt/user/appdata/ForgeFlow", "com.docker.compose.project.config_files": "/mnt/user/appdata/ForgeFlow/compose.yml,/mnt/user/appdata/ForgeFlow/compose.prod.yml", }; const workloads = buildWorkloadInventory({ inventory: { checkouts: [], dockerMan: [], warnings: [], capabilities: {}, containers: [ { ...baseContainer, id: "web", name: "forgeflow-web-1", labels: { ...labels, "com.docker.compose.service": "web" }, }, { ...baseContainer, id: "worker", name: "forgeflow-worker-1", labels: { ...labels, "com.docker.compose.service": "worker" }, }, ], }, server: { id: "unraid", name: "Unraid", basePath: "/mnt/user/appdata" }, repositories: [], profiles: [], }); assert.equal(workloads.length, 1); assert.deepEqual(workloads[0].compose.services.sort(), ["web", "worker"]); assert.deepEqual(workloads[0].compose.configFiles, [ "/mnt/user/appdata/ForgeFlow/compose.yml", "/mnt/user/appdata/ForgeFlow/compose.prod.yml", ]); assert.equal(workloads[0].compose.project, "forgeflow"); assert.equal(workloads[0].remoteFolderCandidate, "ForgeFlow"); }); test("manual workload linking does not claim Gitea parity for unrelated provenance", async () => { let savedState = null; const service = new UnraidDeploymentService({ store: { saveDeploymentState: async (_id, state) => { savedState = state; return state; }, }, ssh: {}, git: {}, diagnostics: null, }); await service.saveWorkloadState( { id: "profile", containerName: "app", remoteFolder: "app", cloneUrl: "https://gitea.itworx.tech/Jens/Expected.git", }, { workloadId: "workload", observedAt: new Date().toISOString(), metadata: { sourceRepository: "https://gitea.itworx.tech/Jens/Other.git", liveRevision: "a".repeat(40), }, runtime: { running: true, health: "healthy" }, containers: [{ name: "app", running: true, health: "healthy" }], compose: { project: "app" }, }, { basePath: "/mnt/user/appdata" }, ); assert.equal(savedState.liveSha, "a".repeat(40)); assert.equal(savedState.matchesGitea, false); assert.equal(savedState.giteaSha, null); }); test("Docker ignore checks identify exact runtime and Git context exclusions", () => { const rules = "# build context\n.git\ndata/\nlogs/**\n!logs/keep.txt\n"; assert.equal(dockerIgnoreHasPath(rules, ".git"), true); assert.equal(dockerIgnoreHasPath(rules, "data"), true); assert.equal(dockerIgnoreHasPath(rules, "logs"), true); assert.equal(dockerIgnoreHasPath(rules, "source"), false); }); test("server inspection detects preserved runtime paths and missing Docker context exclusions", async () => { const b64 = (value) => Buffer.from(value).toString("base64"); let receivedCommand = ""; const store = { getDeploymentProfile: () => ({ id: "production", provider: "ssh-unraid", serverId: "unraid", remoteFolder: "lumaops", preservePaths: ["data", "logs"], }), getServer: () => ({ id: "unraid", name: "Unraid", basePath: "/mnt/user/appdata", }), }; const ssh = { exec: async (_serverId, command) => { receivedCommand = command; return { stdout: `__FORGEFLOW_KV__\nexists=true\nrootGit=true\nhead=${"a".repeat(40)}\nbranch=main\nremote=${b64("ssh://git@gitea/Jens/LumaOps.git")}\ntrackedChanges=\ncomposeFiles=${b64("docker-compose.yml\n")}\nnestedGit=${b64("source\n")}\ndockerfile=true\ndockerignoreContent=${b64(".git\ndata/\n")}\nexistingPreservePaths=${b64("data\nlogs\n")}\n`, stderr: "", exitCode: 0, }; }, }; const service = new UnraidDeploymentService({ store, ssh, git: {}, diagnostics: null, }); const inspection = await service.inspect({ repository: { fullName: "Jens/LumaOps", name: "LumaOps" }, profileId: "production", }); assert.match(receivedCommand, /base64 -d \| bash$/); assert.equal(inspection.remotePath, "/mnt/user/appdata/lumaops"); assert.equal(inspection.dockerignoreGitExcluded, true); assert.deepEqual(inspection.existingPreservePaths.sort(), ["data", "logs"]); assert.deepEqual(inspection.dockerContextExclusionsMissing.sort(), [ "logs", "source", ]); }); test("preflight summary blocks only failed checks", () => { const result = checksSummary([ { id: "a", status: "pass" }, { id: "b", status: "warning" }, { id: "c", status: "fail" }, ]); assert.equal(result.ready, false); assert.deepEqual(result.blocking, ["c"]); }); test("SSH helpers produce pinned fingerprints and quoted commands", () => { assert.match(fingerprintKey(Buffer.from("host-key")), /^SHA256:/); assert.equal(shellQuote("a'b"), "'a'\\''b'"); const wrapped = bash("git fetch origin main"); assert.match(wrapped, /base64 -d \| bash$/); assert.equal(wrapped.includes("\n"), false); const encoded = wrapped.match(/printf '%s' '([A-Za-z0-9+/=]+)'/)[1]; const decoded = Buffer.from(encoded, "base64").toString("utf8"); assert.match(decoded, /GIT_TERMINAL_PROMPT=0/); assert.match(decoded, /BatchMode=yes/); assert.match(decoded, /forgeflow_compose\(\)/); assert.match(decoded, /docker compose "\$@"/); assert.match(decoded, /docker-compose "\$@"/); assert.match(decoded, /git fetch origin main/); }); test("SSH rollback refuses any SHA other than the exact recorded previous deployment", async () => { const previousSha = "a".repeat(40); const store = { getDeploymentProfile: () => ({ id: "production", provider: "ssh-unraid", serverId: "unraid", remoteFolder: "lumaops", composeFile: "docker-compose.yml", branch: "main", environment: "production", }), getServer: () => ({ id: "unraid", basePath: "/mnt/user/appdata" }), getDeploymentState: () => ({ liveSha: "b".repeat(40), previousSha }), }; const service = new UnraidDeploymentService({ store, ssh: {}, git: {}, diagnostics: null, }); await assert.rejects( service.rollback({ repository: { fullName: "Jens/LumaOps", name: "LumaOps", localPath: "/tmp/lumaops", }, profileId: "production", targetSha: "c".repeat(40), }), (error) => error.code === "ROLLBACK_TARGET_NOT_PREVIOUS_SHA", ); }); test("successful SSH rollback records the formerly live SHA as the new rollback target", async () => { const previousSha = "a".repeat(40); const liveSha = "b".repeat(40); const savedStates = []; const operations = []; const store = { getDeploymentProfile: () => ({ id: "production", provider: "ssh-unraid", serverId: "unraid", remoteFolder: "lumaops", composeFile: "docker-compose.yml", branch: "main", environment: "production", healthcheckUrl: "", iconMode: "none", }), getServer: () => ({ id: "unraid", name: "Unraid", basePath: "/mnt/user/appdata", }), getDeploymentState: () => ({ liveSha, previousSha }), addOperation: async (operation) => { operations.push(operation); return operation; }, saveDeploymentState: async (_profileId, state) => { savedStates.push(state); return state; }, }; const git = { verifyCommitOnRemoteBranch: async () => true }; const ssh = { exec: async () => ({ stdout: "", stderr: "", exitCode: 0 }) }; const service = new UnraidDeploymentService({ store, ssh, git, diagnostics: null, }); service.inspect = async () => ({ rootGit: true, trackedChanges: [], head: liveSha, }); service.checkHealth = async () => ({ configured: false, healthy: null, status: null, latencyMs: null, }); service.executePushBundle = async () => ({ stdout: "rollback activated", stderr: "", exitCode: 0 }); const result = await service.rollback({ repository: { fullName: "Jens/LumaOps", name: "LumaOps", localPath: "/tmp/lumaops", }, profileId: "production", targetSha: previousSha, }); assert.equal(result.status, "rolled-back"); assert.equal(savedStates.at(-1).liveSha, previousSha); assert.equal(savedStates.at(-1).previousSha, liveSha); assert.equal(operations.at(-1).previousSha, liveSha); }); test("generated Compose uses a lowercase-safe service while preserving the visible Portfolio container name", () => { const service = new UnraidDeploymentService({ store: {}, ssh: {}, git: {}, diagnostics: null, }); const compose = service.generatedCompose( { composeService: "Portfolio", hostPort: 5150, containerPort: 80 }, { name: "Portfolio" }, ); assert.match(compose, / portfolio:/); assert.match(compose, /image: forgeflow\/portfolio:production/); assert.match(compose, /container_name: Portfolio/); }); test("SSH deployment dispatch returns a running operation while the remote build continues in background", async () => { const sha = "d".repeat(40); const operations = []; let resolveRemote; const store = { getDeploymentProfile: () => ({ id: "production", provider: "ssh-unraid", serverId: "unraid", remoteFolder: "Portfolio", cloneUrl: "forgeflow-gitea:Jens/Portfolio.git", composeFile: "docker-compose.yml", branch: "main", environment: "production", healthcheckUrl: "", iconMode: "none", }), getServer: () => ({ id: "unraid", name: "Unraid", basePath: "/mnt/user/appdata", }), addOperation: async (operation) => { operations.push(structuredClone(operation)); return structuredClone(operation); }, saveDeploymentState: async () => ({}), }; const ssh = { exec: async () => new Promise((resolve) => { resolveRemote = resolve; }), }; const service = new UnraidDeploymentService({ store, ssh, git: {}, diagnostics: null, }); service.preflight = async () => ({ summary: { ready: true, blocking: [] }, inspection: { head: null }, }); service.checkHealth = async () => ({ configured: false, healthy: null, status: null, latencyMs: null, }); service.executePushBundle = async () => new Promise((resolve) => { resolveRemote = resolve; }); const operation = await service.deploy({ repository: { fullName: "Jens/Portfolio", name: "Portfolio" }, profileId: "production", sha, }); assert.equal(operation.status, "running"); assert.match(operation.logs.join("\n"), /background/i); resolveRemote({ stdout: "Container Portfolio started\n", stderr: "", exitCode: 0, }); await new Promise((resolve) => setTimeout(resolve, 10)); assert.equal(operations.at(-1).status, "success"); }); test("Unraid preflight verifies repository access before a deployment can start", async () => { const source = await 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 read access/); }); test("DockerMan metadata uses dockerman labels, a template WebUI and lowercase-safe service/image names", () => { const service = new UnraidDeploymentService({ store: {}, ssh: {}, git: {}, diagnostics: null, }); const metadata = service.metadataCompose( { composeService: "portfolio", containerName: "Portfolio", remoteFolder: "Portfolio", environment: "production", hostPort: 5150, webUiUrl: "http://192.168.10.150:5150/admin", dockerShell: "/bin/sh", }, { name: "Portfolio" }, "file:///boot/config/plugins/dockerMan/images/Portfolio-icon.png", ); assert.match(metadata, / portfolio:/); assert.doesNotMatch(metadata, /image: forgeflow\/portfolio:production/); assert.doesNotMatch(metadata, /container_name: Portfolio/); assert.match(metadata, /net\.unraid\.docker\.managed.*dockerman/); assert.match( metadata, /net\.unraid\.docker\.webui.*http:\/\/\[IP\]:\[PORT:5150\]\/admin/, ); assert.match( metadata, /net\.unraid\.docker\.icon.*file:\/\/\/boot\/config\/plugins\/dockerMan\/images\/Portfolio-icon\.png/, ); }); test("DockerMan integration writes a persistent template fallback and invalidates cached metadata", () => { const service = new UnraidDeploymentService({ store: {}, ssh: {}, git: {}, diagnostics: null, }); const profile = { composeService: "portfolio", containerName: "Portfolio", remoteFolder: "Portfolio", environment: "production", hostPort: 5150, webUiUrl: "http://192.168.10.150:5150/", dockerShell: "/bin/sh", manageDockerMan: true, generatedCompose: true, }; const repository = { name: "Portfolio" }; const icon = "file:///boot/config/plugins/dockerMan/images/Portfolio-icon.png"; const template = service.dockerManTemplate(profile, repository, icon); const refresh = service.dockerManRefreshScript(profile, repository, icon); assert.match(template, /Portfolio<\/Name>/); assert.match( template, /forgeflow\/portfolio:production<\/Repository>/, ); assert.match(template, /http:\/\/\[IP\]:\[PORT:5150\]\/<\/WebUI>/); assert.match( template, /file:\/\/\/boot\/config\/plugins\/dockerMan\/images\/Portfolio-icon\.png<\/Icon>/, ); assert.match(refresh, /templates-user\/my-Portfolio\.xml/); assert.match(refresh, /dynamix\.docker\.manager\/docker\.json/); assert.match( refresh, /cp '\/boot\/config\/plugins\/dockerMan\/images\/Portfolio-icon\.png'/, ); assert.doesNotMatch(refresh, /dockerManRefreshScript/); assert.equal(xmlEscape('A&B<"x">'), "A&B<"x">"); }); test("adopted DockerMan templates are never rewritten", () => { const service = new UnraidDeploymentService({ store: {}, ssh: {}, git: {}, diagnostics: null, }); const refresh = service.dockerManRefreshScript( { composeService: "omniroute", containerName: "omniroute", remoteFolder: "OmniRoute", environment: "production", manageDockerMan: true, generatedCompose: false, adoptedFromServer: true, }, { name: "OmniRoute" }, "", ); assert.match(refresh, /left the existing DockerMan template unchanged/); assert.doesNotMatch(refresh, /templates-user/); }); test("built-in ITWorx DockerMan icon is uploaded to persistent Unraid storage", async (t) => { const { mkdtemp, mkdir, writeFile, rm } = await import("node:fs/promises"); const os = await import("node:os"); const path = await import("node:path"); const sourcePath = await mkdtemp(path.join(os.tmpdir(), "forgeflow-icon-")); t.after(() => rm(sourcePath, { recursive: true, force: true })); const asset = path.join( sourcePath, "src", "renderer", "assets", "itworx-mark.png", ); await mkdir(path.dirname(asset), { recursive: true }); await writeFile(asset, Buffer.from([137, 80, 78, 71])); const uploads = []; const service = new UnraidDeploymentService({ store: {}, git: {}, diagnostics: null, sourcePath, ssh: { uploadFile: async (...args) => { uploads.push(args); return {}; }, }, }); const icon = await service.prepareIcon( { iconMode: "builtin", remoteFolder: "Portfolio" }, { name: "Portfolio" }, { id: "unraid" }, ); assert.equal( icon, "file:///boot/config/plugins/dockerMan/images/Portfolio-icon.png", ); assert.equal(uploads.length, 1); assert.equal(uploads[0][0], "unraid"); assert.equal(uploads[0][1], asset); assert.equal( uploads[0][2], "/boot/config/plugins/dockerMan/images/Portfolio-icon.png", ); }); test("stuck SSH deployment is reconciled to success when exact SHA and container health are live", async () => { const sha = "f".repeat(40); const saved = []; const operation = { id: "op-1", type: "deployment", provider: "ssh-unraid", action: "deploy", repository: "Jens/Portfolio", profileId: "production", sha, status: "running", logs: [], }; const store = { getOperation: () => operation, addOperation: async (next) => { saved.push(next); return next; }, }; const service = new UnraidDeploymentService({ store, ssh: {}, git: {}, diagnostics: null, }); service.refreshProfileState = async () => ({ liveSha: sha, containerRunning: true, healthy: true, }); const result = await service.refreshOperation("op-1"); assert.equal(result.status, "success"); assert.match(result.logs.at(-1), /reconciled/i); assert.equal(saved.at(-1).status, "success"); }); test("DockerMan metadata repair refreshes known Unraid icon caches after container recreation", async () => { const source = await 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/); assert.match(source, /cp \${shellQuote\(localIconPath\)}/); assert.match(source, /never adds destructive recreation or orphan-removal flags/); }); test("stuck deployment is cleared as superseded when a different healthy commit is already live", async () => { const requested = "a".repeat(40); const live = "b".repeat(40); const operation = { id: "op-superseded", type: "deployment", provider: "ssh-unraid", action: "deploy", repository: "Jens/Portfolio", profileId: "production", sha: requested, status: "running", logs: [], }; const saved = []; const service = new UnraidDeploymentService({ store: { getOperation: () => operation, addOperation: async (next) => { saved.push(next); return next; }, }, ssh: {}, git: {}, diagnostics: null, }); service.refreshProfileState = async () => ({ liveSha: live, containerRunning: true, healthy: true, }); const result = await service.refreshOperation(operation.id); assert.equal(result.status, "cancelled"); assert.match(result.error, /Superseded/); assert.equal(saved.at(-1).status, "cancelled"); }); test("existing Unraid deployment discovery derives profile values from Docker, Compose and DockerMan truth", () => { const { deriveDetectedProfile, } = require("../src/main/unraid-deployment-service.cjs"); const result = deriveDetectedProfile({ repository: { name: "blockpilot-autonomous", defaultBranch: "main", sshUrl: "ssh://git@gitea/Jens/blockpilot-autonomous.git", }, server: { id: "unraid", host: "192.168.10.150" }, remoteFolder: "blockpilot-autonomous", remotePath: "/mnt/user/appdata/blockpilot-autonomous", payload: { head: "a".repeat(40), branch: "main", remote: "ssh://git@gitea/Jens/blockpilot-autonomous.git", composeFiles: ["compose.yml"], compose: { services: { app: { image: "blockpilot:test" } } }, containers: [ { Name: "/blockpilot", State: { Running: true }, Config: { Image: "blockpilot:test", Env: ["TOKEN=secret", "MODE=prod"], Labels: { "com.docker.compose.service": "app", "com.docker.compose.project": "blockpilot", }, }, HostConfig: { RestartPolicy: { Name: "unless-stopped" } }, NetworkSettings: { Ports: { "8080/tcp": [{ HostIp: "0.0.0.0", HostPort: "1223" }] }, Networks: { bridge: {} }, }, Mounts: [ { Type: "bind", Source: "/mnt/user/appdata/blockpilot-autonomous/data", Destination: "/data", RW: true, }, ], }, ], dockerManXml: "blockpilothttp://[IP]:[PORT:1223]/https://example.test/icon.png/bin/bash", }, }); 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("push bundle activation validates Compose and services before promoting current SHA", () => { const service = new UnraidDeploymentService({ store: {}, ssh: {}, git: {} }); const repository = { name: "OmniRoute", fullName: "Jens/OmniRoute" }; const profile = { environment: "production", deploymentMode: "push-bundle", composeProject: "omniroute", composeFiles: ["compose.yml", "compose.unraid.yml"], composeServices: ["api", "worker"], preservePaths: ["data", "config"], generatedCompose: false, adoptedFromServer: true, manageDockerMan: false, }; const script = service.pushBundleScript({ repository, profile, remotePath: "/mnt/user/appdata/OmniRoute", targetSha: "a".repeat(40), requestId: "request-1", remotePart: "/mnt/user/appdata/.forgeflow/incoming/request-1.tar.part", digest: "b".repeat(64), metadata: "services:\n api:\n labels: {}\n worker:\n labels: {}\n", generated: "", iconReference: "", }); const configIndex = script.indexOf("config >/dev/null"); const buildIndex = script.indexOf("build"); const upIndex = script.indexOf("up -d --no-build", buildIndex); const serviceCheckIndex = script.indexOf("Compose service $service did not create a container"); const promoteIndex = script.indexOf('current-sha.pending'); assert.ok(configIndex >= 0 && configIndex < buildIndex); assert.ok(buildIndex < upIndex); assert.ok(upIndex < serviceCheckIndex); assert.ok(serviceCheckIndex < promoteIndex); assert.match(script, /mmin \+120/); assert.match(script, /grep -E '\(\^\/\|\(\^\|\/\)\\\.\\\.\(\/\|\$\)\)'/); assert.match(script, /kill -0 "\$lock_pid"/); assert.match(script, /is_preserved "\$rel" && continue/); assert.doesNotMatch(script, /git clone|git -C "\$root" fetch/); assert.match(script, /ForgeFlow left the existing DockerMan template unchanged/); }); test("generated push-bundle command passes Bash syntax validation", { skip: process.platform === "win32" }, async () => { const { spawnSync } = await import("node:child_process"); const service = new UnraidDeploymentService({ store: {}, ssh: {}, git: {} }); const repository = { name: "Demo", fullName: "Jens/Demo" }; const profile = { environment: "production", deploymentMode: "push-bundle", composeProject: "demo", composeFiles: ["compose.yml"], composeServices: ["app"], preservePaths: ["data"], generatedCompose: false, adoptedFromServer: true, manageDockerMan: false, }; const generated = service.pushBundleScript({ repository, profile, remotePath: "/mnt/user/appdata/Demo", targetSha: "d".repeat(40), requestId: "syntax-test", remotePart: "/mnt/user/appdata/Demo/.forgeflow/incoming/syntax-test.tar.part", digest: "e".repeat(64), metadata: "services:\n app:\n labels: {}\n", generated: "", iconReference: "", }); const wrapped = bash(generated); const encoded = wrapped.match(/printf '%s' '([A-Za-z0-9+/=]+)'/)[1]; const script = Buffer.from(encoded, "base64").toString("utf8"); const result = spawnSync("bash", ["-n"], { input: script, encoding: "utf8" }); assert.equal(result.status, 0, result.stderr || result.stdout); }); test("push bundle preflight does not require Git or Gitea credentials on Unraid", async (context) => { const { mkdtemp, writeFile, rm } = await import("node:fs/promises"); const { tmpdir } = await import("node:os"); const { join } = await import("node:path"); const localPath = await mkdtemp(join(tmpdir(), "forgeflow-push-preflight-")); context.after(() => rm(localPath, { recursive: true, force: true })); await writeFile(join(localPath, "compose.yml"), "services:\n app:\n image: example/app:latest\n"); const sha = "c".repeat(40); const profile = { id: "production", name: "Production", environment: "production", provider: "ssh-unraid", branch: "main", serverId: "unraid", remoteFolder: "OmniRoute", deploymentMode: "push-bundle", composeFile: "compose.yml", composeFiles: ["compose.yml"], composeService: "app", composeServices: ["app"], iconMode: "none", generatedCompose: false, preservePaths: ["data"], }; let remoteGitProbeCount = 0; const service = new UnraidDeploymentService({ store: { getDeploymentProfile: () => profile, getServer: () => ({ id: "unraid", name: "Unraid", host: "192.168.10.150", port: 22, username: "root", basePath: "/mnt/user/appdata", hostFingerprint: "SHA256:test", }), }, git: { status: async () => ({ root: localPath, head: sha, clean: true, counts: { changed: 0 }, branch: { head: "main", upstream: "origin/main", ahead: 0, behind: 0 }, }), verifyCommitOnRemoteBranch: async () => true, }, ssh: { test: async () => ({ capabilities: { docker: true, dockerReady: true, compose: true, composeVersion: "Docker Compose version v2", git: false, tar: true, checksum: true, baseWritable: true, }, }), exec: async (_serverId, command) => { if (String(command).includes("git ls-remote")) remoteGitProbeCount += 1; const encodedFiles = Buffer.from("").toString("base64"); return { stdout: `__FORGEFLOW_KV__\nexists=false\nrootGit=false\nhead=\nbranch=\nremote=\ntrackedChanges=\ncomposeFiles=${encodedFiles}\nnestedGit=\ndockerfile=false\ndockerignoreContent=\nexistingPreservePaths=\n`, }; }, }, sourcePath: new URL("..", import.meta.url).pathname, }); service.inspectWriteAccess = async () => ({ ready: true, blocking: [], targets: [], identity: { user: "root" } }); const result = await service.preflight({ repository: { fullName: "Jens/OmniRoute", name: "OmniRoute", localPath, localStatus: { head: sha }, }, profileId: "production", sha, }); assert.equal(remoteGitProbeCount, 0); assert.equal(result.checks.find((item) => item.id === "transfer-path")?.status, "pass"); assert.match(result.checks.find((item) => item.id === "transfer-path")?.detail || "", /No Gitea credential/); assert.equal(result.checks.some((item) => item.id === "server-git-command"), false); assert.equal(result.summary.ready, true, JSON.stringify(result.checks.filter((item) => item.status === "fail"))); });