Files
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

516 lines
18 KiB
Go

package driver
import (
"context"
"fmt"
"os"
"strconv"
"strings"
"sync"
"github.com/container-storage-interface/spec/lib/go/csi"
"github.com/hanzoai/s3-csi/pkg/mountmanager"
"github.com/hanzoai/s3/s3/glog"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"k8s.io/mount-utils"
)
// MounterFactory creates a Mounter for a volume. It is a field on NodeServer
// so tests can substitute a fake that does not touch the real mount service.
type MounterFactory func(volumeID string, readOnly bool, driver *S3Driver, volContext map[string]string) (Mounter, error)
// CapacityFn resolves the desired capacity (bytes) for a volume from the
// orchestrator. Tests can replace this to avoid real Kubernetes API calls.
type CapacityFn func(volumeID string) (int64, error)
// BindMountFn performs a bind mount of source onto target, optionally
// read-only. Used by Volume.Publish and overridden in tests.
type BindMountFn func(source, target string, readOnly bool) error
// HealthCheckFn reports whether a staging path has a live, responsive FUSE
// mount. Overridden in tests to simulate a crashed mount.
type HealthCheckFn func(stagingPath string) bool
type NodeServer struct {
csi.UnimplementedNodeServer
Driver *S3Driver
// information about the managed volumes
volumes sync.Map
volumeMutexes *KeyMutex
// stopCh signals the health monitor goroutine to stop. Guarded by
// stopOnce so NodeCleanup is safe to call from multiple shutdown paths.
stopCh chan struct{}
stopOnce sync.Once
// recoveryWg tracks in-flight per-volume recovery goroutines spawned
// by checkAndRecoverVolumes. Tests can wait on it after driving a
// sweep to synchronize on the recovery work.
recoveryWg sync.WaitGroup
// activeRecoveries holds the volumeIDs that currently have a
// recovery goroutine in flight, used to prevent successive sweeps
// from piling up new recovery goroutines on top of a hung one.
activeRecoveries sync.Map // map[string]struct{}
// Injectable factories / operations (overridden in tests).
mounterFactory MounterFactory
capacityFn CapacityFn
isHealthyFn HealthCheckFn
cleanupStagingFn func(stagingPath string) error
unmountFn func(path string) error
bindMountFn BindMountFn
}
var _ = csi.NodeServer(&NodeServer{})
func (ns *NodeServer) NodeStageVolume(ctx context.Context, req *csi.NodeStageVolumeRequest) (*csi.NodeStageVolumeResponse, error) {
volumeID := req.GetVolumeId()
// mount the fs here
stagingTargetPath := req.GetStagingTargetPath()
glog.Infof("node stage volume %s to %s", volumeID, stagingTargetPath)
// Check arguments
if req.GetVolumeCapability() == nil {
return nil, status.Error(codes.InvalidArgument, "Volume capability missing in request")
}
if !isValidVolumeCapabilities(ns.Driver.vcap, []*csi.VolumeCapability{req.GetVolumeCapability()}) {
// return nil, status.Error(codes.InvalidArgument, "Volume capability not supported")
}
if volumeID == "" {
return nil, status.Error(codes.InvalidArgument, "Volume ID missing in request")
}
if stagingTargetPath == "" {
return nil, status.Error(codes.InvalidArgument, "Target path missing in request")
}
volumeMutex := ns.getVolumeMutex(volumeID)
volumeMutex.Lock()
defer volumeMutex.Unlock()
// The volume has been staged and is in memory cache.
if _, ok := ns.volumes.Load(volumeID); ok {
glog.Infof("volume %s has been already staged", volumeID)
return &csi.NodeStageVolumeResponse{}, nil
}
// Phase 2 Enhancement: Check if staging path exists but is stale/corrupted
// This handles cases where:
// 1. The CSI driver restarted and lost its in-memory state
// 2. The FUSE process died leaving a stale mount
if isStagingPathHealthy(stagingTargetPath) {
// The staging path is healthy - rebuild the cache from the existing mount
// This preserves the existing FUSE mount and avoids disrupting any published volumes
glog.Infof("volume %s has existing healthy mount at %s, rebuilding cache", volumeID, stagingTargetPath)
volume := ns.rebuildVolumeFromStaging(volumeID, stagingTargetPath)
volume.volContext = req.GetVolumeContext()
volume.readOnly = isVolumeReadOnly(req)
ns.volumes.Store(volumeID, volume)
glog.Infof("volume %s cache rebuilt from existing staging at %s", volumeID, stagingTargetPath)
return &csi.NodeStageVolumeResponse{}, nil
}
// Check if there's a stale/corrupted mount that needs cleanup
if _, err := os.Stat(stagingTargetPath); err == nil || mount.IsCorruptedMnt(err) {
glog.Infof("volume %s has stale staging path at %s, cleaning up", volumeID, stagingTargetPath)
if err := cleanupStaleStagingPath(stagingTargetPath); err != nil {
ns.removeVolumeMutex(volumeID)
return nil, status.Errorf(codes.Internal, "failed to cleanup stale staging path %s: %v", stagingTargetPath, err)
}
}
volContext := req.GetVolumeContext()
readOnly := isVolumeReadOnly(req)
volume, err := ns.stageNewVolume(volumeID, stagingTargetPath, volContext, readOnly)
if err != nil {
// node stage is unsuccessful
ns.removeVolumeMutex(volumeID)
if os.IsPermission(err) {
return nil, status.Error(codes.PermissionDenied, err.Error())
}
if strings.Contains(err.Error(), "invalid argument") {
return nil, status.Error(codes.InvalidArgument, err.Error())
}
return nil, status.Error(codes.Internal, err.Error())
}
ns.volumes.Store(volumeID, volume)
glog.Infof("volume %s successfully staged to %s", volumeID, stagingTargetPath)
return &csi.NodeStageVolumeResponse{}, nil
}
func (ns *NodeServer) NodePublishVolume(ctx context.Context, req *csi.NodePublishVolumeRequest) (*csi.NodePublishVolumeResponse, error) {
volumeID := req.GetVolumeId()
targetPath := req.GetTargetPath()
stagingTargetPath := req.GetStagingTargetPath()
glog.Infof("node publish volume %s to %s", volumeID, targetPath)
// Check arguments
if req.GetVolumeCapability() == nil {
return nil, status.Error(codes.InvalidArgument, "Volume capability missing in request")
}
if !isValidVolumeCapabilities(ns.Driver.vcap, []*csi.VolumeCapability{req.GetVolumeCapability()}) {
// return nil, status.Error(codes.InvalidArgument, "Volume capability not supported")
}
if volumeID == "" {
return nil, status.Error(codes.InvalidArgument, "Volume ID missing in request")
}
if targetPath == "" {
return nil, status.Error(codes.InvalidArgument, "Target path missing in request")
}
if stagingTargetPath == "" {
return nil, status.Error(codes.InvalidArgument, "Staging target path missing in request")
}
volumeMutex := ns.getVolumeMutex(volumeID)
volumeMutex.Lock()
defer volumeMutex.Unlock()
volume, ok := ns.volumes.Load(volumeID)
if !ok {
// Phase 1: Self-healing for missing volume cache
// This handles the case where the CSI driver restarted and lost its in-memory state,
// but kubelet thinks the volume is already staged and directly calls NodePublishVolume.
glog.Infof("volume %s not found in cache, attempting self-healing", volumeID)
if isStagingPathHealthy(stagingTargetPath) {
// The staging path is healthy - rebuild volume cache from existing mount
glog.Infof("volume %s has healthy staging at %s, rebuilding cache", volumeID, stagingTargetPath)
rebuiltVol := ns.rebuildVolumeFromStaging(volumeID, stagingTargetPath)
rebuiltVol.volContext = req.GetVolumeContext()
rebuiltVol.readOnly = isPublishVolumeReadOnly(req)
volume = rebuiltVol
ns.volumes.Store(volumeID, volume)
} else {
// The staging path is not healthy - we need to re-stage the volume
// This requires volume context which we have from the request
glog.Infof("volume %s staging path %s is not healthy, re-staging", volumeID, stagingTargetPath)
// Clean up stale staging path if it exists
if err := cleanupStaleStagingPath(stagingTargetPath); err != nil {
ns.removeVolumeMutex(volumeID)
return nil, status.Errorf(codes.Internal, "failed to cleanup stale staging path %s: %v", stagingTargetPath, err)
}
// Re-stage the volume using the shared helper
volContext := req.GetVolumeContext()
readOnly := isPublishVolumeReadOnly(req)
newVolume, err := ns.stageNewVolume(volumeID, stagingTargetPath, volContext, readOnly)
if err != nil {
ns.removeVolumeMutex(volumeID)
return nil, status.Errorf(codes.Internal, "failed to re-stage volume: %v", err)
}
ns.volumes.Store(volumeID, newVolume)
volume = newVolume
glog.Infof("volume %s successfully re-staged to %s", volumeID, stagingTargetPath)
}
}
// When pod uses a volume in read-only mode, k8s will automatically
// mount the volume as a read-only file system.
vol := volume.(*Volume)
if err := vol.Publish(stagingTargetPath, targetPath, req.GetReadonly()); err != nil {
return nil, status.Error(codes.Internal, err.Error())
}
vol.AddPublishPath(targetPath, req.GetReadonly())
glog.Infof("volume %s successfully published to %s", volumeID, targetPath)
return &csi.NodePublishVolumeResponse{}, nil
}
// rebuildVolumeFromStaging creates a Volume struct from an existing healthy staging mount.
// This is used for self-healing when the CSI driver restarts but the FUSE mount is still active.
// Note: The returned Volume won't have an unmounter, so Unstage will need special handling.
func (ns *NodeServer) rebuildVolumeFromStaging(volumeID string, stagingPath string) *Volume {
return &Volume{
VolumeId: volumeID,
StagedPath: stagingPath,
driver: ns.Driver,
localSocket: mountmanager.LocalSocketPath(ns.Driver.volumeSocketDir, volumeID),
bindMountFn: ns.bindMountFn,
// mounter and unmounter are nil - this is intentional
// The FUSE process is already running, we just need to track the volume
// The mount service will have the mount tracked if it's still alive
}
}
// isPublishVolumeReadOnly determines if a volume should be mounted read-only based on the publish request.
func isPublishVolumeReadOnly(req *csi.NodePublishVolumeRequest) bool {
if req.GetReadonly() {
return true
}
cap := req.GetVolumeCapability()
if cap == nil || cap.GetAccessMode() == nil {
return false
}
return isReadOnlyAccessMode(cap.GetAccessMode().Mode)
}
func (ns *NodeServer) NodeUnpublishVolume(ctx context.Context, req *csi.NodeUnpublishVolumeRequest) (*csi.NodeUnpublishVolumeResponse, error) {
volumeID := req.GetVolumeId()
targetPath := req.GetTargetPath()
glog.Infof("node unpublish volume %s from %s", volumeID, targetPath)
if volumeID == "" {
return nil, status.Error(codes.InvalidArgument, "Volume ID missing in request")
}
if targetPath == "" {
return nil, status.Error(codes.InvalidArgument, "Target path missing in request")
}
volumeMutex := ns.getVolumeMutex(volumeID)
volumeMutex.Lock()
defer volumeMutex.Unlock()
volume, ok := ns.volumes.Load(volumeID)
if !ok {
glog.Warningf("volume %s hasn't been published", volumeID)
// make sure there is no any garbage
_ = mount.CleanupMountPoint(targetPath, mountutil, true)
return &csi.NodeUnpublishVolumeResponse{}, nil
}
vol := volume.(*Volume)
if err := vol.Unpublish(targetPath); err != nil {
return nil, status.Error(codes.Internal, err.Error())
}
vol.RemovePublishPath(targetPath)
glog.Infof("volume %s successfully unpublished from %s", volumeID, targetPath)
return &csi.NodeUnpublishVolumeResponse{}, nil
}
func (ns *NodeServer) NodeGetInfo(ctx context.Context, req *csi.NodeGetInfoRequest) (*csi.NodeGetInfoResponse, error) {
glog.V(3).Infof("node get info, node id: %s", ns.Driver.nodeID)
return &csi.NodeGetInfoResponse{
NodeId: ns.Driver.nodeID,
}, nil
}
func (ns *NodeServer) NodeGetCapabilities(ctx context.Context, req *csi.NodeGetCapabilitiesRequest) (*csi.NodeGetCapabilitiesResponse, error) {
glog.V(3).Infof("node get capabilities")
return &csi.NodeGetCapabilitiesResponse{
Capabilities: []*csi.NodeServiceCapability{
{
Type: &csi.NodeServiceCapability_Rpc{
Rpc: &csi.NodeServiceCapability_RPC{
Type: csi.NodeServiceCapability_RPC_STAGE_UNSTAGE_VOLUME,
},
},
},
{
Type: &csi.NodeServiceCapability_Rpc{
Rpc: &csi.NodeServiceCapability_RPC{
Type: csi.NodeServiceCapability_RPC_EXPAND_VOLUME,
},
},
},
},
}, nil
}
func (ns *NodeServer) NodeUnstageVolume(ctx context.Context, req *csi.NodeUnstageVolumeRequest) (*csi.NodeUnstageVolumeResponse, error) {
volumeID := req.GetVolumeId()
stagingTargetPath := req.GetStagingTargetPath()
glog.Infof("node unstage volume %s from %s", volumeID, stagingTargetPath)
// Check arguments
if volumeID == "" {
return nil, status.Error(codes.InvalidArgument, "Volume ID missing in request")
}
if stagingTargetPath == "" {
return nil, status.Error(codes.InvalidArgument, "Target path missing in request")
}
volumeMutex := ns.getVolumeMutex(volumeID)
volumeMutex.Lock()
defer volumeMutex.Unlock()
volume, ok := ns.volumes.Load(volumeID)
if !ok {
glog.Warningf("volume %s hasn't been staged", volumeID)
// make sure there is no any garbage
_ = mount.CleanupMountPoint(stagingTargetPath, mountutil, true)
// Also clean up cache directory and socket if they exist
CleanupVolumeResources(ns.Driver, volumeID)
} else {
if err := volume.(*Volume).Unstage(stagingTargetPath); err != nil {
return nil, status.Error(codes.Internal, err.Error())
} else {
ns.volumes.Delete(volumeID)
}
}
// remove mutex on successfull unstage
ns.volumeMutexes.RemoveMutex(volumeID)
glog.Infof("volume %s successfully unstaged from %s", volumeID, stagingTargetPath)
return &csi.NodeUnstageVolumeResponse{}, nil
}
func (ns *NodeServer) NodeExpandVolume(ctx context.Context, req *csi.NodeExpandVolumeRequest) (*csi.NodeExpandVolumeResponse, error) {
volumeID := req.GetVolumeId()
volumePath := req.GetVolumePath()
requiredBytes := req.GetCapacityRange().GetRequiredBytes()
glog.Infof("node expand volume %s to %d bytes", req.GetVolumeId(), requiredBytes)
if len(volumeID) == 0 {
return nil, status.Error(codes.InvalidArgument, "Volume ID missing in request")
}
if len(volumePath) == 0 {
return nil, status.Error(codes.InvalidArgument, "Volume path missing in request")
}
// TODO Check if volume exists
// TODO Check if node exists
volumeMutex := ns.getVolumeMutex(volumeID)
volumeMutex.Lock()
defer volumeMutex.Unlock()
if volume, ok := ns.volumes.Load(volumeID); ok {
if err := volume.(*Volume).Quota(requiredBytes); err != nil {
return nil, err
}
}
return &csi.NodeExpandVolumeResponse{}, nil
}
func (ns *NodeServer) NodeCleanup() {
ns.stopOnce.Do(func() {
close(ns.stopCh)
// Wait for any in-flight recovery goroutines to finish so the
// driver does not shut down mid-recovery, which could leave a
// volume in a half-staged state.
ns.recoveryWg.Wait()
glog.Infof("node cleanup: health monitor stopped, mount service retains mounts across restarts")
})
}
func (ns *NodeServer) getVolumeMutex(volumeID string) *sync.Mutex {
return ns.volumeMutexes.GetMutex(volumeID)
}
func (ns *NodeServer) removeVolumeMutex(volumeID string) {
ns.volumeMutexes.RemoveMutex(volumeID)
}
// stageNewVolume creates and stages a new volume with the given parameters.
// This is a helper method used by both NodeStageVolume and NodePublishVolume (for re-staging).
// Both the mounter and capacity lookup are resolved via NodeServer fields so
// tests can inject fakes that do not touch the real mount service or k8s API.
func (ns *NodeServer) stageNewVolume(volumeID, stagingTargetPath string, volContext map[string]string, readOnly bool) (*Volume, error) {
effectiveVolContext := cloneVolumeContext(volContext)
capacity, hasCapacity, err := ns.resolveVolumeCapacity(volumeID, effectiveVolContext)
if err != nil {
return nil, err
}
if hasCapacity {
effectiveVolContext[volumeCapacityKey] = strconv.FormatInt(capacity, 10)
}
mounter, err := ns.mounterFactory(volumeID, readOnly, ns.Driver, effectiveVolContext)
if err != nil {
return nil, err
}
volume := NewVolume(volumeID, mounter, ns.Driver)
volume.bindMountFn = ns.bindMountFn
volume.volContext = effectiveVolContext
volume.readOnly = readOnly
if err := volume.Stage(stagingTargetPath); err != nil {
return nil, err
}
// Apply quota if available.
if hasCapacity {
if err := volume.Quota(capacity); err != nil {
glog.Warningf("failed to apply quota for volume %s: %v", volumeID, err)
// Clean up the staged mount since we're returning an error
if unstageErr := volume.Unstage(stagingTargetPath); unstageErr != nil {
glog.Errorf("failed to unstage volume %s after quota failure: %v", volumeID, unstageErr)
}
return nil, err
}
} else {
glog.V(4).Infof("volume capacity is unavailable for %s", volumeID)
}
return volume, nil
}
func cloneVolumeContext(volContext map[string]string) map[string]string {
cloned := make(map[string]string, len(volContext)+1)
for key, value := range volContext {
cloned[key] = value
}
return cloned
}
func (ns *NodeServer) resolveVolumeCapacity(volumeID string, volContext map[string]string) (int64, bool, error) {
if ns.capacityFn != nil {
capacity, err := ns.capacityFn(volumeID)
if err == nil {
return capacity, true, nil
}
glog.V(4).Infof("could not resolve capacity for volume %s from orchestrator API, falling back to volume context: %v", volumeID, err)
}
value := volContext[volumeCapacityKey]
if value == "" {
return 0, false, nil
}
capacity, err := strconv.ParseInt(value, 10, 64)
if err != nil || capacity < 0 {
return 0, false, fmt.Errorf("invalid %s value %q", volumeCapacityKey, value)
}
return capacity, true, nil
}
// isReadOnlyAccessMode checks if the given access mode is read-only.
func isReadOnlyAccessMode(mode csi.VolumeCapability_AccessMode_Mode) bool {
readOnlyModes := []csi.VolumeCapability_AccessMode_Mode{
csi.VolumeCapability_AccessMode_SINGLE_NODE_READER_ONLY,
csi.VolumeCapability_AccessMode_MULTI_NODE_READER_ONLY,
}
for _, readOnlyMode := range readOnlyModes {
if mode == readOnlyMode {
return true
}
}
return false
}
func isVolumeReadOnly(req *csi.NodeStageVolumeRequest) bool {
cap := req.GetVolumeCapability()
if cap == nil || cap.GetAccessMode() == nil {
return false
}
return isReadOnlyAccessMode(cap.GetAccessMode().Mode)
}