fix: stop compose-ps spam after deleting a stack (#1831)

* fix: stop compose-ps spam after deleting a stack

A live logs WebSocket kept calling docker compose ps against the removed
stack directory every 2s. Missing cwd surfaces as spawn ENOENT, which was
logged as Docker CLI unavailable. Skip compose when the dir is gone and
idle the log stream instead of retrying.

* fix: contain stack directory stats inside the compose root

Resolve the stack path against the node's compose directory and refuse
paths that escape it before calling fs.stat, matching the existing
filesystem containment pattern at other sinks.
This commit is contained in:
Anso
2026-08-14 09:24:48 -04:00
committed by GitHub
parent 6a610562a2
commit 4c93947004
4 changed files with 1833 additions and 1631 deletions
+26
View File
@@ -2480,6 +2480,25 @@ class DockerController {
return result;
}
/**
* True when the stack directory is missing (ENOENT) or is not a directory.
* Other stat failures return false so callers keep the existing compose-ps path.
*/
private async isStackDirectoryAbsent(stackName: string): Promise<boolean> {
// Canonical js/path-injection barrier at the fs.stat sink.
const baseResolved = path.resolve(NodeRegistry.getInstance().getComposeDir(this.nodeId));
const stackDir = path.resolve(baseResolved, stackName);
if (!stackDir.startsWith(baseResolved + path.sep)) {
return true;
}
try {
const st = await fs.stat(stackDir);
return !st.isDirectory();
} catch (error) {
return (error as NodeJS.ErrnoException).code === 'ENOENT';
}
}
/**
* Containers visible to `docker compose ps` for this stack. Empty when Compose
* does not manage any containers (including first deploy or mislabeled legacy).
@@ -2530,6 +2549,7 @@ class DockerController {
*/
public async getLegacyOrphanContainersByStack(stackName: string): Promise<Array<{ Id: string }>> {
const stackDir = path.join(NodeRegistry.getInstance().getComposeDir(this.nodeId), stackName);
if (await this.isStackDirectoryAbsent(stackName)) return [];
const toIds = (list: Array<{ Id?: string }>) =>
list.filter((c): c is { Id: string } => typeof c.Id === 'string' && c.Id.length > 0)
.map((c) => ({ Id: c.Id }));
@@ -2573,6 +2593,9 @@ class DockerController {
| { status: 'classification_failed'; error: string }
> {
const stackDir = path.join(NodeRegistry.getInstance().getComposeDir(this.nodeId), stackName);
if (await this.isStackDirectoryAbsent(stackName)) {
return { status: 'classification_failed', error: 'Stack directory is gone' };
}
const toIds = (list: Array<{ Id?: string }>) =>
list.filter((c): c is { Id: string } => typeof c.Id === 'string' && c.Id.length > 0)
.map((c) => c.Id);
@@ -2614,6 +2637,9 @@ class DockerController {
// not the process default, so a non-default local node sees its own stack dir
// and deploy spec.
const stackDir = path.join(NodeRegistry.getInstance().getComposeDir(this.nodeId), stackName);
if (await this.isStackDirectoryAbsent(stackName)) {
return this.enrichContainers([]);
}
try {
const containers = await this.fetchComposePsContainers(stackName, stackDir);