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..f8e7351ff79 --- /dev/null +++ b/frontend/packages/console-app/src/components/cloud-shell/CloudShellExec.tsx @@ -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[]; +}; + +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 = ({ + container, + podname, + namespace, + shcommand, + flags, + impersonate, +}) => { + const [wsOpen, setWsOpen] = React.useState(false); + const [wsError, setWsError] = React.useState(); + const ws = React.useRef(); + const terminal = React.useRef(); + + 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 `; + 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 ; + } + + if (wsOpen) { + return ; + } + + return ; +}; + +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..ee20bcc85bb 100644 --- a/frontend/packages/console-app/src/components/cloud-shell/CloudShellTab.scss +++ b/frontend/packages/console-app/src/components/cloud-shell/CloudShellTab.scss @@ -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); } diff --git a/frontend/packages/console-app/src/components/cloud-shell/CloudShellTab.tsx b/frontend/packages/console-app/src/components/cloud-shell/CloudShellTab.tsx index 7fa3c0df840..ad8c99a94e4 100644 --- a/frontend/packages/console-app/src/components/cloud-shell/CloudShellTab.tsx +++ b/frontend/packages/console-app/src/components/cloud-shell/CloudShellTab.tsx @@ -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 = () => ( -
+ <>
OpenShift command line terminal
- -
+
+ +
+ ); export default CloudShellTab; 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..42ae1173750 --- /dev/null +++ b/frontend/packages/console-app/src/components/cloud-shell/CloudShellTerminal.scss @@ -0,0 +1,7 @@ +.co-cloudshell-terminal { + &__container { + background-color: #000; + color: var(--pf-global--Color--light-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..e6a754deb3e 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, 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; @@ -32,7 +36,40 @@ const resource = { }; const CloudShellTerminal: React.FC = ({ username, onCancel }) => { - const [data, loaded, loadError] = useK8sWatchResource(resource); + const [data, loaded, loadError] = useK8sWatchResource(resource); + const [initData, setInitData] = React.useState(); + const [initError, setInitError] = React.useState(); + 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 ( @@ -40,29 +77,39 @@ const CloudShellTerminal: React.FC = ({ username, onCan ); } - if (!loaded) { - return ; + if (initError) { + return ; } - if (Array.isArray(data)) { - const workspace = data.find( - (d) => d?.metadata?.annotations?.[CLOUD_SHELL_USER_ANNOTATION] === username, + if (!loaded || (workspaceName && !initData)) { + return ( +
+ +
+ ); + } + + if (initData && workspaceNamespace) { + 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 ? ( - - ) : ( -