docker/swarmkit

View on GitHub
agent/csi/plugin/plugin.go

Summary

Maintainability
A
3 hrs
Test Coverage
package plugin

import (
    "context"
    "fmt"
    "path/filepath"
    "sync"

    "google.golang.org/grpc"
    "google.golang.org/grpc/codes"
    "google.golang.org/grpc/status"

    "github.com/container-storage-interface/spec/lib/go/csi"
    "github.com/moby/swarmkit/v2/api"
    "github.com/moby/swarmkit/v2/internal/csi/capability"
    "github.com/moby/swarmkit/v2/log"
    "github.com/moby/swarmkit/v2/node/plugin"
)

// SecretGetter is a reimplementation of the exec.SecretGetter interface in the
// scope of the plugin package. This avoids the needing to import exec into the
// plugin package.
type SecretGetter interface {
    Get(secretID string) (*api.Secret, error)
}

type NodePlugin interface {
    GetPublishedPath(volumeID string) string
    NodeGetInfo(ctx context.Context) (*api.NodeCSIInfo, error)
    NodeStageVolume(ctx context.Context, req *api.VolumeAssignment) error
    NodeUnstageVolume(ctx context.Context, req *api.VolumeAssignment) error
    NodePublishVolume(ctx context.Context, req *api.VolumeAssignment) error
    NodeUnpublishVolume(ctx context.Context, req *api.VolumeAssignment) error
}

type volumePublishStatus struct {
    // stagingPath is staging path of volume
    stagingPath string

    // isPublished keeps track if the volume is published.
    isPublished bool

    // publishedPath is published path of volume
    publishedPath string
}

type nodePlugin struct {
    // name is the name of the plugin, which is used in the Driver.Name field.
    name string

    // socket is the path of the unix socket to connect to this plugin at
    socket string

    // scopePath gets the provided path relative to the plugin directory.
    scopePath func(s string) string

    // secrets is the SecretGetter to get volume secret data
    secrets SecretGetter

    // volumeMap is the map from volume ID to Volume. Will place a volume once it is staged,
    // remove it from the map for unstage.
    // TODO: Make this map persistent if the swarm node goes down
    volumeMap map[string]*volumePublishStatus

    // mu for volumeMap
    mu sync.RWMutex

    // staging indicates that the plugin has staging capabilities.
    staging bool

    // cc is the gRPC client connection
    cc *grpc.ClientConn

    // idClient is the CSI Identity Service client
    idClient csi.IdentityClient

    // nodeClient is the CSI Node Service client
    nodeClient csi.NodeClient
}

const (
    // TargetStagePath is the path within the plugin's scope that the volume is
    // to be staged. This does not need to be accessible or propagated outside
    // of the plugin rootfs.
    TargetStagePath string = "/data/staged"
    // TargetPublishPath is the path within the plugin's scope that the volume
    // is to be published. This needs to be the plugin's PropagatedMount.
    TargetPublishPath string = "/data/published"
)

func NewNodePlugin(name string, p plugin.AddrPlugin, secrets SecretGetter) NodePlugin {
    return newNodePlugin(name, p, secrets)
}

// newNodePlugin returns a raw nodePlugin object, not behind an interface. this
// is useful for testing.
func newNodePlugin(name string, p plugin.AddrPlugin, secrets SecretGetter) *nodePlugin {
    return &nodePlugin{
        name:      name,
        socket:    fmt.Sprintf("%s://%s", p.Addr().Network(), p.Addr().String()),
        scopePath: p.ScopedPath,
        secrets:   secrets,
        volumeMap: map[string]*volumePublishStatus{},
    }
}

// connect is a private method that sets up the identity client and node
// client from a grpc client. it exists separately so that testing code can
// substitute in fake clients without a grpc connection
func (np *nodePlugin) connect(ctx context.Context) error {
    // even though this is a unix socket, we must set WithInsecure or the
    // connection will not be allowed.
    cc, err := grpc.DialContext(ctx, np.socket, grpc.WithInsecure())
    if err != nil {
        return err
    }

    np.cc = cc
    // first, probe the plugin, to ensure that it exists and is ready to go
    idc := csi.NewIdentityClient(cc)
    np.idClient = idc

    np.nodeClient = csi.NewNodeClient(cc)

    return np.init(ctx)
}

func (np *nodePlugin) Client(ctx context.Context) (csi.NodeClient, error) {
    if np.nodeClient == nil {
        if err := np.connect(ctx); err != nil {
            return nil, err
        }
    }
    return np.nodeClient, nil
}

func (np *nodePlugin) init(ctx context.Context) error {
    probe, err := np.idClient.Probe(ctx, &csi.ProbeRequest{})
    if err != nil {
        return err
    }
    if probe.Ready != nil && !probe.Ready.Value {
        return status.Error(codes.FailedPrecondition, "Plugin is not Ready")
    }

    c, err := np.Client(ctx)
    if err != nil {
        return err
    }

    resp, err := c.NodeGetCapabilities(ctx, &csi.NodeGetCapabilitiesRequest{})
    if err != nil {
        // TODO(ameyag): handle
        return err
    }
    if resp == nil {
        return nil
    }
    log.G(ctx).Debugf("plugin advertises %d capabilities", len(resp.Capabilities))
    for _, c := range resp.Capabilities {
        if rpc := c.GetRpc(); rpc != nil {
            log.G(ctx).Debugf("plugin has capability %s", rpc)
            switch rpc.Type {
            case csi.NodeServiceCapability_RPC_STAGE_UNSTAGE_VOLUME:
                np.staging = true
            }
        }
    }

    return nil
}

// GetPublishedPath returns the path at which the provided volume ID is
// published. This path is provided in terms of absolute location on the host,
// not the location in the plugins' scope.
//
// Returns an empty string if the volume does not exist.
func (np *nodePlugin) GetPublishedPath(volumeID string) string {
    np.mu.RLock()
    defer np.mu.RUnlock()
    if volInfo, ok := np.volumeMap[volumeID]; ok {
        if volInfo.isPublished {
            return np.scopePath(volInfo.publishedPath)
        }
    }
    return ""
}

func (np *nodePlugin) NodeGetInfo(ctx context.Context) (*api.NodeCSIInfo, error) {
    c, err := np.Client(ctx)
    if err != nil {
        return nil, err
    }
    resp, err := c.NodeGetInfo(ctx, &csi.NodeGetInfoRequest{})
    if err != nil {
        return nil, err
    }

    i := makeNodeInfo(resp)
    i.PluginName = np.name
    return i, nil
}

func (np *nodePlugin) NodeStageVolume(ctx context.Context, req *api.VolumeAssignment) error {
    np.mu.Lock()
    defer np.mu.Unlock()
    if !np.staging {
        return nil
    }

    stagingTarget := stagePath(req)
    err := capability.CheckArguments(req)
    if err != nil {
        return err
    }

    c, err := np.Client(ctx)
    if err != nil {
        return err
    }

    _, err = c.NodeStageVolume(ctx, &csi.NodeStageVolumeRequest{
        VolumeId:          req.VolumeID,
        StagingTargetPath: stagingTarget,
        Secrets:           np.makeSecrets(req),
        VolumeCapability:  capability.MakeCapability(req.AccessMode),
        VolumeContext:     req.VolumeContext,
        PublishContext:    req.PublishContext,
    })

    if err != nil {
        return err
    }

    v := &volumePublishStatus{
        stagingPath: stagingTarget,
    }

    np.volumeMap[req.ID] = v

    log.G(ctx).Infof("volume staged to path %s", stagingTarget)
    return nil
}

func (np *nodePlugin) NodeUnstageVolume(ctx context.Context, req *api.VolumeAssignment) error {
    np.mu.Lock()
    defer np.mu.Unlock()
    if !np.staging {
        return nil
    }

    stagingTarget := stagePath(req)

    // Check arguments
    if len(req.VolumeID) == 0 {
        return status.Error(codes.FailedPrecondition, "VolumeID missing in request")
    }

    c, err := np.Client(ctx)
    if err != nil {
        return err
    }

    // we must unpublish before we unstage. verify here that the volume is not
    // published.
    if v, ok := np.volumeMap[req.ID]; ok {
        if v.isPublished {
            return status.Errorf(codes.FailedPrecondition, "Volume %s is not unpublished", req.ID)
        }
        return nil
    }

    _, err = c.NodeUnstageVolume(ctx, &csi.NodeUnstageVolumeRequest{
        VolumeId:          req.VolumeID,
        StagingTargetPath: stagingTarget,
    })
    if err != nil {
        return err
    }

    // if the volume doesn't exist in the volumeMap, deleting has no effect.
    delete(np.volumeMap, req.ID)
    log.G(ctx).Info("volume unstaged")

    return nil
}

func (np *nodePlugin) NodePublishVolume(ctx context.Context, req *api.VolumeAssignment) error {
    err := capability.CheckArguments(req)
    if err != nil {
        return err
    }

    np.mu.Lock()
    defer np.mu.Unlock()

    publishTarget := publishPath(req)

    // Some volumes plugins require staging; we track this with a boolean, which
    // also implies a staging path in the path map. If the plugin is marked as
    // requiring staging but does not have a staging path in the map, that is an
    // error.
    var stagingPath string
    if vs, ok := np.volumeMap[req.ID]; ok {
        stagingPath = vs.stagingPath
    } else if np.staging {
        return status.Error(codes.FailedPrecondition, "volume requires staging but was not staged")
    }

    c, err := np.Client(ctx)
    if err != nil {
        return err
    }

    _, err = c.NodePublishVolume(ctx, &csi.NodePublishVolumeRequest{
        VolumeId:          req.VolumeID,
        TargetPath:        publishTarget,
        StagingTargetPath: stagingPath,
        VolumeCapability:  capability.MakeCapability(req.AccessMode),
        Secrets:           np.makeSecrets(req),
        VolumeContext:     req.VolumeContext,
        PublishContext:    req.PublishContext,
    })
    if err != nil {
        return err
    }

    status, ok := np.volumeMap[req.ID]
    if !ok {
        status = &volumePublishStatus{}
        np.volumeMap[req.ID] = status
    }

    status.isPublished = true
    status.publishedPath = publishTarget

    log.G(ctx).Infof("volume published to path %s", publishTarget)

    return nil
}

func (np *nodePlugin) NodeUnpublishVolume(ctx context.Context, req *api.VolumeAssignment) error {
    // Check arguments
    if len(req.VolumeID) == 0 {
        return status.Error(codes.InvalidArgument, "Volume ID missing in request")
    }

    np.mu.Lock()
    defer np.mu.Unlock()
    publishTarget := publishPath(req)

    c, err := np.Client(ctx)
    if err != nil {
        return err
    }

    _, err = c.NodeUnpublishVolume(ctx, &csi.NodeUnpublishVolumeRequest{
        VolumeId:   req.VolumeID,
        TargetPath: publishTarget,
    })

    if err != nil {
        return err
    }

    if v, ok := np.volumeMap[req.ID]; ok {
        v.publishedPath = ""
        v.isPublished = false
        return nil
    }

    log.G(ctx).Info("volume unpublished")
    return nil
}

func (np *nodePlugin) makeSecrets(v *api.VolumeAssignment) map[string]string {
    // this should never happen, but program defensively.
    if v == nil {
        return nil
    }

    secrets := make(map[string]string, len(v.Secrets))
    for _, secret := range v.Secrets {
        // TODO(dperny): handle error from Get
        value, _ := np.secrets.Get(secret.Secret)
        if value != nil {
            secrets[secret.Key] = string(value.Spec.Data)
        }
    }

    return secrets
}

// makeNodeInfo converts a csi.NodeGetInfoResponse object into a swarmkit NodeCSIInfo
// object.
func makeNodeInfo(csiNodeInfo *csi.NodeGetInfoResponse) *api.NodeCSIInfo {
    return &api.NodeCSIInfo{
        NodeID:            csiNodeInfo.NodeId,
        MaxVolumesPerNode: csiNodeInfo.MaxVolumesPerNode,
    }
}

// stagePath returns the staging path for a given volume assignment
func stagePath(v *api.VolumeAssignment) string {
    // this really just exists so we use the same trick to determine staging
    // path across multiple methods and can't forget to change it in one place
    // but not another
    return filepath.Join(TargetStagePath, v.ID)
}

// publishPath returns the publishing path for a given volume assignment
func publishPath(v *api.VolumeAssignment) string {
    // ditto as stagePath
    return filepath.Join(TargetPublishPath, v.ID)
}