feat: split k3s ssh routes from operations
This commit is contained in:
+8
-6
@@ -19,14 +19,14 @@ export function rootHelp(): unknown {
|
||||
{ command: "server cleanup plan [--min-age-hours N] [--limit N]", description: "Dry-run Docker image cleanup plan only: list active/protected images, stale candidates older than the default 24h threshold, risk, estimated reclaim, and manual review commands without deleting anything." },
|
||||
{ command: "server rebuild <backend-core|frontend|dev-frontend-proxy|provider-gateway|todo-note|code-queue-mgr|project-manager|baidu-netdisk|oa-event-flow>", description: "Maintenance-only local Compose rebuild for reviewed main-server services; frontend standard release must use CI artifact plus deploy apply dev/prod artifact consumers." },
|
||||
{ command: "provider attach <providerId> [--master-server URL] [--up] [--force] | provider triage <providerId> [--observed-error text] [--observed-scope scope] [--microservice id ...] [--full|--raw]", description: "Generate the minimal external provider-gateway env/compose bundle or run the low-noise read-only provider health triage contract." },
|
||||
{ command: "ssh <route> [ssh-like args...]", description: "Open a Host SSH / WSL SSH maintenance session through the provider-gateway bridge; route syntax such as `D601:k3s:kubectl` keeps distributed targets structured." },
|
||||
{ command: "ssh <route> [operation args...]", description: "Open a Host SSH / WSL SSH maintenance session through the provider-gateway bridge; route syntax such as `D601:k3s:hwlab-dev:hwlab-cloud-api` only locates distributed targets." },
|
||||
{ command: "ssh <providerId> apply-patch [tool args...] < patch.diff", description: "Invoke the injected remote apply_patch helper directly over SSH passthrough and stream the patch from local stdin." },
|
||||
{ command: "ssh <providerId> py [script-args...] < script.py", description: "Run remote Python from local stdin through SSH passthrough without nested shell quoting; extra args become script argv." },
|
||||
{ command: "ssh <providerId> script [--shell sh|bash] [script-args...] <<'SCRIPT' ...", description: "Run a remote shell script from local stdin using shell -s, avoiding shell-command strings and nested remote heredocs." },
|
||||
{ command: "ssh <providerId> skills [--scope all|wsl|windows] [--limit N]", description: "Discover WSL/Linux and, for WSL providers, Windows skill directories in one SSH passthrough call." },
|
||||
{ command: "ssh <providerId> find <path...> [--max-depth N] [--type d|f|l] [--contains TEXT] [--iname PATTERN] [--limit N] [--sort]", description: "Run a structured remote find command without nested shell quoting or parentheses." },
|
||||
{ command: "ssh <providerId> glob [--root DIR] [--pattern PATTERN] [--contains TEXT] [--type any|f|d] [--limit N] [--sort]", description: "Run remote glob matching through the injected helper without shell glob expansion." },
|
||||
{ command: "ssh D601:k3s[:kubectl|:script:namespace:workload|:namespace:workload[:container]] ...", description: "Run D601 native k3s kubectl, direct workload exec, or stdin shell scripts through route syntax with KUBECONFIG fixed and argv assembled by the CLI." },
|
||||
{ command: "ssh D601:k3s[:namespace:workload[:container]] <kubectl|logs|exec|script|apply-patch|command> ...", description: "Locate the D601 native k3s control plane or workload with route syntax, then run a separate operation with KUBECONFIG fixed and argv assembled by the CLI." },
|
||||
{ command: "ssh <providerId> argv <command> [args...]", description: "Run a non-interactive remote command with each argv token shell-quoted by UniDesk before SSH passthrough; use `ssh <providerId> script` when shell features are required." },
|
||||
{ command: "microservice list", description: "List UniDesk-managed user services and their provider/runtime mapping." },
|
||||
{ command: "microservice status <id>", description: "Show one user service config, repository reference, backend mapping, and runtime status." },
|
||||
@@ -154,15 +154,17 @@ export function sshHelp(): unknown {
|
||||
"bun scripts/cli.ts ssh <providerId> find <path...> [--contains TEXT] [--limit N]",
|
||||
"bun scripts/cli.ts ssh <providerId> glob [--root DIR] [--pattern PATTERN]",
|
||||
"bun scripts/cli.ts ssh D601:k3s",
|
||||
"bun scripts/cli.ts ssh D601:k3s:kubectl get pods -n hwlab-dev",
|
||||
"bun scripts/cli.ts ssh D601:k3s kubectl get pods -n hwlab-dev",
|
||||
"bun scripts/cli.ts ssh D601:k3s script <<'SCRIPT'",
|
||||
"bun scripts/cli.ts ssh D601:k3s:hwlab-dev:hwlab-cloud-api apply-patch <<'PATCH'",
|
||||
"bun scripts/cli.ts ssh D601:k3s:hwlab-dev:hwlab-cloud-api node -e 'console.log(process.version)'",
|
||||
"bun scripts/cli.ts ssh D601:k3s:script:hwlab-dev:hwlab-cloud-api <<'SCRIPT'",
|
||||
"bun scripts/cli.ts ssh D601:k3s:logs:hwlab-dev:hwlab-cloud-api --tail 80",
|
||||
"bun scripts/cli.ts ssh D601:k3s:hwlab-dev:hwlab-cloud-api script <<'SCRIPT'",
|
||||
"bun scripts/cli.ts ssh D601:k3s:hwlab-dev:hwlab-cloud-api logs --tail 80",
|
||||
],
|
||||
notes: [
|
||||
"ssh --help and ssh <route> --help print this JSON help and never open an interactive session.",
|
||||
"For non-interactive remote commands, prefer argv for a single process and script/stdin for shell logic.",
|
||||
"Route syntax is provider:plane:entry-or-namespace:resource:container. For D601 native k3s, D601:k3s controls the cluster, D601:k3s:kubectl exposes kubectl, D601:k3s:<namespace>:<workload> defaults to kubectl exec, and D601:k3s:script:<namespace>:<workload> streams local stdin to shell -s in the target pod.",
|
||||
"Route syntax is provider:plane[:namespace:resource[:container]] and locates a distributed target only. For D601 native k3s, D601:k3s locates the control plane, D601:k3s:<namespace>:<workload> locates a workload, and kubectl/script/logs/apply-patch/exec are operations placed after the route.",
|
||||
"If an ssh-like remote command fails with timeout/kex/exit-255 friction, stderr includes one low-noise UNIDESK_SSH_HINT JSON line with the argv retry command.",
|
||||
"Use -- before a remote command that intentionally starts with a dash.",
|
||||
],
|
||||
|
||||
+387
-42
@@ -5,6 +5,8 @@ export interface ParsedSshArgs {
|
||||
remoteCommand: string | null;
|
||||
requiresStdin: boolean;
|
||||
invocationKind: SshInvocationKind;
|
||||
stdinPrefix?: string;
|
||||
stdinSuffix?: string;
|
||||
}
|
||||
|
||||
export type SshInvocationKind = "interactive" | "argv" | "helper" | "ssh-like";
|
||||
@@ -38,11 +40,12 @@ 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([
|
||||
const legacyK3sOperationRouteSegments = new Set([
|
||||
"guard",
|
||||
"kubectl",
|
||||
"exec",
|
||||
"script",
|
||||
"apply-patch",
|
||||
"logs",
|
||||
"get",
|
||||
"describe",
|
||||
@@ -200,6 +203,266 @@ if __name__ == "__main__":
|
||||
main()
|
||||
`;
|
||||
|
||||
const remoteShApplyPatchSource = String.raw`#!/bin/sh
|
||||
set -eu
|
||||
|
||||
die() {
|
||||
printf 'apply_patch: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
mk_temp() {
|
||||
mktemp "${"$"}{TMPDIR:-/tmp}/unidesk-apply-patch.XXXXXX" || exit 1
|
||||
}
|
||||
|
||||
ensure_parent() {
|
||||
case "$1" in
|
||||
*/*)
|
||||
dir=${"$"}{1%/*}
|
||||
[ -n "$dir" ] || dir=/
|
||||
mkdir -p "$dir"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
read_text_preserve_newlines() {
|
||||
marker="__UNIDESK_APPLY_PATCH_EOF_$$__"
|
||||
text=$(cat "$1"; printf '%s' "$marker") || die "failed to read $1"
|
||||
printf '%s' "${"$"}{text%"$marker"}"
|
||||
}
|
||||
|
||||
write_file() {
|
||||
target=$1
|
||||
source=$2
|
||||
ensure_parent "$target"
|
||||
cp "$source" "$target" || die "failed to write $target"
|
||||
}
|
||||
|
||||
replace_once() {
|
||||
target=$1
|
||||
search_file=$2
|
||||
replace_file=$3
|
||||
[ -e "$target" ] || die "file not found: $target"
|
||||
|
||||
marker="__UNIDESK_APPLY_PATCH_EOF_$$__"
|
||||
old=$(cat "$target"; printf '%s' "$marker") || die "failed to read $target"
|
||||
old=${"$"}{old%"$marker"}
|
||||
search=$(cat "$search_file"; printf '%s' "$marker") || die "failed to read hunk search"
|
||||
search=${"$"}{search%"$marker"}
|
||||
replacement=$(cat "$replace_file"; printf '%s' "$marker") || die "failed to read hunk replacement"
|
||||
replacement=${"$"}{replacement%"$marker"}
|
||||
|
||||
if [ -z "$search" ]; then
|
||||
new=$replacement$old
|
||||
else
|
||||
case "$old" in
|
||||
*"$search"*)
|
||||
prefix=${"$"}{old%%"$search"*}
|
||||
suffix=${"$"}{old#*"$search"}
|
||||
new=$prefix$replacement$suffix
|
||||
;;
|
||||
*)
|
||||
die "hunk context not found in $target"
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
out=$(mk_temp)
|
||||
printf '%s' "$new" > "$out" || die "failed to render patched file"
|
||||
write_file "$target" "$out"
|
||||
rm -f "$out"
|
||||
}
|
||||
|
||||
apply_update() {
|
||||
target=$1
|
||||
body=$2
|
||||
in_hunk=0
|
||||
search_file=
|
||||
replace_file=
|
||||
changed=0
|
||||
|
||||
finish_hunk() {
|
||||
[ "$in_hunk" = 1 ] || return 0
|
||||
replace_once "$target" "$search_file" "$replace_file"
|
||||
rm -f "$search_file" "$replace_file"
|
||||
search_file=
|
||||
replace_file=
|
||||
in_hunk=0
|
||||
changed=1
|
||||
}
|
||||
|
||||
while IFS= read -r line || [ -n "$line" ]; do
|
||||
case "$line" in
|
||||
"*** End of File"*)
|
||||
continue
|
||||
;;
|
||||
"@@"*)
|
||||
finish_hunk
|
||||
search_file=$(mk_temp)
|
||||
replace_file=$(mk_temp)
|
||||
in_hunk=1
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
[ "$in_hunk" = 1 ] || die "expected hunk header in $target"
|
||||
case "$line" in
|
||||
" "*)
|
||||
text=${"$"}{line#?}
|
||||
printf '%s\n' "$text" >> "$search_file"
|
||||
printf '%s\n' "$text" >> "$replace_file"
|
||||
;;
|
||||
"-"*)
|
||||
text=${"$"}{line#?}
|
||||
printf '%s\n' "$text" >> "$search_file"
|
||||
;;
|
||||
"+"*)
|
||||
text=${"$"}{line#?}
|
||||
printf '%s\n' "$text" >> "$replace_file"
|
||||
;;
|
||||
"\\ No newline at end of file")
|
||||
;;
|
||||
*)
|
||||
die "bad hunk line in $target: $line"
|
||||
;;
|
||||
esac
|
||||
done < "$body"
|
||||
|
||||
finish_hunk
|
||||
[ "$changed" = 1 ] || [ -e "$target" ] || die "file not found: $target"
|
||||
}
|
||||
|
||||
is_top_header() {
|
||||
case "$1" in
|
||||
"*** Add File: "*|"*** Update File: "*|"*** Delete File: "*|"*** End Patch")
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
pushed=0
|
||||
pushed_line=
|
||||
next_patch_line() {
|
||||
if [ "$pushed" = 1 ]; then
|
||||
line=$pushed_line
|
||||
pushed=0
|
||||
pushed_line=
|
||||
return 0
|
||||
fi
|
||||
if IFS= read -r line; then
|
||||
return 0
|
||||
fi
|
||||
[ -n "${"$"}{line:-}" ]
|
||||
}
|
||||
|
||||
push_patch_line() {
|
||||
pushed_line=$1
|
||||
pushed=1
|
||||
}
|
||||
|
||||
parse_add_file() {
|
||||
target=$1
|
||||
[ ! -e "$target" ] || die "file already exists: $target"
|
||||
tmp=$(mk_temp)
|
||||
while next_patch_line; do
|
||||
if is_top_header "$line"; then
|
||||
push_patch_line "$line"
|
||||
break
|
||||
fi
|
||||
case "$line" in
|
||||
"+"*)
|
||||
printf '%s\n' "${"$"}{line#?}" >> "$tmp"
|
||||
;;
|
||||
*)
|
||||
rm -f "$tmp"
|
||||
die "add file lines must start with + for $target"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
write_file "$target" "$tmp"
|
||||
rm -f "$tmp"
|
||||
}
|
||||
|
||||
parse_update_file() {
|
||||
target=$1
|
||||
move_to=
|
||||
body=$(mk_temp)
|
||||
if next_patch_line; then
|
||||
case "$line" in
|
||||
"*** Move to: "*)
|
||||
move_to=${"$"}{line#"*** Move to: "}
|
||||
;;
|
||||
*)
|
||||
push_patch_line "$line"
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
while next_patch_line; do
|
||||
if is_top_header "$line"; then
|
||||
push_patch_line "$line"
|
||||
break
|
||||
fi
|
||||
case "$line" in
|
||||
"*** Move to: "*)
|
||||
rm -f "$body"
|
||||
die "move marker must appear before update hunks"
|
||||
;;
|
||||
*)
|
||||
printf '%s\n' "$line" >> "$body"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
if [ -s "$body" ]; then
|
||||
apply_update "$target" "$body"
|
||||
elif [ -z "$move_to" ] && [ ! -e "$target" ]; then
|
||||
rm -f "$body"
|
||||
die "file not found: $target"
|
||||
fi
|
||||
rm -f "$body"
|
||||
if [ -n "$move_to" ]; then
|
||||
[ -e "$target" ] || die "file not found: $target"
|
||||
[ ! -e "$move_to" ] || die "target file already exists: $move_to"
|
||||
ensure_parent "$move_to"
|
||||
mv "$target" "$move_to" || die "failed to move $target to $move_to"
|
||||
fi
|
||||
}
|
||||
|
||||
main() {
|
||||
if [ "${"$"}{1:-}" = "-h" ] || [ "${"$"}{1:-}" = "--help" ]; then
|
||||
printf 'apply_patch: sh-only helper; reads *** Begin Patch format from stdin\n'
|
||||
return 0
|
||||
fi
|
||||
next_patch_line || die "patch must start with *** Begin Patch"
|
||||
[ "$line" = "*** Begin Patch" ] || die "patch must start with *** Begin Patch"
|
||||
while next_patch_line; do
|
||||
case "$line" in
|
||||
"*** End Patch")
|
||||
printf 'Done!\n'
|
||||
return 0
|
||||
;;
|
||||
"*** Add File: "*)
|
||||
parse_add_file "${"$"}{line#"*** Add File: "}"
|
||||
;;
|
||||
"*** Delete File: "*)
|
||||
target=${"$"}{line#"*** Delete File: "}
|
||||
rm -f "$target" || die "failed to delete $target"
|
||||
;;
|
||||
"*** Update File: "*)
|
||||
parse_update_file "${"$"}{line#"*** Update File: "}"
|
||||
;;
|
||||
*)
|
||||
die "unexpected patch line: $line"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
die "missing *** End Patch"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
`;
|
||||
|
||||
const remoteGlobSource = String.raw`#!/usr/bin/env python3
|
||||
import argparse
|
||||
import glob
|
||||
@@ -552,6 +815,7 @@ export function parseSshArgs(args: string[]): ParsedSshArgs {
|
||||
return { remoteCommand: shellArgv(["glob", ...args.slice(1)]), requiresStdin: false, invocationKind: "helper" };
|
||||
}
|
||||
if (subcommand === "k3s") {
|
||||
if (args[1] === "apply-patch") return buildK3sApplyPatchCommand(args.slice(2));
|
||||
return { remoteCommand: buildK3sCommand(args.slice(1)), requiresStdin: false, invocationKind: "helper" };
|
||||
}
|
||||
if (argvQuotedSshSubcommands.has(subcommand)) {
|
||||
@@ -598,19 +862,8 @@ export function parseSshRoute(target: string): ParsedSshRoute {
|
||||
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,
|
||||
};
|
||||
}
|
||||
const [first, second, third, fourth] = rest;
|
||||
if (first && legacyK3sOperationRouteSegments.has(first)) throw new Error(k3sOperationInRouteMessage(target, first, second, third));
|
||||
if (fourth !== undefined) throw new Error("ssh k3s target route supports at most provider:k3s:namespace:resource:container");
|
||||
return {
|
||||
providerId,
|
||||
@@ -623,6 +876,14 @@ export function parseSshRoute(target: string): ParsedSshRoute {
|
||||
};
|
||||
}
|
||||
|
||||
function k3sOperationInRouteMessage(target: string, operation: string, namespace: string | undefined, resource: string | undefined): string {
|
||||
if (operation === "kubectl") return `ssh k3s route must locate a target only; use "ssh D601:k3s kubectl ..." instead of "${target}"`;
|
||||
if (operation === "script" && namespace === undefined) return `ssh k3s route must locate a target only; use "ssh D601:k3s script <<'SCRIPT'" instead of "${target}"`;
|
||||
if (operation === "guard") return `ssh k3s route must locate a target only; use "ssh D601:k3s guard" or "ssh D601:k3s" instead of "${target}"`;
|
||||
if (namespace !== undefined && resource !== undefined) return `ssh k3s route must locate a target only; use "ssh D601:k3s:${namespace}:${resource} ${operation} ..." instead of "${target}"`;
|
||||
return `ssh k3s route must locate a target only; put operation "${operation}" after the route, for example "ssh D601:k3s ${operation} ..."`;
|
||||
}
|
||||
|
||||
function shellArgv(args: string[]): string {
|
||||
return args.map(shellQuote).join(" ");
|
||||
}
|
||||
@@ -729,41 +990,57 @@ function buildFindCommand(args: string[]): string {
|
||||
|
||||
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 === "script") {
|
||||
return { remoteCommand: buildK3sScriptCommand([...k3sRouteTargetArgs(route), ...args]), requiresStdin: true, 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" };
|
||||
return parseK3sControlPlaneOperation(args);
|
||||
}
|
||||
if (route.namespace === null || route.resource === null) {
|
||||
throw new Error("ssh k3s target route requires provider:k3s:<namespace>:<deployment|pod/resource>");
|
||||
}
|
||||
return parseK3sTargetOperation(route, args);
|
||||
}
|
||||
|
||||
function parseK3sControlPlaneOperation(args: string[]): ParsedSshArgs {
|
||||
const operation = args[0] ?? "guard";
|
||||
if (operation === "apply-patch" || operation === "patch") {
|
||||
throw new Error("ssh D601:k3s apply-patch requires a workload route: ssh D601:k3s:<namespace>:<workload> apply-patch");
|
||||
}
|
||||
if (operation === "script" || operation === "sh") {
|
||||
return { remoteCommand: buildK3sScriptCommand(args.slice(1)), requiresStdin: true, invocationKind: "helper" };
|
||||
}
|
||||
if (operation === "guard") {
|
||||
if (args.length > 1) throw new Error("ssh D601:k3s guard does not accept extra arguments");
|
||||
return { remoteCommand: buildD601K3sGuardCommand(), requiresStdin: false, invocationKind: "helper" };
|
||||
}
|
||||
return { remoteCommand: buildK3sCommand(args), requiresStdin: false, invocationKind: "helper" };
|
||||
}
|
||||
|
||||
function parseK3sTargetOperation(route: ParsedSshRoute, args: string[]): ParsedSshArgs {
|
||||
const targetArgs = k3sRouteTargetArgs(route);
|
||||
if (args.length === 0) {
|
||||
return {
|
||||
remoteCommand: shellArgv(["env", `KUBECONFIG=${d601NativeKubeconfig}`, "kubectl", "get", "-n", route.namespace, normalizeK3sRouteResource(route.resource), "-o", "wide"]),
|
||||
remoteCommand: buildK3sTargetObjectCommand("get", route, ["-o", "wide"]),
|
||||
requiresStdin: false,
|
||||
invocationKind: "helper",
|
||||
};
|
||||
}
|
||||
return { remoteCommand: buildK3sExecCommand([...k3sRouteTargetArgs(route), ...k3sRouteCommandArgs(args)]), requiresStdin: false, invocationKind: "helper" };
|
||||
const operation = args[0] ?? "";
|
||||
const operationArgs = args.slice(1);
|
||||
if (operation === "apply-patch" || operation === "patch") return buildK3sApplyPatchCommand([...targetArgs, ...operationArgs]);
|
||||
if (operation === "script") return { remoteCommand: buildK3sScriptCommand([...targetArgs, ...operationArgs]), requiresStdin: true, invocationKind: "helper" };
|
||||
if (operation === "logs") return { remoteCommand: buildK3sLogsCommand([...targetArgs, ...operationArgs]), requiresStdin: false, invocationKind: "helper" };
|
||||
if (operation === "get" || operation === "describe") {
|
||||
return { remoteCommand: buildK3sTargetObjectCommand(operation, route, operationArgs), requiresStdin: false, invocationKind: "helper" };
|
||||
}
|
||||
if (operation === "kubectl") throw new Error("ssh k3s kubectl is a control-plane operation; use ssh D601:k3s kubectl ...");
|
||||
if (operation === "exec") {
|
||||
return { remoteCommand: buildK3sExecCommand([...targetArgs, ...k3sRouteCommandArgs(operationArgs)]), requiresStdin: false, invocationKind: "helper" };
|
||||
}
|
||||
return { remoteCommand: buildK3sExecCommand([...targetArgs, ...k3sRouteCommandArgs(args)]), requiresStdin: false, invocationKind: "helper" };
|
||||
}
|
||||
|
||||
function buildK3sTargetObjectCommand(action: "get" | "describe", route: ParsedSshRoute, args: string[]): string {
|
||||
if (route.namespace === null || route.resource === null) throw new Error(`ssh k3s ${action} target requires namespace and workload route`);
|
||||
if (args.includes("--follow") || args.includes("-f")) throw new Error(`ssh k3s target ${action} does not support follow mode`);
|
||||
return shellArgv(["env", `KUBECONFIG=${d601NativeKubeconfig}`, "kubectl", action, "-n", route.namespace, normalizeK3sRouteResource(route.resource), ...args]);
|
||||
}
|
||||
|
||||
function k3sRouteTargetArgs(route: ParsedSshRoute): string[] {
|
||||
@@ -853,8 +1130,9 @@ function buildK3sExecCommand(args: string[]): string {
|
||||
|
||||
function buildK3sScriptCommand(args: string[]): string {
|
||||
const parsed = parseK3sTargetOptions(args, "ssh k3s script", { requireCommand: false, allowCommand: true, allowShell: true });
|
||||
if (parsed.namespace === null) throw new Error("ssh k3s script requires --namespace <name>");
|
||||
if (parsed.resource === null) throw new Error("ssh k3s script requires --deployment <name>, --pod <name> or --resource <type/name>");
|
||||
if (parsed.namespace === null && parsed.resource === null) return buildK3sHostScriptCommand(parsed);
|
||||
if (parsed.namespace === null) throw new Error("ssh k3s script target requires --namespace <name>");
|
||||
if (parsed.resource === null) throw new Error("ssh k3s script target requires --deployment <name>, --pod <name> or --resource <type/name>");
|
||||
if (parsed.tty) throw new Error("ssh k3s script does not support --tty; stdin is reserved for the script body");
|
||||
const shell = parsed.shell ?? "sh";
|
||||
const kubectlArgs = [
|
||||
@@ -873,6 +1151,45 @@ function buildK3sScriptCommand(args: string[]): string {
|
||||
return shellArgv(["env", `KUBECONFIG=${d601NativeKubeconfig}`, "kubectl", ...kubectlArgs]);
|
||||
}
|
||||
|
||||
function buildK3sApplyPatchCommand(args: string[]): ParsedSshArgs {
|
||||
const parsed = parseK3sTargetOptions(args, "ssh k3s apply-patch", { requireCommand: false, allowCommand: true });
|
||||
if (parsed.namespace === null) throw new Error("ssh k3s apply-patch requires --namespace <name>");
|
||||
if (parsed.resource === null) throw new Error("ssh k3s apply-patch requires --deployment <name>, --pod <name> or --resource <type/name>");
|
||||
if (parsed.tty) throw new Error("ssh k3s apply-patch does not support --tty; stdin is reserved for the patch body");
|
||||
if (parsed.stdin) throw new Error("ssh k3s apply-patch does not accept --stdin; stdin is always the patch body");
|
||||
if (parsed.shell !== null) throw new Error("ssh k3s apply-patch does not accept --shell");
|
||||
if (parsed.kubectlOptions.length > 0) throw new Error("ssh k3s apply-patch does not accept kubectl log options");
|
||||
const kubectlArgs = [
|
||||
"exec",
|
||||
"-i",
|
||||
"-n", parsed.namespace,
|
||||
parsed.resource,
|
||||
...(parsed.container === null ? [] : ["-c", parsed.container]),
|
||||
"--",
|
||||
"sh",
|
||||
"-s",
|
||||
"--",
|
||||
...parsed.command,
|
||||
];
|
||||
const wrapper = podApplyPatchStdinWrapper();
|
||||
return {
|
||||
remoteCommand: shellArgv(["env", `KUBECONFIG=${d601NativeKubeconfig}`, "kubectl", ...kubectlArgs]),
|
||||
requiresStdin: true,
|
||||
invocationKind: "helper",
|
||||
stdinPrefix: wrapper.prefix,
|
||||
stdinSuffix: wrapper.suffix,
|
||||
};
|
||||
}
|
||||
|
||||
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.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=${d601NativeKubeconfig}`, shell, "-s", "--", ...parsed.command]);
|
||||
}
|
||||
|
||||
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>");
|
||||
@@ -1026,6 +1343,25 @@ function buildShellStdinCommand(args: string[]): string {
|
||||
return shellArgv([shell, "-s", "--", ...scriptArgs]);
|
||||
}
|
||||
|
||||
function podApplyPatchStdinWrapper(): { prefix: string; suffix: string } {
|
||||
const toolMarker = "__UNIDESK_APPLY_PATCH_TOOL__";
|
||||
const patchMarker = "__UNIDESK_APPLY_PATCH_PAYLOAD__";
|
||||
if (remoteShApplyPatchSource.includes(toolMarker)) throw new Error("remote apply_patch source contains reserved heredoc marker");
|
||||
return {
|
||||
prefix: [
|
||||
"set -eu",
|
||||
'UNIDESK_SSH_TOOL_DIR="${UNIDESK_SSH_TOOL_DIR:-/tmp/unidesk-ssh-tools}"',
|
||||
'mkdir -p "$UNIDESK_SSH_TOOL_DIR"',
|
||||
`cat > "$UNIDESK_SSH_TOOL_DIR/apply_patch" <<'${toolMarker}'`,
|
||||
remoteShApplyPatchSource.trimEnd(),
|
||||
toolMarker,
|
||||
'chmod 700 "$UNIDESK_SSH_TOOL_DIR/apply_patch"',
|
||||
`"$UNIDESK_SSH_TOOL_DIR/apply_patch" "$@" <<'${patchMarker}'`,
|
||||
].join("\n") + "\n",
|
||||
suffix: `\n${patchMarker}\n`,
|
||||
};
|
||||
}
|
||||
|
||||
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"';
|
||||
@@ -1274,7 +1610,16 @@ export async function runSsh(config: UniDeskConfig, providerId: string, args: st
|
||||
const rawMode = parsed.remoteCommand === null && process.stdin.isTTY;
|
||||
if (rawMode) process.stdin.setRawMode(true);
|
||||
process.stdin.resume();
|
||||
process.stdin.pipe(child.stdin);
|
||||
if (parsed.stdinPrefix !== undefined || parsed.stdinSuffix !== undefined) {
|
||||
if (parsed.stdinPrefix) child.stdin.write(parsed.stdinPrefix);
|
||||
process.stdin.pipe(child.stdin, { end: false });
|
||||
process.stdin.once("end", () => {
|
||||
if (parsed.stdinSuffix) child.stdin.write(parsed.stdinSuffix);
|
||||
child.stdin.end();
|
||||
});
|
||||
} else {
|
||||
process.stdin.pipe(child.stdin);
|
||||
}
|
||||
let stderrTail = "";
|
||||
const appendStderrTail = (chunk: Buffer | string): void => {
|
||||
const text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : chunk;
|
||||
|
||||
Reference in New Issue
Block a user