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
+81
View File
@@ -0,0 +1,81 @@
"use strict";
const path = require("node:path");
const { app } = require("electron");
const { ConfigStore } = require("../src/main/config-store.cjs");
const { GitService } = require("../src/main/git-service.cjs");
const { GiteaService } = require("../src/main/gitea-service.cjs");
const { RepositoryService } = require("../src/main/repository-service.cjs");
const { SshService } = require("../src/main/ssh-service.cjs");
const { UnraidDeploymentService } = require("../src/main/unraid-deployment-service.cjs");
const userDataPath = process.env.FORGEFLOW_USER_DATA
? path.resolve(process.env.FORGEFLOW_USER_DATA)
: path.join(app.getPath("appData"), "forgeflow");
app.setPath("userData", userDataPath);
app.whenReady().then(async () => {
try {
const reconcile = process.argv.includes("--reconcile");
const configureAccess = process.argv.includes("--configure-access");
const repositoryFilter = new Set(String(process.argv.find((value) => value.startsWith("--repository=")) || "")
.slice("--repository=".length).toLowerCase().split(",").map((value) => value.trim()).filter(Boolean));
const store = new ConfigStore(userDataPath);
await store.load();
const git = new GitService();
const gitea = new GiteaService(store);
const repositories = await new RepositoryService(store, git, gitea).refresh();
const ssh = new SshService({ store });
const deployments = new UnraidDeploymentService({ store, ssh, git, gitea, sourcePath: path.resolve(__dirname, "..") });
const reports = [];
for (const server of store.data.servers || []) {
const report = await deployments.scanServerInventory(server.id, repositories, { autoLink: reconcile });
const access = [];
if (configureAccess) {
const refreshedRepositories = await new RepositoryService(store, git, gitea).refresh();
const seenProfiles = new Set();
for (const workload of report.workloads.filter((item) => item.runtime?.running && item.link?.profileId && item.link?.repositoryFullName)) {
if (seenProfiles.has(workload.link.profileId)) continue;
seenProfiles.add(workload.link.profileId);
const repository = refreshedRepositories.find((item) => String(item.fullName).toLowerCase() === String(workload.link.repositoryFullName).toLowerCase());
if (!repository) continue;
if (repositoryFilter.size && !repositoryFilter.has(String(repository.fullName).toLowerCase())) continue;
try {
const configured = await deployments.configureServerGitAccess({ repository, profileId: workload.link.profileId });
access.push({ repository: repository.fullName, ready: true, created: configured.created, remoteSha: configured.remoteSha });
await new Promise((resolve) => setTimeout(resolve, 1500));
} catch (error) {
access.push({ repository: repository.fullName, ready: false, error: error.message });
}
}
}
reports.push({
server: server.name,
capabilities: report.capabilities,
warnings: (report.warnings || []).map((warning) => String(warning).slice(0, 300)),
summary: {
detected: report.detected,
running: report.running,
linked: report.linked,
needsReview: report.needsReview,
},
access,
workloads: report.workloads.filter((workload) => workload.link || (workload.runtime?.running && workload.status !== "unmatched")).map((workload) => ({
name: workload.displayName,
running: workload.runtime?.running === true,
health: workload.runtime?.health || "unknown",
repository: workload.link?.repositoryFullName || workload.suggestedRepository?.fullName || null,
confidence: workload.matchConfidence || workload.status,
folder: workload.remoteFolderCandidate || null,
containers: (workload.containers || []).map((container) => container.name),
})),
});
}
console.log(JSON.stringify(reports, null, 2));
} catch (error) {
console.error(error?.stack || error?.message || String(error));
process.exitCode = 1;
} finally {
app.quit();
}
});
+2 -2
View File
@@ -4,12 +4,12 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const excludedDirectories = new Set(['.git', 'dist', 'node_modules']);
const excludedDirectories = new Set(['.git', 'dist', 'node_modules', 'ForgeFlow-runtime-win-x64']);
const excludedFiles = new Set(['SOURCE_MANIFEST.txt']);
async function collect(directory, output = []) {
for (const entry of await readdir(directory, { withFileTypes: true })) {
if (entry.isDirectory() && excludedDirectories.has(entry.name)) continue;
if (excludedDirectories.has(entry.name)) continue;
const absolute = path.join(directory, entry.name);
if (entry.isDirectory()) await collect(absolute, output);
else if (!excludedFiles.has(entry.name)) output.push(absolute);
+7 -6
View File
@@ -4,9 +4,12 @@ const fs = require("node:fs/promises");
const path = require("node:path");
const { app, safeStorage } = require("electron");
const configuredUserData = process.env.FORGEFLOW_USER_DATA;
if (configuredUserData)
app.setPath("userData", path.resolve(configuredUserData));
const configuredUserData = process.env.FORGEFLOW_USER_DATA
? path.resolve(process.env.FORGEFLOW_USER_DATA)
: path.join(app.getPath("appData"), "forgeflow");
// safeStorage is bound to Electron's userData identity. Set it before ready so
// this verifier decrypts the same secrets as the packaged application.
app.setPath("userData", configuredUserData);
function result(name, ok, detail) {
console.log(
@@ -18,9 +21,7 @@ function result(name, ok, detail) {
app.whenReady().then(async () => {
let passed = true;
try {
const userDataPath = configuredUserData
? path.resolve(configuredUserData)
: path.join(app.getPath("appData"), "forgeflow");
const userDataPath = configuredUserData;
const configPath = path.join(userDataPath, "forgeflow-config.json");
const config = JSON.parse(await fs.readFile(configPath, "utf8"));
const baseUrl = String(config.gitea?.baseUrl || "").replace(/\/+$/, "");
+77 -2
View File
@@ -73,6 +73,11 @@ const required = [
"docs/RELEASE_NOTES_0.8.9.md",
"docs/RELEASE_NOTES_0.9.0.md",
"docs/RELEASE_NOTES_0.9.1.md",
"docs/RELEASE_NOTES_0.9.2.md",
"docs/RELEASE_NOTES_0.9.3.md",
"docs/RELEASE_NOTES_0.9.4.md",
"docs/RELEASE_NOTES_0.9.5.md",
"docs/RELEASE_NOTES_0.10.0.md",
"docs/UPDATING.md",
"docs/DIAGNOSTICS.md",
"docs/DEPLOYMENT_SETUP.md",
@@ -111,9 +116,9 @@ for (const file of required) await access(path.join(root, file));
const packageJson = JSON.parse(
await readFile(path.join(root, "package.json"), "utf8"),
);
if (packageJson.version !== "0.9.1")
if (packageJson.version !== "0.10.0")
throw new Error(
`Expected package version 0.9.1, got ${packageJson.version}.`,
`Expected package version 0.10.0, got ${packageJson.version}.`,
);
const sourceManifest = await readFile(
path.join(root, "SOURCE_MANIFEST.txt"),
@@ -373,6 +378,76 @@ for (const phrase of [
]) {
if (!release091.includes(phrase)) throw new Error(`0.9.1 release notes are missing: ${phrase}`);
}
const release092 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.9.2.md"), "utf8");
for (const phrase of [
"Push bundle",
"server password",
"docker ps -a",
"DockerMan",
"zero counts",
]) {
if (!release092.includes(phrase)) throw new Error(`0.9.2 release notes are missing: ${phrase}`);
}
const release093 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.9.3.md"), "utf8");
for (const phrase of [
"Direct copy",
"Compose YAML",
"linked automatically",
"one-click",
"no remote `git ls-remote`",
]) {
if (!release093.includes(phrase)) throw new Error(`0.9.3 release notes are missing: ${phrase}`);
}
const release094 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.9.4.md"), "utf8");
for (const phrase of [
"real Compose files",
"stale service hints",
"force-recreate",
"container ID",
"previous container",
]) {
if (!release094.includes(phrase)) throw new Error(`0.9.4 release notes are missing: ${phrase}`);
}
const release095 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.9.5.md"), "utf8");
for (const phrase of [
"Check / fix write access",
"exact path, user, owner, group and mode",
"preserves existing executable bits",
"never implicitly executes `docker compose down`",
"retains the backup evidence",
]) {
if (!release095.includes(phrase)) throw new Error(`0.9.5 release notes are missing: ${phrase}`);
}
const release0100 = await readFile(path.join(root, "docs/RELEASE_NOTES_0.10.0.md"), "utf8");
for (const phrase of [
"Server pull",
"read-only deploy key",
"automatic discovery",
"Git Validator",
"SSH host fingerprint",
]) {
if (!release0100.includes(phrase)) throw new Error(`0.10.0 release notes are missing: ${phrase}`);
}
const configSource = await readFile(path.join(root, "src/main/config-store.cjs"), "utf8");
for (const mode of ["server-git", "push-bundle", "monitor-only"]) {
if (!configSource.includes(mode)) throw new Error(`Deployment configuration is missing mode: ${mode}`);
}
const unraidDirectSource = await readFile(path.join(root, "src/main/unraid-deployment-service.cjs"), "utf8");
for (const requiredPhrase of [
"executePushBundle",
"executeServerGitBundle",
"configureServerGitAccess",
"server-git-access",
"git ls-remote --exit-code",
"repository-scoped read-only deploy key",
]) {
if (!unraidDirectSource.includes(requiredPhrase)) throw new Error(`Deployment source is missing: ${requiredPhrase}`);
}
const serverInventorySource = await readFile(path.join(root, "src/main/server-inventory.cjs"), "utf8");
for (const requiredPhrase of ["server-compose-file", "composeDefinitions", "remoteFolderCandidate"]) {
if (!serverInventorySource.includes(requiredPhrase)) throw new Error(`Server inventory source is missing: ${requiredPhrase}`);
}
const giteaUpdateSource = await readFile(path.join(root, "src/main/gitea-service.cjs"), "utf8");
for (const phrase of [
"browser_download_url",