feat: add ssh workspace routes
This commit is contained in:
+100
-16
@@ -18,6 +18,7 @@ export interface ParsedSshRoute {
|
||||
namespace: string | null;
|
||||
resource: string | null;
|
||||
container: string | null;
|
||||
workspace: string | null;
|
||||
raw: string;
|
||||
}
|
||||
|
||||
@@ -40,6 +41,7 @@ export interface SshFailureHint {
|
||||
|
||||
const argvQuotedSshSubcommands = new Set(["rg", "grep", "sed", "nl", "stat", "du", "ls", "cat", "head", "tail", "wc", "pwd"]);
|
||||
const nativeK3sKubeconfig = "/etc/rancher/k3s/k3s.yaml";
|
||||
const k3sResourceKindAliases = new Set(["pod", "po", "pods", "deployment", "deploy", "deployments", "statefulset", "sts", "daemonset", "ds", "job", "jobs"]);
|
||||
const legacyK3sOperationRouteSegments = new Set([
|
||||
"guard",
|
||||
"kubectl",
|
||||
@@ -859,28 +861,94 @@ export function parseSshInvocation(target: string, args: string[]): ParsedSshInv
|
||||
|
||||
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;
|
||||
const firstColon = target.indexOf(":");
|
||||
if (firstColon < 0) {
|
||||
return hostSshRoute(target, target, null);
|
||||
}
|
||||
const providerId = target.slice(0, firstColon);
|
||||
const tail = target.slice(firstColon + 1);
|
||||
if (!providerId) throw new Error("ssh route requires a provider id before ':'");
|
||||
if (tail.length === 0) {
|
||||
return hostSshRoute(providerId, target, null);
|
||||
}
|
||||
if (tail.startsWith("/")) {
|
||||
return hostSshRoute(providerId, target, tail);
|
||||
}
|
||||
const [plane, ...rest] = tail.split(":");
|
||||
if (plane === undefined || plane.length === 0 || plane === "host") {
|
||||
return { providerId, plane: "host", entry: null, namespace: null, resource: null, container: null, raw: target };
|
||||
const workspace = rest.length > 0 ? rest.join(":") : null;
|
||||
if (workspace !== null && !workspace.startsWith("/")) throw new Error("ssh host workspace route requires an absolute path after provider:host:");
|
||||
return hostSshRoute(providerId, target, workspace);
|
||||
}
|
||||
if (plane !== "k3s") throw new Error(`unsupported ssh route plane: ${plane}`);
|
||||
const [first, second, third, fourth] = rest;
|
||||
const operationInRoute = [first, second, third].find((segment) => segment !== undefined && legacyK3sOperationRouteSegments.has(segment));
|
||||
const operationInRoute = [first, second, third].map((segment) => segment === undefined ? undefined : routeSegmentHead(segment)).find((segment) => segment !== undefined && legacyK3sOperationRouteSegments.has(segment));
|
||||
if (operationInRoute !== undefined) throw new Error(k3sOperationInRouteMessage(target, operationInRoute));
|
||||
if (fourth !== undefined) throw new Error("ssh k3s target route supports at most provider:k3s:namespace:resource:container");
|
||||
const targetRoute = parseK3sRouteTargetSegments(second ?? null, third ?? null);
|
||||
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,
|
||||
resource: targetRoute.resource,
|
||||
container: targetRoute.container,
|
||||
workspace: targetRoute.workspace,
|
||||
raw: target,
|
||||
};
|
||||
}
|
||||
|
||||
function hostSshRoute(providerId: string, raw: string, workspace: string | null): ParsedSshRoute {
|
||||
return { providerId, plane: "host", entry: null, namespace: null, resource: null, container: null, workspace, raw };
|
||||
}
|
||||
|
||||
function routeSegmentHead(segment: string): string {
|
||||
return segment.split("/")[0] ?? segment;
|
||||
}
|
||||
|
||||
function parseK3sRouteTargetSegments(rawResource: string | null, rawContainer: string | null): { resource: string | null; container: string | null; workspace: string | null } {
|
||||
const resourceParts = splitK3sResourceWorkspace(rawResource);
|
||||
const containerParts = splitK3sContainerWorkspace(rawContainer);
|
||||
return {
|
||||
resource: resourceParts.resource,
|
||||
container: containerParts.container,
|
||||
workspace: combineK3sRouteWorkspace(resourceParts.workspace, containerParts.workspace),
|
||||
};
|
||||
}
|
||||
|
||||
function splitK3sResourceWorkspace(value: string | null): { resource: string | null; workspace: string | null } {
|
||||
if (value === null || value.length === 0) return { resource: null, workspace: null };
|
||||
const parts = value.split("/");
|
||||
if (parts.length <= 1) return { resource: value, workspace: null };
|
||||
if (isK3sResourceKindAlias(parts[0] ?? "") && (parts[1] ?? "").length > 0) {
|
||||
const workspaceParts = parts.slice(2);
|
||||
return {
|
||||
resource: `${parts[0]}/${parts[1]}`,
|
||||
workspace: workspaceParts.length > 0 ? `/${workspaceParts.join("/")}` : null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
resource: parts[0] ?? value,
|
||||
workspace: parts.length > 1 ? `/${parts.slice(1).join("/")}` : null,
|
||||
};
|
||||
}
|
||||
|
||||
function splitK3sContainerWorkspace(value: string | null): { container: string | null; workspace: string | null } {
|
||||
if (value === null || value.length === 0) return { container: null, workspace: null };
|
||||
const parts = value.split("/");
|
||||
if (parts.length <= 1) return { container: value, workspace: null };
|
||||
return { container: parts[0] ?? value, workspace: `/${parts.slice(1).join("/")}` };
|
||||
}
|
||||
|
||||
function combineK3sRouteWorkspace(first: string | null, second: string | null): string | null {
|
||||
if (first !== null && second !== null && first !== second) throw new Error("ssh k3s route workspace can be specified once, either after the workload or after the container");
|
||||
return first ?? second;
|
||||
}
|
||||
|
||||
function isK3sResourceKindAlias(value: string): boolean {
|
||||
return k3sResourceKindAliases.has(value);
|
||||
}
|
||||
|
||||
function k3sOperationInRouteMessage(target: string, operation: string): string {
|
||||
const providerId = target.split(":")[0] || "<provider>";
|
||||
const operationExample = operation === "guard" ? "guard" : `${operation} ...`;
|
||||
@@ -1053,6 +1121,7 @@ function k3sRouteTargetArgs(route: ParsedSshRoute): string[] {
|
||||
"--namespace", route.namespace,
|
||||
"--resource", normalizeK3sRouteResource(route.resource),
|
||||
...(route.container === null ? [] : ["--container", route.container]),
|
||||
...(route.workspace === null ? [] : ["--workdir", route.workspace]),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1108,6 +1177,7 @@ interface K3sTargetOptions {
|
||||
namespace: string | null;
|
||||
resource: string | null;
|
||||
container: string | null;
|
||||
workspace: string | null;
|
||||
stdin: boolean;
|
||||
tty: boolean;
|
||||
shell: string | null;
|
||||
@@ -1134,7 +1204,7 @@ function buildK3sExecCommand(args: string[]): string {
|
||||
...(parsed.tty ? ["-t"] : []),
|
||||
...parsed.kubectlOptions,
|
||||
"--",
|
||||
...parsed.command,
|
||||
...withK3sWorkspace(parsed, parsed.command),
|
||||
];
|
||||
return shellArgv(["env", `KUBECONFIG=${nativeK3sKubeconfig}`, "kubectl", ...kubectlArgs]);
|
||||
}
|
||||
@@ -1154,10 +1224,7 @@ function buildK3sScriptCommand(args: string[]): string {
|
||||
...(parsed.container === null ? [] : ["-c", parsed.container]),
|
||||
...parsed.kubectlOptions,
|
||||
"--",
|
||||
shell,
|
||||
"-s",
|
||||
"--",
|
||||
...parsed.command,
|
||||
...withK3sWorkspace(parsed, [shell, "-s", "--", ...parsed.command]),
|
||||
];
|
||||
return shellArgv(["env", `KUBECONFIG=${nativeK3sKubeconfig}`, "kubectl", ...kubectlArgs]);
|
||||
}
|
||||
@@ -1177,10 +1244,7 @@ function buildK3sApplyPatchCommand(args: string[]): ParsedSshArgs {
|
||||
parsed.resource,
|
||||
...(parsed.container === null ? [] : ["-c", parsed.container]),
|
||||
"--",
|
||||
"sh",
|
||||
"-s",
|
||||
"--",
|
||||
...parsed.command,
|
||||
...withK3sWorkspace(parsed, ["sh", "-s", "--", ...parsed.command]),
|
||||
];
|
||||
const wrapper = podApplyPatchStdinWrapper();
|
||||
return {
|
||||
@@ -1196,6 +1260,7 @@ function buildK3sHostScriptCommand(parsed: K3sTargetOptions): string {
|
||||
if (parsed.tty) throw new Error("ssh k3s script does not support --tty; stdin is reserved for the script body");
|
||||
if (parsed.stdin) throw new Error("ssh k3s script does not accept --stdin; stdin is always the script body");
|
||||
if (parsed.container !== null) throw new Error("ssh k3s script without a workload does not accept --container");
|
||||
if (parsed.workspace !== null) throw new Error("ssh k3s script without a workload does not accept --workdir");
|
||||
if (parsed.kubectlOptions.length > 0) throw new Error("ssh k3s script without a workload does not accept kubectl log options");
|
||||
const shell = parsed.shell ?? "sh";
|
||||
return shellArgv(["env", `KUBECONFIG=${nativeK3sKubeconfig}`, shell, "-s", "--", ...parsed.command]);
|
||||
@@ -1206,6 +1271,7 @@ function buildK3sLogsCommand(args: string[]): string {
|
||||
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");
|
||||
if (parsed.workspace !== null) throw new Error("ssh k3s logs does not accept --workdir");
|
||||
const kubectlArgs = [
|
||||
"logs",
|
||||
"-n", parsed.namespace,
|
||||
@@ -1220,6 +1286,7 @@ function parseK3sTargetOptions(args: string[], commandName: string, options: Par
|
||||
let namespace: string | null = null;
|
||||
let resource: string | null = null;
|
||||
let container: string | null = null;
|
||||
let workspace: string | null = null;
|
||||
let stdin = false;
|
||||
let tty = false;
|
||||
let shell: string | null = null;
|
||||
@@ -1262,6 +1329,11 @@ function parseK3sTargetOptions(args: string[], commandName: string, options: Par
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--workdir" || arg === "--cwd") {
|
||||
workspace = k3sWorkspaceValue(k3sOptionValue(args, index, `${commandName} ${arg}`), `${commandName} ${arg}`);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--shell") {
|
||||
if (!options.allowShell) throw new Error(`${commandName} does not support --shell`);
|
||||
shell = k3sScriptShell(k3sOptionValue(args, index, `${commandName} ${arg}`), `${commandName} ${arg}`);
|
||||
@@ -1303,7 +1375,7 @@ function parseK3sTargetOptions(args: string[], commandName: string, options: Par
|
||||
|
||||
if (options.requireCommand && command.length === 0) throw new Error(`${commandName} requires -- <command> [args...]`);
|
||||
if (!options.requireCommand && options.allowCommand !== true && command.length > 0) throw new Error(`${commandName} does not accept a command after --`);
|
||||
return { namespace, resource, container, stdin, tty, shell, command, kubectlOptions };
|
||||
return { namespace, resource, container, workspace, stdin, tty, shell, command, kubectlOptions };
|
||||
}
|
||||
|
||||
function k3sOptionValue(args: string[], index: number, option: string): string {
|
||||
@@ -1312,6 +1384,17 @@ function k3sOptionValue(args: string[], index: number, option: string): string {
|
||||
return value;
|
||||
}
|
||||
|
||||
function k3sWorkspaceValue(value: string, option: string): string {
|
||||
if (!value.startsWith("/")) throw new Error(`${option} must be an absolute pod workspace path`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function withK3sWorkspace(parsed: K3sTargetOptions, command: string[]): string[] {
|
||||
if (parsed.workspace === null) return command;
|
||||
if (command.length === 0) throw new Error("ssh k3s workspace route requires a command to execute");
|
||||
return ["sh", "-c", 'cd "$1" || exit; shift; exec "$@"', "unidesk-cwd", parsed.workspace, ...command];
|
||||
}
|
||||
|
||||
function normalizeK3sResource(value: string): string {
|
||||
if (value.includes("/")) return value;
|
||||
return `pod/${value}`;
|
||||
@@ -1588,6 +1671,7 @@ export async function runSsh(config: UniDeskConfig, providerId: string, args: st
|
||||
const payload = {
|
||||
providerId: invocation.providerId,
|
||||
command: wrapSshRemoteCommand(parsed.remoteCommand),
|
||||
cwd: invocation.route.plane === "host" ? invocation.route.workspace ?? undefined : undefined,
|
||||
tty: parsed.remoteCommand === null,
|
||||
stdinEotOnEnd: parsed.remoteCommand !== null,
|
||||
openTimeoutMs,
|
||||
|
||||
Reference in New Issue
Block a user