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

Issue:
Ports not deleted and causing cluster delete to fail
/kind bug

**What steps did you take and what happened:**
Deployed a new cluster with v0.12.3 of capo and tried to delete the provisioned cluster.


**What did you expect to happen:**
Cluster deletes successfully

**Anything else you would like to add:**
[Miscellaneous information that will assist in solving the issue.]


**Environment:**

- Cluster API Provider OpenStack version (Or `git rev-parse HEAD` if manually built):
- Cluster-API version: 0.12.3
- OpenStack version:  wallaby
- Minikube/KIND version: 0.20.0
- Kubernetes version (use `kubectl version`):
Client Version: v1.28.8
Kustomize Version: v5.0.4-0.20230601165947-6ce0bf390ce3
Server Version: v1.27.3

- OS (e.g. from `/etc/os-release`):
NAME="CentOS Stream"
VERSION="9"


Logs from capo

```
I0528 18:55:27.449976       1 openstackcluster_controller.go:235] "Deleting Bastion" controller="openstackcluster" controllerGroup="infrastructure.cluster.x-k8s.io" controllerKind="OpenStackCluster" OpenStackCluster="default/cedev23v12-pdx-cedev" namespace="default" name="cedev23v12-pdx-cedev" reconcileID="1bebd362-e93a-4215-a3fb-de94d0541ea9" cluster="cedev23v12-pdx-cedev"
I0528 18:55:27.450005       1 openstackcluster_controller.go:461] "Reconciling Bastion delete server" controller="openstackcluster" controllerGroup="infrastructure.cluster.x-k8s.io" controllerKind="OpenStackCluster" OpenStackCluster="default/cedev23v12-pdx-cedev" namespace="default" name="cedev23v12-pdx-cedev" reconcileID="1bebd362-e93a-4215-a3fb-de94d0541ea9" cluster="cedev23v12-pdx-cedev"
I0528 18:55:27.450020       1 openstackcluster_controller.go:308] "Deleted Bastion" controller="openstackcluster" controllerGroup="infrastructure.cluster.x-k8s.io" controllerKind="OpenStackCluster" OpenStackCluster="default/cedev23v12-pdx-cedev" namespace="default" name="cedev23v12-pdx-cedev" reconcileID="1bebd362-e93a-4215-a3fb-de94d0541ea9" cluster="cedev23v12-pdx-cedev"
I0528 18:55:27.919991       1 recorder.go:104] "Failed to delete security group k8s-cluster-default-cedev23v12-pdx-cedev-secgroup-controlplane with id 92a44b36-286b-48b0-8d79-0e2fddf28a6d: Expected HTTP response code [202 204] when accessing [DELETE https://staging502.wpc:9696/v2.0/security-groups/92a44b36-286b-48b0-8d79-0e2fddf28a6d], but got 409 instead: {\"NeutronError\": {\"type\": \"SecurityGroupInUse\", \"message\": \"Security Group 92a44b36-286b-48b0-8d79-0e2fddf28a6d in use.\", \"detail\": \"\"}}" logger="events" type="Warning" object={"kind":"OpenStackCluster","namespace":"default","name":"cedev23v12-pdx-cedev","uid":"a61108bd-c1e5-46bb-b457-f34fcab0db63","apiVersion":"infrastructure.cluster.x-k8s.io/v1beta1","resourceVersion":"21309679"} reason="Faileddeletesecuritygroup"
E0528 18:55:27.920301       1 controller.go:316] "Reconciler error" err="failed to delete security groups: Expected HTTP response code [202 204] when accessing [DELETE https://staging502.wpc:9696/v2.0/security-groups/92a44b36-286b-48b0-8d79-0e2fddf28a6d], but got 409 instead: {\"NeutronError\": {\"type\": \"SecurityGroupInUse\", \"message\": \"Security Group 92a44b36-286b-48b0-8d79-0e2fddf28a6d in use.\", \"detail\": \"\"}}" controller="openstackcluster" controllerGroup="infrastructure.cluster.x-k8s.io" controllerKind="OpenStackCluster" OpenStackCluster="default/cedev23v12-pdx-cedev" namespace="default" name="cedev23v12-pdx-cedev" reconcileID="1bebd362-e93a-4215-a3fb-de94d0541ea9"
```



I see that these ports are not really deleted

```
openstack port list --security-group 92a44b36-286b-48b0-8d79-0e2fddf28a6d --os-cloud staging-502
+--------------------------------------+--------------------------------------------+-------------------+-------------------------------------------------------------------------------+--------+
| ID                                   | Name                                       | MAC Address       | Fixed IP Addresses                                                            | Status |
+--------------------------------------+--------------------------------------------+-------------------+-------------------------------------------------------------------------------+--------+
| 20da197a-115a-4811-ae6b-a0e7e330bdfc | cedev23v12-pdx-cedev-control-plane-hkmsk-0 | fa:16:3e:97:d4:2c | ip_address='10.158.121.89', subnet_id='a5a338b6-d0c1-4c33-8b54-db7c19d6b801'  | DOWN   |
| 2e03d1c9-e62c-4ea7-bb92-ae6852906928 | cedev23v12-pdx-cedev-control-plane-8k2q7-0 | fa:16:3e:9f:7f:7d | ip_address='10.158.121.112', subnet_id='a5a338b6-d0c1-4c33-8b54-db7c19d6b801' | DOWN   |
| 35e78d69-be87-4dd5-b9e7-eb1e7b23c41f | cedev23v12-pdx-cedev-control-plane-hkmsk-0 | fa:16:3e:77:32:6a | ip_address='10.158.121.122', subnet_id='a5a338b6-d0c1-4c33-8b54-db7c19d6b801' | DOWN   |
| 6c4233cd-782e-4c1f-8ed8-f81c9fc26861 | cedev23v12-pdx-cedev-control-plane-8k2q7-0 | fa:16:3e:86:db:52 | ip_address='10.158.121.104', subnet_id='a5a338b6-d0c1-4c33-8b54-db7c19d6b801' | DOWN   |
| 8ffe2adf-8d9a-4bcf-b9aa-a8299e59eec7 | cedev23v12-pdx-cedev-control-plane-hkmsk-0 | fa:16:3e:15:08:36 | ip_address='10.158.121.111', subnet_id='a5a338b6-d0c1-4c33-8b54-db7c19d6b801' | DOWN   |
| a8153e5b-8e33-4ff3-b5ad-7c0dabc4e98e | cedev23v12-pdx-cedev-control-plane-8k2q7-0 | fa:16:3e:1c:2b:70 | ip_address='10.158.121.116', subnet_id='a5a338b6-d0c1-4c33-8b54-db7c19d6b801' | DOWN   |
| aaa1375c-423f-493f-ab00-fc26b4fbefe1 | cedev23v12-pdx-cedev-control-plane-b47rj-0 | fa:16:3e:60:b2:f6 | ip_address='10.158.121.115', subnet_id='a5a338b6-d0c1-4c33-8b54-db7c19d6b801' | DOWN   |
| b980b0d1-cc44-4e4e-882e-ccc8e92ff9e6 | cedev23v12-pdx-cedev-control-plane-hkmsk-0 | fa:16:3e:a3:b4:a4 | ip_address='10.158.121.97', subnet_id='a5a338b6-d0c1-4c33-8b54-db7c19d6b801'  | DOWN   |
| bcdbde49-0881-4891-90a1-b153f7ff02c5 | cedev23v12-pdx-cedev-control-plane-8k2q7-0 | fa:16:3e:86:4e:1f | ip_address='10.158.121.124', subnet_id='a5a338b6-d0c1-4c33-8b54-db7c19d6b801' | DOWN   |
| c71d71b4-48d8-4e5f-8baf-53ee8fb195fa | cedev23v12-pdx-cedev-control-plane-b47rj-0 | fa:16:3e:c0:7a:68 | ip_address='10.158.121.90', subnet_id='a5a338b6-d0c1-4c33-8b54-db7c19d6b801'  | DOWN   |
| c826a9c6-e1b1-4c8b-9d1f-9fa3ed1569fa | cedev23v12-pdx-cedev-control-plane-b47rj-0 | fa:16:3e:f7:b3:70 | ip_address='10.158.121.107', subnet_id='a5a338b6-d0c1-4c33-8b54-db7c19d6b801' | DOWN   |
| d84f169d-df86-46a9-b451-25db24e89736 | cedev23v12-pdx-cedev-control-plane-8k2q7-0 | fa:16:3e:10:24:7e | ip_address='10.158.121.92', subnet_id='a5a338b6-d0c1-4c33-8b54-db7c19d6b801'  | DOWN   |
+--------------------------------------+--------------------------------------------+-------------------+-------------------------------------------------------------------------------+--------+
```

There device_id in these ports are empty - so the instances associated with them are already deleted by capo. 

Any idea how to fix this?

Discussion:
@EmilienM: the security group is a red herring, it gets deleted at some point I believe.
we have e2e tests which test that ports are removed, I'm curious how you've hit that one to be honest.
in the debug process you'll need to figure out what ports wasn't removed: was it attempted to be removed or why it wasn't?@sagarun: @EmilienM  Thank you. i will try and look further on my side. Do you know which e2e test covers the port delete case? @sagarun: This is just after i created the server, i see these all downed ports. Only the active port is associated with the node.

```openstack port list -c Name -c Status  --os-cloud staging-502|grep cedev23v12-pdx-cedev
| cedev23v12-pdx-cedev-md-0-zvlwx-ld9rl-0                            | DOWN   |
| cedev23v12-pdx-cedev-control-plane-ch9m4-0                         | DOWN   |
| cedev23v12-pdx-cedev-control-plane-6xtvx                           | ACTIVE |
| cedev23v12-pdx-cedev-md-0-zvlwx-qzs7g-0                            | DOWN   |
| cedev23v12-pdx-cedev-control-plane-6xtvx-0                         | DOWN   |
| cedev23v12-pdx-cedev-md-0-zvlwx-p4bxz                              | ACTIVE |
| cedev23v12-pdx-cedev-control-plane-bgss8                           | ACTIVE |
| cedev23v12-pdx-cedev-control-plane-bgss8-0                         | DOWN   |
| cedev23v12-pdx-cedev-control-plane-ch9m4-0                         | DOWN   |
| cedev23v12-pdx-cedev-control-plane-ch9m4-0                         | DOWN   |
| cedev23v12-pdx-cedev-control-plane-6xtvx-0                         | DOWN   |
| cedev23v12-pdx-cedev-control-plane-6xtvx-0                         | DOWN   |
| cedev23v12-pdx-cedev-control-plane-bgss8-0                         | DOWN   |
| cedev23v12-pdx-cedev-md-0-zvlwx-ld9rl-0                            | DOWN   |
| cedev23v12-pdx-cedev-md-0-zvlwx-qzs7g-0                            | DOWN   |
| cedev23v12-pdx-cedev-md-0-zvlwx-ld9rl-0                            | DOWN   |
| cedev23v12-pdx-cedev-md-0-zvlwx-qzs7g                              | ACTIVE |
| cedev23v12-pdx-cedev-control-plane-ch9m4-0                         | DOWN   |
| cedev23v12-pdx-cedev-control-plane-6xtvx-0                         | DOWN   |
| cedev23v12-pdx-cedev-md-0-zvlwx-p4bxz-0                            | DOWN   |
| cedev23v12-pdx-cedev-md-0-zvlwx-qzs7g-0                            | DOWN   |
| cedev23v12-pdx-cedev-md-0-zvlwx-ld9rl                              | ACTIVE |
| cedev23v12-pdx-cedev-control-plane-bgss8-0                         | DOWN   |
| cedev23v12-pdx-cedev-md-0-zvlwx-ld9rl-0                            | DOWN   |
| cedev23v12-pdx-cedev-control-plane-bgss8-0                         | DOWN   |
| cedev23v12-pdx-cedev-md-0-zvlwx-p4bxz-0                            | DOWN   |
| cedev23v12-pdx-cedev-md-0-zvlwx-p4bxz-0                            | DOWN   |
| cedev23v12-pdx-cedev-control-plane-ch9m4-0                         | DOWN   |
| cedev23v12-pdx-cedev-control-plane-ch9m4                           | ACTIVE |```



The ports in "DOWN" status are the ones that never get deleted and these Down ports are not associated with any instance

``` openstack port show 2230f54f-07a7-4cc2-9e86-6f0a57bf5ff9 --os-cloud staging-502
+-------------------------+-------------------------------------------------------------------------------+
| Field                   | Value                                                                         |
+-------------------------+-------------------------------------------------------------------------------+
| admin_state_up          | UP                                                                            |
| allowed_address_pairs   |                                                                               |
| binding_host_id         |                                                                               |
| binding_profile         |                                                                               |
| binding_vif_details     |                                                                               |
| binding_vif_type        | unbound                                                                       |
| binding_vnic_type       | normal                                                                        |
| created_at              | 2025-05-30T05:07:40Z                                                          |
| data_plane_status       | None                                                                          |
| description             | Created by cluster-api-provider-openstack cluster cedev23v12-pdx-cedev        |
| device_id               |                                                                               |
| device_owner            |                                                                               |
| device_profile          | None                                                                          |
| dns_assignment          | None                                                                          |
| dns_domain              | None                                                                          |
| dns_name                | None                                                                          |
| extra_dhcp_opts         |                                                                               |
| fixed_ips               | ip_address='10.158.121.111', subnet_id='a5a338b6-d0c1-4c33-8b54-db7c19d6b801' |
| hardware_offload_type   | None                                                                          |
| hints                   |                                                                               |
| id                      | 2230f54f-07a7-4cc2-9e86-6f0a57bf5ff9                                          |
| ip_allocation           | None                                                                          |
| mac_address             | fa:16:3e:7c:8a:a4                                                             |
| name                    | cedev23v12-pdx-cedev-control-plane-bgss8-0                                    |
| network_id              | 6daab26b-5ac1-4df9-8637-572e07d5850d                                          |
| numa_affinity_policy    | None                                                                          |
| port_security_enabled   | True                                                                          |
| project_id              | c2b2824c3532479dba290750f745fcc4                                              |
| propagate_uplink_status | None                                                                          |
| resource_request        | None                                                                          |
| revision_number         | 1                                                                             |
| qos_network_policy_id   | None                                                                          |
| qos_policy_id           | None                                                                          |
| security_group_ids      | 4b449fc5-ff6b-43f2-97e0-70e23d4eb102                                          |
| status                  | DOWN                                                                          |
| tags                    |                                                                               |
| trunk_details           | None                                                                          |
| updated_at              | 2025-05-30T05:07:40Z                                                          |
+-------------------------+-------------------------------------------------------------------------------+
❯ openstack port show fe550a28-e824-44e4-9c69-bb372527d9b2 --os-cloud staging-502
+-------------------------+------------------------------------------------------------------------------------------------------+
| Field                   | Value                                                                                                |
+-------------------------+------------------------------------------------------------------------------------------------------+
| admin_state_up          | UP                                                                                                   |
| allowed_address_pairs   |                                                                                                      |
| binding_host_id         | s-mxq1260qvd.sys.az1.eng.pdx.wd                                                                      |
| binding_profile         |                                                                                                      |
| binding_vif_details     | bound_drivers.0='calico', connectivity='legacy', mac_address='00:61:fe:ed:ca:fe', port_filter='True' |
| binding_vif_type        | tap                                                                                                  |
| binding_vnic_type       | normal                                                                                               |
| created_at              | 2025-05-30T04:56:45Z                                                                                 |
| data_plane_status       | None                                                                                                 |
| description             | Created by cluster-api-provider-openstack cluster cedev23v12-pdx-cedev                               |
| device_id               | 6993335b-4645-4419-8fe0-5ba444c1b614                                                                 |
| device_owner            | compute:nova                                                                                         |
| device_profile          | None                                                                                                 |
| dns_assignment          | None                                                                                                 |
| dns_domain              | None                                                                                                 |
| dns_name                | None                                                                                                 |
| extra_dhcp_opts         |                                                                                                      |
| fixed_ips               | ip_address='10.158.121.76', subnet_id='a5a338b6-d0c1-4c33-8b54-db7c19d6b801'                         |
| hardware_offload_type   | None                                                                                                 |
| hints                   |                                                                                                      |
| id                      | fe550a28-e824-44e4-9c69-bb372527d9b2                                                                 |
| ip_allocation           | None                                                                                                 |
| mac_address             | fa:16:3e:b6:5b:52                                                                                    |
| name                    | cedev23v12-pdx-cedev-control-plane-ch9m4                                                             |
| network_id              | 6daab26b-5ac1-4df9-8637-572e07d5850d                                                                 |
| numa_affinity_policy    | None                                                                                                 |
| port_security_enabled   | True                                                                                                 |
| project_id              | c2b2824c3532479dba290750f745fcc4                                                                     |
| propagate_uplink_status | None                                                                                                 |
| resource_request        | None                                                                                                 |
| revision_number         | 4                                                                                                    |
| qos_network_policy_id   | None                                                                                                 |
| qos_policy_id           | None                                                                                                 |
| security_group_ids      | 4b449fc5-ff6b-43f2-97e0-70e23d4eb102                                                                 |
| status                  | ACTIVE                                                                                               |
| tags                    |                                                                                                      |
| trunk_details           | None                                                                                                 |
| updated_at              | 2025-05-30T04:56:49Z                                                                                 |
+-------------------------+------------------------------------------------------------------------------------------------------+```@sagarun: [capo-logs.txt](https://github.com/user-attachments/files/20515390/capo-logs.txt)@snbuback: This also happens in CAPO v0.11.6 and v0.11.3.
The CAPO keeps creating ports periodically. He can't understand the port already exists. Look at the events at the bottom.


```
kubectl describe openstackservers.infrastructure.cluster.x-k8s.io cedev8t1-pdx-cedev-ss-6-ssttg-n6vhj
Name:         cedev8t1-pdx-cedev-ss-6-ssttg-n6vhj
Namespace:    default
Labels:       cluster.x-k8s.io/cluster-name=cedev8t1-pdx-cedev
Annotations:  <none>
API Version:  infrastructure.cluster.x-k8s.io/v1alpha1
Kind:         OpenStackServer
Metadata:
  Creation Timestamp:  2025-05-30T14:24:35Z
  Finalizers:
    openstackserver.infrastructure.cluster.x-k8s.io
  Generation:  1
  Owner References:
    API Version:     infrastructure.cluster.x-k8s.io/v1beta1
    Kind:            OpenStackMachine
    Name:            cedev8t1-pdx-cedev-ss-6-ssttg-n6vhj
    UID:             6e9caa6d-16a9-498a-a711-e20d2764514d
  Resource Version:  188062
  UID:               1bd5df2d-9bcf-4509-b455-c619c076f966
Spec:
  Availability Zone:  nova
  Flavor:             K8S.16.32.100.0
  Identity Ref:
    Cloud Name:  cedev8-staging502
    Name:        cedev8t1-pdx-cedev-cloud-config
  Image:
    Filter:
      Name:  centos-9-kube-v1.31.5-g11517.2
  Ports:
    Name Suffix:  silv2
    Network:
      Filter:
        Name:  cedev8
    Security Groups:
      Id:        9e1c7ef0-51ab-4af6-b617-2fb77f9b25d6
  Ssh Key Name:  cloud-user
  User Data Ref:
    Name:  cedev8t1-pdx-cedev-ss-6-ssttg-n6vhj
Status:
  Conditions:
    Last Transition Time:  2025-05-30T14:24:46Z
    Status:                True
    Type:                  Ready
    Last Transition Time:  2025-05-30T14:24:46Z
    Status:                True
    Type:                  InstanceReady
  Instance ID:             9d8fd882-1586-426e-a53c-cabde0656130
  Instance State:          ACTIVE
  Ready:                   true
  Resolved:
    Flavor ID:  199
    Image ID:   a3b9d631-ce58-491b-9177-04cac420ea4c
    Ports:
      Description:  Created by cluster-api-provider-openstack cluster cedev8t1-pdx-cedev
      Name:         cedev8t1-pdx-cedev-ss-6-ssttg-n6vhj-silv2
      Network ID:   813e9593-f9bb-426b-b959-402494d72690
      Security Groups:
        9e1c7ef0-51ab-4af6-b617-2fb77f9b25d6
  Resources:
    Ports:
      Id:  a2031660-359f-4367-981c-c0c1b3feeb7f
Events:
  Type    Reason                  Age    From                  Message
  ----    ------                  ----   ----                  -------
  Normal  Successfulcreateport    22m    openstack-controller  Created port cedev8t1-pdx-cedev-ss-6-ssttg-n6vhj-silv2 with id a2031660-359f-4367-981c-c0c1b3feeb7f
  Normal  Successfulcreateserver  22m    openstack-controller  Created server  with id 9d8fd882-1586-426e-a53c-cabde0656130
  Normal  Successfulcreateport    21m    openstack-controller  Created port cedev8t1-pdx-cedev-ss-6-ssttg-n6vhj-silv2 with id 2843e429-d1d6-4150-9f2a-8b80b288b372
  Normal  Successfulcreateport    21m    openstack-controller  Created port cedev8t1-pdx-cedev-ss-6-ssttg-n6vhj-silv2 with id ae35f784-b201-4945-b9dc-2c391913a7d0
  Normal  Successfulcreateport    21m    openstack-controller  Created port cedev8t1-pdx-cedev-ss-6-ssttg-n6vhj-silv2 with id 26325220-d065-4f59-9407-8e171876af27
  Normal  Successfulcreateport    18m    openstack-controller  Created port cedev8t1-pdx-cedev-ss-6-ssttg-n6vhj-silv2 with id 9b5fded0-2393-4d41-afd3-20451ec7dc79
  Normal  Successfulcreateport    8m47s  openstack-controller  Created port cedev8t1-pdx-cedev-ss-6-ssttg-n6vhj-silv2 with id 8bf2b3de-c62d-4525-a7b2-5d3607986c93
```

Naturally ports are being created in OpenStack:
```
[root@s-gztv8b3.sys.az1.eng.pdx.wd ~]# openstack port list --network cedev8 | grep 'cedev8t1-pdx-cedev-ss-6-ssttg-n6vhj'
| 26325220-d065-4f59-9407-8e171876af27 | cedev8t1-pdx-cedev-ss-6-ssttg-n6vhj-silv2       | fa:16:3e:cb:75:94 | ip_address='10.158.126.195', subnet_id='814ef284-f2e9-4a87-b0fd-430eaa3714d2' | DOWN   |
| 2843e429-d1d6-4150-9f2a-8b80b288b372 | cedev8t1-pdx-cedev-ss-6-ssttg-n6vhj-silv2       | fa:16:3e:ad:2e:9b | ip_address='10.158.126.224', subnet_id='814ef284-f2e9-4a87-b0fd-430eaa3714d2' | DOWN   |
| 8bf2b3de-c62d-4525-a7b2-5d3607986c93 | cedev8t1-pdx-cedev-ss-6-ssttg-n6vhj-silv2       | fa:16:3e:64:c4:ba | ip_address='10.158.126.196', subnet_id='814ef284-f2e9-4a87-b0fd-430eaa3714d2' | DOWN   |
| 9b5fded0-2393-4d41-afd3-20451ec7dc79 | cedev8t1-pdx-cedev-ss-6-ssttg-n6vhj-silv2       | fa:16:3e:e1:44:06 | ip_address='10.158.126.244', subnet_id='814ef284-f2e9-4a87-b0fd-430eaa3714d2' | DOWN   |
| a2031660-359f-4367-981c-c0c1b3feeb7f | cedev8t1-pdx-cedev-ss-6-ssttg-n6vhj             | fa:16:3e:60:3c:48 | ip_address='10.158.126.231', subnet_id='814ef284-f2e9-4a87-b0fd-430eaa3714d2' | ACTIVE |
| ae35f784-b201-4945-b9dc-2c391913a7d0 | cedev8t1-pdx-cedev-ss-6-ssttg-n6vhj-silv2       | fa:16:3e:e9:11:81 | ip_address='10.158.126.206', subnet_id='814ef284-f2e9-4a87-b0fd-430eaa3714d2' | DOWN   |
| c7e29023-96e6-48f8-af6b-918ade8e219d | cedev8t1-pdx-cedev-ss-6-ssttg-n6vhj-silv2       | fa:16:3e:cb:ec:8c | ip_address='10.158.126.201', subnet_id='814ef284-f2e9-4a87-b0fd-430eaa3714d2' | DOWN   |
```
This happens for all the nodes, controllers or workers.@sagarun: The problem seems to be at EnsurePort As far as we can tell, The list port options

https://github.com/kubernetes-sigs/cluster-api-provider-openstack/blob/release-0.11/pkg/cloud/services/networking/port.go#L164

Atleast in our case
portSpec.Name -> foobar-0
portSpec.NeworkID -> uuid

PortStatus.ID -> id of an openstack port that has a name "foobar" (no suffix -0)

So when all these options were used to listports, it returned empty. Because it can't find a port with name foobar-0 and portStatus.ID. I am really not sure why the first port that is associated with the instance was not created to "-0" suffix.@snbuback: I have identified the bug. It affects versions 0.11.3 and 0.11.6 (and likely versions in between, although these were not explicitly tested).

* In version **0.11.6**, the issue leads to the continuous creation of network ports suffixed with `-0` until network resources (e.g., quotas) are exhausted.
* In version **0.11.3**, only one additional port (suffixed with `-0`) is created due to this bug.

**Problem Details:**

1.  **Initial Port Creation:**
    When CAPO creates a new server (VM), it begins by creating the necessary network ports. For a single port configuration, the `getPortName` function names the port using a numeric suffix based on `netIndex`. For example, if `baseName` is "mycluster-myserver" and `netIndex` is 0, the port is named "mycluster-myserver-0".
    *Relevant code:*
    ```go
    // https://github.com/kubernetes-sigs/cluster-api-provider-openstack/blob/v0.11.6/pkg/cloud/services/networking/port.go#L367-L372
    func getPortName(baseName string, portSpec *infrav1.PortOpts, netIndex int) string {
        if portSpec != nil && portSpec.NameSuffix != nil {
            return fmt.Sprintf("%s-%s", baseName, *portSpec.NameSuffix)
        }
        return fmt.Sprintf("%s-%d", baseName, netIndex)
    }
    ```
    This port is initially created successfully in OpenStack.

2.  **Server Creation and Port Renaming by OpenStack (Nova):**
    Next, CAPO creates the server, passing the ID of the port created in step 1 to the OpenStack server creation API. The problem originates here: when OpenStack (specifically Nova) creates the server and attaches the port, it **renames the port to match the server's name** (e.g., "mycluster-myserver"), thereby removing the `-0` suffix.
    At this stage, the `OpenStackServer` resource within Kubernetes still holds the original port ID, but the port's actual name in OpenStack no longer matches the name CAPO initially expected (it lacks the `-0` suffix).

3.  **Reconciliation Loop and `EnsurePort` Issue:**
    In subsequent reconciliation loops, CAPO calls the `EnsurePort` function to verify the port's status.
    *Relevant code:*
    ```go
    // https://github.com/kubernetes-sigs/cluster-api-provider-openstack/blob/v0.11.6/pkg/cloud/services/networking/port.go#L159-L165
    func (s *Service) EnsurePort(eventObject runtime.Object, portSpec *infrav1.ResolvedPortSpec, portStatus infrav1.PortStatus) (*ports.Port, error) {
        opts := ports.ListOpts{
            Name:      portSpec.Name, // This will be "mycluster-myserver-0"
            NetworkID: portSpec.NetworkID,
        }
        if portStatus.ID != "" { // ID of the *renamed* port is used
            opts.ID = portStatus.ID
        }
    // ...
    ```
    The `EnsurePort` function attempts to find the port using the originally generated name (e.g., "mycluster-myserver-0") *and* the stored port ID. However, because Nova has renamed the port (e.g., to "mycluster-myserver"), the OpenStack API search for a port with `name="mycluster-myserver-0"` and `id=<original_id>` fails to find the existing, renamed port.
    Consequently, CAPO incorrectly concludes the port is missing and attempts to create a *new* port with the name "mycluster-myserver-0".

4.  **Looping Behavior (v0.11.6):**
    In v0.11.6, this cycle repeats. During the next reconciliation, even if a *new* port "mycluster-myserver-0" was just created, the `EnsurePort` logic for the *original* port specification (which still references the ID of the port that was renamed by Nova) will again fail to find a match (name "mycluster-myserver-0" with ID of the renamed port). This leads to repeated attempts to create the port, eventually exhausting network quotas.

5.  **Behavior in v0.11.3:**
    In v0.11.3, the initial mismatch also causes one additional port to be created. However, further port creation is often prevented due to a check in the `OpenStackServer` controller that compares the length of desired ports with existing ports:
    *Relevant code:*
    ```go
    // https://github.com/kubernetes-sigs/cluster-api-provider-openstack/blob/v0.11.3/controllers/openstackserver_controller.go#L421
        if len(desiredPorts) == len(resources.Ports) {
            return nil
        }
    ```
    If an extra port makes these counts match, the controller may not attempt further creations, despite the underlying name/ID inconsistency for one of the ports.


Solution patch:
diff --git a/docs/book/src/topics/external-cloud-provider.md b/docs/book/src/topics/external-cloud-provider.md
index 2c8bd55f..4b4246d5 100644
--- a/docs/book/src/topics/external-cloud-provider.md
+++ b/docs/book/src/topics/external-cloud-provider.md
@@ -9,7 +9,8 @@
 
 # External Cloud Provider
 
-To deploy a cluster using [external cloud provider](https://github.com/kubernetes/cloud-provider-openstack), create a cluster configuration with the [external cloud provider template](https://github.com/kubernetes-sigs/cluster-api-provider-openstack/blob/main/templates/cluster-template-external-cloud-provider.yaml) or refer to [helm chart](https://github.com/kubernetes/cloud-provider-openstack/tree/master/charts/openstack-cloud-controller-manager).
+All [cluster templates](https://github.com/kubernetes-sigs/cluster-api-provider-openstack/blob/main/templates) are meant to be used with the external cloud provider for OpenStack.
+Refer to the [external cloud provider repository](https://github.com/kubernetes/cloud-provider-openstack) or the [helm chart](https://github.com/kubernetes/cloud-provider-openstack/tree/master/charts/openstack-cloud-controller-manager) for more details.
 
 ## Steps of using external cloud provider template
 
diff --git a/pkg/cloud/services/networking/port.go b/pkg/cloud/services/networking/port.go
index 59e1e5a8..d284375e 100644
--- a/pkg/cloud/services/networking/port.go
+++ b/pkg/cloud/services/networking/port.go
@@ -156,12 +156,12 @@ func (s *Service) ensurePortTagsAndTrunk(port *ports.Port, eventObject runtime.O
 // and that the port has suitable tags and trunk. If the PortStatus is already known,
 // use the ID when filtering for existing ports.
 func (s *Service) EnsurePort(eventObject runtime.Object, portSpec *infrav1.ResolvedPortSpec, portStatus infrav1.PortStatus) (*ports.Port, error) {
-	opts := ports.ListOpts{
-		Name:      portSpec.Name,
-		NetworkID: portSpec.NetworkID,
-	}
+	opts := ports.ListOpts{}
 	if portStatus.ID != "" {
 		opts.ID = portStatus.ID
+	} else {
+		opts.Name = portSpec.Name
+		opts.NetworkID = portSpec.NetworkID
 	}
 
 	existingPorts, err := s.client.ListPort(opts)



Test patch:
diff --git a/controllers/openstackserver_controller_test.go b/controllers/openstackserver_controller_test.go
index 31752746..28c6f333 100644
--- a/controllers/openstackserver_controller_test.go
+++ b/controllers/openstackserver_controller_test.go
@@ -119,9 +119,7 @@ var listDefaultPorts = func(r *recorders) {
 
 var listDefaultPortsWithID = func(r *recorders) {
 	r.network.ListPort(ports.ListOpts{
-		Name:      openStackServerName + "-0",
-		ID:        portUUID,
-		NetworkID: networkUUID,
+		ID: portUUID,
 	}).Return([]ports.Port{
 		{
 			ID: portUUID,
diff --git a/test/e2e/data/e2e_conf.yaml b/test/e2e/data/e2e_conf.yaml
index de7baafb..8d74e579 100644
--- a/test/e2e/data/e2e_conf.yaml
+++ b/test/e2e/data/e2e_conf.yaml
@@ -10,7 +10,7 @@ images:
 # Use local dev images built source tree;
 - name: gcr.io/k8s-staging-capi-openstack/capi-openstack-controller:e2e
   loadBehavior: mustLoad
-- name: quay.io/orc/openstack-resource-controller:v2.0.3
+- name: quay.io/orc/openstack-resource-controller:v2.2.0
   loadBehavior: tryLoad
 
 providers:
@@ -146,7 +146,7 @@ providers:
 - name: openstack-resource-controller
   type: RuntimeExtensionProvider # ORC isn't a provider but we fake it so it can be handled by the clusterctl machinery.
   versions:
-  - name: v2.0.99
+  - name: v2.2.0
     value: ../../../../cluster-api-provider-openstack/test/infrastructure/openstack-resource-controller/config/default
     contract: v1beta1
     files:
@@ -173,6 +173,9 @@ variables:
   CCM: "../../data/ccm/cloud-controller-manager.yaml"
   EXP_CLUSTER_RESOURCE_SET: "true"
   OPENSTACK_BASTION_IMAGE_NAME: "cirros-0.6.1-x86_64-disk"
+  OPENSTACK_BASTION_IMAGE_URL: https://storage.googleapis.com/artifacts.k8s-staging-capi-openstack.appspot.com/test/cirros/2022-12-05/cirros-0.6.1-x86_64-disk.img
+  OPENSTACK_BASTION_IMAGE_HASH: 0c839612eb3f2469420f2ccae990827f
+  OPENSTACK_BASTION_IMAGE_HASH_ALGORITHM: "md5"
   OPENSTACK_BASTION_MACHINE_FLAVOR: "m1.tiny"
   OPENSTACK_BASTION_MACHINE_FLAVOR_ALT: "m1.tiny.alt"
   OPENSTACK_CLOUD: "capo-e2e"
@@ -184,7 +187,9 @@ variables:
   OPENSTACK_FAILURE_DOMAIN: "testaz1"
   OPENSTACK_FAILURE_DOMAIN_ALT: "testaz2"
   OPENSTACK_IMAGE_NAME: "ubuntu-2404-kube-v1.31.2"
+  OPENSTACK_IMAGE_URL: https://storage.googleapis.com/artifacts.k8s-staging-capi-openstack.appspot.com/test/ubuntu/2024-11-21/ubuntu-2404-kube-v1.31.2.img
   OPENSTACK_IMAGE_NAME_UPGRADE_FROM: "ubuntu-2204-kube-v1.30.1"
+  OPENSTACK_IMAGE_URL_UPGRADE_FROM: https://storage.googleapis.com/artifacts.k8s-staging-capi-openstack.appspot.com/test/ubuntu/2024-05-28/ubuntu-2204-kube-v1.30.1.img
   OPENSTACK_NODE_MACHINE_FLAVOR: "m1.small"
   OPENSTACK_SSH_KEY_NAME: "cluster-api-provider-openstack-sigs-k8s-io"
   # The default external network created by devstack
@@ -198,8 +203,10 @@ variables:
   EXP_KUBEADM_BOOTSTRAP_FORMAT_IGNITION: "true"
   # The Flatcar image produced by the image-builder
   OPENSTACK_FLATCAR_IMAGE_NAME: "flatcar-stable-4152.2.0-kube-v1.31.2"
+  OPENSTACK_FLATCAR_IMAGE_URL: "https://storage.googleapis.com/artifacts.k8s-staging-capi-openstack.appspot.com/test/flatcar/flatcar-stable-4152.2.0-kube-v1.31.2.img"
   # A plain Flatcar from the Flatcar releases server
   FLATCAR_IMAGE_NAME: "flatcar_production_openstack_image"
+  FLATCAR_IMAGE_URL: https://stable.release.flatcar-linux.net/amd64-usr/current/flatcar_production_openstack_image.img
 
 intervals:
   conformance/wait-control-plane: ["30m", "10s"]
diff --git a/test/e2e/data/kustomize/common-patches/images-without-ref/images.yaml b/test/e2e/data/kustomize/common-patches/images-without-ref/images.yaml
index 4c9ed531..9fc007bd 100644
--- a/test/e2e/data/kustomize/common-patches/images-without-ref/images.yaml
+++ b/test/e2e/data/kustomize/common-patches/images-without-ref/images.yaml
@@ -1,13 +1,20 @@
----
 apiVersion: openstack.k-orc.cloud/v1alpha1
 kind: Image
 metadata:
   name: node-image
+  labels:
+    clusterctl.cluster.x-k8s.io/move: ""
 spec:
-  import:
-    filter:
-      name: ${OPENSTACK_IMAGE_NAME}
-  managementPolicy: unmanaged
+  resource:
+    name: ${OPENSTACK_IMAGE_NAME}
+    content:
+      containerFormat: bare
+      diskFormat: qcow2
+      download:
+        url: ${OPENSTACK_IMAGE_URL}
+  managementPolicy: managed
+  managedOptions:
+    onDelete: detach
   cloudCredentialsRef:
     secretName: ${CLUSTER_NAME}-cloud-config
     cloudName: ${OPENSTACK_CLOUD}
@@ -16,11 +23,22 @@ apiVersion: openstack.k-orc.cloud/v1alpha1
 kind: Image
 metadata:
   name: bastion-image
+  labels:
+    clusterctl.cluster.x-k8s.io/move: ""
 spec:
-  import:
-    filter:
-      name: ${OPENSTACK_BASTION_IMAGE_NAME}
-  managementPolicy: unmanaged
+  resource:
+    name: ${OPENSTACK_BASTION_IMAGE_NAME}
+    content:
+      containerFormat: bare
+      diskFormat: qcow2
+      download:
+        url: ${OPENSTACK_BASTION_IMAGE_URL}
+        hash:
+          algorithm: ${OPENSTACK_BASTION_IMAGE_HASH_ALGORITHM}
+          value: ${OPENSTACK_BASTION_IMAGE_HASH}
+  managementPolicy: managed
+  managedOptions:
+    onDelete: detach
   cloudCredentialsRef:
     secretName: ${CLUSTER_NAME}-cloud-config
-    cloudName: ${OPENSTACK_CLOUD}
\ No newline at end of file
+    cloudName: ${OPENSTACK_CLOUD}
diff --git a/test/e2e/data/kustomize/common-patches/images/images.yaml b/test/e2e/data/kustomize/common-patches/images/images.yaml
index 4c9ed531..9fc007bd 100644
--- a/test/e2e/data/kustomize/common-patches/images/images.yaml
+++ b/test/e2e/data/kustomize/common-patches/images/images.yaml
@@ -1,13 +1,20 @@
----
 apiVersion: openstack.k-orc.cloud/v1alpha1
 kind: Image
 metadata:
   name: node-image
+  labels:
+    clusterctl.cluster.x-k8s.io/move: ""
 spec:
-  import:
-    filter:
-      name: ${OPENSTACK_IMAGE_NAME}
-  managementPolicy: unmanaged
+  resource:
+    name: ${OPENSTACK_IMAGE_NAME}
+    content:
+      containerFormat: bare
+      diskFormat: qcow2
+      download:
+        url: ${OPENSTACK_IMAGE_URL}
+  managementPolicy: managed
+  managedOptions:
+    onDelete: detach
   cloudCredentialsRef:
     secretName: ${CLUSTER_NAME}-cloud-config
     cloudName: ${OPENSTACK_CLOUD}
@@ -16,11 +23,22 @@ apiVersion: openstack.k-orc.cloud/v1alpha1
 kind: Image
 metadata:
   name: bastion-image
+  labels:
+    clusterctl.cluster.x-k8s.io/move: ""
 spec:
-  import:
-    filter:
-      name: ${OPENSTACK_BASTION_IMAGE_NAME}
-  managementPolicy: unmanaged
+  resource:
+    name: ${OPENSTACK_BASTION_IMAGE_NAME}
+    content:
+      containerFormat: bare
+      diskFormat: qcow2
+      download:
+        url: ${OPENSTACK_BASTION_IMAGE_URL}
+        hash:
+          algorithm: ${OPENSTACK_BASTION_IMAGE_HASH_ALGORITHM}
+          value: ${OPENSTACK_BASTION_IMAGE_HASH}
+  managementPolicy: managed
+  managedOptions:
+    onDelete: detach
   cloudCredentialsRef:
     secretName: ${CLUSTER_NAME}-cloud-config
-    cloudName: ${OPENSTACK_CLOUD}
\ No newline at end of file
+    cloudName: ${OPENSTACK_CLOUD}
diff --git a/test/e2e/data/kustomize/flatcar-sysext/kustomization.yaml b/test/e2e/data/kustomize/flatcar-sysext/kustomization.yaml
index 763bab52..3550c5f5 100644
--- a/test/e2e/data/kustomize/flatcar-sysext/kustomization.yaml
+++ b/test/e2e/data/kustomize/flatcar-sysext/kustomization.yaml
@@ -1,4 +1,3 @@
----
 resources:
 - ../../../../../kustomize/v1beta1/flatcar-sysext
 
@@ -17,5 +16,8 @@ patches:
     name: node-image
   patch: |-
     - op: replace
-      path: /spec/import/filter/name
+      path: /spec/resource/name
       value: ${FLATCAR_IMAGE_NAME}
+    - op: replace
+      path: /spec/resource/content/download/url
+      value: ${FLATCAR_IMAGE_URL}
diff --git a/test/e2e/data/kustomize/flatcar/kustomization.yaml b/test/e2e/data/kustomize/flatcar/kustomization.yaml
index e7bbb7d8..674f00cc 100644
--- a/test/e2e/data/kustomize/flatcar/kustomization.yaml
+++ b/test/e2e/data/kustomize/flatcar/kustomization.yaml
@@ -19,5 +19,8 @@ patches:
     name: node-image
   patch: |-
     - op: replace
-      path: /spec/import/filter/name
+      path: /spec/resource/name
       value: ${OPENSTACK_FLATCAR_IMAGE_NAME}
+    - op: replace
+      path: /spec/resource/content/download/url
+      value: ${OPENSTACK_FLATCAR_IMAGE_URL}
diff --git a/test/e2e/data/kustomize/k8s-upgrade/kustomization.yaml b/test/e2e/data/kustomize/k8s-upgrade/kustomization.yaml
index 46e9bbe7..422629f5 100644
--- a/test/e2e/data/kustomize/k8s-upgrade/kustomization.yaml
+++ b/test/e2e/data/kustomize/k8s-upgrade/kustomization.yaml
@@ -2,6 +2,7 @@ apiVersion: kustomize.config.k8s.io/v1beta1
 kind: Kustomization
 resources:
 - ../default
+- upgrade-from-images.yaml
 - upgrade-from-template.yaml
 - upgrade-to-template.yaml
 
diff --git a/test/e2e/data/kustomize/k8s-upgrade/upgrade-from-images.yaml b/test/e2e/data/kustomize/k8s-upgrade/upgrade-from-images.yaml
new file mode 100644
index 00000000..cf6d2ea8
--- /dev/null
+++ b/test/e2e/data/kustomize/k8s-upgrade/upgrade-from-images.yaml
@@ -0,0 +1,20 @@
+apiVersion: openstack.k-orc.cloud/v1alpha1
+kind: Image
+metadata:
+  name: node-image-upgrade-from
+  labels:
+    clusterctl.cluster.x-k8s.io/move: ""
+spec:
+  resource:
+    name: ${OPENSTACK_IMAGE_NAME_UPGRADE_FROM}
+    content:
+      containerFormat: bare
+      diskFormat: qcow2
+      download:
+        url: ${OPENSTACK_IMAGE_URL_UPGRADE_FROM}
+  managementPolicy: managed
+  managedOptions:
+    onDelete: detach
+  cloudCredentialsRef:
+    secretName: ${CLUSTER_NAME}-cloud-config
+    cloudName: ${OPENSTACK_CLOUD}
diff --git a/test/e2e/data/kustomize/k8s-upgrade/upgrade-from-template.yaml b/test/e2e/data/kustomize/k8s-upgrade/upgrade-from-template.yaml
index d4920811..f818e8c1 100644
--- a/test/e2e/data/kustomize/k8s-upgrade/upgrade-from-template.yaml
+++ b/test/e2e/data/kustomize/k8s-upgrade/upgrade-from-template.yaml
@@ -1,4 +1,3 @@
----
 apiVersion: infrastructure.cluster.x-k8s.io/v1beta1
 kind: OpenStackMachineTemplate
 metadata:
@@ -8,8 +7,8 @@ spec:
     spec:
       flavor: ${OPENSTACK_CONTROL_PLANE_MACHINE_FLAVOR}
       image:
-        filter:
-          name: ${OPENSTACK_IMAGE_NAME_UPGRADE_FROM}
+        imageRef:
+          name: node-image-upgrade-from
       sshKeyName: ${OPENSTACK_SSH_KEY_NAME}
       tags:
       - control-plane
@@ -23,8 +22,8 @@ spec:
     spec:
       flavor: ${OPENSTACK_NODE_MACHINE_FLAVOR}
       image:
-        filter:
-          name: ${OPENSTACK_IMAGE_NAME_UPGRADE_FROM}
+        imageRef:
+          name: node-image-upgrade-from
       sshKeyName: ${OPENSTACK_SSH_KEY_NAME}
       tags:
       - machine
diff --git a/test/e2e/data/kustomize/k8s-upgrade/upgrade-to-template.yaml b/test/e2e/data/kustomize/k8s-upgrade/upgrade-to-template.yaml
index 466cb793..777ee038 100644
--- a/test/e2e/data/kustomize/k8s-upgrade/upgrade-to-template.yaml
+++ b/test/e2e/data/kustomize/k8s-upgrade/upgrade-to-template.yaml
@@ -3,7 +3,6 @@
 # The normal templates has the cluster name as part of the OSMT name.
 # See CONTROL_PLANE_MACHINE_TEMPLATE_UPGRADE_TO and WORKERS_MACHINE_TEMPLATE_UPGRADE_TO
 # in the e2e config.
----
 apiVersion: infrastructure.cluster.x-k8s.io/v1beta1
 kind: OpenStackMachineTemplate
 metadata:
@@ -15,8 +14,8 @@ spec:
     spec:
       flavor: ${OPENSTACK_CONTROL_PLANE_MACHINE_FLAVOR}
       image:
-        filter:
-          name: ${OPENSTACK_IMAGE_NAME}
+        imageRef:
+          name: node-image
       sshKeyName: ${OPENSTACK_SSH_KEY_NAME}
       tags:
       - control-plane
@@ -32,8 +31,8 @@ spec:
     spec:
       flavor: ${OPENSTACK_NODE_MACHINE_FLAVOR}
       image:
-        filter:
-          name: ${OPENSTACK_IMAGE_NAME}
+        imageRef:
+          name: node-image
       sshKeyName: ${OPENSTACK_SSH_KEY_NAME}
       tags:
       - machine
diff --git a/test/e2e/data/shared/openstack-resource-controller/metadata.yaml b/test/e2e/data/shared/openstack-resource-controller/metadata.yaml
index e26a837a..56d37f0b 100644
--- a/test/e2e/data/shared/openstack-resource-controller/metadata.yaml
+++ b/test/e2e/data/shared/openstack-resource-controller/metadata.yaml
@@ -8,3 +8,9 @@ releaseSeries:
 - major: 2
   minor: 0
   contract: v1beta1
+- major: 2
+  minor: 1
+  contract: v1beta1
+- major: 2
+  minor: 2
+  contract: v1beta1
diff --git a/test/e2e/shared/cluster.go b/test/e2e/shared/cluster.go
index 76958b39..6373eefd 100644
--- a/test/e2e/shared/cluster.go
+++ b/test/e2e/shared/cluster.go
@@ -105,8 +105,8 @@ func setupBootstrapCluster(config *clusterctl.E2EConfig, scheme *runtime.Scheme,
 }
 
 // initBootstrapCluster uses kind to create a cluster.
-func initBootstrapCluster(e2eCtx *E2EContext) {
-	clusterctl.InitManagementClusterAndWatchControllerLogs(context.TODO(), clusterctl.InitManagementClusterAndWatchControllerLogsInput{
+func initBootstrapCluster(ctx context.Context, e2eCtx *E2EContext) {
+	clusterctl.InitManagementClusterAndWatchControllerLogs(ctx, clusterctl.InitManagementClusterAndWatchControllerLogsInput{
 		ClusterProxy:              e2eCtx.Environment.BootstrapClusterProxy,
 		ClusterctlConfigPath:      e2eCtx.Environment.ClusterctlConfigPath,
 		InfrastructureProviders:   e2eCtx.E2EConfig.InfrastructureProviders(),
diff --git a/test/e2e/shared/images.go b/test/e2e/shared/images.go
index 739c7b7f..11041238 100644
--- a/test/e2e/shared/images.go
+++ b/test/e2e/shared/images.go
@@ -21,189 +21,19 @@ package shared
 
 import (
 	"context"
-	"encoding/base64"
-	neturl "net/url"
-	"slices"
-	"strings"
 
 	. "github.com/onsi/ginkgo/v2"
 	. "github.com/onsi/gomega"
-	corev1 "k8s.io/api/core/v1"
-	meta "k8s.io/apimachinery/pkg/api/meta"
-	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"sigs.k8s.io/controller-runtime/pkg/client"
 
 	orcv1alpha1 "github.com/k-orc/openstack-resource-controller/v2/api/v1alpha1"
-	orcapplyconfigv1alpha1 "github.com/k-orc/openstack-resource-controller/v2/pkg/clients/applyconfiguration/api/v1alpha1"
-
-	"sigs.k8s.io/cluster-api-provider-openstack/internal/util/ssa"
-	"sigs.k8s.io/cluster-api-provider-openstack/pkg/scope"
 )
 
 const (
-	// The base URL of the CAPO staging artifacts bucket.
-	stagingArtifactBase = "https://storage.googleapis.com/artifacts.k8s-staging-capi-openstack.appspot.com/test/"
-
-	// The name of the credentials secret.
-	credentialsSecretName = "openstack-credentials" //nolint:gosec // these aren't hard-coded credentials
-
 	// A tag which will be added to all glances images created by these tests.
 	E2EImageTag = "capo-e2e"
 )
 
-type DownloadImage struct {
-	// The name of the ORC Image object
-	Name string
-	// An absolute URL to download the image from
-	URL string
-	// A path relative to the staging artifact repo to download the image from
-	ArtifactPath string
-	// A hash used to verify the downloaded image
-	Hash, HashAlgorithm string
-}
-
-func CoreImages(e2eCtx *E2EContext) []DownloadImage {
-	return []DownloadImage{
-		{
-			Name:         "cirros",
-			ArtifactPath: "cirros/2022-12-05/" + e2eCtx.E2EConfig.MustGetVariable("OPENSTACK_BASTION_IMAGE_NAME") + ".img",
-
-			// Specifying an image hash means we can't use
-			// web-download. This serves as an E2E test of image
-			// upload. We only do this with cirros because it's very
-			// small and it's going to involve data transfer between
-			// the test runner and wherever OpenStack is running
-			HashAlgorithm: string(orcv1alpha1.ImageHashAlgorithmMD5),
-			// From: https://download.cirros-cloud.net/0.6.1/MD5SUMS
-			Hash: "0c839612eb3f2469420f2ccae990827f",
-		},
-		{
-			Name:         "capo-default",
-			ArtifactPath: "ubuntu/2024-11-21/" + e2eCtx.E2EConfig.MustGetVariable("OPENSTACK_IMAGE_NAME") + ".img",
-		},
-	}
-}
-
-func ApplyCoreImagesPlus(ctx context.Context, e2eCtx *E2EContext, additionalImages ...DownloadImage) {
-	coreImages := CoreImages(e2eCtx)
-	allImages := slices.Concat(coreImages, additionalImages)
-
-	ApplyGlanceImages(ctx, e2eCtx, allImages)
-}
-
-func CreateGlanceCredentials(ctx context.Context, e2eCtx *E2EContext) {
-	k8sClient := e2eCtx.Environment.BootstrapClusterProxy.GetClient()
-
-	// Generate the credentials secret each image will reference
-	credentialsSecret := generateCredentialsSecret(e2eCtx)
-	Expect(k8sClient.Create(ctx, credentialsSecret)).To(Succeed(), "create openstack-credentials in default namespace")
-}
-
-func imageNames(images []DownloadImage) string {
-	names := make([]string, len(images))
-	for i := range images {
-		names[i] = images[i].Name
-	}
-	return strings.Join(names, ", ")
-}
-
-// ApplyGlanceImages creates ORC Images corresponding to the given set of DownloadImages.
-// Note that it does not wait for the images to become available.
-func ApplyGlanceImages(ctx context.Context, e2eCtx *E2EContext, images []DownloadImage) {
-	By("Applying glances images: " + imageNames(images))
-
-	k8sClient := e2eCtx.Environment.BootstrapClusterProxy.GetClient()
-
-	for _, image := range images {
-		// Infer the url if none was specified
-		url := image.URL
-		if url == "" {
-			url = stagingArtifactBase + image.ArtifactPath
-		}
-
-		// Infer the name to use for the glance image
-
-		// Use last part of url
-		u, err := neturl.Parse(url)
-		Expect(err).NotTo(HaveOccurred(), "parsing "+url)
-		d := strings.Split(u.Path, "/")
-		Expect(len(d)).To(BeNumerically(">", 1), "Not enough path elements in "+url)
-		glanceName := d[len(d)-1]
-
-		// Remove the type suffix
-		for _, suffix := range []string{".img", ".qcow2"} {
-			if strings.HasSuffix(glanceName, suffix) {
-				glanceName = glanceName[:len(glanceName)-len(suffix)]
-				continue
-			}
-		}
-
-		var imageHash *orcv1alpha1.ImageHash
-		if image.HashAlgorithm != "" && image.Hash != "" {
-			imageHash = &orcv1alpha1.ImageHash{
-				Algorithm: orcv1alpha1.ImageHashAlgorithm(image.HashAlgorithm),
-				Value:     image.Hash,
-			}
-		}
-
-		// Generate and create the image
-		orcImage, applyConfig := generateORCImage(e2eCtx, image.Name, glanceName, url, imageHash)
-		Logf("Ensuring glance image " + image.Name)
-		Expect(k8sClient.Patch(ctx, orcImage, ssa.ApplyConfigPatch(applyConfig), client.ForceOwnership, client.FieldOwner("capo-e2e"))).To(Succeed(), "ensure image "+image.Name)
-	}
-}
-
-// WaitForGlanceImagesAvailable waits for the ORC Images described by the given set of DownloadImages to become available.
-func WaitForGlanceImagesAvailable(ctx context.Context, e2eCtx *E2EContext, images []DownloadImage) {
-	names := imageNames(images)
-	By("Waiting for glance images to become available: " + names)
-
-	k8sClient := e2eCtx.Environment.BootstrapClusterProxy.GetClient()
-
-	Eventually(func(ctx context.Context) []string {
-		By("Polling images")
-
-		orcImageList := &orcv1alpha1.ImageList{}
-		Expect(k8sClient.List(ctx, orcImageList, client.InNamespace("default"))).To(Succeed())
-
-		var available []string
-		var notAvailable []string
-		for i := range images {
-			imageName := images[i].Name
-			image := func() *orcv1alpha1.Image {
-				for j := range orcImageList.Items {
-					item := &orcImageList.Items[j]
-					if item.Name == imageName {
-						return item
-					}
-				}
-				return nil
-			}()
-
-			Expect(image).ToNot(BeNil(), "Did not find "+imageName+" in image list")
-
-			availableCondition := meta.FindStatusCondition(image.Status.Conditions, orcv1alpha1.ConditionAvailable)
-			if availableCondition == nil || availableCondition.Status != metav1.ConditionTrue {
-				var msg string
-				if availableCondition == nil {
-					msg = "no status yet"
-				} else {
-					msg = availableCondition.Message
-				}
-				notAvailable = append(notAvailable, image.Name+": "+msg)
-			} else {
-				available = append(available, image.Name)
-			}
-		}
-		Logf("Available: " + strings.Join(available, ", "))
-		Logf("Not available: " + strings.Join(notAvailable, ", "))
-
-		return notAvailable
-	}, e2eCtx.E2EConfig.GetIntervals("default", "wait-image-create")...).WithContext(ctx).Should(BeEmpty(), "ORC Images are not available")
-
-	Logf("Glance images became available: " + names)
-}
-
 func DeleteAllORCImages(ctx context.Context, e2eCtx *E2EContext) {
 	k8sClient := e2eCtx.Environment.BootstrapClusterProxy.GetClient()
 
@@ -228,54 +58,3 @@ func DeleteAllORCImages(ctx context.Context, e2eCtx *E2EContext) {
 		return images
 	}, e2eCtx.E2EConfig.GetIntervals("default", "wait-image-delete")...).Should(BeEmpty(), "ORC Images were not deleted")
 }
-
-func generateCredentialsSecret(e2eCtx *E2EContext) *corev1.Secret {
-	// We run in Node1BeforeSuite, so OPENSTACK_CLOUD_YAML_B64 is not yet set
-	openStackCloudYAMLFile := e2eCtx.E2EConfig.MustGetVariable(OpenStackCloudYAMLFile)
-	caCertB64 := e2eCtx.E2EConfig.MustGetVariable(OpenStackCloudCACertB64)
-	caCert, err := base64.StdEncoding.DecodeString(caCertB64)
-	Expect(err).NotTo(HaveOccurred(), "base64 decode CA Cert: "+caCertB64)
-
-	cloudsYAML := getOpenStackCloudYAML(openStackCloudYAMLFile)
-	credentialsSecret := corev1.Secret{
-		StringData: map[string]string{
-			scope.CloudsSecretKey: string(cloudsYAML),
-			scope.CASecretKey:     string(caCert),
-		},
-	}
-	credentialsSecret.SetName(credentialsSecretName)
-	credentialsSecret.SetNamespace("default")
-
-	return &credentialsSecret
-}
-
-func generateORCImage(e2eCtx *E2EContext, name, glanceName, url string, downloadHash *orcv1alpha1.ImageHash) (*orcv1alpha1.Image, *orcapplyconfigv1alpha1.ImageApplyConfiguration) {
-	const imageNamespace = "default"
-
-	applyConfig := orcapplyconfigv1alpha1.Image(name, imageNamespace).
-		WithSpec(orcapplyconfigv1alpha1.ImageSpec().
-			WithResource(orcapplyconfigv1alpha1.ImageResourceSpec().
-				WithName(orcv1alpha1.OpenStackName(glanceName)).
-				WithTags(E2EImageTag).
-				WithContent(orcapplyconfigv1alpha1.ImageContent().
-					WithContainerFormat(orcv1alpha1.ImageContainerFormatBare).
-					WithDiskFormat(orcv1alpha1.ImageDiskFormatQCOW2).
-					WithDownload(orcapplyconfigv1alpha1.ImageContentSourceDownload().
-						WithURL(url)))).
-			WithCloudCredentialsRef(orcapplyconfigv1alpha1.CloudCredentialsReference().
-				WithSecretName(credentialsSecretName).
-				WithCloudName(e2eCtx.E2EConfig.MustGetVariable("OPENSTACK_CLOUD"))))
-
-	if downloadHash != nil {
-		applyConfig.Spec.Resource.Content.Download.
-			WithHash(orcapplyconfigv1alpha1.ImageHash().
-				WithAlgorithm(downloadHash.Algorithm).
-				WithValue(downloadHash.Value))
-	}
-
-	orcImage := &orcv1alpha1.Image{}
-	orcImage.Name = name
-	orcImage.Namespace = imageNamespace
-
-	return orcImage, applyConfig
-}
diff --git a/test/e2e/shared/openstack.go b/test/e2e/shared/openstack.go
index d3ee61ea..6cf9ea4e 100644
--- a/test/e2e/shared/openstack.go
+++ b/test/e2e/shared/openstack.go
@@ -139,6 +139,26 @@ func dumpOpenStack(_ context.Context, e2eCtx *E2EContext, bootstrapClusterProxyN
 	if err := dumpOpenStackVolumes(e2eCtx, logPath); err != nil {
 		_, _ = fmt.Fprintf(GinkgoWriter, "error dumping OpenStack volumes: %s\n", err)
 	}
+
+	if err := dumpOpenStackServers(e2eCtx, logPath); err != nil {
+		_, _ = fmt.Fprintf(GinkgoWriter, "error dumping OpenStack servers: %s\n", err)
+	}
+}
+
+// dumpOpenStackServers writes all OpenStack servers to servers.json under the logPath.
+func dumpOpenStackServers(e2eCtx *E2EContext, logPath string) error {
+	serversList, err := DumpOpenStackServers(e2eCtx, servers.ListOpts{})
+	if err != nil {
+		return err
+	}
+	serversJSON, err := json.MarshalIndent(serversList, "", "    ")
+	if err != nil {
+		return fmt.Errorf("error marshalling servers %v: %s", serversList, err)
+	}
+	if err := os.WriteFile(path.Join(logPath, "servers.json"), serversJSON, 0o600); err != nil {
+		return fmt.Errorf("error writing servers.json %s: %s", serversJSON, err)
+	}
+	return nil
 }
 
 // dumpOpenStackVolumes returns all OpenStack volumes to a file in the artifact folder.
diff --git a/test/e2e/shared/suite.go b/test/e2e/shared/suite.go
index d434adcb..5d48d950 100644
--- a/test/e2e/shared/suite.go
+++ b/test/e2e/shared/suite.go
@@ -155,10 +155,7 @@ func Node1BeforeSuite(ctx context.Context, e2eCtx *E2EContext) []byte {
 	e2eCtx.Environment.BootstrapClusterProvider, e2eCtx.Environment.BootstrapClusterProxy = setupBootstrapCluster(e2eCtx.E2EConfig, e2eCtx.Environment.Scheme, e2eCtx.Settings.UseExistingCluster)
 
 	Logf("Initializing the bootstrap cluster")
-	initBootstrapCluster(e2eCtx)
-
-	// Create credentials used by all glance images
-	CreateGlanceCredentials(ctx, e2eCtx)
+	initBootstrapCluster(ctx, e2eCtx)
 
 	conf := synchronizedBeforeTestSuiteConfig{
 		ArtifactFolder:          e2eCtx.Settings.ArtifactFolder,
diff --git a/test/e2e/suites/conformance/conformance_test.go b/test/e2e/suites/conformance/conformance_test.go
index 5af9998a..6f218349 100644
--- a/test/e2e/suites/conformance/conformance_test.go
+++ b/test/e2e/suites/conformance/conformance_test.go
@@ -49,8 +49,6 @@ var _ = Describe("conformance tests", func() {
 		Expect(e2eCtx.E2EConfig.Variables).To(HaveKey(shared.KubernetesVersion))
 		// Setup a Namespace where to host objects for this spec and create a watcher for the namespace events.
 		namespace = shared.SetupSpecNamespace(ctx, specName, e2eCtx)
-
-		shared.ApplyCoreImagesPlus(ctx, e2eCtx)
 	})
 
 	It(specName, func(ctx context.Context) {
diff --git a/test/e2e/suites/e2e/clusterctl_upgrade_test.go b/test/e2e/suites/e2e/clusterctl_upgrade_test.go
index 29ffb5a5..ec11037f 100644
--- a/test/e2e/suites/e2e/clusterctl_upgrade_test.go
+++ b/test/e2e/suites/e2e/clusterctl_upgrade_test.go
@@ -38,7 +38,6 @@ var (
 
 var _ = Describe("When testing clusterctl upgrades (v0.11=>current) [clusterctl-upgrade]", func() {
 	BeforeEach(func(ctx context.Context) {
-		shared.ApplyCoreImagesPlus(ctx, e2eCtx, upgradeImage)
 		setDownloadE2EImageEnvVar()
 		// Note: This gives the version without the 'v' prefix, so we need to add it below.
 		capoRelease011, err = clusterctl.ResolveRelease(ctx, "go://github.com/kubernetes-sigs/cluster-api-provider-openstack@v0.11")
@@ -72,7 +71,6 @@ var _ = Describe("When testing clusterctl upgrades (v0.11=>current) [clusterctl-
 
 var _ = Describe("When testing clusterctl upgrades (v0.12=>current) [clusterctl-upgrade]", func() {
 	BeforeEach(func(ctx context.Context) {
-		shared.ApplyCoreImagesPlus(ctx, e2eCtx, upgradeImage)
 		setDownloadE2EImageEnvVar()
 		// Note: This gives the version without the 'v' prefix, so we need to add it below.
 		capoRelease012, err = clusterctl.ResolveRelease(ctx, "go://github.com/kubernetes-sigs/cluster-api-provider-openstack@v0.12")
diff --git a/test/e2e/suites/e2e/e2e_suite_test.go b/test/e2e/suites/e2e/e2e_suite_test.go
index 2219dfe8..a4fb17da 100644
--- a/test/e2e/suites/e2e/e2e_suite_test.go
+++ b/test/e2e/suites/e2e/e2e_suite_test.go
@@ -39,9 +39,8 @@ import (
 )
 
 var (
-	e2eCtx       *shared.E2EContext
-	err          error
-	upgradeImage shared.DownloadImage
+	e2eCtx *shared.E2EContext
+	err    error
 )
 
 func init() {
@@ -97,11 +96,6 @@ var _ = SynchronizedBeforeSuite(func(ctx context.Context) []byte {
 	return data
 }, func(data []byte) {
 	shared.AllNodesBeforeSuite(e2eCtx, data)
-
-	upgradeImage = shared.DownloadImage{
-		Name:         "capo-upgrade-from",
-		ArtifactPath: "ubuntu/2024-05-28/" + e2eCtx.E2EConfig.MustGetVariable("OPENSTACK_IMAGE_NAME_UPGRADE_FROM") + ".img",
-	}
 })
 
 // CheckResourceCleanup checks if all resources created during the test are cleaned up by comparing the resources
diff --git a/test/e2e/suites/e2e/e2e_test.go b/test/e2e/suites/e2e/e2e_test.go
index 67c55daf..22c2a958 100644
--- a/test/e2e/suites/e2e/e2e_test.go
+++ b/test/e2e/suites/e2e/e2e_test.go
@@ -63,20 +63,6 @@ import (
 
 const specName = "e2e"
 
-// Additional images required for flatcar tests.
-func flatcarImages(e2eCtx *shared.E2EContext) []shared.DownloadImage {
-	return []shared.DownloadImage{
-		{
-			Name:         "capo-flatcar",
-			ArtifactPath: "flatcar/" + e2eCtx.E2EConfig.MustGetVariable("OPENSTACK_FLATCAR_IMAGE_NAME") + ".img",
-		},
-		{
-			Name: "flatcar-openstack",
-			URL:  "https://stable.release.flatcar-linux.net/amd64-usr/current/flatcar_production_openstack_image.img",
-		},
-	}
-}
-
 var _ = Describe("e2e tests [PR-Blocking]", func() {
 	var (
 		namespace        *corev1.Namespace
@@ -84,9 +70,6 @@ var _ = Describe("e2e tests [PR-Blocking]", func() {
 
 		// Cleanup functions which cannot run until after the cluster has been deleted
 		postClusterCleanup []func(context.Context)
-
-		// Images required for the current test in addition to the core images
-		additionalImages []shared.DownloadImage
 	)
 
 	createCluster := func(ctx context.Context, configCluster clusterctl.ConfigClusterInput, result *clusterctl.ApplyClusterTemplateAndWaitResult) {
@@ -119,12 +102,6 @@ var _ = Describe("e2e tests [PR-Blocking]", func() {
 		Expect(e2eCtx.E2EConfig).ToNot(BeNil(), "Invalid argument. e2eConfig can't be nil when calling %s spec", specName)
 		Expect(e2eCtx.E2EConfig.Variables).To(HaveKey(shared.KubernetesVersion))
 		postClusterCleanup = nil
-
-		additionalImages = nil
-	})
-
-	JustBeforeEach(func(ctx context.Context) {
-		shared.ApplyCoreImagesPlus(ctx, e2eCtx, additionalImages...)
 	})
 
 	Describe("Workload cluster (default)", func() {
@@ -388,10 +365,6 @@ var _ = Describe("e2e tests [PR-Blocking]", func() {
 	})
 
 	Describe("Workload cluster (flatcar)", func() {
-		BeforeEach(func() {
-			additionalImages = append(additionalImages, flatcarImages(e2eCtx)...)
-		})
-
 		It("should be creatable and deletable", func(ctx context.Context) {
 			// Flatcar default user is "core"
 			shared.SetEnvVar(shared.SSHUserMachine, "core", false)
@@ -437,10 +410,6 @@ var _ = Describe("e2e tests [PR-Blocking]", func() {
 	})
 
 	Describe("Workload cluster (flatcar-sysext)", func() {
-		BeforeEach(func() {
-			additionalImages = append(additionalImages, flatcarImages(e2eCtx)...)
-		})
-
 		It("should be creatable and deletable", func(ctx context.Context) {
 			// Flatcar default user is "core"
 			shared.SetEnvVar(shared.SSHUserMachine, "core", false)
diff --git a/test/e2e/suites/e2e/remediations_test.go b/test/e2e/suites/e2e/remediations_test.go
index 8f8c5bdf..d8df1765 100644
--- a/test/e2e/suites/e2e/remediations_test.go
+++ b/test/e2e/suites/e2e/remediations_test.go
@@ -31,10 +31,6 @@ import (
 
 var _ = Describe("When testing unhealthy machines remediation", func() {
 	Describe("When testing MachineDeployment remediation", func() {
-		BeforeEach(func(ctx context.Context) {
-			shared.ApplyCoreImagesPlus(ctx, e2eCtx)
-		})
-
 		capi_e2e.MachineDeploymentRemediationSpec(context.TODO(), func() capi_e2e.MachineDeploymentRemediationSpecInput {
 			return capi_e2e.MachineDeploymentRemediationSpecInput{
 				E2EConfig:             e2eCtx.E2EConfig,
diff --git a/test/e2e/suites/e2e/self_hosted_test.go b/test/e2e/suites/e2e/self_hosted_test.go
index 93946a54..3ad15110 100644
--- a/test/e2e/suites/e2e/self_hosted_test.go
+++ b/test/e2e/suites/e2e/self_hosted_test.go
@@ -29,9 +29,7 @@ import (
 )
 
 var _ = Describe("When testing Cluster API provider Openstack working on [self-hosted] clusters", func() {
-	BeforeEach(func(ctx context.Context) {
-		shared.ApplyCoreImagesPlus(ctx, e2eCtx, upgradeImage)
-
+	BeforeEach(func() {
 		setDownloadE2EImageEnvVar()
 	})
 
diff --git a/test/infrastructure/openstack-resource-controller/config/default/kustomization.yaml b/test/infrastructure/openstack-resource-controller/config/default/kustomization.yaml
index b042e3da..1e332598 100644
--- a/test/infrastructure/openstack-resource-controller/config/default/kustomization.yaml
+++ b/test/infrastructure/openstack-resource-controller/config/default/kustomization.yaml
@@ -8,4 +8,4 @@ labels:
     cluster.x-k8s.io/provider: "runtime-extension-openstack-resource-controller"
 
 resources:
-- https://github.com/k-orc/openstack-resource-controller/releases/download/v2.0.3/install.yaml
+- https://github.com/k-orc/openstack-resource-controller/releases/download/v2.2.0/install.yaml