'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 };