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]>
293 lines
8.9 KiB
Go
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.
|
|
}
|