diff --git a/internal/cloud/openstack/openstack.go b/internal/cloud/openstack/openstack.go index 1c2b00203..700d6fe6b 100644 --- a/internal/cloud/openstack/openstack.go +++ b/internal/cloud/openstack/openstack.go @@ -8,6 +8,7 @@ package openstack import ( "context" + "errors" "fmt" "net/http" "net/netip" @@ -207,6 +208,95 @@ func (c *Cloud) List(ctx context.Context) ([]metadata.InstanceMetadata, error) { return result, nil } +// UID retrieves the UID of the constellation. +func (c *Cloud) UID(ctx context.Context) (string, error) { + uid, err := c.imds.uid(ctx) + if err != nil { + return "", fmt.Errorf("retrieving instance UID: %w", err) + } + return uid, nil +} + +// InitSecretHash retrieves the InitSecretHash of the current instance. +func (c *Cloud) InitSecretHash(ctx context.Context) ([]byte, error) { + initSecretHash, err := c.imds.initSecretHash(ctx) + if err != nil { + return nil, fmt.Errorf("retrieving init secret hash: %w", err) + } + return []byte(initSecretHash), nil +} + +// GetLoadBalancerEndpoint returns the endpoint of the load balancer. +// For OpenStack, the load balancer is a floating ip attached to +// a control plane node. +// TODO(malt3): Rewrite to use real load balancer once it is available. +func (c *Cloud) GetLoadBalancerEndpoint(ctx context.Context) (string, error) { + uid, err := c.imds.uid(ctx) + if err != nil { + return "", fmt.Errorf("getting uid: %w", err) + } + + uidTag := fmt.Sprintf("constellation-uid-%s", uid) + + subnet, err := c.getSubnetCIDR(uidTag) + if err != nil { + return "", err + } + + srvs, err := c.getServers(uidTag) + if err != nil { + return "", err + } + + for _, s := range srvs { + if s.Name == "" { + continue + } + if s.ID == "" { + continue + } + if s.Tags == nil { + continue + } + + subnetAddrs, err := parseSeverAddresses(s.Addresses) + if err != nil { + return "", fmt.Errorf("parsing server %q addresses: %w", s.Name, err) + } + + // In a best effort approach, we take the first fixed IPv4 address that is outside the subnet + // belonging to our cluster and assume it is the "load balancer" floating ip. + for _, serverSubnet := range subnetAddrs { + for _, addr := range serverSubnet.Addresses { + if addr.Type != floatingIP { + continue + } + + if addr.IPVersion != ipV4 { + continue + } + + if addr.IP == "" { + continue + } + + parsedAddr, err := netip.ParseAddr(addr.IP) + if err != nil { + continue + } + + if subnet.Contains(parsedAddr) { + continue + } + + return addr.IP, nil + } + } + } + + return "", errors.New("no load balancer endpoint found") +} + func (c *Cloud) getSubnetCIDR(uidTag string) (netip.Prefix, error) { listSubnetsOpts := subnets.ListOpts{Tags: uidTag} subnetsPage, err := c.api.ListSubnets(listSubnetsOpts).AllPages() diff --git a/internal/cloud/openstack/openstack_test.go b/internal/cloud/openstack/openstack_test.go index aa22a4896..46e12a882 100644 --- a/internal/cloud/openstack/openstack_test.go +++ b/internal/cloud/openstack/openstack_test.go @@ -8,6 +8,7 @@ package openstack import ( "context" + "errors" "fmt" "testing" @@ -145,42 +146,6 @@ func TestList(t *testing.T) { } } - // newSubnetPager returns a subnet pager as we would get from a ListSubnets - newSubnetPager := func(nets []subnets.Subnet, err error) stubPager { - return stubPager{ - page: subnets.SubnetPage{ - LinkedPageBase: pagination.LinkedPageBase{ - PageResult: pagination.PageResult{ - Result: gophercloud.Result{ - Body: struct { - Subnets []subnets.Subnet `json:"subnets"` - }{nets}, - Err: err, - }, - }, - }, - }, - } - } - - // newSeverPager returns a server pager as we would get from a ListServers - newSeverPager := func(srvs []servers.Server, err error) stubPager { - return stubPager{ - page: servers.ServerPage{ - LinkedPageBase: pagination.LinkedPageBase{ - PageResult: pagination.PageResult{ - Result: gophercloud.Result{ - Body: struct { - Servers []servers.Server `json:"servers"` - }{srvs}, - Err: err, - }, - }, - }, - }, - } - } - testCases := map[string]struct { imds imdsAPI api serversAPI @@ -407,3 +372,319 @@ func TestList(t *testing.T) { }) } } + +func TestUID(t *testing.T) { + testCases := map[string]struct { + imds *stubIMDSClient + want string + wantErr bool + }{ + "error returned from IMDS client": { + imds: &stubIMDSClient{uidErr: errors.New("failed")}, + wantErr: true, + }, + "UID returned from IMDS client": { + imds: &stubIMDSClient{uidResult: "uid"}, + want: "uid", + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + assert := assert.New(t) + + c := &Cloud{imds: tc.imds} + + got, err := c.UID(context.Background()) + + if tc.wantErr { + assert.Error(err) + } else { + assert.NoError(err) + assert.Equal(tc.want, got) + } + }) + } +} + +func TestInitSecretHash(t *testing.T) { + testCases := map[string]struct { + imds *stubIMDSClient + want []byte + wantErr bool + }{ + "error returned from IMDS client": { + imds: &stubIMDSClient{initSecretHashErr: errors.New("failed")}, + wantErr: true, + }, + "initSecretHash returned from IMDS client": { + imds: &stubIMDSClient{initSecretHashResult: "initSecretHash"}, + want: []byte("initSecretHash"), + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + assert := assert.New(t) + + c := &Cloud{imds: tc.imds} + + got, err := c.InitSecretHash(context.Background()) + + if tc.wantErr { + assert.Error(err) + } else { + assert.NoError(err) + assert.Equal(tc.want, got) + } + }) + } +} + +func TestGetLoadBalancerEndpoint(t *testing.T) { + // newTestAddrs returns a set of raw server addresses as we would get from + // a ListServers call and as expected by the parseSeverAddresses function. + // The hardcoded addresses don't match what we are looking for. A valid + // address can be injected. You can pass a second valid address to test + // that the first valid one is chosen. + newTestAddrs := func(floatingIP1, floatingIP2 string, fixedIP1 string) map[string]any { + return map[string]any{ + "network1": []any{ + map[string]any{ + "addr": "192.0.2.2", + "version": 4, + "OS-EXT-IPS:type": "floating", + "OS-EXT-IPS-MAC:mac_addr": "fa:16:3e:0c:0c:0c", + }, + }, + "network2": []any{ + map[string]any{ + "addr": fixedIP1, + "version": 4, + "OS-EXT-IPS:type": "fixed", + "OS-EXT-IPS-MAC:mac_addr": "fa:16:3e:0c:0c:0c", + }, + map[string]any{ + "addr": "2001:db8:3333:4444:5555:6666:7777:8888", + "version": 6, + "OS-EXT-IPS:type": "floating", + "OS-EXT-IPS-MAC:mac_addr": "fa:16:3e:0c:0c:0c", + }, + map[string]any{ + "addr": floatingIP1, + "version": 4, + "OS-EXT-IPS:type": "floating", + "OS-EXT-IPS-MAC:mac_addr": "fa:16:3e:0c:0c:0c", + }, + map[string]any{ + "addr": floatingIP2, + "version": 4, + "OS-EXT-IPS:type": "floating", + "OS-EXT-IPS-MAC:mac_addr": "fa:16:3e:0c:0c:0c", + }, + }, + } + } + + testCases := map[string]struct { + imds *stubIMDSClient + api *stubServersClient + want string + wantErr bool + }{ + "error returned from IMDS client": { + imds: &stubIMDSClient{uidErr: errors.New("failed")}, + wantErr: true, + }, + "error returned from getSubnetCIDR": { + imds: &stubIMDSClient{}, + api: &stubServersClient{ + subnetsPager: newSubnetPager(nil, errors.New("failed")), + }, + wantErr: true, + }, + "error returned from getServers": { + imds: &stubIMDSClient{}, + api: &stubServersClient{ + subnetsPager: newSubnetPager([]subnets.Subnet{{CIDR: "192.0.2.0/24"}}, nil), + serversPager: newSeverPager(nil, errors.New("failed")), + }, + wantErr: true, + }, + "sever with empty name skipped": { + imds: &stubIMDSClient{}, + api: &stubServersClient{ + subnetsPager: newSubnetPager([]subnets.Subnet{{CIDR: "192.0.2.0/24"}}, nil), + serversPager: newSeverPager([]servers.Server{ + { + ID: "id1", + Tags: &[]string{"constellation-role-control-plane", "constellation-uid-7777"}, + Addresses: newTestAddrs("198.51.100.0", "", "192.0.2.1"), + }, + }, nil), + }, + wantErr: true, + }, + "server with empty ID skipped": { + imds: &stubIMDSClient{}, + api: &stubServersClient{ + subnetsPager: newSubnetPager([]subnets.Subnet{{CIDR: "192.0.2.0/24"}}, nil), + serversPager: newSeverPager([]servers.Server{ + { + Name: "name1", + Tags: &[]string{"constellation-role-control-plane", "constellation-uid-7777"}, + Addresses: newTestAddrs("198.51.100.0", "", "192.0.2.1"), + }, + }, nil), + }, + wantErr: true, + }, + "sever with nil tags skipped": { + imds: &stubIMDSClient{}, + api: &stubServersClient{ + subnetsPager: newSubnetPager([]subnets.Subnet{{CIDR: "192.0.2.0/24"}}, nil), + serversPager: newSeverPager([]servers.Server{ + { + Name: "name1", + ID: "id1", + Addresses: newTestAddrs("198.51.100.0", "", "192.0.2.1"), + }, + }, nil), + }, + wantErr: true, + }, + "server has invalid address": { + imds: &stubIMDSClient{}, + api: &stubServersClient{ + subnetsPager: newSubnetPager([]subnets.Subnet{{CIDR: "192.0.2.0/24"}}, nil), + serversPager: newSeverPager([]servers.Server{ + { + Name: "name1", + ID: "id1", + Tags: &[]string{"constellation-role-control-plane", "constellation-uid-7777"}, + Addresses: newTestAddrs("", "", "invalidIP"), + }, + }, nil), + }, + wantErr: true, + }, + "server without parseable addresses skipped": { + imds: &stubIMDSClient{}, + api: &stubServersClient{ + subnetsPager: newSubnetPager([]subnets.Subnet{{CIDR: "192.0.2.0/24"}}, nil), + serversPager: newSeverPager([]servers.Server{ + { + Name: "name1", + ID: "id1", + Tags: &[]string{"constellation-role-control-plane", "constellation-uid-7777"}, + Addresses: map[string]any{ + "somekey": "invalid", + }, + }, + }, nil), + }, + wantErr: true, + }, + "invalid endpoint returned from server addresses": { + imds: &stubIMDSClient{}, + api: &stubServersClient{ + subnetsPager: newSubnetPager([]subnets.Subnet{{CIDR: "192.0.2.0/24"}}, nil), + serversPager: newSeverPager([]servers.Server{ + { + Name: "name1", + ID: "id1", + Tags: &[]string{"constellation-role-control-plane", "constellation-uid-7777"}, + Addresses: newTestAddrs("invalidIP", "", "192.0.2.1"), + }, + }, nil), + }, + wantErr: true, + }, + "valid endpoint returned from server addresses not in subnet CIDR": { + imds: &stubIMDSClient{}, + api: &stubServersClient{ + subnetsPager: newSubnetPager([]subnets.Subnet{{CIDR: "192.0.2.0/24"}}, nil), + serversPager: newSeverPager([]servers.Server{ + { + Name: "name1", + ID: "id1", + Tags: &[]string{"constellation-role-control-plane", "constellation-uid-7777"}, + Addresses: newTestAddrs("198.51.100.0", "", "192.0.2.1"), + }, + }, nil), + }, + want: "198.51.100.0", + }, + "first valid endpoint returned from server addresses not in subnet CIDR": { + imds: &stubIMDSClient{}, + api: &stubServersClient{ + subnetsPager: newSubnetPager([]subnets.Subnet{{CIDR: "192.0.2.0/24"}}, nil), + serversPager: newSeverPager([]servers.Server{ + { + Name: "name1", + ID: "id1", + Tags: &[]string{"constellation-role-control-plane", "constellation-uid-7777"}, + Addresses: newTestAddrs("198.51.100.0", "198.51.100.1", "192.0.2.1"), + }, + }, nil), + }, + want: "198.51.100.0", + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + assert := assert.New(t) + + c := &Cloud{ + imds: tc.imds, + api: tc.api, + } + + got, err := c.GetLoadBalancerEndpoint(context.Background()) + + if tc.wantErr { + assert.Error(err) + } else { + assert.NoError(err) + assert.Equal(tc.want, got) + } + }) + } +} + +// newSubnetPager returns a subnet pager as we would get from a ListSubnets. +func newSubnetPager(nets []subnets.Subnet, err error) stubPager { + return stubPager{ + page: subnets.SubnetPage{ + LinkedPageBase: pagination.LinkedPageBase{ + PageResult: pagination.PageResult{ + Result: gophercloud.Result{ + Body: struct { + Subnets []subnets.Subnet `json:"subnets"` + }{nets}, + Err: err, + }, + }, + }, + }, + } +} + +// newSeverPager returns a server pager as we would get from a ListServers. +func newSeverPager(srvs []servers.Server, err error) stubPager { + return stubPager{ + page: servers.ServerPage{ + LinkedPageBase: pagination.LinkedPageBase{ + PageResult: pagination.PageResult{ + Result: gophercloud.Result{ + Body: struct { + Servers []servers.Server `json:"servers"` + }{srvs}, + Err: err, + }, + }, + }, + }, + } +}