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 f810e11 commit e6684c3
Show file tree
Hide file tree
Showing 11 changed files with 348 additions and 102 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import * as React from 'react';
import { connect } from 'react-redux';
import { Base64 } from 'js-base64';
import { 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 './Terminal';
import TerminalLoadingBox from './TerminalLoadingBox';

// 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;
};

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 currentTerminal = this.terminal.current;
currentTerminal && currentTerminal.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 currentTerminal = this.terminal.current;
// error channel
if (raw[0] === '3') {
if (previous.includes(NO_SH)) {
currentTerminal.reset();
currentTerminal.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));
currentTerminal && currentTerminal.onDataReceived(data);
previous = data;
})
.onopen(() => {
const currentTerminal = this.terminal.current;
currentTerminal && currentTerminal.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} />;
}
return <TerminalLoadingBox />;
}
}

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
@@ -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%;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,19 @@ import { connect } from 'react-redux';
import { RootState } from '@console/internal/redux';
import { referenceForModel } from '@console/internal/module/k8s/k8s';
import { useK8sWatchResource } from '@console/internal/components/utils/k8s-watch-hook';
import { LoadingBox, StatusBox } from '@console/internal/components/utils/status-box';
import { StatusBox } from '@console/internal/components/utils/status-box';
import { WorkspaceModel } from '../../models';
import CloudShellTerminalFrame from './CloudShellTerminalFrame';
import CloudshellExec from './CloudShellExec';
import TerminalLoadingBox from './TerminalLoadingBox';
import {
CLOUD_SHELL_LABEL,
CLOUD_SHELL_USER_ANNOTATION,
CloudShellResource,
initTerminal,
InitResponseObject,
} from './cloud-shell-utils';
import CloudShellSetup from './setup/CloudShellSetup';
import './CloudShellTerminal.scss';

type StateProps = {
username: string;
Expand All @@ -33,36 +37,76 @@ const resource = {

const CloudShellTerminal: React.FC<CloudShellTerminalProps> = ({ username, onCancel }) => {
const [data, loaded, loadError] = useK8sWatchResource<CloudShellResource>(resource);
const [workspacePod, setWorkspacePod] = React.useState<InitResponseObject>(null);
const [apiError, setApiError] = React.useState<string>(null);

if (loadError) {
React.useEffect(() => {
if (Array.isArray(data)) {
const workspace = data.find(
(d) => d?.metadata?.annotations?.[CLOUD_SHELL_USER_ANNOTATION] === username,
);

if (workspace && !workspacePod) {
const running = workspace.status?.phase === 'Running';
if (running) {
initTerminal(username, workspace.metadata.name, workspace.metadata.namespace)
.then((res) => {
setWorkspacePod({
pod: res.pod,
container: res.container,
cmd: res.cmd || [],
});
})
.catch(() => {
setApiError('Failed to connect to your OpenShift command line terminal');
});
}
}
}
}, [data, username, workspacePod]);

let workSpace = null;

if (Array.isArray(data)) {
workSpace = data.find(
(d) => d?.metadata?.annotations?.[CLOUD_SHELL_USER_ANNOTATION] === username,
);
}

if (loadError || apiError) {
return (
<StatusBox loaded={loaded} loadError={loadError} label="OpenShift command line terminal" />
<StatusBox
loaded={loaded}
loadError={loadError || apiError}
label="OpenShift command line terminal"
/>
);
}

if (!loaded) {
return <LoadingBox />;
if (!loaded || (workSpace?.metadata.namespace && !workspacePod)) {
return <TerminalLoadingBox message={!loaded ? 'Loading ...' : null} />;
}

if (Array.isArray(data)) {
const workspace = data.find(
(d) => d?.metadata?.annotations?.[CLOUD_SHELL_USER_ANNOTATION] === username,
if (workspacePod) {
return (
<div className="odc-cloudshell-terminal__container">
<CloudshellExec
container={workspacePod.container}
podname={workspacePod.pod}
namespace={workSpace?.metadata.namespace}
shcommand={workspacePod.cmd}
/>
</div>
);
if (workspace) {
const running = workspace.status?.phase === 'Running';
const url = workspace.status?.ideUrl;
return <CloudShellTerminalFrame loading={!running} url={url} />;
}
}

return <CloudShellSetup onCancel={onCancel} />;
};

// For testing
export const InternalCloudShellTerminal = CloudShellTerminal;

const stateToProps = (state: RootState): StateProps => ({
username: state.UI.get('user')?.metadata?.name || '',
});

// exposed for testing
export const InternalCloudShellTerminal = CloudShellTerminal;

export default connect(stateToProps)(CloudShellTerminal);

This file was deleted.

This file was deleted.

0 comments on commit e6684c3

Please sign in to comment.