Files
ForgeFlow-Public/src/main/preflight-service.cjs
T
NuklearRabbit f60b269686
ForgeFlow quality gate / quality (push) Successful in 4m12s
ForgeFlow quality gate / secret-scan (push) Successful in 7s
Publish curated ForgeFlow source from 2ed1787c0b52
2026-09-29 22:50:57 +02:00

209 lines
13 KiB
JavaScript

'use strict';
const fs = require('node:fs/promises');
const path = require('node:path');
const { run } = require('./process-runner.cjs');
function check(id, label, status, detail, { required = false, help = '' } = {}) {
return { id, label, status, detail, required, help };
}
function summarize(checks) {
const counts = checks.reduce((acc, item) => {
acc[item.status] = (acc[item.status] || 0) + 1;
return acc;
}, { pass: 0, warning: 0, fail: 0, skipped: 0 });
const blocking = checks.filter((item) => item.required && item.status === 'fail');
return { counts, blocking: blocking.map((item) => item.id), ready: blocking.length === 0 };
}
class PreflightService {
constructor({ store, git, gitea, deployments, diagnostics, userDataPath, secureStorageAvailable = () => false }) {
this.store = store;
this.git = git;
this.gitea = gitea;
this.deployments = deployments;
this.diagnostics = diagnostics;
this.userDataPath = userDataPath;
this.secureStorageAvailable = secureStorageAvailable;
}
async writableDirectory(directory) {
const marker = path.join(directory, `.forgeflow-write-test-${process.pid}-${Date.now()}`);
await fs.mkdir(directory, { recursive: true });
await fs.writeFile(marker, 'ok', { mode: 0o600 });
await fs.rm(marker, { force: true });
return true;
}
async gitIdentity() {
const [name, email] = await Promise.all([
run('git', ['config', '--global', '--get', 'user.name'], { allowExitCodes: [1], timeout: 10_000 }),
run('git', ['config', '--global', '--get', 'user.email'], { allowExitCodes: [1], timeout: 10_000 })
]);
return { name: name.stdout.trim(), email: email.stdout.trim() };
}
async runSystem({ baseUrl = '', token = '', roots = [] } = {}) {
const startedAt = new Date().toISOString();
const checks = [];
const git = await this.git.isAvailable();
checks.push(check('git.available', 'Git command line', git.available ? 'pass' : 'fail', git.available ? git.version : git.error || 'Git was not found on PATH.', {
required: true,
help: 'Install Git for Windows and ensure git.exe is available on PATH.'
}));
if (git.available) {
try {
const identity = await this.gitIdentity();
checks.push(check('git.identity', 'Git author identity', identity.name && identity.email ? 'pass' : 'warning', identity.name && identity.email ? `${identity.name} <${identity.email}>` : 'Global user.name or user.email is missing.', {
help: 'Set git config --global user.name and user.email before creating commits.'
}));
} catch (error) {
checks.push(check('git.identity', 'Git author identity', 'warning', error.message));
}
}
try {
await this.writableDirectory(this.userDataPath);
checks.push(check('storage.userdata', 'Application data storage', 'pass', 'ForgeFlow can write its local configuration.', { required: true }));
} catch (error) {
checks.push(check('storage.userdata', 'Application data storage', 'fail', error.message, { required: true }));
}
try {
await this.writableDirectory(this.diagnostics.logDirectory);
checks.push(check('storage.diagnostics', 'Diagnostic log storage', 'pass', 'The diagnostic directory is writable.', { required: true }));
} catch (error) {
checks.push(check('storage.diagnostics', 'Diagnostic log storage', 'fail', error.message, { required: true }));
}
checks.push(check('storage.credentials', 'Protected credential storage', this.secureStorageAvailable() ? 'pass' : 'warning', this.secureStorageAvailable()
? 'The operating system can encrypt the Gitea token at rest.'
: 'OS credential encryption is unavailable; the token will remain session-only.', {
help: 'Use a normal signed-in desktop session and make sure the OS credential service is available.'
}));
const normalizedRoots = [...new Set((roots || []).map((item) => String(item || '').trim()).filter(Boolean))];
if (!normalizedRoots.length) {
checks.push(check('workspace.roots', 'Development folders', 'warning', 'No development folder has been selected yet.'));
} else {
for (let index = 0; index < normalizedRoots.length; index += 1) {
const root = normalizedRoots[index];
try {
const stat = await fs.stat(root);
checks.push(check(`workspace.root.${index}`, `Development folder ${index + 1}`, stat.isDirectory() ? 'pass' : 'fail', stat.isDirectory() ? root : 'The selected path is not a directory.', { required: true }));
} catch (error) {
checks.push(check(`workspace.root.${index}`, `Development folder ${index + 1}`, 'fail', error.message, { required: true }));
}
}
}
const effectiveBaseUrl = String(baseUrl || this.store.data.gitea.baseUrl || '').trim();
const effectiveToken = String(token || this.store.getToken() || '').trim();
let giteaValidation = null;
if (!effectiveBaseUrl || !effectiveToken) {
checks.push(check('gitea.connection', 'Gitea connection', 'warning', 'Enter the Gitea URL and a local access token to test the connection.'));
} else {
try {
giteaValidation = await this.gitea.validateConnection(effectiveBaseUrl, effectiveToken);
checks.push(check('gitea.connection', 'Gitea connection', 'pass', `Connected to Gitea ${giteaValidation.version || 'unknown version'} as ${giteaValidation.user?.login || 'user'}.`, { required: true }));
checks.push(check('gitea.repositories', 'Repository access', giteaValidation.repositoryCount >= 0 ? 'pass' : 'warning', `${giteaValidation.repositoryCount} accessible repositories returned.`));
} catch (error) {
checks.push(check('gitea.connection', 'Gitea connection', 'fail', error.message, { required: true }));
}
}
const result = { kind: 'system', startedAt, completedAt: new Date().toISOString(), checks, summary: summarize(checks), giteaValidation };
await this.diagnostics.info('preflight.system.completed', { summary: result.summary, checks });
return result;
}
async fileExists(filePath) {
const stat = await fs.stat(filePath).catch(() => null);
return Boolean(stat?.isFile());
}
async runDeployment({ repository, profileId }) {
const checks = [];
const startedAt = new Date().toISOString();
if (!repository?.fullName) throw new Error('Repository identity is required.');
const profile = this.store.getDeploymentProfile(repository.fullName, profileId);
if (!profile) throw new Error('Deployment profile not found.');
checks.push(check('repository.linked', 'Local repository link', repository.localPath ? 'pass' : 'fail', repository.localPath || 'No local folder is linked.', { required: true }));
if (!repository.localPath) {
const result = { kind: 'deployment', repository: repository.fullName, profileId, startedAt, completedAt: new Date().toISOString(), checks, summary: summarize(checks) };
await this.diagnostics.info('preflight.deployment.completed', result);
return result;
}
let status = null;
try {
status = await this.git.status(repository.localPath);
checks.push(check('git.repository', 'Git working tree', 'pass', status.root, { required: true }));
checks.push(check('git.branch', 'Allowed branch', status.branch.head === profile.branch ? 'pass' : 'fail', `Current: ${status.branch.head || 'detached'}; required: ${profile.branch}.`, { required: true }));
checks.push(check('git.clean', 'Clean working tree', status.clean ? 'pass' : 'fail', status.clean ? 'No uncommitted changes.' : `${status.counts.changed} changed file(s) remain.`, { required: true }));
checks.push(check('git.upstream', 'Published upstream', status.branch.upstream ? 'pass' : 'fail', status.branch.upstream || 'No upstream branch configured.', { required: true }));
checks.push(check('git.sync', 'Local and Gitea synchronized', !status.branch.ahead && !status.branch.behind ? 'pass' : 'fail', `${status.branch.ahead || 0} ahead, ${status.branch.behind || 0} behind.`, { required: true }));
if (status.head) {
try {
await this.git.verifyCommitOnRemoteBranch(repository.localPath, status.head, profile.branch);
checks.push(check('git.remote-sha', 'Exact commit on remote branch', 'pass', `${status.head.slice(0, 7)} exists on origin/${profile.branch}.`, { required: true }));
} catch (error) {
checks.push(check('git.remote-sha', 'Exact commit on remote branch', 'fail', error.message, { required: true }));
}
}
} catch (error) {
checks.push(check('git.repository', 'Git working tree', 'fail', error.message, { required: true }));
}
const workflowPath = path.join(repository.localPath, '.gitea', 'workflows', profile.workflowFile);
checks.push(check('workflow.deploy.local', 'Deploy workflow in local repository', await this.fileExists(workflowPath) ? 'pass' : 'fail', workflowPath, { required: true }));
if (profile.rollbackWorkflowFile) {
const rollbackPath = path.join(repository.localPath, '.gitea', 'workflows', profile.rollbackWorkflowFile);
checks.push(check('workflow.rollback.local', 'Rollback workflow in local repository', await this.fileExists(rollbackPath) ? 'pass' : 'warning', rollbackPath));
}
try {
const [owner, repo] = repository.fullName.split('/');
const remoteWorkflow = await this.gitea.repositoryFileExists({ owner, repo, filePath: `.gitea/workflows/${profile.workflowFile}`, ref: profile.branch });
checks.push(check('workflow.deploy.remote', 'Deploy workflow on Gitea branch', remoteWorkflow ? 'pass' : 'fail', remoteWorkflow ? `${profile.workflowFile} exists on ${profile.branch}.` : `${profile.workflowFile} is not present on ${profile.branch}.`, { required: true }));
try {
await this.gitea.listWorkflowRuns({ owner, repo, branch: profile.branch, limit: 1 });
checks.push(check('gitea.actions', 'Gitea Actions API', 'pass', 'The Actions runs endpoint is accessible.', { required: true }));
} catch (error) {
checks.push(check('gitea.actions', 'Gitea Actions API', 'fail', error.message, { required: true }));
}
} catch (error) {
checks.push(check('workflow.deploy.remote', 'Deploy workflow on Gitea branch', 'fail', error.message, { required: true }));
}
if (profile.statusUrl) {
const state = await this.deployments.readStatusEndpoint(profile.statusUrl);
checks.push(check('server.status.configured', 'Server version endpoint configured', 'pass', profile.statusUrl, { required: true }));
checks.push(check('server.status.reachable', 'Server version endpoint reachable', state.reachable && state.ok ? 'pass' : 'warning', state.reachable && state.ok ? `Endpoint reachable${state.liveSha ? `; live ${state.liveSha.slice(0, 7)}` : '; no live SHA reported yet'}.` : state.error || `HTTP ${state.status || 'unavailable'}.`, { help: 'The first deployment may create the status file. Successful completion still requires the endpoint to return the exact SHA and request ID.' }));
if (state.reachable && state.ok) {
const identityMatches = (!state.repository || state.repository === repository.fullName) && (!state.environment || state.environment === profile.environment);
checks.push(check('server.status.identity', 'Status endpoint target identity', identityMatches ? (state.repository && state.environment ? 'pass' : 'warning') : 'fail', state.repository && state.environment ? `${state.repository} / ${state.environment}` : 'Repository or environment is not present in the current status document.', { required: !identityMatches }));
}
} else checks.push(check('server.status.configured', 'Server version endpoint configured', 'fail', 'A status URL is required for exact post-deployment verification.', { required: true }));
if (profile.healthcheckUrl) {
const health = await this.deployments.checkHealth(profile.healthcheckUrl);
checks.push(check('server.health', 'Application healthcheck', health.healthy ? 'pass' : 'warning', health.healthy ? `HTTP ${health.status} in ${health.latencyMs} ms.` : health.error || `HTTP ${health.status || 'unavailable'}.`));
} else checks.push(check('server.health', 'Application healthcheck', 'warning', 'No healthcheck URL is configured.'));
const result = {
kind: 'deployment', repository: repository.fullName, profileId, profileName: profile.name,
startedAt, completedAt: new Date().toISOString(), checks, summary: summarize(checks),
head: status?.head || null
};
await this.diagnostics.info('preflight.deployment.completed', { repository: repository.fullName, profileId, summary: result.summary, checks });
return result;
}
}
module.exports = { PreflightService, summarize, check };