696 lines
39 KiB
JavaScript
696 lines
39 KiB
JavaScript
import { randomUUID } from 'node:crypto';
|
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
import { dirname, join } from 'node:path';
|
|
import { readRuntimeConfig, setConfigValue, writeRuntimeConfig } from '../../../lib/config-store.mjs';
|
|
import { getSecret, getSecretMetadata, setSecret } from '../../../lib/secret-store.mjs';
|
|
import {
|
|
APP_ROOT, APP_VERSION, BACKUP_DIR, DATA_ROOT, DB_PATH, LOG_DIR,
|
|
MODULES_DIR, MODULE_STATE_PATH, OPERATIONS_DIR, UPLOADS_DIR,
|
|
runtimePathSummary, verifyRuntimeWritable,
|
|
} from '../../../lib/runtime-paths.mjs';
|
|
import { db } from '../../../lib/db.mjs';
|
|
import { PlatformError } from '../../kernel/errors.mjs';
|
|
import { deploymentRepository } from './repository.mjs';
|
|
|
|
const PROVIDERS = new Set(['none', 'coolify']);
|
|
const MODES = new Set(['webhook', 'api']);
|
|
const CHANNELS = new Set(['stable', 'beta']);
|
|
const METHODS = new Set(['GET', 'POST']);
|
|
const TERMINAL_COOLIFY_SUCCESS = new Set(['finished', 'success', 'succeeded', 'completed', 'healthy']);
|
|
const TERMINAL_COOLIFY_FAILURE = new Set(['failed', 'error', 'cancelled', 'canceled', 'stopped']);
|
|
const MASKED_SECRET = '••••••••';
|
|
|
|
function fail(message, code = 'DEPLOYMENT_CONFIGURATION_INVALID', status = 400, details = null) {
|
|
throw new PlatformError(message, { code, status, expose: true, details });
|
|
}
|
|
|
|
function clean(value, max = 1000) {
|
|
const text = String(value ?? '').trim();
|
|
if (text.length > max) fail('Deployment-Einstellung ist zu lang.');
|
|
if (/\r|\n|\0/.test(text)) fail('Deployment-Einstellung enthält unzulässige Steuerzeichen.');
|
|
return text;
|
|
}
|
|
|
|
function cleanUuid(value) {
|
|
const text = clean(value, 120);
|
|
if (text && !/^[a-zA-Z0-9_-]{6,120}$/.test(text)) fail('Coolify Resource UUID besitzt ein ungültiges Format.');
|
|
return text;
|
|
}
|
|
|
|
function cleanIdempotencyKey(value) {
|
|
const text = clean(value || randomUUID(), 160);
|
|
if (!/^[a-zA-Z0-9._:-]{8,160}$/.test(text)) fail('Idempotency-Key besitzt ein ungültiges Format.', 'IDEMPOTENCY_KEY_INVALID');
|
|
return text;
|
|
}
|
|
|
|
function normalizeBaseUrl(value) {
|
|
const text = clean(value, 1000).replace(/\/$/, '');
|
|
if (!text) return '';
|
|
let url;
|
|
try { url = new URL(text); } catch { fail('Coolify URL ist ungültig.'); }
|
|
if (!['https:', 'http:'].includes(url.protocol)) fail('Coolify URL muss HTTP oder HTTPS verwenden.');
|
|
if (url.username || url.password || url.search || url.hash) fail('Coolify URL darf keine Zugangsdaten, Query oder Fragment enthalten.');
|
|
if (process.env.NODE_ENV === 'production' && url.protocol !== 'https:' && !/^(1|true|yes)$/i.test(String(process.env.VENDOO_ALLOW_INSECURE_COOLIFY || 'false'))) {
|
|
fail('In Produktion muss die Coolify URL HTTPS verwenden.');
|
|
}
|
|
return url.toString().replace(/\/$/, '');
|
|
}
|
|
|
|
function normalizeWebhookUrl(value) {
|
|
const text = clean(value, 2000);
|
|
if (!text) return '';
|
|
let url;
|
|
try { url = new URL(text); } catch { fail('Coolify Deploy-Webhook ist ungültig.'); }
|
|
if (!['https:', 'http:'].includes(url.protocol)) fail('Coolify Deploy-Webhook muss HTTP oder HTTPS verwenden.');
|
|
if (url.username || url.password || url.hash) fail('Coolify Deploy-Webhook darf keine URL-Zugangsdaten oder Fragmente enthalten.');
|
|
if (process.env.NODE_ENV === 'production' && url.protocol !== 'https:' && !/^(1|true|yes)$/i.test(String(process.env.VENDOO_ALLOW_INSECURE_COOLIFY || 'false'))) {
|
|
fail('In Produktion muss der Coolify Deploy-Webhook HTTPS verwenden.');
|
|
}
|
|
return url.toString();
|
|
}
|
|
|
|
function normalizeEndpoint(value, fallback, placeholders = []) {
|
|
const text = clean(value || fallback, 500);
|
|
if (!text.startsWith('/') || text.startsWith('//') || text.includes('..')) fail('Coolify API-Endpunkt muss ein sicherer relativer Pfad sein.');
|
|
if (/\r|\n|\0/.test(text)) fail('Coolify API-Endpunkt ist ungültig.');
|
|
const unknown = [...text.matchAll(/\{([^}]+)\}/g)].map(match => match[1]).filter(name => !placeholders.includes(name));
|
|
if (unknown.length) fail(`Unbekannter Platzhalter im Coolify-Endpunkt: ${unknown.join(', ')}`);
|
|
return text;
|
|
}
|
|
|
|
function boolEnv(name, fallback = false) {
|
|
const raw = process.env[name];
|
|
if (raw === undefined || raw === '') return fallback;
|
|
return /^(1|true|yes|on)$/i.test(String(raw));
|
|
}
|
|
|
|
function intEnv(name, fallback, min, max) {
|
|
const value = Number(process.env[name]);
|
|
return Number.isFinite(value) ? Math.max(min, Math.min(max, Math.trunc(value))) : fallback;
|
|
}
|
|
|
|
function buildRevision() {
|
|
return clean(process.env.VENDOO_BUILD_REVISION || process.env.GIT_COMMIT || process.env.SOURCE_COMMIT || '', 160) || null;
|
|
}
|
|
|
|
function currentSettings() {
|
|
const provider = PROVIDERS.has(process.env.VENDOO_DEPLOY_PROVIDER) ? process.env.VENDOO_DEPLOY_PROVIDER : 'none';
|
|
const mode = MODES.has(process.env.COOLIFY_TRIGGER_MODE) ? process.env.COOLIFY_TRIGGER_MODE : 'webhook';
|
|
const channel = CHANNELS.has(process.env.VENDOO_UPDATE_CHANNEL) ? process.env.VENDOO_UPDATE_CHANNEL : 'stable';
|
|
const method = METHODS.has(process.env.COOLIFY_DEPLOY_METHOD) ? process.env.COOLIFY_DEPLOY_METHOD : 'GET';
|
|
return {
|
|
provider,
|
|
coolify_url: String(process.env.COOLIFY_API_URL || '').replace(/\/$/, ''),
|
|
resource_uuid: String(process.env.COOLIFY_RESOURCE_UUID || ''),
|
|
trigger_mode: mode,
|
|
deploy_method: method,
|
|
deploy_endpoint: String(process.env.COOLIFY_DEPLOY_ENDPOINT || '/api/v1/deploy?uuid={uuid}&force={force}'),
|
|
deployment_status_endpoint: String(process.env.COOLIFY_DEPLOYMENT_STATUS_ENDPOINT || '/api/v1/deployments/{deployment_id}'),
|
|
github_repository: String(process.env.VENDOO_GITHUB_REPOSITORY || 'Masterluke77/vendoo'),
|
|
github_branch: String(process.env.VENDOO_GITHUB_BRANCH || 'main'),
|
|
ghcr_image: String(process.env.VENDOO_GHCR_IMAGE || 'ghcr.io/masterluke77/vendoo'),
|
|
update_channel: channel,
|
|
backup_before_deploy: !/^(0|false|no)$/i.test(String(process.env.VENDOO_BACKUP_BEFORE_DEPLOY || 'true')),
|
|
backup_uploads: boolEnv('VENDOO_DEPLOY_BACKUP_UPLOADS', false),
|
|
deployment_timeout_seconds: intEnv('VENDOO_DEPLOY_TIMEOUT_SECONDS', 900, 60, 7200),
|
|
health_poll_seconds: intEnv('VENDOO_DEPLOY_HEALTH_POLL_SECONDS', 15, 5, 300),
|
|
api_token: getSecretMetadata('COOLIFY_API_TOKEN'),
|
|
deploy_webhook: getSecretMetadata('COOLIFY_DEPLOY_WEBHOOK_URL'),
|
|
};
|
|
}
|
|
|
|
function configuredState(settings) {
|
|
return settings.provider === 'coolify' && (
|
|
(settings.trigger_mode === 'webhook' && settings.deploy_webhook.configured) ||
|
|
(settings.trigger_mode === 'api' && settings.coolify_url && settings.resource_uuid && settings.api_token.configured)
|
|
);
|
|
}
|
|
|
|
function publicSettings(repository = deploymentRepository) {
|
|
const settings = currentSettings();
|
|
return {
|
|
...settings,
|
|
configured: configuredState(settings),
|
|
api_token: { configured: settings.api_token.configured, masked: settings.api_token.masked, provider: settings.api_token.provider },
|
|
deploy_webhook: { configured: settings.deploy_webhook.configured, masked: settings.deploy_webhook.masked, provider: settings.deploy_webhook.provider },
|
|
current_version: APP_VERSION,
|
|
build_revision: buildRevision(),
|
|
active_run: repository.getActiveRun(),
|
|
recent_runs: repository.listRuns(10).map(run => ({ ...run, events: repository.listEvents(run.id, 50) })),
|
|
docker_socket_access: false,
|
|
};
|
|
}
|
|
|
|
function buildApiUrl(settings, template, replacements) {
|
|
let path = template;
|
|
for (const [name, value] of Object.entries(replacements)) path = path.replaceAll(`{${name}}`, encodeURIComponent(String(value ?? '')));
|
|
return `${settings.coolify_url}${path}`;
|
|
}
|
|
|
|
function buildApiDeployUrl(settings, force) {
|
|
const endpoint = normalizeEndpoint(settings.deploy_endpoint, '/api/v1/deploy?uuid={uuid}&force={force}', ['uuid', 'force']);
|
|
return buildApiUrl(settings, endpoint, { uuid: settings.resource_uuid, force: force ? 'true' : 'false' });
|
|
}
|
|
|
|
function buildApiStatusUrl(settings, deploymentId) {
|
|
const endpoint = normalizeEndpoint(settings.deployment_status_endpoint, '/api/v1/deployments/{deployment_id}', ['deployment_id']);
|
|
return buildApiUrl(settings, endpoint, { deployment_id: deploymentId });
|
|
}
|
|
|
|
async function fetchWithTimeout(fetchImpl, url, options = {}, timeoutMs = 15000) {
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
try { return await fetchImpl(url, { ...options, signal: controller.signal, redirect: 'error' }); }
|
|
catch (error) {
|
|
if (error?.name === 'AbortError') fail('Anfrage hat das Zeitlimit überschritten.', 'REQUEST_TIMEOUT', 504);
|
|
fail(`Ziel ist nicht erreichbar: ${error.message}`, 'REMOTE_UNREACHABLE', 502);
|
|
} finally { clearTimeout(timeout); }
|
|
}
|
|
|
|
async function readSafeResponse(response) {
|
|
const text = (await response.text()).slice(0, 64 * 1024);
|
|
try { return text ? JSON.parse(text) : null; } catch { return text || null; }
|
|
}
|
|
|
|
function redact(value, depth = 0) {
|
|
if (depth > 8) return '[depth-limit]';
|
|
if (Array.isArray(value)) return value.slice(0, 100).map(item => redact(item, depth + 1));
|
|
if (!value || typeof value !== 'object') return typeof value === 'string' ? value.slice(0, 4000) : value;
|
|
const result = {};
|
|
for (const [key, item] of Object.entries(value)) {
|
|
if (/token|secret|password|authorization|webhook|credential|key/i.test(key)) result[key] = '[redacted]';
|
|
else result[key] = redact(item, depth + 1);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function extractDeploymentId(value, depth = 0) {
|
|
if (depth > 6 || value == null) return null;
|
|
if (Array.isArray(value)) {
|
|
for (const item of value) { const found = extractDeploymentId(item, depth + 1); if (found) return found; }
|
|
return null;
|
|
}
|
|
if (typeof value !== 'object') return null;
|
|
for (const key of ['deployment_id', 'deployment_uuid', 'deploymentUuid', 'uuid', 'id']) {
|
|
const candidate = value[key];
|
|
if (candidate && /^[a-zA-Z0-9_-]{4,160}$/.test(String(candidate))) return String(candidate);
|
|
}
|
|
for (const item of Object.values(value)) { const found = extractDeploymentId(item, depth + 1); if (found) return found; }
|
|
return null;
|
|
}
|
|
|
|
function extractRemoteStatus(value) {
|
|
if (!value || typeof value !== 'object') return '';
|
|
const candidates = [value.status, value.state, value.deployment_status, value.data?.status, value.data?.state];
|
|
return String(candidates.find(Boolean) || '').trim().toLowerCase();
|
|
}
|
|
|
|
function semver(value) {
|
|
const match = String(value || '').trim().match(/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/);
|
|
if (!match) return null;
|
|
return { raw: String(value).replace(/^v/, ''), major: +match[1], minor: +match[2], patch: +match[3], prerelease: match[4] || '' };
|
|
}
|
|
|
|
function compareVersions(a, b) {
|
|
const left = semver(a); const right = semver(b);
|
|
if (!left || !right) fail('Update-Manifest enthält eine ungültige SemVer-Version.', 'UPDATE_VERSION_INVALID', 502);
|
|
for (const key of ['major', 'minor', 'patch']) if (left[key] !== right[key]) return left[key] > right[key] ? 1 : -1;
|
|
if (left.prerelease === right.prerelease) return 0;
|
|
if (!left.prerelease) return 1;
|
|
if (!right.prerelease) return -1;
|
|
return left.prerelease.localeCompare(right.prerelease, undefined, { numeric: true });
|
|
}
|
|
|
|
function validateUpdateResult(result, channel) {
|
|
if (!result?.configured) return { ...result, current_version: APP_VERSION, channel };
|
|
const latest = String(result.latest_version || result.version || result.manifest?.version || '');
|
|
const parsed = semver(latest);
|
|
if (!parsed) fail('Update-Manifest enthält keine gültige SemVer-Version.', 'UPDATE_VERSION_INVALID', 502);
|
|
if (channel === 'stable' && parsed.prerelease) fail('Der Stable-Kanal akzeptiert keine Vorabversion.', 'UPDATE_CHANNEL_MISMATCH', 502);
|
|
const manifest = result.manifest && typeof result.manifest === 'object' ? result.manifest : {};
|
|
const manifestChannel = String(manifest.channel || channel).toLowerCase();
|
|
if (!CHANNELS.has(manifestChannel) || (channel === 'stable' && manifestChannel !== 'stable')) {
|
|
fail('Update-Manifest gehört nicht zum konfigurierten Update-Kanal.', 'UPDATE_CHANNEL_MISMATCH', 502);
|
|
}
|
|
const updateAvailable = compareVersions(latest, APP_VERSION) > 0;
|
|
const sha256 = String(manifest.sha256 || manifest.package_sha256 || result.sha256 || '').trim().toLowerCase();
|
|
if (updateAvailable && !/^[a-f0-9]{64}$/.test(sha256)) fail('Update-Manifest enthält keine gültige SHA-256-Prüfsumme.', 'UPDATE_CHECKSUM_MISSING', 502);
|
|
const downloadUrl = String(manifest.download_url || manifest.package_url || result.download_url || '').trim();
|
|
if (downloadUrl) {
|
|
let url;
|
|
try { url = new URL(downloadUrl); } catch { fail('Update-Manifest enthält eine ungültige Paket-URL.', 'UPDATE_URL_INVALID', 502); }
|
|
if (url.protocol !== 'https:') fail('Update-Pakete müssen über HTTPS bereitgestellt werden.', 'UPDATE_URL_INSECURE', 502);
|
|
}
|
|
return {
|
|
...result,
|
|
configured: true,
|
|
current_version: APP_VERSION,
|
|
latest_version: parsed.raw,
|
|
update_available: updateAvailable,
|
|
channel,
|
|
manifest: { ...manifest, sha256, download_url: downloadUrl || '' },
|
|
};
|
|
}
|
|
|
|
function directorySize(root, maxEntries = 100000) {
|
|
let bytes = 0; let files = 0; let truncated = false;
|
|
const queue = [root];
|
|
while (queue.length) {
|
|
const current = queue.pop();
|
|
if (!existsSync(current)) continue;
|
|
let entries;
|
|
try { entries = readdirSync(current, { withFileTypes: true }); } catch { continue; }
|
|
for (const entry of entries) {
|
|
if (++files > maxEntries) { truncated = true; queue.length = 0; break; }
|
|
const absolute = join(current, entry.name);
|
|
try {
|
|
if (entry.isDirectory()) queue.push(absolute);
|
|
else if (entry.isFile()) bytes += statSync(absolute).size;
|
|
} catch {}
|
|
}
|
|
}
|
|
return { path: root, bytes, files, truncated };
|
|
}
|
|
|
|
function fluxStatus(getPlatformSnapshot) {
|
|
try {
|
|
const module = getPlatformSnapshot?.()?.modules?.find(item => item.id === 'vendoo.flux-studio');
|
|
if (module) return { enabled: Boolean(module.enabled), state: module.state, source: 'platform-kernel' };
|
|
} catch {}
|
|
try {
|
|
if (existsSync(MODULE_STATE_PATH)) {
|
|
const state = JSON.parse(readFileSync(MODULE_STATE_PATH, 'utf8'));
|
|
const record = state?.modules?.['vendoo.flux-studio'];
|
|
if (record) return { enabled: record.enabled !== false, state: record.enabled === false ? 'disabled' : 'configured', source: 'module-state' };
|
|
}
|
|
} catch {}
|
|
const initiallyDisabled = String(process.env.VENDOO_INITIAL_DISABLED_MODULES || '').split(',').map(item => item.trim()).includes('vendoo.flux-studio');
|
|
return { enabled: !initiallyDisabled, state: initiallyDisabled ? 'disabled' : 'default-enabled', source: 'environment-default' };
|
|
}
|
|
|
|
function internalReadiness() {
|
|
const checks = [];
|
|
try {
|
|
const value = db.prepare('SELECT 1 AS ok').get();
|
|
checks.push({ name: 'database', ok: Number(value?.ok) === 1 });
|
|
} catch (error) { checks.push({ name: 'database', ok: false, error: error.message }); }
|
|
for (const item of verifyRuntimeWritable()) checks.push({ name: 'filesystem', ok: item.writable, path: item.path, error: item.error || null });
|
|
return { ok: checks.every(item => item.ok), checks };
|
|
}
|
|
|
|
async function probePublicReadiness(fetchImpl, timeoutMs = 8000) {
|
|
const base = String(process.env.PUBLIC_BASE_URL || `http://127.0.0.1:${process.env.PORT || 8124}`).replace(/\/$/, '');
|
|
const response = await fetchWithTimeout(fetchImpl, `${base}/readyz`, { headers: { Accept: 'application/json', 'User-Agent': `Vendoo/${APP_VERSION}` } }, timeoutMs);
|
|
const body = await readSafeResponse(response);
|
|
return { ok: response.ok && body?.ok !== false, http_status: response.status, version: body?.version || null, body: redact(body) };
|
|
}
|
|
|
|
function productionChecks({ getPlatformSnapshot } = {}) {
|
|
const results = [];
|
|
const add = (id, status, title, message, details = {}) => results.push({ id, status, title, message, details });
|
|
const publicBase = String(process.env.PUBLIC_BASE_URL || '').trim();
|
|
let publicUrl = null;
|
|
try { publicUrl = publicBase ? new URL(publicBase) : null; } catch {}
|
|
add('https', publicUrl?.protocol === 'https:' ? 'pass' : 'fail', 'Öffentliche HTTPS-URL', publicUrl?.protocol === 'https:' ? publicUrl.origin : 'PUBLIC_BASE_URL fehlt oder verwendet nicht HTTPS.');
|
|
const secureCookieMode = String(process.env.VENDOO_COOKIE_SECURE || 'auto').trim().toLowerCase();
|
|
const secureCookies = secureCookieMode === 'true' || (secureCookieMode === 'auto' && publicUrl?.protocol === 'https:');
|
|
add('secure-cookies', secureCookies ? 'pass' : 'fail', 'Sichere Cookies', secureCookies ? `Secure-Cookies sind aktiv (${secureCookieMode}).` : 'VENDOO_COOKIE_SECURE ist nicht aktiv oder Auto-Erkennung hat keine HTTPS-URL.');
|
|
add('trust-proxy', boolEnv('VENDOO_TRUST_PROXY', false) ? 'pass' : 'warning', 'Trust Proxy', boolEnv('VENDOO_TRUST_PROXY', false) ? 'Reverse-Proxy-Vertrauen ist aktiviert.' : 'VENDOO_TRUST_PROXY ist nicht aktiv.');
|
|
|
|
const expectedHost = publicUrl?.host || '';
|
|
const hosts = String(process.env.VENDOO_ALLOWED_HOSTS || '').split(',').map(item => item.trim()).filter(Boolean);
|
|
const origins = String(process.env.VENDOO_ALLOWED_ORIGINS || '').split(',').map(item => item.trim()).filter(Boolean);
|
|
add('allowed-host', expectedHost && hosts.includes(expectedHost) ? 'pass' : 'fail', 'Allowed Host', expectedHost && hosts.includes(expectedHost) ? expectedHost : 'Produktivhost fehlt in VENDOO_ALLOWED_HOSTS.', { expected: expectedHost, configured_count: hosts.length });
|
|
add('allowed-origin', publicUrl?.origin && origins.includes(publicUrl.origin) ? 'pass' : 'fail', 'Allowed Origin', publicUrl?.origin && origins.includes(publicUrl.origin) ? publicUrl.origin : 'Produktivorigin fehlt in VENDOO_ALLOWED_ORIGINS.', { expected: publicUrl?.origin || '', configured_count: origins.length });
|
|
|
|
const persistent = DATA_ROOT !== APP_ROOT && dirname(DB_PATH).startsWith(DATA_ROOT) && UPLOADS_DIR.startsWith(DATA_ROOT) && BACKUP_DIR.startsWith(DATA_ROOT);
|
|
add('persistent-paths', persistent ? 'pass' : 'fail', 'Persistente Pfade', persistent ? `Daten liegen unter ${DATA_ROOT}.` : 'Mindestens ein Laufzeitpfad liegt nicht im persistenten Datenbereich.', runtimePathSummary());
|
|
const writable = verifyRuntimeWritable();
|
|
add('writable', writable.every(item => item.writable) ? 'pass' : 'fail', 'Schreibrechte', writable.every(item => item.writable) ? 'Alle Laufzeitpfade sind beschreibbar.' : 'Mindestens ein Laufzeitpfad ist nicht beschreibbar.', { paths: writable });
|
|
|
|
let integrity = 'unknown';
|
|
try { integrity = String(db.pragma('integrity_check', { simple: true })); } catch (error) { integrity = error.message; }
|
|
add('sqlite-integrity', integrity === 'ok' ? 'pass' : 'fail', 'SQLite-Integrität', integrity === 'ok' ? 'SQLite meldet ok.' : `SQLite-Integritätsprüfung: ${integrity}`);
|
|
add('backup-target', existsSync(BACKUP_DIR) ? 'pass' : 'fail', 'Backupziel', existsSync(BACKUP_DIR) ? BACKUP_DIR : 'Backupziel fehlt.');
|
|
|
|
const flux = fluxStatus(getPlatformSnapshot);
|
|
add('flux-disabled', flux.enabled ? 'fail' : 'pass', 'FLUX Studio deaktiviert', flux.enabled ? 'FLUX Studio ist aktiv und widerspricht dem Produktions-Scope.' : 'FLUX Studio ist deaktiviert.', flux);
|
|
const settings = currentSettings();
|
|
add('deployment-secret', configuredState(settings) ? 'pass' : 'warning', 'Coolify-Zugang', configuredState(settings) ? 'Deployment-Zugang ist gesetzt; Secretwerte werden nicht angezeigt.' : 'Coolify-Deployment ist noch nicht vollständig konfiguriert.', {
|
|
mode: settings.trigger_mode,
|
|
api_token_configured: settings.api_token.configured,
|
|
webhook_configured: settings.deploy_webhook.configured,
|
|
});
|
|
const autoDeploy = String(process.env.VENDOO_COOLIFY_AUTO_DEPLOY || '').trim().toLowerCase();
|
|
add('auto-deploy', ['false', '0', 'off', 'no'].includes(autoDeploy) ? 'pass' : 'warning', 'Coolify Auto Deploy', ['false', '0', 'off', 'no'].includes(autoDeploy) ? 'Auto Deploy ist als deaktiviert dokumentiert.' : 'Vendoo kann den Coolify-Schalter nicht sicher auslesen. Auto Deploy manuell deaktiviert lassen.', { configured: autoDeploy || 'unknown' });
|
|
return results;
|
|
}
|
|
|
|
export function createDeploymentService(adapters = {}) {
|
|
const {
|
|
createBackup,
|
|
verifyStoredBackup,
|
|
checkForUpdates,
|
|
repository = deploymentRepository,
|
|
fetchImpl = globalThis.fetch,
|
|
getPlatformSnapshot,
|
|
} = adapters;
|
|
if (typeof createBackup !== 'function') fail('Backup-Adapter fehlt.', 'DEPLOYMENT_ADAPTER_MISSING', 500);
|
|
if (typeof verifyStoredBackup !== 'function') fail('Backup-Verifikationsadapter fehlt.', 'DEPLOYMENT_ADAPTER_MISSING', 500);
|
|
if (typeof fetchImpl !== 'function') fail('Fetch-Adapter fehlt.', 'DEPLOYMENT_ADAPTER_MISSING', 500);
|
|
|
|
let monitorTimer = null;
|
|
let monitoring = false;
|
|
|
|
async function checkUpdates() {
|
|
if (typeof checkForUpdates !== 'function') return { configured: false, current_version: APP_VERSION, message: 'Updateprüfung ist nicht verfügbar.' };
|
|
return validateUpdateResult(await checkForUpdates({ channel: currentSettings().update_channel }), currentSettings().update_channel);
|
|
}
|
|
|
|
async function testConnection() {
|
|
const settings = currentSettings();
|
|
if (settings.provider !== 'coolify') fail('Coolify ist nicht als Deployment-Provider aktiviert.');
|
|
if (!settings.coolify_url) fail('Coolify URL fehlt.');
|
|
const headers = { Accept: 'application/json', 'User-Agent': `Vendoo/${APP_VERSION}` };
|
|
if (settings.trigger_mode === 'api') {
|
|
const token = getSecret('COOLIFY_API_TOKEN');
|
|
if (!token) fail('Coolify API-Token fehlt.');
|
|
headers.Authorization = `Bearer ${token}`;
|
|
}
|
|
const response = await fetchWithTimeout(fetchImpl, `${settings.coolify_url}/api/health`, { headers }, 10000);
|
|
const body = await readSafeResponse(response);
|
|
if (!response.ok) fail(`Coolify Healthcheck antwortet mit HTTP ${response.status}.`, 'COOLIFY_HEALTH_FAILED', 502, { response: redact(body) });
|
|
return { ok: true, status: response.status, response: redact(body), url: settings.coolify_url };
|
|
}
|
|
|
|
async function requestCoolify(settings, force) {
|
|
let url;
|
|
const headers = { Accept: 'application/json', 'User-Agent': `Vendoo/${APP_VERSION}` };
|
|
const method = settings.deploy_method;
|
|
if (settings.trigger_mode === 'webhook') {
|
|
url = normalizeWebhookUrl(getSecret('COOLIFY_DEPLOY_WEBHOOK_URL'));
|
|
if (!url) fail('Coolify Deploy-Webhook ist nicht konfiguriert.');
|
|
} else {
|
|
const token = getSecret('COOLIFY_API_TOKEN');
|
|
if (!settings.coolify_url || !settings.resource_uuid || !token) fail('Coolify API URL, Resource UUID oder API Token fehlt.');
|
|
url = buildApiDeployUrl(settings, Boolean(force));
|
|
headers.Authorization = `Bearer ${token}`;
|
|
}
|
|
const response = await fetchWithTimeout(fetchImpl, url, { method, headers }, 20000);
|
|
const body = await readSafeResponse(response);
|
|
if (!response.ok) fail(`Coolify hat den Deployment-Auftrag mit HTTP ${response.status} abgewiesen.`, 'COOLIFY_DEPLOY_REJECTED', 502, { response: redact(body) });
|
|
return { method, http_status: response.status, body: redact(body), deployment_id: extractDeploymentId(body) };
|
|
}
|
|
|
|
async function trigger(input = {}, context = {}) {
|
|
if (input.confirmation !== 'DEPLOY') fail('Zur Bestätigung muss exakt DEPLOY übermittelt werden.', 'DEPLOYMENT_CONFIRMATION_REQUIRED');
|
|
const settings = currentSettings();
|
|
if (!configuredState(settings)) fail('Coolify ist nicht vollständig konfiguriert.', 'DEPLOYMENT_NOT_CONFIGURED');
|
|
const targetVersion = clean(input.target_version || APP_VERSION, 40).replace(/^v/, '');
|
|
if (!semver(targetVersion)) fail('Zielversion ist keine gültige SemVer-Version.', 'DEPLOYMENT_TARGET_VERSION_INVALID');
|
|
const idempotencyKey = cleanIdempotencyKey(input.idempotency_key || context.idempotencyKey);
|
|
const existing = repository.findByIdempotencyKey(idempotencyKey);
|
|
if (existing) return { ...existing, reused: true, events: repository.listEvents(existing.id) };
|
|
|
|
if (compareVersions(targetVersion, APP_VERSION) > 0) {
|
|
const update = await checkUpdates();
|
|
if (!update.configured || update.latest_version !== targetVersion || !update.update_available) {
|
|
fail('Zielversion ist nicht durch das freigegebene Update-Manifest bestätigt.', 'DEPLOYMENT_TARGET_NOT_APPROVED', 409, { target_version: targetVersion, update });
|
|
}
|
|
}
|
|
|
|
const timeoutAt = new Date(Date.now() + settings.deployment_timeout_seconds * 1000).toISOString();
|
|
const created = repository.createRun({
|
|
idempotencyKey,
|
|
provider: settings.provider,
|
|
triggerMode: settings.trigger_mode,
|
|
currentVersion: APP_VERSION,
|
|
targetVersion,
|
|
buildRevisionBefore: buildRevision(),
|
|
requestedBy: context.userId || null,
|
|
reason: clean(input.reason || 'manual', 120),
|
|
githubRepository: settings.github_repository,
|
|
githubBranch: settings.github_branch,
|
|
timeoutAt,
|
|
request: { force: Boolean(input.force), target_version: targetVersion, backup_required: settings.backup_before_deploy },
|
|
});
|
|
if (created.reused) return { ...created.run, reused: true, events: repository.listEvents(created.run.id) };
|
|
let run = created.run;
|
|
|
|
try {
|
|
if (settings.backup_before_deploy) {
|
|
run = repository.transition(run.id, 'backup_pending', { message: 'Vorab-Backup ist verpflichtend.' });
|
|
run = repository.transition(run.id, 'backup_running', { message: 'Vorab-Backup wird erstellt.' });
|
|
const backup = await createBackup({
|
|
kind: 'manual', includeUploads: settings.backup_uploads, includeConfig: true,
|
|
label: `pre-deploy-${APP_VERSION}-to-${targetVersion}`,
|
|
});
|
|
const verification = verifyStoredBackup(backup.id);
|
|
repository.recordBackupVerification({
|
|
backupId: backup.id,
|
|
runId: run.id,
|
|
status: verification.ok ? 'verified' : 'invalid',
|
|
sha256: backup.sha256,
|
|
fileSize: backup.file_size,
|
|
sqliteIntegrity: verification.ok ? 'ok' : 'failed',
|
|
manifest: verification.manifest || backup.manifest || {},
|
|
errors: verification.errors || [],
|
|
warnings: verification.warnings || [],
|
|
});
|
|
if (!verification.ok) fail(`Vorab-Backup ist nicht verifiziert: ${(verification.errors || []).join('; ')}`, 'DEPLOYMENT_BACKUP_INVALID', 409);
|
|
run = repository.transition(run.id, 'backup_verified', {
|
|
message: 'Vorab-Backup wurde erstellt und verifiziert.',
|
|
patch: { backup_id: backup.id },
|
|
details: { backup_id: backup.id, sha256: backup.sha256, file_size: backup.file_size },
|
|
});
|
|
}
|
|
|
|
run = repository.transition(run.id, 'deploy_requested', { message: 'Coolify-Deployment wird angefordert.' });
|
|
const remote = await requestCoolify(settings, Boolean(input.force));
|
|
run = repository.transition(run.id, settings.trigger_mode === 'api' ? 'deploy_running' : 'health_wait', {
|
|
message: settings.trigger_mode === 'api' ? 'Coolify hat den Deployment-Auftrag angenommen.' : 'Webhook wurde angenommen; externe Bestätigung oder neue Zielversion wird abgewartet.',
|
|
eventType: 'coolify.accepted',
|
|
patch: { deployment_id: remote.deployment_id || null, result_json: remote },
|
|
details: { http_status: remote.http_status, deployment_id: remote.deployment_id || null },
|
|
});
|
|
scheduleMonitor(1000);
|
|
return { ...run, reused: false, events: repository.listEvents(run.id), message: 'Deployment-Auftrag angenommen. Erfolg wird erst nach Status- und Readinessprüfung gemeldet.' };
|
|
} catch (error) {
|
|
const latest = repository.getRun(run.id);
|
|
if (latest && !['failed', 'succeeded'].includes(latest.state)) {
|
|
try {
|
|
repository.transition(run.id, 'failed', {
|
|
message: error.message || 'Deployment-Auftrag ist fehlgeschlagen.',
|
|
eventType: 'run.failed',
|
|
patch: { error_code: error.code || 'DEPLOYMENT_FAILED', error_message: String(error.message || error).slice(0, 2000) },
|
|
});
|
|
} catch {}
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function pollCoolifyStatus(run, settings) {
|
|
if (settings.trigger_mode !== 'api' || !run.deployment_id || !settings.coolify_url) return { available: false };
|
|
const token = getSecret('COOLIFY_API_TOKEN');
|
|
if (!token) return { available: false, error: 'API-Token fehlt.' };
|
|
const url = buildApiStatusUrl(settings, run.deployment_id);
|
|
const response = await fetchWithTimeout(fetchImpl, url, {
|
|
headers: { Accept: 'application/json', Authorization: `Bearer ${token}`, 'User-Agent': `Vendoo/${APP_VERSION}` },
|
|
}, 10000);
|
|
const body = await readSafeResponse(response);
|
|
if (!response.ok) return { available: true, ok: false, http_status: response.status, body: redact(body) };
|
|
const status = extractRemoteStatus(body);
|
|
return { available: true, ok: true, http_status: response.status, status, body: redact(body) };
|
|
}
|
|
|
|
async function refreshRun(runId = null, { externalConfirmed = false } = {}) {
|
|
const run = runId ? repository.getRun(runId) : repository.getActiveRun();
|
|
if (!run) return { state: 'idle', message: 'Kein aktives Deployment.' };
|
|
if (['succeeded', 'failed', 'rollback_required'].includes(run.state)) return { ...run, events: repository.listEvents(run.id) };
|
|
if (run.timeout_at && Date.parse(run.timeout_at) <= Date.now()) {
|
|
const timedOut = repository.transition(run.id, 'rollback_required', {
|
|
message: 'Deployment-Zeitlimit überschritten. Rollback oder manuelle Prüfung erforderlich.',
|
|
eventType: 'run.timeout',
|
|
patch: { error_code: 'DEPLOYMENT_TIMEOUT', error_message: 'Zeitlimit überschritten.' },
|
|
});
|
|
return { ...timedOut, events: repository.listEvents(run.id) };
|
|
}
|
|
|
|
const settings = currentSettings();
|
|
let remote = { available: false };
|
|
try { remote = await pollCoolifyStatus(run, settings); }
|
|
catch (error) {
|
|
repository.patchRun(run.id, {}, { eventType: 'coolify.status_error', message: 'Coolify-Status konnte nicht gelesen werden.', details: { error: error.message } });
|
|
}
|
|
if (remote.status && TERMINAL_COOLIFY_FAILURE.has(remote.status)) {
|
|
const failed = repository.transition(run.id, 'rollback_required', {
|
|
message: `Coolify meldet den Fehlerstatus ${remote.status}.`,
|
|
eventType: 'coolify.failed',
|
|
patch: { result_json: remote, error_code: 'COOLIFY_DEPLOYMENT_FAILED', error_message: `Coolify-Status: ${remote.status}` },
|
|
});
|
|
return { ...failed, remote, events: repository.listEvents(run.id) };
|
|
}
|
|
if (run.state === 'deploy_running' && remote.available && remote.ok) {
|
|
repository.transition(run.id, 'health_wait', { message: 'Deploymentstatus wurde gelesen; Readiness und Zielversion werden geprüft.', eventType: 'health.wait', details: { remote_status: remote.status || 'unknown' } });
|
|
}
|
|
|
|
let readiness;
|
|
try { readiness = await probePublicReadiness(fetchImpl, 8000); }
|
|
catch (error) { readiness = { ok: false, error: error.message }; }
|
|
const targetMatches = readiness.ok && String(readiness.version || '').replace(/^v/, '') === String(run.target_version).replace(/^v/, '');
|
|
const remoteSucceeded = remote.available && remote.ok && TERMINAL_COOLIFY_SUCCESS.has(remote.status);
|
|
const persistedConfirmation = repository.listEvents(run.id).some(event => event.event_type === 'external.succeeded');
|
|
const confirmed = Boolean(externalConfirmed || persistedConfirmation);
|
|
const deploymentConfirmed = settings.trigger_mode === 'api' ? (remoteSucceeded || confirmed) : confirmed;
|
|
if (readiness.ok && targetMatches && deploymentConfirmed) {
|
|
const succeeded = repository.transition(run.id, 'succeeded', {
|
|
message: 'Deployment, Readiness und Zielversion wurden erfolgreich bestätigt.',
|
|
eventType: 'run.succeeded',
|
|
patch: { build_revision_after: buildRevision(), result_json: { remote, readiness } },
|
|
details: { target_version: run.target_version, readiness_version: readiness.version, remote_status: remote.status || null },
|
|
});
|
|
return { ...succeeded, remote, readiness, events: repository.listEvents(run.id) };
|
|
}
|
|
repository.patchRun(run.id, { result_json: { remote, readiness } }, {
|
|
eventType: 'health.pending',
|
|
message: readiness.ok ? 'Readiness ist grün, aber Zielversion oder externe Bestätigung fehlt.' : 'Readiness ist noch nicht grün.',
|
|
details: { readiness_ok: Boolean(readiness.ok), readiness_version: readiness.version || null, target_version: run.target_version, remote_status: remote.status || null, external_confirmation: confirmed, deployment_confirmed: deploymentConfirmed },
|
|
});
|
|
return { ...repository.getRun(run.id), remote, readiness, events: repository.listEvents(run.id) };
|
|
}
|
|
|
|
async function confirmExternal(runId, input = {}) {
|
|
if (input.confirmation !== 'CONFIRM') fail('Zur externen Bestätigung muss exakt CONFIRM übermittelt werden.', 'DEPLOYMENT_EXTERNAL_CONFIRMATION_REQUIRED');
|
|
const run = repository.getRun(runId);
|
|
if (!run) fail('Deployment-Auftrag wurde nicht gefunden.', 'DEPLOYMENT_RUN_NOT_FOUND', 404);
|
|
if (!['deploy_requested', 'deploy_running', 'health_wait', 'rollback_required'].includes(run.state)) fail('Dieser Deployment-Auftrag kann nicht extern bestätigt werden.', 'DEPLOYMENT_CONFIRMATION_STATE_INVALID', 409);
|
|
const outcome = clean(input.outcome || 'succeeded', 20);
|
|
if (outcome === 'failed') {
|
|
const nextState = run.state === 'rollback_required' ? 'failed' : 'rollback_required';
|
|
return repository.transition(run.id, nextState, {
|
|
message: clean(input.message || 'Externes Deployment wurde als fehlgeschlagen bestätigt.', 500),
|
|
eventType: 'external.failed',
|
|
patch: { error_code: 'EXTERNAL_DEPLOYMENT_FAILED', error_message: clean(input.message || 'Externe Fehlermeldung.', 1000) },
|
|
});
|
|
}
|
|
if (outcome === 'running') {
|
|
if (run.state === 'deploy_requested') repository.transition(run.id, 'deploy_running', { message: 'Externes System bestätigt laufendes Deployment.', eventType: 'external.running' });
|
|
return refreshRun(run.id);
|
|
}
|
|
if (outcome !== 'succeeded') fail('Externe Bestätigung muss running, succeeded oder failed verwenden.');
|
|
repository.addEvent(run.id, run.state, 'external.succeeded', clean(input.message || 'Externes System meldet Abschluss.', 500), {});
|
|
return refreshRun(run.id, { externalConfirmed: true });
|
|
}
|
|
|
|
function scheduleMonitor(delayMs = null) {
|
|
if (monitorTimer) clearTimeout(monitorTimer);
|
|
const interval = currentSettings().health_poll_seconds * 1000;
|
|
monitorTimer = setTimeout(async () => {
|
|
if (monitoring) return scheduleMonitor(interval);
|
|
monitoring = true;
|
|
try { await refreshRun(); } catch (error) { console.error('Deployment-Monitor:', error.message); }
|
|
finally {
|
|
monitoring = false;
|
|
const active = repository.getActiveRun();
|
|
if (active && active.state !== 'rollback_required') scheduleMonitor(interval);
|
|
}
|
|
}, delayMs ?? interval);
|
|
monitorTimer.unref?.();
|
|
}
|
|
|
|
async function status() {
|
|
const settings = publicSettings(repository);
|
|
const lastBackup = db.prepare(`SELECT id, kind, label, file_name, file_size, sha256, status, verified_at, created_at
|
|
FROM operation_backups WHERE status = 'verified' ORDER BY created_at DESC LIMIT 1`).get() || null;
|
|
return {
|
|
...settings,
|
|
production: {
|
|
version: APP_VERSION,
|
|
build_revision: buildRevision(),
|
|
deployment_provider: settings.provider,
|
|
domain: (() => { try { return new URL(String(process.env.PUBLIC_BASE_URL || '')).host; } catch { return ''; } })(),
|
|
public_base_url: String(process.env.PUBLIC_BASE_URL || ''),
|
|
readiness: internalReadiness(),
|
|
database: { path: DB_PATH, integrity: (() => { try { return db.pragma('integrity_check', { simple: true }); } catch (error) { return error.message; } })() },
|
|
storage: {
|
|
data: directorySize(DATA_ROOT), uploads: directorySize(UPLOADS_DIR), backups: directorySize(BACKUP_DIR),
|
|
operations: directorySize(OPERATIONS_DIR), logs: directorySize(LOG_DIR), modules: directorySize(MODULES_DIR),
|
|
},
|
|
last_backup: lastBackup,
|
|
last_deployment: repository.listRuns(1)[0] || null,
|
|
flux: fluxStatus(getPlatformSnapshot),
|
|
},
|
|
latest_production_check: repository.latestProductionCheck(),
|
|
};
|
|
}
|
|
|
|
function update(input = {}) {
|
|
const provider = clean(input.provider || 'none', 20);
|
|
const mode = clean(input.trigger_mode || 'webhook', 20);
|
|
const channel = clean(input.update_channel || 'stable', 20);
|
|
const method = clean(input.deploy_method || 'GET', 10).toUpperCase();
|
|
if (!PROVIDERS.has(provider)) fail('Unbekannter Deployment-Provider.');
|
|
if (!MODES.has(mode)) fail('Unbekannter Coolify Trigger-Modus.');
|
|
if (!CHANNELS.has(channel)) fail('Unbekannter Update-Kanal.');
|
|
if (!METHODS.has(method)) fail('Coolify Deploy-Methode muss GET oder POST sein.');
|
|
|
|
const values = {
|
|
VENDOO_DEPLOY_PROVIDER: provider,
|
|
COOLIFY_API_URL: normalizeBaseUrl(input.coolify_url || ''),
|
|
COOLIFY_RESOURCE_UUID: cleanUuid(input.resource_uuid || ''),
|
|
COOLIFY_TRIGGER_MODE: mode,
|
|
COOLIFY_DEPLOY_METHOD: method,
|
|
COOLIFY_DEPLOY_ENDPOINT: normalizeEndpoint(input.deploy_endpoint, '/api/v1/deploy?uuid={uuid}&force={force}', ['uuid', 'force']),
|
|
COOLIFY_DEPLOYMENT_STATUS_ENDPOINT: normalizeEndpoint(input.deployment_status_endpoint, '/api/v1/deployments/{deployment_id}', ['deployment_id']),
|
|
VENDOO_GITHUB_REPOSITORY: clean(input.github_repository || 'Masterluke77/vendoo', 200),
|
|
VENDOO_GITHUB_BRANCH: clean(input.github_branch || 'main', 120),
|
|
VENDOO_GHCR_IMAGE: clean(input.ghcr_image || 'ghcr.io/masterluke77/vendoo', 300),
|
|
VENDOO_UPDATE_CHANNEL: channel,
|
|
VENDOO_BACKUP_BEFORE_DEPLOY: input.backup_before_deploy === false ? 'false' : 'true',
|
|
VENDOO_DEPLOY_BACKUP_UPLOADS: input.backup_uploads === true ? 'true' : 'false',
|
|
VENDOO_DEPLOY_TIMEOUT_SECONDS: String(Math.max(60, Math.min(7200, Number(input.deployment_timeout_seconds) || 900))),
|
|
VENDOO_DEPLOY_HEALTH_POLL_SECONDS: String(Math.max(5, Math.min(300, Number(input.health_poll_seconds) || 15))),
|
|
};
|
|
let content = readRuntimeConfig();
|
|
for (const [key, value] of Object.entries(values)) {
|
|
content = setConfigValue(content, key, value);
|
|
process.env[key] = value;
|
|
}
|
|
writeRuntimeConfig(content);
|
|
if (input.api_token !== undefined && input.api_token !== '' && input.api_token !== MASKED_SECRET) {
|
|
setSecret('COOLIFY_API_TOKEN', clean(input.api_token, 16384), { source: 'deployment-ui' });
|
|
}
|
|
if (input.deploy_webhook_url !== undefined && input.deploy_webhook_url !== '' && input.deploy_webhook_url !== MASKED_SECRET) {
|
|
setSecret('COOLIFY_DEPLOY_WEBHOOK_URL', normalizeWebhookUrl(input.deploy_webhook_url), { source: 'deployment-ui' });
|
|
}
|
|
return publicSettings(repository);
|
|
}
|
|
|
|
function runProductionCheck(context = {}) {
|
|
const results = productionChecks({ getPlatformSnapshot });
|
|
return repository.createProductionCheck({ createdBy: context.userId || null, results });
|
|
}
|
|
|
|
scheduleMonitor(2500);
|
|
|
|
return Object.freeze({
|
|
status,
|
|
update,
|
|
testConnection,
|
|
trigger,
|
|
checkUpdates,
|
|
refreshRun,
|
|
confirmExternal,
|
|
listRuns(limit) { return repository.listRuns(limit).map(run => ({ ...run, events: repository.listEvents(run.id) })); },
|
|
getRun(id) { const run = repository.getRun(id); return run ? { ...run, events: repository.listEvents(id) } : null; },
|
|
runProductionCheck,
|
|
latestProductionCheck: repository.latestProductionCheck,
|
|
health() {
|
|
const settings = publicSettings(repository);
|
|
return { ok: true, provider: settings.provider, configured: settings.configured, state: settings.active_run?.state || 'idle', docker_socket_access: false };
|
|
},
|
|
stop() { if (monitorTimer) clearTimeout(monitorTimer); monitorTimer = null; },
|
|
});
|
|
}
|