feat: harden server pull deployments and git hygiene
This commit is contained in:
1 parent
d4d77c827a
commit
56efd1a00c
33 files changed
+2390
-633
No files matched your search
@@ -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/);
|
||||
});
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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")));
|
||||
});
|
||||
Reference in new issue
Block a user