diff --git a/docs/usage/options.md b/docs/usage/options.md index 24e21d8..790560d 100644 --- a/docs/usage/options.md +++ b/docs/usage/options.md @@ -28,6 +28,7 @@ Available Options for the IONOS Cloud Docker Machine Driver: | `--ionoscloud-lan-id` | Existing Ionos Cloud LAN ID (numeric) in which to create the Docker Host | | `--ionoscloud-lan-name` | Existing Ionos Cloud LAN Name (string) in which to create the Docker Host | | `--ionoscloud-additional-lans` | Names of existing IONOS Lans to connect the machine to. Names that are not found are ignored | +| `--ionoscloud-additional-disks` | A list of disk types and sizes for additional volumes to be created on the machine, the format is DISK_TYPE:DISK_SIZE | | `--ionoscloud-disk-size` | Ionos Cloud Volume Disk-Size in GB \(10, 50, 100, 200, 400\) | | `--ionoscloud-disk-type` | Ionos Cloud Volume Disk-Type \(HDD, SSD, SSD Standard, SSD Premium, DAS\). If server type is CUBE this value is ignored and "DAS" is used, "DAS" cannot be used with ENTERPRISE servers | | `--ionoscloud-image` | Ionos Cloud Image Id or Alias \(ubuntu:latest, debian:latest, etc.\). If Image Id is set, please make sure the disk type supports the image type. | @@ -93,6 +94,7 @@ Environment variables are also supported for setting options. This is a list of | `--ionoscloud-lan-id` | `IONOSCLOUD_LAN_ID` | | `--ionoscloud-lan-name` | `IONOSCLOUD_LAN_NAME` | | `--ionoscloud-additional-lans` | `IONOSCLOUD_ADDITIONAL_LANS` | +| `--ionoscloud-additional-disks` | `IONOSCLOUD_ADDITIONAL_DISKS` | | `--ionoscloud-disk-size` | `IONOSCLOUD_DISK_SIZE` | | `--ionoscloud-disk-type` | `IONOSCLOUD_DISK_TYPE` | | `--ionoscloud-image` | `IONOSCLOUD_IMAGE` | diff --git a/ionos_create_machine.go b/ionos_create_machine.go index dc0379b..56f538b 100644 --- a/ionos_create_machine.go +++ b/ionos_create_machine.go @@ -179,13 +179,13 @@ func (d *Driver) CreateIonosServer() (err error) { imagePassword = nil } floatDiskSize := float32(d.DiskSize) - volumeProperties := sdkgo.VolumeProperties{ Type: &d.DiskType, Name: &d.MachineName, ImagePassword: imagePassword, SshKeys: sshKeys, UserData: &ud, + BootOrder: pointer.From("PRIMARY"), } if !d.UseAlias { @@ -229,12 +229,27 @@ func (d *Driver) CreateIonosServer() (err error) { serverToCreate.Properties.NicMultiQueue = &d.NicMultiQueue } - attachedVolumes := sdkgo.NewAttachedVolumesWithDefaults() - attachedVolumes.Items = &[]sdkgo.Volume{ - { - Properties: &volumeProperties, - }, + // configure all volumes + volumes := make([]sdkgo.Volume, len(d.AdditionalDisks)+1) + volumes[0] = sdkgo.Volume{Properties: &volumeProperties} + for i := range d.AdditionalDisks { + name := fmt.Sprintf("%s-vol-%d", d.MachineName, i+1) + size := float32(d.AdditionalDisks[i].Size) + license := "OTHER" + volumes[i+1] = sdkgo.Volume{ + Properties: &sdkgo.VolumeProperties{ + Name: &name, + Type: &(d.AdditionalDisks[i].Type), + Size: &size, + LicenceType: &license, + BootOrder: pointer.From("NONE"), + }, + } } + + attachedVolumes := sdkgo.NewAttachedVolumesWithDefaults() + attachedVolumes.Items = &volumes + serverToCreate.Entities = sdkgo.NewServerEntitiesWithDefaults() serverToCreate.Entities.SetVolumes(*attachedVolumes) @@ -409,6 +424,13 @@ func (d *Driver) CreateIonosMachine() (err error) { return fmt.Errorf("error getting server by id: %w", err) } d.VolumeId = *(*server.Entities.GetVolumes().Items)[0].GetId() + for i, id := range *server.Entities.GetVolumes().Items { + // skip first, as it is handled by volumeId + if i == 0 { + continue + } + d.AdditionalVolumeIds = append(d.AdditionalVolumeIds, *id.GetId()) + } log.Debugf("Volume ID: %v", d.VolumeId) nics := server.Entities.GetNics() diff --git a/ionoscloud.go b/ionoscloud.go index b786875..1f39e79 100644 --- a/ionoscloud.go +++ b/ionoscloud.go @@ -33,6 +33,7 @@ const ( flagServerAvailabilityZone = "ionoscloud-server-availability-zone" flagDiskSize = "ionoscloud-disk-size" flagDiskType = "ionoscloud-disk-type" + flagAdditionalDisks = "ionoscloud-additional-disks" flagServerType = "ionoscloud-server-type" flagTemplate = "ionoscloud-template" flagImage = "ionoscloud-image" @@ -94,6 +95,12 @@ const ( // it will be set to `DEV`. var DriverVersion string +// DiskProperties hold information of the properties of additional disks +type DiskProperties struct { + Type string + Size int +} + type Driver struct { *drivers.BaseDriver client func() utils.ClientService @@ -109,6 +116,7 @@ type Driver struct { SSHUser string DiskSize int DiskType string + AdditionalDisks []DiskProperties Image string ImagePassword string Size int @@ -131,6 +139,7 @@ type Driver struct { AdditionalLans []string AdditionalLansIds []int AdditionalNicsIds []string + AdditionalVolumeIds []string DatacenterId string DatacenterName string VolumeId string @@ -234,6 +243,11 @@ func (d *Driver) GetCreateFlags() []mcnflag.Flag { EnvVar: extflag.KebabCaseToEnvVarCase(flagNatLansToGateways), Usage: "Ionos Cloud NAT map of LANs to a slice of their Gateway IPs. Example: \"1=10.0.0.1,10.0.0.2:2=10.0.0.10\"", }, + mcnflag.StringSliceFlag{ + Name: flagAdditionalDisks, + EnvVar: extflag.KebabCaseToEnvVarCase(flagAdditionalDisks), + Usage: "Additional Disks to attach to the VM, must provide volume type (HDD,SSD) and size (in GB). Example: \"HDD,10\"", + }, mcnflag.BoolFlag{ Name: flagPrivateLan, EnvVar: extflag.KebabCaseToEnvVarCase(flagPrivateLan), @@ -475,6 +489,36 @@ func (d *Driver) SetConfigFromFlags(opts drivers.DriverOptions) error { d.Endpoint = sdkgo.DefaultIonosServerUrl } + if err := d.SetAdditionalDisks(opts.StringSlice(flagAdditionalDisks)); err != nil { + return err + } + + return nil +} + +// SetAdditionalDisks goes over the received list of additional disks, checks that everything is ok and +// initializes d.AdditionalDisks +func (d *Driver) SetAdditionalDisks(additionalDisksStringList []string) error { + for _, disk := range additionalDisksStringList { + props := strings.Split(disk, ":") + if len(props) != 2 { + return fmt.Errorf("invalid additional disk configuration: %s, must be \"type:size\"", disk) + } + diskTypes := []string{"HDD", "SSD", "SSD Standard", "SSD Premium"} + if !slices.Contains(diskTypes, props[0]) { + return fmt.Errorf("invalid additional disk type: %s, must be one of %q", props[0], diskTypes) + } + diskProperties := DiskProperties{ + Type: props[0], + } + if size, err := strconv.Atoi(props[1]); err != nil { + return fmt.Errorf("invalid additional disk size: %s, must be an integer", props[1]) + } else { + diskProperties.Size = size + } + + d.AdditionalDisks = append(d.AdditionalDisks, diskProperties) + } return nil } @@ -705,6 +749,19 @@ func (d *Driver) Remove() error { } } } + var notDeleted []string + for _, volumeId := range d.AdditionalVolumeIds { + if d.DatacenterId != "" && volumeId != "" { + log.Debugf("Starting deleting Volume with Id: %v", volumeId) + err = d.client().RemoveVolume(d.DatacenterId, volumeId) + if err != nil { + result = multierror.Append(result, fmt.Errorf("error removing volume: %w", err)) + notDeleted = append(notDeleted, volumeId) + } + } + } + d.AdditionalVolumeIds = notDeleted + if d.DatacenterId != "" && d.VolumeId != "" && d.ServerType != "CUBE" { log.Debugf("Starting deleting Volume with Id: %v", d.VolumeId) err = d.client().RemoveVolume(d.DatacenterId, d.VolumeId) diff --git a/ionoscloud_test.go b/ionoscloud_test.go index 9406fcd..a8a1e87 100644 --- a/ionoscloud_test.go +++ b/ionoscloud_test.go @@ -345,6 +345,56 @@ func TestSetConfigFromCustomFlags(t *testing.T) { assert.Equal(t, defaultAvailabilityZone, driver.ServerAvailabilityZone) } +func TestSetConfigFromCustomFlagsAdditionalDisks(t *testing.T) { + driver, _ := NewTestDriverFlagsSet(t, map[string]interface{}{ + flagAdditionalDisks: []string{"HDD:10", "SSD Premium:13"}, + }) + assert.Equal(t, driver.AdditionalDisks, []DiskProperties{{"HDD", 10}, {"SSD Premium", 13}}) + + driver, _ = NewTestDriverFlagsSet(t, map[string]interface{}{ + flagAdditionalDisks: []string{"SSD Standard:123", "SSD:124", "SSD Premium:412"}, + }) + assert.Equal(t, driver.AdditionalDisks, []DiskProperties{{"SSD Standard", 123}, {"SSD", 124}, {"SSD Premium", 412}}) + + driver, _ = NewTestDriverFlagsSet(t, map[string]interface{}{ + flagAdditionalDisks: []string{}, + }) + assert.Equal(t, driver.AdditionalDisks, []DiskProperties(nil)) +} + +func TestSetConfigFromCustomFlagsAdditionalDisksError(t *testing.T) { + driver, _ := NewTestDriver(t, defaultHostName, defaultStorePath) + checkFlags := &drivers.CheckDriverOptions{ + FlagsValues: map[string]interface{}{ + flagAdditionalDisks: []string{"HDD:10qdwq::", "SSD Premium:13"}, + }, + CreateFlags: driver.GetCreateFlags(), + } + err := driver.SetConfigFromFlags(checkFlags) + assert.Equal(t, err.Error(), "invalid additional disk configuration: HDD:10qdwq::, must be \"type:size\"") + assert.Empty(t, checkFlags.InvalidFlags) + + checkFlags = &drivers.CheckDriverOptions{ + FlagsValues: map[string]interface{}{ + flagAdditionalDisks: []string{"wrongDiskType:10", "SSD Premium:13"}, + }, + CreateFlags: driver.GetCreateFlags(), + } + err = driver.SetConfigFromFlags(checkFlags) + assert.Equal(t, err.Error(), "invalid additional disk type: wrongDiskType, must be one of [\"HDD\" \"SSD\" \"SSD Standard\" \"SSD Premium\"]") + assert.Empty(t, checkFlags.InvalidFlags) + + checkFlags = &drivers.CheckDriverOptions{ + FlagsValues: map[string]interface{}{ + flagAdditionalDisks: []string{"SSD Standard:notInt", "SSD Premium:13"}, + }, + CreateFlags: driver.GetCreateFlags(), + } + err = driver.SetConfigFromFlags(checkFlags) + assert.Equal(t, err.Error(), "invalid additional disk size: notInt, must be an integer") + assert.Empty(t, checkFlags.InvalidFlags) +} + func TestDriverName(t *testing.T) { driver, _ := NewTestDriverFlagsSet(t, authFlagsSet) assert.Equal(t, driverName, driver.DriverName()) @@ -592,6 +642,178 @@ func TestCreate(t *testing.T) { userdataFlag := drivers.DriverUserdataFlag(driver) assert.Empty(t, userdataFlag) } + +func TestCreateAdditionalDisks(t *testing.T) { + driver, clientMock := NewTestDriverFlagsSet(t, authFlagsSet) + driver.SSHKey = testVar + driver.CpuFamily = "INTEL_SKYLAKE" + driver.Location = testRegion + driver.DatacenterName = datacenterName + driver.ImagePassword = "" + driver.LanName = lanName1 + driver.AdditionalLans = []string{lanName1, lanName2} + driver.AdditionalDisks = []DiskProperties{{"HDD", 10}} + + gomock.InOrder( + clientMock.EXPECT().GetDatacenters().Return(&sdkgo.Datacenters{Items: &[]sdkgo.Datacenter{}}, nil), + clientMock.EXPECT().GetLocationById("us", "ewr").Return(location, nil), + clientMock.EXPECT().GetImageById(imageAlias).Return(&sdkgo.Image{Id: sdkgo.ToPtr(testImageIdVar)}, nil), + + clientMock.EXPECT().CreateDatacenter(datacenterName, testRegion).Return(dc, nil), + clientMock.EXPECT().CreateLan(*dc.Id, lanName1, true).Return(lan_post, nil), + clientMock.EXPECT().GetLan(*dc.Id, *lan_post.Id).Return(lan_get, nil), + clientMock.EXPECT().CreateIpBlock(int32(1), testRegion).Return(ipblock, nil), + clientMock.EXPECT().GetIpBlockIps(ipblock).Return(ipblock.Properties.Ips, nil), + clientMock.EXPECT().GetLocationById("us", "ewr").Return(location, nil), + clientMock.EXPECT().GetImageById(imageAlias).Return(&sdkgo.Image{Id: sdkgo.ToPtr(testImageIdVar)}, nil), + clientMock.EXPECT().UpdateCloudInitFile(driver.CloudInit, "hostname", []interface{}{driver.MachineName}, true, "skip").Return(cloudInit, nil), + clientMock.EXPECT().CreateServer(*dc.Id, gomock.AssignableToTypeOf(sdkgo.Server{})).DoAndReturn( + func(datacenterId string, serverToCreate sdkgo.Server) (*sdkgo.Server, error) { + assert.Equal(t, driver.MachineName, *serverToCreate.Properties.Name) + assert.Equal(t, driver.CpuFamily, *serverToCreate.Properties.CpuFamily) + assert.Equal(t, int32(driver.Ram), *serverToCreate.Properties.Ram) + assert.Equal(t, int32(driver.Cores), *serverToCreate.Properties.Cores) + assert.Equal(t, driver.ServerAvailabilityZone, *serverToCreate.Properties.AvailabilityZone) + assert.Nil(t, serverToCreate.Properties.Type) + assert.Nil(t, serverToCreate.Properties.NicMultiQueue) + volumes := *serverToCreate.Entities.Volumes.Items + assert.Len(t, volumes, 2) + assert.Equal(t, driver.DiskType, *volumes[0].Properties.Type) + assert.Equal(t, driver.MachineName, *volumes[0].Properties.Name) + assert.Equal(t, driver.ImagePassword, *volumes[0].Properties.ImagePassword) + assert.Equal(t, []string{testVar}, *volumes[0].Properties.SshKeys) + assert.Equal(t, base64.StdEncoding.EncodeToString([]byte(cloudInit)), *volumes[0].Properties.UserData) + assert.Equal(t, testImageIdVar, *volumes[0].Properties.Image) + assert.Equal(t, float32(driver.DiskSize), *volumes[0].Properties.Size) + assert.Equal(t, driver.VolumeAvailabilityZone, *volumes[0].Properties.AvailabilityZone) + assert.Nil(t, volumes[0].Properties.ImageAlias) + + assert.Equal(t, "HDD", *volumes[1].Properties.Type) + assert.Equal(t, fmt.Sprintf("%s-vol-1", driver.MachineName), *volumes[1].Properties.Name) + assert.Nil(t, volumes[1].Properties.ImagePassword) + assert.Nil(t, volumes[1].Properties.SshKeys) + assert.Nil(t, volumes[1].Properties.UserData) + assert.Nil(t, volumes[1].Properties.Image) + assert.Equal(t, float32(10), *volumes[1].Properties.Size) + assert.Nil(t, volumes[1].Properties.AvailabilityZone) + assert.Nil(t, volumes[1].Properties.ImageAlias) + + nics := *serverToCreate.Entities.Nics.Items + assert.Len(t, nics, 1) + assert.Equal(t, driver.MachineName, *nics[0].Properties.Name) + assert.Equal(t, int32(1), *nics[0].Properties.Lan) + assert.Equal(t, *ipblock.Properties.Ips, *nics[0].Properties.Ips) + assert.Equal(t, driver.NicDhcp, *nics[0].Properties.Dhcp) + serverToCreate.Id = &serverId + + return &serverToCreate, nil + }), + clientMock.EXPECT().GetServer(*dc.Id, serverId, int32(2)).Return(server, nil), + clientMock.EXPECT().GetNic(*dc.Id, serverId, nicId).Return(nic, nil), + ) + err := driver.PreCreateCheck() + assert.NoError(t, err) + err = driver.Create() + assert.NoError(t, err) + userdataFlag := drivers.DriverUserdataFlag(driver) + assert.Empty(t, userdataFlag) +} + +func TestCreateAdditionalDisks2(t *testing.T) { + driver, clientMock := NewTestDriverFlagsSet(t, authFlagsSet) + driver.SSHKey = testVar + driver.CpuFamily = "INTEL_SKYLAKE" + driver.Location = testRegion + driver.DatacenterName = datacenterName + driver.ImagePassword = "" + driver.LanName = lanName1 + driver.AdditionalLans = []string{lanName1, lanName2} + driver.AdditionalDisks = []DiskProperties{{"HDD", 10}, {"SSD Premium", 11}, {"SSD", 13}} + + gomock.InOrder( + clientMock.EXPECT().GetDatacenters().Return(&sdkgo.Datacenters{Items: &[]sdkgo.Datacenter{}}, nil), + clientMock.EXPECT().GetLocationById("us", "ewr").Return(location, nil), + clientMock.EXPECT().GetImageById(imageAlias).Return(&sdkgo.Image{Id: sdkgo.ToPtr(testImageIdVar)}, nil), + + clientMock.EXPECT().CreateDatacenter(datacenterName, testRegion).Return(dc, nil), + clientMock.EXPECT().CreateLan(*dc.Id, lanName1, true).Return(lan_post, nil), + clientMock.EXPECT().GetLan(*dc.Id, *lan_post.Id).Return(lan_get, nil), + clientMock.EXPECT().CreateIpBlock(int32(1), testRegion).Return(ipblock, nil), + clientMock.EXPECT().GetIpBlockIps(ipblock).Return(ipblock.Properties.Ips, nil), + clientMock.EXPECT().GetLocationById("us", "ewr").Return(location, nil), + clientMock.EXPECT().GetImageById(imageAlias).Return(&sdkgo.Image{Id: sdkgo.ToPtr(testImageIdVar)}, nil), + clientMock.EXPECT().UpdateCloudInitFile(driver.CloudInit, "hostname", []interface{}{driver.MachineName}, true, "skip").Return(cloudInit, nil), + clientMock.EXPECT().CreateServer(*dc.Id, gomock.AssignableToTypeOf(sdkgo.Server{})).DoAndReturn( + func(datacenterId string, serverToCreate sdkgo.Server) (*sdkgo.Server, error) { + assert.Equal(t, driver.MachineName, *serverToCreate.Properties.Name) + assert.Equal(t, driver.CpuFamily, *serverToCreate.Properties.CpuFamily) + assert.Equal(t, int32(driver.Ram), *serverToCreate.Properties.Ram) + assert.Equal(t, int32(driver.Cores), *serverToCreate.Properties.Cores) + assert.Equal(t, driver.ServerAvailabilityZone, *serverToCreate.Properties.AvailabilityZone) + assert.Nil(t, serverToCreate.Properties.Type) + assert.Nil(t, serverToCreate.Properties.NicMultiQueue) + volumes := *serverToCreate.Entities.Volumes.Items + assert.Len(t, volumes, 4) + assert.Equal(t, driver.DiskType, *volumes[0].Properties.Type) + assert.Equal(t, driver.MachineName, *volumes[0].Properties.Name) + assert.Equal(t, driver.ImagePassword, *volumes[0].Properties.ImagePassword) + assert.Equal(t, []string{testVar}, *volumes[0].Properties.SshKeys) + assert.Equal(t, base64.StdEncoding.EncodeToString([]byte(cloudInit)), *volumes[0].Properties.UserData) + assert.Equal(t, testImageIdVar, *volumes[0].Properties.Image) + assert.Equal(t, float32(driver.DiskSize), *volumes[0].Properties.Size) + assert.Equal(t, driver.VolumeAvailabilityZone, *volumes[0].Properties.AvailabilityZone) + assert.Nil(t, volumes[0].Properties.ImageAlias) + + assert.Equal(t, "HDD", *volumes[1].Properties.Type) + assert.Equal(t, fmt.Sprintf("%s-vol-1", driver.MachineName), *volumes[1].Properties.Name) + assert.Nil(t, volumes[1].Properties.ImagePassword) + assert.Nil(t, volumes[1].Properties.SshKeys) + assert.Nil(t, volumes[1].Properties.UserData) + assert.Nil(t, volumes[1].Properties.Image) + assert.Equal(t, float32(10), *volumes[1].Properties.Size) + assert.Nil(t, volumes[1].Properties.AvailabilityZone) + assert.Nil(t, volumes[1].Properties.ImageAlias) + + assert.Equal(t, "SSD Premium", *volumes[2].Properties.Type) + assert.Equal(t, fmt.Sprintf("%s-vol-2", driver.MachineName), *volumes[2].Properties.Name) + assert.Nil(t, volumes[2].Properties.ImagePassword) + assert.Nil(t, volumes[2].Properties.SshKeys) + assert.Nil(t, volumes[2].Properties.UserData) + assert.Nil(t, volumes[2].Properties.Image) + assert.Equal(t, float32(11), *volumes[2].Properties.Size) + assert.Nil(t, volumes[2].Properties.AvailabilityZone) + assert.Nil(t, volumes[2].Properties.ImageAlias) + + assert.Equal(t, "SSD", *volumes[3].Properties.Type) + assert.Equal(t, fmt.Sprintf("%s-vol-3", driver.MachineName), *volumes[3].Properties.Name) + assert.Nil(t, volumes[3].Properties.ImagePassword) + assert.Nil(t, volumes[3].Properties.SshKeys) + assert.Nil(t, volumes[3].Properties.UserData) + assert.Nil(t, volumes[3].Properties.Image) + assert.Equal(t, float32(13), *volumes[3].Properties.Size) + assert.Nil(t, volumes[3].Properties.AvailabilityZone) + assert.Nil(t, volumes[3].Properties.ImageAlias) + + nics := *serverToCreate.Entities.Nics.Items + assert.Len(t, nics, 1) + assert.Equal(t, driver.MachineName, *nics[0].Properties.Name) + assert.Equal(t, int32(1), *nics[0].Properties.Lan) + assert.Equal(t, *ipblock.Properties.Ips, *nics[0].Properties.Ips) + assert.Equal(t, driver.NicDhcp, *nics[0].Properties.Dhcp) + serverToCreate.Id = &serverId + + return &serverToCreate, nil + }), + clientMock.EXPECT().GetServer(*dc.Id, serverId, int32(2)).Return(server, nil), + clientMock.EXPECT().GetNic(*dc.Id, serverId, nicId).Return(nic, nil), + ) + err := driver.PreCreateCheck() + assert.NoError(t, err) + err = driver.Create() + assert.NoError(t, err) + userdataFlag := drivers.DriverUserdataFlag(driver) + assert.Empty(t, userdataFlag) +} func TestCreateAppendRkeProvisioning(t *testing.T) { driver, clientMock := NewTestDriverFlagsSet(t, authFlagsSet) driver.SSHKey = testVar