Skip to content

Commit 887452b

Browse files
authored
feat(instance): support more IP options in server creation (#4219)
1 parent 25171cf commit 887452b

10 files changed

+8263
-836
lines changed

cmd/scw/testdata/test-all-usage-instance-server-create-usage.golden

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@ ARGS:
4040
[name=<generated>] Server name
4141
[root-volume] Local root volume of the server
4242
[additional-volumes.{index}] Additional local and block volumes attached to your server
43-
[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>)
43+
[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>)
44+
[dynamic-ip-required] Define if a dynamic IPv4 is required for the Instance. If server has no IPv4, a dynamic one will be allocated.
4445
[tags.{index}] Server tags
4546
[ipv6] Enable IPv6, to be used with routed-ip-enabled=false
4647
[stopped] Do not start server after its creation

docs/commands/instance.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1713,7 +1713,8 @@ scw instance server create [arg=value ...]
17131713
| name | Default: `<generated>` | Server name |
17141714
| root-volume | | Local root volume of the server |
17151715
| additional-volumes.{index} | | Additional local and block volumes attached to your server |
1716-
| 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>) |
1716+
| 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>) |
1717+
| dynamic-ip-required | | Define if a dynamic IPv4 is required for the Instance. If server has no IPv4, a dynamic one will be allocated. |
17171718
| tags.{index} | | Server tags |
17181719
| ipv6 | | Enable IPv6, to be used with routed-ip-enabled=false |
17191720
| stopped | | Do not start server after its creation |

internal/namespaces/instance/v1/custom_ip.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package instance
22

33
import (
44
"context"
5+
"errors"
56
"fmt"
67
"net"
78
"reflect"
@@ -190,3 +191,51 @@ func ipDetachCommand() *core.Command {
190191
},
191192
}
192193
}
194+
195+
func cleanIPs(api *instance.API, zone scw.Zone, ipIDs []string) []error {
196+
errs := []error(nil)
197+
for _, ipID := range ipIDs {
198+
err := api.DeleteIP(&instance.DeleteIPRequest{
199+
Zone: zone,
200+
IP: ipID,
201+
})
202+
if err != nil {
203+
errs = append(errs, err)
204+
}
205+
}
206+
207+
return errs
208+
}
209+
210+
func ipIDsFromResponses(resps []*instance.CreateIPResponse) []string {
211+
IDs := make([]string, 0, len(resps))
212+
for _, resp := range resps {
213+
IDs = append(IDs, resp.IP.ID)
214+
}
215+
216+
return IDs
217+
}
218+
219+
// createIPs will create multiple IPs, if one creation fails, all created IPs will be cleaned up.
220+
func createIPs(api *instance.API, reqs []*instance.CreateIPRequest, opts ...scw.RequestOption) ([]string, error) {
221+
resps := make([]*instance.CreateIPResponse, 0, len(reqs))
222+
for _, req := range reqs {
223+
resp, err := api.CreateIP(req, opts...)
224+
if err != nil {
225+
if len(resps) > 0 {
226+
errs := cleanIPs(api, resps[0].IP.Zone, ipIDsFromResponses(resps))
227+
if len(errs) > 0 {
228+
cleanErr := errors.Join(errs...)
229+
cleanErr = fmt.Errorf("failed to clean IPs after creation failure: %w", cleanErr)
230+
err = fmt.Errorf("%s: %w", cleanErr, err)
231+
}
232+
}
233+
234+
return nil, err
235+
}
236+
237+
resps = append(resps, resp)
238+
}
239+
240+
return ipIDsFromResponses(resps), nil
241+
}

internal/namespaces/instance/v1/custom_server_create.go

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ type instanceCreateServerRequest struct {
2626
RootVolume string
2727
AdditionalVolumes []string
2828
IP string
29+
DynamicIPRequired *bool
2930
Tags []string
3031
IPv6 bool
3132
Stopped bool
@@ -89,9 +90,13 @@ func serverCreateCommand() *core.Command {
8990
},
9091
{
9192
Name: "ip",
92-
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>)`,
93+
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>)`,
9394
Default: core.DefaultValueSetter("new"),
9495
},
96+
{
97+
Name: "dynamic-ip-required",
98+
Short: "Define if a dynamic IPv4 is required for the Instance. If server has no IPv4, a dynamic one will be allocated.",
99+
},
95100
{
96101
Name: "tags.{index}",
97102
Short: "Server tags",
@@ -211,6 +216,7 @@ func instanceServerCreateRun(ctx context.Context, argsI interface{}) (i interfac
211216
AddEnableIPv6(scw.BoolPtr(args.IPv6)).
212217
AddTags(args.Tags).
213218
AddRoutedIPEnabled(args.RoutedIPEnabled).
219+
AddDynamicIPRequired(args.DynamicIPRequired).
214220
AddAdminPasswordEncryptionSSHKeyID(args.AdminPasswordEncryptionSSHKeyID).
215221
AddBootType(args.BootType).
216222
AddSecurityGroup(args.SecurityGroupID).
@@ -240,9 +246,9 @@ func instanceServerCreateRun(ctx context.Context, argsI interface{}) (i interfac
240246
return nil, err
241247
}
242248

243-
createReq, createIPReq := serverBuilder.Build()
249+
createReq, createIPReqs := serverBuilder.Build()
244250
postCreationSetup := serverBuilder.BuildPostCreationSetup()
245-
needIPCreation := createIPReq != nil
251+
needIPCreation := len(createIPReqs) > 0
246252

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

255-
ipRes, err := apiInstance.CreateIP(createIPReq)
261+
ipIDs, err := createIPs(apiInstance, createIPReqs)
256262
if err != nil {
257-
return nil, fmt.Errorf("error while creating your public IP: %s", err)
263+
return nil, fmt.Errorf("error while creating your public IPs: %s", err)
258264
}
259-
createReq.PublicIP = scw.StringPtr(ipRes.IP.ID)
260-
logger.Debugf("IP created: %s", createReq.PublicIP)
265+
266+
createReq.PublicIPs = scw.StringsPtr(ipIDs)
267+
logger.Debugf("IPs created: %s", strings.Join(ipIDs, ", "))
261268
}
262269

263270
//
@@ -266,15 +273,13 @@ func instanceServerCreateRun(ctx context.Context, argsI interface{}) (i interfac
266273
logger.Debugf("creating server")
267274
serverRes, err := apiInstance.CreateServer(createReq)
268275
if err != nil {
269-
if needIPCreation && createReq.PublicIP != nil {
276+
if needIPCreation && createReq.PublicIPs != nil {
270277
// Delete the created IP
271-
logger.Debugf("deleting created IP: %s", createReq.PublicIP)
272-
err := apiInstance.DeleteIP(&instance.DeleteIPRequest{
273-
Zone: args.Zone,
274-
IP: *createReq.PublicIP,
275-
})
276-
if err != nil {
277-
logger.Warningf("cannot delete the create IP %s: %s.", createReq.PublicIP, err)
278+
formattedIPs := strings.Join(*createReq.PublicIPs, ", ")
279+
logger.Debugf("deleting created IPs: %s", formattedIPs)
280+
errs := cleanIPs(apiInstance, createReq.Zone, *createReq.PublicIPs)
281+
if len(errs) > 0 {
282+
logger.Warningf("cannot delete created IPs %s: %s.", formattedIPs, errors.Join(errs...))
278283
}
279284
}
280285

internal/namespaces/instance/v1/custom_server_create_builder.go

Lines changed: 33 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@ import (
2121
type ServerBuilder struct {
2222
// createdReq is the request being built
2323
createReq *instance.CreateServerRequest
24-
// createIPReq is filled with a request if an IP is needed
25-
createIPReq *instance.CreateIPRequest
24+
// createIPReqs is filled with requests if one or more IP are needed
25+
createIPReqs []*instance.CreateIPRequest
2626

2727
// volumes is the list of requested volumes
2828
volumes []*VolumeBuilder
@@ -98,6 +98,14 @@ func (sb *ServerBuilder) AddRoutedIPEnabled(routedIPEnabled *bool) *ServerBuilde
9898
return sb
9999
}
100100

101+
func (sb *ServerBuilder) AddDynamicIPRequired(dynamicIPRequired *bool) *ServerBuilder {
102+
if dynamicIPRequired != nil {
103+
sb.createReq.DynamicIPRequired = dynamicIPRequired
104+
}
105+
106+
return sb
107+
}
108+
101109
func (sb *ServerBuilder) AddAdminPasswordEncryptionSSHKeyID(adminPasswordEncryptionSSHKeyID *string) *ServerBuilder {
102110
if adminPasswordEncryptionSSHKeyID != nil {
103111
sb.createReq.AdminPasswordEncryptionSSHKeyID = adminPasswordEncryptionSSHKeyID
@@ -128,18 +136,6 @@ func (sb *ServerBuilder) rootVolumeIsSBS() bool {
128136
return rootVolume.VolumeType == instance.VolumeVolumeTypeSbsVolume
129137
}
130138

131-
// defaultIPType returns the default IP type when created by the CLI. Used for ServerBuilder.AddIP
132-
func (sb *ServerBuilder) defaultIPType() instance.IPType {
133-
if sb.createReq.RoutedIPEnabled != nil { //nolint: staticcheck // Field is deprecated but still supported
134-
if *sb.createReq.RoutedIPEnabled { //nolint: staticcheck // Field is deprecated but still supported
135-
return instance.IPTypeRoutedIPv4
136-
}
137-
return instance.IPTypeNat
138-
}
139-
140-
return ""
141-
}
142-
143139
func (sb *ServerBuilder) marketplaceImageType() marketplace.LocalImageType {
144140
if sb.rootVolumeIsSBS() {
145141
return marketplace.LocalImageTypeInstanceSbs
@@ -195,12 +191,28 @@ func (sb *ServerBuilder) AddImage(image string) (*ServerBuilder, error) {
195191
// - "none"
196192
func (sb *ServerBuilder) AddIP(ip string) (*ServerBuilder, error) {
197193
switch {
198-
case ip == "" || ip == "new":
199-
sb.createIPReq = &instance.CreateIPRequest{
194+
case ip == "" || ip == "new" || ip == "ipv4":
195+
sb.createIPReqs = []*instance.CreateIPRequest{{
200196
Zone: sb.createReq.Zone,
201197
Project: sb.createReq.Project,
202-
Type: sb.defaultIPType(),
203-
}
198+
Type: instance.IPTypeRoutedIPv4,
199+
}}
200+
case ip == "ipv6":
201+
sb.createIPReqs = []*instance.CreateIPRequest{{
202+
Zone: sb.createReq.Zone,
203+
Project: sb.createReq.Project,
204+
Type: instance.IPTypeRoutedIPv6,
205+
}}
206+
case ip == "both":
207+
sb.createIPReqs = []*instance.CreateIPRequest{{
208+
Zone: sb.createReq.Zone,
209+
Project: sb.createReq.Project,
210+
Type: instance.IPTypeRoutedIPv4,
211+
}, {
212+
Zone: sb.createReq.Zone,
213+
Project: sb.createReq.Project,
214+
Type: instance.IPTypeRoutedIPv6,
215+
}}
204216
case validation.IsUUID(ip):
205217
sb.createReq.PublicIP = scw.StringPtr(ip)
206218
case net.ParseIP(ip) != nil:
@@ -219,7 +231,7 @@ func (sb *ServerBuilder) AddIP(ip string) (*ServerBuilder, error) {
219231
case ip == "none":
220232
sb.createReq.DynamicIPRequired = scw.BoolPtr(false)
221233
default:
222-
return sb, fmt.Errorf(`invalid IP "%s", should be either 'new', 'dynamic', 'none', an IP address ID or a reserved flexible IP address`, ip)
234+
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)
223235
}
224236

225237
return sb, nil
@@ -351,8 +363,8 @@ func (sb *ServerBuilder) Validate() error {
351363
return sb.ValidateVolumes()
352364
}
353365

354-
func (sb *ServerBuilder) Build() (*instance.CreateServerRequest, *instance.CreateIPRequest) {
355-
return sb.createReq, sb.createIPReq
366+
func (sb *ServerBuilder) Build() (*instance.CreateServerRequest, []*instance.CreateIPRequest) {
367+
return sb.createReq, sb.createIPReqs
356368
}
357369

358370
type PostServerCreationSetupFunc func(ctx context.Context, server *instance.Server) error

internal/namespaces/instance/v1/custom_server_create_test.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -402,6 +402,41 @@ func Test_CreateServer(t *testing.T) {
402402
),
403403
AfterFunc: deleteServerAfterFunc(),
404404
}))
405+
406+
t.Run("with ipv6 and dynamic ip", core.Test(&core.TestConfig{
407+
Commands: instance.GetCommands(),
408+
Cmd: "scw instance server create image=ubuntu_bionic dynamic-ip-required=true ip=ipv6 -w", // IPv6 is created at runtime
409+
Check: core.TestCheckCombine(
410+
core.TestCheckExitCode(0),
411+
func(t *testing.T, ctx *core.CheckFuncCtx) {
412+
t.Helper()
413+
assert.NotNil(t, ctx.Result, "server is nil")
414+
server := ctx.Result.(*instanceSDK.Server)
415+
assert.Len(t, server.PublicIPs, 2)
416+
assert.Equal(t, instanceSDK.ServerIPIPFamilyInet, server.PublicIPs[0].Family)
417+
assert.True(t, server.PublicIPs[0].Dynamic)
418+
assert.Equal(t, instanceSDK.ServerIPIPFamilyInet6, server.PublicIPs[1].Family)
419+
},
420+
),
421+
AfterFunc: deleteServerAfterFunc(),
422+
}))
423+
424+
t.Run("with ipv6 and ipv4", core.Test(&core.TestConfig{
425+
Commands: instance.GetCommands(),
426+
Cmd: "scw instance server create image=ubuntu_bionic ip=both -w", // IPv6 is created at runtime
427+
Check: core.TestCheckCombine(
428+
core.TestCheckExitCode(0),
429+
func(t *testing.T, ctx *core.CheckFuncCtx) {
430+
t.Helper()
431+
assert.NotNil(t, ctx.Result, "server is nil")
432+
server := ctx.Result.(*instanceSDK.Server)
433+
assert.Len(t, server.PublicIPs, 2)
434+
assert.Equal(t, instanceSDK.ServerIPIPFamilyInet, server.PublicIPs[0].Family)
435+
assert.Equal(t, instanceSDK.ServerIPIPFamilyInet6, server.PublicIPs[1].Family)
436+
},
437+
),
438+
AfterFunc: deleteServerAfterFunc(),
439+
}))
405440
})
406441
}
407442

0 commit comments

Comments
 (0)