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 19, 2020
1 parent 553107b commit 9e7d7cf
Show file tree
Hide file tree
Showing 11 changed files with 356 additions and 99 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import * as React from 'react';
import { Terminal as XTerminal } from 'xterm';
import * as fit from 'xterm/lib/addons/fit/fit';

XTerminal.applyAddon(fit);

type TerminalProps = {
onData: (data: string) => void;
className?: string;
shouldResize?: boolean;
};

export class Terminal extends React.Component<TerminalProps> {
private terminalRef;

private terminal;

private resizeObserver;

constructor(props) {
super(props);
this.terminalRef = React.createRef();
const options = {
fontFamily: 'monospace',
fontSize: 16,
cursorBlink: false,
cols: 80,
rows: 25,
padding: 30,
};
this.terminal = new XTerminal(Object.assign({}, options));
this.terminal.on('data', this.props.onData);
}

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

onDataReceived = (data) => {
this.terminal && this.terminal.write(data);
};

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.terminalRef.current);
this.focus();
const { shouldResize = true } = this.props;
try {
this.terminal.fit();
} finally {
if (shouldResize) {
this.resizeObserver = new ResizeObserver(() => {
this.terminal.fit();
});
this.resizeObserver.observe(this.terminalRef.current);
}
}
}

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

render() {
return <div ref={this.terminalRef} style={{ width: '100%', height: '100%' }} />;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import * as React from 'react';
import { connect } from 'react-redux';
import { Base64 } from 'js-base64';
import { LoadingBox, StatusBox } from '@console/internal/components/utils';
import { connectToFlags, WithFlagsProps } from '@console/internal/reducers/features';
import { impersonateStateToProps } from '@console/internal/reducers/ui';
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 = {
container: string;
podname: string;
namespace: string;
shcommand?: string[];
message?: string;
shouldResize?: boolean;
};

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 & StateProps & WithFlagsProps,
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, flags, impersonate } = this.props;
const usedClient = 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 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;
}

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

render() {
const { open, error } = this.state;
if (error) {
return <StatusBox loadError={error} label="OpenShift command line terminal" />;
}
if (open) {
return (
<Terminal onData={this.onData} ref={this.terminal} shouldResize={this.props.shouldResize} />
);
}
return <LoadingBox message="Connecting to your OpenShift command line terminal" />;
}
}

type StateProps = {
impersonate?: {
subprotocols: string[];
};
};

export default connect<StateProps>(impersonateStateToProps)(
connectToFlags<CloudShellExecProps & WithFlagsProps>(FLAGS.OPENSHIFT)(CloudShellExec),
);
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
.co-cloud-shell-tab {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
position: fixed;
top: 0px;
left: 0px;
right: 0px;
bottom: 0px;
overflow: hidden;
padding-bottom: var(--pf-global--spacer--xl);
background-color: var(--pf-global--Color--dark-100);
&__header {
flex-shrink: 0;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import './CloudShellTab.scss';
const CloudShellTab: React.FC = () => (
<div className="co-cloud-shell-tab">
<div className="co-cloud-shell-tab__header">OpenShift command line terminal</div>
<CloudShellTerminal />
<CloudShellTerminal shouldResize={false} />
</div>
);

Expand Down
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 9e7d7cf

Please sign in to comment.