From e6684c38d167de69b1a4b38218e925e74cd525a2 Mon Sep 17 00:00:00 2001 From: Abhi Date: Wed, 13 May 2020 20:03:46 +0530 Subject: [PATCH 1/7] fix(cloud-shell): Use CloudShell exec and resizable terminal --- .../components/cloud-shell/CloudShellExec.tsx | 157 ++++++++++++++++++ .../components/cloud-shell/CloudShellTab.scss | 10 +- .../cloud-shell/CloudShellTerminal.scss | 8 + .../cloud-shell/CloudShellTerminal.tsx | 80 +++++++-- .../cloud-shell/CloudShellTerminalFrame.scss | 15 -- .../cloud-shell/CloudShellTerminalFrame.tsx | 20 --- .../src/components/cloud-shell/Terminal.tsx | 78 +++++++++ .../cloud-shell/TerminalLoadingBox.tsx | 14 ++ .../__tests__/CloudShellTerminal.spec.tsx | 19 --- .../CloudShellTerminalFrame.spec.tsx | 21 --- .../cloud-shell/cloud-shell-utils.ts | 28 +++- 11 files changed, 348 insertions(+), 102 deletions(-) create mode 100644 frontend/packages/console-app/src/components/cloud-shell/CloudShellExec.tsx create mode 100644 frontend/packages/console-app/src/components/cloud-shell/CloudShellTerminal.scss delete mode 100644 frontend/packages/console-app/src/components/cloud-shell/CloudShellTerminalFrame.scss delete mode 100644 frontend/packages/console-app/src/components/cloud-shell/CloudShellTerminalFrame.tsx create mode 100644 frontend/packages/console-app/src/components/cloud-shell/Terminal.tsx create mode 100644 frontend/packages/console-app/src/components/cloud-shell/TerminalLoadingBox.tsx delete mode 100644 frontend/packages/console-app/src/components/cloud-shell/__tests__/CloudShellTerminalFrame.spec.tsx diff --git a/frontend/packages/console-app/src/components/cloud-shell/CloudShellExec.tsx b/frontend/packages/console-app/src/components/cloud-shell/CloudShellExec.tsx new file mode 100644 index 00000000000..e4a27da0d4f --- /dev/null +++ b/frontend/packages/console-app/src/components/cloud-shell/CloudShellExec.tsx @@ -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 `, + ); + 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 ; + } + if (open) { + return ; + } + return ; + } +} + +type StateProps = { + impersonate?: { + subprotocols: string[]; + }; +}; + +export default connect(impersonateStateToProps)( + connectToFlags(FLAGS.OPENSHIFT)(CloudShellExec), +); diff --git a/frontend/packages/console-app/src/components/cloud-shell/CloudShellTab.scss b/frontend/packages/console-app/src/components/cloud-shell/CloudShellTab.scss index 944353fc43d..6c174277f13 100644 --- a/frontend/packages/console-app/src/components/cloud-shell/CloudShellTab.scss +++ b/frontend/packages/console-app/src/components/cloud-shell/CloudShellTab.scss @@ -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; diff --git a/frontend/packages/console-app/src/components/cloud-shell/CloudShellTerminal.scss b/frontend/packages/console-app/src/components/cloud-shell/CloudShellTerminal.scss new file mode 100644 index 00000000000..a1b6482324b --- /dev/null +++ b/frontend/packages/console-app/src/components/cloud-shell/CloudShellTerminal.scss @@ -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%; + } +} diff --git a/frontend/packages/console-app/src/components/cloud-shell/CloudShellTerminal.tsx b/frontend/packages/console-app/src/components/cloud-shell/CloudShellTerminal.tsx index ea697698dd1..0e62e192b41 100644 --- a/frontend/packages/console-app/src/components/cloud-shell/CloudShellTerminal.tsx +++ b/frontend/packages/console-app/src/components/cloud-shell/CloudShellTerminal.tsx @@ -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; @@ -33,36 +37,76 @@ const resource = { const CloudShellTerminal: React.FC = ({ username, onCancel }) => { const [data, loaded, loadError] = useK8sWatchResource(resource); + const [workspacePod, setWorkspacePod] = React.useState(null); + const [apiError, setApiError] = React.useState(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 ( - + ); } - if (!loaded) { - return ; + if (!loaded || (workSpace?.metadata.namespace && !workspacePod)) { + return ; } - if (Array.isArray(data)) { - const workspace = data.find( - (d) => d?.metadata?.annotations?.[CLOUD_SHELL_USER_ANNOTATION] === username, + if (workspacePod) { + return ( +
+ +
); - if (workspace) { - const running = workspace.status?.phase === 'Running'; - const url = workspace.status?.ideUrl; - return ; - } } - return ; }; +// 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); diff --git a/frontend/packages/console-app/src/components/cloud-shell/CloudShellTerminalFrame.scss b/frontend/packages/console-app/src/components/cloud-shell/CloudShellTerminalFrame.scss deleted file mode 100644 index 19b3af9d863..00000000000 --- a/frontend/packages/console-app/src/components/cloud-shell/CloudShellTerminalFrame.scss +++ /dev/null @@ -1,15 +0,0 @@ -.co-cloud-shell-terminal-frame { - // match color to iframe terminal page - background-color: #000; - width: 100%; - height: 100%; - color: var(--pf-global--Color--light-100); - overflow: hidden; - position: absolute; - - & > iframe { - height: 100%; - width: 100%; - border: 0; - } -} diff --git a/frontend/packages/console-app/src/components/cloud-shell/CloudShellTerminalFrame.tsx b/frontend/packages/console-app/src/components/cloud-shell/CloudShellTerminalFrame.tsx deleted file mode 100644 index d694d0a5c52..00000000000 --- a/frontend/packages/console-app/src/components/cloud-shell/CloudShellTerminalFrame.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import * as React from 'react'; -import { LoadingBox } from '@console/internal/components/utils/status-box'; -import './CloudShellTerminalFrame.scss'; - -type CloudShellTerminalFrameProps = { - loading?: boolean; - url?: string; -}; - -const CloudShellTerminalFrame: React.FC = ({ loading, url }) => ( -
- {loading ? ( - - ) : ( -