Skip to content

feat: Add Windows support to retina-shell #1617

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 5 commits into from
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,22 @@ retina-shell-image:
TAG=$(RETINA_PLATFORM_TAG) \
CONTEXT_DIR=$(REPO_ROOT)

retina-shell-image-win:
for year in $(WINDOWS_YEARS); do \
tag=$(TAG)-windows-ltsc$$year-amd64; \
echo "Building retina-shell Windows image with tag $$tag"; \
set -e ; \
$(MAKE) container-$(CONTAINER_BUILDER) \
PLATFORM=windows/amd64 \
DOCKERFILE=shell/Dockerfile.windows \
REGISTRY=$(IMAGE_REGISTRY) \
IMAGE=$(RETINA_SHELL_IMAGE) \
OS_VERSION=ltsc$$year \
VERSION=$(TAG) \
TAG=$$tag \
CONTEXT_DIR=$(REPO_ROOT); \
done

kubectl-retina-image:
echo "Building for $(PLATFORM)"
set -e ; \
Expand Down
52 changes: 51 additions & 1 deletion cli/cmd/shell.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package cmd

import (
"context"
"errors"
"fmt"
"os"
Expand All @@ -12,6 +13,7 @@ import (
v1 "k8s.io/api/core/v1"
"k8s.io/cli-runtime/pkg/genericclioptions"
"k8s.io/cli-runtime/pkg/resource"
"k8s.io/client-go/kubernetes"
cmdutil "k8s.io/kubectl/pkg/cmd/util"
"k8s.io/kubectl/pkg/scheme"
"k8s.io/kubectl/pkg/util/templates"
Expand All @@ -22,6 +24,7 @@ var (
matchVersionFlags *cmdutil.MatchVersionFlags
retinaShellImageRepo string
retinaShellImageVersion string
windowsImageTag string
mountHostFilesystem bool
allowHostFilesystemWrite bool
hostPID bool
Expand All @@ -38,6 +41,9 @@ var (

defaultTimeout = 30 * time.Second

// Default Windows image tag suffix
defaultWindowsImageTag = "windows-ltsc2022-amd64"

errMissingRequiredRetinaShellImageVersionArg = errors.New("missing required --retina-shell-image-version")
errUnsupportedResourceType = errors.New("unsupported resource type")
)
Expand All @@ -57,6 +63,10 @@ var shellCmd = &cobra.Command{
CLI flags (--retina-shell-image-repo and --retina-shell-image-version) or
environment variables (RETINA_SHELL_IMAGE_REPO and RETINA_SHELL_IMAGE_VERSION).
CLI flags take precedence over env vars.

For Windows nodes, the shell image will automatically use the Windows variant with the
specified Windows image tag suffix (--windows-image-tag). Windows support requires a
Windows node with HostProcess containers enabled.
`),

Example: templates.Examples(`
Expand All @@ -75,6 +85,9 @@ var shellCmd = &cobra.Command{
# start a shell in a node, with NET_RAW and NET_ADMIN capabilities
# (required for iptables and tcpdump)
kubectl retina shell node001 --capabilities NET_RAW,NET_ADMIN

# start a shell in a Windows node
kubectl retina shell win-node001
`),
Args: cobra.ExactArgs(1),
RunE: func(_ *cobra.Command, args []string) error {
Expand Down Expand Up @@ -106,6 +119,13 @@ var shellCmd = &cobra.Command{
return fmt.Errorf("error constructing REST config: %w", err)
}

// Create Kubernetes clientset to determine node OS
clientset, err := kubernetes.NewForConfig(restConfig)
if err != nil {
return fmt.Errorf("error creating clientset: %w", err)
}

// Create a generic config for now, we'll update image based on node OS
config := shell.Config{
RestConfig: restConfig,
RetinaShellImage: fmt.Sprintf("%s:%s", retinaShellImageRepo, retinaShellImageVersion),
Expand All @@ -123,10 +143,34 @@ var shellCmd = &cobra.Command{

switch obj := info.Object.(type) {
case *v1.Node:
podDebugNamespace := namespace
nodeName := obj.Name
podDebugNamespace := namespace

// Get the OS and update the image if it's Windows
nodeOS := obj.Labels["kubernetes.io/os"]
if nodeOS == "windows" {
// For Windows, use the Windows-specific image tag
windowsImageVersion := fmt.Sprintf("%s-%s", retinaShellImageVersion, windowsImageTag)
config.RetinaShellImage = fmt.Sprintf("%s:%s", retinaShellImageRepo, windowsImageVersion)
fmt.Printf("Using Windows shell image: %s\n", config.RetinaShellImage)
}

return shell.RunInNode(config, nodeName, podDebugNamespace)
case *v1.Pod:
// For pods, we need to get the node OS based on the pod's node
ctx := context.Background()
nodeOS, err := shell.GetNodeOS(ctx, clientset, obj.Spec.NodeName)
if err != nil {
return fmt.Errorf("error getting node OS: %w", err)
}

if nodeOS == "windows" {
// For Windows, use the Windows-specific image tag
windowsImageVersion := fmt.Sprintf("%s-%s", retinaShellImageVersion, windowsImageTag)
config.RetinaShellImage = fmt.Sprintf("%s:%s", retinaShellImageRepo, windowsImageVersion)
fmt.Printf("Using Windows shell image: %s\n", config.RetinaShellImage)
}

return shell.RunInPod(config, obj.Namespace, obj.Name)
default:
gvk := obj.GetObjectKind().GroupVersionKind()
Expand Down Expand Up @@ -154,9 +198,15 @@ func init() {
retinaShellImageVersion = envVersion
}
}
if !cmd.Flags().Changed("windows-image-tag") {
if envWindowsTag := os.Getenv("RETINA_SHELL_WINDOWS_IMAGE_TAG"); envWindowsTag != "" {
windowsImageTag = envWindowsTag
}
}
}
shellCmd.Flags().StringVar(&retinaShellImageRepo, "retina-shell-image-repo", defaultRetinaShellImageRepo, "The container registry repository for the image to use for the shell container")
shellCmd.Flags().StringVar(&retinaShellImageVersion, "retina-shell-image-version", defaultRetinaShellImageVersion, "The version (tag) of the image to use for the shell container")
shellCmd.Flags().StringVar(&windowsImageTag, "windows-image-tag", defaultWindowsImageTag, "The tag suffix to use for Windows shell images (e.g., 'windows-ltsc2022-amd64')")
shellCmd.Flags().BoolVarP(&mountHostFilesystem, "mount-host-filesystem", "m", false, "Mount the host filesystem to /host. Applies only to nodes, not pods.")
shellCmd.Flags().BoolVarP(&allowHostFilesystemWrite, "allow-host-filesystem-write", "w", false,
"Allow write access to the host filesystem. Implies --mount-host-filesystem. Applies only to nodes, not pods.")
Expand Down
52 changes: 52 additions & 0 deletions shell/Dockerfile.windows
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
ARG WINDOWS_VERSION=ltsc2022

# Using a smaller Windows Server Core base image
FROM mcr.microsoft.com/windows/servercore:${WINDOWS_VERSION}

SHELL ["powershell", "-Command", "$ErrorActionPreference = 'Stop'; $ProgressPreference = 'SilentlyContinue';"]

# Install common network troubleshooting tools
# - Netstat, ping, ipconfig, etc. are built into Windows
# - NirSoft WinDump (tcpdump equivalent for Windows)
# - Portqry - port scanner

# Download and install NirSoft WinDump
RUN mkdir -p C:\tools; \
Invoke-WebRequest -Uri "https://www.nirsoft.net/utils/windump_setup.exe" -OutFile "C:\tools\windump_setup.exe" -UseBasicParsing; \
Start-Process -FilePath "C:\tools\windump_setup.exe" -ArgumentList "/S" -Wait; \
Remove-Item -Path "C:\tools\windump_setup.exe" -Force

# Download and install PortQry
RUN Invoke-WebRequest -Uri "https://download.microsoft.com/download/3/b/5/3b51a025-7522-4686-87a6-47aa8ea68141/PortQryV2.exe" -OutFile "C:\Windows\System32\PortQry.exe" -UseBasicParsing

# Download and extract Nmap network scanner
RUN Invoke-WebRequest -Uri "https://nmap.org/dist/nmap-7.92-win32.zip" -OutFile "C:\tools\nmap.zip" -UseBasicParsing; \
Expand-Archive -Path "C:\tools\nmap.zip" -DestinationPath "C:\tools" -Force; \
Move-Item -Path "C:\tools\nmap-*\*" -Destination "C:\tools\" -Force; \
$env:PATH = "C:\tools;$env:PATH"

# Create a convenience script to display available tools
RUN Set-Content -Path "C:\tools\show-tools.cmd" -Value @"
@echo off
echo.
echo Available Network Troubleshooting Tools:
echo - ipconfig : Show network configuration
echo - netstat : Show network connections
echo - ping : Test connectivity
echo - tracert : Trace route
echo - nslookup : DNS lookup
echo - route : Show/manipulate routing table
echo - netsh : Network shell for configuration
echo - nmap : Network discovery and security auditing
echo - portqry : Port scanner
echo - windump : Packet analyzer (tcpdump for Windows)
echo.
"@

# Set PATH to include tools
ENV PATH="C:\tools;C:\Windows\System32;C:\Windows;C:\Windows\System32\WindowsPowerShell\v1.0"

WORKDIR C:\

# Default entry point
CMD ["cmd.exe"]
30 changes: 27 additions & 3 deletions shell/manifests.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ func hostNetworkPodForNodeDebug(config Config, debugPodNamespace, nodeName strin
RestartPolicy: v1.RestartPolicyNever,
Tolerations: []v1.Toleration{{Operator: v1.TolerationOpExists}},
HostNetwork: true,
HostPID: config.HostPID,
HostPID: config.HostPID && config.NodeOS != "windows", // HostPID is not applicable for Windows
Containers: []v1.Container{
{
Name: "retina-shell",
Expand All @@ -53,18 +53,42 @@ func hostNetworkPodForNodeDebug(config Config, debugPodNamespace, nodeName strin
},
}

// Add Windows specific settings if needed
if config.NodeOS == "windows" {
// Use hostProcess container for Windows (equivalent to privileged on Linux)
trueValue := true
falseValue := false
runAsUserName := "NT AUTHORITY\\SYSTEM"
pod.Spec.SecurityContext = &v1.PodSecurityContext{
WindowsOptions: &v1.WindowsSecurityContextOptions{
HostProcess: &trueValue,
RunAsUserName: &runAsUserName,
},
RunAsNonRoot: &falseValue,
}
}

// Mount host filesystem if requested (different paths for Windows vs Linux)
if config.MountHostFilesystem || config.AllowHostFilesystemWrite {
hostPath := "/"
mountPath := "/host"

if config.NodeOS == "windows" {
hostPath = "C:\\"
mountPath = "C:\\host"
}

pod.Spec.Volumes = append(pod.Spec.Volumes, v1.Volume{
Name: "host-filesystem",
VolumeSource: v1.VolumeSource{
HostPath: &v1.HostPathVolumeSource{
Path: "/",
Path: hostPath,
},
},
})
pod.Spec.Containers[0].VolumeMounts = append(pod.Spec.Containers[0].VolumeMounts, v1.VolumeMount{
Name: "host-filesystem",
MountPath: "/host",
MountPath: mountPath,
ReadOnly: !config.AllowHostFilesystemWrite,
})
}
Expand Down
49 changes: 49 additions & 0 deletions shell/manifests_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
)

const testRetinaImage = "retina-shell:v0.0.1"
const testRetinaWindowsImage = "retina-shell:v0.0.1-windows-ltsc2022-amd64"

func TestEphemeralContainerForPodDebug(t *testing.T) {
ec := ephemeralContainerForPodDebug(Config{RetinaShellImage: testRetinaImage})
Expand Down Expand Up @@ -89,3 +90,51 @@ func TestHostNetworkPodForNodeDebugWithMountHostFilesystemWithWriteAccess(t *tes
assert.Equal(t, "/host", pod.Spec.Containers[0].VolumeMounts[0].MountPath)
assert.False(t, pod.Spec.Containers[0].VolumeMounts[0].ReadOnly)
}

// Test Windows node support
func TestHostNetworkPodForWindowsNodeDebug(t *testing.T) {
config := Config{
RetinaShellImage: testRetinaWindowsImage,
NodeOS: "windows",
}
pod := hostNetworkPodForNodeDebug(config, "kube-system", "win-node0001")

// Verify Windows specific configurations
assert.NotNil(t, pod.Spec.SecurityContext)
assert.NotNil(t, pod.Spec.SecurityContext.WindowsOptions)
assert.NotNil(t, pod.Spec.SecurityContext.WindowsOptions.HostProcess)
assert.True(t, *pod.Spec.SecurityContext.WindowsOptions.HostProcess)
assert.Equal(t, "NT AUTHORITY\\SYSTEM", *pod.Spec.SecurityContext.WindowsOptions.RunAsUserName)

// HostPID should be false for Windows regardless of setting
assert.False(t, pod.Spec.HostPID)
}

func TestHostNetworkPodForWindowsNodeDebugWithMountHostFilesystem(t *testing.T) {
config := Config{
RetinaShellImage: testRetinaWindowsImage,
NodeOS: "windows",
MountHostFilesystem: true,
}
pod := hostNetworkPodForNodeDebug(config, "kube-system", "win-node0001")

assert.Len(t, pod.Spec.Volumes, 1)
assert.Equal(t, "host-filesystem", pod.Spec.Volumes[0].Name)
assert.Equal(t, "C:\\", pod.Spec.Volumes[0].VolumeSource.HostPath.Path)

assert.Len(t, pod.Spec.Containers[0].VolumeMounts, 1)
assert.Equal(t, "host-filesystem", pod.Spec.Containers[0].VolumeMounts[0].Name)
assert.Equal(t, "C:\\host", pod.Spec.Containers[0].VolumeMounts[0].MountPath)
}

func TestHostNetworkPodForWindowsNodeWithHostPID(t *testing.T) {
config := Config{
RetinaShellImage: testRetinaWindowsImage,
NodeOS: "windows",
HostPID: true, // Should be ignored for Windows
}
pod := hostNetworkPodForNodeDebug(config, "kube-system", "win-node0001")

// HostPID should be false for Windows regardless of the setting
assert.False(t, pod.Spec.HostPID)
}
15 changes: 15 additions & 0 deletions shell/shell.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ type Config struct {
HostPID bool
Capabilities []string
Timeout time.Duration
NodeOS string // "linux" or "windows"

// Host filesystem access applies only to nodes, not pods.
MountHostFilesystem bool
Expand Down Expand Up @@ -45,6 +46,13 @@ func RunInPod(config Config, podNamespace, podName string) error {
return fmt.Errorf("error validating operating system for node %s: %w", pod.Spec.NodeName, err)
}

// Get the node OS for the pod's node
nodeOS, err := GetNodeOS(ctx, clientset, pod.Spec.NodeName)
if err != nil {
return fmt.Errorf("error getting OS for node %s: %w", pod.Spec.NodeName, err)
}
config.NodeOS = nodeOS

fmt.Printf("Starting ephemeral container in pod %s/%s\n", podNamespace, podName)
ephemeralContainer := ephemeralContainerForPodDebug(config)
pod.Spec.EphemeralContainers = append(pod.Spec.EphemeralContainers, ephemeralContainer)
Expand Down Expand Up @@ -77,6 +85,13 @@ func RunInNode(config Config, nodeName, debugPodNamespace string) error {
return fmt.Errorf("error validating operating system for node %s: %w", nodeName, err)
}

// Get the node OS
nodeOS, err := GetNodeOS(ctx, clientset, nodeName)
if err != nil {
return fmt.Errorf("error getting OS for node %s: %w", nodeName, err)
}
config.NodeOS = nodeOS

pod := hostNetworkPodForNodeDebug(config, debugPodNamespace, nodeName)

fmt.Printf("Starting host networking pod %s/%s on node %s\n", debugPodNamespace, pod.Name, nodeName)
Expand Down
20 changes: 15 additions & 5 deletions shell/validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,28 @@ import (
"k8s.io/client-go/kubernetes"
)

var errUnsupportedOperatingSystem = errors.New("unsupported OS (retina-shell requires Linux)")
var errUnsupportedOperatingSystem = errors.New("unsupported OS (retina-shell requires Linux or Windows)")

func validateOperatingSystemSupportedForNode(ctx context.Context, clientset *kubernetes.Clientset, nodeName string) error {
// GetNodeOS retrieves the operating system of a node from its labels
func GetNodeOS(ctx context.Context, clientset kubernetes.Interface, nodeName string) (string, error) {
node, err := clientset.CoreV1().
Nodes().
Get(ctx, nodeName, metav1.GetOptions{})
if err != nil {
return fmt.Errorf("error retrieving node %s: %w", nodeName, err)
return "", fmt.Errorf("error retrieving node %s: %w", nodeName, err)
}

osLabel := node.Labels["kubernetes.io/os"]
if osLabel != "linux" { // Only Linux supported for now.
return node.Labels["kubernetes.io/os"], nil
}

func validateOperatingSystemSupportedForNode(ctx context.Context, clientset kubernetes.Interface, nodeName string) error {
os, err := GetNodeOS(ctx, clientset, nodeName)
if err != nil {
return err
}

// Support both Linux and Windows
if os != "linux" && os != "windows" {
return errUnsupportedOperatingSystem
}

Expand Down
Loading