Update
This commit is contained in:
@@ -0,0 +1,424 @@
|
||||
'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 };
|
||||
Reference in New Issue
Block a user