209 lines
13 KiB
JavaScript
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 };
|