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
+23
View File
@@ -99,3 +99,26 @@ test("Git Validator recognizes remote aliases and secret-shaped tracked paths",
assert.equal(isSensitiveTrackedPath("config/private.pem"), true);
assert.equal(isSensitiveTrackedPath(".env.example"), false);
});
test("Git Validator reports reproducibility, CI and editor hygiene and creates reviewable defaults", async (t) => {
const root = await mkdtemp(path.join(os.tmpdir(), "forgeflow-hygiene-"));
t.after(() => rm(root, { recursive: true, force: true }));
await git(["init", "-b", "main"], root);
await git(["config", "user.name", "ForgeFlow Test"], root);
await git(["config", "user.email", "forgeflow@example.invalid"], root);
await git(["remote", "add", "origin", "https://gitea.example.test/jens/app.git"], root);
await writeFile(path.join(root, "package.json"), '{"name":"app"}\n', "utf8");
await writeFile(path.join(root, "README.md"), "# App\n", "utf8");
await writeFile(path.join(root, ".gitignore"), ".env\n", "utf8");
await git(["add", "."], root);
await git(["commit", "-m", "Initial"], root);
const validator = new GitValidatorService({ git: new GitService(), gitea: { getBranchProtection: async () => ({ protected: true, enableForcePush: false }) } });
const repository = { fullName: "jens/app", name: "app", owner: { login: "jens" }, defaultBranch: "main", localPath: root, cloneUrl: "https://gitea.example.test/jens/app.git" };
const report = await validator.scan(repository);
assert.equal(report.checks.find((check) => check.id === "dependency-lock").status, "warning");
assert.equal(report.checks.find((check) => check.id === "continuous-integration").status, "warning");
for (const action of ["add-gitattributes", "add-editorconfig"])
await validator.repair(repository, { fixAction: action });
assert.match(await readFile(path.join(root, ".gitattributes"), "utf8"), /text=auto/);
assert.match(await readFile(path.join(root, ".editorconfig"), "utf8"), /root = true/);
});
+24
View File
@@ -170,3 +170,27 @@ test('creates conservative default branch protection rules', async () => {
assert.equal(request.options.body.enable_force_push, false);
assert.equal(request.options.body.rule_name, 'main');
});
test('creates repository-scoped read-only deploy keys and reuses only safe matches', async () => {
const service = new GiteaService(makeStore());
const publicKey = `ssh-ed25519 ${Buffer.from('public-key-material').toString('base64')} forgeflow:test`;
const requests = [];
service.request = async (pathname, options = {}) => {
requests.push({ pathname, options });
if (!options.method) return { data: [] };
return { data: { id: 41, key: publicKey, read_only: true } };
};
const created = await service.ensureReadOnlyDeployKey({ owner: 'jens', repo: 'app', title: 'ForgeFlow', publicKey });
assert.equal(created.created, true);
assert.equal(requests[1].options.body.read_only, true);
service.request = async () => ({ data: [{ id: 41, key: publicKey, read_only: true }] });
const reused = await service.ensureReadOnlyDeployKey({ owner: 'jens', repo: 'app', title: 'ForgeFlow', publicKey });
assert.equal(reused.created, false);
service.request = async () => ({ data: [{ id: 41, key: publicKey, read_only: false }] });
await assert.rejects(
() => service.ensureReadOnlyDeployKey({ owner: 'jens', repo: 'app', title: 'ForgeFlow', publicKey }),
(error) => error.code === 'DEPLOY_KEY_NOT_READ_ONLY',
);
});
+1 -1
View File
@@ -108,7 +108,7 @@ test("SSH deployments are polled in the background and Portfolio casing is prese
assert.match(renderer, /function startOperationPolling\(\)/);
assert.match(renderer, /startOperationPolling\(\);/);
assert.match(renderer, /Visible container name/);
assert.match(renderer, /Compose service \(internal\)/);
assert.match(renderer, /Compose services to verify/);
});
test("deployment profiles expose built-in/uploaded DockerMan icons and automatic metadata repair", async () => {
+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")));
});