import express from 'express'; import dotenv from 'dotenv'; import https from 'https'; import axios from 'axios'; import http from 'http'; import { spawn } from 'child_process'; import { Server } from 'socket.io'; import path from 'path'; import fs from 'fs'; import os from 'os'; import crypto from 'crypto'; import { fileURLToPath } from 'url'; import { db } from './db/index.js'; import { logRedeployEvent, buildLogFilter, deleteLogById, deleteLogsByFilters, exportLogsByFilters } from './db/redeployLogs.js'; import { getSetting, setSetting, deleteSetting } from './db/settings.js'; import { activateMaintenanceMode, deactivateMaintenanceMode, getMaintenanceState, isMaintenanceModeActive } from './maintenance/state.js'; dotenv.config(); const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const app = express(); app.use(express.json()); app.use(express.static(path.join(__dirname, 'public'))); app.get('*', (req, res, next) => { if (req.path.startsWith('/api')) return next(); res.sendFile(path.join(__dirname, 'public', 'index.html')); }); const PORT = 4001; const ENDPOINT_ID = Number(process.env.PORTAINER_ENDPOINT_ID); const agent = new https.Agent({ rejectUnauthorized: false }); const axiosInstance = axios.create({ httpsAgent: agent, headers: { "X-API-Key": process.env.PORTAINER_API_KEY }, baseURL: process.env.PORTAINER_URL, }); const REDEPLOY_PHASES = { QUEUED: 'queued', STARTED: 'started', SUCCESS: 'success', ERROR: 'error', INFO: 'info' }; const redeployingStacks = new Map(); const isActiveRedeployPhase = (phase) => phase === REDEPLOY_PHASES.QUEUED || phase === REDEPLOY_PHASES.STARTED; const resolveRedeployPhase = (phase, message) => { if (phase) return phase; if (message) return REDEPLOY_PHASES.INFO; return REDEPLOY_PHASES.SUCCESS; }; const server = http.createServer(app); const io = new Server(server, { path: "/socket.io", cors: { origin: "*" } }); io.on("connection", (socket) => { console.log(`🔌 [Socket] Client verbunden: ${socket.id}`); }); const broadcastRedeployStatus = ({ stackId, stackName, phase, message }) => { if (!stackId) return; const resolvedPhase = resolveRedeployPhase(phase, message); const isRedeploying = isActiveRedeployPhase(resolvedPhase); if (isRedeploying) { redeployingStacks.set(String(stackId), { phase: resolvedPhase, stackName: stackName || null, message: message || null, updatedAt: Date.now() }); } else if (resolvedPhase === REDEPLOY_PHASES.SUCCESS || resolvedPhase === REDEPLOY_PHASES.ERROR || resolvedPhase === 'idle') { redeployingStacks.delete(String(stackId)); } const payload = { stackId, stackName, phase: resolvedPhase, message, isRedeploying, redeployPhase: resolvedPhase }; io.emit("redeployStatus", payload); const label = stackName ? `${stackName} (${stackId})` : `Stack ${stackId}`; console.log(`🔄 [RedeployStatus] ${label} -> ${resolvedPhase}${message ? `: ${message}` : ""}`); }; const REDEPLOY_TYPES = { SINGLE: 'Einzeln', ALL: 'Alle', SELECTION: 'Auswahl', MAINTENANCE: 'Wartung' }; const SELF_STACK_ID = process.env.SELF_STACK_ID ? String(process.env.SELF_STACK_ID) : null; const PORTAINER_SCRIPT_SETTING_KEY = 'portainer_update_script'; const PORTAINER_SSH_CONFIG_KEY = 'portainer_ssh_config'; const DEFAULT_PORTAINER_UPDATE_SCRIPT = [ 'docker stop portainer', 'docker rm portainer', 'docker pull portainer/portainer-ee:lts', 'docker run -d -p 8000:8000 -p 9443:9443 --name=portainer --restart=always -v /var/run/docker.sock:/var/run/docker.sock -v portainer_data:/data portainer/portainer-ee:lts' ].join('\n'); let portainerUpdateState = { running: false, status: 'idle', stage: 'idle', startedAt: null, finishedAt: null, targetVersion: null, resultVersion: null, scriptSource: null, message: null, error: null, logs: [] }; const addUpdateLog = (message, level = 'info') => { const entry = { timestamp: new Date().toISOString(), level, message }; portainerUpdateState = { ...portainerUpdateState, logs: [...(portainerUpdateState.logs || []).slice(-50), entry] }; console.log(`🛠️ [PortainerUpdate:${level}] ${message}`); }; const updatePortainerState = (partial = {}) => { portainerUpdateState = { ...portainerUpdateState, ...partial }; return portainerUpdateState; }; const getPortainerUpdateStatus = () => ({ ...portainerUpdateState }); const getCustomPortainerScript = () => { const row = getSetting(PORTAINER_SCRIPT_SETTING_KEY); if (!row) return null; const raw = typeof row.value === 'string' ? row.value : ''; if (!raw.trim()) return null; return { script: normalizeScriptText(raw), updatedAt: row.updated_at || null }; }; const normalizeScriptText = (script) => String(script ?? '').replace(/\r\n/g, '\n'); const saveCustomPortainerScript = (script) => { const normalized = normalizeScriptText(script); if (!normalized.trim()) { deleteSetting(PORTAINER_SCRIPT_SETTING_KEY); return null; } setSetting(PORTAINER_SCRIPT_SETTING_KEY, normalized); return normalized; }; const getEffectivePortainerScript = () => { const custom = getCustomPortainerScript(); if (custom) { return { script: custom.script, source: 'custom', updatedAt: custom.updatedAt }; } return { script: DEFAULT_PORTAINER_UPDATE_SCRIPT, source: 'default', updatedAt: null }; }; const SSH_ENCRYPTION_KEY = crypto.createHash('sha256') .update(process.env.PORTAINER_SSH_SECRET || process.env.PORTAINER_API_KEY || 'stackpulse-portainer-ssh-secret') .digest(); const encryptSensitive = (value) => { if (!value) return null; const iv = crypto.randomBytes(12); const cipher = crypto.createCipheriv('aes-256-gcm', SSH_ENCRYPTION_KEY, iv); const encrypted = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]); const authTag = cipher.getAuthTag(); return { iv: iv.toString('base64'), content: encrypted.toString('base64'), tag: authTag.toString('base64') }; }; const decryptSensitive = (payload) => { if (!payload || !payload.content) return ''; try { const iv = Buffer.from(payload.iv, 'base64'); const content = Buffer.from(payload.content, 'base64'); const tag = Buffer.from(payload.tag, 'base64'); const decipher = crypto.createDecipheriv('aes-256-gcm', SSH_ENCRYPTION_KEY, iv); decipher.setAuthTag(tag); const decrypted = Buffer.concat([decipher.update(content), decipher.final()]); return decrypted.toString('utf8'); } catch (err) { console.warn('⚠️ [Maintenance] Konnte privaten SSH Schlüssel nicht entschlüsseln:', err.message); return ''; } }; const DEFAULT_PORTAINER_SSH_CONFIG = { host: '', port: 22, username: '', extraSshArgs: [], privateKey: '', privateKeyPassphrase: '' }; const normalizeString = (value, fallback = '') => { if (value === undefined || value === null) return fallback; return String(value).trim(); }; const normalizePort = (value, fallback = DEFAULT_PORTAINER_SSH_CONFIG.port) => { const parsed = Number.parseInt(value, 10); return Number.isNaN(parsed) ? fallback : parsed; }; const tokenizeSshArgLine = (line) => { if (!line) return []; const tokens = []; const regex = /"([^"]*)"|'([^']*)'|(\S+)/g; let match; while ((match = regex.exec(line)) !== null) { if (match[1] !== undefined) { tokens.push(match[1]); } else if (match[2] !== undefined) { tokens.push(match[2]); } else if (match[3] !== undefined) { tokens.push(match[3]); } } return tokens; }; const normalizeExtraArgs = (value, fallback = []) => { if (Array.isArray(value)) { return value.map((entry) => String(entry).trim()).filter(Boolean); } if (typeof value === 'string') { return value .split(/\r?\n/) .map((entry) => entry.trim()) .filter(Boolean); } return Array.isArray(fallback) ? [...fallback] : []; }; const normalizePrivateKey = (value, fallback = DEFAULT_PORTAINER_SSH_CONFIG.privateKey) => { if (value === undefined) return fallback; if (value === null) return ''; if (typeof value !== 'string') return String(value); return value.replace(/\r\n/g, '\n').replace(/\r/g, '\n').trim(); }; const normalizePrivateKeyPassphrase = (value, fallback = DEFAULT_PORTAINER_SSH_CONFIG.privateKeyPassphrase) => { if (value === undefined) return fallback; if (value === null) return ''; return String(value); }; const getPortainerSshConfig = () => { const stored = getSetting(PORTAINER_SSH_CONFIG_KEY); if (!stored) { return { ...DEFAULT_PORTAINER_SSH_CONFIG }; } try { const parsed = stored.value ? JSON.parse(stored.value) : {}; const decryptedKey = parsed.privateKeyEncrypted ? decryptSensitive(parsed.privateKeyEncrypted) : ''; return { host: normalizeString(parsed.host), port: normalizePort(parsed.port), username: normalizeString(parsed.username), extraSshArgs: normalizeExtraArgs(parsed.extraSshArgs), privateKey: decryptedKey, privateKeyPassphrase: parsed.privateKeyPassphraseEncrypted ? decryptSensitive(parsed.privateKeyPassphraseEncrypted) : '' }; } catch (err) { console.warn('⚠️ [Maintenance] Konnte Portainer SSH Konfiguration nicht parsen:', err.message); return { ...DEFAULT_PORTAINER_SSH_CONFIG }; } }; const persistPortainerSshConfig = (config) => { const payload = { host: config.host, port: config.port, username: config.username, extraSshArgs: config.extraSshArgs, privateKeyEncrypted: config.privateKey ? encryptSensitive(config.privateKey) : null, privateKeyPassphraseEncrypted: config.privateKeyPassphrase ? encryptSensitive(config.privateKeyPassphrase) : null }; setSetting(PORTAINER_SSH_CONFIG_KEY, JSON.stringify(payload)); }; const mergeSshConfig = (base, overrides = {}) => ({ host: normalizeString(overrides.host, base.host), port: normalizePort(overrides.port, base.port), username: normalizeString(overrides.username, base.username), extraSshArgs: normalizeExtraArgs(overrides.extraSshArgs, base.extraSshArgs), privateKey: normalizePrivateKey(overrides.privateKey, base.privateKey), privateKeyPassphrase: normalizePrivateKeyPassphrase(overrides.privateKeyPassphrase, base.privateKeyPassphrase) }); const savePortainerSshConfig = (payload = {}) => { const current = getPortainerSshConfig(); const next = mergeSshConfig(current, payload); persistPortainerSshConfig(next); return next; }; const deletePortainerSshConfig = () => { deleteSetting(PORTAINER_SSH_CONFIG_KEY); return { ...DEFAULT_PORTAINER_SSH_CONFIG }; }; const createTempPrivateKeyFile = (privateKey) => { const normalized = normalizePrivateKey(privateKey, ''); const content = normalized.endsWith('\n') ? normalized : `${normalized}\n`; const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'stackpulse-ssh-')); const filePath = path.join(tmpDir, 'id_rsa'); fs.writeFileSync(filePath, content, { mode: 0o600 }); return { filePath, cleanup: () => { try { fs.unlinkSync(filePath); } catch (err) { } try { fs.rmdirSync(tmpDir); } catch (err) { } } }; }; const createTempAskPassScript = (passphrase) => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'stackpulse-askpass-')); const scriptPath = path.join(tmpDir, 'askpass.sh'); const scriptContent = "#!/bin/sh\nprintf '%s\\n' \"$STACKPULSE_SSH_PASS\"\n"; fs.writeFileSync(scriptPath, scriptContent, { mode: 0o700 }); return { env: { SSH_ASKPASS: scriptPath, SSH_ASKPASS_REQUIRE: 'force', DISPLAY: process.env.DISPLAY || ':9999', STACKPULSE_SSH_PASS: passphrase }, cleanup: () => { try { fs.unlinkSync(scriptPath); } catch (err) { } try { fs.rmdirSync(tmpDir); } catch (err) { } } }; }; const buildSshCommandArgs = (config) => { const sshConfig = mergeSshConfig(DEFAULT_PORTAINER_SSH_CONFIG, config); if (!sshConfig.host) { throw new Error('SSH Host ist nicht konfiguriert'); } if (!sshConfig.username) { throw new Error('SSH Benutzer ist nicht konfiguriert'); } const args = [ '-p', String(sshConfig.port), '-o', 'StrictHostKeyChecking=no' ]; const cleanupTasks = []; const registerCleanup = (fn) => { if (typeof fn === 'function') { cleanupTasks.push(fn); } }; if (!sshConfig.privateKeyPassphrase) { args.push('-o', 'BatchMode=yes'); } if (sshConfig.privateKey) { const { filePath, cleanup: dispose } = createTempPrivateKeyFile(sshConfig.privateKey); registerCleanup(dispose); args.push('-i', filePath); } if (sshConfig.extraSshArgs.length) { const expanded = sshConfig.extraSshArgs.flatMap((entry) => tokenizeSshArgLine(entry)); if (expanded.length) { args.push(...expanded); } } let envOverrides = {}; if (sshConfig.privateKeyPassphrase) { const { env, cleanup: dispose } = createTempAskPassScript(sshConfig.privateKeyPassphrase); envOverrides = { ...envOverrides, ...env }; registerCleanup(dispose); } args.push(`${sshConfig.username}@${sshConfig.host}`); const cleanup = () => { while (cleanupTasks.length) { const task = cleanupTasks.pop(); try { task(); } catch (err) { } } }; return { args, sshConfig, cleanup, env: envOverrides }; }; const ensureSshConfigReady = () => getPortainerSshConfig(); const testSshConnection = async (configOverride = null) => { const baseConfig = configOverride ? mergeSshConfig(getPortainerSshConfig(), configOverride) : ensureSshConfigReady(); const hasHost = Boolean(baseConfig.host && baseConfig.username); if (!hasHost) { throw new Error('SSH-Konfiguration ist unvollständig (Host/Benutzer erforderlich).'); } const { args, env: envOverrides, cleanup } = buildSshCommandArgs(baseConfig); const sshArgs = [...args, 'echo', '__PORTAINER_SSH_TEST__']; try { return await new Promise((resolve, reject) => { const childEnv = { ...process.env, ...envOverrides }; const child = spawn('ssh', sshArgs, { env: childEnv, stdio: ['ignore', 'pipe', 'pipe'] }); let output = ''; let errorOutput = ''; child.stdout.on('data', (chunk) => { output += chunk.toString(); }); child.stderr.on('data', (chunk) => { errorOutput += chunk.toString(); }); child.on('error', reject); child.on('close', (code) => { if (code === 0 && output.includes('__PORTAINER_SSH_TEST__')) { resolve({ success: true, output: output.trim() }); } else { const message = errorOutput.trim() || `SSH Test fehlgeschlagen (Exit-Code ${code})`; reject(new Error(message)); } }); }); } finally { cleanup(); } }; const detectPortainerContainer = async () => { try { const containersRes = await axiosInstance.get(`/api/endpoints/${ENDPOINT_ID}/docker/containers/json`, { params: { all: true } }); const containers = Array.isArray(containersRes.data) ? containersRes.data : []; const normalizeName = (value) => (typeof value === 'string' ? value.replace(/^\//, '').toLowerCase() : ''); const isPortainerContainer = (container = {}) => { const names = Array.isArray(container.Names) ? container.Names.map(normalizeName) : []; const image = String(container.Image ?? '').toLowerCase(); const labels = container.Labels || {}; if (labels['io.portainer.container']) return true; if (labels['io.portainer.role'] === 'instance') return true; if (names.includes('portainer') || names.includes('portainer_ce')) return true; if (image.includes('portainer/portainer')) return true; if (image.includes('portainer-ce')) return true; return false; }; const matchedContainer = containers.find((entry) => isPortainerContainer(entry)); if (!matchedContainer) { return { summary: null, error: 'Portainer-Container nicht gefunden' }; } const inspectRes = await axiosInstance.get(`/api/endpoints/${ENDPOINT_ID}/docker/containers/${matchedContainer.Id}/json`); const inspect = inspectRes.data ?? {}; const trimName = (value) => (typeof value === 'string' ? value.replace(/^\//, '') : value); const toArray = (value) => { if (!value) return []; if (Array.isArray(value)) return value.map((item) => String(item)); return [String(value)]; }; const summary = { id: inspect.Id ?? matchedContainer.Id ?? null, name: trimName(inspect.Name) ?? (matchedContainer.Names?.[0] ? trimName(matchedContainer.Names[0]) : null), image: inspect.Config?.Image ?? matchedContainer.Image ?? null, created: inspect.Created ?? null, entrypoint: toArray(inspect.Config?.Entrypoint), command: toArray(inspect.Config?.Cmd), args: toArray(inspect.Args), env: Array.isArray(inspect.Config?.Env) ? inspect.Config.Env : [], binds: Array.isArray(inspect.HostConfig?.Binds) ? inspect.HostConfig.Binds : [], mounts: Array.isArray(inspect.Mounts) ? inspect.Mounts.map((mount) => ({ type: mount.Type ?? null, source: mount.Source ?? null, destination: mount.Destination ?? null, mode: mount.Mode ?? null, rw: typeof mount.RW === 'boolean' ? mount.RW : null })) : [], labels: inspect.Config?.Labels ?? matchedContainer.Labels ?? {}, ports: inspect.HostConfig?.PortBindings ?? null, networks: inspect.NetworkSettings?.Networks ?? null, restartPolicy: inspect.HostConfig?.RestartPolicy ?? null }; return { summary, error: null }; } catch (err) { const message = err.response?.data?.message || err.message; return { summary: null, error: message }; } }; const waitForPortainerAvailability = async ({ timeoutMs = 5 * 60 * 1000, intervalMs = 5000 } = {}) => { const start = Date.now(); while (Date.now() - start < timeoutMs) { try { await axiosInstance.get('/api/status'); return true; } catch (err) { await new Promise((resolve) => setTimeout(resolve, intervalMs)); } } return false; }; const normalizeVersion = (value) => { if (!value) return null; return String(value).trim().replace(/^v/i, ''); }; const semverParts = (value) => { const normalized = normalizeVersion(value); if (!normalized) return null; return normalized.split(/[.-]/).map((segment) => { const numericPart = segment.replace(/[^0-9].*$/, ''); const parsed = Number.parseInt(numericPart, 10); return Number.isNaN(parsed) ? 0 : parsed; }); }; const compareSemver = (a, b) => { const partsA = semverParts(a) || []; const partsB = semverParts(b) || []; const length = Math.max(partsA.length, partsB.length); for (let index = 0; index < length; index += 1) { const valueA = partsA[index] ?? 0; const valueB = partsB[index] ?? 0; if (valueA > valueB) return 1; if (valueA < valueB) return -1; } return 0; }; const fetchPortainerStatusSummary = async () => { const statusRes = await axiosInstance.get('/api/status'); const statusData = statusRes.data ?? {}; const currentVersion = statusData.Version ?? statusData.ServerVersion ?? statusData.Server?.Version ?? statusData.ServerInfo?.Version ?? statusData.ServerVersionNumber ?? null; const edition = statusData.Edition ?? statusData.Server?.Edition ?? null; const build = statusData.BuildNumber ?? statusData.Server?.Build ?? null; const errors = {}; let latestVersion = null; const { summary: containerSummary, error: containerError } = await detectPortainerContainer(); if (containerError) { errors.container = containerError; } try { const githubRes = await axios.get('https://api.github.com/repos/portainer/portainer/releases/latest', { headers: { 'Accept': 'application/vnd.github+json', 'User-Agent': 'StackPulse-Maintenance' }, timeout: 5000 }); latestVersion = githubRes.data?.tag_name ?? githubRes.data?.tagName ?? null; } catch (err) { const message = err.response?.data?.message || err.message; console.warn(`⚠️ [Maintenance] Konnte Portainer-Latest-Version nicht ermitteln: ${message}`); errors.latestVersion = message; } if (!currentVersion) { errors.currentVersion = 'Portainer-Version konnte nicht ermittelt werden'; } const normalizedCurrent = normalizeVersion(currentVersion); const normalizedLatest = normalizeVersion(latestVersion); const portainerFlag = typeof statusData.UpdateAvailable === 'boolean' ? statusData.UpdateAvailable : null; let updateAvailable = null; if (normalizedCurrent && normalizedLatest) { updateAvailable = compareSemver(normalizedCurrent, normalizedLatest) < 0; } else if (portainerFlag !== null) { updateAvailable = portainerFlag; } const responsePayload = { currentVersion, latestVersion, normalized: { current: normalizedCurrent, latest: normalizedLatest }, updateAvailable, portainerFlag, edition: edition ?? null, build: build ?? null, fetchedAt: new Date().toISOString(), container: containerSummary }; if (Object.keys(errors).length) { responsePayload.errors = errors; } return responsePayload; }; const maintenanceGuard = (req, res, next) => { if (!isMaintenanceModeActive()) { return next(); } return res.status(423).json({ error: 'Wartungsmodus aktiv', maintenance: getMaintenanceState(), update: getPortainerUpdateStatus() }); }; const logScriptOutput = (data, level) => { if (!data) return; const text = data.toString(); text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean).forEach((line) => addUpdateLog(line, level)); }; const executePortainerUpdateScript = async (script) => { const normalized = normalizeScriptText(script); if (!normalized.trim()) { addUpdateLog('Kein Update-Skript definiert. Vorgang wird übersprungen.', 'warning'); return; } const scriptWithUnixNewlines = normalized.endsWith('\n') ? normalized : `${normalized}\n`; const sshConfig = getPortainerSshConfig(); const useSsh = Boolean(sshConfig.host && sshConfig.username); if (!useSsh) { addUpdateLog('Führe Update-Skript lokal auf dem StackPulse-Host aus.', 'info'); return new Promise((resolve, reject) => { const child = spawn('bash', ['-lc', `set -e\n${scriptWithUnixNewlines}`], { env: process.env, stdio: ['ignore', 'pipe', 'pipe'] }); child.stdout.on('data', (chunk) => logScriptOutput(chunk, 'stdout')); child.stderr.on('data', (chunk) => logScriptOutput(chunk, 'stderr')); child.on('error', (err) => reject(err)); child.on('close', (code) => { if (code === 0) { resolve(); } else { reject(new Error(`Update-Skript beendet mit Exit-Code ${code}`)); } }); }); } const { args, env: envOverrides, cleanup } = buildSshCommandArgs(sshConfig); const sshArgs = [...args, 'bash', '-s']; addUpdateLog(`Verbinde zu ${sshConfig.username}@${sshConfig.host} für Update-Skript`, 'info'); const sshPromise = new Promise((resolve, reject) => { const childEnv = { ...process.env, ...envOverrides }; const child = spawn('ssh', sshArgs, { env: childEnv, stdio: ['pipe', 'pipe', 'pipe'] }); child.stdout.on('data', (chunk) => logScriptOutput(chunk, 'stdout')); child.stderr.on('data', (chunk) => logScriptOutput(chunk, 'stderr')); child.on('error', (err) => reject(err)); child.on('close', (code) => { if (code === 0) { resolve(); } else { reject(new Error(`SSH Befehl beendet mit Exit-Code ${code}`)); } }); child.stdin.write(`set -e ${scriptWithUnixNewlines}`); child.stdin.end(); }); return sshPromise.finally(() => cleanup()); }; let currentPortainerUpdatePromise = null; const performPortainerUpdate = async ({ script, scriptSource, targetVersion }) => { let maintenanceActivated = false; try { addUpdateLog(`Portainer-Update gestartet (Quelle: ${scriptSource})`, 'info'); updatePortainerState({ status: 'running', stage: 'activating-maintenance', message: 'Wartungsmodus wird aktiviert' }); const maintenanceDetails = { type: 'portainer-update', targetVersion: targetVersion ?? null, scriptSource }; const maintenanceState = activateMaintenanceMode({ message: 'Portainer Update läuft', extra: maintenanceDetails }); maintenanceActivated = true; logRedeployEvent({ stackId: 'portainer', stackName: 'Portainer', status: 'started', message: `Portainer Update gestartet (Ziel: ${targetVersion ?? 'unbekannt'})`, endpoint: ENDPOINT_ID, redeployType: REDEPLOY_TYPES.MAINTENANCE }); logRedeployEvent({ stackId: 'maintenance', stackName: 'StackPulse Wartung', status: 'started', message: 'Wartungsmodus aktiviert (Portainer Update)', endpoint: ENDPOINT_ID, redeployType: REDEPLOY_TYPES.MAINTENANCE }); updatePortainerState({ stage: 'executing-script', message: 'Update-Skript wird ausgeführt' }); await executePortainerUpdateScript(script); addUpdateLog('Update-Skript erfolgreich abgeschlossen', 'info'); updatePortainerState({ stage: 'waiting', message: 'Warte auf Portainer-Verfügbarkeit' }); const available = await waitForPortainerAvailability({ timeoutMs: 5 * 60 * 1000, intervalMs: 5000 }); if (!available) { throw new Error('Portainer blieb nach dem Update unerreichbar'); } addUpdateLog('Portainer antwortet wieder. Ermittle Version…', 'info'); const statusAfter = await fetchPortainerStatusSummary().catch(() => null); const finalVersion = statusAfter?.currentVersion ?? null; logRedeployEvent({ stackId: 'portainer', stackName: 'Portainer', status: 'success', message: `Portainer Update abgeschlossen (Version: ${finalVersion ?? 'unbekannt'})`, endpoint: ENDPOINT_ID, redeployType: REDEPLOY_TYPES.MAINTENANCE }); updatePortainerState({ running: false, status: 'success', stage: 'completed', finishedAt: new Date().toISOString(), message: 'Portainer Update abgeschlossen', error: null, resultVersion: finalVersion }); addUpdateLog('Portainer Update erfolgreich abgeschlossen', 'success'); if (maintenanceActivated || maintenanceState?.active) { deactivateMaintenanceMode({ message: 'Portainer Update abgeschlossen' }); maintenanceActivated = false; logRedeployEvent({ stackId: 'maintenance', stackName: 'StackPulse Wartung', status: 'success', message: 'Wartungsmodus deaktiviert', endpoint: ENDPOINT_ID, redeployType: REDEPLOY_TYPES.MAINTENANCE }); } } catch (err) { const message = err?.message || 'Portainer Update fehlgeschlagen'; logRedeployEvent({ stackId: 'portainer', stackName: 'Portainer', status: 'error', message, endpoint: ENDPOINT_ID, redeployType: REDEPLOY_TYPES.MAINTENANCE }); updatePortainerState({ running: false, status: 'error', stage: 'failed', finishedAt: new Date().toISOString(), message, error: message }); addUpdateLog(message, 'error'); if (maintenanceActivated || isMaintenanceModeActive()) { deactivateMaintenanceMode({ message: 'Portainer Update fehlgeschlagen' }); logRedeployEvent({ stackId: 'maintenance', stackName: 'StackPulse Wartung', status: 'error', message: 'Wartungsmodus deaktiviert (Fehler)', endpoint: ENDPOINT_ID, redeployType: REDEPLOY_TYPES.MAINTENANCE }); } } finally { currentPortainerUpdatePromise = null; } }; const fetchPortainerStacks = async () => { const stacksRes = await axiosInstance.get('/api/stacks'); return stacksRes.data.filter((stack) => stack.EndpointId === ENDPOINT_ID); }; const buildStackCollections = (stacks = []) => { const collections = new Map(); stacks.forEach((stack) => { const name = stack.Name || 'Unbenannt'; const isSelf = SELF_STACK_ID && String(stack.Id) === SELF_STACK_ID; const entry = collections.get(name); if (!entry) { collections.set(name, { canonical: stack, isSelf, members: [stack] }); return; } entry.members.push(stack); if (!entry.isSelf && isSelf) { entry.canonical = stack; entry.isSelf = true; } }); const canonicalStacks = []; const duplicates = []; collections.forEach((entry, name) => { canonicalStacks.push(entry.canonical); if (entry.members.length > 1) { const seenIds = new Set(); const duplicateEntries = entry.members.filter((member) => { const id = String(member.Id); if (id === String(entry.canonical.Id)) { return false; } if (seenIds.has(id)) { return false; } seenIds.add(id); return true; }); if (duplicateEntries.length > 0) { duplicates.push({ name, canonical: entry.canonical, members: duplicateEntries }); } } }); return { canonicalStacks, duplicates }; }; const loadStackCollections = async () => { const filteredStacks = await fetchPortainerStacks(); return { filteredStacks, ...buildStackCollections(filteredStacks) }; }; const isStackOutdated = async (stackId) => { try { const statusRes = await axiosInstance.get(`/api/stacks/${stackId}/images_status?refresh=true`); return statusRes.data?.Status === 'outdated'; } catch (err) { console.error(`⚠️ Konnte Update-Status für Stack ${stackId} nicht ermitteln:`, err.message); return true; } }; const filterOutdatedStacks = async (stacks = []) => { const results = await Promise.all( stacks.map(async (stack) => ({ stack, outdated: SELF_STACK_ID && String(stack.Id) === SELF_STACK_ID ? false : await isStackOutdated(stack.Id) })) ); return { eligibleStacks: results.filter((entry) => entry.outdated).map((entry) => entry.stack), skippedStacks: results.filter((entry) => !entry.outdated).map((entry) => entry.stack), }; }; const getRedeployMessages = (type) => { switch (type) { case REDEPLOY_TYPES.ALL: return { started: 'Redeploy ALL gestartet', success: 'Redeploy ALL abgeschlossen' }; case REDEPLOY_TYPES.SELECTION: return { started: 'Redeploy Auswahl gestartet', success: 'Redeploy Auswahl abgeschlossen' }; case REDEPLOY_TYPES.MAINTENANCE: return { started: 'Redeploy (Wartung) gestartet', success: 'Redeploy (Wartung) abgeschlossen' }; default: return { started: 'Redeploy gestartet', success: 'Redeploy erfolgreich abgeschlossen' }; } }; const shouldFallbackToStackFile = (message) => { if (!message) return false; const normalized = String(message).toLowerCase(); return normalized.includes('not created from git') || normalized.includes('no git configuration'); }; const redeployStackById = async (stackId, redeployType) => { let stack; const messages = getRedeployMessages(redeployType); try { const stackRes = await axiosInstance.get(`/api/stacks/${stackId}`); stack = stackRes.data; if (stack.EndpointId !== ENDPOINT_ID) { throw new Error(`Stack gehört nicht zum Endpoint ${ENDPOINT_ID}`); } const targetId = stack.Id || stackId; const targetName = stack.Name || `Stack ${stackId}`; broadcastRedeployStatus({ stackId: targetId, stackName: targetName, phase: REDEPLOY_PHASES.STARTED }); logRedeployEvent({ stackId: targetId, stackName: targetName, status: 'started', message: messages.started, endpoint: stack.EndpointId, redeployType }); const redeployViaStackFile = async () => { const fileRes = await axiosInstance.get(`/api/stacks/${stack.Id}/file`); const stackFileContent = fileRes.data?.StackFileContent; if (!stackFileContent) { throw new Error('Stack file konnte nicht geladen werden'); } if (stack.Type === 2) { const services = fileRes.data?.Config?.services || {}; for (const serviceName in services) { const imageName = services[serviceName].image; if (!imageName) continue; try { console.log(`🖼️ Pulling image "${imageName}" für Service "${serviceName}"`); await axiosInstance.post( `/api/endpoints/${stack.EndpointId}/docker/images/create?fromImage=${encodeURIComponent(imageName)}` ); } catch (err) { console.error(`❌ Fehler beim Pulling von Image "${imageName}":`, err.message); } } } const updatePayload = { StackFileContent: stackFileContent, Prune: false, PullImage: true, Env: stack.Env || [] }; const swarmId = stack.SwarmId || stack.SwarmID || fileRes.data?.SwarmID; if (swarmId) { updatePayload.SwarmID = swarmId; } await axiosInstance.put( `/api/stacks/${stack.Id}`, updatePayload, { params: { endpointId: stack.EndpointId } } ); }; const isGitStack = Boolean(stack.GitConfig?.RepositoryURL); let gitRedeploySucceeded = false; if (isGitStack) { try { console.log(`🔄 [Redeploy] Git Stack "${stack.Name}" (${stack.Id}) wird redeployed`); await axiosInstance.put(`/api/stacks/${stack.Id}/git/redeploy?endpointId=${stack.EndpointId}`); gitRedeploySucceeded = true; } catch (err) { const gitErrorMessage = err.response?.data?.message || err.message; if (shouldFallbackToStackFile(gitErrorMessage)) { console.warn(`⚠️ Git Redeploy nicht möglich für Stack "${stack.Name}" (${stack.Id}): ${gitErrorMessage}. Fallback auf Stack-Datei.`); } else { throw err; } } } if (!gitRedeploySucceeded) { console.log(`🔄 [Redeploy] Stack "${stack.Name}" (${stack.Id}) wird über Stack-Datei redeployed`); await redeployViaStackFile(); } broadcastRedeployStatus({ stackId: targetId, stackName: targetName, phase: REDEPLOY_PHASES.SUCCESS }); logRedeployEvent({ stackId: targetId, stackName: targetName, status: 'success', message: messages.success, endpoint: stack.EndpointId, redeployType }); return stack; } catch (err) { const errorMessage = err.response?.data?.message || err.message; const fallbackId = stack?.Id || stackId; const fallbackName = stack?.Name || `Stack ${stackId}`; broadcastRedeployStatus({ stackId: fallbackId, stackName: fallbackName, phase: REDEPLOY_PHASES.ERROR, message: errorMessage }); logRedeployEvent({ stackId: fallbackId, stackName: fallbackName, status: 'error', message: errorMessage, endpoint: stack?.EndpointId || ENDPOINT_ID, redeployType }); console.error(`❌ Fehler beim Redeploy von Stack ${fallbackName}:`, errorMessage); throw new Error(errorMessage); } }; // --- API Endpoints --- // Stacks abrufen app.get('/api/stacks', maintenanceGuard, async (req, res) => { console.log("ℹ️ [API] GET /api/stacks: Abruf gestartet"); try { const { canonicalStacks, duplicates } = await loadStackCollections(); const duplicateNames = duplicates.map((entry) => entry.name); const duplicateNameSet = new Set(duplicateNames); if (duplicateNames.length) { console.warn(`⚠️ [API] GET /api/stacks: Doppelte Stack-Namen erkannt: ${duplicateNames.join(', ')}`); } const stacksWithStatus = await Promise.all( canonicalStacks.map(async (stack) => { try { const statusRes = await axiosInstance.get( `/api/stacks/${stack.Id}/images_status?refresh=true` ); const statusEmoji = statusRes.data.Status === 'outdated' ? '⚠️' : '✅'; const redeployMeta = redeployingStacks.get(String(stack.Id)); const redeployPhase = redeployMeta?.phase || null; return { ...stack, updateStatus: statusEmoji, redeploying: redeployPhase === REDEPLOY_PHASES.STARTED || redeployPhase === REDEPLOY_PHASES.QUEUED, redeployPhase, redeployQueued: redeployPhase === REDEPLOY_PHASES.QUEUED, redeployDisabled: SELF_STACK_ID ? String(stack.Id) === SELF_STACK_ID : false, duplicateName: duplicateNameSet.has(stack.Name) }; } catch (err) { console.error(`❌ Fehler beim Abrufen des Status für Stack ${stack.Id}:`, err.message); const redeployMeta = redeployingStacks.get(String(stack.Id)); const redeployPhase = redeployMeta?.phase || null; return { ...stack, updateStatus: '❌', redeploying: redeployPhase === REDEPLOY_PHASES.STARTED || redeployPhase === REDEPLOY_PHASES.QUEUED, redeployPhase, redeployQueued: redeployPhase === REDEPLOY_PHASES.QUEUED, redeployDisabled: SELF_STACK_ID ? String(stack.Id) === SELF_STACK_ID : false, duplicateName: duplicateNameSet.has(stack.Name) }; } }) ); stacksWithStatus.sort((a, b) => a.Name.localeCompare(b.Name)); console.log(`✅ GET /api/stacks: Abruf erfolgreich, ${stacksWithStatus.length} Stacks geladen`); res.json(stacksWithStatus); } catch (err) { console.error(`❌ Fehler beim Abrufen der Stacks:`, err.message); res.status(500).json({ error: err.message }); } }); app.get('/api/maintenance/portainer-status', async (req, res) => { console.log("🧭 [Maintenance] GET /api/maintenance/portainer-status: Prüfung gestartet"); try { const payload = await fetchPortainerStatusSummary(); res.json(payload); } catch (err) { const message = err.response?.data?.message || err.message || 'Unbekannter Fehler'; console.error(`❌ [Maintenance] Fehler beim Prüfen des Portainer-Status: ${message}`); res.status(500).json({ error: message }); } }); app.get('/api/maintenance/config', (req, res) => { const custom = getCustomPortainerScript(); const effective = getEffectivePortainerScript(); const ssh = getPortainerSshConfig(); res.json({ maintenance: getMaintenanceState(), update: getPortainerUpdateStatus(), script: { default: DEFAULT_PORTAINER_UPDATE_SCRIPT, custom: custom?.script ?? null, customUpdatedAt: custom?.updatedAt ?? null, effective: effective.script, source: effective.source, updatedAt: effective.updatedAt }, ssh: { host: ssh.host, port: ssh.port, username: ssh.username, extraSshArgs: ssh.extraSshArgs, privateKeyStored: Boolean(ssh.privateKey), privateKeyPassphraseStored: Boolean(ssh.privateKeyPassphrase) } }); }); app.put('/api/maintenance/ssh-config', (req, res) => { try { const config = savePortainerSshConfig(req.body || {}); res.json({ success: true, ssh: { host: config.host, port: config.port, username: config.username, extraSshArgs: config.extraSshArgs, privateKeyStored: Boolean(config.privateKey), privateKeyPassphraseStored: Boolean(config.privateKeyPassphrase) } }); } catch (err) { console.error('❌ [Maintenance] Fehler beim Speichern der SSH-Konfiguration:', err.message); res.status(400).json({ error: err.message || 'Ungültige SSH-Konfiguration' }); } }); app.delete('/api/maintenance/ssh-config', (req, res) => { const config = deletePortainerSshConfig(); res.json({ success: true, ssh: { host: config.host, port: config.port, username: config.username, extraSshArgs: config.extraSshArgs, privateKeyStored: false, privateKeyPassphraseStored: false } }); }); app.post('/api/maintenance/test-ssh', async (req, res) => { try { const override = req.body && Object.keys(req.body).length ? req.body : null; const result = await testSshConnection(override); res.json({ success: true, result }); } catch (err) { console.error('❌ [Maintenance] SSH Test fehlgeschlagen:', err.message); res.status(500).json({ error: err.message || 'SSH Test fehlgeschlagen' }); } }); app.put('/api/maintenance/update-script', (req, res) => { if (portainerUpdateState.running) { return res.status(409).json({ error: 'Aktualisierung läuft. Skript kann derzeit nicht geändert werden.', update: getPortainerUpdateStatus() }); } const incoming = req.body?.script; if (typeof incoming !== 'string') { return res.status(400).json({ error: 'Feld "script" (string) wird benötigt.' }); } saveCustomPortainerScript(incoming); const custom = getCustomPortainerScript(); const effective = getEffectivePortainerScript(); res.json({ success: true, script: { default: DEFAULT_PORTAINER_UPDATE_SCRIPT, custom: custom?.script ?? null, customUpdatedAt: custom?.updatedAt ?? null, effective: effective.script, source: effective.source, updatedAt: effective.updatedAt } }); }); app.delete('/api/maintenance/update-script', (req, res) => { if (portainerUpdateState.running) { return res.status(409).json({ error: 'Aktualisierung läuft. Skript kann derzeit nicht geändert werden.', update: getPortainerUpdateStatus() }); } saveCustomPortainerScript(''); const effective = getEffectivePortainerScript(); res.json({ success: true, script: { default: DEFAULT_PORTAINER_UPDATE_SCRIPT, custom: null, customUpdatedAt: null, effective: effective.script, source: effective.source, updatedAt: effective.updatedAt } }); }); app.get('/api/maintenance/update-status', (req, res) => { res.json({ maintenance: getMaintenanceState(), update: getPortainerUpdateStatus() }); }); app.post('/api/maintenance/portainer-update', async (req, res) => { if (portainerUpdateState.running) { return res.status(409).json({ error: 'Ein Portainer-Update läuft bereits.', update: getPortainerUpdateStatus() }); } if (isMaintenanceModeActive()) { return res.status(423).json({ error: 'Wartungsmodus ist bereits aktiv.', maintenance: getMaintenanceState() }); } const overrideScript = typeof req.body?.script === 'string' ? req.body.script : null; let scriptSource = 'default'; let scriptToRun = DEFAULT_PORTAINER_UPDATE_SCRIPT; if (overrideScript && overrideScript.trim().length) { scriptSource = 'override'; scriptToRun = overrideScript.replace(/\r?\n/g, '\n'); } else { const effective = getEffectivePortainerScript(); scriptSource = effective.source; scriptToRun = effective.script; } let targetVersion = null; try { const statusBefore = await fetchPortainerStatusSummary(); targetVersion = statusBefore?.latestVersion ?? statusBefore?.currentVersion ?? null; } catch (err) { console.warn('⚠️ [Maintenance] Konnte Portainer-Status vor Update nicht ermitteln:', err.message); } updatePortainerState({ running: true, status: 'running', stage: 'initializing', startedAt: new Date().toISOString(), finishedAt: null, targetVersion, scriptSource, message: 'Portainer Update wird vorbereitet', error: null, logs: [] }); addUpdateLog('Vorbereitung abgeschlossen, Update wird gestartet', 'info'); res.json({ success: true, maintenance: getMaintenanceState(), update: getPortainerUpdateStatus() }); currentPortainerUpdatePromise = performPortainerUpdate({ script: scriptToRun, scriptSource, targetVersion }).catch((err) => { console.error('❌ [Maintenance] Portainer Update Fehler:', err.message); }); }); app.get('/api/maintenance/duplicates', maintenanceGuard, async (req, res) => { console.log("🧹 [Maintenance] GET /api/maintenance/duplicates: Abruf gestartet"); try { const { duplicates } = await loadStackCollections(); const payload = duplicates .sort((a, b) => a.name.localeCompare(b.name)) .map((entry) => ({ name: entry.name, canonical: { Id: entry.canonical.Id, Name: entry.canonical.Name, EndpointId: entry.canonical.EndpointId, Type: entry.canonical.Type, Created: entry.canonical.Created }, duplicates: entry.members.map((stack) => ({ Id: stack.Id, Name: stack.Name, EndpointId: stack.EndpointId, Type: stack.Type, Created: stack.Created })) })); res.json({ total: payload.length, items: payload }); } catch (err) { console.error(`❌ [Maintenance] Fehler beim Abrufen der Duplikate:`, err.message); res.status(500).json({ error: 'Fehler beim Abrufen der doppelten Stacks' }); } }); app.post('/api/maintenance/duplicates/cleanup', maintenanceGuard, async (req, res) => { const canonicalId = req.body?.canonicalId; const duplicateIdsInput = Array.isArray(req.body?.duplicateIds) ? req.body.duplicateIds : []; const duplicateIds = duplicateIdsInput .map((value) => String(value).trim()) .filter((value) => value.length > 0); if (!canonicalId) { return res.status(400).json({ error: 'canonicalId ist erforderlich' }); } if (!duplicateIds.length) { return res.status(400).json({ error: 'duplicateIds ist erforderlich' }); } const canonicalIdStr = String(canonicalId); console.log(`🧹 [Maintenance] Bereinigung angefordert für Stack ${canonicalIdStr}. Ziel-IDs: ${duplicateIds.join(', ')}`); try { const { duplicates } = await loadStackCollections(); const target = duplicates.find((entry) => String(entry.canonical.Id) === canonicalIdStr); if (!target) { return res.status(404).json({ error: 'Kein doppelter Stack für diese ID gefunden' }); } const duplicatesToDelete = target.members.filter((stack) => duplicateIds.includes(String(stack.Id))); if (!duplicatesToDelete.length) { return res.status(400).json({ error: 'Keine passenden Duplikate gefunden' }); } logRedeployEvent({ stackId: target.canonical.Id, stackName: target.canonical.Name, status: 'started', message: `Bereinigung doppelter Stacks gestartet (${duplicatesToDelete.length} Einträge)`, endpoint: target.canonical.EndpointId, redeployType: REDEPLOY_TYPES.MAINTENANCE }); const results = []; const errors = []; for (const stack of duplicatesToDelete) { try { await axiosInstance.delete(`/api/stacks/${stack.Id}`, { params: { endpointId: stack.EndpointId } }); console.log(`🧹 [Maintenance] Stack entfernt: ${stack.Name} (${stack.Id})`); results.push({ id: stack.Id, name: stack.Name, endpointId: stack.EndpointId, status: 'deleted' }); } catch (err) { const message = err.response?.data?.message || err.message; console.error(`❌ [Maintenance] Fehler beim Entfernen von Stack ${stack.Id}:`, message); errors.push({ id: stack.Id, message }); results.push({ id: stack.Id, name: stack.Name, endpointId: stack.EndpointId, status: 'error', message }); } } if (errors.length) { const failedIds = errors.map((entry) => entry.id).join(', '); logRedeployEvent({ stackId: target.canonical.Id, stackName: target.canonical.Name, status: 'error', message: `Bereinigung fehlgeschlagen für IDs: ${failedIds}`, endpoint: target.canonical.EndpointId, redeployType: REDEPLOY_TYPES.MAINTENANCE }); return res.status(500).json({ success: false, canonical: { id: target.canonical.Id, name: target.canonical.Name, endpointId: target.canonical.EndpointId }, results }); } logRedeployEvent({ stackId: target.canonical.Id, stackName: target.canonical.Name, status: 'success', message: `Bereinigung abgeschlossen. Entfernte IDs: ${results.map((entry) => entry.id).join(', ')}`, endpoint: target.canonical.EndpointId, redeployType: REDEPLOY_TYPES.MAINTENANCE }); res.json({ success: true, canonical: { id: target.canonical.Id, name: target.canonical.Name, endpointId: target.canonical.EndpointId }, removed: results.length, results }); } catch (err) { const message = err.response?.data?.message || err.message; console.error(`❌ [Maintenance] Fehler bei der Bereinigung:`, message); res.status(500).json({ error: message || 'Fehler bei der Bereinigung' }); } }); // Redeploy-Logs abrufen app.get('/api/logs', (req, res) => { const perPageParam = req.query.perPage ?? req.query.limit; const perPage = perPageParam === 'all' ? 'all' : Math.min(parseInt(perPageParam, 10) || 50, 500); const page = Math.max(parseInt(req.query.page, 10) || 1, 1); const limit = perPage === 'all' ? undefined : perPage; const offset = perPage === 'all' ? 0 : (page - 1) * perPage; const { whereClause, params } = buildLogFilter(req.query); const baseQuery = ` SELECT id, timestamp, stack_id AS stackId, stack_name AS stackName, status, message, endpoint, redeploy_type AS redeployType FROM redeploy_logs ${whereClause} ORDER BY datetime(timestamp) DESC `; const countQuery = ` SELECT COUNT(*) as total FROM redeploy_logs ${whereClause} `; const query = limit !== undefined ? `${baseQuery} LIMIT @limit OFFSET @offset` : baseQuery; if (limit !== undefined) { params.limit = limit; params.offset = offset; } try { const stmt = db.prepare(query); const logs = stmt.all(params); const total = db.prepare(countQuery).get(params)?.total ?? logs.length; res.json({ total, items: logs, page, perPage: perPage === 'all' ? 'all' : limit }); } catch (err) { console.error('❌ Fehler beim Abrufen der Redeploy-Logs:', err.message); if (err.message.includes('no such table')) { return res.status(500).json({ error: 'redeploy_logs table nicht gefunden. Bitte Migration ausführen.' }); } res.status(500).json({ error: 'Fehler beim Abrufen der Redeploy-Logs' }); } }); app.delete('/api/logs/:id', (req, res) => { const id = Number(req.params.id); if (Number.isNaN(id)) { return res.status(400).json({ error: 'Ungültige ID' }); } try { const changes = deleteLogById(id); if (!changes) { return res.status(404).json({ error: 'Eintrag nicht gefunden' }); } res.json({ success: true, deleted: changes }); } catch (err) { console.error('❌ Fehler beim Löschen des Redeploy-Logs:', err.message); res.status(500).json({ error: 'Fehler beim Löschen des Redeploy-Logs' }); } }); app.delete('/api/logs', (req, res) => { try { const deleted = deleteLogsByFilters(req.query); res.json({ success: true, deleted }); } catch (err) { console.error('❌ Fehler beim Löschen der Redeploy-Logs:', err.message); res.status(500).json({ error: 'Fehler beim Löschen der Redeploy-Logs' }); } }); app.get('/api/logs/export', (req, res) => { const format = (req.query.format || 'txt').toLowerCase(); if (!['txt', 'sql'].includes(format)) { return res.status(400).json({ error: 'Ungültiges Export-Format' }); } try { const payload = exportLogsByFilters(req.query, format); res.setHeader('Content-Type', payload.contentType); res.setHeader('Content-Disposition', `attachment; filename="${payload.filename}"`); res.send(payload.content); } catch (err) { console.error('❌ Fehler beim Export der Redeploy-Logs:', err.message); res.status(500).json({ error: 'Fehler beim Export der Redeploy-Logs' }); } }); // Einzel-Redeploy app.put('/api/stacks/:id/redeploy', async (req, res) => { const { id } = req.params; console.log(`🔄 PUT /api/stacks/${id}/redeploy: Redeploy gestartet`); try { await redeployStackById(id, REDEPLOY_TYPES.SINGLE); console.log(`✅ PUT /api/stacks/${id}/redeploy: Redeploy erfolgreich abgeschlossen`); res.json({ success: true, message: 'Stack redeployed' }); } catch (err) { const errorMessage = err.message || 'Unbekannter Fehler beim Redeploy'; console.error(`❌ Fehler beim Redeploy von Stack ${id}:`, errorMessage); res.status(500).json({ error: errorMessage }); } }); // Redeploy ALL app.put('/api/stacks/redeploy-all', maintenanceGuard, async (req, res) => { console.log(`🚀 PUT /api/stacks/redeploy-all: Redeploy ALL gestartet`); try { const stacksRes = await axiosInstance.get('/api/stacks'); const filteredStacks = stacksRes.data.filter(stack => stack.EndpointId === ENDPOINT_ID); console.log("📦 Redeploy ALL für folgende Stacks:"); filteredStacks.forEach(s => console.log(` - ${s.Name}`)); const { eligibleStacks, skippedStacks } = await filterOutdatedStacks(filteredStacks); if (skippedStacks.length) { skippedStacks.forEach((stack) => { console.log(`⏭️ Übersprungen (aktuell): ${stack.Name} (${stack.Id})`); }); } const stackSummaryList = eligibleStacks.map((stack) => `${stack.Name} (${stack.Id})`); const stackSummary = stackSummaryList.length ? stackSummaryList.join(', ') : 'keine Stacks'; logRedeployEvent({ stackId: '---', stackName: '---', status: 'started', message: `Redeploy ALL gestartet für: ${stackSummary}`, endpoint: ENDPOINT_ID, redeployType: REDEPLOY_TYPES.ALL }); if (!eligibleStacks.length) { console.log('ℹ️ Keine veralteten Stacks für Redeploy ALL vorhanden'); return res.json({ success: true, message: 'Keine veralteten Stacks gefunden' }); } eligibleStacks.forEach((stack) => { broadcastRedeployStatus({ stackId: stack.Id, stackName: stack.Name, phase: REDEPLOY_PHASES.QUEUED }); }); for (const stack of eligibleStacks) { try { await redeployStackById(stack.Id, REDEPLOY_TYPES.ALL); console.log(`✅ Redeploy ALL -> Stack ${stack.Name} (${stack.Id}) erfolgreich`); } catch (err) { console.error(`❌ Redeploy ALL -> Stack ${stack.Name} (${stack.Id}) fehlgeschlagen:`, err.message); } } logRedeployEvent({ stackId: '---', stackName: '---', status: 'success', message: 'Redeploy ALL abgeschlossen', endpoint: ENDPOINT_ID, redeployType: REDEPLOY_TYPES.ALL }); res.json({ success: true, message: 'Redeploy ALL abgeschlossen' }); } catch (err) { const message = err.response?.data?.message || err.message; logRedeployEvent({ stackId: '---', stackName: '---', status: 'error', message, endpoint: ENDPOINT_ID, redeployType: REDEPLOY_TYPES.ALL }); console.error('❌ Fehler bei Redeploy ALL:', message); res.status(500).json({ error: message }); } }); // Redeploy selection app.put('/api/stacks/redeploy-selection', maintenanceGuard, async (req, res) => { const { stackIds } = req.body || {}; const totalCount = Array.isArray(stackIds) ? stackIds.length : 0; console.log(`🚀 PUT /api/stacks/redeploy-selection: Redeploy Auswahl gestartet (${totalCount} Stacks)`); if (!Array.isArray(stackIds) || stackIds.length === 0) { return res.status(400).json({ error: 'stackIds muss eine nicht leere Array sein' }); } try { const normalizedIds = stackIds.map((id) => String(id)); const { filteredStacks } = await loadStackCollections(); const stacksById = new Map(filteredStacks.map((stack) => [String(stack.Id), stack])); const missingIds = normalizedIds.filter((id) => !stacksById.has(id)); if (missingIds.length) { return res.status(400).json({ error: `Ungültige Stack-IDs: ${missingIds.join(', ')}` }); } const selectedStacks = normalizedIds.map((id) => stacksById.get(id)).filter(Boolean); if (!selectedStacks.length) { return res.status(400).json({ error: 'Keine gültigen Stacks für Redeploy Auswahl gefunden' }); } const { eligibleStacks, skippedStacks } = await filterOutdatedStacks(selectedStacks); if (skippedStacks.length) { skippedStacks.forEach((stack) => { console.log(`⏭️ Redeploy Auswahl übersprungen (aktuell): ${stack.Name} (${stack.Id})`); }); } const summaryList = eligibleStacks.map((stack) => `${stack.Name} (${stack.Id})`); const summaryText = summaryList.length ? summaryList.join(', ') : 'keine Stacks'; logRedeployEvent({ stackId: stackIds.join(','), stackName: `Auswahl (${stackIds.length})`, status: 'started', message: `Redeploy Auswahl gestartet für: ${summaryText}`, endpoint: ENDPOINT_ID, redeployType: REDEPLOY_TYPES.SELECTION }); if (!eligibleStacks.length) { logRedeployEvent({ stackId: stackIds.join(','), stackName: `Auswahl (${stackIds.length})`, status: 'success', message: 'Redeploy Auswahl übersprungen: keine veralteten Stacks', endpoint: ENDPOINT_ID, redeployType: REDEPLOY_TYPES.SELECTION }); return res.json({ success: true, message: 'Keine veralteten Stacks in der Auswahl' }); } eligibleStacks.forEach((stack) => { broadcastRedeployStatus({ stackId: stack.Id, stackName: stack.Name, phase: REDEPLOY_PHASES.QUEUED }); }); for (const stack of eligibleStacks) { try { await redeployStackById(stack.Id, REDEPLOY_TYPES.SELECTION); console.log(`✅ Redeploy Auswahl -> Stack ${stack.Name} (${stack.Id}) erfolgreich`); } catch (err) { console.error(`❌ Redeploy Auswahl -> Stack ${stack.Name} (${stack.Id}) fehlgeschlagen:`, err.message); } } logRedeployEvent({ stackId: stackIds.join(','), stackName: `Auswahl (${stackIds.length})`, status: 'success', message: 'Redeploy Auswahl abgeschlossen', endpoint: ENDPOINT_ID, redeployType: REDEPLOY_TYPES.SELECTION }); res.json({ success: true, message: 'Redeploy Auswahl abgeschlossen' }); } catch (err) { const message = err.response?.data?.message || err.message; logRedeployEvent({ stackId: Array.isArray(stackIds) ? stackIds.join(',') : String(stackIds ?? ''), stackName: `Auswahl (${Array.isArray(stackIds) ? stackIds.length : 0})`, status: 'error', message, endpoint: ENDPOINT_ID, redeployType: REDEPLOY_TYPES.SELECTION }); console.error('❌ Fehler bei Redeploy Auswahl:', message); res.status(500).json({ error: message }); } }); server.listen(PORT, () => { console.log(`🚀 Server läuft auf Port ${PORT}`); });