feat: harden server pull deployments and git hygiene

This commit is contained in:
NuklearRabbit
2026-07-28 08:27:09 +02:00
parent d4d77c827a
commit 56efd1a00c
33 changed files with 2390 additions and 633 deletions
+53 -9
View File
@@ -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")));
});