Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
19 changes: 19 additions & 0 deletions internal/kube/nodeops_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package kube

import (
"slices"
"strings"
"testing"

corev1 "k8s.io/api/core/v1"
Expand Down Expand Up @@ -63,6 +64,24 @@ func TestPodKeysSorted(t *testing.T) {
}
}

func TestNodeShellCommand(t *testing.T) {
normal := nodeShellCommand("Amazon Linux 2023")
if !slices.Contains(normal, "--mount") {
t.Fatal("normal host shell must enter the host mount namespace")
}
if normal[len(normal)-1] == "" || !strings.Contains(normal[len(normal)-1], "bash") {
t.Fatalf("normal host shell should prefer bash: %v", normal)
}

br := nodeShellCommand("Bottlerocket OS 1.61.0 (aws-k8s-1.35)")
if slices.Contains(br, "--mount") {
t.Fatal("Bottlerocket shell must NOT enter the host mount namespace (brush jail)")
}
if !strings.Contains(br[len(br)-1], "/proc/1/root") {
t.Fatalf("Bottlerocket shell should start in the host fs at /proc/1/root: %v", br)
}
}

func TestNodeShellPodSpec(t *testing.T) {
pod := nodeShellPod("worker-1")
if pod.Spec.NodeName != "worker-1" {
Expand Down
41 changes: 31 additions & 10 deletions internal/kube/nodeshell.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package kube
import (
"context"
"fmt"
"strings"
"time"

corev1 "k8s.io/api/core/v1"
Expand All @@ -19,20 +20,35 @@ const (
nodeShellReadyTimeout = 90 * time.Second
)

// nodeShellCommand re-enters the host's namespaces through PID 1 and runs the
// host's login shell, so the shell and every binary resolve from the host
// filesystem, not the helper image. /bin/sh is the entry point because it
// exists on every host (minimal node VMs such as OrbStack's ship no bash); the
// -c probe upgrades to bash -l when the host has it, falling back to sh -l.
// nodeShellCommand returns the nsenter invocation for the node's host OS.
//
// nsenter options are the long form, not the short `-t 1 -m -u -i -n -p`
// cluster: the helper image's busybox nsenter mis-parses the short
// optional-arg form on some node runtimes, exec'ing the post-`--` program with
// the wrong argv.
var nodeShellCommand = []string{
"nsenter", "--target", "1", "--mount", "--uts", "--ipc", "--net", "--pid", "--",
"sh", "-c",
"if command -v bash >/dev/null 2>&1; then exec bash -l; else exec sh -l; fi",
//
// Normal hosts: enter every namespace including --mount, so the shell and `/`
// are the host's. The `sh -c` probe prefers the host's bash and falls back to
// sh — bash is not guaranteed (minimal node VMs such as OrbStack's ship none).
//
// Bottlerocket: its host /bin/sh is `brush`, a sandboxed shell whose
// allow-list refuses almost every program (even `ls`), so a --mount host shell
// is unusable. Instead we skip --mount and run the helper image's own busybox
// shell with the host's pid/net/ipc/uts namespaces, starting in /proc/1/root
// (the live host filesystem). You still see every host process and the whole
// host fs; only the shell binary and `/` come from the helper image.
func nodeShellCommand(osImage string) []string {
if strings.Contains(strings.ToLower(osImage), "bottlerocket") {
return []string{
"nsenter", "--target", "1", "--uts", "--ipc", "--net", "--pid", "--",
"/bin/sh", "-c", "cd /proc/1/root 2>/dev/null; exec /bin/sh -l",
}
}
return []string{
"nsenter", "--target", "1", "--mount", "--uts", "--ipc", "--net", "--pid", "--",
"sh", "-c",
"if command -v bash >/dev/null 2>&1; then exec bash -l; else exec sh -l; fi",
}
}

// StartNodeShell gives a root shell on the node by creating a privileged
Expand All @@ -57,6 +73,11 @@ func (m *ClientManager) StartNodeShell(
return "", err
}

osImage := ""
if node, err := cs.CoreV1().Nodes().Get(parent, nodeName, metav1.GetOptions{}); err == nil {
osImage = node.Status.NodeInfo.OSImage
}

created, err := cs.CoreV1().Pods(nodeShellNamespace).Create(
parent, nodeShellPod(nodeName), metav1.CreateOptions{FieldManager: "klustr"})
if err != nil {
Expand All @@ -69,7 +90,7 @@ func (m *ClientManager) StartNodeShell(
}

id, err := m.execs.start(
parent, cfg, cs, nodeShellNamespace, created.Name, nodeShellContainerName, nodeShellCommand,
parent, cfg, cs, nodeShellNamespace, created.Name, nodeShellContainerName, nodeShellCommand(osImage),
onData,
func(err error) {
deleteNodeShellPod(cs, created.Name)
Expand Down
Loading