425 lines
23 KiB
JavaScript
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 };
|