@kinvolk/headlamp-plugin
Version:
The needed infrastructure for building Headlamp plugins.
348 lines (347 loc) • 12.9 kB
JavaScript
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 { FitAddon } from '@xterm/addon-fit';
import { Terminal as XTerminal } from '@xterm/xterm';
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, stream } from '../../lib/k8s/apiProxy';
import Pod from '../../lib/k8s/pod';
import { Dialog } from '../common/Dialog';
const decoder = new TextDecoder('utf-8');
const encoder = new TextEncoder();
var Channel;
(function (Channel) {
Channel[Channel["StdIn"] = 0] = "StdIn";
Channel[Channel["StdOut"] = 1] = "StdOut";
Channel[Channel["StdErr"] = 2] = "StdErr";
Channel[Channel["ServerError"] = 3] = "ServerError";
Channel[Channel["Resize"] = 4] = "Resize";
})(Channel || (Channel = {}));
const shellPod = (name, namespace, nodeName, nodeShellImage) => {
return {
kind: 'Pod',
apiVersion: 'v1',
metadata: {
name,
namespace,
},
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'],
},
],
},
};
};
function uniqueString() {
const timestamp = Date.now().toString(36);
const randomNum = Math.random().toString(36).substring(2, 5);
return `${timestamp}-${randomNum}`;
}
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-shell-${item.getName()}-${uniqueString()}`;
const kubePod = shellPod(podName, namespace, item.getName(), linuxImage);
try {
await apply(kubePod);
}
catch (e) {
console.error('Error:NodeShell: creating pod', e);
return {};
}
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/${namespace}/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 = async () => {
const pod = new Pod(kubePod);
try {
await pod.delete();
}
catch (e) {
console.error('Error:NodeShell: deleting pod', e);
return {};
}
};
return {
stream: stream(url, onExec, { additionalProtocols, isJson: false }),
onClose: onClose,
};
}
export function NodeShellTerminal(props) {
const { item, onClose, title, ...other } = props;
const [terminalContainerRef, setTerminalContainerRef] = useState(null);
const xtermRef = useRef(null);
const fitAddonRef = useRef(null);
const streamRef = useRef(null);
const wrappedOnClose = () => {
if (!!onClose) {
onClose();
}
if (!!xtermRef.current?.onClose) {
xtermRef.current?.onClose();
}
};
// @todo: Give the real exec type when we have it.
function setupTerminal(containerRef, xterm, fitAddon) {
if (!containerRef) {
return;
}
xterm.open(containerRef);
let lastKeyPressEvent = null;
xterm.onData(data => {
let dataToSend = data;
// On MacOS with a German layout, the Alt+7 should yield a | character, but
// the onData event doesn't get it. So we need to add a custom key handler.
// No need to check for the actual platform because the key patterns should
// be good enough.
if (data === '\u001b7' &&
lastKeyPressEvent?.key === '|' &&
lastKeyPressEvent.code === 'Digit7') {
dataToSend = '|';
}
send(0, dataToSend);
});
xterm.onResize(size => {
send(4, `{"Width":${size.cols},"Height":${size.rows}}`);
});
// Allow copy/paste in terminal
xterm.attachCustomKeyEventHandler(arg => {
if (arg.type === 'keydown') {
lastKeyPressEvent = arg;
}
else {
lastKeyPressEvent = null;
}
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 send(channel, data) {
const socket = streamRef.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);
}
function onData(xtermc, bytes) {
const xterm = xtermc.xterm;
// Only show data from stdout, stderr and server error channel.
const 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 (streamRef.current) {
streamRef.current?.cancel();
}
return;
}
if (isShellNotFoundError(channel, text)) {
shellConnectFailed(xtermc);
return;
}
xterm.write(text);
}
function isSuccessfulExitError(channel, text) {
// 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, text) {
// 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) {
const xterm = xtermc.xterm;
xterm.clear();
xterm.write('Failed to connect…\r\n');
}
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();
streamRef.current?.cancel();
}
xtermRef.current = {
xterm: new XTerminal({
cursorBlink: true,
cursorStyle: 'underline',
scrollback: 10000,
rows: 30, // initial rows before fit
windowsMode: false,
allowProposedApi: true,
}),
connected: false,
reconnectOnEnter: false,
};
fitAddonRef.current = new FitAddon();
xtermRef.current.xterm.loadAddon(fitAddonRef.current);
(async function () {
xtermRef?.current?.xterm.writeln('Trying to open a shell');
const { stream, onClose } = await shell(item, (items) => onData(xtermRef.current, items));
streamRef.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();
streamRef.current?.cancel();
window.removeEventListener('resize', handler);
};
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[terminalContainerRef, props.open]);
return (_jsx(Dialog, { onClose: wrappedOnClose, onFullScreenToggled: () => {
setTimeout(() => {
fitAddonRef.current.fit();
}, 1);
}, keepMounted: true, withFullScreen: true, title: title, ...other, children: _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' } }) }) }) }));
}