feat: implement smart error parser and post-deploy health probe

This commit is contained in:
SaelixCode
2026-03-05 19:59:35 -05:00
parent d38d48fafe
commit 953049a45d
5 changed files with 135 additions and 10 deletions
+3
View File
@@ -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.
+17 -6
View File
@@ -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' });
+37 -4
View File
@@ -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<void>((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) => {
+4
View File
@@ -25,6 +25,10 @@ class DockerController {
return DockerController.instance;
}
public getDocker(): Docker {
return this.docker;
}
public async getDiskUsage() {
const df = await this.docker.df();
+74
View File
@@ -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 };
}
}