-
Notifications
You must be signed in to change notification settings - Fork 3.1k
feat(status): classify failing layer when gateway probe fails (#3271) #3309
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
kagura-agent
wants to merge
2
commits into
NVIDIA:main
from
kagura-agent:feat/status-failure-layer-classifier
Closed
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,121 @@ | ||
| // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| import net from "node:net"; | ||
| import { execSync } from "node:child_process"; | ||
|
|
||
| import { GATEWAY_PORT } from "../../core/ports"; | ||
|
|
||
| const DEFAULT_CONTAINER = "openshell-cluster-nemoclaw"; | ||
| const DOCKER_TIMEOUT_MS = 3000; | ||
| const PORT_PROBE_TIMEOUT_MS = 2000; | ||
|
|
||
| export type GatewayFailureLayer = | ||
| | "docker_unreachable" | ||
| | "container_exited_port_conflict" | ||
| | "container_exited" | ||
| | "gateway_unreachable"; | ||
|
|
||
| export type GatewayFailureResult = { | ||
| layer: GatewayFailureLayer; | ||
| detail: string; | ||
| }; | ||
|
|
||
| export type GatewayFailureRunners = { | ||
| dockerInfo: () => boolean; | ||
| dockerIsRunning: (container: string) => boolean; | ||
| portProbe: (port: number) => Promise<boolean>; | ||
| }; | ||
|
|
||
| function defaultDockerInfo(): boolean { | ||
| try { | ||
| execSync("docker info", { timeout: DOCKER_TIMEOUT_MS, stdio: "pipe" }); | ||
| return true; | ||
| } catch { | ||
| return false; | ||
| } | ||
| } | ||
|
|
||
| function defaultDockerIsRunning(container: string): boolean { | ||
| try { | ||
| const out = execSync(`docker ps --filter name=${container} --format "{{.Names}}"`, { | ||
| timeout: DOCKER_TIMEOUT_MS, | ||
| stdio: "pipe", | ||
| encoding: "utf-8", | ||
| }); | ||
| return out.trim().split("\n").some((line) => line.trim() === container); | ||
| } catch { | ||
| return false; | ||
| } | ||
| } | ||
|
|
||
| function defaultPortProbe(port: number): Promise<boolean> { | ||
| return new Promise((resolve) => { | ||
| const sock = net.connect({ host: "127.0.0.1", port }, () => { | ||
| sock.destroy(); | ||
| resolve(true); | ||
| }); | ||
| sock.setTimeout(PORT_PROBE_TIMEOUT_MS); | ||
| sock.on("timeout", () => { | ||
| sock.destroy(); | ||
| resolve(false); | ||
| }); | ||
| sock.on("error", () => { | ||
| resolve(false); | ||
| }); | ||
| }); | ||
| } | ||
|
|
||
| const defaultRunners: GatewayFailureRunners = { | ||
| dockerInfo: defaultDockerInfo, | ||
| dockerIsRunning: defaultDockerIsRunning, | ||
| portProbe: defaultPortProbe, | ||
| }; | ||
|
|
||
| export async function classifyGatewayFailure( | ||
| _sandboxName: string, | ||
| opts?: { runners?: GatewayFailureRunners }, | ||
| ): Promise<GatewayFailureResult> { | ||
| const runners = opts?.runners ?? defaultRunners; | ||
|
|
||
| if (!runners.dockerInfo()) { | ||
| return { | ||
| layer: "docker_unreachable", | ||
| detail: "Docker daemon is not reachable (docker info failed or timed out).", | ||
| }; | ||
| } | ||
|
|
||
| const containerRunning = runners.dockerIsRunning(DEFAULT_CONTAINER); | ||
|
|
||
| if (!containerRunning) { | ||
| const portInUse = await runners.portProbe(GATEWAY_PORT); | ||
| if (portInUse) { | ||
| return { | ||
| layer: "container_exited_port_conflict", | ||
| detail: `Container '${DEFAULT_CONTAINER}' is not running, but port ${GATEWAY_PORT} is held by another process.`, | ||
| }; | ||
| } | ||
| return { | ||
| layer: "container_exited", | ||
| detail: `Container '${DEFAULT_CONTAINER}' is not running.`, | ||
| }; | ||
| } | ||
|
|
||
| return { | ||
| layer: "gateway_unreachable", | ||
| detail: `Container '${DEFAULT_CONTAINER}' is running but the gateway API is not responding.`, | ||
| }; | ||
| } | ||
|
|
||
| const LAYER_HEADERS: Record<GatewayFailureLayer, string> = { | ||
| docker_unreachable: "Failure layer: docker_unreachable — Docker daemon is not reachable.", | ||
| container_exited_port_conflict: | ||
| "Failure layer: container_exited_port_conflict — container stopped, gateway port held by foreign process.", | ||
| container_exited: "Failure layer: container_exited — container is not running.", | ||
| gateway_unreachable: | ||
| "Failure layer: gateway_unreachable — container running but gateway API unresponsive.", | ||
| }; | ||
|
|
||
| export function getLayerHeader(layer: GatewayFailureLayer): string { | ||
| return LAYER_HEADERS[layer]; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,89 @@ | ||
| // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | ||
| // SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| import { describe, expect, it } from "vitest"; | ||
|
|
||
| import { | ||
| classifyGatewayFailure, | ||
| getLayerHeader, | ||
| type GatewayFailureRunners, | ||
| } from "../dist/lib/actions/sandbox/gateway-failure-classifier.js"; | ||
|
|
||
| function makeRunners(overrides: Partial<GatewayFailureRunners> = {}): GatewayFailureRunners { | ||
| return { | ||
| dockerInfo: () => true, | ||
| dockerIsRunning: () => true, | ||
| portProbe: async () => false, | ||
| ...overrides, | ||
| }; | ||
| } | ||
|
|
||
| describe("classifyGatewayFailure", () => { | ||
| it("returns docker_unreachable when docker info fails", async () => { | ||
| const result = await classifyGatewayFailure("my-sandbox", { | ||
| runners: makeRunners({ dockerInfo: () => false }), | ||
| }); | ||
| expect(result.layer).toBe("docker_unreachable"); | ||
| expect(result.detail).toContain("Docker daemon"); | ||
| }); | ||
|
|
||
| it("returns container_exited_port_conflict when container is stopped and port is in use", async () => { | ||
| const result = await classifyGatewayFailure("my-sandbox", { | ||
| runners: makeRunners({ | ||
| dockerIsRunning: () => false, | ||
| portProbe: async () => true, | ||
| }), | ||
| }); | ||
| expect(result.layer).toBe("container_exited_port_conflict"); | ||
| expect(result.detail).toContain("port"); | ||
| expect(result.detail).toContain("another process"); | ||
| }); | ||
|
|
||
| it("returns container_exited when container is stopped and port is free", async () => { | ||
| const result = await classifyGatewayFailure("my-sandbox", { | ||
| runners: makeRunners({ | ||
| dockerIsRunning: () => false, | ||
| portProbe: async () => false, | ||
| }), | ||
| }); | ||
| expect(result.layer).toBe("container_exited"); | ||
| expect(result.detail).toContain("not running"); | ||
| }); | ||
|
|
||
| it("returns gateway_unreachable when container is running but gateway unresponsive", async () => { | ||
| const result = await classifyGatewayFailure("my-sandbox", { | ||
| runners: makeRunners(), | ||
| }); | ||
| expect(result.layer).toBe("gateway_unreachable"); | ||
| expect(result.detail).toContain("not responding"); | ||
| }); | ||
|
|
||
| it("does not call dockerIsRunning or portProbe when docker info fails", async () => { | ||
| let dockerIsRunningCalled = false; | ||
| let portProbeCalled = false; | ||
| await classifyGatewayFailure("my-sandbox", { | ||
| runners: makeRunners({ | ||
| dockerInfo: () => false, | ||
| dockerIsRunning: () => { | ||
| dockerIsRunningCalled = true; | ||
| return false; | ||
| }, | ||
| portProbe: async () => { | ||
| portProbeCalled = true; | ||
| return false; | ||
| }, | ||
| }), | ||
| }); | ||
| expect(dockerIsRunningCalled).toBe(false); | ||
| expect(portProbeCalled).toBe(false); | ||
| }); | ||
| }); | ||
|
|
||
| describe("getLayerHeader", () => { | ||
| it("returns a header containing the layer name", () => { | ||
| expect(getLayerHeader("docker_unreachable")).toContain("docker_unreachable"); | ||
| expect(getLayerHeader("container_exited_port_conflict")).toContain("container_exited_port_conflict"); | ||
| expect(getLayerHeader("container_exited")).toContain("container_exited"); | ||
| expect(getLayerHeader("gateway_unreachable")).toContain("gateway_unreachable"); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Classifier skips the required exited-container presence check.
The
container_exited*paths currently trigger whenever the container is “not running”. This misses the explicit “container exists/exited (docker ps -a)" condition, so a missing container can be mislabeled as exited (or exited+port-conflict).Proposed fix
export type GatewayFailureRunners = { dockerInfo: () => boolean; dockerIsRunning: (container: string) => boolean; + dockerExists: (container: string) => boolean; portProbe: (port: number) => Promise<boolean>; }; +function defaultDockerExists(container: string): boolean { + try { + const out = execSync(`docker ps -a --filter name=${container} --format "{{.Names}}"`, { + timeout: DOCKER_TIMEOUT_MS, + stdio: "pipe", + encoding: "utf-8", + }); + return out.trim().split("\n").some((line) => line.trim() === container); + } catch { + return false; + } +} + const defaultRunners: GatewayFailureRunners = { dockerInfo: defaultDockerInfo, dockerIsRunning: defaultDockerIsRunning, + dockerExists: defaultDockerExists, portProbe: defaultPortProbe, }; @@ const containerRunning = runners.dockerIsRunning(DEFAULT_CONTAINER); + const containerExists = runners.dockerExists(DEFAULT_CONTAINER); - if (!containerRunning) { + if (!containerRunning && containerExists) { const portInUse = await runners.portProbe(GATEWAY_PORT); if (portInUse) { return { layer: "container_exited_port_conflict", detail: `Container '${DEFAULT_CONTAINER}' is not running, but port ${GATEWAY_PORT} is held by another process.`, }; } return { layer: "container_exited", detail: `Container '${DEFAULT_CONTAINER}' is not running.`, }; }Also applies to: 88-101
🤖 Prompt for AI Agents