feat: harden server pull deployments and git hygiene
This commit is contained in:
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user