diff --git a/frontend/src/components/App/Settings/SettingsCluster.tsx b/frontend/src/components/App/Settings/SettingsCluster.tsx index 1dd93f49545..b47797b7841 100644 --- a/frontend/src/components/App/Settings/SettingsCluster.tsx +++ b/frontend/src/components/App/Settings/SettingsCluster.tsx @@ -6,7 +6,7 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; import { useDispatch } from 'react-redux'; import { useHistory } from 'react-router-dom'; -import helpers, { ClusterSettings } from '../../../helpers'; +import helpers, { ClusterSettings, DEFAULT_DROP_SHELL_IMAGE } from '../../../helpers'; import { useCluster, useClustersConf } from '../../../lib/k8s'; import { deleteCluster } from '../../../lib/k8s/apiProxy'; import { setConfig } from '../../../redux/configSlice'; @@ -47,12 +47,25 @@ function isValidNamespaceFormat(namespace: string) { return regex.test(namespace); } +function isValidImageFormat(image: string) { + // We allow empty strings just because that's the default value in our case. + if (!image) { + return true; + } + + // Validates that the namespace is a valid DNS-1123 label and returns a boolean. + // https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#dns-label-names + //const regex = new RegExp('^[a-z0-9]([-a-z0-9]*[a-z0-9])?$'); + return true; +} + export default function SettingsCluster() { const cluster = useCluster(); const clusterConf = useClustersConf(); const { t } = useTranslation(['translation']); const [defaultNamespace, setDefaultNamespace] = React.useState('default'); const [userDefaultNamespace, setUserDefaultNamespace] = React.useState(''); + const [dropShellImage, setDropShellImage] = React.useState(''); const [newAllowedNamespace, setNewAllowedNamespace] = React.useState(''); const [clusterSettings, setClusterSettings] = React.useState(null); const classes = useStyles(); @@ -127,10 +140,34 @@ export default function SettingsCluster() { }; }, [userDefaultNamespace]); + React.useEffect(() => { + let timeoutHandle: NodeJS.Timeout | null = null; + + if (isEditingDropShellImage()) { + // We store the namespace after a timeout. + timeoutHandle = setTimeout(() => { + if (isValidImageFormat(dropShellImage)) { + storeNewDropShellImage(dropShellImage); + } + }, 1000); + } + + return () => { + if (timeoutHandle) { + clearTimeout(timeoutHandle); + timeoutHandle = null; + } + }; + }, [dropShellImage]); + function isEditingDefaultNamespace() { return clusterSettings?.defaultNamespace !== userDefaultNamespace; } + function isEditingDropShellImage() { + return clusterSettings?.dropShellImage !== dropShellImage; + } + if (!cluster) { return null; } @@ -163,7 +200,18 @@ export default function SettingsCluster() { }); } + function storeNewDropShellImage(image: string) { + setClusterSettings((settings: ClusterSettings | null) => { + const newSettings = { ...(settings || {}) }; + if (isValidImageFormat(image)) { + newSettings.dropShellImage = image; + } + return newSettings; + }); + } + const isValidDefaultNamespace = isValidNamespaceFormat(userDefaultNamespace); + const isValidDropShellImage = isValidImageFormat(dropShellImage); const isValidNewAllowedNamespace = isValidNamespaceFormat(newAllowedNamespace); const invalidNamespaceMessage = t( "translation|Namespaces must contain only lowercase alphanumeric characters or '-', and must start and end with an alphanumeric character." @@ -295,6 +343,40 @@ export default function SettingsCluster() { ), }, + { + name: t('translation|Drop Node Shell Image'), + value: ( + { + let value = event.target.value; + value = value.replace(' ', ''); + setDropShellImage(value); + }} + value={dropShellImage} + placeholder={DEFAULT_DROP_SHELL_IMAGE} + error={!isValidImageFormat} + helperText={ + isValidDropShellImage + ? t( + 'translation|The default image is used for dropping a shell into a node (when not specified directly).' + ) + : invalidNamespaceMessage + } + InputProps={{ + endAdornment: isEditingDropShellImage() ? ( + + ) : ( + + ), + className: classes.input, + }} + /> + ), + }, ]} /> diff --git a/frontend/src/components/common/Terminal.tsx b/frontend/src/components/common/Terminal.tsx index 7a8bd6d87fb..539b47bc595 100644 --- a/frontend/src/components/common/Terminal.tsx +++ b/frontend/src/components/common/Terminal.tsx @@ -12,6 +12,7 @@ import React, { useState } 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 Pod from '../../lib/k8s/pod'; import { Dialog } from './Dialog'; @@ -59,7 +60,7 @@ const useStyle = makeStyles(theme => ({ })); interface TerminalProps extends DialogProps { - item: Pod; + item: Pod | Node; isAttach?: boolean; onClose?: () => void; } @@ -68,16 +69,77 @@ interface XTerminalConnected { xterm: XTerminal; connected: boolean; reconnectOnEnter: boolean; + onClose?: () => void; } -type execReturn = ReturnType; +interface ContainerSelectionProps { + item: Pod; + containerFormControlClass: string; + container: string; + setContainer: (container: string) => void; +} + +function ContainerSelection(props: ContainerSelectionProps) { + const { item, container, containerFormControlClass, setContainer } = props; + const { t } = useTranslation(['translation', 'glossary']); + + function wrappedHandleContainerChange(event: any) { + setContainer(event.target.value); + } + return ( + + + + {t('glossary|Container')} + + + + + ); +} export default function Terminal(props: TerminalProps) { const { item, onClose, isAttach, ...other } = props; const classes = useStyle(); const [terminalContainerRef, setTerminalContainerRef] = React.useState(null); const [container, setContainer] = useState(getDefaultContainer()); - const execOrAttachRef = React.useRef(null); + const streamRef = React.useRef(null); const fitAddonRef = React.useRef(null); const xtermRef = React.useRef(null); const [shells, setShells] = React.useState({ @@ -86,8 +148,25 @@ export default function Terminal(props: TerminalProps) { }); const { t } = useTranslation(['translation', 'glossary']); + function isPod(item: Pod | Node): item is Pod { + return item && 'containers' in item.spec; + } + + const wrappedOnClose = () => { + if (!!onClose) { + onClose(); + } + + if (!!xtermRef.current?.onClose) { + xtermRef.current?.onClose(); + } + }; + function getDefaultContainer() { - return item.spec.containers.length > 0 ? item.spec.containers[0].name : ''; + if (isPod(item)) { + return item.spec.containers.length > 0 ? item.spec.containers[0].name : ''; + } + return ''; } // @todo: Give the real exec type when we have it. @@ -119,15 +198,16 @@ export default function Terminal(props: TerminalProps) { return false; } } - - if (!isAttach && arg.type === 'keydown' && arg.code === 'Enter') { - if (xtermRef.current?.reconnectOnEnter) { - setShells(shells => ({ - ...shells, - currentIdx: 0, - })); - xtermRef.current!.reconnectOnEnter = false; - return false; + if (isPod(item)) { + if (!isAttach && arg.type === 'keydown' && arg.code === 'Enter') { + if (xtermRef.current?.reconnectOnEnter) { + setShells(shells => ({ + ...shells, + currentIdx: 0, + })); + xtermRef.current!.reconnectOnEnter = false; + return false; + } } } @@ -138,7 +218,7 @@ export default function Terminal(props: TerminalProps) { } function send(channel: number, data: string) { - const socket = execOrAttachRef.current!.getSocket(); + const socket = streamRef.current!.getSocket(); // We should only send data if the socket is ready. if (!socket || socket.readyState !== 1) { @@ -182,12 +262,10 @@ export default function Terminal(props: TerminalProps) { } if (isSuccessfulExitError(channel, text)) { - if (!!onClose) { - onClose(); - } + wrappedOnClose(); - if (execOrAttachRef.current) { - execOrAttachRef.current?.cancel(); + if (streamRef.current) { + streamRef.current?.cancel(); } return; @@ -197,16 +275,18 @@ export default function Terminal(props: TerminalProps) { shellConnectFailed(xtermc); return; } - if (isAttach) { - // in case of attach if we didn't recieve any data from the process we should notify the user that if any data comes - // we will be showing it in the terminal - if (firstConnect && !text) { - text = - t( - "Any new output for this container's process should be shown below. In case it doesn't show up, press enter…" - ) + '\r\n'; + if (isPod(item)) { + if (isAttach) { + // in case of attach if we didn't recieve any data from the process we should notify the user that if any data comes + // we will be showing it in the terminal + if (firstConnect && !text) { + text = + t( + "Any new output for this container's process should be shown below. In case it doesn't show up, press enter…" + ) + '\r\n'; + } + text = text.replace(/\r\n/g, '\n').replace(/\n/g, '\r\n'); } - text = text.replace(/\r\n/g, '\n').replace(/\n/g, '\r\n'); } xterm.write(text); } @@ -230,22 +310,27 @@ export default function Terminal(props: TerminalProps) { function shellConnectFailed(xtermc: XTerminalConnected) { const xterm = xtermc.xterm; - const command = getCurrentShellCommand(); - if (isLastShell()) { - if (xtermc.connected) { - xterm.write(t('Failed to run "{{command}}"…', { command }) + '\r\n'); - } else { - xterm.clear(); - xterm.write(t('Failed to connect…') + '\r\n'); - } + if (isPod(item)) { + const command = getCurrentShellCommand(); + if (isLastShell()) { + if (xtermc.connected) { + xterm.write(t('Failed to run "{{command}}"…', { command }) + '\r\n'); + } else { + xterm.clear(); + xterm.write(t('Failed to connect…') + '\r\n'); + } - xterm.write('\r\n' + t('Press the enter key to reconnect.') + '\r\n'); - if (xtermRef.current) { - xtermRef.current.reconnectOnEnter = true; + xterm.write('\r\n' + t('Press the enter key to reconnect.') + '\r\n'); + if (xtermRef.current) { + xtermRef.current.reconnectOnEnter = true; + } + } else { + xterm.write(t('Failed to run "{{ command }}"', { command }) + '\r\n'); + tryNextShell(); } } else { - xterm.write(t('Failed to run "{{ command }}"', { command }) + '\r\n'); - tryNextShell(); + xterm.clear(); + xterm.write(t('Failed to connect…') + '\r\n'); } } @@ -256,10 +341,12 @@ export default function Terminal(props: TerminalProps) { return; } - // Don't do anything until the pod's container is assigned. We used the pod's late - // assignment to prevent calling exec before the dialog is opened. - if (container === null) { - return; + if (isPod(item)) { + // Don't do anything until the pod's container is assigned. We used the pod's late + // assignment to prevent calling exec before the dialog is opened. + if (container === null) { + return; + } } // Don't do anything if the dialog is not open. @@ -269,7 +356,7 @@ export default function Terminal(props: TerminalProps) { if (xtermRef.current) { xtermRef.current.xterm.dispose(); - execOrAttachRef.current?.cancel(); + streamRef.current?.cancel(); } const isWindows = ['Windows', 'Win16', 'Win32', 'WinCE'].indexOf(navigator?.platform) >= 0; @@ -289,26 +376,35 @@ export default function Terminal(props: TerminalProps) { xtermRef.current.xterm.loadAddon(fitAddonRef.current); (async function () { - if (isAttach) { - xtermRef?.current?.xterm.writeln( - t('Trying to attach to the container {{ container }}…', { container }) + '\n' - ); - - execOrAttachRef.current = await item.attach( - container, - (items: ArrayBuffer) => onData(xtermRef.current!, items), - { failCb: () => shellConnectFailed(xtermRef.current!) } - ); + if (isPod(item)) { + if (isAttach) { + xtermRef?.current?.xterm.writeln( + t('Trying to attach to the container {{ container }}…', { container }) + '\n' + ); + + streamRef.current = await item.attach( + container!!, + (items: ArrayBuffer) => onData(xtermRef.current!, items), + { failCb: () => shellConnectFailed(xtermRef.current!) } + ); + } else { + const command = getCurrentShellCommand(); + + xtermRef?.current?.xterm.writeln(t('Trying to run "{{command}}"…', { command }) + '\n'); + + streamRef.current = await item.exec( + container!!, + (items: ArrayBuffer) => onData(xtermRef.current!, items), + { command: [command], failCb: () => shellConnectFailed(xtermRef.current!) } + ); + } } else { - const command = getCurrentShellCommand(); - - xtermRef?.current?.xterm.writeln(t('Trying to run "{{command}}"…', { command }) + '\n'); - - execOrAttachRef.current = await item.exec( - container, - (items: ArrayBuffer) => onData(xtermRef.current!, items), - { command: [command], failCb: () => shellConnectFailed(xtermRef.current!) } + xtermRef?.current?.xterm.writeln(t('Trying to open a shell')); + const { stream, onClose } = await item.shell((items: ArrayBuffer) => + onData(xtermRef.current!, items) ); + streamRef.current = stream; + xtermRef.current!.onClose = onClose; } setupTerminal(terminalContainerRef, xtermRef.current!.xterm, fitAddonRef.current!); })(); @@ -321,7 +417,7 @@ export default function Terminal(props: TerminalProps) { return function cleanup() { xtermRef.current?.xterm.dispose(); - execOrAttachRef.current?.cancel(); + streamRef.current?.cancel(); window.removeEventListener('resize', handler); }; }, @@ -329,24 +425,26 @@ export default function Terminal(props: TerminalProps) { [container, terminalContainerRef, shells, props.open] ); - React.useEffect( - () => { - if (props.open && container === null) { - setContainer(getDefaultContainer()); + if (isPod(item)) { + React.useEffect( + () => { + if (props.open && container === null) { + setContainer(getDefaultContainer()); + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [props.open] + ); + + React.useEffect(() => { + if (!isAttach && shells.available.length === 0) { + setShells({ + available: getAvailableShells(), + currentIdx: 0, + }); } - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [props.open] - ); - - React.useEffect(() => { - if (!isAttach && shells.available.length === 0) { - setShells({ - available: getAvailableShells(), - currentIdx: 0, - }); - } - }, [item]); + }, [item]); + } function getAvailableShells() { const selector = item.spec?.nodeSelector || {}; @@ -359,10 +457,6 @@ export default function Terminal(props: TerminalProps) { return ['bash', '/bin/bash', 'sh', '/bin/sh', 'powershell.exe', 'cmd.exe']; } - function handleContainerChange(event: any) { - setContainer(event.target.value); - } - function isSuccessfulExitError(channel: number, text: string): boolean { // Linux container Error if (channel === 3) { @@ -397,7 +491,7 @@ export default function Terminal(props: TerminalProps) { return ( { setTimeout(() => { fitAddonRef.current!.fit(); @@ -406,57 +500,25 @@ export default function Terminal(props: TerminalProps) { keepMounted withFullScreen title={ - isAttach - ? t('Attach: {{ itemName }}', { itemName: item.metadata.name }) - : t('Terminal: {{ itemName }}', { itemName: item.metadata.name }) + isPod(item) + ? isAttach + ? t('Attach: {{ itemName }}', { itemName: item.metadata.name }) + : t('Terminal: {{ itemName }}', { itemName: item.metadata.name }) + : t('Shell: {{ itemName }}', { itemName: item.metadata.name }) } {...other} > - - - - {t('glossary|Container')} - - - - + {isPod(item) ? ( + + ) : ( + <> + )}
(); const { t } = useTranslation(['translation']); const dispatch = useDispatch(); - // method to generate a unique string - const uniqueString = () => { - const timestamp = Date.now().toString(36); - const randomNum = Math.random().toString(36).substr(2, 5); - return `${timestamp}-${randomNum}`; - }; const job = _.cloneDeep(cronJob.spec.jobTemplate); const [jobName, setJobName] = useState( diff --git a/frontend/src/components/node/Details.tsx b/frontend/src/components/node/Details.tsx index 065ac17fba2..4277c58bb6c 100644 --- a/frontend/src/components/node/Details.tsx +++ b/frontend/src/components/node/Details.tsx @@ -10,6 +10,7 @@ import { useParams } from 'react-router-dom'; import { apply, drainNode, drainNodeStatus } from '../../lib/k8s/apiProxy'; import { KubeMetrics } from '../../lib/k8s/cluster'; import Node from '../../lib/k8s/node'; +import Pod from '../../lib/k8s/pod'; import { getCluster, timeAgo } from '../../lib/util'; import { DefaultHeaderAction } from '../../redux/actionButtonsSlice'; import { clusterAction } from '../../redux/clusterActionSlice'; @@ -20,6 +21,7 @@ import { DetailsGrid, OwnedPodsSection } from '../common/Resource'; import AuthVisible from '../common/Resource/AuthVisible'; import { SectionBox } from '../common/SectionBox'; import { NameValueTable } from '../common/SimpleTable'; +import Terminal from '../common/Terminal'; function NodeConditionsLabel(props: { node: Node }) { const { node } = props; @@ -43,6 +45,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(() => { @@ -150,6 +153,10 @@ export default function NodeDetails() { ); } + function isLinux(item: Node): boolean { + return item?.status?.nodeInfo?.operatingSystem === 'linux'; + } + return ( ), }, + { + id: DefaultHeaderAction.NODE_SHELL, + action: ( + + + setShowShell(true)} + iconButtonProps={{ + disabled: !isLinux(item), + }} + /> + + + ), + }, ]; }} extraInfo={item => @@ -223,6 +254,19 @@ export default function NodeDetails() { id: 'headlamp.node-owned-pods', section: , }, + { + id: 'headlamp.node-shell', + section: ( + { + setShowShell(false); + }} + /> + ), + }, ] } /> diff --git a/frontend/src/helpers/index.ts b/frontend/src/helpers/index.ts index ac6ec29b9d2..bccc505d69f 100644 --- a/frontend/src/helpers/index.ts +++ b/frontend/src/helpers/index.ts @@ -306,9 +306,12 @@ function getProductName(): string | undefined { return process.env.REACT_APP_HEADLAMP_PRODUCT_NAME; } +export const DEFAULT_DROP_SHELL_IMAGE = 'docker.io/alpine:3.19'; + export interface ClusterSettings { defaultNamespace?: string; allowedNamespaces?: string[]; + dropShellImage?: string; } function storeClusterSettings(clusterName: string, settings: ClusterSettings) { diff --git a/frontend/src/i18n/locales/de/glossary.json b/frontend/src/i18n/locales/de/glossary.json index b7fd72d71e9..14894d7ba24 100644 --- a/frontend/src/i18n/locales/de/glossary.json +++ b/frontend/src/i18n/locales/de/glossary.json @@ -97,6 +97,8 @@ "Uncordon": "Planungssperre aufheben", "Cordon": "Planungssperre setzen", "Drain": "Entleeren", + "Drop Node Shell": "Drop Node Shell", + "Drop Node Shell is not support on this operatingSystem: {{ device }}": "Drop Node Shell is not support on this operatingSystem: {{ device }}", "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 685a11a32ff..d86d208f6a9 100644 --- a/frontend/src/i18n/locales/de/translation.json +++ b/frontend/src/i18n/locales/de/translation.json @@ -54,6 +54,8 @@ "The default namespace for e.g. when applying resources (when not specified directly).": "Der Standard-Namespace z. B. für die Anwendung von Ressourcen (wenn nicht anders angegeben).", "Allowed namespaces": "Erlaubte Namespaces", "The list of namespaces you are allowed to access in this cluster.": "Liste der Namespaces, auf die Sie in diesem Cluster zugreifen dürfen.", + "Drop Node Shell Image": "Drop Node Shell Image", + "The default image is used for dropping a shell into a node (when not specified directly).": "", "Remove Cluster": "Cluster entfernen", "Are you sure you want to remove the cluster \"{{ clusterName }}\"?": "Sind Sie sicher, dass Sie den Cluster \"{{ clusterName }}\" entfernen möchten?", "Server": "Server", @@ -242,8 +244,10 @@ "Failed to run \"{{ command }}\"": "Ausführung von \"{{ command }}\" fehlgeschlagen", "Trying to attach to the container {{ container }}…": "Versuche, an den Container {{ container }} anzuhängen…", "Trying to run \"{{command}}\"…": "Versuche, \"{{command}}\" auszuführen…", + "Trying to open a shell": "Trying to open a shell", "Attach: {{ itemName }}": "Anhängen: {{ itemName }}", "Terminal: {{ itemName }}": "Terminal: {{ itemName }}", + "Shell: {{ itemName }}": "Shell: {{ itemName }}", "Timezone": "Zeitzone", "Add": "Hinzufügen", "Data": "Daten", diff --git a/frontend/src/i18n/locales/en/glossary.json b/frontend/src/i18n/locales/en/glossary.json index c85ab052988..106a2b7fb29 100644 --- a/frontend/src/i18n/locales/en/glossary.json +++ b/frontend/src/i18n/locales/en/glossary.json @@ -97,6 +97,8 @@ "Uncordon": "Uncordon", "Cordon": "Cordon", "Drain": "Drain", + "Drop Node Shell": "Drop Node Shell", + "Drop Node Shell is not support on this operatingSystem: {{ device }}": "Drop Node Shell is not support on this operatingSystem: {{ device }}", "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 874e16a0da2..82c037f8806 100644 --- a/frontend/src/i18n/locales/en/translation.json +++ b/frontend/src/i18n/locales/en/translation.json @@ -54,6 +54,8 @@ "The default namespace for e.g. when applying resources (when not specified directly).": "The default namespace for e.g. when applying resources (when not specified directly).", "Allowed namespaces": "Allowed namespaces", "The list of namespaces you are allowed to access in this cluster.": "The list of namespaces you are allowed to access in this cluster.", + "Drop Node Shell Image": "Drop Node Shell Image", + "The default image is used for dropping a shell into a node (when not specified directly).": "The default image is used for dropping a shell into a node (when not specified directly).", "Remove Cluster": "Remove Cluster", "Are you sure you want to remove the cluster \"{{ clusterName }}\"?": "Are you sure you want to remove the cluster \"{{ clusterName }}\"?", "Server": "Server", @@ -242,8 +244,10 @@ "Failed to run \"{{ command }}\"": "Failed to run \"{{ command }}\"", "Trying to attach to the container {{ container }}…": "Trying to attach to the container {{ container }}…", "Trying to run \"{{command}}\"…": "Trying to run \"{{command}}\"…", + "Trying to open a shell": "Trying to open a shell", "Attach: {{ itemName }}": "Attach: {{ itemName }}", "Terminal: {{ itemName }}": "Terminal: {{ itemName }}", + "Shell: {{ itemName }}": "Shell: {{ itemName }}", "Timezone": "Timezone", "Add": "Add", "Data": "Data", diff --git a/frontend/src/i18n/locales/es/glossary.json b/frontend/src/i18n/locales/es/glossary.json index 21035596b61..64ed18d3fc2 100644 --- a/frontend/src/i18n/locales/es/glossary.json +++ b/frontend/src/i18n/locales/es/glossary.json @@ -97,6 +97,8 @@ "Uncordon": "Desbloquear", "Cordon": "Bloquear", "Drain": "Drenar", + "Drop Node Shell": "Drop Node Shell", + "Drop Node Shell is not support on this operatingSystem: {{ device }}": "Drop Node Shell is not support on this operatingSystem: {{ device }}", "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 0593099a46f..ce57d2a9d50 100644 --- a/frontend/src/i18n/locales/es/translation.json +++ b/frontend/src/i18n/locales/es/translation.json @@ -54,6 +54,8 @@ "The default namespace for e.g. when applying resources (when not specified directly).": "El espacio de nombre por defecto para, por ejemplo, cuando se aplican recursos (cuando no especificado directamente).", "Allowed namespaces": "Espacios de nombre permitidos", "The list of namespaces you are allowed to access in this cluster.": "La lista de espacios de nombre a los que tiene permiso para acceder en este cluster.", + "Drop Node Shell Image": "Drop Node Shell Image", + "The default image is used for dropping a shell into a node (when not specified directly).": "", "Remove Cluster": "Eliminar cluster", "Are you sure you want to remove the cluster \"{{ clusterName }}\"?": "¿Está seguro de que desea eliminar el cluster \"{{ clusterName }}\"?", "Server": "Servidor", @@ -242,8 +244,10 @@ "Failed to run \"{{ command }}\"": "Fallo al ejecutar \"{{ command }}\"", "Trying to attach to the container {{ container }}…": "Intentando adjuntarse al contenedor {{ container }}…", "Trying to run \"{{command}}\"…": "Intentando ejecutar \"{{command}}\"…", + "Trying to open a shell": "Trying to open a shell", "Attach: {{ itemName }}": "Adjuntar: {{ itemName }}", "Terminal: {{ itemName }}": "Terminal: {{ itemName }}", + "Shell: {{ itemName }}": "Shell: {{ itemName }}", "Timezone": "Huso horario", "Add": "Añadir", "Data": "Datos", diff --git a/frontend/src/i18n/locales/fr/glossary.json b/frontend/src/i18n/locales/fr/glossary.json index 3f2c840eb1b..5349c1604ed 100644 --- a/frontend/src/i18n/locales/fr/glossary.json +++ b/frontend/src/i18n/locales/fr/glossary.json @@ -97,6 +97,8 @@ "Uncordon": "Débloquer", "Cordon": "Bloquer", "Drain": "Vider", + "Drop Node Shell": "Drop Node Shell", + "Drop Node Shell is not support on this operatingSystem: {{ device }}": "Drop Node Shell is not support on this operatingSystem: {{ device }}", "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 0222459524b..de3b7eadc49 100644 --- a/frontend/src/i18n/locales/fr/translation.json +++ b/frontend/src/i18n/locales/fr/translation.json @@ -54,6 +54,8 @@ "The default namespace for e.g. when applying resources (when not specified directly).": "L'espace de noms par défaut, par exemple lors de l'application de ressources (lorsqu'il n'est pas spécifié directement).", "Allowed namespaces": "Espaces de noms autorisés", "The list of namespaces you are allowed to access in this cluster.": "La liste des espaces de noms que vous pouvez accéder dans ce cluster.", + "Drop Node Shell Image": "Drop Node Shell Image", + "The default image is used for dropping a shell into a node (when not specified directly).": "", "Remove Cluster": "Supprimer le cluster", "Are you sure you want to remove the cluster \"{{ clusterName }}\"?": "Voulez-vous vraiment supprimer le cluster \"{{ clusterName }}\"?", "Server": "Serveur", @@ -242,8 +244,10 @@ "Failed to run \"{{ command }}\"": "Échec de l'exécution de \"{commande }}\"", "Trying to attach to the container {{ container }}…": "Tentative de connexion au conteneur {{ container }}…", "Trying to run \"{{command}}\"…": "Essayer d'exécuter \"{{command}}\"…", + "Trying to open a shell": "Trying to open a shell", "Attach: {{ itemName }}": "Attacher : {{ itemName }}", "Terminal: {{ itemName }}": "Terminal : {{ itemName }}", + "Shell: {{ itemName }}": "Shell: {{ itemName }}", "Timezone": "Fuseau horaire", "Add": "Add", "Data": "Données", diff --git a/frontend/src/i18n/locales/pt/glossary.json b/frontend/src/i18n/locales/pt/glossary.json index 61075eb5b90..b7b3f347acf 100644 --- a/frontend/src/i18n/locales/pt/glossary.json +++ b/frontend/src/i18n/locales/pt/glossary.json @@ -97,6 +97,8 @@ "Uncordon": "Desbloquear", "Cordon": "Bloquear", "Drain": "Esvaziar", + "Drop Node Shell": "Drop Node Shell", + "Drop Node Shell is not support on this operatingSystem: {{ device }}": "Drop Node Shell is not support on this operatingSystem: {{ device }}", "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 7e3db05fd51..f7f5ada4fba 100644 --- a/frontend/src/i18n/locales/pt/translation.json +++ b/frontend/src/i18n/locales/pt/translation.json @@ -54,6 +54,8 @@ "The default namespace for e.g. when applying resources (when not specified directly).": "O namespace por defeito, por exemplo, quando se aplicam recursos (quando não especificado directamente).", "Allowed namespaces": "Namespaces permitidos", "The list of namespaces you are allowed to access in this cluster.": "A lista de namespaces que tem permissão para aceder neste cluster.", + "Drop Node Shell Image": "Drop Node Shell Image", + "The default image is used for dropping a shell into a node (when not specified directly).": "", "Remove Cluster": "Remover Cluster", "Are you sure you want to remove the cluster \"{{ clusterName }}\"?": "Tem a certeza que quer remover o cluster \"{{ clusterName }}\"?", "Server": "Servidor", @@ -242,8 +244,10 @@ "Failed to run \"{{ command }}\"": "Falha ao executar \"{{ command }}\"", "Trying to attach to the container {{ container }}…": "A tentar anexar ao container {{ container }}…", "Trying to run \"{{command}}\"…": "A tentar executar \"{{command}}\"…", + "Trying to open a shell": "Trying to open a shell", "Attach: {{ itemName }}": "Anexar: {{ itemName }}", "Terminal: {{ itemName }}": "Terminal: {{ itemName }}", + "Shell: {{ itemName }}": "Shell: {{ itemName }}", "Timezone": "Fuso horário", "Add": "Adicionar", "Data": "Dados", diff --git a/frontend/src/lib/k8s/node.ts b/frontend/src/lib/k8s/node.ts index c7423df6de2..798d8a81401 100644 --- a/frontend/src/lib/k8s/node.ts +++ b/frontend/src/lib/k8s/node.ts @@ -1,8 +1,10 @@ import React from 'react'; -import { useErrorState } from '../util'; +import helpers, { DEFAULT_DROP_SHELL_IMAGE } from '../../helpers'; +import { getCluster, uniqueString, 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,50 @@ class Node extends makeKubeObject('node') { getInternalIP(): string { return this.status.addresses.find(address => address.type === 'InternalIP')?.address || ''; } + + async shell(onExec: StreamResultsCb) { + const cluster = getCluster(); + if (!cluster) { + return {}; + } + + const clusterSettings = helpers.loadClusterSettings(cluster); + let image = clusterSettings.dropShellImage; + const podName = `node-shell-${this.getName()}-${uniqueString()}`; + if (image === null || image === '') { + image = DEFAULT_DROP_SHELL_IMAGE; + } + console.log(image); + const kubePod = shellPod(podName, this.getName(), image!!); + 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/lib/util.ts b/frontend/src/lib/util.ts index d8c37145e6d..67ac8c42d9f 100644 --- a/frontend/src/lib/util.ts +++ b/frontend/src/lib/util.ts @@ -394,6 +394,12 @@ export function useId(prefix = '') { return id; } +export function uniqueString() { + const timestamp = Date.now().toString(36); + const randomNum = Math.random().toString(36).substring(2, 5); + return `${timestamp}-${randomNum}`; +} + // Make units available from here export * as auth from './auth'; export * as units from './units'; 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 {