* docs: prepare Vendoo 1.41.2 git-ignore boundary hotfix * fix: track native source modules with root-anchored runtime ignores
245 lines
12 KiB
JavaScript
245 lines
12 KiB
JavaScript
import { mkdirSync, existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
import { dirname, join } from 'node:path';
|
|
import { readRuntimeConfig, setConfigValue, writeRuntimeConfig } from '../../../lib/config-store.mjs';
|
|
import { getSecret, getSecretMetadata, hasSecret, setSecret } from '../../../lib/secret-store.mjs';
|
|
import { OPERATIONS_DIR, APP_VERSION } from '../../../lib/runtime-paths.mjs';
|
|
import { PlatformError } from '../../kernel/errors.mjs';
|
|
|
|
const LAST_DEPLOYMENT_PATH = join(OPERATIONS_DIR, 'last-deployment-request.json');
|
|
const PROVIDERS = new Set(['none', 'coolify']);
|
|
const MODES = new Set(['webhook', 'api']);
|
|
const CHANNELS = new Set(['stable', 'beta']);
|
|
const METHODS = new Set(['GET', 'POST']);
|
|
|
|
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 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 readLastDeployment() {
|
|
try { return existsSync(LAST_DEPLOYMENT_PATH) ? JSON.parse(readFileSync(LAST_DEPLOYMENT_PATH, 'utf8')) : null; }
|
|
catch { return null; }
|
|
}
|
|
|
|
function writeLastDeployment(value) {
|
|
mkdirSync(dirname(LAST_DEPLOYMENT_PATH), { recursive: true });
|
|
const temporary = `${LAST_DEPLOYMENT_PATH}.${process.pid}.tmp`;
|
|
writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
renameSync(temporary, LAST_DEPLOYMENT_PATH);
|
|
}
|
|
|
|
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}'),
|
|
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: /^(1|true|yes)$/i.test(String(process.env.VENDOO_DEPLOY_BACKUP_UPLOADS || 'false')),
|
|
api_token: getSecretMetadata('COOLIFY_API_TOKEN'),
|
|
deploy_webhook: getSecretMetadata('COOLIFY_DEPLOY_WEBHOOK_URL'),
|
|
};
|
|
}
|
|
|
|
function publicSettings() {
|
|
const settings = currentSettings();
|
|
const configured = 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)
|
|
);
|
|
return {
|
|
...settings,
|
|
configured,
|
|
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,
|
|
last_deployment: readLastDeployment(),
|
|
docker_socket_access: false,
|
|
};
|
|
}
|
|
|
|
function buildApiDeployUrl(settings, force) {
|
|
const endpoint = settings.deploy_endpoint || '/api/v1/deploy?uuid={uuid}&force={force}';
|
|
if (!endpoint.startsWith('/')) fail('Coolify API-Endpunkt muss mit / beginnen.');
|
|
if (/\r|\n|\0/.test(endpoint) || endpoint.includes('..')) fail('Coolify API-Endpunkt ist ungültig.');
|
|
const path = endpoint
|
|
.replaceAll('{uuid}', encodeURIComponent(settings.resource_uuid))
|
|
.replaceAll('{force}', force ? 'true' : 'false');
|
|
return `${settings.coolify_url}${path}`;
|
|
}
|
|
|
|
async function fetchWithTimeout(url, options = {}, timeoutMs = 15000) {
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
try { return await fetch(url, { ...options, signal: controller.signal, redirect: 'error' }); }
|
|
catch (error) {
|
|
if (error?.name === 'AbortError') fail('Coolify-Anfrage hat das Zeitlimit überschritten.', 'COOLIFY_TIMEOUT', 504);
|
|
fail(`Coolify ist nicht erreichbar: ${error.message}`, 'COOLIFY_UNREACHABLE', 502);
|
|
} finally { clearTimeout(timeout); }
|
|
}
|
|
|
|
async function readSafeResponse(response) {
|
|
const text = (await response.text()).slice(0, 4000);
|
|
try { return text ? JSON.parse(text) : null; } catch { return text || null; }
|
|
}
|
|
|
|
export function createDeploymentService({ createBackup, checkForUpdates } = {}) {
|
|
if (typeof createBackup !== 'function') fail('Backup-Adapter fehlt.', 'DEPLOYMENT_ADAPTER_MISSING', 500);
|
|
|
|
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 response = await fetchWithTimeout(`${settings.coolify_url}/api/health`, { headers: { Accept: 'application/json' } }, 10000);
|
|
const body = await readSafeResponse(response);
|
|
if (!response.ok) fail(`Coolify Healthcheck antwortet mit HTTP ${response.status}.`, 'COOLIFY_HEALTH_FAILED', 502, { response: body });
|
|
return { ok: true, status: response.status, response: body, url: settings.coolify_url };
|
|
}
|
|
|
|
async function trigger({ force = false, confirmation = '', reason = 'manual' } = {}) {
|
|
if (confirmation !== 'DEPLOY') fail('Zur Bestätigung muss exakt DEPLOY übermittelt werden.', 'DEPLOYMENT_CONFIRMATION_REQUIRED');
|
|
const settings = currentSettings();
|
|
if (settings.provider !== 'coolify') fail('Coolify ist nicht als Deployment-Provider aktiviert.');
|
|
|
|
let backup = null;
|
|
if (settings.backup_before_deploy) {
|
|
backup = await createBackup({
|
|
kind: 'manual', includeUploads: settings.backup_uploads, includeConfig: true,
|
|
label: `pre-deploy-${APP_VERSION}`,
|
|
});
|
|
}
|
|
|
|
let url;
|
|
const headers = { Accept: 'application/json', 'User-Agent': `Vendoo/${APP_VERSION}` };
|
|
let 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 requestedAt = new Date().toISOString();
|
|
const response = await fetchWithTimeout(url, { method, headers }, 20000);
|
|
const body = await readSafeResponse(response);
|
|
const record = {
|
|
requested_at: requestedAt,
|
|
provider: 'coolify', trigger_mode: settings.trigger_mode, method,
|
|
resource_uuid: settings.resource_uuid || null,
|
|
github_repository: settings.github_repository, github_branch: settings.github_branch,
|
|
version: APP_VERSION, force: Boolean(force), reason: clean(reason, 120),
|
|
accepted: response.ok, http_status: response.status,
|
|
backup_id: backup?.id || null, backup_file: backup?.filename || null,
|
|
response: body,
|
|
};
|
|
writeLastDeployment(record);
|
|
if (!response.ok) fail(`Coolify hat den Deployment-Auftrag mit HTTP ${response.status} abgewiesen.`, 'COOLIFY_DEPLOY_REJECTED', 502, { response: body, backup_id: backup?.id || null });
|
|
return { ...record, message: 'Coolify-Deployment wurde angefordert. Der laufende Container wird nicht selbst verändert.' };
|
|
}
|
|
|
|
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: clean(input.deploy_endpoint || '/api/v1/deploy?uuid={uuid}&force={force}', 500),
|
|
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',
|
|
};
|
|
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 !== '••••••••') {
|
|
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 !== '••••••••') {
|
|
setSecret('COOLIFY_DEPLOY_WEBHOOK_URL', normalizeWebhookUrl(input.deploy_webhook_url), { source: 'deployment-ui' });
|
|
}
|
|
return publicSettings();
|
|
}
|
|
|
|
return Object.freeze({
|
|
status: publicSettings,
|
|
update,
|
|
testConnection,
|
|
trigger,
|
|
async checkUpdates() {
|
|
if (typeof checkForUpdates !== 'function') return { configured: false, current_version: APP_VERSION, message: 'Updateprüfung ist nicht verfügbar.' };
|
|
return checkForUpdates();
|
|
},
|
|
health() {
|
|
const status = publicSettings();
|
|
return { ok: true, provider: status.provider, configured: status.configured, docker_socket_access: false };
|
|
},
|
|
});
|
|
}
|