From ed1a60bee06a8e0a5280de058874c8abe8e62bad Mon Sep 17 00:00:00 2001 From: SaelixCode Date: Tue, 3 Mar 2026 15:25:05 -0500 Subject: [PATCH] feat: implement dynamic env file resolution and enhance error handling in stack operations --- backend/src/index.ts | 118 ++++++++++++++++++++--- backend/src/services/ComposeService.ts | 22 +++++ frontend/src/components/EditorLayout.tsx | 48 ++++++--- 3 files changed, 161 insertions(+), 27 deletions(-) diff --git a/backend/src/index.ts b/backend/src/index.ts index b05548a9..b154f3c4 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -18,6 +18,8 @@ import { HostTerminalService } from './services/HostTerminalService'; import { DatabaseService } from './services/DatabaseService'; import { NotificationService } from './services/NotificationService'; import { MonitorService } from './services/MonitorService'; +import YAML from 'yaml'; +import { promises as fsPromises } from 'fs'; const execAsync = promisify(exec); @@ -360,16 +362,77 @@ app.put('/api/stacks/:stackName', async (req: Request, res: Response) => { } }); +// Helper: resolve the env file path dynamically from compose.yaml's env_file field +async function resolveEnvFilePath(stackName: string): Promise { + const stackDir = path.join(fileSystemService.getBaseDir(), stackName); + const defaultEnvPath = path.join(stackDir, '.env'); + + try { + // Try to read and parse the compose file + const composeFiles = ['compose.yaml', 'compose.yml', 'docker-compose.yaml', 'docker-compose.yml']; + let composeContent: string | null = null; + + for (const file of composeFiles) { + try { + composeContent = await fsPromises.readFile(path.join(stackDir, file), 'utf-8'); + break; + } catch { + // Try next file + } + } + + if (!composeContent) return defaultEnvPath; + + const parsed = YAML.parse(composeContent); + if (!parsed?.services) return defaultEnvPath; + + // Iterate through services, looking for the first env_file declaration + for (const serviceName of Object.keys(parsed.services)) { + const service = parsed.services[serviceName]; + if (!service?.env_file) continue; + + let envFilePath: string; + + if (typeof service.env_file === 'string') { + envFilePath = service.env_file; + } else if (Array.isArray(service.env_file) && service.env_file.length > 0) { + // Handle array format: take the first entry + const first = service.env_file[0]; + envFilePath = typeof first === 'string' ? first : (first?.path || ''); + } else { + continue; + } + + if (!envFilePath) continue; + + // Resolve: absolute path stays as-is, relative path resolves against stackDir + if (path.isAbsolute(envFilePath)) { + return envFilePath; + } + return path.resolve(stackDir, envFilePath); + } + } catch (error) { + console.warn(`Could not parse compose.yaml for env_file resolution in stack "${stackName}":`, error); + } + + return defaultEnvPath; +} + app.get('/api/stacks/:stackName/env', async (req: Request, res: Response) => { try { const stackName = req.params.stackName as string; - const exists = await fileSystemService.envExists(stackName); - if (!exists) { + const envPath = await resolveEnvFilePath(stackName); + + try { + await fsPromises.access(envPath); + } catch { return res.status(404).json({ error: 'Env file not found' }); } - const content = await fileSystemService.getEnvContent(stackName); + + const content = await fsPromises.readFile(envPath, 'utf-8'); res.send(content); } catch (error) { + console.error('Failed to read env file:', error); res.status(500).json({ error: 'Failed to read env file' }); } }); @@ -384,7 +447,9 @@ app.put('/api/stacks/:stackName/env', async (req: Request, res: Response) => { if (typeof content !== 'string') { return res.status(400).json({ error: 'Content must be a string' }); } - await fileSystemService.saveEnvContent(stackName, content); + + const envPath = await resolveEnvFilePath(stackName); + await fsPromises.writeFile(envPath, content, 'utf-8'); res.json({ message: 'Env file saved successfully' }); } catch (error) { console.error('Failed to save env file:', error); @@ -502,33 +567,54 @@ app.post('/api/stacks/:stackName/down', async (req: Request, res: Response) => { app.post('/api/stacks/:stackName/restart', async (req: Request, res: Response) => { try { const stackName = req.params.stackName as string; - await composeService.runCommand(stackName, 'restart', terminalWs || undefined); - res.json({ status: 'Command started' }); - } catch (error) { + const dockerController = DockerController.getInstance(); + const containers = await dockerController.getContainersByStack(stackName); + + if (!containers || containers.length === 0) { + return res.status(404).json({ error: 'No containers found for this stack.' }); + } + + await Promise.all(containers.map(c => dockerController.restartContainer(c.Id))); + res.json({ success: true, message: 'Restart completed via Engine API.' }); + } catch (error: any) { console.error('Failed to restart containers:', error); - res.status(500).json({ error: 'Failed to restart containers' }); + res.status(500).json({ error: error.message || 'Failed to restart containers' }); } }); app.post('/api/stacks/:stackName/stop', async (req: Request, res: Response) => { try { const stackName = req.params.stackName as string; - await composeService.runCommand(stackName, 'stop', terminalWs || undefined); - res.json({ status: 'Command started' }); - } catch (error) { + const dockerController = DockerController.getInstance(); + const containers = await dockerController.getContainersByStack(stackName); + + if (!containers || containers.length === 0) { + return res.status(404).json({ error: 'No containers found for this stack.' }); + } + + await Promise.all(containers.map(c => dockerController.stopContainer(c.Id))); + res.json({ success: true, message: 'Stop completed via Engine API.' }); + } catch (error: any) { console.error('Failed to stop containers:', error); - res.status(500).json({ error: 'Failed to stop containers' }); + res.status(500).json({ error: error.message || 'Failed to stop containers' }); } }); app.post('/api/stacks/:stackName/start', async (req: Request, res: Response) => { try { const stackName = req.params.stackName as string; - await composeService.runCommand(stackName, 'start', terminalWs || undefined); - res.json({ status: 'Command started' }); - } catch (error) { + const dockerController = DockerController.getInstance(); + const containers = await dockerController.getContainersByStack(stackName); + + if (!containers || containers.length === 0) { + return res.status(404).json({ error: 'No containers found for this stack.' }); + } + + await Promise.all(containers.map(c => dockerController.startContainer(c.Id))); + res.json({ success: true, message: 'Start completed via Engine API.' }); + } catch (error: any) { console.error('Failed to start containers:', error); - res.status(500).json({ error: 'Failed to start containers' }); + res.status(500).json({ error: error.message || 'Failed to start containers' }); } }); diff --git a/backend/src/services/ComposeService.ts b/backend/src/services/ComposeService.ts index 0511ce6a..615c8d8f 100644 --- a/backend/src/services/ComposeService.ts +++ b/backend/src/services/ComposeService.ts @@ -77,6 +77,17 @@ export class ComposeService { async deployStack(stackName: string, ws?: WebSocket): Promise { const stackDir = path.join(this.baseDir, stackName); + try { + const dockerController = DockerController.getInstance(); + const legacyContainers = await dockerController.getContainersByStack(stackName); + if (legacyContainers && legacyContainers.length > 0) { + if (ws) ws.send(`=== Cleaning up existing containers for clean deployment ===\n`); + await dockerController.removeContainers(legacyContainers.map(c => c.Id)); + } + } catch (e) { + console.warn(`Failed to clean up legacy containers for ${stackName}:`, e); + } + return new Promise((resolve, reject) => { const args = ['compose', 'up', '-d', '--remove-orphans']; const child = spawn('docker', args, { @@ -263,6 +274,17 @@ export class ComposeService { } }; + try { + const dockerController = DockerController.getInstance(); + const legacyContainers = await dockerController.getContainersByStack(stackName); + if (legacyContainers && legacyContainers.length > 0) { + sendOutput(`=== Cleaning up existing containers for clean update ===\n`); + await dockerController.removeContainers(legacyContainers.map(c => c.Id)); + } + } catch (e) { + console.warn(`Failed to clean up legacy containers for ${stackName}:`, e); + } + // Step 1: Pull images sendOutput('=== Pulling latest images ===\n'); await new Promise((resolve, reject) => { diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 0a24a258..f422363b 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -351,9 +351,13 @@ export default function EditorLayout() { const stackName = selectedFile.replace(/\.(yml|yaml)$/, ''); setLoadingAction('deploy'); try { - await apiFetch(`/stacks/${stackName}/deploy`, { + const response = await apiFetch(`/stacks/${stackName}/deploy`, { method: 'POST', }); + if (!response.ok) { + const errText = await response.text(); + throw new Error(errText || 'Deploy failed'); + } toast.success("Stack deployed successfully!"); // Refresh containers after deploy const containersRes = await apiFetch(`/stacks/${stackName}/containers`); @@ -362,7 +366,7 @@ export default function EditorLayout() { await refreshStacks(true); } catch (error: any) { console.error('Failed to deploy:', error); - toast.error(error.message || "Failed to deploy stack"); + toast.error(error.message || 'Failed to deploy stack'); } finally { setLoadingAction(null); } @@ -375,16 +379,22 @@ export default function EditorLayout() { const stackName = selectedFile.replace(/\.(yml|yaml)$/, ''); setLoadingAction('stop'); try { - await apiFetch(`/stacks/${stackName}/stop`, { + const response = await apiFetch(`/stacks/${stackName}/stop`, { method: 'POST', }); + if (!response.ok) { + const errText = await response.text(); + throw new Error(errText || 'Stop failed'); + } + toast.success('Stack stopped successfully!'); // Refresh containers after stop const containersRes = await apiFetch(`/stacks/${stackName}/containers`); const conts = await containersRes.json(); setContainers(Array.isArray(conts) ? conts : []); await refreshStacks(true); - } catch (error) { + } catch (error: any) { console.error('Failed to stop:', error); + toast.error(error.message || 'Failed to stop stack'); } finally { setLoadingAction(null); } @@ -397,16 +407,22 @@ export default function EditorLayout() { const stackName = selectedFile.replace(/\.(yml|yaml)$/, ''); setLoadingAction('restart'); try { - await apiFetch(`/stacks/${stackName}/restart`, { + const response = await apiFetch(`/stacks/${stackName}/restart`, { method: 'POST', }); + if (!response.ok) { + const errText = await response.text(); + throw new Error(errText || 'Restart failed'); + } + toast.success('Stack restarted successfully!'); // Refresh containers after restart const containersRes = await apiFetch(`/stacks/${stackName}/containers`); const conts = await containersRes.json(); setContainers(Array.isArray(conts) ? conts : []); await refreshStacks(true); - } catch (error) { + } catch (error: any) { console.error('Failed to restart:', error); + toast.error(error.message || 'Failed to restart stack'); } finally { setLoadingAction(null); } @@ -419,16 +435,22 @@ export default function EditorLayout() { const stackName = selectedFile.replace(/\.(yml|yaml)$/, ''); setLoadingAction('update'); try { - await apiFetch(`/stacks/${stackName}/update`, { + const response = await apiFetch(`/stacks/${stackName}/update`, { method: 'POST', }); + if (!response.ok) { + const errText = await response.text(); + throw new Error(errText || 'Update failed'); + } + toast.success('Stack updated successfully!'); // Refresh containers after update const containersRes = await apiFetch(`/stacks/${stackName}/containers`); const conts = await containersRes.json(); setContainers(Array.isArray(conts) ? conts : []); await refreshStacks(true); - } catch (error) { + } catch (error: any) { console.error('Failed to update:', error); + toast.error(error.message || 'Failed to update stack'); } finally { setLoadingAction(null); } @@ -441,7 +463,11 @@ export default function EditorLayout() { const response = await apiFetch(`/stacks/${stackToDelete}`, { method: 'DELETE', }); - if (!response.ok) throw new Error('Failed to delete stack'); + if (!response.ok) { + const errText = await response.text(); + throw new Error(errText || 'Failed to delete stack'); + } + toast.success('Stack deleted successfully!'); setDeleteDialogOpen(false); setStackToDelete(null); if (selectedFile === stackToDelete) { @@ -455,9 +481,9 @@ export default function EditorLayout() { setIsEditing(false); } await refreshStacks(); - } catch (error) { + } catch (error: any) { console.error('Failed to delete stack:', error); - toast.error('Failed to delete stack'); + toast.error(error.message || 'Failed to delete stack'); } finally { setLoadingAction(null); }