diff --git a/frontend/src/components/App/Settings/SettingsCluster.tsx b/frontend/src/components/App/Settings/SettingsCluster.tsx index 67eaafa629..bfc72bd7f5 100644 --- a/frontend/src/components/App/Settings/SettingsCluster.tsx +++ b/frontend/src/components/App/Settings/SettingsCluster.tsx @@ -14,7 +14,7 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; import { useDispatch } from 'react-redux'; import { useHistory, useLocation } from 'react-router-dom'; -import helpers, { ClusterSettings } from '../../../helpers'; +import helpers, { ClusterSettings, DEFAULT_NODE_SHELL_LINUX_IMAGE } from '../../../helpers'; import { useCluster, useClustersConf } from '../../../lib/k8s'; import { deleteCluster, parseKubeConfig, renameCluster } from '../../../lib/k8s/apiProxy'; import { setConfig, setStatelessConfig } from '../../../redux/configSlice'; @@ -85,6 +85,7 @@ export default function SettingsCluster() { const { t } = useTranslation(['translation']); const [defaultNamespace, setDefaultNamespace] = React.useState('default'); const [userDefaultNamespace, setUserDefaultNamespace] = React.useState(''); + const [nodeShellLinuxImage, setNodeShellLinuxImage] = React.useState(''); const [newAllowedNamespace, setNewAllowedNamespace] = React.useState(''); const [clusterSettings, setClusterSettings] = React.useState(null); const [cluster, setCluster] = React.useState(useCluster() || ''); @@ -217,10 +218,32 @@ export default function SettingsCluster() { } }, [location.search, clusters]); + React.useEffect(() => { + let timeoutHandle: NodeJS.Timeout | null = null; + + if (isEditingNodeShellLinuxImage()) { + // We store the node shell image after a timeout. + timeoutHandle = setTimeout(() => { + storeNewNodeShellLinuxImage(nodeShellLinuxImage); + }, 1000); + } + + return () => { + if (timeoutHandle) { + clearTimeout(timeoutHandle); + timeoutHandle = null; + } + }; + }, [nodeShellLinuxImage]); + function isEditingDefaultNamespace() { return clusterSettings?.defaultNamespace !== userDefaultNamespace; } + function isEditingNodeShellLinuxImage() { + return clusterSettings?.nodeShellLinuxImage !== nodeShellLinuxImage; + } + function storeNewAllowedNamespace(namespace: string) { setNewAllowedNamespace(''); setClusterSettings((settings: ClusterSettings | null) => { @@ -265,6 +288,14 @@ export default function SettingsCluster() { }); } + function storeNewNodeShellLinuxImage(image: string) { + setClusterSettings((settings: ClusterSettings | null) => { + const newSettings = { ...(settings || {}) }; + newSettings.nodeShellLinuxImage = image; + return newSettings; + }); + } + const isValidDefaultNamespace = isValidNamespaceFormat(userDefaultNamespace); const isValidCurrentName = isValidClusterNameFormat(newClusterName); const isValidNewAllowedNamespace = isValidNamespaceFormat(newAllowedNamespace); @@ -504,6 +535,35 @@ export default function SettingsCluster() { ), }, + { + name: t('translation|Node Shell Linux Image'), + value: ( + { + let value = event.target.value; + value = value.replace(' ', ''); + setNodeShellLinuxImage(value); + }} + value={nodeShellLinuxImage} + placeholder={DEFAULT_NODE_SHELL_LINUX_IMAGE} + helperText={t( + 'translation|The default image is used for dropping a shell into a node (when not specified directly).' + )} + InputProps={{ + endAdornment: isEditingNodeShellLinuxImage() ? ( + + ) : ( + + ), + sx: { maxWidth: 250 }, + }} + /> + ), + }, ]} /> diff --git a/frontend/src/components/common/Terminal.tsx b/frontend/src/components/common/Terminal.tsx index 51439789a2..99708fad3b 100644 --- a/frontend/src/components/common/Terminal.tsx +++ b/frontend/src/components/common/Terminal.tsx @@ -11,6 +11,7 @@ import { Terminal as XTerminal } from '@xterm/xterm'; import _ from 'lodash'; import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; +import Node from '../../lib/k8s/node'; import Pod from '../../lib/k8s/pod'; import { Dialog } from './Dialog'; @@ -26,7 +27,8 @@ enum Channel { } interface TerminalProps extends DialogProps { - item: Pod; + item: Pod | Node; + title: string; isAttach?: boolean; onClose?: () => void; } @@ -35,15 +37,75 @@ interface XTerminalConnected { xterm: XTerminal; connected: boolean; reconnectOnEnter: boolean; + onClose?: () => void; } -type execReturn = ReturnType; +interface ContainerSelectionProps { + item: Pod; + container: string; + setContainer: (container: string) => void; +} + +function ContainerSelection(props: ContainerSelectionProps) { + const { item, container, 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 { item, onClose, isAttach, title, ...other } = props; 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({ @@ -52,8 +114,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. @@ -85,15 +164,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; + } } } @@ -104,7 +184,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) { @@ -148,12 +228,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; @@ -163,16 +241,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); } @@ -196,22 +276,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'); } } @@ -222,10 +307,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. @@ -235,7 +322,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; @@ -256,26 +343,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 => 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 => 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!); })(); @@ -288,7 +384,7 @@ export default function Terminal(props: TerminalProps) { return function cleanup() { xtermRef.current?.xterm.dispose(); - execOrAttachRef.current?.cancel(); + streamRef.current?.cancel(); window.removeEventListener('resize', handler); }; }, @@ -298,8 +394,10 @@ export default function Terminal(props: TerminalProps) { React.useEffect( () => { - if (props.open && container === null) { - setContainer(getDefaultContainer()); + if (isPod(item)) { + if (props.open && container === null) { + setContainer(getDefaultContainer()); + } } }, // eslint-disable-next-line react-hooks/exhaustive-deps @@ -307,11 +405,13 @@ export default function Terminal(props: TerminalProps) { ); React.useEffect(() => { - if (!isAttach && shells.available.length === 0) { - setShells({ - available: getAvailableShells(), - currentIdx: 0, - }); + if (isPod(item)) { + if (!isAttach && shells.available.length === 0) { + setShells({ + available: getAvailableShells(), + currentIdx: 0, + }); + } } }, [item]); @@ -326,10 +426,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) { @@ -364,7 +460,7 @@ export default function Terminal(props: TerminalProps) { return ( { setTimeout(() => { fitAddonRef.current!.fit(); @@ -372,11 +468,7 @@ export default function Terminal(props: TerminalProps) { }} keepMounted withFullScreen - title={ - isAttach - ? t('Attach: {{ itemName }}', { itemName: item.metadata.name }) - : t('Terminal: {{ itemName }}', { itemName: item.metadata.name }) - } + title={title} {...other} > - - - - {t('glossary|Container')} - - - - + {isPod(item) ? ( + + ) : ( + <> + )} ({ paddingTop: theme.spacing(1), diff --git a/frontend/src/components/node/Details.tsx b/frontend/src/components/node/Details.tsx index c650b7efa7..a883d0f261 100644 --- a/frontend/src/components/node/Details.tsx +++ b/frontend/src/components/node/Details.tsx @@ -11,6 +11,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'; @@ -22,6 +23,7 @@ import { ConditionsSection, DetailsGrid, OwnedPodsSection } from '../common/Reso import AuthVisible from '../common/Resource/AuthVisible'; import { SectionBox } from '../common/SectionBox'; import { NameValueTable } from '../common/SimpleTable'; +import Terminal from '../common/Terminal'; import { NodeTaintsLabel } from './utils'; function NodeConditionsLabel(props: { node: Node }) { @@ -47,6 +49,7 @@ export default function NodeDetails(props: { name?: string }) { 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; const [drainDialogOpen, setDrainDialogOpen] = useState(false); @@ -159,6 +162,10 @@ export default function NodeDetails(props: { name?: string }) { ); } + function isLinux(item: Node | null): boolean { + return item?.status?.nodeInfo?.operatingSystem === 'linux'; + } + function DrainDialog() { return ( <> @@ -224,6 +231,34 @@ export default function NodeDetails(props: { name?: string }) { ), }, + { + id: DefaultHeaderAction.NODE_SHELL, + action: ( + + + setShowShell(true)} + iconButtonProps={{ + disabled: !isLinux(item), + }} + /> + + + ), + }, ]; }} extraInfo={item => @@ -261,6 +296,20 @@ export default function NodeDetails(props: { name?: string }) { id: 'headlamp.node-owned-pods', section: , }, + { + id: 'headlamp.node-shell', + section: ( + { + setShowShell(false); + }} + /> + ), + }, ] } /> diff --git a/frontend/src/components/pod/Details.tsx b/frontend/src/components/pod/Details.tsx index bb9ba404fb..9f8d1ddd16 100644 --- a/frontend/src/components/pod/Details.tsx +++ b/frontend/src/components/pod/Details.tsx @@ -562,6 +562,11 @@ export default function PodDetails(props: PodDetailsProps) { section: ( { diff --git a/frontend/src/helpers/index.ts b/frontend/src/helpers/index.ts index 40780c37e9..a270874830 100644 --- a/frontend/src/helpers/index.ts +++ b/frontend/src/helpers/index.ts @@ -306,10 +306,13 @@ function getProductName(): string | undefined { return import.meta.env.REACT_APP_HEADLAMP_PRODUCT_NAME; } +export const DEFAULT_NODE_SHELL_LINUX_IMAGE = 'docker.io/library/alpine:latest'; + export interface ClusterSettings { defaultNamespace?: string; allowedNamespaces?: string[]; currentName?: string; + nodeShellLinuxImage?: 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 867d56c829..c0532147a5 100644 --- a/frontend/src/i18n/locales/de/glossary.json +++ b/frontend/src/i18n/locales/de/glossary.json @@ -100,7 +100,10 @@ "Uncordon": "Planungssperre aufheben", "Cordon": "Planungssperre setzen", "Drain": "Entleeren", + "Node Shell": "", + "Node shell is not supported in this OS: {{ nodeOS }}": "", "Pod CIDR": "CIDR des Pods", + "Shell: {{ itemName }}": "", "Uptime": "Betriebszeit", "System Info": "System-Informationen", "Architecture": "Architektur", @@ -132,6 +135,8 @@ "Pod IP": "Pod IP", "QoS Class": "QoS-Klasse", "Priority": "Priorität", + "Attach: {{ itemName }}": "", + "Terminal: {{ itemName }}": "", "Restarts": "Neustarts", "{{ restarts }} ({{ abbrevTime }} ago)": "{{ restarts }} (vor {{ abbrevTime }})", "Nominated Node": "Nominierte Node", diff --git a/frontend/src/i18n/locales/de/translation.json b/frontend/src/i18n/locales/de/translation.json index d5ae092f42..22450c47f6 100644 --- a/frontend/src/i18n/locales/de/translation.json +++ b/frontend/src/i18n/locales/de/translation.json @@ -79,6 +79,8 @@ "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.", "Add namespace": "", + "Node Shell Linux Image": "", + "The default image is used for dropping a shell into a node (when not specified directly).": "", "Remove Cluster": "Cluster entfernen", "Server": "Server", "light theme": "helles Design", @@ -272,8 +274,7 @@ "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…", - "Attach: {{ itemName }}": "Anhängen: {{ itemName }}", - "Terminal: {{ itemName }}": "Terminal: {{ itemName }}", + "Trying to open a shell": "Trying to open a shell", "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 fe69299f90..42599d7663 100644 --- a/frontend/src/i18n/locales/en/glossary.json +++ b/frontend/src/i18n/locales/en/glossary.json @@ -100,7 +100,10 @@ "Uncordon": "Uncordon", "Cordon": "Cordon", "Drain": "Drain", + "Node Shell": "Node Shell", + "Node shell is not supported in this OS: {{ nodeOS }}": "Node shell is not supported in this OS: {{ nodeOS }}", "Pod CIDR": "Pod CIDR", + "Shell: {{ itemName }}": "Shell: {{ itemName }}", "Uptime": "Uptime", "System Info": "System Info", "Architecture": "Architecture", @@ -132,6 +135,8 @@ "Pod IP": "Pod IP", "QoS Class": "QoS Class", "Priority": "Priority", + "Attach: {{ itemName }}": "Attach: {{ itemName }}", + "Terminal: {{ itemName }}": "Terminal: {{ itemName }}", "Restarts": "Restarts", "{{ restarts }} ({{ abbrevTime }} ago)": "{{ restarts }} ({{ abbrevTime }} ago)", "Nominated Node": "Nominated Node", diff --git a/frontend/src/i18n/locales/en/translation.json b/frontend/src/i18n/locales/en/translation.json index 492a71a4f2..25f5d967eb 100644 --- a/frontend/src/i18n/locales/en/translation.json +++ b/frontend/src/i18n/locales/en/translation.json @@ -79,6 +79,8 @@ "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.", "Add namespace": "Add namespace", + "Node Shell Linux Image": "Node Shell Linux 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", "Server": "Server", "light theme": "light theme", @@ -272,8 +274,7 @@ "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}}\"…", - "Attach: {{ itemName }}": "Attach: {{ itemName }}", - "Terminal: {{ itemName }}": "Terminal: {{ itemName }}", + "Trying to open a shell": "Trying to open a shell", "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 c577910684..21459c61cc 100644 --- a/frontend/src/i18n/locales/es/glossary.json +++ b/frontend/src/i18n/locales/es/glossary.json @@ -100,7 +100,10 @@ "Uncordon": "Desbloquear", "Cordon": "Bloquear", "Drain": "Drenar", + "Node Shell": "", + "Node shell is not supported in this OS: {{ nodeOS }}": "", "Pod CIDR": "Pod CIDR", + "Shell: {{ itemName }}": "", "Uptime": "Tiempo de actividad", "System Info": "Info. de Sistema", "Architecture": "Arquitectura", @@ -132,6 +135,8 @@ "Pod IP": "IP del Pod", "QoS Class": "Clase QoS", "Priority": "Prioridad", + "Attach: {{ itemName }}": "", + "Terminal: {{ itemName }}": "", "Restarts": "Reinicios", "{{ restarts }} ({{ abbrevTime }} ago)": "{{ restarts }} (hace {{ abbrevTime }})", "Nominated Node": "Nodo Nominado", diff --git a/frontend/src/i18n/locales/es/translation.json b/frontend/src/i18n/locales/es/translation.json index 76609c7387..e06022c0e8 100644 --- a/frontend/src/i18n/locales/es/translation.json +++ b/frontend/src/i18n/locales/es/translation.json @@ -79,6 +79,8 @@ "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.", "Add namespace": "", + "Node Shell Linux Image": "", + "The default image is used for dropping a shell into a node (when not specified directly).": "", "Remove Cluster": "Eliminar cluster", "Server": "Servidor", "light theme": "tema claro", @@ -273,8 +275,7 @@ "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}}\"…", - "Attach: {{ itemName }}": "Adjuntar: {{ itemName }}", - "Terminal: {{ itemName }}": "Terminal: {{ itemName }}", + "Trying to open a shell": "Trying to open a shell", "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 5016b4448a..5aa74fdc31 100644 --- a/frontend/src/i18n/locales/fr/glossary.json +++ b/frontend/src/i18n/locales/fr/glossary.json @@ -100,7 +100,10 @@ "Uncordon": "Débloquer", "Cordon": "Bloquer", "Drain": "Vider", + "Node Shell": "", + "Node shell is not supported in this OS: {{ nodeOS }}": "", "Pod CIDR": "CIDR du pod", + "Shell: {{ itemName }}": "", "Uptime": "Temps de fonctionnement", "System Info": "Informations sur le système", "Architecture": "Architecture", @@ -132,6 +135,8 @@ "Pod IP": "Pod IP", "QoS Class": "Classe QoS", "Priority": "Priorité", + "Attach: {{ itemName }}": "", + "Terminal: {{ itemName }}": "", "Restarts": "Redémarrages", "{{ restarts }} ({{ abbrevTime }} ago)": "{{ restarts }} (il y a {{ abbrevTime }})", "Nominated Node": "Nœud nommé", diff --git a/frontend/src/i18n/locales/fr/translation.json b/frontend/src/i18n/locales/fr/translation.json index 3173335774..ce4b841e56 100644 --- a/frontend/src/i18n/locales/fr/translation.json +++ b/frontend/src/i18n/locales/fr/translation.json @@ -79,6 +79,8 @@ "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.", "Add namespace": "", + "Node Shell Linux Image": "", + "The default image is used for dropping a shell into a node (when not specified directly).": "", "Remove Cluster": "Supprimer le cluster", "Server": "Serveur", "light theme": "thème clair", @@ -273,8 +275,7 @@ "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}}\"…", - "Attach: {{ itemName }}": "Attacher : {{ itemName }}", - "Terminal: {{ itemName }}": "Terminal : {{ itemName }}", + "Trying to open a shell": "Trying to open a shell", "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 fdf7e072ca..ba4bf6502d 100644 --- a/frontend/src/i18n/locales/pt/glossary.json +++ b/frontend/src/i18n/locales/pt/glossary.json @@ -100,7 +100,10 @@ "Uncordon": "Desbloquear", "Cordon": "Bloquear", "Drain": "Esvaziar", + "Node Shell": "", + "Node shell is not supported in this OS: {{ nodeOS }}": "", "Pod CIDR": "Pod CIDR", + "Shell: {{ itemName }}": "", "Uptime": "Uptime", "System Info": "Info. do Sistema", "Architecture": "Arquitectura", @@ -132,6 +135,8 @@ "Pod IP": "IP do Pod", "QoS Class": "Classe QoS", "Priority": "Prioridade", + "Attach: {{ itemName }}": "", + "Terminal: {{ itemName }}": "", "Restarts": "Reinícios", "{{ restarts }} ({{ abbrevTime }} ago)": "{{ restarts }} (há {{ abbrevTime }})", "Nominated Node": "Node Nomeado", diff --git a/frontend/src/i18n/locales/pt/translation.json b/frontend/src/i18n/locales/pt/translation.json index 4b3c9b7cb7..0b3bcc5443 100644 --- a/frontend/src/i18n/locales/pt/translation.json +++ b/frontend/src/i18n/locales/pt/translation.json @@ -79,6 +79,8 @@ "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.", "Add namespace": "", + "Node Shell Linux Image": "", + "The default image is used for dropping a shell into a node (when not specified directly).": "", "Remove Cluster": "Remover Cluster", "Server": "Servidor", "light theme": "tema claro", @@ -273,8 +275,7 @@ "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}}\"…", - "Attach: {{ itemName }}": "Anexar: {{ itemName }}", - "Terminal: {{ itemName }}": "Terminal: {{ itemName }}", + "Trying to open a shell": "Trying to open a shell", "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 e993390f9f..0b63ab268f 100644 --- a/frontend/src/lib/k8s/node.ts +++ b/frontend/src/lib/k8s/node.ts @@ -1,9 +1,12 @@ import React from 'react'; -import { useErrorState } from '../util'; +import helpers, { DEFAULT_NODE_SHELL_LINUX_IMAGE } from '../../helpers'; +import { getCluster, uniqueString, useErrorState } from '../util'; import { useConnectApi } from '.'; import { ApiError, metrics } from './apiProxy'; +import { apply, stream, StreamResultsCb } from './apiProxy'; import { KubeCondition, KubeMetrics } from './cluster'; import { KubeObject, KubeObjectInterface } from './KubeObject'; +import Pod, { KubePod } from './pod'; export interface KubeNode extends KubeObjectInterface { status: { @@ -53,6 +56,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 KubeObject { static kind = 'Node'; static apiName = 'nodes'; @@ -91,6 +130,50 @@ class Node extends KubeObject { 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.nodeShellLinuxImage ?? ''; + const podName = `node-shell-${this.getName()}-${uniqueString()}`; + if (image === '') { + image = DEFAULT_NODE_SHELL_LINUX_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 ee504b343b..79300a9074 100644 --- a/frontend/src/lib/util.ts +++ b/frontend/src/lib/util.ts @@ -448,6 +448,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/plugin/__snapshots__/pluginLib.snapshot b/frontend/src/plugin/__snapshots__/pluginLib.snapshot index 7bb73c5a37..4c4bb4c078 100644 --- a/frontend/src/plugin/__snapshots__/pluginLib.snapshot +++ b/frontend/src/plugin/__snapshots__/pluginLib.snapshot @@ -202,6 +202,7 @@ "DELETE": "DELETE", "EDIT": "EDIT", "NODE_DRAIN": "NODE_DRAIN", + "NODE_SHELL": "NODE_SHELL", "NODE_TOGGLE_CORDON": "NODE_TOGGLE_CORDON", "POD_ATTACH": "POD_ATTACH", "POD_LOGS": "POD_LOGS", @@ -16078,6 +16079,7 @@ "localeDate": [Function], "normalizeUnit": [Function], "timeAgo": [Function], + "uniqueString": [Function], "units": { "TO_GB": 1073741824, "TO_ONE_CPU": 1000000000, diff --git a/frontend/src/redux/actionButtonsSlice.ts b/frontend/src/redux/actionButtonsSlice.ts index cf96080970..40aef87d6a 100644 --- a/frontend/src/redux/actionButtonsSlice.ts +++ b/frontend/src/redux/actionButtonsSlice.ts @@ -35,6 +35,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 {