fix: add provider rootfs diagnostic

This commit is contained in:
Codex
2026-07-05 18:08:17 +00:00
parent 0bb2183a78
commit 42f605dceb
4 changed files with 235 additions and 3 deletions
+190
View File
@@ -1,5 +1,6 @@
import { runCommand } from "./command";
import { type UniDeskConfig, repoRoot } from "./config";
import { runSshCommandCapture } from "./ssh";
export const dispatchCommands = ["docker.ps", "provider.upgrade", "host.ssh", "microservice.http", "echo"] as const;
export type DebugDispatchCommand = typeof dispatchCommands[number];
@@ -374,6 +375,195 @@ export async function debugEgressProxy(_config: UniDeskConfig, providerId: strin
};
}
const providerRootfsMarker = "__UNIDESK_PROVIDER_ROOTFS__";
function safeDockerName(value: string): string {
return value.replace(/[^a-zA-Z0-9_.-]/gu, "-").slice(0, 80);
}
function providerGatewayContainerName(providerId: string): string {
return `unidesk-provider-gateway-${safeDockerName(providerId)}`.toLowerCase();
}
function providerRootfsDiagnosticScript(providerId: string): string {
const container = providerGatewayContainerName(providerId);
return [
"set +e",
`MARK=${shellQuote(providerRootfsMarker)}`,
`container=${shellQuote(container)}`,
`provider_id=${shellQuote(providerId)}`,
'emit() { printf "%s%s=%s\\n" "$MARK" "$1" "$2"; }',
'emit_b64() { key="$1"; value="$2"; encoded=$(printf "%s" "$value" | base64 | tr -d "\\n"); emit "$key" "$encoded"; }',
'emit provider_id "$provider_id"',
'emit container_name "$container"',
"inspect_output=$(docker inspect \"$container\" --format 'id={{.Id}}\nname={{.Name}}\nimageId={{.Image}}\nimageName={{.Config.Image}}\nstatus={{.State.Status}}\nrunning={{.State.Running}}\nrestartPolicy={{.HostConfig.RestartPolicy.Name}}\npidMode={{.HostConfig.PidMode}}' 2>&1)",
"inspect_code=$?",
'emit inspect_exit_code "$inspect_code"',
'emit_b64 inspect_output "$inspect_output"',
"smoke_output=$(docker exec \"$container\" /bin/sh -lc 'set -eu\n test -x /bin/sh\n test -x /usr/bin/ssh\n /usr/bin/ssh -V >/tmp/unidesk-provider-ssh-version 2>&1 || { cat /tmp/unidesk-provider-ssh-version >&2; exit 41; }\n command -v docker >/dev/null\n env -u DOCKER_HOST docker version --format \"{{.Client.Version}}\" >/dev/null\n test -e /lib/libz.so.1 -o -e /usr/lib/libz.so.1\n command -v bun >/dev/null\n printf \"rootfs-smoke-ok\\n\"' 2>&1)",
"smoke_code=$?",
'emit rootfs_smoke_exit_code "$smoke_code"',
'emit_b64 rootfs_smoke_output "$smoke_output"',
'system_df_output=$(docker system df 2>&1)',
"system_df_code=$?",
'emit docker_system_df_exit_code "$system_df_code"',
'emit_b64 docker_system_df_output "$system_df_output"',
"snapshot_summary=$(printf '%s\\n' \"$system_df_output\" | grep -Ei 'snapshot|lowerdir|no such file|not found|missing|lstat|failed' | tail -20)",
'emit_b64 snapshot_missing_summary "$snapshot_summary"',
'logs_output=$(docker logs --since 24h --tail 3000 "$container" 2>&1)',
"logs_code=$?",
'emit docker_logs_exit_code "$logs_code"',
"last_started=$(printf '%s\\n' \"$logs_output\" | grep 'host_ssh_session_started' | tail -1)",
"last_success=$(printf '%s\\n' \"$logs_output\" | grep 'host_ssh_session_completed' | grep '\"exitCode\":0' | tail -1)",
"last_failure=$(printf '%s\\n' \"$logs_output\" | grep -E 'host_ssh_session_completed|host_ssh_session_failed|host_ssh_session_error' | grep -Ev '\"exitCode\":0' | tail -1)",
'emit_b64 last_host_ssh_started "$last_started"',
'emit_b64 last_host_ssh_success "$last_success"',
'emit_b64 last_host_ssh_failure "$last_failure"',
"exit 0",
].join("\n");
}
function parseProviderRootfsFields(stdout: string): Record<string, string> {
const fields: Record<string, string> = {};
for (const line of stdout.split(/\r?\n/u)) {
if (!line.startsWith(providerRootfsMarker)) continue;
const payload = line.slice(providerRootfsMarker.length);
const index = payload.indexOf("=");
if (index <= 0) continue;
fields[payload.slice(0, index)] = payload.slice(index + 1);
}
return fields;
}
function b64Field(fields: Record<string, string>, key: string): string {
const raw = fields[key] ?? "";
if (raw.length === 0) return "";
try {
return Buffer.from(raw, "base64").toString("utf8");
} catch {
return "";
}
}
function numberField(fields: Record<string, string>, key: string): number | null {
const value = Number(fields[key]);
return Number.isFinite(value) ? value : null;
}
function parseKeyValueBlock(text: string): Record<string, string> {
const values: Record<string, string> = {};
for (const line of text.split(/\r?\n/u)) {
const index = line.indexOf("=");
if (index <= 0) continue;
values[line.slice(0, index)] = line.slice(index + 1);
}
return values;
}
function trimForDiagnostic(text: string, maxChars = 1200): string {
const trimmed = text.trim();
if (trimmed.length <= maxChars) return trimmed;
return trimmed.slice(trimmed.length - maxChars);
}
function extractIsoTimeFromLogLine(line: string): string | null {
const trimmed = line.trim();
if (trimmed.length === 0) return null;
try {
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
for (const key of ["time", "timestamp", "at"]) {
const value = parsed[key];
if (typeof value === "string" && value.length > 0) return value;
}
} catch {
// Fall through to regex extraction for non-JSON log formats.
}
return trimmed.match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z/u)?.[0] ?? null;
}
export async function debugProviderRootfs(config: UniDeskConfig, providerId: string): Promise<unknown> {
const script = providerRootfsDiagnosticScript(providerId);
const capture = await runSshCommandCapture(config, providerId, ["bash"], script);
const fields = parseProviderRootfsFields(capture.stdout);
const inspectOutput = b64Field(fields, "inspect_output");
const inspect = parseKeyValueBlock(inspectOutput);
const smokeOutput = b64Field(fields, "rootfs_smoke_output");
const systemDfOutput = b64Field(fields, "docker_system_df_output");
const snapshotSummary = b64Field(fields, "snapshot_missing_summary");
const lastStarted = b64Field(fields, "last_host_ssh_started");
const lastSuccess = b64Field(fields, "last_host_ssh_success");
const lastFailure = b64Field(fields, "last_host_ssh_failure");
const inspectExitCode = numberField(fields, "inspect_exit_code");
const smokeExitCode = numberField(fields, "rootfs_smoke_exit_code");
const systemDfExitCode = numberField(fields, "docker_system_df_exit_code");
const dockerLogsExitCode = numberField(fields, "docker_logs_exit_code");
const transOk = capture.exitCode === 0;
const ok = transOk && inspectExitCode === 0 && smokeExitCode === 0 && systemDfExitCode === 0;
return {
ok,
providerId,
containerName: fields.container_name ?? providerGatewayContainerName(providerId),
trans: {
ok: transOk,
exitCode: capture.exitCode,
stderrTail: trimForDiagnostic(capture.stderr, 1600),
},
container: {
inspectExitCode,
id: inspect.id ?? null,
name: inspect.name?.replace(/^\//u, "") ?? null,
imageId: inspect.imageId ?? null,
imageName: inspect.imageName ?? null,
status: inspect.status ?? null,
running: inspect.running ?? null,
restartPolicy: inspect.restartPolicy ?? null,
pidMode: inspect.pidMode ?? null,
inspectError: inspectExitCode === 0 ? null : trimForDiagnostic(inspectOutput),
},
rootfsSmoke: {
ok: smokeExitCode === 0,
exitCode: smokeExitCode,
outputTail: trimForDiagnostic(smokeOutput),
checks: ["/bin/sh", "/usr/bin/ssh -V", "libz.so.1", "docker client", "bun"],
},
dockerSystemDf: {
ok: systemDfExitCode === 0,
exitCode: systemDfExitCode,
outputTail: trimForDiagnostic(systemDfOutput),
snapshotMissingSummary: trimForDiagnostic(snapshotSummary),
},
hostSsh: {
dockerLogsExitCode,
lastStarted: {
at: extractIsoTimeFromLogLine(lastStarted),
line: trimForDiagnostic(lastStarted),
},
lastSuccess: {
at: extractIsoTimeFromLogLine(lastSuccess),
line: trimForDiagnostic(lastSuccess),
},
lastFailure: {
at: extractIsoTimeFromLogLine(lastFailure),
line: trimForDiagnostic(lastFailure),
},
note: "Success/failure rows require provider-gateway versions that log host_ssh_session_completed or host_ssh_session_failed; older containers may only expose lastStarted/error.",
},
classification: !transOk
? "trans-channel-unavailable"
: inspectExitCode !== 0
? "provider-container-missing"
: smokeExitCode !== 0
? "provider-rootfs-smoke-failed"
: systemDfExitCode !== 0
? "docker-system-df-failed"
: "provider-rootfs-healthy",
next: {
sshPool: `bun scripts/cli.ts debug ssh-pool ${providerId}`,
providerUpgrade: `bun scripts/cli.ts debug dispatch ${providerId} provider.upgrade --mode schedule --wait-ms 300000`,
},
};
}
async function waitForTask(taskId: string, timeoutMs: number): Promise<unknown> {
const started = Date.now();
let latest: unknown = null;
+3 -1
View File
@@ -99,6 +99,7 @@ export function rootHelp(): unknown {
{ command: "debug health", description: "Probe internal core, nodes, system/Docker status, frontend, provider ingress, and public boundary." },
{ command: "debug ssh-pool <providerId>", description: "Show bounded host.ssh.tcp-pool labels for one provider, including ready/claimed/desired/lastError." },
{ command: "debug egress-proxy <providerId>", description: "Show provider-gateway egress proxy tunnel counts, stale tunnel diagnosis, active target summaries, and recent closed tunnel lifecycle without URL credential leakage." },
{ command: "debug provider-rootfs <providerId>", description: "Run a read-only provider-gateway rootfs diagnostic over trans: image id, runtime smoke, docker system df snapshot errors, and recent host_ssh lifecycle lines." },
{ command: "debug dispatch [providerId] [docker.ps|provider.upgrade|host.ssh|microservice.http|echo] [--wait-ms N]", description: "Submit a real internal-core dispatch request for CLI debugging." },
{ command: "debug task <taskId|latest>", description: "Read a dispatched task record from internal core for CLI debugging." },
{ command: "network perf [--service code-queue --path /api/tasks/overview?limit=30 --count N --concurrency N --label before|after]", description: "Benchmark frontend -> backend-core -> provider/adapter user-service networking and report latency/proxy-mode distributions." },
@@ -544,12 +545,13 @@ function jobHelp(): unknown {
function debugHelp(): unknown {
return {
command: "debug health|ssh-pool|egress-proxy|dispatch|task",
command: "debug health|ssh-pool|egress-proxy|provider-rootfs|dispatch|task",
output: "json",
usage: [
"bun scripts/cli.ts debug health",
"bun scripts/cli.ts debug ssh-pool <providerId>",
"bun scripts/cli.ts debug egress-proxy <providerId>",
"bun scripts/cli.ts debug provider-rootfs <providerId>",
"bun scripts/cli.ts debug dispatch [providerId] [docker.ps|provider.upgrade|host.ssh|microservice.http|echo] [--wait-ms N]",
"bun scripts/cli.ts debug task <taskId|latest>",
],