Manifestbasierter Production-Updater 3.3, Modulnavigation, optionales Produktbild Studio, Listing-Studio-Aktionsleiste sowie plattformübergreifende Windows-/Linux-Release-Gates.
193 lines
8.9 KiB
JavaScript
193 lines
8.9 KiB
JavaScript
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();
|