UNPKG

@kinvolk/headlamp-plugin

Version:

The needed infrastructure for building Headlamp plugins.

258 lines (257 loc) 9.14 kB
import { jsx as _jsx } from "react/jsx-runtime"; /* * Copyright 2025 The Kubernetes Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import Box from '@mui/material/Box'; import DialogContent from '@mui/material/DialogContent'; import _ from 'lodash'; import { useEffect, useRef, useState } from 'react'; import { DEFAULT_NODE_SHELL_LINUX_IMAGE, DEFAULT_NODE_SHELL_NAMESPACE, loadClusterSettings, } from '../../helpers/clusterSettings'; import { getCluster } from '../../lib/cluster'; import { apply } from '../../lib/k8s/api/v1/apply'; import { stream } from '../../lib/k8s/api/v1/streamingApi'; import { Channel, useTerminalStream } from '../../lib/k8s/useTerminalStream'; const shellPod = (name, namespace, nodeName, nodeShellImage) => { return { kind: 'Pod', apiVersion: 'v1', metadata: { name, namespace, }, spec: { nodeName, restartPolicy: 'Never', terminationGracePeriodSeconds: 30, hostPID: true, hostIPC: true, hostNetwork: true, tolerations: [ { operator: 'Exists', }, ], containers: [ { name: 'debugger', image: nodeShellImage, terminationMessagePolicy: 'File', tty: true, stdin: true, stdinOnce: true, volumeMounts: [ { mountPath: '/host', name: 'host-root', }, ], }, ], volumes: [ { name: 'host-root', hostPath: { path: '/', type: 'Directory', }, }, ], }, }; }; function uniqueString() { const alphabet = '23456789abcdefghjkmnpqrstuvwxyz'; let res = ''; for (let i = 0; i < 5; i++) { const idx = Math.floor(Math.random() * alphabet.length); res += alphabet[idx]; } return res; } async function shell(item, onExec) { const cluster = getCluster(); if (!cluster) { return {}; } const clusterSettings = loadClusterSettings(cluster); const config = clusterSettings.nodeShellTerminal; const linuxImage = config?.linuxImage || DEFAULT_NODE_SHELL_LINUX_IMAGE; const namespace = config?.namespace || DEFAULT_NODE_SHELL_NAMESPACE; const podName = `node-debugger-${item.getName()}-${uniqueString()}`; const kubePod = shellPod(podName, namespace, item.getName(), linuxImage); try { await apply(kubePod); } catch (e) { console.error('Error:DebugNode: creating pod', e); return {}; } const tty = true; const stdin = true; const stdout = true; const stderr = true; const url = `/api/v1/namespaces/${namespace}/pods/${podName}/attach?container=debugger&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', ]; return { stream: stream(url, onExec, { additionalProtocols, isJson: false }), }; } export function NodeShellTerminal(props) { const { item, onClose } = props; const [terminalContainerRef, setTerminalContainerRef] = useState(null); const exitSentRef = useRef(false); const pendingExitRef = useRef(false); const { xtermRef, streamRef, send } = useTerminalStream({ containerRef: terminalContainerRef, connectStream: async (onDataCallback) => { xtermRef.current?.xterm.writeln('Trying to open a shell'); const { stream } = await shell(item, onDataCallback); return { stream, }; }, onClose: wrappedOnClose, errorHandlers: { isSuccessfulExit: isSuccessfulExitError, isShellNotFound: isShellNotFoundError, onConnectionFailed: shellConnectFailed, }, }); const sendExitIfPossible = () => { if (exitSentRef.current) { return true; } const socket = streamRef.current?.getSocket(); if (!socket || socket.readyState !== WebSocket.OPEN) { return false; } send(Channel.StdIn, 'exit\r'); exitSentRef.current = true; pendingExitRef.current = false; setTimeout(() => streamRef.current?.cancel(), 1000); return true; }; const requestShellExit = (reason) => { if (exitSentRef.current) { return; } const sent = sendExitIfPossible(); if (!sent) { console.debug('Queueing exit for shell (not yet connected)', { reason }); pendingExitRef.current = true; } else { console.debug('Exit command sent to shell', { reason }); } }; function wrappedOnClose() { requestShellExit('dialog-close'); if (onClose) { onClose(); } } function isSuccessfulExitError(channel, text) { // Linux container Error if (channel === Channel.ServerError) { try { const error = JSON.parse(text); if (_.isEmpty(error.metadata) && error.status === 'Success') { if (pendingExitRef.current && !exitSentRef.current) { sendExitIfPossible(); } return true; } } catch (e) { console.debug('NodeShellTerminal: failed to parse server error channel data', { channel, text, error: e, }); } } return false; } function isShellNotFoundError(channel, text) { // Linux container Error if (channel === Channel.ServerError) { try { const error = JSON.parse(text); if (error.code === 500 && error.status === 'Failure' && error.reason === 'InternalError') { return true; } } catch (e) { console.debug('NodeShellTerminal: failed to parse server error channel data', { channel, text, error: e, }); } } // Windows container Error if (channel === Channel.StdOut) { if (text.includes('The system cannot find the file specified')) { return true; } } return false; } function shellConnectFailed(xtermc) { const xterm = xtermc.xterm; xterm.clear(); xterm.write('Failed to connect…\r\n'); } useEffect(() => { const handleBeforeUnload = () => { requestShellExit('window-beforeunload'); }; window.addEventListener('beforeunload', handleBeforeUnload); return () => { window.removeEventListener('beforeunload', handleBeforeUnload); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return (_jsx(DialogContent, { sx: theme => ({ 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: theme.spacing(1), }, }, }), children: _jsx(Box, { sx: theme => ({ paddingTop: theme.spacing(1), flex: 1, width: '100%', overflow: 'hidden', display: 'flex', flexDirection: 'column-reverse', }), children: _jsx("div", { id: "xterm-container", ref: x => setTerminalContainerRef(x), style: { flex: 1, display: 'flex', flexDirection: 'column-reverse' } }) }) })); }