Repository: kubernetes-sigs/cluster-api-provider-openstack

Issue:
Loadbalancer member not created with subnet id
/kind bug

**What steps did you take and what happened:**

Create a new cluster with a loadbalancer with a VIP subnet in one network while the cluster resides on another network with this configuration:
```
apiVersion: infrastructure.cluster.x-k8s.io/v1beta1
kind: OpenStackCluster
...
spec:
  apiServerLoadBalancer:
    enabled: true
    provider: ovn
    network:
      filter:
        name: network1
    subnets:
    - id: b9be113f-7c72-40a8-9a8f-51413a43a4f6
  ...
  network:
    filter:
      name: network2
  subnets:
  - id: 794735f4-6d6b-4b79-9784-2484d8f33109
```

Reconciling the loadbalancer for the control plane fails with a `LoadBalancerMemberError` with the following error:
```
Provider 'ovn' does not support a requested option: Subnet is required, or Loadbalancer associated with Pool must have a subnet, for Member creation with OVN Provider Driver if it is not the same as LB VIP subnet
```

I reproduced the error with this command:
```
$ openstack loadbalancer member create --address 10.2.0.222 --protocol-port 6443 82b18fb4-3c11-44dc-8b64-9f13af12b36f
Provider 'ovn' does not support a requested option: Subnet is required, or Loadbalancer associated with Pool must have a subnet, for Member creation with OVN Provider Driver if it is not the same as LB VIP subnet (HTTP 501) (Request-ID: req-76ddc667-989b-4c00-b659-98cd98c61ef1)
```

An OVN loadbalancer can have a VIP subnet that is in a different network than the pool members.
It is possible to add a new member by providing the subnet id:
```
$ openstack loadbalancer member create --address 10.2.0.222 --protocol-port 6443 --subnet-id 794735f4-6d6b-4b79-9784-2484d8f33109 82b18fb4-3c11-44dc-8b64-9f13af12b36f
```

The loadbalancer then connects to the Kubernetes control plane machine.

**What did you expect to happen:**

When registering a loadbalancer member the subnet id should be provided if the LB VIP subnet differs from the cluster subnet. 

**Anything else you would like to add:**

I traced this to these lines:

https://github.com/kubernetes-sigs/cluster-api-provider-openstack/blob/main/pkg/cloud/services/loadbalancer/loadbalancer.go#L706-L711

The subnet id of the OpenstackMachine is available when this method is called.

**Environment:**

- Cluster API Provider OpenStack version (Or `git rev-parse HEAD` if manually built): v0.12.5 
- Cluster-API version: v1.10.7
- OpenStack version: 2024.1
- Minikube/KIND version: N/A
- Kubernetes version (use `kubectl version`): N/A
- OS (e.g. from `/etc/os-release`): N/A


Discussion:
@lentzi90: Thank you for the issue! This looks like a genuine bug and a continuation of https://github.com/kubernetes-sigs/cluster-api-provider-openstack/pull/2339.
Would you be able to propose a fix and create a pull request?

Solution patch:
diff --git a/.github/ISSUE_TEMPLATE/new_release.md b/.github/ISSUE_TEMPLATE/new_release.md
index 5378e7a6..0c5f9ac0 100644
--- a/.github/ISSUE_TEMPLATE/new_release.md
+++ b/.github/ISSUE_TEMPLATE/new_release.md
@@ -14,12 +14,12 @@ For details, see [RELEASE.md](https://github.com/kubernetes-sigs/cluster-api-pro
 - [ ] [When bumping `X` or `Y`] Add a new entry of new release branch to [depandabot.yml](https://github.com/kubernetes-sigs/cluster-api-provider-openstack/blob/main/.github/dependabot.yml).
 - [ ] [When bumping `X` or `Y`] Add a new entry of new release branch to [security-scan.yaml](https://github.com/kubernetes-sigs/cluster-api-provider-openstack/blob/main/.github/workflows/security-scan.yaml).
 - [ ] [When bumping `X` or `Y`] Add a new entry to [metadata.yaml](https://github.com/kubernetes-sigs/cluster-api-provider-openstack/blob/main/metadata.yaml)
-  as [described in the CAPI book](https://cluster-api.sigs.k8s.io/clusterctl/provider-contract.html#metadata-yaml)
+  as [described in the CAPI book](https://cluster-api.sigs.k8s.io/developer/providers/contracts/clusterctl#metadata-yaml)
   on the release branch prior to release.
-- [ ] Push tag to the repository.
+- [ ] Create the PR after generating release notes according to [RELEASE.md](https://github.com/kubernetes-sigs/cluster-api-provider-openstack/blob/main/RELEASE.md). Verify that the release PR looks good and make changes if necessary. When this PR is merged, release automation will push the tag to upstream and create a draft release notes.
 - [ ] Promote the [staging image](https://console.cloud.google.com/cloud-build/builds?project=k8s-staging-capi-openstack) by
   adding the new sha=>tag mapping to [images.yaml](https://github.com/kubernetes/k8s.io/blob/main/registry.k8s.io/images/k8s-staging-capi-openstack/images.yaml).
-- [ ] Verify that the new draft release looks good and make changes if necessary.
+- [ ] Verify that the new draft release looks good.
 - [ ] Verify that the image was promoted sucessfully.
 - [ ] Publish the release.
   Mark the release as "latest" if it is the most recent minor release.
diff --git a/RELEASE.md b/RELEASE.md
index 8b7ea9f0..611e60db 100644
--- a/RELEASE.md
+++ b/RELEASE.md
@@ -41,40 +41,73 @@ The content of the release notes differs depending on the type of release, speci
 There is an [issue template](.github/ISSUE_TEMPLATE/new_release.md) to help track release activities.
 
 1. Make sure your repo is clean by git's standards. It is recommended to use a fresh checkout.
-1. When bumping `X` or `Y` (but not Z or the pre-release suffix) in the release version you must create a new release branch called `release-X.Y`.
-   > NOTE: `upstream` should be the name of the remote pointing to `github.com/kubernetes-sigs/cluster-api-provider-openstack`
-    - `git checkout main`
-    - `git pull`
-    - `git checkout -b release-X.Y`
-    - `git push --set-upstream upstream`
+1. Repository Setup
+   - Clone the repository: `git clone git@github.com:kubernetes-sigs/cluster-api-provider-openstack.git`
+   or if using existing repository, make sure origin is set to the fork and
+   upstream is set to `kubernetes-sigs`. Verify if your remote is set properly or not
+   by using following command `git remote -v`.
+   - Fetch the remote (`kubernetes-sigs`): `git fetch upstream`
+   This makes sure that all the tags are accessible.
+
 1. When bumping `X` or `Y` (but not Z or the pre-release suffix) in the release version, ensure you have added a new
    entry to [metadata.yaml](https://github.com/kubernetes-sigs/cluster-api-provider-openstack/blob/main/metadata.yaml)
-   as [described in the CAPI book](https://cluster-api.sigs.k8s.io/clusterctl/provider-contract.html#metadata-yaml), and
+   as [described in the CAPI book](https://cluster-api.sigs.k8s.io/developer/providers/contracts/clusterctl#metadata-yaml), and
    that this has been committed to the release branch prior to release.
-1. Make sure you are on the correct release branch: `release-X.Y`
-1. Set an environment variable with the version, e.g.:
-    - `VERSION=v0.6.0`
-1. Create an annotated tag
-    - `git tag -s -a $VERSION -m $VERSION`.
-1. Push the tag to the GitHub repository:
-   > NOTE: `upstream` should be the name of the remote pointing to `github.com/kubernetes-sigs/cluster-api-provider-openstack`
-    - `git push upstream $VERSION`
-
-   This will cause the image to be automatically built by CI and pushed to the staging repository. As this only builds
-   the image, it only takes a few minutes.
-   It also triggers the [release](https://github.com/kubernetes-sigs/cluster-api-provider-openstack/blob/main/.github/workflows/release.yaml) workflow which will generate release notes and artifacts, and create a draft release in GitHub.
-1. Follow the [image promotion process](https://github.com/kubernetes/k8s.io/blob/main/registry.k8s.io/README.md#image-promoter) to promote the image from the staging repo to `registry.k8s.io/capi-openstack`.
-   The staging repository can be inspected at https://console.cloud.google.com/gcr/images/k8s-staging-capi-openstack/GLOBAL. Be
+
+1. Creating Release Notes
+   - Switch to the main branch: `git checkout main`
+   - Create a new branch for the release notes**:
+     `git checkout -b release-notes-X.Y.Z origin/main`
+   - Generate the release notes: `RELEASE_TAG=vX.Y.Z make generate-release-notes`
+   - Replace `vX.Y.Z` with the new release tag you're creating.
+   - This command generates the release notes here
+     `releasenotes/<RELEASE_TAG>.md` .
+
+1. Next step is to clean up the release note manually.
+   - If release is not an alpha or a beta or release candidate, check for duplicates,
+     reverts, and incorrect classifications of PRs, and whatever release
+     creation tagged to be manually checked.
+   - For any superseded PRs (like same dependency uplifted multiple times, or
+     commit revertion) that provide no value to the release, move them to
+     Superseded section. This way the changes are acknowledged to be part of the
+     release, but not overwhelming the important changes contained in the release.
+   - Commit your changes, push the new branch and create a pull request:
+     - The commit and PR title should be 🚀 Release v1.x.y:
+       -`git commit -S -s -m ":rocket: Release vX.Y.Z"`
+       -`git push -u origin release-notes-X.Y.Z`
+     - Important! The commit should only contain the release notes file, nothing
+     else, otherwise automation will not work.
+   - Ask maintainers and release team members to review your pull request.
+
+   Once the PR is merged, the following GitHub actions are triggered:
+
+   - GitHub action `Create Release` runs following jobs
+     - GitHub job `push_release_tags` will create and push the tags. This action
+     will also create release branch if its missing and release is `rc` or minor.
+     - GitHub job `create draft release` creates draft release. Don't publish the
+     release yet. Running actions are visible on the
+      [Actions](https://github.com/kubernetes-sigs/cluster-api-provider-openstack/actions)
+      page, and draft release will be visible on top of the
+      [Releases](https://github.com/kubernetes-sigs/cluster-api-provider-openstack/releases).
+
+      The image will also be automatically built by CI and pushed to the staging repository. As this only builds the image, it only takes a few minutes.
+
+1. Follow the [image promotion process](https://github.com/kubernetes/k8s.io/blob/main/registry.k8s.io/README.md#image-promoter) to promote the image from   the staging repo to `registry.k8s.io/capi-openstack`.
+   The staging repository can be inspected at [Staging CAPI Openstack](https://console.cloud.google.com/gcr/images/k8s-staging-capi-openstack/GLOBAL). Be
    sure to choose the top level `capi-openstack-controller`, which will provide the multi-arch manifest, rather than one for a specific architecture.
    The image build logs are available at [Cloud Build](https://console.cloud.google.com/cloud-build/builds?project=k8s-staging-capi-openstack).
    Add the new sha=>tag mapping to the [images.yaml](https://github.com/kubernetes/k8s.io/blob/main/registry.k8s.io/images/k8s-staging-capi-openstack/images.yaml) (use the sha of the image with the corresponding tag). The PR to update the [images.yaml](https://github.com/kubernetes/k8s.io/blob/main/registry.k8s.io/images/k8s-staging-capi-openstack/images.yaml) must be approved in the [OWNERS](https://github.com/kubernetes/k8s.io/blob/main/registry.k8s.io/images/k8s-staging-capi-openstack/OWNERS) file and merged.
 
-   It is good practise to get somebody else to review this PR. It is safe to perform the following steps while waiting
-   for review and the promotion of the image.
+   It is good practise to get somebody else to review this PR. It is safe to perform the following steps while waiting for review and the promotion of the image.
+
 1. Check carefully the [draft release](https://github.com/kubernetes-sigs/cluster-api-provider-openstack/releases)
    created by the workflow. Ensure that the release notes are correct and that the artifacts are present.
    If any changes are needed, edit the release notes in the GitHub UI and add any missing artifacts.
 1. Ensure that the release image has been promoted.
+1. If the release you're making is not a new major release, new minor release,
+   or a new patch release from the latest release branch, uncheck the box for
+   latest release. If it is a release candidate (RC) or a beta or an alpha
+   release, tick pre-release box.
 1. Publish release.
 
 ### Post release actions
@@ -96,5 +129,5 @@ Releasing requires a particular set of permissions.
 
 There is a post-submit Prow job running after each commit on `main` which pushes a new image to the staging repo (`gcr.io/k8s-staging-capi-openstack/capi-openstack-controller:latest`). Following configuration is involved:
 * staging gcr bucket: [kubernetes/k8s.io/blob/main/registry.k8s.io/manifests/k8s-staging-capi-openstack/promoter-manifest.yaml](https://github.com/kubernetes/k8s.io/blob/main/registry.k8s.io/manifests/k8s-staging-capi-openstack/promoter-manifest.yaml)
-* post-submit `post-capi-openstack-push-images` Prow job: [kubernetes/test-infra/blob/master/config/jobs/image-pushing/k8s-staging-cluster-api.yaml](https://github.com/kubernetes/test-infra/blob/master/config/jobs/image-pushing/k8s-staging-cluster-api.yaml)) (corresponding dashboard is located at [https://testgrid.k8s.io/sig-cluster-lifecycle-image-pushes#post-capi-openstack-push-images](https://testgrid.k8s.io/sig-cluster-lifecycle-image-pushes#post-capi-openstack-push-images))
+* post-submit `post-capi-openstack-push-images` Prow job: [kubernetes/test-infra/blob/master/config/jobs/image-pushing/k8s-staging-cluster-api.yaml](https://github.com/kubernetes/test-infra/blob/master/config/jobs/image-pushing/k8s-staging-cluster-api.yaml) (corresponding dashboard is located at [https://testgrid.k8s.io/sig-cluster-lifecycle-image-pushes#post-capi-openstack-push-images](https://testgrid.k8s.io/sig-cluster-lifecycle-image-pushes#post-capi-openstack-push-images))
 * Google Cloud Build configuration which is used by the Prow job: [kubernetes-sigs/cluster-api-provider-openstack/cloudbuild.yaml](https://github.com/kubernetes-sigs/cluster-api-provider-openstack/blob/main/cloudbuild.yaml)
diff --git a/pkg/cloud/services/loadbalancer/loadbalancer.go b/pkg/cloud/services/loadbalancer/loadbalancer.go
index efcd79a1..5c88c979 100644
--- a/pkg/cloud/services/loadbalancer/loadbalancer.go
+++ b/pkg/cloud/services/loadbalancer/loadbalancer.go
@@ -710,6 +710,10 @@ func (s *Service) ReconcileLoadBalancerMember(openStackCluster *infrav1.OpenStac
 			Tags:         openStackCluster.Spec.Tags,
 		}
 
+		if openStackCluster.Status.Network.ID != openStackCluster.Status.APIServerLoadBalancer.LoadBalancerNetwork.ID {
+			lbMemberOpts.SubnetID = openStackCluster.Status.Network.Subnets[0].ID
+		}
+
 		if _, err := s.waitForLoadBalancerActive(lbID); err != nil {
 			return err
 		}



Test patch:
diff --git a/pkg/cloud/services/loadbalancer/loadbalancer_test.go b/pkg/cloud/services/loadbalancer/loadbalancer_test.go
index 4c92670b..a6a04d48 100644
--- a/pkg/cloud/services/loadbalancer/loadbalancer_test.go
+++ b/pkg/cloud/services/loadbalancer/loadbalancer_test.go
@@ -32,6 +32,7 @@ import (
 	"github.com/gophercloud/gophercloud/v2/openstack/loadbalancer/v2/providers"
 	. "github.com/onsi/gomega" //nolint:revive
 	"go.uber.org/mock/gomock"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/utils/ptr"
 	clusterv1beta1 "sigs.k8s.io/cluster-api/api/core/v1beta1"
 
@@ -926,3 +927,252 @@ func Test_getOrCreateAPILoadBalancer(t *testing.T) {
 		})
 	}
 }
+
+func Test_ReconcileLoadBalancerMember(t *testing.T) {
+	g := NewWithT(t)
+	mockCtrl := gomock.NewController(t)
+	defer mockCtrl.Finish()
+
+	const (
+		clusterName         = "AAAAA"
+		clusterResourceName = "k8s-clusterapi-cluster-AAAAA"
+		memberIP            = "10.0.0.1"
+		wrongMemberIP       = "10.0.0.20"
+		port                = 6443
+		machineName         = "machine-1"
+
+		clusterNetID = "aaaaaaaa-bbbb-cccc-dddd-111111111111"
+		subnetID     = "aaaaaaaa-bbbb-cccc-dddd-222222222222"
+		lbID         = "aaaaaaaa-bbbb-cccc-dddd-333333333333"
+		listenerID   = "aaaaaaaa-bbbb-cccc-dddd-444444444444"
+		poolID       = "aaaaaaaa-bbbb-cccc-dddd-555555555555"
+		memberID     = "aaaaaaaa-bbbb-cccc-dddd-666666666666"
+		lbNetOtherID = "aaaaaaaa-bbbb-cccc-dddd-999999999999"
+	)
+
+	makeCluster := func(provider *string, lbNetworkID string) *infrav1.OpenStackCluster {
+		return &infrav1.OpenStackCluster{
+			Spec: infrav1.OpenStackClusterSpec{
+				APIServerLoadBalancer: &infrav1.APIServerLoadBalancer{
+					Enabled:  ptr.To(true),
+					Provider: provider,
+					Network: &infrav1.NetworkParam{
+						ID: &lbNetworkID,
+					},
+				},
+				DisableAPIServerFloatingIP: ptr.To(true),
+				ControlPlaneEndpoint: &clusterv1beta1.APIEndpoint{
+					Host: apiHostname,
+					Port: port,
+				},
+				Tags: []string{"k8s", "clusterapi"},
+			},
+			Status: infrav1.OpenStackClusterStatus{
+				APIServerLoadBalancer: &infrav1.LoadBalancer{
+					ID: lbID,
+					LoadBalancerNetwork: &infrav1.NetworkStatusWithSubnets{
+						NetworkStatus: infrav1.NetworkStatus{
+							ID: lbNetworkID,
+						},
+					},
+				},
+				Network: &infrav1.NetworkStatusWithSubnets{
+					NetworkStatus: infrav1.NetworkStatus{
+						ID: clusterNetID,
+					},
+					Subnets: []infrav1.Subnet{
+						{ID: subnetID},
+					},
+				},
+			},
+		}
+	}
+
+	openStackMachine := &infrav1.OpenStackMachine{
+		ObjectMeta: metav1.ObjectMeta{Name: machineName},
+	}
+
+	lbtests := []struct {
+		name               string
+		clusterSpec        *infrav1.OpenStackCluster
+		expectNetwork      func(m *mock.MockNetworkClientMockRecorder)
+		expectLoadBalancer func(m *mock.MockLbClientMockRecorder)
+		wantError          error
+	}{
+		{
+			name:          "LB member exists, dont create",
+			clusterSpec:   makeCluster(nil, clusterNetID),
+			expectNetwork: func(*mock.MockNetworkClientMockRecorder) {},
+			expectLoadBalancer: func(m *mock.MockLbClientMockRecorder) {
+				activeLB := loadbalancers.LoadBalancer{
+					ID:                 lbID,
+					Name:               clusterResourceName + "-kubeapi",
+					ProvisioningStatus: "ACTIVE",
+				}
+				m.GetLoadBalancer(lbID).Return(&activeLB, nil).AnyTimes()
+
+				pool := pools.Pool{
+					ID:   poolID,
+					Name: fmt.Sprintf("%s-kubeapi-%d", clusterResourceName, port),
+				}
+				m.ListPools(pools.ListOpts{Name: pool.Name}).Return([]pools.Pool{pool}, nil)
+
+				member := pools.Member{
+					Name:    fmt.Sprintf("%s-kubeapi-%d-%s", clusterResourceName, port, machineName),
+					Address: memberIP,
+				}
+				m.ListPoolMember(poolID, pools.ListMembersOpts{Name: member.Name}).Return([]pools.Member{member}, nil)
+			},
+			wantError: nil,
+		},
+		{
+			name:          "No LB member, create",
+			clusterSpec:   makeCluster(nil, clusterNetID),
+			expectNetwork: func(*mock.MockNetworkClientMockRecorder) {},
+			expectLoadBalancer: func(m *mock.MockLbClientMockRecorder) {
+				activeLB := loadbalancers.LoadBalancer{
+					ID:                 lbID,
+					Name:               clusterResourceName + "-kubeapi",
+					ProvisioningStatus: "ACTIVE",
+				}
+				m.GetLoadBalancer(lbID).Return(&activeLB, nil).AnyTimes()
+
+				pool := pools.Pool{
+					ID:   poolID,
+					Name: fmt.Sprintf("%s-kubeapi-%d", clusterResourceName, port),
+				}
+				m.ListPools(pools.ListOpts{Name: pool.Name}).Return([]pools.Pool{pool}, nil)
+
+				poolMemberName := fmt.Sprintf("%s-kubeapi-%d-%s", clusterResourceName, port, machineName)
+				m.ListPoolMember(poolID, pools.ListMembersOpts{Name: poolMemberName}).Return([]pools.Member{}, nil)
+
+				m.CreatePoolMember(
+					poolID,
+					gomock.AssignableToTypeOf(pools.CreateMemberOpts{}),
+				).DoAndReturn(func(_ string, got pools.CreateMemberOpts) (*pools.Member, error) {
+					// SubnetID must be empty here
+					g.Expect(got.SubnetID).To(Equal(""))
+					return &pools.Member{ID: "member-2"}, nil
+				})
+			},
+			wantError: nil,
+		},
+		{
+			name:          "No pool found, return error",
+			clusterSpec:   makeCluster(nil, clusterNetID),
+			expectNetwork: func(*mock.MockNetworkClientMockRecorder) {},
+			expectLoadBalancer: func(m *mock.MockLbClientMockRecorder) {
+				activeLB := loadbalancers.LoadBalancer{
+					ID:                 lbID,
+					Name:               clusterResourceName + "-kubeapi",
+					ProvisioningStatus: "ACTIVE",
+				}
+				m.GetLoadBalancer(lbID).Return(&activeLB, nil).AnyTimes()
+
+				poolName := fmt.Sprintf("%s-kubeapi-%d", clusterResourceName, port)
+				m.ListPools(pools.ListOpts{Name: poolName}).Return([]pools.Pool{}, nil)
+			},
+			wantError: errors.New("load balancer pool does not exist yet"),
+		},
+		{
+			name:          "LB member with wrong address, re-create",
+			clusterSpec:   makeCluster(nil, clusterNetID),
+			expectNetwork: func(*mock.MockNetworkClientMockRecorder) {},
+			expectLoadBalancer: func(m *mock.MockLbClientMockRecorder) {
+				activeLB := loadbalancers.LoadBalancer{
+					ID:                 lbID,
+					Name:               clusterResourceName + "-kubeapi",
+					ProvisioningStatus: "ACTIVE",
+				}
+				m.GetLoadBalancer(lbID).Return(&activeLB, nil).AnyTimes()
+
+				pool := pools.Pool{
+					ID:   poolID,
+					Name: fmt.Sprintf("%s-kubeapi-%d", clusterResourceName, port),
+				}
+				m.ListPools(pools.ListOpts{Name: pool.Name}).Return([]pools.Pool{pool}, nil)
+
+				member := pools.Member{
+					Name:    fmt.Sprintf("%s-kubeapi-%d-%s", clusterResourceName, port, machineName),
+					Address: wrongMemberIP,
+					ID:      memberID,
+				}
+				m.ListPoolMember(poolID, pools.ListMembersOpts{Name: member.Name}).Return([]pools.Member{member}, nil)
+
+				m.DeletePoolMember(poolID, memberID).Return(nil)
+
+				m.CreatePoolMember(
+					poolID,
+					gomock.AssignableToTypeOf(pools.CreateMemberOpts{}),
+				).DoAndReturn(func(_ string, got pools.CreateMemberOpts) (*pools.Member, error) {
+					// SubnetID must be empty here
+					g.Expect(got.SubnetID).To(Equal(""))
+					return &pools.Member{ID: "member-2"}, nil
+				})
+			},
+			wantError: nil,
+		},
+		{
+			name:        "different LB and cluster networks, set SubnetID on member create",
+			clusterSpec: makeCluster(nil, lbNetOtherID),
+			expectNetwork: func(*mock.MockNetworkClientMockRecorder) {
+				// not used by this path
+			},
+			expectLoadBalancer: func(m *mock.MockLbClientMockRecorder) {
+				// LB initially ACTIVE whenever we wait
+				activeLB := loadbalancers.LoadBalancer{
+					ID:                 lbID,
+					Name:               clusterResourceName + "-kubeapi",
+					ProvisioningStatus: "ACTIVE",
+				}
+				m.GetLoadBalancer(lbID).Return(&activeLB, nil).AnyTimes()
+
+				pool := pools.Pool{
+					ID:   poolID,
+					Name: fmt.Sprintf("%s-kubeapi-%d", clusterResourceName, port),
+				}
+				m.ListPools(pools.ListOpts{Name: pool.Name}).Return([]pools.Pool{pool}, nil)
+
+				memberName := fmt.Sprintf("%s-kubeapi-%d-%s", clusterResourceName, port, machineName)
+				m.ListPoolMember(poolID, pools.ListMembersOpts{Name: memberName}).Return([]pools.Member{}, nil)
+
+				// Expect CreatePoolMember; capture opts to assert SubnetID is set
+				m.CreatePoolMember(
+					poolID,
+					gomock.AssignableToTypeOf(pools.CreateMemberOpts{}),
+				).DoAndReturn(func(_ string, got pools.CreateMemberOpts) (*pools.Member, error) {
+					g.Expect(got.Address).To(Equal(memberIP))
+					g.Expect(got.ProtocolPort).To(Equal(port))
+					expName := fmt.Sprintf("%s-kubeapi-%d-%s", clusterResourceName, port, machineName)
+					g.Expect(got.Name).To(Equal(expName))
+					g.Expect(got.SubnetID).To(Equal(subnetID))
+					// Tags should pass through
+					g.Expect(got.Tags).To(ConsistOf("k8s", "clusterapi"))
+					return &pools.Member{ID: "member-1", Address: memberIP, ProtocolPort: port}, nil
+				})
+			},
+			wantError: nil,
+		},
+	}
+
+	for _, tt := range lbtests {
+		t.Run(tt.name, func(t *testing.T) {
+			g := NewWithT(t)
+			log := testr.New(t)
+
+			mockScopeFactory := scope.NewMockScopeFactory(mockCtrl, "")
+			lbs, err := NewService(scope.NewWithLogger(mockScopeFactory, log))
+			g.Expect(err).NotTo(HaveOccurred())
+
+			tt.expectNetwork(mockScopeFactory.NetworkClient.EXPECT())
+			tt.expectLoadBalancer(mockScopeFactory.LbClient.EXPECT())
+
+			err = lbs.ReconcileLoadBalancerMember(tt.clusterSpec, openStackMachine, clusterName, memberIP)
+			if tt.wantError != nil {
+				g.Expect(err).To(MatchError(tt.wantError))
+			} else {
+				g.Expect(err).NotTo(HaveOccurred())
+			}
+		})
+	}
+}