diff --git a/scripts/src/ssh.ts b/scripts/src/ssh.ts index 88e33246..0387a176 100644 --- a/scripts/src/ssh.ts +++ b/scripts/src/ssh.ts @@ -1322,7 +1322,7 @@ function parseK3sControlPlaneOperation(route: ParsedSshRoute, args: string[]): P throw new Error("remote patch entrypoints are `apply-patch` for the default v2 engine and `apply-patch-v1` for the legacy helper"); } if (operation === "script" || operation === "sh") { - return { remoteCommand: buildK3sScriptCommand(args.slice(1)), requiresStdin: true, invocationKind: "helper" }; + return buildK3sScriptOperation(args.slice(1)); } if (operation === "shell") { const parsed = parseShellStringOperationArgs(args.slice(1), `ssh ${route.providerId}:k3s shell`); @@ -1359,7 +1359,7 @@ function parseK3sTargetOperation(route: ParsedSshRoute, args: string[]): ParsedS if (operation === "patch" || operation === "patch-v1" || operation === "v2") { throw new Error("remote patch entrypoints are `apply-patch` for the default v2 engine and `apply-patch-v1` for the legacy helper"); } - if (operation === "script") return { remoteCommand: buildK3sScriptCommand([...targetArgs, ...operationArgs]), requiresStdin: true, invocationKind: "helper" }; + if (operation === "script") return buildK3sScriptOperation([...targetArgs, ...operationArgs]); if (operation === "shell") { const parsed = parseShellStringOperationArgs(operationArgs, `ssh ${route.raw} shell`); return { remoteCommand: buildK3sExecCommand([...targetArgs, "--", parsed.shell, "-c", parsed.command]), requiresStdin: false, invocationKind: "helper" }; @@ -1439,7 +1439,11 @@ function buildK3sCommand(providerId: string, args: string[]): string { } if (action === "guard") return buildK3sGuardCommand(providerId); if (action === "exec") return buildK3sExecCommand(args.slice(1)); - if (action === "script") return buildK3sScriptCommand(args.slice(1)); + if (action === "script") { + const parsed = buildK3sScriptOperation(args.slice(1)); + if (parsed.remoteCommand === null) throw new Error("ssh k3s script resolved to an interactive command unexpectedly"); + return parsed.remoteCommand; + } if (action === "logs") return buildK3sLogsCommand(args.slice(1)); if (action === "kubectl") { const kubectlArgs = args.slice(1); @@ -1511,8 +1515,15 @@ function buildK3sExecCommand(args: string[]): string { return shellArgv(["env", `KUBECONFIG=${nativeK3sKubeconfig}`, "kubectl", ...kubectlArgs]); } -function buildK3sScriptCommand(args: string[]): string { +function buildK3sScriptOperation(args: string[]): ParsedSshArgs { const parsed = parseK3sTargetOptions(args, "ssh k3s script", { requireCommand: false, allowCommand: true, allowShell: true }); + if (parsed.shell === null && parsed.command.length > 0) { + return { remoteCommand: buildK3sInlineScriptCommand(parsed), requiresStdin: false, invocationKind: "helper" }; + } + return { remoteCommand: buildK3sStdinScriptCommand(parsed), requiresStdin: true, invocationKind: "helper" }; +} + +function buildK3sStdinScriptCommand(parsed: K3sTargetOptions): string { if (parsed.namespace === null && parsed.resource === null) return buildK3sHostScriptCommand(parsed); if (parsed.namespace === null) throw new Error("ssh k3s script target requires --namespace "); if (parsed.resource === null) throw new Error("ssh k3s script target requires --deployment , --pod or --resource "); @@ -1531,6 +1542,31 @@ function buildK3sScriptCommand(args: string[]): string { return shellArgv(["env", `KUBECONFIG=${nativeK3sKubeconfig}`, "kubectl", ...kubectlArgs]); } +function buildK3sInlineScriptCommand(parsed: K3sTargetOptions): string { + if (parsed.command.length === 0) throw new Error("ssh k3s script -- requires a command"); + 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"); + const command = parsed.command.length === 1 ? ["sh", "-c", parsed.command[0] ?? ""] : parsed.command; + if (parsed.namespace === null && parsed.resource === null) { + 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"); + return shellArgv(["env", `KUBECONFIG=${nativeK3sKubeconfig}`, ...command]); + } + if (parsed.namespace === null) throw new Error("ssh k3s script target requires --namespace "); + if (parsed.resource === null) throw new Error("ssh k3s script target requires --deployment , --pod or --resource "); + const kubectlArgs = [ + "exec", + "-n", parsed.namespace, + parsed.resource, + ...(parsed.container === null ? [] : ["-c", parsed.container]), + ...parsed.kubectlOptions, + "--", + ...withK3sWorkspace(parsed, command), + ]; + return shellArgv(["env", `KUBECONFIG=${nativeK3sKubeconfig}`, "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 "); diff --git a/scripts/ssh-argv-guidance-contract-test.ts b/scripts/ssh-argv-guidance-contract-test.ts index 954203aa..56f638a1 100644 --- a/scripts/ssh-argv-guidance-contract-test.ts +++ b/scripts/ssh-argv-guidance-contract-test.ts @@ -475,6 +475,14 @@ export async function runSshArgvGuidanceContract(): Promise { assertCondition(routeControlScript.parsed.requiresStdin === true, "k3s control-plane script operation must stream local stdin", routeControlScript); assertCondition(routeControlScript.parsed.remoteCommand === "'env' 'KUBECONFIG=/etc/rancher/k3s/k3s.yaml' 'bash' '-s' '--' 'arg'", "D601:k3s script must inject native kubeconfig without manual export", routeControlScript); + const routeControlScriptOneLiner = parseSshInvocation("D601:k3s", ["script", "--", "echo k3s-script-ok"]); + assertCondition(routeControlScriptOneLiner.parsed.requiresStdin === false, "k3s control-plane script -- one-liner must not wait for stdin", routeControlScriptOneLiner); + assertCondition(routeControlScriptOneLiner.parsed.remoteCommand === "'env' 'KUBECONFIG=/etc/rancher/k3s/k3s.yaml' 'sh' '-c' 'echo k3s-script-ok'", "k3s control-plane script -- one-liner must run through the native kubeconfig shell path", routeControlScriptOneLiner); + + const routePodScriptOneLiner = parseSshInvocation("D601:k3s:hwlab-dev:hwlab-cloud-api", ["script", "--", "echo pod-script-ok"]); + assertCondition(routePodScriptOneLiner.parsed.requiresStdin === false, "k3s workload script -- one-liner must not wait for stdin", routePodScriptOneLiner); + assertCondition(routePodScriptOneLiner.parsed.remoteCommand === "'env' 'KUBECONFIG=/etc/rancher/k3s/k3s.yaml' 'kubectl' 'exec' '-n' 'hwlab-dev' 'deployment/hwlab-cloud-api' '--' 'sh' '-c' 'echo pod-script-ok'", "k3s workload script -- one-liner must run as sh -c inside the workload", routePodScriptOneLiner); + const topLevelScriptSeparator = extractRemoteCliOptions(["ssh", "D601:/tmp", "script", "--", "sed", "-n", "1,2p", "file.txt"]); assertCondition(JSON.stringify(topLevelScriptSeparator.args) === JSON.stringify(["ssh", "D601:/tmp", "script", "--", "sed", "-n", "1,2p", "file.txt"]), "top-level remote option parser must preserve command-local -- after the command starts", topLevelScriptSeparator); const topLevelScriptInvocation = parseSshInvocation(topLevelScriptSeparator.args[1] as string, topLevelScriptSeparator.args.slice(2));