Skip to content

Commit 9fd40f6

Browse files
committed
feat(kubectl): Implement PodExec tunnel emulation
1 parent c704b2a commit 9fd40f6

File tree

1 file changed

+82
-8
lines changed

1 file changed

+82
-8
lines changed

transports/via-kubectl-raw.ts

Lines changed: 82 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { readableStreamFromReader, TextLineStream } from '../deps.ts';
2-
import { RestClient, RequestOptions, JSONValue } from '../lib/contract.ts';
2+
import { RestClient, RequestOptions, JSONValue, KubernetesTunnel } from '../lib/contract.ts';
33
import { JsonParsingTransformer } from '../lib/stream-transformers.ts';
44

55
const isVerbose = Deno.args.includes('--verbose');
@@ -32,6 +32,7 @@ export class KubectlRawRestClient implements RestClient {
3232
bodyRaw?: Uint8Array;
3333
bodyJson?: JSONValue;
3434
bodyStream?: ReadableStream<Uint8Array>;
35+
bodyPassthru?: boolean;
3536
}) {
3637

3738
const hasReqBody = opts.bodyJson !== undefined || !!opts.bodyRaw || !!opts.bodyStream;
@@ -41,14 +42,13 @@ export class KubectlRawRestClient implements RestClient {
4142
'--context', this.contextName,
4243
] : [];
4344

44-
const kubectl = new Deno.Command('kubectl', {
45+
const p = new Deno.Command('kubectl', {
4546
args: [...ctxArgs, ...args],
46-
stdin: hasReqBody ? 'piped' : 'null',
47+
stdin: (hasReqBody || opts.bodyPassthru) ? 'piped' : 'null',
4748
stdout: 'piped',
4849
stderr: 'inherit',
4950
signal: opts.abortSignal,
50-
});
51-
const p = kubectl.spawn();
51+
}).spawn();
5252

5353
if (hasReqBody) {
5454
if (opts.bodyStream) {
@@ -82,6 +82,21 @@ export class KubectlRawRestClient implements RestClient {
8282

8383
if (opts.abortSignal?.aborted) throw new Error(`Given AbortSignal is already aborted`);
8484

85+
if (opts.expectTunnel) {
86+
if (opts.expectTunnel.includes('v4.channel.k8s.io')) {
87+
// We can implement PodExec with `kubectl exec`, for this specific route:
88+
const match = new URLPattern({
89+
pathname: '/api/:version/namespaces/:namespace/pods/:podName/exec',
90+
}).exec({ pathname: opts.path });
91+
if (match) {
92+
const { namespace, podName } = match.pathname.groups;
93+
return await this.emulateExecTunnel(namespace!, podName!, opts.querystring ?? new URLSearchParams(), opts.abortSignal);
94+
}
95+
}
96+
if (opts.expectTunnel) throw new Error(
97+
`That socket-based API (via ${opts.expectTunnel[0]}) is not implemented by this client.`);
98+
}
99+
85100
let path = opts.path || '/';
86101
const query = opts.querystring?.toString() ?? '';
87102
if (query) {
@@ -91,9 +106,6 @@ export class KubectlRawRestClient implements RestClient {
91106
const hasReqBody = opts.bodyJson !== undefined || !!opts.bodyRaw || !!opts.bodyStream;
92107
isVerbose && console.error(opts.method, path, hasReqBody ? '(w/ body)' : '');
93108

94-
if (opts.expectTunnel) throw new Error(
95-
`Channel-based APIs are not currently implemented by this client.`);
96-
97109
let rawArgs = [command, ...(hasReqBody ? ['-f', '-'] : []), "--raw", path];
98110

99111
if (command === 'patch') {
@@ -139,6 +151,68 @@ export class KubectlRawRestClient implements RestClient {
139151
}
140152
}
141153

154+
private async emulateExecTunnel(namespace: string, podName: string, querystring: URLSearchParams, signal?: AbortSignal): Promise<KubernetesTunnel> {
155+
const wantsStdin = querystring.get('stdin') == '1';
156+
const wantsTty = querystring.get('tty') == '1';
157+
const wantsContainer = querystring.get('container');
158+
159+
// upstream feature request: https://github.com/denoland/deno/issues/3994
160+
if (wantsTty) throw new Error(
161+
`This Kubernetes client (${this.constructor.name} does not support opening TTYs. Try a Kubeconfig-based client if you need TTY.`);
162+
163+
const [p, status] = await this.runKubectl([
164+
'exec',
165+
...(querystring.get('stdin') == '1' ? ['--stdin'] : []),
166+
...(querystring.get('tty') == '1' ? ['--tty'] : []),
167+
...(namespace ? ['-n', namespace] : []),
168+
`--quiet`,
169+
podName,
170+
...(wantsContainer ? ['-c', wantsContainer] : []),
171+
`--`, // disable non-positional arguments after here, for safety
172+
...querystring.getAll('command'),
173+
], {
174+
abortSignal: signal,
175+
bodyPassthru: true, // lets us use the raw streams
176+
});
177+
178+
return {
179+
transportProtocol: 'Opaque',
180+
subProtocol: 'v4.channel.k8s.io',
181+
ready: () => Promise.resolve(), // we don't actually know!
182+
stop: () => Promise.resolve(p.kill()),
183+
getChannel: (opts) => {
184+
if (opts.streamIndex == 0 && wantsStdin) {
185+
return Promise.resolve({ writable: p.stdin } as any);
186+
}
187+
if (opts.streamIndex == 1) {
188+
return Promise.resolve({ readable: p.stdout } as any);
189+
}
190+
if (opts.streamIndex == 2) {
191+
// We don't pipe stderr, but we don't block it either
192+
// Just provide a dummy stream for compatibility
193+
const readable = new ReadableStream({
194+
start(ctlr) { ctlr.close() },
195+
});
196+
return Promise.resolve({ readable } as any);
197+
}
198+
if (opts.streamIndex == 3) {
199+
// Invent a JSON stream and give a limited ExecStatus
200+
const readable = new ReadableStream({
201+
async start(ctlr) {
202+
const stat = await status;
203+
ctlr.enqueue(new TextEncoder().encode(JSON.stringify({
204+
status: stat.success ? 'Success' : 'Failure',
205+
message: `kubectl exited with ${stat.code}`,
206+
})));
207+
ctlr.close();
208+
},
209+
});
210+
return Promise.resolve({ readable } as any);
211+
}
212+
throw new Error(`BUG: Unmocked stream ${opts.streamIndex} in kubectl client!`);
213+
},
214+
};
215+
}
142216
}
143217

144218
// `kubectl patch` doesn't have --raw so we convert the HTTP request into a non-raw `kubectl patch` command

0 commit comments

Comments
 (0)