Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(instance): support sbs volumes in server creation #3968

Merged
merged 7 commits into from
Jul 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ EXAMPLES:
Create and start an instance from a snapshot
scw instance server create image=none root-volume=local:<snapshot_id>

Create and start an instance using existing volume
scw instance server create image=ubuntu_focal additional-volumes.0=<volume_id>

Use an existing IP
ip=$(scw instance ip create | grep id | awk '{ print $2 }')
scw instance server create image=ubuntu_focal ip=$ip
Expand Down
5 changes: 5 additions & 0 deletions docs/commands/instance.md
Original file line number Diff line number Diff line change
Expand Up @@ -1758,6 +1758,11 @@ Create and start an instance from a snapshot
scw instance server create image=none root-volume=local:<snapshot_id>
```

Create and start an instance using existing volume
```
scw instance server create image=ubuntu_focal additional-volumes.0=<volume_id>
```

Use an existing IP
```
ip=$(scw instance ip create | grep id | awk '{ print $2 }')
Expand Down
90 changes: 60 additions & 30 deletions internal/namespaces/instance/v1/custom_server_create.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (

"github.com/dustin/go-humanize"
"github.com/scaleway/scaleway-cli/v2/internal/core"
block "github.com/scaleway/scaleway-sdk-go/api/block/v1alpha1"
"github.com/scaleway/scaleway-sdk-go/api/instance/v1"
"github.com/scaleway/scaleway-sdk-go/api/marketplace/v2"
"github.com/scaleway/scaleway-sdk-go/logger"
Expand Down Expand Up @@ -167,6 +168,10 @@ func serverCreateCommand() *core.Command {
Short: "Create and start an instance from a snapshot",
ArgsJSON: `{"image":"none","root_volume":"local:<snapshot_id>"}`,
},
{
Short: "Create and start an instance using existing volume",
ArgsJSON: `{"image":"ubuntu_focal","additional_volumes":["<volume_id>"]}`,
},
{
Short: "Use an existing IP",
Raw: `ip=$(scw instance ip create | grep id | awk '{ print $2 }')
Expand Down Expand Up @@ -208,17 +213,17 @@ func instanceServerCreateRun(ctx context.Context, argsI interface{}) (i interfac
AddSecurityGroup(args.SecurityGroupID).
AddPlacementGroup(args.PlacementGroupID)

serverBuilder, err = serverBuilder.AddImage(args.Image)
serverBuilder, err = serverBuilder.AddVolumes(args.RootVolume, args.AdditionalVolumes)
if err != nil {
return nil, err
}

serverBuilder, err = serverBuilder.AddIP(args.IP)
serverBuilder, err = serverBuilder.AddImage(args.Image)
if err != nil {
return nil, err
}

serverBuilder, err = serverBuilder.AddVolumes(args.RootVolume, args.AdditionalVolumes)
serverBuilder, err = serverBuilder.AddIP(args.IP)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -353,10 +358,10 @@ func addDefaultVolumes(serverType *instance.ServerType, volumes map[string]*inst

// buildVolumes creates the initial volume map.
// It is not the definitive one, it will be mutated all along the process.
func buildVolumes(api *instance.API, zone scw.Zone, serverName, rootVolume string, additionalVolumes []string) (map[string]*instance.VolumeServerTemplate, error) {
func buildVolumes(api *instance.API, blockAPI *block.API, zone scw.Zone, serverName, rootVolume string, additionalVolumes []string) (map[string]*instance.VolumeServerTemplate, error) {
volumes := make(map[string]*instance.VolumeServerTemplate)
if rootVolume != "" {
rootVolumeTemplate, err := buildVolumeTemplate(api, zone, rootVolume)
rootVolumeTemplate, err := buildVolumeTemplate(api, blockAPI, zone, rootVolume)
if err != nil {
return nil, err
}
Expand All @@ -365,21 +370,13 @@ func buildVolumes(api *instance.API, zone scw.Zone, serverName, rootVolume strin
}

for i, v := range additionalVolumes {
volumeTemplate, err := buildVolumeTemplate(api, zone, v)
volumeTemplate, err := buildVolumeTemplate(api, blockAPI, zone, v)
if err != nil {
return nil, err
}
index := strconv.Itoa(i + 1)
volumeTemplate.Name = scw.StringPtr(serverName + "-" + index)

// Remove extra data for API validation.
if volumeTemplate.ID != nil {
volumeTemplate = &instance.VolumeServerTemplate{
ID: volumeTemplate.ID,
Name: volumeTemplate.Name,
}
}

volumes[index] = volumeTemplate
}

Expand All @@ -394,7 +391,7 @@ func buildVolumes(api *instance.API, zone scw.Zone, serverName, rootVolume strin
// - a "creation" format: ^((local|l|block|b|scratch|s):)?\d+GB?$ (size is handled by go-humanize, so other sizes are supported)
// - a "creation" format with a snapshot id: l:<uuid> b:<uuid>
// - a UUID format
func buildVolumeTemplate(api *instance.API, zone scw.Zone, flagV string) (*instance.VolumeServerTemplate, error) {
func buildVolumeTemplate(api *instance.API, blockAPI *block.API, zone scw.Zone, flagV string) (*instance.VolumeServerTemplate, error) {
parts := strings.Split(strings.TrimSpace(flagV), ":")

// Create volume.
Expand All @@ -408,6 +405,8 @@ func buildVolumeTemplate(api *instance.API, zone scw.Zone, flagV string) (*insta
vt.VolumeType = instance.VolumeVolumeTypeBSSD
case "s", "scratch":
vt.VolumeType = instance.VolumeVolumeTypeScratch
case "sbs":
vt.VolumeType = instance.VolumeVolumeTypeSbsVolume
default:
return nil, fmt.Errorf("invalid volume type %s in %s volume", parts[0], flagV)
}
Expand All @@ -427,7 +426,7 @@ func buildVolumeTemplate(api *instance.API, zone scw.Zone, flagV string) (*insta

// UUID format.
if len(parts) == 1 && validation.IsUUID(parts[0]) {
return buildVolumeTemplateFromUUID(api, zone, parts[0])
return buildVolumeTemplateFromUUID(api, blockAPI, zone, parts[0])
}

return nil, &core.CliError{
Expand All @@ -441,27 +440,46 @@ func buildVolumeTemplate(api *instance.API, zone scw.Zone, flagV string) (*insta
// Add volume types and sizes allow US to treat UUID volumes like the others and simplify the implementation.
// The instance API refuse the type and the size for UUID volumes, therefore,
// sanitizeVolumeMap function will remove them.
func buildVolumeTemplateFromUUID(api *instance.API, zone scw.Zone, volumeUUID string) (*instance.VolumeServerTemplate, error) {
func buildVolumeTemplateFromUUID(api *instance.API, blockAPI *block.API, zone scw.Zone, volumeUUID string) (*instance.VolumeServerTemplate, error) {
res, err := api.GetVolume(&instance.GetVolumeRequest{
Zone: zone,
VolumeID: volumeUUID,
})
if err != nil && !core.IsNotFoundError(err) {
return nil, err
}

if res != nil {
// Check that volume is not already attached to a server.
if res.Volume.Server != nil {
return nil, fmt.Errorf("volume %s is already attached to %s server", res.Volume.ID, res.Volume.Server.ID)
}

return &instance.VolumeServerTemplate{
ID: &res.Volume.ID,
VolumeType: res.Volume.VolumeType,
Size: &res.Volume.Size,
}, nil
}

blockRes, err := blockAPI.GetVolume(&block.GetVolumeRequest{
Zone: zone,
VolumeID: volumeUUID,
})
if err != nil {
if core.IsNotFoundError(err) {
return nil, fmt.Errorf("volume %s does not exist", volumeUUID)
}
return nil, err
}

// Check that volume is not already attached to a server.
if res.Volume.Server != nil {
return nil, fmt.Errorf("volume %s is already attached to %s server", res.Volume.ID, res.Volume.Server.ID)
if len(blockRes.References) > 0 {
return nil, fmt.Errorf("volume %s is already attached to %s %s", blockRes.ID, blockRes.References[0].ProductResourceID, blockRes.References[0].ProductResourceType)
}

return &instance.VolumeServerTemplate{
ID: &res.Volume.ID,
VolumeType: res.Volume.VolumeType,
Size: &res.Volume.Size,
ID: &blockRes.ID,
VolumeType: instance.VolumeVolumeTypeSbsVolume, // TODO: support snapshot
}, nil
}

Expand Down Expand Up @@ -525,18 +543,23 @@ func validateLocalVolumeSizes(volumes map[string]*instance.VolumeServerTemplate,
volumeConstraint := serverType.VolumesConstraint

// If no root volume provided, count the default root volume size added by the API.
if rootVolume := volumes["0"]; rootVolume == nil {
localVolumeTotalSize += defaultRootVolumeSize
// Only count if server allows LSSD.
if rootVolume := volumes["0"]; rootVolume == nil &&
serverType.PerVolumeConstraint != nil &&
serverType.PerVolumeConstraint.LSSD != nil &&
serverType.PerVolumeConstraint.LSSD.MaxSize > 0 {
localVolumeTotalSize += defaultRootVolumeSize // defaultRootVolumeSize may be used for a block volume
}

if localVolumeTotalSize < volumeConstraint.MinSize || localVolumeTotalSize > volumeConstraint.MaxSize {
min := humanize.Bytes(uint64(volumeConstraint.MinSize))
computedLocal := humanize.Bytes(uint64(localVolumeTotalSize))
if volumeConstraint.MinSize == volumeConstraint.MaxSize {
return fmt.Errorf("%s total local volume size must be equal to %s", commercialType, min)
return fmt.Errorf("%s total local volume size must be equal to %s, got %s", commercialType, min, computedLocal)
}

max := humanize.Bytes(uint64(volumeConstraint.MaxSize))
return fmt.Errorf("%s total local volume size must be between %s and %s", commercialType, min, max)
return fmt.Errorf("%s total local volume size must be between %s and %s, got %s", commercialType, min, max, computedLocal)
}

return nil
Expand Down Expand Up @@ -571,9 +594,16 @@ func sanitizeVolumeMap(serverName string, volumes map[string]*instance.VolumeSer
// Remove extra data for API validation.
switch {
case v.ID != nil:
v = &instance.VolumeServerTemplate{
ID: v.ID,
Name: v.Name,
if v.VolumeType == instance.VolumeVolumeTypeSbsVolume {
v = &instance.VolumeServerTemplate{
ID: v.ID,
VolumeType: v.VolumeType,
}
} else {
v = &instance.VolumeServerTemplate{
ID: v.ID,
Name: v.Name,
}
}
case v.BaseSnapshot != nil:
v = &instance.VolumeServerTemplate{
Expand Down
60 changes: 49 additions & 11 deletions internal/namespaces/instance/v1/custom_server_create_builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"strings"

"github.com/scaleway/scaleway-cli/v2/internal/core"
block "github.com/scaleway/scaleway-sdk-go/api/block/v1alpha1"
"github.com/scaleway/scaleway-sdk-go/api/instance/v1"
"github.com/scaleway/scaleway-sdk-go/api/marketplace/v2"
"github.com/scaleway/scaleway-sdk-go/logger"
Expand All @@ -22,6 +23,7 @@ type ServerBuilder struct {
// All needed APIs
apiMarketplace *marketplace.API
apiInstance *instance.API
apiBlock *block.API

// serverType is filled with the ServerType if CommercialType is found in the API.
serverType *instance.ServerType
Expand All @@ -38,6 +40,7 @@ func NewServerBuilder(client *scw.Client, name string, zone scw.Zone, commercial
},
apiMarketplace: marketplace.NewAPI(client),
apiInstance: instance.NewAPI(client),
apiBlock: block.NewAPI(client),
}

sb.serverType = getServerType(sb.apiInstance, sb.createReq.Zone, sb.createReq.CommercialType)
Expand Down Expand Up @@ -95,6 +98,24 @@ func (sb *ServerBuilder) isWindows() bool {
return commercialTypeIsWindowsServer(sb.createReq.CommercialType)
}

func (sb *ServerBuilder) rootVolume() *instance.VolumeServerTemplate {
rootVolume, exists := sb.createReq.Volumes["0"]
if !exists {
return nil
}

return rootVolume
}

func (sb *ServerBuilder) rootVolumeIsSBS() bool {
rootVolume := sb.rootVolume()
if rootVolume == nil {
return false
}

return rootVolume.VolumeType == instance.VolumeVolumeTypeSbsVolume
}

// defaultIPType returns the default IP type when created by the CLI. Used for ServerBuilder.AddIP
func (sb *ServerBuilder) defaultIPType() instance.IPType {
if sb.createReq.RoutedIPEnabled != nil {
Expand All @@ -107,6 +128,13 @@ func (sb *ServerBuilder) defaultIPType() instance.IPType {
return ""
}

func (sb *ServerBuilder) marketplaceImageType() marketplace.LocalImageType {
if sb.rootVolumeIsSBS() {
return marketplace.LocalImageTypeInstanceSbs
}
return marketplace.LocalImageTypeInstanceLocal
}

// AddImage handle a custom image argument.
// image could be:
// - A local image UUID.
Expand All @@ -122,7 +150,7 @@ func (sb *ServerBuilder) AddImage(image string) (*ServerBuilder, error) {
ImageLabel: imageLabel,
Zone: sb.createReq.Zone,
CommercialType: sb.createReq.CommercialType,
Type: marketplace.LocalImageTypeInstanceLocal,
Type: sb.marketplaceImageType(),
})
if err != nil {
return sb, err
Expand Down Expand Up @@ -194,15 +222,29 @@ func (sb *ServerBuilder) AddIP(ip string) (*ServerBuilder, error) {
func (sb *ServerBuilder) AddVolumes(rootVolume string, additionalVolumes []string) (*ServerBuilder, error) {
if len(additionalVolumes) > 0 || rootVolume != "" {
// Create initial volume template map.
volumes, err := buildVolumes(sb.apiInstance, sb.createReq.Zone, sb.createReq.Name, rootVolume, additionalVolumes)
volumes, err := buildVolumes(sb.apiInstance, sb.apiBlock, sb.createReq.Zone, sb.createReq.Name, rootVolume, additionalVolumes)
if err != nil {
return sb, err
}

// Sanitize the volume map to respect API schemas
sb.createReq.Volumes = volumes
}

if sb.serverType != nil {
sb.createReq.Volumes = addDefaultVolumes(sb.serverType, sb.createReq.Volumes)
}

return sb, nil
}

func (sb *ServerBuilder) ValidateVolumes() error {
volumes := sb.createReq.Volumes
if volumes != nil {
// Validate root volume type and size.
if sb.serverImage != nil {
if _, hasRootVolume := volumes["0"]; sb.serverImage != nil && hasRootVolume {
if err := validateRootVolume(sb.serverImage.RootVolume.Size, volumes["0"]); err != nil {
return sb, err
return err
}
} else {
logger.Warningf("skipping root volume validation")
Expand All @@ -211,7 +253,7 @@ func (sb *ServerBuilder) AddVolumes(rootVolume string, additionalVolumes []strin
// Validate total local volume sizes.
if sb.serverType != nil && sb.serverImage != nil {
if err := validateLocalVolumeSizes(volumes, sb.serverType, sb.createReq.CommercialType, sb.serverImage.RootVolume.Size); err != nil {
return sb, err
return err
}
} else {
logger.Warningf("skip local volume size validation")
Expand All @@ -221,11 +263,7 @@ func (sb *ServerBuilder) AddVolumes(rootVolume string, additionalVolumes []strin
sb.createReq.Volumes = sanitizeVolumeMap(sb.createReq.Name, volumes)
}

if sb.serverType != nil {
sb.createReq.Volumes = addDefaultVolumes(sb.serverType, sb.createReq.Volumes)
}

return sb, nil
return nil
}

func (sb *ServerBuilder) AddBootType(bootType string) *ServerBuilder {
Expand Down Expand Up @@ -270,7 +308,7 @@ func (sb *ServerBuilder) Validate() error {
logger.Warningf("skipping image server-type compatibility validation")
}

return nil
return sb.ValidateVolumes()
}

func (sb *ServerBuilder) Build() (*instance.CreateServerRequest, *instance.CreateIPRequest) {
Expand Down
Loading
Loading