mirror of
https://github.com/hanzoai/s3-csi.git
synced 2026-09-30 23:52:32 +00:00
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]>
351 lines
11 KiB
Go
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,
|
|
)
|
|
}
|