Gateway: treat scope-limited probe RPC as degraded

This commit is contained in:
Mainframe
2026-03-13 20:58:28 -05:00
committed by joshavant
parent bed661609e
commit 2bea8ef91b
4 changed files with 127 additions and 4 deletions

View File

@@ -201,6 +201,54 @@ describe("gateway-status command", () => {
expect(targets[0]?.summary).toBeTruthy();
});
it("treats missing-scope RPC probe failures as degraded but reachable", async () => {
const { runtime, runtimeLogs, runtimeErrors } = createRuntimeCapture();
readBestEffortConfig.mockResolvedValueOnce({
gateway: {
mode: "local",
auth: { mode: "token", token: "ltok" },
},
} as never);
probeGateway.mockResolvedValueOnce({
ok: false,
url: "ws://127.0.0.1:18789",
connectLatencyMs: 51,
error: "missing scope: operator.read",
close: null,
health: null,
status: null,
presence: null,
configSnapshot: null,
});
await runGatewayStatus(runtime, { timeout: "1000", json: true });
expect(runtimeErrors).toHaveLength(0);
const parsed = JSON.parse(runtimeLogs.join("\n")) as {
ok?: boolean;
degraded?: boolean;
warnings?: Array<{ code?: string; targetIds?: string[] }>;
targets?: Array<{
connect?: {
ok?: boolean;
rpcOk?: boolean;
scopeLimited?: boolean;
};
}>;
};
expect(parsed.ok).toBe(true);
expect(parsed.degraded).toBe(true);
expect(parsed.targets?.[0]?.connect).toMatchObject({
ok: true,
rpcOk: false,
scopeLimited: true,
});
const scopeLimitedWarning = parsed.warnings?.find(
(warning) => warning.code === "probe_scope_limited",
);
expect(scopeLimitedWarning?.targetIds).toContain("localLoopback");
});
it("surfaces unresolved SecretRef auth diagnostics in warnings", async () => {
const { runtime, runtimeLogs, runtimeErrors } = createRuntimeCapture();
await withEnvAsync({ MISSING_GATEWAY_TOKEN: undefined }, async () => {

View File

@@ -10,6 +10,8 @@ import { colorize, isRich, theme } from "../terminal/theme.js";
import {
buildNetworkHints,
extractConfigSummary,
isProbeReachable,
isScopeLimitedProbeFailure,
type GatewayStatusTarget,
parseTimeoutMs,
pickGatewaySelfPresence,
@@ -193,8 +195,10 @@ export async function gatewayStatusCommand(
},
);
const reachable = probed.filter((p) => p.probe.ok);
const reachable = probed.filter((p) => isProbeReachable(p.probe));
const ok = reachable.length > 0;
const degradedScopeLimited = probed.filter((p) => isScopeLimitedProbeFailure(p.probe));
const degraded = degradedScopeLimited.length > 0;
const multipleGateways = reachable.length > 1;
const primary =
reachable.find((p) => p.target.kind === "explicit") ??
@@ -236,12 +240,21 @@ export async function gatewayStatusCommand(
});
}
}
for (const result of degradedScopeLimited) {
warnings.push({
code: "probe_scope_limited",
message:
"Probe diagnostics are limited by gateway scopes (missing operator.read). Connection succeeded, but status details may be incomplete. Hint: pair device identity or use credentials with operator.read.",
targetIds: [result.target.id],
});
}
if (opts.json) {
runtime.log(
JSON.stringify(
{
ok,
degraded,
ts: Date.now(),
durationMs: Date.now() - startedAt,
timeoutMs: overallTimeoutMs,
@@ -274,7 +287,9 @@ export async function gatewayStatusCommand(
active: p.target.active,
tunnel: p.target.tunnel ?? null,
connect: {
ok: p.probe.ok,
ok: isProbeReachable(p.probe),
rpcOk: p.probe.ok,
scopeLimited: isScopeLimitedProbeFailure(p.probe),
latencyMs: p.probe.connectLatencyMs,
error: p.probe.error,
close: p.probe.close,

View File

@@ -1,6 +1,12 @@
import { describe, expect, it } from "vitest";
import { withEnvAsync } from "../../test-utils/env.js";
import { extractConfigSummary, resolveAuthForTarget } from "./helpers.js";
import {
extractConfigSummary,
isProbeReachable,
isScopeLimitedProbeFailure,
renderProbeSummaryLine,
resolveAuthForTarget,
} from "./helpers.js";
describe("extractConfigSummary", () => {
it("marks SecretRef-backed gateway auth credentials as configured", () => {
@@ -229,3 +235,41 @@ describe("resolveAuthForTarget", () => {
);
});
});
describe("probe reachability classification", () => {
it("treats missing-scope RPC failures as scope-limited and reachable", () => {
const probe = {
ok: false,
url: "ws://127.0.0.1:18789",
connectLatencyMs: 51,
error: "missing scope: operator.read",
close: null,
health: null,
status: null,
presence: null,
configSnapshot: null,
};
expect(isScopeLimitedProbeFailure(probe)).toBe(true);
expect(isProbeReachable(probe)).toBe(true);
expect(renderProbeSummaryLine(probe, false)).toContain("RPC: limited");
});
it("keeps non-scope RPC failures as unreachable", () => {
const probe = {
ok: false,
url: "ws://127.0.0.1:18789",
connectLatencyMs: 43,
error: "unknown method: status",
close: null,
health: null,
status: null,
presence: null,
configSnapshot: null,
};
expect(isScopeLimitedProbeFailure(probe)).toBe(false);
expect(isProbeReachable(probe)).toBe(false);
expect(renderProbeSummaryLine(probe, false)).toContain("RPC: failed");
});
});

View File

@@ -9,6 +9,8 @@ import { pickPrimaryTailnetIPv4 } from "../../infra/tailnet.js";
import { colorize, theme } from "../../terminal/theme.js";
import { pickGatewaySelfPresence } from "../gateway-presence.js";
const MISSING_SCOPE_PATTERN = /\bmissing scope:\s*[a-z0-9._-]+/i;
type TargetKind = "explicit" | "configRemote" | "localLoopback" | "sshTunnel";
export type GatewayStatusTarget = {
@@ -324,6 +326,17 @@ export function renderTargetHeader(target: GatewayStatusTarget, rich: boolean) {
return `${colorize(rich, theme.heading, kindLabel)} ${colorize(rich, theme.muted, target.url)}`;
}
export function isScopeLimitedProbeFailure(probe: GatewayProbeResult): boolean {
if (probe.ok || probe.connectLatencyMs == null) {
return false;
}
return MISSING_SCOPE_PATTERN.test(probe.error ?? "");
}
export function isProbeReachable(probe: GatewayProbeResult): boolean {
return probe.ok || isScopeLimitedProbeFailure(probe);
}
export function renderProbeSummaryLine(probe: GatewayProbeResult, rich: boolean) {
if (probe.ok) {
const latency =
@@ -335,7 +348,10 @@ export function renderProbeSummaryLine(probe: GatewayProbeResult, rich: boolean)
if (probe.connectLatencyMs != null) {
const latency =
typeof probe.connectLatencyMs === "number" ? `${probe.connectLatencyMs}ms` : "unknown";
return `${colorize(rich, theme.success, "Connect: ok")} (${latency}) · ${colorize(rich, theme.error, "RPC: failed")}${detail}`;
const rpcStatus = isScopeLimitedProbeFailure(probe)
? colorize(rich, theme.warn, "RPC: limited")
: colorize(rich, theme.error, "RPC: failed");
return `${colorize(rich, theme.success, "Connect: ok")} (${latency}) · ${rpcStatus}${detail}`;
}
return `${colorize(rich, theme.error, "Connect: failed")}${detail}`;