Skip to content

Commit 76fbcaf

Browse files
authored
feat(instance): add terminate command (#998)
This command uses the server action API: with `action=terminate` https://developers.scaleway.com/en/products/instance/api/#post-049a5b to quickly delete an instance without backing up its attached volumes. Options: * `with-ip`: also delete the flexible IPs * `with-block`: by default, `terminate` will delete the block storage volumes. When `false`, the command will detach the block volumes before calling `terminate` * prompt user by default when terminating an instance with at least 1 block volume * Check IP
1 parent 4da8ded commit 76fbcaf

17 files changed

+7983
-0
lines changed

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

+3
Original file line numberDiff line numberDiff line change
@@ -26,5 +26,8 @@ GLOBAL FLAGS:
2626
-p, --profile string The config profile to use
2727

2828
SEE ALSO:
29+
# Terminate a running server
30+
scw instance server terminate
31+
2932
# Stop a running server
3033
scw instance server stop
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
Terminates a server with the given ID and all of its volumes.
2+
3+
USAGE:
4+
scw instance server terminate <server-id ...> [arg=value ...]
5+
6+
EXAMPLES:
7+
Terminate a server in the default zone with a given id
8+
scw instance server terminate 11111111-1111-1111-1111-111111111111
9+
10+
Terminate a server in fr-par-1 zone with a given id
11+
scw instance server terminate 11111111-1111-1111-1111-111111111111 zone=fr-par-1
12+
13+
Terminate a server and also delete its flexible IPs
14+
scw instance server terminate 11111111-1111-1111-1111-111111111111 with-ip=true
15+
16+
ARGS:
17+
server-id
18+
[with-ip] Delete the IP attached to the server
19+
[with-block=prompt] Delete the Block Storage volumes attached to the server (prompt | true | false)
20+
[zone] Zone to target. If none is passed will use default zone from the config
21+
22+
FLAGS:
23+
-h, --help help for terminate
24+
25+
GLOBAL FLAGS:
26+
-D, --debug Enable debug mode
27+
-o, --output string Output format: json or human
28+
-p, --profile string The config profile to use
29+
30+
SEE ALSO:
31+
# delete a running server
32+
scw instance server delete
33+
34+
# Stop a running server
35+
scw instance server stop

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

+1
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ AVAILABLE COMMANDS:
3737
console Connect to the serial console of an instance
3838
create Create server
3939
delete Delete server
40+
terminate Terminate server
4041
detach-volume Detach a volume from its server
4142
ssh SSH into a server
4243
start Power on server

internal/namespaces/instance/v1/custom.go

+1
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ func GetCommands() *core.Commands {
4949
serverConsoleCommand(),
5050
serverCreateCommand(),
5151
serverDeleteCommand(),
52+
serverTerminateCommand(),
5253
serverDetachVolumeCommand(),
5354
serverSSHCommand(),
5455
serverStartCommand(),

internal/namespaces/instance/v1/custom_server.go

+166
Original file line numberDiff line numberDiff line change
@@ -713,6 +713,10 @@ func serverDeleteCommand() *core.Command {
713713
},
714714
},
715715
SeeAlsos: []*core.SeeAlso{
716+
{
717+
Command: "scw instance server terminate",
718+
Short: "Terminate a running server",
719+
},
716720
{
717721
Command: "scw instance server stop",
718722
Short: "Stop a running server",
@@ -822,3 +826,165 @@ func serverDeleteCommand() *core.Command {
822826
},
823827
}
824828
}
829+
830+
type customTerminateServerRequest struct {
831+
Zone scw.Zone
832+
ServerID string
833+
WithIP bool
834+
WithBlock withBlock
835+
}
836+
837+
type withBlock string
838+
839+
const (
840+
withBlockPrompt = withBlock("prompt")
841+
withBlockTrue = withBlock("true")
842+
withBlockFalse = withBlock("false")
843+
)
844+
845+
func serverTerminateCommand() *core.Command {
846+
return &core.Command{
847+
Short: `Terminate server`,
848+
Long: `Terminates a server with the given ID and all of its volumes.`,
849+
Namespace: "instance",
850+
Verb: "terminate",
851+
Resource: "server",
852+
ArgsType: reflect.TypeOf(customTerminateServerRequest{}),
853+
ArgSpecs: core.ArgSpecs{
854+
{
855+
Name: "server-id",
856+
Required: true,
857+
Positional: true,
858+
},
859+
{
860+
Name: "with-ip",
861+
Short: "Delete the IP attached to the server",
862+
},
863+
{
864+
Name: "with-block",
865+
Short: "Delete the Block Storage volumes attached to the server",
866+
Default: core.DefaultValueSetter("prompt"),
867+
EnumValues: []string{
868+
string(withBlockPrompt),
869+
string(withBlockTrue),
870+
string(withBlockFalse),
871+
},
872+
},
873+
core.ZoneArgSpec(),
874+
},
875+
Examples: []*core.Example{
876+
{
877+
Short: "Terminate a server in the default zone with a given id",
878+
Request: `{"server_id": "11111111-1111-1111-1111-111111111111"}`,
879+
},
880+
{
881+
Short: "Terminate a server in fr-par-1 zone with a given id",
882+
Request: `{"zone":"fr-par-1", "server_id": "11111111-1111-1111-1111-111111111111"}`,
883+
},
884+
{
885+
Short: "Terminate a server and also delete its flexible IPs",
886+
Request: `{"with_ip":true, "server_id": "11111111-1111-1111-1111-111111111111"}`,
887+
},
888+
},
889+
SeeAlsos: []*core.SeeAlso{
890+
{
891+
Command: "scw instance server delete",
892+
Short: "delete a running server",
893+
},
894+
{
895+
Command: "scw instance server stop",
896+
Short: "Stop a running server",
897+
},
898+
},
899+
Run: func(ctx context.Context, argsI interface{}) (interface{}, error) {
900+
terminateServerArgs := argsI.(*customTerminateServerRequest)
901+
902+
client := core.ExtractClient(ctx)
903+
api := instance.NewAPI(client)
904+
905+
server, err := api.GetServer(&instance.GetServerRequest{
906+
Zone: terminateServerArgs.Zone,
907+
ServerID: terminateServerArgs.ServerID,
908+
})
909+
if err != nil {
910+
return nil, err
911+
}
912+
913+
deleteBlockVolumes, err := shouldDeleteBlockVolumes(server, terminateServerArgs.WithBlock)
914+
if err != nil {
915+
return nil, err
916+
}
917+
918+
if !deleteBlockVolumes {
919+
// detach block storage volumes before terminating the instance to preserve them
920+
var multiErr error
921+
for _, volume := range server.Server.Volumes {
922+
if volume.VolumeType != instance.VolumeTypeBSSD {
923+
continue
924+
}
925+
926+
if _, err := api.DetachVolume(&instance.DetachVolumeRequest{
927+
Zone: terminateServerArgs.Zone,
928+
VolumeID: volume.ID,
929+
}); err != nil {
930+
multiErr = multierror.Append(multiErr, err)
931+
continue
932+
}
933+
934+
_, _ = interactive.Printf("successfully detached volume %s\n", volume.Name)
935+
}
936+
937+
if multiErr != nil {
938+
return nil, multiErr
939+
}
940+
}
941+
942+
if _, err := api.ServerAction(&instance.ServerActionRequest{
943+
Zone: terminateServerArgs.Zone,
944+
ServerID: terminateServerArgs.ServerID,
945+
Action: instance.ServerActionTerminate,
946+
}); err != nil {
947+
return nil, err
948+
}
949+
950+
var multiErr error
951+
if terminateServerArgs.WithIP && server.Server.PublicIP != nil && !server.Server.PublicIP.Dynamic {
952+
err = api.DeleteIP(&instance.DeleteIPRequest{
953+
Zone: terminateServerArgs.Zone,
954+
IP: server.Server.PublicIP.ID,
955+
})
956+
if err != nil {
957+
multiErr = multierror.Append(multiErr, err)
958+
} else {
959+
_, _ = interactive.Printf("successfully deleted ip %s\n", server.Server.PublicIP.Address.String())
960+
}
961+
}
962+
963+
return &core.SuccessResult{}, multiErr
964+
},
965+
}
966+
}
967+
968+
func shouldDeleteBlockVolumes(server *instance.GetServerResponse, terminateWithBlock withBlock) (bool, error) {
969+
switch terminateWithBlock {
970+
case withBlockTrue:
971+
return true, nil
972+
case withBlockFalse:
973+
return false, nil
974+
case withBlockPrompt:
975+
// Only prompt user if at least one block volume is attached to the instance
976+
for _, volume := range server.Server.Volumes {
977+
if volume.VolumeType != instance.VolumeTypeBSSD {
978+
continue
979+
}
980+
981+
return interactive.PromptBoolWithConfig(&interactive.PromptBoolConfig{
982+
Prompt: "Do you also want to delete block volumes attached to this instance ?",
983+
DefaultValue: false,
984+
})
985+
}
986+
return false, nil
987+
default:
988+
return false, fmt.Errorf("unsupported with-block value %v", terminateWithBlock)
989+
}
990+
}

internal/namespaces/instance/v1/custom_server_test.go

+71
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,77 @@ func Test_ServerDelete(t *testing.T) {
279279
interactive.IsInteractive = false
280280
}
281281

282+
// These tests needs to be run in sequence
283+
// since they are using the interactive print
284+
func Test_ServerTerminate(t *testing.T) {
285+
interactive.IsInteractive = true
286+
287+
t.Run("without IP", core.Test(&core.TestConfig{
288+
Commands: GetCommands(),
289+
BeforeFunc: core.ExecStoreBeforeCmd("Server", "scw instance server create image=ubuntu-bionic -w"),
290+
Cmd: `scw instance server terminate {{ .Server.ID }}`,
291+
Check: core.TestCheckCombine(
292+
core.TestCheckGolden(),
293+
core.TestCheckExitCode(0),
294+
func(t *testing.T, ctx *core.CheckFuncCtx) {
295+
api := instance.NewAPI(ctx.Client)
296+
server := ctx.Meta["Server"].(*instance.Server)
297+
_, err := api.GetIP(&instance.GetIPRequest{
298+
IP: server.PublicIP.ID,
299+
})
300+
assert.NoError(t, err)
301+
},
302+
),
303+
AfterFunc: core.ExecAfterCmd(`scw instance ip delete {{ index .Server.PublicIP.ID }}`),
304+
DisableParallel: true,
305+
}))
306+
307+
t.Run("with IP", core.Test(&core.TestConfig{
308+
Commands: GetCommands(),
309+
BeforeFunc: core.ExecStoreBeforeCmd("Server", "scw instance server create image=ubuntu-bionic -w"),
310+
Cmd: `scw instance server terminate {{ .Server.ID }} with-ip=true`,
311+
Check: core.TestCheckCombine(
312+
core.TestCheckGolden(),
313+
core.TestCheckExitCode(0),
314+
func(t *testing.T, ctx *core.CheckFuncCtx) {
315+
api := instance.NewAPI(ctx.Client)
316+
server := ctx.Meta["Server"].(*instance.Server)
317+
_, err := api.GetIP(&instance.GetIPRequest{
318+
IP: server.PublicIP.ID,
319+
})
320+
require.IsType(t, &scw.ResponseError{}, err)
321+
assert.Equal(t, 403, err.(*scw.ResponseError).StatusCode)
322+
},
323+
),
324+
DisableParallel: true,
325+
}))
326+
327+
t.Run("without block", core.Test(&core.TestConfig{
328+
Commands: GetCommands(),
329+
BeforeFunc: core.ExecStoreBeforeCmd("Server", "scw instance server create image=ubuntu-bionic additional-volumes.0=block:10G -w"),
330+
Cmd: `scw instance server terminate {{ .Server.ID }} with-ip=true with-block=false`,
331+
Check: core.TestCheckCombine(
332+
core.TestCheckGolden(),
333+
core.TestCheckExitCode(0),
334+
),
335+
AfterFunc: core.ExecAfterCmd(`scw instance volume delete {{ (index .Server.Volumes "1").ID }}`),
336+
DisableParallel: true,
337+
}))
338+
339+
t.Run("with block", core.Test(&core.TestConfig{
340+
Commands: GetCommands(),
341+
BeforeFunc: core.ExecStoreBeforeCmd("Server", "scw instance server create image=ubuntu-bionic additional-volumes.0=block:10G -w"),
342+
Cmd: `scw instance server terminate {{ .Server.ID }} with-ip=true with-block=true`,
343+
Check: core.TestCheckCombine(
344+
core.TestCheckGolden(),
345+
core.TestCheckExitCode(0),
346+
),
347+
DisableParallel: true,
348+
}))
349+
350+
interactive.IsInteractive = false
351+
}
352+
282353
// These tests needs to be run in sequence
283354
// since they are using the interactive print
284355
func Test_ServerBackup(t *testing.T) {

0 commit comments

Comments
 (0)