Files
s3-csi/pkg/driver/container_remount_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

293 lines
8.9 KiB
Go

//go:build linux
package driver
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestExtractPodUID(t *testing.T) {
tests := []struct {
path string
want string
}{
{
path: "/var/lib/kubelet/pods/abc-123-def/volumes/kubernetes.io~csi/pv-name/mount",
want: "abc-123-def",
},
{
path: "/var/lib/kubelet/pods/550e8400-e29b-41d4-a716-446655440000/volumes/kubernetes.io~csi/my-pv/mount",
want: "550e8400-e29b-41d4-a716-446655440000",
},
{
path: "/some/other/path",
want: "",
},
{
path: "/var/lib/kubelet/pods/",
want: "",
},
{
path: "",
want: "",
},
}
for _, tt := range tests {
got := extractPodUID(tt.path)
if got != tt.want {
t.Errorf("extractPodUID(%q) = %q, want %q", tt.path, got, tt.want)
}
}
}
func TestParseMountInfoSelf(t *testing.T) {
// Parse our own mountinfo to verify the parser works.
entries, err := parseMountInfo(os.Getpid())
if err != nil {
t.Fatalf("parseMountInfo(self): %v", err)
}
if len(entries) == 0 {
t.Fatal("parseMountInfo(self) returned 0 entries")
}
// Every entry should have a non-empty device and mountpoint.
for i, e := range entries {
if e.device == "" {
t.Errorf("entry %d: empty device", i)
}
if e.mountpoint == "" {
t.Errorf("entry %d: empty mountpoint", i)
}
}
}
func TestParseMountInfoFromFile(t *testing.T) {
// Write a synthetic mountinfo and parse it via parseMountInfo using
// /proc/self/fd trick — or just test the parsing logic directly.
content := `22 1 0:21 / /sys rw,nosuid,nodev,noexec,relatime shared:7 - sysfs sysfs rw
28 22 0:26 / /sys/fs/fuse/connections rw,nosuid,nodev,noexec,relatime shared:17 - fusectl fusectl rw
100 50 0:55 / /mnt/s3 rw,nosuid,nodev,relatime - fuse.s3 s3 rw,user_id=0,group_id=0
101 50 0:56 / /data rw,nosuid,nodev,relatime - fuse s3 rw,user_id=0,group_id=0
`
tmpDir := t.TempDir()
tmpFile := filepath.Join(tmpDir, "mountinfo")
if err := os.WriteFile(tmpFile, []byte(content), 0644); err != nil {
t.Fatal(err)
}
// parseMountInfo reads from /proc/<pid>/mountinfo, but we can test
// the core logic by creating a helper. For now, test findContainerMountByDevice
// with a known PID (self) or test parseMountInfoFromReader.
// Instead, let's directly verify the struct parsing with manual entries.
entries := []mountInfoEntry{
{device: "0:21", mountpoint: "/sys", fstype: "sysfs"},
{device: "0:26", mountpoint: "/sys/fs/fuse/connections", fstype: "fusectl"},
{device: "0:55", mountpoint: "/mnt/s3", fstype: "fuse.s3"},
{device: "0:56", mountpoint: "/data", fstype: "fuse"},
}
// Test isFuseFS
if isFuseFS(entries[0].fstype) {
t.Error("sysfs should not be fuse")
}
if isFuseFS(entries[1].fstype) {
t.Error("fusectl should not be fuse (it is the control fs)")
}
if !isFuseFS(entries[2].fstype) {
t.Error("fuse.s3 should be fuse")
}
if !isFuseFS(entries[3].fstype) {
t.Error("fuse should be fuse")
}
}
// TestParseMountInfoReaderCapturesRoot verifies the mountinfo root field
// (field 4, the subPath offset) is parsed. Regression guard for #271:
// dropping this field is what made subPath mounts re-bind to the share
// root after FUSE recovery.
func TestParseMountInfoReaderCapturesRoot(t *testing.T) {
// Two binds of the same FUSE device: a plain mount (root "/") and a
// subPath mount (root "/store").
content := `100 50 0:55 / /var/lib/kubelet/pods/uid/volumes/kubernetes.io~csi/pv/mount rw,relatime - fuse.s3 s3 rw,user_id=0,group_id=0
101 50 0:55 /store /var/lib/kubelet/pods/uid/volume-subpaths/pv/ctr/0 rw,nosuid,nodev,relatime - fuse.s3 s3 rw,user_id=0,group_id=0
`
entries, err := parseMountInfoReader(strings.NewReader(content))
if err != nil {
t.Fatalf("parseMountInfoReader: %v", err)
}
if len(entries) != 2 {
t.Fatalf("expected 2 entries, got %d", len(entries))
}
if entries[0].root != "/" {
t.Errorf("plain mount: root = %q, want %q", entries[0].root, "/")
}
if entries[1].root != "/store" {
t.Errorf("subPath mount: root = %q, want %q", entries[1].root, "/store")
}
for i, e := range entries {
if e.device != "0:55" {
t.Errorf("entry %d: device = %q, want 0:55", i, e.device)
}
if !isFuseFS(e.fstype) {
t.Errorf("entry %d: fstype %q not recognized as fuse", i, e.fstype)
}
}
}
// TestParseMountInfoReaderUnescapesOctal verifies that octal escapes in
// the root and mountpoint fields (util-linux escapes space/tab/newline/
// backslash) are decoded, so a subPath containing a space binds the right
// subdirectory after recovery instead of failing or dropping the offset.
func TestParseMountInfoReaderUnescapesOctal(t *testing.T) {
// subPath "my store" -> root "/my\040store"; mountpoint also has a space.
content := "200 50 0:55 /my\\040store /var/lib/kubelet/pods/uid/a\\040b rw,relatime - fuse.s3 s3 rw\n"
entries, err := parseMountInfoReader(strings.NewReader(content))
if err != nil {
t.Fatalf("parseMountInfoReader: %v", err)
}
if len(entries) != 1 {
t.Fatalf("expected 1 entry, got %d", len(entries))
}
if entries[0].root != "/my store" {
t.Errorf("root = %q, want %q", entries[0].root, "/my store")
}
if entries[0].mountpoint != "/var/lib/kubelet/pods/uid/a b" {
t.Errorf("mountpoint = %q, want %q", entries[0].mountpoint, "/var/lib/kubelet/pods/uid/a b")
}
}
func TestUnescapeMountField(t *testing.T) {
tests := []struct {
in, want string
}{
{"/", "/"},
{"/store", "/store"},
{"/my\\040store", "/my store"}, // space
{"/a\\011b", "/a\tb"}, // tab
{"/a\\012b", "/a\nb"}, // newline
{"/a\\134b", "/a\\b"}, // backslash
{"/a\\040b\\040c", "/a b c"}, // multiple
{"/trailing\\04", "/trailing\\04"}, // incomplete escape left verbatim
{"/not\\888escape", "/not\\888escape"}, // non-octal digits left verbatim
}
for _, tt := range tests {
if got := unescapeMountField(tt.in); got != tt.want {
t.Errorf("unescapeMountField(%q) = %q, want %q", tt.in, got, tt.want)
}
}
}
func TestIsFuseFS(t *testing.T) {
tests := []struct {
fstype string
want bool
}{
{"fuse", true},
{"fuse.s3", true},
{"fuse.sshfs", true},
{"fusectl", false},
{"ext4", false},
{"tmpfs", false},
{"sysfs", false},
{"", false},
}
for _, tt := range tests {
got := isFuseFS(tt.fstype)
if got != tt.want {
t.Errorf("isFuseFS(%q) = %v, want %v", tt.fstype, got, tt.want)
}
}
}
func TestIsStaleMount(t *testing.T) {
// nil error is not stale
if isStaleMount(nil) {
t.Error("nil error should not be stale")
}
// os.ErrNotExist is not stale (mount point doesn't exist at all)
if isStaleMount(os.ErrNotExist) {
t.Error("ErrNotExist should not be stale")
}
}
func TestFindContainerMountsByDevice(t *testing.T) {
// This test uses our own PID's mountinfo. We look for a device
// that we know exists (root mount) and one that doesn't.
entries, err := parseMountInfo(os.Getpid())
if err != nil {
t.Fatalf("parseMountInfo: %v", err)
}
// No FUSE mounts should be found with a bogus device.
mps, err := findContainerMountsByDevice(os.Getpid(), "999:999")
if err != nil {
t.Fatalf("findContainerMountsByDevice: %v", err)
}
if len(mps) != 0 {
t.Errorf("expected no match for bogus device, got %v", mps)
}
// If there are any FUSE mounts in our mountinfo, test that we can find them.
for _, e := range entries {
if isFuseFS(e.fstype) {
mps, err := findContainerMountsByDevice(os.Getpid(), e.device)
if err != nil {
t.Fatalf("findContainerMountsByDevice: %v", err)
}
if len(mps) == 0 {
t.Errorf("expected to find mount for device %s, got empty", e.device)
}
break
}
}
}
// TestRecoverVolumeCallsContainerRemount verifies that recoverVolume
// invokes remountInContainers after a successful recovery. Since we
// cannot test actual namespace operations in a unit test, we verify
// the function is called (it will be a no-op because there are no
// matching containers in the test environment).
func TestRecoverVolumeCallsContainerRemount(t *testing.T) {
state := newFakeMountState()
ns := newNodeServerWithFakes(t, state)
root := t.TempDir()
stagingPath := filepath.Join(root, "staging")
// Use a realistic publish path so extractPodUID can find it.
publishPath := filepath.Join(root, "pods", "test-uid-123", "volumes", "mount")
volCtx := map[string]string{"collection": "c"}
vol, err := ns.stageNewVolume("vol-1", stagingPath, volCtx, false)
if err != nil {
t.Fatalf("stageNewVolume: %v", err)
}
vol.volContext = volCtx
ns.volumes.Store("vol-1", vol)
if err := vol.Publish(stagingPath, publishPath, false); err != nil {
t.Fatalf("publish: %v", err)
}
vol.AddPublishPath(publishPath, false)
// Simulate crash and trigger recovery.
state.healthy.Store(false)
ns.checkAndRecoverVolumes()
ns.recoveryWg.Wait()
// Verify recovery happened (host-level).
state.mu.Lock()
defer state.mu.Unlock()
if state.stageCalls != 2 {
t.Errorf("expected 2 stage calls, got %d", state.stageCalls)
}
// Container remount would have been called but found no matching
// containers (test environment). The important thing is that the
// code path didn't panic.
}