From 953049a45dfba4f413e71767004f69c601469e43 Mon Sep 17 00:00:00 2001 From: SaelixCode Date: Thu, 5 Mar 2026 19:59:35 -0500 Subject: [PATCH] feat: implement smart error parser and post-deploy health probe --- CHANGELOG.md | 3 + backend/src/index.ts | 23 ++++++-- backend/src/services/ComposeService.ts | 41 +++++++++++-- backend/src/services/DockerController.ts | 4 ++ backend/src/utils/ErrorParser.ts | 74 ++++++++++++++++++++++++ 5 files changed, 135 insertions(+), 10 deletions(-) create mode 100644 backend/src/utils/ErrorParser.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index add881fc..fbd57455 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +- **Added:** Smart Error Parser with telemetry-ready rule IDs to translate cryptic Docker output. +- **Added:** Post-Deploy Health Probe to catch immediate container crashes that slip past Compose. +- **Changed:** Rollback engine respects a `canSilentlyRollback` flag to protect user-authored configurations. - **Changed:** Removed rigid volume sanitization, allowing full user control over bind paths. - **Added:** Editable Host Volumes in the deployment UI. - **Added:** Custom Environment Variable injection tool. diff --git a/backend/src/index.ts b/backend/src/index.ts index 12668727..770aeae7 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -20,6 +20,7 @@ import { DatabaseService } from './services/DatabaseService'; import { NotificationService } from './services/NotificationService'; import { MonitorService } from './services/MonitorService'; import { templateService } from './services/TemplateService'; +import { ErrorParser } from './utils/ErrorParser'; import YAML from 'yaml'; import { promises as fsPromises } from 'fs'; @@ -1086,13 +1087,23 @@ app.post('/api/templates/deploy', async (req: Request, res: Response) => { // 4. Deploy the stack with atomic rollback try { await composeService.deployStack(stackName, terminalWs || undefined); - } catch (deployError) { - // ATOMIC ROLLBACK: If Docker fails, delete the folder we just created - await fileSystemService.deleteStack(stackName); - throw deployError; // Pass error to outer catch block - } + res.json({ success: true, message: 'Template deployed successfully' }); + } catch (deployError: any) { + const rawError = deployError.message || String(deployError); + const parsed = ErrorParser.parse(rawError); - res.json({ success: true, message: 'Template deployed successfully' }); + const shouldRollback = parsed.rule ? parsed.rule.canSilentlyRollback : true; + + if (shouldRollback) { + await fileSystemService.deleteStack(stackName); + } + + res.status(500).json({ + error: parsed.message, + rolledBack: shouldRollback, + ruleId: parsed.rule?.id || 'UNKNOWN' + }); + } } catch (error: any) { console.error('Failed to deploy template:', error); res.status(500).json({ error: error.message || 'Failed to deploy template' }); diff --git a/backend/src/services/ComposeService.ts b/backend/src/services/ComposeService.ts index 615c8d8f..80768886 100644 --- a/backend/src/services/ComposeService.ts +++ b/backend/src/services/ComposeService.ts @@ -88,7 +88,7 @@ export class ComposeService { console.warn(`Failed to clean up legacy containers for ${stackName}:`, e); } - return new Promise((resolve, reject) => { + await new Promise((resolve, reject) => { const args = ['compose', 'up', '-d', '--remove-orphans']; const child = spawn('docker', args, { cwd: stackDir, @@ -101,7 +101,11 @@ export class ComposeService { let errorLog = ''; if (ws) { - child.stdout.on('data', (data: Buffer) => ws.send(data.toString())); + child.stdout.on('data', (data: Buffer) => { + const text = data.toString(); + errorLog += text; + ws.send(text); + }); child.stderr.on('data', (data: Buffer) => { const text = data.toString(); errorLog += text; @@ -113,7 +117,9 @@ export class ComposeService { else reject(new Error(errorLog.trim() || `Command failed with code ${code}`)); }); } else { - child.stdout.on('data', () => { }); // Drains stdout + child.stdout.on('data', (data: Buffer) => { + errorLog += data.toString(); + }); child.stderr.on('data', (data: Buffer) => { errorLog += data.toString(); }); @@ -129,6 +135,31 @@ export class ComposeService { reject(error); }); }); + + // Post-Deploy Health Probe + await new Promise(resolve => setTimeout(resolve, 3000)); + + const dockerController = DockerController.getInstance(); + const containers = await dockerController.getDocker().listContainers({ + all: true, + filters: { label: [`com.docker.compose.project=${stackName}`] } + }); + + for (const containerInfo of containers) { + if (containerInfo.State === 'exited') { + const container = dockerController.getDocker().getContainer(containerInfo.Id); + const inspectData = await container.inspect(); + const exitCode = inspectData.State.ExitCode; + + if (exitCode !== 0) { + const logs = await container.logs({ stdout: true, stderr: true, tail: 50 }); + // Strip Docker log multiplex headers if they exist + let logStr = logs.toString('utf-8'); + throw new Error(`CONTAINER_CRASHED\nExit Code: ${exitCode}\n${logStr}`); + } + } + } + } /** @@ -335,7 +366,9 @@ export class ComposeService { let errorLog = ''; upProcess.stdout.on('data', (data: Buffer) => { - sendOutput(data.toString()); + const text = data.toString(); + errorLog += text; + sendOutput(text); }); upProcess.stderr.on('data', (data: Buffer) => { diff --git a/backend/src/services/DockerController.ts b/backend/src/services/DockerController.ts index 41c8a283..1e5b75a5 100644 --- a/backend/src/services/DockerController.ts +++ b/backend/src/services/DockerController.ts @@ -25,6 +25,10 @@ class DockerController { return DockerController.instance; } + public getDocker(): Docker { + return this.docker; + } + public async getDiskUsage() { const df = await this.docker.df(); diff --git a/backend/src/utils/ErrorParser.ts b/backend/src/utils/ErrorParser.ts new file mode 100644 index 00000000..5be2bd85 --- /dev/null +++ b/backend/src/utils/ErrorParser.ts @@ -0,0 +1,74 @@ +export interface ErrorRule { + id: string; + type: 'startup' | 'runtime' | 'ambiguous'; + pattern: RegExp; + getMessage: (matches: RegExpMatchArray) => string; + canSilentlyRollback: boolean; +} + +export class ErrorParser { + private static rules: ErrorRule[] = [ + { + id: 'PORT_CONFLICT', + type: 'startup', + pattern: /(?:bind: address already in use|ports are not available: exposing port TCP [^:]+:(\d+))/, + getMessage: (m) => `Port ${m[1] || 'conflict'} is already in use by another service on this server.`, + canSilentlyRollback: true + }, + { + id: 'NAME_CONFLICT', + type: 'startup', + pattern: /Conflict\. The container name "\/?([^"]+)" is already in use/, + getMessage: (m) => `A container named '${m[1]}' already exists.`, + canSilentlyRollback: true + }, + { + id: 'YAML_SYNTAX', + type: 'startup', + pattern: /(?:yaml: line (\d+):|mapping values are not allowed here)/, + getMessage: (m) => m[1] ? `Syntax error in compose.yaml near line ${m[1]}.` : `Syntax error in compose.yaml.`, + canSilentlyRollback: false + }, + { + id: 'MISSING_ENV', + type: 'startup', + pattern: /(?:invalid interpolation format|required variable ([^\s]+) is missing)/, + getMessage: (m) => `Missing required environment variable: ${m[1] || 'Check configuration'}.`, + canSilentlyRollback: false + }, + { + id: 'ARCH_MISMATCH', + type: 'runtime', + pattern: /(?:exec format error|does not match the specified platform)/i, + getMessage: () => `Architecture mismatch. This image is not compatible with this server's CPU architecture.`, + canSilentlyRollback: true + }, + { + id: 'MISSING_NETWORK', + type: 'startup', + pattern: /network ([^\s]+) declared as external, but could not be found/, + getMessage: (m) => `The external network '${m[1]}' does not exist.`, + canSilentlyRollback: false + }, + { + id: 'HOST_PORT_CONFLICT', + type: 'startup', + pattern: /host-mode networking can not work with published ports/, + getMessage: () => `Network mode 'host' cannot be combined with explicit port mappings.`, + canSilentlyRollback: false + } + ]; + + public static parse(errorOutput: string): { message: string, rule: ErrorRule | null } { + for (const rule of this.rules) { + const match = errorOutput.match(rule.pattern); + if (match) { + return { message: rule.getMessage(match), rule }; + } + } + // Fallback: Extract the last meaningful line, strip Docker progress bars + const lines = errorOutput.split('\n').map(l => l.trim()).filter(l => l.length > 0 && !l.includes('Downloading') && !l.includes('Extracting') && !l.includes('Pulling') && !l.includes('Download complete')); + const lastLine = lines.length > 0 ? lines[lines.length - 1] : 'Unknown deployment error occurred.'; + return { message: lastLine, rule: null }; + } +}