mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
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.
This commit is contained in:
@@ -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<unknown[]>;
|
||||
smartFallback: (...a: unknown[]) => Promise<unknown[]>;
|
||||
};
|
||||
|
||||
function spyOrphanDc(
|
||||
dc: ReturnType<typeof DockerController.getInstance>,
|
||||
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<unknown[]> }, 'fetchComposePsContainers')
|
||||
.mockResolvedValue([{ ID: 'managed-c1', Name: 'web-1' }]);
|
||||
const fallbackSpy = vi.spyOn(dc as unknown as { smartFallback: (...a: unknown[]) => Promise<unknown[]> }, '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<unknown[]> }, 'fetchComposePsContainers')
|
||||
.mockResolvedValue([]);
|
||||
const fallbackSpy = vi.spyOn(dc as unknown as { smartFallback: (...a: unknown[]) => Promise<unknown[]> }, '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<unknown[]> }, 'fetchComposePsContainers')
|
||||
.mockRejectedValue(new Error('compose ps failed'));
|
||||
const fallbackSpy = vi.spyOn(dc as unknown as { smartFallback: (...a: unknown[]) => Promise<unknown[]> }, '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<string, string> = {}) {
|
||||
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' });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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<Array<{ Id: string }>> {
|
||||
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<string, string> | 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<any[]> {
|
||||
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<string, string>();
|
||||
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<string, string>();
|
||||
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<void> {
|
||||
|
||||
Reference in New Issue
Block a user