Files
ForgeFlow/src/main/deployment-service.cjs
T
2026-07-24 20:29:23 +02:00

425 lines
23 KiB
JavaScript

'use strict';
const crypto = require('node:crypto');
const { assertDeploymentRequest, assertFullCommitSha, assertHttpUrl } = require('../shared/validation.cjs');
const { redactSecrets } = require('./log-redaction.cjs');
const TERMINAL_STATUSES = new Set(['success', 'failed', 'cancelled', 'rolled-back']);
function applicationVerificationFailure(operation, state) {
if (!state?.statusConfigured) return { stage: 'version-verification', message: 'No server status endpoint is configured.' };
if (!state.statusReachable) return { stage: 'version-verification', message: state.error || 'The server status endpoint is not reachable.' };
if (!state.statusRepository) return { stage: 'version-verification', message: 'The server status endpoint did not identify its repository.' };
if (state.statusRepository !== operation.repository) return { stage: 'version-verification', message: `The status endpoint belongs to ${state.statusRepository}, not ${operation.repository}.` };
if (!state.statusEnvironment) return { stage: 'version-verification', message: 'The server status endpoint did not identify its environment.' };
if (state.statusEnvironment !== operation.environment) return { stage: 'version-verification', message: `The status endpoint belongs to ${state.statusEnvironment}, not ${operation.environment}.` };
if (!state.liveSha) return { stage: 'version-verification', message: 'The server status endpoint did not return a valid full commit SHA.' };
if (state.liveSha !== operation.sha) return { stage: 'version-verification', message: `Server reports ${state.liveSha.slice(0, 7)} instead of ${operation.shortSha}.` };
if (!state.requestedSha) return { stage: 'version-verification', message: 'The server status endpoint did not return the requested commit SHA.' };
if (state.requestedSha !== operation.sha) return { stage: 'version-verification', message: 'The server status document was created for a different requested commit.' };
if (!state.requestId) return { stage: 'version-verification', message: 'The server status endpoint did not return the deployment request ID.' };
if (state.requestId !== operation.id) return { stage: 'version-verification', message: 'The server status belongs to a different deployment request.' };
if (state.lastExitCode !== 0) return { stage: 'server-command', message: `The server deployment command reported exit code ${state.lastExitCode ?? 'unknown'}.` };
if (state.healthy !== true) return { stage: 'healthcheck', message: state.error || `The server did not report a healthy application state (${state.healthStatus || 'unknown'}).` };
return null;
}
function terminalRunConclusion(run) {
const value = String(run?.conclusion || run?.status || '').toLowerCase();
if (['success'].includes(value)) return 'success';
if (['failure', 'failed', 'timed_out', 'startup_failure'].includes(value)) return 'failed';
if (['cancelled', 'canceled', 'skipped'].includes(value)) return 'cancelled';
return null;
}
function isRunningStatus(value) {
return ['running', 'in_progress', 'processing'].includes(String(value || '').toLowerCase());
}
function isQueuedStatus(value) {
return ['pending', 'queued', 'waiting', 'blocked', 'requested'].includes(String(value || '').toLowerCase());
}
class DeploymentService {
constructor(store, giteaService, gitService, diagnostics = null) {
this.store = store;
this.gitea = giteaService;
this.git = gitService;
this.diagnostics = diagnostics;
this.refreshLocks = new Set();
}
splitRepository(fullName) {
const [owner, repo, ...unexpected] = String(fullName || '').split('/');
if (!owner || !repo || unexpected.length) throw new Error('Invalid Gitea repository identity.');
return { owner, repo };
}
makeStages() {
return [
{ id: 'requested', label: 'Requested', status: 'complete' },
{ id: 'verified', label: 'Verified', status: 'complete' },
{ id: 'queued', label: 'Workflow queued', status: 'active' },
{ id: 'runner', label: 'Runner execution', status: 'pending' },
{ id: 'healthcheck', label: 'Healthcheck', status: 'pending' },
{ id: 'complete', label: 'Complete', status: 'pending' }
];
}
setStage(operation, id, status) {
const stage = operation.stages?.find((item) => item.id === id);
if (stage) stage.status = status;
}
appendLog(operation, line) {
const clean = redactSecrets(line, [this.store.getToken()]);
operation.logs = Array.isArray(operation.logs) ? operation.logs : [];
if (operation.logs.at(-1) !== clean) operation.logs.push(clean);
operation.logs = operation.logs.slice(-1000);
}
async captureBaselineRunIds(owner, repo, branch, operation) {
try {
const result = await this.gitea.listWorkflowRuns({ owner, repo, branch, limit: 50 });
const ids = (result.runs || []).map((run) => run.id).filter((id) => id !== null && id !== undefined).map(String);
operation.baselineRunIds = [...new Set(ids)].slice(0, 100);
this.appendLog(operation, `[info] Captured ${operation.baselineRunIds.length} existing Actions run identifier(s) before dispatch.`);
} catch (error) {
operation.baselineRunIds = [];
this.appendLog(operation, `[warning] Could not capture the pre-dispatch run baseline: ${error.message}`);
}
}
async validateDeploy(repository, profile, sha) {
assertDeploymentRequest(profile, sha);
const localStatus = await this.git.status(repository.localPath);
if (localStatus.head !== sha) throw new Error('The selected commit no longer matches the local repository. Refresh before deploying.');
if (localStatus.branch.head !== profile.branch) throw new Error(`This profile only allows deployments from ${profile.branch}.`);
if (localStatus.counts.changed) throw new Error('Commit local changes before deploying.');
if (localStatus.branch.ahead) throw new Error('Push all local commits before deploying.');
if (localStatus.branch.behind) throw new Error('Synchronize with Gitea before deploying.');
if (!localStatus.branch.upstream) throw new Error('Publish this branch to Gitea before deploying.');
await this.git.verifyCommitOnRemoteBranch(repository.localPath, sha, profile.branch);
return localStatus;
}
async deploy({ repository, profileId, sha }) {
if (!repository?.fullName || !repository?.localPath) throw new Error('A linked local repository is required for deployment.');
const profile = this.store.getDeploymentProfile(repository.fullName, profileId);
const fullSha = assertFullCommitSha(sha);
await this.validateDeploy(repository, profile, fullSha);
const { owner, repo } = this.splitRepository(repository.fullName);
const operation = {
id: crypto.randomUUID(),
type: 'deployment',
action: 'deploy',
status: 'requested',
repository: repository.fullName,
profileId,
profileName: profile.name,
environment: profile.environment,
workflowFile: profile.workflowFile,
branch: profile.branch,
sha: fullSha,
shortSha: fullSha.slice(0, 7),
dispatchedAt: new Date().toISOString(),
stages: this.makeStages(),
logs: [
`[info] Verified clean ${profile.branch} at ${fullSha}`,
`[info] Dispatching ${profile.workflowFile} for ${repository.fullName}`
]
};
await this.captureBaselineRunIds(owner, repo, profile.branch, operation);
await this.store.addOperation(operation);
await this.diagnostics?.info('deployment.dispatch.requested', { operationId: operation.id, repository: operation.repository, profileId, environment: operation.environment, branch: operation.branch, sha: operation.sha, workflowFile: operation.workflowFile });
try {
await this.gitea.dispatchWorkflow({
owner,
repo,
workflowFile: profile.workflowFile,
ref: profile.branch,
inputs: { environment: profile.environment, commit_sha: fullSha, request_id: operation.id }
});
operation.status = 'queued';
this.appendLog(operation, '[ok] Gitea accepted the workflow dispatch request.');
this.appendLog(operation, '[info] Resolving the corresponding Actions run…');
const saved = await this.store.addOperation(operation);
await this.diagnostics?.info('deployment.dispatch.accepted', { operationId: operation.id, repository: operation.repository, status: operation.status });
return saved;
} catch (error) {
operation.status = 'failed';
this.setStage(operation, 'queued', 'failed');
operation.failure = { stage: 'dispatch', message: error.message };
this.appendLog(operation, `[error] ${error.message}`);
await this.store.addOperation(operation);
await this.diagnostics?.error('deployment.dispatch.failed', { operationId: operation.id, repository: operation.repository, message: error.message, code: error.code, status: error.status });
throw error;
}
}
async rollback({ repository, profileId, targetSha }) {
if (!repository?.fullName || !repository?.localPath) throw new Error('A linked local repository is required for rollback.');
const profile = this.store.getDeploymentProfile(repository.fullName, profileId);
if (!profile) throw new Error('Deployment profile not found.');
if (!profile.rollbackWorkflowFile) throw new Error('No rollback workflow is configured for this profile.');
const fullSha = assertFullCommitSha(targetSha);
assertDeploymentRequest({ ...profile, workflowFile: profile.rollbackWorkflowFile }, fullSha);
const state = await this.refreshProfileState(repository.fullName, profileId);
if (!state.statusReachable) throw new Error(state.error || 'The server status endpoint must be reachable before rollback.');
if (state.statusRepository !== repository.fullName || state.statusEnvironment !== profile.environment) throw new Error('The status endpoint does not match this repository and environment.');
if (!state.previousSha) throw new Error('The server status endpoint does not report a previous version.');
if (state.previousSha !== fullSha) throw new Error('The requested rollback SHA is no longer the previous server version. Refresh the environment state.');
if (state.liveSha === fullSha) throw new Error('The requested rollback version is already live.');
await this.git.verifyCommitOnRemoteBranch(repository.localPath, fullSha, profile.branch);
const { owner, repo } = this.splitRepository(repository.fullName);
const operation = {
id: crypto.randomUUID(),
type: 'deployment',
action: 'rollback',
status: 'requested',
repository: repository.fullName,
profileId,
profileName: profile.name,
environment: profile.environment,
workflowFile: profile.rollbackWorkflowFile,
branch: profile.branch,
sha: fullSha,
shortSha: fullSha.slice(0, 7),
dispatchedAt: new Date().toISOString(),
stages: this.makeStages(),
logs: [
`[warning] Rollback target verified on origin/${profile.branch}: ${fullSha}`,
`[info] Dispatching ${profile.rollbackWorkflowFile}`
]
};
await this.captureBaselineRunIds(owner, repo, profile.branch, operation);
await this.store.addOperation(operation);
await this.diagnostics?.info('deployment.rollback.requested', { operationId: operation.id, repository: operation.repository, profileId, environment: operation.environment, branch: operation.branch, sha: operation.sha, workflowFile: operation.workflowFile });
try {
await this.gitea.dispatchWorkflow({
owner,
repo,
workflowFile: profile.rollbackWorkflowFile,
ref: profile.branch,
inputs: { environment: profile.environment, target_sha: fullSha, request_id: operation.id }
});
operation.status = 'queued';
this.appendLog(operation, '[ok] Gitea accepted the rollback request.');
const saved = await this.store.addOperation(operation);
await this.diagnostics?.info('deployment.rollback.accepted', { operationId: operation.id, repository: operation.repository });
return saved;
} catch (error) {
operation.status = 'failed';
this.setStage(operation, 'queued', 'failed');
operation.failure = { stage: 'dispatch', message: error.message };
this.appendLog(operation, `[error] ${error.message}`);
await this.store.addOperation(operation);
await this.diagnostics?.error('deployment.rollback.failed', { operationId: operation.id, repository: operation.repository, message: error.message, code: error.code, status: error.status });
throw error;
}
}
mapJobsToStages(operation, jobs) {
operation.jobs = jobs;
if (!jobs.length) return;
const running = jobs.some((job) => isRunningStatus(job.status));
const failed = jobs.some((job) => terminalRunConclusion(job) === 'failed');
const allDone = jobs.every((job) => terminalRunConclusion(job));
this.setStage(operation, 'queued', 'complete');
this.setStage(operation, 'runner', failed ? 'failed' : allDone ? 'complete' : running ? 'active' : 'pending');
}
async refreshOperation(operationId) {
if (this.refreshLocks.has(operationId)) return this.store.getOperation(operationId);
const operation = this.store.getOperation(operationId);
if (!operation || operation.type !== 'deployment') throw new Error('Deployment operation not found.');
if (TERMINAL_STATUSES.has(operation.status)) return operation;
this.refreshLocks.add(operationId);
try {
const profile = this.store.getDeploymentProfile(operation.repository, operation.profileId);
if (!profile) throw new Error('The deployment profile used by this operation no longer exists.');
const { owner, repo } = this.splitRepository(operation.repository);
const found = await this.gitea.findWorkflowRun({
owner,
repo,
sha: operation.sha,
branch: operation.branch,
workflowFile: operation.workflowFile,
dispatchedAt: operation.dispatchedAt || operation.createdAt,
excludeRunIds: operation.baselineRunIds || []
});
if (!found.run) {
operation.status = 'queued';
this.setStage(operation, 'queued', 'active');
this.appendLog(operation, '[info] Workflow is queued or not visible through the Actions API yet.');
return await this.store.addOperation(operation);
}
operation.run = { ...found.run, source: found.source };
operation.runUrl = found.run.htmlUrl || `${this.store.data.gitea.baseUrl}/${operation.repository}/actions/runs/${found.run.runNumber}`;
this.setStage(operation, 'queued', 'complete');
const runConclusion = terminalRunConclusion(found.run);
if (!runConclusion) {
operation.status = isRunningStatus(found.run.status) ? 'running' : 'queued';
this.setStage(operation, 'runner', operation.status === 'running' ? 'active' : 'pending');
}
try {
const jobs = await this.gitea.listWorkflowJobs({ owner, repo, runNumber: found.run.runNumber });
this.mapJobsToStages(operation, jobs);
for (const job of jobs) {
const conclusion = job.conclusion || job.status;
this.appendLog(operation, `[job] ${job.name}: ${conclusion}`);
}
// Raw runner output is intentionally not ingested or persisted. Open the trusted Gitea run for full logs.
} catch (error) {
this.appendLog(operation, `[warning] Job details unavailable: ${error.message}`);
}
if (runConclusion === 'success') {
this.setStage(operation, 'runner', 'complete');
this.setStage(operation, 'healthcheck', 'active');
const state = await this.refreshProfileState(operation.repository, operation.profileId, { expectedSha: operation.sha });
operation.applicationState = state;
const verificationFailure = applicationVerificationFailure(operation, state);
if (verificationFailure) {
operation.status = 'failed';
this.setStage(operation, 'healthcheck', 'failed');
this.setStage(operation, 'complete', 'failed');
operation.failure = verificationFailure;
this.appendLog(operation, `[error] ${verificationFailure.message}`);
} else {
operation.status = operation.action === 'rollback' ? 'rolled-back' : 'success';
this.setStage(operation, 'healthcheck', 'complete');
this.setStage(operation, 'complete', 'complete');
this.appendLog(operation, `[ok] ${operation.action === 'rollback' ? 'Rollback' : 'Deployment'} completed successfully.`);
}
} else if (runConclusion === 'failed' || runConclusion === 'cancelled') {
operation.status = runConclusion;
this.setStage(operation, 'runner', runConclusion === 'failed' ? 'failed' : 'cancelled');
this.setStage(operation, 'healthcheck', 'skipped');
this.setStage(operation, 'complete', runConclusion === 'failed' ? 'failed' : 'cancelled');
operation.failure = { stage: 'runner', message: `Gitea Actions finished with ${runConclusion}.` };
this.appendLog(operation, `[error] ${operation.failure.message}`);
}
const saved = await this.store.addOperation(operation);
if (TERMINAL_STATUSES.has(operation.status)) {
await this.diagnostics?.info('deployment.operation.terminal', { operationId: operation.id, repository: operation.repository, status: operation.status, failure: operation.failure || null, applicationState: operation.applicationState || null });
} else {
await this.diagnostics?.debug('deployment.operation.refreshed', { operationId: operation.id, repository: operation.repository, status: operation.status, run: operation.run ? { id: operation.run.id, runNumber: operation.run.runNumber, status: operation.run.status, conclusion: operation.run.conclusion } : null });
}
return saved;
} catch (error) {
operation.pollError = error.message;
this.appendLog(operation, `[warning] Status refresh failed: ${error.message}`);
await this.diagnostics?.warning('deployment.operation.poll-failed', { operationId: operation.id, repository: operation.repository, message: error.message });
return await this.store.addOperation(operation);
} finally {
this.refreshLocks.delete(operationId);
}
}
async refreshActiveOperations() {
const active = this.store.data.operations.filter((item) => item.type === 'deployment' && !TERMINAL_STATUSES.has(item.status));
const results = [];
for (const operation of active.slice(0, 20)) results.push(await this.refreshOperation(operation.id));
return results;
}
async checkHealth(url) {
if (!url) return { configured: false, healthy: null };
const normalized = assertHttpUrl(url, { label: 'Healthcheck URL' });
const started = Date.now();
try {
const response = await fetch(normalized, { signal: AbortSignal.timeout(10_000), redirect: 'follow', headers: { Accept: 'application/json, text/plain, */*' } });
return { configured: true, healthy: response.ok, status: response.status, latencyMs: Date.now() - started };
} catch (error) {
return { configured: true, healthy: false, error: error.message, latencyMs: Date.now() - started };
}
}
async readStatusEndpoint(url) {
if (!url) return { configured: false };
const normalized = assertHttpUrl(url, { label: 'Application status URL' });
const started = Date.now();
try {
const response = await fetch(normalized, { signal: AbortSignal.timeout(10_000), redirect: 'follow', headers: { Accept: 'application/json' } });
if (!response.ok) return { configured: true, reachable: true, ok: false, status: response.status, latencyMs: Date.now() - started };
const payload = await response.json();
const liveSha = payload.commit_sha || payload.commitSha || payload.sha || payload.version?.commit_sha || payload.version?.sha || null;
const previousSha = payload.previous_sha || payload.previousSha || payload.previous?.sha || null;
const requestId = payload.request_id || payload.requestId || null;
const requestedSha = payload.requested_sha || payload.requestedSha || null;
const repository = payload.repository || null;
const environment = payload.environment || null;
const rawExitCode = payload.last_exit_code ?? payload.lastExitCode ?? null;
return {
configured: true,
reachable: true,
ok: true,
status: response.status,
latencyMs: Date.now() - started,
liveSha: /^[a-f0-9]{40,64}$/i.test(String(liveSha || '')) ? String(liveSha).toLowerCase() : null,
previousSha: /^[a-f0-9]{40,64}$/i.test(String(previousSha || '')) ? String(previousSha).toLowerCase() : null,
requestId: typeof requestId === 'string' ? requestId.slice(0, 100) : null,
requestedSha: /^[a-f0-9]{40,64}$/i.test(String(requestedSha || '')) ? String(requestedSha).toLowerCase() : null,
repository: typeof repository === 'string' ? repository.slice(0, 200) : null,
environment: typeof environment === 'string' ? environment.slice(0, 64).toLowerCase() : null,
lastExitCode: rawExitCode !== null && rawExitCode !== '' && Number.isInteger(Number(rawExitCode)) ? Number(rawExitCode) : null,
deployedAt: payload.deployed_at || payload.deployedAt || null,
health: payload.health || payload.status || null,
payload
};
} catch (error) {
return { configured: true, reachable: false, ok: false, error: error.message, latencyMs: Date.now() - started };
}
}
async refreshProfileState(fullName, profileId, { expectedSha = null } = {}) {
const profile = this.store.getDeploymentProfile(fullName, profileId);
if (!profile) throw new Error('Deployment profile not found.');
const [status, health] = await Promise.all([
this.readStatusEndpoint(profile.statusUrl),
this.checkHealth(profile.healthcheckUrl)
]);
const state = {
profileId,
repository: fullName,
environment: profile.environment,
liveSha: status.liveSha || null,
previousSha: status.previousSha || null,
deployedAt: status.deployedAt || null,
statusConfigured: Boolean(status.configured),
statusReachable: status.configured ? Boolean(status.reachable && status.ok) : null,
statusCode: status.status || null,
statusRepository: status.repository || null,
statusEnvironment: status.environment || null,
requestedSha: status.requestedSha || null,
lastExitCode: status.lastExitCode,
healthConfigured: Boolean(health.configured),
healthy: health.configured
? Boolean(health.healthy)
: (['healthy', 'ok', 'success', 'ready'].includes(String(status.health || '').toLowerCase())
? true
: (['unhealthy', 'failed', 'error', 'degraded'].includes(String(status.health || '').toLowerCase()) ? false : null)),
healthStatus: health.status || status.health || null,
latencyMs: health.latencyMs ?? status.latencyMs ?? null,
expectedSha: expectedSha || null,
requestId: status.requestId || null,
versionMatches: expectedSha && status.liveSha ? status.liveSha === expectedSha : null,
error: health.error || status.error || null,
checkedAt: new Date().toISOString()
};
return this.store.saveDeploymentState(profileId, state);
}
}
module.exports = { DeploymentService, TERMINAL_STATUSES, terminalRunConclusion, applicationVerificationFailure };