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

Bug 1838658: Use new proxy to connect to cloudshell in terminal #5428

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import * as React from 'react';
import { connect } from 'react-redux';
import { Base64 } from 'js-base64';
import { LoadError } 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, { ImperativeTerminalType } 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 Props = {
container: string;
podname: string;
namespace: string;
shcommand?: string[];
Comment on lines +21 to +24
Copy link
Contributor

Choose a reason for hiding this comment

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

All of these props affect the web socket but none of them will cause the web socket to reconnect if they change.

We may not get into this use case for the current implementation however we should be creating reactive components that update according to their incoming props.

};

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

type CloudShellExecProps = Props & StateProps & WithFlagsProps;

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

const CloudShellExec: React.FC<CloudShellExecProps> = ({
container,
podname,
namespace,
shcommand,
flags,
impersonate,
}) => {
const [wsOpen, setWsOpen] = React.useState<boolean>(false);
const [wsError, setWsError] = React.useState<string>();
const ws = React.useRef<WSFactory>();
const terminal = React.useRef<ImperativeTerminalType>();

const onData = React.useCallback((data: string): void => {
ws.current && ws.current.send(`0${Base64.encode(data)}`);
}, []);

React.useEffect(() => {
let unmounted: boolean;
const usedClient = flags[FLAGS.OPENSHIFT] ? 'oc' : 'kubectl';
const cmd = shcommand || ['sh', '-i', '-c', 'TERM=xterm sh'];
const subprotocols = (impersonate?.subprotocols || []).concat('base64.channel.k8s.io');

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

const path = resourceURL(PodModel, urlOpts);
const wsOpts = {
host: 'auto',
reconnect: true,
jsonParse: false,
path,
subprotocols,
};

const websocket: WSFactory = new WSFactory(`${podname}-terminal`, wsOpts);
let previous;

websocket
.onmessage((msg) => {
const currentTerminal = terminal.current;
// error channel
if (msg[0] === '3') {
if (previous.includes(NO_SH)) {
const errMsg = `This container doesn't have a /bin/sh shell. Try specifying your command in a terminal with:\r\n\r\n ${usedClient} -n ${namespace} exec ${podname} -ti <command>`;
currentTerminal && currentTerminal.reset();
currentTerminal && currentTerminal.onConnectionClosed(errMsg);
websocket.destroy();
previous = '';
return;
}
}
const data = Base64.decode(msg.slice(1));
currentTerminal && currentTerminal.onDataReceived(data);
previous = data;
})
.onopen(() => {
const currentTerminal = terminal.current;
currentTerminal && currentTerminal.reset();
previous = '';
if (!unmounted) setWsOpen(true);
})
.onclose((evt) => {
if (!evt || evt.wasClean === true) {
return;
}
const currentTerminal = terminal.current;
const error = evt.reason || 'The terminal connection has closed.';
currentTerminal && currentTerminal.onConnectionClosed(error);
websocket.destroy();
if (!unmounted) setWsError(error);
}) // eslint-disable-next-line no-console
.onerror((evt) => console.error(`WS error?! ${evt}`));

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

return () => {
unmounted = true;
websocket.destroy();
};
}, [container, flags, impersonate, namespace, podname, shcommand]);

if (wsError) {
return <LoadError message={wsError} label="OpenShift command line terminal" canRetry={false} />;
}

if (wsOpen) {
return <Terminal onData={onData} ref={terminal} />;
}

return <TerminalLoadingBox />;
};

export default connect<StateProps>(impersonateStateToProps)(
connectToFlags<CloudShellExecProps & WithFlagsProps>(FLAGS.OPENSHIFT)(CloudShellExec),
);
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
.co-cloud-shell-tab {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
overflow: hidden;
background-color: var(--pf-global--Color--dark-100);
&__body {
display: flex;
flex: 1;
flex-direction: column;
height: 100%;
width: 100%;
overflow: hidden;
}

&__header {
flex-shrink: 0;
min-height: var(--pf-global--target-size--MinHeight);
background-color: var(--pf-global--palette--black-200);
display: flex;
align-items: center;
display: flex;
flex-shrink: 0;
min-height: var(--pf-global--target-size--MinHeight);
&-text {
padding: 0 var(--pf-global--spacer--md);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import * as React from 'react';
import { InlineTechPreviewBadge } from '@console/shared';
import CloudShellTerminal from './CloudShellTerminal';
import './CloudShellTab.scss';
import { InlineTechPreviewBadge } from '@console/shared';

const CloudShellTab: React.FC = () => (
<div className="co-cloud-shell-tab">
<>
<div className="co-cloud-shell-tab__header">
<div className="co-cloud-shell-tab__header-text">OpenShift command line terminal</div>
<InlineTechPreviewBadge />
</div>
<CloudShellTerminal />
</div>
<div className="co-cloud-shell-tab__body">
<CloudShellTerminal />
</div>
</>
);

export default CloudShellTab;
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
.co-cloudshell-terminal {
&__container {
background-color: #000;
color: var(--pf-global--Color--light-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, LoadError } 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,
TerminalInitData,
initTerminal,
} from './cloud-shell-utils';
import CloudShellSetup from './setup/CloudShellSetup';
import './CloudShellTerminal.scss';

type StateProps = {
username: string;
Expand All @@ -32,37 +36,80 @@ const resource = {
};

const CloudShellTerminal: React.FC<CloudShellTerminalProps> = ({ username, onCancel }) => {
const [data, loaded, loadError] = useK8sWatchResource<CloudShellResource>(resource);
const [data, loaded, loadError] = useK8sWatchResource<CloudShellResource[]>(resource);
const [initData, setInitData] = React.useState<TerminalInitData>();
const [initError, setInitError] = React.useState<string>();
let workspace: CloudShellResource;
let workspaceName: string;
let workspaceNamespace: string;
let workspacePhase: string;

if (Array.isArray(data)) {
workspace = data.find(
(ws) => ws?.metadata?.annotations?.[CLOUD_SHELL_USER_ANNOTATION] === username,
);
workspacePhase = workspace?.status?.phase;
workspaceName = workspace?.metadata?.name;
workspaceNamespace = workspace?.metadata?.namespace;
}

React.useEffect(() => {
let unmounted = false;

if (workspacePhase === 'Running') {
initTerminal(username, workspaceName, workspaceNamespace)
.then((res: TerminalInitData) => {
if (!unmounted) setInitData(res);
})
.catch(() => {
if (!unmounted) setInitError('Failed to connect to your OpenShift command line terminal');
});
}

return () => {
unmounted = true;
};
}, [username, workspaceName, workspaceNamespace, workspacePhase]);

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

if (!loaded) {
return <LoadingBox />;
if (initError) {
return <LoadError message={initError} label="OpenShift command line terminal" />;
}

if (Array.isArray(data)) {
const workspace = data.find(
(d) => d?.metadata?.annotations?.[CLOUD_SHELL_USER_ANNOTATION] === username,
if (!loaded || (workspaceName && !initData)) {
return (
<div className="co-cloudshell-terminal__container">
<TerminalLoadingBox />
</div>
);
}

if (initData && workspaceNamespace) {
return (
<div className="co-cloudshell-terminal__container">
<CloudshellExec
namespace={workspaceNamespace}
container={initData.container}
podname={initData.pod}
shcommand={initData.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.

Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
.co-terminal {
height: 100%;

> .terminal {
height: 100%;
}
}

Loading