Merge pull request #7012 from hashicorp/f-csi-volumes
Container Storage Interface Support
This commit is contained in:
commit
076fbbf08f
38
acl/acl.go
38
acl/acl.go
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -11,5 +11,7 @@ const (
|
|||
Nodes Context = "nodes"
|
||||
Namespaces Context = "namespaces"
|
||||
Quotas Context = "quotas"
|
||||
Plugins Context = "plugins"
|
||||
Volumes Context = "volumes"
|
||||
All Context = "all"
|
||||
)
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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")
|
||||
}
|
47
api/nodes.go
47
api/nodes.go
|
@ -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
|
||||
|
|
59
api/tasks.go
59
api/tasks.go
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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{
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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() {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
|
@ -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),
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -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"
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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{}
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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))
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
|
|
|
@ -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{
|
||||
|
|
|
@ -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"))
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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])
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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])
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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 +1,2 @@
|
|||
provisioning.json
|
||||
csi/input/volumes.json
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
|
|
|
@ -48,6 +48,7 @@ data "aws_iam_policy_document" "auto_discover_cluster" {
|
|||
"ec2:DescribeTags",
|
||||
"ec2:DescribeVolume*",
|
||||
"ec2:AttachVolume",
|
||||
"ec2:DetachVolume",
|
||||
"autoscaling:DescribeAutoScalingGroups",
|
||||
]
|
||||
resources = ["*"]
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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{}
|
|
@ -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)
|
||||
}
|
|
@ -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")
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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{
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
||||
}
|
|
@ -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")
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
66
nomad/fsm.go
66
nomad/fsm.go
|
@ -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
Loading…
Reference in New Issue