From e94b706a9fc9508a299335ae6d108b711e0c9e38 Mon Sep 17 00:00:00 2001 From: SaelixCode Date: Fri, 20 Feb 2026 23:37:37 -0500 Subject: [PATCH] refactor: enhance DockerController with smart fallback for legacy containers; add YAML parsing support and improve container retrieval logic --- backend/package-lock.json | 14 ++- backend/package.json | 4 +- backend/src/services/DockerController.ts | 140 ++++++++++++++++++----- 3 files changed, 128 insertions(+), 30 deletions(-) diff --git a/backend/package-lock.json b/backend/package-lock.json index 965256bd..ce4d1ad5 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -21,13 +21,15 @@ "express": "^5.2.1", "jsonwebtoken": "^9.0.3", "systeminformation": "^5.31.1", - "ws": "^8.19.0" + "ws": "^8.19.0", + "yaml": "^2.8.2" }, "devDependencies": { "@types/bcrypt": "^6.0.0", "@types/cookie-parser": "^1.4.10", "@types/jsonwebtoken": "^9.0.10", "@types/node": "^25.3.0", + "@types/yaml": "^1.9.6", "nodemon": "^3.1.13", "ts-node": "^10.9.2", "typescript": "^5.9.3" @@ -422,6 +424,16 @@ "@types/node": "*" } }, + "node_modules/@types/yaml": { + "version": "1.9.6", + "resolved": "https://registry.npmjs.org/@types/yaml/-/yaml-1.9.6.tgz", + "integrity": "sha512-VKOCuDN57wngmyQnRqcn4vuGWCXViISHv+UCCjrKcf1yt4zyfMmOGlZDI2ucTHK72V8ki+sd7h21OZL6O5S52A==", + "dev": true, + "license": "MIT", + "dependencies": { + "yaml": "*" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", diff --git a/backend/package.json b/backend/package.json index 092e3776..f078bf81 100644 --- a/backend/package.json +++ b/backend/package.json @@ -18,6 +18,7 @@ "@types/cookie-parser": "^1.4.10", "@types/jsonwebtoken": "^9.0.10", "@types/node": "^25.3.0", + "@types/yaml": "^1.9.6", "nodemon": "^3.1.13", "ts-node": "^10.9.2", "typescript": "^5.9.3" @@ -35,6 +36,7 @@ "express": "^5.2.1", "jsonwebtoken": "^9.0.3", "systeminformation": "^5.31.1", - "ws": "^8.19.0" + "ws": "^8.19.0", + "yaml": "^2.8.2" } } diff --git a/backend/src/services/DockerController.ts b/backend/src/services/DockerController.ts index df51a99f..9fc5119a 100644 --- a/backend/src/services/DockerController.ts +++ b/backend/src/services/DockerController.ts @@ -4,6 +4,8 @@ import { Duplex } from 'stream'; import { exec } from 'child_process'; import { promisify } from 'util'; import path from 'path'; +import fs from 'fs/promises'; +import * as yaml from 'yaml'; const execAsync = promisify(exec); const COMPOSE_DIR = process.env.COMPOSE_DIR || '/app/compose'; @@ -36,8 +38,9 @@ class DockerController { } public async getContainersByStack(stackName: string) { + const stackDir = path.join(COMPOSE_DIR, stackName); + try { - const stackDir = path.join(COMPOSE_DIR, stackName); const { stdout, stderr } = await execAsync('docker compose ps --format json -a', { cwd: stackDir, env: { @@ -46,11 +49,6 @@ class DockerController { } }); - // Guard clause for empty output (stack has no containers) - if (!stdout || stdout.trim() === '') { - return []; - } - // Robust JSON parsing - handle both JSON array and newline-separated JSON objects // Docker Compose v2 may return either format depending on version interface ComposeContainer { @@ -60,36 +58,122 @@ class DockerController { Status?: string; } - let containers: ComposeContainer[]; - try { - // Try parsing as a standard JSON array - const parsed = JSON.parse(stdout); - containers = Array.isArray(parsed) ? parsed : [parsed]; - } catch (parseError) { - // Fallback: parse newline-separated JSON objects, filtering out empty lines + let containers: ComposeContainer[] = []; + + // Only parse if stdout has content + if (stdout && stdout.trim() !== '') { try { - const lines = stdout.trim().split('\n').filter(line => line.trim() !== ''); - containers = lines.map(line => JSON.parse(line) as ComposeContainer); - } catch (innerError) { - // Log parsing failure with stderr for debugging - console.error(`Docker Compose JSON Parse Error for ${stackName}:`, stderr || (parseError as Error).message); - return []; + // Try parsing as a standard JSON array + const parsed = JSON.parse(stdout); + containers = Array.isArray(parsed) ? parsed : [parsed]; + } catch (parseError) { + // Fallback: parse newline-separated JSON objects, filtering out empty lines + try { + const lines = stdout.trim().split('\n').filter(line => line.trim() !== ''); + containers = lines.map(line => JSON.parse(line) as ComposeContainer); + } catch (innerError) { + // Log parsing failure with stderr for debugging + console.error(`Docker Compose JSON Parse Error for ${stackName}:`, stderr || (parseError as Error).message); + // Don't return empty - trigger smart fallback below + } } } - // Map to frontend's expected interface - // Note: docker compose ps returns Name (singular), but frontend expects Names (array) - // Dockerode returns Names with leading slash, so we add it for compatibility - return containers.map((c) => ({ - Id: c.ID || '', - Names: ['/' + (c.Name || '')], // Add leading slash to match Dockerode format - State: c.State || 'unknown', - Status: c.Status || '' - })); + // If containers found via docker compose ps, return them + if (containers.length > 0) { + // Map to frontend's expected interface + // Note: docker compose ps returns Name (singular), but frontend expects Names (array) + // Dockerode returns Names with leading slash, so we add it for compatibility + return containers.map((c) => ({ + Id: c.ID || '', + Names: ['/' + (c.Name || '')], // Add leading slash to match Dockerode format + State: c.State || 'unknown', + Status: c.Status || '' + })); + } + + // SMART FALLBACK: Trigger when docker compose ps returns empty + // This handles legacy containers with incorrect project labels + return await this.smartFallback(stackName, stackDir); + } catch (error) { // If command fails (e.g., stack not deployed, invalid YAML, missing env_file) const execError = error as { stderr?: string; message?: string }; console.error(`Docker Compose Error for ${stackName}:`, execError.stderr || execError.message); + + // Try smart fallback even on error + return await this.smartFallback(stackName, stackDir); + } + } + + /** + * Smart Fallback: Find legacy containers by parsing compose YAML definitions. + * This handles containers that were deployed with incorrect project labels + * that cause `docker compose ps` to ignore them. + */ + private async smartFallback(stackName: string, stackDir: string): Promise { + try { + // 1. Flexible Compose File Discovery + // Try multiple valid compose file names + const composeFileNames = ['compose.yaml', 'docker-compose.yml', 'compose.yml', 'docker-compose.yaml']; + let yamlContent: string | null = null; + + for (const fileName of composeFileNames) { + try { + yamlContent = await fs.readFile(path.join(stackDir, fileName), 'utf-8'); + break; // Successfully read a file, stop trying + } catch { + // File doesn't exist, try next + continue; + } + } + + if (!yamlContent) { + // No compose file found + return []; + } + + const parsedYaml = yaml.parse(yamlContent); + + if (!parsedYaml || !parsedYaml.services) return []; + + // 2. Extract expected container names with legacy prefix support + const expectedNames: string[] = []; + for (const [serviceName, serviceConfig] of Object.entries(parsedYaml.services)) { + const config = serviceConfig as any; + if (config.container_name) { + expectedNames.push(config.container_name); + } else { + // Standard v2 naming + expectedNames.push(serviceName); + expectedNames.push(`${stackName}-${serviceName}-1`); + // Legacy project prefix catch - accounts for orphan containers + expectedNames.push(`compose-${serviceName}-1`); + expectedNames.push(`compose_${serviceName}_1`); + } + } + + // 3. Query the raw Docker daemon + const allContainers = await this.docker.listContainers({ all: true }); + + // 4. Match containers by name + const fallbackContainers = allContainers.filter(container => { + // container.Names usually looks like ['/plex'] + return container.Names.some(name => { + const strippedName = name.replace(/^\//, ''); + return expectedNames.includes(strippedName); + }); + }); + + // 5. Map to the frontend interface + return fallbackContainers.map(c => ({ + Id: c.Id, + Names: c.Names, + State: c.State, + Status: c.Status + })); + } catch (fallbackError) { + console.error(`Smart Fallback failed for ${stackName}:`, fallbackError); return []; } }