Merge pull request #7012 from hashicorp/f-csi-volumes

Container Storage Interface Support
This commit is contained in:
Tim Gross 2020-03-23 14:19:46 -04:00 committed by GitHub
commit 076fbbf08f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
149 changed files with 21587 additions and 311 deletions

View File

@ -62,6 +62,7 @@ type ACL struct {
node string node string
operator string operator string
quota string quota string
plugin string
} }
// maxPrivilege returns the policy which grants the most privilege // maxPrivilege returns the policy which grants the most privilege
@ -74,6 +75,8 @@ func maxPrivilege(a, b string) string {
return PolicyWrite return PolicyWrite
case a == PolicyRead || b == PolicyRead: case a == PolicyRead || b == PolicyRead:
return PolicyRead return PolicyRead
case a == PolicyList || b == PolicyList:
return PolicyList
default: default:
return "" return ""
} }
@ -193,6 +196,9 @@ func NewACL(management bool, policies []*Policy) (*ACL, error) {
if policy.Quota != nil { if policy.Quota != nil {
acl.quota = maxPrivilege(acl.quota, policy.Quota.Policy) acl.quota = maxPrivilege(acl.quota, policy.Quota.Policy)
} }
if policy.Plugin != nil {
acl.plugin = maxPrivilege(acl.plugin, policy.Plugin.Policy)
}
} }
// Finalize the namespaces // Finalize the namespaces
@ -477,6 +483,38 @@ func (a *ACL) AllowQuotaWrite() bool {
} }
} }
// AllowPluginRead checks if read operations are allowed for all plugins
func (a *ACL) AllowPluginRead() bool {
switch {
// ACL is nil only if ACLs are disabled
case a == nil:
return true
case a.management:
return true
case a.plugin == PolicyRead:
return true
default:
return false
}
}
// AllowPluginList checks if list operations are allowed for all plugins
func (a *ACL) AllowPluginList() bool {
switch {
// ACL is nil only if ACLs are disabled
case a == nil:
return true
case a.management:
return true
case a.plugin == PolicyList:
return true
case a.plugin == PolicyRead:
return true
default:
return false
}
}
// IsManagement checks if this represents a management token // IsManagement checks if this represents a management token
func (a *ACL) IsManagement() bool { func (a *ACL) IsManagement() bool {
return a.management return a.management

View File

@ -13,6 +13,7 @@ const (
// which always takes precedence and supercedes. // which always takes precedence and supercedes.
PolicyDeny = "deny" PolicyDeny = "deny"
PolicyRead = "read" PolicyRead = "read"
PolicyList = "list"
PolicyWrite = "write" PolicyWrite = "write"
) )
@ -22,17 +23,22 @@ const (
// combined we take the union of all capabilities. If the deny capability is present, it // combined we take the union of all capabilities. If the deny capability is present, it
// takes precedence and overwrites all other capabilities. // takes precedence and overwrites all other capabilities.
NamespaceCapabilityDeny = "deny" NamespaceCapabilityDeny = "deny"
NamespaceCapabilityListJobs = "list-jobs" NamespaceCapabilityListJobs = "list-jobs"
NamespaceCapabilityReadJob = "read-job" NamespaceCapabilityReadJob = "read-job"
NamespaceCapabilitySubmitJob = "submit-job" NamespaceCapabilitySubmitJob = "submit-job"
NamespaceCapabilityDispatchJob = "dispatch-job" NamespaceCapabilityDispatchJob = "dispatch-job"
NamespaceCapabilityReadLogs = "read-logs" NamespaceCapabilityReadLogs = "read-logs"
NamespaceCapabilityReadFS = "read-fs" NamespaceCapabilityReadFS = "read-fs"
NamespaceCapabilityAllocExec = "alloc-exec" NamespaceCapabilityAllocExec = "alloc-exec"
NamespaceCapabilityAllocNodeExec = "alloc-node-exec" NamespaceCapabilityAllocNodeExec = "alloc-node-exec"
NamespaceCapabilityAllocLifecycle = "alloc-lifecycle" NamespaceCapabilityAllocLifecycle = "alloc-lifecycle"
NamespaceCapabilitySentinelOverride = "sentinel-override" NamespaceCapabilitySentinelOverride = "sentinel-override"
NamespaceCapabilityCSIRegisterPlugin = "csi-register-plugin"
NamespaceCapabilityCSIWriteVolume = "csi-write-volume"
NamespaceCapabilityCSIReadVolume = "csi-read-volume"
NamespaceCapabilityCSIListVolume = "csi-list-volume"
NamespaceCapabilityCSIMountVolume = "csi-mount-volume"
) )
var ( var (
@ -62,6 +68,7 @@ type Policy struct {
Node *NodePolicy `hcl:"node"` Node *NodePolicy `hcl:"node"`
Operator *OperatorPolicy `hcl:"operator"` Operator *OperatorPolicy `hcl:"operator"`
Quota *QuotaPolicy `hcl:"quota"` Quota *QuotaPolicy `hcl:"quota"`
Plugin *PluginPolicy `hcl:"plugin"`
Raw string `hcl:"-"` Raw string `hcl:"-"`
} }
@ -73,7 +80,8 @@ func (p *Policy) IsEmpty() bool {
p.Agent == nil && p.Agent == nil &&
p.Node == nil && p.Node == nil &&
p.Operator == nil && p.Operator == nil &&
p.Quota == nil p.Quota == nil &&
p.Plugin == nil
} }
// NamespacePolicy is the policy for a specific namespace // NamespacePolicy is the policy for a specific namespace
@ -106,6 +114,10 @@ type QuotaPolicy struct {
Policy string Policy string
} }
type PluginPolicy struct {
Policy string
}
// isPolicyValid makes sure the given string matches one of the valid policies. // isPolicyValid makes sure the given string matches one of the valid policies.
func isPolicyValid(policy string) bool { func isPolicyValid(policy string) bool {
switch policy { switch policy {
@ -116,13 +128,23 @@ func isPolicyValid(policy string) bool {
} }
} }
func (p *PluginPolicy) isValid() bool {
switch p.Policy {
case PolicyDeny, PolicyRead, PolicyList:
return true
default:
return false
}
}
// isNamespaceCapabilityValid ensures the given capability is valid for a namespace policy // isNamespaceCapabilityValid ensures the given capability is valid for a namespace policy
func isNamespaceCapabilityValid(cap string) bool { func isNamespaceCapabilityValid(cap string) bool {
switch cap { switch cap {
case NamespaceCapabilityDeny, NamespaceCapabilityListJobs, NamespaceCapabilityReadJob, case NamespaceCapabilityDeny, NamespaceCapabilityListJobs, NamespaceCapabilityReadJob,
NamespaceCapabilitySubmitJob, NamespaceCapabilityDispatchJob, NamespaceCapabilityReadLogs, NamespaceCapabilitySubmitJob, NamespaceCapabilityDispatchJob, NamespaceCapabilityReadLogs,
NamespaceCapabilityReadFS, NamespaceCapabilityAllocLifecycle, NamespaceCapabilityReadFS, NamespaceCapabilityAllocLifecycle,
NamespaceCapabilityAllocExec, NamespaceCapabilityAllocNodeExec: NamespaceCapabilityAllocExec, NamespaceCapabilityAllocNodeExec,
NamespaceCapabilityCSIReadVolume, NamespaceCapabilityCSIWriteVolume, NamespaceCapabilityCSIListVolume, NamespaceCapabilityCSIMountVolume, NamespaceCapabilityCSIRegisterPlugin:
return true return true
// Separate the enterprise-only capabilities // Separate the enterprise-only capabilities
case NamespaceCapabilitySentinelOverride: case NamespaceCapabilitySentinelOverride:
@ -135,25 +157,31 @@ func isNamespaceCapabilityValid(cap string) bool {
// expandNamespacePolicy provides the equivalent set of capabilities for // expandNamespacePolicy provides the equivalent set of capabilities for
// a namespace policy // a namespace policy
func expandNamespacePolicy(policy string) []string { func expandNamespacePolicy(policy string) []string {
read := []string{
NamespaceCapabilityListJobs,
NamespaceCapabilityReadJob,
NamespaceCapabilityCSIListVolume,
NamespaceCapabilityCSIReadVolume,
}
write := append(read, []string{
NamespaceCapabilitySubmitJob,
NamespaceCapabilityDispatchJob,
NamespaceCapabilityReadLogs,
NamespaceCapabilityReadFS,
NamespaceCapabilityAllocExec,
NamespaceCapabilityAllocLifecycle,
NamespaceCapabilityCSIMountVolume,
NamespaceCapabilityCSIWriteVolume,
}...)
switch policy { switch policy {
case PolicyDeny: case PolicyDeny:
return []string{NamespaceCapabilityDeny} return []string{NamespaceCapabilityDeny}
case PolicyRead: case PolicyRead:
return []string{ return read
NamespaceCapabilityListJobs,
NamespaceCapabilityReadJob,
}
case PolicyWrite: case PolicyWrite:
return []string{ return write
NamespaceCapabilityListJobs,
NamespaceCapabilityReadJob,
NamespaceCapabilitySubmitJob,
NamespaceCapabilityDispatchJob,
NamespaceCapabilityReadLogs,
NamespaceCapabilityReadFS,
NamespaceCapabilityAllocExec,
NamespaceCapabilityAllocLifecycle,
}
default: default:
return nil return nil
} }
@ -261,5 +289,9 @@ func Parse(rules string) (*Policy, error) {
if p.Quota != nil && !isPolicyValid(p.Quota.Policy) { if p.Quota != nil && !isPolicyValid(p.Quota.Policy) {
return nil, fmt.Errorf("Invalid quota policy: %#v", p.Quota) return nil, fmt.Errorf("Invalid quota policy: %#v", p.Quota)
} }
if p.Plugin != nil && !p.Plugin.isValid() {
return nil, fmt.Errorf("Invalid plugin policy: %#v", p.Plugin)
}
return p, nil return p, nil
} }

View File

@ -30,6 +30,8 @@ func TestParse(t *testing.T) {
Capabilities: []string{ Capabilities: []string{
NamespaceCapabilityListJobs, NamespaceCapabilityListJobs,
NamespaceCapabilityReadJob, NamespaceCapabilityReadJob,
NamespaceCapabilityCSIListVolume,
NamespaceCapabilityCSIReadVolume,
}, },
}, },
}, },
@ -58,6 +60,9 @@ func TestParse(t *testing.T) {
quota { quota {
policy = "read" policy = "read"
} }
plugin {
policy = "read"
}
`, `,
"", "",
&Policy{ &Policy{
@ -68,6 +73,8 @@ func TestParse(t *testing.T) {
Capabilities: []string{ Capabilities: []string{
NamespaceCapabilityListJobs, NamespaceCapabilityListJobs,
NamespaceCapabilityReadJob, NamespaceCapabilityReadJob,
NamespaceCapabilityCSIListVolume,
NamespaceCapabilityCSIReadVolume,
}, },
}, },
{ {
@ -76,12 +83,16 @@ func TestParse(t *testing.T) {
Capabilities: []string{ Capabilities: []string{
NamespaceCapabilityListJobs, NamespaceCapabilityListJobs,
NamespaceCapabilityReadJob, NamespaceCapabilityReadJob,
NamespaceCapabilityCSIListVolume,
NamespaceCapabilityCSIReadVolume,
NamespaceCapabilitySubmitJob, NamespaceCapabilitySubmitJob,
NamespaceCapabilityDispatchJob, NamespaceCapabilityDispatchJob,
NamespaceCapabilityReadLogs, NamespaceCapabilityReadLogs,
NamespaceCapabilityReadFS, NamespaceCapabilityReadFS,
NamespaceCapabilityAllocExec, NamespaceCapabilityAllocExec,
NamespaceCapabilityAllocLifecycle, NamespaceCapabilityAllocLifecycle,
NamespaceCapabilityCSIMountVolume,
NamespaceCapabilityCSIWriteVolume,
}, },
}, },
{ {
@ -104,6 +115,9 @@ func TestParse(t *testing.T) {
Quota: &QuotaPolicy{ Quota: &QuotaPolicy{
Policy: PolicyRead, Policy: PolicyRead,
}, },
Plugin: &PluginPolicy{
Policy: PolicyRead,
},
}, },
}, },
{ {
@ -246,6 +260,28 @@ func TestParse(t *testing.T) {
"Invalid host volume name", "Invalid host volume name",
nil, nil,
}, },
{
`
plugin {
policy = "list"
}
`,
"",
&Policy{
Plugin: &PluginPolicy{
Policy: PolicyList,
},
},
},
{
`
plugin {
policy = "reader"
}
`,
"Invalid plugin policy",
nil,
},
} }
for idx, tc := range tcases { for idx, tc := range tcases {

View File

@ -399,6 +399,36 @@ type NodeScoreMeta struct {
NormScore float64 NormScore float64
} }
// Stub returns a list stub for the allocation
func (a *Allocation) Stub() *AllocationListStub {
return &AllocationListStub{
ID: a.ID,
EvalID: a.EvalID,
Name: a.Name,
Namespace: a.Namespace,
NodeID: a.NodeID,
NodeName: a.NodeName,
JobID: a.JobID,
JobType: *a.Job.Type,
JobVersion: *a.Job.Version,
TaskGroup: a.TaskGroup,
DesiredStatus: a.DesiredStatus,
DesiredDescription: a.DesiredDescription,
ClientStatus: a.ClientStatus,
ClientDescription: a.ClientDescription,
TaskStates: a.TaskStates,
DeploymentStatus: a.DeploymentStatus,
FollowupEvalID: a.FollowupEvalID,
RescheduleTracker: a.RescheduleTracker,
PreemptedAllocations: a.PreemptedAllocations,
PreemptedByAllocation: a.PreemptedByAllocation,
CreateIndex: a.CreateIndex,
ModifyIndex: a.ModifyIndex,
CreateTime: a.CreateTime,
ModifyTime: a.ModifyTime,
}
}
// AllocationListStub is used to return a subset of an allocation // AllocationListStub is used to return a subset of an allocation
// during list operations. // during list operations.
type AllocationListStub struct { type AllocationListStub struct {
@ -477,18 +507,23 @@ func (a AllocIndexSort) Swap(i, j int) {
a[i], a[j] = a[j], a[i] a[i], a[j] = a[j], a[i]
} }
func (a Allocation) GetTaskGroup() *TaskGroup {
for _, tg := range a.Job.TaskGroups {
if *tg.Name == a.TaskGroup {
return tg
}
}
return nil
}
// RescheduleInfo is used to calculate remaining reschedule attempts // RescheduleInfo is used to calculate remaining reschedule attempts
// according to the given time and the task groups reschedule policy // according to the given time and the task groups reschedule policy
func (a Allocation) RescheduleInfo(t time.Time) (int, int) { func (a Allocation) RescheduleInfo(t time.Time) (int, int) {
var reschedulePolicy *ReschedulePolicy tg := a.GetTaskGroup()
for _, tg := range a.Job.TaskGroups { if tg == nil || tg.ReschedulePolicy == nil {
if *tg.Name == a.TaskGroup {
reschedulePolicy = tg.ReschedulePolicy
}
}
if reschedulePolicy == nil {
return 0, 0 return 0, 0
} }
reschedulePolicy := tg.ReschedulePolicy
availableAttempts := *reschedulePolicy.Attempts availableAttempts := *reschedulePolicy.Attempts
interval := *reschedulePolicy.Interval interval := *reschedulePolicy.Interval
attempted := 0 attempted := 0

View File

@ -11,5 +11,7 @@ const (
Nodes Context = "nodes" Nodes Context = "nodes"
Namespaces Context = "namespaces" Namespaces Context = "namespaces"
Quotas Context = "quotas" Quotas Context = "quotas"
Plugins Context = "plugins"
Volumes Context = "volumes"
All Context = "all" All Context = "all"
) )

256
api/csi.go Normal file
View File

@ -0,0 +1,256 @@
package api
import (
"sort"
"time"
)
// CSIVolumes is used to query the top level csi volumes
type CSIVolumes struct {
client *Client
}
// CSIVolumes returns a handle on the CSIVolumes endpoint
func (c *Client) CSIVolumes() *CSIVolumes {
return &CSIVolumes{client: c}
}
// List returns all CSI volumes
func (v *CSIVolumes) List(q *QueryOptions) ([]*CSIVolumeListStub, *QueryMeta, error) {
var resp []*CSIVolumeListStub
qm, err := v.client.query("/v1/volumes?type=csi", &resp, q)
if err != nil {
return nil, nil, err
}
sort.Sort(CSIVolumeIndexSort(resp))
return resp, qm, nil
}
// PluginList returns all CSI volumes for the specified plugin id
func (v *CSIVolumes) PluginList(pluginID string) ([]*CSIVolumeListStub, *QueryMeta, error) {
return v.List(&QueryOptions{Prefix: pluginID})
}
// Info is used to retrieve a single CSIVolume
func (v *CSIVolumes) Info(id string, q *QueryOptions) (*CSIVolume, *QueryMeta, error) {
var resp CSIVolume
qm, err := v.client.query("/v1/volume/csi/"+id, &resp, q)
if err != nil {
return nil, nil, err
}
// Cleanup allocation representation for the ui
resp.allocs()
return &resp, qm, nil
}
func (v *CSIVolumes) Register(vol *CSIVolume, w *WriteOptions) (*WriteMeta, error) {
req := CSIVolumeRegisterRequest{
Volumes: []*CSIVolume{vol},
}
meta, err := v.client.write("/v1/volume/csi/"+vol.ID, req, nil, w)
return meta, err
}
func (v *CSIVolumes) Deregister(id string, w *WriteOptions) error {
_, err := v.client.delete("/v1/volume/csi/"+id, nil, w)
return err
}
// CSIVolumeAttachmentMode duplicated in nomad/structs/csi.go
type CSIVolumeAttachmentMode string
const (
CSIVolumeAttachmentModeUnknown CSIVolumeAttachmentMode = ""
CSIVolumeAttachmentModeBlockDevice CSIVolumeAttachmentMode = "block-device"
CSIVolumeAttachmentModeFilesystem CSIVolumeAttachmentMode = "file-system"
)
// CSIVolumeAccessMode duplicated in nomad/structs/csi.go
type CSIVolumeAccessMode string
const (
CSIVolumeAccessModeUnknown CSIVolumeAccessMode = ""
CSIVolumeAccessModeSingleNodeReader CSIVolumeAccessMode = "single-node-reader-only"
CSIVolumeAccessModeSingleNodeWriter CSIVolumeAccessMode = "single-node-writer"
CSIVolumeAccessModeMultiNodeReader CSIVolumeAccessMode = "multi-node-reader-only"
CSIVolumeAccessModeMultiNodeSingleWriter CSIVolumeAccessMode = "multi-node-single-writer"
CSIVolumeAccessModeMultiNodeMultiWriter CSIVolumeAccessMode = "multi-node-multi-writer"
)
type CSIMountOptions struct {
FSType string `hcl:"fs_type"`
MountFlags []string `hcl:"mount_flags"`
ExtraKeysHCL []string `hcl:",unusedKeys" json:"-"` // report unexpected keys
}
// CSIVolume is used for serialization, see also nomad/structs/csi.go
type CSIVolume struct {
ID string
Name string
ExternalID string `hcl:"external_id"`
Namespace string
Topologies []*CSITopology
AccessMode CSIVolumeAccessMode `hcl:"access_mode"`
AttachmentMode CSIVolumeAttachmentMode `hcl:"attachment_mode"`
MountOptions *CSIMountOptions `hcl:"mount_options"`
// Allocations, tracking claim status
ReadAllocs map[string]*Allocation
WriteAllocs map[string]*Allocation
// Combine structs.{Read,Write}Allocs
Allocations []*AllocationListStub
// Schedulable is true if all the denormalized plugin health fields are true
Schedulable bool
PluginID string `hcl:"plugin_id"`
Provider string
ProviderVersion string
ControllerRequired bool
ControllersHealthy int
ControllersExpected int
NodesHealthy int
NodesExpected int
ResourceExhausted time.Time
CreateIndex uint64
ModifyIndex uint64
// ExtraKeysHCL is used by the hcl parser to report unexpected keys
ExtraKeysHCL []string `hcl:",unusedKeys" json:"-"`
}
// allocs is called after we query the volume (creating this CSIVolume struct) to collapse
// allocations for the UI
func (v *CSIVolume) allocs() {
for _, a := range v.WriteAllocs {
v.Allocations = append(v.Allocations, a.Stub())
}
for _, a := range v.ReadAllocs {
v.Allocations = append(v.Allocations, a.Stub())
}
}
type CSIVolumeIndexSort []*CSIVolumeListStub
func (v CSIVolumeIndexSort) Len() int {
return len(v)
}
func (v CSIVolumeIndexSort) Less(i, j int) bool {
return v[i].CreateIndex > v[j].CreateIndex
}
func (v CSIVolumeIndexSort) Swap(i, j int) {
v[i], v[j] = v[j], v[i]
}
// CSIVolumeListStub omits allocations. See also nomad/structs/csi.go
type CSIVolumeListStub struct {
ID string
Namespace string
Name string
ExternalID string
Topologies []*CSITopology
AccessMode CSIVolumeAccessMode
AttachmentMode CSIVolumeAttachmentMode
MountOptions *CSIMountOptions
Schedulable bool
PluginID string
Provider string
ControllerRequired bool
ControllersHealthy int
ControllersExpected int
NodesHealthy int
NodesExpected int
ResourceExhausted time.Time
CreateIndex uint64
ModifyIndex uint64
}
type CSIVolumeRegisterRequest struct {
Volumes []*CSIVolume
WriteRequest
}
type CSIVolumeDeregisterRequest struct {
VolumeIDs []string
WriteRequest
}
// CSI Plugins are jobs with plugin specific data
type CSIPlugins struct {
client *Client
}
type CSIPlugin struct {
ID string
Provider string
Version string
ControllerRequired bool
// Map Node.ID to CSIInfo fingerprint results
Controllers map[string]*CSIInfo
Nodes map[string]*CSIInfo
Allocations []*AllocationListStub
ControllersHealthy int
NodesHealthy int
CreateIndex uint64
ModifyIndex uint64
}
type CSIPluginListStub struct {
ID string
Provider string
ControllerRequired bool
ControllersHealthy int
ControllersExpected int
NodesHealthy int
NodesExpected int
CreateIndex uint64
ModifyIndex uint64
}
type CSIPluginIndexSort []*CSIPluginListStub
func (v CSIPluginIndexSort) Len() int {
return len(v)
}
func (v CSIPluginIndexSort) Less(i, j int) bool {
return v[i].CreateIndex > v[j].CreateIndex
}
func (v CSIPluginIndexSort) Swap(i, j int) {
v[i], v[j] = v[j], v[i]
}
// CSIPlugins returns a handle on the CSIPlugins endpoint
func (c *Client) CSIPlugins() *CSIPlugins {
return &CSIPlugins{client: c}
}
// List returns all CSI plugins
func (v *CSIPlugins) List(q *QueryOptions) ([]*CSIPluginListStub, *QueryMeta, error) {
var resp []*CSIPluginListStub
qm, err := v.client.query("/v1/plugins?type=csi", &resp, q)
if err != nil {
return nil, nil, err
}
sort.Sort(CSIPluginIndexSort(resp))
return resp, qm, nil
}
// Info is used to retrieve a single CSI Plugin Job
func (v *CSIPlugins) Info(id string, q *QueryOptions) (*CSIPlugin, *QueryMeta, error) {
var resp *CSIPlugin
qm, err := v.client.query("/v1/plugin/csi/"+id, &resp, q)
if err != nil {
return nil, nil, err
}
return resp, qm, nil
}

101
api/csi_test.go Normal file
View File

@ -0,0 +1,101 @@
package api
import (
"testing"
"github.com/stretchr/testify/require"
)
// TestCSIVolumes_CRUD fails because of a combination of removing the job to plugin creation
// pathway and checking for plugin existence (but not yet health) at registration time.
// There are two possible solutions:
// 1. Expose the test server RPC server and force a Node.Update to fingerprint a plugin
// 2. Build and deploy a dummy CSI plugin via a job, and have it really fingerprint
func TestCSIVolumes_CRUD(t *testing.T) {
t.Parallel()
c, s, root := makeACLClient(t, nil, nil)
defer s.Stop()
v := c.CSIVolumes()
// Successful empty result
vols, qm, err := v.List(nil)
require.NoError(t, err)
require.NotEqual(t, 0, qm.LastIndex)
require.Equal(t, 0, len(vols))
// FIXME we're bailing out here until one of the fixes is available
return
// Authorized QueryOpts. Use the root token to just bypass ACL details
opts := &QueryOptions{
Region: "global",
Namespace: "default",
AuthToken: root.SecretID,
}
wpts := &WriteOptions{
Region: "global",
Namespace: "default",
AuthToken: root.SecretID,
}
// Create node plugins
nodes, _, err := c.Nodes().List(nil)
require.NoError(t, err)
require.Equal(t, 1, len(nodes))
nodeStub := nodes[0]
node, _, err := c.Nodes().Info(nodeStub.ID, nil)
require.NoError(t, err)
node.CSINodePlugins = map[string]*CSIInfo{
"foo": {
PluginID: "foo",
Healthy: true,
RequiresControllerPlugin: false,
RequiresTopologies: false,
NodeInfo: &CSINodeInfo{
ID: nodeStub.ID,
MaxVolumes: 200,
},
},
}
// Register a volume
// This id is here as a string to avoid importing helper, which causes the lint
// rule that checks that the api package is isolated to fail
id := "DEADBEEF-31B5-8F78-7986-DD404FDA0CD1"
_, err = v.Register(&CSIVolume{
ID: id,
Namespace: "default",
PluginID: "foo",
AccessMode: CSIVolumeAccessModeMultiNodeSingleWriter,
AttachmentMode: CSIVolumeAttachmentModeFilesystem,
Topologies: []*CSITopology{{Segments: map[string]string{"foo": "bar"}}},
}, wpts)
require.NoError(t, err)
// Successful result with volumes
vols, qm, err = v.List(opts)
require.NoError(t, err)
require.NotEqual(t, 0, qm.LastIndex)
require.Equal(t, 1, len(vols))
// Successful info query
vol, qm, err := v.Info(id, opts)
require.NoError(t, err)
require.Equal(t, "bar", vol.Topologies[0].Segments["foo"])
// Deregister the volume
err = v.Deregister(id, wpts)
require.NoError(t, err)
// Successful empty result
vols, qm, err = v.List(nil)
require.NoError(t, err)
require.NotEqual(t, 0, qm.LastIndex)
require.Equal(t, 0, len(vols))
// Failed info query
vol, qm, err = v.Info(id, opts)
require.Error(t, err, "missing")
}

View File

@ -392,6 +392,16 @@ func (n *Nodes) Allocations(nodeID string, q *QueryOptions) ([]*Allocation, *Que
return resp, qm, nil return resp, qm, nil
} }
func (n *Nodes) CSIVolumes(nodeID string, q *QueryOptions) ([]*CSIVolumeListStub, error) {
var resp []*CSIVolumeListStub
path := fmt.Sprintf("/v1/volumes?type=csi&node_id=%s", nodeID)
if _, err := n.client.query(path, &resp, q); err != nil {
return nil, err
}
return resp, nil
}
// ForceEvaluate is used to force-evaluate an existing node. // ForceEvaluate is used to force-evaluate an existing node.
func (n *Nodes) ForceEvaluate(nodeID string, q *WriteOptions) (string, *WriteMeta, error) { func (n *Nodes) ForceEvaluate(nodeID string, q *WriteOptions) (string, *WriteMeta, error) {
var resp nodeEvalResponse var resp nodeEvalResponse
@ -464,6 +474,8 @@ type Node struct {
Events []*NodeEvent Events []*NodeEvent
Drivers map[string]*DriverInfo Drivers map[string]*DriverInfo
HostVolumes map[string]*HostVolumeInfo HostVolumes map[string]*HostVolumeInfo
CSIControllerPlugins map[string]*CSIInfo
CSINodePlugins map[string]*CSIInfo
CreateIndex uint64 CreateIndex uint64
ModifyIndex uint64 ModifyIndex uint64
} }
@ -511,6 +523,41 @@ type NodeReservedNetworkResources struct {
ReservedHostPorts string ReservedHostPorts string
} }
type CSITopology struct {
Segments map[string]string
}
// CSINodeInfo is the fingerprinted data from a CSI Plugin that is specific to
// the Node API.
type CSINodeInfo struct {
ID string
MaxVolumes int64
AccessibleTopology *CSITopology
RequiresNodeStageVolume bool
}
// CSIControllerInfo is the fingerprinted data from a CSI Plugin that is specific to
// the Controller API.
type CSIControllerInfo struct {
SupportsReadOnlyAttach bool
SupportsAttachDetach bool
SupportsListVolumes bool
SupportsListVolumesAttachedNodes bool
}
// CSIInfo is the current state of a single CSI Plugin. This is updated regularly
// as plugin health changes on the node.
type CSIInfo struct {
PluginID string
Healthy bool
HealthDescription string
UpdateTime time.Time
RequiresControllerPlugin bool
RequiresTopologies bool
ControllerInfo *CSIControllerInfo `json:",omitempty"`
NodeInfo *CSINodeInfo `json:",omitempty"`
}
// DrainStrategy describes a Node's drain behavior. // DrainStrategy describes a Node's drain behavior.
type DrainStrategy struct { type DrainStrategy struct {
// DrainSpec is the user declared drain specification // DrainSpec is the user declared drain specification

View File

@ -377,10 +377,12 @@ func (m *MigrateStrategy) Copy() *MigrateStrategy {
// VolumeRequest is a representation of a storage volume that a TaskGroup wishes to use. // VolumeRequest is a representation of a storage volume that a TaskGroup wishes to use.
type VolumeRequest struct { type VolumeRequest struct {
Name string Name string
Type string Type string
Source string Source string
ReadOnly bool `mapstructure:"read_only"` ReadOnly bool `hcl:"read_only"`
MountOptions *CSIMountOptions `hcl:"mount_options"`
ExtraKeysHCL []string `hcl:",unusedKeys" json:"-"`
} }
const ( const (
@ -643,6 +645,7 @@ type Task struct {
Templates []*Template Templates []*Template
DispatchPayload *DispatchPayloadConfig DispatchPayload *DispatchPayloadConfig
VolumeMounts []*VolumeMount VolumeMounts []*VolumeMount
CSIPluginConfig *TaskCSIPluginConfig `mapstructure:"csi_plugin" json:"csi_plugin,omitempty"`
Leader bool Leader bool
ShutdownDelay time.Duration `mapstructure:"shutdown_delay"` ShutdownDelay time.Duration `mapstructure:"shutdown_delay"`
KillSignal string `mapstructure:"kill_signal"` KillSignal string `mapstructure:"kill_signal"`
@ -683,6 +686,9 @@ func (t *Task) Canonicalize(tg *TaskGroup, job *Job) {
if t.Lifecycle.Empty() { if t.Lifecycle.Empty() {
t.Lifecycle = nil t.Lifecycle = nil
} }
if t.CSIPluginConfig != nil {
t.CSIPluginConfig.Canonicalize()
}
} }
// TaskArtifact is used to download artifacts before running a task. // TaskArtifact is used to download artifacts before running a task.
@ -909,3 +915,48 @@ type TaskEvent struct {
TaskSignal string TaskSignal string
GenericSource string GenericSource string
} }
// CSIPluginType is an enum string that encapsulates the valid options for a
// CSIPlugin stanza's Type. These modes will allow the plugin to be used in
// different ways by the client.
type CSIPluginType string
const (
// CSIPluginTypeNode indicates that Nomad should only use the plugin for
// performing Node RPCs against the provided plugin.
CSIPluginTypeNode CSIPluginType = "node"
// CSIPluginTypeController indicates that Nomad should only use the plugin for
// performing Controller RPCs against the provided plugin.
CSIPluginTypeController CSIPluginType = "controller"
// CSIPluginTypeMonolith indicates that Nomad can use the provided plugin for
// both controller and node rpcs.
CSIPluginTypeMonolith CSIPluginType = "monolith"
)
// TaskCSIPluginConfig contains the data that is required to setup a task as a
// CSI plugin. This will be used by the csi_plugin_supervisor_hook to configure
// mounts for the plugin and initiate the connection to the plugin catalog.
type TaskCSIPluginConfig struct {
// ID is the identifier of the plugin.
// Ideally this should be the FQDN of the plugin.
ID string `mapstructure:"id"`
// CSIPluginType instructs Nomad on how to handle processing a plugin
Type CSIPluginType `mapstructure:"type"`
// MountDir is the destination that nomad should mount in its CSI
// directory for the plugin. It will then expect a file called CSISocketName
// to be created by the plugin, and will provide references into
// "MountDir/CSIIntermediaryDirname/VolumeName/AllocID for mounts.
//
// Default is /csi.
MountDir string `mapstructure:"mount_dir"`
}
func (t *TaskCSIPluginConfig) Canonicalize() {
if t.MountDir == "" {
t.MountDir = "/csi"
}
}

View File

@ -17,7 +17,9 @@ import (
"github.com/hashicorp/nomad/client/config" "github.com/hashicorp/nomad/client/config"
"github.com/hashicorp/nomad/client/consul" "github.com/hashicorp/nomad/client/consul"
"github.com/hashicorp/nomad/client/devicemanager" "github.com/hashicorp/nomad/client/devicemanager"
"github.com/hashicorp/nomad/client/dynamicplugins"
cinterfaces "github.com/hashicorp/nomad/client/interfaces" cinterfaces "github.com/hashicorp/nomad/client/interfaces"
"github.com/hashicorp/nomad/client/pluginmanager/csimanager"
"github.com/hashicorp/nomad/client/pluginmanager/drivermanager" "github.com/hashicorp/nomad/client/pluginmanager/drivermanager"
cstate "github.com/hashicorp/nomad/client/state" cstate "github.com/hashicorp/nomad/client/state"
cstructs "github.com/hashicorp/nomad/client/structs" cstructs "github.com/hashicorp/nomad/client/structs"
@ -118,6 +120,10 @@ type allocRunner struct {
// transistions. // transistions.
runnerHooks []interfaces.RunnerHook runnerHooks []interfaces.RunnerHook
// hookState is the output of allocrunner hooks
hookState *cstructs.AllocHookResources
hookStateMu sync.RWMutex
// tasks are the set of task runners // tasks are the set of task runners
tasks map[string]*taskrunner.TaskRunner tasks map[string]*taskrunner.TaskRunner
@ -134,6 +140,14 @@ type allocRunner struct {
// prevAllocMigrator allows the migration of a previous allocations alloc dir. // prevAllocMigrator allows the migration of a previous allocations alloc dir.
prevAllocMigrator allocwatcher.PrevAllocMigrator prevAllocMigrator allocwatcher.PrevAllocMigrator
// dynamicRegistry contains all locally registered dynamic plugins (e.g csi
// plugins).
dynamicRegistry dynamicplugins.Registry
// csiManager is used to wait for CSI Volumes to be attached, and by the task
// runner to manage their mounting
csiManager csimanager.Manager
// devicemanager is used to mount devices as well as lookup device // devicemanager is used to mount devices as well as lookup device
// statistics // statistics
devicemanager devicemanager.Manager devicemanager devicemanager.Manager
@ -148,6 +162,15 @@ type allocRunner struct {
serversContactedCh chan struct{} serversContactedCh chan struct{}
taskHookCoordinator *taskHookCoordinator taskHookCoordinator *taskHookCoordinator
// rpcClient is the RPC Client that should be used by the allocrunner and its
// hooks to communicate with Nomad Servers.
rpcClient RPCer
}
// RPCer is the interface needed by hooks to make RPC calls.
type RPCer interface {
RPC(method string, args interface{}, reply interface{}) error
} }
// NewAllocRunner returns a new allocation runner. // NewAllocRunner returns a new allocation runner.
@ -178,9 +201,12 @@ func NewAllocRunner(config *Config) (*allocRunner, error) {
deviceStatsReporter: config.DeviceStatsReporter, deviceStatsReporter: config.DeviceStatsReporter,
prevAllocWatcher: config.PrevAllocWatcher, prevAllocWatcher: config.PrevAllocWatcher,
prevAllocMigrator: config.PrevAllocMigrator, prevAllocMigrator: config.PrevAllocMigrator,
dynamicRegistry: config.DynamicRegistry,
csiManager: config.CSIManager,
devicemanager: config.DeviceManager, devicemanager: config.DeviceManager,
driverManager: config.DriverManager, driverManager: config.DriverManager,
serversContactedCh: config.ServersContactedCh, serversContactedCh: config.ServersContactedCh,
rpcClient: config.RPCClient,
} }
// Create the logger based on the allocation ID // Create the logger based on the allocation ID
@ -218,10 +244,12 @@ func (ar *allocRunner) initTaskRunners(tasks []*structs.Task) error {
Logger: ar.logger, Logger: ar.logger,
StateDB: ar.stateDB, StateDB: ar.stateDB,
StateUpdater: ar, StateUpdater: ar,
DynamicRegistry: ar.dynamicRegistry,
Consul: ar.consulClient, Consul: ar.consulClient,
ConsulSI: ar.sidsClient, ConsulSI: ar.sidsClient,
Vault: ar.vaultClient, Vault: ar.vaultClient,
DeviceStatsReporter: ar.deviceStatsReporter, DeviceStatsReporter: ar.deviceStatsReporter,
CSIManager: ar.csiManager,
DeviceManager: ar.devicemanager, DeviceManager: ar.devicemanager,
DriverManager: ar.driverManager, DriverManager: ar.driverManager,
ServersContactedCh: ar.serversContactedCh, ServersContactedCh: ar.serversContactedCh,

View File

@ -7,11 +7,41 @@ import (
multierror "github.com/hashicorp/go-multierror" multierror "github.com/hashicorp/go-multierror"
"github.com/hashicorp/nomad/client/allocrunner/interfaces" "github.com/hashicorp/nomad/client/allocrunner/interfaces"
clientconfig "github.com/hashicorp/nomad/client/config" clientconfig "github.com/hashicorp/nomad/client/config"
cstructs "github.com/hashicorp/nomad/client/structs"
"github.com/hashicorp/nomad/client/taskenv" "github.com/hashicorp/nomad/client/taskenv"
"github.com/hashicorp/nomad/nomad/structs" "github.com/hashicorp/nomad/nomad/structs"
"github.com/hashicorp/nomad/plugins/drivers" "github.com/hashicorp/nomad/plugins/drivers"
) )
type hookResourceSetter interface {
GetAllocHookResources() *cstructs.AllocHookResources
SetAllocHookResources(*cstructs.AllocHookResources)
}
type allocHookResourceSetter struct {
ar *allocRunner
}
func (a *allocHookResourceSetter) GetAllocHookResources() *cstructs.AllocHookResources {
a.ar.hookStateMu.RLock()
defer a.ar.hookStateMu.RUnlock()
return a.ar.hookState
}
func (a *allocHookResourceSetter) SetAllocHookResources(res *cstructs.AllocHookResources) {
a.ar.hookStateMu.Lock()
defer a.ar.hookStateMu.Unlock()
a.ar.hookState = res
// Propagate to all of the TRs within the lock to ensure consistent state.
// TODO: Refactor so TR's pull state from AR?
for _, tr := range a.ar.tasks {
tr.SetAllocHookResources(res)
}
}
type networkIsolationSetter interface { type networkIsolationSetter interface {
SetNetworkIsolation(*drivers.NetworkIsolationSpec) SetNetworkIsolation(*drivers.NetworkIsolationSpec)
} }
@ -105,6 +135,10 @@ func (ar *allocRunner) initRunnerHooks(config *clientconfig.Config) error {
// create network isolation setting shim // create network isolation setting shim
ns := &allocNetworkIsolationSetter{ar: ar} ns := &allocNetworkIsolationSetter{ar: ar}
// create hook resource setting shim
hrs := &allocHookResourceSetter{ar: ar}
hrs.SetAllocHookResources(&cstructs.AllocHookResources{})
// build the network manager // build the network manager
nm, err := newNetworkManager(ar.Alloc(), ar.driverManager) nm, err := newNetworkManager(ar.Alloc(), ar.driverManager)
if err != nil { if err != nil {
@ -134,6 +168,7 @@ func (ar *allocRunner) initRunnerHooks(config *clientconfig.Config) error {
logger: hookLogger, logger: hookLogger,
}), }),
newConsulSockHook(hookLogger, alloc, ar.allocDir, config.ConsulConfig), newConsulSockHook(hookLogger, alloc, ar.allocDir, config.ConsulConfig),
newCSIHook(hookLogger, alloc, ar.rpcClient, ar.csiManager, hrs),
} }
return nil return nil

View File

@ -6,7 +6,9 @@ import (
clientconfig "github.com/hashicorp/nomad/client/config" clientconfig "github.com/hashicorp/nomad/client/config"
"github.com/hashicorp/nomad/client/consul" "github.com/hashicorp/nomad/client/consul"
"github.com/hashicorp/nomad/client/devicemanager" "github.com/hashicorp/nomad/client/devicemanager"
"github.com/hashicorp/nomad/client/dynamicplugins"
"github.com/hashicorp/nomad/client/interfaces" "github.com/hashicorp/nomad/client/interfaces"
"github.com/hashicorp/nomad/client/pluginmanager/csimanager"
"github.com/hashicorp/nomad/client/pluginmanager/drivermanager" "github.com/hashicorp/nomad/client/pluginmanager/drivermanager"
cstate "github.com/hashicorp/nomad/client/state" cstate "github.com/hashicorp/nomad/client/state"
"github.com/hashicorp/nomad/client/vaultclient" "github.com/hashicorp/nomad/client/vaultclient"
@ -48,6 +50,14 @@ type Config struct {
// PrevAllocMigrator allows the migration of a previous allocations alloc dir // PrevAllocMigrator allows the migration of a previous allocations alloc dir
PrevAllocMigrator allocwatcher.PrevAllocMigrator PrevAllocMigrator allocwatcher.PrevAllocMigrator
// DynamicRegistry contains all locally registered dynamic plugins (e.g csi
// plugins).
DynamicRegistry dynamicplugins.Registry
// CSIManager is used to wait for CSI Volumes to be attached, and by the task
// runner to manage their mounting
CSIManager csimanager.Manager
// DeviceManager is used to mount devices as well as lookup device // DeviceManager is used to mount devices as well as lookup device
// statistics // statistics
DeviceManager devicemanager.Manager DeviceManager devicemanager.Manager
@ -58,4 +68,8 @@ type Config struct {
// ServersContactedCh is closed when the first GetClientAllocs call to // ServersContactedCh is closed when the first GetClientAllocs call to
// servers succeeds and allocs are synced. // servers succeeds and allocs are synced.
ServersContactedCh chan struct{} ServersContactedCh chan struct{}
// RPCClient is the RPC Client that should be used by the allocrunner and its
// hooks to communicate with Nomad Servers.
RPCClient RPCer
} }

View File

@ -0,0 +1,218 @@
package allocrunner
import (
"context"
"fmt"
hclog "github.com/hashicorp/go-hclog"
multierror "github.com/hashicorp/go-multierror"
"github.com/hashicorp/nomad/client/pluginmanager/csimanager"
"github.com/hashicorp/nomad/nomad/structs"
)
// csiHook will wait for remote csi volumes to be attached to the host before
// continuing.
//
// It is a noop for allocs that do not depend on CSI Volumes.
type csiHook struct {
alloc *structs.Allocation
logger hclog.Logger
csimanager csimanager.Manager
rpcClient RPCer
updater hookResourceSetter
}
func (c *csiHook) Name() string {
return "csi_hook"
}
func (c *csiHook) Prerun() error {
if !c.shouldRun() {
return nil
}
ctx := context.TODO()
volumes, err := c.claimVolumesFromAlloc()
if err != nil {
return fmt.Errorf("claim volumes: %v", err)
}
mounts := make(map[string]*csimanager.MountInfo, len(volumes))
for alias, pair := range volumes {
mounter, err := c.csimanager.MounterForVolume(ctx, pair.volume)
if err != nil {
return err
}
usageOpts := &csimanager.UsageOptions{
ReadOnly: pair.request.ReadOnly,
AttachmentMode: string(pair.volume.AttachmentMode),
AccessMode: string(pair.volume.AccessMode),
MountOptions: pair.request.MountOptions,
}
mountInfo, err := mounter.MountVolume(ctx, pair.volume, c.alloc, usageOpts, pair.publishContext)
if err != nil {
return err
}
mounts[alias] = mountInfo
}
res := c.updater.GetAllocHookResources()
res.CSIMounts = mounts
c.updater.SetAllocHookResources(res)
return nil
}
func (c *csiHook) Postrun() error {
if !c.shouldRun() {
return nil
}
ctx := context.TODO()
volumes, err := c.csiVolumesFromAlloc()
if err != nil {
return err
}
// For Postrun, we accumulate all unmount errors, rather than stopping on the
// first failure. This is because we want to make a best effort to free all
// storage, and in some cases there may be incorrect errors from volumes that
// never mounted correctly during prerun when an alloc is failed. It may also
// fail because a volume was externally deleted while in use by this alloc.
var result *multierror.Error
for _, pair := range volumes {
mounter, err := c.csimanager.MounterForVolume(ctx, pair.volume)
if err != nil {
result = multierror.Append(result, err)
continue
}
usageOpts := &csimanager.UsageOptions{
ReadOnly: pair.request.ReadOnly,
AttachmentMode: string(pair.volume.AttachmentMode),
AccessMode: string(pair.volume.AccessMode),
}
err = mounter.UnmountVolume(ctx, pair.volume, c.alloc, usageOpts)
if err != nil {
result = multierror.Append(result, err)
continue
}
}
return result.ErrorOrNil()
}
type volumeAndRequest struct {
volume *structs.CSIVolume
request *structs.VolumeRequest
// When volumeAndRequest was returned from a volume claim, this field will be
// populated for plugins that require it.
publishContext map[string]string
}
// claimVolumesFromAlloc is used by the pre-run hook to fetch all of the volume
// metadata and claim it for use by this alloc/node at the same time.
func (c *csiHook) claimVolumesFromAlloc() (map[string]*volumeAndRequest, error) {
result := make(map[string]*volumeAndRequest)
tg := c.alloc.Job.LookupTaskGroup(c.alloc.TaskGroup)
// Initially, populate the result map with all of the requests
for alias, volumeRequest := range tg.Volumes {
if volumeRequest.Type == structs.VolumeTypeCSI {
result[alias] = &volumeAndRequest{request: volumeRequest}
}
}
// Iterate over the result map and upsert the volume field as each volume gets
// claimed by the server.
for alias, pair := range result {
claimType := structs.CSIVolumeClaimWrite
if pair.request.ReadOnly {
claimType = structs.CSIVolumeClaimRead
}
req := &structs.CSIVolumeClaimRequest{
VolumeID: pair.request.Source,
AllocationID: c.alloc.ID,
Claim: claimType,
}
req.Region = c.alloc.Job.Region
var resp structs.CSIVolumeClaimResponse
if err := c.rpcClient.RPC("CSIVolume.Claim", req, &resp); err != nil {
return nil, err
}
if resp.Volume == nil {
return nil, fmt.Errorf("Unexpected nil volume returned for ID: %v", pair.request.Source)
}
result[alias].volume = resp.Volume
result[alias].publishContext = resp.PublishContext
}
return result, nil
}
// csiVolumesFromAlloc finds all the CSI Volume requests from the allocation's
// task group and then fetches them from the Nomad Server, before returning
// them in the form of map[RequestedAlias]*volumeAndReqest. This allows us to
// thread the request context through to determine usage options for each volume.
//
// If any volume fails to validate then we return an error.
func (c *csiHook) csiVolumesFromAlloc() (map[string]*volumeAndRequest, error) {
vols := make(map[string]*volumeAndRequest)
tg := c.alloc.Job.LookupTaskGroup(c.alloc.TaskGroup)
for alias, vol := range tg.Volumes {
if vol.Type == structs.VolumeTypeCSI {
vols[alias] = &volumeAndRequest{request: vol}
}
}
for alias, pair := range vols {
req := &structs.CSIVolumeGetRequest{
ID: pair.request.Source,
}
req.Region = c.alloc.Job.Region
var resp structs.CSIVolumeGetResponse
if err := c.rpcClient.RPC("CSIVolume.Get", req, &resp); err != nil {
return nil, err
}
if resp.Volume == nil {
return nil, fmt.Errorf("Unexpected nil volume returned for ID: %v", pair.request.Source)
}
vols[alias].volume = resp.Volume
}
return vols, nil
}
func newCSIHook(logger hclog.Logger, alloc *structs.Allocation, rpcClient RPCer, csi csimanager.Manager, updater hookResourceSetter) *csiHook {
return &csiHook{
alloc: alloc,
logger: logger.Named("csi_hook"),
rpcClient: rpcClient,
csimanager: csi,
updater: updater,
}
}
func (h *csiHook) shouldRun() bool {
tg := h.alloc.Job.LookupTaskGroup(h.alloc.TaskGroup)
for _, vol := range tg.Volumes {
if vol.Type == structs.VolumeTypeCSI {
return true
}
}
return false
}

View File

@ -0,0 +1,373 @@
package taskrunner
import (
"context"
"fmt"
"os"
"path/filepath"
"sync"
"time"
hclog "github.com/hashicorp/go-hclog"
"github.com/hashicorp/nomad/client/allocrunner/interfaces"
ti "github.com/hashicorp/nomad/client/allocrunner/taskrunner/interfaces"
"github.com/hashicorp/nomad/client/dynamicplugins"
"github.com/hashicorp/nomad/nomad/structs"
"github.com/hashicorp/nomad/plugins/csi"
"github.com/hashicorp/nomad/plugins/drivers"
)
// csiPluginSupervisorHook manages supervising plugins that are running as Nomad
// tasks. These plugins will be fingerprinted and it will manage connecting them
// to their requisite plugin manager.
//
// It provides a couple of things to a task running inside Nomad. These are:
// * A mount to the `plugin_mount_dir`, that will then be used by Nomad
// to connect to the nested plugin and handle volume mounts.
// * When the task has started, it starts a loop of attempting to connect to the
// plugin, to perform initial fingerprinting of the plugins capabilities before
// notifying the plugin manager of the plugin.
type csiPluginSupervisorHook struct {
logger hclog.Logger
alloc *structs.Allocation
task *structs.Task
runner *TaskRunner
mountPoint string
// eventEmitter is used to emit events to the task
eventEmitter ti.EventEmitter
shutdownCtx context.Context
shutdownCancelFn context.CancelFunc
running bool
runningLock sync.Mutex
// previousHealthstate is used by the supervisor goroutine to track historic
// health states for gating task events.
previousHealthState bool
}
// The plugin supervisor uses the PrestartHook mechanism to setup the requisite
// mount points and configuration for the task that exposes a CSI plugin.
var _ interfaces.TaskPrestartHook = &csiPluginSupervisorHook{}
// The plugin supervisor uses the PoststartHook mechanism to start polling the
// plugin for readiness and supported functionality before registering the
// plugin with the catalog.
var _ interfaces.TaskPoststartHook = &csiPluginSupervisorHook{}
// The plugin supervisor uses the StopHook mechanism to deregister the plugin
// with the catalog and to ensure any mounts are cleaned up.
var _ interfaces.TaskStopHook = &csiPluginSupervisorHook{}
func newCSIPluginSupervisorHook(csiRootDir string, eventEmitter ti.EventEmitter, runner *TaskRunner, logger hclog.Logger) *csiPluginSupervisorHook {
task := runner.Task()
// The Plugin directory will look something like this:
// .
// ..
// csi.sock - A unix domain socket used to communicate with the CSI Plugin
// staging/
// {volume-id}/{usage-mode-hash}/ - Intermediary mount point that will be used by plugins that support NODE_STAGE_UNSTAGE capabilities.
// per-alloc/
// {alloc-id}/{volume-id}/{usage-mode-hash}/ - Mount Point that will be bind-mounted into tasks that utilise the volume
pluginRoot := filepath.Join(csiRootDir, string(task.CSIPluginConfig.Type), task.CSIPluginConfig.ID)
shutdownCtx, cancelFn := context.WithCancel(context.Background())
hook := &csiPluginSupervisorHook{
alloc: runner.Alloc(),
runner: runner,
logger: logger,
task: task,
mountPoint: pluginRoot,
shutdownCtx: shutdownCtx,
shutdownCancelFn: cancelFn,
eventEmitter: eventEmitter,
}
return hook
}
func (*csiPluginSupervisorHook) Name() string {
return "csi_plugin_supervisor"
}
// Prestart is called before the task is started including after every
// restart. This requires that the mount paths for a plugin be idempotent,
// despite us not knowing the name of the plugin ahead of time.
// Because of this, we use the allocid_taskname as the unique identifier for a
// plugin on the filesystem.
func (h *csiPluginSupervisorHook) Prestart(ctx context.Context,
req *interfaces.TaskPrestartRequest, resp *interfaces.TaskPrestartResponse) error {
// Create the mount directory that the container will access if it doesn't
// already exist. Default to only nomad user access.
if err := os.MkdirAll(h.mountPoint, 0700); err != nil && !os.IsExist(err) {
return fmt.Errorf("failed to create mount point: %v", err)
}
configMount := &drivers.MountConfig{
TaskPath: h.task.CSIPluginConfig.MountDir,
HostPath: h.mountPoint,
Readonly: false,
PropagationMode: "bidirectional",
}
devMount := &drivers.MountConfig{
TaskPath: "/dev",
HostPath: "/dev",
Readonly: false,
}
mounts := ensureMountpointInserted(h.runner.hookResources.getMounts(), configMount)
mounts = ensureMountpointInserted(mounts, devMount)
h.runner.hookResources.setMounts(mounts)
resp.Done = true
return nil
}
// Poststart is called after the task has started. Poststart is not
// called if the allocation is terminal.
//
// The context is cancelled if the task is killed.
func (h *csiPluginSupervisorHook) Poststart(_ context.Context, _ *interfaces.TaskPoststartRequest, _ *interfaces.TaskPoststartResponse) error {
// If we're already running the supervisor routine, then we don't need to try
// and restart it here as it only terminates on `Stop` hooks.
h.runningLock.Lock()
if h.running {
h.runningLock.Unlock()
return nil
}
h.runningLock.Unlock()
go h.ensureSupervisorLoop(h.shutdownCtx)
return nil
}
// ensureSupervisorLoop should be called in a goroutine. It will terminate when
// the passed in context is terminated.
//
// The supervisor works by:
// - Initially waiting for the plugin to become available. This loop is expensive
// and may do things like create new gRPC Clients on every iteration.
// - After receiving an initial healthy status, it will inform the plugin catalog
// of the plugin, registering it with the plugins fingerprinted capabilities.
// - We then perform a more lightweight check, simply probing the plugin on a less
// frequent interval to ensure it is still alive, emitting task events when this
// status changes.
//
// Deeper fingerprinting of the plugin is implemented by the csimanager.
func (h *csiPluginSupervisorHook) ensureSupervisorLoop(ctx context.Context) {
h.runningLock.Lock()
if h.running == true {
h.runningLock.Unlock()
return
}
h.running = true
h.runningLock.Unlock()
defer func() {
h.runningLock.Lock()
h.running = false
h.runningLock.Unlock()
}()
socketPath := filepath.Join(h.mountPoint, structs.CSISocketName)
t := time.NewTimer(0)
// Step 1: Wait for the plugin to initially become available.
WAITFORREADY:
for {
select {
case <-ctx.Done():
return
case <-t.C:
pluginHealthy, err := h.supervisorLoopOnce(ctx, socketPath)
if err != nil || !pluginHealthy {
h.logger.Debug("CSI Plugin not ready", "error", err)
// Plugin is not yet returning healthy, because we want to optimise for
// quickly bringing a plugin online, we use a short timeout here.
// TODO(dani): Test with more plugins and adjust.
t.Reset(5 * time.Second)
continue
}
// Mark the plugin as healthy in a task event
h.previousHealthState = pluginHealthy
event := structs.NewTaskEvent(structs.TaskPluginHealthy)
event.SetMessage(fmt.Sprintf("plugin: %s", h.task.CSIPluginConfig.ID))
h.eventEmitter.EmitEvent(event)
break WAITFORREADY
}
}
// Step 2: Register the plugin with the catalog.
deregisterPluginFn, err := h.registerPlugin(socketPath)
if err != nil {
h.logger.Error("CSI Plugin registration failed", "error", err)
event := structs.NewTaskEvent(structs.TaskPluginUnhealthy)
event.SetMessage(fmt.Sprintf("failed to register plugin: %s, reason: %v", h.task.CSIPluginConfig.ID, err))
h.eventEmitter.EmitEvent(event)
}
// Step 3: Start the lightweight supervisor loop.
t.Reset(0)
for {
select {
case <-ctx.Done():
// De-register plugins on task shutdown
deregisterPluginFn()
return
case <-t.C:
pluginHealthy, err := h.supervisorLoopOnce(ctx, socketPath)
if err != nil {
h.logger.Error("CSI Plugin fingerprinting failed", "error", err)
}
// The plugin has transitioned to a healthy state. Emit an event.
if !h.previousHealthState && pluginHealthy {
event := structs.NewTaskEvent(structs.TaskPluginHealthy)
event.SetMessage(fmt.Sprintf("plugin: %s", h.task.CSIPluginConfig.ID))
h.eventEmitter.EmitEvent(event)
}
// The plugin has transitioned to an unhealthy state. Emit an event.
if h.previousHealthState && !pluginHealthy {
event := structs.NewTaskEvent(structs.TaskPluginUnhealthy)
if err != nil {
event.SetMessage(fmt.Sprintf("error: %v", err))
} else {
event.SetMessage("Unknown Reason")
}
h.eventEmitter.EmitEvent(event)
}
h.previousHealthState = pluginHealthy
// This loop is informational and in some plugins this may be expensive to
// validate. We use a longer timeout (30s) to avoid causing undue work.
t.Reset(30 * time.Second)
}
}
}
func (h *csiPluginSupervisorHook) registerPlugin(socketPath string) (func(), error) {
// At this point we know the plugin is ready and we can fingerprint it
// to get its vendor name and version
client, err := csi.NewClient(socketPath, h.logger.Named("csi_client").With("plugin.name", h.task.CSIPluginConfig.ID, "plugin.type", h.task.CSIPluginConfig.Type))
defer client.Close()
if err != nil {
return nil, fmt.Errorf("failed to create csi client: %v", err)
}
info, err := client.PluginInfo()
if err != nil {
return nil, fmt.Errorf("failed to probe plugin: %v", err)
}
mkInfoFn := func(pluginType string) *dynamicplugins.PluginInfo {
return &dynamicplugins.PluginInfo{
Type: pluginType,
Name: h.task.CSIPluginConfig.ID,
Version: info.PluginVersion,
ConnectionInfo: &dynamicplugins.PluginConnectionInfo{
SocketPath: socketPath,
},
AllocID: h.alloc.ID,
Options: map[string]string{
"Provider": info.Name, // vendor name
"MountPoint": h.mountPoint,
"ContainerMountPoint": h.task.CSIPluginConfig.MountDir,
},
}
}
registrations := []*dynamicplugins.PluginInfo{}
switch h.task.CSIPluginConfig.Type {
case structs.CSIPluginTypeController:
registrations = append(registrations, mkInfoFn(dynamicplugins.PluginTypeCSIController))
case structs.CSIPluginTypeNode:
registrations = append(registrations, mkInfoFn(dynamicplugins.PluginTypeCSINode))
case structs.CSIPluginTypeMonolith:
registrations = append(registrations, mkInfoFn(dynamicplugins.PluginTypeCSIController))
registrations = append(registrations, mkInfoFn(dynamicplugins.PluginTypeCSINode))
}
deregistrationFns := []func(){}
for _, reg := range registrations {
if err := h.runner.dynamicRegistry.RegisterPlugin(reg); err != nil {
for _, fn := range deregistrationFns {
fn()
}
return nil, err
}
// need to rebind these so that each deregistration function
// closes over its own registration
rname := reg.Name
rtype := reg.Type
deregistrationFns = append(deregistrationFns, func() {
err := h.runner.dynamicRegistry.DeregisterPlugin(rtype, rname)
if err != nil {
h.logger.Error("failed to deregister csi plugin", "name", rname, "type", rtype, "error", err)
}
})
}
return func() {
for _, fn := range deregistrationFns {
fn()
}
}, nil
}
func (h *csiPluginSupervisorHook) supervisorLoopOnce(ctx context.Context, socketPath string) (bool, error) {
_, err := os.Stat(socketPath)
if err != nil {
return false, fmt.Errorf("failed to stat socket: %v", err)
}
client, err := csi.NewClient(socketPath, h.logger.Named("csi_client").With("plugin.name", h.task.CSIPluginConfig.ID, "plugin.type", h.task.CSIPluginConfig.Type))
defer client.Close()
if err != nil {
return false, fmt.Errorf("failed to create csi client: %v", err)
}
healthy, err := client.PluginProbe(ctx)
if err != nil {
return false, fmt.Errorf("failed to probe plugin: %v", err)
}
return healthy, nil
}
// Stop is called after the task has exited and will not be started
// again. It is the only hook guaranteed to be executed whenever
// TaskRunner.Run is called (and not gracefully shutting down).
// Therefore it may be called even when prestart and the other hooks
// have not.
//
// Stop hooks must be idempotent. The context is cancelled prematurely if the
// task is killed.
func (h *csiPluginSupervisorHook) Stop(_ context.Context, req *interfaces.TaskStopRequest, _ *interfaces.TaskStopResponse) error {
h.shutdownCancelFn()
return nil
}
func ensureMountpointInserted(mounts []*drivers.MountConfig, mount *drivers.MountConfig) []*drivers.MountConfig {
for _, mnt := range mounts {
if mnt.IsEqual(mount) {
return mounts
}
}
mounts = append(mounts, mount)
return mounts
}

View File

@ -19,7 +19,9 @@ import (
"github.com/hashicorp/nomad/client/config" "github.com/hashicorp/nomad/client/config"
"github.com/hashicorp/nomad/client/consul" "github.com/hashicorp/nomad/client/consul"
"github.com/hashicorp/nomad/client/devicemanager" "github.com/hashicorp/nomad/client/devicemanager"
"github.com/hashicorp/nomad/client/dynamicplugins"
cinterfaces "github.com/hashicorp/nomad/client/interfaces" cinterfaces "github.com/hashicorp/nomad/client/interfaces"
"github.com/hashicorp/nomad/client/pluginmanager/csimanager"
"github.com/hashicorp/nomad/client/pluginmanager/drivermanager" "github.com/hashicorp/nomad/client/pluginmanager/drivermanager"
cstate "github.com/hashicorp/nomad/client/state" cstate "github.com/hashicorp/nomad/client/state"
cstructs "github.com/hashicorp/nomad/client/structs" cstructs "github.com/hashicorp/nomad/client/structs"
@ -186,6 +188,9 @@ type TaskRunner struct {
// deviceStatsReporter is used to lookup resource usage for alloc devices // deviceStatsReporter is used to lookup resource usage for alloc devices
deviceStatsReporter cinterfaces.DeviceStatsReporter deviceStatsReporter cinterfaces.DeviceStatsReporter
// csiManager is used to manage the mounting of CSI volumes into tasks
csiManager csimanager.Manager
// devicemanager is used to mount devices as well as lookup device // devicemanager is used to mount devices as well as lookup device
// statistics // statistics
devicemanager devicemanager.Manager devicemanager devicemanager.Manager
@ -194,6 +199,9 @@ type TaskRunner struct {
// handlers // handlers
driverManager drivermanager.Manager driverManager drivermanager.Manager
// dynamicRegistry is where dynamic plugins should be registered.
dynamicRegistry dynamicplugins.Registry
// maxEvents is the capacity of the TaskEvents on the TaskState. // maxEvents is the capacity of the TaskEvents on the TaskState.
// Defaults to defaultMaxEvents but overrideable for testing. // Defaults to defaultMaxEvents but overrideable for testing.
maxEvents int maxEvents int
@ -212,6 +220,8 @@ type TaskRunner struct {
networkIsolationLock sync.Mutex networkIsolationLock sync.Mutex
networkIsolationSpec *drivers.NetworkIsolationSpec networkIsolationSpec *drivers.NetworkIsolationSpec
allocHookResources *cstructs.AllocHookResources
} }
type Config struct { type Config struct {
@ -227,6 +237,9 @@ type Config struct {
// ConsulSI is the client to use for managing Consul SI tokens // ConsulSI is the client to use for managing Consul SI tokens
ConsulSI consul.ServiceIdentityAPI ConsulSI consul.ServiceIdentityAPI
// DynamicRegistry is where dynamic plugins should be registered.
DynamicRegistry dynamicplugins.Registry
// Vault is the client to use to derive and renew Vault tokens // Vault is the client to use to derive and renew Vault tokens
Vault vaultclient.VaultClient Vault vaultclient.VaultClient
@ -239,6 +252,9 @@ type Config struct {
// deviceStatsReporter is used to lookup resource usage for alloc devices // deviceStatsReporter is used to lookup resource usage for alloc devices
DeviceStatsReporter cinterfaces.DeviceStatsReporter DeviceStatsReporter cinterfaces.DeviceStatsReporter
// CSIManager is used to manage the mounting of CSI volumes into tasks
CSIManager csimanager.Manager
// DeviceManager is used to mount devices as well as lookup device // DeviceManager is used to mount devices as well as lookup device
// statistics // statistics
DeviceManager devicemanager.Manager DeviceManager devicemanager.Manager
@ -285,6 +301,7 @@ func NewTaskRunner(config *Config) (*TaskRunner, error) {
taskName: config.Task.Name, taskName: config.Task.Name,
taskLeader: config.Task.Leader, taskLeader: config.Task.Leader,
envBuilder: envBuilder, envBuilder: envBuilder,
dynamicRegistry: config.DynamicRegistry,
consulClient: config.Consul, consulClient: config.Consul,
siClient: config.ConsulSI, siClient: config.ConsulSI,
vaultClient: config.Vault, vaultClient: config.Vault,
@ -299,6 +316,7 @@ func NewTaskRunner(config *Config) (*TaskRunner, error) {
shutdownCtxCancel: trCancel, shutdownCtxCancel: trCancel,
triggerUpdateCh: make(chan struct{}, triggerUpdateChCap), triggerUpdateCh: make(chan struct{}, triggerUpdateChCap),
waitCh: make(chan struct{}), waitCh: make(chan struct{}),
csiManager: config.CSIManager,
devicemanager: config.DeviceManager, devicemanager: config.DeviceManager,
driverManager: config.DriverManager, driverManager: config.DriverManager,
maxEvents: defaultMaxEvents, maxEvents: defaultMaxEvents,
@ -1392,3 +1410,7 @@ func (tr *TaskRunner) TaskExecHandler() drivermanager.TaskExecHandler {
func (tr *TaskRunner) DriverCapabilities() (*drivers.Capabilities, error) { func (tr *TaskRunner) DriverCapabilities() (*drivers.Capabilities, error) {
return tr.driver.Capabilities() return tr.driver.Capabilities()
} }
func (tr *TaskRunner) SetAllocHookResources(res *cstructs.AllocHookResources) {
tr.allocHookResources = res
}

View File

@ -3,6 +3,7 @@ package taskrunner
import ( import (
"context" "context"
"fmt" "fmt"
"path/filepath"
"sync" "sync"
"time" "time"
@ -69,6 +70,11 @@ func (tr *TaskRunner) initHooks() {
newDeviceHook(tr.devicemanager, hookLogger), newDeviceHook(tr.devicemanager, hookLogger),
} }
// If the task has a CSI stanza, add the hook.
if task.CSIPluginConfig != nil {
tr.runnerHooks = append(tr.runnerHooks, newCSIPluginSupervisorHook(filepath.Join(tr.clientConfig.StateDir, "csi"), tr, tr, hookLogger))
}
// If Vault is enabled, add the hook // If Vault is enabled, add the hook
if task.Vault != nil { if task.Vault != nil {
tr.runnerHooks = append(tr.runnerHooks, newVaultHook(&vaultHookConfig{ tr.runnerHooks = append(tr.runnerHooks, newVaultHook(&vaultHookConfig{

View File

@ -7,14 +7,16 @@ import (
log "github.com/hashicorp/go-hclog" log "github.com/hashicorp/go-hclog"
multierror "github.com/hashicorp/go-multierror" multierror "github.com/hashicorp/go-multierror"
"github.com/hashicorp/nomad/client/allocrunner/interfaces" "github.com/hashicorp/nomad/client/allocrunner/interfaces"
"github.com/hashicorp/nomad/client/taskenv"
"github.com/hashicorp/nomad/nomad/structs" "github.com/hashicorp/nomad/nomad/structs"
"github.com/hashicorp/nomad/plugins/drivers" "github.com/hashicorp/nomad/plugins/drivers"
) )
type volumeHook struct { type volumeHook struct {
alloc *structs.Allocation alloc *structs.Allocation
runner *TaskRunner runner *TaskRunner
logger log.Logger logger log.Logger
taskEnv *taskenv.TaskEnv
} }
func newVolumeHook(runner *TaskRunner, logger log.Logger) *volumeHook { func newVolumeHook(runner *TaskRunner, logger log.Logger) *volumeHook {
@ -34,6 +36,8 @@ func validateHostVolumes(requestedByAlias map[string]*structs.VolumeRequest, cli
var result error var result error
for _, req := range requestedByAlias { for _, req := range requestedByAlias {
// This is a defensive check, but this function should only ever receive
// host-type volumes.
if req.Type != structs.VolumeTypeHost { if req.Type != structs.VolumeTypeHost {
continue continue
} }
@ -55,8 +59,16 @@ func (h *volumeHook) hostVolumeMountConfigurations(taskMounts []*structs.VolumeM
for _, m := range taskMounts { for _, m := range taskMounts {
req, ok := taskVolumesByAlias[m.Volume] req, ok := taskVolumesByAlias[m.Volume]
if !ok { if !ok {
// Should never happen unless we misvalidated on job submission // This function receives only the task volumes that are of type Host,
return nil, fmt.Errorf("No group volume declaration found named: %s", m.Volume) // if we can't find a group volume then we assume the mount is for another
// type.
continue
}
// This is a defensive check, but this function should only ever receive
// host-type volumes.
if req.Type != structs.VolumeTypeHost {
continue
} }
hostVolume, ok := clientVolumesByName[req.Source] hostVolume, ok := clientVolumesByName[req.Source]
@ -77,22 +89,100 @@ func (h *volumeHook) hostVolumeMountConfigurations(taskMounts []*structs.VolumeM
return mounts, nil return mounts, nil
} }
func (h *volumeHook) Prestart(ctx context.Context, req *interfaces.TaskPrestartRequest, resp *interfaces.TaskPrestartResponse) error { // partitionVolumesByType takes a map of volume-alias to volume-request and
volumes := h.alloc.Job.LookupTaskGroup(h.alloc.TaskGroup).Volumes // returns them in the form of volume-type:(volume-alias:volume-request)
mounts := h.runner.hookResources.getMounts() func partitionVolumesByType(xs map[string]*structs.VolumeRequest) map[string]map[string]*structs.VolumeRequest {
result := make(map[string]map[string]*structs.VolumeRequest)
for name, req := range xs {
txs, ok := result[req.Type]
if !ok {
txs = make(map[string]*structs.VolumeRequest)
result[req.Type] = txs
}
txs[name] = req
}
return result
}
func (h *volumeHook) prepareHostVolumes(req *interfaces.TaskPrestartRequest, volumes map[string]*structs.VolumeRequest) ([]*drivers.MountConfig, error) {
hostVolumes := h.runner.clientConfig.Node.HostVolumes hostVolumes := h.runner.clientConfig.Node.HostVolumes
// Always validate volumes to ensure that we do not allow volumes to be used // Always validate volumes to ensure that we do not allow volumes to be used
// if a host is restarted and loses the host volume configuration. // if a host is restarted and loses the host volume configuration.
if err := validateHostVolumes(volumes, hostVolumes); err != nil { if err := validateHostVolumes(volumes, hostVolumes); err != nil {
h.logger.Error("Requested Host Volume does not exist", "existing", hostVolumes, "requested", volumes) h.logger.Error("Requested Host Volume does not exist", "existing", hostVolumes, "requested", volumes)
return fmt.Errorf("host volume validation error: %v", err) return nil, fmt.Errorf("host volume validation error: %v", err)
} }
requestedMounts, err := h.hostVolumeMountConfigurations(req.Task.VolumeMounts, volumes, hostVolumes) hostVolumeMounts, err := h.hostVolumeMountConfigurations(req.Task.VolumeMounts, volumes, hostVolumes)
if err != nil {
h.logger.Error("Failed to generate host volume mounts", "error", err)
return nil, err
}
return hostVolumeMounts, nil
}
// partitionMountsByVolume takes a list of volume mounts and returns them in the
// form of volume-alias:[]volume-mount because one volume may be mounted multiple
// times.
func partitionMountsByVolume(xs []*structs.VolumeMount) map[string][]*structs.VolumeMount {
result := make(map[string][]*structs.VolumeMount)
for _, mount := range xs {
result[mount.Volume] = append(result[mount.Volume], mount)
}
return result
}
func (h *volumeHook) prepareCSIVolumes(req *interfaces.TaskPrestartRequest, volumes map[string]*structs.VolumeRequest) ([]*drivers.MountConfig, error) {
if len(volumes) == 0 {
return nil, nil
}
var mounts []*drivers.MountConfig
mountRequests := partitionMountsByVolume(req.Task.VolumeMounts)
csiMountPoints := h.runner.allocHookResources.GetCSIMounts()
for alias, request := range volumes {
mountsForAlias, ok := mountRequests[alias]
if !ok {
// This task doesn't use the volume
continue
}
csiMountPoint, ok := csiMountPoints[alias]
if !ok {
return nil, fmt.Errorf("No CSI Mount Point found for volume: %s", alias)
}
for _, m := range mountsForAlias {
mcfg := &drivers.MountConfig{
HostPath: csiMountPoint.Source,
TaskPath: m.Destination,
Readonly: request.ReadOnly || m.ReadOnly,
}
mounts = append(mounts, mcfg)
}
}
return mounts, nil
}
func (h *volumeHook) Prestart(ctx context.Context, req *interfaces.TaskPrestartRequest, resp *interfaces.TaskPrestartResponse) error {
h.taskEnv = req.TaskEnv
interpolateVolumeMounts(req.Task.VolumeMounts, h.taskEnv)
volumes := partitionVolumesByType(h.alloc.Job.LookupTaskGroup(h.alloc.TaskGroup).Volumes)
hostVolumeMounts, err := h.prepareHostVolumes(req, volumes[structs.VolumeTypeHost])
if err != nil {
return err
}
csiVolumeMounts, err := h.prepareCSIVolumes(req, volumes[structs.VolumeTypeCSI])
if err != nil { if err != nil {
h.logger.Error("Failed to generate volume mounts", "error", err)
return err return err
} }
@ -100,17 +190,22 @@ func (h *volumeHook) Prestart(ctx context.Context, req *interfaces.TaskPrestartR
// already exist. Although this loop is somewhat expensive, there are only // already exist. Although this loop is somewhat expensive, there are only
// a small number of mounts that exist within most individual tasks. We may // a small number of mounts that exist within most individual tasks. We may
// want to revisit this using a `hookdata` param to be "mount only once" // want to revisit this using a `hookdata` param to be "mount only once"
REQUESTED: mounts := h.runner.hookResources.getMounts()
for _, m := range requestedMounts { for _, m := range hostVolumeMounts {
for _, em := range mounts { mounts = ensureMountpointInserted(mounts, m)
if em.IsEqual(m) { }
continue REQUESTED for _, m := range csiVolumeMounts {
} mounts = ensureMountpointInserted(mounts, m)
}
mounts = append(mounts, m)
} }
h.runner.hookResources.setMounts(mounts) h.runner.hookResources.setMounts(mounts)
return nil return nil
} }
func interpolateVolumeMounts(mounts []*structs.VolumeMount, taskEnv *taskenv.TaskEnv) {
for _, mount := range mounts {
mount.Volume = taskEnv.ReplaceEnv(mount.Volume)
mount.Destination = taskEnv.ReplaceEnv(mount.Destination)
mount.PropagationMode = taskEnv.ReplaceEnv(mount.PropagationMode)
}
}

View File

@ -0,0 +1,182 @@
package taskrunner
import (
"testing"
"github.com/hashicorp/nomad/client/allocrunner/interfaces"
"github.com/hashicorp/nomad/client/pluginmanager/csimanager"
cstructs "github.com/hashicorp/nomad/client/structs"
"github.com/hashicorp/nomad/client/taskenv"
"github.com/hashicorp/nomad/helper/testlog"
"github.com/hashicorp/nomad/nomad/mock"
"github.com/hashicorp/nomad/nomad/structs"
"github.com/hashicorp/nomad/plugins/drivers"
"github.com/stretchr/testify/require"
)
func TestVolumeHook_PartitionMountsByVolume_Works(t *testing.T) {
mounts := []*structs.VolumeMount{
{
Volume: "foo",
Destination: "/tmp",
ReadOnly: false,
},
{
Volume: "foo",
Destination: "/bar",
ReadOnly: false,
},
{
Volume: "baz",
Destination: "/baz",
ReadOnly: false,
},
}
expected := map[string][]*structs.VolumeMount{
"foo": {
{
Volume: "foo",
Destination: "/tmp",
ReadOnly: false,
},
{
Volume: "foo",
Destination: "/bar",
ReadOnly: false,
},
},
"baz": {
{
Volume: "baz",
Destination: "/baz",
ReadOnly: false,
},
},
}
// Test with a real collection
partitioned := partitionMountsByVolume(mounts)
require.Equal(t, expected, partitioned)
// Test with nil/emptylist
partitioned = partitionMountsByVolume(nil)
require.Equal(t, map[string][]*structs.VolumeMount{}, partitioned)
}
func TestVolumeHook_prepareCSIVolumes(t *testing.T) {
req := &interfaces.TaskPrestartRequest{
Task: &structs.Task{
VolumeMounts: []*structs.VolumeMount{
{
Volume: "foo",
Destination: "/bar",
},
},
},
}
volumes := map[string]*structs.VolumeRequest{
"foo": {
Type: "csi",
Source: "my-test-volume",
},
}
tr := &TaskRunner{
allocHookResources: &cstructs.AllocHookResources{
CSIMounts: map[string]*csimanager.MountInfo{
"foo": {
Source: "/mnt/my-test-volume",
},
},
},
}
expected := []*drivers.MountConfig{
{
HostPath: "/mnt/my-test-volume",
TaskPath: "/bar",
},
}
hook := &volumeHook{
logger: testlog.HCLogger(t),
alloc: structs.MockAlloc(),
runner: tr,
}
mounts, err := hook.prepareCSIVolumes(req, volumes)
require.NoError(t, err)
require.Equal(t, expected, mounts)
}
func TestVolumeHook_Interpolation(t *testing.T) {
alloc := mock.Alloc()
task := alloc.Job.TaskGroups[0].Tasks[0]
taskEnv := taskenv.NewBuilder(mock.Node(), alloc, task, "global").SetHookEnv("volume",
map[string]string{
"PROPAGATION_MODE": "private",
"VOLUME_ID": "my-other-volume",
},
).Build()
mounts := []*structs.VolumeMount{
{
Volume: "foo",
Destination: "/tmp",
ReadOnly: false,
PropagationMode: "bidirectional",
},
{
Volume: "foo",
Destination: "/bar-${NOMAD_JOB_NAME}",
ReadOnly: false,
PropagationMode: "bidirectional",
},
{
Volume: "${VOLUME_ID}",
Destination: "/baz",
ReadOnly: false,
PropagationMode: "bidirectional",
},
{
Volume: "foo",
Destination: "/quux",
ReadOnly: false,
PropagationMode: "${PROPAGATION_MODE}",
},
}
expected := []*structs.VolumeMount{
{
Volume: "foo",
Destination: "/tmp",
ReadOnly: false,
PropagationMode: "bidirectional",
},
{
Volume: "foo",
Destination: "/bar-my-job",
ReadOnly: false,
PropagationMode: "bidirectional",
},
{
Volume: "my-other-volume",
Destination: "/baz",
ReadOnly: false,
PropagationMode: "bidirectional",
},
{
Volume: "foo",
Destination: "/quux",
ReadOnly: false,
PropagationMode: "private",
},
}
interpolateVolumeMounts(mounts, taskEnv)
require.Equal(t, expected, mounts)
}

View File

@ -26,8 +26,10 @@ import (
"github.com/hashicorp/nomad/client/config" "github.com/hashicorp/nomad/client/config"
consulApi "github.com/hashicorp/nomad/client/consul" consulApi "github.com/hashicorp/nomad/client/consul"
"github.com/hashicorp/nomad/client/devicemanager" "github.com/hashicorp/nomad/client/devicemanager"
"github.com/hashicorp/nomad/client/dynamicplugins"
"github.com/hashicorp/nomad/client/fingerprint" "github.com/hashicorp/nomad/client/fingerprint"
"github.com/hashicorp/nomad/client/pluginmanager" "github.com/hashicorp/nomad/client/pluginmanager"
"github.com/hashicorp/nomad/client/pluginmanager/csimanager"
"github.com/hashicorp/nomad/client/pluginmanager/drivermanager" "github.com/hashicorp/nomad/client/pluginmanager/drivermanager"
"github.com/hashicorp/nomad/client/servers" "github.com/hashicorp/nomad/client/servers"
"github.com/hashicorp/nomad/client/state" "github.com/hashicorp/nomad/client/state"
@ -42,6 +44,7 @@ import (
"github.com/hashicorp/nomad/helper/uuid" "github.com/hashicorp/nomad/helper/uuid"
"github.com/hashicorp/nomad/nomad/structs" "github.com/hashicorp/nomad/nomad/structs"
nconfig "github.com/hashicorp/nomad/nomad/structs/config" nconfig "github.com/hashicorp/nomad/nomad/structs/config"
"github.com/hashicorp/nomad/plugins/csi"
"github.com/hashicorp/nomad/plugins/device" "github.com/hashicorp/nomad/plugins/device"
"github.com/hashicorp/nomad/plugins/drivers" "github.com/hashicorp/nomad/plugins/drivers"
vaultapi "github.com/hashicorp/vault/api" vaultapi "github.com/hashicorp/vault/api"
@ -258,6 +261,9 @@ type Client struct {
// pluginManagers is the set of PluginManagers registered by the client // pluginManagers is the set of PluginManagers registered by the client
pluginManagers *pluginmanager.PluginGroup pluginManagers *pluginmanager.PluginGroup
// csimanager is responsible for managing csi plugins.
csimanager csimanager.Manager
// devicemanger is responsible for managing device plugins. // devicemanger is responsible for managing device plugins.
devicemanager devicemanager.Manager devicemanager devicemanager.Manager
@ -279,6 +285,10 @@ type Client struct {
// successfully run once. // successfully run once.
serversContactedCh chan struct{} serversContactedCh chan struct{}
serversContactedOnce sync.Once serversContactedOnce sync.Once
// dynamicRegistry provides access to plugins that are dynamically registered
// with a nomad client. Currently only used for CSI.
dynamicRegistry dynamicplugins.Registry
} }
var ( var (
@ -336,6 +346,7 @@ func NewClient(cfg *config.Config, consulCatalog consul.CatalogAPI, consulServic
c.batchNodeUpdates = newBatchNodeUpdates( c.batchNodeUpdates = newBatchNodeUpdates(
c.updateNodeFromDriver, c.updateNodeFromDriver,
c.updateNodeFromDevices, c.updateNodeFromDevices,
c.updateNodeFromCSI,
) )
// Initialize the server manager // Initialize the server manager
@ -344,11 +355,22 @@ func NewClient(cfg *config.Config, consulCatalog consul.CatalogAPI, consulServic
// Start server manager rebalancing go routine // Start server manager rebalancing go routine
go c.servers.Start() go c.servers.Start()
// Initialize the client // initialize the client
if err := c.init(); err != nil { if err := c.init(); err != nil {
return nil, fmt.Errorf("failed to initialize client: %v", err) return nil, fmt.Errorf("failed to initialize client: %v", err)
} }
// initialize the dynamic registry (needs to happen after init)
c.dynamicRegistry =
dynamicplugins.NewRegistry(c.stateDB, map[string]dynamicplugins.PluginDispenser{
dynamicplugins.PluginTypeCSIController: func(info *dynamicplugins.PluginInfo) (interface{}, error) {
return csi.NewClient(info.ConnectionInfo.SocketPath, logger.Named("csi_client").With("plugin.name", info.Name, "plugin.type", "controller"))
},
dynamicplugins.PluginTypeCSINode: func(info *dynamicplugins.PluginInfo) (interface{}, error) {
return csi.NewClient(info.ConnectionInfo.SocketPath, logger.Named("csi_client").With("plugin.name", info.Name, "plugin.type", "client"))
}, // TODO(tgross): refactor these dispenser constructors into csimanager to tidy it up
})
// Setup the clients RPC server // Setup the clients RPC server
c.setupClientRpc() c.setupClientRpc()
@ -383,6 +405,16 @@ func NewClient(cfg *config.Config, consulCatalog consul.CatalogAPI, consulServic
allowlistDrivers := cfg.ReadStringListToMap("driver.whitelist") allowlistDrivers := cfg.ReadStringListToMap("driver.whitelist")
blocklistDrivers := cfg.ReadStringListToMap("driver.blacklist") blocklistDrivers := cfg.ReadStringListToMap("driver.blacklist")
// Setup the csi manager
csiConfig := &csimanager.Config{
Logger: c.logger,
DynamicRegistry: c.dynamicRegistry,
UpdateNodeCSIInfoFunc: c.batchNodeUpdates.updateNodeFromCSI,
}
csiManager := csimanager.New(csiConfig)
c.csimanager = csiManager
c.pluginManagers.RegisterAndRun(csiManager.PluginManager())
// Setup the driver manager // Setup the driver manager
driverConfig := &drivermanager.Config{ driverConfig := &drivermanager.Config{
Logger: c.logger, Logger: c.logger,
@ -1054,9 +1086,12 @@ func (c *Client) restoreState() error {
Vault: c.vaultClient, Vault: c.vaultClient,
PrevAllocWatcher: prevAllocWatcher, PrevAllocWatcher: prevAllocWatcher,
PrevAllocMigrator: prevAllocMigrator, PrevAllocMigrator: prevAllocMigrator,
DynamicRegistry: c.dynamicRegistry,
CSIManager: c.csimanager,
DeviceManager: c.devicemanager, DeviceManager: c.devicemanager,
DriverManager: c.drivermanager, DriverManager: c.drivermanager,
ServersContactedCh: c.serversContactedCh, ServersContactedCh: c.serversContactedCh,
RPCClient: c,
} }
c.configLock.RUnlock() c.configLock.RUnlock()
@ -1279,6 +1314,12 @@ func (c *Client) setupNode() error {
if node.Drivers == nil { if node.Drivers == nil {
node.Drivers = make(map[string]*structs.DriverInfo) node.Drivers = make(map[string]*structs.DriverInfo)
} }
if node.CSIControllerPlugins == nil {
node.CSIControllerPlugins = make(map[string]*structs.CSIInfo)
}
if node.CSINodePlugins == nil {
node.CSINodePlugins = make(map[string]*structs.CSIInfo)
}
if node.Meta == nil { if node.Meta == nil {
node.Meta = make(map[string]string) node.Meta = make(map[string]string)
} }
@ -2310,8 +2351,11 @@ func (c *Client) addAlloc(alloc *structs.Allocation, migrateToken string) error
DeviceStatsReporter: c, DeviceStatsReporter: c,
PrevAllocWatcher: prevAllocWatcher, PrevAllocWatcher: prevAllocWatcher,
PrevAllocMigrator: prevAllocMigrator, PrevAllocMigrator: prevAllocMigrator,
DynamicRegistry: c.dynamicRegistry,
CSIManager: c.csimanager,
DeviceManager: c.devicemanager, DeviceManager: c.devicemanager,
DriverManager: c.drivermanager, DriverManager: c.drivermanager,
RPCClient: c,
} }
c.configLock.RUnlock() c.configLock.RUnlock()

View File

@ -0,0 +1,164 @@
package client
import (
"context"
"errors"
"time"
metrics "github.com/armon/go-metrics"
"github.com/hashicorp/nomad/client/dynamicplugins"
"github.com/hashicorp/nomad/client/structs"
"github.com/hashicorp/nomad/plugins/csi"
)
// CSIController endpoint is used for interacting with CSI plugins on a client.
// TODO: Submit metrics with labels to allow debugging per plugin perf problems.
type CSIController struct {
c *Client
}
const (
// CSIPluginRequestTimeout is the timeout that should be used when making reqs
// against CSI Plugins. It is copied from Kubernetes as an initial seed value.
// https://github.com/kubernetes/kubernetes/blob/e680ad7156f263a6d8129cc0117fda58602e50ad/pkg/volume/csi/csi_plugin.go#L52
CSIPluginRequestTimeout = 2 * time.Minute
)
var (
ErrPluginTypeError = errors.New("CSI Plugin loaded incorrectly")
)
// ValidateVolume is used during volume registration to validate
// that a volume exists and that the capabilities it was registered with are
// supported by the CSI Plugin and external volume configuration.
func (c *CSIController) ValidateVolume(req *structs.ClientCSIControllerValidateVolumeRequest, resp *structs.ClientCSIControllerValidateVolumeResponse) error {
defer metrics.MeasureSince([]string{"client", "csi_controller", "validate_volume"}, time.Now())
if req.VolumeID == "" {
return errors.New("VolumeID is required")
}
if req.PluginID == "" {
return errors.New("PluginID is required")
}
plugin, err := c.findControllerPlugin(req.PluginID)
if err != nil {
return err
}
defer plugin.Close()
caps, err := csi.VolumeCapabilityFromStructs(req.AttachmentMode, req.AccessMode)
if err != nil {
return err
}
ctx, cancelFn := c.requestContext()
defer cancelFn()
return plugin.ControllerValidateCapabilties(ctx, req.VolumeID, caps)
}
// AttachVolume is used to attach a volume from a CSI Cluster to
// the storage node provided in the request.
//
// The controller attachment flow currently works as follows:
// 1. Validate the volume request
// 2. Call ControllerPublishVolume on the CSI Plugin to trigger a remote attachment
//
// In the future this may be expanded to request dynamic secrets for attachment.
func (c *CSIController) AttachVolume(req *structs.ClientCSIControllerAttachVolumeRequest, resp *structs.ClientCSIControllerAttachVolumeResponse) error {
defer metrics.MeasureSince([]string{"client", "csi_controller", "publish_volume"}, time.Now())
plugin, err := c.findControllerPlugin(req.PluginID)
if err != nil {
return err
}
defer plugin.Close()
// The following block of validation checks should not be reached on a
// real Nomad cluster as all of this data should be validated when registering
// volumes with the cluster. They serve as a defensive check before forwarding
// requests to plugins, and to aid with development.
if req.VolumeID == "" {
return errors.New("VolumeID is required")
}
if req.ClientCSINodeID == "" {
return errors.New("ClientCSINodeID is required")
}
csiReq, err := req.ToCSIRequest()
if err != nil {
return err
}
// Submit the request for a volume to the CSI Plugin.
ctx, cancelFn := c.requestContext()
defer cancelFn()
cresp, err := plugin.ControllerPublishVolume(ctx, csiReq)
if err != nil {
return err
}
resp.PublishContext = cresp.PublishContext
return nil
}
// DetachVolume is used to detach a volume from a CSI Cluster from
// the storage node provided in the request.
func (c *CSIController) DetachVolume(req *structs.ClientCSIControllerDetachVolumeRequest, resp *structs.ClientCSIControllerDetachVolumeResponse) error {
defer metrics.MeasureSince([]string{"client", "csi_controller", "unpublish_volume"}, time.Now())
plugin, err := c.findControllerPlugin(req.PluginID)
if err != nil {
return err
}
defer plugin.Close()
// The following block of validation checks should not be reached on a
// real Nomad cluster as all of this data should be validated when registering
// volumes with the cluster. They serve as a defensive check before forwarding
// requests to plugins, and to aid with development.
if req.VolumeID == "" {
return errors.New("VolumeID is required")
}
if req.ClientCSINodeID == "" {
return errors.New("ClientCSINodeID is required")
}
csiReq := req.ToCSIRequest()
// Submit the request for a volume to the CSI Plugin.
ctx, cancelFn := c.requestContext()
defer cancelFn()
_, err = plugin.ControllerUnpublishVolume(ctx, csiReq)
if err != nil {
return err
}
return nil
}
func (c *CSIController) findControllerPlugin(name string) (csi.CSIPlugin, error) {
return c.findPlugin(dynamicplugins.PluginTypeCSIController, name)
}
// TODO: Cache Plugin Clients?
func (c *CSIController) findPlugin(ptype, name string) (csi.CSIPlugin, error) {
pIface, err := c.c.dynamicRegistry.DispensePlugin(ptype, name)
if err != nil {
return nil, err
}
plugin, ok := pIface.(csi.CSIPlugin)
if !ok {
return nil, ErrPluginTypeError
}
return plugin, nil
}
func (c *CSIController) requestContext() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), CSIPluginRequestTimeout)
}

View File

@ -0,0 +1,348 @@
package client
import (
"errors"
"testing"
"github.com/hashicorp/nomad/client/dynamicplugins"
"github.com/hashicorp/nomad/client/structs"
nstructs "github.com/hashicorp/nomad/nomad/structs"
"github.com/hashicorp/nomad/plugins/csi"
"github.com/hashicorp/nomad/plugins/csi/fake"
"github.com/stretchr/testify/require"
)
var fakePlugin = &dynamicplugins.PluginInfo{
Name: "test-plugin",
Type: "csi-controller",
ConnectionInfo: &dynamicplugins.PluginConnectionInfo{},
}
func TestCSIController_AttachVolume(t *testing.T) {
t.Parallel()
cases := []struct {
Name string
ClientSetupFunc func(*fake.Client)
Request *structs.ClientCSIControllerAttachVolumeRequest
ExpectedErr error
ExpectedResponse *structs.ClientCSIControllerAttachVolumeResponse
}{
{
Name: "returns plugin not found errors",
Request: &structs.ClientCSIControllerAttachVolumeRequest{
CSIControllerQuery: structs.CSIControllerQuery{
PluginID: "some-garbage",
},
},
ExpectedErr: errors.New("plugin some-garbage for type csi-controller not found"),
},
{
Name: "validates volumeid is not empty",
Request: &structs.ClientCSIControllerAttachVolumeRequest{
CSIControllerQuery: structs.CSIControllerQuery{
PluginID: fakePlugin.Name,
},
},
ExpectedErr: errors.New("VolumeID is required"),
},
{
Name: "validates nodeid is not empty",
Request: &structs.ClientCSIControllerAttachVolumeRequest{
CSIControllerQuery: structs.CSIControllerQuery{
PluginID: fakePlugin.Name,
},
VolumeID: "1234-4321-1234-4321",
},
ExpectedErr: errors.New("ClientCSINodeID is required"),
},
{
Name: "validates AccessMode",
Request: &structs.ClientCSIControllerAttachVolumeRequest{
CSIControllerQuery: structs.CSIControllerQuery{
PluginID: fakePlugin.Name,
},
VolumeID: "1234-4321-1234-4321",
ClientCSINodeID: "abcde",
AttachmentMode: nstructs.CSIVolumeAttachmentModeFilesystem,
AccessMode: nstructs.CSIVolumeAccessMode("foo"),
},
ExpectedErr: errors.New("Unknown volume access mode: foo"),
},
{
Name: "validates attachmentmode is not empty",
Request: &structs.ClientCSIControllerAttachVolumeRequest{
CSIControllerQuery: structs.CSIControllerQuery{
PluginID: fakePlugin.Name,
},
VolumeID: "1234-4321-1234-4321",
ClientCSINodeID: "abcde",
AccessMode: nstructs.CSIVolumeAccessModeMultiNodeReader,
AttachmentMode: nstructs.CSIVolumeAttachmentMode("bar"),
},
ExpectedErr: errors.New("Unknown volume attachment mode: bar"),
},
{
Name: "returns transitive errors",
ClientSetupFunc: func(fc *fake.Client) {
fc.NextControllerPublishVolumeErr = errors.New("hello")
},
Request: &structs.ClientCSIControllerAttachVolumeRequest{
CSIControllerQuery: structs.CSIControllerQuery{
PluginID: fakePlugin.Name,
},
VolumeID: "1234-4321-1234-4321",
ClientCSINodeID: "abcde",
AccessMode: nstructs.CSIVolumeAccessModeSingleNodeWriter,
AttachmentMode: nstructs.CSIVolumeAttachmentModeFilesystem,
},
ExpectedErr: errors.New("hello"),
},
{
Name: "handles nil PublishContext",
ClientSetupFunc: func(fc *fake.Client) {
fc.NextControllerPublishVolumeResponse = &csi.ControllerPublishVolumeResponse{}
},
Request: &structs.ClientCSIControllerAttachVolumeRequest{
CSIControllerQuery: structs.CSIControllerQuery{
PluginID: fakePlugin.Name,
},
VolumeID: "1234-4321-1234-4321",
ClientCSINodeID: "abcde",
AccessMode: nstructs.CSIVolumeAccessModeSingleNodeWriter,
AttachmentMode: nstructs.CSIVolumeAttachmentModeFilesystem,
},
ExpectedResponse: &structs.ClientCSIControllerAttachVolumeResponse{},
},
{
Name: "handles non-nil PublishContext",
ClientSetupFunc: func(fc *fake.Client) {
fc.NextControllerPublishVolumeResponse = &csi.ControllerPublishVolumeResponse{
PublishContext: map[string]string{"foo": "bar"},
}
},
Request: &structs.ClientCSIControllerAttachVolumeRequest{
CSIControllerQuery: structs.CSIControllerQuery{
PluginID: fakePlugin.Name,
},
VolumeID: "1234-4321-1234-4321",
ClientCSINodeID: "abcde",
AccessMode: nstructs.CSIVolumeAccessModeSingleNodeWriter,
AttachmentMode: nstructs.CSIVolumeAttachmentModeFilesystem,
},
ExpectedResponse: &structs.ClientCSIControllerAttachVolumeResponse{
PublishContext: map[string]string{"foo": "bar"},
},
},
}
for _, tc := range cases {
t.Run(tc.Name, func(t *testing.T) {
require := require.New(t)
client, cleanup := TestClient(t, nil)
defer cleanup()
fakeClient := &fake.Client{}
if tc.ClientSetupFunc != nil {
tc.ClientSetupFunc(fakeClient)
}
dispenserFunc := func(*dynamicplugins.PluginInfo) (interface{}, error) {
return fakeClient, nil
}
client.dynamicRegistry.StubDispenserForType(dynamicplugins.PluginTypeCSIController, dispenserFunc)
err := client.dynamicRegistry.RegisterPlugin(fakePlugin)
require.Nil(err)
var resp structs.ClientCSIControllerAttachVolumeResponse
err = client.ClientRPC("CSIController.AttachVolume", tc.Request, &resp)
require.Equal(tc.ExpectedErr, err)
if tc.ExpectedResponse != nil {
require.Equal(tc.ExpectedResponse, &resp)
}
})
}
}
func TestCSIController_ValidateVolume(t *testing.T) {
t.Parallel()
cases := []struct {
Name string
ClientSetupFunc func(*fake.Client)
Request *structs.ClientCSIControllerValidateVolumeRequest
ExpectedErr error
ExpectedResponse *structs.ClientCSIControllerValidateVolumeResponse
}{
{
Name: "validates volumeid is not empty",
Request: &structs.ClientCSIControllerValidateVolumeRequest{
CSIControllerQuery: structs.CSIControllerQuery{
PluginID: fakePlugin.Name,
},
},
ExpectedErr: errors.New("VolumeID is required"),
},
{
Name: "returns plugin not found errors",
Request: &structs.ClientCSIControllerValidateVolumeRequest{
CSIControllerQuery: structs.CSIControllerQuery{
PluginID: "some-garbage",
},
VolumeID: "foo",
},
ExpectedErr: errors.New("plugin some-garbage for type csi-controller not found"),
},
{
Name: "validates attachmentmode",
Request: &structs.ClientCSIControllerValidateVolumeRequest{
CSIControllerQuery: structs.CSIControllerQuery{
PluginID: fakePlugin.Name,
},
VolumeID: "1234-4321-1234-4321",
AttachmentMode: nstructs.CSIVolumeAttachmentMode("bar"),
AccessMode: nstructs.CSIVolumeAccessModeMultiNodeReader,
},
ExpectedErr: errors.New("Unknown volume attachment mode: bar"),
},
{
Name: "validates AccessMode",
Request: &structs.ClientCSIControllerValidateVolumeRequest{
CSIControllerQuery: structs.CSIControllerQuery{
PluginID: fakePlugin.Name,
},
VolumeID: "1234-4321-1234-4321",
AttachmentMode: nstructs.CSIVolumeAttachmentModeFilesystem,
AccessMode: nstructs.CSIVolumeAccessMode("foo"),
},
ExpectedErr: errors.New("Unknown volume access mode: foo"),
},
{
Name: "returns transitive errors",
ClientSetupFunc: func(fc *fake.Client) {
fc.NextControllerValidateVolumeErr = errors.New("hello")
},
Request: &structs.ClientCSIControllerValidateVolumeRequest{
CSIControllerQuery: structs.CSIControllerQuery{
PluginID: fakePlugin.Name,
},
VolumeID: "1234-4321-1234-4321",
AccessMode: nstructs.CSIVolumeAccessModeSingleNodeWriter,
AttachmentMode: nstructs.CSIVolumeAttachmentModeFilesystem,
},
ExpectedErr: errors.New("hello"),
},
}
for _, tc := range cases {
t.Run(tc.Name, func(t *testing.T) {
require := require.New(t)
client, cleanup := TestClient(t, nil)
defer cleanup()
fakeClient := &fake.Client{}
if tc.ClientSetupFunc != nil {
tc.ClientSetupFunc(fakeClient)
}
dispenserFunc := func(*dynamicplugins.PluginInfo) (interface{}, error) {
return fakeClient, nil
}
client.dynamicRegistry.StubDispenserForType(dynamicplugins.PluginTypeCSIController, dispenserFunc)
err := client.dynamicRegistry.RegisterPlugin(fakePlugin)
require.Nil(err)
var resp structs.ClientCSIControllerValidateVolumeResponse
err = client.ClientRPC("CSIController.ValidateVolume", tc.Request, &resp)
require.Equal(tc.ExpectedErr, err)
if tc.ExpectedResponse != nil {
require.Equal(tc.ExpectedResponse, &resp)
}
})
}
}
func TestCSIController_DetachVolume(t *testing.T) {
t.Parallel()
cases := []struct {
Name string
ClientSetupFunc func(*fake.Client)
Request *structs.ClientCSIControllerDetachVolumeRequest
ExpectedErr error
ExpectedResponse *structs.ClientCSIControllerDetachVolumeResponse
}{
{
Name: "returns plugin not found errors",
Request: &structs.ClientCSIControllerDetachVolumeRequest{
CSIControllerQuery: structs.CSIControllerQuery{
PluginID: "some-garbage",
},
},
ExpectedErr: errors.New("plugin some-garbage for type csi-controller not found"),
},
{
Name: "validates volumeid is not empty",
Request: &structs.ClientCSIControllerDetachVolumeRequest{
CSIControllerQuery: structs.CSIControllerQuery{
PluginID: fakePlugin.Name,
},
},
ExpectedErr: errors.New("VolumeID is required"),
},
{
Name: "validates nodeid is not empty",
Request: &structs.ClientCSIControllerDetachVolumeRequest{
CSIControllerQuery: structs.CSIControllerQuery{
PluginID: fakePlugin.Name,
},
VolumeID: "1234-4321-1234-4321",
},
ExpectedErr: errors.New("ClientCSINodeID is required"),
},
{
Name: "returns transitive errors",
ClientSetupFunc: func(fc *fake.Client) {
fc.NextControllerUnpublishVolumeErr = errors.New("hello")
},
Request: &structs.ClientCSIControllerDetachVolumeRequest{
CSIControllerQuery: structs.CSIControllerQuery{
PluginID: fakePlugin.Name,
},
VolumeID: "1234-4321-1234-4321",
ClientCSINodeID: "abcde",
},
ExpectedErr: errors.New("hello"),
},
}
for _, tc := range cases {
t.Run(tc.Name, func(t *testing.T) {
require := require.New(t)
client, cleanup := TestClient(t, nil)
defer cleanup()
fakeClient := &fake.Client{}
if tc.ClientSetupFunc != nil {
tc.ClientSetupFunc(fakeClient)
}
dispenserFunc := func(*dynamicplugins.PluginInfo) (interface{}, error) {
return fakeClient, nil
}
client.dynamicRegistry.StubDispenserForType(dynamicplugins.PluginTypeCSIController, dispenserFunc)
err := client.dynamicRegistry.RegisterPlugin(fakePlugin)
require.Nil(err)
var resp structs.ClientCSIControllerDetachVolumeResponse
err = client.ClientRPC("CSIController.DetachVolume", tc.Request, &resp)
require.Equal(tc.ExpectedErr, err)
if tc.ExpectedResponse != nil {
require.Equal(tc.ExpectedResponse, &resp)
}
})
}
}

View File

@ -128,7 +128,7 @@ func New(c *Config) *manager {
// PluginType identifies this manager to the plugin manager and satisfies the PluginManager interface. // PluginType identifies this manager to the plugin manager and satisfies the PluginManager interface.
func (*manager) PluginType() string { return base.PluginTypeDevice } func (*manager) PluginType() string { return base.PluginTypeDevice }
// Run starts thed device manager. The manager will shutdown any previously // Run starts the device manager. The manager will shutdown any previously
// launched plugin and then begin fingerprinting and stats collection on all new // launched plugin and then begin fingerprinting and stats collection on all new
// device plugins. // device plugins.
func (m *manager) Run() { func (m *manager) Run() {

View File

@ -2,10 +2,10 @@ package state
import pstructs "github.com/hashicorp/nomad/plugins/shared/structs" import pstructs "github.com/hashicorp/nomad/plugins/shared/structs"
// PluginState is used to store the device managers state across restarts of the // PluginState is used to store the device manager's state across restarts of the
// agent // agent
type PluginState struct { type PluginState struct {
// ReattachConfigs are the set of reattach configs for plugin's launched by // ReattachConfigs are the set of reattach configs for plugins launched by
// the device manager // the device manager
ReattachConfigs map[string]*pstructs.ReattachConfig ReattachConfigs map[string]*pstructs.ReattachConfig
} }

View File

@ -0,0 +1,421 @@
// dynamicplugins is a package that manages dynamic plugins in Nomad.
// It exposes a registry that allows for plugins to be registered/deregistered
// and also allows subscribers to receive real time updates of these events.
package dynamicplugins
import (
"context"
"errors"
"fmt"
"sync"
)
const (
PluginTypeCSIController = "csi-controller"
PluginTypeCSINode = "csi-node"
)
// Registry is an interface that allows for the dynamic registration of plugins
// that are running as Nomad Tasks.
type Registry interface {
RegisterPlugin(info *PluginInfo) error
DeregisterPlugin(ptype, name string) error
ListPlugins(ptype string) []*PluginInfo
DispensePlugin(ptype, name string) (interface{}, error)
PluginsUpdatedCh(ctx context.Context, ptype string) <-chan *PluginUpdateEvent
Shutdown()
StubDispenserForType(ptype string, dispenser PluginDispenser)
}
// RegistryState is what we persist in the client state store. It contains
// a map of plugin types to maps of plugin name -> PluginInfo.
type RegistryState struct {
Plugins map[string]map[string]*PluginInfo
}
type PluginDispenser func(info *PluginInfo) (interface{}, error)
// NewRegistry takes a map of `plugintype` to PluginDispenser functions
// that should be used to vend clients for plugins to be used.
func NewRegistry(state StateStorage, dispensers map[string]PluginDispenser) Registry {
registry := &dynamicRegistry{
plugins: make(map[string]map[string]*PluginInfo),
broadcasters: make(map[string]*pluginEventBroadcaster),
dispensers: dispensers,
state: state,
}
// populate the state and initial broadcasters if we have an
// existing state DB to restore
if state != nil {
storedState, err := state.GetDynamicPluginRegistryState()
if err == nil && storedState != nil {
registry.plugins = storedState.Plugins
for ptype := range registry.plugins {
registry.broadcasterForPluginType(ptype)
}
}
}
return registry
}
// StateStorage is used to persist the dynamic plugin registry's state
// across agent restarts.
type StateStorage interface {
// GetDynamicPluginRegistryState is used to restore the registry state
GetDynamicPluginRegistryState() (*RegistryState, error)
// PutDynamicPluginRegistryState is used to store the registry state
PutDynamicPluginRegistryState(state *RegistryState) error
}
// PluginInfo is the metadata that is stored by the registry for a given plugin.
type PluginInfo struct {
Name string
Type string
Version string
// ConnectionInfo should only be used externally during `RegisterPlugin` and
// may not be exposed in the future.
ConnectionInfo *PluginConnectionInfo
// AllocID tracks the allocation running the plugin
AllocID string
// Options is used for plugin registrations to pass further metadata along to
// other subsystems
Options map[string]string
}
// PluginConnectionInfo is the data required to connect to the plugin.
// note: We currently only support Unix Domain Sockets, but this may be expanded
// to support other connection modes in the future.
type PluginConnectionInfo struct {
// SocketPath is the path to the plugins api socket.
SocketPath string
}
// EventType is the enum of events that will be emitted by a Registry's
// PluginsUpdatedCh.
type EventType string
const (
// EventTypeRegistered is emitted by the Registry when a new plugin has been
// registered.
EventTypeRegistered EventType = "registered"
// EventTypeDeregistered is emitted by the Registry when a plugin has been
// removed.
EventTypeDeregistered EventType = "deregistered"
)
// PluginUpdateEvent is a struct that is sent over a PluginsUpdatedCh when
// plugins are added or removed from the registry.
type PluginUpdateEvent struct {
EventType EventType
Info *PluginInfo
}
type dynamicRegistry struct {
plugins map[string]map[string]*PluginInfo
pluginsLock sync.RWMutex
broadcasters map[string]*pluginEventBroadcaster
broadcastersLock sync.Mutex
dispensers map[string]PluginDispenser
stubDispensers map[string]PluginDispenser
state StateStorage
}
// StubDispenserForType allows test functions to provide alternative plugin
// dispensers to simplify writing tests for higher level Nomad features.
// This function should not be called from production code.
func (d *dynamicRegistry) StubDispenserForType(ptype string, dispenser PluginDispenser) {
// delete from stubs
if dispenser == nil && d.stubDispensers != nil {
delete(d.stubDispensers, ptype)
if len(d.stubDispensers) == 0 {
d.stubDispensers = nil
}
return
}
// setup stubs
if d.stubDispensers == nil {
d.stubDispensers = make(map[string]PluginDispenser, 1)
}
d.stubDispensers[ptype] = dispenser
}
func (d *dynamicRegistry) RegisterPlugin(info *PluginInfo) error {
if info.Type == "" {
// This error shouldn't make it to a production cluster and is to aid
// developers during the development of new plugin types.
return errors.New("Plugin.Type must not be empty")
}
if info.ConnectionInfo == nil {
// This error shouldn't make it to a production cluster and is to aid
// developers during the development of new plugin types.
return errors.New("Plugin.ConnectionInfo must not be nil")
}
if info.Name == "" {
// This error shouldn't make it to a production cluster and is to aid
// developers during the development of new plugin types.
return errors.New("Plugin.Name must not be empty")
}
d.pluginsLock.Lock()
defer d.pluginsLock.Unlock()
pmap, ok := d.plugins[info.Type]
if !ok {
pmap = make(map[string]*PluginInfo, 1)
d.plugins[info.Type] = pmap
}
pmap[info.Name] = info
broadcaster := d.broadcasterForPluginType(info.Type)
event := &PluginUpdateEvent{
EventType: EventTypeRegistered,
Info: info,
}
broadcaster.broadcast(event)
return d.sync()
}
func (d *dynamicRegistry) broadcasterForPluginType(ptype string) *pluginEventBroadcaster {
d.broadcastersLock.Lock()
defer d.broadcastersLock.Unlock()
broadcaster, ok := d.broadcasters[ptype]
if !ok {
broadcaster = newPluginEventBroadcaster()
d.broadcasters[ptype] = broadcaster
}
return broadcaster
}
func (d *dynamicRegistry) DeregisterPlugin(ptype, name string) error {
d.pluginsLock.Lock()
defer d.pluginsLock.Unlock()
if ptype == "" {
// This error shouldn't make it to a production cluster and is to aid
// developers during the development of new plugin types.
return errors.New("must specify plugin type to deregister")
}
if name == "" {
// This error shouldn't make it to a production cluster and is to aid
// developers during the development of new plugin types.
return errors.New("must specify plugin name to deregister")
}
pmap, ok := d.plugins[ptype]
if !ok {
// If this occurs there's a bug in the registration handler.
return fmt.Errorf("no plugins registered for type: %s", ptype)
}
info, ok := pmap[name]
if !ok {
// plugin already deregistered, don't send events or try re-deleting.
return nil
}
delete(pmap, name)
broadcaster := d.broadcasterForPluginType(ptype)
event := &PluginUpdateEvent{
EventType: EventTypeDeregistered,
Info: info,
}
broadcaster.broadcast(event)
return d.sync()
}
func (d *dynamicRegistry) ListPlugins(ptype string) []*PluginInfo {
d.pluginsLock.RLock()
defer d.pluginsLock.RUnlock()
pmap, ok := d.plugins[ptype]
if !ok {
return nil
}
plugins := make([]*PluginInfo, 0, len(pmap))
for _, info := range pmap {
plugins = append(plugins, info)
}
return plugins
}
func (d *dynamicRegistry) DispensePlugin(ptype string, name string) (interface{}, error) {
d.pluginsLock.Lock()
defer d.pluginsLock.Unlock()
if ptype == "" {
// This error shouldn't make it to a production cluster and is to aid
// developers during the development of new plugin types.
return nil, errors.New("must specify plugin type to dispense")
}
if name == "" {
// This error shouldn't make it to a production cluster and is to aid
// developers during the development of new plugin types.
return nil, errors.New("must specify plugin name to dispense")
}
dispenseFunc, ok := d.dispensers[ptype]
if !ok {
// This error shouldn't make it to a production cluster and is to aid
// developers during the development of new plugin types.
return nil, fmt.Errorf("no plugin dispenser found for type: %s", ptype)
}
// After initially loading the dispenser (to avoid masking missing setup in
// client/client.go), we then check to see if we have any stub dispensers for
// this plugin type. If we do, then replace the dispenser fn with the stub.
if d.stubDispensers != nil {
if stub, ok := d.stubDispensers[ptype]; ok {
dispenseFunc = stub
}
}
pmap, ok := d.plugins[ptype]
if !ok {
return nil, fmt.Errorf("no plugins registered for type: %s", ptype)
}
info, ok := pmap[name]
if !ok {
return nil, fmt.Errorf("plugin %s for type %s not found", name, ptype)
}
return dispenseFunc(info)
}
// PluginsUpdatedCh returns a channel over which plugin events for the requested
// plugin type will be emitted. These events are strongly ordered and will never
// be dropped.
//
// The receiving channel _must not_ be closed before the provided context is
// cancelled.
func (d *dynamicRegistry) PluginsUpdatedCh(ctx context.Context, ptype string) <-chan *PluginUpdateEvent {
b := d.broadcasterForPluginType(ptype)
ch := b.subscribe()
go func() {
select {
case <-b.shutdownCh:
return
case <-ctx.Done():
b.unsubscribe(ch)
}
}()
return ch
}
func (d *dynamicRegistry) sync() error {
if d.state != nil {
storedState := &RegistryState{Plugins: d.plugins}
return d.state.PutDynamicPluginRegistryState(storedState)
}
return nil
}
func (d *dynamicRegistry) Shutdown() {
for _, b := range d.broadcasters {
b.shutdown()
}
}
type pluginEventBroadcaster struct {
stopCh chan struct{}
shutdownCh chan struct{}
publishCh chan *PluginUpdateEvent
subscriptions map[chan *PluginUpdateEvent]struct{}
subscriptionsLock sync.RWMutex
}
func newPluginEventBroadcaster() *pluginEventBroadcaster {
b := &pluginEventBroadcaster{
stopCh: make(chan struct{}),
shutdownCh: make(chan struct{}),
publishCh: make(chan *PluginUpdateEvent, 1),
subscriptions: make(map[chan *PluginUpdateEvent]struct{}),
}
go b.run()
return b
}
func (p *pluginEventBroadcaster) run() {
for {
select {
case <-p.stopCh:
close(p.shutdownCh)
return
case msg := <-p.publishCh:
p.subscriptionsLock.RLock()
for msgCh := range p.subscriptions {
select {
case msgCh <- msg:
}
}
p.subscriptionsLock.RUnlock()
}
}
}
func (p *pluginEventBroadcaster) shutdown() {
close(p.stopCh)
// Wait for loop to exit before closing subscriptions
<-p.shutdownCh
p.subscriptionsLock.Lock()
for sub := range p.subscriptions {
delete(p.subscriptions, sub)
close(sub)
}
p.subscriptionsLock.Unlock()
}
func (p *pluginEventBroadcaster) broadcast(e *PluginUpdateEvent) {
p.publishCh <- e
}
func (p *pluginEventBroadcaster) subscribe() chan *PluginUpdateEvent {
p.subscriptionsLock.Lock()
defer p.subscriptionsLock.Unlock()
ch := make(chan *PluginUpdateEvent, 1)
p.subscriptions[ch] = struct{}{}
return ch
}
func (p *pluginEventBroadcaster) unsubscribe(ch chan *PluginUpdateEvent) {
p.subscriptionsLock.Lock()
defer p.subscriptionsLock.Unlock()
_, ok := p.subscriptions[ch]
if ok {
delete(p.subscriptions, ch)
close(ch)
}
}

View File

@ -0,0 +1,244 @@
package dynamicplugins
import (
"context"
"sync"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestPluginEventBroadcaster_SendsMessagesToAllClients(t *testing.T) {
t.Parallel()
b := newPluginEventBroadcaster()
defer close(b.stopCh)
var rcv1, rcv2 bool
ch1 := b.subscribe()
ch2 := b.subscribe()
listenFunc := func(ch chan *PluginUpdateEvent, updateBool *bool) {
select {
case <-ch:
*updateBool = true
}
}
go listenFunc(ch1, &rcv1)
go listenFunc(ch2, &rcv2)
b.broadcast(&PluginUpdateEvent{})
require.Eventually(t, func() bool {
return rcv1 == true && rcv2 == true
}, 1*time.Second, 200*time.Millisecond)
}
func TestPluginEventBroadcaster_UnsubscribeWorks(t *testing.T) {
t.Parallel()
b := newPluginEventBroadcaster()
defer close(b.stopCh)
var rcv1 bool
ch1 := b.subscribe()
listenFunc := func(ch chan *PluginUpdateEvent, updateBool *bool) {
select {
case e := <-ch:
if e == nil {
*updateBool = true
}
}
}
go listenFunc(ch1, &rcv1)
b.unsubscribe(ch1)
b.broadcast(&PluginUpdateEvent{})
require.Eventually(t, func() bool {
return rcv1 == true
}, 1*time.Second, 200*time.Millisecond)
}
func TestDynamicRegistry_RegisterPlugin_SendsUpdateEvents(t *testing.T) {
t.Parallel()
r := NewRegistry(nil, nil)
ctx, cancelFn := context.WithCancel(context.Background())
defer cancelFn()
ch := r.PluginsUpdatedCh(ctx, "csi")
receivedRegistrationEvent := false
listenFunc := func(ch <-chan *PluginUpdateEvent, updateBool *bool) {
select {
case e := <-ch:
if e == nil {
return
}
if e.EventType == EventTypeRegistered {
*updateBool = true
}
}
}
go listenFunc(ch, &receivedRegistrationEvent)
err := r.RegisterPlugin(&PluginInfo{
Type: "csi",
Name: "my-plugin",
ConnectionInfo: &PluginConnectionInfo{},
})
require.NoError(t, err)
require.Eventually(t, func() bool {
return receivedRegistrationEvent == true
}, 1*time.Second, 200*time.Millisecond)
}
func TestDynamicRegistry_DeregisterPlugin_SendsUpdateEvents(t *testing.T) {
t.Parallel()
r := NewRegistry(nil, nil)
ctx, cancelFn := context.WithCancel(context.Background())
defer cancelFn()
ch := r.PluginsUpdatedCh(ctx, "csi")
receivedDeregistrationEvent := false
listenFunc := func(ch <-chan *PluginUpdateEvent, updateBool *bool) {
for {
select {
case e := <-ch:
if e == nil {
return
}
if e.EventType == EventTypeDeregistered {
*updateBool = true
}
}
}
}
go listenFunc(ch, &receivedDeregistrationEvent)
err := r.RegisterPlugin(&PluginInfo{
Type: "csi",
Name: "my-plugin",
ConnectionInfo: &PluginConnectionInfo{},
})
require.NoError(t, err)
err = r.DeregisterPlugin("csi", "my-plugin")
require.NoError(t, err)
require.Eventually(t, func() bool {
return receivedDeregistrationEvent == true
}, 1*time.Second, 200*time.Millisecond)
}
func TestDynamicRegistry_DispensePlugin_Works(t *testing.T) {
dispenseFn := func(i *PluginInfo) (interface{}, error) {
return struct{}{}, nil
}
registry := NewRegistry(nil, map[string]PluginDispenser{"csi": dispenseFn})
err := registry.RegisterPlugin(&PluginInfo{
Type: "csi",
Name: "my-plugin",
ConnectionInfo: &PluginConnectionInfo{},
})
require.NoError(t, err)
result, err := registry.DispensePlugin("unknown-type", "unknown-name")
require.Nil(t, result)
require.EqualError(t, err, "no plugin dispenser found for type: unknown-type")
result, err = registry.DispensePlugin("csi", "unknown-name")
require.Nil(t, result)
require.EqualError(t, err, "plugin unknown-name for type csi not found")
result, err = registry.DispensePlugin("csi", "my-plugin")
require.NotNil(t, result)
require.NoError(t, err)
}
func TestDynamicRegistry_IsolatePluginTypes(t *testing.T) {
t.Parallel()
r := NewRegistry(nil, nil)
err := r.RegisterPlugin(&PluginInfo{
Type: PluginTypeCSIController,
Name: "my-plugin",
ConnectionInfo: &PluginConnectionInfo{},
})
require.NoError(t, err)
err = r.RegisterPlugin(&PluginInfo{
Type: PluginTypeCSINode,
Name: "my-plugin",
ConnectionInfo: &PluginConnectionInfo{},
})
require.NoError(t, err)
err = r.DeregisterPlugin(PluginTypeCSIController, "my-plugin")
require.NoError(t, err)
require.Equal(t, len(r.ListPlugins(PluginTypeCSINode)), 1)
require.Equal(t, len(r.ListPlugins(PluginTypeCSIController)), 0)
}
func TestDynamicRegistry_StateStore(t *testing.T) {
t.Parallel()
dispenseFn := func(i *PluginInfo) (interface{}, error) {
return i, nil
}
memdb := &MemDB{}
oldR := NewRegistry(memdb, map[string]PluginDispenser{"csi": dispenseFn})
err := oldR.RegisterPlugin(&PluginInfo{
Type: "csi",
Name: "my-plugin",
ConnectionInfo: &PluginConnectionInfo{},
})
require.NoError(t, err)
result, err := oldR.DispensePlugin("csi", "my-plugin")
require.NotNil(t, result)
require.NoError(t, err)
// recreate the registry from the state store and query again
newR := NewRegistry(memdb, map[string]PluginDispenser{"csi": dispenseFn})
result, err = newR.DispensePlugin("csi", "my-plugin")
require.NotNil(t, result)
require.NoError(t, err)
}
// MemDB implements a StateDB that stores data in memory and should only be
// used for testing. All methods are safe for concurrent use. This is a
// partial implementation of the MemDB in the client/state package, copied
// here to avoid circular dependencies.
type MemDB struct {
dynamicManagerPs *RegistryState
mu sync.RWMutex
}
func (m *MemDB) GetDynamicPluginRegistryState() (*RegistryState, error) {
m.mu.Lock()
defer m.mu.Unlock()
return m.dynamicManagerPs, nil
}
func (m *MemDB) PutDynamicPluginRegistryState(ps *RegistryState) error {
m.mu.Lock()
defer m.mu.Unlock()
m.dynamicManagerPs = ps
return nil
}

View File

@ -7,6 +7,7 @@ import (
"time" "time"
"github.com/hashicorp/nomad/client/devicemanager" "github.com/hashicorp/nomad/client/devicemanager"
"github.com/hashicorp/nomad/client/pluginmanager/csimanager"
"github.com/hashicorp/nomad/client/pluginmanager/drivermanager" "github.com/hashicorp/nomad/client/pluginmanager/drivermanager"
"github.com/hashicorp/nomad/nomad/structs" "github.com/hashicorp/nomad/nomad/structs"
) )
@ -40,6 +41,23 @@ SEND_BATCH:
c.configLock.Lock() c.configLock.Lock()
defer c.configLock.Unlock() defer c.configLock.Unlock()
// csi updates
var csiChanged bool
c.batchNodeUpdates.batchCSIUpdates(func(name string, info *structs.CSIInfo) {
if c.updateNodeFromCSIControllerLocked(name, info) {
if c.config.Node.CSIControllerPlugins[name].UpdateTime.IsZero() {
c.config.Node.CSIControllerPlugins[name].UpdateTime = time.Now()
}
csiChanged = true
}
if c.updateNodeFromCSINodeLocked(name, info) {
if c.config.Node.CSINodePlugins[name].UpdateTime.IsZero() {
c.config.Node.CSINodePlugins[name].UpdateTime = time.Now()
}
csiChanged = true
}
})
// driver node updates // driver node updates
var driverChanged bool var driverChanged bool
c.batchNodeUpdates.batchDriverUpdates(func(driver string, info *structs.DriverInfo) { c.batchNodeUpdates.batchDriverUpdates(func(driver string, info *structs.DriverInfo) {
@ -61,13 +79,128 @@ SEND_BATCH:
}) })
// only update the node if changes occurred // only update the node if changes occurred
if driverChanged || devicesChanged { if driverChanged || devicesChanged || csiChanged {
c.updateNodeLocked() c.updateNodeLocked()
} }
close(c.fpInitialized) close(c.fpInitialized)
} }
// updateNodeFromCSI receives a CSIInfo struct for the plugin and updates the
// node accordingly
func (c *Client) updateNodeFromCSI(name string, info *structs.CSIInfo) {
c.configLock.Lock()
defer c.configLock.Unlock()
changed := false
if c.updateNodeFromCSIControllerLocked(name, info) {
if c.config.Node.CSIControllerPlugins[name].UpdateTime.IsZero() {
c.config.Node.CSIControllerPlugins[name].UpdateTime = time.Now()
}
changed = true
}
if c.updateNodeFromCSINodeLocked(name, info) {
if c.config.Node.CSINodePlugins[name].UpdateTime.IsZero() {
c.config.Node.CSINodePlugins[name].UpdateTime = time.Now()
}
changed = true
}
if changed {
c.updateNodeLocked()
}
}
// updateNodeFromCSIControllerLocked makes the changes to the node from a csi
// update but does not send the update to the server. c.configLock must be held
// before calling this func.
//
// It is safe to call for all CSI Updates, but will only perform changes when
// a ControllerInfo field is present.
func (c *Client) updateNodeFromCSIControllerLocked(name string, info *structs.CSIInfo) bool {
var changed bool
if info.ControllerInfo == nil {
return false
}
i := info.Copy()
i.NodeInfo = nil
oldController, hadController := c.config.Node.CSIControllerPlugins[name]
if !hadController {
// If the controller info has not yet been set, do that here
changed = true
c.config.Node.CSIControllerPlugins[name] = i
} else {
// The controller info has already been set, fix it up
if !oldController.Equal(i) {
c.config.Node.CSIControllerPlugins[name] = i
changed = true
}
// If health state has changed, trigger node event
if oldController.Healthy != i.Healthy || oldController.HealthDescription != i.HealthDescription {
changed = true
if i.HealthDescription != "" {
event := &structs.NodeEvent{
Subsystem: "CSI",
Message: i.HealthDescription,
Timestamp: time.Now(),
Details: map[string]string{"plugin": name, "type": "controller"},
}
c.triggerNodeEvent(event)
}
}
}
return changed
}
// updateNodeFromCSINodeLocked makes the changes to the node from a csi
// update but does not send the update to the server. c.configLock must be hel
// before calling this func.
//
// It is safe to call for all CSI Updates, but will only perform changes when
// a NodeInfo field is present.
func (c *Client) updateNodeFromCSINodeLocked(name string, info *structs.CSIInfo) bool {
var changed bool
if info.NodeInfo == nil {
return false
}
i := info.Copy()
i.ControllerInfo = nil
oldNode, hadNode := c.config.Node.CSINodePlugins[name]
if !hadNode {
// If the Node info has not yet been set, do that here
changed = true
c.config.Node.CSINodePlugins[name] = i
} else {
// The node info has already been set, fix it up
if !oldNode.Equal(info) {
c.config.Node.CSINodePlugins[name] = i
changed = true
}
// If health state has changed, trigger node event
if oldNode.Healthy != i.Healthy || oldNode.HealthDescription != i.HealthDescription {
changed = true
if i.HealthDescription != "" {
event := &structs.NodeEvent{
Subsystem: "CSI",
Message: i.HealthDescription,
Timestamp: time.Now(),
Details: map[string]string{"plugin": name, "type": "node"},
}
c.triggerNodeEvent(event)
}
}
}
return changed
}
// updateNodeFromDriver receives a DriverInfo struct for the driver and updates // updateNodeFromDriver receives a DriverInfo struct for the driver and updates
// the node accordingly // the node accordingly
func (c *Client) updateNodeFromDriver(name string, info *structs.DriverInfo) { func (c *Client) updateNodeFromDriver(name string, info *structs.DriverInfo) {
@ -187,20 +320,71 @@ type batchNodeUpdates struct {
devicesBatched bool devicesBatched bool
devicesCB devicemanager.UpdateNodeDevicesFn devicesCB devicemanager.UpdateNodeDevicesFn
devicesMu sync.Mutex devicesMu sync.Mutex
// access to csi fields must hold csiMu lock
csiNodePlugins map[string]*structs.CSIInfo
csiControllerPlugins map[string]*structs.CSIInfo
csiBatched bool
csiCB csimanager.UpdateNodeCSIInfoFunc
csiMu sync.Mutex
} }
func newBatchNodeUpdates( func newBatchNodeUpdates(
driverCB drivermanager.UpdateNodeDriverInfoFn, driverCB drivermanager.UpdateNodeDriverInfoFn,
devicesCB devicemanager.UpdateNodeDevicesFn) *batchNodeUpdates { devicesCB devicemanager.UpdateNodeDevicesFn,
csiCB csimanager.UpdateNodeCSIInfoFunc) *batchNodeUpdates {
return &batchNodeUpdates{ return &batchNodeUpdates{
drivers: make(map[string]*structs.DriverInfo), drivers: make(map[string]*structs.DriverInfo),
driverCB: driverCB, driverCB: driverCB,
devices: []*structs.NodeDeviceResource{}, devices: []*structs.NodeDeviceResource{},
devicesCB: devicesCB, devicesCB: devicesCB,
csiNodePlugins: make(map[string]*structs.CSIInfo),
csiControllerPlugins: make(map[string]*structs.CSIInfo),
csiCB: csiCB,
} }
} }
// updateNodeFromCSI implements csimanager.UpdateNodeCSIInfoFunc and is used in
// the csi manager to send csi fingerprints to the server.
func (b *batchNodeUpdates) updateNodeFromCSI(plugin string, info *structs.CSIInfo) {
b.csiMu.Lock()
defer b.csiMu.Unlock()
if b.csiBatched {
b.csiCB(plugin, info)
return
}
// Only one of these is expected to be set, but a future implementation that
// explicitly models monolith plugins with a single fingerprinter may set both
if info.ControllerInfo != nil {
b.csiControllerPlugins[plugin] = info
}
if info.NodeInfo != nil {
b.csiNodePlugins[plugin] = info
}
}
// batchCSIUpdates sends all of the batched CSI updates by calling f for each
// plugin batched
func (b *batchNodeUpdates) batchCSIUpdates(f csimanager.UpdateNodeCSIInfoFunc) error {
b.csiMu.Lock()
defer b.csiMu.Unlock()
if b.csiBatched {
return fmt.Errorf("csi updates already batched")
}
b.csiBatched = true
for plugin, info := range b.csiNodePlugins {
f(plugin, info)
}
for plugin, info := range b.csiControllerPlugins {
f(plugin, info)
}
return nil
}
// updateNodeFromDriver implements drivermanager.UpdateNodeDriverInfoFn and is // updateNodeFromDriver implements drivermanager.UpdateNodeDriverInfoFn and is
// used in the driver manager to send driver fingerprints to // used in the driver manager to send driver fingerprints to
func (b *batchNodeUpdates) updateNodeFromDriver(driver string, info *structs.DriverInfo) { func (b *batchNodeUpdates) updateNodeFromDriver(driver string, info *structs.DriverInfo) {

View File

@ -0,0 +1,15 @@
/**
csimanager manages locally running CSI Plugins on a Nomad host, and provides a
few different interfaces.
It provides:
- a pluginmanager.PluginManager implementation that is used to fingerprint and
heartbeat local node plugins
- (TODO) a csimanager.AttachmentWaiter implementation that can be used to wait for an
external CSIVolume to be attached to the node before returning
- (TODO) a csimanager.NodeController implementation that is used to manage the node-local
portions of the CSI specification, and encompassess volume staging/publishing
- (TODO) a csimanager.VolumeChecker implementation that can be used by hooks to ensure
their volumes are healthy(ish)
*/
package csimanager

View File

@ -0,0 +1,175 @@
package csimanager
import (
"context"
"fmt"
"github.com/hashicorp/go-hclog"
"github.com/hashicorp/nomad/client/dynamicplugins"
"github.com/hashicorp/nomad/helper"
"github.com/hashicorp/nomad/nomad/structs"
"github.com/hashicorp/nomad/plugins/csi"
)
type pluginFingerprinter struct {
logger hclog.Logger
client csi.CSIPlugin
info *dynamicplugins.PluginInfo
// basicInfo holds a cache of data that should not change within a CSI plugin.
// This allows us to minimize the number of requests we make to plugins on each
// run of the fingerprinter, and reduces the chances of performing overly
// expensive actions repeatedly, and improves stability of data through
// transient failures.
basicInfo *structs.CSIInfo
fingerprintNode bool
fingerprintController bool
hadFirstSuccessfulFingerprint bool
// hadFirstSuccessfulFingerprintCh is closed the first time a fingerprint
// is completed successfully.
hadFirstSuccessfulFingerprintCh chan struct{}
// requiresStaging is set on a first successful fingerprint. It allows the
// csimanager to efficiently query this as it shouldn't change after a plugin
// is started. Removing this bool will require storing a cache of recent successful
// results that can be used by subscribers of the `hadFirstSuccessfulFingerprintCh`.
requiresStaging bool
}
func (p *pluginFingerprinter) fingerprint(ctx context.Context) *structs.CSIInfo {
if p.basicInfo == nil {
info, err := p.buildBasicFingerprint(ctx)
if err != nil {
// If we receive a fingerprinting error, update the stats with as much
// info as possible and wait for the next fingerprint interval.
info.HealthDescription = fmt.Sprintf("failed initial fingerprint with err: %v", err)
info.Healthy = false
return info
}
// If fingerprinting succeeded, we don't need to repopulate the basic
// info again.
p.basicInfo = info
}
info := p.basicInfo.Copy()
var fp *structs.CSIInfo
var err error
if p.fingerprintNode {
fp, err = p.buildNodeFingerprint(ctx, info)
} else if p.fingerprintController {
fp, err = p.buildControllerFingerprint(ctx, info)
}
if err != nil {
info.Healthy = false
info.HealthDescription = fmt.Sprintf("failed fingerprinting with error: %v", err)
} else {
info = fp
if !p.hadFirstSuccessfulFingerprint {
p.hadFirstSuccessfulFingerprint = true
if p.fingerprintNode {
p.requiresStaging = info.NodeInfo.RequiresNodeStageVolume
}
close(p.hadFirstSuccessfulFingerprintCh)
}
}
return info
}
func (p *pluginFingerprinter) buildBasicFingerprint(ctx context.Context) (*structs.CSIInfo, error) {
info := &structs.CSIInfo{
PluginID: p.info.Name,
AllocID: p.info.AllocID,
Provider: p.info.Options["Provider"],
ProviderVersion: p.info.Version,
Healthy: false,
HealthDescription: "initial fingerprint not completed",
}
if p.fingerprintNode {
info.NodeInfo = &structs.CSINodeInfo{}
}
if p.fingerprintController {
info.ControllerInfo = &structs.CSIControllerInfo{}
}
capabilities, err := p.client.PluginGetCapabilities(ctx)
if err != nil {
return info, err
}
info.RequiresControllerPlugin = capabilities.HasControllerService()
info.RequiresTopologies = capabilities.HasToplogies()
if p.fingerprintNode {
nodeInfo, err := p.client.NodeGetInfo(ctx)
if err != nil {
return info, err
}
info.NodeInfo.ID = nodeInfo.NodeID
info.NodeInfo.MaxVolumes = nodeInfo.MaxVolumes
info.NodeInfo.AccessibleTopology = structCSITopologyFromCSITopology(nodeInfo.AccessibleTopology)
}
return info, nil
}
func applyCapabilitySetToControllerInfo(cs *csi.ControllerCapabilitySet, info *structs.CSIControllerInfo) {
info.SupportsReadOnlyAttach = cs.HasPublishReadonly
info.SupportsAttachDetach = cs.HasPublishUnpublishVolume
info.SupportsListVolumes = cs.HasListVolumes
info.SupportsListVolumesAttachedNodes = cs.HasListVolumesPublishedNodes
}
func (p *pluginFingerprinter) buildControllerFingerprint(ctx context.Context, base *structs.CSIInfo) (*structs.CSIInfo, error) {
fp := base.Copy()
healthy, err := p.client.PluginProbe(ctx)
if err != nil {
return nil, err
}
fp.SetHealthy(healthy)
caps, err := p.client.ControllerGetCapabilities(ctx)
if err != nil {
return fp, err
}
applyCapabilitySetToControllerInfo(caps, fp.ControllerInfo)
return fp, nil
}
func (p *pluginFingerprinter) buildNodeFingerprint(ctx context.Context, base *structs.CSIInfo) (*structs.CSIInfo, error) {
fp := base.Copy()
healthy, err := p.client.PluginProbe(ctx)
if err != nil {
return nil, err
}
fp.SetHealthy(healthy)
caps, err := p.client.NodeGetCapabilities(ctx)
if err != nil {
return fp, err
}
fp.NodeInfo.RequiresNodeStageVolume = caps.HasStageUnstageVolume
return fp, nil
}
func structCSITopologyFromCSITopology(a *csi.Topology) *structs.CSITopology {
if a == nil {
return nil
}
return &structs.CSITopology{
Segments: helper.CopyMapStringString(a.Segments),
}
}

View File

@ -0,0 +1,277 @@
package csimanager
import (
"context"
"errors"
"testing"
"github.com/hashicorp/nomad/nomad/structs"
"github.com/hashicorp/nomad/plugins/csi"
"github.com/stretchr/testify/require"
)
func TestBuildBasicFingerprint_Node(t *testing.T) {
tt := []struct {
Name string
Capabilities *csi.PluginCapabilitySet
CapabilitiesErr error
CapabilitiesCallCount int64
NodeInfo *csi.NodeGetInfoResponse
NodeInfoErr error
NodeInfoCallCount int64
ExpectedCSIInfo *structs.CSIInfo
ExpectedErr error
}{
{
Name: "Minimal successful response",
Capabilities: &csi.PluginCapabilitySet{},
CapabilitiesCallCount: 1,
NodeInfo: &csi.NodeGetInfoResponse{
NodeID: "foobar",
MaxVolumes: 5,
AccessibleTopology: nil,
},
NodeInfoCallCount: 1,
ExpectedCSIInfo: &structs.CSIInfo{
PluginID: "test-plugin",
Healthy: false,
HealthDescription: "initial fingerprint not completed",
NodeInfo: &structs.CSINodeInfo{
ID: "foobar",
MaxVolumes: 5,
},
},
},
{
Name: "Successful response with capabilities and topologies",
Capabilities: csi.NewTestPluginCapabilitySet(true, false),
CapabilitiesCallCount: 1,
NodeInfo: &csi.NodeGetInfoResponse{
NodeID: "foobar",
MaxVolumes: 5,
AccessibleTopology: &csi.Topology{
Segments: map[string]string{
"com.hashicorp.nomad/node-id": "foobar",
},
},
},
NodeInfoCallCount: 1,
ExpectedCSIInfo: &structs.CSIInfo{
PluginID: "test-plugin",
Healthy: false,
HealthDescription: "initial fingerprint not completed",
RequiresTopologies: true,
NodeInfo: &structs.CSINodeInfo{
ID: "foobar",
MaxVolumes: 5,
AccessibleTopology: &structs.CSITopology{
Segments: map[string]string{
"com.hashicorp.nomad/node-id": "foobar",
},
},
},
},
},
{
Name: "PluginGetCapabilities Failed",
CapabilitiesErr: errors.New("request failed"),
CapabilitiesCallCount: 1,
NodeInfoCallCount: 0,
ExpectedCSIInfo: &structs.CSIInfo{
PluginID: "test-plugin",
Healthy: false,
HealthDescription: "initial fingerprint not completed",
NodeInfo: &structs.CSINodeInfo{},
},
ExpectedErr: errors.New("request failed"),
},
{
Name: "NodeGetInfo Failed",
Capabilities: &csi.PluginCapabilitySet{},
CapabilitiesCallCount: 1,
NodeInfoErr: errors.New("request failed"),
NodeInfoCallCount: 1,
ExpectedCSIInfo: &structs.CSIInfo{
PluginID: "test-plugin",
Healthy: false,
HealthDescription: "initial fingerprint not completed",
NodeInfo: &structs.CSINodeInfo{},
},
ExpectedErr: errors.New("request failed"),
},
}
for _, test := range tt {
t.Run(test.Name, func(t *testing.T) {
client, im := setupTestNodeInstanceManager(t)
client.NextPluginGetCapabilitiesResponse = test.Capabilities
client.NextPluginGetCapabilitiesErr = test.CapabilitiesErr
client.NextNodeGetInfoResponse = test.NodeInfo
client.NextNodeGetInfoErr = test.NodeInfoErr
info, err := im.fp.buildBasicFingerprint(context.TODO())
require.Equal(t, test.ExpectedCSIInfo, info)
require.Equal(t, test.ExpectedErr, err)
require.Equal(t, test.CapabilitiesCallCount, client.PluginGetCapabilitiesCallCount)
require.Equal(t, test.NodeInfoCallCount, client.NodeGetInfoCallCount)
})
}
}
func TestBuildControllerFingerprint(t *testing.T) {
tt := []struct {
Name string
Capabilities *csi.ControllerCapabilitySet
CapabilitiesErr error
CapabilitiesCallCount int64
ProbeResponse bool
ProbeErr error
ProbeCallCount int64
ExpectedControllerInfo *structs.CSIControllerInfo
ExpectedErr error
}{
{
Name: "Minimal successful response",
Capabilities: &csi.ControllerCapabilitySet{},
CapabilitiesCallCount: 1,
ProbeResponse: true,
ProbeCallCount: 1,
ExpectedControllerInfo: &structs.CSIControllerInfo{},
},
{
Name: "Successful response with capabilities",
Capabilities: &csi.ControllerCapabilitySet{
HasListVolumes: true,
},
CapabilitiesCallCount: 1,
ProbeResponse: true,
ProbeCallCount: 1,
ExpectedControllerInfo: &structs.CSIControllerInfo{
SupportsListVolumes: true,
},
},
{
Name: "ControllerGetCapabilities Failed",
CapabilitiesErr: errors.New("request failed"),
CapabilitiesCallCount: 1,
ProbeResponse: true,
ProbeCallCount: 1,
ExpectedControllerInfo: &structs.CSIControllerInfo{},
ExpectedErr: errors.New("request failed"),
},
}
for _, test := range tt {
t.Run(test.Name, func(t *testing.T) {
client, im := setupTestNodeInstanceManager(t)
client.NextControllerGetCapabilitiesResponse = test.Capabilities
client.NextControllerGetCapabilitiesErr = test.CapabilitiesErr
client.NextPluginProbeResponse = test.ProbeResponse
client.NextPluginProbeErr = test.ProbeErr
info, err := im.fp.buildControllerFingerprint(context.TODO(), &structs.CSIInfo{ControllerInfo: &structs.CSIControllerInfo{}})
require.Equal(t, test.ExpectedControllerInfo, info.ControllerInfo)
require.Equal(t, test.ExpectedErr, err)
require.Equal(t, test.CapabilitiesCallCount, client.ControllerGetCapabilitiesCallCount)
require.Equal(t, test.ProbeCallCount, client.PluginProbeCallCount)
})
}
}
func TestBuildNodeFingerprint(t *testing.T) {
tt := []struct {
Name string
Capabilities *csi.NodeCapabilitySet
CapabilitiesErr error
CapabilitiesCallCount int64
ExpectedCSINodeInfo *structs.CSINodeInfo
ExpectedErr error
}{
{
Name: "Minimal successful response",
Capabilities: &csi.NodeCapabilitySet{},
CapabilitiesCallCount: 1,
ExpectedCSINodeInfo: &structs.CSINodeInfo{
RequiresNodeStageVolume: false,
},
},
{
Name: "Successful response with capabilities and topologies",
Capabilities: &csi.NodeCapabilitySet{
HasStageUnstageVolume: true,
},
CapabilitiesCallCount: 1,
ExpectedCSINodeInfo: &structs.CSINodeInfo{
RequiresNodeStageVolume: true,
},
},
{
Name: "NodeGetCapabilities Failed",
CapabilitiesErr: errors.New("request failed"),
CapabilitiesCallCount: 1,
ExpectedCSINodeInfo: &structs.CSINodeInfo{},
ExpectedErr: errors.New("request failed"),
},
}
for _, test := range tt {
t.Run(test.Name, func(t *testing.T) {
client, im := setupTestNodeInstanceManager(t)
client.NextNodeGetCapabilitiesResponse = test.Capabilities
client.NextNodeGetCapabilitiesErr = test.CapabilitiesErr
info, err := im.fp.buildNodeFingerprint(context.TODO(), &structs.CSIInfo{NodeInfo: &structs.CSINodeInfo{}})
require.Equal(t, test.ExpectedCSINodeInfo, info.NodeInfo)
require.Equal(t, test.ExpectedErr, err)
require.Equal(t, test.CapabilitiesCallCount, client.NodeGetCapabilitiesCallCount)
})
}
}

View File

@ -0,0 +1,157 @@
package csimanager
import (
"context"
"time"
"github.com/hashicorp/go-hclog"
"github.com/hashicorp/nomad/client/dynamicplugins"
"github.com/hashicorp/nomad/plugins/csi"
)
const managerFingerprintInterval = 30 * time.Second
// instanceManager is used to manage the fingerprinting and supervision of a
// single CSI Plugin.
type instanceManager struct {
info *dynamicplugins.PluginInfo
logger hclog.Logger
updater UpdateNodeCSIInfoFunc
shutdownCtx context.Context
shutdownCtxCancelFn context.CancelFunc
shutdownCh chan struct{}
// mountPoint is the root of the mount dir where plugin specific data may be
// stored and where mount points will be created
mountPoint string
// containerMountPoint is the location _inside_ the plugin container that the
// `mountPoint` is bound in to.
containerMountPoint string
// AllocID is the allocation id of the task group running the dynamic plugin
allocID string
fp *pluginFingerprinter
volumeManager *volumeManager
volumeManagerSetupCh chan struct{}
client csi.CSIPlugin
}
func newInstanceManager(logger hclog.Logger, updater UpdateNodeCSIInfoFunc, p *dynamicplugins.PluginInfo) *instanceManager {
ctx, cancelFn := context.WithCancel(context.Background())
logger = logger.Named(p.Name)
return &instanceManager{
logger: logger,
info: p,
updater: updater,
fp: &pluginFingerprinter{
logger: logger.Named("fingerprinter"),
info: p,
fingerprintNode: p.Type == dynamicplugins.PluginTypeCSINode,
fingerprintController: p.Type == dynamicplugins.PluginTypeCSIController,
hadFirstSuccessfulFingerprintCh: make(chan struct{}),
},
mountPoint: p.Options["MountPoint"],
containerMountPoint: p.Options["ContainerMountPoint"],
allocID: p.AllocID,
volumeManagerSetupCh: make(chan struct{}),
shutdownCtx: ctx,
shutdownCtxCancelFn: cancelFn,
shutdownCh: make(chan struct{}),
}
}
func (i *instanceManager) run() {
c, err := csi.NewClient(i.info.ConnectionInfo.SocketPath, i.logger)
if err != nil {
i.logger.Error("failed to setup instance manager client", "error", err)
close(i.shutdownCh)
return
}
i.client = c
i.fp.client = c
go i.setupVolumeManager()
go i.runLoop()
}
func (i *instanceManager) setupVolumeManager() {
if i.info.Type != dynamicplugins.PluginTypeCSINode {
i.logger.Debug("not a node plugin, skipping volume manager setup", "type", i.info.Type)
return
}
select {
case <-i.shutdownCtx.Done():
return
case <-i.fp.hadFirstSuccessfulFingerprintCh:
i.volumeManager = newVolumeManager(i.logger, i.client, i.mountPoint, i.containerMountPoint, i.fp.requiresStaging)
i.logger.Debug("volume manager setup complete")
close(i.volumeManagerSetupCh)
return
}
}
// VolumeMounter returns the volume manager that is configured for the given plugin
// instance. If called before the volume manager has been setup, it will block until
// the volume manager is ready or the context is closed.
func (i *instanceManager) VolumeMounter(ctx context.Context) (VolumeMounter, error) {
select {
case <-i.volumeManagerSetupCh:
return i.volumeManager, nil
case <-ctx.Done():
return nil, ctx.Err()
}
}
func (i *instanceManager) requestCtxWithTimeout(timeout time.Duration) (context.Context, context.CancelFunc) {
return context.WithTimeout(i.shutdownCtx, timeout)
}
func (i *instanceManager) runLoop() {
timer := time.NewTimer(0)
for {
select {
case <-i.shutdownCtx.Done():
if i.client != nil {
i.client.Close()
i.client = nil
}
// run one last fingerprint so that we mark the plugin as unhealthy.
// the client has been closed so this will return quickly with the
// plugin's basic info
ctx, cancelFn := i.requestCtxWithTimeout(time.Second)
info := i.fp.fingerprint(ctx)
cancelFn()
if info != nil {
i.updater(i.info.Name, info)
}
close(i.shutdownCh)
return
case <-timer.C:
ctx, cancelFn := i.requestCtxWithTimeout(managerFingerprintInterval)
info := i.fp.fingerprint(ctx)
cancelFn()
if info != nil {
i.updater(i.info.Name, info)
}
timer.Reset(managerFingerprintInterval)
}
}
}
func (i *instanceManager) shutdown() {
i.shutdownCtxCancelFn()
<-i.shutdownCh
}

View File

@ -0,0 +1,82 @@
package csimanager
import (
"context"
"fmt"
"sync"
"testing"
"time"
"github.com/hashicorp/nomad/client/dynamicplugins"
"github.com/hashicorp/nomad/helper/testlog"
"github.com/hashicorp/nomad/nomad/structs"
"github.com/hashicorp/nomad/plugins/csi"
"github.com/hashicorp/nomad/plugins/csi/fake"
"github.com/stretchr/testify/require"
)
func setupTestNodeInstanceManager(t *testing.T) (*fake.Client, *instanceManager) {
tp := &fake.Client{}
logger := testlog.HCLogger(t)
pinfo := &dynamicplugins.PluginInfo{
Name: "test-plugin",
}
return tp, &instanceManager{
logger: logger,
info: pinfo,
client: tp,
fp: &pluginFingerprinter{
logger: logger.Named("fingerprinter"),
info: pinfo,
client: tp,
fingerprintNode: true,
hadFirstSuccessfulFingerprintCh: make(chan struct{}),
},
}
}
func TestInstanceManager_Shutdown(t *testing.T) {
var pluginHealth bool
var lock sync.Mutex
ctx, cancelFn := context.WithCancel(context.Background())
client, im := setupTestNodeInstanceManager(t)
im.shutdownCtx = ctx
im.shutdownCtxCancelFn = cancelFn
im.shutdownCh = make(chan struct{})
im.updater = func(_ string, info *structs.CSIInfo) {
fmt.Println(info)
lock.Lock()
defer lock.Unlock()
pluginHealth = info.Healthy
}
// set up a mock successful fingerprint so that we can get
// a healthy plugin before shutting down
client.NextPluginGetCapabilitiesResponse = &csi.PluginCapabilitySet{}
client.NextPluginGetCapabilitiesErr = nil
client.NextNodeGetInfoResponse = &csi.NodeGetInfoResponse{NodeID: "foo"}
client.NextNodeGetInfoErr = nil
client.NextNodeGetCapabilitiesResponse = &csi.NodeCapabilitySet{}
client.NextNodeGetCapabilitiesErr = nil
client.NextPluginProbeResponse = true
go im.runLoop()
require.Eventually(t, func() bool {
lock.Lock()
defer lock.Unlock()
return pluginHealth
}, 1*time.Second, 10*time.Millisecond)
cancelFn() // fires im.shutdown()
require.Eventually(t, func() bool {
lock.Lock()
defer lock.Unlock()
return !pluginHealth
}, 1*time.Second, 10*time.Millisecond)
}

View File

@ -0,0 +1,64 @@
package csimanager
import (
"context"
"errors"
"strings"
"github.com/hashicorp/nomad/client/pluginmanager"
"github.com/hashicorp/nomad/nomad/structs"
)
var (
PluginNotFoundErr = errors.New("Plugin not found")
)
type MountInfo struct {
Source string
IsDevice bool
}
type UsageOptions struct {
ReadOnly bool
AttachmentMode string
AccessMode string
MountOptions *structs.CSIMountOptions
}
// ToFS is used by a VolumeManager to construct the path to where a volume
// should be staged/published. It should always return a string that is easy
// enough to manage as a filesystem path segment (e.g avoid starting the string
// with a special character).
func (u *UsageOptions) ToFS() string {
var sb strings.Builder
if u.ReadOnly {
sb.WriteString("ro-")
} else {
sb.WriteString("rw-")
}
sb.WriteString(u.AttachmentMode)
sb.WriteString("-")
sb.WriteString(u.AccessMode)
return sb.String()
}
type VolumeMounter interface {
MountVolume(ctx context.Context, vol *structs.CSIVolume, alloc *structs.Allocation, usageOpts *UsageOptions, publishContext map[string]string) (*MountInfo, error)
UnmountVolume(ctx context.Context, vol *structs.CSIVolume, alloc *structs.Allocation, usageOpts *UsageOptions) error
}
type Manager interface {
// PluginManager returns a PluginManager for use by the node fingerprinter.
PluginManager() pluginmanager.PluginManager
// MounterForVolume returns a VolumeMounter for the given requested volume.
// If there is no plugin registered for this volume type, a PluginNotFoundErr
// will be returned.
MounterForVolume(ctx context.Context, volume *structs.CSIVolume) (VolumeMounter, error)
// Shutdown shuts down the Manager and unmounts any locally attached volumes.
Shutdown()
}

View File

@ -0,0 +1,225 @@
package csimanager
import (
"context"
"sync"
"time"
"github.com/hashicorp/go-hclog"
"github.com/hashicorp/nomad/client/dynamicplugins"
"github.com/hashicorp/nomad/client/pluginmanager"
"github.com/hashicorp/nomad/nomad/structs"
)
// defaultPluginResyncPeriod is the time interval used to do a full resync
// against the dynamicplugins, to account for missed updates.
const defaultPluginResyncPeriod = 30 * time.Second
// UpdateNodeCSIInfoFunc is the callback used to update the node from
// fingerprinting
type UpdateNodeCSIInfoFunc func(string, *structs.CSIInfo)
type Config struct {
Logger hclog.Logger
DynamicRegistry dynamicplugins.Registry
UpdateNodeCSIInfoFunc UpdateNodeCSIInfoFunc
PluginResyncPeriod time.Duration
}
// New returns a new PluginManager that will handle managing CSI plugins from
// the dynamicRegistry from the provided Config.
func New(config *Config) Manager {
// Use a dedicated internal context for managing plugin shutdown.
ctx, cancelFn := context.WithCancel(context.Background())
if config.PluginResyncPeriod == 0 {
config.PluginResyncPeriod = defaultPluginResyncPeriod
}
return &csiManager{
logger: config.Logger,
registry: config.DynamicRegistry,
instances: make(map[string]map[string]*instanceManager),
updateNodeCSIInfoFunc: config.UpdateNodeCSIInfoFunc,
pluginResyncPeriod: config.PluginResyncPeriod,
shutdownCtx: ctx,
shutdownCtxCancelFn: cancelFn,
shutdownCh: make(chan struct{}),
}
}
type csiManager struct {
// instances should only be accessed from the run() goroutine and the shutdown
// fn. It is a map of PluginType : [PluginName : instanceManager]
instances map[string]map[string]*instanceManager
registry dynamicplugins.Registry
logger hclog.Logger
pluginResyncPeriod time.Duration
updateNodeCSIInfoFunc UpdateNodeCSIInfoFunc
shutdownCtx context.Context
shutdownCtxCancelFn context.CancelFunc
shutdownCh chan struct{}
}
func (c *csiManager) PluginManager() pluginmanager.PluginManager {
return c
}
func (c *csiManager) MounterForVolume(ctx context.Context, vol *structs.CSIVolume) (VolumeMounter, error) {
nodePlugins, hasAnyNodePlugins := c.instances["csi-node"]
if !hasAnyNodePlugins {
return nil, PluginNotFoundErr
}
mgr, hasPlugin := nodePlugins[vol.PluginID]
if !hasPlugin {
return nil, PluginNotFoundErr
}
return mgr.VolumeMounter(ctx)
}
// Run starts a plugin manager and should return early
func (c *csiManager) Run() {
go c.runLoop()
}
func (c *csiManager) runLoop() {
timer := time.NewTimer(0) // ensure we sync immediately in first pass
controllerUpdates := c.registry.PluginsUpdatedCh(c.shutdownCtx, "csi-controller")
nodeUpdates := c.registry.PluginsUpdatedCh(c.shutdownCtx, "csi-node")
for {
select {
case <-timer.C:
c.resyncPluginsFromRegistry("csi-controller")
c.resyncPluginsFromRegistry("csi-node")
timer.Reset(c.pluginResyncPeriod)
case event := <-controllerUpdates:
c.handlePluginEvent(event)
case event := <-nodeUpdates:
c.handlePluginEvent(event)
case <-c.shutdownCtx.Done():
close(c.shutdownCh)
return
}
}
}
// resyncPluginsFromRegistry does a full sync of the running instance
// managers against those in the registry. we primarily will use update
// events from the registry.
func (c *csiManager) resyncPluginsFromRegistry(ptype string) {
plugins := c.registry.ListPlugins(ptype)
seen := make(map[string]struct{}, len(plugins))
// For every plugin in the registry, ensure that we have an existing plugin
// running. Also build the map of valid plugin names.
// Note: monolith plugins that run as both controllers and nodes get a
// separate instance manager for both modes.
for _, plugin := range plugins {
seen[plugin.Name] = struct{}{}
c.ensureInstance(plugin)
}
// For every instance manager, if we did not find it during the plugin
// iterator, shut it down and remove it from the table.
instances := c.instancesForType(ptype)
for name, mgr := range instances {
if _, ok := seen[name]; !ok {
c.ensureNoInstance(mgr.info)
}
}
}
// handlePluginEvent syncs a single event against the plugin registry
func (c *csiManager) handlePluginEvent(event *dynamicplugins.PluginUpdateEvent) {
if event == nil {
return
}
c.logger.Trace("dynamic plugin event",
"event", event.EventType,
"plugin_id", event.Info.Name,
"plugin_alloc_id", event.Info.AllocID)
switch event.EventType {
case dynamicplugins.EventTypeRegistered:
c.ensureInstance(event.Info)
case dynamicplugins.EventTypeDeregistered:
c.ensureNoInstance(event.Info)
default:
c.logger.Error("received unknown dynamic plugin event type",
"type", event.EventType)
}
}
// Ensure we have an instance manager for the plugin and add it to
// the CSI manager's tracking table for that plugin type.
func (c *csiManager) ensureInstance(plugin *dynamicplugins.PluginInfo) {
name := plugin.Name
ptype := plugin.Type
instances := c.instancesForType(ptype)
if _, ok := instances[name]; !ok {
c.logger.Debug("detected new CSI plugin", "name", name, "type", ptype)
mgr := newInstanceManager(c.logger, c.updateNodeCSIInfoFunc, plugin)
instances[name] = mgr
mgr.run()
}
}
// Shut down the instance manager for a plugin and remove it from
// the CSI manager's tracking table for that plugin type.
func (c *csiManager) ensureNoInstance(plugin *dynamicplugins.PluginInfo) {
name := plugin.Name
ptype := plugin.Type
instances := c.instancesForType(ptype)
if mgr, ok := instances[name]; ok {
c.logger.Debug("shutting down CSI plugin", "name", name, "type", ptype)
mgr.shutdown()
delete(instances, name)
}
}
// Get the instance managers table for a specific plugin type,
// ensuring it's been initialized if it doesn't exist.
func (c *csiManager) instancesForType(ptype string) map[string]*instanceManager {
pluginMap, ok := c.instances[ptype]
if !ok {
pluginMap = make(map[string]*instanceManager)
c.instances[ptype] = pluginMap
}
return pluginMap
}
// Shutdown should gracefully shutdown all plugins managed by the manager.
// It must block until shutdown is complete
func (c *csiManager) Shutdown() {
// Shut down the run loop
c.shutdownCtxCancelFn()
// Wait for plugin manager shutdown to complete so that we
// don't try to shutdown instance managers while runLoop is
// doing a resync
<-c.shutdownCh
// Shutdown all the instance managers in parallel
var wg sync.WaitGroup
for _, pluginMap := range c.instances {
for _, mgr := range pluginMap {
wg.Add(1)
go func(mgr *instanceManager) {
mgr.shutdown()
wg.Done()
}(mgr)
}
}
wg.Wait()
}
// PluginType is the type of plugin which the manager manages
func (c *csiManager) PluginType() string {
return "csi"
}

View File

@ -0,0 +1,161 @@
package csimanager
import (
"testing"
"time"
"github.com/hashicorp/nomad/client/dynamicplugins"
"github.com/hashicorp/nomad/client/pluginmanager"
"github.com/hashicorp/nomad/helper/testlog"
"github.com/hashicorp/nomad/nomad/structs"
"github.com/stretchr/testify/require"
)
var _ pluginmanager.PluginManager = (*csiManager)(nil)
var fakePlugin = &dynamicplugins.PluginInfo{
Name: "my-plugin",
Type: "csi-controller",
ConnectionInfo: &dynamicplugins.PluginConnectionInfo{},
}
func setupRegistry() dynamicplugins.Registry {
return dynamicplugins.NewRegistry(
nil,
map[string]dynamicplugins.PluginDispenser{
"csi-controller": func(*dynamicplugins.PluginInfo) (interface{}, error) {
return nil, nil
},
})
}
func TestManager_Setup_Shutdown(t *testing.T) {
r := setupRegistry()
defer r.Shutdown()
cfg := &Config{
Logger: testlog.HCLogger(t),
DynamicRegistry: r,
UpdateNodeCSIInfoFunc: func(string, *structs.CSIInfo) {},
}
pm := New(cfg).(*csiManager)
pm.Run()
pm.Shutdown()
}
func TestManager_RegisterPlugin(t *testing.T) {
registry := setupRegistry()
defer registry.Shutdown()
require.NotNil(t, registry)
cfg := &Config{
Logger: testlog.HCLogger(t),
DynamicRegistry: registry,
UpdateNodeCSIInfoFunc: func(string, *structs.CSIInfo) {},
}
pm := New(cfg).(*csiManager)
defer pm.Shutdown()
require.NotNil(t, pm.registry)
err := registry.RegisterPlugin(fakePlugin)
require.Nil(t, err)
pm.Run()
require.Eventually(t, func() bool {
pmap, ok := pm.instances[fakePlugin.Type]
if !ok {
return false
}
_, ok = pmap[fakePlugin.Name]
return ok
}, 5*time.Second, 10*time.Millisecond)
}
func TestManager_DeregisterPlugin(t *testing.T) {
registry := setupRegistry()
defer registry.Shutdown()
require.NotNil(t, registry)
cfg := &Config{
Logger: testlog.HCLogger(t),
DynamicRegistry: registry,
UpdateNodeCSIInfoFunc: func(string, *structs.CSIInfo) {},
PluginResyncPeriod: 500 * time.Millisecond,
}
pm := New(cfg).(*csiManager)
defer pm.Shutdown()
require.NotNil(t, pm.registry)
err := registry.RegisterPlugin(fakePlugin)
require.Nil(t, err)
pm.Run()
require.Eventually(t, func() bool {
_, ok := pm.instances[fakePlugin.Type][fakePlugin.Name]
return ok
}, 5*time.Second, 10*time.Millisecond)
err = registry.DeregisterPlugin(fakePlugin.Type, fakePlugin.Name)
require.Nil(t, err)
require.Eventually(t, func() bool {
_, ok := pm.instances[fakePlugin.Type][fakePlugin.Name]
return !ok
}, 5*time.Second, 10*time.Millisecond)
}
// TestManager_MultiplePlugins ensures that multiple plugins with the same
// name but different types (as found with monolith plugins) don't interfere
// with each other.
func TestManager_MultiplePlugins(t *testing.T) {
registry := setupRegistry()
defer registry.Shutdown()
require.NotNil(t, registry)
cfg := &Config{
Logger: testlog.HCLogger(t),
DynamicRegistry: registry,
UpdateNodeCSIInfoFunc: func(string, *structs.CSIInfo) {},
PluginResyncPeriod: 500 * time.Millisecond,
}
pm := New(cfg).(*csiManager)
defer pm.Shutdown()
require.NotNil(t, pm.registry)
err := registry.RegisterPlugin(fakePlugin)
require.Nil(t, err)
fakeNodePlugin := *fakePlugin
fakeNodePlugin.Type = "csi-node"
err = registry.RegisterPlugin(&fakeNodePlugin)
require.Nil(t, err)
pm.Run()
require.Eventually(t, func() bool {
_, ok := pm.instances[fakePlugin.Type][fakePlugin.Name]
return ok
}, 5*time.Second, 10*time.Millisecond)
require.Eventually(t, func() bool {
_, ok := pm.instances[fakeNodePlugin.Type][fakeNodePlugin.Name]
return ok
}, 5*time.Second, 10*time.Millisecond)
err = registry.DeregisterPlugin(fakePlugin.Type, fakePlugin.Name)
require.Nil(t, err)
require.Eventually(t, func() bool {
_, ok := pm.instances[fakePlugin.Type][fakePlugin.Name]
return !ok
}, 5*time.Second, 10*time.Millisecond)
}

View File

@ -0,0 +1,71 @@
package csimanager
import (
"sync"
"github.com/hashicorp/nomad/nomad/structs"
)
// volumeUsageTracker tracks the allocations that depend on a given volume
type volumeUsageTracker struct {
// state is a map of volumeUsageKey to a slice of allocation ids
state map[volumeUsageKey][]string
stateMu sync.Mutex
}
func newVolumeUsageTracker() *volumeUsageTracker {
return &volumeUsageTracker{
state: make(map[volumeUsageKey][]string),
}
}
type volumeUsageKey struct {
volume *structs.CSIVolume
usageOpts UsageOptions
}
func (v *volumeUsageTracker) allocsForKey(key volumeUsageKey) []string {
return v.state[key]
}
func (v *volumeUsageTracker) appendAlloc(key volumeUsageKey, alloc *structs.Allocation) {
allocs := v.allocsForKey(key)
allocs = append(allocs, alloc.ID)
v.state[key] = allocs
}
func (v *volumeUsageTracker) removeAlloc(key volumeUsageKey, needle *structs.Allocation) {
allocs := v.allocsForKey(key)
var newAllocs []string
for _, allocID := range allocs {
if allocID != needle.ID {
newAllocs = append(newAllocs, allocID)
}
}
if len(newAllocs) == 0 {
delete(v.state, key)
} else {
v.state[key] = newAllocs
}
}
func (v *volumeUsageTracker) Claim(alloc *structs.Allocation, volume *structs.CSIVolume, usage *UsageOptions) {
v.stateMu.Lock()
defer v.stateMu.Unlock()
key := volumeUsageKey{volume: volume, usageOpts: *usage}
v.appendAlloc(key, alloc)
}
// Free removes the allocation from the state list for the given alloc. If the
// alloc is the last allocation for the volume then it returns true.
func (v *volumeUsageTracker) Free(alloc *structs.Allocation, volume *structs.CSIVolume, usage *UsageOptions) bool {
v.stateMu.Lock()
defer v.stateMu.Unlock()
key := volumeUsageKey{volume: volume, usageOpts: *usage}
v.removeAlloc(key, alloc)
allocs := v.allocsForKey(key)
return len(allocs) == 0
}

View File

@ -0,0 +1,62 @@
package csimanager
import (
"testing"
"github.com/hashicorp/nomad/nomad/mock"
"github.com/hashicorp/nomad/nomad/structs"
"github.com/stretchr/testify/require"
)
func TestUsageTracker(t *testing.T) {
mockAllocs := []*structs.Allocation{
mock.Alloc(),
mock.Alloc(),
mock.Alloc(),
mock.Alloc(),
mock.Alloc(),
}
cases := []struct {
Name string
RegisterAllocs []*structs.Allocation
FreeAllocs []*structs.Allocation
ExpectedResult bool
}{
{
Name: "Register and deregister all allocs",
RegisterAllocs: mockAllocs,
FreeAllocs: mockAllocs,
ExpectedResult: true,
},
{
Name: "Register all and deregister partial allocs",
RegisterAllocs: mockAllocs,
FreeAllocs: mockAllocs[0:3],
ExpectedResult: false,
},
}
for _, tc := range cases {
t.Run(tc.Name, func(t *testing.T) {
tracker := newVolumeUsageTracker()
volume := &structs.CSIVolume{
ID: "foo",
}
for _, alloc := range tc.RegisterAllocs {
tracker.Claim(alloc, volume, &UsageOptions{})
}
result := false
for _, alloc := range tc.FreeAllocs {
result = tracker.Free(alloc, volume, &UsageOptions{})
}
require.Equal(t, tc.ExpectedResult, result, "Tracker State: %#v", tracker.state)
})
}
}

View File

@ -0,0 +1,319 @@
package csimanager
import (
"context"
"fmt"
"os"
"path/filepath"
"time"
grpc_retry "github.com/grpc-ecosystem/go-grpc-middleware/retry"
"github.com/hashicorp/go-hclog"
"github.com/hashicorp/go-multierror"
"github.com/hashicorp/nomad/helper/mount"
"github.com/hashicorp/nomad/nomad/structs"
"github.com/hashicorp/nomad/plugins/csi"
)
var _ VolumeMounter = &volumeManager{}
const (
DefaultMountActionTimeout = 2 * time.Minute
StagingDirName = "staging"
AllocSpecificDirName = "per-alloc"
)
// volumeManager handles the state of attached volumes for a given CSI Plugin.
//
// volumeManagers outlive the lifetime of a given allocation as volumes may be
// shared by multiple allocations on the same node.
//
// volumes are stored by an enriched volume usage struct as the CSI Spec requires
// slightly different usage based on the given usage model.
type volumeManager struct {
logger hclog.Logger
plugin csi.CSIPlugin
usageTracker *volumeUsageTracker
// mountRoot is the root of where plugin directories and mounts may be created
// e.g /opt/nomad.d/statedir/csi/my-csi-plugin/
mountRoot string
// containerMountPoint is the location _inside_ the plugin container that the
// `mountRoot` is bound in to.
containerMountPoint string
// requiresStaging shows whether the plugin requires that the volume manager
// calls NodeStageVolume and NodeUnstageVolume RPCs during setup and teardown
requiresStaging bool
}
func newVolumeManager(logger hclog.Logger, plugin csi.CSIPlugin, rootDir, containerRootDir string, requiresStaging bool) *volumeManager {
return &volumeManager{
logger: logger.Named("volume_manager"),
plugin: plugin,
mountRoot: rootDir,
containerMountPoint: containerRootDir,
requiresStaging: requiresStaging,
usageTracker: newVolumeUsageTracker(),
}
}
func (v *volumeManager) stagingDirForVolume(root string, vol *structs.CSIVolume, usage *UsageOptions) string {
return filepath.Join(root, StagingDirName, vol.ID, usage.ToFS())
}
func (v *volumeManager) allocDirForVolume(root string, vol *structs.CSIVolume, alloc *structs.Allocation, usage *UsageOptions) string {
return filepath.Join(root, AllocSpecificDirName, alloc.ID, vol.ID, usage.ToFS())
}
// ensureStagingDir attempts to create a directory for use when staging a volume
// and then validates that the path is not already a mount point for e.g an
// existing volume stage.
//
// Returns whether the directory is a pre-existing mountpoint, the staging path,
// and any errors that occurred.
func (v *volumeManager) ensureStagingDir(vol *structs.CSIVolume, usage *UsageOptions) (string, bool, error) {
stagingPath := v.stagingDirForVolume(v.mountRoot, vol, usage)
// Make the staging path, owned by the Nomad User
if err := os.MkdirAll(stagingPath, 0700); err != nil && !os.IsExist(err) {
return "", false, fmt.Errorf("failed to create staging directory for volume (%s): %v", vol.ID, err)
}
// Validate that it is not already a mount point
m := mount.New()
isNotMount, err := m.IsNotAMountPoint(stagingPath)
if err != nil {
return "", false, fmt.Errorf("mount point detection failed for volume (%s): %v", vol.ID, err)
}
return stagingPath, !isNotMount, nil
}
// ensureAllocDir attempts to create a directory for use when publishing a volume
// and then validates that the path is not already a mount point (e.g when reattaching
// to existing allocs).
//
// Returns whether the directory is a pre-existing mountpoint, the publish path,
// and any errors that occurred.
func (v *volumeManager) ensureAllocDir(vol *structs.CSIVolume, alloc *structs.Allocation, usage *UsageOptions) (string, bool, error) {
allocPath := v.allocDirForVolume(v.mountRoot, vol, alloc, usage)
// Make the alloc path, owned by the Nomad User
if err := os.MkdirAll(allocPath, 0700); err != nil && !os.IsExist(err) {
return "", false, fmt.Errorf("failed to create allocation directory for volume (%s): %v", vol.ID, err)
}
// Validate that it is not already a mount point
m := mount.New()
isNotMount, err := m.IsNotAMountPoint(allocPath)
if err != nil {
return "", false, fmt.Errorf("mount point detection failed for volume (%s): %v", vol.ID, err)
}
return allocPath, !isNotMount, nil
}
func volumeCapability(vol *structs.CSIVolume, usage *UsageOptions) (*csi.VolumeCapability, error) {
capability, err := csi.VolumeCapabilityFromStructs(vol.AttachmentMode, vol.AccessMode)
if err != nil {
return nil, err
}
var opts *structs.CSIMountOptions
if vol.MountOptions == nil {
opts = usage.MountOptions
} else {
opts = vol.MountOptions.Copy()
opts.Merge(usage.MountOptions)
}
capability.MountVolume = opts
return capability, nil
}
// stageVolume prepares a volume for use by allocations. When a plugin exposes
// the STAGE_UNSTAGE_VOLUME capability it MUST be called once-per-volume for a
// given usage mode before the volume can be NodePublish-ed.
func (v *volumeManager) stageVolume(ctx context.Context, vol *structs.CSIVolume, usage *UsageOptions, publishContext map[string]string) error {
logger := hclog.FromContext(ctx)
logger.Trace("Preparing volume staging environment")
hostStagingPath, isMount, err := v.ensureStagingDir(vol, usage)
if err != nil {
return err
}
pluginStagingPath := v.stagingDirForVolume(v.containerMountPoint, vol, usage)
logger.Trace("Volume staging environment", "pre-existing_mount", isMount, "host_staging_path", hostStagingPath, "plugin_staging_path", pluginStagingPath)
if isMount {
logger.Debug("re-using existing staging mount for volume", "staging_path", hostStagingPath)
return nil
}
capability, err := volumeCapability(vol, usage)
if err != nil {
return err
}
// We currently treat all explicit CSI NodeStageVolume errors (aside from timeouts, codes.ResourceExhausted, and codes.Unavailable)
// as fatal.
// In the future, we can provide more useful error messages based on
// different types of error. For error documentation see:
// https://github.com/container-storage-interface/spec/blob/4731db0e0bc53238b93850f43ab05d9355df0fd9/spec.md#nodestagevolume-errors
return v.plugin.NodeStageVolume(ctx,
vol.ID,
publishContext,
pluginStagingPath,
capability,
grpc_retry.WithPerRetryTimeout(DefaultMountActionTimeout),
grpc_retry.WithMax(3),
grpc_retry.WithBackoff(grpc_retry.BackoffExponential(100*time.Millisecond)),
)
}
func (v *volumeManager) publishVolume(ctx context.Context, vol *structs.CSIVolume, alloc *structs.Allocation, usage *UsageOptions, publishContext map[string]string) (*MountInfo, error) {
logger := hclog.FromContext(ctx)
var pluginStagingPath string
if v.requiresStaging {
pluginStagingPath = v.stagingDirForVolume(v.containerMountPoint, vol, usage)
}
hostTargetPath, isMount, err := v.ensureAllocDir(vol, alloc, usage)
if err != nil {
return nil, err
}
pluginTargetPath := v.allocDirForVolume(v.containerMountPoint, vol, alloc, usage)
if isMount {
logger.Debug("Re-using existing published volume for allocation")
return &MountInfo{Source: hostTargetPath}, nil
}
capabilities, err := volumeCapability(vol, usage)
if err != nil {
return nil, err
}
err = v.plugin.NodePublishVolume(ctx, &csi.NodePublishVolumeRequest{
VolumeID: vol.RemoteID(),
PublishContext: publishContext,
StagingTargetPath: pluginStagingPath,
TargetPath: pluginTargetPath,
VolumeCapability: capabilities,
Readonly: usage.ReadOnly,
},
grpc_retry.WithPerRetryTimeout(DefaultMountActionTimeout),
grpc_retry.WithMax(3),
grpc_retry.WithBackoff(grpc_retry.BackoffExponential(100*time.Millisecond)),
)
return &MountInfo{Source: hostTargetPath}, err
}
// MountVolume performs the steps required for using a given volume
// configuration for the provided allocation.
// It is passed the publishContext from remote attachment, and specific usage
// modes from the CSI Hook.
// It then uses this state to stage and publish the volume as required for use
// by the given allocation.
func (v *volumeManager) MountVolume(ctx context.Context, vol *structs.CSIVolume, alloc *structs.Allocation, usage *UsageOptions, publishContext map[string]string) (*MountInfo, error) {
logger := v.logger.With("volume_id", vol.ID, "alloc_id", alloc.ID)
ctx = hclog.WithContext(ctx, logger)
if v.requiresStaging {
if err := v.stageVolume(ctx, vol, usage, publishContext); err != nil {
return nil, err
}
}
mountInfo, err := v.publishVolume(ctx, vol, alloc, usage, publishContext)
if err != nil {
return nil, err
}
v.usageTracker.Claim(alloc, vol, usage)
return mountInfo, nil
}
// unstageVolume is the inverse operation of `stageVolume` and must be called
// once for each staging path that a volume has been staged under.
// It is safe to call multiple times and a plugin is required to return OK if
// the volume has been unstaged or was never staged on the node.
func (v *volumeManager) unstageVolume(ctx context.Context, vol *structs.CSIVolume, usage *UsageOptions) error {
logger := hclog.FromContext(ctx)
logger.Trace("Unstaging volume")
stagingPath := v.stagingDirForVolume(v.containerMountPoint, vol, usage)
return v.plugin.NodeUnstageVolume(ctx,
vol.ID,
stagingPath,
grpc_retry.WithPerRetryTimeout(DefaultMountActionTimeout),
grpc_retry.WithMax(3),
grpc_retry.WithBackoff(grpc_retry.BackoffExponential(100*time.Millisecond)),
)
}
func combineErrors(maybeErrs ...error) error {
var result *multierror.Error
for _, err := range maybeErrs {
if err == nil {
continue
}
result = multierror.Append(result, err)
}
return result.ErrorOrNil()
}
func (v *volumeManager) unpublishVolume(ctx context.Context, vol *structs.CSIVolume, alloc *structs.Allocation, usage *UsageOptions) error {
pluginTargetPath := v.allocDirForVolume(v.containerMountPoint, vol, alloc, usage)
rpcErr := v.plugin.NodeUnpublishVolume(ctx, vol.ID, pluginTargetPath,
grpc_retry.WithPerRetryTimeout(DefaultMountActionTimeout),
grpc_retry.WithMax(3),
grpc_retry.WithBackoff(grpc_retry.BackoffExponential(100*time.Millisecond)),
)
hostTargetPath := v.allocDirForVolume(v.mountRoot, vol, alloc, usage)
if _, err := os.Stat(hostTargetPath); os.IsNotExist(err) {
// Host Target Path already got destroyed, just return any rpcErr
return rpcErr
}
// Host Target Path was not cleaned up, attempt to do so here. If it's still
// a mount then removing the dir will fail and we'll return any rpcErr and the
// file error.
rmErr := os.Remove(hostTargetPath)
if rmErr != nil {
return combineErrors(rpcErr, rmErr)
}
// We successfully removed the directory, return any rpcErrors that were
// encountered, but because we got here, they were probably flaky or was
// cleaned up externally. We might want to just return `nil` here in the
// future.
return rpcErr
}
func (v *volumeManager) UnmountVolume(ctx context.Context, vol *structs.CSIVolume, alloc *structs.Allocation, usage *UsageOptions) error {
logger := v.logger.With("volume_id", vol.ID, "alloc_id", alloc.ID)
ctx = hclog.WithContext(ctx, logger)
err := v.unpublishVolume(ctx, vol, alloc, usage)
if err != nil {
return err
}
canRelease := v.usageTracker.Free(alloc, vol, usage)
if !v.requiresStaging || !canRelease {
return nil
}
return v.unstageVolume(ctx, vol, usage)
}

View File

@ -0,0 +1,424 @@
package csimanager
import (
"context"
"errors"
"io/ioutil"
"os"
"runtime"
"testing"
"github.com/hashicorp/nomad/helper/testlog"
"github.com/hashicorp/nomad/nomad/structs"
"github.com/hashicorp/nomad/plugins/csi"
csifake "github.com/hashicorp/nomad/plugins/csi/fake"
"github.com/stretchr/testify/require"
)
func tmpDir(t testing.TB) string {
t.Helper()
dir, err := ioutil.TempDir("", "nomad")
require.NoError(t, err)
return dir
}
func TestVolumeManager_ensureStagingDir(t *testing.T) {
t.Parallel()
cases := []struct {
Name string
Volume *structs.CSIVolume
UsageOptions *UsageOptions
CreateDirAheadOfTime bool
MountDirAheadOfTime bool
ExpectedErr error
ExpectedMountState bool
}{
{
Name: "Creates a directory when one does not exist",
Volume: &structs.CSIVolume{ID: "foo"},
UsageOptions: &UsageOptions{},
},
{
Name: "Does not fail because of a pre-existing directory",
Volume: &structs.CSIVolume{ID: "foo"},
UsageOptions: &UsageOptions{},
CreateDirAheadOfTime: true,
},
{
Name: "Returns negative mount info",
UsageOptions: &UsageOptions{},
Volume: &structs.CSIVolume{ID: "foo"},
},
{
Name: "Returns positive mount info",
Volume: &structs.CSIVolume{ID: "foo"},
UsageOptions: &UsageOptions{},
CreateDirAheadOfTime: true,
MountDirAheadOfTime: true,
ExpectedMountState: true,
},
}
for _, tc := range cases {
t.Run(tc.Name, func(t *testing.T) {
// Step 1: Validate that the test case makes sense
if !tc.CreateDirAheadOfTime && tc.MountDirAheadOfTime {
require.Fail(t, "Cannot Mount without creating a dir")
}
if tc.MountDirAheadOfTime {
// We can enable these tests by either mounting a fake device on linux
// e.g shipping a small ext4 image file and using that as a loopback
// device, but there's no convenient way to implement this.
t.Skip("TODO: Skipped because we don't detect bind mounts")
}
// Step 2: Test Setup
tmpPath := tmpDir(t)
defer os.RemoveAll(tmpPath)
csiFake := &csifake.Client{}
manager := newVolumeManager(testlog.HCLogger(t), csiFake, tmpPath, tmpPath, true)
expectedStagingPath := manager.stagingDirForVolume(tmpPath, tc.Volume, tc.UsageOptions)
if tc.CreateDirAheadOfTime {
err := os.MkdirAll(expectedStagingPath, 0700)
require.NoError(t, err)
}
// Step 3: Now we can do some testing
path, detectedMount, testErr := manager.ensureStagingDir(tc.Volume, tc.UsageOptions)
if tc.ExpectedErr != nil {
require.EqualError(t, testErr, tc.ExpectedErr.Error())
return // We don't perform extra validation if an error was detected.
}
require.NoError(t, testErr)
require.Equal(t, tc.ExpectedMountState, detectedMount)
// If the ensureStagingDir call had to create a directory itself, then here
// we validate that the directory exists and its permissions
if !tc.CreateDirAheadOfTime {
file, err := os.Lstat(path)
require.NoError(t, err)
require.True(t, file.IsDir())
// TODO: Figure out a windows equivalent of this test
if runtime.GOOS != "windows" {
require.Equal(t, os.FileMode(0700), file.Mode().Perm())
}
}
})
}
}
func TestVolumeManager_stageVolume(t *testing.T) {
t.Parallel()
cases := []struct {
Name string
Volume *structs.CSIVolume
UsageOptions *UsageOptions
PluginErr error
ExpectedErr error
}{
{
Name: "Returns an error when an invalid AttachmentMode is provided",
Volume: &structs.CSIVolume{
ID: "foo",
AttachmentMode: "nonsense",
},
UsageOptions: &UsageOptions{},
ExpectedErr: errors.New("Unknown volume attachment mode: nonsense"),
},
{
Name: "Returns an error when an invalid AccessMode is provided",
Volume: &structs.CSIVolume{
ID: "foo",
AttachmentMode: structs.CSIVolumeAttachmentModeBlockDevice,
AccessMode: "nonsense",
},
UsageOptions: &UsageOptions{},
ExpectedErr: errors.New("Unknown volume access mode: nonsense"),
},
{
Name: "Returns an error when the plugin returns an error",
Volume: &structs.CSIVolume{
ID: "foo",
AttachmentMode: structs.CSIVolumeAttachmentModeBlockDevice,
AccessMode: structs.CSIVolumeAccessModeMultiNodeMultiWriter,
},
UsageOptions: &UsageOptions{},
PluginErr: errors.New("Some Unknown Error"),
ExpectedErr: errors.New("Some Unknown Error"),
},
{
Name: "Happy Path",
Volume: &structs.CSIVolume{
ID: "foo",
AttachmentMode: structs.CSIVolumeAttachmentModeBlockDevice,
AccessMode: structs.CSIVolumeAccessModeMultiNodeMultiWriter,
},
UsageOptions: &UsageOptions{},
PluginErr: nil,
ExpectedErr: nil,
},
}
for _, tc := range cases {
t.Run(tc.Name, func(t *testing.T) {
tmpPath := tmpDir(t)
defer os.RemoveAll(tmpPath)
csiFake := &csifake.Client{}
csiFake.NextNodeStageVolumeErr = tc.PluginErr
manager := newVolumeManager(testlog.HCLogger(t), csiFake, tmpPath, tmpPath, true)
ctx := context.Background()
err := manager.stageVolume(ctx, tc.Volume, tc.UsageOptions, nil)
if tc.ExpectedErr != nil {
require.EqualError(t, err, tc.ExpectedErr.Error())
} else {
require.NoError(t, err)
}
})
}
}
func TestVolumeManager_unstageVolume(t *testing.T) {
t.Parallel()
cases := []struct {
Name string
Volume *structs.CSIVolume
UsageOptions *UsageOptions
PluginErr error
ExpectedErr error
ExpectedCSICallCount int64
}{
{
Name: "Returns an error when the plugin returns an error",
Volume: &structs.CSIVolume{
ID: "foo",
},
UsageOptions: &UsageOptions{},
PluginErr: errors.New("Some Unknown Error"),
ExpectedErr: errors.New("Some Unknown Error"),
ExpectedCSICallCount: 1,
},
{
Name: "Happy Path",
Volume: &structs.CSIVolume{
ID: "foo",
},
UsageOptions: &UsageOptions{},
PluginErr: nil,
ExpectedErr: nil,
ExpectedCSICallCount: 1,
},
}
for _, tc := range cases {
t.Run(tc.Name, func(t *testing.T) {
tmpPath := tmpDir(t)
defer os.RemoveAll(tmpPath)
csiFake := &csifake.Client{}
csiFake.NextNodeUnstageVolumeErr = tc.PluginErr
manager := newVolumeManager(testlog.HCLogger(t), csiFake, tmpPath, tmpPath, true)
ctx := context.Background()
err := manager.unstageVolume(ctx, tc.Volume, tc.UsageOptions)
if tc.ExpectedErr != nil {
require.EqualError(t, err, tc.ExpectedErr.Error())
} else {
require.NoError(t, err)
}
require.Equal(t, tc.ExpectedCSICallCount, csiFake.NodeUnstageVolumeCallCount)
})
}
}
func TestVolumeManager_publishVolume(t *testing.T) {
t.Parallel()
cases := []struct {
Name string
Allocation *structs.Allocation
Volume *structs.CSIVolume
UsageOptions *UsageOptions
PluginErr error
ExpectedErr error
ExpectedCSICallCount int64
ExpectedVolumeCapability *csi.VolumeCapability
}{
{
Name: "Returns an error when the plugin returns an error",
Allocation: structs.MockAlloc(),
Volume: &structs.CSIVolume{
ID: "foo",
AttachmentMode: structs.CSIVolumeAttachmentModeBlockDevice,
AccessMode: structs.CSIVolumeAccessModeMultiNodeMultiWriter,
},
UsageOptions: &UsageOptions{},
PluginErr: errors.New("Some Unknown Error"),
ExpectedErr: errors.New("Some Unknown Error"),
ExpectedCSICallCount: 1,
},
{
Name: "Happy Path",
Allocation: structs.MockAlloc(),
Volume: &structs.CSIVolume{
ID: "foo",
AttachmentMode: structs.CSIVolumeAttachmentModeBlockDevice,
AccessMode: structs.CSIVolumeAccessModeMultiNodeMultiWriter,
},
UsageOptions: &UsageOptions{},
PluginErr: nil,
ExpectedErr: nil,
ExpectedCSICallCount: 1,
},
{
Name: "Mount options in the volume",
Allocation: structs.MockAlloc(),
Volume: &structs.CSIVolume{
ID: "foo",
AttachmentMode: structs.CSIVolumeAttachmentModeFilesystem,
AccessMode: structs.CSIVolumeAccessModeMultiNodeMultiWriter,
MountOptions: &structs.CSIMountOptions{
MountFlags: []string{"ro"},
},
},
UsageOptions: &UsageOptions{},
PluginErr: nil,
ExpectedErr: nil,
ExpectedCSICallCount: 1,
ExpectedVolumeCapability: &csi.VolumeCapability{
AccessType: csi.VolumeAccessTypeMount,
AccessMode: csi.VolumeAccessModeMultiNodeMultiWriter,
MountVolume: &structs.CSIMountOptions{
MountFlags: []string{"ro"},
},
},
},
{
Name: "Mount options override in the request",
Allocation: structs.MockAlloc(),
Volume: &structs.CSIVolume{
ID: "foo",
AttachmentMode: structs.CSIVolumeAttachmentModeFilesystem,
AccessMode: structs.CSIVolumeAccessModeMultiNodeMultiWriter,
MountOptions: &structs.CSIMountOptions{
MountFlags: []string{"ro"},
},
},
UsageOptions: &UsageOptions{
MountOptions: &structs.CSIMountOptions{
MountFlags: []string{"rw"},
},
},
PluginErr: nil,
ExpectedErr: nil,
ExpectedCSICallCount: 1,
ExpectedVolumeCapability: &csi.VolumeCapability{
AccessType: csi.VolumeAccessTypeMount,
AccessMode: csi.VolumeAccessModeMultiNodeMultiWriter,
MountVolume: &structs.CSIMountOptions{
MountFlags: []string{"rw"},
},
},
},
}
for _, tc := range cases {
t.Run(tc.Name, func(t *testing.T) {
tmpPath := tmpDir(t)
defer os.RemoveAll(tmpPath)
csiFake := &csifake.Client{}
csiFake.NextNodePublishVolumeErr = tc.PluginErr
manager := newVolumeManager(testlog.HCLogger(t), csiFake, tmpPath, tmpPath, true)
ctx := context.Background()
_, err := manager.publishVolume(ctx, tc.Volume, tc.Allocation, tc.UsageOptions, nil)
if tc.ExpectedErr != nil {
require.EqualError(t, err, tc.ExpectedErr.Error())
} else {
require.NoError(t, err)
}
require.Equal(t, tc.ExpectedCSICallCount, csiFake.NodePublishVolumeCallCount)
if tc.ExpectedVolumeCapability != nil {
require.Equal(t, tc.ExpectedVolumeCapability, csiFake.PrevVolumeCapability)
}
})
}
}
func TestVolumeManager_unpublishVolume(t *testing.T) {
t.Parallel()
cases := []struct {
Name string
Allocation *structs.Allocation
Volume *structs.CSIVolume
UsageOptions *UsageOptions
PluginErr error
ExpectedErr error
ExpectedCSICallCount int64
}{
{
Name: "Returns an error when the plugin returns an error",
Allocation: structs.MockAlloc(),
Volume: &structs.CSIVolume{
ID: "foo",
},
UsageOptions: &UsageOptions{},
PluginErr: errors.New("Some Unknown Error"),
ExpectedErr: errors.New("Some Unknown Error"),
ExpectedCSICallCount: 1,
},
{
Name: "Happy Path",
Allocation: structs.MockAlloc(),
Volume: &structs.CSIVolume{
ID: "foo",
},
UsageOptions: &UsageOptions{},
PluginErr: nil,
ExpectedErr: nil,
ExpectedCSICallCount: 1,
},
}
for _, tc := range cases {
t.Run(tc.Name, func(t *testing.T) {
tmpPath := tmpDir(t)
defer os.RemoveAll(tmpPath)
csiFake := &csifake.Client{}
csiFake.NextNodeUnpublishVolumeErr = tc.PluginErr
manager := newVolumeManager(testlog.HCLogger(t), csiFake, tmpPath, tmpPath, true)
ctx := context.Background()
err := manager.unpublishVolume(ctx, tc.Volume, tc.Allocation, tc.UsageOptions)
if tc.ExpectedErr != nil {
require.EqualError(t, err, tc.ExpectedErr.Error())
} else {
require.NoError(t, err)
}
require.Equal(t, tc.ExpectedCSICallCount, csiFake.NodeUnpublishVolumeCallCount)
})
}
}

View File

@ -2,10 +2,10 @@ package state
import pstructs "github.com/hashicorp/nomad/plugins/shared/structs" import pstructs "github.com/hashicorp/nomad/plugins/shared/structs"
// PluginState is used to store the driver managers state across restarts of the // PluginState is used to store the driver manager's state across restarts of the
// agent // agent
type PluginState struct { type PluginState struct {
// ReattachConfigs are the set of reattach configs for plugin's launched by // ReattachConfigs are the set of reattach configs for plugins launched by
// the driver manager // the driver manager
ReattachConfigs map[string]*pstructs.ReattachConfig ReattachConfigs map[string]*pstructs.ReattachConfig
} }

View File

@ -20,10 +20,11 @@ import (
// rpcEndpoints holds the RPC endpoints // rpcEndpoints holds the RPC endpoints
type rpcEndpoints struct { type rpcEndpoints struct {
ClientStats *ClientStats ClientStats *ClientStats
FileSystem *FileSystem CSIController *CSIController
Allocations *Allocations FileSystem *FileSystem
Agent *Agent Allocations *Allocations
Agent *Agent
} }
// ClientRPC is used to make a local, client only RPC call // ClientRPC is used to make a local, client only RPC call
@ -217,6 +218,7 @@ func (c *Client) streamingRpcConn(server *servers.Server, method string) (net.Co
func (c *Client) setupClientRpc() { func (c *Client) setupClientRpc() {
// Initialize the RPC handlers // Initialize the RPC handlers
c.endpoints.ClientStats = &ClientStats{c} c.endpoints.ClientStats = &ClientStats{c}
c.endpoints.CSIController = &CSIController{c}
c.endpoints.FileSystem = NewFileSystemEndpoint(c) c.endpoints.FileSystem = NewFileSystemEndpoint(c)
c.endpoints.Allocations = NewAllocationsEndpoint(c) c.endpoints.Allocations = NewAllocationsEndpoint(c)
c.endpoints.Agent = NewAgentEndpoint(c) c.endpoints.Agent = NewAgentEndpoint(c)
@ -234,6 +236,7 @@ func (c *Client) setupClientRpc() {
func (c *Client) setupClientRpcServer(server *rpc.Server) { func (c *Client) setupClientRpcServer(server *rpc.Server) {
// Register the endpoints // Register the endpoints
server.Register(c.endpoints.ClientStats) server.Register(c.endpoints.ClientStats)
server.Register(c.endpoints.CSIController)
server.Register(c.endpoints.FileSystem) server.Register(c.endpoints.FileSystem)
server.Register(c.endpoints.Allocations) server.Register(c.endpoints.Allocations)
server.Register(c.endpoints.Agent) server.Register(c.endpoints.Agent)

View File

@ -8,6 +8,7 @@ import (
trstate "github.com/hashicorp/nomad/client/allocrunner/taskrunner/state" trstate "github.com/hashicorp/nomad/client/allocrunner/taskrunner/state"
dmstate "github.com/hashicorp/nomad/client/devicemanager/state" dmstate "github.com/hashicorp/nomad/client/devicemanager/state"
"github.com/hashicorp/nomad/client/dynamicplugins"
driverstate "github.com/hashicorp/nomad/client/pluginmanager/drivermanager/state" driverstate "github.com/hashicorp/nomad/client/pluginmanager/drivermanager/state"
"github.com/hashicorp/nomad/helper/testlog" "github.com/hashicorp/nomad/helper/testlog"
"github.com/hashicorp/nomad/nomad/mock" "github.com/hashicorp/nomad/nomad/mock"
@ -238,6 +239,31 @@ func TestStateDB_DriverManager(t *testing.T) {
}) })
} }
// TestStateDB_DynamicRegistry asserts the behavior of dynamic registry state related StateDB
// methods.
func TestStateDB_DynamicRegistry(t *testing.T) {
t.Parallel()
testDB(t, func(t *testing.T, db StateDB) {
require := require.New(t)
// Getting nonexistent state should return nils
ps, err := db.GetDynamicPluginRegistryState()
require.NoError(err)
require.Nil(ps)
// Putting PluginState should work
state := &dynamicplugins.RegistryState{}
require.NoError(db.PutDynamicPluginRegistryState(state))
// Getting should return the available state
ps, err = db.GetDynamicPluginRegistryState()
require.NoError(err)
require.NotNil(ps)
require.Equal(state, ps)
})
}
// TestStateDB_Upgrade asserts calling Upgrade on new databases always // TestStateDB_Upgrade asserts calling Upgrade on new databases always
// succeeds. // succeeds.
func TestStateDB_Upgrade(t *testing.T) { func TestStateDB_Upgrade(t *testing.T) {

View File

@ -3,6 +3,7 @@ package state
import ( import (
"github.com/hashicorp/nomad/client/allocrunner/taskrunner/state" "github.com/hashicorp/nomad/client/allocrunner/taskrunner/state"
dmstate "github.com/hashicorp/nomad/client/devicemanager/state" dmstate "github.com/hashicorp/nomad/client/devicemanager/state"
"github.com/hashicorp/nomad/client/dynamicplugins"
driverstate "github.com/hashicorp/nomad/client/pluginmanager/drivermanager/state" driverstate "github.com/hashicorp/nomad/client/pluginmanager/drivermanager/state"
"github.com/hashicorp/nomad/nomad/structs" "github.com/hashicorp/nomad/nomad/structs"
) )
@ -69,6 +70,12 @@ type StateDB interface {
// state. // state.
PutDriverPluginState(state *driverstate.PluginState) error PutDriverPluginState(state *driverstate.PluginState) error
// GetDynamicPluginRegistryState is used to retrieve a dynamic plugin manager's state.
GetDynamicPluginRegistryState() (*dynamicplugins.RegistryState, error)
// PutDynamicPluginRegistryState is used to store the dynamic plugin managers's state.
PutDynamicPluginRegistryState(state *dynamicplugins.RegistryState) error
// Close the database. Unsafe for further use after calling regardless // Close the database. Unsafe for further use after calling regardless
// of return value. // of return value.
Close() error Close() error

View File

@ -6,6 +6,7 @@ import (
hclog "github.com/hashicorp/go-hclog" hclog "github.com/hashicorp/go-hclog"
"github.com/hashicorp/nomad/client/allocrunner/taskrunner/state" "github.com/hashicorp/nomad/client/allocrunner/taskrunner/state"
dmstate "github.com/hashicorp/nomad/client/devicemanager/state" dmstate "github.com/hashicorp/nomad/client/devicemanager/state"
"github.com/hashicorp/nomad/client/dynamicplugins"
driverstate "github.com/hashicorp/nomad/client/pluginmanager/drivermanager/state" driverstate "github.com/hashicorp/nomad/client/pluginmanager/drivermanager/state"
"github.com/hashicorp/nomad/nomad/structs" "github.com/hashicorp/nomad/nomad/structs"
) )
@ -29,6 +30,9 @@ type MemDB struct {
// drivermanager -> plugin-state // drivermanager -> plugin-state
driverManagerPs *driverstate.PluginState driverManagerPs *driverstate.PluginState
// dynamicmanager -> registry-state
dynamicManagerPs *dynamicplugins.RegistryState
logger hclog.Logger logger hclog.Logger
mu sync.RWMutex mu sync.RWMutex
@ -193,6 +197,19 @@ func (m *MemDB) PutDriverPluginState(ps *driverstate.PluginState) error {
return nil return nil
} }
func (m *MemDB) GetDynamicPluginRegistryState() (*dynamicplugins.RegistryState, error) {
m.mu.Lock()
defer m.mu.Unlock()
return m.dynamicManagerPs, nil
}
func (m *MemDB) PutDynamicPluginRegistryState(ps *dynamicplugins.RegistryState) error {
m.mu.Lock()
defer m.mu.Unlock()
m.dynamicManagerPs = ps
return nil
}
func (m *MemDB) Close() error { func (m *MemDB) Close() error {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()

View File

@ -3,6 +3,7 @@ package state
import ( import (
"github.com/hashicorp/nomad/client/allocrunner/taskrunner/state" "github.com/hashicorp/nomad/client/allocrunner/taskrunner/state"
dmstate "github.com/hashicorp/nomad/client/devicemanager/state" dmstate "github.com/hashicorp/nomad/client/devicemanager/state"
"github.com/hashicorp/nomad/client/dynamicplugins"
driverstate "github.com/hashicorp/nomad/client/pluginmanager/drivermanager/state" driverstate "github.com/hashicorp/nomad/client/pluginmanager/drivermanager/state"
"github.com/hashicorp/nomad/nomad/structs" "github.com/hashicorp/nomad/nomad/structs"
) )
@ -70,6 +71,14 @@ func (n NoopDB) GetDriverPluginState() (*driverstate.PluginState, error) {
return nil, nil return nil, nil
} }
func (n NoopDB) PutDynamicPluginRegistryState(ps *dynamicplugins.RegistryState) error {
return nil
}
func (n NoopDB) GetDynamicPluginRegistryState() (*dynamicplugins.RegistryState, error) {
return nil, nil
}
func (n NoopDB) Close() error { func (n NoopDB) Close() error {
return nil return nil
} }

View File

@ -11,6 +11,7 @@ import (
hclog "github.com/hashicorp/go-hclog" hclog "github.com/hashicorp/go-hclog"
trstate "github.com/hashicorp/nomad/client/allocrunner/taskrunner/state" trstate "github.com/hashicorp/nomad/client/allocrunner/taskrunner/state"
dmstate "github.com/hashicorp/nomad/client/devicemanager/state" dmstate "github.com/hashicorp/nomad/client/devicemanager/state"
"github.com/hashicorp/nomad/client/dynamicplugins"
driverstate "github.com/hashicorp/nomad/client/pluginmanager/drivermanager/state" driverstate "github.com/hashicorp/nomad/client/pluginmanager/drivermanager/state"
"github.com/hashicorp/nomad/helper/boltdd" "github.com/hashicorp/nomad/helper/boltdd"
"github.com/hashicorp/nomad/nomad/structs" "github.com/hashicorp/nomad/nomad/structs"
@ -34,7 +35,10 @@ devicemanager/
|--> plugin_state -> *dmstate.PluginState |--> plugin_state -> *dmstate.PluginState
drivermanager/ drivermanager/
|--> plugin_state -> *dmstate.PluginState |--> plugin_state -> *driverstate.PluginState
dynamicplugins/
|--> registry_state -> *dynamicplugins.RegistryState
*/ */
var ( var (
@ -73,13 +77,20 @@ var (
// data // data
devManagerBucket = []byte("devicemanager") devManagerBucket = []byte("devicemanager")
// driverManagerBucket is the bucket name container all driver manager // driverManagerBucket is the bucket name containing all driver manager
// related data // related data
driverManagerBucket = []byte("drivermanager") driverManagerBucket = []byte("drivermanager")
// managerPluginStateKey is the key by which plugin manager plugin state is // managerPluginStateKey is the key by which plugin manager plugin state is
// stored at // stored at
managerPluginStateKey = []byte("plugin_state") managerPluginStateKey = []byte("plugin_state")
// dynamicPluginBucket is the bucket name containing all dynamic plugin
// registry data. each dynamic plugin registry will have its own subbucket.
dynamicPluginBucket = []byte("dynamicplugins")
// registryStateKey is the key at which dynamic plugin registry state is stored
registryStateKey = []byte("registry_state")
) )
// taskBucketName returns the bucket name for the given task name. // taskBucketName returns the bucket name for the given task name.
@ -598,6 +609,52 @@ func (s *BoltStateDB) GetDriverPluginState() (*driverstate.PluginState, error) {
return ps, nil return ps, nil
} }
// PutDynamicPluginRegistryState stores the dynamic plugin registry's
// state or returns an error.
func (s *BoltStateDB) PutDynamicPluginRegistryState(ps *dynamicplugins.RegistryState) error {
return s.db.Update(func(tx *boltdd.Tx) error {
// Retrieve the root dynamic plugin manager bucket
dynamicBkt, err := tx.CreateBucketIfNotExists(dynamicPluginBucket)
if err != nil {
return err
}
return dynamicBkt.Put(registryStateKey, ps)
})
}
// GetDynamicPluginRegistryState stores the dynamic plugin registry's
// registry state or returns an error.
func (s *BoltStateDB) GetDynamicPluginRegistryState() (*dynamicplugins.RegistryState, error) {
var ps *dynamicplugins.RegistryState
err := s.db.View(func(tx *boltdd.Tx) error {
dynamicBkt := tx.Bucket(dynamicPluginBucket)
if dynamicBkt == nil {
// No state, return
return nil
}
// Restore Plugin State if it exists
ps = &dynamicplugins.RegistryState{}
if err := dynamicBkt.Get(registryStateKey, ps); err != nil {
if !boltdd.IsErrNotFound(err) {
return fmt.Errorf("failed to read dynamic plugin registry state: %v", err)
}
// Key not found, reset ps to nil
ps = nil
}
return nil
})
if err != nil {
return nil, err
}
return ps, nil
}
// init initializes metadata entries in a newly created state database. // init initializes metadata entries in a newly created state database.
func (s *BoltStateDB) init() error { func (s *BoltStateDB) init() error {
return s.db.Update(func(tx *boltdd.Tx) error { return s.db.Update(func(tx *boltdd.Tx) error {

View File

@ -0,0 +1,29 @@
package structs
import (
"sync"
"github.com/hashicorp/nomad/client/pluginmanager/csimanager"
)
// AllocHookResources contains data that is provided by AllocRunner Hooks for
// consumption by TaskRunners
type AllocHookResources struct {
CSIMounts map[string]*csimanager.MountInfo
mu sync.RWMutex
}
func (a *AllocHookResources) GetCSIMounts() map[string]*csimanager.MountInfo {
a.mu.RLock()
defer a.mu.RUnlock()
return a.CSIMounts
}
func (a *AllocHookResources) SetCSIMounts(m map[string]*csimanager.MountInfo) {
a.mu.Lock()
defer a.mu.Unlock()
a.CSIMounts = m
}

131
client/structs/csi.go Normal file
View File

@ -0,0 +1,131 @@
package structs
import (
"github.com/hashicorp/nomad/nomad/structs"
"github.com/hashicorp/nomad/plugins/csi"
)
// CSIVolumeMountOptions contains the mount options that should be provided when
// attaching and mounting a volume with the CSIVolumeAttachmentModeFilesystem
// attachment mode.
type CSIVolumeMountOptions struct {
// Filesystem is the desired filesystem type that should be used by the volume
// (e.g ext4, aufs, zfs). This field is optional.
Filesystem string
// MountFlags contain the mount options that should be used for the volume.
// These may contain _sensitive_ data and should not be leaked to logs or
// returned in debugging data.
// The total size of this field must be under 4KiB.
MountFlags []string
}
// CSIControllerQuery is used to specify various flags for queries against CSI
// Controllers
type CSIControllerQuery struct {
// ControllerNodeID is the node that should be targeted by the request
ControllerNodeID string
// PluginID is the plugin that should be targeted on the given node.
PluginID string
}
type ClientCSIControllerValidateVolumeRequest struct {
VolumeID string
AttachmentMode structs.CSIVolumeAttachmentMode
AccessMode structs.CSIVolumeAccessMode
CSIControllerQuery
}
type ClientCSIControllerValidateVolumeResponse struct {
}
type ClientCSIControllerAttachVolumeRequest struct {
// The ID of the volume to be used on a node.
// This field is REQUIRED.
VolumeID string
// The ID of the node. This field is REQUIRED. This must match the NodeID that
// is fingerprinted by the target node for this plugin name.
ClientCSINodeID string
// AttachmentMode indicates how the volume should be attached and mounted into
// a task.
AttachmentMode structs.CSIVolumeAttachmentMode
// AccessMode indicates the desired concurrent access model for the volume
AccessMode structs.CSIVolumeAccessMode
// MountOptions is an optional field that contains additional configuration
// when providing an AttachmentMode of CSIVolumeAttachmentModeFilesystem
MountOptions *CSIVolumeMountOptions
// ReadOnly indicates that the volume will be used in a readonly fashion. This
// only works when the Controller has the PublishReadonly capability.
ReadOnly bool
CSIControllerQuery
}
func (c *ClientCSIControllerAttachVolumeRequest) ToCSIRequest() (*csi.ControllerPublishVolumeRequest, error) {
if c == nil {
return &csi.ControllerPublishVolumeRequest{}, nil
}
caps, err := csi.VolumeCapabilityFromStructs(c.AttachmentMode, c.AccessMode)
if err != nil {
return nil, err
}
return &csi.ControllerPublishVolumeRequest{
VolumeID: c.VolumeID,
NodeID: c.ClientCSINodeID,
ReadOnly: c.ReadOnly,
VolumeCapability: caps,
}, nil
}
type ClientCSIControllerAttachVolumeResponse struct {
// Opaque static publish properties of the volume. SP MAY use this
// field to ensure subsequent `NodeStageVolume` or `NodePublishVolume`
// calls calls have contextual information.
// The contents of this field SHALL be opaque to nomad.
// The contents of this field SHALL NOT be mutable.
// The contents of this field SHALL be safe for the nomad to cache.
// The contents of this field SHOULD NOT contain sensitive
// information.
// The contents of this field SHOULD NOT be used for uniquely
// identifying a volume. The `volume_id` alone SHOULD be sufficient to
// identify the volume.
// This field is OPTIONAL and when present MUST be passed to
// subsequent `NodeStageVolume` or `NodePublishVolume` calls
PublishContext map[string]string
}
type ClientCSIControllerDetachVolumeRequest struct {
// The ID of the volume to be unpublished for the node
// This field is REQUIRED.
VolumeID string
// The CSI Node ID for the Node that the volume should be detached from.
// This field is REQUIRED. This must match the NodeID that is fingerprinted
// by the target node for this plugin name.
ClientCSINodeID string
CSIControllerQuery
}
func (c *ClientCSIControllerDetachVolumeRequest) ToCSIRequest() *csi.ControllerUnpublishVolumeRequest {
if c == nil {
return &csi.ControllerUnpublishVolumeRequest{}
}
return &csi.ControllerUnpublishVolumeRequest{
VolumeID: c.VolumeID,
NodeID: c.ClientCSINodeID,
}
}
type ClientCSIControllerDetachVolumeResponse struct{}

View File

@ -6,11 +6,10 @@ import (
"io" "io"
"os" "os"
"path/filepath" "path/filepath"
"reflect"
"strings"
"time" "time"
"github.com/hashicorp/hcl" "github.com/hashicorp/hcl"
"github.com/hashicorp/nomad/helper"
"github.com/hashicorp/nomad/nomad/structs/config" "github.com/hashicorp/nomad/nomad/structs/config"
) )
@ -110,49 +109,33 @@ func durations(xs []td) error {
return nil return nil
} }
// removeEqualFold removes the first string that EqualFold matches
func removeEqualFold(xs *[]string, search string) {
sl := *xs
for i, x := range sl {
if strings.EqualFold(x, search) {
sl = append(sl[:i], sl[i+1:]...)
if len(sl) == 0 {
*xs = nil
} else {
*xs = sl
}
return
}
}
}
func extraKeys(c *Config) error { func extraKeys(c *Config) error {
// hcl leaves behind extra keys when parsing JSON. These keys // hcl leaves behind extra keys when parsing JSON. These keys
// are kept on the top level, taken from slices or the keys of // are kept on the top level, taken from slices or the keys of
// structs contained in slices. Clean up before looking for // structs contained in slices. Clean up before looking for
// extra keys. // extra keys.
for range c.HTTPAPIResponseHeaders { for range c.HTTPAPIResponseHeaders {
removeEqualFold(&c.ExtraKeysHCL, "http_api_response_headers") helper.RemoveEqualFold(&c.ExtraKeysHCL, "http_api_response_headers")
} }
for _, p := range c.Plugins { for _, p := range c.Plugins {
removeEqualFold(&c.ExtraKeysHCL, p.Name) helper.RemoveEqualFold(&c.ExtraKeysHCL, p.Name)
removeEqualFold(&c.ExtraKeysHCL, "config") helper.RemoveEqualFold(&c.ExtraKeysHCL, "config")
removeEqualFold(&c.ExtraKeysHCL, "plugin") helper.RemoveEqualFold(&c.ExtraKeysHCL, "plugin")
} }
for _, k := range []string{"options", "meta", "chroot_env", "servers", "server_join"} { for _, k := range []string{"options", "meta", "chroot_env", "servers", "server_join"} {
removeEqualFold(&c.ExtraKeysHCL, k) helper.RemoveEqualFold(&c.ExtraKeysHCL, k)
removeEqualFold(&c.ExtraKeysHCL, "client") helper.RemoveEqualFold(&c.ExtraKeysHCL, "client")
} }
// stats is an unused key, continue to silently ignore it // stats is an unused key, continue to silently ignore it
removeEqualFold(&c.Client.ExtraKeysHCL, "stats") helper.RemoveEqualFold(&c.Client.ExtraKeysHCL, "stats")
// Remove HostVolume extra keys // Remove HostVolume extra keys
for _, hv := range c.Client.HostVolumes { for _, hv := range c.Client.HostVolumes {
removeEqualFold(&c.Client.ExtraKeysHCL, hv.Name) helper.RemoveEqualFold(&c.Client.ExtraKeysHCL, hv.Name)
removeEqualFold(&c.Client.ExtraKeysHCL, "host_volume") helper.RemoveEqualFold(&c.Client.ExtraKeysHCL, "host_volume")
} }
// Remove AuditConfig extra keys // Remove AuditConfig extra keys
@ -167,60 +150,14 @@ func extraKeys(c *Config) error {
} }
for _, k := range []string{"enabled_schedulers", "start_join", "retry_join", "server_join"} { for _, k := range []string{"enabled_schedulers", "start_join", "retry_join", "server_join"} {
removeEqualFold(&c.ExtraKeysHCL, k) helper.RemoveEqualFold(&c.ExtraKeysHCL, k)
removeEqualFold(&c.ExtraKeysHCL, "server") helper.RemoveEqualFold(&c.ExtraKeysHCL, "server")
} }
for _, k := range []string{"datadog_tags"} { for _, k := range []string{"datadog_tags"} {
removeEqualFold(&c.ExtraKeysHCL, k) helper.RemoveEqualFold(&c.ExtraKeysHCL, k)
removeEqualFold(&c.ExtraKeysHCL, "telemetry") helper.RemoveEqualFold(&c.ExtraKeysHCL, "telemetry")
} }
return extraKeysImpl([]string{}, reflect.ValueOf(*c)) return helper.UnusedKeys(c)
}
// extraKeysImpl returns an error if any extraKeys array is not empty
func extraKeysImpl(path []string, val reflect.Value) error {
stype := val.Type()
for i := 0; i < stype.NumField(); i++ {
ftype := stype.Field(i)
fval := val.Field(i)
name := ftype.Name
prop := ""
tagSplit(ftype, "hcl", &name, &prop)
if fval.Kind() == reflect.Ptr {
fval = reflect.Indirect(fval)
}
// struct? recurse. add the struct's key to the path
if fval.Kind() == reflect.Struct {
err := extraKeysImpl(append([]string{name}, path...), fval)
if err != nil {
return err
}
}
if "unusedKeys" == prop {
if ks, ok := fval.Interface().([]string); ok && len(ks) != 0 {
return fmt.Errorf("%s unexpected keys %s",
strings.Join(path, "."),
strings.Join(ks, ", "))
}
}
}
return nil
}
// tagSplit reads the named tag from the structfield and splits its values into strings
func tagSplit(field reflect.StructField, tagName string, vars ...*string) {
tag := strings.Split(field.Tag.Get(tagName), ",")
end := len(tag) - 1
for i, s := range vars {
if i > end {
return
}
*s = tag[i]
}
} }

View File

@ -0,0 +1,200 @@
package agent
import (
"net/http"
"strings"
"github.com/hashicorp/nomad/nomad/structs"
)
const errRequiresType = "Missing required parameter type"
func (s *HTTPServer) CSIVolumesRequest(resp http.ResponseWriter, req *http.Request) (interface{}, error) {
if req.Method != "GET" {
return nil, CodedError(405, ErrInvalidMethod)
}
// Type filters volume lists to a specific type. When support for non-CSI volumes is
// introduced, we'll need to dispatch here
query := req.URL.Query()
qtype, ok := query["type"]
if !ok {
return nil, CodedError(400, errRequiresType)
}
if qtype[0] != "csi" {
return nil, nil
}
args := structs.CSIVolumeListRequest{}
if s.parse(resp, req, &args.Region, &args.QueryOptions) {
return nil, nil
}
if plugin, ok := query["plugin_id"]; ok {
args.PluginID = plugin[0]
}
if node, ok := query["node_id"]; ok {
args.NodeID = node[0]
}
var out structs.CSIVolumeListResponse
if err := s.agent.RPC("CSIVolume.List", &args, &out); err != nil {
return nil, err
}
setMeta(resp, &out.QueryMeta)
return out.Volumes, nil
}
// CSIVolumeSpecificRequest dispatches GET and PUT
func (s *HTTPServer) CSIVolumeSpecificRequest(resp http.ResponseWriter, req *http.Request) (interface{}, error) {
// Tokenize the suffix of the path to get the volume id
reqSuffix := strings.TrimPrefix(req.URL.Path, "/v1/volume/csi/")
tokens := strings.Split(reqSuffix, "/")
if len(tokens) > 2 || len(tokens) < 1 {
return nil, CodedError(404, resourceNotFoundErr)
}
id := tokens[0]
switch req.Method {
case "GET":
return s.csiVolumeGet(id, resp, req)
case "PUT":
return s.csiVolumePut(id, resp, req)
case "DELETE":
return s.csiVolumeDelete(id, resp, req)
default:
return nil, CodedError(405, ErrInvalidMethod)
}
}
func (s *HTTPServer) csiVolumeGet(id string, resp http.ResponseWriter, req *http.Request) (interface{}, error) {
args := structs.CSIVolumeGetRequest{
ID: id,
}
if s.parse(resp, req, &args.Region, &args.QueryOptions) {
return nil, nil
}
var out structs.CSIVolumeGetResponse
if err := s.agent.RPC("CSIVolume.Get", &args, &out); err != nil {
return nil, err
}
setMeta(resp, &out.QueryMeta)
if out.Volume == nil {
return nil, CodedError(404, "volume not found")
}
return out.Volume, nil
}
func (s *HTTPServer) csiVolumePut(id string, resp http.ResponseWriter, req *http.Request) (interface{}, error) {
if req.Method != "PUT" {
return nil, CodedError(405, ErrInvalidMethod)
}
args0 := structs.CSIVolumeRegisterRequest{}
if err := decodeBody(req, &args0); err != nil {
return err, CodedError(400, err.Error())
}
args := structs.CSIVolumeRegisterRequest{
Volumes: args0.Volumes,
}
s.parseWriteRequest(req, &args.WriteRequest)
var out structs.CSIVolumeRegisterResponse
if err := s.agent.RPC("CSIVolume.Register", &args, &out); err != nil {
return nil, err
}
setMeta(resp, &out.QueryMeta)
return nil, nil
}
func (s *HTTPServer) csiVolumeDelete(id string, resp http.ResponseWriter, req *http.Request) (interface{}, error) {
if req.Method != "DELETE" {
return nil, CodedError(405, ErrInvalidMethod)
}
args := structs.CSIVolumeDeregisterRequest{
VolumeIDs: []string{id},
}
s.parseWriteRequest(req, &args.WriteRequest)
var out structs.CSIVolumeDeregisterResponse
if err := s.agent.RPC("CSIVolume.Deregister", &args, &out); err != nil {
return nil, err
}
setMeta(resp, &out.QueryMeta)
return nil, nil
}
// CSIPluginsRequest lists CSI plugins
func (s *HTTPServer) CSIPluginsRequest(resp http.ResponseWriter, req *http.Request) (interface{}, error) {
if req.Method != "GET" {
return nil, CodedError(405, ErrInvalidMethod)
}
// Type filters plugin lists to a specific type. When support for non-CSI plugins is
// introduced, we'll need to dispatch here
query := req.URL.Query()
qtype, ok := query["type"]
if !ok {
return nil, CodedError(400, errRequiresType)
}
if qtype[0] != "csi" {
return nil, nil
}
args := structs.CSIPluginListRequest{}
if s.parse(resp, req, &args.Region, &args.QueryOptions) {
return nil, nil
}
var out structs.CSIPluginListResponse
if err := s.agent.RPC("CSIPlugin.List", &args, &out); err != nil {
return nil, err
}
setMeta(resp, &out.QueryMeta)
return out.Plugins, nil
}
// CSIPluginSpecificRequest list the job with CSIInfo
func (s *HTTPServer) CSIPluginSpecificRequest(resp http.ResponseWriter, req *http.Request) (interface{}, error) {
if req.Method != "GET" {
return nil, CodedError(405, ErrInvalidMethod)
}
// Tokenize the suffix of the path to get the plugin id
reqSuffix := strings.TrimPrefix(req.URL.Path, "/v1/plugin/csi/")
tokens := strings.Split(reqSuffix, "/")
if len(tokens) > 2 || len(tokens) < 1 {
return nil, CodedError(404, resourceNotFoundErr)
}
id := tokens[0]
args := structs.CSIPluginGetRequest{ID: id}
if s.parse(resp, req, &args.Region, &args.QueryOptions) {
return nil, nil
}
var out structs.CSIPluginGetResponse
if err := s.agent.RPC("CSIPlugin.Get", &args, &out); err != nil {
return nil, err
}
setMeta(resp, &out.QueryMeta)
if out.Plugin == nil {
return nil, CodedError(404, "plugin not found")
}
return out.Plugin, nil
}

View File

@ -263,6 +263,11 @@ func (s *HTTPServer) registerHandlers(enableDebug bool) {
s.mux.HandleFunc("/v1/deployments", s.wrap(s.DeploymentsRequest)) s.mux.HandleFunc("/v1/deployments", s.wrap(s.DeploymentsRequest))
s.mux.HandleFunc("/v1/deployment/", s.wrap(s.DeploymentSpecificRequest)) s.mux.HandleFunc("/v1/deployment/", s.wrap(s.DeploymentSpecificRequest))
s.mux.HandleFunc("/v1/volumes", s.wrap(s.CSIVolumesRequest))
s.mux.HandleFunc("/v1/volume/csi/", s.wrap(s.CSIVolumeSpecificRequest))
s.mux.HandleFunc("/v1/plugins", s.wrap(s.CSIPluginsRequest))
s.mux.HandleFunc("/v1/plugin/csi/", s.wrap(s.CSIPluginSpecificRequest))
s.mux.HandleFunc("/v1/acl/policies", s.wrap(s.ACLPoliciesRequest)) s.mux.HandleFunc("/v1/acl/policies", s.wrap(s.ACLPoliciesRequest))
s.mux.HandleFunc("/v1/acl/policy/", s.wrap(s.ACLPolicySpecificRequest)) s.mux.HandleFunc("/v1/acl/policy/", s.wrap(s.ACLPolicySpecificRequest))

View File

@ -749,8 +749,9 @@ func ApiTgToStructsTG(taskGroup *api.TaskGroup, tg *structs.TaskGroup) {
if l := len(taskGroup.Volumes); l != 0 { if l := len(taskGroup.Volumes); l != 0 {
tg.Volumes = make(map[string]*structs.VolumeRequest, l) tg.Volumes = make(map[string]*structs.VolumeRequest, l)
for k, v := range taskGroup.Volumes { for k, v := range taskGroup.Volumes {
if v.Type != structs.VolumeTypeHost { if v.Type != structs.VolumeTypeHost && v.Type != structs.VolumeTypeCSI {
// Ignore non-host volumes in this iteration currently. // Ignore volumes we don't understand in this iteration currently.
// - This is because we don't currently have a way to return errors here.
continue continue
} }
@ -761,6 +762,13 @@ func ApiTgToStructsTG(taskGroup *api.TaskGroup, tg *structs.TaskGroup) {
Source: v.Source, Source: v.Source,
} }
if v.MountOptions != nil {
vol.MountOptions = &structs.CSIMountOptions{
FSType: v.MountOptions.FSType,
MountFlags: v.MountOptions.MountFlags,
}
}
tg.Volumes[k] = vol tg.Volumes[k] = vol
} }
} }
@ -812,6 +820,7 @@ func ApiTaskToStructsTask(apiTask *api.Task, structsTask *structs.Task) {
structsTask.Kind = structs.TaskKind(apiTask.Kind) structsTask.Kind = structs.TaskKind(apiTask.Kind)
structsTask.Constraints = ApiConstraintsToStructs(apiTask.Constraints) structsTask.Constraints = ApiConstraintsToStructs(apiTask.Constraints)
structsTask.Affinities = ApiAffinitiesToStructs(apiTask.Affinities) structsTask.Affinities = ApiAffinitiesToStructs(apiTask.Affinities)
structsTask.CSIPluginConfig = ApiCSIPluginConfigToStructsCSIPluginConfig(apiTask.CSIPluginConfig)
if l := len(apiTask.VolumeMounts); l != 0 { if l := len(apiTask.VolumeMounts); l != 0 {
structsTask.VolumeMounts = make([]*structs.VolumeMount, l) structsTask.VolumeMounts = make([]*structs.VolumeMount, l)
@ -933,6 +942,18 @@ func ApiTaskToStructsTask(apiTask *api.Task, structsTask *structs.Task) {
} }
} }
func ApiCSIPluginConfigToStructsCSIPluginConfig(apiConfig *api.TaskCSIPluginConfig) *structs.TaskCSIPluginConfig {
if apiConfig == nil {
return nil
}
sc := &structs.TaskCSIPluginConfig{}
sc.ID = apiConfig.ID
sc.Type = structs.CSIPluginType(apiConfig.Type)
sc.MountDir = apiConfig.MountDir
return sc
}
func ApiResourcesToStructs(in *api.Resources) *structs.Resources { func ApiResourcesToStructs(in *api.Resources) *structs.Resources {
if in == nil { if in == nil {
return nil return nil

View File

@ -12,6 +12,7 @@ import (
"github.com/hashicorp/nomad/api" "github.com/hashicorp/nomad/api"
"github.com/hashicorp/nomad/api/contexts" "github.com/hashicorp/nomad/api/contexts"
"github.com/hashicorp/nomad/client/allocrunner/taskrunner/restarts" "github.com/hashicorp/nomad/client/allocrunner/taskrunner/restarts"
"github.com/hashicorp/nomad/nomad/structs"
"github.com/posener/complete" "github.com/posener/complete"
) )
@ -214,7 +215,7 @@ func (c *AllocStatusCommand) Run(args []string) int {
c.Ui.Output("Omitting resource statistics since the node is down.") c.Ui.Output("Omitting resource statistics since the node is down.")
} }
} }
c.outputTaskDetails(alloc, stats, displayStats) c.outputTaskDetails(alloc, stats, displayStats, verbose)
} }
// Format the detailed status // Format the detailed status
@ -362,12 +363,13 @@ func futureEvalTimePretty(evalID string, client *api.Client) string {
// outputTaskDetails prints task details for each task in the allocation, // outputTaskDetails prints task details for each task in the allocation,
// optionally printing verbose statistics if displayStats is set // optionally printing verbose statistics if displayStats is set
func (c *AllocStatusCommand) outputTaskDetails(alloc *api.Allocation, stats *api.AllocResourceUsage, displayStats bool) { func (c *AllocStatusCommand) outputTaskDetails(alloc *api.Allocation, stats *api.AllocResourceUsage, displayStats bool, verbose bool) {
for task := range c.sortedTaskStateIterator(alloc.TaskStates) { for task := range c.sortedTaskStateIterator(alloc.TaskStates) {
state := alloc.TaskStates[task] state := alloc.TaskStates[task]
c.Ui.Output(c.Colorize().Color(fmt.Sprintf("\n[bold]Task %q is %q[reset]", task, state.State))) c.Ui.Output(c.Colorize().Color(fmt.Sprintf("\n[bold]Task %q is %q[reset]", task, state.State)))
c.outputTaskResources(alloc, task, stats, displayStats) c.outputTaskResources(alloc, task, stats, displayStats)
c.Ui.Output("") c.Ui.Output("")
c.outputTaskVolumes(alloc, task, verbose)
c.outputTaskStatus(state) c.outputTaskStatus(state)
} }
} }
@ -721,3 +723,80 @@ func (c *AllocStatusCommand) sortedTaskStateIterator(m map[string]*api.TaskState
close(output) close(output)
return output return output
} }
func (c *AllocStatusCommand) outputTaskVolumes(alloc *api.Allocation, taskName string, verbose bool) {
var task *api.Task
var tg *api.TaskGroup
FOUND:
for _, tg = range alloc.Job.TaskGroups {
for _, task = range tg.Tasks {
if task.Name == taskName {
break FOUND
}
}
}
if task == nil || tg == nil {
c.Ui.Error(fmt.Sprintf("Could not find task data for %q", taskName))
return
}
if len(task.VolumeMounts) == 0 {
return
}
client, err := c.Meta.Client()
if err != nil {
c.Ui.Error(fmt.Sprintf("Error initializing client: %s", err))
return
}
var hostVolumesOutput []string
var csiVolumesOutput []string
hostVolumesOutput = append(hostVolumesOutput, "ID|Read Only")
if verbose {
csiVolumesOutput = append(csiVolumesOutput,
"ID|Plugin|Provider|Schedulable|Read Only|Mount Options")
} else {
csiVolumesOutput = append(csiVolumesOutput, "ID|Read Only")
}
for _, volMount := range task.VolumeMounts {
volReq := tg.Volumes[*volMount.Volume]
switch volReq.Type {
case structs.VolumeTypeHost:
hostVolumesOutput = append(hostVolumesOutput,
fmt.Sprintf("%s|%v", volReq.Name, *volMount.ReadOnly))
case structs.VolumeTypeCSI:
if verbose {
// there's an extra API call per volume here so we toggle it
// off with the -verbose flag
vol, _, err := client.CSIVolumes().Info(volReq.Name, nil)
if err != nil {
c.Ui.Error(fmt.Sprintf("Error retrieving volume info for %q: %s",
volReq.Name, err))
continue
}
csiVolumesOutput = append(csiVolumesOutput,
fmt.Sprintf("%s|%s|%s|%v|%v|%s",
volReq.Name,
vol.PluginID,
vol.Provider,
vol.Schedulable,
volReq.ReadOnly,
csiVolMountOption(vol.MountOptions, volReq.MountOptions),
))
} else {
csiVolumesOutput = append(csiVolumesOutput,
fmt.Sprintf("%s|%v", volReq.Name, volReq.ReadOnly))
}
}
}
if len(hostVolumesOutput) > 1 {
c.Ui.Output("Host Volumes:")
c.Ui.Output(formatList(hostVolumesOutput))
c.Ui.Output("") // line padding to next stanza
}
if len(csiVolumesOutput) > 1 {
c.Ui.Output("CSI Volumes:")
c.Ui.Output(formatList(csiVolumesOutput))
c.Ui.Output("") // line padding to next stanza
}
}

View File

@ -2,11 +2,14 @@ package command
import ( import (
"fmt" "fmt"
"io/ioutil"
"os"
"regexp" "regexp"
"strings" "strings"
"testing" "testing"
"time" "time"
"github.com/hashicorp/nomad/command/agent"
"github.com/hashicorp/nomad/helper/uuid" "github.com/hashicorp/nomad/helper/uuid"
"github.com/hashicorp/nomad/nomad/mock" "github.com/hashicorp/nomad/nomad/mock"
"github.com/hashicorp/nomad/nomad/structs" "github.com/hashicorp/nomad/nomad/structs"
@ -315,3 +318,146 @@ func TestAllocStatusCommand_AutocompleteArgs(t *testing.T) {
assert.Equal(1, len(res)) assert.Equal(1, len(res))
assert.Equal(a.ID, res[0]) assert.Equal(a.ID, res[0])
} }
func TestAllocStatusCommand_HostVolumes(t *testing.T) {
t.Parallel()
// We have to create a tempdir for the host volume even though we're
// not going to use it b/c the server validates the config on startup
tmpDir, err := ioutil.TempDir("", "vol0")
if err != nil {
t.Fatalf("unable to create tempdir for test: %v", err)
}
defer os.RemoveAll(tmpDir)
vol0 := uuid.Generate()
srv, _, url := testServer(t, true, func(c *agent.Config) {
c.Client.HostVolumes = []*structs.ClientHostVolumeConfig{
{
Name: vol0,
Path: tmpDir,
ReadOnly: false,
},
}
})
defer srv.Shutdown()
state := srv.Agent.Server().State()
// Upsert the job and alloc
node := mock.Node()
alloc := mock.Alloc()
alloc.Metrics = &structs.AllocMetric{}
alloc.NodeID = node.ID
job := alloc.Job
job.TaskGroups[0].Volumes = map[string]*structs.VolumeRequest{
vol0: {
Name: vol0,
Type: structs.VolumeTypeHost,
Source: tmpDir,
},
}
job.TaskGroups[0].Tasks[0].VolumeMounts = []*structs.VolumeMount{
{
Volume: vol0,
Destination: "/var/www",
ReadOnly: true,
PropagationMode: "private",
},
}
// fakes the placement enough so that we have something to iterate
// on in 'nomad alloc status'
alloc.TaskStates = map[string]*structs.TaskState{
"web": &structs.TaskState{
Events: []*structs.TaskEvent{
structs.NewTaskEvent("test event").SetMessage("test msg"),
},
},
}
summary := mock.JobSummary(alloc.JobID)
require.NoError(t, state.UpsertJobSummary(1004, summary))
require.NoError(t, state.UpsertAllocs(1005, []*structs.Allocation{alloc}))
ui := new(cli.MockUi)
cmd := &AllocStatusCommand{Meta: Meta{Ui: ui}}
if code := cmd.Run([]string{"-address=" + url, "-verbose", alloc.ID}); code != 0 {
t.Fatalf("expected exit 0, got: %d", code)
}
out := ui.OutputWriter.String()
require.Contains(t, out, "Host Volumes")
require.Contains(t, out, fmt.Sprintf("%s true", vol0))
require.NotContains(t, out, "CSI Volumes")
}
func TestAllocStatusCommand_CSIVolumes(t *testing.T) {
t.Parallel()
srv, _, url := testServer(t, true, nil)
defer srv.Shutdown()
state := srv.Agent.Server().State()
// Upsert the node, plugin, and volume
vol0 := uuid.Generate()
node := mock.Node()
node.CSINodePlugins = map[string]*structs.CSIInfo{
"minnie": {
PluginID: "minnie",
Healthy: true,
NodeInfo: &structs.CSINodeInfo{},
},
}
err := state.UpsertNode(1001, node)
require.NoError(t, err)
vols := []*structs.CSIVolume{{
ID: vol0,
Namespace: structs.DefaultNamespace,
PluginID: "minnie",
AccessMode: structs.CSIVolumeAccessModeMultiNodeSingleWriter,
AttachmentMode: structs.CSIVolumeAttachmentModeFilesystem,
Topologies: []*structs.CSITopology{{
Segments: map[string]string{"foo": "bar"},
}},
}}
err = state.CSIVolumeRegister(1002, vols)
require.NoError(t, err)
// Upsert the job and alloc
alloc := mock.Alloc()
alloc.Metrics = &structs.AllocMetric{}
alloc.NodeID = node.ID
job := alloc.Job
job.TaskGroups[0].Volumes = map[string]*structs.VolumeRequest{
vol0: {
Name: vol0,
Type: structs.VolumeTypeCSI,
Source: "/tmp/vol0",
},
}
job.TaskGroups[0].Tasks[0].VolumeMounts = []*structs.VolumeMount{
{
Volume: vol0,
Destination: "/var/www",
ReadOnly: true,
PropagationMode: "private",
},
}
// if we don't set a task state, there's nothing to iterate on alloc status
alloc.TaskStates = map[string]*structs.TaskState{
"web": &structs.TaskState{
Events: []*structs.TaskEvent{
structs.NewTaskEvent("test event").SetMessage("test msg"),
},
},
}
summary := mock.JobSummary(alloc.JobID)
require.NoError(t, state.UpsertJobSummary(1004, summary))
require.NoError(t, state.UpsertAllocs(1005, []*structs.Allocation{alloc}))
ui := new(cli.MockUi)
cmd := &AllocStatusCommand{Meta: Meta{Ui: ui}}
if code := cmd.Run([]string{"-address=" + url, "-verbose", alloc.ID}); code != 0 {
t.Fatalf("expected exit 0, got: %d", code)
}
out := ui.OutputWriter.String()
require.Contains(t, out, "CSI Volumes")
require.Contains(t, out, fmt.Sprintf("%s minnie", vol0))
require.NotContains(t, out, "Host Volumes")
}

View File

@ -493,6 +493,17 @@ func Commands(metaPtr *Meta, agentUi cli.Ui) map[string]cli.CommandFactory {
}, nil }, nil
}, },
"plugin": func() (cli.Command, error) {
return &PluginCommand{
Meta: meta,
}, nil
},
"plugin status": func() (cli.Command, error) {
return &PluginStatusCommand{
Meta: meta,
}, nil
},
"quota": func() (cli.Command, error) { "quota": func() (cli.Command, error) {
return &QuotaCommand{ return &QuotaCommand{
Meta: meta, Meta: meta,
@ -646,6 +657,26 @@ func Commands(metaPtr *Meta, agentUi cli.Ui) map[string]cli.CommandFactory {
Ui: meta.Ui, Ui: meta.Ui,
}, nil }, nil
}, },
"volume": func() (cli.Command, error) {
return &VolumeCommand{
Meta: meta,
}, nil
},
"volume status": func() (cli.Command, error) {
return &VolumeStatusCommand{
Meta: meta,
}, nil
},
"volume register": func() (cli.Command, error) {
return &VolumeRegisterCommand{
Meta: meta,
}, nil
},
"volume deregister": func() (cli.Command, error) {
return &VolumeDeregisterCommand{
Meta: meta,
}, nil
},
} }
deprecated := map[string]cli.CommandFactory{ deprecated := map[string]cli.CommandFactory{

View File

@ -299,6 +299,40 @@ func nodeDrivers(n *api.Node) []string {
return drivers return drivers
} }
func nodeCSIControllerNames(n *api.Node) []string {
var names []string
for name := range n.CSIControllerPlugins {
names = append(names, name)
}
sort.Strings(names)
return names
}
func nodeCSINodeNames(n *api.Node) []string {
var names []string
for name := range n.CSINodePlugins {
names = append(names, name)
}
sort.Strings(names)
return names
}
func nodeCSIVolumeNames(n *api.Node, allocs []*api.Allocation) []string {
var names []string
for _, alloc := range allocs {
tg := alloc.GetTaskGroup()
if tg == nil || len(tg.Volumes) == 0 {
continue
}
for _, v := range tg.Volumes {
names = append(names, v.Name)
}
}
sort.Strings(names)
return names
}
func nodeVolumeNames(n *api.Node) []string { func nodeVolumeNames(n *api.Node) []string {
var volumes []string var volumes []string
for name := range n.HostVolumes { for name := range n.HostVolumes {
@ -331,6 +365,20 @@ func formatDrain(n *api.Node) string {
} }
func (c *NodeStatusCommand) formatNode(client *api.Client, node *api.Node) int { func (c *NodeStatusCommand) formatNode(client *api.Client, node *api.Node) int {
// Make one API call for allocations
nodeAllocs, _, err := client.Nodes().Allocations(node.ID, nil)
if err != nil {
c.Ui.Error(fmt.Sprintf("Error querying node allocations: %s", err))
return 1
}
var runningAllocs []*api.Allocation
for _, alloc := range nodeAllocs {
if alloc.ClientStatus == "running" {
runningAllocs = append(runningAllocs, alloc)
}
}
// Format the header output // Format the header output
basic := []string{ basic := []string{
fmt.Sprintf("ID|%s", node.ID), fmt.Sprintf("ID|%s", node.ID),
@ -340,15 +388,18 @@ func (c *NodeStatusCommand) formatNode(client *api.Client, node *api.Node) int {
fmt.Sprintf("Drain|%v", formatDrain(node)), fmt.Sprintf("Drain|%v", formatDrain(node)),
fmt.Sprintf("Eligibility|%s", node.SchedulingEligibility), fmt.Sprintf("Eligibility|%s", node.SchedulingEligibility),
fmt.Sprintf("Status|%s", node.Status), fmt.Sprintf("Status|%s", node.Status),
fmt.Sprintf("CSI Controllers|%s", strings.Join(nodeCSIControllerNames(node), ",")),
fmt.Sprintf("CSI Drivers|%s", strings.Join(nodeCSINodeNames(node), ",")),
} }
if c.short { if c.short {
basic = append(basic, fmt.Sprintf("Host Volumes|%s", strings.Join(nodeVolumeNames(node), ","))) basic = append(basic, fmt.Sprintf("Host Volumes|%s", strings.Join(nodeVolumeNames(node), ",")))
basic = append(basic, fmt.Sprintf("CSI Volumes|%s", strings.Join(nodeCSIVolumeNames(node, runningAllocs), ",")))
basic = append(basic, fmt.Sprintf("Drivers|%s", strings.Join(nodeDrivers(node), ","))) basic = append(basic, fmt.Sprintf("Drivers|%s", strings.Join(nodeDrivers(node), ",")))
c.Ui.Output(c.Colorize().Color(formatKV(basic))) c.Ui.Output(c.Colorize().Color(formatKV(basic)))
// Output alloc info // Output alloc info
if err := c.outputAllocInfo(client, node); err != nil { if err := c.outputAllocInfo(node, nodeAllocs); err != nil {
c.Ui.Error(fmt.Sprintf("%s", err)) c.Ui.Error(fmt.Sprintf("%s", err))
return 1 return 1
} }
@ -371,7 +422,7 @@ func (c *NodeStatusCommand) formatNode(client *api.Client, node *api.Node) int {
// driver info in the basic output // driver info in the basic output
if !c.verbose { if !c.verbose {
basic = append(basic, fmt.Sprintf("Host Volumes|%s", strings.Join(nodeVolumeNames(node), ","))) basic = append(basic, fmt.Sprintf("Host Volumes|%s", strings.Join(nodeVolumeNames(node), ",")))
basic = append(basic, fmt.Sprintf("CSI Volumes|%s", strings.Join(nodeCSIVolumeNames(node, runningAllocs), ",")))
driverStatus := fmt.Sprintf("Driver Status| %s", c.outputTruncatedNodeDriverInfo(node)) driverStatus := fmt.Sprintf("Driver Status| %s", c.outputTruncatedNodeDriverInfo(node))
basic = append(basic, driverStatus) basic = append(basic, driverStatus)
} }
@ -382,6 +433,7 @@ func (c *NodeStatusCommand) formatNode(client *api.Client, node *api.Node) int {
// If we're running in verbose mode, include full host volume and driver info // If we're running in verbose mode, include full host volume and driver info
if c.verbose { if c.verbose {
c.outputNodeVolumeInfo(node) c.outputNodeVolumeInfo(node)
c.outputNodeCSIVolumeInfo(client, node, runningAllocs)
c.outputNodeDriverInfo(node) c.outputNodeDriverInfo(node)
} }
@ -389,12 +441,6 @@ func (c *NodeStatusCommand) formatNode(client *api.Client, node *api.Node) int {
c.outputNodeStatusEvents(node) c.outputNodeStatusEvents(node)
// Get list of running allocations on the node // Get list of running allocations on the node
runningAllocs, err := getRunningAllocs(client, node.ID)
if err != nil {
c.Ui.Error(fmt.Sprintf("Error querying node for running allocations: %s", err))
return 1
}
allocatedResources := getAllocatedResources(client, runningAllocs, node) allocatedResources := getAllocatedResources(client, runningAllocs, node)
c.Ui.Output(c.Colorize().Color("\n[bold]Allocated Resources[reset]")) c.Ui.Output(c.Colorize().Color("\n[bold]Allocated Resources[reset]"))
c.Ui.Output(formatList(allocatedResources)) c.Ui.Output(formatList(allocatedResources))
@ -432,7 +478,7 @@ func (c *NodeStatusCommand) formatNode(client *api.Client, node *api.Node) int {
} }
} }
if err := c.outputAllocInfo(client, node); err != nil { if err := c.outputAllocInfo(node, nodeAllocs); err != nil {
c.Ui.Error(fmt.Sprintf("%s", err)) c.Ui.Error(fmt.Sprintf("%s", err))
return 1 return 1
} }
@ -440,12 +486,7 @@ func (c *NodeStatusCommand) formatNode(client *api.Client, node *api.Node) int {
return 0 return 0
} }
func (c *NodeStatusCommand) outputAllocInfo(client *api.Client, node *api.Node) error { func (c *NodeStatusCommand) outputAllocInfo(node *api.Node, nodeAllocs []*api.Allocation) error {
nodeAllocs, _, err := client.Nodes().Allocations(node.ID, nil)
if err != nil {
return fmt.Errorf("Error querying node allocations: %s", err)
}
c.Ui.Output(c.Colorize().Color("\n[bold]Allocations[reset]")) c.Ui.Output(c.Colorize().Color("\n[bold]Allocations[reset]"))
c.Ui.Output(formatAllocList(nodeAllocs, c.verbose, c.length)) c.Ui.Output(formatAllocList(nodeAllocs, c.verbose, c.length))
@ -495,6 +536,58 @@ func (c *NodeStatusCommand) outputNodeVolumeInfo(node *api.Node) {
c.Ui.Output(formatList(output)) c.Ui.Output(formatList(output))
} }
func (c *NodeStatusCommand) outputNodeCSIVolumeInfo(client *api.Client, node *api.Node, runningAllocs []*api.Allocation) {
c.Ui.Output(c.Colorize().Color("\n[bold]CSI Volumes"))
// Duplicate nodeCSIVolumeNames to sort by name but also index volume names to ids
var names []string
requests := map[string]*api.VolumeRequest{}
for _, alloc := range runningAllocs {
tg := alloc.GetTaskGroup()
if tg == nil || len(tg.Volumes) == 0 {
continue
}
for _, v := range tg.Volumes {
names = append(names, v.Name)
requests[v.Source] = v
}
}
if len(names) == 0 {
return
}
sort.Strings(names)
// Fetch the volume objects with current status
// Ignore an error, all we're going to do is omit the volumes
volumes := map[string]*api.CSIVolumeListStub{}
vs, _ := client.Nodes().CSIVolumes(node.ID, nil)
for _, v := range vs {
n := requests[v.ID].Name
volumes[n] = v
}
// Output the volumes in name order
output := make([]string, 0, len(names)+1)
output = append(output, "ID|Name|Plugin ID|Schedulable|Provider|Access Mode|Mount Options")
for _, name := range names {
v := volumes[name]
r := requests[v.ID]
output = append(output, fmt.Sprintf(
"%s|%s|%s|%t|%s|%s|%s",
v.ID,
name,
v.PluginID,
v.Schedulable,
v.Provider,
v.AccessMode,
csiVolMountOption(v.MountOptions, r.MountOptions),
))
}
c.Ui.Output(formatList(output))
}
func (c *NodeStatusCommand) outputNodeDriverInfo(node *api.Node) { func (c *NodeStatusCommand) outputNodeDriverInfo(node *api.Node) {
c.Ui.Output(c.Colorize().Color("\n[bold]Drivers")) c.Ui.Output(c.Colorize().Color("\n[bold]Drivers"))

26
command/plugin.go Normal file
View File

@ -0,0 +1,26 @@
package command
import "github.com/mitchellh/cli"
type PluginCommand struct {
Meta
}
func (c *PluginCommand) Help() string {
helpText := `
Usage nomad plugin status [options] [plugin]
This command groups subcommands for interacting with plugins.
`
return helpText
}
func (c *PluginCommand) Synopsis() string {
return "Inspect plugins"
}
func (c *PluginCommand) Name() string { return "plugin" }
func (c *PluginCommand) Run(args []string) int {
return cli.RunResultHelp
}

146
command/plugin_status.go Normal file
View File

@ -0,0 +1,146 @@
package command
import (
"fmt"
"strings"
"github.com/hashicorp/nomad/api/contexts"
"github.com/posener/complete"
)
type PluginStatusCommand struct {
Meta
length int
short bool
verbose bool
json bool
template string
}
func (c *PluginStatusCommand) Help() string {
helpText := `
Usage nomad plugin status [options] <plugin>
Display status information about a plugin. If no plugin id is given,
a list of all plugins will be displayed.
General Options:
` + generalOptionsUsage() + `
Status Options:
-type <type>
List only plugins of type <type>.
-short
Display short output.
-verbose
Display full information.
-json
Output the allocation in its JSON format.
-t
Format and display allocation using a Go template.
`
return helpText
}
func (c *PluginStatusCommand) Synopsis() string {
return "Display status information about a plugin"
}
// predictVolumeType is also used in volume_status
var predictVolumeType = complete.PredictFunc(func(a complete.Args) []string {
types := []string{"csi"}
for _, t := range types {
if strings.Contains(t, a.Last) {
return []string{t}
}
}
return nil
})
func (c *PluginStatusCommand) AutocompleteFlags() complete.Flags {
return mergeAutocompleteFlags(c.Meta.AutocompleteFlags(FlagSetClient),
complete.Flags{
"-type": predictVolumeType,
"-short": complete.PredictNothing,
"-verbose": complete.PredictNothing,
"-json": complete.PredictNothing,
"-t": complete.PredictAnything,
})
}
func (c *PluginStatusCommand) AutocompleteArgs() complete.Predictor {
return complete.PredictFunc(func(a complete.Args) []string {
client, err := c.Meta.Client()
if err != nil {
return nil
}
resp, _, err := client.Search().PrefixSearch(a.Last, contexts.Plugins, nil)
if err != nil {
return []string{}
}
return resp.Matches[contexts.Plugins]
})
}
func (c *PluginStatusCommand) Name() string { return "plugin status" }
func (c *PluginStatusCommand) Run(args []string) int {
var typeArg string
flags := c.Meta.FlagSet(c.Name(), FlagSetClient)
flags.Usage = func() { c.Ui.Output(c.Help()) }
flags.StringVar(&typeArg, "type", "", "")
flags.BoolVar(&c.short, "short", false, "")
flags.BoolVar(&c.verbose, "verbose", false, "")
flags.BoolVar(&c.json, "json", false, "")
flags.StringVar(&c.template, "t", "", "")
if err := flags.Parse(args); err != nil {
c.Ui.Error(fmt.Sprintf("Error parsing arguments %s", err))
return 1
}
typeArg = strings.ToLower(typeArg)
// Check that we either got no arguments or exactly one.
args = flags.Args()
if len(args) > 1 {
c.Ui.Error("This command takes either no arguments or one: <plugin>")
c.Ui.Error(commandErrorText(c))
return 1
}
// Truncate the id unless full length is requested
c.length = shortId
if c.verbose {
c.length = fullId
}
// Get the HTTP client
client, err := c.Meta.Client()
if err != nil {
c.Ui.Error(fmt.Sprintf("Error initializing client: %s", err))
return 1
}
id := ""
if len(args) == 1 {
id = args[0]
}
code := c.csiStatus(client, id)
if code != 0 {
return code
}
// Extend this section with other plugin implementations
return 0
}

View File

@ -0,0 +1,113 @@
package command
import (
"fmt"
"sort"
"strings"
"github.com/hashicorp/nomad/api"
)
func (c *PluginStatusCommand) csiBanner() {
if !(c.json || len(c.template) > 0) {
c.Ui.Output(c.Colorize().Color("[bold]Container Storage Interface[reset]"))
}
}
func (c *PluginStatusCommand) csiStatus(client *api.Client, id string) int {
if id == "" {
c.csiBanner()
plugs, _, err := client.CSIPlugins().List(nil)
if err != nil {
c.Ui.Error(fmt.Sprintf("Error querying CSI plugins: %s", err))
return 1
}
if len(plugs) == 0 {
// No output if we have no plugins
c.Ui.Error("No CSI plugins")
} else {
str, err := c.csiFormatPlugins(plugs)
if err != nil {
c.Ui.Error(fmt.Sprintf("Error formatting: %s", err))
return 1
}
c.Ui.Output(str)
}
return 0
}
// Lookup matched a single plugin
plug, _, err := client.CSIPlugins().Info(id, nil)
if err != nil {
c.Ui.Error(fmt.Sprintf("Error querying plugin: %s", err))
return 1
}
str, err := c.csiFormatPlugin(plug)
if err != nil {
c.Ui.Error(fmt.Sprintf("Error formatting plugin: %s", err))
return 1
}
c.Ui.Output(str)
return 0
}
func (c *PluginStatusCommand) csiFormatPlugins(plugs []*api.CSIPluginListStub) (string, error) {
// Sort the output by quota name
sort.Slice(plugs, func(i, j int) bool { return plugs[i].ID < plugs[j].ID })
if c.json || len(c.template) > 0 {
out, err := Format(c.json, c.template, plugs)
if err != nil {
return "", fmt.Errorf("format error: %v", err)
}
return out, nil
}
rows := make([]string, len(plugs)+1)
rows[0] = "ID|Provider|Controllers Healthy/Expected|Nodes Healthy/Expected"
for i, p := range plugs {
rows[i+1] = fmt.Sprintf("%s|%s|%d/%d|%d/%d",
limit(p.ID, c.length),
p.Provider,
p.ControllersHealthy,
p.ControllersExpected,
p.NodesHealthy,
p.NodesExpected,
)
}
return formatList(rows), nil
}
func (c *PluginStatusCommand) csiFormatPlugin(plug *api.CSIPlugin) (string, error) {
if c.json || len(c.template) > 0 {
out, err := Format(c.json, c.template, plug)
if err != nil {
return "", fmt.Errorf("format error: %v", err)
}
return out, nil
}
output := []string{
fmt.Sprintf("ID|%s", plug.ID),
fmt.Sprintf("Provider|%s", plug.Provider),
fmt.Sprintf("Version|%s", plug.Version),
fmt.Sprintf("Controllers Healthy|%d", plug.ControllersHealthy),
fmt.Sprintf("Controllers Expected|%d", len(plug.Controllers)),
fmt.Sprintf("Nodes Healthy|%d", plug.NodesHealthy),
fmt.Sprintf("Nodes Expected|%d", len(plug.Nodes)),
}
// Exit early
if c.short {
return formatKV(output), nil
}
// Format the allocs
banner := c.Colorize().Color("\n[bold]Allocations[reset]")
allocs := formatAllocListStubs(plug.Allocations, c.verbose, c.length)
full := []string{formatKV(output), banner, allocs}
return strings.Join(full, "\n"), nil
}

View File

@ -0,0 +1,57 @@
package command
import (
"testing"
"github.com/hashicorp/go-memdb"
"github.com/hashicorp/nomad/nomad"
"github.com/mitchellh/cli"
"github.com/posener/complete"
"github.com/stretchr/testify/require"
)
func TestPluginStatusCommand_Implements(t *testing.T) {
t.Parallel()
var _ cli.Command = &PluginStatusCommand{}
}
func TestPluginStatusCommand_Fails(t *testing.T) {
t.Parallel()
ui := new(cli.MockUi)
cmd := &PluginStatusCommand{Meta: Meta{Ui: ui}}
// Fails on misuse
code := cmd.Run([]string{"some", "bad", "args"})
require.Equal(t, 1, code)
out := ui.ErrorWriter.String()
require.Contains(t, out, commandErrorText(cmd))
ui.ErrorWriter.Reset()
}
func TestPluginStatusCommand_AutocompleteArgs(t *testing.T) {
t.Parallel()
srv, _, url := testServer(t, true, nil)
defer srv.Shutdown()
ui := new(cli.MockUi)
cmd := &PluginStatusCommand{Meta: Meta{Ui: ui, flagAddress: url}}
// Create a plugin
id := "long-plugin-id"
state := srv.Agent.Server().State()
cleanup := nomad.CreateTestCSIPlugin(state, id)
defer cleanup()
ws := memdb.NewWatchSet()
plug, err := state.CSIPluginByID(ws, id)
require.NoError(t, err)
prefix := plug.ID[:len(plug.ID)-5]
args := complete.Args{Last: prefix}
predictor := cmd.AutocompleteArgs()
res := predictor.Predict(args)
require.Equal(t, 1, len(res))
require.Equal(t, plug.ID, res[0])
}

View File

@ -162,6 +162,10 @@ func (c *StatusCommand) Run(args []string) int {
cmd = &NamespaceStatusCommand{Meta: c.Meta} cmd = &NamespaceStatusCommand{Meta: c.Meta}
case contexts.Quotas: case contexts.Quotas:
cmd = &QuotaStatusCommand{Meta: c.Meta} cmd = &QuotaStatusCommand{Meta: c.Meta}
case contexts.Plugins:
cmd = &PluginStatusCommand{Meta: c.Meta}
case contexts.Volumes:
cmd = &VolumeStatusCommand{Meta: c.Meta}
default: default:
c.Ui.Error(fmt.Sprintf("Unable to resolve ID: %q", id)) c.Ui.Error(fmt.Sprintf("Unable to resolve ID: %q", id))
return 1 return 1

46
command/volume.go Normal file
View File

@ -0,0 +1,46 @@
package command
import (
"strings"
"github.com/mitchellh/cli"
)
type VolumeCommand struct {
Meta
}
func (c *VolumeCommand) Help() string {
helpText := `
Usage: nomad volume <subcommand> [options]
volume groups commands that interact with volumes.
Register a new volume or update an existing volume:
$ nomad volume register <input>
Examine the status of a volume:
$ nomad volume status <id>
Deregister an unused volume:
$ nomad volume deregister <id>
Please see the individual subcommand help for detailed usage information.
`
return strings.TrimSpace(helpText)
}
func (c *VolumeCommand) Name() string {
return "volume"
}
func (c *VolumeCommand) Synopsis() string {
return "Interact with volumes"
}
func (c *VolumeCommand) Run(args []string) int {
return cli.RunResultHelp
}

View File

@ -0,0 +1,88 @@
package command
import (
"fmt"
"strings"
"github.com/hashicorp/nomad/api/contexts"
"github.com/posener/complete"
)
type VolumeDeregisterCommand struct {
Meta
}
func (c *VolumeDeregisterCommand) Help() string {
helpText := `
Usage: nomad volume deregister [options] <id>
Remove an unused volume from Nomad.
General Options:
` + generalOptionsUsage()
return strings.TrimSpace(helpText)
}
func (c *VolumeDeregisterCommand) AutocompleteFlags() complete.Flags {
return c.Meta.AutocompleteFlags(FlagSetClient)
}
func (c *VolumeDeregisterCommand) AutocompleteArgs() complete.Predictor {
return complete.PredictFunc(func(a complete.Args) []string {
client, err := c.Meta.Client()
if err != nil {
return nil
}
// When multiple volume types are implemented, this search should merge contexts
resp, _, err := client.Search().PrefixSearch(a.Last, contexts.Volumes, nil)
if err != nil {
return []string{}
}
return resp.Matches[contexts.Volumes]
})
}
func (c *VolumeDeregisterCommand) Synopsis() string {
return "Remove a volume"
}
func (c *VolumeDeregisterCommand) Name() string { return "volume deregister" }
func (c *VolumeDeregisterCommand) Run(args []string) int {
flags := c.Meta.FlagSet(c.Name(), FlagSetClient)
flags.Usage = func() { c.Ui.Output(c.Help()) }
if err := flags.Parse(args); err != nil {
c.Ui.Error(fmt.Sprintf("Error parsing arguments %s", err))
return 1
}
// Check that we get exactly one argument
args = flags.Args()
if l := len(args); l != 1 {
c.Ui.Error("This command takes one argument: <id>")
c.Ui.Error(commandErrorText(c))
return 1
}
volID := args[0]
// Get the HTTP client
client, err := c.Meta.Client()
if err != nil {
c.Ui.Error(fmt.Sprintf("Error initializing client: %s", err))
return 1
}
// Deregister only works on CSI volumes, but could be extended to support other
// network interfaces or host volumes
err = client.CSIVolumes().Deregister(volID, nil)
if err != nil {
c.Ui.Error(fmt.Sprintf("Error deregistering volume: %s", err))
return 1
}
return 0
}

130
command/volume_register.go Normal file
View File

@ -0,0 +1,130 @@
package command
import (
"fmt"
"io/ioutil"
"os"
"strings"
"github.com/hashicorp/hcl"
"github.com/hashicorp/hcl/hcl/ast"
"github.com/posener/complete"
)
type VolumeRegisterCommand struct {
Meta
}
func (c *VolumeRegisterCommand) Help() string {
helpText := `
Usage: nomad volume register [options] <input>
Creates or updates a volume in Nomad. The volume must exist on the remote
storage provider before it can be used by a task.
If the supplied path is "-" the volume file is read from stdin. Otherwise, it
is read from the file at the supplied path.
General Options:
` + generalOptionsUsage()
return strings.TrimSpace(helpText)
}
func (c *VolumeRegisterCommand) AutocompleteFlags() complete.Flags {
return c.Meta.AutocompleteFlags(FlagSetClient)
}
func (c *VolumeRegisterCommand) AutocompleteArgs() complete.Predictor {
return complete.PredictFiles("*")
}
func (c *VolumeRegisterCommand) Synopsis() string {
return "Create or update a volume"
}
func (c *VolumeRegisterCommand) Name() string { return "volume register" }
func (c *VolumeRegisterCommand) Run(args []string) int {
flags := c.Meta.FlagSet(c.Name(), FlagSetClient)
flags.Usage = func() { c.Ui.Output(c.Help()) }
if err := flags.Parse(args); err != nil {
c.Ui.Error(fmt.Sprintf("Error parsing arguments %s", err))
return 1
}
// Check that we get exactly one argument
args = flags.Args()
if l := len(args); l != 1 {
c.Ui.Error("This command takes one argument: <input>")
c.Ui.Error(commandErrorText(c))
return 1
}
// Read the file contents
file := args[0]
var rawVolume []byte
var err error
if file == "-" {
rawVolume, err = ioutil.ReadAll(os.Stdin)
if err != nil {
c.Ui.Error(fmt.Sprintf("Failed to read stdin: %v", err))
return 1
}
} else {
rawVolume, err = ioutil.ReadFile(file)
if err != nil {
c.Ui.Error(fmt.Sprintf("Failed to read file: %v", err))
return 1
}
}
ast, volType, err := parseVolumeType(string(rawVolume))
if err != nil {
c.Ui.Error(fmt.Sprintf("Error parsing the volume type: %s", err))
return 1
}
volType = strings.ToLower(volType)
// Get the HTTP client
client, err := c.Meta.Client()
if err != nil {
c.Ui.Error(fmt.Sprintf("Error initializing client: %s", err))
return 1
}
switch volType {
case "csi":
code := c.csiRegister(client, ast)
if code != 0 {
return code
}
default:
c.Ui.Error(fmt.Sprintf("Error unknown volume type: %s", volType))
return 1
}
return 0
}
// parseVolume is used to parse the quota specification from HCL
func parseVolumeType(input string) (*ast.File, string, error) {
// Parse the AST first
ast, err := hcl.Parse(input)
if err != nil {
return nil, "", fmt.Errorf("parse error: %v", err)
}
// Decode the type, so we can dispatch on it
dispatch := &struct {
T string `hcl:"type"`
}{}
err = hcl.DecodeObject(dispatch, ast)
if err != nil {
return nil, "", fmt.Errorf("dispatch error: %v", err)
}
return ast, dispatch.T, nil
}

View File

@ -0,0 +1,44 @@
package command
import (
"fmt"
"github.com/hashicorp/hcl"
"github.com/hashicorp/hcl/hcl/ast"
"github.com/hashicorp/nomad/api"
"github.com/hashicorp/nomad/helper"
)
func (c *VolumeRegisterCommand) csiRegister(client *api.Client, ast *ast.File) int {
vol, err := csiDecodeVolume(ast)
if err != nil {
c.Ui.Error(fmt.Sprintf("Error decoding the volume definition: %s", err))
return 1
}
_, err = client.CSIVolumes().Register(vol, nil)
if err != nil {
c.Ui.Error(fmt.Sprintf("Error registering volume: %s", err))
return 1
}
return 0
}
// parseVolume is used to parse the quota specification from HCL
func csiDecodeVolume(input *ast.File) (*api.CSIVolume, error) {
output := &api.CSIVolume{}
err := hcl.DecodeObject(output, input)
if err != nil {
return nil, err
}
// api.CSIVolume doesn't have the type field, it's used only for dispatch in
// parseVolumeType
helper.RemoveEqualFold(&output.ExtraKeysHCL, "type")
err = helper.UnusedKeys(output)
if err != nil {
return nil, err
}
return output, nil
}

View File

@ -0,0 +1,97 @@
package command
import (
"testing"
"github.com/hashicorp/hcl"
"github.com/hashicorp/nomad/api"
"github.com/stretchr/testify/require"
)
func TestVolumeDispatchParse(t *testing.T) {
t.Parallel()
cases := []struct {
hcl string
t string
err string
}{{
hcl: `
type = "foo"
rando = "bar"
`,
t: "foo",
err: "",
}, {
hcl: `{"id": "foo", "type": "foo", "other": "bar"}`,
t: "foo",
err: "",
}}
for _, c := range cases {
t.Run(c.hcl, func(t *testing.T) {
_, s, err := parseVolumeType(c.hcl)
require.Equal(t, c.t, s)
if c.err == "" {
require.NoError(t, err)
} else {
require.Contains(t, err.Error(), c.err)
}
})
}
}
func TestCSIVolumeParse(t *testing.T) {
t.Parallel()
cases := []struct {
hcl string
q *api.CSIVolume
err string
}{{
hcl: `
id = "foo"
type = "csi"
namespace = "n"
access_mode = "single-node-writer"
attachment_mode = "file-system"
plugin_id = "p"
`,
q: &api.CSIVolume{
ID: "foo",
Namespace: "n",
AccessMode: "single-node-writer",
AttachmentMode: "file-system",
PluginID: "p",
},
err: "",
}, {
hcl: `
{"id": "foo", "namespace": "n", "type": "csi", "access_mode": "single-node-writer", "attachment_mode": "file-system",
"plugin_id": "p"}
`,
q: &api.CSIVolume{
ID: "foo",
Namespace: "n",
AccessMode: "single-node-writer",
AttachmentMode: "file-system",
PluginID: "p",
},
err: "",
}}
for _, c := range cases {
t.Run(c.hcl, func(t *testing.T) {
ast, err := hcl.ParseString(c.hcl)
require.NoError(t, err)
vol, err := csiDecodeVolume(ast)
require.Equal(t, c.q, vol)
if c.err == "" {
require.NoError(t, err)
} else {
require.Contains(t, err.Error(), c.err)
}
})
}
}

134
command/volume_status.go Normal file
View File

@ -0,0 +1,134 @@
package command
import (
"fmt"
"strings"
"github.com/hashicorp/nomad/api/contexts"
"github.com/posener/complete"
)
type VolumeStatusCommand struct {
Meta
length int
short bool
verbose bool
json bool
template string
}
func (c *VolumeStatusCommand) Help() string {
helpText := `
Usage: nomad volume status [options] <id>
Display status information about a CSI volume. If no volume id is given, a
list of all volumes will be displayed.
General Options:
` + generalOptionsUsage() + `
Status Options:
-type <type>
List only volumes of type <type>.
-short
Display short output. Used only when a single volume is being
queried, and drops verbose information about allocations.
-verbose
Display full allocation information.
-json
Output the allocation in its JSON format.
-t
Format and display allocation using a Go template.
`
return strings.TrimSpace(helpText)
}
func (c *VolumeStatusCommand) Synopsis() string {
return "Display status information about a volume"
}
func (c *VolumeStatusCommand) AutocompleteFlags() complete.Flags {
return mergeAutocompleteFlags(c.Meta.AutocompleteFlags(FlagSetClient),
complete.Flags{
"-type": predictVolumeType,
"-short": complete.PredictNothing,
"-verbose": complete.PredictNothing,
"-json": complete.PredictNothing,
"-t": complete.PredictAnything,
})
}
func (c *VolumeStatusCommand) AutocompleteArgs() complete.Predictor {
return complete.PredictFunc(func(a complete.Args) []string {
client, err := c.Meta.Client()
if err != nil {
return nil
}
resp, _, err := client.Search().PrefixSearch(a.Last, contexts.Volumes, nil)
if err != nil {
return []string{}
}
return resp.Matches[contexts.Volumes]
})
}
func (c *VolumeStatusCommand) Name() string { return "volume status" }
func (c *VolumeStatusCommand) Run(args []string) int {
var typeArg string
flags := c.Meta.FlagSet(c.Name(), FlagSetClient)
flags.Usage = func() { c.Ui.Output(c.Help()) }
flags.StringVar(&typeArg, "type", "", "")
flags.BoolVar(&c.short, "short", false, "")
flags.BoolVar(&c.verbose, "verbose", false, "")
flags.BoolVar(&c.json, "json", false, "")
flags.StringVar(&c.template, "t", "", "")
if err := flags.Parse(args); err != nil {
c.Ui.Error(fmt.Sprintf("Error parsing arguments %s", err))
return 1
}
// Check that we either got no arguments or exactly one
args = flags.Args()
if len(args) > 1 {
c.Ui.Error("This command takes either no arguments or one: <id>")
c.Ui.Error(commandErrorText(c))
return 1
}
// Truncate the id unless full length is requested
c.length = shortId
if c.verbose {
c.length = fullId
}
// Get the HTTP client
client, err := c.Meta.Client()
if err != nil {
c.Ui.Error(fmt.Sprintf("Error initializing client: %s", err))
return 1
}
id := ""
if len(args) == 1 {
id = args[0]
}
code := c.csiStatus(client, id)
if code != 0 {
return code
}
// Extend this section with other volume implementations
return 0
}

View File

@ -0,0 +1,191 @@
package command
import (
"fmt"
"sort"
"strings"
"github.com/hashicorp/nomad/api"
"github.com/hashicorp/nomad/nomad/structs"
)
func (c *VolumeStatusCommand) csiBanner() {
if !(c.json || len(c.template) > 0) {
c.Ui.Output(c.Colorize().Color("[bold]Container Storage Interface[reset]"))
}
}
func (c *VolumeStatusCommand) csiStatus(client *api.Client, id string) int {
// Invoke list mode if no volume id
if id == "" {
c.csiBanner()
vols, _, err := client.CSIVolumes().List(nil)
if err != nil {
c.Ui.Error(fmt.Sprintf("Error querying volumes: %s", err))
return 1
}
if len(vols) == 0 {
// No output if we have no volumes
c.Ui.Error("No CSI volumes")
} else {
str, err := c.csiFormatVolumes(vols)
if err != nil {
c.Ui.Error(fmt.Sprintf("Error formatting: %s", err))
return 1
}
c.Ui.Output(str)
}
return 0
}
// Try querying the volume
vol, _, err := client.CSIVolumes().Info(id, nil)
if err != nil {
c.Ui.Error(fmt.Sprintf("Error querying volume: %s", err))
return 1
}
str, err := c.formatBasic(vol)
if err != nil {
c.Ui.Error(fmt.Sprintf("Error formatting volume: %s", err))
return 1
}
c.Ui.Output(str)
return 0
}
func (c *VolumeStatusCommand) csiFormatVolumes(vols []*api.CSIVolumeListStub) (string, error) {
// Sort the output by volume id
sort.Slice(vols, func(i, j int) bool { return vols[i].ID < vols[j].ID })
if c.json || len(c.template) > 0 {
out, err := Format(c.json, c.template, vols)
if err != nil {
return "", fmt.Errorf("format error: %v", err)
}
return out, nil
}
rows := make([]string, len(vols)+1)
rows[0] = "ID|Name|Plugin ID|Schedulable|Access Mode"
for i, v := range vols {
rows[i+1] = fmt.Sprintf("%s|%s|%s|%t|%s",
limit(v.ID, c.length),
v.Name,
v.PluginID,
v.Schedulable,
v.AccessMode,
)
}
return formatList(rows), nil
}
func (c *VolumeStatusCommand) formatBasic(vol *api.CSIVolume) (string, error) {
if c.json || len(c.template) > 0 {
out, err := Format(c.json, c.template, vol)
if err != nil {
return "", fmt.Errorf("format error: %v", err)
}
return out, nil
}
output := []string{
fmt.Sprintf("ID|%s", vol.ID),
fmt.Sprintf("Name|%s", vol.Name),
fmt.Sprintf("External ID|%s", vol.ExternalID),
fmt.Sprintf("Plugin ID|%s", vol.PluginID),
fmt.Sprintf("Provider|%s", vol.Provider),
fmt.Sprintf("Version|%s", vol.ProviderVersion),
fmt.Sprintf("Schedulable|%t", vol.Schedulable),
fmt.Sprintf("Controllers Healthy|%d", vol.ControllersHealthy),
fmt.Sprintf("Controllers Expected|%d", vol.ControllersExpected),
fmt.Sprintf("Nodes Healthy|%d", vol.NodesHealthy),
fmt.Sprintf("Nodes Expected|%d", vol.NodesExpected),
fmt.Sprintf("Access Mode|%s", vol.AccessMode),
fmt.Sprintf("Attachment Mode|%s", vol.AttachmentMode),
fmt.Sprintf("Mount Options|%s", csiVolMountOption(vol.MountOptions, nil)),
fmt.Sprintf("Namespace|%s", vol.Namespace),
}
// Exit early
if c.short {
return formatKV(output), nil
}
// Format the allocs
banner := c.Colorize().Color("\n[bold]Allocations[reset]")
allocs := formatAllocListStubs(vol.Allocations, c.verbose, c.length)
full := []string{formatKV(output), banner, allocs}
return strings.Join(full, "\n"), nil
}
func (c *VolumeStatusCommand) formatTopologies(vol *api.CSIVolume) string {
var out []string
// Find the union of all the keys
head := map[string]string{}
for _, t := range vol.Topologies {
for key := range t.Segments {
if _, ok := head[key]; !ok {
head[key] = ""
}
}
}
// Append the header
var line []string
for key := range head {
line = append(line, key)
}
out = append(out, strings.Join(line, " "))
// Append each topology
for _, t := range vol.Topologies {
line = []string{}
for key := range head {
line = append(line, t.Segments[key])
}
out = append(out, strings.Join(line, " "))
}
return strings.Join(out, "\n")
}
func csiVolMountOption(volume, request *api.CSIMountOptions) string {
var req, opts *structs.CSIMountOptions
if request != nil {
req = &structs.CSIMountOptions{
FSType: request.FSType,
MountFlags: request.MountFlags,
}
}
if volume == nil {
opts = req
} else {
opts = &structs.CSIMountOptions{
FSType: volume.FSType,
MountFlags: volume.MountFlags,
}
opts.Merge(req)
}
if opts == nil {
return "<none>"
}
var out string
if opts.FSType != "" {
out = fmt.Sprintf("fs_type: %s", opts.FSType)
}
if len(opts.MountFlags) > 0 {
out = fmt.Sprintf("%s flags: %s", out, strings.Join(opts.MountFlags, ", "))
}
return out
}

View File

@ -0,0 +1,58 @@
package command
import (
"testing"
"github.com/hashicorp/nomad/helper/uuid"
"github.com/hashicorp/nomad/nomad/structs"
"github.com/mitchellh/cli"
"github.com/posener/complete"
"github.com/stretchr/testify/require"
)
func TestCSIVolumeStatusCommand_Implements(t *testing.T) {
t.Parallel()
var _ cli.Command = &VolumeStatusCommand{}
}
func TestCSIVolumeStatusCommand_Fails(t *testing.T) {
t.Parallel()
ui := new(cli.MockUi)
cmd := &VolumeStatusCommand{Meta: Meta{Ui: ui}}
// Fails on misuse
code := cmd.Run([]string{"some", "bad", "args"})
require.Equal(t, 1, code)
out := ui.ErrorWriter.String()
require.Contains(t, out, commandErrorText(cmd))
ui.ErrorWriter.Reset()
}
func TestCSIVolumeStatusCommand_AutocompleteArgs(t *testing.T) {
t.Parallel()
srv, _, url := testServer(t, true, nil)
defer srv.Shutdown()
ui := new(cli.MockUi)
cmd := &VolumeStatusCommand{Meta: Meta{Ui: ui, flagAddress: url}}
state := srv.Agent.Server().State()
vol := &structs.CSIVolume{
ID: uuid.Generate(),
Namespace: "default",
PluginID: "glade",
}
require.NoError(t, state.CSIVolumeRegister(1000, []*structs.CSIVolume{vol}))
prefix := vol.ID[:len(vol.ID)-5]
args := complete.Args{Last: prefix}
predictor := cmd.AutocompleteArgs()
res := predictor.Predict(args)
require.Equal(t, 1, len(res))
require.Equal(t, vol.ID, res[0])
}

View File

@ -19,6 +19,7 @@ CLI (command/) -> API Client (api/) -> HTTP API (command/agent) -> RPC (nomad/)
* [ ] Implement `-verbose` (expands truncated UUIDs, adds other detail) * [ ] Implement `-verbose` (expands truncated UUIDs, adds other detail)
* [ ] Update help text * [ ] Update help text
* [ ] Implement and test new HTTP endpoint in `command/agent/<command>_endpoint.go` * [ ] Implement and test new HTTP endpoint in `command/agent/<command>_endpoint.go`
* [ ] Register new URL paths in `command/agent/http.go`
* [ ] Implement and test new RPC endpoint in `nomad/<command>_endpoint.go` * [ ] Implement and test new RPC endpoint in `nomad/<command>_endpoint.go`
* [ ] Implement and test new Client RPC endpoint in * [ ] Implement and test new Client RPC endpoint in
`client/<command>_endpoint.go` (For client endpoints like Filesystem only) `client/<command>_endpoint.go` (For client endpoints like Filesystem only)

View File

@ -7,19 +7,27 @@ Prefer adding a new message to changing any existing RPC messages.
* [ ] `Request` struct and `*RequestType` constant in * [ ] `Request` struct and `*RequestType` constant in
`nomad/structs/structs.go`. Append the constant, old constant `nomad/structs/structs.go`. Append the constant, old constant
values must remain unchanged values must remain unchanged
* [ ] In `nomad/fsm.go`, add a dispatch case to the switch statement in `Apply`
* [ ] In `nomad/fsm.go`, add a dispatch case to the switch statement in `(n *nomadFSM) Apply`
* `*nomadFSM` method to decode the request and call the state method * `*nomadFSM` method to decode the request and call the state method
* [ ] State method for modifying objects in a `Txn` in `nomad/state/state_store.go` * [ ] State method for modifying objects in a `Txn` in `nomad/state/state_store.go`
* `nomad/state/state_store_test.go` * `nomad/state/state_store_test.go`
* [ ] Handler for the request in `nomad/foo_endpoint.go` * [ ] Handler for the request in `nomad/foo_endpoint.go`
* RPCs are resolved by matching the method name for bound structs * RPCs are resolved by matching the method name for bound structs
[net/rpc](https://golang.org/pkg/net/rpc/) [net/rpc](https://golang.org/pkg/net/rpc/)
* Check ACLs for security, list endpoints filter by ACL * Check ACLs for security, list endpoints filter by ACL
* Register new RPC struct in `nomad/server.go`
* Check ACLs to enforce security
* Wrapper for the HTTP request in `command/agent/foo_endpoint.go` * Wrapper for the HTTP request in `command/agent/foo_endpoint.go`
* Backwards compatibility requires a new endpoint, an upgraded * Backwards compatibility requires a new endpoint, an upgraded
client or server may be forwarding this request to an old server, client or server may be forwarding this request to an old server,
without support for the new RPC without support for the new RPC
* RPCs triggered by an internal process may not need support * RPCs triggered by an internal process may not need support
* Check ACLs as an optimization
* [ ] `nomad/core_sched.go` sends many RPCs * [ ] `nomad/core_sched.go` sends many RPCs
* `ServersMeetMinimumVersion` asserts that the server cluster is * `ServersMeetMinimumVersion` asserts that the server cluster is
upgraded, so use this to gaurd sending the new RPC, else send the old RPC upgraded, so use this to gaurd sending the new RPC, else send the old RPC

1
e2e/.gitignore vendored
View File

@ -1 +1,2 @@
provisioning.json provisioning.json
csi/input/volumes.json

251
e2e/csi/csi.go Normal file
View File

@ -0,0 +1,251 @@
package csi
import (
"bytes"
"context"
"encoding/json"
"io/ioutil"
"os"
"time"
"github.com/hashicorp/nomad/api"
"github.com/hashicorp/nomad/e2e/e2eutil"
"github.com/hashicorp/nomad/e2e/framework"
"github.com/hashicorp/nomad/helper/uuid"
"github.com/stretchr/testify/require"
)
type CSIVolumesTest struct {
framework.TC
jobIds []string
volumeIDs *volumeConfig
}
func init() {
framework.AddSuites(&framework.TestSuite{
Component: "CSI",
CanRunLocal: true,
Consul: false,
Cases: []framework.TestCase{
new(CSIVolumesTest),
},
})
}
type volumeConfig struct {
EBSVolumeID string `json:"ebs_volume"`
EFSVolumeID string `json:"efs_volume"`
}
func (tc *CSIVolumesTest) BeforeAll(f *framework.F) {
t := f.T()
// The volume IDs come from the external provider, so we need
// to read the configuration out of our Terraform output.
rawjson, err := ioutil.ReadFile("csi/input/volumes.json")
if err != nil {
t.Skip("volume ID configuration not found, try running 'terraform output volumes > ../csi/input/volumes.json'")
}
volumeIDs := &volumeConfig{}
err = json.Unmarshal(rawjson, volumeIDs)
if err != nil {
t.Fatal("volume ID configuration could not be read")
}
tc.volumeIDs = volumeIDs
// Ensure cluster has leader and at least two client
// nodes in a ready state before running tests
e2eutil.WaitForLeader(t, tc.Nomad())
e2eutil.WaitForNodesReady(t, tc.Nomad(), 2)
}
// TestEBSVolumeClaim launches AWS EBS plugins and registers an EBS volume
// as a Nomad CSI volume. We then deploy a job that writes to the volume,
// stop that job, and reuse the volume for another job which should be able
// to read the data written by the first job.
func (tc *CSIVolumesTest) TestEBSVolumeClaim(f *framework.F) {
t := f.T()
require := require.New(t)
nomadClient := tc.Nomad()
uuid := uuid.Generate()
// deploy the controller plugin job
controllerJobID := "aws-ebs-plugin-controller-" + uuid[0:8]
tc.jobIds = append(tc.jobIds, controllerJobID)
e2eutil.RegisterAndWaitForAllocs(t, nomadClient,
"csi/input/plugin-aws-ebs-controller.nomad", controllerJobID, "")
// deploy the node plugins job
nodesJobID := "aws-ebs-plugin-nodes-" + uuid[0:8]
tc.jobIds = append(tc.jobIds, nodesJobID)
e2eutil.RegisterAndWaitForAllocs(t, nomadClient,
"csi/input/plugin-aws-ebs-nodes.nomad", nodesJobID, "")
// wait for plugin to become healthy
require.Eventually(func() bool {
plugin, _, err := nomadClient.CSIPlugins().Info("aws-ebs0", nil)
if err != nil {
return false
}
if plugin.ControllersHealthy != 1 || plugin.NodesHealthy < 2 {
return false
}
return true
// TODO(tgross): cut down this time after fixing
// https://github.com/hashicorp/nomad/issues/7296
}, 90*time.Second, 5*time.Second)
// register a volume
volID := "ebs-vol0"
vol := &api.CSIVolume{
ID: volID,
Name: volID,
ExternalID: tc.volumeIDs.EBSVolumeID,
AccessMode: "single-node-writer",
AttachmentMode: "file-system",
PluginID: "aws-ebs0",
}
_, err := nomadClient.CSIVolumes().Register(vol, nil)
require.NoError(err)
defer nomadClient.CSIVolumes().Deregister(volID, nil)
// deploy a job that writes to the volume
writeJobID := "write-ebs-" + uuid[0:8]
tc.jobIds = append(tc.jobIds, writeJobID)
writeAllocs := e2eutil.RegisterAndWaitForAllocs(t, nomadClient,
"csi/input/use-ebs-volume.nomad", writeJobID, "")
writeAllocID := writeAllocs[0].ID
e2eutil.WaitForAllocRunning(t, nomadClient, writeAllocID)
// read data from volume and assert the writer wrote a file to it
writeAlloc, _, err := nomadClient.Allocations().Info(writeAllocID, nil)
require.NoError(err)
expectedPath := "/local/test/" + writeAllocID
_, err = readFile(nomadClient, writeAlloc, expectedPath)
require.NoError(err)
// Shutdown the writer so we can run a reader.
// we could mount the EBS volume with multi-attach, but we
// want this test to exercise the unpublish workflow.
nomadClient.Jobs().Deregister(writeJobID, true, nil)
// deploy a job so we can read from the volume
readJobID := "read-ebs-" + uuid[0:8]
tc.jobIds = append(tc.jobIds, readJobID)
readAllocs := e2eutil.RegisterAndWaitForAllocs(t, nomadClient,
"csi/input/use-ebs-volume.nomad", readJobID, "")
readAllocID := readAllocs[0].ID
e2eutil.WaitForAllocRunning(t, nomadClient, readAllocID)
// ensure we clean up claim before we deregister volumes
defer nomadClient.Jobs().Deregister(readJobID, true, nil)
// read data from volume and assert the writer wrote a file to it
readAlloc, _, err := nomadClient.Allocations().Info(readAllocID, nil)
require.NoError(err)
_, err = readFile(nomadClient, readAlloc, expectedPath)
require.NoError(err)
}
// TestEFSVolumeClaim launches AWS EFS plugins and registers an EFS volume
// as a Nomad CSI volume. We then deploy a job that writes to the volume,
// and share the volume with another job which should be able to read the
// data written by the first job.
func (tc *CSIVolumesTest) TestEFSVolumeClaim(f *framework.F) {
t := f.T()
require := require.New(t)
nomadClient := tc.Nomad()
uuid := uuid.Generate()
// deploy the node plugins job (no need for a controller for EFS)
nodesJobID := "aws-efs-plugin-nodes-" + uuid[0:8]
tc.jobIds = append(tc.jobIds, nodesJobID)
e2eutil.RegisterAndWaitForAllocs(t, nomadClient,
"csi/input/plugin-aws-efs-nodes.nomad", nodesJobID, "")
// wait for plugin to become healthy
require.Eventually(func() bool {
plugin, _, err := nomadClient.CSIPlugins().Info("aws-efs0", nil)
if err != nil {
return false
}
if plugin.NodesHealthy < 2 {
return false
}
return true
// TODO(tgross): cut down this time after fixing
// https://github.com/hashicorp/nomad/issues/7296
}, 90*time.Second, 5*time.Second)
// register a volume
volID := "efs-vol0"
vol := &api.CSIVolume{
ID: volID,
Name: volID,
ExternalID: tc.volumeIDs.EFSVolumeID,
AccessMode: "single-node-writer",
AttachmentMode: "file-system",
PluginID: "aws-efs0",
}
_, err := nomadClient.CSIVolumes().Register(vol, nil)
require.NoError(err)
defer nomadClient.CSIVolumes().Deregister(volID, nil)
// deploy a job that writes to the volume
writeJobID := "write-efs-" + uuid[0:8]
writeAllocs := e2eutil.RegisterAndWaitForAllocs(t, nomadClient,
"csi/input/use-efs-volume-write.nomad", writeJobID, "")
writeAllocID := writeAllocs[0].ID
e2eutil.WaitForAllocRunning(t, nomadClient, writeAllocID)
// read data from volume and assert the writer wrote a file to it
writeAlloc, _, err := nomadClient.Allocations().Info(writeAllocID, nil)
require.NoError(err)
expectedPath := "/local/test/" + writeAllocID
_, err = readFile(nomadClient, writeAlloc, expectedPath)
require.NoError(err)
// Shutdown the writer so we can run a reader.
// although EFS should support multiple readers, the plugin
// does not.
nomadClient.Jobs().Deregister(writeJobID, true, nil)
// deploy a job that reads from the volume.
readJobID := "read-efs-" + uuid[0:8]
readAllocs := e2eutil.RegisterAndWaitForAllocs(t, nomadClient,
"csi/input/use-efs-volume-read.nomad", readJobID, "")
defer nomadClient.Jobs().Deregister(readJobID, true, nil)
e2eutil.WaitForAllocRunning(t, nomadClient, readAllocs[0].ID)
// read data from volume and assert the writer wrote a file to it
readAlloc, _, err := nomadClient.Allocations().Info(readAllocs[0].ID, nil)
require.NoError(err)
_, err = readFile(nomadClient, readAlloc, expectedPath)
require.NoError(err)
}
func (tc *CSIVolumesTest) AfterEach(f *framework.F) {
nomadClient := tc.Nomad()
jobs := nomadClient.Jobs()
// Stop all jobs in test
for _, id := range tc.jobIds {
jobs.Deregister(id, true, nil)
}
// Garbage collect
nomadClient.System().GarbageCollect()
}
// TODO(tgross): replace this w/ AllocFS().Stat() after
// https://github.com/hashicorp/nomad/issues/7365 is fixed
func readFile(client *api.Client, alloc *api.Allocation, path string) (bytes.Buffer, error) {
ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second)
defer cancelFn()
var stdout, stderr bytes.Buffer
_, err := client.Allocations().Exec(ctx,
alloc, "task", false,
[]string{"cat", path},
os.Stdin, &stdout, &stderr,
make(chan api.TerminalSize), nil)
return stdout, err
}

View File

@ -0,0 +1,40 @@
# jobspec for running CSI plugin for AWS EBS, derived from
# the kubernetes manifests found at
# https://github.com/kubernetes-sigs/aws-ebs-csi-driver/tree/master/deploy/kubernetes
job "plugin-aws-ebs-controller" {
datacenters = ["dc1"]
group "controller" {
task "plugin" {
driver = "docker"
config {
image = "amazon/aws-ebs-csi-driver:latest"
args = [
"controller",
"--endpoint=unix://csi/csi.sock",
"--logtostderr",
"--v=5",
]
# note: plugins running as controllers don't
# need to run as privileged tasks
}
csi_plugin {
id = "aws-ebs0"
type = "controller"
mount_dir = "/csi"
}
# note: there's no upstream guidance on resource usage so
# this is a best guess until we profile it in heavy use
resources {
cpu = 500
memory = 256
}
}
}
}

View File

@ -0,0 +1,43 @@
# jobspec for running CSI plugin for AWS EBS, derived from
# the kubernetes manifests found at
# https://github.com/kubernetes-sigs/aws-ebs-csi-driver/tree/master/deploy/kubernetes
job "plugin-aws-ebs-nodes" {
datacenters = ["dc1"]
# you can run node plugins as service jobs as well, but this ensures
# that all nodes in the DC have a copy.
type = "system"
group "nodes" {
task "plugin" {
driver = "docker"
config {
image = "amazon/aws-ebs-csi-driver:latest"
args = [
"node",
"--endpoint=unix://csi/csi.sock",
"--logtostderr",
"--v=5",
]
privileged = true
}
csi_plugin {
id = "aws-ebs0"
type = "node"
mount_dir = "/csi"
}
# note: there's no upstream guidance on resource usage so
# this is a best guess until we profile it in heavy use
resources {
cpu = 500
memory = 256
}
}
}
}

View File

@ -0,0 +1,45 @@
# jobspec for running CSI plugin for AWS EFS, derived from
# the kubernetes manifests found at
# https://github.com/kubernetes-sigs/aws-efs-csi-driver/tree/master/deploy/kubernetes
job "plugin-aws-efs-nodes" {
datacenters = ["dc1"]
# you can run node plugins as service jobs as well, but this ensures
# that all nodes in the DC have a copy.
type = "system"
group "nodes" {
task "plugin" {
driver = "docker"
config {
image = "amazon/aws-efs-csi-driver:latest"
# note: the EFS driver doesn't seem to respect the --endpoint
# flag and always sets up the listener at '/tmp/csi.sock'
args = [
"node",
"--endpoint=unix://tmp/csi.sock",
"--logtostderr",
"--v=5",
]
privileged = true
}
csi_plugin {
id = "aws-efs0"
type = "node"
mount_dir = "/tmp"
}
# note: there's no upstream guidance on resource usage so
# this is a best guess until we profile it in heavy use
resources {
cpu = 500
memory = 256
}
}
}
}

View File

@ -0,0 +1,32 @@
# a job that mounts an EBS volume and writes its job ID as a file
job "use-ebs-volume" {
datacenters = ["dc1"]
group "group" {
volume "test" {
type = "csi"
source = "ebs-vol0"
}
task "task" {
driver = "docker"
config {
image = "busybox:1"
command = "/bin/sh"
args = ["-c", "touch /local/test/${NOMAD_ALLOC_ID}; sleep 3600"]
}
volume_mount {
volume = "test"
destination = "${NOMAD_TASK_DIR}/test"
read_only = false
}
resources {
cpu = 500
memory = 128
}
}
}
}

View File

@ -0,0 +1,33 @@
# a job that mounts the EFS volume and sleeps, so that we can
# read its mounted file system remotely
job "use-efs-volume" {
datacenters = ["dc1"]
group "group" {
volume "test" {
type = "csi"
source = "efs-vol0"
}
task "task" {
driver = "docker"
config {
image = "busybox:1"
command = "/bin/sh"
args = ["-c", "sleep 3600"]
}
volume_mount {
volume = "test"
destination = "${NOMAD_TASK_DIR}/test"
read_only = true
}
resources {
cpu = 500
memory = 128
}
}
}
}

View File

@ -0,0 +1,32 @@
# a job that mounts an EFS volume and writes its job ID as a file
job "use-efs-volume" {
datacenters = ["dc1"]
group "group" {
volume "test" {
type = "csi"
source = "efs-vol0"
}
task "task" {
driver = "docker"
config {
image = "busybox:1"
command = "/bin/sh"
args = ["-c", "touch /local/test/${NOMAD_ALLOC_ID}; sleep 3600"]
}
volume_mount {
volume = "test"
destination = "${NOMAD_TASK_DIR}/test"
read_only = false
}
resources {
cpu = 500
memory = 128
}
}
}
}

View File

@ -13,6 +13,7 @@ import (
_ "github.com/hashicorp/nomad/e2e/connect" _ "github.com/hashicorp/nomad/e2e/connect"
_ "github.com/hashicorp/nomad/e2e/consul" _ "github.com/hashicorp/nomad/e2e/consul"
_ "github.com/hashicorp/nomad/e2e/consultemplate" _ "github.com/hashicorp/nomad/e2e/consultemplate"
_ "github.com/hashicorp/nomad/e2e/csi"
_ "github.com/hashicorp/nomad/e2e/deployment" _ "github.com/hashicorp/nomad/e2e/deployment"
_ "github.com/hashicorp/nomad/e2e/example" _ "github.com/hashicorp/nomad/e2e/example"
_ "github.com/hashicorp/nomad/e2e/hostvolumes" _ "github.com/hashicorp/nomad/e2e/hostvolumes"

View File

@ -48,6 +48,7 @@ data "aws_iam_policy_document" "auto_discover_cluster" {
"ec2:DescribeTags", "ec2:DescribeTags",
"ec2:DescribeVolume*", "ec2:DescribeVolume*",
"ec2:AttachVolume", "ec2:AttachVolume",
"ec2:DetachVolume",
"autoscaling:DescribeAutoScalingGroups", "autoscaling:DescribeAutoScalingGroups",
] ]
resources = ["*"] resources = ["*"]

View File

@ -9,6 +9,15 @@ export NOMAD_E2E=1
EOM EOM
} }
output "volumes" {
description = "get volume IDs needed to register volumes for CSI testing."
value = jsonencode(
{
"ebs_volume" : aws_ebs_volume.csi.id,
"efs_volume" : aws_efs_file_system.csi.id,
})
}
output "provisioning" { output "provisioning" {
description = "output to a file to be use w/ E2E framework -provision.terraform" description = "output to a file to be use w/ E2E framework -provision.terraform"
value = jsonencode( value = jsonencode(

View File

@ -3,7 +3,9 @@ package helper
import ( import (
"crypto/sha512" "crypto/sha512"
"fmt" "fmt"
"reflect"
"regexp" "regexp"
"strings"
"time" "time"
multierror "github.com/hashicorp/go-multierror" multierror "github.com/hashicorp/go-multierror"
@ -387,3 +389,75 @@ func CheckHCLKeys(node ast.Node, valid []string) error {
return result return result
} }
// UnusedKeys returns a pretty-printed error if any `hcl:",unusedKeys"` is not empty
func UnusedKeys(obj interface{}) error {
val := reflect.ValueOf(obj)
if val.Kind() == reflect.Ptr {
val = reflect.Indirect(val)
}
return unusedKeysImpl([]string{}, val)
}
func unusedKeysImpl(path []string, val reflect.Value) error {
stype := val.Type()
for i := 0; i < stype.NumField(); i++ {
ftype := stype.Field(i)
fval := val.Field(i)
tags := strings.Split(ftype.Tag.Get("hcl"), ",")
name := tags[0]
tags = tags[1:]
if fval.Kind() == reflect.Ptr {
fval = reflect.Indirect(fval)
}
// struct? recurse. Add the struct's key to the path
if fval.Kind() == reflect.Struct {
err := unusedKeysImpl(append([]string{name}, path...), fval)
if err != nil {
return err
}
continue
}
// Search the hcl tags for "unusedKeys"
unusedKeys := false
for _, p := range tags {
if p == "unusedKeys" {
unusedKeys = true
break
}
}
if unusedKeys {
ks, ok := fval.Interface().([]string)
if ok && len(ks) != 0 {
ps := ""
if len(path) > 0 {
ps = strings.Join(path, ".") + " "
}
return fmt.Errorf("%sunexpected keys %s",
ps,
strings.Join(ks, ", "))
}
}
}
return nil
}
// RemoveEqualFold removes the first string that EqualFold matches. It updates xs in place
func RemoveEqualFold(xs *[]string, search string) {
sl := *xs
for i, x := range sl {
if strings.EqualFold(x, search) {
sl = append(sl[:i], sl[i+1:]...)
if len(sl) == 0 {
*xs = nil
} else {
*xs = sl
}
return
}
}
}

View File

@ -0,0 +1,42 @@
package logging
import (
"context"
"path"
"time"
"github.com/hashicorp/go-hclog"
"google.golang.org/grpc"
"google.golang.org/grpc/status"
)
// UnaryClientInterceptor returns a new unary client interceptor that logs the execution of gRPC calls.
func UnaryClientInterceptor(logger hclog.Logger, opts ...Option) grpc.UnaryClientInterceptor {
o := evaluateClientOpt(opts)
return func(ctx context.Context, method string, req, reply interface{}, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
startTime := time.Now()
err := invoker(ctx, method, req, reply, cc, opts...)
emitClientLog(logger, o, method, startTime, err, "finished client unary call")
return err
}
}
// StreamClientInterceptor returns a new streaming client interceptor that logs the execution of gRPC calls.
func StreamClientInterceptor(logger hclog.Logger, opts ...Option) grpc.StreamClientInterceptor {
o := evaluateClientOpt(opts)
return func(ctx context.Context, desc *grpc.StreamDesc, cc *grpc.ClientConn, method string, streamer grpc.Streamer, opts ...grpc.CallOption) (grpc.ClientStream, error) {
startTime := time.Now()
clientStream, err := streamer(ctx, desc, cc, method, opts...)
emitClientLog(logger, o, method, startTime, err, "finished client streaming call")
return clientStream, err
}
}
func emitClientLog(logger hclog.Logger, o *options, fullMethodString string, startTime time.Time, err error, msg string) {
code := status.Code(err)
logLevel := o.levelFunc(code)
reqDuration := time.Now().Sub(startTime)
service := path.Dir(fullMethodString)[1:]
method := path.Base(fullMethodString)
logger.Log(logLevel, msg, "grpc.code", code, "duration", reqDuration, "grpc.service", service, "grpc.method", method)
}

View File

@ -0,0 +1,89 @@
package logging
import (
"github.com/hashicorp/go-hclog"
"google.golang.org/grpc/codes"
)
type options struct {
levelFunc CodeToLevel
}
var defaultOptions = &options{}
type Option func(*options)
func evaluateClientOpt(opts []Option) *options {
optCopy := &options{}
*optCopy = *defaultOptions
optCopy.levelFunc = DefaultCodeToLevel
for _, o := range opts {
o(optCopy)
}
return optCopy
}
func WithStatusCodeToLevelFunc(fn CodeToLevel) Option {
return func(opts *options) {
opts.levelFunc = fn
}
}
// CodeToLevel function defines the mapping between gRPC return codes and hclog level.
type CodeToLevel func(code codes.Code) hclog.Level
func DefaultCodeToLevel(code codes.Code) hclog.Level {
switch code {
// Trace Logs -- Useful for Nomad developers but not necessarily always wanted
case codes.OK:
return hclog.Trace
// Debug logs
case codes.Canceled:
return hclog.Debug
case codes.InvalidArgument:
return hclog.Debug
case codes.ResourceExhausted:
return hclog.Debug
case codes.FailedPrecondition:
return hclog.Debug
case codes.Aborted:
return hclog.Debug
case codes.OutOfRange:
return hclog.Debug
case codes.NotFound:
return hclog.Debug
case codes.AlreadyExists:
return hclog.Debug
// Info Logs - More curious/interesting than debug, but not necessarily critical
case codes.Unknown:
return hclog.Info
case codes.DeadlineExceeded:
return hclog.Info
case codes.PermissionDenied:
return hclog.Info
case codes.Unauthenticated:
// unauthenticated requests are probably usually fine?
return hclog.Info
case codes.Unavailable:
// unavailable errors indicate the upstream is not currently available. Info
// because I would guess these are usually transient and will be handled by
// retry mechanisms before being served as a higher level warning.
return hclog.Info
// Warn Logs - These are almost definitely bad in most cases - usually because
// the upstream is broken.
case codes.Unimplemented:
return hclog.Warn
case codes.Internal:
return hclog.Warn
case codes.DataLoss:
return hclog.Warn
default:
// Codes that aren't implemented as part of a CodeToLevel case are probably
// unknown and should be surfaced.
return hclog.Info
}
}

16
helper/mount/mount.go Normal file
View File

@ -0,0 +1,16 @@
package mount
// Mounter defines the set of methods to allow for mount operations on a system.
type Mounter interface {
// IsNotAMountPoint detects if a provided directory is not a mountpoint.
IsNotAMountPoint(file string) (bool, error)
// Mount will mount filesystem according to the specified configuration, on
// the condition that the target path is *not* already mounted. Options must
// be specified like the mount or fstab unix commands: "opt1=val1,opt2=val2".
Mount(device, target, mountType, options string) error
}
// Compile-time check to ensure all Mounter implementations satisfy
// the mount interface.
var _ Mounter = &mounter{}

View File

@ -0,0 +1,31 @@
// +build linux
package mount
import (
docker_mount "github.com/docker/docker/pkg/mount"
)
// mounter provides the default implementation of mount.Mounter
// for the linux platform.
// Currently it delegates to the docker `mount` package.
type mounter struct {
}
// New returns a Mounter for the current system.
func New() Mounter {
return &mounter{}
}
// IsNotAMountPoint determines if a directory is not a mountpoint.
// It does this by checking the path against the contents of /proc/self/mountinfo
func (m *mounter) IsNotAMountPoint(path string) (bool, error) {
isMount, err := docker_mount.Mounted(path)
return !isMount, err
}
func (m *mounter) Mount(device, target, mountType, options string) error {
// Defer to the docker implementation of `Mount`, it's correct enough for our
// usecase and avoids us needing to shell out to the `mount` utility.
return docker_mount.Mount(device, target, mountType, options)
}

View File

@ -0,0 +1,25 @@
// +build !linux
package mount
import (
"errors"
)
// mounter provides the default implementation of mount.Mounter
// for unsupported platforms.
type mounter struct {
}
// New returns a Mounter for the current system.
func New() Mounter {
return &mounter{}
}
func (m *mounter) IsNotAMountPoint(path string) (bool, error) {
return false, errors.New("Unsupported platform")
}
func (m *mounter) Mount(device, target, mountType, options string) error {
return errors.New("Unsupported platform")
}

View File

@ -295,41 +295,17 @@ func parseRestartPolicy(final **api.RestartPolicy, list *ast.ObjectList) error {
} }
func parseVolumes(out *map[string]*api.VolumeRequest, list *ast.ObjectList) error { func parseVolumes(out *map[string]*api.VolumeRequest, list *ast.ObjectList) error {
volumes := make(map[string]*api.VolumeRequest, len(list.Items)) hcl.DecodeObject(out, list)
for _, item := range list.Items { for k, v := range *out {
n := item.Keys[0].Token.Value().(string) err := helper.UnusedKeys(v)
valid := []string{
"type",
"read_only",
"hidden",
"source",
}
if err := helper.CheckHCLKeys(item.Val, valid); err != nil {
return err
}
var m map[string]interface{}
if err := hcl.DecodeObject(&m, item.Val); err != nil {
return err
}
var result api.VolumeRequest
dec, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
WeaklyTypedInput: true,
Result: &result,
})
if err != nil { if err != nil {
return err return err
} }
if err := dec.Decode(m); err != nil { // This is supported by `hcl:",key"`, but that only works if we start at the
return err // parent ast.ObjectItem
} v.Name = k
result.Name = n
volumes[n] = &result
} }
*out = volumes
return nil return nil
} }

View File

@ -74,6 +74,7 @@ func parseTask(item *ast.ObjectItem) (*api.Task, error) {
"kill_signal", "kill_signal",
"kind", "kind",
"volume_mount", "volume_mount",
"csi_plugin",
} }
if err := helper.CheckHCLKeys(listVal, valid); err != nil { if err := helper.CheckHCLKeys(listVal, valid); err != nil {
return nil, err return nil, err
@ -97,6 +98,7 @@ func parseTask(item *ast.ObjectItem) (*api.Task, error) {
delete(m, "template") delete(m, "template")
delete(m, "vault") delete(m, "vault")
delete(m, "volume_mount") delete(m, "volume_mount")
delete(m, "csi_plugin")
// Build the task // Build the task
var t api.Task var t api.Task
@ -135,6 +137,25 @@ func parseTask(item *ast.ObjectItem) (*api.Task, error) {
t.Services = services t.Services = services
} }
if o := listVal.Filter("csi_plugin"); len(o.Items) > 0 {
if len(o.Items) != 1 {
return nil, fmt.Errorf("csi_plugin -> Expected single stanza, got %d", len(o.Items))
}
i := o.Elem().Items[0]
var m map[string]interface{}
if err := hcl.DecodeObject(&m, i.Val); err != nil {
return nil, err
}
var cfg api.TaskCSIPluginConfig
if err := mapstructure.WeakDecode(m, &cfg); err != nil {
return nil, err
}
t.CSIPluginConfig = &cfg
}
// If we have config, then parse that // If we have config, then parse that
if o := listVal.Filter("config"); len(o.Items) > 0 { if o := listVal.Filter("config"); len(o.Items) > 0 {
for _, o := range o.Elem().Items { for _, o := range o.Elem().Items {

View File

@ -117,11 +117,32 @@ func TestParse(t *testing.T) {
Operand: "=", Operand: "=",
}, },
}, },
Volumes: map[string]*api.VolumeRequest{ Volumes: map[string]*api.VolumeRequest{
"foo": { "foo": {
Name: "foo", Name: "foo",
Type: "host", Type: "host",
Source: "/path",
ExtraKeysHCL: nil,
},
"bar": {
Name: "bar",
Type: "csi",
Source: "bar-vol",
MountOptions: &api.CSIMountOptions{
FSType: "ext4",
},
ExtraKeysHCL: nil,
},
"baz": {
Name: "baz",
Type: "csi",
Source: "bar-vol",
MountOptions: &api.CSIMountOptions{
MountFlags: []string{
"ro",
},
},
ExtraKeysHCL: nil,
}, },
}, },
Affinities: []*api.Affinity{ Affinities: []*api.Affinity{
@ -569,6 +590,30 @@ func TestParse(t *testing.T) {
}, },
false, false,
}, },
{
"csi-plugin.hcl",
&api.Job{
ID: helper.StringToPtr("binstore-storagelocker"),
Name: helper.StringToPtr("binstore-storagelocker"),
TaskGroups: []*api.TaskGroup{
{
Name: helper.StringToPtr("binsl"),
Tasks: []*api.Task{
{
Name: "binstore",
Driver: "docker",
CSIPluginConfig: &api.TaskCSIPluginConfig{
ID: "org.hashicorp.csi",
Type: api.CSIPluginTypeMonolith,
MountDir: "/csi/test",
},
},
},
},
},
},
false,
},
{ {
"service-check-initial-status.hcl", "service-check-initial-status.hcl",
&api.Job{ &api.Job{

View File

@ -71,7 +71,26 @@ job "binstore-storagelocker" {
count = 5 count = 5
volume "foo" { volume "foo" {
type = "host" type = "host"
source = "/path"
}
volume "bar" {
type = "csi"
source = "bar-vol"
mount_options {
fs_type = "ext4"
}
}
volume "baz" {
type = "csi"
source = "bar-vol"
mount_options {
mount_flags = ["ro"]
}
} }
restart { restart {

View File

@ -0,0 +1,13 @@
job "binstore-storagelocker" {
group "binsl" {
task "binstore" {
driver = "docker"
csi_plugin {
id = "org.hashicorp.csi"
type = "monolith"
mount_dir = "/csi/test"
}
}
}
}

View File

@ -0,0 +1,118 @@
package nomad
import (
"errors"
"fmt"
"time"
metrics "github.com/armon/go-metrics"
log "github.com/hashicorp/go-hclog"
cstructs "github.com/hashicorp/nomad/client/structs"
)
// ClientCSIController is used to forward RPC requests to the targed Nomad client's
// CSIController endpoint.
type ClientCSIController struct {
srv *Server
logger log.Logger
}
func (a *ClientCSIController) AttachVolume(args *cstructs.ClientCSIControllerAttachVolumeRequest, reply *cstructs.ClientCSIControllerAttachVolumeResponse) error {
defer metrics.MeasureSince([]string{"nomad", "client_csi_controller", "attach_volume"}, time.Now())
// Verify the arguments.
if args.ControllerNodeID == "" {
return errors.New("missing ControllerNodeID")
}
// Make sure Node is valid and new enough to support RPC
snap, err := a.srv.State().Snapshot()
if err != nil {
return err
}
_, err = getNodeForRpc(snap, args.ControllerNodeID)
if err != nil {
return err
}
// Get the connection to the client
state, ok := a.srv.getNodeConn(args.ControllerNodeID)
if !ok {
return findNodeConnAndForward(a.srv, args.ControllerNodeID, "ClientCSIController.AttachVolume", args, reply)
}
// Make the RPC
err = NodeRpc(state.Session, "CSIController.AttachVolume", args, reply)
if err != nil {
return fmt.Errorf("attach volume: %v", err)
}
return nil
}
func (a *ClientCSIController) ValidateVolume(args *cstructs.ClientCSIControllerValidateVolumeRequest, reply *cstructs.ClientCSIControllerValidateVolumeResponse) error {
defer metrics.MeasureSince([]string{"nomad", "client_csi_controller", "validate_volume"}, time.Now())
// Verify the arguments.
if args.ControllerNodeID == "" {
return errors.New("missing ControllerNodeID")
}
// Make sure Node is valid and new enough to support RPC
snap, err := a.srv.State().Snapshot()
if err != nil {
return err
}
_, err = getNodeForRpc(snap, args.ControllerNodeID)
if err != nil {
return err
}
// Get the connection to the client
state, ok := a.srv.getNodeConn(args.ControllerNodeID)
if !ok {
return findNodeConnAndForward(a.srv, args.ControllerNodeID, "ClientCSIController.ValidateVolume", args, reply)
}
// Make the RPC
err = NodeRpc(state.Session, "CSIController.ValidateVolume", args, reply)
if err != nil {
return fmt.Errorf("validate volume: %v", err)
}
return nil
}
func (a *ClientCSIController) DetachVolume(args *cstructs.ClientCSIControllerDetachVolumeRequest, reply *cstructs.ClientCSIControllerDetachVolumeResponse) error {
defer metrics.MeasureSince([]string{"nomad", "client_csi_controller", "detach_volume"}, time.Now())
// Verify the arguments.
if args.ControllerNodeID == "" {
return errors.New("missing ControllerNodeID")
}
// Make sure Node is valid and new enough to support RPC
snap, err := a.srv.State().Snapshot()
if err != nil {
return err
}
_, err = getNodeForRpc(snap, args.ControllerNodeID)
if err != nil {
return err
}
// Get the connection to the client
state, ok := a.srv.getNodeConn(args.ControllerNodeID)
if !ok {
return findNodeConnAndForward(a.srv, args.ControllerNodeID, "ClientCSIController.DetachVolume", args, reply)
}
// Make the RPC
err = NodeRpc(state.Session, "CSIController.DetachVolume", args, reply)
if err != nil {
return fmt.Errorf("detach volume: %v", err)
}
return nil
}

View File

@ -0,0 +1,169 @@
package nomad
import (
"testing"
msgpackrpc "github.com/hashicorp/net-rpc-msgpackrpc"
"github.com/hashicorp/nomad/client"
"github.com/hashicorp/nomad/client/config"
cstructs "github.com/hashicorp/nomad/client/structs"
"github.com/hashicorp/nomad/nomad/structs"
"github.com/hashicorp/nomad/testutil"
"github.com/stretchr/testify/require"
)
func TestClientCSIController_AttachVolume_Local(t *testing.T) {
t.Parallel()
require := require.New(t)
// Start a server and client
s, cleanupS := TestServer(t, nil)
defer cleanupS()
codec := rpcClient(t, s)
testutil.WaitForLeader(t, s.RPC)
c, cleanupC := client.TestClient(t, func(c *config.Config) {
c.Servers = []string{s.config.RPCAddr.String()}
})
defer cleanupC()
testutil.WaitForResult(func() (bool, error) {
nodes := s.connectedNodes()
return len(nodes) == 1, nil
}, func(err error) {
require.Fail("should have a client")
})
req := &cstructs.ClientCSIControllerAttachVolumeRequest{
CSIControllerQuery: cstructs.CSIControllerQuery{ControllerNodeID: c.NodeID()},
}
// Fetch the response
var resp structs.GenericResponse
err := msgpackrpc.CallWithCodec(codec, "ClientCSIController.AttachVolume", req, &resp)
require.NotNil(err)
// Should recieve an error from the client endpoint
require.Contains(err.Error(), "must specify plugin name to dispense")
}
func TestClientCSIController_AttachVolume_Forwarded(t *testing.T) {
t.Parallel()
require := require.New(t)
// Start a server and client
s1, cleanupS1 := TestServer(t, func(c *Config) { c.BootstrapExpect = 2 })
defer cleanupS1()
s2, cleanupS2 := TestServer(t, func(c *Config) { c.BootstrapExpect = 2 })
defer cleanupS2()
TestJoin(t, s1, s2)
testutil.WaitForLeader(t, s1.RPC)
testutil.WaitForLeader(t, s2.RPC)
codec := rpcClient(t, s2)
c, cleanupC := client.TestClient(t, func(c *config.Config) {
c.Servers = []string{s2.config.RPCAddr.String()}
c.GCDiskUsageThreshold = 100.0
})
defer cleanupC()
testutil.WaitForResult(func() (bool, error) {
nodes := s2.connectedNodes()
return len(nodes) == 1, nil
}, func(err error) {
require.Fail("should have a client")
})
// Force remove the connection locally in case it exists
s1.nodeConnsLock.Lock()
delete(s1.nodeConns, c.NodeID())
s1.nodeConnsLock.Unlock()
req := &cstructs.ClientCSIControllerAttachVolumeRequest{
CSIControllerQuery: cstructs.CSIControllerQuery{ControllerNodeID: c.NodeID()},
}
// Fetch the response
var resp structs.GenericResponse
err := msgpackrpc.CallWithCodec(codec, "ClientCSIController.AttachVolume", req, &resp)
require.NotNil(err)
// Should recieve an error from the client endpoint
require.Contains(err.Error(), "must specify plugin name to dispense")
}
func TestClientCSIController_DetachVolume_Local(t *testing.T) {
t.Parallel()
require := require.New(t)
// Start a server and client
s, cleanupS := TestServer(t, nil)
defer cleanupS()
codec := rpcClient(t, s)
testutil.WaitForLeader(t, s.RPC)
c, cleanupC := client.TestClient(t, func(c *config.Config) {
c.Servers = []string{s.config.RPCAddr.String()}
})
defer cleanupC()
testutil.WaitForResult(func() (bool, error) {
nodes := s.connectedNodes()
return len(nodes) == 1, nil
}, func(err error) {
require.Fail("should have a client")
})
req := &cstructs.ClientCSIControllerDetachVolumeRequest{
CSIControllerQuery: cstructs.CSIControllerQuery{ControllerNodeID: c.NodeID()},
}
// Fetch the response
var resp structs.GenericResponse
err := msgpackrpc.CallWithCodec(codec, "ClientCSIController.DetachVolume", req, &resp)
require.NotNil(err)
// Should recieve an error from the client endpoint
require.Contains(err.Error(), "must specify plugin name to dispense")
}
func TestClientCSIController_DetachVolume_Forwarded(t *testing.T) {
t.Parallel()
require := require.New(t)
// Start a server and client
s1, cleanupS1 := TestServer(t, func(c *Config) { c.BootstrapExpect = 2 })
defer cleanupS1()
s2, cleanupS2 := TestServer(t, func(c *Config) { c.BootstrapExpect = 2 })
defer cleanupS2()
TestJoin(t, s1, s2)
testutil.WaitForLeader(t, s1.RPC)
testutil.WaitForLeader(t, s2.RPC)
codec := rpcClient(t, s2)
c, cleanupC := client.TestClient(t, func(c *config.Config) {
c.Servers = []string{s2.config.RPCAddr.String()}
c.GCDiskUsageThreshold = 100.0
})
defer cleanupC()
testutil.WaitForResult(func() (bool, error) {
nodes := s2.connectedNodes()
return len(nodes) == 1, nil
}, func(err error) {
require.Fail("should have a client")
})
// Force remove the connection locally in case it exists
s1.nodeConnsLock.Lock()
delete(s1.nodeConns, c.NodeID())
s1.nodeConnsLock.Unlock()
req := &cstructs.ClientCSIControllerDetachVolumeRequest{
CSIControllerQuery: cstructs.CSIControllerQuery{ControllerNodeID: c.NodeID()},
}
// Fetch the response
var resp structs.GenericResponse
err := msgpackrpc.CallWithCodec(codec, "ClientCSIController.DetachVolume", req, &resp)
require.NotNil(err)
// Should recieve an error from the client endpoint
require.Contains(err.Error(), "must specify plugin name to dispense")
}

View File

@ -219,14 +219,14 @@ func NodeRpc(session *yamux.Session, method string, args, reply interface{}) err
// Open a new session // Open a new session
stream, err := session.Open() stream, err := session.Open()
if err != nil { if err != nil {
return err return fmt.Errorf("session open: %v", err)
} }
defer stream.Close() defer stream.Close()
// Write the RpcNomad byte to set the mode // Write the RpcNomad byte to set the mode
if _, err := stream.Write([]byte{byte(pool.RpcNomad)}); err != nil { if _, err := stream.Write([]byte{byte(pool.RpcNomad)}); err != nil {
stream.Close() stream.Close()
return err return fmt.Errorf("set mode: %v", err)
} }
// Make the RPC // Make the RPC

View File

@ -3,10 +3,12 @@ package nomad
import ( import (
"fmt" "fmt"
"math" "math"
"strings"
"time" "time"
log "github.com/hashicorp/go-hclog" log "github.com/hashicorp/go-hclog"
memdb "github.com/hashicorp/go-memdb" memdb "github.com/hashicorp/go-memdb"
multierror "github.com/hashicorp/go-multierror"
version "github.com/hashicorp/go-version" version "github.com/hashicorp/go-version"
"github.com/hashicorp/nomad/nomad/state" "github.com/hashicorp/nomad/nomad/state"
"github.com/hashicorp/nomad/nomad/structs" "github.com/hashicorp/nomad/nomad/structs"
@ -41,7 +43,8 @@ func NewCoreScheduler(srv *Server, snap *state.StateSnapshot) scheduler.Schedule
// Process is used to implement the scheduler.Scheduler interface // Process is used to implement the scheduler.Scheduler interface
func (c *CoreScheduler) Process(eval *structs.Evaluation) error { func (c *CoreScheduler) Process(eval *structs.Evaluation) error {
switch eval.JobID { job := strings.Split(eval.JobID, ":") // extra data can be smuggled in w/ JobID
switch job[0] {
case structs.CoreJobEvalGC: case structs.CoreJobEvalGC:
return c.evalGC(eval) return c.evalGC(eval)
case structs.CoreJobNodeGC: case structs.CoreJobNodeGC:
@ -50,6 +53,8 @@ func (c *CoreScheduler) Process(eval *structs.Evaluation) error {
return c.jobGC(eval) return c.jobGC(eval)
case structs.CoreJobDeploymentGC: case structs.CoreJobDeploymentGC:
return c.deploymentGC(eval) return c.deploymentGC(eval)
case structs.CoreJobCSIVolumeClaimGC:
return c.csiVolumeClaimGC(eval)
case structs.CoreJobForceGC: case structs.CoreJobForceGC:
return c.forceGC(eval) return c.forceGC(eval)
default: default:
@ -141,6 +146,7 @@ OUTER:
gcAlloc = append(gcAlloc, jobAlloc...) gcAlloc = append(gcAlloc, jobAlloc...)
gcEval = append(gcEval, jobEval...) gcEval = append(gcEval, jobEval...)
} }
} }
// Fast-path the nothing case // Fast-path the nothing case
@ -150,6 +156,11 @@ OUTER:
c.logger.Debug("job GC found eligible objects", c.logger.Debug("job GC found eligible objects",
"jobs", len(gcJob), "evals", len(gcEval), "allocs", len(gcAlloc)) "jobs", len(gcJob), "evals", len(gcEval), "allocs", len(gcAlloc))
// Clean up any outstanding volume claims
if err := c.volumeClaimReap(gcJob, eval.LeaderACL); err != nil {
return err
}
// Reap the evals and allocs // Reap the evals and allocs
if err := c.evalReap(gcEval, gcAlloc); err != nil { if err := c.evalReap(gcEval, gcAlloc); err != nil {
return err return err
@ -703,3 +714,124 @@ func allocGCEligible(a *structs.Allocation, job *structs.Job, gcTime time.Time,
return timeDiff > interval.Nanoseconds() return timeDiff > interval.Nanoseconds()
} }
// csiVolumeClaimGC is used to garbage collect CSI volume claims
func (c *CoreScheduler) csiVolumeClaimGC(eval *structs.Evaluation) error {
c.logger.Trace("garbage collecting unclaimed CSI volume claims")
// JobID smuggled in with the eval's own JobID
var jobID string
evalJobID := strings.Split(eval.JobID, ":")
if len(evalJobID) != 2 {
c.logger.Error("volume gc called without jobID")
return nil
}
jobID = evalJobID[1]
job, err := c.srv.State().JobByID(nil, eval.Namespace, jobID)
if err != nil || job == nil {
c.logger.Trace(
"cannot find job to perform volume claim GC. it may have been garbage collected",
"job", jobID)
return nil
}
c.volumeClaimReap([]*structs.Job{job}, eval.LeaderACL)
return nil
}
// volumeClaimReap contacts the leader and releases volume claims from
// terminal allocs
func (c *CoreScheduler) volumeClaimReap(jobs []*structs.Job, leaderACL string) error {
ws := memdb.NewWatchSet()
var result *multierror.Error
for _, job := range jobs {
c.logger.Trace("garbage collecting unclaimed CSI volume claims for job", "job", job.ID)
for _, taskGroup := range job.TaskGroups {
for _, tgVolume := range taskGroup.Volumes {
if tgVolume.Type != structs.VolumeTypeCSI {
continue // filter to just CSI volumes
}
volID := tgVolume.Source
vol, err := c.srv.State().CSIVolumeByID(ws, job.Namespace, volID)
if err != nil {
result = multierror.Append(result, err)
continue
}
if vol == nil {
c.logger.Trace("cannot find volume to be GC'd. it may have been deregistered",
"volume", volID)
continue
}
vol, err = c.srv.State().CSIVolumeDenormalize(ws, vol)
if err != nil {
result = multierror.Append(result, err)
continue
}
gcAllocs := []string{} // alloc IDs
claimedNodes := map[string]struct{}{}
knownNodes := []string{}
collectFunc := func(allocs map[string]*structs.Allocation) {
for _, alloc := range allocs {
// we call denormalize on the volume above to populate
// Allocation pointers. But the alloc might have been
// garbage collected concurrently, so if the alloc is
// still nil we can safely skip it.
if alloc == nil {
continue
}
knownNodes = append(knownNodes, alloc.NodeID)
if !alloc.Terminated() {
// if there are any unterminated allocs, we
// don't want to unpublish the volume, just
// release the alloc's claim
claimedNodes[alloc.NodeID] = struct{}{}
continue
}
gcAllocs = append(gcAllocs, alloc.ID)
}
}
collectFunc(vol.WriteAllocs)
collectFunc(vol.ReadAllocs)
req := &structs.CSIVolumeClaimRequest{
VolumeID: volID,
AllocationID: "", // controller unpublish never uses this field
Claim: structs.CSIVolumeClaimRelease,
WriteRequest: structs.WriteRequest{
Region: job.Region,
Namespace: job.Namespace,
AuthToken: leaderACL,
},
}
// we only emit the controller unpublish if no other allocs
// on the node need it, but we also only want to make this
// call at most once per node
for _, node := range knownNodes {
if _, isClaimed := claimedNodes[node]; isClaimed {
continue
}
err = c.srv.controllerUnpublishVolume(req, node)
if err != nil {
result = multierror.Append(result, err)
continue
}
}
for _, allocID := range gcAllocs {
req.AllocationID = allocID
err = c.srv.RPC("CSIVolume.Claim", req, &structs.CSIVolumeClaimResponse{})
if err != nil {
c.logger.Error("volume claim release failed", "error", err)
result = multierror.Append(result, err)
}
}
}
}
}
return result.ErrorOrNil()
}

View File

@ -2193,3 +2193,241 @@ func TestAllocation_GCEligible(t *testing.T) {
alloc.ClientStatus = structs.AllocClientStatusComplete alloc.ClientStatus = structs.AllocClientStatusComplete
require.True(allocGCEligible(alloc, nil, time.Now(), 1000)) require.True(allocGCEligible(alloc, nil, time.Now(), 1000))
} }
func TestCSI_GCVolumeClaims(t *testing.T) {
t.Parallel()
srv, shutdown := TestServer(t, func(c *Config) { c.NumSchedulers = 0 })
defer shutdown()
testutil.WaitForLeader(t, srv.RPC)
state := srv.fsm.State()
ws := memdb.NewWatchSet()
// Create a client node, plugin, and volume
node := mock.Node()
node.Attributes["nomad.version"] = "0.11.0" // client RPCs not supported on early version
node.CSINodePlugins = map[string]*structs.CSIInfo{
"csi-plugin-example": {PluginID: "csi-plugin-example",
Healthy: true,
NodeInfo: &structs.CSINodeInfo{},
},
}
err := state.UpsertNode(99, node)
require.NoError(t, err)
volId0 := uuid.Generate()
ns := structs.DefaultNamespace
vols := []*structs.CSIVolume{{
ID: volId0,
Namespace: ns,
PluginID: "csi-plugin-example",
AccessMode: structs.CSIVolumeAccessModeMultiNodeSingleWriter,
AttachmentMode: structs.CSIVolumeAttachmentModeFilesystem,
}}
err = state.CSIVolumeRegister(100, vols)
require.NoError(t, err)
vol, err := state.CSIVolumeByID(ws, ns, volId0)
require.NoError(t, err)
require.Len(t, vol.ReadAllocs, 0)
require.Len(t, vol.WriteAllocs, 0)
// Create a job with 2 allocations
job := mock.Job()
job.TaskGroups[0].Volumes = map[string]*structs.VolumeRequest{
"_": {
Name: "someVolume",
Type: structs.VolumeTypeCSI,
Source: volId0,
ReadOnly: false,
},
}
err = state.UpsertJob(101, job)
require.NoError(t, err)
alloc1 := mock.Alloc()
alloc1.JobID = job.ID
alloc1.NodeID = node.ID
err = state.UpsertJobSummary(102, mock.JobSummary(alloc1.JobID))
require.NoError(t, err)
alloc1.TaskGroup = job.TaskGroups[0].Name
alloc2 := mock.Alloc()
alloc2.JobID = job.ID
alloc2.NodeID = node.ID
err = state.UpsertJobSummary(103, mock.JobSummary(alloc2.JobID))
require.NoError(t, err)
alloc2.TaskGroup = job.TaskGroups[0].Name
err = state.UpsertAllocs(104, []*structs.Allocation{alloc1, alloc2})
require.NoError(t, err)
// Claim the volumes and verify the claims were set
err = state.CSIVolumeClaim(105, ns, volId0, alloc1, structs.CSIVolumeClaimWrite)
require.NoError(t, err)
err = state.CSIVolumeClaim(106, ns, volId0, alloc2, structs.CSIVolumeClaimRead)
require.NoError(t, err)
vol, err = state.CSIVolumeByID(ws, ns, volId0)
require.NoError(t, err)
require.Len(t, vol.ReadAllocs, 1)
require.Len(t, vol.WriteAllocs, 1)
// Update the 1st alloc as failed/terminated
alloc1.ClientStatus = structs.AllocClientStatusFailed
err = state.UpdateAllocsFromClient(107, []*structs.Allocation{alloc1})
require.NoError(t, err)
// Create the GC eval we'd get from Node.UpdateAlloc
now := time.Now().UTC()
eval := &structs.Evaluation{
ID: uuid.Generate(),
Namespace: job.Namespace,
Priority: structs.CoreJobPriority,
Type: structs.JobTypeCore,
TriggeredBy: structs.EvalTriggerAllocStop,
JobID: structs.CoreJobCSIVolumeClaimGC + ":" + job.ID,
LeaderACL: srv.getLeaderAcl(),
Status: structs.EvalStatusPending,
CreateTime: now.UTC().UnixNano(),
ModifyTime: now.UTC().UnixNano(),
}
// Process the eval
snap, err := state.Snapshot()
require.NoError(t, err)
core := NewCoreScheduler(srv, snap)
err = core.Process(eval)
require.NoError(t, err)
// Verify the claim was released
vol, err = state.CSIVolumeByID(ws, ns, volId0)
require.NoError(t, err)
require.Len(t, vol.ReadAllocs, 1)
require.Len(t, vol.WriteAllocs, 0)
}
func TestCSI_GCVolumeClaims_Controller(t *testing.T) {
t.Parallel()
srv, shutdown := TestServer(t, func(c *Config) { c.NumSchedulers = 0 })
defer shutdown()
testutil.WaitForLeader(t, srv.RPC)
state := srv.fsm.State()
ws := memdb.NewWatchSet()
// Create a client node, plugin, and volume
node := mock.Node()
node.Attributes["nomad.version"] = "0.11.0" // client RPCs not supported on early version
node.CSINodePlugins = map[string]*structs.CSIInfo{
"csi-plugin-example": {
PluginID: "csi-plugin-example",
Healthy: true,
RequiresControllerPlugin: true,
NodeInfo: &structs.CSINodeInfo{},
},
}
node.CSIControllerPlugins = map[string]*structs.CSIInfo{
"csi-plugin-example": {
PluginID: "csi-plugin-example",
Healthy: true,
RequiresControllerPlugin: true,
ControllerInfo: &structs.CSIControllerInfo{
SupportsReadOnlyAttach: true,
SupportsAttachDetach: true,
SupportsListVolumes: true,
SupportsListVolumesAttachedNodes: false,
},
},
}
err := state.UpsertNode(99, node)
require.NoError(t, err)
volId0 := uuid.Generate()
ns := structs.DefaultNamespace
vols := []*structs.CSIVolume{{
ID: volId0,
Namespace: ns,
PluginID: "csi-plugin-example",
AccessMode: structs.CSIVolumeAccessModeMultiNodeSingleWriter,
AttachmentMode: structs.CSIVolumeAttachmentModeFilesystem,
}}
err = state.CSIVolumeRegister(100, vols)
require.NoError(t, err)
vol, err := state.CSIVolumeByID(ws, ns, volId0)
require.NoError(t, err)
require.True(t, vol.ControllerRequired)
require.Len(t, vol.ReadAllocs, 0)
require.Len(t, vol.WriteAllocs, 0)
// Create a job with 2 allocations
job := mock.Job()
job.TaskGroups[0].Volumes = map[string]*structs.VolumeRequest{
"_": {
Name: "someVolume",
Type: structs.VolumeTypeCSI,
Source: volId0,
ReadOnly: false,
},
}
err = state.UpsertJob(101, job)
require.NoError(t, err)
alloc1 := mock.Alloc()
alloc1.JobID = job.ID
alloc1.NodeID = node.ID
err = state.UpsertJobSummary(102, mock.JobSummary(alloc1.JobID))
require.NoError(t, err)
alloc1.TaskGroup = job.TaskGroups[0].Name
alloc2 := mock.Alloc()
alloc2.JobID = job.ID
alloc2.NodeID = node.ID
err = state.UpsertJobSummary(103, mock.JobSummary(alloc2.JobID))
require.NoError(t, err)
alloc2.TaskGroup = job.TaskGroups[0].Name
err = state.UpsertAllocs(104, []*structs.Allocation{alloc1, alloc2})
require.NoError(t, err)
// Claim the volumes and verify the claims were set
err = state.CSIVolumeClaim(105, ns, volId0, alloc1, structs.CSIVolumeClaimWrite)
require.NoError(t, err)
err = state.CSIVolumeClaim(106, ns, volId0, alloc2, structs.CSIVolumeClaimRead)
require.NoError(t, err)
vol, err = state.CSIVolumeByID(ws, ns, volId0)
require.NoError(t, err)
require.Len(t, vol.ReadAllocs, 1)
require.Len(t, vol.WriteAllocs, 1)
// Update both allocs as failed/terminated
alloc1.ClientStatus = structs.AllocClientStatusFailed
alloc2.ClientStatus = structs.AllocClientStatusFailed
err = state.UpdateAllocsFromClient(107, []*structs.Allocation{alloc1, alloc2})
require.NoError(t, err)
// Create the GC eval we'd get from Node.UpdateAlloc
now := time.Now().UTC()
eval := &structs.Evaluation{
ID: uuid.Generate(),
Namespace: job.Namespace,
Priority: structs.CoreJobPriority,
Type: structs.JobTypeCore,
TriggeredBy: structs.EvalTriggerAllocStop,
JobID: structs.CoreJobCSIVolumeClaimGC + ":" + job.ID,
LeaderACL: srv.getLeaderAcl(),
Status: structs.EvalStatusPending,
CreateTime: now.UTC().UnixNano(),
ModifyTime: now.UTC().UnixNano(),
}
// Process the eval
snap, err := state.Snapshot()
require.NoError(t, err)
core := NewCoreScheduler(srv, snap)
err = core.Process(eval)
require.NoError(t, err)
// Verify both claims were released
vol, err = state.CSIVolumeByID(ws, ns, volId0)
require.NoError(t, err)
require.Len(t, vol.ReadAllocs, 0)
require.Len(t, vol.WriteAllocs, 0)
}

678
nomad/csi_endpoint.go Normal file
View File

@ -0,0 +1,678 @@
package nomad
import (
"fmt"
"math/rand"
"time"
metrics "github.com/armon/go-metrics"
log "github.com/hashicorp/go-hclog"
memdb "github.com/hashicorp/go-memdb"
multierror "github.com/hashicorp/go-multierror"
"github.com/hashicorp/nomad/acl"
cstructs "github.com/hashicorp/nomad/client/structs"
"github.com/hashicorp/nomad/nomad/state"
"github.com/hashicorp/nomad/nomad/structs"
)
// CSIVolume wraps the structs.CSIVolume with request data and server context
type CSIVolume struct {
srv *Server
logger log.Logger
}
// QueryACLObj looks up the ACL token in the request and returns the acl.ACL object
// - fallback to node secret ids
func (srv *Server) QueryACLObj(args *structs.QueryOptions, allowNodeAccess bool) (*acl.ACL, error) {
// Lookup the token
aclObj, err := srv.ResolveToken(args.AuthToken)
if err != nil {
// If ResolveToken had an unexpected error return that
if !structs.IsErrTokenNotFound(err) {
return nil, err
}
// If we don't allow access to this endpoint from Nodes, then return token
// not found.
if !allowNodeAccess {
return nil, structs.ErrTokenNotFound
}
ws := memdb.NewWatchSet()
// Attempt to lookup AuthToken as a Node.SecretID since nodes may call
// call this endpoint and don't have an ACL token.
node, stateErr := srv.fsm.State().NodeBySecretID(ws, args.AuthToken)
if stateErr != nil {
// Return the original ResolveToken error with this err
var merr multierror.Error
merr.Errors = append(merr.Errors, err, stateErr)
return nil, merr.ErrorOrNil()
}
// We did not find a Node for this ID, so return Token Not Found.
if node == nil {
return nil, structs.ErrTokenNotFound
}
}
// Return either the users aclObj, or nil if ACLs are disabled.
return aclObj, nil
}
// WriteACLObj calls QueryACLObj for a WriteRequest
func (srv *Server) WriteACLObj(args *structs.WriteRequest, allowNodeAccess bool) (*acl.ACL, error) {
opts := &structs.QueryOptions{
Region: args.RequestRegion(),
Namespace: args.RequestNamespace(),
AuthToken: args.AuthToken,
}
return srv.QueryACLObj(opts, allowNodeAccess)
}
const (
csiVolumeTable = "csi_volumes"
csiPluginTable = "csi_plugins"
)
// replySetIndex sets the reply with the last index that modified the table
func (srv *Server) replySetIndex(table string, reply *structs.QueryMeta) error {
s := srv.fsm.State()
index, err := s.Index(table)
if err != nil {
return err
}
reply.Index = index
// Set the query response
srv.setQueryMeta(reply)
return nil
}
// List replies with CSIVolumes, filtered by ACL access
func (v *CSIVolume) List(args *structs.CSIVolumeListRequest, reply *structs.CSIVolumeListResponse) error {
if done, err := v.srv.forward("CSIVolume.List", args, args, reply); done {
return err
}
allowVolume := acl.NamespaceValidator(acl.NamespaceCapabilityCSIListVolume,
acl.NamespaceCapabilityCSIReadVolume,
acl.NamespaceCapabilityCSIMountVolume,
acl.NamespaceCapabilityListJobs)
aclObj, err := v.srv.QueryACLObj(&args.QueryOptions, false)
if err != nil {
return err
}
if !allowVolume(aclObj, args.RequestNamespace()) {
return structs.ErrPermissionDenied
}
metricsStart := time.Now()
defer metrics.MeasureSince([]string{"nomad", "volume", "list"}, metricsStart)
ns := args.RequestNamespace()
opts := blockingOptions{
queryOpts: &args.QueryOptions,
queryMeta: &reply.QueryMeta,
run: func(ws memdb.WatchSet, state *state.StateStore) error {
// Query all volumes
var err error
var iter memdb.ResultIterator
if args.NodeID != "" {
iter, err = state.CSIVolumesByNodeID(ws, ns, args.NodeID)
} else if args.PluginID != "" {
iter, err = state.CSIVolumesByPluginID(ws, ns, args.PluginID)
} else {
iter, err = state.CSIVolumesByNamespace(ws, ns)
}
if err != nil {
return err
}
// Collect results, filter by ACL access
var vs []*structs.CSIVolListStub
for {
raw := iter.Next()
if raw == nil {
break
}
vol := raw.(*structs.CSIVolume)
vol, err := state.CSIVolumeDenormalizePlugins(ws, vol.Copy())
if err != nil {
return err
}
// Filter (possibly again) on PluginID to handle passing both NodeID and PluginID
if args.PluginID != "" && args.PluginID != vol.PluginID {
continue
}
vs = append(vs, vol.Stub())
}
reply.Volumes = vs
return v.srv.replySetIndex(csiVolumeTable, &reply.QueryMeta)
}}
return v.srv.blockingRPC(&opts)
}
// Get fetches detailed information about a specific volume
func (v *CSIVolume) Get(args *structs.CSIVolumeGetRequest, reply *structs.CSIVolumeGetResponse) error {
if done, err := v.srv.forward("CSIVolume.Get", args, args, reply); done {
return err
}
allowCSIAccess := acl.NamespaceValidator(acl.NamespaceCapabilityCSIReadVolume,
acl.NamespaceCapabilityCSIMountVolume,
acl.NamespaceCapabilityReadJob)
aclObj, err := v.srv.QueryACLObj(&args.QueryOptions, true)
if err != nil {
return err
}
ns := args.RequestNamespace()
if !allowCSIAccess(aclObj, ns) {
return structs.ErrPermissionDenied
}
metricsStart := time.Now()
defer metrics.MeasureSince([]string{"nomad", "volume", "get"}, metricsStart)
opts := blockingOptions{
queryOpts: &args.QueryOptions,
queryMeta: &reply.QueryMeta,
run: func(ws memdb.WatchSet, state *state.StateStore) error {
vol, err := state.CSIVolumeByID(ws, ns, args.ID)
if err != nil {
return err
}
if vol != nil {
vol, err = state.CSIVolumeDenormalize(ws, vol)
}
if err != nil {
return err
}
reply.Volume = vol
return v.srv.replySetIndex(csiVolumeTable, &reply.QueryMeta)
}}
return v.srv.blockingRPC(&opts)
}
func (srv *Server) pluginValidateVolume(req *structs.CSIVolumeRegisterRequest, vol *structs.CSIVolume) (*structs.CSIPlugin, error) {
state := srv.fsm.State()
ws := memdb.NewWatchSet()
plugin, err := state.CSIPluginByID(ws, vol.PluginID)
if err != nil {
return nil, err
}
if plugin == nil {
return nil, fmt.Errorf("no CSI plugin named: %s could be found", vol.PluginID)
}
vol.Provider = plugin.Provider
vol.ProviderVersion = plugin.Version
return plugin, nil
}
func (srv *Server) controllerValidateVolume(req *structs.CSIVolumeRegisterRequest, vol *structs.CSIVolume, plugin *structs.CSIPlugin) error {
if !plugin.ControllerRequired {
// The plugin does not require a controller, so for now we won't do any
// further validation of the volume.
return nil
}
// The plugin requires a controller. Now we do some validation of the Volume
// to ensure that the registered capabilities are valid and that the volume
// exists.
// plugin IDs are not scoped to region/DC but volumes are.
// so any node we get for a controller is already in the same region/DC
// for the volume.
nodeID, err := srv.nodeForControllerPlugin(plugin)
if err != nil || nodeID == "" {
return err
}
method := "ClientCSIController.ValidateVolume"
cReq := &cstructs.ClientCSIControllerValidateVolumeRequest{
VolumeID: vol.RemoteID(),
AttachmentMode: vol.AttachmentMode,
AccessMode: vol.AccessMode,
}
cReq.PluginID = plugin.ID
cReq.ControllerNodeID = nodeID
cResp := &cstructs.ClientCSIControllerValidateVolumeResponse{}
return srv.RPC(method, cReq, cResp)
}
// Register registers a new volume
func (v *CSIVolume) Register(args *structs.CSIVolumeRegisterRequest, reply *structs.CSIVolumeRegisterResponse) error {
if done, err := v.srv.forward("CSIVolume.Register", args, args, reply); done {
return err
}
allowVolume := acl.NamespaceValidator(acl.NamespaceCapabilityCSIWriteVolume)
aclObj, err := v.srv.WriteACLObj(&args.WriteRequest, false)
if err != nil {
return err
}
metricsStart := time.Now()
defer metrics.MeasureSince([]string{"nomad", "volume", "register"}, metricsStart)
if !allowVolume(aclObj, args.RequestNamespace()) || !aclObj.AllowPluginRead() {
return structs.ErrPermissionDenied
}
// This is the only namespace we ACL checked, force all the volumes to use it.
// We also validate that the plugin exists for each plugin, and validate the
// capabilities when the plugin has a controller.
for _, vol := range args.Volumes {
vol.Namespace = args.RequestNamespace()
if err = vol.Validate(); err != nil {
return err
}
plugin, err := v.srv.pluginValidateVolume(args, vol)
if err != nil {
return err
}
if err := v.srv.controllerValidateVolume(args, vol, plugin); err != nil {
return err
}
}
resp, index, err := v.srv.raftApply(structs.CSIVolumeRegisterRequestType, args)
if err != nil {
v.logger.Error("csi raft apply failed", "error", err, "method", "register")
return err
}
if respErr, ok := resp.(error); ok {
return respErr
}
reply.Index = index
v.srv.setQueryMeta(&reply.QueryMeta)
return nil
}
// Deregister removes a set of volumes
func (v *CSIVolume) Deregister(args *structs.CSIVolumeDeregisterRequest, reply *structs.CSIVolumeDeregisterResponse) error {
if done, err := v.srv.forward("CSIVolume.Deregister", args, args, reply); done {
return err
}
allowVolume := acl.NamespaceValidator(acl.NamespaceCapabilityCSIWriteVolume)
aclObj, err := v.srv.WriteACLObj(&args.WriteRequest, false)
if err != nil {
return err
}
metricsStart := time.Now()
defer metrics.MeasureSince([]string{"nomad", "volume", "deregister"}, metricsStart)
ns := args.RequestNamespace()
if !allowVolume(aclObj, ns) {
return structs.ErrPermissionDenied
}
resp, index, err := v.srv.raftApply(structs.CSIVolumeDeregisterRequestType, args)
if err != nil {
v.logger.Error("csi raft apply failed", "error", err, "method", "deregister")
return err
}
if respErr, ok := resp.(error); ok {
return respErr
}
reply.Index = index
v.srv.setQueryMeta(&reply.QueryMeta)
return nil
}
// Claim submits a change to a volume claim
func (v *CSIVolume) Claim(args *structs.CSIVolumeClaimRequest, reply *structs.CSIVolumeClaimResponse) error {
if done, err := v.srv.forward("CSIVolume.Claim", args, args, reply); done {
return err
}
allowVolume := acl.NamespaceValidator(acl.NamespaceCapabilityCSIMountVolume)
aclObj, err := v.srv.WriteACLObj(&args.WriteRequest, true)
if err != nil {
return err
}
metricsStart := time.Now()
defer metrics.MeasureSince([]string{"nomad", "volume", "claim"}, metricsStart)
if !allowVolume(aclObj, args.RequestNamespace()) || !aclObj.AllowPluginRead() {
return structs.ErrPermissionDenied
}
// if this is a new claim, add a Volume and PublishContext from the
// controller (if any) to the reply
if args.Claim != structs.CSIVolumeClaimRelease {
err = v.srv.controllerPublishVolume(args, reply)
if err != nil {
return fmt.Errorf("controller publish: %v", err)
}
}
resp, index, err := v.srv.raftApply(structs.CSIVolumeClaimRequestType, args)
if err != nil {
v.logger.Error("csi raft apply failed", "error", err, "method", "claim")
return err
}
if respErr, ok := resp.(error); ok {
return respErr
}
reply.Index = index
v.srv.setQueryMeta(&reply.QueryMeta)
return nil
}
// allowCSIMount is called on Job register to check mount permission
func allowCSIMount(aclObj *acl.ACL, namespace string) bool {
return aclObj.AllowPluginRead() &&
aclObj.AllowNsOp(namespace, acl.NamespaceCapabilityCSIMountVolume)
}
// CSIPlugin wraps the structs.CSIPlugin with request data and server context
type CSIPlugin struct {
srv *Server
logger log.Logger
}
// List replies with CSIPlugins, filtered by ACL access
func (v *CSIPlugin) List(args *structs.CSIPluginListRequest, reply *structs.CSIPluginListResponse) error {
if done, err := v.srv.forward("CSIPlugin.List", args, args, reply); done {
return err
}
aclObj, err := v.srv.QueryACLObj(&args.QueryOptions, false)
if err != nil {
return err
}
if !aclObj.AllowPluginList() {
return structs.ErrPermissionDenied
}
metricsStart := time.Now()
defer metrics.MeasureSince([]string{"nomad", "plugin", "list"}, metricsStart)
opts := blockingOptions{
queryOpts: &args.QueryOptions,
queryMeta: &reply.QueryMeta,
run: func(ws memdb.WatchSet, state *state.StateStore) error {
// Query all plugins
iter, err := state.CSIPlugins(ws)
if err != nil {
return err
}
// Collect results
var ps []*structs.CSIPluginListStub
for {
raw := iter.Next()
if raw == nil {
break
}
plug := raw.(*structs.CSIPlugin)
ps = append(ps, plug.Stub())
}
reply.Plugins = ps
return v.srv.replySetIndex(csiPluginTable, &reply.QueryMeta)
}}
return v.srv.blockingRPC(&opts)
}
// Get fetches detailed information about a specific plugin
func (v *CSIPlugin) Get(args *structs.CSIPluginGetRequest, reply *structs.CSIPluginGetResponse) error {
if done, err := v.srv.forward("CSIPlugin.Get", args, args, reply); done {
return err
}
aclObj, err := v.srv.QueryACLObj(&args.QueryOptions, false)
if err != nil {
return err
}
if !aclObj.AllowPluginRead() {
return structs.ErrPermissionDenied
}
withAllocs := aclObj == nil ||
aclObj.AllowNsOp(args.RequestNamespace(), acl.NamespaceCapabilityReadJob)
metricsStart := time.Now()
defer metrics.MeasureSince([]string{"nomad", "plugin", "get"}, metricsStart)
opts := blockingOptions{
queryOpts: &args.QueryOptions,
queryMeta: &reply.QueryMeta,
run: func(ws memdb.WatchSet, state *state.StateStore) error {
plug, err := state.CSIPluginByID(ws, args.ID)
if err != nil {
return err
}
if plug == nil {
return nil
}
if withAllocs {
plug, err = state.CSIPluginDenormalize(ws, plug.Copy())
if err != nil {
return err
}
// Filter the allocation stubs by our namespace. withAllocs
// means we're allowed
var as []*structs.AllocListStub
for _, a := range plug.Allocations {
if a.Namespace == args.RequestNamespace() {
as = append(as, a)
}
}
plug.Allocations = as
}
reply.Plugin = plug
return v.srv.replySetIndex(csiPluginTable, &reply.QueryMeta)
}}
return v.srv.blockingRPC(&opts)
}
// controllerPublishVolume sends publish request to the CSI controller
// plugin associated with a volume, if any.
func (srv *Server) controllerPublishVolume(req *structs.CSIVolumeClaimRequest, resp *structs.CSIVolumeClaimResponse) error {
plug, vol, err := srv.volAndPluginLookup(req.RequestNamespace(), req.VolumeID)
if err != nil {
return err
}
// Set the Response volume from the lookup
resp.Volume = vol
// Validate the existence of the allocation, regardless of whether we need it
// now.
state := srv.fsm.State()
ws := memdb.NewWatchSet()
alloc, err := state.AllocByID(ws, req.AllocationID)
if err != nil {
return err
}
if alloc == nil {
return fmt.Errorf("%s: %s", structs.ErrUnknownAllocationPrefix, req.AllocationID)
}
// if no plugin was returned then controller validation is not required.
// Here we can return nil.
if plug == nil {
return nil
}
// plugin IDs are not scoped to region/DC but volumes are.
// so any node we get for a controller is already in the same region/DC
// for the volume.
nodeID, err := srv.nodeForControllerPlugin(plug)
if err != nil || nodeID == "" {
return err
}
targetNode, err := state.NodeByID(ws, alloc.NodeID)
if err != nil {
return err
}
if targetNode == nil {
return fmt.Errorf("%s: %s", structs.ErrUnknownNodePrefix, alloc.NodeID)
}
targetCSIInfo, ok := targetNode.CSINodePlugins[plug.ID]
if !ok {
return fmt.Errorf("Failed to find NodeInfo for node: %s", targetNode.ID)
}
method := "ClientCSIController.AttachVolume"
cReq := &cstructs.ClientCSIControllerAttachVolumeRequest{
VolumeID: vol.RemoteID(),
ClientCSINodeID: targetCSIInfo.NodeInfo.ID,
AttachmentMode: vol.AttachmentMode,
AccessMode: vol.AccessMode,
ReadOnly: req.Claim == structs.CSIVolumeClaimRead,
}
cReq.PluginID = plug.ID
cReq.ControllerNodeID = nodeID
cResp := &cstructs.ClientCSIControllerAttachVolumeResponse{}
err = srv.RPC(method, cReq, cResp)
if err != nil {
return fmt.Errorf("attach volume: %v", err)
}
resp.PublishContext = cResp.PublishContext
return nil
}
// controllerUnpublishVolume sends an unpublish request to the CSI
// controller plugin associated with a volume, if any.
// TODO: the only caller of this won't have an alloc pointer handy, should it be its own request arg type?
func (srv *Server) controllerUnpublishVolume(req *structs.CSIVolumeClaimRequest, targetNomadNodeID string) error {
plug, vol, err := srv.volAndPluginLookup(req.RequestNamespace(), req.VolumeID)
if plug == nil || vol == nil || err != nil {
return err // possibly nil if no controller required
}
ws := memdb.NewWatchSet()
state := srv.State()
targetNode, err := state.NodeByID(ws, targetNomadNodeID)
if err != nil {
return err
}
if targetNode == nil {
return fmt.Errorf("%s: %s", structs.ErrUnknownNodePrefix, targetNomadNodeID)
}
targetCSIInfo, ok := targetNode.CSINodePlugins[plug.ID]
if !ok {
return fmt.Errorf("Failed to find NodeInfo for node: %s", targetNode.ID)
}
// plugin IDs are not scoped to region/DC but volumes are.
// so any node we get for a controller is already in the same region/DC
// for the volume.
nodeID, err := srv.nodeForControllerPlugin(plug)
if err != nil || nodeID == "" {
return err
}
method := "ClientCSIController.DetachVolume"
cReq := &cstructs.ClientCSIControllerDetachVolumeRequest{
VolumeID: vol.RemoteID(),
ClientCSINodeID: targetCSIInfo.NodeInfo.ID,
}
cReq.PluginID = plug.ID
cReq.ControllerNodeID = nodeID
return srv.RPC(method, cReq, &cstructs.ClientCSIControllerDetachVolumeResponse{})
}
func (srv *Server) volAndPluginLookup(namespace, volID string) (*structs.CSIPlugin, *structs.CSIVolume, error) {
state := srv.fsm.State()
ws := memdb.NewWatchSet()
vol, err := state.CSIVolumeByID(ws, namespace, volID)
if err != nil {
return nil, nil, err
}
if vol == nil {
return nil, nil, fmt.Errorf("volume not found: %s", volID)
}
if !vol.ControllerRequired {
return nil, vol, nil
}
// note: we do this same lookup in CSIVolumeByID but then throw
// away the pointer to the plugin rather than attaching it to
// the volume so we have to do it again here.
plug, err := state.CSIPluginByID(ws, vol.PluginID)
if err != nil {
return nil, nil, err
}
if plug == nil {
return nil, nil, fmt.Errorf("plugin not found: %s", vol.PluginID)
}
return plug, vol, nil
}
// nodeForControllerPlugin returns the node ID for a random controller
// to load-balance long-blocking RPCs across client nodes.
func (srv *Server) nodeForControllerPlugin(plugin *structs.CSIPlugin) (string, error) {
count := len(plugin.Controllers)
if count == 0 {
return "", fmt.Errorf("no controllers available for plugin %q", plugin.ID)
}
snap, err := srv.fsm.State().Snapshot()
if err != nil {
return "", err
}
// iterating maps is "random" but unspecified and isn't particularly
// random with small maps, so not well-suited for load balancing.
// so we shuffle the keys and iterate over them.
clientIDs := make([]string, count)
for clientID := range plugin.Controllers {
clientIDs = append(clientIDs, clientID)
}
rand.Shuffle(count, func(i, j int) {
clientIDs[i], clientIDs[j] = clientIDs[j], clientIDs[i]
})
for _, clientID := range clientIDs {
controller := plugin.Controllers[clientID]
if !controller.IsController() {
// we don't have separate types for CSIInfo depending on
// whether it's a controller or node. this error shouldn't
// make it to production but is to aid developers during
// development
err = fmt.Errorf("plugin is not a controller")
continue
}
_, err = getNodeForRpc(snap, clientID)
if err != nil {
continue
}
return clientID, nil
}
return "", err
}

728
nomad/csi_endpoint_test.go Normal file
View File

@ -0,0 +1,728 @@
package nomad
import (
"fmt"
"testing"
memdb "github.com/hashicorp/go-memdb"
msgpackrpc "github.com/hashicorp/net-rpc-msgpackrpc"
"github.com/hashicorp/nomad/acl"
"github.com/hashicorp/nomad/helper/uuid"
"github.com/hashicorp/nomad/nomad/mock"
"github.com/hashicorp/nomad/nomad/structs"
"github.com/hashicorp/nomad/testutil"
"github.com/stretchr/testify/require"
)
func TestCSIVolumeEndpoint_Get(t *testing.T) {
t.Parallel()
srv, shutdown := TestServer(t, func(c *Config) {
c.NumSchedulers = 0 // Prevent automatic dequeue
})
defer shutdown()
testutil.WaitForLeader(t, srv.RPC)
ns := structs.DefaultNamespace
state := srv.fsm.State()
codec := rpcClient(t, srv)
id0 := uuid.Generate()
// Create the volume
vols := []*structs.CSIVolume{{
ID: id0,
Namespace: ns,
AccessMode: structs.CSIVolumeAccessModeMultiNodeSingleWriter,
AttachmentMode: structs.CSIVolumeAttachmentModeFilesystem,
PluginID: "minnie",
}}
err := state.CSIVolumeRegister(999, vols)
require.NoError(t, err)
// Create the register request
req := &structs.CSIVolumeGetRequest{
ID: id0,
QueryOptions: structs.QueryOptions{
Region: "global",
Namespace: ns,
},
}
var resp structs.CSIVolumeGetResponse
err = msgpackrpc.CallWithCodec(codec, "CSIVolume.Get", req, &resp)
require.NoError(t, err)
require.Equal(t, uint64(999), resp.Index)
require.Equal(t, vols[0].ID, resp.Volume.ID)
}
func TestCSIVolumeEndpoint_Get_ACL(t *testing.T) {
t.Parallel()
srv, shutdown := TestServer(t, func(c *Config) {
c.NumSchedulers = 0 // Prevent automatic dequeue
})
defer shutdown()
testutil.WaitForLeader(t, srv.RPC)
ns := structs.DefaultNamespace
state := srv.fsm.State()
state.BootstrapACLTokens(1, 0, mock.ACLManagementToken())
srv.config.ACLEnabled = true
policy := mock.NamespacePolicy(ns, "", []string{acl.NamespaceCapabilityCSIReadVolume})
validToken := mock.CreatePolicyAndToken(t, state, 1001, "csi-access", policy)
codec := rpcClient(t, srv)
id0 := uuid.Generate()
// Create the volume
vols := []*structs.CSIVolume{{
ID: id0,
Namespace: ns,
AccessMode: structs.CSIVolumeAccessModeMultiNodeSingleWriter,
AttachmentMode: structs.CSIVolumeAttachmentModeFilesystem,
PluginID: "minnie",
}}
err := state.CSIVolumeRegister(999, vols)
require.NoError(t, err)
// Create the register request
req := &structs.CSIVolumeGetRequest{
ID: id0,
QueryOptions: structs.QueryOptions{
Region: "global",
Namespace: ns,
AuthToken: validToken.SecretID,
},
}
var resp structs.CSIVolumeGetResponse
err = msgpackrpc.CallWithCodec(codec, "CSIVolume.Get", req, &resp)
require.NoError(t, err)
require.Equal(t, uint64(999), resp.Index)
require.Equal(t, vols[0].ID, resp.Volume.ID)
}
func TestCSIVolumeEndpoint_Register(t *testing.T) {
t.Parallel()
srv, shutdown := TestServer(t, func(c *Config) {
c.NumSchedulers = 0 // Prevent automatic dequeue
})
defer shutdown()
testutil.WaitForLeader(t, srv.RPC)
ns := structs.DefaultNamespace
state := srv.fsm.State()
codec := rpcClient(t, srv)
id0 := uuid.Generate()
// Create the node and plugin
node := mock.Node()
node.CSINodePlugins = map[string]*structs.CSIInfo{
"minnie": {PluginID: "minnie",
Healthy: true,
// Registers as node plugin that does not require a controller to skip
// the client RPC during registration.
NodeInfo: &structs.CSINodeInfo{},
},
}
require.NoError(t, state.UpsertNode(1000, node))
// Create the volume
vols := []*structs.CSIVolume{{
ID: id0,
Namespace: "notTheNamespace",
PluginID: "minnie",
AccessMode: structs.CSIVolumeAccessModeMultiNodeReader,
AttachmentMode: structs.CSIVolumeAttachmentModeFilesystem,
}}
// Create the register request
req1 := &structs.CSIVolumeRegisterRequest{
Volumes: vols,
WriteRequest: structs.WriteRequest{
Region: "global",
Namespace: ns,
},
}
resp1 := &structs.CSIVolumeRegisterResponse{}
err := msgpackrpc.CallWithCodec(codec, "CSIVolume.Register", req1, resp1)
require.NoError(t, err)
require.NotEqual(t, uint64(0), resp1.Index)
// Get the volume back out
req2 := &structs.CSIVolumeGetRequest{
ID: id0,
QueryOptions: structs.QueryOptions{
Region: "global",
},
}
resp2 := &structs.CSIVolumeGetResponse{}
err = msgpackrpc.CallWithCodec(codec, "CSIVolume.Get", req2, resp2)
require.NoError(t, err)
require.Equal(t, resp1.Index, resp2.Index)
require.Equal(t, vols[0].ID, resp2.Volume.ID)
// Registration does not update
req1.Volumes[0].PluginID = "adam"
err = msgpackrpc.CallWithCodec(codec, "CSIVolume.Register", req1, resp1)
require.Error(t, err, "exists")
// Deregistration works
req3 := &structs.CSIVolumeDeregisterRequest{
VolumeIDs: []string{id0},
WriteRequest: structs.WriteRequest{
Region: "global",
Namespace: ns,
},
}
resp3 := &structs.CSIVolumeDeregisterResponse{}
err = msgpackrpc.CallWithCodec(codec, "CSIVolume.Deregister", req3, resp3)
require.NoError(t, err)
// Volume is missing
err = msgpackrpc.CallWithCodec(codec, "CSIVolume.Get", req2, resp2)
require.NoError(t, err)
require.Nil(t, resp2.Volume)
}
// TestCSIVolumeEndpoint_Claim exercises the VolumeClaim RPC, verifying that claims
// are honored only if the volume exists, the mode is permitted, and the volume
// is schedulable according to its count of claims.
func TestCSIVolumeEndpoint_Claim(t *testing.T) {
t.Parallel()
srv, shutdown := TestServer(t, func(c *Config) {
c.NumSchedulers = 0 // Prevent automatic dequeue
})
defer shutdown()
testutil.WaitForLeader(t, srv.RPC)
ns := "not-default-ns"
state := srv.fsm.State()
codec := rpcClient(t, srv)
id0 := uuid.Generate()
alloc := mock.BatchAlloc()
// Create an initial volume claim request; we expect it to fail
// because there's no such volume yet.
claimReq := &structs.CSIVolumeClaimRequest{
VolumeID: id0,
AllocationID: alloc.ID,
Claim: structs.CSIVolumeClaimWrite,
WriteRequest: structs.WriteRequest{
Region: "global",
Namespace: ns,
},
}
claimResp := &structs.CSIVolumeClaimResponse{}
err := msgpackrpc.CallWithCodec(codec, "CSIVolume.Claim", claimReq, claimResp)
require.EqualError(t, err, fmt.Sprintf("controller publish: volume not found: %s", id0),
"expected 'volume not found' error because volume hasn't yet been created")
// Create a client node, plugin, alloc, and volume
node := mock.Node()
node.CSINodePlugins = map[string]*structs.CSIInfo{
"minnie": {
PluginID: "minnie",
Healthy: true,
NodeInfo: &structs.CSINodeInfo{},
},
}
err = state.UpsertNode(1002, node)
require.NoError(t, err)
vols := []*structs.CSIVolume{{
ID: id0,
Namespace: ns,
PluginID: "minnie",
AccessMode: structs.CSIVolumeAccessModeMultiNodeSingleWriter,
AttachmentMode: structs.CSIVolumeAttachmentModeFilesystem,
Topologies: []*structs.CSITopology{{
Segments: map[string]string{"foo": "bar"},
}},
}}
err = state.CSIVolumeRegister(1003, vols)
require.NoError(t, err)
// Upsert the job and alloc
alloc.NodeID = node.ID
summary := mock.JobSummary(alloc.JobID)
require.NoError(t, state.UpsertJobSummary(1004, summary))
require.NoError(t, state.UpsertAllocs(1005, []*structs.Allocation{alloc}))
// Now our claim should succeed
err = msgpackrpc.CallWithCodec(codec, "CSIVolume.Claim", claimReq, claimResp)
require.NoError(t, err)
// Verify the claim was set
volGetReq := &structs.CSIVolumeGetRequest{
ID: id0,
QueryOptions: structs.QueryOptions{
Region: "global",
Namespace: ns,
},
}
volGetResp := &structs.CSIVolumeGetResponse{}
err = msgpackrpc.CallWithCodec(codec, "CSIVolume.Get", volGetReq, volGetResp)
require.NoError(t, err)
require.Equal(t, id0, volGetResp.Volume.ID)
require.Len(t, volGetResp.Volume.ReadAllocs, 0)
require.Len(t, volGetResp.Volume.WriteAllocs, 1)
// Make another writer claim for a different alloc
alloc2 := mock.Alloc()
summary = mock.JobSummary(alloc2.JobID)
require.NoError(t, state.UpsertJobSummary(1005, summary))
require.NoError(t, state.UpsertAllocs(1006, []*structs.Allocation{alloc2}))
claimReq.AllocationID = alloc2.ID
err = msgpackrpc.CallWithCodec(codec, "CSIVolume.Claim", claimReq, claimResp)
require.EqualError(t, err, "volume max claim reached",
"expected 'volume max claim reached' because we only allow 1 writer")
// Fix the mode and our claim will succeed
claimReq.Claim = structs.CSIVolumeClaimRead
err = msgpackrpc.CallWithCodec(codec, "CSIVolume.Claim", claimReq, claimResp)
require.NoError(t, err)
// Verify the new claim was set
err = msgpackrpc.CallWithCodec(codec, "CSIVolume.Get", volGetReq, volGetResp)
require.NoError(t, err)
require.Equal(t, id0, volGetResp.Volume.ID)
require.Len(t, volGetResp.Volume.ReadAllocs, 1)
require.Len(t, volGetResp.Volume.WriteAllocs, 1)
// Claim is idempotent
err = msgpackrpc.CallWithCodec(codec, "CSIVolume.Claim", claimReq, claimResp)
require.NoError(t, err)
err = msgpackrpc.CallWithCodec(codec, "CSIVolume.Get", volGetReq, volGetResp)
require.NoError(t, err)
require.Equal(t, id0, volGetResp.Volume.ID)
require.Len(t, volGetResp.Volume.ReadAllocs, 1)
require.Len(t, volGetResp.Volume.WriteAllocs, 1)
}
// TestCSIVolumeEndpoint_ClaimWithController exercises the VolumeClaim RPC
// when a controller is required.
func TestCSIVolumeEndpoint_ClaimWithController(t *testing.T) {
t.Parallel()
srv, shutdown := TestServer(t, func(c *Config) {
c.ACLEnabled = true
c.NumSchedulers = 0 // Prevent automatic dequeue
})
defer shutdown()
testutil.WaitForLeader(t, srv.RPC)
ns := structs.DefaultNamespace
state := srv.fsm.State()
state.BootstrapACLTokens(1, 0, mock.ACLManagementToken())
policy := mock.NamespacePolicy(ns, "", []string{acl.NamespaceCapabilityCSIMountVolume}) +
mock.PluginPolicy("read")
accessToken := mock.CreatePolicyAndToken(t, state, 1001, "claim", policy)
codec := rpcClient(t, srv)
id0 := uuid.Generate()
// Create a client node, plugin, alloc, and volume
node := mock.Node()
node.Attributes["nomad.version"] = "0.11.0" // client RPCs not supported on early version
node.CSIControllerPlugins = map[string]*structs.CSIInfo{
"minnie": {
PluginID: "minnie",
Healthy: true,
ControllerInfo: &structs.CSIControllerInfo{
SupportsAttachDetach: true,
},
RequiresControllerPlugin: true,
},
}
node.CSINodePlugins = map[string]*structs.CSIInfo{
"minnie": {
PluginID: "minnie",
Healthy: true,
NodeInfo: &structs.CSINodeInfo{},
},
}
err := state.UpsertNode(1002, node)
require.NoError(t, err)
vols := []*structs.CSIVolume{{
ID: id0,
Namespace: ns,
PluginID: "minnie",
ControllerRequired: true,
AccessMode: structs.CSIVolumeAccessModeMultiNodeSingleWriter,
AttachmentMode: structs.CSIVolumeAttachmentModeFilesystem,
}}
err = state.CSIVolumeRegister(1003, vols)
alloc := mock.BatchAlloc()
alloc.NodeID = node.ID
summary := mock.JobSummary(alloc.JobID)
require.NoError(t, state.UpsertJobSummary(1004, summary))
require.NoError(t, state.UpsertAllocs(1005, []*structs.Allocation{alloc}))
// Make the volume claim
claimReq := &structs.CSIVolumeClaimRequest{
VolumeID: id0,
AllocationID: alloc.ID,
Claim: structs.CSIVolumeClaimWrite,
WriteRequest: structs.WriteRequest{
Region: "global",
Namespace: ns,
AuthToken: accessToken.SecretID,
},
}
claimResp := &structs.CSIVolumeClaimResponse{}
err = msgpackrpc.CallWithCodec(codec, "CSIVolume.Claim", claimReq, claimResp)
// Because the node is not registered
require.EqualError(t, err, "controller publish: attach volume: No path to node")
}
func TestCSIVolumeEndpoint_List(t *testing.T) {
t.Parallel()
srv, shutdown := TestServer(t, func(c *Config) {
c.NumSchedulers = 0 // Prevent automatic dequeue
})
defer shutdown()
testutil.WaitForLeader(t, srv.RPC)
ns := structs.DefaultNamespace
ms := "altNamespace"
state := srv.fsm.State()
state.BootstrapACLTokens(1, 0, mock.ACLManagementToken())
srv.config.ACLEnabled = true
codec := rpcClient(t, srv)
nsPolicy := mock.NamespacePolicy(ns, "", []string{acl.NamespaceCapabilityCSIReadVolume}) +
mock.PluginPolicy("read")
nsTok := mock.CreatePolicyAndToken(t, state, 1000, "csi-access", nsPolicy)
id0 := uuid.Generate()
id1 := uuid.Generate()
id2 := uuid.Generate()
// Create the volume
vols := []*structs.CSIVolume{{
ID: id0,
Namespace: ns,
AccessMode: structs.CSIVolumeAccessModeMultiNodeReader,
AttachmentMode: structs.CSIVolumeAttachmentModeFilesystem,
PluginID: "minnie",
}, {
ID: id1,
Namespace: ns,
AccessMode: structs.CSIVolumeAccessModeMultiNodeSingleWriter,
AttachmentMode: structs.CSIVolumeAttachmentModeFilesystem,
PluginID: "adam",
}, {
ID: id2,
Namespace: ms,
AccessMode: structs.CSIVolumeAccessModeMultiNodeSingleWriter,
AttachmentMode: structs.CSIVolumeAttachmentModeFilesystem,
PluginID: "paddy",
}}
err := state.CSIVolumeRegister(1002, vols)
require.NoError(t, err)
var resp structs.CSIVolumeListResponse
// Query everything in the namespace
req := &structs.CSIVolumeListRequest{
QueryOptions: structs.QueryOptions{
Region: "global",
AuthToken: nsTok.SecretID,
Namespace: ns,
},
}
err = msgpackrpc.CallWithCodec(codec, "CSIVolume.List", req, &resp)
require.NoError(t, err)
require.Equal(t, uint64(1002), resp.Index)
require.Equal(t, 2, len(resp.Volumes))
ids := map[string]bool{vols[0].ID: true, vols[1].ID: true}
for _, v := range resp.Volumes {
delete(ids, v.ID)
}
require.Equal(t, 0, len(ids))
// Query by PluginID in ns
req = &structs.CSIVolumeListRequest{
PluginID: "adam",
QueryOptions: structs.QueryOptions{
Region: "global",
Namespace: ns,
AuthToken: nsTok.SecretID,
},
}
err = msgpackrpc.CallWithCodec(codec, "CSIVolume.List", req, &resp)
require.NoError(t, err)
require.Equal(t, 1, len(resp.Volumes))
require.Equal(t, vols[1].ID, resp.Volumes[0].ID)
// Query by PluginID in ms
msPolicy := mock.NamespacePolicy(ms, "", []string{acl.NamespaceCapabilityCSIListVolume}) +
mock.PluginPolicy("read")
msTok := mock.CreatePolicyAndToken(t, state, 1003, "csi-access2", msPolicy)
req = &structs.CSIVolumeListRequest{
PluginID: "paddy",
QueryOptions: structs.QueryOptions{
Region: "global",
Namespace: ms,
AuthToken: msTok.SecretID,
},
}
err = msgpackrpc.CallWithCodec(codec, "CSIVolume.List", req, &resp)
require.NoError(t, err)
require.Equal(t, 1, len(resp.Volumes))
}
func TestCSIPluginEndpoint_RegisterViaFingerprint(t *testing.T) {
t.Parallel()
srv, shutdown := TestServer(t, func(c *Config) {
c.NumSchedulers = 0 // Prevent automatic dequeue
})
defer shutdown()
testutil.WaitForLeader(t, srv.RPC)
deleteNodes := CreateTestCSIPlugin(srv.fsm.State(), "foo")
defer deleteNodes()
state := srv.fsm.State()
state.BootstrapACLTokens(1, 0, mock.ACLManagementToken())
srv.config.ACLEnabled = true
codec := rpcClient(t, srv)
// Get the plugin back out
listJob := mock.NamespacePolicy(structs.DefaultNamespace, "", []string{acl.NamespaceCapabilityReadJob})
policy := mock.PluginPolicy("read") + listJob
getToken := mock.CreatePolicyAndToken(t, state, 1001, "plugin-read", policy)
req2 := &structs.CSIPluginGetRequest{
ID: "foo",
QueryOptions: structs.QueryOptions{
Region: "global",
AuthToken: getToken.SecretID,
},
}
resp2 := &structs.CSIPluginGetResponse{}
err := msgpackrpc.CallWithCodec(codec, "CSIPlugin.Get", req2, resp2)
require.NoError(t, err)
// Get requires plugin-read, not plugin-list
lPolicy := mock.PluginPolicy("list")
lTok := mock.CreatePolicyAndToken(t, state, 1003, "plugin-list", lPolicy)
req2.AuthToken = lTok.SecretID
err = msgpackrpc.CallWithCodec(codec, "CSIPlugin.Get", req2, resp2)
require.Error(t, err, "Permission denied")
// List plugins
req3 := &structs.CSIPluginListRequest{
QueryOptions: structs.QueryOptions{
Region: "global",
AuthToken: getToken.SecretID,
},
}
resp3 := &structs.CSIPluginListResponse{}
err = msgpackrpc.CallWithCodec(codec, "CSIPlugin.List", req3, resp3)
require.NoError(t, err)
require.Equal(t, 1, len(resp3.Plugins))
// ensure that plugin->alloc denormalization does COW correctly
err = msgpackrpc.CallWithCodec(codec, "CSIPlugin.List", req3, resp3)
require.NoError(t, err)
require.Equal(t, 1, len(resp3.Plugins))
// List allows plugin-list
req3.AuthToken = lTok.SecretID
err = msgpackrpc.CallWithCodec(codec, "CSIPlugin.List", req3, resp3)
require.NoError(t, err)
require.Equal(t, 1, len(resp3.Plugins))
// Deregistration works
deleteNodes()
// Plugin is missing
req2.AuthToken = getToken.SecretID
err = msgpackrpc.CallWithCodec(codec, "CSIPlugin.Get", req2, resp2)
require.NoError(t, err)
require.Nil(t, resp2.Plugin)
}
// TestCSIPluginEndpoint_ACLNamespaceAlloc checks that allocations are filtered by namespace
// when getting plugins, and enforcing that the client has job-read ACL access to the
// namespace of the allocations
func TestCSIPluginEndpoint_ACLNamespaceAlloc(t *testing.T) {
t.Parallel()
srv, shutdown := TestServer(t, func(c *Config) {
c.NumSchedulers = 0 // Prevent automatic dequeue
})
defer shutdown()
testutil.WaitForLeader(t, srv.RPC)
state := srv.fsm.State()
// Setup ACLs
state.BootstrapACLTokens(1, 0, mock.ACLManagementToken())
srv.config.ACLEnabled = true
codec := rpcClient(t, srv)
listJob := mock.NamespacePolicy(structs.DefaultNamespace, "", []string{acl.NamespaceCapabilityReadJob})
policy := mock.PluginPolicy("read") + listJob
getToken := mock.CreatePolicyAndToken(t, state, 1001, "plugin-read", policy)
// Create the plugin and then some allocations to pretend to be the allocs that are
// running the plugin tasks
deleteNodes := CreateTestCSIPlugin(srv.fsm.State(), "foo")
defer deleteNodes()
plug, _ := state.CSIPluginByID(memdb.NewWatchSet(), "foo")
var allocs []*structs.Allocation
for _, info := range plug.Controllers {
a := mock.Alloc()
a.ID = info.AllocID
allocs = append(allocs, a)
}
for _, info := range plug.Nodes {
a := mock.Alloc()
a.ID = info.AllocID
allocs = append(allocs, a)
}
require.Equal(t, 3, len(allocs))
allocs[0].Namespace = "notTheNamespace"
err := state.UpsertAllocs(1003, allocs)
require.NoError(t, err)
req := &structs.CSIPluginGetRequest{
ID: "foo",
QueryOptions: structs.QueryOptions{
Region: "global",
AuthToken: getToken.SecretID,
},
}
resp := &structs.CSIPluginGetResponse{}
err = msgpackrpc.CallWithCodec(codec, "CSIPlugin.Get", req, resp)
require.NoError(t, err)
require.Equal(t, 2, len(resp.Plugin.Allocations))
for _, a := range resp.Plugin.Allocations {
require.Equal(t, structs.DefaultNamespace, a.Namespace)
}
p2 := mock.PluginPolicy("read")
t2 := mock.CreatePolicyAndToken(t, state, 1004, "plugin-read2", p2)
req.AuthToken = t2.SecretID
err = msgpackrpc.CallWithCodec(codec, "CSIPlugin.Get", req, resp)
require.NoError(t, err)
require.Equal(t, 0, len(resp.Plugin.Allocations))
}
func TestCSI_RPCVolumeAndPluginLookup(t *testing.T) {
srv, shutdown := TestServer(t, func(c *Config) {})
defer shutdown()
testutil.WaitForLeader(t, srv.RPC)
state := srv.fsm.State()
id0 := uuid.Generate()
id1 := uuid.Generate()
id2 := uuid.Generate()
ns := "notTheNamespace"
// Create a client node with a plugin
node := mock.Node()
node.CSINodePlugins = map[string]*structs.CSIInfo{
"minnie": {PluginID: "minnie", Healthy: true, RequiresControllerPlugin: true,
ControllerInfo: &structs.CSIControllerInfo{SupportsAttachDetach: true},
},
"adam": {PluginID: "adam", Healthy: true},
}
err := state.UpsertNode(3, node)
require.NoError(t, err)
// Create 2 volumes
vols := []*structs.CSIVolume{
{
ID: id0,
Namespace: ns,
PluginID: "minnie",
AccessMode: structs.CSIVolumeAccessModeMultiNodeSingleWriter,
AttachmentMode: structs.CSIVolumeAttachmentModeFilesystem,
ControllerRequired: true,
},
{
ID: id1,
Namespace: ns,
PluginID: "adam",
AccessMode: structs.CSIVolumeAccessModeMultiNodeSingleWriter,
AttachmentMode: structs.CSIVolumeAttachmentModeFilesystem,
ControllerRequired: false,
},
}
err = state.CSIVolumeRegister(1002, vols)
require.NoError(t, err)
// has controller
plugin, vol, err := srv.volAndPluginLookup(ns, id0)
require.NotNil(t, plugin)
require.NotNil(t, vol)
require.NoError(t, err)
// no controller
plugin, vol, err = srv.volAndPluginLookup(ns, id1)
require.Nil(t, plugin)
require.NotNil(t, vol)
require.NoError(t, err)
// doesn't exist
plugin, vol, err = srv.volAndPluginLookup(ns, id2)
require.Nil(t, plugin)
require.Nil(t, vol)
require.EqualError(t, err, fmt.Sprintf("volume not found: %s", id2))
}
func TestCSI_NodeForControllerPlugin(t *testing.T) {
t.Parallel()
srv, shutdown := TestServer(t, func(c *Config) {})
testutil.WaitForLeader(t, srv.RPC)
defer shutdown()
plugins := map[string]*structs.CSIInfo{
"minnie": {PluginID: "minnie",
Healthy: true,
ControllerInfo: &structs.CSIControllerInfo{},
NodeInfo: &structs.CSINodeInfo{},
RequiresControllerPlugin: true,
},
}
state := srv.fsm.State()
node1 := mock.Node()
node1.Attributes["nomad.version"] = "0.11.0" // client RPCs not supported on early versions
node1.CSIControllerPlugins = plugins
node2 := mock.Node()
node2.CSIControllerPlugins = plugins
node2.ID = uuid.Generate()
node3 := mock.Node()
node3.ID = uuid.Generate()
err := state.UpsertNode(1002, node1)
require.NoError(t, err)
err = state.UpsertNode(1003, node2)
require.NoError(t, err)
err = state.UpsertNode(1004, node3)
require.NoError(t, err)
ws := memdb.NewWatchSet()
plugin, err := state.CSIPluginByID(ws, "minnie")
require.NoError(t, err)
nodeID, err := srv.nodeForControllerPlugin(plugin)
// only node1 has both the controller and a recent Nomad version
require.Equal(t, nodeID, node1.ID)
}

View File

@ -260,6 +260,12 @@ func (n *nomadFSM) Apply(log *raft.Log) interface{} {
return n.applyUpsertSIAccessor(buf[1:], log.Index) return n.applyUpsertSIAccessor(buf[1:], log.Index)
case structs.ServiceIdentityAccessorDeregisterRequestType: case structs.ServiceIdentityAccessorDeregisterRequestType:
return n.applyDeregisterSIAccessor(buf[1:], log.Index) return n.applyDeregisterSIAccessor(buf[1:], log.Index)
case structs.CSIVolumeRegisterRequestType:
return n.applyCSIVolumeRegister(buf[1:], log.Index)
case structs.CSIVolumeDeregisterRequestType:
return n.applyCSIVolumeDeregister(buf[1:], log.Index)
case structs.CSIVolumeClaimRequestType:
return n.applyCSIVolumeClaim(buf[1:], log.Index)
} }
// Check enterprise only message types. // Check enterprise only message types.
@ -1114,6 +1120,66 @@ func (n *nomadFSM) applySchedulerConfigUpdate(buf []byte, index uint64) interfac
return n.state.SchedulerSetConfig(index, &req.Config) return n.state.SchedulerSetConfig(index, &req.Config)
} }
func (n *nomadFSM) applyCSIVolumeRegister(buf []byte, index uint64) interface{} {
var req structs.CSIVolumeRegisterRequest
if err := structs.Decode(buf, &req); err != nil {
panic(fmt.Errorf("failed to decode request: %v", err))
}
defer metrics.MeasureSince([]string{"nomad", "fsm", "apply_csi_volume_register"}, time.Now())
if err := n.state.CSIVolumeRegister(index, req.Volumes); err != nil {
n.logger.Error("CSIVolumeRegister failed", "error", err)
return err
}
return nil
}
func (n *nomadFSM) applyCSIVolumeDeregister(buf []byte, index uint64) interface{} {
var req structs.CSIVolumeDeregisterRequest
if err := structs.Decode(buf, &req); err != nil {
panic(fmt.Errorf("failed to decode request: %v", err))
}
defer metrics.MeasureSince([]string{"nomad", "fsm", "apply_csi_volume_deregister"}, time.Now())
if err := n.state.CSIVolumeDeregister(index, req.RequestNamespace(), req.VolumeIDs); err != nil {
n.logger.Error("CSIVolumeDeregister failed", "error", err)
return err
}
return nil
}
func (n *nomadFSM) applyCSIVolumeClaim(buf []byte, index uint64) interface{} {
var req structs.CSIVolumeClaimRequest
if err := structs.Decode(buf, &req); err != nil {
panic(fmt.Errorf("failed to decode request: %v", err))
}
defer metrics.MeasureSince([]string{"nomad", "fsm", "apply_csi_volume_claim"}, time.Now())
ws := memdb.NewWatchSet()
alloc, err := n.state.AllocByID(ws, req.AllocationID)
if err != nil {
n.logger.Error("AllocByID failed", "error", err)
return err
}
if alloc == nil {
n.logger.Error("AllocByID failed to find alloc", "alloc_id", req.AllocationID)
if err != nil {
return err
}
return structs.ErrUnknownAllocationPrefix
}
if err := n.state.CSIVolumeClaim(index, req.RequestNamespace(), req.VolumeID, alloc, req.Claim); err != nil {
n.logger.Error("CSIVolumeClaim failed", "error", err)
return err
}
return nil
}
func (n *nomadFSM) Snapshot() (raft.FSMSnapshot, error) { func (n *nomadFSM) Snapshot() (raft.FSMSnapshot, error) {
// Create a new snapshot // Create a new snapshot
snap, err := n.state.Snapshot() snap, err := n.state.Snapshot()

Some files were not shown because too many files have changed in this diff Show More