Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support terminal resizes #240

Merged
merged 2 commits into from
May 2, 2019
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions examples/typescript/attach/attach-resize-example.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import * as k8s from '@kubernetes/client-node';

const kc = new k8s.KubeConfig();
kc.loadFromDefault();

const terminalSizeQueue = new k8s.TerminalSizeQueue();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@lizardruss Thanks for the contribution! I haven't had the chance to test it out yet.

Wondering if it is better to move this logic private to the client? So the library binds the events and adds to the internal queues.

It feels like its a standard event on NodeJS.WriteStream so maybe we can hide this implementation logic from the users of the client?

It not only would solve a bunch of copy paste between attach and exec but also means that it becomes improvement clients get without any code changes.

Unless this is how it is with most of the other clients @brendanburns your thoughts?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for the suggestion! I like the idea of removing the TerminalSizeQueue class. I mostly just ported it from the kubectl codebase, and it didn't feel quite right.

If I understand you correctly, you're suggesting to use the stdout argument to detect resizes. Currently its typed as Writable | null for exec and Writable | any for attach. Changing to NodeJS.WriteStream might be a breaking change.

Another potential issue is that NodeJS.WriteStream's rows and columns properties are optional. I think tty.WriteStream might be the more accurate type, since NodeJS.WriteStream doesn't have a documented resize event. I'll make that change in the examples.

For more context, I won't be using process.stdout for my use case. Instead I'll be using this from a set of web based services that support xterm.js. In this case a WebSocket will be connected to Readable and Writeable streams, and resizing will be done through a separate service call.

I'll experiment with creating a tty.WriteStream instance and connecting it to a WebSocket. That could be a more straightforward implementation, but I've never tried instantiating that class.

Copy link
Contributor Author

@lizardruss lizardruss Apr 27, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Creating a new tty.WriteStream instance has an odd side effect of also writing to stdout. 👎

It's probably better to stick with the NodeJS.WriteStream interface. I'll try making an implementation that can be used with a web socket.

terminalSizeQueue.resize(getSize(process.stdout));
process.stdout.on('resize', () => {
terminalSizeQueue.resize(getSize(process.stdout));
});

const attach = new k8s.Attach(kc);
attach.attach(
'default',
'nginx-4217019353-9gl4s',
'nginx',
process.stdout,
process.stderr,
null /* stdin */,
false /* tty */,
terminalSizeQueue,
);

function getSize(writeStream: NodeJS.WriteStream): k8s.TerminalSize {
return {
height: writeStream.rows!,
width: writeStream.columns!,
};
}
39 changes: 39 additions & 0 deletions examples/typescript/exec/exec-resize-example.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import * as k8s from '@kubernetes/client-node';
import * as stream from 'stream';

const command = process.argv[2];

const kc = new k8s.KubeConfig();
kc.loadFromDefault();

const terminalSizeQueue = new k8s.TerminalSizeQueue();
terminalSizeQueue.resize(getSize(process.stdout));
process.stdout.on('resize', () => {
terminalSizeQueue.resize(getSize(process.stdout));
});

const exec = new k8s.Exec(kc);
exec.exec(
'tutor',
'tutor-environment-operator-deployment-c888b5cd8-qp95f',
'default',
command,
process.stdout as stream.Writable,
process.stderr as stream.Writable,
process.stdin as stream.Readable,
true /* tty */,
(status: k8s.V1Status) => {
// tslint:disable-next-line:no-console
console.log('Exited with status:');
// tslint:disable-next-line:no-console
console.log(JSON.stringify(status, null, 2));
},
terminalSizeQueue,
);

function getSize(writeStream: NodeJS.WriteStream): k8s.TerminalSize {
return {
height: writeStream.rows!,
width: writeStream.columns!,
};
}
7 changes: 6 additions & 1 deletion src/attach.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import querystring = require('querystring');
import stream = require('stream');

import { KubeConfig } from './config';
import { TerminalSizeQueue } from './terminal-size-queue';
import { WebSocketHandler, WebSocketInterface } from './web-socket-handler';

export class Attach {
Expand All @@ -24,6 +25,7 @@ export class Attach {
stderr: stream.Writable | any,
stdin: stream.Readable | any,
tty: boolean,
terminalSizeQueue?: TerminalSizeQueue,
): Promise<WebSocket> {
const query = {
container: containerName,
Expand All @@ -43,9 +45,12 @@ export class Attach {
},
);
if (stdin != null) {
WebSocketHandler.handleStandardInput(conn, stdin);
WebSocketHandler.handleStandardInput(conn, stdin, WebSocketHandler.StdinStream);
}

if (terminalSizeQueue != null) {
WebSocketHandler.handleStandardInput(conn, terminalSizeQueue, WebSocketHandler.ResizeStream);
}
return conn;
}
}
51 changes: 41 additions & 10 deletions src/attach_test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { expect } from 'chai';
import { EventEmitter } from 'events';
import WebSocket = require('isomorphic-ws');
import { ReadableStreamBuffer, WritableStreamBuffer } from 'stream-buffers';
import { anyFunction, anything, capture, instance, mock, verify, when } from 'ts-mockito';
import { CallAwaiter, matchBuffer } from '../test';

import { Attach } from './attach';
import { KubeConfig } from './config';
import { TerminalSize, TerminalSizeQueue } from './terminal-size-queue';
import { WebSocketHandler, WebSocketInterface } from './web-socket-handler';

describe('Attach', () => {
Expand Down Expand Up @@ -46,11 +49,14 @@ describe('Attach', () => {

it('should correctly attach to streams', async () => {
const kc = new KubeConfig();
const fakeWebSocket: WebSocketInterface = mock(WebSocketHandler);
const attach = new Attach(kc, instance(fakeWebSocket));
const fakeWebSocketInterface: WebSocketInterface = mock(WebSocketHandler);
const fakeWebSocket: WebSocket = mock(WebSocket);
const callAwaiter: CallAwaiter = new CallAwaiter();
const attach = new Attach(kc, instance(fakeWebSocketInterface));
const osStream = new WritableStreamBuffer();
const errStream = new WritableStreamBuffer();
const isStream = new ReadableStreamBuffer();
const terminalSizeQueue = new TerminalSizeQueue();

const namespace = 'somenamespace';
const pod = 'somepod';
Expand All @@ -59,11 +65,24 @@ describe('Attach', () => {
const path = `/api/v1/namespaces/${namespace}/pods/${pod}/attach`;
const args = `container=${container}&stderr=true&stdin=true&stdout=true&tty=false`;

const fakeConn: WebSocket = mock(WebSocket);
when(fakeWebSocket.connect(`${path}?${args}`, null, anyFunction())).thenResolve(fakeConn);

await attach.attach(namespace, pod, container, osStream, errStream, isStream, false);
const [, , outputFn] = capture(fakeWebSocket.connect).last();
const fakeConn: WebSocket = instance(fakeWebSocket);
when(fakeWebSocketInterface.connect(`${path}?${args}`, null, anyFunction())).thenResolve(
fakeConn,
);
when(fakeWebSocket.send(anything())).thenCall(callAwaiter.resolveCall('send'));
when(fakeWebSocket.close()).thenCall(callAwaiter.resolveCall('close'));

await attach.attach(
namespace,
pod,
container,
osStream,
errStream,
isStream,
false,
terminalSizeQueue,
);
const [, , outputFn] = capture(fakeWebSocketInterface.connect).last();

/* tslint:disable:no-unused-expression */
expect(outputFn).to.not.be.null;
Expand Down Expand Up @@ -91,11 +110,23 @@ describe('Attach', () => {
}

const msg = 'This is test data';
const inputPromise = callAwaiter.awaitCall('send');
isStream.put(msg);
verify(fakeConn.send(msg));

await inputPromise;
verify(fakeWebSocket.send(matchBuffer(WebSocketHandler.StdinStream, msg))).called();

const terminalSize: TerminalSize = { height: 80, width: 120 };
const resizePromise = callAwaiter.awaitCall('send');
terminalSizeQueue.resize(terminalSize);
await resizePromise;
verify(
fakeWebSocket.send(matchBuffer(WebSocketHandler.ResizeStream, JSON.stringify(terminalSize))),
).called();

const closePromise = callAwaiter.awaitCall('close');
isStream.stop();
verify(fakeConn.close());
await closePromise;
verify(fakeWebSocket.close()).called();
});
});
});
7 changes: 6 additions & 1 deletion src/exec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import stream = require('stream');

import { V1Status } from './api';
import { KubeConfig } from './config';
import { TerminalSizeQueue } from './terminal-size-queue';
import { WebSocketHandler, WebSocketInterface } from './web-socket-handler';

export class Exec {
Expand Down Expand Up @@ -40,6 +41,7 @@ export class Exec {
stdin: stream.Readable | null,
tty: boolean,
statusCallback?: (status: V1Status) => void,
terminalSizeQueue?: TerminalSizeQueue,
): Promise<WebSocket> {
const query = {
stdout: stdout != null,
Expand All @@ -66,7 +68,10 @@ export class Exec {
},
);
if (stdin != null) {
WebSocketHandler.handleStandardInput(conn, stdin);
WebSocketHandler.handleStandardInput(conn, stdin, WebSocketHandler.StdinStream);
}
if (terminalSizeQueue != null) {
WebSocketHandler.handleStandardInput(conn, terminalSizeQueue, WebSocketHandler.ResizeStream);
}
return conn;
}
Expand Down
39 changes: 30 additions & 9 deletions src/exec_test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { expect } from 'chai';
import WebSocket = require('isomorphic-ws');
import { ReadableStreamBuffer, WritableStreamBuffer } from 'stream-buffers';
import { anyFunction, capture, instance, mock, verify, when } from 'ts-mockito';
import { anyFunction, anything, capture, instance, mock, verify, when } from 'ts-mockito';

import { CallAwaiter, matchBuffer } from '../test';
import { V1Status } from './api';
import { KubeConfig } from './config';
import { Exec } from './exec';
import { TerminalSize, TerminalSizeQueue } from './terminal-size-queue';
import { WebSocketHandler, WebSocketInterface } from './web-socket-handler';

describe('Exec', () => {
Expand Down Expand Up @@ -55,11 +57,14 @@ describe('Exec', () => {

it('should correctly attach to streams', async () => {
const kc = new KubeConfig();
const fakeWebSocket: WebSocketInterface = mock(WebSocketHandler);
const exec = new Exec(kc, instance(fakeWebSocket));
const fakeWebSocketInterface: WebSocketInterface = mock(WebSocketHandler);
const fakeWebSocket: WebSocket = mock(WebSocket);
const callAwaiter: CallAwaiter = new CallAwaiter();
const exec = new Exec(kc, instance(fakeWebSocketInterface));
const osStream = new WritableStreamBuffer();
const errStream = new WritableStreamBuffer();
const isStream = new ReadableStreamBuffer();
const terminalSizeQueue = new TerminalSizeQueue();

const namespace = 'somenamespace';
const pod = 'somepod';
Expand All @@ -71,8 +76,12 @@ describe('Exec', () => {

let statusOut = {} as V1Status;

const fakeConn: WebSocket = mock(WebSocket);
when(fakeWebSocket.connect(`${path}?${args}`, null, anyFunction())).thenResolve(fakeConn);
const fakeConn: WebSocket = instance(fakeWebSocket);
when(fakeWebSocketInterface.connect(`${path}?${args}`, null, anyFunction())).thenResolve(
fakeConn,
);
when(fakeWebSocket.send(anything())).thenCall(callAwaiter.resolveCall('send'));
when(fakeWebSocket.close()).thenCall(callAwaiter.resolveCall('close'));

await exec.exec(
namespace,
Expand All @@ -86,9 +95,10 @@ describe('Exec', () => {
(status: V1Status) => {
statusOut = status;
},
terminalSizeQueue,
);

const [, , outputFn] = capture(fakeWebSocket.connect).last();
const [, , outputFn] = capture(fakeWebSocketInterface.connect).last();

/* tslint:disable:no-unused-expression */
expect(outputFn).to.not.be.null;
Expand Down Expand Up @@ -116,19 +126,30 @@ describe('Exec', () => {
}

const msg = 'This is test data';
const inputPromise = callAwaiter.awaitCall('send');
isStream.put(msg);
verify(fakeConn.send(msg));
await inputPromise;
verify(fakeWebSocket.send(matchBuffer(WebSocketHandler.StdinStream, msg))).called();

const terminalSize: TerminalSize = { height: 80, width: 120 };
const resizePromise = callAwaiter.awaitCall('send');
terminalSizeQueue.resize(terminalSize);
await resizePromise;
verify(
fakeWebSocket.send(matchBuffer(WebSocketHandler.ResizeStream, JSON.stringify(terminalSize))),
).called();

const statusIn = {
code: 100,
message: 'this is a test',
} as V1Status;

outputFn(WebSocketHandler.StatusStream, Buffer.from(JSON.stringify(statusIn)));
expect(statusOut).to.deep.equal(statusIn);

const closePromise = callAwaiter.awaitCall('close');
isStream.stop();
verify(fakeConn.close());
await closePromise;
verify(fakeWebSocket.close()).called();
});
});
});
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ export * from './portforward';
export * from './types';
export * from './yaml';
export * from './log';
export * from './terminal-size-queue';
20 changes: 20 additions & 0 deletions src/terminal-size-queue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { Readable, ReadableOptions } from 'stream';

export interface TerminalSize {
height: number;
width: number;
}

export class TerminalSizeQueue extends Readable {
constructor(opts: ReadableOptions = {}) {
super({
...opts,
// tslint:disable-next-line:no-empty
read() {},
});
}

public resize(size: TerminalSize) {
this.push(JSON.stringify(size));
}
}
1 change: 1 addition & 0 deletions src/web-socket-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export class WebSocketHandler implements WebSocketInterface {
public static readonly StdoutStream = 1;
public static readonly StderrStream = 2;
public static readonly StatusStream = 3;
public static readonly ResizeStream = 4;

public static handleStandardStreams(
streamNum: number,
Expand Down
13 changes: 13 additions & 0 deletions test/call-awaiter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { EventEmitter } from 'events';

export class CallAwaiter extends EventEmitter {
public awaitCall(event: string) {
return new Promise<any[]>((resolve) => {
this.once(event, resolve);
});
}

public resolveCall(event: string) {
return (...args: any[]) => this.emit(event, ...args);
}
}
2 changes: 2 additions & 0 deletions test/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './call-awaiter';
export * from './match-buffer';
26 changes: 26 additions & 0 deletions test/match-buffer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { Matcher } from 'ts-mockito/lib/matcher/type/Matcher';

export function matchBuffer(channel: number, contents: string): Matcher {
return new StringBufferMatcher(channel, contents);
}

class StringBufferMatcher extends Matcher {
constructor(private channel: number, private contents: string) {
super();
}

public match(value: any): boolean {
if (value instanceof Buffer) {
const buffer = value as Buffer;
const channel: number = buffer.readInt8(0);
const contents: string = buffer.toString('utf8', 1);
return this.channel === channel && this.contents === contents;
}

return false;
}

public toString(): string {
return `buffer did not contain "${this.contents}"`;
}
}