feat: implement dynamic env file resolution and enhance error handling in stack operations

This commit is contained in:
SaelixCode
2026-03-03 15:25:05 -05:00
parent 242602480e
commit ed1a60bee0
3 changed files with 161 additions and 27 deletions
+102 -16
View File
@@ -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<string> {
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' });
}
});
+22
View File
@@ -77,6 +77,17 @@ export class ComposeService {
async deployStack(stackName: string, ws?: WebSocket): Promise<void> {
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<void>((resolve, reject) => {
+37 -11
View File
@@ -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);
}