|
|
|
@@ -16,7 +16,7 @@ const {
|
|
|
|
|
bash,
|
|
|
|
|
} = require("../src/main/unraid-deployment-service.cjs");
|
|
|
|
|
const { fingerprintKey, shellQuote } = require("../src/main/ssh-service.cjs");
|
|
|
|
|
const { buildWorkloadInventory } = require("../src/main/server-inventory.cjs");
|
|
|
|
|
const { buildWorkloadInventory, deploymentRootCandidate } = require("../src/main/server-inventory.cjs");
|
|
|
|
|
|
|
|
|
|
test("Unraid remote paths cannot escape appdata project folder", () => {
|
|
|
|
|
assert.equal(safeRemoteFolder("lumaops"), "lumaops");
|
|
|
|
@@ -28,6 +28,12 @@ test("Unraid remote paths cannot escape appdata project folder", () => {
|
|
|
|
|
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(
|
|
|
|
@@ -41,6 +47,39 @@ test("server inspection key-value payload is decoded safely", () => {
|
|
|
|
|
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([
|
|
|
|
@@ -458,6 +497,7 @@ test("successful SSH rollback records the formerly live SHA as the new rollback
|
|
|
|
|
status: null,
|
|
|
|
|
latencyMs: null,
|
|
|
|
|
});
|
|
|
|
|
service.executePushBundle = async () => ({ stdout: "rollback activated", stderr: "", exitCode: 0 });
|
|
|
|
|
const result = await service.rollback({
|
|
|
|
|
repository: {
|
|
|
|
|
fullName: "Jens/LumaOps",
|
|
|
|
@@ -539,6 +579,7 @@ test("SSH deployment dispatch returns a running operation while the remote build
|
|
|
|
|
status: null,
|
|
|
|
|
latencyMs: null,
|
|
|
|
|
});
|
|
|
|
|
service.executePushBundle = async () => new Promise((resolve) => { resolveRemote = resolve; });
|
|
|
|
|
|
|
|
|
|
const operation = await service.deploy({
|
|
|
|
|
repository: { fullName: "Jens/Portfolio", name: "Portfolio" },
|
|
|
|
@@ -566,7 +607,7 @@ test("Unraid preflight verifies repository access before a deployment can start"
|
|
|
|
|
);
|
|
|
|
|
assert.match(source, /server-git-access/);
|
|
|
|
|
assert.match(source, /git ls-remote --exit-code/);
|
|
|
|
|
assert.match(source, /Unraid → Gitea access/);
|
|
|
|
|
assert.match(source, /Unraid → Gitea read access/);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("DockerMan metadata uses dockerman labels, a template WebUI and lowercase-safe service/image names", () => {
|
|
|
|
@@ -767,7 +808,7 @@ test("DockerMan metadata repair refreshes known Unraid icon caches after contain
|
|
|
|
|
assert.match(source, /dynamix\.docker\.manager\/images/);
|
|
|
|
|
assert.match(source, /-icon\.png/);
|
|
|
|
|
assert.match(source, /cp \${shellQuote\(localIconPath\)}/);
|
|
|
|
|
assert.match(source, /--force-recreate/);
|
|
|
|
|
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 () => {
|
|
|
|
@@ -962,7 +1003,7 @@ test("linked Compose deployments retain the existing project, files and service
|
|
|
|
|
const invocation = service.composeInvocation(profile, repository);
|
|
|
|
|
assert.match(invocation, /-p 'omniroute-production'/);
|
|
|
|
|
assert.ok(invocation.indexOf("-f 'compose.yml'") < invocation.indexOf("-f 'compose.unraid.yml'"));
|
|
|
|
|
assert.ok(invocation.indexOf("-f 'compose.unraid.yml'") < invocation.indexOf("-f '.forgeflow\/compose.metadata.yml'"));
|
|
|
|
|
assert.doesNotMatch(invocation, /compose\.metadata\.yml/);
|
|
|
|
|
assert.deepEqual(service.deploymentServices(profile, repository), ["api", "worker"]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
@@ -993,10 +1034,12 @@ test("push bundle activation validates Compose and services before promoting cur
|
|
|
|
|
iconReference: "",
|
|
|
|
|
});
|
|
|
|
|
const configIndex = script.indexOf("config >/dev/null");
|
|
|
|
|
const upIndex = script.indexOf("up -d --build");
|
|
|
|
|
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 < upIndex);
|
|
|
|
|
assert.ok(configIndex >= 0 && configIndex < buildIndex);
|
|
|
|
|
assert.ok(buildIndex < upIndex);
|
|
|
|
|
assert.ok(upIndex < serviceCheckIndex);
|
|
|
|
|
assert.ok(serviceCheckIndex < promoteIndex);
|
|
|
|
|
assert.match(script, /mmin \+120/);
|
|
|
|
@@ -1113,6 +1156,7 @@ test("push bundle preflight does not require Git or Gitea credentials on Unraid"
|
|
|
|
|
},
|
|
|
|
|
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",
|
|
|
|
@@ -1124,8 +1168,8 @@ test("push bundle preflight does not require Git or Gitea credentials on Unraid"
|
|
|
|
|
sha,
|
|
|
|
|
});
|
|
|
|
|
assert.equal(remoteGitProbeCount, 0);
|
|
|
|
|
assert.equal(result.checks.find((item) => item.id === "server-git-access")?.status, "pass");
|
|
|
|
|
assert.match(result.checks.find((item) => item.id === "server-git-access")?.detail || "", /Not required/);
|
|
|
|
|
assert.equal(result.checks.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);
|
|
|
|
|
assert.equal(result.summary.ready, true, JSON.stringify(result.checks.filter((item) => item.status === "fail")));
|
|
|
|
|
});
|
|
|
|
|