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
operator string
quota string
plugin string
}
// maxPrivilege returns the policy which grants the most privilege
@ -74,6 +75,8 @@ func maxPrivilege(a, b string) string {
return PolicyWrite
case a == PolicyRead || b == PolicyRead:
return PolicyRead
case a == PolicyList || b == PolicyList:
return PolicyList
default:
return ""
}
@ -193,6 +196,9 @@ func NewACL(management bool, policies []*Policy) (*ACL, error) {
if policy.Quota != nil {
acl.quota = maxPrivilege(acl.quota, policy.Quota.Policy)
}
if policy.Plugin != nil {
acl.plugin = maxPrivilege(acl.plugin, policy.Plugin.Policy)
}
}
// 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
func (a *ACL) IsManagement() bool {
return a.management

View File

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

View File

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

View File

@ -399,6 +399,36 @@ type NodeScoreMeta struct {
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
// during list operations.
type AllocationListStub struct {
@ -477,18 +507,23 @@ func (a AllocIndexSort) Swap(i, j int) {
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
// according to the given time and the task groups reschedule policy
func (a Allocation) RescheduleInfo(t time.Time) (int, int) {
var reschedulePolicy *ReschedulePolicy
for _, tg := range a.Job.TaskGroups {
if *tg.Name == a.TaskGroup {
reschedulePolicy = tg.ReschedulePolicy
}
}
if reschedulePolicy == nil {
tg := a.GetTaskGroup()
if tg == nil || tg.ReschedulePolicy == nil {
return 0, 0
}
reschedulePolicy := tg.ReschedulePolicy
availableAttempts := *reschedulePolicy.Attempts
interval := *reschedulePolicy.Interval
attempted := 0

View File

@ -11,5 +11,7 @@ const (
Nodes Context = "nodes"
Namespaces Context = "namespaces"
Quotas Context = "quotas"
Plugins Context = "plugins"
Volumes Context = "volumes"
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
}
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.
func (n *Nodes) ForceEvaluate(nodeID string, q *WriteOptions) (string, *WriteMeta, error) {
var resp nodeEvalResponse
@ -464,6 +474,8 @@ type Node struct {
Events []*NodeEvent
Drivers map[string]*DriverInfo
HostVolumes map[string]*HostVolumeInfo
CSIControllerPlugins map[string]*CSIInfo
CSINodePlugins map[string]*CSIInfo
CreateIndex uint64
ModifyIndex uint64
}
@ -511,6 +523,41 @@ type NodeReservedNetworkResources struct {
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.
type DrainStrategy struct {
// 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.
type VolumeRequest struct {
Name string
Type string
Source string
ReadOnly bool `mapstructure:"read_only"`
Name string
Type string
Source string
ReadOnly bool `hcl:"read_only"`
MountOptions *CSIMountOptions `hcl:"mount_options"`
ExtraKeysHCL []string `hcl:",unusedKeys" json:"-"`
}
const (
@ -643,6 +645,7 @@ type Task struct {
Templates []*Template
DispatchPayload *DispatchPayloadConfig
VolumeMounts []*VolumeMount
CSIPluginConfig *TaskCSIPluginConfig `mapstructure:"csi_plugin" json:"csi_plugin,omitempty"`
Leader bool
ShutdownDelay time.Duration `mapstructure:"shutdown_delay"`
KillSignal string `mapstructure:"kill_signal"`
@ -683,6 +686,9 @@ func (t *Task) Canonicalize(tg *TaskGroup, job *Job) {
if t.Lifecycle.Empty() {
t.Lifecycle = nil
}
if t.CSIPluginConfig != nil {
t.CSIPluginConfig.Canonicalize()
}
}
// TaskArtifact is used to download artifacts before running a task.
@ -909,3 +915,48 @@ type TaskEvent struct {
TaskSignal 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/consul"
"github.com/hashicorp/nomad/client/devicemanager"
"github.com/hashicorp/nomad/client/dynamicplugins"
cinterfaces "github.com/hashicorp/nomad/client/interfaces"
"github.com/hashicorp/nomad/client/pluginmanager/csimanager"
"github.com/hashicorp/nomad/client/pluginmanager/drivermanager"
cstate "github.com/hashicorp/nomad/client/state"
cstructs "github.com/hashicorp/nomad/client/structs"
@ -118,6 +120,10 @@ type allocRunner struct {
// transistions.
runnerHooks []interfaces.RunnerHook
// hookState is the output of allocrunner hooks
hookState *cstructs.AllocHookResources
hookStateMu sync.RWMutex
// tasks are the set of task runners
tasks map[string]*taskrunner.TaskRunner
@ -134,6 +140,14 @@ type allocRunner struct {
// prevAllocMigrator allows the migration of a previous allocations alloc dir.
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
// statistics
devicemanager devicemanager.Manager
@ -148,6 +162,15 @@ type allocRunner struct {
serversContactedCh chan struct{}
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.
@ -178,9 +201,12 @@ func NewAllocRunner(config *Config) (*allocRunner, error) {
deviceStatsReporter: config.DeviceStatsReporter,
prevAllocWatcher: config.PrevAllocWatcher,
prevAllocMigrator: config.PrevAllocMigrator,
dynamicRegistry: config.DynamicRegistry,
csiManager: config.CSIManager,
devicemanager: config.DeviceManager,
driverManager: config.DriverManager,
serversContactedCh: config.ServersContactedCh,
rpcClient: config.RPCClient,
}
// Create the logger based on the allocation ID
@ -218,10 +244,12 @@ func (ar *allocRunner) initTaskRunners(tasks []*structs.Task) error {
Logger: ar.logger,
StateDB: ar.stateDB,
StateUpdater: ar,
DynamicRegistry: ar.dynamicRegistry,
Consul: ar.consulClient,
ConsulSI: ar.sidsClient,
Vault: ar.vaultClient,
DeviceStatsReporter: ar.deviceStatsReporter,
CSIManager: ar.csiManager,
DeviceManager: ar.devicemanager,
DriverManager: ar.driverManager,
ServersContactedCh: ar.serversContactedCh,

View File

@ -7,11 +7,41 @@ import (
multierror "github.com/hashicorp/go-multierror"
"github.com/hashicorp/nomad/client/allocrunner/interfaces"
clientconfig "github.com/hashicorp/nomad/client/config"
cstructs "github.com/hashicorp/nomad/client/structs"
"github.com/hashicorp/nomad/client/taskenv"
"github.com/hashicorp/nomad/nomad/structs"
"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 {
SetNetworkIsolation(*drivers.NetworkIsolationSpec)
}
@ -105,6 +135,10 @@ func (ar *allocRunner) initRunnerHooks(config *clientconfig.Config) error {
// create network isolation setting shim
ns := &allocNetworkIsolationSetter{ar: ar}
// create hook resource setting shim
hrs := &allocHookResourceSetter{ar: ar}
hrs.SetAllocHookResources(&cstructs.AllocHookResources{})
// build the network manager
nm, err := newNetworkManager(ar.Alloc(), ar.driverManager)
if err != nil {
@ -134,6 +168,7 @@ func (ar *allocRunner) initRunnerHooks(config *clientconfig.Config) error {
logger: hookLogger,
}),
newConsulSockHook(hookLogger, alloc, ar.allocDir, config.ConsulConfig),
newCSIHook(hookLogger, alloc, ar.rpcClient, ar.csiManager, hrs),
}
return nil

View File

@ -6,7 +6,9 @@ import (
clientconfig "github.com/hashicorp/nomad/client/config"
"github.com/hashicorp/nomad/client/consul"
"github.com/hashicorp/nomad/client/devicemanager"
"github.com/hashicorp/nomad/client/dynamicplugins"
"github.com/hashicorp/nomad/client/interfaces"
"github.com/hashicorp/nomad/client/pluginmanager/csimanager"
"github.com/hashicorp/nomad/client/pluginmanager/drivermanager"
cstate "github.com/hashicorp/nomad/client/state"
"github.com/hashicorp/nomad/client/vaultclient"
@ -48,6 +50,14 @@ type Config struct {
// PrevAllocMigrator allows the migration of a previous allocations alloc dir
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
// statistics
DeviceManager devicemanager.Manager
@ -58,4 +68,8 @@ type Config struct {
// ServersContactedCh is closed when the first GetClientAllocs call to
// servers succeeds and allocs are synced.
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/consul"
"github.com/hashicorp/nomad/client/devicemanager"
"github.com/hashicorp/nomad/client/dynamicplugins"
cinterfaces "github.com/hashicorp/nomad/client/interfaces"
"github.com/hashicorp/nomad/client/pluginmanager/csimanager"
"github.com/hashicorp/nomad/client/pluginmanager/drivermanager"
cstate "github.com/hashicorp/nomad/client/state"
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 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
// statistics
devicemanager devicemanager.Manager
@ -194,6 +199,9 @@ type TaskRunner struct {
// handlers
driverManager drivermanager.Manager
// dynamicRegistry is where dynamic plugins should be registered.
dynamicRegistry dynamicplugins.Registry
// maxEvents is the capacity of the TaskEvents on the TaskState.
// Defaults to defaultMaxEvents but overrideable for testing.
maxEvents int
@ -212,6 +220,8 @@ type TaskRunner struct {
networkIsolationLock sync.Mutex
networkIsolationSpec *drivers.NetworkIsolationSpec
allocHookResources *cstructs.AllocHookResources
}
type Config struct {
@ -227,6 +237,9 @@ type Config struct {
// ConsulSI is the client to use for managing Consul SI tokens
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 vaultclient.VaultClient
@ -239,6 +252,9 @@ type Config struct {
// deviceStatsReporter is used to lookup resource usage for alloc devices
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
// statistics
DeviceManager devicemanager.Manager
@ -285,6 +301,7 @@ func NewTaskRunner(config *Config) (*TaskRunner, error) {
taskName: config.Task.Name,
taskLeader: config.Task.Leader,
envBuilder: envBuilder,
dynamicRegistry: config.DynamicRegistry,
consulClient: config.Consul,
siClient: config.ConsulSI,
vaultClient: config.Vault,
@ -299,6 +316,7 @@ func NewTaskRunner(config *Config) (*TaskRunner, error) {
shutdownCtxCancel: trCancel,
triggerUpdateCh: make(chan struct{}, triggerUpdateChCap),
waitCh: make(chan struct{}),
csiManager: config.CSIManager,
devicemanager: config.DeviceManager,
driverManager: config.DriverManager,
maxEvents: defaultMaxEvents,
@ -1392,3 +1410,7 @@ func (tr *TaskRunner) TaskExecHandler() drivermanager.TaskExecHandler {
func (tr *TaskRunner) DriverCapabilities() (*drivers.Capabilities, error) {
return tr.driver.Capabilities()
}
func (tr *TaskRunner) SetAllocHookResources(res *cstructs.AllocHookResources) {
tr.allocHookResources = res
}

View File

@ -3,6 +3,7 @@ package taskrunner
import (
"context"
"fmt"
"path/filepath"
"sync"
"time"
@ -69,6 +70,11 @@ func (tr *TaskRunner) initHooks() {
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 task.Vault != nil {
tr.runnerHooks = append(tr.runnerHooks, newVaultHook(&vaultHookConfig{

View File

@ -7,14 +7,16 @@ import (
log "github.com/hashicorp/go-hclog"
multierror "github.com/hashicorp/go-multierror"
"github.com/hashicorp/nomad/client/allocrunner/interfaces"
"github.com/hashicorp/nomad/client/taskenv"
"github.com/hashicorp/nomad/nomad/structs"
"github.com/hashicorp/nomad/plugins/drivers"
)
type volumeHook struct {
alloc *structs.Allocation
runner *TaskRunner
logger log.Logger
alloc *structs.Allocation
runner *TaskRunner
logger log.Logger
taskEnv *taskenv.TaskEnv
}
func newVolumeHook(runner *TaskRunner, logger log.Logger) *volumeHook {
@ -34,6 +36,8 @@ func validateHostVolumes(requestedByAlias map[string]*structs.VolumeRequest, cli
var result error
for _, req := range requestedByAlias {
// This is a defensive check, but this function should only ever receive
// host-type volumes.
if req.Type != structs.VolumeTypeHost {
continue
}
@ -55,8 +59,16 @@ func (h *volumeHook) hostVolumeMountConfigurations(taskMounts []*structs.VolumeM
for _, m := range taskMounts {
req, ok := taskVolumesByAlias[m.Volume]
if !ok {
// Should never happen unless we misvalidated on job submission
return nil, fmt.Errorf("No group volume declaration found named: %s", m.Volume)
// This function receives only the task volumes that are of type Host,
// 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]
@ -77,22 +89,100 @@ func (h *volumeHook) hostVolumeMountConfigurations(taskMounts []*structs.VolumeM
return mounts, nil
}
func (h *volumeHook) Prestart(ctx context.Context, req *interfaces.TaskPrestartRequest, resp *interfaces.TaskPrestartResponse) error {
volumes := h.alloc.Job.LookupTaskGroup(h.alloc.TaskGroup).Volumes
mounts := h.runner.hookResources.getMounts()
// partitionVolumesByType takes a map of volume-alias to volume-request and
// returns them in the form of volume-type:(volume-alias:volume-request)
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
// 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 err := validateHostVolumes(volumes, hostVolumes); err != nil {
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 {
h.logger.Error("Failed to generate volume mounts", "error", 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
// 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"
REQUESTED:
for _, m := range requestedMounts {
for _, em := range mounts {
if em.IsEqual(m) {
continue REQUESTED
}
}
mounts = append(mounts, m)
mounts := h.runner.hookResources.getMounts()
for _, m := range hostVolumeMounts {
mounts = ensureMountpointInserted(mounts, m)
}
for _, m := range csiVolumeMounts {
mounts = ensureMountpointInserted(mounts, m)
}
h.runner.hookResources.setMounts(mounts)
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"
consulApi "github.com/hashicorp/nomad/client/consul"
"github.com/hashicorp/nomad/client/devicemanager"
"github.com/hashicorp/nomad/client/dynamicplugins"
"github.com/hashicorp/nomad/client/fingerprint"
"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/servers"
"github.com/hashicorp/nomad/client/state"
@ -42,6 +44,7 @@ import (
"github.com/hashicorp/nomad/helper/uuid"
"github.com/hashicorp/nomad/nomad/structs"
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/drivers"
vaultapi "github.com/hashicorp/vault/api"
@ -258,6 +261,9 @@ type Client struct {
// pluginManagers is the set of PluginManagers registered by the client
pluginManagers *pluginmanager.PluginGroup
// csimanager is responsible for managing csi plugins.
csimanager csimanager.Manager
// devicemanger is responsible for managing device plugins.
devicemanager devicemanager.Manager
@ -279,6 +285,10 @@ type Client struct {
// successfully run once.
serversContactedCh chan struct{}
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 (
@ -336,6 +346,7 @@ func NewClient(cfg *config.Config, consulCatalog consul.CatalogAPI, consulServic
c.batchNodeUpdates = newBatchNodeUpdates(
c.updateNodeFromDriver,
c.updateNodeFromDevices,
c.updateNodeFromCSI,
)
// Initialize the server manager
@ -344,11 +355,22 @@ func NewClient(cfg *config.Config, consulCatalog consul.CatalogAPI, consulServic
// Start server manager rebalancing go routine
go c.servers.Start()
// Initialize the client
// initialize the client
if err := c.init(); err != nil {
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
c.setupClientRpc()
@ -383,6 +405,16 @@ func NewClient(cfg *config.Config, consulCatalog consul.CatalogAPI, consulServic
allowlistDrivers := cfg.ReadStringListToMap("driver.whitelist")
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
driverConfig := &drivermanager.Config{
Logger: c.logger,
@ -1054,9 +1086,12 @@ func (c *Client) restoreState() error {
Vault: c.vaultClient,
PrevAllocWatcher: prevAllocWatcher,
PrevAllocMigrator: prevAllocMigrator,
DynamicRegistry: c.dynamicRegistry,
CSIManager: c.csimanager,
DeviceManager: c.devicemanager,
DriverManager: c.drivermanager,
ServersContactedCh: c.serversContactedCh,
RPCClient: c,
}
c.configLock.RUnlock()
@ -1279,6 +1314,12 @@ func (c *Client) setupNode() error {
if node.Drivers == nil {
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 {
node.Meta = make(map[string]string)
}
@ -2310,8 +2351,11 @@ func (c *Client) addAlloc(alloc *structs.Allocation, migrateToken string) error
DeviceStatsReporter: c,
PrevAllocWatcher: prevAllocWatcher,
PrevAllocMigrator: prevAllocMigrator,
DynamicRegistry: c.dynamicRegistry,
CSIManager: c.csimanager,
DeviceManager: c.devicemanager,
DriverManager: c.drivermanager,
RPCClient: c,
}
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.
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
// device plugins.
func (m *manager) Run() {

View File

@ -2,10 +2,10 @@ package state
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
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
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"
"github.com/hashicorp/nomad/client/devicemanager"
"github.com/hashicorp/nomad/client/pluginmanager/csimanager"
"github.com/hashicorp/nomad/client/pluginmanager/drivermanager"
"github.com/hashicorp/nomad/nomad/structs"
)
@ -40,6 +41,23 @@ SEND_BATCH:
c.configLock.Lock()
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
var driverChanged bool
c.batchNodeUpdates.batchDriverUpdates(func(driver string, info *structs.DriverInfo) {
@ -61,13 +79,128 @@ SEND_BATCH:
})
// only update the node if changes occurred
if driverChanged || devicesChanged {
if driverChanged || devicesChanged || csiChanged {
c.updateNodeLocked()
}
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
// the node accordingly
func (c *Client) updateNodeFromDriver(name string, info *structs.DriverInfo) {
@ -187,20 +320,71 @@ type batchNodeUpdates struct {
devicesBatched bool
devicesCB devicemanager.UpdateNodeDevicesFn
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(
driverCB drivermanager.UpdateNodeDriverInfoFn,
devicesCB devicemanager.UpdateNodeDevicesFn) *batchNodeUpdates {
devicesCB devicemanager.UpdateNodeDevicesFn,
csiCB csimanager.UpdateNodeCSIInfoFunc) *batchNodeUpdates {
return &batchNodeUpdates{
drivers: make(map[string]*structs.DriverInfo),
driverCB: driverCB,
devices: []*structs.NodeDeviceResource{},
devicesCB: devicesCB,
drivers: make(map[string]*structs.DriverInfo),
driverCB: driverCB,
devices: []*structs.NodeDeviceResource{},
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
// used in the driver manager to send driver fingerprints to
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"
// 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
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
ReattachConfigs map[string]*pstructs.ReattachConfig
}

View File

@ -20,10 +20,11 @@ import (
// rpcEndpoints holds the RPC endpoints
type rpcEndpoints struct {
ClientStats *ClientStats
FileSystem *FileSystem
Allocations *Allocations
Agent *Agent
ClientStats *ClientStats
CSIController *CSIController
FileSystem *FileSystem
Allocations *Allocations
Agent *Agent
}
// 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() {
// Initialize the RPC handlers
c.endpoints.ClientStats = &ClientStats{c}
c.endpoints.CSIController = &CSIController{c}
c.endpoints.FileSystem = NewFileSystemEndpoint(c)
c.endpoints.Allocations = NewAllocationsEndpoint(c)
c.endpoints.Agent = NewAgentEndpoint(c)
@ -234,6 +236,7 @@ func (c *Client) setupClientRpc() {
func (c *Client) setupClientRpcServer(server *rpc.Server) {
// Register the endpoints
server.Register(c.endpoints.ClientStats)
server.Register(c.endpoints.CSIController)
server.Register(c.endpoints.FileSystem)
server.Register(c.endpoints.Allocations)
server.Register(c.endpoints.Agent)

View File

@ -8,6 +8,7 @@ import (
trstate "github.com/hashicorp/nomad/client/allocrunner/taskrunner/state"
dmstate "github.com/hashicorp/nomad/client/devicemanager/state"
"github.com/hashicorp/nomad/client/dynamicplugins"
driverstate "github.com/hashicorp/nomad/client/pluginmanager/drivermanager/state"
"github.com/hashicorp/nomad/helper/testlog"
"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
// succeeds.
func TestStateDB_Upgrade(t *testing.T) {

View File

@ -3,6 +3,7 @@ package state
import (
"github.com/hashicorp/nomad/client/allocrunner/taskrunner/state"
dmstate "github.com/hashicorp/nomad/client/devicemanager/state"
"github.com/hashicorp/nomad/client/dynamicplugins"
driverstate "github.com/hashicorp/nomad/client/pluginmanager/drivermanager/state"
"github.com/hashicorp/nomad/nomad/structs"
)
@ -69,6 +70,12 @@ type StateDB interface {
// state.
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
// of return value.
Close() error

View File

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

View File

@ -3,6 +3,7 @@ package state
import (
"github.com/hashicorp/nomad/client/allocrunner/taskrunner/state"
dmstate "github.com/hashicorp/nomad/client/devicemanager/state"
"github.com/hashicorp/nomad/client/dynamicplugins"
driverstate "github.com/hashicorp/nomad/client/pluginmanager/drivermanager/state"
"github.com/hashicorp/nomad/nomad/structs"
)
@ -70,6 +71,14 @@ func (n NoopDB) GetDriverPluginState() (*driverstate.PluginState, error) {
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 {
return nil
}

View File

@ -11,6 +11,7 @@ import (
hclog "github.com/hashicorp/go-hclog"
trstate "github.com/hashicorp/nomad/client/allocrunner/taskrunner/state"
dmstate "github.com/hashicorp/nomad/client/devicemanager/state"
"github.com/hashicorp/nomad/client/dynamicplugins"
driverstate "github.com/hashicorp/nomad/client/pluginmanager/drivermanager/state"
"github.com/hashicorp/nomad/helper/boltdd"
"github.com/hashicorp/nomad/nomad/structs"
@ -34,7 +35,10 @@ devicemanager/
|--> plugin_state -> *dmstate.PluginState
drivermanager/
|--> plugin_state -> *dmstate.PluginState
|--> plugin_state -> *driverstate.PluginState
dynamicplugins/
|--> registry_state -> *dynamicplugins.RegistryState
*/
var (
@ -73,13 +77,20 @@ var (
// data
devManagerBucket = []byte("devicemanager")
// driverManagerBucket is the bucket name container all driver manager
// driverManagerBucket is the bucket name containing all driver manager
// related data
driverManagerBucket = []byte("drivermanager")
// managerPluginStateKey is the key by which plugin manager plugin state is
// stored at
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.
@ -598,6 +609,52 @@ func (s *BoltStateDB) GetDriverPluginState() (*driverstate.PluginState, error) {
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.
func (s *BoltStateDB) init() 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"
"os"
"path/filepath"
"reflect"
"strings"
"time"
"github.com/hashicorp/hcl"
"github.com/hashicorp/nomad/helper"
"github.com/hashicorp/nomad/nomad/structs/config"
)
@ -110,49 +109,33 @@ func durations(xs []td) error {
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 {
// hcl leaves behind extra keys when parsing JSON. These keys
// are kept on the top level, taken from slices or the keys of
// structs contained in slices. Clean up before looking for
// extra keys.
for range c.HTTPAPIResponseHeaders {
removeEqualFold(&c.ExtraKeysHCL, "http_api_response_headers")
helper.RemoveEqualFold(&c.ExtraKeysHCL, "http_api_response_headers")
}
for _, p := range c.Plugins {
removeEqualFold(&c.ExtraKeysHCL, p.Name)
removeEqualFold(&c.ExtraKeysHCL, "config")
removeEqualFold(&c.ExtraKeysHCL, "plugin")
helper.RemoveEqualFold(&c.ExtraKeysHCL, p.Name)
helper.RemoveEqualFold(&c.ExtraKeysHCL, "config")
helper.RemoveEqualFold(&c.ExtraKeysHCL, "plugin")
}
for _, k := range []string{"options", "meta", "chroot_env", "servers", "server_join"} {
removeEqualFold(&c.ExtraKeysHCL, k)
removeEqualFold(&c.ExtraKeysHCL, "client")
helper.RemoveEqualFold(&c.ExtraKeysHCL, k)
helper.RemoveEqualFold(&c.ExtraKeysHCL, "client")
}
// 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
for _, hv := range c.Client.HostVolumes {
removeEqualFold(&c.Client.ExtraKeysHCL, hv.Name)
removeEqualFold(&c.Client.ExtraKeysHCL, "host_volume")
helper.RemoveEqualFold(&c.Client.ExtraKeysHCL, hv.Name)
helper.RemoveEqualFold(&c.Client.ExtraKeysHCL, "host_volume")
}
// 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"} {
removeEqualFold(&c.ExtraKeysHCL, k)
removeEqualFold(&c.ExtraKeysHCL, "server")
helper.RemoveEqualFold(&c.ExtraKeysHCL, k)
helper.RemoveEqualFold(&c.ExtraKeysHCL, "server")
}
for _, k := range []string{"datadog_tags"} {
removeEqualFold(&c.ExtraKeysHCL, k)
removeEqualFold(&c.ExtraKeysHCL, "telemetry")
helper.RemoveEqualFold(&c.ExtraKeysHCL, k)
helper.RemoveEqualFold(&c.ExtraKeysHCL, "telemetry")
}
return extraKeysImpl([]string{}, reflect.ValueOf(*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]
}
return helper.UnusedKeys(c)
}

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/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/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 {
tg.Volumes = make(map[string]*structs.VolumeRequest, l)
for k, v := range taskGroup.Volumes {
if v.Type != structs.VolumeTypeHost {
// Ignore non-host volumes in this iteration currently.
if v.Type != structs.VolumeTypeHost && v.Type != structs.VolumeTypeCSI {
// 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
}
@ -761,6 +762,13 @@ func ApiTgToStructsTG(taskGroup *api.TaskGroup, tg *structs.TaskGroup) {
Source: v.Source,
}
if v.MountOptions != nil {
vol.MountOptions = &structs.CSIMountOptions{
FSType: v.MountOptions.FSType,
MountFlags: v.MountOptions.MountFlags,
}
}
tg.Volumes[k] = vol
}
}
@ -812,6 +820,7 @@ func ApiTaskToStructsTask(apiTask *api.Task, structsTask *structs.Task) {
structsTask.Kind = structs.TaskKind(apiTask.Kind)
structsTask.Constraints = ApiConstraintsToStructs(apiTask.Constraints)
structsTask.Affinities = ApiAffinitiesToStructs(apiTask.Affinities)
structsTask.CSIPluginConfig = ApiCSIPluginConfigToStructsCSIPluginConfig(apiTask.CSIPluginConfig)
if l := len(apiTask.VolumeMounts); l != 0 {
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 {
if in == nil {
return nil

View File

@ -12,6 +12,7 @@ import (
"github.com/hashicorp/nomad/api"
"github.com/hashicorp/nomad/api/contexts"
"github.com/hashicorp/nomad/client/allocrunner/taskrunner/restarts"
"github.com/hashicorp/nomad/nomad/structs"
"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.outputTaskDetails(alloc, stats, displayStats)
c.outputTaskDetails(alloc, stats, displayStats, verbose)
}
// 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,
// 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) {
state := alloc.TaskStates[task]
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.Ui.Output("")
c.outputTaskVolumes(alloc, task, verbose)
c.outputTaskStatus(state)
}
}
@ -721,3 +723,80 @@ func (c *AllocStatusCommand) sortedTaskStateIterator(m map[string]*api.TaskState
close(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 (
"fmt"
"io/ioutil"
"os"
"regexp"
"strings"
"testing"
"time"
"github.com/hashicorp/nomad/command/agent"
"github.com/hashicorp/nomad/helper/uuid"
"github.com/hashicorp/nomad/nomad/mock"
"github.com/hashicorp/nomad/nomad/structs"
@ -315,3 +318,146 @@ func TestAllocStatusCommand_AutocompleteArgs(t *testing.T) {
assert.Equal(1, len(res))
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
},
"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) {
return &QuotaCommand{
Meta: meta,
@ -646,6 +657,26 @@ func Commands(metaPtr *Meta, agentUi cli.Ui) map[string]cli.CommandFactory {
Ui: meta.Ui,
}, 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{

View File

@ -299,6 +299,40 @@ func nodeDrivers(n *api.Node) []string {
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 {
var volumes []string
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 {
// 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
basic := []string{
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("Eligibility|%s", node.SchedulingEligibility),
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 {
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), ",")))
c.Ui.Output(c.Colorize().Color(formatKV(basic)))
// 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))
return 1
}
@ -371,7 +422,7 @@ func (c *NodeStatusCommand) formatNode(client *api.Client, node *api.Node) int {
// driver info in the basic output
if !c.verbose {
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))
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 c.verbose {
c.outputNodeVolumeInfo(node)
c.outputNodeCSIVolumeInfo(client, node, runningAllocs)
c.outputNodeDriverInfo(node)
}
@ -389,12 +441,6 @@ func (c *NodeStatusCommand) formatNode(client *api.Client, node *api.Node) int {
c.outputNodeStatusEvents(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)
c.Ui.Output(c.Colorize().Color("\n[bold]Allocated Resources[reset]"))
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))
return 1
}
@ -440,12 +486,7 @@ func (c *NodeStatusCommand) formatNode(client *api.Client, node *api.Node) int {
return 0
}
func (c *NodeStatusCommand) outputAllocInfo(client *api.Client, node *api.Node) error {
nodeAllocs, _, err := client.Nodes().Allocations(node.ID, nil)
if err != nil {
return fmt.Errorf("Error querying node allocations: %s", err)
}
func (c *NodeStatusCommand) outputAllocInfo(node *api.Node, nodeAllocs []*api.Allocation) error {
c.Ui.Output(c.Colorize().Color("\n[bold]Allocations[reset]"))
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))
}
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) {
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}
case contexts.Quotas:
cmd = &QuotaStatusCommand{Meta: c.Meta}
case contexts.Plugins:
cmd = &PluginStatusCommand{Meta: c.Meta}
case contexts.Volumes:
cmd = &VolumeStatusCommand{Meta: c.Meta}
default:
c.Ui.Error(fmt.Sprintf("Unable to resolve ID: %q", id))
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)
* [ ] Update help text
* [ ] 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 Client RPC endpoint in
`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
`nomad/structs/structs.go`. Append the constant, old constant
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
* [ ] State method for modifying objects in a `Txn` in `nomad/state/state_store.go`
* `nomad/state/state_store_test.go`
* [ ] Handler for the request in `nomad/foo_endpoint.go`
* RPCs are resolved by matching the method name for bound structs
[net/rpc](https://golang.org/pkg/net/rpc/)
* 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`
* Backwards compatibility requires a new endpoint, an upgraded
client or server may be forwarding this request to an old server,
without support for the new RPC
* RPCs triggered by an internal process may not need support
* Check ACLs as an optimization
* [ ] `nomad/core_sched.go` sends many RPCs
* `ServersMeetMinimumVersion` asserts that the server cluster is
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
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/consul"
_ "github.com/hashicorp/nomad/e2e/consultemplate"
_ "github.com/hashicorp/nomad/e2e/csi"
_ "github.com/hashicorp/nomad/e2e/deployment"
_ "github.com/hashicorp/nomad/e2e/example"
_ "github.com/hashicorp/nomad/e2e/hostvolumes"

View File

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

View File

@ -9,6 +9,15 @@ export NOMAD_E2E=1
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" {
description = "output to a file to be use w/ E2E framework -provision.terraform"
value = jsonencode(

View File

@ -3,7 +3,9 @@ package helper
import (
"crypto/sha512"
"fmt"
"reflect"
"regexp"
"strings"
"time"
multierror "github.com/hashicorp/go-multierror"
@ -387,3 +389,75 @@ func CheckHCLKeys(node ast.Node, valid []string) error {
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 {
volumes := make(map[string]*api.VolumeRequest, len(list.Items))
hcl.DecodeObject(out, list)
for _, item := range list.Items {
n := item.Keys[0].Token.Value().(string)
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,
})
for k, v := range *out {
err := helper.UnusedKeys(v)
if err != nil {
return err
}
if err := dec.Decode(m); err != nil {
return err
}
result.Name = n
volumes[n] = &result
// This is supported by `hcl:",key"`, but that only works if we start at the
// parent ast.ObjectItem
v.Name = k
}
*out = volumes
return nil
}

View File

@ -74,6 +74,7 @@ func parseTask(item *ast.ObjectItem) (*api.Task, error) {
"kill_signal",
"kind",
"volume_mount",
"csi_plugin",
}
if err := helper.CheckHCLKeys(listVal, valid); err != nil {
return nil, err
@ -97,6 +98,7 @@ func parseTask(item *ast.ObjectItem) (*api.Task, error) {
delete(m, "template")
delete(m, "vault")
delete(m, "volume_mount")
delete(m, "csi_plugin")
// Build the task
var t api.Task
@ -135,6 +137,25 @@ func parseTask(item *ast.ObjectItem) (*api.Task, error) {
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 o := listVal.Filter("config"); len(o.Items) > 0 {
for _, o := range o.Elem().Items {

View File

@ -117,11 +117,32 @@ func TestParse(t *testing.T) {
Operand: "=",
},
},
Volumes: map[string]*api.VolumeRequest{
"foo": {
Name: "foo",
Type: "host",
Name: "foo",
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{
@ -569,6 +590,30 @@ func TestParse(t *testing.T) {
},
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",
&api.Job{

View File

@ -71,7 +71,26 @@ job "binstore-storagelocker" {
count = 5
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 {

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
stream, err := session.Open()
if err != nil {
return err
return fmt.Errorf("session open: %v", err)
}
defer stream.Close()
// Write the RpcNomad byte to set the mode
if _, err := stream.Write([]byte{byte(pool.RpcNomad)}); err != nil {
stream.Close()
return err
return fmt.Errorf("set mode: %v", err)
}
// Make the RPC

View File

@ -3,10 +3,12 @@ package nomad
import (
"fmt"
"math"
"strings"
"time"
log "github.com/hashicorp/go-hclog"
memdb "github.com/hashicorp/go-memdb"
multierror "github.com/hashicorp/go-multierror"
version "github.com/hashicorp/go-version"
"github.com/hashicorp/nomad/nomad/state"
"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
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:
return c.evalGC(eval)
case structs.CoreJobNodeGC:
@ -50,6 +53,8 @@ func (c *CoreScheduler) Process(eval *structs.Evaluation) error {
return c.jobGC(eval)
case structs.CoreJobDeploymentGC:
return c.deploymentGC(eval)
case structs.CoreJobCSIVolumeClaimGC:
return c.csiVolumeClaimGC(eval)
case structs.CoreJobForceGC:
return c.forceGC(eval)
default:
@ -141,6 +146,7 @@ OUTER:
gcAlloc = append(gcAlloc, jobAlloc...)
gcEval = append(gcEval, jobEval...)
}
}
// Fast-path the nothing case
@ -150,6 +156,11 @@ OUTER:
c.logger.Debug("job GC found eligible objects",
"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
if err := c.evalReap(gcEval, gcAlloc); err != nil {
return err
@ -703,3 +714,124 @@ func allocGCEligible(a *structs.Allocation, job *structs.Job, gcTime time.Time,
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
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)
case structs.ServiceIdentityAccessorDeregisterRequestType:
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.
@ -1114,6 +1120,66 @@ func (n *nomadFSM) applySchedulerConfigUpdate(buf []byte, index uint64) interfac
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) {
// Create a new snapshot
snap, err := n.state.Snapshot()

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