import { randomUUID } from 'node:crypto'; import { db } from '../../../lib/db.mjs'; import { PlatformError } from '../../kernel/errors.mjs'; import { ACTIVE_DEPLOYMENT_STATES, DEPLOYMENT_STATES, canTransitionDeployment, isActiveDeploymentState, } from './state-machine.mjs'; function parseJson(value, fallback) { try { return value ? JSON.parse(value) : fallback; } catch { return fallback; } } function serializeJson(value, fallback) { try { return JSON.stringify(value ?? fallback); } catch { return JSON.stringify(fallback); } } function normalizeRun(row) { if (!row) return null; return { ...row, request: parseJson(row.request_json, {}), result: parseJson(row.result_json, {}), active: isActiveDeploymentState(row.state), }; } function fail(message, code = 'DEPLOYMENT_STATE_INVALID', status = 409, details = null) { throw new PlatformError(message, { code, status, expose: true, details }); } export function createDeploymentRepository(database = db) { const insertRun = database.prepare(`INSERT INTO deployment_runs ( id, idempotency_key, state, provider, trigger_mode, current_version, target_version, build_revision_before, requested_by, reason, github_repository, github_branch, request_json, timeout_at, started_at, updated_at ) VALUES (?, ?, 'checking', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))`); const insertEvent = database.prepare(`INSERT INTO deployment_events (run_id, state, event_type, message, details_json) VALUES (?, ?, ?, ?, ?)`); const getRunStmt = database.prepare('SELECT * FROM deployment_runs WHERE id = ?'); const getIdempotentStmt = database.prepare('SELECT * FROM deployment_runs WHERE idempotency_key = ?'); const getActiveStmt = database.prepare(`SELECT * FROM deployment_runs WHERE state IN (${ACTIVE_DEPLOYMENT_STATES.map(() => '?').join(',')}) ORDER BY created_at DESC LIMIT 1`); function addEvent(runId, state, eventType, message, details = {}) { insertEvent.run(runId, state, String(eventType || 'state').slice(0, 80), String(message || '').slice(0, 1000), serializeJson(details, {})); } function createRun(input) { const id = input.id || randomUUID(); const existing = getIdempotentStmt.get(input.idempotencyKey); if (existing) return { run: normalizeRun(existing), reused: true }; const active = getActiveStmt.get(...ACTIVE_DEPLOYMENT_STATES); if (active) fail('Es läuft bereits ein Deployment oder es ist ein Rollback erforderlich.', 'DEPLOYMENT_ALREADY_ACTIVE', 409, { run_id: active.id, state: active.state }); try { insertRun.run( id, input.idempotencyKey, input.provider || 'coolify', input.triggerMode || 'webhook', input.currentVersion, input.targetVersion, input.buildRevisionBefore || null, input.requestedBy || null, input.reason || 'manual', input.githubRepository || null, input.githubBranch || null, serializeJson(input.request || {}, {}), input.timeoutAt || null, ); } catch (error) { if (String(error?.message || '').includes('UNIQUE')) { const raced = getIdempotentStmt.get(input.idempotencyKey); if (raced) return { run: normalizeRun(raced), reused: true }; } throw error; } addEvent(id, 'checking', 'run.created', 'Deployment-Auftrag wurde angelegt.', { target_version: input.targetVersion, trigger_mode: input.triggerMode, }); return { run: normalizeRun(getRunStmt.get(id)), reused: false }; } function getRun(id) { return normalizeRun(getRunStmt.get(id)); } function findByIdempotencyKey(key) { return normalizeRun(getIdempotentStmt.get(key)); } function getActiveRun() { return normalizeRun(getActiveStmt.get(...ACTIVE_DEPLOYMENT_STATES)); } function listRuns(limit = 25) { const safeLimit = Math.max(1, Math.min(200, Number(limit) || 25)); return database.prepare('SELECT * FROM deployment_runs ORDER BY created_at DESC LIMIT ?').all(safeLimit).map(normalizeRun); } function listEvents(runId, limit = 200) { const safeLimit = Math.max(1, Math.min(1000, Number(limit) || 200)); return database.prepare(`SELECT * FROM deployment_events WHERE run_id = ? ORDER BY id ASC LIMIT ?`).all(runId, safeLimit) .map(row => ({ ...row, details: parseJson(row.details_json, {}) })); } function transition(runId, nextState, { message = '', eventType = 'state.changed', details = {}, patch = {} } = {}) { if (!DEPLOYMENT_STATES.includes(nextState)) fail('Unbekannter Deployment-Zustand.', 'DEPLOYMENT_STATE_UNKNOWN', 500, { next_state: nextState }); const current = getRunStmt.get(runId); if (!current) fail('Deployment-Auftrag wurde nicht gefunden.', 'DEPLOYMENT_RUN_NOT_FOUND', 404); if (current.state === nextState) return normalizeRun(current); if (!canTransitionDeployment(current.state, nextState)) { fail(`Ungültiger Deployment-Übergang von ${current.state} nach ${nextState}.`, 'DEPLOYMENT_TRANSITION_BLOCKED', 409, { run_id: runId, from: current.state, to: nextState, }); } const completed = ['succeeded', 'failed'].includes(nextState); const columns = ['state = ?', "updated_at = datetime('now')"]; const values = [nextState]; const allowedPatch = new Set([ 'deployment_id', 'backup_id', 'result_json', 'error_code', 'error_message', 'build_revision_after', 'timeout_at', ]); for (const [key, value] of Object.entries(patch || {})) { if (!allowedPatch.has(key)) continue; columns.push(`${key} = ?`); values.push(key.endsWith('_json') ? serializeJson(value, {}) : value); } if (completed) columns.push("completed_at = datetime('now')"); values.push(runId); database.prepare(`UPDATE deployment_runs SET ${columns.join(', ')} WHERE id = ?`).run(...values); addEvent(runId, nextState, eventType, message || `Zustand: ${nextState}`, details); return normalizeRun(getRunStmt.get(runId)); } function patchRun(runId, patch = {}, { eventType = null, message = '', details = {} } = {}) { const current = getRunStmt.get(runId); if (!current) fail('Deployment-Auftrag wurde nicht gefunden.', 'DEPLOYMENT_RUN_NOT_FOUND', 404); const allowedPatch = new Set([ 'deployment_id', 'backup_id', 'result_json', 'error_code', 'error_message', 'build_revision_after', 'timeout_at', ]); const columns = ["updated_at = datetime('now')"]; const values = []; for (const [key, value] of Object.entries(patch || {})) { if (!allowedPatch.has(key)) continue; columns.push(`${key} = ?`); values.push(key.endsWith('_json') ? serializeJson(value, {}) : value); } values.push(runId); database.prepare(`UPDATE deployment_runs SET ${columns.join(', ')} WHERE id = ?`).run(...values); if (eventType) addEvent(runId, current.state, eventType, message || eventType, details); return normalizeRun(getRunStmt.get(runId)); } function recordBackupVerification({ backupId, runId = null, status, sha256 = null, fileSize = 0, sqliteIntegrity = null, manifest = {}, errors = [], warnings = [] }) { const id = randomUUID(); database.prepare(`INSERT INTO backup_verifications (id, backup_id, deployment_run_id, status, sha256, file_size, sqlite_integrity, manifest_json, errors_json, warnings_json, verified_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))`).run( id, backupId, runId, status, sha256, Number(fileSize) || 0, sqliteIntegrity, serializeJson(manifest, {}), serializeJson(errors, []), serializeJson(warnings, []), ); return database.prepare('SELECT * FROM backup_verifications WHERE id = ?').get(id); } function createProductionCheck({ createdBy = null, results = [] } = {}) { const id = randomUUID(); const passed = results.filter(item => item.status === 'pass').length; const warnings = results.filter(item => item.status === 'warning').length; const failed = results.filter(item => item.status === 'fail').length; const overall = failed ? 'failed' : (warnings ? 'warning' : 'passed'); database.prepare(`INSERT INTO production_checks (id, overall_status, passed_count, warning_count, failed_count, results_json, created_by) VALUES (?, ?, ?, ?, ?, ?, ?)`).run(id, overall, passed, warnings, failed, serializeJson(results, []), createdBy); return { id, overall_status: overall, passed_count: passed, warning_count: warnings, failed_count: failed, results }; } function latestProductionCheck() { const row = database.prepare('SELECT * FROM production_checks ORDER BY created_at DESC LIMIT 1').get(); return row ? { ...row, results: parseJson(row.results_json, []) } : null; } return Object.freeze({ createRun, getRun, findByIdempotencyKey, getActiveRun, listRuns, listEvents, transition, patchRun, addEvent, recordBackupVerification, createProductionCheck, latestProductionCheck, }); } export const deploymentRepository = createDeploymentRepository();