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 more IP options in server creation #4219

Merged
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 @@ -40,7 +40,8 @@ ARGS:
[name=<generated>] Server name
[root-volume] Local root volume of the server
[additional-volumes.{index}] Additional local and block volumes attached to your server
[ip=new] Either an IP, an IP ID, 'new' to create a new IP, 'dynamic' to use a dynamic IP or 'none' for no public IP (new | dynamic | none | <id> | <address>)
[ip=new] Either an IP, an IP ID, ('new', 'ipv4', 'ipv6' or 'both') to create new IPs, 'dynamic' to use a dynamic IP or 'none' for no public IP (new | ipv4 | ipv6 | both | dynamic | none | <id> | <address>)
[dynamic-ip-required] Define if a dynamic IPv4 is required for the Instance. If server has no IPv4, a dynamic one will be allocated.
[tags.{index}] Server tags
[ipv6] Enable IPv6, to be used with routed-ip-enabled=false
[stopped] Do not start server after its creation
Expand Down
3 changes: 2 additions & 1 deletion docs/commands/instance.md
Original file line number Diff line number Diff line change
Expand Up @@ -1713,7 +1713,8 @@ scw instance server create [arg=value ...]
| name | Default: `<generated>` | Server name |
| root-volume | | Local root volume of the server |
| additional-volumes.{index} | | Additional local and block volumes attached to your server |
| ip | Default: `new` | Either an IP, an IP ID, 'new' to create a new IP, 'dynamic' to use a dynamic IP or 'none' for no public IP (new | dynamic | none | <id> | <address>) |
| ip | Default: `new` | Either an IP, an IP ID, ('new', 'ipv4', 'ipv6' or 'both') to create new IPs, 'dynamic' to use a dynamic IP or 'none' for no public IP (new | ipv4 | ipv6 | both | dynamic | none | <id> | <address>) |
| dynamic-ip-required | | Define if a dynamic IPv4 is required for the Instance. If server has no IPv4, a dynamic one will be allocated. |
| tags.{index} | | Server tags |
| ipv6 | | Enable IPv6, to be used with routed-ip-enabled=false |
| stopped | | Do not start server after its creation |
Expand Down
49 changes: 49 additions & 0 deletions internal/namespaces/instance/v1/custom_ip.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package instance

import (
"context"
"errors"
"fmt"
"net"
"reflect"
Expand Down Expand Up @@ -190,3 +191,51 @@ func ipDetachCommand() *core.Command {
},
}
}

func cleanIPs(api *instance.API, zone scw.Zone, ipIDs []string) []error {
errs := []error(nil)
for _, ipID := range ipIDs {
err := api.DeleteIP(&instance.DeleteIPRequest{
Zone: zone,
IP: ipID,
})
if err != nil {
errs = append(errs, err)
}
}

return errs
}

func ipIDsFromResponses(resps []*instance.CreateIPResponse) []string {
IDs := make([]string, 0, len(resps))
for _, resp := range resps {
IDs = append(IDs, resp.IP.ID)
}

return IDs
}

// createIPs will create multiple IPs, if one creation fails, all created IPs will be cleaned up.
func createIPs(api *instance.API, reqs []*instance.CreateIPRequest, opts ...scw.RequestOption) ([]string, error) {
resps := make([]*instance.CreateIPResponse, 0, len(reqs))
for _, req := range reqs {
resp, err := api.CreateIP(req, opts...)
if err != nil {
if len(resps) > 0 {
errs := cleanIPs(api, resps[0].IP.Zone, ipIDsFromResponses(resps))
if len(errs) > 0 {
cleanErr := errors.Join(errs...)
cleanErr = fmt.Errorf("failed to clean IPs after creation failure: %w", cleanErr)
err = fmt.Errorf("%s: %w", cleanErr, err)
}
}

return nil, err
}

resps = append(resps, resp)
}

return ipIDsFromResponses(resps), nil
}
35 changes: 20 additions & 15 deletions internal/namespaces/instance/v1/custom_server_create.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ type instanceCreateServerRequest struct {
RootVolume string
AdditionalVolumes []string
IP string
DynamicIPRequired *bool
Tags []string
IPv6 bool
Stopped bool
Expand Down Expand Up @@ -89,9 +90,13 @@ func serverCreateCommand() *core.Command {
},
{
Name: "ip",
Short: `Either an IP, an IP ID, 'new' to create a new IP, 'dynamic' to use a dynamic IP or 'none' for no public IP (new | dynamic | none | <id> | <address>)`,
Short: `Either an IP, an IP ID, ('new', 'ipv4', 'ipv6' or 'both') to create new IPs, 'dynamic' to use a dynamic IP or 'none' for no public IP (new | ipv4 | ipv6 | both | dynamic | none | <id> | <address>)`,
Default: core.DefaultValueSetter("new"),
},
{
Name: "dynamic-ip-required",
Short: "Define if a dynamic IPv4 is required for the Instance. If server has no IPv4, a dynamic one will be allocated.",
},
{
Name: "tags.{index}",
Short: "Server tags",
Expand Down Expand Up @@ -211,6 +216,7 @@ func instanceServerCreateRun(ctx context.Context, argsI interface{}) (i interfac
AddEnableIPv6(scw.BoolPtr(args.IPv6)).
AddTags(args.Tags).
AddRoutedIPEnabled(args.RoutedIPEnabled).
AddDynamicIPRequired(args.DynamicIPRequired).
AddAdminPasswordEncryptionSSHKeyID(args.AdminPasswordEncryptionSSHKeyID).
AddBootType(args.BootType).
AddSecurityGroup(args.SecurityGroupID).
Expand Down Expand Up @@ -240,9 +246,9 @@ func instanceServerCreateRun(ctx context.Context, argsI interface{}) (i interfac
return nil, err
}

createReq, createIPReq := serverBuilder.Build()
createReq, createIPReqs := serverBuilder.Build()
postCreationSetup := serverBuilder.BuildPostCreationSetup()
needIPCreation := createIPReq != nil
needIPCreation := len(createIPReqs) > 0

//
// IP creation
Expand All @@ -252,12 +258,13 @@ func instanceServerCreateRun(ctx context.Context, argsI interface{}) (i interfac
if needIPCreation {
logger.Debugf("creating IP")

ipRes, err := apiInstance.CreateIP(createIPReq)
ipIDs, err := createIPs(apiInstance, createIPReqs)
if err != nil {
return nil, fmt.Errorf("error while creating your public IP: %s", err)
return nil, fmt.Errorf("error while creating your public IPs: %s", err)
}
createReq.PublicIP = scw.StringPtr(ipRes.IP.ID)
logger.Debugf("IP created: %s", createReq.PublicIP)

createReq.PublicIPs = scw.StringsPtr(ipIDs)
logger.Debugf("IPs created: %s", strings.Join(ipIDs, ", "))
}

//
Expand All @@ -266,15 +273,13 @@ func instanceServerCreateRun(ctx context.Context, argsI interface{}) (i interfac
logger.Debugf("creating server")
serverRes, err := apiInstance.CreateServer(createReq)
if err != nil {
if needIPCreation && createReq.PublicIP != nil {
if needIPCreation && createReq.PublicIPs != nil {
// Delete the created IP
logger.Debugf("deleting created IP: %s", createReq.PublicIP)
err := apiInstance.DeleteIP(&instance.DeleteIPRequest{
Zone: args.Zone,
IP: *createReq.PublicIP,
})
if err != nil {
logger.Warningf("cannot delete the create IP %s: %s.", createReq.PublicIP, err)
formattedIPs := strings.Join(*createReq.PublicIPs, ", ")
logger.Debugf("deleting created IPs: %s", formattedIPs)
errs := cleanIPs(apiInstance, createReq.Zone, *createReq.PublicIPs)
if len(errs) > 0 {
logger.Warningf("cannot delete created IPs %s: %s.", formattedIPs, errors.Join(errs...))
}
}

Expand Down
54 changes: 33 additions & 21 deletions internal/namespaces/instance/v1/custom_server_create_builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ import (
type ServerBuilder struct {
// createdReq is the request being built
createReq *instance.CreateServerRequest
// createIPReq is filled with a request if an IP is needed
createIPReq *instance.CreateIPRequest
// createIPReqs is filled with requests if one or more IP are needed
createIPReqs []*instance.CreateIPRequest

// volumes is the list of requested volumes
volumes []*VolumeBuilder
Expand Down Expand Up @@ -98,6 +98,14 @@ func (sb *ServerBuilder) AddRoutedIPEnabled(routedIPEnabled *bool) *ServerBuilde
return sb
}

func (sb *ServerBuilder) AddDynamicIPRequired(dynamicIPRequired *bool) *ServerBuilder {
if dynamicIPRequired != nil {
sb.createReq.DynamicIPRequired = dynamicIPRequired
}

return sb
}

func (sb *ServerBuilder) AddAdminPasswordEncryptionSSHKeyID(adminPasswordEncryptionSSHKeyID *string) *ServerBuilder {
if adminPasswordEncryptionSSHKeyID != nil {
sb.createReq.AdminPasswordEncryptionSSHKeyID = adminPasswordEncryptionSSHKeyID
Expand Down Expand Up @@ -128,18 +136,6 @@ func (sb *ServerBuilder) rootVolumeIsSBS() bool {
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 { //nolint: staticcheck // Field is deprecated but still supported
if *sb.createReq.RoutedIPEnabled { //nolint: staticcheck // Field is deprecated but still supported
return instance.IPTypeRoutedIPv4
}
return instance.IPTypeNat
}

return ""
}

func (sb *ServerBuilder) marketplaceImageType() marketplace.LocalImageType {
if sb.rootVolumeIsSBS() {
return marketplace.LocalImageTypeInstanceSbs
Expand Down Expand Up @@ -195,12 +191,28 @@ func (sb *ServerBuilder) AddImage(image string) (*ServerBuilder, error) {
// - "none"
func (sb *ServerBuilder) AddIP(ip string) (*ServerBuilder, error) {
switch {
case ip == "" || ip == "new":
sb.createIPReq = &instance.CreateIPRequest{
case ip == "" || ip == "new" || ip == "ipv4":
sb.createIPReqs = []*instance.CreateIPRequest{{
Zone: sb.createReq.Zone,
Project: sb.createReq.Project,
Type: sb.defaultIPType(),
}
Type: instance.IPTypeRoutedIPv4,
}}
case ip == "ipv6":
sb.createIPReqs = []*instance.CreateIPRequest{{
Zone: sb.createReq.Zone,
Project: sb.createReq.Project,
Type: instance.IPTypeRoutedIPv6,
}}
case ip == "both":
sb.createIPReqs = []*instance.CreateIPRequest{{
Zone: sb.createReq.Zone,
Project: sb.createReq.Project,
Type: instance.IPTypeRoutedIPv4,
}, {
Zone: sb.createReq.Zone,
Project: sb.createReq.Project,
Type: instance.IPTypeRoutedIPv6,
}}
case validation.IsUUID(ip):
sb.createReq.PublicIP = scw.StringPtr(ip)
case net.ParseIP(ip) != nil:
Expand All @@ -219,7 +231,7 @@ func (sb *ServerBuilder) AddIP(ip string) (*ServerBuilder, error) {
case ip == "none":
sb.createReq.DynamicIPRequired = scw.BoolPtr(false)
default:
return sb, fmt.Errorf(`invalid IP "%s", should be either 'new', 'dynamic', 'none', an IP address ID or a reserved flexible IP address`, ip)
return sb, fmt.Errorf(`invalid IP "%s", should be either 'new', 'ipv4', 'ipv6', 'both', 'dynamic', 'none', an IP address ID or a reserved flexible IP address`, ip)
}

return sb, nil
Expand Down Expand Up @@ -351,8 +363,8 @@ func (sb *ServerBuilder) Validate() error {
return sb.ValidateVolumes()
}

func (sb *ServerBuilder) Build() (*instance.CreateServerRequest, *instance.CreateIPRequest) {
return sb.createReq, sb.createIPReq
func (sb *ServerBuilder) Build() (*instance.CreateServerRequest, []*instance.CreateIPRequest) {
return sb.createReq, sb.createIPReqs
}

type PostServerCreationSetupFunc func(ctx context.Context, server *instance.Server) error
Expand Down
35 changes: 35 additions & 0 deletions internal/namespaces/instance/v1/custom_server_create_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,41 @@ func Test_CreateServer(t *testing.T) {
),
AfterFunc: deleteServerAfterFunc(),
}))

t.Run("with ipv6 and dynamic ip", core.Test(&core.TestConfig{
Commands: instance.GetCommands(),
Cmd: "scw instance server create image=ubuntu_bionic dynamic-ip-required=true ip=ipv6 -w", // IPv6 is created at runtime
Check: core.TestCheckCombine(
core.TestCheckExitCode(0),
func(t *testing.T, ctx *core.CheckFuncCtx) {
t.Helper()
assert.NotNil(t, ctx.Result, "server is nil")
server := ctx.Result.(*instanceSDK.Server)
assert.Len(t, server.PublicIPs, 2)
assert.Equal(t, instanceSDK.ServerIPIPFamilyInet, server.PublicIPs[0].Family)
assert.True(t, server.PublicIPs[0].Dynamic)
assert.Equal(t, instanceSDK.ServerIPIPFamilyInet6, server.PublicIPs[1].Family)
},
),
AfterFunc: deleteServerAfterFunc(),
}))

t.Run("with ipv6 and ipv4", core.Test(&core.TestConfig{
Commands: instance.GetCommands(),
Cmd: "scw instance server create image=ubuntu_bionic ip=both -w", // IPv6 is created at runtime
Check: core.TestCheckCombine(
core.TestCheckExitCode(0),
func(t *testing.T, ctx *core.CheckFuncCtx) {
t.Helper()
assert.NotNil(t, ctx.Result, "server is nil")
server := ctx.Result.(*instanceSDK.Server)
assert.Len(t, server.PublicIPs, 2)
assert.Equal(t, instanceSDK.ServerIPIPFamilyInet, server.PublicIPs[0].Family)
assert.Equal(t, instanceSDK.ServerIPIPFamilyInet6, server.PublicIPs[1].Family)
},
),
AfterFunc: deleteServerAfterFunc(),
}))
})
}

Expand Down
Loading
Loading