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

Issue:
Panic in OpenStackMachineReconciler if OpenStackCluster.Status.Network is nil (Hosted Control Plane scenario)
/kind bug

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

In a “hosted control plane” setup (where the control plane runs outside of OpenStack, and only worker nodes are provisioned in OpenStack), OpenStackCluster.Status.Network can remain nil. Currently, the CAPO code in OpenStackMachineReconciler.getOrCreateMachineServer() assumes openStackCluster.Status.Network is always non-nil. This leads to a nil pointer dereference (panic) when calling:

```
machineServerSpec := openStackMachineSpecToOpenStackServerSpec(
    &openStackMachine.Spec,
    identityRef,
    compute.InstanceTags(&openStackMachine.Spec, openStackCluster),
    failureDomain,
    userDataRef,
    getManagedSecurityGroup(openStackCluster, machine),
    openStackCluster.Status.Network.ID,  // <- panic if .Network is nil
)
```

The controller then crashes, making it impossible to provision worker nodes.
- In HPC scenarios, there is no control-plane node running in OpenStack, so CAPO never populates OpenStackCluster.Status.Network.
- The machine reconciliation panics in openstackmachine_controller.go due to a nil pointer dereference on openStackCluster.Status.Network.ID.

Logs:

```
0116 03:44:55.377796       1 openstackmachine_controller.go:361] "Reconciling Machine" controller="openstackmachine" controllerGroup="infrastructure.cluster.x-k8s.io" controllerKind="OpenStackMachine" OpenStackMachine="kcm-system/openstack-dev-hosted-cp-md-fcpqk-8l4q5" namespace="kcm-system" name="openstack-dev-hosted-cp-md-fcpqk-8l4q5" reconcileID="b00cfcbb-ae39-4bb9-aa87-0bcde7cb350d" openStackMachine="openstack-dev-hosted-cp-md-fcpqk-8l4q5" machine="openstack-dev-hosted-cp-md-fcpqk-8l4q5" cluster="openstack-dev-hosted-cp" openStackCluster="openstack-dev-hosted-cp"
I0116 03:44:55.378942       1 controller.go:110] "Observed a panic in reconciler: runtime error: invalid memory address or nil pointer dereference" controller="openstackmachine" controllerGroup="infrastructure.cluster.x-k8s.io" controllerKind="OpenStackMachine" OpenStackMachine="kcm-system/openstack-dev-hosted-cp-md-fcpqk-8l4q5" namespace="kcm-system" name="openstack-dev-hosted-cp-md-fcpqk-8l4q5" reconcileID="b00cfcbb-ae39-4bb9-aa87-0bcde7cb350d"
panic: runtime error: invalid memory address or nil pointer dereference [recovered]
        panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x1 addr=0x10 pc=0x1baafba]

goroutine 357 [running]:
sigs.k8s.io/controller-runtime/pkg/internal/controller.(*Controller).Reconcile.func1()
        /go/pkg/mod/sigs.k8s.io/controller-runtime@v0.18.5/pkg/internal/controller/controller.go:111 +0x1e5
panic({0x1dccbe0?, 0x362a670?})
        /usr/local/go/src/runtime/panic.go:770 +0x132
sigs.k8s.io/cluster-api-provider-openstack/controllers.(*OpenStackMachineReconciler).getOrCreateMachineServer(0xc00043a2a0, {0x2440550, 0xc0005b7230}, 0xc0004deb08, 0xc0006bc508, 0xc0008fa008)
        /workspace/controllers/openstackmachine_controller.go:586 +0x35a
sigs.k8s.io/cluster-api-provider-openstack/controllers.(*OpenStackMachineReconciler).reconcileMachineServer(0x24467c8?, {0x2440550?, 0xc0005b7230?}, 0xc0006d1560, 0x13?, 0x0?, 0x0?)
        /workspace/controllers/openstackmachine_controller.go:544 +0x3d
sigs.k8s.io/cluster-api-provider-openstack/controllers.(*OpenStackMachineReconciler).reconcileNormal(0xc00043a2a0, {0x2440550, 0xc0005b7230}, 0xc0006d1560, {0xc000059500, 0x22}, 0xc0004deb08, 0xc0008fa008, 0xc0006bc508)
        /workspace/controllers/openstackmachine_controller.go:363 +0x178
sigs.k8s.io/cluster-api-provider-openstack/controllers.(*OpenStackMachineReconciler).Reconcile(0xc00043a2a0, {0x2440550, 0xc0005b7230}, {{{0xc0006b5576?, 0x0?}, {0xc00059d050?, 0xc0008f1d10?}}})
        /workspace/controllers/openstackmachine_controller.go:161 +0xbd8
sigs.k8s.io/controller-runtime/pkg/internal/controller.(*Controller).Reconcile(0x24467c8?, {0x2440550?, 0xc0005b7230?}, {{{0xc0006b5576?, 0xb?}, {0xc00059d050?, 0x0?}}})
        /go/pkg/mod/sigs.k8s.io/controller-runtime@v0.18.5/pkg/internal/controller/controller.go:114 +0xb7
sigs.k8s.io/controller-runtime/pkg/internal/controller.(*Controller).reconcileHandler(0xc0004f2160, {0x2440588, 0xc00022f810}, {0x1e96420, 0xc00003d920})
        /go/pkg/mod/sigs.k8s.io/controller-runtime@v0.18.5/pkg/internal/controller/controller.go:311 +0x3bc
sigs.k8s.io/controller-runtime/pkg/internal/controller.(*Controller).processNextWorkItem(0xc0004f2160, {0x2440588, 0xc00022f810})
        /go/pkg/mod/sigs.k8s.io/controller-runtime@v0.18.5/pkg/internal/controller/controller.go:261 +0x1be
sigs.k8s.io/controller-runtime/pkg/internal/controller.(*Controller).Start.func2.2()
        /go/pkg/mod/sigs.k8s.io/controller-runtime@v0.18.5/pkg/internal/controller/controller.go:222 +0x79
created by sigs.k8s.io/controller-runtime/pkg/internal/controller.(*Controller).Start.func2 in goroutine 203
        /go/pkg/mod/sigs.k8s.io/controller-runtime@v0.18.5/pkg/internal/controller/controller.go:218 +0x486
```

**What did you expect to happen:**
That CAPO would handle the absence of status.network gracefully—e.g. by marking the OpenStackMachine with a condition or requeueing—rather than panicking.


**Environment:**

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


Hints from issue comments:
@bnallapeta: /assign bnallapeta@k8s-triage-robot: The Kubernetes project currently lacks enough contributors to adequately respond to all issues.

This bot triages un-triaged issues according to the following rules:
- After 90d of inactivity, `lifecycle/stale` is applied
- After 30d of inactivity since `lifecycle/stale` was applied, `lifecycle/rotten` is applied
- After 30d of inactivity since `lifecycle/rotten` was applied, the issue is closed

You can:
- Mark this issue as fresh with `/remove-lifecycle stale`
- Close this issue with `/close`
- Offer to help out with [Issue Triage][1]

Please send feedback to sig-contributor-experience at [kubernetes/community](https://github.com/kubernetes/community).

/lifecycle stale

[1]: https://www.kubernetes.dev/docs/guide/issue-triage/@k8s-triage-robot: The Kubernetes project currently lacks enough active contributors to adequately respond to all issues.

This bot triages un-triaged issues according to the following rules:
- After 90d of inactivity, `lifecycle/stale` is applied
- After 30d of inactivity since `lifecycle/stale` was applied, `lifecycle/rotten` is applied
- After 30d of inactivity since `lifecycle/rotten` was applied, the issue is closed

You can:
- Mark this issue as fresh with `/remove-lifecycle rotten`
- Close this issue with `/close`
- Offer to help out with [Issue Triage][1]

Please send feedback to sig-contributor-experience at [kubernetes/community](https://github.com/kubernetes/community).

/lifecycle rotten

[1]: https://www.kubernetes.dev/docs/guide/issue-triage/@k8s-triage-robot: The Kubernetes project currently lacks enough active contributors to adequately respond to all issues and PRs.

This bot triages issues according to the following rules:
- After 90d of inactivity, `lifecycle/stale` is applied
- After 30d of inactivity since `lifecycle/stale` was applied, `lifecycle/rotten` is applied
- After 30d of inactivity since `lifecycle/rotten` was applied, the issue is closed

You can:
- Reopen this issue with `/reopen`
- Mark this issue as fresh with `/remove-lifecycle rotten`
- Offer to help out with [Issue Triage][1]

Please send feedback to sig-contributor-experience at [kubernetes/community](https://github.com/kubernetes/community).

/close not-planned

[1]: https://www.kubernetes.dev/docs/guide/issue-triage/@k8s-ci-robot: @k8s-triage-robot: Closing this issue, marking it as "Not Planned".

<details>

In response to [this](https://github.com/kubernetes-sigs/cluster-api-provider-openstack/issues/2380#issuecomment-3001714572):

>The Kubernetes project currently lacks enough active contributors to adequately respond to all issues and PRs.
>
>This bot triages issues according to the following rules:
>- After 90d of inactivity, `lifecycle/stale` is applied
>- After 30d of inactivity since `lifecycle/stale` was applied, `lifecycle/rotten` is applied
>- After 30d of inactivity since `lifecycle/rotten` was applied, the issue is closed
>
>You can:
>- Reopen this issue with `/reopen`
>- Mark this issue as fresh with `/remove-lifecycle rotten`
>- Offer to help out with [Issue Triage][1]
>
>Please send feedback to sig-contributor-experience at [kubernetes/community](https://github.com/kubernetes/community).
>
>/close not-planned
>
>[1]: https://www.kubernetes.dev/docs/guide/issue-triage/


Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes-sigs/prow](https://github.com/kubernetes-sigs/prow/issues/new?title=Prow%20issue:) repository.
</details>@broboa: I am a CAPI/CAPO noob, and am trying to set up a cluster in OpenStack using k0smotron, but it seems that I am encoutering this bug, because as soon as the control plane is ready and the machines get created, the `capo-controller-manager` crashes hard and prints the below error a couple of dozen times. Keep in mind that k0smotron runs the control plane as pods, so it matches @bnallapeta's description:

>In HPC scenarios, there is no control-plane node running in OpenStack, so CAPO never populates OpenStackCluster.Status.Network.

CAPO version is v0.12.4.

```txt
I0724 09:58:43.657622       1 openstackmachine_controller.go:352] "Bootstrap data secret reference is not yet available" controller="openstackmachine" controllerGroup="infrastructure.cluster.x-k8s.io" controllerKind="OpenStackMachine" OpenStackMachine="infra-dev-2/dev01-worker-f79sk-bj54x" namespace="infra-dev-2" name="dev01-worker-f79sk-bj54x" reconcileID="eb59a8bc-0996-4341-b697-ff336d17574e" openStackMachine="dev01-worker-f79sk-bj54x" machine="dev01-worker-f79sk-bj54x" cluster="dev01" openStackCluster="dev01"
I0724 09:58:43.749624       1 openstackmachine_controller.go:352] "Bootstrap data secret reference is not yet available" controller="openstackmachine" controllerGroup="infrastructure.cluster.x-k8s.io" controllerKind="OpenStackMachine" OpenStackMachine="infra-dev-2/dev01-worker-f79sk-wpj2d" namespace="infra-dev-2" name="dev01-worker-f79sk-wpj2d" reconcileID="b4b6d8c7-4b84-4763-b3b0-ae1e13bdf550" openStackMachine="dev01-worker-f79sk-wpj2d" machine="dev01-worker-f79sk-wpj2d" cluster="dev01" openStackCluster="dev01"
I0724 09:58:43.835289       1 warning_handler.go:65] "metadata.finalizers: \"openstackmachine.infrastructure.cluster.x-k8s.io\": prefer a domain-qualified finalizer name including a path (/) to avoid accidental conflicts with other finalizer writers" logger="KubeAPIWarningLogger"
I0724 09:58:43.881121       1 openstackmachine_controller.go:361] "Reconciling Machine" controller="openstackmachine" controllerGroup="infrastructure.cluster.x-k8s.io" controllerKind="OpenStackMachine" OpenStackMachine="infra-dev-2/dev01-worker-f79sk-bj54x" namespace="infra-dev-2" name="dev01-worker-f79sk-bj54x" reconcileID="562bc44b-be3b-4b28-a653-14db800207be" openStackMachine="dev01-worker-f79sk-bj54x" machine="dev01-worker-f79sk-bj54x" cluster="dev01" openStackCluster="dev01"
E0724 09:58:43.881826       1 signal_unix.go:917] "Observed a panic" controller="openstackmachine" controllerGroup="infrastructure.cluster.x-k8s.io" controllerKind="OpenStackMachine" OpenStackMachine="infra-dev-2/dev01-worker-f79sk-bj54x" namespace="infra-dev-2" name="dev01-worker-f79sk-bj54x" reconcileID="562bc44b-be3b-4b28-a653-14db800207be" panic="runtime error: invalid memory address or nil pointer dereference" panicGoValue="\"invalid memory address or nil pointer dereference\"" stacktrace=<
	goroutine 297 [running]:
	k8s.io/apimachinery/pkg/util/runtime.logPanic({0x24cb1f0, 0xc000df7140}, {0x1e33ba0, 0x371dcb0})
		/go/pkg/mod/k8s.io/apimachinery@v0.31.10/pkg/util/runtime/runtime.go:107 +0xbc
	sigs.k8s.io/controller-runtime/pkg/internal/controller.(*Controller[...]).Reconcile.func1()
		/go/pkg/mod/sigs.k8s.io/controller-runtime@v0.19.7/pkg/internal/controller/controller.go:105 +0x112
	panic({0x1e33ba0?, 0x371dcb0?})
		/usr/local/go/src/runtime/panic.go:791 +0x132
	sigs.k8s.io/cluster-api-provider-openstack/controllers.(*OpenStackMachineReconciler).getOrCreateMachineServer(0xc000420c00, {0x24cb1f0, 0xc000df7140}, 0xc0005778c8, 0xc000d99908, 0xc000ea6008)
		/workspace/controllers/openstackmachine_controller.go:591 +0x35a
	sigs.k8s.io/cluster-api-provider-openstack/controllers.(*OpenStackMachineReconciler).reconcileMachineServer(0x24d1de8?, {0x24cb1f0?, 0xc000df7140?}, 0xc000e02900, 0x13?, 0x0?, 0x0?)
		/workspace/controllers/openstackmachine_controller.go:549 +0x3d
	sigs.k8s.io/cluster-api-provider-openstack/controllers.(*OpenStackMachineReconciler).reconcileNormal(0xc000420c00, {0x24cb1f0, 0xc000df7140}, 0xc000e02900, {0xc000e10270, 0x11}, 0xc0005778c8, 0xc000ea6008, 0xc000d99908)
		/workspace/controllers/openstackmachine_controller.go:363 +0x178
	sigs.k8s.io/cluster-api-provider-openstack/controllers.(*OpenStackMachineReconciler).Reconcile(0xc000420c00, {0x24cb1f0, 0xc000df7140}, {{{0xc000012c60?, 0xc000df7140?}, {0xc000b93680?, 0x0?}}})
		/workspace/controllers/openstackmachine_controller.go:161 +0xbd8
	sigs.k8s.io/controller-runtime/pkg/internal/controller.(*Controller[...]).Reconcile(0xc000b65680?, {0x24cb1f0?, 0xc000df7140?}, {{{0xc000012c60?, 0x0?}, {0xc000b93680?, 0x0?}}})
		/go/pkg/mod/sigs.k8s.io/controller-runtime@v0.19.7/pkg/internal/controller/controller.go:116 +0xbf
	sigs.k8s.io/controller-runtime/pkg/internal/controller.(*Controller[...]).reconcileHandler(0x24e4fa0, {0x24cb228, 0xc00045da40}, {{{0xc000012c60, 0xb}, {0xc000b93680, 0x18}}})
		/go/pkg/mod/sigs.k8s.io/controller-runtime@v0.19.7/pkg/internal/controller/controller.go:303 +0x3a5
	sigs.k8s.io/controller-runtime/pkg/internal/controller.(*Controller[...]).processNextWorkItem(0x24e4fa0, {0x24cb228, 0xc00045da40})
		/go/pkg/mod/sigs.k8s.io/controller-runtime@v0.19.7/pkg/internal/controller/controller.go:263 +0x20e
	sigs.k8s.io/controller-runtime/pkg/internal/controller.(*Controller[...]).Start.func2.2()
		/go/pkg/mod/sigs.k8s.io/controller-runtime@v0.19.7/pkg/internal/controller/controller.go:224 +0x85
	created by sigs.k8s.io/controller-runtime/pkg/internal/controller.(*Controller[...]).Start.func2 in goroutine 188
		/go/pkg/mod/sigs.k8s.io/controller-runtime@v0.19.7/pkg/internal/controller/controller.go:220 +0x510
 >
E0724 09:58:43.881893       1 controller.go:316] "Reconciler error" err="panic: runtime error: invalid memory address or nil pointer dereference [recovered]" controller="openstackmachine" controllerGroup="infrastructure.cluster.x-k8s.io" controllerKind="OpenStackMachine" OpenStackMachine="infra-dev-2/dev01-worker-f79sk-bj54x" namespace="infra-dev-2" name="dev01-worker-f79sk-bj54x" reconcileID="562bc44b-be3b-4b28-a653-14db800207be"
I0724 09:58:43.900940       1 openstackmachine_controller.go:361] "Reconciling Machine" controller="openstackmachine" controllerGroup="infrastructure.cluster.x-k8s.io" controllerKind="OpenStackMachine" OpenStackMachine="infra-dev-2/dev01-worker-f79sk-bj54x" namespace="infra-dev-2" name="dev01-worker-f79sk-bj54x" reconcileID="7f058b86-7f81-4eb8-afe5-d58011f892ad" openStackMachine="dev01-worker-f79sk-bj54x" machine="dev01-worker-f79sk-bj54x" cluster="dev01" openStackCluster="dev01"
```

Since the bot closed this issue and it seems pretty severe, I figure I'll ping some maintainers from Red Hat to check this out: @mdbooth @EmilienM @bnallapeta: /reopen@k8s-ci-robot: @bnallapeta: Reopened this issue.

<details>

In response to [this](https://github.com/kubernetes-sigs/cluster-api-provider-openstack/issues/2380#issuecomment-3146990007):

>/reopen


Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes-sigs/prow](https://github.com/kubernetes-sigs/prow/issues/new?title=Prow%20issue:) repository.
</details>@TruongHoangPhuLoc: Hello @bnallapeta,
I got the same issue when it comes to hosted contron plane. It sounds like the nil field in openStackCluster.Status.Network.ID broke the code.
Here is the describe of openstackcluster. Even though I specify the network with the existing one, the network.id in status field doesnt appear. I assume that's because its self managed cluster in cluster.x-k8s.io/managed-by
```
Name:         capi-k0s-ubuntu-openstack
Namespace:    default
Labels:       cluster.x-k8s.io/cluster-name=capi-k0s-ubuntu-openstack
Annotations:  cluster.x-k8s.io/managed-by: k0smotron
API Version:  infrastructure.cluster.x-k8s.io/v1beta1
Kind:         OpenStackCluster
Metadata:
  Creation Timestamp:  2025-08-16T09:38:10Z
  Generation:          2
  Owner References:
    API Version:           cluster.x-k8s.io/v1beta1
    Block Owner Deletion:  true
    Controller:            true
    Kind:                  Cluster
    Name:                  capi-k0s-ubuntu-openstack
    UID:                   d89e24f4-0eea-4037-9340-39cdf52cbbf0
  Resource Version:        371395
  UID:                     8a21c495-c652-47b4-a265-b2220bed214f
Spec:
  Control Plane Endpoint:
    Host:  172.16.1.117
    Port:  32274
  External Network:
    Id:  2d04c67b-781a-476e-aa7a-5160c073005e
  Identity Ref:
    Cloud Name:  openstack
    Name:        capi-k0s-ubuntu-openstack-cloud-config
  Managed Security Groups:
    All Nodes Security Group Rules:
      Description:                 Allow all IPv4 ingress traffic
      Direction:                   ingress
      Ether Type:                  IPv4
      Name:                        allow-all-traffic
      Protocol:                    any
    Allow All In Cluster Traffic:  true
  Network:
    Id:  9b3b087c-5f37-4f5e-af4d-af11a87122e8
  Subnets:
    Id:  7a8b05bf-0dad-4712-80b8-8af7035ea3d9
Status:
  Ready:  true
Events:   <none>                                         
```
```
I0816 09:09:04.466062       1 openstackmachine_controller.go:361] "Reconciling Machine" 
  controller="openstackmachine" 
  controllerGroup="infrastructure.cluster.x-k8s.io" 
  controllerKind="OpenStackMachine" 
  OpenStackMachine="default/capi-k0s-ubuntu-openstack-worker-vms-lsl2j-7zn6f"

E0816 09:09:04.466716       1 signal_unix.go:917] "Observed a panic" 
  controller="openstackmachine" 
  controllerGroup="infrastructure.cluster.x-k8s.io" 
  controllerKind="OpenStackMachine" 
  OpenStackMachine="default/capi-k0s-ubuntu-openstack-worker-vms-lsl2j-7zn6f" 
  namespace="default"

goroutine 322 [running]:
  k8s.io/apimachinery/pkg/util/runtime.logPanic(...)
    /go/pkg/mod/k8s.io/apimachinery@v0.31.10/pkg/util/runtime/runtime.go:107 +0xbc

  sigs.k8s.io/controller-runtime/pkg/internal/controller.(*Controller[...]).Reconcile.func1()
    /go/pkg/mod/sigs.k8s.io/controller-runtime@v0.19.7/pkg/internal/controller/controller.go:105 +0x112

  panic(...)
    /usr/local/go/src/runtime/panic.go:791 +0x132

  sigs.k8s.io/cluster-api-provider-openstack/controllers.(*OpenStackMachineReconciler).getOrCreateMachineServer(...)
    /workspace/controllers/openstackmachine_controller.go:591 +0x35a

  sigs.k8s.io/cluster-api-provider-openstack/controllers.(*OpenStackMachineReconciler).reconcileMachineServer(...)
    /workspace/controllers/openstackmachine_controller.go:549 +0x3d

  sigs.k8s.io/cluster-api-provider-openstack/controllers.(*OpenStackMachineReconciler).reconcileNormal(...)
    /workspace/controllers/openstackmachine_controller.go:363 +0x178

  sigs.k8s.io/cluster-api-provider-openstack/controllers.(*OpenStackMachineReconciler).Reconcile(...)
    /workspace/controllers/openstackmachine_controller.go:161 +0xbd8

  sigs.k8s.io/controller-runtime/pkg/internal/controller.(*Controller[...]).Reconcile(...)
    /go/pkg/mod/sigs.k8s.io/controller-runtime@v0.19.7/pkg/internal/controller/controller.go:116 +0xbf

  sigs.k8s.io/controller-runtime/pkg/internal/controller.(*Controller[...]).reconcileHandler(...)
    /go/pkg/mod/sigs.k8s.io/controller-runtime@v0.19.7/pkg/internal/controller/controller.go:303 +0x3a5

  sigs.k8s.io/controller-runtime/pkg/internal/controller.(*Controller[...]).processNextWorkItem(...)
    /go/pkg/mod/sigs.k8s.io/controller-runtime@v0.19.7/pkg/internal/controller/controller.go:263 +0x20e

  sigs.k8s.io/controller-runtime/pkg/internal/controller.(*Controller[...]).Start.func2.2()
    /go/pkg/mod/sigs.k8s.io/controller-runtime@v0.19.7/pkg/internal/controller/controller.go:224 +0x85

created by sigs.k8s.io/controller-runtime/pkg/internal/controller.(*Controller[...]).Start.func2 in goroutine 164
  /go/pkg/mod/sigs.k8s.io/controller-runtime@v0.19.7/pkg/internal/controller/controller.go:220 +0x510

E0816 09:09:04.466805       1 controller.go:316] "Reconciler error" 
  err="panic: runtime error: invalid memory address or nil pointer dereference [recovered]" 
  controller="openstackmachine" 
  controllerGroup="infrastructure.cluster.x-k8s.io" 
  controllerKind="OpenStackMachine" 
  OpenStackMachine="default/capi-k0s-ubuntu-openstack-worker-vms-lsl2j-7zn6f"
```
Best regards@TruongHoangPhuLoc: Hi @bnallapeta and @broboa 
First of all, I want to express my deep gratitude to you for pointing out the issue. It was very helpful for me to implement the fix on my local dev and now it's working with hosted control plane (k0smotron)
I added the helper function to check if the cluster network status is nil - which is in our case when the cluster is self managed. Then if it's nil, trying to grab the network from the machine.specs
```
func getDefaultNetworkID(machineSpec *infrav1.OpenStackMachineSpec, clusterStatus *infrav1.OpenStackClusterStatus) string {
    // Check cluster status network first
    if clusterStatus != nil && clusterStatus.Network != nil && clusterStatus.Network.ID != "" {
        return clusterStatus.Network.ID
    }

    // Fallback: look into machine spec ports
    for _, port := range machineSpec.Ports {
        if port.Network != nil && port.Network.ID != nil {
            return *port.Network.ID
        }
    }

    // Nothing found, return empty string (caller must handle appropriately)
    return ""
}
```
My machine.spec
```
apiVersion: infrastructure.cluster.x-k8s.io/v1beta1
kind: OpenStackMachineTemplate
metadata:
  name: capi-k0s-ubuntu-openstack-worker-vm-template
  namespace: default
spec:
  template:
    spec:
      flavor: m1.k8s-worker-small 
      identityRef:
        cloudName: openstack
        name: capi-k0s-ubuntu-openstack-cloud-config
      image:
        id: 6f51d911-6ba8-4d60-be8c-d82f324b64a2 # your image
      sshKeyName: loctruong-automation
      ports:
      - network:
          id: 9b3b087c-5f37-4f5e-af4d-af11a87122e8
      securityGroups:
      - id: 47cdf56f-d2ce-46f2-9751-fcbbd40c37e3
```
as the result, it could create a machine

<img width="2322" height="187" alt="Image" src="https://github.com/user-attachments/assets/ff9bb269-bfc9-42e8-af27-70b31bdfaf34" />

and the k0smotron now could work with capo to create separate worker machine

<img width="2515" height="116" alt="Image" src="https://github.com/user-attachments/assets/1eed285d-c857-46b3-a36d-16480d3776b9" />

My dev image is here so you guys could test it out
docker.io/locthp/capi-openstack-controller-amd64:dev

In case you would like, I could propose a PR to introduce the diff

Best regards@bnallapeta: @TruongHoangPhuLoc Yeah, my environment was exactly the same too (k0smotoron). And glad to see you got it working. 

I have #2635 open and I am waiting for the maintainers to take a look.

Solution patch:
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 3a84f7f5..ba58a7a9 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -103,6 +103,7 @@ updates:
   - dependency-name: "sigs.k8s.io/structured-merge-diff/*"
   # These dependencies are skipped because they require a newer version of go:
   - dependency-name: "github.com/a8m/envsubst"
+  - dependency-name: "github.com/onsi/gomega"
   labels:
     - "area/dependency"
     - "ok-to-test"
diff --git a/.golangci.yml b/.golangci.yml
index b8cb94ef..f2430a1e 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -1,204 +1,216 @@
+version: "2"
+run:
+  go: "1.24"
+  build-tags:
+    - e2e
+  allow-parallel-runners: true
 linters:
-  disable-all: true
+  default: none
   enable:
-  - asasalint
-  - asciicheck
-  - bidichk
-  - bodyclose
-  - copyloopvar
-  - cyclop
-  - dogsled
-  - dupword
-  - durationcheck
-  - errcheck
-  - forbidigo
-  - gci
-  - goconst
-  - gocritic
-  - gocyclo
-  - godot
-  - gofmt
-  - gofumpt
-  - goheader
-  - goimports
-  - gomodguard
-  - goprintffuncname
-  - gosec
-  - gosimple
-  - govet
-  - importas
-  - ineffassign
-  - makezero
-  - misspell
-  - nakedret
-  - nestif
-  - nilerr
-  - noctx
-  - nolintlint
-  - prealloc
-  - predeclared
-  - revive
-  - rowserrcheck
-  - sqlclosecheck
-  - staticcheck
-  - stylecheck
-  - thelper
-  - typecheck
-  - unconvert
-  - unparam
-  - unused
-  - wastedassign
-  - whitespace
-
-linters-settings:
-  cyclop:
-    # TODO(sbuerin) fix remaining findings and set to 20 afterwards
-    max-complexity: 30
-  gci:
-    sections:
-    - standard
-    - default
-    - prefix(github.com/k-orc/openstack-resource-controller)
-    - prefix(sigs.k8s.io/cluster-api-provider-openstack)
-  gocritic:
-    enabled-tags:
-    - diagnostic
-    - experimental
-    - performance
-    disabled-checks:
-    - appendAssign
-    - dupImport # https://github.com/go-critic/go-critic/issues/845
-    - evalOrder
-    - ifElseChain
-    - octalLiteral
-    - regexpSimplify
-    - sloppyReassign
-    - truncateCmp
-    - typeDefFirst
-    - unnamedResult
-    - unnecessaryDefer
-    - whyNoLint
-    - wrapperFunc
-    - rangeValCopy
-    - hugeParam
-  importas:
-    no-unaliased: true
-    alias:
-    # Kubernetes
-    - pkg: k8s.io/api/core/v1
-      alias: corev1
-    - pkg: k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1
-      alias: apiextensionsv1
-    - pkg: k8s.io/apimachinery/pkg/apis/meta/v1
-      alias: metav1
-    - pkg: k8s.io/apimachinery/pkg/api/errors
-      alias: apierrors
-    - pkg: k8s.io/apimachinery/pkg/util/errors
-      alias: kerrors
-    # Controller Runtime
-    - pkg: sigs.k8s.io/controller-runtime
-      alias: ctrl
-    # CAPO
-    - pkg: sigs.k8s.io/cluster-api-provider-openstack/api/v1alpha1
-      alias: infrav1alpha1
-    - pkg: sigs.k8s.io/cluster-api-provider-openstack/api/v1beta1
-      alias: infrav1
-    - pkg: sigs.k8s.io/cluster-api-provider-openstack/pkg/utils/errors
-      alias: capoerrors
-    # CAPI
-    - pkg: sigs.k8s.io/cluster-api/api/v1alpha3
-      alias: clusterv1alpha3
-    - pkg: sigs.k8s.io/cluster-api/api/v1alpha4
-      alias: clusterv1alpha4
-    - pkg: sigs.k8s.io/cluster-api/api/core/v1beta2
-      alias: clusterv1
-    - pkg: sigs.k8s.io/cluster-api/api/core/v1beta1
-      alias: clusterv1beta1
-    - pkg: sigs.k8s.io/cluster-api/util/deprecated/v1beta1/conditions
-      alias: v1beta1conditions
-    - pkg: sigs.k8s.io/cluster-api/util/deprecated/v1beta1/conditions/v1beta2
-      alias: deprecatedv1beta2conditions
-    - pkg: sigs.k8s.io/cluster-api/util/conditions/deprecated/v1beta1
-      alias: deprecatedv1beta1conditions
-    - pkg: sigs.k8s.io/cluster-api/util/deprecated/v1beta1/patch
-      alias: v1beta1patch
-    - pkg: sigs.k8s.io/cluster-api/api/ipam/v1beta2
-      alias: ipamv1
-    # CABPK
-    - pkg: sigs.k8s.io/cluster-api/bootstrap/kubeadm/api/v1alpha3
-      alias: bootstrapv1alpha3
-    - pkg: sigs.k8s.io/cluster-api/bootstrap/kubeadm/api/v1alpha4
-      alias: bootstrapv1alpha4
-    - pkg: sigs.k8s.io/cluster-api/bootstrap/kubeadm/api/v1beta1
-      alias: bootstrapv1
-    # KCP
-    - pkg: sigs.k8s.io/cluster-api/controlplane/kubeadm/api/v1alpha3
-      alias: controlplanev1alpha3
-    - pkg: sigs.k8s.io/cluster-api/controlplane/kubeadm/api/v1alpha4
-      alias: controlplanev1alpha4
-    - pkg: sigs.k8s.io/cluster-api/controlplane/kubeadm/api/v1beta1
-      alias: controlplanev1
-    # ORC
-    - pkg: github.com/k-orc/openstack-resource-controller/api/v1alpha1
-      alias: orcv1alpha1
-
-  nolintlint:
-    # https://github.com/golangci/golangci-lint/issues/3228
-    allow-unused: true
-  nestif:
-    # minimal complexity of if statements to report, 5 by default
-    # TODO(sbuerin) fix remaining findings and set to 5 after:
-    # https://github.com/kubernetes-sigs/cluster-api-provider-openstack/pull/778
-    min-complexity: 13
-
-issues:
-  max-same-issues: 0
-  max-issues-per-linter: 0
-  # List of regexps of issue texts to exclude, empty list by default.
-  exclude-rules:
-  - linters:
-    - gosec
-    text: "G108: Profiling endpoint is automatically exposed on /debug/pprof"
-  - linters:
+    - asasalint
+    - asciicheck
+    - bidichk
+    - bodyclose
+    - copyloopvar
+    - cyclop
+    - dogsled
+    - dupword
+    - durationcheck
+    - errcheck
+    - forbidigo
+    - goconst
+    - gocritic
+    - gocyclo
+    - godot
+    - goheader
+    - gomodguard
+    - goprintffuncname
     - gosec
-    text: "G108: Profiling endpoint is automatically exposed on /debug/pprof"
-  # This directive allows the embed package to be imported with an underscore everywhere.
-  - linters:
-    - revive
-    source: _ "embed"
-  - linters:
-    - revive
-    - stylecheck
-    path: (test)/.*.go
-    text: should not use dot imports
-  - linters:
+    - govet
+    - importas
+    - ineffassign
+    - makezero
+    - misspell
+    - nakedret
+    - nestif
+    - nilerr
+    - noctx
+    - nolintlint
+    - prealloc
+    - predeclared
     - revive
-    path: test/e2e/shared/defaults.go
-    text: "exported: exported const .* should have comment \\(or a comment on this block\\) or be unexported"
-  - linters:
-    - revive
-    text: "var-naming: don't use underscores in Go names;"
-  - linters:
-    - stylecheck
-    text: "ST1003: should not use underscores in Go names;"
-    path: (api\/.*|pkg/utils/optional)\/.*conversion.*\.go$
-  - linters:
-    - stylecheck
-    text: "ST1003: should not use underscores in Go names;"
-    path: pkg/utils/conversioncommon/.*.go
-  - linters:
-    - staticcheck
-    text: 'SA1019: "sigs.k8s.io/cluster-api/api/core/v1beta1" is deprecated: This package is deprecated and is going to be removed when support for v1beta1 will be dropped.'
-  - linters:
+    - rowserrcheck
+    - sqlclosecheck
     - staticcheck
-    text: 'SA1019: "sigs.k8s.io/cluster-api/util/deprecated/v1beta1/conditions" is deprecated: This package is deprecated and is going to be removed when support for v1beta1 will be dropped. Please see https://github.com/kubernetes-sigs/cluster-api/blob/main/docs/proposals/20240916-improve-status-in-CAPI-resources.md for more details.'
-  exclude-files:
-  - "zz_generated.*\\.go$"
-
-run:
-  go: "1.24"
-  timeout: 10m
-  build-tags:
-  - e2e
-
-  allow-parallel-runners: true
+    - thelper
+    - unconvert
+    - unparam
+    - unused
+    - wastedassign
+    - whitespace
+  settings:
+    # TODO(sbuerin) fix remaining findings and set to 20 afterwards
+    cyclop:
+      max-complexity: 30
+    gocritic:
+      disabled-checks:
+        - appendAssign
+        - dupImport # https://github.com/go-critic/go-critic/issues/845
+        - evalOrder
+        - ifElseChain
+        - octalLiteral
+        - regexpSimplify
+        - sloppyReassign
+        - truncateCmp
+        - typeDefFirst
+        - unnamedResult
+        - unnecessaryDefer
+        - whyNoLint
+        - wrapperFunc
+        - rangeValCopy
+        - hugeParam
+      enabled-tags:
+        - diagnostic
+        - experimental
+        - performance
+    importas:
+      alias:
+        # Kubernetes
+        - pkg: k8s.io/api/core/v1
+          alias: corev1
+        - pkg: k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1
+          alias: apiextensionsv1
+        - pkg: k8s.io/apimachinery/pkg/apis/meta/v1
+          alias: metav1
+        - pkg: k8s.io/apimachinery/pkg/api/errors
+          alias: apierrors
+        - pkg: k8s.io/apimachinery/pkg/util/errors
+          alias: kerrors
+        # Controller Runtime
+        - pkg: sigs.k8s.io/controller-runtime
+          alias: ctrl
+        # CAPO
+        - pkg: sigs.k8s.io/cluster-api-provider-openstack/api/v1alpha1
+          alias: infrav1alpha1
+        - pkg: sigs.k8s.io/cluster-api-provider-openstack/api/v1beta1
+          alias: infrav1
+        - pkg: sigs.k8s.io/cluster-api-provider-openstack/pkg/utils/errors
+          alias: capoerrors
+        # CAPI
+        - pkg: sigs.k8s.io/cluster-api/api/v1alpha3
+          alias: clusterv1alpha3
+        - pkg: sigs.k8s.io/cluster-api/api/v1alpha4
+          alias: clusterv1alpha4
+        - pkg: sigs.k8s.io/cluster-api/api/core/v1beta2
+          alias: clusterv1
+        - pkg: sigs.k8s.io/cluster-api/api/core/v1beta1
+          alias: clusterv1beta1
+        - pkg: sigs.k8s.io/cluster-api/util/deprecated/v1beta1/conditions
+          alias: v1beta1conditions
+        - pkg: sigs.k8s.io/cluster-api/util/deprecated/v1beta1/conditions/v1beta2
+          alias: deprecatedv1beta2conditions
+        - pkg: sigs.k8s.io/cluster-api/util/conditions/deprecated/v1beta1
+          alias: deprecatedv1beta1conditions
+        - pkg: sigs.k8s.io/cluster-api/util/deprecated/v1beta1/patch
+          alias: v1beta1patch
+        - pkg: sigs.k8s.io/cluster-api/api/ipam/v1beta2
+          alias: ipamv1
+        # CABPK
+        - pkg: sigs.k8s.io/cluster-api/bootstrap/kubeadm/api/v1alpha3
+          alias: bootstrapv1alpha3
+        - pkg: sigs.k8s.io/cluster-api/bootstrap/kubeadm/api/v1alpha4
+          alias: bootstrapv1alpha4
+        - pkg: sigs.k8s.io/cluster-api/bootstrap/kubeadm/api/v1beta1
+          alias: bootstrapv1
+        # KCP
+        - pkg: sigs.k8s.io/cluster-api/controlplane/kubeadm/api/v1alpha3
+          alias: controlplanev1alpha3
+        - pkg: sigs.k8s.io/cluster-api/controlplane/kubeadm/api/v1alpha4
+          alias: controlplanev1alpha4
+        - pkg: sigs.k8s.io/cluster-api/controlplane/kubeadm/api/v1beta1
+          alias: controlplanev1
+        # ORC
+        - pkg: github.com/k-orc/openstack-resource-controller/api/v1alpha1
+          alias: orcv1alpha1
+      no-unaliased: true
+    nestif:
+      # minimal complexity of if statements to report, 5 by default
+      # TODO(sbuerin) fix remaining findings and set to 5 after:
+      # https://github.com/kubernetes-sigs/cluster-api-provider-openstack/pull/778
+      min-complexity: 13
+    nolintlint:
+      # https://github.com/golangci/golangci-lint/issues/3228
+      allow-unused: true
+  exclusions:
+    generated: lax
+    presets:
+      - comments
+      - common-false-positives
+      - legacy
+      - std-error-handling
+    # List of regexps of issue texts to exclude, empty list by default.
+    rules:
+      - linters:
+          - gosec
+        text: 'G108: Profiling endpoint is automatically exposed on /debug/pprof'
+      - linters:
+          - gosec
+        text: 'G108: Profiling endpoint is automatically exposed on /debug/pprof'
+      # This directive allows the embed package to be imported with an underscore everywhere.
+      - linters:
+          - revive
+        source: _ "embed"
+      - linters:
+          - revive
+          - staticcheck
+        path: (test)/.*.go
+        text: should not use dot imports
+      - linters:
+          - revive
+        path: test/e2e/shared/defaults.go
+        text: 'exported: exported const .* should have comment \(or a comment on this block\) or be unexported'
+      - linters:
+          - revive
+        text: 'var-naming: don''t use underscores in Go names;'
+      - linters:
+          - staticcheck
+        path: (api\/.*|pkg/utils/optional)\/.*conversion.*\.go$
+        text: 'ST1003: should not use underscores in Go names;'
+      - linters:
+          - staticcheck
+        path: pkg/utils/conversioncommon/.*.go
+        text: 'ST1003: should not use underscores in Go names;'
+      - linters:
+          - staticcheck
+        text: 'SA1019: "sigs.k8s.io/cluster-api/api/core/v1beta1" is deprecated: This package is deprecated and is going to be removed when support for v1beta1 will be dropped.'
+      - linters:
+          - staticcheck
+        text: 'SA1019: "sigs.k8s.io/cluster-api/util/deprecated/v1beta1/conditions" is deprecated: This package is deprecated and is going to be removed when support for v1beta1 will be dropped. Please see https://github.com/kubernetes-sigs/cluster-api/blob/main/docs/proposals/20240916-improve-status-in-CAPI-resources.md for more details.'
+    paths:
+      - zz_generated.*\.go$
+      - third_party$
+      - builtin$
+      - examples$
+issues:
+  max-issues-per-linter: 0
+  max-same-issues: 0
+formatters:
+  enable:
+    - gci
+    - gofmt
+    - gofumpt
+    - goimports
+  settings:
+    gci:
+      sections:
+        - standard
+        - default
+        - prefix(github.com/k-orc/openstack-resource-controller)
+        - prefix(sigs.k8s.io/cluster-api-provider-openstack)
+  exclusions:
+    generated: lax
+    paths:
+      - zz_generated.*\.go$
+      - third_party$
+      - builtin$
+      - examples$
diff --git a/Makefile b/Makefile
index d010d943..3fa7ff5b 100644
--- a/Makefile
+++ b/Makefile
@@ -278,14 +278,14 @@ $(GOVULNCHECK): # Build govulncheck.
 
 .PHONY: lint
 lint: $(GOLANGCI_LINT) ## Lint codebase
-	$(GOLANGCI_LINT) run -v --fast=false
+	$(GOLANGCI_LINT) run -v
 
 .PHONY: lint-update
 lint-update: $(GOLANGCI_LINT) ## Lint codebase
-	$(GOLANGCI_LINT) run -v --fast=false --fix
+	$(GOLANGCI_LINT) run -v --fix
 
 lint-fast: $(GOLANGCI_LINT) ## Run only faster linters to detect possible issues
-	$(GOLANGCI_LINT) run -v --fast=true
+	$(GOLANGCI_LINT) run -v --fast-only
 
 ## --------------------------------------
 ##@ Generate
diff --git a/controllers/openstackcluster_controller.go b/controllers/openstackcluster_controller.go
index 1ee9c4c6..edaed080 100644
--- a/controllers/openstackcluster_controller.go
+++ b/controllers/openstackcluster_controller.go
@@ -499,7 +499,10 @@ func (r *OpenStackClusterReconciler) reconcileBastionServer(ctx context.Context,
 	}
 
 	// If the bastion is found but the spec has changed, we need to delete it and reconcile.
-	bastionServerSpec := bastionToOpenStackServerSpec(openStackCluster)
+	bastionServerSpec, err := bastionToOpenStackServerSpec(openStackCluster)
+	if err != nil {
+		return nil, true, err
+	}
 	if !bastionNotFound && server != nil && !apiequality.Semantic.DeepEqual(bastionServerSpec, &server.Spec) {
 		scope.Logger().Info("Bastion spec has changed, re-creating the OpenStackServer object")
 		if err := r.deleteBastion(ctx, scope, cluster, openStackCluster); err != nil {
@@ -545,7 +548,10 @@ func (r *OpenStackClusterReconciler) getBastionServer(ctx context.Context, openS
 // createBastionServer creates the OpenStackServer object for the bastion server.
 // It returns the OpenStackServer object and an error if any.
 func (r *OpenStackClusterReconciler) createBastionServer(ctx context.Context, openStackCluster *infrav1.OpenStackCluster, cluster *clusterv1.Cluster) (*infrav1alpha1.OpenStackServer, error) {
-	bastionServerSpec := bastionToOpenStackServerSpec(openStackCluster)
+	bastionServerSpec, err := bastionToOpenStackServerSpec(openStackCluster)
+	if err != nil {
+		return nil, err
+	}
 	bastionServer := &infrav1alpha1.OpenStackServer{
 		ObjectMeta: metav1.ObjectMeta{
 			Labels: map[string]string{
@@ -573,7 +579,7 @@ func (r *OpenStackClusterReconciler) createBastionServer(ctx context.Context, op
 
 // bastionToOpenStackServerSpec converts the OpenStackMachineSpec for the bastion to an OpenStackServerSpec.
 // It returns the OpenStackServerSpec and an error if any.
-func bastionToOpenStackServerSpec(openStackCluster *infrav1.OpenStackCluster) *infrav1alpha1.OpenStackServerSpec {
+func bastionToOpenStackServerSpec(openStackCluster *infrav1.OpenStackCluster) (*infrav1alpha1.OpenStackServerSpec, error) {
 	bastion := openStackCluster.Spec.Bastion
 	if bastion == nil {
 		bastion = &infrav1.Bastion{}
@@ -588,9 +594,12 @@ func bastionToOpenStackServerSpec(openStackCluster *infrav1.OpenStackCluster) *i
 	if bastion.AvailabilityZone != nil {
 		az = *bastion.AvailabilityZone
 	}
-	openStackServerSpec := openStackMachineSpecToOpenStackServerSpec(bastion.Spec, openStackCluster.Spec.IdentityRef, compute.InstanceTags(bastion.Spec, openStackCluster), az, nil, getBastionSecurityGroupID(openStackCluster), openStackCluster.Status.Network.ID)
+	openStackServerSpec, err := openStackMachineSpecToOpenStackServerSpec(bastion.Spec, openStackCluster.Spec.IdentityRef, compute.InstanceTags(bastion.Spec, openStackCluster), az, nil, getBastionSecurityGroupID(openStackCluster), openStackCluster.Status.Network)
+	if err != nil {
+		return nil, err
+	}
 
-	return openStackServerSpec
+	return openStackServerSpec, nil
 }
 
 func bastionName(clusterResourceName string) string {
diff --git a/controllers/openstackfloatingippool_controller.go b/controllers/openstackfloatingippool_controller.go
index ec9d1512..c96e92a8 100644
--- a/controllers/openstackfloatingippool_controller.go
+++ b/controllers/openstackfloatingippool_controller.go
@@ -91,7 +91,7 @@ func (r *OpenStackFloatingIPPoolReconciler) Reconcile(ctx context.Context, req c
 		return ctrl.Result{}, err
 	}
 
-	if pool.ObjectMeta.DeletionTimestamp.IsZero() {
+	if pool.DeletionTimestamp.IsZero() {
 		// Add finalizer if it does not exist
 		if controllerutil.AddFinalizer(pool, infrav1alpha1.OpenStackFloatingIPPoolFinalizer) {
 			return ctrl.Result{}, r.Client.Update(ctx, pool)
@@ -125,7 +125,7 @@ func (r *OpenStackFloatingIPPoolReconciler) Reconcile(ctx context.Context, req c
 
 	for _, claim := range claims.Items {
 		log := log.WithValues("claim", claim.Name)
-		if !claim.ObjectMeta.DeletionTimestamp.IsZero() {
+		if !claim.DeletionTimestamp.IsZero() {
 			continue
 		}
 
@@ -281,7 +281,7 @@ func (r *OpenStackFloatingIPPoolReconciler) reconcileIPAddresses(ctx context.Con
 
 	for i := 0; i < len(ipAddresses.Items); i++ {
 		ipAddress := &(ipAddresses.Items[i])
-		if ipAddress.ObjectMeta.DeletionTimestamp.IsZero() {
+		if ipAddress.DeletionTimestamp.IsZero() {
 			pool.Status.ClaimedIPs = append(pool.Status.ClaimedIPs, ipAddress.Spec.Address)
 			continue
 		}
diff --git a/controllers/openstackmachine_controller.go b/controllers/openstackmachine_controller.go
index 112111fe..48ed9bcd 100644
--- a/controllers/openstackmachine_controller.go
+++ b/controllers/openstackmachine_controller.go
@@ -380,7 +380,7 @@ func (r *OpenStackMachineReconciler) reconcileNormal(ctx context.Context, scope
 
 	if instanceStatus == nil {
 		v1beta1conditions.MarkFalse(openStackMachine, infrav1.InstanceReadyCondition, infrav1.InstanceDeletedReason, clusterv1beta1.ConditionSeverityError, infrav1.ServerUnexpectedDeletedMessage)
-		openStackMachine.SetFailure(capoerrors.DeprecatedCAPIUpdateMachineError, errors.New(infrav1.ServerUnexpectedDeletedMessage)) //nolint:stylecheck // This error is not used as an error
+		openStackMachine.SetFailure(capoerrors.DeprecatedCAPIUpdateMachineError, errors.New(infrav1.ServerUnexpectedDeletedMessage)) //nolint:staticcheck // This error is not used as an error
 		return ctrl.Result{}, nil
 	}
 
@@ -480,7 +480,18 @@ func (r *OpenStackMachineReconciler) getMachineServer(ctx context.Context, openS
 
 // openStackMachineSpecToOpenStackServerSpec converts an OpenStackMachineSpec to an OpenStackServerSpec.
 // It returns the OpenStackServerSpec object and an error if there is any.
-func openStackMachineSpecToOpenStackServerSpec(openStackMachineSpec *infrav1.OpenStackMachineSpec, identityRef infrav1.OpenStackIdentityReference, tags []string, failureDomain string, userDataRef *corev1.LocalObjectReference, defaultSecGroup *string, defaultNetworkID string) *infrav1alpha1.OpenStackServerSpec {
+func openStackMachineSpecToOpenStackServerSpec(openStackMachineSpec *infrav1.OpenStackMachineSpec, identityRef infrav1.OpenStackIdentityReference, tags []string, failureDomain string, userDataRef *corev1.LocalObjectReference, defaultSecGroup *string, clusterNetwork *infrav1.NetworkStatusWithSubnets) (*infrav1alpha1.OpenStackServerSpec, error) {
+	// Determine default network ID if the cluster status exposes one.
+	var defaultNetworkID string
+	if clusterNetwork != nil {
+		defaultNetworkID = clusterNetwork.ID
+	}
+
+	// If no cluster network is available AND the machine spec did not define any ports with a network, we cannot choose a network.
+	if defaultNetworkID == "" && len(openStackMachineSpec.Ports) == 0 {
+		return nil, capoerrors.Terminal(infrav1.InvalidMachineSpecReason, "no network configured: cluster network is missing and machine spec does not define ports with a network")
+	}
+
 	openStackServerSpec := &infrav1alpha1.OpenStackServerSpec{
 		AdditionalBlockDevices:            openStackMachineSpec.AdditionalBlockDevices,
 		ConfigDrive:                       openStackMachineSpec.ConfigDrive,
@@ -522,7 +533,8 @@ func openStackMachineSpecToOpenStackServerSpec(openStackMachineSpec *infrav1.Ope
 		serverPorts = make([]infrav1.PortOpts, 1)
 	}
 	for i := range serverPorts {
-		if serverPorts[i].Network == nil {
+		// Only inject the default network when we actually have an ID.
+		if serverPorts[i].Network == nil && defaultNetworkID != "" {
 			serverPorts[i].Network = &infrav1.NetworkParam{
 				ID: &defaultNetworkID,
 			}
@@ -540,7 +552,7 @@ func openStackMachineSpecToOpenStackServerSpec(openStackMachineSpec *infrav1.Ope
 	}
 	openStackServerSpec.Ports = serverPorts
 
-	return openStackServerSpec
+	return openStackServerSpec, nil
 }
 
 // reconcileMachineServer reconciles the OpenStackServer object for the OpenStackMachine.
@@ -589,7 +601,10 @@ func (r *OpenStackMachineReconciler) getOrCreateMachineServer(ctx context.Contex
 			}
 			return openStackCluster.Spec.IdentityRef
 		}()
-		machineServerSpec := openStackMachineSpecToOpenStackServerSpec(&openStackMachine.Spec, identityRef, compute.InstanceTags(&openStackMachine.Spec, openStackCluster), failureDomain, userDataRef, getManagedSecurityGroup(openStackCluster, machine), openStackCluster.Status.Network.ID)
+		machineServerSpec, err := openStackMachineSpecToOpenStackServerSpec(&openStackMachine.Spec, identityRef, compute.InstanceTags(&openStackMachine.Spec, openStackCluster), failureDomain, userDataRef, getManagedSecurityGroup(openStackCluster, machine), openStackCluster.Status.Network)
+		if err != nil {
+			return nil, err
+		}
 		machineServer = &infrav1alpha1.OpenStackServer{
 			ObjectMeta: metav1.ObjectMeta{
 				Labels: map[string]string{
@@ -714,7 +729,7 @@ func (r *OpenStackMachineReconciler) OpenStackClusterToOpenStackMachines(ctx con
 		log := log.WithValues("objectMapper", "openStackClusterToOpenStackMachine", "namespace", c.Namespace, "openStackCluster", c.Name)
 
 		// Don't handle deleted OpenStackClusters
-		if !c.ObjectMeta.DeletionTimestamp.IsZero() {
+		if !c.DeletionTimestamp.IsZero() {
 			log.V(4).Info("OpenStackClusters has a deletion timestamp, skipping mapping.")
 			return nil
 		}
@@ -744,7 +759,7 @@ func (r *OpenStackMachineReconciler) requeueOpenStackMachinesForUnpausedCluster(
 		log := log.WithValues("objectMapper", "clusterToOpenStackMachine", "namespace", c.Namespace, "cluster", c.Name)
 
 		// Don't handle deleted clusters
-		if !c.ObjectMeta.DeletionTimestamp.IsZero() {
+		if !c.DeletionTimestamp.IsZero() {
 			log.V(4).Info("Cluster has a deletion timestamp, skipping mapping.")
 			return nil
 		}
diff --git a/controllers/openstackserver_controller.go b/controllers/openstackserver_controller.go
index ab4aa955..8ea05b8c 100644
--- a/controllers/openstackserver_controller.go
+++ b/controllers/openstackserver_controller.go
@@ -132,7 +132,7 @@ func (r *OpenStackServerReconciler) Reconcile(ctx context.Context, req ctrl.Requ
 		}
 	}()
 
-	if !openStackServer.ObjectMeta.DeletionTimestamp.IsZero() {
+	if !openStackServer.DeletionTimestamp.IsZero() {
 		// When moving a cluster, we need to populate the server status with the resources
 		// that were in another object's status.
 		// This is because the status is not persisted across CAPI resources moves.
@@ -626,8 +626,8 @@ func (r *OpenStackServerReconciler) getOrCreateIPAddressClaimForFloatingAddress(
 
 	// If the OpenStackServer has a ClusterNameLabel, set it on the IPAddressClaim as well.
 	// This is useful for garbage collection of IPAddressClaims when a Cluster is deleted.
-	if openStackServer.ObjectMeta.Labels[clusterv1.ClusterNameLabel] != "" {
-		claim.ObjectMeta.Labels[clusterv1.ClusterNameLabel] = openStackServer.ObjectMeta.Labels[clusterv1.ClusterNameLabel]
+	if openStackServer.Labels[clusterv1.ClusterNameLabel] != "" {
+		claim.Labels[clusterv1.ClusterNameLabel] = openStackServer.Labels[clusterv1.ClusterNameLabel]
 	}
 
 	if err := r.Client.Create(ctx, claim); err != nil {
diff --git a/docs/book/src/SUMMARY.md b/docs/book/src/SUMMARY.md
index 9365622a..91ce91ac 100644
--- a/docs/book/src/SUMMARY.md
+++ b/docs/book/src/SUMMARY.md
@@ -5,6 +5,7 @@
 - [Configuration](clusteropenstack/configuration.md)
 - [Topics](./topics/index.md)
     - [external cloud provider](./topics/external-cloud-provider.md)
+    - [hosted control plane](./topics/hosted-control-plane.md)
     - [move from bootstrap](./topics/mover.md)
     - [trouble shooting](./topics/troubleshooting.md)
     - [CRD Changes](./topics/crd-changes/index.md)
diff --git a/docs/book/src/topics/hosted-control-plane.md b/docs/book/src/topics/hosted-control-plane.md
new file mode 100644
index 00000000..c3ce423a
--- /dev/null
+++ b/docs/book/src/topics/hosted-control-plane.md
@@ -0,0 +1,407 @@
+<!-- START doctoc generated TOC please keep comment here to allow auto update -->
+<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
+**Table of Contents**
+
+- [Hosted Control Plane on OpenStack with Cluster API](#hosted-control-plane-on-openstack-with-cluster-api)
+  - [Overview](#overview)
+    - [Advantages of Hosted Control Planes](#advantages-of-hosted-control-planes)
+  - [Prerequisites](#prerequisites)
+    - [Management Cluster Requirements](#management-cluster-requirements)
+    - [Architecture Considerations](#architecture-considerations)
+  - [Setup Instructions](#setup-instructions)
+    - [0. Create the hcp-system namespace](#0-create-the-hcp-system-namespace)
+    - [1. Prepare OpenStack Credentials](#1-prepare-openstack-credentials)
+    - [2. Ensure Storage is Available (Required for etcd)](#2-ensure-storage-is-available-required-for-etcd)
+    - [3. Configure and Deploy the Cluster](#3-configure-and-deploy-the-cluster)
+      - [Hosted Control Plane Cluster Manifests (`openstack-hcp-cluster.yaml`)](#hosted-control-plane-cluster-manifests-openstack-hcp-clusteryaml)
+  - [Deployment and Monitoring](#deployment-and-monitoring)
+    - [Deploy the Cluster](#deploy-the-cluster)
+    - [Monitor Cluster Creation](#monitor-cluster-creation)
+  - [Post-Deployment Configuration](#post-deployment-configuration)
+    - [Retrieve Workload Cluster Access](#retrieve-workload-cluster-access)
+    - [Configure OpenStack Integration](#configure-openstack-integration)
+    - [Verify Workload Cluster](#verify-workload-cluster)
+  - [Troubleshooting](#troubleshooting)
+    - [Common Issues and Solutions](#common-issues-and-solutions)
+  - [Conclusion](#conclusion)
+
+<!-- END doctoc generated TOC please keep comment here to allow auto update -->
+
+# Hosted Control Plane on OpenStack with Cluster API
+
+## Overview 
+
+This guide demonstrates how to deploy a Kubernetes cluster on OpenStack using Cluster API Provider OpenStack (CAPO) with a hosted control plane architecture. The guide uses k0smotron as the hosted control plane provider to illustrate a fully CAPI-native approach.
+
+### Advantages of Hosted Control Planes
+
+- **Resource Efficiency**: Control plane components run as pods on the management cluster, reducing the infrastructure footprint
+- **Simplified Management**: Centralized control plane management across multiple workload clusters
+- **High Availability**: Leverages the management cluster's infrastructure for control plane resilience
+- **Cost Optimization**: Eliminates the need for dedicated control plane nodes in each workload cluster
+
+> **Note**: CAPO is not opinionated about which control plane provider you use. This guide uses k0smotron as an example. For alternative hosted control plane solutions like Kamaji, see the [Kamaji documentation](https://kamaji.clastix.io/getting-started/kamaji-generic/).
+
+This architecture consists of:
+- A management cluster that hosts the control plane pods
+- A workload cluster with worker nodes created by CAPI/CAPO
+
+## Prerequisites
+
+Before proceeding, ensure your management cluster meets the following requirements:
+
+### Management Cluster Requirements
+
+- A healthy Kubernetes cluster with cluster-admin access
+- Cluster API initialized with the OpenStack infrastructure provider:
+  ```bash
+  clusterctl init --infrastructure openstack
+  ```
+- k0smotron installed (docs: https://docs.k0smotron.io/stable/install/#software-prerequisites)
+- A storage solution for etcd (required), e.g. OpenStack Cinder CSI (recommended), local-path-provisioner, Ceph CSI, or any CSI driver with a default StorageClass
+- A LoadBalancer implementation for the hosted control plane service (recommended), e.g. OpenStack CCM/Octavia or MetalLB
+
+### Architecture Considerations
+
+The hosted control plane etcd persistent volume resides on the management cluster, requiring functional storage. The workload cluster receives its own Cloud Controller Manager (CCM) and Container Storage Interface (CSI) drivers through k0s configuration extensions.
+
+## Setup Instructions
+
+### 0. Create the hcp-system namespace
+
+```bash
+kubectl create namespace hcp-system
+```
+*Note: Throughout the guide, we will use the `hcp-system` namespace to deploy all the components. Ideally, you can choose a different namespace, but you will need to adjust the manifests accordingly.*
+
+### 1. Prepare OpenStack Credentials
+
+Create a secret with credentials for the workload cluster external cloud provider. The cluster manifest expects a secret named `openstack-cloud-config` in the `hcp-system` namespace with a `clouds.yaml` key.
+
+Create the secret directly from your `clouds.yaml` file like this:
+```bash
+kubectl -n hcp-system create secret generic openstack-cloud-config \
+  --from-file=clouds.yaml=./clouds.yaml
+```
+
+### 2. Ensure Storage is Available (Required for etcd)
+
+The hosted control plane requires persistent storage for etcd. Ensure your management cluster has:
+- A functional CSI driver (e.g., OpenStack Cinder CSI, local-path-provisioner, Ceph CSI)
+- A default StorageClass configured
+
+Verify storage is ready:
+```bash
+kubectl get storageclass
+```
+
+### 3. Configure and Deploy the Cluster
+
+Before applying the manifests, update the following fields:
+
+- Update network, router, and subnet names in `OpenStackCluster` and `OpenStackMachineTemplate` to match your OpenStack environment
+
+#### Hosted Control Plane Cluster Manifests (`openstack-hcp-cluster.yaml`)
+
+```yaml
+apiVersion: cluster.x-k8s.io/v1beta1
+kind: Cluster
+metadata:
+  name: openstack-hcp-cluster
+  namespace: hcp-system
+spec:
+  clusterNetwork:
+    pods:
+      cidrBlocks: [10.244.0.0/16] # Adjust accordingly
+    serviceDomain: cluster.local
+    services:
+      cidrBlocks: [10.96.0.0/12] # Adjust accordingly
+  controlPlaneRef:
+    apiVersion: controlplane.cluster.x-k8s.io/v1beta1
+    kind: K0smotronControlPlane
+    name: openstack-hcp-cluster-cp
+    namespace: hcp-system
+  infrastructureRef:
+    apiVersion: infrastructure.cluster.x-k8s.io/v1beta1
+    kind: OpenStackCluster
+    name: openstack-hcp-cluster
+    namespace: hcp-system
+---
+apiVersion: infrastructure.cluster.x-k8s.io/v1beta1
+kind: OpenStackCluster
+metadata:
+  name: openstack-hcp-cluster
+  namespace: hcp-system
+spec:
+  externalNetwork:
+    filter:
+      name: public
+  identityRef:
+    cloudName: openstack
+    name: openstack-cloud-config
+    region: RegionOne
+  network:
+    filter:
+      name: k8s-clusterapi-cluster-default-capo-test
+  router:
+    filter:
+      name: k8s-clusterapi-cluster-default-capo-test
+  subnets:
+  - filter:
+      name: k8s-clusterapi-cluster-default-capo-test
+---
+apiVersion: cluster.x-k8s.io/v1beta1
+kind: MachineDeployment
+metadata:
+  name: openstack-hcp-cluster-md
+  namespace: hcp-system
+spec:
+  clusterName: openstack-hcp-cluster
+  replicas: 1
+  selector:
+    matchLabels:
+      cluster.x-k8s.io/cluster-name: openstack-hcp-cluster
+  template:
+    metadata:
+      labels:
+        cluster.x-k8s.io/cluster-name: openstack-hcp-cluster
+    spec:
+      bootstrap:
+        configRef:
+          apiVersion: bootstrap.cluster.x-k8s.io/v1beta1
+          kind: K0sWorkerConfigTemplate
+          name: openstack-hcp-cluster-machine-config
+          namespace: hcp-system
+      clusterName: openstack-hcp-cluster
+      infrastructureRef:
+        apiVersion: infrastructure.cluster.x-k8s.io/v1beta1
+        kind: OpenStackMachineTemplate
+        name: openstack-hcp-cluster-mt
+        namespace: hcp-system
+      version: v1.32.6
+---
+apiVersion: infrastructure.cluster.x-k8s.io/v1beta1
+kind: OpenStackMachineTemplate
+metadata:
+  name: openstack-hcp-cluster-mt
+  namespace: hcp-system
+spec:
+  template:
+    spec:
+      flavor: m1.medium
+      identityRef:
+        cloudName: openstack
+        name: openstack-cloud-config
+        region: RegionOne
+      image:
+        filter:
+          name: ubuntu-22.04-x86_64
+      ports:
+      - network:
+          filter:
+            name: k8s-clusterapi-cluster-default-capo-test
+      securityGroups:
+      - filter:
+          name: default
+---
+apiVersion: controlplane.cluster.x-k8s.io/v1beta1
+kind: K0smotronControlPlane
+metadata:
+  name: openstack-hcp-cluster-cp
+  namespace: hcp-system
+spec:
+  controllerPlaneFlags:
+  - --enable-cloud-provider=true
+  - --debug=true
+  etcd:
+    autoDeletePVCs: false
+    image: quay.io/k0sproject/etcd:v3.5.13
+    persistence:
+      size: 1Gi
+  image: ghcr.io/k0sproject/k0s:v1.32.6-k0s.0  # pinned GHCR tag to avoid rate limits with docker hub
+  k0sConfig:
+    apiVersion: k0s.k0sproject.io/v1beta1
+    kind: ClusterConfig
+    metadata:
+      name: k0s
+    spec:
+      extensions:
+        helm:
+          charts:
+          - chartname: openstack/openstack-cloud-controller-manager
+            name: openstack-ccm
+            namespace: kube-system
+            order: 1
+            values: |
+              secret:
+                enabled: true
+                name: openstack-cloud-config
+                create: false
+              nodeSelector: null
+              tolerations:
+                - key: node.cloudprovider.kubernetes.io/uninitialized
+                  value: "true"
+                  effect: NoSchedule
+                - key: node-role.kubernetes.io/control-plane
+                  effect: NoSchedule
+                - key: node-role.kubernetes.io/master
+                  effect: NoSchedule
+              extraEnv:
+                - name: OS_CCM_REGIONAL
+                  value: "true"
+              extraVolumes:
+                - name: flexvolume-dir
+                  hostPath:
+                    path: /usr/libexec/kubernetes/kubelet-plugins/volume/exec
+                - name: k8s-certs
+                  hostPath:
+                    path: /etc/kubernetes/pki
+              extraVolumeMounts:
+                - name: flexvolume-dir
+                  mountPath: /usr/libexec/kubernetes/kubelet-plugins/volume/exec
+                  readOnly: true
+                - name: k8s-certs
+                  mountPath: /etc/kubernetes/pki
+                  readOnly: true
+            version: 2.31.1
+          - chartname: openstack/openstack-cinder-csi
+            name: openstack-csi
+            namespace: kube-system
+            order: 2
+            values: |
+              storageClass:
+                enabled: true
+                delete:
+                  isDefault: true
+                  allowVolumeExpansion: true
+                retain:
+                  isDefault: false
+                  allowVolumeExpansion: false
+              secret:
+                enabled: true
+                name: openstack-cloud-config
+                create: false   # set to true if you want the chart to create the Secret in workload cluster
+              csi:
+                plugin:
+                  nodePlugin:
+                    kubeletDir: /var/lib/k0s/kubelet   # workload cluster nodes run k0s
+            version: 2.31.2
+          repositories:
+          - name: openstack
+            url: https://kubernetes.github.io/cloud-provider-openstack/
+      network:
+        calico:
+          mode: vxlan
+        clusterDomain: cluster.local
+        podCIDR: 10.244.0.0/16
+        provider: calico
+        serviceCIDR: 10.96.0.0/12
+  replicas: 1
+  service:
+    apiPort: 6443
+    konnectivityPort: 8132
+    type: LoadBalancer
+---
+apiVersion: bootstrap.cluster.x-k8s.io/v1beta1
+kind: K0sWorkerConfigTemplate
+metadata:
+  name: openstack-hcp-cluster-machine-config
+  namespace: hcp-system
+spec:
+  template:
+    spec:
+      args:
+      - --enable-cloud-provider
+      - --kubelet-extra-args="--cloud-provider=external"
+      - --debug=true
+      version: v1.32.6+k0s.0
+```
+
+## Deployment and Monitoring
+
+### Deploy the Cluster
+
+Apply the cluster manifest:
+
+```bash
+kubectl apply -f openstack-hcp-cluster.yaml
+```
+
+### Monitor Cluster Creation
+
+Monitor the hosted control plane deployment:
+
+```bash
+# Watch etcd PVC binding and pod startup on the management cluster
+kubectl -n hcp-system get pvc
+kubectl -n hcp-system get pods -w
+
+# Verify the LoadBalancer service receives an external IP address
+kubectl -n hcp-system get svc openstack-hcp-cluster-cp -o wide
+```
+
+Expected components in the `hcp-system` namespace:
+- `kmc-openstack-hcp-cluster-etcd-0` pod in Running state
+- `kmc-openstack-hcp-cluster-0` (controller) pod in Running state  
+- `openstack-hcp-cluster-cp` service (LoadBalancer) with an assigned EXTERNAL-IP
+
+The control plane will become operational within a few minutes, followed by worker nodes joining the cluster.
+
+## Post-Deployment Configuration
+
+### Retrieve Workload Cluster Access
+
+Obtain the workload cluster kubeconfig:
+
+```bash
+clusterctl -n hcp-system get kubeconfig openstack-hcp-cluster > workload-cluster.kubeconfig
+```
+
+### Configure OpenStack Integration
+
+If the cluster manifest has `create: false` for secrets (as shown in the example), manually create the OpenStack credentials in the workload cluster:
+
+```bash
+kubectl --kubeconfig workload-cluster.kubeconfig -n kube-system create secret generic openstack-cloud-config \
+  --from-file=clouds.yaml=./clouds.yaml
+```
+
+### Verify Workload Cluster
+
+Validate the workload cluster components and nodes:
+
+```bash
+kubectl --kubeconfig workload-cluster.kubeconfig -n kube-system get pods | egrep -i 'openstack|cinder|calico'
+kubectl --kubeconfig workload-cluster.kubeconfig get sc
+kubectl --kubeconfig workload-cluster.kubeconfig get nodes
+```
+
+## Troubleshooting
+
+### Common Issues and Solutions
+
+**PVC Pending (Management Cluster)**
+- Ensure Cinder CSI is running on the management cluster
+- Verify correct kubelet directory configuration
+- Check OpenStack credentials secret is properly mounted
+- Confirm default StorageClass is configured
+
+**Node Plugin Mount Errors**
+- Verify kubelet directory mismatch: management cluster uses `/var/lib/kubelet`, workload cluster k0s nodes use `/var/lib/k0s/kubelet`
+
+**Invalid Image Name Errors**
+- Avoid double-tagging by pinning the image to `ghcr.io/k0sproject/k0s:<version>-k0s.0`
+- Set `version: null` in the k0smotron configuration
+
+**Image Pull Errors (429 rate limit)**
+- Use GitHub Container Registry (GHCR) instead of Docker Hub
+- Configure imagePullSecrets if necessary
+
+**LoadBalancer Pending**
+- Ensure the management cluster has a functional LoadBalancer implementation (OpenStack CCM/Octavia or MetalLB)
+
+## Conclusion
+
+This guide demonstrated how to deploy a Kubernetes cluster on OpenStack using a hosted control plane architecture with Cluster API Provider OpenStack (CAPO) and k0smotron.
+
+For production deployments, ensure proper sizing of the management cluster to handle multiple hosted control planes and their associated workloads.
diff --git a/go.mod b/go.mod
index ac302464..91453203 100644
--- a/go.mod
+++ b/go.mod
@@ -8,12 +8,12 @@ require (
 	github.com/google/go-cmp v0.7.0
 	github.com/google/gofuzz v1.2.0
 	github.com/google/uuid v1.6.0
-	github.com/gophercloud/gophercloud/v2 v2.7.0
+	github.com/gophercloud/gophercloud/v2 v2.8.0
 	github.com/gophercloud/utils/v2 v2.0.0-20241220104409-2e0af06694a1
 	github.com/hashicorp/go-version v1.7.0
 	github.com/k-orc/openstack-resource-controller/v2 v2.2.0
-	github.com/onsi/ginkgo/v2 v2.24.0
-	github.com/onsi/gomega v1.38.0
+	github.com/onsi/ginkgo/v2 v2.25.2
+	github.com/onsi/gomega v1.38.2
 	github.com/prometheus/client_golang v1.23.0
 	github.com/spf13/pflag v1.0.7
 	go.uber.org/mock v0.6.0
@@ -41,7 +41,7 @@ require (
 	cel.dev/expr v0.19.1 // indirect
 	github.com/BurntSushi/toml v1.4.0 // indirect
 	github.com/MakeNowJust/heredoc v1.0.0 // indirect
-	github.com/Masterminds/semver/v3 v3.3.1 // indirect
+	github.com/Masterminds/semver/v3 v3.4.0 // indirect
 	github.com/Microsoft/go-winio v0.5.0 // indirect
 	github.com/NYTimes/gziphandler v1.1.1 // indirect
 	github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 // indirect
@@ -127,7 +127,7 @@ require (
 	go.uber.org/multierr v1.11.0 // indirect
 	go.uber.org/zap v1.27.0 // indirect
 	go.yaml.in/yaml/v2 v2.4.2 // indirect
-	go.yaml.in/yaml/v3 v3.0.3 // indirect
+	go.yaml.in/yaml/v3 v3.0.4 // indirect
 	golang.org/x/exp v0.0.0-20241217172543-b2144cdd0a67 // indirect
 	golang.org/x/mod v0.27.0 // indirect
 	golang.org/x/net v0.43.0 // indirect
@@ -142,7 +142,7 @@ require (
 	google.golang.org/genproto/googleapis/api v0.0.0-20250106144421-5f5ef82da422 // indirect
 	google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f // indirect
 	google.golang.org/grpc v1.71.3 // indirect
-	google.golang.org/protobuf v1.36.6 // indirect
+	google.golang.org/protobuf v1.36.7 // indirect
 	gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
 	gopkg.in/inf.v0 v0.9.1 // indirect
 	gopkg.in/yaml.v3 v3.0.1 // indirect
diff --git a/go.sum b/go.sum
index 264ed6e3..86352cd6 100644
--- a/go.sum
+++ b/go.sum
@@ -12,8 +12,8 @@ github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ
 github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
 github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=
 github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=
-github.com/Masterminds/semver/v3 v3.3.1 h1:QtNSWtVZ3nBfk8mAOu/B6v7FMJ+NHTIgUPi7rj+4nv4=
-github.com/Masterminds/semver/v3 v3.3.1/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
+github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
+github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
 github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs=
 github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0=
 github.com/Microsoft/go-winio v0.5.0 h1:Elr9Wn+sGKPlkaBvwu4mTrxtmOp3F3yV9qhaHbXGjwU=
@@ -131,8 +131,8 @@ github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaU
 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
 github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/gophercloud/gophercloud/v2 v2.7.0 h1:o0m4kgVcPgHlcXiWAjoVxGd8QCmvM5VU+YM71pFbn0E=
-github.com/gophercloud/gophercloud/v2 v2.7.0/go.mod h1:Ki/ILhYZr/5EPebrPL9Ej+tUg4lqx71/YH2JWVeU+Qk=
+github.com/gophercloud/gophercloud/v2 v2.8.0 h1:of2+8tT6+FbEYHfYC8GBu8TXJNsXYSNm9KuvpX7Neqo=
+github.com/gophercloud/gophercloud/v2 v2.8.0/go.mod h1:Ki/ILhYZr/5EPebrPL9Ej+tUg4lqx71/YH2JWVeU+Qk=
 github.com/gophercloud/utils/v2 v2.0.0-20241220104409-2e0af06694a1 h1:LS70kbNdqoalMwLXEzP9Xb/cYv9UCzWioXaOynxrytc=
 github.com/gophercloud/utils/v2 v2.0.0-20241220104409-2e0af06694a1/go.mod h1:qDhuzCRKi90/Yyl/yEqkg8+qABEvK44LhP0D3GWKGtY=
 github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0 h1:TmHmbvxPmaegwhDubVz0lICL0J5Ka2vwTzhoePEXsGE=
@@ -195,10 +195,10 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq
 github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
 github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
 github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
-github.com/onsi/ginkgo/v2 v2.24.0 h1:obZz8LAnHicNdbBqvG3ytAFx8fgza+i1IDpBVcHT2YE=
-github.com/onsi/ginkgo/v2 v2.24.0/go.mod h1:ppTWQ1dh9KM/F1XgpeRqelR+zHVwV81DGRSDnFxK7Sk=
-github.com/onsi/gomega v1.38.0 h1:c/WX+w8SLAinvuKKQFh77WEucCnPk4j2OTUr7lt7BeY=
-github.com/onsi/gomega v1.38.0/go.mod h1:OcXcwId0b9QsE7Y49u+BTrL4IdKOBOKnD6VQNTJEB6o=
+github.com/onsi/ginkgo/v2 v2.25.2 h1:hepmgwx1D+llZleKQDMEvy8vIlCxMGt7W5ZxDjIEhsw=
+github.com/onsi/ginkgo/v2 v2.25.2/go.mod h1:43uiyQC4Ed2tkOzLsEYm7hnrb7UJTWHYNsuy3bG/snE=
+github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
+github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
 github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
 github.com/opencontainers/image-spec v1.0.2 h1:9yCKha/T5XdGtO0q9Q9a6T5NUCsTn/DrBg0D7ufOcFM=
@@ -301,8 +301,8 @@ go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
 go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
 go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
 go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
-go.yaml.in/yaml/v3 v3.0.3 h1:bXOww4E/J3f66rav3pX3m8w6jDE4knZjGOw8b5Y6iNE=
-go.yaml.in/yaml/v3 v3.0.3/go.mod h1:tBHosrYAkRZjRAOREWbDnBXUf08JOwYq++0QNwQiWzI=
+go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
+go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
 golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
@@ -372,8 +372,8 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f h1:
 google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f/go.mod h1:+2Yz8+CLJbIfL9z73EW45avw8Lmge3xVElCP9zEKi50=
 google.golang.org/grpc v1.71.3 h1:iEhneYTxOruJyZAxdAv8Y0iRZvsc5M6KoW7UA0/7jn0=
 google.golang.org/grpc v1.71.3/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec=
-google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
-google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
+google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A=
+google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
diff --git a/hack/tools/Makefile b/hack/tools/Makefile
index 8d353baa..a6089774 100644
--- a/hack/tools/Makefile
+++ b/hack/tools/Makefile
@@ -15,7 +15,7 @@
 ROOT_DIR_RELATIVE := ../..
 include $(ROOT_DIR_RELATIVE)/common.mk
 
-GOLANGCI_LINT_VERSION ?= v1.64.6
+GOLANGCI_LINT_VERSION ?= v2.4.0
 
 # GOTESTSUM version without the leading 'v'
 GOTESTSUM_VERSION ?= 1.12.0
diff --git a/hack/tools/go.mod b/hack/tools/go.mod
index f59ead84..c57cfe20 100644
--- a/hack/tools/go.mod
+++ b/hack/tools/go.mod
@@ -6,7 +6,7 @@ require (
 	github.com/a8m/envsubst v1.4.2
 	github.com/ahmetb/gen-crd-api-reference-docs v0.3.1-0.20220420215017-3f29e6853552
 	github.com/itchyny/gojq v0.12.17
-	github.com/onsi/ginkgo/v2 v2.24.0
+	github.com/onsi/ginkgo/v2 v2.25.2
 	go.uber.org/mock v0.6.0
 	k8s.io/code-generator v0.33.4
 	sigs.k8s.io/cluster-api-provider-openstack v0.0.0
@@ -19,7 +19,7 @@ require (
 require (
 	cel.dev/expr v0.24.0 // indirect
 	dario.cat/mergo v1.0.1 // indirect
-	github.com/Masterminds/semver/v3 v3.3.1 // indirect
+	github.com/Masterminds/semver/v3 v3.4.0 // indirect
 	github.com/Microsoft/go-winio v0.6.2 // indirect
 	github.com/NYTimes/gziphandler v1.1.1 // indirect
 	github.com/ProtonMail/go-crypto v1.1.3 // indirect
@@ -63,7 +63,7 @@ require (
 	github.com/google/go-querystring v1.1.0 // indirect
 	github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect
 	github.com/google/uuid v1.6.0 // indirect
-	github.com/gophercloud/gophercloud/v2 v2.7.0 // indirect
+	github.com/gophercloud/gophercloud/v2 v2.8.0 // indirect
 	github.com/gophercloud/utils/v2 v2.0.0-20241220104409-2e0af06694a1 // indirect
 	github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0 // indirect
 	github.com/hashicorp/go-version v1.7.0 // indirect
@@ -84,7 +84,7 @@ require (
 	github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect
 	github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
 	github.com/nozzle/throttler v0.0.0-20180817012639-2ea982251481 // indirect
-	github.com/onsi/gomega v1.38.0 // indirect
+	github.com/onsi/gomega v1.38.2 // indirect
 	github.com/pjbgf/sha1cd v0.3.0 // indirect
 	github.com/pkg/errors v0.9.1 // indirect
 	github.com/prometheus/client_golang v1.23.0 // indirect
@@ -117,7 +117,7 @@ require (
 	go.uber.org/multierr v1.11.0 // indirect
 	go.uber.org/zap v1.27.0 // indirect
 	go.yaml.in/yaml/v2 v2.4.2 // indirect
-	go.yaml.in/yaml/v3 v3.0.3 // indirect
+	go.yaml.in/yaml/v3 v3.0.4 // indirect
 	golang.org/x/crypto v0.41.0 // indirect
 	golang.org/x/exp v0.0.0-20241217172543-b2144cdd0a67 // indirect
 	golang.org/x/mod v0.27.0 // indirect
@@ -134,7 +134,7 @@ require (
 	google.golang.org/genproto/googleapis/api v0.0.0-20250721164621-a45f3dfb1074 // indirect
 	google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0 // indirect
 	google.golang.org/grpc v1.74.2 // indirect
-	google.golang.org/protobuf v1.36.6 // indirect
+	google.golang.org/protobuf v1.36.7 // indirect
 	gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
 	gopkg.in/inf.v0 v0.9.1 // indirect
 	gopkg.in/warnings.v0 v0.1.2 // indirect
diff --git a/hack/tools/go.sum b/hack/tools/go.sum
index 48175a80..f343bfde 100644
--- a/hack/tools/go.sum
+++ b/hack/tools/go.sum
@@ -6,8 +6,8 @@ github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ
 github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
 github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=
 github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=
-github.com/Masterminds/semver/v3 v3.3.1 h1:QtNSWtVZ3nBfk8mAOu/B6v7FMJ+NHTIgUPi7rj+4nv4=
-github.com/Masterminds/semver/v3 v3.3.1/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
+github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
+github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
 github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs=
 github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0=
 github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
@@ -155,8 +155,8 @@ github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J
 github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
 github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/gophercloud/gophercloud/v2 v2.7.0 h1:o0m4kgVcPgHlcXiWAjoVxGd8QCmvM5VU+YM71pFbn0E=
-github.com/gophercloud/gophercloud/v2 v2.7.0/go.mod h1:Ki/ILhYZr/5EPebrPL9Ej+tUg4lqx71/YH2JWVeU+Qk=
+github.com/gophercloud/gophercloud/v2 v2.8.0 h1:of2+8tT6+FbEYHfYC8GBu8TXJNsXYSNm9KuvpX7Neqo=
+github.com/gophercloud/gophercloud/v2 v2.8.0/go.mod h1:Ki/ILhYZr/5EPebrPL9Ej+tUg4lqx71/YH2JWVeU+Qk=
 github.com/gophercloud/utils/v2 v2.0.0-20241220104409-2e0af06694a1 h1:LS70kbNdqoalMwLXEzP9Xb/cYv9UCzWioXaOynxrytc=
 github.com/gophercloud/utils/v2 v2.0.0-20241220104409-2e0af06694a1/go.mod h1:qDhuzCRKi90/Yyl/yEqkg8+qABEvK44LhP0D3GWKGtY=
 github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0 h1:TmHmbvxPmaegwhDubVz0lICL0J5Ka2vwTzhoePEXsGE=
@@ -229,10 +229,10 @@ github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N
 github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
 github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
 github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
-github.com/onsi/ginkgo/v2 v2.24.0 h1:obZz8LAnHicNdbBqvG3ytAFx8fgza+i1IDpBVcHT2YE=
-github.com/onsi/ginkgo/v2 v2.24.0/go.mod h1:ppTWQ1dh9KM/F1XgpeRqelR+zHVwV81DGRSDnFxK7Sk=
-github.com/onsi/gomega v1.38.0 h1:c/WX+w8SLAinvuKKQFh77WEucCnPk4j2OTUr7lt7BeY=
-github.com/onsi/gomega v1.38.0/go.mod h1:OcXcwId0b9QsE7Y49u+BTrL4IdKOBOKnD6VQNTJEB6o=
+github.com/onsi/ginkgo/v2 v2.25.2 h1:hepmgwx1D+llZleKQDMEvy8vIlCxMGt7W5ZxDjIEhsw=
+github.com/onsi/ginkgo/v2 v2.25.2/go.mod h1:43uiyQC4Ed2tkOzLsEYm7hnrb7UJTWHYNsuy3bG/snE=
+github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
+github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
 github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
 github.com/opencontainers/image-spec v1.1.0-rc5 h1:Ygwkfw9bpDvs+c9E34SdgGOj41dX/cbdlwvlWt0pnFI=
@@ -352,8 +352,8 @@ go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
 go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
 go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
 go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
-go.yaml.in/yaml/v3 v3.0.3 h1:bXOww4E/J3f66rav3pX3m8w6jDE4knZjGOw8b5Y6iNE=
-go.yaml.in/yaml/v3 v3.0.3/go.mod h1:tBHosrYAkRZjRAOREWbDnBXUf08JOwYq++0QNwQiWzI=
+go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
+go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
 golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
@@ -426,8 +426,8 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0 h1:
 google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
 google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4=
 google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM=
-google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
-google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
+google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A=
+google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
diff --git a/pkg/cloud/services/compute/instance.go b/pkg/cloud/services/compute/instance.go
index b6761e81..cf626023 100644
--- a/pkg/cloud/services/compute/instance.go
+++ b/pkg/cloud/services/compute/instance.go
@@ -311,7 +311,8 @@ func (s *Service) getBlockDevices(eventObject runtime.Object, instanceSpec *Inst
 			return nil, fmt.Errorf("block device name 'root' is reserved")
 		}
 
-		if blockDeviceSpec.Storage.Type == infrav1.VolumeBlockDevice {
+		switch blockDeviceSpec.Storage.Type {
+		case infrav1.VolumeBlockDevice:
 			blockDevice, err := s.getOrCreateVolumeBuilder(eventObject, instanceSpec, &blockDeviceSpec, "", fmt.Sprintf("Additional block device for %s", instanceSpec.Name))
 			if err != nil {
 				return nil, err
@@ -319,11 +320,11 @@ func (s *Service) getBlockDevices(eventObject runtime.Object, instanceSpec *Inst
 			bdUUID = blockDevice.ID
 			sourceType = servers.SourceVolume
 			destinationType = servers.DestinationVolume
-		} else if blockDeviceSpec.Storage.Type == infrav1.LocalBlockDevice {
+		case infrav1.LocalBlockDevice:
 			sourceType = servers.SourceBlank
 			destinationType = servers.DestinationLocal
 			localDiskSizeGiB = blockDeviceSpec.SizeGiB
-		} else {
+		default:
 			return nil, fmt.Errorf("invalid block device type %s", blockDeviceSpec.Storage.Type)
 		}
 
diff --git a/pkg/cloud/services/compute/referenced_resources.go b/pkg/cloud/services/compute/referenced_resources.go
index 35e9ff09..c08724e7 100644
--- a/pkg/cloud/services/compute/referenced_resources.go
+++ b/pkg/cloud/services/compute/referenced_resources.go
@@ -52,8 +52,8 @@ func ResolveServerSpec(ctx context.Context, scope *scope.WithLogger, k8sClient c
 
 	// If the server is bound to a cluster, we use the cluster name to generate the port description.
 	var clusterName string
-	if openStackServer.ObjectMeta.Labels[clusterv1.ClusterNameLabel] != "" {
-		clusterName = openStackServer.ObjectMeta.Labels[clusterv1.ClusterNameLabel]
+	if openStackServer.Labels[clusterv1.ClusterNameLabel] != "" {
+		clusterName = openStackServer.Labels[clusterv1.ClusterNameLabel]
 	}
 
 	computeService, err := NewService(scope)



Test patch:
diff --git a/controllers/openstackmachine_controller_test.go b/controllers/openstackmachine_controller_test.go
index e66e9dc2..58ace391 100644
--- a/controllers/openstackmachine_controller_test.go
+++ b/controllers/openstackmachine_controller_test.go
@@ -95,9 +95,11 @@ func TestOpenStackMachineSpecToOpenStackServerSpec(t *testing.T) {
 	tags := []string{"tag1", "tag2"}
 	userData := &corev1.LocalObjectReference{Name: "server-data-secret"}
 	tests := []struct {
-		name string
-		spec *infrav1.OpenStackMachineSpec
-		want *infrav1alpha1.OpenStackServerSpec
+		name           string
+		spec           *infrav1.OpenStackMachineSpec
+		clusterNetwork *infrav1.NetworkStatusWithSubnets
+		want           *infrav1alpha1.OpenStackServerSpec
+		wantErr        bool
 	}{
 		{
 			name: "Test a minimum OpenStackMachineSpec to OpenStackServerSpec conversion",
@@ -106,6 +108,7 @@ func TestOpenStackMachineSpecToOpenStackServerSpec(t *testing.T) {
 				Image:      image,
 				SSHKeyName: sshKeyName,
 			},
+			clusterNetwork: openStackCluster.Status.Network,
 			want: &infrav1alpha1.OpenStackServerSpec{
 				Flavor:      ptr.To(flavorName),
 				IdentityRef: identityRef,
@@ -128,6 +131,7 @@ func TestOpenStackMachineSpecToOpenStackServerSpec(t *testing.T) {
 					},
 				},
 			},
+			clusterNetwork: openStackCluster.Status.Network,
 			want: &infrav1alpha1.OpenStackServerSpec{
 				Flavor:      ptr.To(flavorName),
 				IdentityRef: identityRef,
@@ -146,6 +150,7 @@ func TestOpenStackMachineSpecToOpenStackServerSpec(t *testing.T) {
 				Image:      image,
 				SSHKeyName: sshKeyName,
 			},
+			clusterNetwork: openStackCluster.Status.Network,
 			want: &infrav1alpha1.OpenStackServerSpec{
 				Flavor:      ptr.To(flavorName),
 				FlavorID:    ptr.To(flavorUUID),
@@ -164,6 +169,7 @@ func TestOpenStackMachineSpecToOpenStackServerSpec(t *testing.T) {
 				Image:      image,
 				SSHKeyName: sshKeyName,
 			},
+			clusterNetwork: openStackCluster.Status.Network,
 			want: &infrav1alpha1.OpenStackServerSpec{
 				FlavorID:    ptr.To(flavorUUID),
 				IdentityRef: identityRef,
@@ -174,12 +180,90 @@ func TestOpenStackMachineSpecToOpenStackServerSpec(t *testing.T) {
 				UserDataRef: userData,
 			},
 		},
+		{
+			name: "Cluster network nil, machine defines port network and overrides SG",
+			spec: &infrav1.OpenStackMachineSpec{
+				Ports: []infrav1.PortOpts{{
+					Network: &infrav1.NetworkParam{ID: ptr.To(networkUUID)},
+				}},
+				SecurityGroups: []infrav1.SecurityGroupParam{{ID: ptr.To(extraSecurityGroupUUID)}},
+			},
+			clusterNetwork: nil,
+			want: &infrav1alpha1.OpenStackServerSpec{
+				IdentityRef: identityRef,
+				Ports: []infrav1.PortOpts{{
+					Network: &infrav1.NetworkParam{ID: ptr.To(networkUUID)},
+					SecurityGroups: []infrav1.SecurityGroupParam{
+						{ID: ptr.To(workerSecurityGroupUUID)},
+						{ID: ptr.To(extraSecurityGroupUUID)},
+					},
+				}},
+				Tags:        tags,
+				UserDataRef: userData,
+			},
+		},
+		{
+			name: "Cluster network nil, machine defines port network and falls back to cluster SG",
+			spec: &infrav1.OpenStackMachineSpec{
+				Ports: []infrav1.PortOpts{{
+					Network: &infrav1.NetworkParam{ID: ptr.To(networkUUID)},
+				}},
+			},
+			clusterNetwork: nil,
+			want: &infrav1alpha1.OpenStackServerSpec{
+				IdentityRef: identityRef,
+				Ports: []infrav1.PortOpts{{
+					Network:        &infrav1.NetworkParam{ID: ptr.To(networkUUID)},
+					SecurityGroups: []infrav1.SecurityGroupParam{{ID: ptr.To(workerSecurityGroupUUID)}},
+				}},
+				Tags:        tags,
+				UserDataRef: userData,
+			},
+		},
+		{
+			name: "Error case: no cluster network and no machine ports",
+			spec: &infrav1.OpenStackMachineSpec{
+				Flavor:     ptr.To(flavorName),
+				Image:      image,
+				SSHKeyName: sshKeyName,
+				// No ports defined
+			},
+			clusterNetwork: nil,
+			want:           nil,
+			wantErr:        true,
+		},
+		{
+			name: "Empty cluster network ID, machine defines explicit ports",
+			spec: &infrav1.OpenStackMachineSpec{
+				Flavor: ptr.To(flavorName),
+				Image:  image,
+				Ports: []infrav1.PortOpts{{
+					Network: &infrav1.NetworkParam{ID: ptr.To(networkUUID)},
+				}},
+			},
+			clusterNetwork: &infrav1.NetworkStatusWithSubnets{NetworkStatus: infrav1.NetworkStatus{ID: ""}},
+			want: &infrav1alpha1.OpenStackServerSpec{
+				Flavor:      ptr.To(flavorName),
+				IdentityRef: identityRef,
+				Image:       image,
+				Ports: []infrav1.PortOpts{{
+					Network:        &infrav1.NetworkParam{ID: ptr.To(networkUUID)},
+					SecurityGroups: []infrav1.SecurityGroupParam{{ID: ptr.To(workerSecurityGroupUUID)}},
+				}},
+				Tags:        tags,
+				UserDataRef: userData,
+			},
+		},
 	}
 	for i := range tests {
 		tt := tests[i]
 		t.Run(tt.name, func(t *testing.T) {
-			spec := openStackMachineSpecToOpenStackServerSpec(tt.spec, identityRef, tags, "", userData, &openStackCluster.Status.WorkerSecurityGroup.ID, openStackCluster.Status.Network.ID)
-			if !reflect.DeepEqual(spec, tt.want) {
+			spec, err := openStackMachineSpecToOpenStackServerSpec(tt.spec, identityRef, tags, "", userData, &openStackCluster.Status.WorkerSecurityGroup.ID, tt.clusterNetwork)
+			if (err != nil) != tt.wantErr {
+				t.Errorf("openStackMachineSpecToOpenStackServerSpec() error = %v, wantErr %v", err, tt.wantErr)
+				return
+			}
+			if !tt.wantErr && !reflect.DeepEqual(spec, tt.want) {
 				t.Errorf("openStackMachineSpecToOpenStackServerSpec() got = %+v, want %+v", spec, tt.want)
 			}
 		})
diff --git a/test/e2e/shared/common.go b/test/e2e/shared/common.go
index 1f506ede..791e440f 100644
--- a/test/e2e/shared/common.go
+++ b/test/e2e/shared/common.go
@@ -187,7 +187,7 @@ type OpenStackLogCollector struct {
 
 // CollectMachineLog gets logs for the OpenStack resources related to the given machine.
 func (o OpenStackLogCollector) CollectMachineLog(ctx context.Context, managementClusterClient client.Client, m *clusterv1.Machine, outputPath string) error {
-	Logf("Collecting logs for machine %q and storing them in %q", m.ObjectMeta.Name, outputPath)
+	Logf("Collecting logs for machine %q and storing them in %q", m.Name, outputPath)
 
 	if err := os.MkdirAll(outputPath, 0o750); err != nil {
 		return fmt.Errorf("couldn't create directory %q for logs: %s", outputPath, err)