Skip to content

Commit

Permalink
fix(cloud-shell): Use CloudShell exec and resizable terminal
Browse files Browse the repository at this point in the history
  • Loading branch information
abhinandan13jan authored and root committed May 14, 2020
1 parent 553107b commit 1ab4cc1
Show file tree
Hide file tree
Showing 8 changed files with 386 additions and 48 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import * as React from 'react';
import { Terminal as XTerminal } from 'xterm';
import Measure from 'react-measure';
import * as fit from 'xterm/lib/addons/fit/fit';
import * as full from 'xterm/lib/addons/fullscreen/fullscreen';

XTerminal.applyAddon(fit);
XTerminal.applyAddon(full);

type TerminalProps = {
onData: any;
className?: string;
};

type TerminalState = {
width: number;
height: number;
};

export class Terminal extends React.Component<TerminalProps, TerminalState> {
private innerRef;

private outerRef;

private onDataReceived;

private terminal;

private padding;

constructor(props) {
super(props);
this.innerRef = React.createRef();
this.outerRef = React.createRef();
this.state = {
width: 0,
height: 0,
};
this.onDataReceived = (data) => {
this.terminal && this.terminal.write(data);
};
this.padding = 20;
const options = {
fontFamily: 'monospace',
fontSize: 16,
cursorBlink: false,
cols: 80,
rows: 25,
};
this.terminal = new XTerminal(Object.assign({}, options));
this.terminal.on('data', this.props.onData);
}

focus = () => {
this.terminal && this.terminal.focus();
};

onResize = () => {
const node = this.outerRef.current;

if (!node) {
return;
}

const bodyRect = document.body.getBoundingClientRect();
const nodeRect = node.getBoundingClientRect();

// This assumes we want to fill everything below and to the right. In full-screen, fill entire viewport
// Use body height when node top is too close to pageRect height
const { bottom } = bodyRect;
const height = Math.floor(bottom - nodeRect.top);
const width = Math.floor(bodyRect.width - nodeRect.left);

if (height === this.state.height && width === this.state.width) {
return;
}
// rerender with correct dimensions
this.setState({ height, width }, () => {
const { terminal } = this;
if (!terminal) {
return;
}
// tell the terminal to resize itself
terminal.fit();
});
};

onConnectionClosed = (reason) => {
const { terminal } = this;
if (!terminal) {
return;
}
terminal.write(`\x1b[31m${reason || 'disconnected'}\x1b[m\r\n`);
terminal.cursorHidden = true;
terminal.setOption('disableStdin', true);
terminal.refresh(terminal.y, terminal.y);
};

componentDidMount() {
this.terminal.open(this.innerRef.current);
this.focus();
this.onResize();
this.onDataReceived();
}

componentWillUnmount() {
this.terminal && this.terminal.destroy();
}

render() {
return (
<Measure
bounds
onResize={() => {
this.onResize();
}}
>
{({ measureRef }) => (
<div
style={{
width: '100%',
height: '100%',
padding: this.padding / 2,
}}
ref={measureRef}
>
<div ref={this.outerRef} className={this.props.className}>
<div
ref={this.innerRef}
style={{ width: this.state.width, height: this.state.height }}
className="console"
/>
</div>
</div>
)}
</Measure>
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import * as React from 'react';
import * as _ from 'lodash';
import { Base64 } from 'js-base64';
import store from '@console/internal/redux';
import { LoadingBox } from '@console/internal/components/utils';
import { connectToFlags, WithFlagsProps, FlagsObject } from '@console/internal/reducers/features';
import { FLAGS } from '@console/shared';
import { WSFactory } from '@console/internal/module/ws-factory';
import { resourceURL } from '@console/internal/module/k8s';
import { PodModel } from '@console/internal/models';
import { Terminal } from './AutoResizeTerminal';

// pod exec WS protocol is FD prefixed, base64 encoded data (sometimes json stringified)

// Channel 0 is STDIN, 1 is STDOUT, 2 is STDERR (if TTY is not requested), and 3 is a special error channel - 4 is C&C
// The server only reads from STDIN, writes to the other three.
// see also: https://github.com/kubernetes/kubernetes/pull/13885

type CloudShellExecProps = WithFlagsProps & {
container: string;
podname: string;
namespace: string;
shcommand?: string[];
message?: string;
flags?: FlagsObject;
};

type CloudShellExecState = {
open: boolean;
error: string;
};

const NO_SH =
'starting container process caused "exec: \\"sh\\": executable file not found in $PATH"';

class CloudShellExec extends React.PureComponent<CloudShellExecProps, CloudShellExecState> {
private terminal;

private ws;

constructor(props) {
super(props);
this.state = {
open: false,
error: null,
};
this.terminal = React.createRef();
}

connect = () => {
const { container, podname, namespace, shcommand } = this.props;
const usedClient = this.props.flags[FLAGS.OPENSHIFT] ? 'oc' : 'kubectl';
const cmd = shcommand || ['sh', '-i', '-c', 'TERM=xterm sh'];

const params = {
ns: namespace,
name: podname,
path: 'exec',
queryParams: {
stdout: '1',
stdin: '1',
stderr: '1',
tty: '1',
container,
command: cmd.map((c) => encodeURIComponent(c)).join('&command='),
},
};

if (this.ws) {
this.ws.destroy();
const { current } = this.terminal;
current && current.onConnectionClosed(`connecting to ${container}`);
}

const impersonate = store.getState().UI.get('impersonate', {});
const subprotocols = (impersonate.subprotocols || []).concat('base64.channel.k8s.io');

let previous;
this.ws = new WSFactory(`${podname}-terminal`, {
host: 'auto',
reconnect: true,
path: resourceURL(PodModel, params),
jsonParse: false,
subprotocols,
})
.onmessage((raw) => {
const { current } = this.terminal;
// error channel
if (raw[0] === '3') {
if (previous.includes(NO_SH)) {
current.reset();
current.onConnectionClosed(
`This container doesn't have a /bin/sh shell. Try specifying your command in a terminal with:\r\n\r\n ${usedClient} -n ${this.props.namespace} exec ${this.props.podname} -ti <command>`,
);
this.ws.destroy();
previous = '';
return;
}
}
const data = Base64.decode(raw.slice(1));
current && current.onDataReceived(data);
previous = data;
})
.onopen(() => {
const { current } = this.terminal;
current && current.reset();
previous = '';
this.setState({ open: true, error: null });
})
.onclose((evt) => {
if (!evt || evt.wasClean === true) {
return;
}
const error = evt.reason || 'The terminal connection has closed.';
this.setState({ error });
this.terminal.current && this.terminal.current.onConnectionClosed(error);
this.ws.destroy();
}) // eslint-disable-next-line no-console
.onerror((evt) => console.error(`WS error?! ${evt}`));
};

componentDidMount() {
this.connect();
}

componentWillUnmount() {
this.ws && this.ws.destroy();
delete this.ws;
}

static getDerivedStateFromProps(nextProps, prevState) {
const containers = _.get(nextProps.obj, 'spec.containers', []).map((n) => n.name);
if (_.isEqual(containers, prevState.containers)) {
return null;
}
return { containers };
}

onData = (data) => {
this.ws && this.ws.send(`0${Base64.encode(data)}`);
};

render() {
const { open, error } = this.state;
const { message } = this.props;
if (error) {
return <div className="text-center cos-error-title">{error}</div>;
}
if (open) {
return (
<div style={{ width: '100%', height: '100%' }}>
{message}
<Terminal onData={this.onData} ref={this.terminal} />
</div>
);
}
return <LoadingBox message="Connecting to OpenShift Terminal" />;
}
}

export default connectToFlags(FLAGS.OPENSHIFT)(CloudShellExec);
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
.odc-cloudshell-terminal {
&__container {
background-color: var(--pf-global--palette--black-1000);
color: var(--pf-global--primary-color--100);
width: 100%;
height: 100%;
}
}

0 comments on commit 1ab4cc1

Please sign in to comment.