diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 622cbc2c501..bd68e8ae6b0 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -36,6 +36,7 @@ "@types/react-router-dom": "^5.3.1", "@types/react-window": "^1.8.5", "@types/semver": "^7.3.8", + "@types/uuid": "^9.0.4", "@types/webpack-env": "^1.16.2", "@typescript-eslint/eslint-plugin": "^4.33.0", "@typescript-eslint/parser": "^4.33.0", @@ -14651,6 +14652,11 @@ "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.6.tgz", "integrity": "sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ==" }, + "node_modules/@types/uuid": { + "version": "9.0.7", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.7.tgz", + "integrity": "sha512-WUtIVRUZ9i5dYXefDEAI7sh9/O7jGvHg7Df/5O/gtH3Yabe5odI3UWopVR1qbPXQtvOxWu3mM4XxlYeZtMWF4g==" + }, "node_modules/@types/webpack": { "version": "4.41.32", "resolved": "https://registry.npmjs.org/@types/webpack/-/webpack-4.41.32.tgz", @@ -50476,6 +50482,11 @@ "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.6.tgz", "integrity": "sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ==" }, + "@types/uuid": { + "version": "9.0.7", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.7.tgz", + "integrity": "sha512-WUtIVRUZ9i5dYXefDEAI7sh9/O7jGvHg7Df/5O/gtH3Yabe5odI3UWopVR1qbPXQtvOxWu3mM4XxlYeZtMWF4g==" + }, "@types/webpack": { "version": "4.41.32", "resolved": "https://registry.npmjs.org/@types/webpack/-/webpack-4.41.32.tgz", diff --git a/frontend/package.json b/frontend/package.json index 8a611927d72..b9d707020a5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -31,6 +31,7 @@ "@types/react-router-dom": "^5.3.1", "@types/react-window": "^1.8.5", "@types/semver": "^7.3.8", + "@types/uuid": "^9.0.4", "@types/webpack-env": "^1.16.2", "@typescript-eslint/eslint-plugin": "^4.33.0", "@typescript-eslint/parser": "^4.33.0", diff --git a/frontend/src/components/common/Shell.tsx b/frontend/src/components/common/Shell.tsx new file mode 100644 index 00000000000..28248187a88 --- /dev/null +++ b/frontend/src/components/common/Shell.tsx @@ -0,0 +1,307 @@ +import 'xterm/css/xterm.css'; +import { Box } from '@mui/material'; +import { DialogProps } from '@mui/material/Dialog'; +import DialogContent from '@mui/material/DialogContent'; +import makeStyles from '@mui/styles/makeStyles'; +import _ from 'lodash'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Terminal as XTerminal } from 'xterm'; +import { FitAddon } from 'xterm-addon-fit'; +import Node from '../../lib/k8s/node'; +import { Dialog } from './Dialog'; + +const decoder = new TextDecoder('utf-8'); +const encoder = new TextEncoder(); + +enum Channel { + StdIn = 0, + StdOut, + StdErr, + ServerError, + Resize, +} + +const useStyle = makeStyles(theme => ({ + dialogContent: { + height: '100%', + display: 'flex', + flexDirection: 'column', + '& .xterm ': { + height: '100vh', // So the terminal doesn't stay shrunk when shrinking vertically and maximizing again. + '& .xterm-viewport': { + width: 'initial !important', // BugFix: https://github.com/xtermjs/xterm.js/issues/3564#issuecomment-1004417440 + }, + }, + '& #xterm-container': { + overflow: 'hidden', + width: '100%', + '& .terminal.xterm': { + padding: 10, + }, + }, + }, + containerFormControl: { + minWidth: '11rem', + }, + terminalBox: { + paddingTop: theme.spacing(1), + flex: 1, + width: '100%', + overflow: 'hidden', + display: 'flex', + flexDirection: 'column-reverse', + }, +})); + +interface ShellProps extends DialogProps { + item: Node; + onClose?: () => void; +} + +interface XTerminalConnected { + xterm: XTerminal; + connected: boolean; + reconnectOnEnter: boolean; + onClose?: () => void; +} + +type execReturn = ReturnType; + +export default function Shell(props: ShellProps) { + const { item, onClose, ...other } = props; + const classes = useStyle(); + const fitAddonRef = React.useRef(null); + const { t } = useTranslation(['translation', 'glossary']); + const [terminalContainerRef, setTerminalContainerRef] = React.useState(null); + const xtermRef = React.useRef(null); + const shellRef = React.useRef(null); + + const wrappedOnClose = () => { + if (!!onClose) { + onClose(); + } + + if (!!xtermRef.current?.onClose) { + xtermRef.current?.onClose(); + } + }; + + function onData(xtermc: XTerminalConnected, bytes: ArrayBuffer) { + const xterm = xtermc.xterm; + // Only show data from stdout, stderr and server error channel. + const channel: Channel = new Int8Array(bytes.slice(0, 1))[0]; + if (channel < Channel.StdOut || channel > Channel.ServerError) { + return; + } + + // The first byte is discarded because it just identifies whether + // this data is from stderr, stdout, or stdin. + const data = bytes.slice(1); + const text = decoder.decode(data); + + // Send resize command to server once connection is establised. + if (!xtermc.connected) { + xterm.clear(); + (async function () { + send(4, `{"Width":${xterm.cols},"Height":${xterm.rows}}`); + })(); + // On server error, don't set it as connected + if (channel !== Channel.ServerError) { + xtermc.connected = true; + console.debug('Terminal is now connected'); + } + } + + if (isSuccessfulExitError(channel, text)) { + wrappedOnClose(); + + if (shellRef.current) { + shellRef.current?.cancel(); + } + + return; + } + + if (isShellNotFoundError(channel, text)) { + shellConnectFailed(xtermc); + return; + } + xterm.write(text); + } + + // @todo: Give the real exec type when we have it. + function setupTerminal(containerRef: HTMLElement, xterm: XTerminal, fitAddon: FitAddon) { + if (!containerRef) { + return; + } + + xterm.open(containerRef); + + xterm.onData(data => { + send(0, data); + }); + + xterm.onResize(size => { + send(4, `{"Width":${size.cols},"Height":${size.rows}}`); + }); + + // Allow copy/paste in terminal + xterm.attachCustomKeyEventHandler(arg => { + if (arg.ctrlKey && arg.type === 'keydown') { + if (arg.code === 'KeyC') { + const selection = xterm.getSelection(); + if (selection) { + return false; + } + } + if (arg.code === 'KeyV') { + return false; + } + } + + return true; + }); + + fitAddon.fit(); + } + + function isSuccessfulExitError(channel: number, text: string): boolean { + // Linux container Error + if (channel === 3) { + try { + const error = JSON.parse(text); + if (_.isEmpty(error.metadata) && error.status === 'Success') { + return true; + } + } catch {} + } + return false; + } + + function isShellNotFoundError(channel: number, text: string): boolean { + // Linux container Error + if (channel === 3) { + try { + const error = JSON.parse(text); + if (error.code === 500 && error.status === 'Failure' && error.reason === 'InternalError') { + return true; + } + } catch {} + } + // Windows container Error + if (channel === 1) { + if (text.includes('The system cannot find the file specified')) { + return true; + } + } + return false; + } + + function shellConnectFailed(xtermc: XTerminalConnected) { + const xterm = xtermc.xterm; + xterm.clear(); + xterm.write(t('Failed to connect…') + '\r\n'); + } + + function send(channel: number, data: string) { + const socket = shellRef.current!.getSocket(); + + // We should only send data if the socket is ready. + if (!socket || socket.readyState !== 1) { + console.debug('Could not send data to exec: Socket not ready...', socket); + return; + } + + const encoded = encoder.encode(data); + const buffer = new Uint8Array([channel, ...encoded]); + + socket.send(buffer); + } + + React.useEffect( + () => { + // We need a valid container ref for the terminal to add itself to it. + if (terminalContainerRef === null) { + return; + } + + // Don't do anything if the dialog is not open. + if (!props.open) { + return; + } + + if (xtermRef.current) { + xtermRef.current.xterm.dispose(); + shellRef.current?.cancel(); + } + + const isWindows = ['Windows', 'Win16', 'Win32', 'WinCE'].indexOf(navigator?.platform) >= 0; + xtermRef.current = { + xterm: new XTerminal({ + cursorBlink: true, + cursorStyle: 'underline', + scrollback: 10000, + rows: 30, // initial rows before fit + windowsMode: isWindows, + }), + connected: false, + reconnectOnEnter: false, + }; + + fitAddonRef.current = new FitAddon(); + xtermRef.current.xterm.loadAddon(fitAddonRef.current); + + (async function () { + xtermRef?.current?.xterm.writeln(t('Trying to open a shell')); + const { stream, onClose } = await item.shell((items: ArrayBuffer) => + onData(xtermRef.current!, items) + ); + shellRef.current = stream; + xtermRef.current!.onClose = onClose; + setupTerminal(terminalContainerRef, xtermRef.current!.xterm, fitAddonRef.current!); + })(); + + const handler = () => { + fitAddonRef.current!.fit(); + }; + + window.addEventListener('resize', handler); + + return function cleanup() { + xtermRef.current?.xterm.dispose(); + shellRef.current?.cancel(); + window.removeEventListener('resize', handler); + }; + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [terminalContainerRef, props.open] + ); + + return ( + { + wrappedOnClose(); + }} + onFullScreenToggled={() => { + setTimeout(() => { + fitAddonRef.current!.fit(); + }, 1); + }} + keepMounted + withFullScreen + title={t('Shell: {{ itemName }}', { itemName: item.metadata.name })} + {...other} + > + + +
setTerminalContainerRef(x)} + style={{ flex: 1, display: 'flex', flexDirection: 'column-reverse' }} + /> + + +
+ ); +} diff --git a/frontend/src/components/common/index.test.ts b/frontend/src/components/common/index.test.ts index 1d61d111ca8..fcacb80f4a8 100644 --- a/frontend/src/components/common/index.test.ts +++ b/frontend/src/components/common/index.test.ts @@ -34,6 +34,7 @@ const checkExports = [ 'SectionBox', 'SectionFilterHeader', 'SectionHeader', + 'Shell', 'ShowHideLabel', 'SimpleTable', 'Tabs', diff --git a/frontend/src/components/common/index.ts b/frontend/src/components/common/index.ts index 26fb82af105..121b1f004ab 100644 --- a/frontend/src/components/common/index.ts +++ b/frontend/src/components/common/index.ts @@ -28,6 +28,8 @@ export * from './SectionFilterHeader'; export { default as SectionFilterHeader } from './SectionFilterHeader'; export * from './SectionHeader'; export { default as SectionHeader } from './SectionHeader'; +export * from './Shell'; +export { default as Shell } from './Shell'; export * from './ShowHideLabel'; export { default as ShowHideLabel } from './ShowHideLabel'; export * from './SimpleTable'; diff --git a/frontend/src/components/node/Details.tsx b/frontend/src/components/node/Details.tsx index 065ac17fba2..47aa035d020 100644 --- a/frontend/src/components/node/Details.tsx +++ b/frontend/src/components/node/Details.tsx @@ -19,6 +19,7 @@ import { HeaderLabel, StatusLabel, ValueLabel } from '../common/Label'; import { DetailsGrid, OwnedPodsSection } from '../common/Resource'; import AuthVisible from '../common/Resource/AuthVisible'; import { SectionBox } from '../common/SectionBox'; +import Shell from '../common/Shell'; import { NameValueTable } from '../common/SimpleTable'; function NodeConditionsLabel(props: { node: Node }) { @@ -43,6 +44,7 @@ export default function NodeDetails() { const [isNodeDrainInProgress, setisNodeDrainInProgress] = React.useState(false); const [nodeFromAPI, nodeError] = Node.useGet(name); const [node, setNode] = useState(nodeFromAPI); + const [showShell, setShowShell] = React.useState(false); const noMetrics = metricsError?.status === 404; useEffect(() => { @@ -194,6 +196,19 @@ export default function NodeDetails() { ), }, + { + id: DefaultHeaderAction.NODE_SHELL, + action: ( + setShowShell(true)} + iconButtonProps={{ + disabled: item?.status?.nodeInfo?.operatingSystem != 'linux', + }} + /> + ), + }, ]; }} extraInfo={item => @@ -223,6 +238,19 @@ export default function NodeDetails() { id: 'headlamp.node-owned-pods', section: , }, + { + id: 'headlamp.node-shell', + section: ( + { + setShowShell(false); + }} + /> + ), + }, ] } /> diff --git a/frontend/src/i18n/locales/de/glossary.json b/frontend/src/i18n/locales/de/glossary.json index f52c083b6cb..d6f61b721fa 100644 --- a/frontend/src/i18n/locales/de/glossary.json +++ b/frontend/src/i18n/locales/de/glossary.json @@ -96,6 +96,7 @@ "Uncordon": "Planungssperre aufheben", "Cordon": "Planungssperre setzen", "Drain": "Entleeren", + "Drop Node Shell": "Drop Node Shell", "Pod CIDR": "CIDR des Pods", "Uptime": "Betriebszeit", "System Info": "System-Informationen", diff --git a/frontend/src/i18n/locales/de/translation.json b/frontend/src/i18n/locales/de/translation.json index f2e3621ee09..87908f7712e 100644 --- a/frontend/src/i18n/locales/de/translation.json +++ b/frontend/src/i18n/locales/de/translation.json @@ -226,6 +226,9 @@ "Show filter": "Filter anzeigen", "Search": "Suchen", "Filter": "Filter", + "Failed to connect…": "Verbindung fehlgeschlagen…", + "Trying to open a shell": "Trying to open a shell", + "Shell: {{ itemName }}": "Shell: {{ itemName }}", "Collapse": "Einklappen", "Expand": "Ausklappen", "sort up": "Niedrigste zuerst", @@ -236,7 +239,6 @@ "No data matching the filter criteria.": "Keine Daten, die den Filterkriterien entsprechen.", "Any new output for this container's process should be shown below. In case it doesn't show up, press enter…": "Jede neue Ausgabe für den Prozess dieses Containers sollte unten angezeigt werden. Falls sie nicht angezeigt wird, drücken Sie Enter…", "Failed to run \"{{command}}\"…": "Fehler bei der Ausführung von \"{{command}}\"…", - "Failed to connect…": "Verbindung fehlgeschlagen…", "Press the enter key to reconnect.": "Drücken Sie die Eingabetaste, um die Verbindung wiederherzustellen.", "Failed to run \"{{ command }}\"": "Ausführung von \"{{ command }}\" fehlgeschlagen", "Trying to attach to the container {{ container }}…": "Versuche, an den Container {{ container }} anzuhängen…", diff --git a/frontend/src/i18n/locales/en/glossary.json b/frontend/src/i18n/locales/en/glossary.json index 4ceeda4d48f..41875bc2609 100644 --- a/frontend/src/i18n/locales/en/glossary.json +++ b/frontend/src/i18n/locales/en/glossary.json @@ -96,6 +96,7 @@ "Uncordon": "Uncordon", "Cordon": "Cordon", "Drain": "Drain", + "Drop Node Shell": "Drop Node Shell", "Pod CIDR": "Pod CIDR", "Uptime": "Uptime", "System Info": "System Info", diff --git a/frontend/src/i18n/locales/en/translation.json b/frontend/src/i18n/locales/en/translation.json index 2d1f9273d29..aba66dde765 100644 --- a/frontend/src/i18n/locales/en/translation.json +++ b/frontend/src/i18n/locales/en/translation.json @@ -226,6 +226,9 @@ "Show filter": "Show filter", "Search": "Search", "Filter": "Filter", + "Failed to connect…": "Failed to connect…", + "Trying to open a shell": "Trying to open a shell", + "Shell: {{ itemName }}": "Shell: {{ itemName }}", "Collapse": "Collapse", "Expand": "Expand", "sort up": "sort up", @@ -236,7 +239,6 @@ "No data matching the filter criteria.": "No data matching the filter criteria.", "Any new output for this container's process should be shown below. In case it doesn't show up, press enter…": "Any new output for this container's process should be shown below. In case it doesn't show up, press enter…", "Failed to run \"{{command}}\"…": "Failed to run \"{{command}}\"…", - "Failed to connect…": "Failed to connect…", "Press the enter key to reconnect.": "Press the enter key to reconnect.", "Failed to run \"{{ command }}\"": "Failed to run \"{{ command }}\"", "Trying to attach to the container {{ container }}…": "Trying to attach to the container {{ container }}…", diff --git a/frontend/src/i18n/locales/es/glossary.json b/frontend/src/i18n/locales/es/glossary.json index 2e335d7e247..1d5e6387feb 100644 --- a/frontend/src/i18n/locales/es/glossary.json +++ b/frontend/src/i18n/locales/es/glossary.json @@ -96,6 +96,7 @@ "Uncordon": "Desbloquear", "Cordon": "Bloquear", "Drain": "Drenar", + "Drop Node Shell": "Drop Node Shell", "Pod CIDR": "Pod CIDR", "Uptime": "Tiempo de actividad", "System Info": "Info. de Sistema", diff --git a/frontend/src/i18n/locales/es/translation.json b/frontend/src/i18n/locales/es/translation.json index 79cab2196e9..03a34e55cfd 100644 --- a/frontend/src/i18n/locales/es/translation.json +++ b/frontend/src/i18n/locales/es/translation.json @@ -226,6 +226,9 @@ "Show filter": "Mostrar filtro", "Search": "Buscar", "Filter": "Filtrar", + "Failed to connect…": "Fallo al conectar…", + "Trying to open a shell": "Trying to open a shell", + "Shell: {{ itemName }}": "Shell: {{ itemName }}", "Collapse": "Colapsar", "Expand": "Expandir", "sort up": "ordenar asc.", @@ -236,7 +239,6 @@ "No data matching the filter criteria.": "Sin datos que correspondan a los criterios de filtrado", "Any new output for this container's process should be shown below. In case it doesn't show up, press enter…": "Cualquier nuevo output para el proceso de este contenedor debería mostrarse abajo. En caso de que no aparezca, pulse intro…", "Failed to run \"{{command}}\"…": "Fallor al ejecutar \"{{command}}\"…", - "Failed to connect…": "Fallo al conectar…", "Press the enter key to reconnect.": "Pulse la tecla intro para reconectar.", "Failed to run \"{{ command }}\"": "Fallo al ejecutar \"{{ command }}\"", "Trying to attach to the container {{ container }}…": "Intentando adjuntarse al contenedor {{ container }}…", diff --git a/frontend/src/i18n/locales/fr/glossary.json b/frontend/src/i18n/locales/fr/glossary.json index 29730890282..db0623783cf 100644 --- a/frontend/src/i18n/locales/fr/glossary.json +++ b/frontend/src/i18n/locales/fr/glossary.json @@ -96,6 +96,7 @@ "Uncordon": "Débloquer", "Cordon": "Bloquer", "Drain": "Vider", + "Drop Node Shell": "Drop Node Shell", "Pod CIDR": "CIDR du pod", "Uptime": "Temps de fonctionnement", "System Info": "Informations sur le système", diff --git a/frontend/src/i18n/locales/fr/translation.json b/frontend/src/i18n/locales/fr/translation.json index da420cdd357..67efa6b927f 100644 --- a/frontend/src/i18n/locales/fr/translation.json +++ b/frontend/src/i18n/locales/fr/translation.json @@ -226,6 +226,9 @@ "Show filter": "Afficher le filtre", "Search": "Recherche", "Filter": "Filtrer", + "Failed to connect…": "Échec de la connexion…", + "Trying to open a shell": "Trying to open a shell", + "Shell: {{ itemName }}": "Shell: {{ itemName }}", "Collapse": "Réduire", "Expand": "Développer", "sort up": "trier par ordre croissant", @@ -236,7 +239,6 @@ "No data matching the filter criteria.": "Aucune donnée ne correspond aux critères de filtrage.", "Any new output for this container's process should be shown below. In case it doesn't show up, press enter…": "Toute nouvelle sortie pour le processus de ce conteneur devrait être affichée ci-dessous. Si elle n'apparaît pas, appuyez sur Entrée…", "Failed to run \"{{command}}\"…": "Échec de l'exécution de \"{{command}}\"…", - "Failed to connect…": "Échec de la connexion…", "Press the enter key to reconnect.": "Appuyez sur la touche Entrée pour vous reconnecter.", "Failed to run \"{{ command }}\"": "Échec de l'exécution de \"{commande }}\"", "Trying to attach to the container {{ container }}…": "Tentative de connexion au conteneur {{ container }}…", diff --git a/frontend/src/i18n/locales/pt/glossary.json b/frontend/src/i18n/locales/pt/glossary.json index 3587c47dec7..b7915abb729 100644 --- a/frontend/src/i18n/locales/pt/glossary.json +++ b/frontend/src/i18n/locales/pt/glossary.json @@ -96,6 +96,7 @@ "Uncordon": "Desbloquear", "Cordon": "Bloquear", "Drain": "Esvaziar", + "Drop Node Shell": "Drop Node Shell", "Pod CIDR": "Pod CIDR", "Uptime": "Uptime", "System Info": "Info. do Sistema", diff --git a/frontend/src/i18n/locales/pt/translation.json b/frontend/src/i18n/locales/pt/translation.json index f7997a57adf..eb5b7f8c3e4 100644 --- a/frontend/src/i18n/locales/pt/translation.json +++ b/frontend/src/i18n/locales/pt/translation.json @@ -226,6 +226,9 @@ "Show filter": "Mostrar filtro", "Search": "Pesquisar", "Filter": "Filtrar", + "Failed to connect…": "Falha ao conectar…", + "Trying to open a shell": "Trying to open a shell", + "Shell: {{ itemName }}": "Shell: {{ itemName }}", "Collapse": "Encolher", "Expand": "Expandir", "sort up": "ordenar asc.", @@ -236,7 +239,6 @@ "No data matching the filter criteria.": "Sem dados que verifiquem os critérios de filtro.", "Any new output for this container's process should be shown below. In case it doesn't show up, press enter…": "Qualquer novo output para o processo deste container deverá ser mostrado abaixo. Caso não apareça, pressione enter…", "Failed to run \"{{command}}\"…": "Falha ao executar \"{{command}}\"…", - "Failed to connect…": "Falha ao conectar…", "Press the enter key to reconnect.": "Pressione a tecla \"enter\" para se reconectar.", "Failed to run \"{{ command }}\"": "Falha ao executar \"{{ command }}\"", "Trying to attach to the container {{ container }}…": "A tentar anexar ao container {{ container }}…", diff --git a/frontend/src/lib/k8s/node.ts b/frontend/src/lib/k8s/node.ts index c7423df6de2..e290f863e58 100644 --- a/frontend/src/lib/k8s/node.ts +++ b/frontend/src/lib/k8s/node.ts @@ -1,8 +1,10 @@ import React from 'react'; +import { v4 as uuidv4 } from 'uuid'; import { useErrorState } from '../util'; import { useConnectApi } from '.'; -import { ApiError, apiFactory, metrics } from './apiProxy'; +import { ApiError, apiFactory, apply, metrics, stream, StreamResultsCb } from './apiProxy'; import { KubeCondition, KubeMetrics, KubeObjectInterface, makeKubeObject } from './cluster'; +import Pod, { KubePod } from './pod'; export interface KubeNode extends KubeObjectInterface { status: { @@ -36,6 +38,42 @@ export interface KubeNode extends KubeObjectInterface { }; } +const shellPod = (name: string, nodeName: string, nodeShellImage: string) => { + return { + kind: 'Pod', + apiVersion: 'v1', + metadata: { + name, + namespace: 'kube-system', + }, + spec: { + nodeName, + restartPolicy: 'Never', + terminationGracePeriodSeconds: 0, + hostPID: true, + hostIPC: true, + hostNetwork: true, + tolerations: [ + { + operator: 'Exists', + }, + ], + priorityClassName: 'system-node-critical', + containers: [ + { + name: 'shell', + image: nodeShellImage, + securityContext: { + privileged: true, + }, + command: ['nsenter'], + args: ['-t', '1', '-m', '-u', '-i', '-n', 'sleep', '14000'], + }, + ], + }, + } as unknown as KubePod; +}; + class Node extends makeKubeObject('node') { static apiEndpoint = apiFactory('', 'v1', 'nodes'); @@ -71,6 +109,39 @@ class Node extends makeKubeObject('node') { getInternalIP(): string { return this.status.addresses.find(address => address.type === 'InternalIP')?.address || ''; } + + async shell(onExec: StreamResultsCb) { + const podName = `node-shell-${this.getName()}-${uuidv4()}`; + const kubePod = shellPod(podName, this.getName(), 'docker.io/alpine:3.13'); + await apply(kubePod); + const command = [ + 'sh', + '-c', + '((clear && bash) || (clear && zsh) || (clear && ash) || (clear && sh))', + ]; + const tty = true; + const stdin = true; + const stdout = true; + const stderr = true; + const commandStr = command.map(item => '&command=' + encodeURIComponent(item)).join(''); + const url = `/api/v1/namespaces/kube-system/pods/${podName}/exec?container=shell${commandStr}&stdin=${ + stdin ? 1 : 0 + }&stderr=${stderr ? 1 : 0}&stdout=${stdout ? 1 : 0}&tty=${tty ? 1 : 0}`; + const additionalProtocols = [ + 'v4.channel.k8s.io', + 'v3.channel.k8s.io', + 'v2.channel.k8s.io', + 'channel.k8s.io', + ]; + const onClose = () => { + const pod = new Pod(kubePod); + pod.delete(); + }; + return { + stream: stream(url, onExec, { additionalProtocols, isJson: false }), + onClose: onClose, + }; + } } export default Node; diff --git a/frontend/src/redux/actionButtonsSlice.ts b/frontend/src/redux/actionButtonsSlice.ts index 010af2a44a7..db855f642ca 100644 --- a/frontend/src/redux/actionButtonsSlice.ts +++ b/frontend/src/redux/actionButtonsSlice.ts @@ -36,6 +36,7 @@ export enum DefaultHeaderAction { POD_ATTACH = 'POD_ATTACH', NODE_TOGGLE_CORDON = 'NODE_TOGGLE_CORDON', NODE_DRAIN = 'NODE_DRAIN', + NODE_SHELL = 'NODE_SHELL', } export enum DefaultAppBarAction {