feat: add structured ssh route passthrough
This commit is contained in:
+315
-5
@@ -9,6 +9,22 @@ export interface ParsedSshArgs {
|
||||
|
||||
export type SshInvocationKind = "interactive" | "argv" | "helper" | "ssh-like";
|
||||
|
||||
export interface ParsedSshRoute {
|
||||
providerId: string;
|
||||
plane: "host" | "k3s";
|
||||
entry: string | null;
|
||||
namespace: string | null;
|
||||
resource: string | null;
|
||||
container: string | null;
|
||||
raw: string;
|
||||
}
|
||||
|
||||
export interface ParsedSshInvocation {
|
||||
providerId: string;
|
||||
route: ParsedSshRoute;
|
||||
parsed: ParsedSshArgs;
|
||||
}
|
||||
|
||||
export interface SshFailureHint {
|
||||
code: "ssh-like-command-friction";
|
||||
providerId: string;
|
||||
@@ -21,6 +37,24 @@ export interface SshFailureHint {
|
||||
}
|
||||
|
||||
const argvQuotedSshSubcommands = new Set(["rg", "grep", "sed", "nl", "stat", "du", "ls", "cat", "head", "tail", "wc", "pwd"]);
|
||||
const d601NativeKubeconfig = "/etc/rancher/k3s/k3s.yaml";
|
||||
const k3sRouteEntries = new Set([
|
||||
"guard",
|
||||
"kubectl",
|
||||
"exec",
|
||||
"logs",
|
||||
"get",
|
||||
"describe",
|
||||
"top",
|
||||
"rollout",
|
||||
"wait",
|
||||
"config",
|
||||
"version",
|
||||
"cluster-info",
|
||||
"auth",
|
||||
"api-resources",
|
||||
"api-versions",
|
||||
]);
|
||||
|
||||
const remoteApplyPatchSource = String.raw`#!/usr/bin/env python3
|
||||
import sys
|
||||
@@ -513,6 +547,9 @@ export function parseSshArgs(args: string[]): ParsedSshArgs {
|
||||
if (subcommand === "glob") {
|
||||
return { remoteCommand: shellArgv(["glob", ...args.slice(1)]), requiresStdin: false, invocationKind: "helper" };
|
||||
}
|
||||
if (subcommand === "k3s") {
|
||||
return { remoteCommand: buildK3sCommand(args.slice(1)), requiresStdin: false, invocationKind: "helper" };
|
||||
}
|
||||
if (argvQuotedSshSubcommands.has(subcommand)) {
|
||||
return { remoteCommand: shellArgv(args), requiresStdin: false, invocationKind: "argv" };
|
||||
}
|
||||
@@ -542,6 +579,46 @@ export function parseSshArgs(args: string[]): ParsedSshArgs {
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSshInvocation(target: string, args: string[]): ParsedSshInvocation {
|
||||
const route = parseSshRoute(target);
|
||||
const parsed = route.plane === "k3s" ? parseK3sRouteArgs(route, args) : parseSshArgs(args);
|
||||
return { providerId: route.providerId, route, parsed };
|
||||
}
|
||||
|
||||
export function parseSshRoute(target: string): ParsedSshRoute {
|
||||
if (!target) throw new Error("ssh requires provider id, for example: bun scripts/cli.ts ssh D601");
|
||||
const parts = target.split(":");
|
||||
const [providerId, plane, ...rest] = parts;
|
||||
if (!providerId) throw new Error("ssh route requires a provider id before ':'");
|
||||
if (plane === undefined || plane.length === 0 || plane === "host") {
|
||||
return { providerId, plane: "host", entry: null, namespace: null, resource: null, container: null, raw: target };
|
||||
}
|
||||
if (plane !== "k3s") throw new Error(`unsupported ssh route plane: ${plane}`);
|
||||
const [first, second, third, fourth, ...extra] = rest;
|
||||
if (first && k3sRouteEntries.has(first)) {
|
||||
if (extra.length > 0) throw new Error("ssh k3s command route supports at most provider:k3s:entry:namespace:resource:container");
|
||||
return {
|
||||
providerId,
|
||||
plane: "k3s",
|
||||
entry: first,
|
||||
namespace: second && second.length > 0 ? second : null,
|
||||
resource: third && third.length > 0 ? third : null,
|
||||
container: fourth && fourth.length > 0 ? fourth : null,
|
||||
raw: target,
|
||||
};
|
||||
}
|
||||
if (fourth !== undefined) throw new Error("ssh k3s target route supports at most provider:k3s:namespace:resource:container");
|
||||
return {
|
||||
providerId,
|
||||
plane: "k3s",
|
||||
entry: null,
|
||||
namespace: first && first.length > 0 ? first : null,
|
||||
resource: second && second.length > 0 ? second : null,
|
||||
container: third && third.length > 0 ? third : null,
|
||||
raw: target,
|
||||
};
|
||||
}
|
||||
|
||||
function shellArgv(args: string[]): string {
|
||||
return args.map(shellQuote).join(" ");
|
||||
}
|
||||
@@ -556,6 +633,10 @@ function positiveInt(value: string, option: string): number {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function optionalPositiveInt(value: string, option: string): string {
|
||||
return String(positiveInt(value, option));
|
||||
}
|
||||
|
||||
function findOptionValue(args: string[], index: number, option: string): string {
|
||||
const value = args[index + 1];
|
||||
if (value === undefined || value.length === 0) throw new Error(`ssh find ${option} requires a value`);
|
||||
@@ -642,6 +723,235 @@ function buildFindCommand(args: string[]): string {
|
||||
return command;
|
||||
}
|
||||
|
||||
function parseK3sRouteArgs(route: ParsedSshRoute, args: string[]): ParsedSshArgs {
|
||||
if (route.entry === null && route.namespace === null && route.resource === null) {
|
||||
const k3sArgs = args.length === 0 ? ["guard"] : args;
|
||||
return { remoteCommand: buildK3sCommand(k3sArgs), requiresStdin: false, invocationKind: "helper" };
|
||||
}
|
||||
if (route.entry === "guard") {
|
||||
if (args.length > 0) throw new Error("ssh route D601:k3s:guard does not accept extra arguments");
|
||||
return { remoteCommand: buildD601K3sGuardCommand(), requiresStdin: false, invocationKind: "helper" };
|
||||
}
|
||||
if (route.entry === "kubectl") {
|
||||
if (args.length === 0) throw new Error("ssh route D601:k3s:kubectl requires kubectl arguments");
|
||||
return { remoteCommand: buildK3sCommand(["kubectl", ...args]), requiresStdin: false, invocationKind: "helper" };
|
||||
}
|
||||
if (route.entry === "logs") {
|
||||
return { remoteCommand: buildK3sLogsCommand([...k3sRouteTargetArgs(route), ...args]), requiresStdin: false, invocationKind: "helper" };
|
||||
}
|
||||
if (route.entry === "exec") {
|
||||
return { remoteCommand: buildK3sExecCommand([...k3sRouteTargetArgs(route), ...k3sRouteCommandArgs(args)]), requiresStdin: false, invocationKind: "helper" };
|
||||
}
|
||||
if (route.entry !== null) {
|
||||
const k3sArgs = [route.entry, ...(route.namespace === null ? [] : [route.namespace]), ...(route.resource === null ? [] : [route.resource]), ...args];
|
||||
return { remoteCommand: buildK3sCommand(k3sArgs), requiresStdin: false, invocationKind: "helper" };
|
||||
}
|
||||
if (route.namespace === null || route.resource === null) {
|
||||
throw new Error("ssh k3s target route requires provider:k3s:<namespace>:<deployment|pod/resource>");
|
||||
}
|
||||
if (args.length === 0) {
|
||||
return {
|
||||
remoteCommand: shellArgv(["env", `KUBECONFIG=${d601NativeKubeconfig}`, "kubectl", "get", "-n", route.namespace, normalizeK3sRouteResource(route.resource), "-o", "wide"]),
|
||||
requiresStdin: false,
|
||||
invocationKind: "helper",
|
||||
};
|
||||
}
|
||||
return { remoteCommand: buildK3sExecCommand([...k3sRouteTargetArgs(route), ...k3sRouteCommandArgs(args)]), requiresStdin: false, invocationKind: "helper" };
|
||||
}
|
||||
|
||||
function k3sRouteTargetArgs(route: ParsedSshRoute): string[] {
|
||||
if (route.namespace === null) throw new Error(`ssh route ${route.raw} requires a namespace segment`);
|
||||
if (route.resource === null) throw new Error(`ssh route ${route.raw} requires a workload or pod segment`);
|
||||
return [
|
||||
"--namespace", route.namespace,
|
||||
"--resource", normalizeK3sRouteResource(route.resource),
|
||||
...(route.container === null ? [] : ["--container", route.container]),
|
||||
];
|
||||
}
|
||||
|
||||
function k3sRouteCommandArgs(args: string[]): string[] {
|
||||
if (args.length === 0) throw new Error("ssh k3s target route requires a command to exec");
|
||||
return args[0] === "--" ? args : ["--", ...args];
|
||||
}
|
||||
|
||||
function buildK3sCommand(args: string[]): string {
|
||||
const action = args[0] ?? "";
|
||||
if (action.length === 0 || action === "--help" || action === "-h" || action === "help") {
|
||||
throw new Error("ssh k3s requires a subcommand: guard, kubectl, get, describe, logs or exec");
|
||||
}
|
||||
if (action === "guard") return buildD601K3sGuardCommand();
|
||||
if (action === "exec") return buildK3sExecCommand(args.slice(1));
|
||||
if (action === "logs") return buildK3sLogsCommand(args.slice(1));
|
||||
if (action === "kubectl") {
|
||||
const kubectlArgs = args.slice(1);
|
||||
if (kubectlArgs.length === 0) throw new Error("ssh k3s kubectl requires kubectl arguments");
|
||||
return shellArgv(["env", `KUBECONFIG=${d601NativeKubeconfig}`, "kubectl", ...kubectlArgs]);
|
||||
}
|
||||
return shellArgv(["env", `KUBECONFIG=${d601NativeKubeconfig}`, "kubectl", ...args]);
|
||||
}
|
||||
|
||||
function buildD601K3sGuardCommand(): string {
|
||||
const script = [
|
||||
"set -euo pipefail",
|
||||
`export KUBECONFIG=${shellQuote(d601NativeKubeconfig)}`,
|
||||
"context=$(kubectl config current-context)",
|
||||
"server=$(kubectl config view --minify -o jsonpath='{.clusters[0].cluster.server}')",
|
||||
"nodes=$(kubectl get nodes -o jsonpath='{range .items[*]}{.metadata.name}{\"\\n\"}{end}')",
|
||||
"printf 'kubeconfig=%s\\n' \"$KUBECONFIG\"",
|
||||
"printf 'context=%s\\n' \"$context\"",
|
||||
"printf 'server=%s\\n' \"$server\"",
|
||||
"printf 'nodes=%s\\n' \"$(printf '%s' \"$nodes\" | tr '\\n' ' ')\"",
|
||||
"printf '%s\\n' \"$nodes\" | grep -Fx d601 >/dev/null || { printf 'native_k3s_guard=blocked reason=d601-node-missing\\n' >&2; exit 1; }",
|
||||
"case \"$server\" in *127.0.0.1:11700*|*docker-desktop*) printf 'native_k3s_guard=blocked reason=docker-desktop-context server=%s\\n' \"$server\" >&2; exit 1;; esac",
|
||||
"printf 'native_k3s_guard=ok\\n'",
|
||||
].join("; ");
|
||||
return shellArgv(["bash", "-lc", script]);
|
||||
}
|
||||
|
||||
interface K3sTargetOptions {
|
||||
namespace: string | null;
|
||||
resource: string | null;
|
||||
container: string | null;
|
||||
stdin: boolean;
|
||||
tty: boolean;
|
||||
command: string[];
|
||||
kubectlOptions: string[];
|
||||
}
|
||||
|
||||
function buildK3sExecCommand(args: string[]): string {
|
||||
const parsed = parseK3sTargetOptions(args, "ssh k3s exec", { requireCommand: true });
|
||||
if (parsed.namespace === null) throw new Error("ssh k3s exec requires --namespace <name>");
|
||||
if (parsed.resource === null) throw new Error("ssh k3s exec requires --deployment <name>, --pod <name> or --resource <type/name>");
|
||||
const kubectlArgs = [
|
||||
"exec",
|
||||
"-n", parsed.namespace,
|
||||
parsed.resource,
|
||||
...(parsed.container === null ? [] : ["-c", parsed.container]),
|
||||
...(parsed.stdin ? ["-i"] : []),
|
||||
...(parsed.tty ? ["-t"] : []),
|
||||
...parsed.kubectlOptions,
|
||||
"--",
|
||||
...parsed.command,
|
||||
];
|
||||
return shellArgv(["env", `KUBECONFIG=${d601NativeKubeconfig}`, "kubectl", ...kubectlArgs]);
|
||||
}
|
||||
|
||||
function buildK3sLogsCommand(args: string[]): string {
|
||||
const parsed = parseK3sTargetOptions(args, "ssh k3s logs", { requireCommand: false });
|
||||
if (parsed.namespace === null) throw new Error("ssh k3s logs requires --namespace <name>");
|
||||
if (parsed.resource === null) throw new Error("ssh k3s logs requires --deployment <name>, --pod <name> or --resource <type/name>");
|
||||
if (parsed.stdin || parsed.tty) throw new Error("ssh k3s logs does not support --stdin or --tty");
|
||||
const kubectlArgs = [
|
||||
"logs",
|
||||
"-n", parsed.namespace,
|
||||
parsed.resource,
|
||||
...(parsed.container === null ? [] : ["-c", parsed.container]),
|
||||
...parsed.kubectlOptions,
|
||||
];
|
||||
return shellArgv(["env", `KUBECONFIG=${d601NativeKubeconfig}`, "kubectl", ...kubectlArgs]);
|
||||
}
|
||||
|
||||
function parseK3sTargetOptions(args: string[], commandName: string, options: { requireCommand: boolean }): K3sTargetOptions {
|
||||
let namespace: string | null = null;
|
||||
let resource: string | null = null;
|
||||
let container: string | null = null;
|
||||
let stdin = false;
|
||||
let tty = false;
|
||||
const kubectlOptions: string[] = [];
|
||||
const command: string[] = [];
|
||||
let afterDoubleDash = false;
|
||||
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index] ?? "";
|
||||
if (afterDoubleDash) {
|
||||
command.push(arg);
|
||||
continue;
|
||||
}
|
||||
if (arg === "--") {
|
||||
afterDoubleDash = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--namespace" || arg === "-n") {
|
||||
namespace = k3sOptionValue(args, index, `${commandName} ${arg}`);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--deployment" || arg === "--deploy") {
|
||||
resource = `deployment/${k3sOptionValue(args, index, `${commandName} ${arg}`)}`;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--pod") {
|
||||
resource = `pod/${k3sOptionValue(args, index, `${commandName} ${arg}`)}`;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--resource") {
|
||||
resource = normalizeK3sResource(k3sOptionValue(args, index, `${commandName} ${arg}`));
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--container" || arg === "-c") {
|
||||
container = k3sOptionValue(args, index, `${commandName} ${arg}`);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--stdin" || arg === "-i") {
|
||||
stdin = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--tty" || arg === "-t") {
|
||||
tty = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--tail") {
|
||||
kubectlOptions.push("--tail", optionalPositiveInt(k3sOptionValue(args, index, `${commandName} ${arg}`), `${commandName} --tail`));
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--since" || arg === "--since-time" || arg === "--limit-bytes") {
|
||||
kubectlOptions.push(arg, k3sOptionValue(args, index, `${commandName} ${arg}`));
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--previous" || arg === "--timestamps" || arg === "--all-containers" || arg === "--prefix") {
|
||||
kubectlOptions.push(arg);
|
||||
continue;
|
||||
}
|
||||
if (arg === "--follow" || arg === "-f") {
|
||||
throw new Error(`${commandName} does not support ${arg}; use a bounded logs command and poll again`);
|
||||
}
|
||||
if (arg.startsWith("-")) throw new Error(`unsupported ${commandName} option: ${arg}`);
|
||||
if (resource === null) {
|
||||
resource = normalizeK3sResource(arg);
|
||||
continue;
|
||||
}
|
||||
throw new Error(`unexpected ${commandName} argument before --: ${arg}`);
|
||||
}
|
||||
|
||||
if (options.requireCommand && command.length === 0) throw new Error(`${commandName} requires -- <command> [args...]`);
|
||||
if (!options.requireCommand && command.length > 0) throw new Error(`${commandName} does not accept a command after --`);
|
||||
return { namespace, resource, container, stdin, tty, command, kubectlOptions };
|
||||
}
|
||||
|
||||
function k3sOptionValue(args: string[], index: number, option: string): string {
|
||||
const value = args[index + 1];
|
||||
if (value === undefined || value.length === 0) throw new Error(`${option} requires a value`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeK3sResource(value: string): string {
|
||||
if (value.includes("/")) return value;
|
||||
return `pod/${value}`;
|
||||
}
|
||||
|
||||
function normalizeK3sRouteResource(value: string): string {
|
||||
if (value.startsWith("deploy/")) return `deployment/${value.slice("deploy/".length)}`;
|
||||
if (value.startsWith("po/")) return `pod/${value.slice("po/".length)}`;
|
||||
if (value.includes("/")) return value;
|
||||
return `deployment/${value}`;
|
||||
}
|
||||
|
||||
function buildPythonStdinCommand(args: string[]): string {
|
||||
const pythonArgs = args.map(shellQuote).join(" ");
|
||||
const execArgs = pythonArgs.length > 0 ? ` "$UNIDESK_SSH_PY_FILE" ${pythonArgs}` : ' "$UNIDESK_SSH_PY_FILE"';
|
||||
@@ -850,12 +1160,12 @@ function terminalSize(): { cols: number; rows: number } {
|
||||
}
|
||||
|
||||
export async function runSsh(config: UniDeskConfig, providerId: string, args: string[]): Promise<number> {
|
||||
if (!providerId) throw new Error("ssh requires provider id, for example: bun scripts/cli.ts ssh D518");
|
||||
const parsed = parseSshArgs(args);
|
||||
const invocation = parseSshInvocation(providerId, args);
|
||||
const parsed = invocation.parsed;
|
||||
const size = terminalSize();
|
||||
const openTimeoutMs = Math.max(15000, Number(process.env.UNIDESK_SSH_OPEN_TIMEOUT_MS || 60000));
|
||||
const payload = {
|
||||
providerId,
|
||||
providerId: invocation.providerId,
|
||||
command: wrapSshRemoteCommand(parsed.remoteCommand),
|
||||
tty: parsed.remoteCommand === null,
|
||||
stdinEotOnEnd: parsed.remoteCommand !== null,
|
||||
@@ -912,14 +1222,14 @@ export async function runSsh(config: UniDeskConfig, providerId: string, args: st
|
||||
const message = `unidesk ssh failed to start broker: ${error.message}\n`;
|
||||
appendStderrTail(message);
|
||||
process.stderr.write(message);
|
||||
const hint = sshFailureHint(providerId, parsed, 255, stderrTail);
|
||||
const hint = sshFailureHint(invocation.providerId, parsed, 255, stderrTail);
|
||||
if (hint !== null) process.stderr.write(formatSshFailureHint(hint));
|
||||
resolve(255);
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
restore();
|
||||
const exitCode = code ?? 255;
|
||||
const hint = sshFailureHint(providerId, parsed, exitCode, stderrTail);
|
||||
const hint = sshFailureHint(invocation.providerId, parsed, exitCode, stderrTail);
|
||||
if (hint !== null) process.stderr.write(formatSshFailureHint(hint));
|
||||
resolve(exitCode);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user