From 688767092a3b4be5a11709fac72ffcd7ffadb299 Mon Sep 17 00:00:00 2001 From: Anso Date: Sun, 26 Jul 2026 03:21:28 -0400 Subject: [PATCH] fix(stacks): fail closed when compose ps errors during update orphan classify (#1708) * fix(stacks): fail closed when compose ps errors during update orphan classify A thrown compose ps must not name-match containers as removable orphans; that destroyed healthy runtimes after transient ps failures post-acquire. * fix(stacks): fail closed on unparseable compose ps and gate orphan fallback Garbage compose ps stdout must throw, not look like an empty success. smartFallback also requires working_dir or config_files evidence so bare name collisions are not removed as orphans. * fix(stacks): barrier smartFallback compose path for CodeQL Validate stackName and resolve the compose file under the node compose root with an inline startsWith containment check before fs.readFile. --- .../src/__tests__/docker-controller.test.ts | 213 +++++++++++++-- backend/src/services/DockerController.ts | 252 ++++++++++-------- 2 files changed, 333 insertions(+), 132 deletions(-) diff --git a/backend/src/__tests__/docker-controller.test.ts b/backend/src/__tests__/docker-controller.test.ts index e55e7082..67591035 100644 --- a/backend/src/__tests__/docker-controller.test.ts +++ b/backend/src/__tests__/docker-controller.test.ts @@ -2,11 +2,11 @@ * Unit tests for DockerController — validateApiData, state-safe container ops, * disk usage, classified resources, orphan detection, and error paths. */ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; // ── Hoisted mocks ────────────────────────────────────────────────────── -const { mockDocker } = vi.hoisted(() => { +const { mockDocker, composeDirRef, mockExecFileAsync } = vi.hoisted(() => { const mockDocker = { df: vi.fn(), listImages: vi.fn().mockResolvedValue([]), @@ -23,7 +23,11 @@ const { mockDocker } = vi.hoisted(() => { pruneVolumes: vi.fn().mockResolvedValue({ SpaceReclaimed: 0 }), createNetwork: vi.fn(), }; - return { mockDocker }; + return { + mockDocker, + composeDirRef: { current: '/test/compose' }, + mockExecFileAsync: vi.fn(), + }; }); vi.mock('../services/NodeRegistry', () => ({ @@ -31,7 +35,16 @@ vi.mock('../services/NodeRegistry', () => ({ getInstance: () => ({ getDocker: () => mockDocker, getDefaultNodeId: () => 1, - getComposeDir: () => '/test/compose', + getComposeDir: () => composeDirRef.current, + }), + }, +})); + +vi.mock('../services/DatabaseService', () => ({ + DatabaseService: { + getInstance: () => ({ + getGitSource: () => undefined, + getStackProjectEnvFiles: () => [], }), }, })); @@ -42,14 +55,19 @@ vi.mock('child_process', () => ({ execFile: vi.fn(), })); vi.mock('util', () => ({ - promisify: () => vi.fn(), + promisify: () => mockExecFileAsync, })); import DockerController, { selectMainWebPort, parseExitCode, isContainerFailed } from '../services/DockerController'; import { CacheService } from '../services/CacheService'; +import fs from 'fs/promises'; +import os from 'os'; +import path from 'path'; beforeEach(() => { vi.clearAllMocks(); + composeDirRef.current = '/test/compose'; + mockExecFileAsync.mockResolvedValue({ stdout: '', stderr: '' }); }); // ── validateApiData ──────────────────────────────────────────────────── @@ -1694,13 +1712,23 @@ describe('DockerController - getBulkStackStatuses partial status', () => { // ── getLegacyOrphanContainersByStack (#1565) ─────────────────────────── +type OrphanDcSpies = { + fetchComposePsContainers: (...a: unknown[]) => Promise; + smartFallback: (...a: unknown[]) => Promise; +}; + +function spyOrphanDc( + dc: ReturnType, + method: keyof OrphanDcSpies, +) { + return vi.spyOn(dc as unknown as OrphanDcSpies, method); +} + describe('DockerController - getLegacyOrphanContainersByStack', () => { it('returns [] when compose ps already manages containers', async () => { const dc = DockerController.getInstance(1); - const fetchSpy = vi.spyOn(dc as unknown as { fetchComposePsContainers: (...a: unknown[]) => Promise }, 'fetchComposePsContainers') - .mockResolvedValue([{ ID: 'managed-c1', Name: 'web-1' }]); - const fallbackSpy = vi.spyOn(dc as unknown as { smartFallback: (...a: unknown[]) => Promise }, 'smartFallback') - .mockResolvedValue([{ Id: 'legacy-c1' }]); + const fetchSpy = spyOrphanDc(dc, 'fetchComposePsContainers').mockResolvedValue([{ ID: 'managed-c1', Name: 'web-1' }]); + const fallbackSpy = spyOrphanDc(dc, 'smartFallback').mockResolvedValue([{ Id: 'legacy-c1' }]); await expect(dc.getLegacyOrphanContainersByStack('my-stack')).resolves.toEqual([]); expect(fallbackSpy).not.toHaveBeenCalled(); @@ -1710,45 +1738,174 @@ describe('DockerController - getLegacyOrphanContainersByStack', () => { it('returns legacy orphan IDs when compose ps is empty', async () => { const dc = DockerController.getInstance(1); - const fetchSpy = vi.spyOn(dc as unknown as { fetchComposePsContainers: (...a: unknown[]) => Promise }, 'fetchComposePsContainers') - .mockResolvedValue([]); - const fallbackSpy = vi.spyOn(dc as unknown as { smartFallback: (...a: unknown[]) => Promise }, 'smartFallback') - .mockResolvedValue([{ Id: 'legacy-c1' }, { Id: '' }, {}]); + const fetchSpy = spyOrphanDc(dc, 'fetchComposePsContainers').mockResolvedValue([]); + const fallbackSpy = spyOrphanDc(dc, 'smartFallback').mockResolvedValue([{ Id: 'legacy-c1' }, { Id: '' }, {}]); await expect(dc.getLegacyOrphanContainersByStack('my-stack')).resolves.toEqual([{ Id: 'legacy-c1' }]); fetchSpy.mockRestore(); fallbackSpy.mockRestore(); }); - it('falls back to legacy orphan lookup when compose ps throws', async () => { + it('returns [] when compose ps throws, even if fallback would match containers', async () => { const dc = DockerController.getInstance(1); - const fetchSpy = vi.spyOn(dc as unknown as { fetchComposePsContainers: (...a: unknown[]) => Promise }, 'fetchComposePsContainers') - .mockRejectedValue(new Error('compose ps failed')); - const fallbackSpy = vi.spyOn(dc as unknown as { smartFallback: (...a: unknown[]) => Promise }, 'smartFallback') - .mockResolvedValue([{ Id: 'legacy-c2' }]); + const fetchSpy = spyOrphanDc(dc, 'fetchComposePsContainers').mockRejectedValue(new Error('compose ps failed')); + const fallbackSpy = spyOrphanDc(dc, 'smartFallback').mockResolvedValue([{ Id: 'running-compose-c2' }]); - await expect(dc.getLegacyOrphanContainersByStack('my-stack')).resolves.toEqual([{ Id: 'legacy-c2' }]); + await expect(dc.getLegacyOrphanContainersByStack('my-stack')).resolves.toEqual([]); + // Must not consult name-matching fallback: those IDs may be healthy Compose runtimes. + expect(fallbackSpy).not.toHaveBeenCalled(); fetchSpy.mockRestore(); fallbackSpy.mockRestore(); }); + + it('returns [] and skips fallback when compose ps stdout is unparseable', async () => { + mockExecFileAsync.mockResolvedValue({ + stdout: 'this is not json at all just random text { broken [', + stderr: '', + }); + const dc = DockerController.getInstance(1); + const fallbackSpy = spyOrphanDc(dc, 'smartFallback').mockResolvedValue([{ Id: 'running-c1' }]); + + await expect(dc.getLegacyOrphanContainersByStack('my-stack')).resolves.toEqual([]); + expect(fallbackSpy).not.toHaveBeenCalled(); + fallbackSpy.mockRestore(); + }); }); describe('DockerController - classifyLegacyOrphansForUpdate', () => { - it('returns none when compose ps already manages containers', async () => { - // Reuse the same mocks as getLegacyOrphanContainersByStack tests in this file. + async function classifyDc() { const { default: DockerController } = await import('../services/DockerController'); - const dc = DockerController.getInstance(1); - vi.spyOn(dc as any, 'fetchComposePsContainers').mockResolvedValue([{ ID: 'c1' }]); + return DockerController.getInstance(1); + } + + it('returns none when compose ps already manages containers', async () => { + const dc = await classifyDc(); + spyOrphanDc(dc, 'fetchComposePsContainers').mockResolvedValue([{ ID: 'c1' }]); await expect(dc.classifyLegacyOrphansForUpdate('my-stack')).resolves.toEqual({ status: 'none' }); }); - it('returns classification_failed when compose ps and fallback both fail', async () => { - const { default: DockerController } = await import('../services/DockerController'); - const dc = DockerController.getInstance(1); - vi.spyOn(dc as any, 'fetchComposePsContainers').mockRejectedValue(new Error('compose boom')); - vi.spyOn(dc as any, 'smartFallback').mockRejectedValue(new Error('fallback boom')); + it('returns orphans when compose ps is empty and fallback finds legacy containers', async () => { + const dc = await classifyDc(); + spyOrphanDc(dc, 'fetchComposePsContainers').mockResolvedValue([]); + spyOrphanDc(dc, 'smartFallback').mockResolvedValue([{ Id: 'legacy-c1' }, { Id: 'legacy-c2' }]); + await expect(dc.classifyLegacyOrphansForUpdate('my-stack')).resolves.toEqual({ + status: 'orphans', + ids: ['legacy-c1', 'legacy-c2'], + }); + }); + + it('returns classification_failed when compose ps throws, even if fallback would match containers', async () => { + const dc = await classifyDc(); + spyOrphanDc(dc, 'fetchComposePsContainers').mockRejectedValue(new Error('compose boom')); + const fallbackSpy = spyOrphanDc(dc, 'smartFallback').mockResolvedValue([{ Id: 'running-compose-c1' }]); + const result = await dc.classifyLegacyOrphansForUpdate('my-stack'); + expect(result).toEqual({ status: 'classification_failed', error: 'compose boom' }); + // Must not consult name-matching fallback: those IDs may be healthy Compose runtimes. + expect(fallbackSpy).not.toHaveBeenCalled(); + }); + + it('returns classification_failed when compose ps is empty and fallback throws', async () => { + const dc = await classifyDc(); + spyOrphanDc(dc, 'fetchComposePsContainers').mockResolvedValue([]); + spyOrphanDc(dc, 'smartFallback').mockRejectedValue(new Error('fallback boom')); + const result = await dc.classifyLegacyOrphansForUpdate('my-stack'); + expect(result).toEqual({ status: 'classification_failed', error: 'fallback boom' }); + }); + + it('returns classification_failed when compose ps stdout is unparseable', async () => { + mockExecFileAsync.mockResolvedValue({ + stdout: 'this is not json at all just random text { broken [', + stderr: '', + }); + const dc = await classifyDc(); + const fallbackSpy = spyOrphanDc(dc, 'smartFallback').mockResolvedValue([{ Id: 'running-c1' }]); const result = await dc.classifyLegacyOrphansForUpdate('my-stack'); expect(result.status).toBe('classification_failed'); + expect(String((result as { error?: string }).error ?? '')).toMatch(/unparseable JSON/i); + expect(fallbackSpy).not.toHaveBeenCalled(); + }); +}); + +describe('DockerController - smartFallback stack-dir evidence', () => { + const composeYaml = `services:\n web:\n image: nginx:alpine\n`; + let stackDir: string; + let tmpRoot: string; + + function runningContainer(id: string, name: string, labels: Record = {}) { + return { Id: id, Names: [name], Labels: labels, State: 'running', Status: 'Up', Ports: [] }; + } + + async function orphansWithEmptyPs() { + const dc = DockerController.getInstance(1); + spyOrphanDc(dc, 'fetchComposePsContainers').mockResolvedValue([]); + return dc.getLegacyOrphanContainersByStack('my-stack'); + } + + beforeEach(async () => { + tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'sencho-orphan-')); + composeDirRef.current = tmpRoot; + stackDir = path.join(tmpRoot, 'my-stack'); + await fs.mkdir(stackDir); + await fs.writeFile(path.join(stackDir, 'docker-compose.yml'), composeYaml); + }); + + afterEach(async () => { + composeDirRef.current = '/test/compose'; + await fs.rm(tmpRoot, { recursive: true, force: true }); + }); + + it('excludes name-matched containers without stack-dir evidence', async () => { + mockDocker.listContainers.mockResolvedValue([ + runningContainer('unrelated-web', '/web'), + ]); + await expect(orphansWithEmptyPs()).resolves.toEqual([]); + }); + + it('includes name-matched containers whose working_dir equals the stack dir', async () => { + mockDocker.listContainers.mockResolvedValue([ + runningContainer('legacy-web', '/web', { + 'com.docker.compose.project.working_dir': stackDir, + }), + ]); + await expect(orphansWithEmptyPs()).resolves.toEqual([{ Id: 'legacy-web' }]); + }); + + it('matches working_dir case-insensitively on win32', async () => { + if (process.platform !== 'win32') return; + mockDocker.listContainers.mockResolvedValue([ + runningContainer('legacy-web-case', '/web', { + 'com.docker.compose.project.working_dir': stackDir.toUpperCase(), + }), + ]); + await expect(orphansWithEmptyPs()).resolves.toEqual([{ Id: 'legacy-web-case' }]); + }); + + it('excludes name-matched containers whose working_dir points elsewhere', async () => { + mockDocker.listContainers.mockResolvedValue([ + runningContainer('other-stack-web', '/web', { + 'com.docker.compose.project.working_dir': path.join(tmpRoot, 'other-stack'), + }), + ]); + await expect(orphansWithEmptyPs()).resolves.toEqual([]); + }); + + it('includes name-matched containers whose config_files path is under the stack dir', async () => { + mockDocker.listContainers.mockResolvedValue([ + runningContainer('legacy-cfg', '/my-stack-web-1', { + 'com.docker.compose.project.config_files': path.join(stackDir, 'docker-compose.yml'), + }), + ]); + await expect(orphansWithEmptyPs()).resolves.toEqual([{ Id: 'legacy-cfg' }]); + }); + + it('surfaces listContainers failure as classification_failed on update', async () => { + mockDocker.listContainers.mockRejectedValue(new Error('daemon unavailable')); + + const { default: DockerController } = await import('../services/DockerController'); + const dc = DockerController.getInstance(1); + spyOrphanDc(dc, 'fetchComposePsContainers').mockResolvedValue([]); + const result = await dc.classifyLegacyOrphansForUpdate('my-stack'); + expect(result).toEqual({ status: 'classification_failed', error: 'daemon unavailable' }); }); }); diff --git a/backend/src/services/DockerController.ts b/backend/src/services/DockerController.ts index 7c46a91a..00ae367e 100644 --- a/backend/src/services/DockerController.ts +++ b/backend/src/services/DockerController.ts @@ -22,7 +22,7 @@ import { type PruneTarget, } from './prunePlan'; import type { NetworkingNetworkBase } from './network/networkingTypes'; -import { isPathWithinBase } from '../utils/validation'; +import { isPathWithinBase, isValidStackName } from '../utils/validation'; import { isDebugEnabled } from '../utils/debug'; import { getErrorMessage } from '../utils/errors'; import { sanitizeForLog } from '../utils/safeLog'; @@ -2230,8 +2230,12 @@ class DockerController { try { const lines = stdout.trim().split('\n').filter(line => line.trim() !== ''); containers = lines.map(line => JSON.parse(line) as ComposePsContainer); - } catch (innerError) { - console.error('Docker Compose JSON Parse Error for %s:', sanitizeForLog(stackName), sanitizeForLog(stderr || (parseError as Error).message)); + } catch { + const detail = stderr || (parseError as Error).message || 'unparseable compose ps output'; + console.error('Docker Compose JSON Parse Error for %s:', sanitizeForLog(stackName), sanitizeForLog(detail)); + // Fail closed: garbage stdout is not a successful empty ps. Callers must not + // treat this as "no containers" and run name-matched orphan removal. + throw new Error(`docker compose ps returned unparseable JSON: ${detail}`); } } } @@ -2242,6 +2246,9 @@ class DockerController { * Legacy orphan containers that Compose ps cannot see but would block a deploy * (wrong project labels). When Compose already manages the stack, returns [] so * deploy can rely on selective `compose up` recreation. + * A thrown `compose ps` returns [] (fail closed): name-matched fallback must not + * run, because those IDs may still be healthy Compose runtimes that deploy would + * then destroy before `compose up` (which often fails for the same reason). */ public async getLegacyOrphanContainersByStack(stackName: string): Promise> { const stackDir = path.join(NodeRegistry.getInstance().getComposeDir(this.nodeId), stackName); @@ -2249,26 +2256,36 @@ class DockerController { list.filter((c): c is { Id: string } => typeof c.Id === 'string' && c.Id.length > 0) .map((c) => ({ Id: c.Id })); + let composeContainers: ComposePsContainer[]; try { - const composeContainers = await this.fetchComposePsContainers(stackName, stackDir); - if (composeContainers.length > 0) return []; - return toIds(await this.smartFallback(stackName, stackDir)); + composeContainers = await this.fetchComposePsContainers(stackName, stackDir); } catch (error) { const execError = error as NodeJS.ErrnoException & { stderr?: string }; const mapped = describeSpawnError(execError, { command: 'docker compose ps' }); - const detail = execError.stderr || mapped.message; + const detail = execError.stderr || mapped.message || getErrorMessage(error, 'docker compose ps failed'); console.error('Docker Compose Error for %s:', sanitizeForLog(stackName), sanitizeForLog(detail)); - try { - return toIds(await this.smartFallback(stackName, stackDir)); - } catch { - return []; - } + // Fail closed: do not run smartFallback (see method JSDoc). + return []; + } + if (composeContainers.length > 0) return []; + // Empty successful ps: only then may smartFallback report leftovers (name + stack-dir evidence). + try { + return toIds(await this.smartFallback(stackName, stackDir)); + } catch (fallbackError) { + console.error( + 'Smart Fallback failed for %s:', + sanitizeForLog(stackName), + sanitizeForLog(getErrorMessage(fallbackError, 'smartFallback failed')), + ); + return []; } } /** * Strict Result API for full-stack updates. Never converts Compose-ps + fallback * failure into empty success. Compose-managed containers are never orphan IDs. + * A thrown `compose ps` is classification_failed (fail closed): name-matched + * fallback must not run, because those IDs may still be healthy Compose runtimes. */ public async classifyLegacyOrphansForUpdate( stackName: string, @@ -2302,18 +2319,15 @@ class DockerController { const composeContainers = await this.fetchComposePsContainers(stackName, stackDir); // Compose already manages this stack: no legacy orphan cleanup (same as deploy). if (composeContainers.length > 0) return { status: 'none' }; + // Empty successful ps: only then may smartFallback report leftovers (name + stack-dir evidence). return await fallbackOrphans(); } catch (error) { const execError = error as NodeJS.ErrnoException & { stderr?: string }; const mapped = describeSpawnError(execError, { command: 'docker compose ps' }); const detail = execError.stderr || mapped.message || getErrorMessage(error, 'docker compose ps failed'); console.error('Docker Compose Error for %s:', sanitizeForLog(stackName), sanitizeForLog(detail)); - // Unlike getLegacyOrphanContainersByStack, never convert dual failure into empty success. - const fallback = await fallbackOrphans(); - if (fallback.status === 'classification_failed') { - return { status: 'classification_failed', error: String(detail) }; - } - return fallback; + // Fail closed: do not run smartFallback (see method JSDoc). + return { status: 'classification_failed', error: String(detail) }; } } @@ -2349,11 +2363,6 @@ class DockerController { }); return await this.enrichContainers(mapped); } - - // SMART FALLBACK: Trigger when docker compose ps returns empty - // This handles legacy containers with incorrect project labels - return await this.enrichContainers(await this.smartFallback(stackName, stackDir)); - } catch (error) { // If command fails (e.g., stack not deployed, invalid YAML, missing env_file, // or host under memory pressure causing posix_spawn to fail with ENOMEM, @@ -2362,9 +2371,23 @@ class DockerController { const mapped = describeSpawnError(execError, { command: 'docker compose ps' }); const detail = execError.stderr || mapped.message; console.error('Docker Compose Error for %s:', sanitizeForLog(stackName), sanitizeForLog(detail)); + } - // Try smart fallback even on error + // Empty successful ps or compose error: soft fallback for UI listing (never break the page). + return this.listViaSoftSmartFallback(stackName, stackDir); + } + + /** UI listing path: smartFallback, or empty list if it throws. */ + private async listViaSoftSmartFallback(stackName: string, stackDir: string) { + try { return await this.enrichContainers(await this.smartFallback(stackName, stackDir)); + } catch (fallbackError) { + console.error( + 'Smart Fallback failed for %s:', + sanitizeForLog(stackName), + sanitizeForLog(getErrorMessage(fallbackError, 'smartFallback failed')), + ); + return this.enrichContainers([]); } } @@ -2388,95 +2411,116 @@ class DockerController { })); } + /** + * True when container labels bind this runtime to `stackDir` (working_dir or + * config_files). Name match alone is not enough for orphan removal. + */ + private static containerHasStackDirEvidence( + labels: Record | undefined, + stackDir: string, + ): boolean { + if (!labels) return false; + const resolvedStackDir = path.resolve(stackDir); + const pathKey = (p: string) => + process.platform === 'win32' ? path.resolve(p).toLowerCase() : path.resolve(p); + const stackKey = pathKey(resolvedStackDir); + + const workingDir = labels['com.docker.compose.project.working_dir']; + if (workingDir && pathKey(workingDir) === stackKey) return true; + + const firstFile = labels['com.docker.compose.project.config_files']?.split(',')[0]?.trim(); + if (!firstFile) return false; + const fileKey = pathKey(firstFile); + return fileKey === stackKey || fileKey.startsWith(stackKey + path.sep); + } + /** * 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. + * Handles containers with incorrect project labels that `docker compose ps` + * ignores. Name match alone is insufficient: a container must also have + * working_dir or config_files evidence for this stack directory. Errors + * propagate to callers (classify fail-closes; UI listing soft-catches). */ private async smartFallback(stackName: string, stackDir: string): Promise { - try { - // 1. Flexible Compose File Discovery - // Try multiple valid compose file names - const composeFileNames = COMPOSE_FILE_NAMES; - let yamlContent: string | null = null; + if (!isValidStackName(stackName)) { + throw new Error('Invalid stack path'); + } + // Canonical inline js/path-injection barrier at the fs.readFile sink below. + // CodeQL credits neither wrapped isPathWithinBase nor a barrier separated + // from the sink; resolve under the compose root and require containment. + const baseResolved = path.resolve(NodeRegistry.getInstance().getComposeDir(this.nodeId)); + const resolvedStackDir = path.resolve(baseResolved, stackName); + if (!resolvedStackDir.startsWith(baseResolved + path.sep)) { + throw new Error('Invalid stack path'); + } + // Prefer the validated path over the caller-provided stackDir. + stackDir = resolvedStackDir; - 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 + let yamlContent: string | null = null; + for (const fileName of COMPOSE_FILE_NAMES) { + try { + const composePath = path.resolve(stackDir, fileName); + if (!composePath.startsWith(stackDir + path.sep)) { continue; } + yamlContent = await fs.readFile(composePath, 'utf-8'); + break; + } catch { + 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[] = []; - const nameToService = new Map(); - for (const [serviceName, serviceConfig] of Object.entries(parsedYaml.services)) { - const config = serviceConfig as { container_name?: string }; - nameToService.set(serviceName, serviceName); - if (config.container_name) { - expectedNames.push(config.container_name); - nameToService.set(config.container_name, serviceName); - } 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 => { - const strippedName = c.Names?.[0]?.replace(/^\//, '') ?? ''; - const labelService = c.Labels?.['com.docker.compose.service']; - const service = (typeof labelService === 'string' && labelService.length > 0 - ? labelService - : nameToService.get(strippedName)) ?? ''; - let Ports: { PrivatePort: number, PublicPort: number, Type?: string }[] = []; - if (c.Ports && Array.isArray(c.Ports)) { - Ports = c.Ports - .filter((p: any) => typeof p.PublicPort === 'number' && p.PublicPort > 0) - .map((p: any) => ({ PrivatePort: (p.PrivatePort || 0) as number, PublicPort: p.PublicPort as number, Type: typeof p.Type === 'string' ? p.Type.toLowerCase() : undefined })); - } - return { - Id: c.Id, - Names: c.Names, - Service: service, - State: c.State, - Status: c.Status, - Labels: c.Labels, - Ports - }; - }); - } catch (fallbackError) { - console.error('Smart Fallback failed for %s:', sanitizeForLog(stackName), sanitizeForLog((fallbackError as Error)?.message ?? String(fallbackError))); - return []; } + if (!yamlContent) return []; + + const parsedYaml = yaml.parse(yamlContent); + if (!parsedYaml || !parsedYaml.services) return []; + + const expectedNames: string[] = []; + const nameToService = new Map(); + for (const [serviceName, serviceConfig] of Object.entries(parsedYaml.services)) { + const config = serviceConfig as { container_name?: string }; + nameToService.set(serviceName, serviceName); + if (config.container_name) { + expectedNames.push(config.container_name); + nameToService.set(config.container_name, serviceName); + } else { + expectedNames.push(serviceName); + expectedNames.push(`${stackName}-${serviceName}-1`); + // Legacy project prefixes for orphan containers + expectedNames.push(`compose-${serviceName}-1`); + expectedNames.push(`compose_${serviceName}_1`); + } + } + + const allContainers = await this.docker.listContainers({ all: true }); + const fallbackContainers = allContainers.filter(container => { + const nameMatch = container.Names.some(name => + expectedNames.includes(name.replace(/^\//, '')), + ); + return nameMatch && DockerController.containerHasStackDirEvidence(container.Labels, stackDir); + }); + + return fallbackContainers.map(c => { + const strippedName = c.Names?.[0]?.replace(/^\//, '') ?? ''; + const labelService = c.Labels?.['com.docker.compose.service']; + const service = (typeof labelService === 'string' && labelService.length > 0 + ? labelService + : nameToService.get(strippedName)) ?? ''; + let Ports: { PrivatePort: number, PublicPort: number, Type?: string }[] = []; + if (c.Ports && Array.isArray(c.Ports)) { + Ports = c.Ports + .filter((p: any) => typeof p.PublicPort === 'number' && p.PublicPort > 0) + .map((p: any) => ({ PrivatePort: (p.PrivatePort || 0) as number, PublicPort: p.PublicPort as number, Type: typeof p.Type === 'string' ? p.Type.toLowerCase() : undefined })); + } + return { + Id: c.Id, + Names: c.Names, + Service: service, + State: c.State, + Status: c.Status, + Labels: c.Labels, + Ports + }; + }); } public async streamContainerLogs(containerId: string, req: any, res: any): Promise {