Files
s3-csi/pkg/driver/container_remount_integration_test.go
zeekayandHanzo Dev 69ddc9947a s3-csi: ZAP-native CSI driver for the Hanzo filer
Fork of seaweedfs/seaweedfs-csi-driver, renamed seaweedfs -> s3 throughout and
ported off protobuf entirely.

WHY a fork was unavoidable: hanzoai/s3 serves HanzoFiler over the native ZAP
transport on the port still named "grpc" (18888) — s3/command/filer.go binds it
with transport.ListenStream and says it "replaces the legacy gRPC HanzoFiler
server: the whole filer (28 unary + 5 streaming RPCs) now answers over ZAP, no
gRPC". RegisterHanzoFilerServer exists nowhere in that tree. So the upstream
driver's gRPC client cannot connect at all; it fails as
`Unimplemented: unknown service filer_pb.SeaweedFiler`, which LOOKS like a
service-name mismatch but is a gRPC client hitting a non-gRPC port. Renaming the
service in the client would not have helped.

pkg/filer is the only place this driver touches storage, and it speaks ZAP:
  Mkdir  -> CreateEntry           (CreateVolume)
  Remove -> DeleteEntry           (DeleteVolume)
  Exists -> LookupDirectoryEntry  (ValidateVolumeCapabilities)
One shared, concurrency-safe client multiplexes over a single ZAP connection,
dials lazily so controller start does not depend on filer readiness, and redials
exactly once on a transport fault (covers a filer restart closing an idle conn
without becoming a retry storm against a struggling backend).

pkg/driver/volume.go Quota() likewise moves to s3/wire/mount — the fork generates
no mount_pb at all. Dead gRPC plumbing (WithFilerClient, AdjustedUrl,
grpcDialOption) is deleted rather than left inert.

The kubelet-facing side stays CSI gRPC: that is the Kubernetes contract between
kubelet and any CSI driver, not a Hanzo choice.

Zero protobuf call sites remain. Whole module compiles clean.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-27 16:42:29 -07:00

351 lines
11 KiB
Go

//go:build linux && integration
package driver
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"syscall"
"testing"
"time"
"golang.org/x/sys/unix"
)
// These integration tests exercise the real setns / mount namespace
// machinery. They require:
// - Linux (build tag already enforced)
// - Root privileges (CAP_SYS_ADMIN) for unshare / setns / mount
//
// Run with: sudo go test -tags integration -run Integration ./pkg/driver/
func skipIfNotRoot(t *testing.T) {
t.Helper()
if os.Getuid() != 0 {
t.Skip("integration tests require root")
}
}
// TestIntegrationRemountViaSetns creates a child process in a separate
// mount namespace, mounts a tmpfs inside it, then uses remountViaSetns
// to replace that mount with a bind from a different directory.
func TestIntegrationRemountViaSetns(t *testing.T) {
skipIfNotRoot(t)
// --- Setup: create two directories with distinct content ---
root := t.TempDir()
originalDir := filepath.Join(root, "original")
replacementDir := filepath.Join(root, "replacement")
childMountpoint := filepath.Join(root, "childmnt")
for _, d := range []string{originalDir, replacementDir, childMountpoint} {
if err := os.MkdirAll(d, 0755); err != nil {
t.Fatal(err)
}
}
if err := os.WriteFile(filepath.Join(originalDir, "marker"), []byte("original"), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(replacementDir, "marker"), []byte("replacement"), 0644); err != nil {
t.Fatal(err)
}
// --- Start a child process in a new mount namespace ---
// The child bind-mounts originalDir onto childMountpoint inside its
// private namespace, writes its PID to a file, then sleeps.
pidFile := filepath.Join(root, "child.pid")
readyFile := filepath.Join(root, "child.ready")
child := exec.Command("unshare", "--mount", "--propagation", "private",
"sh", "-c", fmt.Sprintf(
`mount --bind %s %s && echo $$ > %s && touch %s && sleep 60`,
originalDir, childMountpoint, pidFile, readyFile,
))
child.SysProcAttr = &syscall.SysProcAttr{
Pdeathsig: syscall.SIGKILL,
}
if err := child.Start(); err != nil {
t.Fatalf("start child: %v", err)
}
defer func() {
child.Process.Kill()
child.Wait()
}()
// Wait for the child to signal readiness.
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if _, err := os.Stat(readyFile); err == nil {
break
}
time.Sleep(50 * time.Millisecond)
}
pidBytes, err := os.ReadFile(pidFile)
if err != nil {
t.Fatalf("read child PID: %v", err)
}
childPID, err := strconv.Atoi(strings.TrimSpace(string(pidBytes)))
if err != nil {
t.Fatalf("parse child PID %q: %v", string(pidBytes), err)
}
// --- Verify: from the child's namespace, the mountpoint has "original" content ---
markerPath := fmt.Sprintf("/proc/%d/root%s/marker", childPID, childMountpoint)
data, err := os.ReadFile(markerPath)
if err != nil {
t.Fatalf("read marker in child namespace: %v", err)
}
if got := strings.TrimSpace(string(data)); got != "original" {
t.Fatalf("expected 'original' marker, got %q", got)
}
// --- Act: use remountViaSetns to replace the mount ---
if err := remountViaSetns(childPID, childMountpoint, replacementDir, "", false); err != nil {
t.Fatalf("remountViaSetns: %v", err)
}
// --- Verify: the child's mountpoint now shows "replacement" content ---
data, err = os.ReadFile(markerPath)
if err != nil {
t.Fatalf("read marker after remount: %v", err)
}
if got := strings.TrimSpace(string(data)); got != "replacement" {
t.Errorf("expected 'replacement' marker after remount, got %q", got)
}
// --- Verify: our own namespace was restored correctly ---
// We should still be able to read our own files normally.
if _, err := os.ReadDir(root); err != nil {
t.Errorf("failed to read test root after remount (namespace leak?): %v", err)
}
}
// TestIntegrationRemountViaSetnsSubPath verifies that when a non-empty
// subPath is supplied, remountViaSetns binds from <staging>/<subPath>
// rather than the staging root. This is the regression guard for
// s3/s3-csi#271, where a subPath volume mount
// silently resolved to the share root after FUSE recovery.
func TestIntegrationRemountViaSetnsSubPath(t *testing.T) {
skipIfNotRoot(t)
// staging/ has a "store" subdir; only the subdir should be visible at
// the container mountpoint after the remount.
root := t.TempDir()
stagingDir := filepath.Join(root, "staging")
storeDir := filepath.Join(stagingDir, "store")
childMountpoint := filepath.Join(root, "childmnt")
for _, d := range []string{storeDir, childMountpoint} {
if err := os.MkdirAll(d, 0755); err != nil {
t.Fatal(err)
}
}
// Marker at the staging root must NOT leak through; the subdir marker
// is what a correct subPath bind should expose.
if err := os.WriteFile(filepath.Join(stagingDir, "marker"), []byte("root"), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(storeDir, "marker"), []byte("instore"), 0644); err != nil {
t.Fatal(err)
}
pidFile := filepath.Join(root, "child.pid")
readyFile := filepath.Join(root, "child.ready")
// The child starts with an empty mount over childMountpoint to stand
// in for the stale FUSE bind that recovery will replace.
child := exec.Command("unshare", "--mount", "--propagation", "private",
"sh", "-c", fmt.Sprintf(
`mount -t tmpfs none %s && echo $$ > %s && touch %s && sleep 60`,
childMountpoint, pidFile, readyFile,
))
child.SysProcAttr = &syscall.SysProcAttr{Pdeathsig: syscall.SIGKILL}
if err := child.Start(); err != nil {
t.Fatalf("start child: %v", err)
}
defer func() {
child.Process.Kill()
child.Wait()
}()
deadline := time.Now().Add(5 * time.Second)
ready := false
for time.Now().Before(deadline) {
if _, err := os.Stat(readyFile); err == nil {
ready = true
break
}
time.Sleep(50 * time.Millisecond)
}
if !ready {
t.Fatal("child did not become ready")
}
pidBytes, err := os.ReadFile(pidFile)
if err != nil {
t.Fatalf("read child PID: %v", err)
}
childPID, err := strconv.Atoi(strings.TrimSpace(string(pidBytes)))
if err != nil {
t.Fatalf("parse child PID %q: %v", string(pidBytes), err)
}
// Act: remount with subPath "/store" (mountinfo root shape, with the
// leading slash production passes from mountInfoEntry.root).
if err := remountViaSetns(childPID, childMountpoint, stagingDir, "/store", false); err != nil {
t.Fatalf("remountViaSetns: %v", err)
}
// Verify: the container mountpoint shows the subdir content, not the root.
markerPath := fmt.Sprintf("/proc/%d/root%s/marker", childPID, childMountpoint)
data, err := os.ReadFile(markerPath)
if err != nil {
t.Fatalf("read marker after subPath remount: %v", err)
}
if got := strings.TrimSpace(string(data)); got != "instore" {
t.Errorf("expected 'instore' marker (subPath bind), got %q — subPath offset was dropped", got)
}
}
// TestIntegrationRemountViaSetnsRestoresNamespaceOnFailure verifies that
// if the bind mount fails (e.g., target doesn't exist in child
// namespace), remountViaSetns still restores the caller's namespace.
func TestIntegrationRemountViaSetnsRestoresNamespaceOnFailure(t *testing.T) {
skipIfNotRoot(t)
root := t.TempDir()
srcDir := filepath.Join(root, "src")
os.MkdirAll(srcDir, 0755)
pidFile := filepath.Join(root, "child.pid")
readyFile := filepath.Join(root, "child.ready")
child := exec.Command("unshare", "--mount", "--propagation", "private",
"sh", "-c", fmt.Sprintf(
`echo $$ > %s && touch %s && sleep 60`, pidFile, readyFile,
))
child.SysProcAttr = &syscall.SysProcAttr{Pdeathsig: syscall.SIGKILL}
if err := child.Start(); err != nil {
t.Fatal(err)
}
defer func() {
child.Process.Kill()
child.Wait()
}()
deadline := time.Now().Add(5 * time.Second)
ready := false
for time.Now().Before(deadline) {
if _, err := os.Stat(readyFile); err == nil {
ready = true
break
}
time.Sleep(50 * time.Millisecond)
}
if !ready {
t.Fatal("child did not become ready")
}
pidBytes, err := os.ReadFile(pidFile)
if err != nil {
t.Fatalf("read child PID: %v", err)
}
childPID, err := strconv.Atoi(strings.TrimSpace(string(pidBytes)))
if err != nil {
t.Fatalf("parse child PID %q: %v", string(pidBytes), err)
}
// Try to remount a path that doesn't exist in the child namespace.
remountErr := remountViaSetns(childPID, "/nonexistent/path/that/does/not/exist", srcDir, "", false)
if remountErr == nil {
t.Fatal("expected error for nonexistent target, got nil")
}
// Our namespace should still be intact.
if _, readErr := os.ReadDir(root); readErr != nil {
t.Fatalf("namespace not restored after failed remount: %v", readErr)
}
}
// TestIntegrationFindContainerPIDsForPod verifies that
// findContainerPIDsForPod can find our own process when we create a
// child with a matching cgroup path.
func TestIntegrationFindContainerPIDsForPod(t *testing.T) {
skipIfNotRoot(t)
// Read our own cgroup to see what format the system uses.
data, err := os.ReadFile("/proc/self/cgroup")
if err != nil {
t.Skipf("cannot read own cgroup: %v", err)
}
t.Logf("own cgroup:\n%s", string(data))
// Search for our own PID using a substring of our cgroup. This
// tests the scanning logic without needing a real pod.
// Use a fake pod UID that won't match anything.
pids, err := findContainerPIDsForPod("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee")
if err != nil {
t.Fatalf("findContainerPIDsForPod: %v", err)
}
if len(pids) != 0 {
t.Errorf("expected 0 PIDs for bogus UID, got %d", len(pids))
}
}
// TestIntegrationGetMountDevice verifies that getMountDevice can read
// the device of a real mount (e.g., the root filesystem or /tmp).
func TestIntegrationGetMountDevice(t *testing.T) {
skipIfNotRoot(t)
// Create a tmpfs mount to have a known target.
mnt := filepath.Join(t.TempDir(), "mnt")
os.MkdirAll(mnt, 0755)
if err := unix.Mount("tmpfs", mnt, "tmpfs", 0, "size=1m"); err != nil {
t.Fatalf("mount tmpfs: %v", err)
}
defer unix.Unmount(mnt, unix.MNT_DETACH)
dev, err := getMountDevice(mnt)
if err != nil {
t.Fatalf("getMountDevice(%s): %v", mnt, err)
}
if dev == "" {
t.Fatal("getMountDevice returned empty device")
}
// Device should be in "major:minor" format.
parts := strings.Split(dev, ":")
if len(parts) != 2 {
t.Errorf("expected device format 'major:minor', got %q", dev)
}
t.Logf("tmpfs device: %s", dev)
}
// TestIntegrationRemountInContainersNoOp verifies that
// remountInContainers is a safe no-op when there are no matching
// containers (the common case in unit test / CI environments).
func TestIntegrationRemountInContainersNoOp(t *testing.T) {
skipIfNotRoot(t)
// Should not panic or error even with bogus paths.
remountInContainers(
"/var/lib/kubelet/pods/fake-uid/volumes/kubernetes.io~csi/pv/mount",
"/tmp/nonexistent-staging",
"0:999",
false,
)
}
// TestIntegrationRemountStaleFuseInContainersNoOp verifies the
// scan-based variant is also a safe no-op.
func TestIntegrationRemountStaleFuseInContainersNoOp(t *testing.T) {
skipIfNotRoot(t)
remountStaleFuseInContainers(
"/var/lib/kubelet/pods/fake-uid/volumes/kubernetes.io~csi/pv/mount",
"/tmp/nonexistent-staging",
false,
)
}