Skip to content

Commit 3b896fe

Browse files
authored
feat: allow deletion of multiple resources at once (#719)
Currently, the CLI only allows you to delete one resource at a time. This can be time consuming and tedious if you want to delete multiple resources, because you have to wait for each resource to be deleted before you can delete the next one. This PR adds the ability to delete multiple resources at once by passing them to the `delete` subcommand as multiple positional arguments, e.g. `hcloud server delete server1 server2 ...`. It also adds tests to verify that this works as intended.
1 parent 6cea4cd commit 3b896fe

File tree

13 files changed

+540
-16
lines changed

13 files changed

+540
-16
lines changed

internal/cmd/base/delete.go

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package base
22

33
import (
4+
"errors"
45
"fmt"
56
"reflect"
67

@@ -31,10 +32,10 @@ func (dc *DeleteCmd) CobraCommand(s state.State) *cobra.Command {
3132
}
3233

3334
cmd := &cobra.Command{
34-
Use: fmt.Sprintf("delete %s<%s>", opts, util.ToKebabCase(dc.ResourceNameSingular)),
35+
Use: fmt.Sprintf("delete %s<%s>...", opts, util.ToKebabCase(dc.ResourceNameSingular)),
3536
Short: dc.ShortDescription,
3637
Args: util.Validate,
37-
ValidArgsFunction: cmpl.SuggestArgs(cmpl.SuggestCandidatesF(dc.NameSuggestions(s.Client()))),
38+
ValidArgsFunction: cmpl.SuggestCandidatesF(dc.NameSuggestions(s.Client())),
3839
TraverseChildren: true,
3940
DisableFlagsInUseLine: true,
4041
PreRunE: util.ChainRunE(s.EnsureToken),
@@ -50,22 +51,27 @@ func (dc *DeleteCmd) CobraCommand(s state.State) *cobra.Command {
5051

5152
// Run executes a describe command.
5253
func (dc *DeleteCmd) Run(s state.State, cmd *cobra.Command, args []string) error {
54+
var cmdErr error
5355

54-
idOrName := args[0]
55-
resource, _, err := dc.Fetch(s, cmd, idOrName)
56-
if err != nil {
57-
return err
58-
}
56+
for _, idOrName := range args {
57+
resource, _, err := dc.Fetch(s, cmd, idOrName)
58+
if err != nil {
59+
cmdErr = errors.Join(cmdErr, err)
60+
continue
61+
}
5962

60-
// resource is an interface that always has a type, so the interface is never nil
61-
// (i.e. == nil) is always false.
62-
if reflect.ValueOf(resource).IsNil() {
63-
return fmt.Errorf("%s not found: %s", dc.ResourceNameSingular, idOrName)
64-
}
63+
// resource is an interface that always has a type, so the interface is never nil
64+
// (i.e. == nil) is always false.
65+
if reflect.ValueOf(resource).IsNil() {
66+
cmdErr = errors.Join(cmdErr, fmt.Errorf("%s not found: %s", dc.ResourceNameSingular, idOrName))
67+
continue
68+
}
6569

66-
if err := dc.Delete(s, cmd, resource); err != nil {
67-
return fmt.Errorf("deleting %s %s failed: %s", dc.ResourceNameSingular, idOrName, err)
70+
if err = dc.Delete(s, cmd, resource); err != nil {
71+
cmdErr = errors.Join(cmdErr, fmt.Errorf("deleting %s %s failed: %s", dc.ResourceNameSingular, idOrName, err))
72+
}
73+
cmd.Printf("%s %v deleted\n", dc.ResourceNameSingular, idOrName)
6874
}
69-
cmd.Printf("%s %v deleted\n", dc.ResourceNameSingular, idOrName)
70-
return nil
75+
76+
return cmdErr
7177
}

internal/cmd/base/delete_test.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,16 @@ func TestDelete(t *testing.T) {
4141
Args: []string{"delete", "123"},
4242
ExpOut: "Fetching fake resource\nDeleting fake resource\nFake resource 123 deleted\n",
4343
},
44+
"no flags multiple": {
45+
Args: []string{"delete", "123", "456", "789"},
46+
ExpOut: "Fetching fake resource\nDeleting fake resource\nFake resource 123 deleted\nFetching fake resource\n" +
47+
"Deleting fake resource\nFake resource 456 deleted\nFetching fake resource\nDeleting fake resource\nFake resource 789 deleted\n",
48+
},
4449
"quiet": {
4550
Args: []string{"delete", "123", "--quiet"},
4651
},
52+
"quiet multiple": {
53+
Args: []string{"delete", "123", "456", "789", "--quiet"},
54+
},
4755
})
4856
}

internal/cmd/certificate/delete_test.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package certificate_test
22

33
import (
4+
"fmt"
5+
"strings"
46
"testing"
57

68
"github.com/golang/mock/gomock"
@@ -38,3 +40,47 @@ func TestDelete(t *testing.T) {
3840
assert.Empty(t, errOut)
3941
assert.Equal(t, expOut, out)
4042
}
43+
44+
func TestDeleteMultiple(t *testing.T) {
45+
fx := testutil.NewFixture(t)
46+
defer fx.Finish()
47+
48+
cmd := certificate.DeleteCmd.CobraCommand(fx.State())
49+
fx.ExpectEnsureToken()
50+
51+
certs := []*hcloud.Certificate{
52+
{
53+
ID: 123,
54+
Name: "test1",
55+
},
56+
{
57+
ID: 456,
58+
Name: "test2",
59+
},
60+
{
61+
ID: 789,
62+
Name: "test3",
63+
},
64+
}
65+
66+
expOutBuilder := strings.Builder{}
67+
68+
var names []string
69+
for _, cert := range certs {
70+
names = append(names, cert.Name)
71+
expOutBuilder.WriteString(fmt.Sprintf("certificate %s deleted\n", cert.Name))
72+
fx.Client.CertificateClient.EXPECT().
73+
Get(gomock.Any(), cert.Name).
74+
Return(cert, nil, nil)
75+
fx.Client.CertificateClient.EXPECT().
76+
Delete(gomock.Any(), cert).
77+
Return(nil, nil)
78+
}
79+
80+
out, errOut, err := fx.Run(cmd, names)
81+
expOut := expOutBuilder.String()
82+
83+
assert.NoError(t, err)
84+
assert.Empty(t, errOut)
85+
assert.Equal(t, expOut, out)
86+
}

internal/cmd/firewall/delete_test.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package firewall_test
22

33
import (
4+
"fmt"
5+
"strings"
46
"testing"
57

68
"github.com/golang/mock/gomock"
@@ -38,3 +40,47 @@ func TestDelete(t *testing.T) {
3840
assert.Empty(t, errOut)
3941
assert.Equal(t, expOut, out)
4042
}
43+
44+
func TestDeleteMultiple(t *testing.T) {
45+
fx := testutil.NewFixture(t)
46+
defer fx.Finish()
47+
48+
cmd := firewall.DeleteCmd.CobraCommand(fx.State())
49+
fx.ExpectEnsureToken()
50+
51+
firewalls := []*hcloud.Firewall{
52+
{
53+
ID: 123,
54+
Name: "test1",
55+
},
56+
{
57+
ID: 456,
58+
Name: "test2",
59+
},
60+
{
61+
ID: 789,
62+
Name: "test3",
63+
},
64+
}
65+
66+
expOutBuilder := strings.Builder{}
67+
68+
var names []string
69+
for _, fw := range firewalls {
70+
names = append(names, fw.Name)
71+
expOutBuilder.WriteString(fmt.Sprintf("firewall %s deleted\n", fw.Name))
72+
fx.Client.FirewallClient.EXPECT().
73+
Get(gomock.Any(), fw.Name).
74+
Return(fw, nil, nil)
75+
fx.Client.FirewallClient.EXPECT().
76+
Delete(gomock.Any(), fw).
77+
Return(nil, nil)
78+
}
79+
80+
out, errOut, err := fx.Run(cmd, names)
81+
expOut := expOutBuilder.String()
82+
83+
assert.NoError(t, err)
84+
assert.Empty(t, errOut)
85+
assert.Equal(t, expOut, out)
86+
}

internal/cmd/floatingip/delete_test.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package floatingip_test
22

33
import (
4+
"fmt"
5+
"strings"
46
"testing"
57

68
"github.com/golang/mock/gomock"
@@ -38,3 +40,47 @@ func TestDelete(t *testing.T) {
3840
assert.Empty(t, errOut)
3941
assert.Equal(t, expOut, out)
4042
}
43+
44+
func TestDeleteMultiple(t *testing.T) {
45+
fx := testutil.NewFixture(t)
46+
defer fx.Finish()
47+
48+
cmd := floatingip.DeleteCmd.CobraCommand(fx.State())
49+
fx.ExpectEnsureToken()
50+
51+
ips := []*hcloud.FloatingIP{
52+
{
53+
ID: 123,
54+
Name: "test1",
55+
},
56+
{
57+
ID: 456,
58+
Name: "test2",
59+
},
60+
{
61+
ID: 789,
62+
Name: "test3",
63+
},
64+
}
65+
66+
expOutBuilder := strings.Builder{}
67+
68+
var names []string
69+
for _, ip := range ips {
70+
names = append(names, ip.Name)
71+
expOutBuilder.WriteString(fmt.Sprintf("Floating IP %s deleted\n", ip.Name))
72+
fx.Client.FloatingIPClient.EXPECT().
73+
Get(gomock.Any(), ip.Name).
74+
Return(ip, nil, nil)
75+
fx.Client.FloatingIPClient.EXPECT().
76+
Delete(gomock.Any(), ip).
77+
Return(nil, nil)
78+
}
79+
80+
out, errOut, err := fx.Run(cmd, names)
81+
expOut := expOutBuilder.String()
82+
83+
assert.NoError(t, err)
84+
assert.Empty(t, errOut)
85+
assert.Equal(t, expOut, out)
86+
}

internal/cmd/image/delete_test.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package image_test
22

33
import (
4+
"fmt"
5+
"strings"
46
"testing"
57

68
"github.com/golang/mock/gomock"
@@ -38,3 +40,47 @@ func TestDelete(t *testing.T) {
3840
assert.Empty(t, errOut)
3941
assert.Equal(t, expOut, out)
4042
}
43+
44+
func TestDeleteMultiple(t *testing.T) {
45+
fx := testutil.NewFixture(t)
46+
defer fx.Finish()
47+
48+
cmd := image.DeleteCmd.CobraCommand(fx.State())
49+
fx.ExpectEnsureToken()
50+
51+
images := []*hcloud.Image{
52+
{
53+
ID: 123,
54+
Name: "test1",
55+
},
56+
{
57+
ID: 456,
58+
Name: "test2",
59+
},
60+
{
61+
ID: 789,
62+
Name: "test3",
63+
},
64+
}
65+
66+
expOutBuilder := strings.Builder{}
67+
68+
var names []string
69+
for _, img := range images {
70+
names = append(names, img.Name)
71+
expOutBuilder.WriteString(fmt.Sprintf("image %s deleted\n", img.Name))
72+
fx.Client.ImageClient.EXPECT().
73+
Get(gomock.Any(), img.Name).
74+
Return(img, nil, nil)
75+
fx.Client.ImageClient.EXPECT().
76+
Delete(gomock.Any(), img).
77+
Return(nil, nil)
78+
}
79+
80+
out, errOut, err := fx.Run(cmd, names)
81+
expOut := expOutBuilder.String()
82+
83+
assert.NoError(t, err)
84+
assert.Empty(t, errOut)
85+
assert.Equal(t, expOut, out)
86+
}

internal/cmd/loadbalancer/delete_test.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package loadbalancer_test
22

33
import (
4+
"fmt"
5+
"strings"
46
"testing"
57

68
"github.com/golang/mock/gomock"
@@ -38,3 +40,47 @@ func TestDelete(t *testing.T) {
3840
assert.Empty(t, errOut)
3941
assert.Equal(t, expOut, out)
4042
}
43+
44+
func TestDeleteMultiple(t *testing.T) {
45+
fx := testutil.NewFixture(t)
46+
defer fx.Finish()
47+
48+
cmd := loadbalancer.DeleteCmd.CobraCommand(fx.State())
49+
fx.ExpectEnsureToken()
50+
51+
loadBalancers := []*hcloud.LoadBalancer{
52+
{
53+
ID: 123,
54+
Name: "test1",
55+
},
56+
{
57+
ID: 456,
58+
Name: "test2",
59+
},
60+
{
61+
ID: 789,
62+
Name: "test3",
63+
},
64+
}
65+
66+
expOutBuilder := strings.Builder{}
67+
68+
var names []string
69+
for _, lb := range loadBalancers {
70+
names = append(names, lb.Name)
71+
expOutBuilder.WriteString(fmt.Sprintf("Load Balancer %s deleted\n", lb.Name))
72+
fx.Client.LoadBalancerClient.EXPECT().
73+
Get(gomock.Any(), lb.Name).
74+
Return(lb, nil, nil)
75+
fx.Client.LoadBalancerClient.EXPECT().
76+
Delete(gomock.Any(), lb).
77+
Return(nil, nil)
78+
}
79+
80+
out, errOut, err := fx.Run(cmd, names)
81+
expOut := expOutBuilder.String()
82+
83+
assert.NoError(t, err)
84+
assert.Empty(t, errOut)
85+
assert.Equal(t, expOut, out)
86+
}

0 commit comments

Comments
 (0)