Skip to content

Commit

Permalink
node-shell: Control namespace and allow to disable node-shell
Browse files Browse the repository at this point in the history
Signed-off-by: farodin91 <[email protected]>
  • Loading branch information
farodin91 committed Dec 29, 2024
1 parent 637e6b2 commit d8eaac9
Show file tree
Hide file tree
Showing 5 changed files with 112 additions and 44 deletions.
18 changes: 14 additions & 4 deletions node-shell/src/components/NodeShellAction.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,27 @@

import { ActionButton } from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import { Node } from '@kinvolk/headlamp-plugin/lib';
import Node from '@kinvolk/headlamp-plugin/lib/K8s/node';
import { getCluster } from '@kinvolk/headlamp-plugin/lib/Utils';
import { useState } from 'react';
import { isEnabled } from '../util';
import { NodeShellTerminal } from './NodeShellTerminal';

export function NodeShellAction({ item }) {
const [showShell, setShowShell] = useState(false);
const cluster = getCluster();
function isLinux(item: Node | null): boolean {
return item?.status?.nodeInfo?.operatingSystem === 'linux';
}
if (!isEnabled(cluster)) {
return <></>;
}
return (
<>
<ActionButton
description={isLinux(item) ? 'Node Shell' : `Node shell is not supported in this OS: ${item?.status?.nodeInfo?.operatingSystem}`}
description={
isLinux(item)
? 'Node Shell'
: `Node shell is not supported in this OS: ${item?.status?.nodeInfo?.operatingSystem}`
}
icon="mdi:console"
onClick={() => setShowShell(true)}
iconButtonProps={{
Expand All @@ -28,5 +37,6 @@ export function NodeShellAction({ item }) {
setShowShell(false);
}}
/>
</>)
</>
);
}
51 changes: 25 additions & 26 deletions node-shell/src/components/NodeShellTerminal.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@

import { apply, stream, StreamResultsCb } from '@kinvolk/headlamp-plugin/lib/ApiProxy';
import { Dialog, DialogProps } from '@kinvolk/headlamp-plugin/lib/CommonComponents';
import DialogContent from '@mui/material/DialogContent';
import Node from '@kinvolk/headlamp-plugin/lib/K8s/node';
import Pod, { KubePod } from '@kinvolk/headlamp-plugin/lib/K8s/pod';
import { getCluster } from '@kinvolk/headlamp-plugin/lib/Utils';
import { Box } from '@mui/material';
import { Node } from '@kinvolk/headlamp-plugin/lib';
import { useEffect, useRef, useState } from 'react';
import { Terminal as XTerminal } from '@xterm/xterm';
import DialogContent from '@mui/material/DialogContent';
import { FitAddon } from '@xterm/addon-fit';
import Pod, { KubePod } from '@kinvolk/headlamp-plugin/lib/lib/k8s/pod';
import { apply, stream, StreamResultsCb } from '@kinvolk/headlamp-plugin/lib/ApiProxy';
import { DEFAULT_NODE_SHELL_LINUX_IMAGE } from './Settings';
import { Terminal as XTerminal } from '@xterm/xterm';
import _ from 'lodash';
import { useEffect, useRef, useState } from 'react';
import { getClusterConfig } from '../util';
import { getCluster } from '@kinvolk/headlamp-plugin/lib/Utils';
import { DEFAULT_NODE_SHELL_LINUX_IMAGE, DEFAULT_NODE_SHELL_NAMESPACE } from './Settings';

const decoder = new TextDecoder('utf-8');
const encoder = new TextEncoder();
Expand All @@ -26,7 +26,7 @@ enum Channel {
interface NodeShellTerminalProps extends DialogProps {
item: Node;
title: string;
open: boolean
open: boolean;
onClose?: () => void;
}

Expand All @@ -37,14 +37,13 @@ interface XTerminalConnected {
onClose?: () => void;
}


const shellPod = (name: string, nodeName: string, nodeShellImage: string) => {
const shellPod = (name: string, namespace: string, nodeName: string, nodeShellImage: string) => {
return {
kind: 'Pod',
apiVersion: 'v1',
metadata: {
name,
namespace: 'kube-system',
namespace,
},
spec: {
nodeName,
Expand Down Expand Up @@ -87,13 +86,17 @@ async function shell(item: Node, onExec: StreamResultsCb) {
}

//const clusterSettings = helpers.loadClusterSettings(cluster);
const config = getClusterConfig(cluster)
const config = getClusterConfig(cluster);
let image = config?.image || '';
let namespace = config?.namespace || '';
const podName = `node-shell-${item.getName()}-${uniqueString()}`;
if (image === '') {
image = DEFAULT_NODE_SHELL_LINUX_IMAGE;
}
const kubePod = shellPod(podName, item.getName(), image!!);
if (namespace === '') {
namespace = DEFAULT_NODE_SHELL_NAMESPACE;
}
const kubePod = shellPod(podName, namespace, item.getName(), image!!);
try {
await apply(kubePod);
} catch (e) {
Expand All @@ -110,8 +113,9 @@ async function shell(item: Node, onExec: StreamResultsCb) {
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 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',
Expand All @@ -135,7 +139,6 @@ export function NodeShellTerminal(props: NodeShellTerminalProps) {
const fitAddonRef = useRef<FitAddon | null>(null);
const streamRef = useRef<any | null>(null);


const wrappedOnClose = () => {
if (!!onClose) {
onClose();
Expand All @@ -146,7 +149,6 @@ export function NodeShellTerminal(props: NodeShellTerminalProps) {
}
};


// @todo: Give the real exec type when we have it.
function setupTerminal(containerRef: HTMLElement, xterm: XTerminal, fitAddon: FitAddon) {
if (!containerRef) {
Expand Down Expand Up @@ -198,7 +200,6 @@ export function NodeShellTerminal(props: NodeShellTerminalProps) {
socket.send(buffer);
}


function onData(xtermc: XTerminalConnected, bytes: ArrayBuffer) {
const xterm = xtermc.xterm;
// Only show data from stdout, stderr and server error channel.
Expand All @@ -210,7 +211,7 @@ export function NodeShellTerminal(props: NodeShellTerminalProps) {
// The first byte is discarded because it just identifies whether
// this data is from stderr, stdout, or stdin.
const data = bytes.slice(1);
let text = decoder.decode(data);
const text = decoder.decode(data);

// Send resize command to server once connection is establised.
if (!xtermc.connected) {
Expand Down Expand Up @@ -250,7 +251,7 @@ export function NodeShellTerminal(props: NodeShellTerminalProps) {
if (_.isEmpty(error.metadata) && error.status === 'Success') {
return true;
}
} catch { }
} catch {}
}
return false;
}
Expand All @@ -263,7 +264,7 @@ export function NodeShellTerminal(props: NodeShellTerminalProps) {
if (error.code === 500 && error.status === 'Failure' && error.reason === 'InternalError') {
return true;
}
} catch { }
} catch {}
}
// Windows container Error
if (channel === 1) {
Expand All @@ -274,7 +275,6 @@ export function NodeShellTerminal(props: NodeShellTerminalProps) {
return false;
}


function shellConnectFailed(xtermc: XTerminalConnected) {
const xterm = xtermc.xterm;
xterm.clear();
Expand Down Expand Up @@ -354,7 +354,6 @@ export function NodeShellTerminal(props: NodeShellTerminalProps) {
title={title}
{...other}
>

<DialogContent
sx={theme => ({
height: '100%',
Expand Down Expand Up @@ -393,5 +392,5 @@ export function NodeShellTerminal(props: NodeShellTerminalProps) {
</Box>
</DialogContent>
</Dialog>
)
);
}
55 changes: 50 additions & 5 deletions node-shell/src/components/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,30 +3,34 @@ import { useClustersConf } from '@kinvolk/headlamp-plugin/lib/k8s';
import Box from '@mui/material/Box';
import MenuItem from '@mui/material/MenuItem';
import Select from '@mui/material/Select';
import Switch from '@mui/material/Switch';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import { useEffect, useState } from 'react';

export const DEFAULT_NODE_SHELL_LINUX_IMAGE = 'docker.io/library/alpine:latest'
export const DEFAULT_NODE_SHELL_LINUX_IMAGE = 'docker.io/library/alpine:latest';
export const DEFAULT_NODE_SHELL_NAMESPACE = 'kube-system';

/**
* Props for the Settings component.
* @interface SettingsProps
* @property {Object.<string, {isMetricsEnabled?: boolean, autoDetect?: boolean, address?: string, defaultTimespan?: string}>} data - Configuration data for each cluster
* @property {Object.<string, {isEnabled?: boolean, namespace?: string, image?: string}>} data - Configuration data for each cluster
* @property {Function} onDataChange - Callback function when data changes
*/
interface SettingsProps {
data: Record<
string,
{
image?: string;
namespace?: string;
isEnabled?: boolean;
}
>;
onDataChange: (newData: SettingsProps['data']) => void;
}

/**
* Settings component for configuring Prometheus metrics.
* Settings component for configuring Node-Shell Action.
*/
export function Settings(props: SettingsProps) {
const { data, onDataChange } = props;
Expand All @@ -44,14 +48,33 @@ export function Settings(props: SettingsProps) {
if (selectedCluster && !data?.[selectedCluster]) {
onDataChange({
...data,
[selectedCluster]: { image: DEFAULT_NODE_SHELL_LINUX_IMAGE},
[selectedCluster]: { image: DEFAULT_NODE_SHELL_LINUX_IMAGE },
});
}
}, [selectedCluster, data, onDataChange]);

const selectedClusterData = data?.[selectedCluster] || {};
const isEnabled = selectedClusterData.isEnabled ?? true;

const settingsRows = [
{
name: 'Enable Node Shell',
value: (
<Switch
checked={isEnabled}
onChange={e => {
const newEnabled = e.target.checked;
onDataChange({
...(data || {}),
[selectedCluster]: {
...((data || {})[selectedCluster] || {}),
isEnabled: newEnabled,
},
});
}}
/>
),
},
{
name: 'Node Shell Linux Image',
value: (
Expand All @@ -68,7 +91,29 @@ export function Settings(props: SettingsProps) {
});
}}
placeholder={DEFAULT_NODE_SHELL_LINUX_IMAGE}
helperText={'The default image is used for dropping a shell into a node (when not specified directly).'}
helperText={
'The default image is used for dropping a shell into a node (when not specified directly).'
}
/>
),
},
{
name: 'Namespace',
value: (
<TextField
value={selectedClusterData.namespace || ''}
onChange={e => {
const newNamespace = e.target.value;
onDataChange({
...(data || {}),
[selectedCluster]: {
...((data || {})[selectedCluster] || {}),
namespace: newNamespace,
},
});
}}
placeholder={DEFAULT_NODE_SHELL_NAMESPACE}
helperText={'The default namespace is kube-system.'}
/>
),
},
Expand Down
9 changes: 6 additions & 3 deletions node-shell/src/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { registerDetailsViewHeaderActionsProcessor, registerPluginSettings} from '@kinvolk/headlamp-plugin/lib';
import {
registerDetailsViewHeaderActionsProcessor,
registerPluginSettings,
} from '@kinvolk/headlamp-plugin/lib';
import { NodeShellAction } from './components/NodeShellAction';
import { Settings } from './components/Settings';

Expand All @@ -10,8 +13,8 @@ registerDetailsViewHeaderActionsProcessor((resource, actions) => {
return actions;
}

if (resource.kind !== "Node") {
return actions
if (resource.kind !== 'Node') {
return actions;
}

actions.splice(0, 0, {
Expand Down
23 changes: 17 additions & 6 deletions node-shell/src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,36 @@ export const PLUGIN_NAME = 'node-shell';

/**
* ClusterData type represents the configuration data for a cluster.
* @property {boolean} autoDetect - Whether to auto-detect Prometheus metrics.
* @property {boolean} isMetricsEnabled - Whether metrics are enabled for the cluster.
* @property {string} address - The address of the Prometheus service.
* @property {string} defaultTimespan - The default timespan for metrics.
* @property {boolean} isEnabled - Whether node-shell is enabled for the cluster.
* @property {string} image - Image to create the node shell.
* @property {string} namespace - The namespace to spawn the pod to create a node shell.
*/
type ClusterData = {
image?: string;
namespace?: string;
isEnabled?: boolean;
};

/**
* Conf type represents the configuration data for the prometheus plugin.
* Conf type represents the configuration data for the node-shell plugin.
* @property {[cluster: string]: ClusterData} - The configuration data for each cluster.
*/
type Conf = {
[cluster: string]: ClusterData;
};

/**
* getConfigStore returns the config store for the prometheus plugin.
* isEnabled checks if node-shell is enabled for a specific cluster.
* @param {string} cluster - The name of the cluster.
* @returns {boolean} True or null if node-shell is enabled, false otherwise.
*/
export function isEnabled(cluster: string): boolean {
const clusterData = getClusterConfig(cluster);
return clusterData?.isEnabled ?? true;
}

/**
* getConfigStore returns the config store for the node-shell plugin.
* @returns {ConfigStore<Conf>} The config store.
*/
export function getConfigStore(): ConfigStore<Conf> {
Expand Down

0 comments on commit d8eaac9

Please sign in to comment.