Skip to content

Commit ab32ebf

Browse files
authored
feat: Implement tunnel-based APIs (Exec, Attach, Portforward) (#6)
* fix(generation): Implement quirked argv parameters It seems like Kubernetes should OpenAPI should be changed to present this argument properly. In any non-trivial case, it will be a list. * Collapse param building loop for lists * Properly implement PodExec with examples * Improve PodExec interface and examples * Add missing method on PortforwardTunnel * Drop Deno v1.22 from CI - lacks Deno.consoleSize() * Update /x/kubernetes_client to v0.7.0 * Put 'tunnel' into tunnel API names * Fix array arg of portforward API too * Get PortForward going with WebSockets * Rename ChannelTunnel to StdioTunnel * Add a basic test for each tunnel utility class * Make test green on somewhat older Denos (v1.28) * Update README
1 parent f671940 commit ab32ebf

File tree

11 files changed

+627
-81
lines changed

11 files changed

+627
-81
lines changed

.github/workflows/deno-ci.yaml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ jobs:
1313
strategy:
1414
matrix:
1515
deno-version:
16-
- v1.22
1716
- v1.28
1817
- v1.32
1918
- v1.36
@@ -49,3 +48,6 @@ jobs:
4948

5049
- name: Check lib/examples/*.ts
5150
run: time deno check lib/examples/*.ts
51+
52+
- name: Test
53+
run: time deno test

generation/codegen-mod.ts

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export function generateModuleTypescript(surface: SurfaceMap, api: SurfaceApi):
2121
chunks.push(`import * as c from "../../common.ts";`);
2222
chunks.push(`import * as operations from "../../operations.ts";`);
2323
chunks.push(`import * as ${api.friendlyName} from "./structs.ts";`);
24+
const tunnelsImport = `import * as tunnels from "../../tunnels.ts";`;
2425
chunks.push('');
2526

2627
const foreignApis = new Set<SurfaceApi>();
@@ -114,6 +115,9 @@ export function generateModuleTypescript(surface: SurfaceMap, api: SurfaceApi):
114115
} else throw new Error(`Unknown param.in ${param.in}`);
115116
}
116117

118+
let funcName = op.operationName;
119+
let expectsTunnel: 'PortforwardTunnel' | 'StdioTunnel' | null = null;
120+
117121
// Entirely specialcase and collapse each method's proxy functions into one
118122
if (op['x-kubernetes-action'] === 'connect' && op.operationName.endsWith('Proxy')) {
119123
if (op.method !== 'get') return; // only emit the GET function, and make it generic
@@ -135,6 +139,29 @@ export function generateModuleTypescript(surface: SurfaceMap, api: SurfaceApi):
135139
chunks.push(` return await this.#client.performRequest({ ...opts, path });`);
136140
chunks.push(` }\n`);
137141
return;
142+
143+
// Specialcase bidirectionally-tunneled APIs (these use either SPDY/3.1 or WebSockets)
144+
} else if (op['x-kubernetes-action'] === 'connect') {
145+
if (op.method !== 'get') return; // only emit the GET function, method doesn't matter at this level
146+
147+
const middleName = op.operationName.slice('connectGet'.length);
148+
funcName = `tunnel${middleName}`;
149+
150+
if (middleName == 'PodAttach' || middleName == 'PodExec') {
151+
expectsTunnel = 'StdioTunnel';
152+
// Make several extra params required
153+
const commandArg = opts.find(x => x[0].name == 'command');
154+
if (commandArg) commandArg[0].required = true;
155+
const stdoutArg = opts.find(x => x[0].name == 'stdout');
156+
if (stdoutArg) stdoutArg[0].required = true;
157+
}
158+
if (middleName == 'PodPortforward') {
159+
expectsTunnel = 'PortforwardTunnel';
160+
}
161+
162+
if (!expectsTunnel) {
163+
throw new Error(`TODO: connect action was unexpected: ${funcName}`);
164+
}
138165
}
139166

140167
let accept = 'application/json';
@@ -158,7 +185,7 @@ export function generateModuleTypescript(surface: SurfaceMap, api: SurfaceApi):
158185
// return AcmeCertManagerIoV1.toOrder(resp);
159186
// }
160187

161-
chunks.push(` async ${op.operationName}(${writeSig(args, opts, ' ')}) {`);
188+
chunks.push(` async ${funcName}(${writeSig(args, opts, ' ')}) {`);
162189
const isWatch = op.operationName.startsWith('watch');
163190
const isStream = op.operationName.startsWith('stream');
164191

@@ -186,6 +213,17 @@ export function generateModuleTypescript(surface: SurfaceMap, api: SurfaceApi):
186213
case 'number':
187214
chunks.push(` ${maybeIf}query.append(${idStr}, String(opts[${idStr}]));`);
188215
break;
216+
case 'list': {
217+
const loop = `for (const item of opts[${idStr}]${opt[0].required ? '' : ' ?? []'}) `;
218+
if (opt[1].inner.type == 'string') {
219+
chunks.push(` ${loop}query.append(${idStr}, item);`);
220+
break;
221+
}
222+
if (opt[1].inner.type == 'number') {
223+
chunks.push(` ${loop}query.append(${idStr}, String(item));`);
224+
break;
225+
}
226+
} /* falls through */
189227
default:
190228
chunks.push(` // TODO: ${opt[0].in} ${opt[0].name} ${opt[0].required} ${opt[0].type} ${JSON.stringify(opt[1])}`);
191229
}
@@ -195,7 +233,10 @@ export function generateModuleTypescript(surface: SurfaceMap, api: SurfaceApi):
195233
chunks.push(` const resp = await this.#client.performRequest({`);
196234
chunks.push(` method: ${JSON.stringify(op.method.toUpperCase())},`);
197235
chunks.push(` path: \`\${this.#root}${JSON.stringify(opPath).slice(1,-1).replace(/{/g, '${')}\`,`);
198-
if (accept === 'application/json') {
236+
if (expectsTunnel) {
237+
if (!chunks.includes(tunnelsImport)) chunks.splice(6, 0, tunnelsImport);
238+
chunks.push(` expectTunnel: tunnels.${expectsTunnel}.supportedProtocols,`);
239+
} else if (accept === 'application/json') {
199240
chunks.push(` expectJson: true,`);
200241
}
201242
if (isWatch || isStream) {
@@ -219,6 +260,13 @@ export function generateModuleTypescript(surface: SurfaceMap, api: SurfaceApi):
219260
chunks.push(` abortSignal: opts.abortSignal,`);
220261
chunks.push(` });`);
221262

263+
if (expectsTunnel) {
264+
chunks.push(`\n const tunnel = new tunnels.${expectsTunnel}(resp, query);`);
265+
chunks.push(` await tunnel.ready;`);
266+
chunks.push(` return tunnel;`);
267+
chunks.push(` }\n`);
268+
return;
269+
}
222270
if (isStream) {
223271
if (accept === 'text/plain') {
224272
chunks.push(` return resp.pipeThrough(new TextDecoderStream('utf-8'));`);
@@ -285,6 +333,8 @@ export function generateModuleTypescript(surface: SurfaceMap, api: SurfaceApi):
285333
return `${api.friendlyName}.${shape.reference}`;
286334
case 'foreign':
287335
return `${shape.api.friendlyName}.${shape.name}`;
336+
case 'list':
337+
return `Array<${writeType(shape.inner)}>`;
288338
case 'special':
289339
return `c.${shape.name}`;
290340
}

generation/describe-surface.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,28 @@ export function describeSurface(wholeSpec: OpenAPI2) {
174174
const allParams = new Array<OpenAPI2RequestParameter>()
175175
.concat(methodObj.parameters ?? [], pathObj.parameters ?? []);
176176

177+
// Special-case for PodExec/PodAttach which do not type 'command' as a list.
178+
const commandArg = allParams.find(x => x.name == 'command' && x.description?.includes('argv array'));
179+
if (commandArg) {
180+
commandArg.schema = {
181+
type: 'array',
182+
items: {
183+
type: 'string',
184+
},
185+
};
186+
commandArg.type = undefined;
187+
}
188+
const portArg = allParams.find(x => x.name == 'ports' && x.description?.includes('List of ports'));
189+
if (portArg) {
190+
portArg.schema = {
191+
type: 'array',
192+
items: {
193+
type: 'number',
194+
},
195+
};
196+
portArg.type = undefined;
197+
}
198+
177199
if (opName == 'getPodLog') {
178200
// Add a streaming variant for pod logs
179201
api.operations.push({

lib/README.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,13 @@ see `/x/kubernetes_client` for more information.
3232

3333
## Changelog
3434

35-
* `v0.5.0` on `2023-08-??`:
36-
* Updating `/x/kubernetes_client` API contract to `v0.6.0`.
35+
* `v0.5.0` on `2023-08-19`:
36+
* Updating `/x/kubernetes_client` API contract to `v0.7.0`.
37+
* Actually implement PodExec, PodAttach, PodPortForward APIs with a new tunnel implementation.
38+
* Includes 'builtin' APIs generated from K8s `v1.28.0`.
39+
* New APIs: `admissionregistration.k8s.io/v1beta1`, `certificates.k8s.io/v1alpha1`.
40+
* Also, API additions for sidecar containers and `SelfSubjectReview`.
41+
* Fix several structures incorrectly typed as `{}` instead of `JSONValue`.
3742

3843
* `v0.4.0` on `2023-02-10`:
3944
* Updating `/x/kubernetes_client` API contract to `v0.5.0`.

lib/builtin/core@v1/mod.ts

Lines changed: 26 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import * as AutoscalingV1 from "../autoscaling@v1/structs.ts";
88
import * as PolicyV1 from "../policy@v1/structs.ts";
99
import * as MetaV1 from "../meta@v1/structs.ts";
1010
import * as CoreV1 from "./structs.ts";
11+
import * as tunnels from "../../tunnels.ts";
1112

1213
export class CoreV1Api {
1314
#client: c.RestClient;
@@ -1332,50 +1333,31 @@ export class CoreV1NamespacedApi {
13321333
return CoreV1.toPod(resp);
13331334
}
13341335

1335-
async connectGetPodAttach(name: string, opts: {
1336+
async tunnelPodAttach(name: string, opts: {
13361337
container?: string;
13371338
stderr?: boolean;
13381339
stdin?: boolean;
1339-
stdout?: boolean;
1340+
stdout: boolean;
13401341
tty?: boolean;
13411342
abortSignal?: AbortSignal;
1342-
} = {}) {
1343+
}) {
13431344
const query = new URLSearchParams;
13441345
if (opts["container"] != null) query.append("container", opts["container"]);
13451346
if (opts["stderr"] != null) query.append("stderr", opts["stderr"] ? '1' : '0');
13461347
if (opts["stdin"] != null) query.append("stdin", opts["stdin"] ? '1' : '0');
1347-
if (opts["stdout"] != null) query.append("stdout", opts["stdout"] ? '1' : '0');
1348+
query.append("stdout", opts["stdout"] ? '1' : '0');
13481349
if (opts["tty"] != null) query.append("tty", opts["tty"] ? '1' : '0');
13491350
const resp = await this.#client.performRequest({
13501351
method: "GET",
13511352
path: `${this.#root}pods/${name}/attach`,
1352-
expectJson: true,
1353+
expectTunnel: tunnels.StdioTunnel.supportedProtocols,
13531354
querystring: query,
13541355
abortSignal: opts.abortSignal,
13551356
});
1356-
}
13571357

1358-
async connectPostPodAttach(name: string, opts: {
1359-
container?: string;
1360-
stderr?: boolean;
1361-
stdin?: boolean;
1362-
stdout?: boolean;
1363-
tty?: boolean;
1364-
abortSignal?: AbortSignal;
1365-
} = {}) {
1366-
const query = new URLSearchParams;
1367-
if (opts["container"] != null) query.append("container", opts["container"]);
1368-
if (opts["stderr"] != null) query.append("stderr", opts["stderr"] ? '1' : '0');
1369-
if (opts["stdin"] != null) query.append("stdin", opts["stdin"] ? '1' : '0');
1370-
if (opts["stdout"] != null) query.append("stdout", opts["stdout"] ? '1' : '0');
1371-
if (opts["tty"] != null) query.append("tty", opts["tty"] ? '1' : '0');
1372-
const resp = await this.#client.performRequest({
1373-
method: "POST",
1374-
path: `${this.#root}pods/${name}/attach`,
1375-
expectJson: true,
1376-
querystring: query,
1377-
abortSignal: opts.abortSignal,
1378-
});
1358+
const tunnel = new tunnels.StdioTunnel(resp, query);
1359+
await tunnel.ready;
1360+
return tunnel;
13791361
}
13801362

13811363
async createPodBinding(name: string, body: CoreV1.Binding, opts: operations.PutOpts = {}) {
@@ -1437,54 +1419,33 @@ export class CoreV1NamespacedApi {
14371419
return PolicyV1.toEviction(resp);
14381420
}
14391421

1440-
async connectGetPodExec(name: string, opts: {
1441-
command?: string;
1422+
async tunnelPodExec(name: string, opts: {
1423+
command: Array<string>;
14421424
container?: string;
14431425
stderr?: boolean;
14441426
stdin?: boolean;
1445-
stdout?: boolean;
1427+
stdout: boolean;
14461428
tty?: boolean;
14471429
abortSignal?: AbortSignal;
1448-
} = {}) {
1430+
}) {
14491431
const query = new URLSearchParams;
1450-
if (opts["command"] != null) query.append("command", opts["command"]);
1432+
for (const item of opts["command"]) query.append("command", item);
14511433
if (opts["container"] != null) query.append("container", opts["container"]);
14521434
if (opts["stderr"] != null) query.append("stderr", opts["stderr"] ? '1' : '0');
14531435
if (opts["stdin"] != null) query.append("stdin", opts["stdin"] ? '1' : '0');
1454-
if (opts["stdout"] != null) query.append("stdout", opts["stdout"] ? '1' : '0');
1436+
query.append("stdout", opts["stdout"] ? '1' : '0');
14551437
if (opts["tty"] != null) query.append("tty", opts["tty"] ? '1' : '0');
14561438
const resp = await this.#client.performRequest({
14571439
method: "GET",
14581440
path: `${this.#root}pods/${name}/exec`,
1459-
expectJson: true,
1441+
expectTunnel: tunnels.StdioTunnel.supportedProtocols,
14601442
querystring: query,
14611443
abortSignal: opts.abortSignal,
14621444
});
1463-
}
14641445

1465-
async connectPostPodExec(name: string, opts: {
1466-
command?: string;
1467-
container?: string;
1468-
stderr?: boolean;
1469-
stdin?: boolean;
1470-
stdout?: boolean;
1471-
tty?: boolean;
1472-
abortSignal?: AbortSignal;
1473-
} = {}) {
1474-
const query = new URLSearchParams;
1475-
if (opts["command"] != null) query.append("command", opts["command"]);
1476-
if (opts["container"] != null) query.append("container", opts["container"]);
1477-
if (opts["stderr"] != null) query.append("stderr", opts["stderr"] ? '1' : '0');
1478-
if (opts["stdin"] != null) query.append("stdin", opts["stdin"] ? '1' : '0');
1479-
if (opts["stdout"] != null) query.append("stdout", opts["stdout"] ? '1' : '0');
1480-
if (opts["tty"] != null) query.append("tty", opts["tty"] ? '1' : '0');
1481-
const resp = await this.#client.performRequest({
1482-
method: "POST",
1483-
path: `${this.#root}pods/${name}/exec`,
1484-
expectJson: true,
1485-
querystring: query,
1486-
abortSignal: opts.abortSignal,
1487-
});
1446+
const tunnel = new tunnels.StdioTunnel(resp, query);
1447+
await tunnel.ready;
1448+
return tunnel;
14881449
}
14891450

14901451
async streamPodLog(name: string, opts: {
@@ -1544,34 +1505,23 @@ export class CoreV1NamespacedApi {
15441505
return new TextDecoder('utf-8').decode(resp);
15451506
}
15461507

1547-
async connectGetPodPortforward(name: string, opts: {
1548-
ports?: number;
1508+
async tunnelPodPortforward(name: string, opts: {
1509+
ports?: Array<number>;
15491510
abortSignal?: AbortSignal;
15501511
} = {}) {
15511512
const query = new URLSearchParams;
1552-
if (opts["ports"] != null) query.append("ports", String(opts["ports"]));
1513+
for (const item of opts["ports"] ?? []) query.append("ports", String(item));
15531514
const resp = await this.#client.performRequest({
15541515
method: "GET",
15551516
path: `${this.#root}pods/${name}/portforward`,
1556-
expectJson: true,
1517+
expectTunnel: tunnels.PortforwardTunnel.supportedProtocols,
15571518
querystring: query,
15581519
abortSignal: opts.abortSignal,
15591520
});
1560-
}
15611521

1562-
async connectPostPodPortforward(name: string, opts: {
1563-
ports?: number;
1564-
abortSignal?: AbortSignal;
1565-
} = {}) {
1566-
const query = new URLSearchParams;
1567-
if (opts["ports"] != null) query.append("ports", String(opts["ports"]));
1568-
const resp = await this.#client.performRequest({
1569-
method: "POST",
1570-
path: `${this.#root}pods/${name}/portforward`,
1571-
expectJson: true,
1572-
querystring: query,
1573-
abortSignal: opts.abortSignal,
1574-
});
1522+
const tunnel = new tunnels.PortforwardTunnel(resp, query);
1523+
await tunnel.ready;
1524+
return tunnel;
15751525
}
15761526

15771527
proxyPodRequest(podName: string, opts: c.ProxyOptions & {expectStream: true; expectJson: true}): Promise<ReadableStream<c.JSONValue>>;

lib/client.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@
33
// so this is provided an optional utility (as opposed to deps.ts)
44

55
export * from "https://deno.land/x/kubernetes_client@v0.7.0/mod.ts";
6+
export * as tunnelBeta from "https://deno.land/x/kubernetes_client@v0.7.0/tunnel-beta/via-websocket.ts";

lib/examples/pod-exec-output.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
#!/usr/bin/env -S deno run --allow-net --allow-read --allow-env --unstable
2+
3+
import { tunnelBeta, makeClientProviderChain } from '../client.ts';
4+
import { CoreV1Api } from '../builtin/core@v1/mod.ts';
5+
6+
// Set up an experimental client which can use Websockets
7+
const client = await makeClientProviderChain(tunnelBeta.WebsocketRestClient).getClient();
8+
const coreApi = new CoreV1Api(client);
9+
10+
// Launch a process into a particular container
11+
const tunnel = await coreApi
12+
.namespace('media')
13+
.tunnelPodExec('sabnzbd-srv-0', {
14+
command: ['uname', '-a'],
15+
stdout: true,
16+
stderr: true,
17+
});
18+
19+
// Buffer & print the contents of stdout
20+
const output = await tunnel.output();
21+
console.log(new TextDecoder().decode(output.stdout).trimEnd());
22+
23+
// Print any error that occurred
24+
if (output.status !== 'Success') {
25+
console.error(output.message);
26+
}

0 commit comments

Comments
 (0)