Repository: keikoproj/instance-manager (go)

Issue:
Add mountOptions to volumes
We should add an `mountOptions` to volumes to support use cases of initializing a file system and mounting it.
The root volume is already initialized by the AMI, but additional EBS volumes only gets attached to the EC2 - which is pretty useless for the user.

Current API
```
volumes:
  - name: <string> : represents the device name, e.g. /dev/xvda (required)
  type: <string> : represents the type of volume, must be one of supported types "standard", "io1", "gp2", "st1", "sc1" (required)
  size: <int64> : represents a volume size in gigabytes, cannot be used with snapshotId
  snapshotId : <string> : represents a snapshot ID to use, cannot be used with size
  iops: <int64> : represents number of IOPS to provision volume with (min 100)
  deleteOnTermination : <bool> : delete the EBS volume when the instance is terminated (defaults to true)
  encrypted: <bool> : encrypt the EBS volume with a KMS key
```

Proposed changes
```
volumes:
  - name: /dev/xvdb
  type: gp2
  size: 32
  iops: 100
  deleteOnTermination: true
  encrypted: true

  # describes options for automatic formatting/mounting
  mountOptions:
    fileSystem: XFS    # support XFS or EXT4
    mount: /data         # where to mount the volume
    persistance: true  # should volume persist after reboots
```

using mountOptions will allow a user to customize a specific EBS volume and make it usable.
This will inject pre-bootstrap userdata to make sure the volume is formatted and mounted by using `mkfs` and modify `/etc/fstab` for persistence

Reference:
https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ebs-using-volumes.html

Pull request:
Add mountOptions to NodeVolume to initialize volumes
Fixes #167 

This PR adds support for mountOptions under NodeVolume struct, which when used injects commands into UserData to create a filesystem and mount it.

TBD:
- [x] Unit test
- [x] Docs
- [x] BDD Passing

Patch:
diff --git a/api/v1alpha1/instancegroup_types.go b/api/v1alpha1/instancegroup_types.go
index 69c4d5e..4c0af7e 100644
--- a/api/v1alpha1/instancegroup_types.go
+++ b/api/v1alpha1/instancegroup_types.go
@@ -67,6 +67,9 @@ const (
 	ForbidConcurrencyPolicy  = "forbid"
 	AllowConcurrencyPolicy   = "allow"
 	ReplaceConcurrencyPolicy = "replace"
+
+	FileSystemTypeXFS  = "xfs"
+	FileSystemTypeEXT4 = "ext4"
 )
 
 var (
@@ -84,6 +87,8 @@ var (
 		},
 	}
 
+	AllowedFileSystemTypes = []string{FileSystemTypeXFS, FileSystemTypeEXT4}
+
 	log = ctrl.Log.WithName("v1alpha1")
 )
 
@@ -192,14 +197,22 @@ type UserDataStage struct {
 }
 
 type NodeVolume struct {
-	Name                string `json:"name"`
-	Type                string `json:"type"`
-	Size                int64  `json:"size"`
-	Iops                int64  `json:"iops,omitempty"`
-	DeleteOnTermination *bool  `json:"deleteOnTermination,omitempty"`
-	Encrypted           *bool  `json:"encrypted,omitempty"`
-	SnapshotID          string `json:"snapshotId,omitempty"`
+	Name                string                  `json:"name"`
+	Type                string                  `json:"type"`
+	Size                int64                   `json:"size"`
+	Iops                int64                   `json:"iops,omitempty"`
+	DeleteOnTermination *bool                   `json:"deleteOnTermination,omitempty"`
+	Encrypted           *bool                   `json:"encrypted,omitempty"`
+	SnapshotID          string                  `json:"snapshotId,omitempty"`
+	MountOptions        *NodeVolumeMountOptions `json:"mountOptions,omitempty"`
+}
+
+type NodeVolumeMountOptions struct {
+	FileSystem  string `json:"fileSystem,omitempty"`
+	Mount       string `json:"mount,omitempty"`
+	Persistance *bool  `json:"persistance,omitempty"`
 }
+
 type EKSFargateSpec struct {
 	ClusterName         string                `json:"clusterName"`
 	PodExecutionRoleArn string                `json:"podExecutionRoleArn,omitempty"`
@@ -322,6 +335,11 @@ func (c *EKSConfiguration) Validate() error {
 		if !common.ContainsEqualFold(awsprovider.AllowedVolumeTypes, v.Type) {
 			return errors.Errorf("validation failed, volume type '%v' is unsuppoeted", v.Type)
 		}
+
+		if v.Iops != 0 && !strings.EqualFold(v.Type, "io1") {
+			log.Info("cannot apply IOPS configuration for volumeType, only type 'io1' supported", "volumeType", v.Type)
+		}
+
 		if v.SnapshotID != "" {
 			if v.Size > 0 {
 				return errors.Errorf("validation failed, 'volume.snapshotId' and 'volume.size' are mutually exclusive")
diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go
index dd009eb..0b20b85 100644
--- a/api/v1alpha1/zz_generated.deepcopy.go
+++ b/api/v1alpha1/zz_generated.deepcopy.go
@@ -429,6 +429,11 @@ func (in *NodeVolume) DeepCopyInto(out *NodeVolume) {
 		*out = new(bool)
 		**out = **in
 	}
+	if in.MountOptions != nil {
+		in, out := &in.MountOptions, &out.MountOptions
+		*out = new(NodeVolumeMountOptions)
+		(*in).DeepCopyInto(*out)
+	}
 }
 
 // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NodeVolume.
@@ -441,6 +446,26 @@ func (in *NodeVolume) DeepCopy() *NodeVolume {
 	return out
 }
 
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *NodeVolumeMountOptions) DeepCopyInto(out *NodeVolumeMountOptions) {
+	*out = *in
+	if in.Persistance != nil {
+		in, out := &in.Persistance, &out.Persistance
+		*out = new(bool)
+		**out = **in
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NodeVolumeMountOptions.
+func (in *NodeVolumeMountOptions) DeepCopy() *NodeVolumeMountOptions {
+	if in == nil {
+		return nil
+	}
+	out := new(NodeVolumeMountOptions)
+	in.DeepCopyInto(out)
+	return out
+}
+
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *RollingUpdateStrategy) DeepCopyInto(out *RollingUpdateStrategy) {
 	*out = *in
diff --git a/config/crd/bases/instancemgr.keikoproj.io_instancegroups.yaml b/config/crd/bases/instancemgr.keikoproj.io_instancegroups.yaml
index f44888a..de98e72 100644
--- a/config/crd/bases/instancemgr.keikoproj.io_instancegroups.yaml
+++ b/config/crd/bases/instancemgr.keikoproj.io_instancegroups.yaml
@@ -173,6 +173,15 @@ spec:
                           iops:
                             format: int64
                             type: integer
+                          mountOptions:
+                            properties:
+                              fileSystem:
+                                type: string
+                              mount:
+                                type: string
+                              persistance:
+                                type: boolean
+                            type: object
                           name:
                             type: string
                           size:
diff --git a/controllers/providers/aws/aws.go b/controllers/providers/aws/aws.go
index 2a72064..e07fdee 100644
--- a/controllers/providers/aws/aws.go
+++ b/controllers/providers/aws/aws.go
@@ -16,11 +16,8 @@ limitations under the License.
 package aws
 
 import (
-	"bytes"
-	"encoding/base64"
 	"os"
 	"strings"
-	"text/template"
 	"time"
 
 	"github.com/aws/aws-sdk-go/aws"
@@ -112,31 +109,6 @@ const (
 	LaunchConfigurationNotFoundErrorMessage = "Launch configuration name not found"
 )
 
-type EKSUserData struct {
-	ClusterName   string
-	Arguments     string
-	PreBootstrap  []string
-	PostBootstrap []string
-}
-
-var UserDataPayload = `#!/bin/bash
-{{range $pre := .PreBootstrap}}{{$pre}}{{end}}
-set -o xtrace
-/etc/eks/bootstrap.sh {{ .ClusterName }} {{ .Arguments }}
-set +o xtrace
-{{range $post := .PostBootstrap}}{{$post}}{{end}}`
-
-func RenderUserData(data EKSUserData) []byte {
-	out := &bytes.Buffer{}
-	tmpl := template.New("userData")
-	var err error
-	if tmpl, err = tmpl.Parse(UserDataPayload); err != nil {
-		log.Error(err, "failed to parse userData template")
-	}
-	tmpl.Execute(out, data)
-	return out.Bytes()
-}
-
 func (w *AwsWorker) RoleExist(name string) (*iam.Role, bool) {
 	out, err := w.GetRole(name)
 	if err != nil {
@@ -176,7 +148,7 @@ func (w *AwsWorker) GetBasicBlockDevice(name, volType, snapshot string, volSize,
 	if encrypt != nil {
 		device.Ebs.Encrypted = encrypt
 	}
-	if iops != 0 {
+	if iops != 0 && strings.EqualFold(volType, "io1") {
 		device.Ebs.Iops = aws.Int64(iops)
 	}
 	if volSize != 0 {
@@ -281,16 +253,6 @@ func (w *AwsWorker) SetResumeProcesses(name string, processesToResume []string)
 	return nil
 }
 
-func (w *AwsWorker) GetBasicUserData(clusterName, args string, preScript, postScript []string) string {
-	userData := RenderUserData(EKSUserData{
-		ClusterName:   clusterName,
-		Arguments:     args,
-		PreBootstrap:  preScript,
-		PostBootstrap: postScript,
-	})
-	return base64.StdEncoding.EncodeToString(userData)
-}
-
 func (w *AwsWorker) NewTag(key, val, resource string) *autoscaling.Tag {
 	return &autoscaling.Tag{
 		Key:               aws.String(key),
diff --git a/controllers/provisioners/eks/create.go b/controllers/provisioners/eks/create.go
index aaa09a6..07f8118 100644
--- a/controllers/provisioners/eks/create.go
+++ b/controllers/provisioners/eks/create.go
@@ -31,17 +31,19 @@ import (
 
 func (ctx *EksInstanceGroupContext) Create() error {
 	var (
-		configName            string
-		instanceGroup         = ctx.GetInstanceGroup()
-		state                 = ctx.GetDiscoveredState()
-		scalingConfig         = state.GetScalingConfiguration()
-		configuration         = instanceGroup.GetEKSConfiguration()
-		args                  = ctx.GetBootstrapArgs()
-		preScript, postScript = ctx.GetUserDataStages()
-		clusterName           = configuration.GetClusterName()
-		userData              = ctx.AwsWorker.GetBasicUserData(clusterName, args, preScript, postScript)
-		sgs                   = ctx.ResolveSecurityGroups()
-		spotPrice             = configuration.GetSpotPrice()
+		configName    string
+		instanceGroup = ctx.GetInstanceGroup()
+		state         = ctx.GetDiscoveredState()
+		scalingConfig = state.GetScalingConfiguration()
+		configuration = instanceGroup.GetEKSConfiguration()
+		args          = ctx.GetBootstrapArgs()
+
+		userDataPayload = ctx.GetUserDataStages()
+		clusterName     = configuration.GetClusterName()
+		mounts          = ctx.GetMountOpts()
+		userData        = ctx.GetBasicUserData(clusterName, args, userDataPayload, mounts)
+		sgs             = ctx.ResolveSecurityGroups()
+		spotPrice       = configuration.GetSpotPrice()
 	)
 
 	instanceGroup.SetState(v1alpha1.ReconcileModifying)
diff --git a/controllers/provisioners/eks/eks.go b/controllers/provisioners/eks/eks.go
index 9af5fd0..43e2c31 100644
--- a/controllers/provisioners/eks/eks.go
+++ b/controllers/provisioners/eks/eks.go
@@ -75,6 +75,26 @@ type EksInstanceGroupContext struct {
 	ResourcePrefix   string
 }
 
+type UserDataPayload struct {
+	PreBootstrap  []string
+	PostBootstrap []string
+}
+
+type MountOpts struct {
+	FileSystem  string
+	Device      string
+	Mount       string
+	Persistance bool
+}
+
+type EKSUserData struct {
+	ClusterName   string
+	Arguments     string
+	PreBootstrap  []string
+	PostBootstrap []string
+	MountOptions  []MountOpts
+}
+
 func (ctx *EksInstanceGroupContext) GetInstanceGroup() *v1alpha1.InstanceGroup {
 	if ctx != nil {
 		return ctx.InstanceGroup
diff --git a/controllers/provisioners/eks/helpers.go b/controllers/provisioners/eks/helpers.go
index af97c61..67bc655 100644
--- a/controllers/provisioners/eks/helpers.go
+++ b/controllers/provisioners/eks/helpers.go
@@ -16,10 +16,13 @@ limitations under the License.
 package eks
 
 import (
+	"bytes"
+	"encoding/base64"
 	"fmt"
 	"reflect"
 	"sort"
 	"strings"
+	"text/template"
 
 	"github.com/Masterminds/semver"
 	"github.com/aws/aws-sdk-go/aws"
@@ -93,7 +96,44 @@ func (ctx *EksInstanceGroupContext) ResolveSecurityGroups() []string {
 	return resolved
 }
 
-func (ctx *EksInstanceGroupContext) GetUserDataStages() ([]string, []string) {
+func (ctx *EksInstanceGroupContext) GetBasicUserData(clusterName, args string, payload UserDataPayload, mounts []MountOpts) string {
+
+	var UserDataTemplate = `#!/bin/bash
+{{range $pre := .PreBootstrap}}{{$pre}}{{end}}
+{{- range .MountOptions}}
+mkfs.{{ .FileSystem | ToLower }} {{ .Device }}
+mkdir {{ .Mount }}
+mount {{ .Device }} {{ .Mount }}
+mount
+{{- if .Persistance}}
+echo "{{ .Device}}    {{ .Mount }}    {{ .FileSystem | ToLower }}    defaults    0    2" >> /etc/fstab
+{{- end}}
+{{- end}}
+set -o xtrace
+/etc/eks/bootstrap.sh {{ .ClusterName }} {{ .Arguments }}
+set +o xtrace
+{{range $post := .PostBootstrap}}{{$post}}{{end}}`
+
+	data := EKSUserData{
+		ClusterName:   clusterName,
+		Arguments:     args,
+		PreBootstrap:  payload.PreBootstrap,
+		PostBootstrap: payload.PostBootstrap,
+		MountOptions:  mounts,
+	}
+	out := &bytes.Buffer{}
+	tmpl := template.New("userData").Funcs(template.FuncMap{
+		"ToLower": strings.ToLower,
+	})
+	var err error
+	if tmpl, err = tmpl.Parse(UserDataTemplate); err != nil {
+		ctx.Log.Error(err, "failed to parse userData template")
+	}
+	tmpl.Execute(out, data)
+	return base64.StdEncoding.EncodeToString(out.Bytes())
+}
+
+func (ctx *EksInstanceGroupContext) GetUserDataStages() UserDataPayload {
 
 	var (
 		instanceGroup = ctx.GetInstanceGroup()
@@ -101,7 +141,8 @@ func (ctx *EksInstanceGroupContext) GetUserDataStages() ([]string, []string) {
 		userData      = configuration.GetUserData()
 	)
 
-	var preScript, postScript []string
+	payload := UserDataPayload{}
+
 	for _, stage := range userData {
 		switch {
 		case strings.EqualFold(stage.Stage, v1alpha1.PreBootstrapStage):
@@ -109,18 +150,54 @@ func (ctx *EksInstanceGroupContext) GetUserDataStages() ([]string, []string) {
 			if err != nil {
 				ctx.Log.Error(err, "failed to decode base64 stage data", "stage", stage.Stage, "data", stage.Data)
 			}
-			preScript = append(preScript, data)
+			payload.PreBootstrap = append(payload.PreBootstrap, data)
 		case strings.EqualFold(stage.Stage, v1alpha1.PostBootstrapStage):
 			data, err := common.GetDecodedString(stage.Data)
 			if err != nil {
 				ctx.Log.Error(err, "failed to decode base64 stage data", "stage", stage.Stage, "data", stage.Data)
 			}
-			postScript = append(postScript, data)
+			payload.PostBootstrap = append(payload.PostBootstrap, data)
 		default:
 			ctx.Log.Info("invalid userdata stage will not be rendered", "stage", stage.Stage, "data", stage.Data)
 		}
 	}
-	return preScript, postScript
+	return payload
+}
+
+func (ctx *EksInstanceGroupContext) GetMountOpts() []MountOpts {
+	var (
+		mountOpts     = make([]MountOpts, 0)
+		instanceGroup = ctx.GetInstanceGroup()
+		configuration = instanceGroup.GetEKSConfiguration()
+		volumes       = configuration.GetVolumes()
+	)
+
+	for _, vol := range volumes {
+		if vol.MountOptions == nil {
+			continue
+		}
+		if !common.ContainsEqualFold(v1alpha1.AllowedFileSystemTypes, vol.MountOptions.FileSystem) {
+			ctx.Log.Error(errors.New("file system type unsupported"), "file-system", vol.MountOptions.FileSystem, "allowed-values", v1alpha1.AllowedFileSystemTypes)
+			continue
+		}
+		if common.StringEmpty(vol.MountOptions.Mount) || !strings.HasPrefix(vol.MountOptions.Mount, "/") {
+			ctx.Log.Error(errors.New("provided mount path is invalid"), "volume", vol.Name, "mount", vol.MountOptions.Mount)
+			continue
+		}
+
+		var persistance bool
+		if vol.MountOptions.Persistance == nil {
+			persistance = true
+		}
+
+		mountOpts = append(mountOpts, MountOpts{
+			FileSystem:  vol.MountOptions.FileSystem,
+			Device:      vol.Name,
+			Mount:       vol.MountOptions.Mount,
+			Persistance: persistance,
+		})
+	}
+	return mountOpts
 }
 
 func (ctx *EksInstanceGroupContext) GetAddedTags(asgName string) []*autoscaling.Tag {
@@ -281,6 +358,7 @@ func (ctx *EksInstanceGroupContext) GetBootstrapArgs() string {
 		configuration = instanceGroup.GetEKSConfiguration()
 		bootstrapArgs = configuration.GetBootstrapArguments()
 	)
+
 	labelsFlag := fmt.Sprintf("--node-labels=%v", strings.Join(ctx.GetLabelList(), ","))
 	taintsFlag := fmt.Sprintf("--register-with-taints=%v", strings.Join(ctx.GetTaintList(), ","))
 	return fmt.Sprintf("--kubelet-extra-args '%v %v %v'", labelsFlag, taintsFlag, bootstrapArgs)
diff --git a/controllers/provisioners/eks/update.go b/controllers/provisioners/eks/update.go
index f9b967e..59abba1 100644
--- a/controllers/provisioners/eks/update.go
+++ b/controllers/provisioners/eks/update.go
@@ -30,17 +30,18 @@ import (
 
 func (ctx *EksInstanceGroupContext) Update() error {
 	var (
-		rotationNeeded        bool
-		instanceGroup         = ctx.GetInstanceGroup()
-		state                 = ctx.GetDiscoveredState()
-		scalingConfig         = state.GetScalingConfiguration()
-		configuration         = instanceGroup.GetEKSConfiguration()
-		args                  = ctx.GetBootstrapArgs()
-		preScript, postScript = ctx.GetUserDataStages()
-		clusterName           = configuration.GetClusterName()
-		userData              = ctx.AwsWorker.GetBasicUserData(clusterName, args, preScript, postScript)
-		sgs                   = ctx.ResolveSecurityGroups()
-		spotPrice             = configuration.GetSpotPrice()
+		rotationNeeded  bool
+		instanceGroup   = ctx.GetInstanceGroup()
+		state           = ctx.GetDiscoveredState()
+		scalingConfig   = state.GetScalingConfiguration()
+		configuration   = instanceGroup.GetEKSConfiguration()
+		args            = ctx.GetBootstrapArgs()
+		userDataPayload = ctx.GetUserDataStages()
+		clusterName     = configuration.GetClusterName()
+		mounts          = ctx.GetMountOpts()
+		userData        = ctx.GetBasicUserData(clusterName, args, userDataPayload, mounts)
+		sgs             = ctx.ResolveSecurityGroups()
+		spotPrice       = configuration.GetSpotPrice()
 	)
 
 	instanceGroup.SetState(v1alpha1.ReconcileModifying)
diff --git a/docs/EKS.md b/docs/EKS.md
index d1e2d10..696ea1d 100644
--- a/docs/EKS.md
+++ b/docs/EKS.md
@@ -119,6 +119,24 @@ spec:
         iops: <int64> : represents number of IOPS to provision volume with (min 100)
         deleteOnTermination : <bool> : delete the EBS volume when the instance is terminated (defaults to true)
         encrypted: <bool> : encrypt the EBS volume with a KMS key
+        mountOptions: <MountOptions> : auto-mount options for additional volumes
+```
+
+### MountOptions
+
+MountOptions helps with formatting & mounting an EBS volume by injecting commands to userdata.
+This should not be used for the root volume that is defined by the AMI.
+
+```yaml
+spec:
+  provisioner: eks
+  eks:
+    configuration:
+      volumes:
+      - mountOptions:
+          fileSystem: <string> : the type of file system to format volume to, must be one of the supported types "xfs" or "ext4"
+          mount: <string> : the mount path to mount the volume to
+          persistance: <bool> : make mount persist after reboot by adding it to /etc/fstab (default true)
 ```
 
 ### Taint


Test patch:
diff --git a/controllers/provisioners/eks/helpers_test.go b/controllers/provisioners/eks/helpers_test.go
index 66d1667..8d92b3e 100644
--- a/controllers/provisioners/eks/helpers_test.go
+++ b/controllers/provisioners/eks/helpers_test.go
@@ -282,6 +282,119 @@ func TestGetLabelList(t *testing.T) {
 	}
 }
 
+func TestGetMountOpts(t *testing.T) {
+	var (
+		g             = gomega.NewGomegaWithT(t)
+		k             = MockKubernetesClientSet()
+		ig            = MockInstanceGroup()
+		configuration = ig.GetEKSConfiguration()
+		asgMock       = NewAutoScalingMocker()
+		iamMock       = NewIamMocker()
+		eksMock       = NewEksMocker()
+		ec2Mock       = NewEc2Mocker()
+	)
+
+	w := MockAwsWorker(asgMock, iamMock, eksMock, ec2Mock)
+	ctx := MockContext(ig, k, w)
+
+	volumeNoOpts := v1alpha1.NodeVolume{
+		Name:                "/dev/xvda1",
+		Type:                "gp2",
+		Size:                100,
+		DeleteOnTermination: aws.Bool(true),
+		Encrypted:           aws.Bool(true),
+	}
+
+	volumeWithOpts := v1alpha1.NodeVolume{
+		Name:                "/dev/xvda2",
+		Type:                "gp2",
+		Size:                100,
+		DeleteOnTermination: aws.Bool(true),
+		Encrypted:           aws.Bool(true),
+		MountOptions: &v1alpha1.NodeVolumeMountOptions{
+			FileSystem:  "xfs",
+			Mount:       "/data",
+			Persistance: aws.Bool(false),
+		},
+	}
+
+	volumeWithOpts2 := v1alpha1.NodeVolume{
+		Name:                "/dev/xvda3",
+		Type:                "gp2",
+		Size:                200,
+		DeleteOnTermination: aws.Bool(true),
+		Encrypted:           aws.Bool(true),
+		MountOptions: &v1alpha1.NodeVolumeMountOptions{
+			FileSystem: "xfs",
+			Mount:      "/data2",
+		},
+	}
+
+	volumeInvalidOpts := v1alpha1.NodeVolume{
+		Name:                "/dev/xvda2",
+		Type:                "gp2",
+		Size:                100,
+		DeleteOnTermination: aws.Bool(true),
+		Encrypted:           aws.Bool(true),
+		MountOptions: &v1alpha1.NodeVolumeMountOptions{
+			FileSystem:  "ext3",
+			Mount:       "/data",
+			Persistance: aws.Bool(true),
+		},
+	}
+
+	volumeInvalidOpts2 := v1alpha1.NodeVolume{
+		Name:                "/dev/xvda2",
+		Type:                "gp2",
+		Size:                100,
+		DeleteOnTermination: aws.Bool(true),
+		Encrypted:           aws.Bool(true),
+		MountOptions: &v1alpha1.NodeVolumeMountOptions{
+			FileSystem:  "ext4",
+			Mount:       "data",
+			Persistance: aws.Bool(false),
+		},
+	}
+
+	tests := []struct {
+		volumes        []v1alpha1.NodeVolume
+		expectedMounts []MountOpts
+	}{
+		{volumes: []v1alpha1.NodeVolume{volumeNoOpts}, expectedMounts: []MountOpts{}},
+		{volumes: []v1alpha1.NodeVolume{volumeNoOpts, volumeWithOpts}, expectedMounts: []MountOpts{
+			{
+				FileSystem:  "xfs",
+				Device:      "/dev/xvda2",
+				Mount:       "/data",
+				Persistance: false,
+			},
+		}},
+		{volumes: []v1alpha1.NodeVolume{volumeWithOpts2, volumeWithOpts}, expectedMounts: []MountOpts{
+			{
+				FileSystem:  "xfs",
+				Device:      "/dev/xvda2",
+				Mount:       "/data",
+				Persistance: false,
+			},
+			{
+				FileSystem:  "xfs",
+				Device:      "/dev/xvda3",
+				Mount:       "/data2",
+				Persistance: true,
+			},
+		}},
+		{volumes: []v1alpha1.NodeVolume{volumeNoOpts}, expectedMounts: []MountOpts{}},
+		{volumes: []v1alpha1.NodeVolume{volumeInvalidOpts, volumeInvalidOpts2}, expectedMounts: []MountOpts{}},
+	}
+
+	for i, tc := range tests {
+		t.Logf("Test #%v - %+v", i, tc)
+		configuration.Volumes = tc.volumes
+		mounts := ctx.GetMountOpts()
+		g.Expect(mounts).To(gomega.ConsistOf(tc.expectedMounts))
+	}
+}
+
 func TestGetUserDataStages(t *testing.T) {
 	var (
 		g             = gomega.NewGomegaWithT(t)
@@ -298,16 +411,16 @@ func TestGetUserDataStages(t *testing.T) {
 	ctx := MockContext(ig, k, w)
 
 	tests := []struct {
-		preBootstrapScript    []string
-		postBootstrapScript   []string
-		expectedPreBootstrap  []string
-		expectedPostBootstrap []string
+		preBootstrapScript  []string
+		postBootstrapScript []string
+		bootstrapScript     string
+		expectedPayload     UserDataPayload
 	}{
 		{},
-		{preBootstrapScript: []string{""}, postBootstrapScript: []string{""}, expectedPreBootstrap: []string{""}, expectedPostBootstrap: []string{""}},
-		{preBootstrapScript: []string{"dGVzdA=="}, postBootstrapScript: []string{"dGVzdDE="}, expectedPreBootstrap: []string{"test"}, expectedPostBootstrap: []string{"test1"}},
-		{preBootstrapScript: []string{"prebootstrap1"}, postBootstrapScript: []string{"postbootstrap"}, expectedPreBootstrap: []string{"prebootstrap1"}, expectedPostBootstrap: []string{"postbootstrap"}},
-		{preBootstrapScript: []string{"prebootstrap1", "prebootstrap2"}, postBootstrapScript: []string{"postbootstrap1", "postbootstrap2"}, expectedPreBootstrap: []string{"prebootstrap1", "prebootstrap2"}, expectedPostBootstrap: []string{"postbootstrap1", "postbootstrap2"}},
+		{preBootstrapScript: []string{""}, postBootstrapScript: []string{""}, expectedPayload: UserDataPayload{PreBootstrap: []string{""}, PostBootstrap: []string{""}}},
+		{preBootstrapScript: []string{"dGVzdA=="}, postBootstrapScript: []string{"dGVzdDE="}, expectedPayload: UserDataPayload{PreBootstrap: []string{"test"}, PostBootstrap: []string{"test1"}}},
+		{preBootstrapScript: []string{"prebootstrap1"}, postBootstrapScript: []string{"postbootstrap"}, expectedPayload: UserDataPayload{PreBootstrap: []string{"prebootstrap1"}, PostBootstrap: []string{"postbootstrap"}}},
+		{preBootstrapScript: []string{"prebootstrap1", "prebootstrap2"}, postBootstrapScript: []string{"postbootstrap1", "postbootstrap2"}, expectedPayload: UserDataPayload{PreBootstrap: []string{"prebootstrap1", "prebootstrap2"}, PostBootstrap: []string{"postbootstrap1", "postbootstrap2"}}},
 	}
 
 	for i, tc := range tests {
@@ -334,8 +447,7 @@ func TestGetUserDataStages(t *testing.T) {
 			Stage: "invalid-stage",
 			Data:  "test",
 		})
-		preScript, postScript := ctx.GetUserDataStages()
-		g.Expect(preScript).To(gomega.ConsistOf(tc.expectedPreBootstrap))
-		g.Expect(postScript).To(gomega.ConsistOf(tc.expectedPostBootstrap))
+		payload := ctx.GetUserDataStages()
+		g.Expect(payload).To(gomega.Equal(tc.expectedPayload))
 	}
 }
diff --git a/test-bdd/templates/instance-group.yaml b/test-bdd/templates/instance-group.yaml
index 96f9b78..e27cdcc 100644
--- a/test-bdd/templates/instance-group.yaml
+++ b/test-bdd/templates/instance-group.yaml
@@ -29,9 +29,12 @@ spec:
       image: {{ .AmiID }}
       instanceType: t2.small
       volumes:
-      - name: /dev/xvda
+      - name: /dev/xvdb
         type: gp2
         size: 30
+        mountOptions:
+          fileSystem: xfs
+          mount: /data
       securityGroups: {{range $element := .NodeSecurityGroups}}
         - {{$element}}
       {{ end }}