Skip to content

Commit d4e3726

Browse files
authored
feat(network): implement list-members command (#1491)
This PR implements the new `hcloud network list-members` command. See the [changelog](https://docs.hetzner.cloud/changelog#2026-09-15-network-members-endpoint) for more details.
1 parent 0856baa commit d4e3726

7 files changed

Lines changed: 301 additions & 0 deletions

File tree

‎docs/reference/manual/hcloud_network.md‎

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎docs/reference/manual/hcloud_network_list-members.md‎

Lines changed: 55 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
package network
2+
3+
import (
4+
"fmt"
5+
"net"
6+
"slices"
7+
"strings"
8+
9+
"github.com/spf13/cobra"
10+
"github.com/spf13/pflag"
11+
12+
"github.com/hetznercloud/cli/internal/cmd/base"
13+
"github.com/hetznercloud/cli/internal/cmd/cmpl"
14+
"github.com/hetznercloud/cli/internal/cmd/output"
15+
"github.com/hetznercloud/cli/internal/cmd/util"
16+
"github.com/hetznercloud/cli/internal/hcapi2"
17+
"github.com/hetznercloud/cli/internal/state"
18+
"github.com/hetznercloud/cli/internal/state/config"
19+
"github.com/hetznercloud/hcloud-go/v2/hcloud"
20+
"github.com/hetznercloud/hcloud-go/v2/hcloud/schema"
21+
)
22+
23+
var networkMemberTypeStrings = []string{
24+
string(hcloud.NetworkMemberTypeLoadBalancer),
25+
string(hcloud.NetworkMemberTypeServer),
26+
}
27+
28+
var networkMemberStatusStrings = []string{
29+
string(hcloud.NetworkMemberStatusAttaching),
30+
string(hcloud.NetworkMemberStatusDetaching),
31+
string(hcloud.NetworkMemberStatusError),
32+
string(hcloud.NetworkMemberStatusOK),
33+
string(hcloud.NetworkMemberStatusUpdating),
34+
}
35+
36+
var ListMembersCmd = &base.ListCmd[*hcloud.NetworkMember, schema.NetworkMember]{
37+
ResourceNamePlural: "Network Members",
38+
JSONKeyGetByName: "members",
39+
40+
DefaultColumns: []string{"type", "id", "status", "ip", "alias_ips", "subnet"},
41+
SortOption: config.OptionSortNetworkMember,
42+
43+
ValidArgsFunction: func(client hcapi2.Client) cobra.CompletionFunc {
44+
return cmpl.SuggestCandidatesF(client.Network().Names)
45+
},
46+
47+
PositionalArgumentOverride: []string{"network"},
48+
49+
AdditionalFlags: func(cmd *cobra.Command) {
50+
cmd.Flags().StringSlice("type", nil, "Only Members with one of these types are displayed")
51+
_ = cmd.RegisterFlagCompletionFunc("type", cmpl.SuggestCandidates(networkMemberTypeStrings...))
52+
53+
cmd.Flags().StringSlice("status", nil, "Only Members with one of these statuses are displayed")
54+
_ = cmd.RegisterFlagCompletionFunc("status", cmpl.SuggestCandidates(networkMemberStatusStrings...))
55+
56+
cmd.Flags().StringSlice("subnet", nil, "Only Members attached to one of these subnets are displayed")
57+
},
58+
59+
FetchWithArgs: func(s state.State, flags *pflag.FlagSet, listOpts hcloud.ListOpts, sorts []string, args []string) ([]*hcloud.NetworkMember, error) {
60+
networkIDOrName := args[0]
61+
62+
types, _ := flags.GetStringSlice("type")
63+
subnets, _ := flags.GetStringSlice("subnet")
64+
statuses, _ := flags.GetStringSlice("status")
65+
66+
opts := hcloud.NetworkMemberListOpts{ListOpts: listOpts}
67+
if len(sorts) > 0 {
68+
opts.Sort = sorts
69+
}
70+
71+
if len(types) > 0 {
72+
for _, typ := range types {
73+
if !slices.Contains(networkMemberTypeStrings, typ) {
74+
return nil, fmt.Errorf("invalid type: %s", typ)
75+
}
76+
opts.Type = append(opts.Type, hcloud.NetworkMemberType(typ))
77+
}
78+
}
79+
80+
if len(subnets) > 0 {
81+
for _, subnet := range subnets {
82+
_, ipNet, err := net.ParseCIDR(subnet)
83+
if err != nil {
84+
return nil, fmt.Errorf("invalid subnet: %s", subnet)
85+
}
86+
opts.Subnet = append(opts.Subnet, ipNet)
87+
}
88+
}
89+
90+
if len(statuses) > 0 {
91+
for _, status := range statuses {
92+
if !slices.Contains(networkMemberStatusStrings, status) {
93+
return nil, fmt.Errorf("invalid status: %s", status)
94+
}
95+
opts.Status = append(opts.Status, hcloud.NetworkMemberStatus(status))
96+
}
97+
}
98+
99+
network, _, err := s.Client().Network().Get(s, networkIDOrName)
100+
if err != nil {
101+
return nil, err
102+
}
103+
if network == nil {
104+
return nil, fmt.Errorf("Network not found: %s", networkIDOrName)
105+
}
106+
107+
return s.Client().Network().AllMembersWithOpts(s, network, opts)
108+
},
109+
110+
OutputTable: func(t *output.Table[*hcloud.NetworkMember], _ hcapi2.Client) {
111+
t.
112+
AddAllowedFields(&hcloud.NetworkMember{}).
113+
AddFieldFn("ip", func(member *hcloud.NetworkMember) string {
114+
if member.IP == nil {
115+
return util.NA("")
116+
}
117+
return member.IP.String()
118+
}).
119+
AddFieldFn("alias_ips", func(member *hcloud.NetworkMember) string {
120+
var aliasIPs []string
121+
for _, aliasIP := range member.AliasIPs {
122+
aliasIPs = append(aliasIPs, aliasIP.String())
123+
}
124+
return util.NA(strings.Join(aliasIPs, "\n"))
125+
}).
126+
AddFieldFn("subnet", func(member *hcloud.NetworkMember) string {
127+
if member.Subnet == nil {
128+
return util.NA("")
129+
}
130+
return member.Subnet.String()
131+
})
132+
},
133+
134+
Schema: hcloud.SchemaFromNetworkMember,
135+
136+
Configure: func(_ state.State, cmd *cobra.Command) *cobra.Command {
137+
cmd.Use = "list-members [options] <network>"
138+
return cmd
139+
},
140+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
package network_test
2+
3+
import (
4+
"net"
5+
"testing"
6+
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
"go.uber.org/mock/gomock"
10+
11+
"github.com/hetznercloud/cli/internal/cmd/network"
12+
"github.com/hetznercloud/cli/internal/testutil"
13+
"github.com/hetznercloud/hcloud-go/v2/hcloud"
14+
)
15+
16+
func TestListMembers(t *testing.T) {
17+
fx := testutil.NewFixture(t)
18+
defer fx.Finish()
19+
20+
cmd := network.ListMembersCmd.CobraCommand(fx.State())
21+
22+
networkRes := &hcloud.Network{
23+
ID: 123,
24+
Name: "test-network",
25+
IPRange: &net.IPNet{IP: net.ParseIP("10.0.0.0"), Mask: net.CIDRMask(16, 32)},
26+
}
27+
28+
members := []*hcloud.NetworkMember{
29+
{
30+
Type: hcloud.NetworkMemberTypeServer,
31+
ID: 42,
32+
IP: net.IPv4(10, 0, 0, 2),
33+
Status: hcloud.NetworkMemberStatusOK,
34+
AliasIPs: []net.IP{net.IPv4(10, 0, 0, 3), net.IPv4(10, 0, 0, 4)},
35+
Subnet: &net.IPNet{
36+
IP: net.IPv4(10, 0, 0, 0),
37+
Mask: net.CIDRMask(24, 32),
38+
},
39+
},
40+
{
41+
Type: hcloud.NetworkMemberTypeLoadBalancer,
42+
ID: 7,
43+
IP: net.IPv4(10, 0, 0, 5),
44+
Status: hcloud.NetworkMemberStatusAttaching,
45+
Subnet: &net.IPNet{
46+
IP: net.IPv4(10, 0, 0, 0),
47+
Mask: net.CIDRMask(24, 32),
48+
},
49+
},
50+
}
51+
52+
fx.ExpectEnsureToken()
53+
fx.Client.NetworkClient.EXPECT().
54+
Get(gomock.Any(), "test-network").
55+
Return(networkRes, nil, nil)
56+
fx.Client.NetworkClient.EXPECT().
57+
AllMembersWithOpts(
58+
gomock.Any(),
59+
networkRes,
60+
hcloud.NetworkMemberListOpts{
61+
ListOpts: hcloud.ListOpts{PerPage: 50},
62+
Sort: []string{"id:asc"},
63+
},
64+
).
65+
Return(members, nil)
66+
67+
out, errOut, err := fx.Run(cmd, []string{"test-network"})
68+
69+
expOut := `TYPE ID STATUS IP ALIAS IPS SUBNET
70+
server 42 ok 10.0.0.2 10.0.0.3 10.0.0.0/24
71+
10.0.0.4
72+
load_balancer 7 attaching 10.0.0.5 - 10.0.0.0/24
73+
`
74+
75+
require.NoError(t, err)
76+
assert.Empty(t, errOut)
77+
assert.Equal(t, expOut, out)
78+
}

‎internal/cmd/network/network.go‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,5 +43,9 @@ func NewCommand(s state.State) *cobra.Command {
4343
AddSubnetCmd.CobraCommand(s),
4444
RemoveSubnetCmd.CobraCommand(s),
4545
)
46+
47+
util.AddGroup(cmd, "member", "Members",
48+
ListMembersCmd.CobraCommand(s),
49+
)
4650
return cmd
4751
}

‎internal/state/config/options.go‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,15 @@ var (
259259
nil,
260260
)
261261

262+
OptionSortNetworkMember = newOpt(
263+
"sort.network.member",
264+
"Default sorting for Network Member resource",
265+
[]string{"id:asc"},
266+
(DefaultPreferenceFlags&^OptionFlagPFlag)|OptionFlagSlice|OptionFlagHidden,
267+
nil,
268+
nil,
269+
)
270+
262271
OptionSortPlacementGroup = newOpt(
263272
"sort.placement-group",
264273
"Default sorting for Placement Group resource",

‎test/e2e/network_test.go‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,20 @@ func TestNetwork(t *testing.T) {
6565
)
6666
})
6767

68+
t.Run("list-members", func(t *testing.T) {
69+
t.Run("non-existing-network", func(t *testing.T) {
70+
out, err := runCommand(t, "network", "list-members", "non-existing-network")
71+
require.EqualError(t, err, "Network not found: non-existing-network")
72+
assert.Empty(t, out)
73+
})
74+
75+
t.Run("no-members", func(t *testing.T) {
76+
out, err := runCommand(t, "network", "list-members", strconv.FormatInt(networkID, 10))
77+
require.NoError(t, err)
78+
assert.Equal(t, "TYPE ID STATUS IP ALIAS IPS SUBNET\n", out)
79+
})
80+
})
81+
6882
t.Run("change-ip-range", func(t *testing.T) {
6983
t.Run("non-existing-network", func(t *testing.T) {
7084
out, err := runCommand(t, "network", "change-ip-range", "--ip-range", "10.0.2.0/16", "non-existing-network")

0 commit comments

Comments
 (0)