This is an automated email from the ASF dual-hosted git repository.

vishesh92 pushed a commit to branch add-simulator-e2e-environment
in repository 
https://gitbox.apache.org/repos/asf/cloudstack-kubernetes-provider.git

commit d695e141f99c78bf33b1d9f20eca6fb4076f2e9a
Author: vishesh92 <[email protected]>
AuthorDate: Mon Aug 31 13:53:02 2026 +0530

    Add a simulated dev/test environment and simulator-based e2e CI
    
    The repository had no way to exercise the CCM end to end. The only
    "run against real CloudStack" hook was configFromEnv() in
    cloudstack_test.go, which skips unless CS_API_URL and friends are set,
    and nothing set them. As a result EnsureLoadBalancer,
    UpdateLoadBalancer and EnsureLoadBalancerDeleted -- the three functions
    holding nearly all of the load balancer branching -- had no test
    coverage at all, and the README pointed at a Docker Hub image
    (cloudstack/simulator) that no longer exists.
    
    Add hack/e2e, which brings up a CloudStack simulator, deploys its
    advanced zone, mints admin API keys, creates a kind cluster and
    deploys CloudStack VMs matching its nodes, then runs the CCM against
    both. docs/development.md documents the same steps by hand so the
    environment is understandable rather than magic.
    
    Add a Go e2e suite under test/e2e covering load balancer lifecycle,
    node initialization, service annotations and the VPC/network ACL path.
    It is behind the e2e build tag, so it stays out of `make test` and
    `go build ./...`, and it needs no new module dependencies.
    
    Run all of it in CI as a matrix of the latest two Kubernetes versions
    against the latest two CloudStack releases. The CloudStack axis is not
    only version coverage: 4.22 and later update a load balancer rule's
    CIDR list in place while earlier releases delete and recreate the
    rule, so both branches are exercised. Cells run in parallel and share
    a single image build, so the workflow costs about as much wall-clock
    as one run.
    
    Fix two project-scoping bugs the new suite uncovered.
    updateNetworkACL fetched the network and ACL list without the project,
    so every LoadBalancer service on a VPC owned by a project failed with
    "error fetching Network with ID" and never got an ingress address.
    getNetworkIDFromIPAddress had the same omission, breaking load
    balancer deletion for projects.
    
    Also add the local cloud-config, cmk-config and kube-config files to
    .gitignore. They hold live credentials and were previously untracked
    but not ignored.
    
    Fixes #4
    
    Co-Authored-By: Claude Opus 5 <[email protected]>
---
 .github/workflows/e2e-simulator.yml | 180 +++++++++++++++++
 .gitignore                          |   6 +
 .golangci.yml                       |   3 +
 Makefile                            |  18 +-
 README.md                           |  66 +++----
 cloudstack_loadbalancer.go          |   8 +-
 cloudstack_loadbalancer_test.go     |  32 +--
 docs/development.md                 | 366 ++++++++++++++++++++++++++++++++++
 hack/e2e/10-simulator-up.sh         | 111 +++++++++++
 hack/e2e/20-kind-up.sh              |  52 +++++
 hack/e2e/30-topology-isolated.sh    |  87 ++++++++
 hack/e2e/40-ccm-deploy.sh           |  93 +++++++++
 hack/e2e/50-topology-vpc.sh         | 123 ++++++++++++
 hack/e2e/90-collect-artifacts.sh    |  64 ++++++
 hack/e2e/99-down.sh                 |  38 ++++
 hack/e2e/env.sh                     |  65 ++++++
 hack/e2e/kind-config.yaml           |  62 ++++++
 hack/e2e/lib/cs.sh                  |  89 +++++++++
 hack/e2e/lib/log.sh                 |  56 ++++++
 hack/e2e/up.sh                      |  38 ++++
 test/e2e/annotations_test.go        | 176 +++++++++++++++++
 test/e2e/framework.go               | 381 ++++++++++++++++++++++++++++++++++++
 test/e2e/loadbalancer_test.go       | 213 ++++++++++++++++++++
 test/e2e/node_test.go               | 137 +++++++++++++
 test/e2e/vpc_test.go                | 132 +++++++++++++
 25 files changed, 2534 insertions(+), 62 deletions(-)

diff --git a/.github/workflows/e2e-simulator.yml 
b/.github/workflows/e2e-simulator.yml
new file mode 100644
index 00000000..bf63ff2a
--- /dev/null
+++ b/.github/workflows/e2e-simulator.yml
@@ -0,0 +1,180 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+name: E2E (CloudStack simulator)
+
+on:
+  push:
+    branches:
+      - main
+  pull_request:
+  workflow_dispatch:
+
+permissions:
+  contents: read
+
+concurrency:
+  group: ${{ github.workflow }}-${{ github.event.pull_request.number || 
github.ref }}
+  cancel-in-progress: true
+
+jobs:
+  # Build the CCM image once and share it with every matrix cell. The
+  # distroless image is small enough that passing it as an artifact is much
+  # cheaper than four redundant builds.
+  build:
+    if: github.repository == 'apache/cloudstack-kubernetes-provider' || 
github.event_name == 'workflow_dispatch'
+    runs-on: ubuntu-latest
+    timeout-minutes: 20
+    steps:
+      - uses: actions/checkout@v6
+
+      - uses: docker/setup-buildx-action@v3
+
+      - name: Build CCM image
+        uses: docker/build-push-action@v6
+        with:
+          context: .
+          load: true
+          platforms: linux/amd64
+          tags: apache/cloudstack-kubernetes-provider:e2e
+          cache-from: type=gha
+          cache-to: type=gha,mode=max
+
+      - name: Export image
+        run: docker save apache/cloudstack-kubernetes-provider:e2e | zstd -T0 
-o ccm-image.tar.zst
+
+      - uses: actions/upload-artifact@v4
+        with:
+          name: ccm-image
+          path: ccm-image.tar.zst
+          retention-days: 1
+
+  e2e:
+    needs: build
+    runs-on: ubuntu-latest
+    timeout-minutes: 45
+    strategy:
+      fail-fast: false
+      matrix:
+        # Latest two Kubernetes minors and latest two CloudStack releases.
+        # The CloudStack axis is not just version coverage: >= 4.22 updates a
+        # load balancer rule's CIDR list in place, while older versions delete
+        # and recreate the rule, so both branches get exercised.
+        k8s: ['v1.37.0', 'v1.36.4']
+        acs: ['4.22.1.0', '4.21.0.0']
+    env:
+      KIND_NODE_IMAGE: kindest/node:${{ matrix.k8s }}
+      SIM_TAG: ${{ matrix.acs }}
+      CCM_IMAGE: apache/cloudstack-kubernetes-provider:e2e
+      # The secrets context is not available in a step-level `if`, so resolve
+      # it once here. Forks without the secret still run, just unauthenticated.
+      HAS_DOCKERHUB_CREDS: ${{ secrets.DOCKERHUB_TOKEN != '' }}
+    steps:
+      - uses: actions/checkout@v6
+
+      - uses: actions/setup-go@v6
+        with:
+          go-version-file: go.mod
+
+      - name: Free disk space
+        run: |
+          sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc \
+                      /usr/local/share/boost "${AGENT_TOOLSDIRECTORY:-}"
+          docker system prune -af
+          df -h /
+
+      # Authenticated pulls avoid Docker Hub's anonymous rate limit, which
+      # four parallel 2 GB simulator pulls would otherwise run into.
+      - name: Log in to Docker Hub
+        if: env.HAS_DOCKERHUB_CREDS == 'true'
+        uses: docker/login-action@v3
+        with:
+          username: ${{ secrets.DOCKERHUB_USER }}
+          password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+      - name: Pull simulator image
+        run: |
+          for i in 1 2 3; do
+            docker pull "apache/cloudstack-simulator:${SIM_TAG}" && exit 0
+            echo "pull attempt $i failed, retrying"
+            sleep 30
+          done
+          exit 1
+
+      - uses: actions/download-artifact@v4
+        with:
+          name: ccm-image
+
+      - name: Load CCM image
+        run: zstd -dc ccm-image.tar.zst | docker load
+
+      - uses: helm/kind-action@v1
+        with:
+          install_only: true
+          version: v0.32.0
+
+      - name: Start simulator and deploy zone
+        run: hack/e2e/10-simulator-up.sh
+
+      - name: Create kind cluster
+        run: hack/e2e/20-kind-up.sh
+
+      - name: Create isolated network topology
+        run: hack/e2e/30-topology-isolated.sh
+
+      - name: Deploy the cloud controller manager
+        run: hack/e2e/40-ccm-deploy.sh
+
+      - name: Unit and acceptance tests against the live simulator
+        run: |
+          . hack/e2e/_out/keys.env
+          CS_API_URL=http://localhost:8080/client/api make test
+
+      - name: E2E phase 1 (load balancer, nodes, annotations)
+        run: |
+          . hack/e2e/_out/keys.env
+          KUBECONFIG="${PWD}/hack/e2e/_out/kubeconfig" \
+          CS_API_URL=http://localhost:8080/client/api \
+          go test -tags e2e -v -timeout 30m ./test/e2e/... -run 
'TestLB|TestNode|TestAnnot'
+
+      - name: Create VPC topology
+        run: hack/e2e/50-topology-vpc.sh
+
+      - name: E2E phase 2 (VPC / network ACL)
+        run: |
+          . hack/e2e/_out/keys.env
+          . hack/e2e/_out/ids.env
+          KUBECONFIG="${PWD}/hack/e2e/_out/kubeconfig" \
+          CS_API_URL=http://localhost:8080/client/api \
+          CS_PROJECT_ID="${E2E_PROJECT_ID}" \
+          E2E_ACL_ID="${E2E_ACL_ID}" E2E_VPC_ID="${E2E_VPC_ID}" \
+          go test -tags e2e -v -timeout 30m ./test/e2e/... -run 'TestVPC'
+
+      - name: Collect artifacts
+        if: always()
+        run: hack/e2e/90-collect-artifacts.sh
+
+      - uses: actions/upload-artifact@v4
+        if: always()
+        with:
+          name: e2e-artifacts-${{ matrix.k8s }}-${{ matrix.acs }}
+          path: hack/e2e/_out/artifacts
+          retention-days: 7
+
+      - name: Tear down
+        if: always()
+        run: hack/e2e/99-down.sh
diff --git a/.gitignore b/.gitignore
index 79e12562..73c4ecea 100644
--- a/.gitignore
+++ b/.gitignore
@@ -26,6 +26,12 @@ go.work.sum
 # env file
 .env
 
+# Local dev/test credentials and generated harness state — never commit these
+/cloud-config
+/cmk-config
+/kube-config
+/hack/e2e/_out/
+
 # Editor/IDE
 .idea/
 .vscode/
\ No newline at end of file
diff --git a/.golangci.yml b/.golangci.yml
index 379c873f..84ae959e 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -19,6 +19,9 @@ version: "2"
 run:
   modules-download-mode: readonly
   issues-exit-code: 1
+  # Lint the build-tagged e2e suite too; without this, goheader/gosec silently 
skip it.
+  build-tags:
+    - e2e
 linters:
   enable:
     - goheader
diff --git a/Makefile b/Makefile
index bbb03f0d..2d336a8b 100644
--- a/Makefile
+++ b/Makefile
@@ -32,7 +32,7 @@ export GO111MODULE=on
 CMD_SRC=\
        cmd/cloudstack-ccm/main.go
 
-.PHONY: all clean docker
+.PHONY: all clean docker e2e-up e2e-down test-e2e
 
 all: cloudstack-ccm
 
@@ -53,6 +53,22 @@ ifneq (${GIT_IS_TAG},NOT_A_TAG)
        docker tag apache/cloudstack-kubernetes-provider:${GIT_COMMIT_SHORT} 
apache/cloudstack-kubernetes-provider:${GIT_TAG}
 endif
 
+# Simulator-based e2e environment; see docs/development.md
+e2e-up:
+       hack/e2e/up.sh
+
+e2e-down:
+       hack/e2e/99-down.sh
+
+# go test runs with the package directory as its working directory, so
+# KUBECONFIG must be absolute.
+test-e2e:
+       @test -f hack/e2e/_out/keys.env || (echo "environment not up; run 'make 
e2e-up' first" && exit 1)
+       . hack/e2e/_out/keys.env && \
+       KUBECONFIG=${CURDIR}/hack/e2e/_out/kubeconfig \
+       CS_API_URL=http://localhost:8080/client/api \
+       go test -tags e2e -v -timeout 30m ./test/e2e/... -run 
'TestLB|TestNode|TestAnnot'
+
 lint: gofmt
        @(echo "Running golangci-lint...")
        golangci-lint run
diff --git a/README.md b/README.md
index 381f2748..085f332b 100644
--- a/README.md
+++ b/README.md
@@ -44,6 +44,11 @@ ssl-no-verify = <Disable SSL certificate validation: true or 
false (optional)>
 
 The access token needs to be able to fetch VM information and deploy load 
balancers in the project or domain where the nodes reside.
 
+The account must also be allowed to call `listManagementServersMetrics`, which 
the controller uses
+on startup to determine the management server version. This is a root admin 
API and is **not**
+included in the default `User` role; without it the controller exits 
immediately with
+`no management servers found`.
+
 To create the secret, use the following command:
 ```bash
 kubectl -n kube-system create secret generic cloudstack-secret 
--from-file=cloud-config
@@ -226,55 +231,34 @@ make docker
 
 ### Testing
 
-You need a local instance of the CloudStack Management Server or a 'real' one 
to connect to.
+Unit tests need nothing but Go:
+
+```bash
+make test
+```
+
+For anything beyond that you need a CloudStack Management Server to talk to.
 The CCM supports the same cloud-config configuration file format used by [the 
cs tool](https://github.com/exoscale/cs),
-so you can simply point it to that.
+so you can simply point it at one you already have:
 
 ```bash
 ./cloudstack-ccm --cloud-provider external-cloudstack --cloud-config 
./cloud-config --kubeconfig ~/.kube/config
 ```
 
-Replace k8s-apiserver with the host name of your Kubernetes development 
clusters's API server.
-
-If you don't have a 'real' CloudStack installation, you can also launch a 
local [simulator instance](https://hub.docker.com/r/cloudstack/simulator) 
instead. This is very useful for dry-run testing.
-
-### Debugging
-
-You can use the VSCode extension 
[Go](https://marketplace.visualstudio.com/items?itemName=golang.go) to debug 
the CCM.
-Add the following configuration to the `.vscode/launch.json` file to launch 
the CCM and debug it.
-
-```json
-{
-    "version": "0.2.0",
-    "configurations": [
-        {
-            "name": "Launch CloudStack CCM",
-            "type": "go",
-            "request": "launch",
-            "mode": "auto",
-            "program": "${workspaceFolder}/cmd/cloudstack-ccm",
-            "env": {},
-            "args": [
-                "--cloud-provider=external-cloudstack",
-                "--cloud-config=${workspaceFolder}/cloud-config",
-                "--kubeconfig=${env:HOME}/.kube/config",
-                "--leader-elect=false",
-                "--v=4"
-            ],
-            "showLog": true,
-            "trace": "verbose"
-        },
-        {
-            "name": "Attach to Process",
-            "type": "go",
-            "request": "attach",
-            "mode": "local",
-            "processId": 0
-        }
-    ]
-}
+If you don't have a 'real' CloudStack installation, you don't need one. The 
repository ships a
+fully simulated environment — a kind cluster, the
+[CloudStack simulator](https://hub.docker.com/r/apache/cloudstack-simulator) 
and the CCM, all in
+containers:
+
+```bash
+make e2e-up      # bring the environment up
+make test-e2e    # run the end-to-end suite against it
+make e2e-down    # tear it down
 ```
 
+See [docs/development.md](docs/development.md) for the full walkthrough, how 
to run the CCM as a
+host process under a debugger, and troubleshooting.
+
 ## Copyright
 
 Copyright 2019 The Apache Software Foundation
diff --git a/cloudstack_loadbalancer.go b/cloudstack_loadbalancer.go
index ffbdd7cd..0e77e4ab 100644
--- a/cloudstack_loadbalancer.go
+++ b/cloudstack_loadbalancer.go
@@ -471,7 +471,7 @@ func (cs *CSCloud) getLoadBalancer(service *corev1.Service) 
(*loadBalancer, erro
 
 // Get network ID from Public IP Address
 func (cs *CSCloud) getNetworkIDFromIPAddress(publicIpId string) (string, 
error) {
-       ip, count, err := cs.client.Address.GetPublicIpAddressByID(publicIpId)
+       ip, count, err := cs.client.Address.GetPublicIpAddressByID(publicIpId, 
cloudstack.WithProject(cs.projectID))
        if err != nil {
                klog.Errorf("Failed to fetch the public IP for id: %v", 
publicIpId)
                return "", err
@@ -480,7 +480,7 @@ func (cs *CSCloud) getNetworkIDFromIPAddress(publicIpId 
string) (string, error)
                return "", err
        }
        if ip.Networkid != "" {
-               network, _, netErr := 
cs.client.Network.GetNetworkByID(ip.Associatednetworkid)
+               network, _, netErr := 
cs.client.Network.GetNetworkByID(ip.Associatednetworkid, 
cloudstack.WithProject(cs.projectID))
                if netErr != nil {
                        klog.Errorf("Failed to fetch the network for id: %v", 
ip.Associatednetworkid)
                        return "", err
@@ -981,12 +981,12 @@ func (lb *loadBalancer) updateFirewallRule(publicIpId 
string, publicPort int, pr
 }
 
 func (lb *loadBalancer) updateNetworkACL(publicPort int, protocol 
LoadBalancerProtocol, networkId string) (bool, error) {
-       network, _, err := lb.Network.GetNetworkByID(networkId)
+       network, _, err := lb.Network.GetNetworkByID(networkId, 
cloudstack.WithProject(lb.projectID))
        if err != nil {
                return false, fmt.Errorf("error fetching Network with ID: %v, 
due to: %s", networkId, err)
        }
 
-       networkAclList, count, err := 
lb.NetworkACL.GetNetworkACLListByID(network.Aclid)
+       networkAclList, count, err := 
lb.NetworkACL.GetNetworkACLListByID(network.Aclid, 
cloudstack.WithProject(lb.projectID))
        if err != nil {
                return false, fmt.Errorf("error fetching Network ACL List with 
ID: %v, due to: %s", network.Aclid, err)
        }
diff --git a/cloudstack_loadbalancer_test.go b/cloudstack_loadbalancer_test.go
index 4bbf38e7..878d3025 100644
--- a/cloudstack_loadbalancer_test.go
+++ b/cloudstack_loadbalancer_test.go
@@ -2829,8 +2829,8 @@ func TestUpdateNetworkACL(t *testing.T) {
                }
 
                gomock.InOrder(
-                       
mockNetwork.EXPECT().GetNetworkByID("net-123").Return(networkResp, 1, nil),
-                       
mockNetworkACL.EXPECT().GetNetworkACLListByID("acl-456").Return(aclListResp, 1, 
nil),
+                       mockNetwork.EXPECT().GetNetworkByID("net-123", 
gomock.Any()).Return(networkResp, 1, nil),
+                       
mockNetworkACL.EXPECT().GetNetworkACLListByID("acl-456", 
gomock.Any()).Return(aclListResp, 1, nil),
                        
mockNetworkACL.EXPECT().NewListNetworkACLsParams().Return(listParams),
                        
mockNetworkACL.EXPECT().ListNetworkACLs(gomock.Any()).Return(listResp, nil),
                        
mockNetworkACL.EXPECT().NewCreateNetworkACLParams("tcp").Return(createParams),
@@ -2884,8 +2884,8 @@ func TestUpdateNetworkACL(t *testing.T) {
                }
 
                gomock.InOrder(
-                       
mockNetwork.EXPECT().GetNetworkByID("net-123").Return(networkResp, 1, nil),
-                       
mockNetworkACL.EXPECT().GetNetworkACLListByID("acl-456").Return(aclListResp, 1, 
nil),
+                       mockNetwork.EXPECT().GetNetworkByID("net-123", 
gomock.Any()).Return(networkResp, 1, nil),
+                       
mockNetworkACL.EXPECT().GetNetworkACLListByID("acl-456", 
gomock.Any()).Return(aclListResp, 1, nil),
                        
mockNetworkACL.EXPECT().NewListNetworkACLsParams().Return(listParams),
                        
mockNetworkACL.EXPECT().ListNetworkACLs(gomock.Any()).Return(listResp, nil),
                )
@@ -2924,8 +2924,8 @@ func TestUpdateNetworkACL(t *testing.T) {
                }
 
                gomock.InOrder(
-                       
mockNetwork.EXPECT().GetNetworkByID("net-123").Return(networkResp, 1, nil),
-                       
mockNetworkACL.EXPECT().GetNetworkACLListByID("acl-456").Return(aclListResp, 1, 
nil),
+                       mockNetwork.EXPECT().GetNetworkByID("net-123", 
gomock.Any()).Return(networkResp, 1, nil),
+                       
mockNetworkACL.EXPECT().GetNetworkACLListByID("acl-456", 
gomock.Any()).Return(aclListResp, 1, nil),
                )
 
                lb := &loadBalancer{
@@ -2951,7 +2951,7 @@ func TestUpdateNetworkACL(t *testing.T) {
                mockNetwork := cloudstack.NewMockNetworkServiceIface(ctrl)
                apiErr := fmt.Errorf("network API error")
 
-               mockNetwork.EXPECT().GetNetworkByID("net-123").Return(nil, 1, 
apiErr)
+               mockNetwork.EXPECT().GetNetworkByID("net-123", 
gomock.Any()).Return(nil, 1, apiErr)
 
                lb := &loadBalancer{
                        CloudStackClient: &cloudstack.CloudStackClient{
@@ -2983,8 +2983,8 @@ func TestUpdateNetworkACL(t *testing.T) {
                apiErr := fmt.Errorf("ACL list API error")
 
                gomock.InOrder(
-                       
mockNetwork.EXPECT().GetNetworkByID("net-123").Return(networkResp, 1, nil),
-                       
mockNetworkACL.EXPECT().GetNetworkACLListByID("acl-456").Return(nil, 0, apiErr),
+                       mockNetwork.EXPECT().GetNetworkByID("net-123", 
gomock.Any()).Return(networkResp, 1, nil),
+                       
mockNetworkACL.EXPECT().GetNetworkACLListByID("acl-456", 
gomock.Any()).Return(nil, 0, apiErr),
                )
 
                lb := &loadBalancer{
@@ -3024,8 +3024,8 @@ func TestUpdateNetworkACL(t *testing.T) {
                apiErr := fmt.Errorf("list ACL API error")
 
                gomock.InOrder(
-                       
mockNetwork.EXPECT().GetNetworkByID("net-123").Return(networkResp, 1, nil),
-                       
mockNetworkACL.EXPECT().GetNetworkACLListByID("acl-456").Return(aclListResp, 1, 
nil),
+                       mockNetwork.EXPECT().GetNetworkByID("net-123", 
gomock.Any()).Return(networkResp, 1, nil),
+                       
mockNetworkACL.EXPECT().GetNetworkACLListByID("acl-456", 
gomock.Any()).Return(aclListResp, 1, nil),
                        
mockNetworkACL.EXPECT().NewListNetworkACLsParams().Return(listParams),
                        
mockNetworkACL.EXPECT().ListNetworkACLs(gomock.Any()).Return(nil, apiErr),
                )
@@ -3073,8 +3073,8 @@ func TestUpdateNetworkACL(t *testing.T) {
                apiErr := fmt.Errorf("create ACL API error")
 
                gomock.InOrder(
-                       
mockNetwork.EXPECT().GetNetworkByID("net-123").Return(networkResp, 1, nil),
-                       
mockNetworkACL.EXPECT().GetNetworkACLListByID("acl-456").Return(aclListResp, 1, 
nil),
+                       mockNetwork.EXPECT().GetNetworkByID("net-123", 
gomock.Any()).Return(networkResp, 1, nil),
+                       
mockNetworkACL.EXPECT().GetNetworkACLListByID("acl-456", 
gomock.Any()).Return(aclListResp, 1, nil),
                        
mockNetworkACL.EXPECT().NewListNetworkACLsParams().Return(listParams),
                        
mockNetworkACL.EXPECT().ListNetworkACLs(gomock.Any()).Return(listResp, nil),
                        
mockNetworkACL.EXPECT().NewCreateNetworkACLParams("tcp").Return(createParams),
@@ -3407,8 +3407,8 @@ func TestGetNetworkIDFromIPAddress(t *testing.T) {
                }
 
                gomock.InOrder(
-                       
mockAddress.EXPECT().GetPublicIpAddressByID("ip-123").Return(ipResp, 1, nil),
-                       
mockNetwork.EXPECT().GetNetworkByID("net-123").Return(networkResp, 1, nil),
+                       mockAddress.EXPECT().GetPublicIpAddressByID("ip-123", 
gomock.Any()).Return(ipResp, 1, nil),
+                       mockNetwork.EXPECT().GetNetworkByID("net-123", 
gomock.Any()).Return(networkResp, 1, nil),
                )
 
                cs := &CSCloud{
@@ -3434,7 +3434,7 @@ func TestGetNetworkIDFromIPAddress(t *testing.T) {
                mockAddress := cloudstack.NewMockAddressServiceIface(ctrl)
                apiErr := fmt.Errorf("IP not found")
 
-               
mockAddress.EXPECT().GetPublicIpAddressByID("ip-123").Return(nil, 0, apiErr)
+               mockAddress.EXPECT().GetPublicIpAddressByID("ip-123", 
gomock.Any()).Return(nil, 0, apiErr)
 
                cs := &CSCloud{
                        client: &cloudstack.CloudStackClient{
diff --git a/docs/development.md b/docs/development.md
new file mode 100644
index 00000000..b2dc1825
--- /dev/null
+++ b/docs/development.md
@@ -0,0 +1,366 @@
+<!--
+Licensed to the Apache Software Foundation (ASF) under one
+or more contributor license agreements.  See the NOTICE file
+distributed with this work for additional information
+regarding copyright ownership.  The ASF licenses this file
+to you under the Apache License, Version 2.0 (the
+"License"); you may not use this file except in compliance
+with the License.  You may obtain a copy of the License at
+
+  http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing,
+software distributed under the License is distributed on an
+"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+KIND, either express or implied.  See the License for the
+specific language governing permissions and limitations
+under the License.
+-->
+
+# Development
+
+This document describes how to run a fully simulated development and test
+environment for the CloudStack Kubernetes Provider: a real Kubernetes API
+server, a real CloudStack management server and the CCM itself, all in
+containers on your workstation. Nothing is mocked — the CCM makes genuine
+CloudStack API calls and the resulting load balancer rules, public IPs and
+firewall/ACL rules are real database objects you can inspect.
+
+The pieces are:
+
+| Component | What provides it |
+| --- | --- |
+| Kubernetes API server + kubelets | a [kind](https://kind.sigs.k8s.io/) 
cluster |
+| CloudStack management server | the 
[`apache/cloudstack-simulator`](https://hub.docker.com/r/apache/cloudstack-simulator)
 container |
+| Cloud controller manager | this repository, either in-cluster or as a host 
process |
+
+## Prerequisites
+
+* Docker
+* [kind](https://kind.sigs.k8s.io/) v0.30 or later
+* `kubectl`
+* Go 1.23 or later
+* `jq` and `curl`
+* About 12 GB of free disk and 8 GB of RAM
+
+> **linux/amd64 only.** `apache/cloudstack-simulator` is not published for
+> arm64. On Apple Silicon you can run it under emulation with
+> `--platform linux/amd64` (expect the simulator to take three to five times
+> longer to start), point the environment at a simulator running on an x86
+> host, or build an arm64 image yourself from `tools/docker/` in the
+> [apache/cloudstack](https://github.com/apache/cloudstack) repository.
+
+## Quickstart
+
+```bash
+make e2e-up          # simulator + zone + kind cluster + CloudStack VMs + CCM
+make test-e2e        # run the end-to-end suite
+make e2e-down        # tear everything down
+```
+
+`make e2e-up` takes about seven minutes once the simulator image is pulled —
+roughly 90 seconds for the simulator to start, two and a half minutes to
+deploy the zone, and the rest for the kind cluster, the VMs and the CCM. The
+first run also has to pull a ~2 GB image.
+
+Once it is up, try the thing the CCM exists for:
+
+```bash
+export KUBECONFIG=hack/e2e/_out/kubeconfig
+kubectl create deployment web --image=nginx
+kubectl expose deployment web --port=80 --type=LoadBalancer
+kubectl get svc web -w
+```
+
+The service gets an `EXTERNAL-IP` from the simulator's public IP range
+(`192.168.2.0/24`), and the corresponding rule shows up in CloudStack:
+
+```bash
+curl -s 
"http://localhost:8080/client/api?command=listLoadBalancerRules&response=json...";
 | jq
+```
+
+The CloudStack UI is also available: run the simulator with `-p 8081:5050`
+and open <http://localhost:8081/>, logging in as `admin` / `password`.
+
+## What the scripts do
+
+`hack/e2e/up.sh` chains four numbered scripts. Each is independently runnable
+and safe to re-run. All tunables live in `hack/e2e/env.sh` and can be
+overridden from the environment.
+
+### 1. `10-simulator-up.sh` — simulator and zone
+
+Creates a docker bridge network (`cs-ccm-e2e`, `172.30.0.0/24`) that both the
+simulator and the kind nodes will join, then starts the simulator on it:
+
+```bash
+docker network create --subnet 172.30.0.0/24 cs-ccm-e2e
+docker run -d --name cloudstack-simulator --network cs-ccm-e2e \
+    -p 127.0.0.1:8080:8080 apache/cloudstack-simulator:4.22.1.0
+```
+
+The image exposes three ports and it matters which one you use:
+
+| Port | What it is |
+| --- | --- |
+| **8080** | the management server API (`/client/api`) — **use this one** |
+| 8096 | the unauthenticated integration API, used by marvin |
+| 5050 | the Vue UI development server, which proxies to 8080 |
+
+The upstream simulator README suggests `-p 8080:5050`, which publishes the
+*UI*. For API access, publish container port 8080 directly.
+
+Readiness is checked in three stages rather than with a fixed sleep: jetty
+answering at all, then a successful `login`, then 
`listManagementServersMetrics`
+returning a server. The last one matters because the CCM makes exactly that
+call on startup and refuses to run until it succeeds.
+
+The zone is then deployed with marvin, which is preinstalled in the image:
+
+```bash
+docker exec cloudstack-simulator python3 \
+    /root/tools/marvin/marvin/deployDataCenter.py -i 
/root/setup/dev/advanced.cfg
+```
+
+This creates the `Sandbox-simulator` advanced zone with a public IP range of
+`192.168.2.2`–`192.168.2.200`.
+
+Finally the script mints admin API keys. There is no signing involved: it logs
+in with `admin`/`password`, which returns a `sessionkey` and a `JSESSIONID`
+cookie, and then calls `getUserKeys`. Note that the sessionkey must be sent
+both as a cookie *and* as a request parameter, and that `listUsers` is not a
+substitute — it returns the API key but never the secret. `registerUserKeys`
+is used only when no key pair exists yet, because it *rotates* the keys.
+
+Keys land in `hack/e2e/_out/keys.env`.
+
+### 2. `20-kind-up.sh` — the Kubernetes cluster
+
+```bash
+KIND_EXPERIMENTAL_DOCKER_NETWORK=cs-ccm-e2e kind create cluster \
+    --name cs-ccm-e2e --config hack/e2e/kind-config.yaml
+```
+
+The cluster config does two important things:
+
+* `cloud-provider: external` in every node's `kubeletExtraArgs`, so nodes
+  register with the `node.cloudprovider.kubernetes.io/uninitialized` taint.
+  Removing that taint is the CCM's job, and is how you know it works.
+* `kubelet-preferred-address-types: InternalIP` on the API server. Once the
+  CCM initializes a node it sets the node's Hostname address to the CloudStack
+  instance's hostname, which for the simulator is the simulated hypervisor
+  agent and is not resolvable. Without this setting, `kubectl logs` and
+  `kubectl exec` stop working after node initialization.
+
+The cluster is named so that node names are deterministic:
+`cs-ccm-e2e-control-plane`, `cs-ccm-e2e-worker`, `cs-ccm-e2e-worker2`. Two
+workers exist so tests can check that the control plane node — which kubeadm
+labels `node.kubernetes.io/exclude-from-external-load-balancers` — is left out
+of load balancer membership.
+
+The script records each node's IP on the shared docker network into
+`hack/e2e/_out/node-ips`. The next step depends on it.
+
+> CoreDNS stays `Pending` until the CCM removes the uninitialized taint. That
+> is expected; don't wait for it.
+
+### 3. `30-topology-isolated.sh` — matching CloudStack VMs
+
+**This is the part that makes or breaks the environment.** The CCM looks up
+each Kubernetes node by name in CloudStack, so a VM must exist whose name
+exactly matches the node name. On top of that, kind starts kubelet with
+`--node-ip=<the node's docker IP>`, and the CCM's node controller refuses to
+initialize a node whose kubelet-reported IP is not among the addresses the
+cloud provider reports for it. So the VMs must also carry the *same IP
+addresses* as the kind node containers.
+
+The script therefore aligns the zone's guest CIDR with the docker subnet,
+creates an isolated network on it, and deploys one VM per node pinned to that
+node's IP:
+
+```bash
+cs updateZone id=$ZONE guestcidraddress=172.30.0.0/24
+cs createNetwork name=ccm-e2e-iso networkofferingid=$OFFERING \
+    gateway=172.30.0.1 netmask=255.255.255.0 zoneid=$ZONE
+cs deployVirtualMachine name=cs-ccm-e2e-worker displayname=cs-ccm-e2e-worker \
+    ipaddress=172.30.0.4 networkids=$NET ...
+```
+
+The offering used is `DefaultIsolatedNetworkOfferingWithSourceNatService`,
+which provides the **Firewall** service — so on this network the CCM manages
+firewall rules. (The VPC scenario below uses an offering that provides
+**NetworkACL** instead, exercising the other branch.)
+
+All offerings and templates are looked up by name, because their UUIDs differ
+between simulator deployments.
+
+### 4. `40-ccm-deploy.sh` — the controller
+
+Generates two `cloud-config` files that differ **only in `api-url`**:
+
+* `hack/e2e/_out/cloud-config` — used by the in-cluster deployment, pointing
+  at the simulator's IP on the `cs-ccm-e2e` docker network.
+* `hack/e2e/_out/cloud-config-host` — used when you run the CCM as a host
+  process, pointing at `http://localhost:8080/client/api`.
+
+The in-cluster config must use the simulator's **IP address**, not its
+container name or network alias: pods have their own network namespace and
+cannot reach Docker's embedded DNS resolver, and `host.docker.internal` does
+not exist on Linux Docker Engine.
+
+Both configs set `zone` explicitly. If `zone` is empty the CCM tries to
+detect it by looking up its own pod, which cannot work when running as a host
+process.
+
+The script then loads the image into kind, applies the repository's
+[`deployment.yaml`](../deployment.yaml) and patches it for testing: the local
+image with `imagePullPolicy: Never`, `--leader-elect=false` (single replica,
+faster startup), `--v=4` for useful logs, and higher CPU limits — the stock
+manifest's `limits.cpu: 50m` throttles informer startup badly on shared CI
+runners.
+
+Finally it waits for every node to lose the uninitialized taint.
+
+## Running the CCM as a host process
+
+For interactive development and debugging, skip step 4 and run the binary
+directly against the same environment:
+
+```bash
+make
+./cloudstack-ccm \
+    --cloud-provider=external-cloudstack \
+    --cloud-config=hack/e2e/_out/cloud-config-host \
+    --kubeconfig=hack/e2e/_out/kubeconfig \
+    --leader-elect=false \
+    --v=4
+```
+
+If the in-cluster CCM is already running, scale it down first so the two do
+not fight over the same services:
+
+```bash
+kubectl -n kube-system scale deployment/cloud-controller-manager --replicas=0
+```
+
+To debug it in VS Code with the [Go
+extension](https://marketplace.visualstudio.com/items?itemName=golang.go), add
+this to `.vscode/launch.json`:
+
+```json
+{
+    "version": "0.2.0",
+    "configurations": [
+        {
+            "name": "Launch CloudStack CCM",
+            "type": "go",
+            "request": "launch",
+            "mode": "auto",
+            "program": "${workspaceFolder}/cmd/cloudstack-ccm",
+            "args": [
+                "--cloud-provider=external-cloudstack",
+                
"--cloud-config=${workspaceFolder}/hack/e2e/_out/cloud-config-host",
+                "--kubeconfig=${workspaceFolder}/hack/e2e/_out/kubeconfig",
+                "--leader-elect=false",
+                "--v=4"
+            ],
+            "showLog": true
+        }
+    ]
+}
+```
+
+## The VPC scenario
+
+`hack/e2e/50-topology-vpc.sh` switches the environment to a VPC network so the
+Network ACL code path can be exercised. It creates a VPC, a **custom** ACL 
list,
+a tier network and per-node VMs, then re-points the CCM at them and restarts 
it.
+
+Two details are worth knowing:
+
+* The ACL list must be a custom one. The CCM deliberately refuses to add rules
+  to the built-in `default_allow` and `default_deny` lists.
+* Everything is created inside a **CloudStack project**. The CCM matches VM
+  names across the whole account, and fails with `found hosts that belong to
+  different networks` if the matched VMs are spread over several networks.
+  Because CloudStack hides project resources from non-project queries and vice
+  versa, putting the VPC VMs in a project makes the two scenarios mutually
+  invisible without needing a second cluster or a second account.
+
+The tier reuses the same subnet as the isolated network, so the VMs keep the
+same IP addresses and node initialization continues to work after the switch.
+
+## Running the tests
+
+Unit tests need nothing but Go:
+
+```bash
+make test
+```
+
+The end-to-end suite needs the environment above. It is behind the `e2e` build
+tag, so it never runs as part of `make test` or `go build ./...`:
+
+```bash
+make test-e2e
+```
+
+Configuration comes from the environment, using the same variable names as the
+existing opt-in acceptance tests in `cloudstack_test.go`:
+
+| Variable | Meaning |
+| --- | --- |
+| `KUBECONFIG` | cluster under test |
+| `CS_API_URL` | CloudStack API endpoint as reachable from the test process |
+| `CS_API_KEY`, `CS_SECRET_KEY` | CloudStack credentials |
+| `CS_PROJECT_ID` | optional; set during the VPC phase |
+
+When any of them is missing the tests skip rather than fail. The same suite
+runs against a real CloudStack installation — just point the variables at it.
+
+Each test creates its own namespace and cleans up after itself. Because load
+balancer provisioning is asynchronous, all assertions poll rather than
+assuming immediate consistency.
+
+### Known limitation: provider IDs
+
+kind starts kubelet with `--provider-id=kind://docker/<cluster>/<node>`, and
+Kubernetes only allows a node's provider ID to be set once. In this
+environment the CCM therefore never assigns the
+`external-cloudstack://<instance UUID>` provider ID it would set on a real
+cluster. `TestNode_ProviderID` detects this, logs the value it *would* have
+assigned, and reports itself as skipped, so the gap stays visible instead of
+quietly passing. Everything else about node initialization — taint removal,
+labels, addresses — is exercised normally.
+
+## Continuous integration
+
+[`.github/workflows/e2e-simulator.yml`](../.github/workflows/e2e-simulator.yml)
+runs this environment on every pull request and every push to `main`, as a
+matrix of the latest two Kubernetes versions against the latest two CloudStack
+releases. The CloudStack axis is not only version coverage: CloudStack 4.22
+and later update a load balancer rule's CIDR list in place, while earlier
+versions delete and recreate the rule, so both branches get tested.
+
+All matrix cells run in parallel and a shared build job compiles the CCM image
+once, so the whole workflow takes about as long as a single run — roughly
+fifteen minutes, most of it the simulator image pull and zone deployment.
+
+To change the versions under test, edit the `k8s` and `acs` lists in the
+matrix. Both use explicit patch-level tags
+([`kindest/node`](https://hub.docker.com/r/kindest/node/tags) and
+[`apache/cloudstack-simulator`](https://hub.docker.com/r/apache/cloudstack-simulator/tags)),
+so a run is reproducible; avoid floating tags like `latest`.
+
+## Troubleshooting
+
+| Symptom | Cause |
+| --- | --- |
+| CCM exits with `no management servers found` | The account cannot call 
`listManagementServersMetrics`. This is a root-admin API; the default `User` 
role does not include it. |
+| Nodes keep the uninitialized taint; CCM logs `provided node ip for node 
"..." is not valid` | The CloudStack VM's NIC IP does not match the IP kubelet 
registered with. Recreate the VM with `ipaddress=` set to the kind node's 
docker IP. |
+| Services stay `<pending>`; CCM logs `none of the hosts matched the list of 
VMs retrieved from CS API` | No CloudStack VM has a name matching a Kubernetes 
node name. |
+| CCM logs `found hosts that belong to different networks` | VMs matching the 
node names exist on more than one network — typically leftovers from a previous 
scenario. |
+| No ACL rules are created on a VPC network | The tier uses `default_allow` or 
`default_deny`. The CCM only manages rules on custom ACL lists. |
+| CoreDNS stuck `Pending` | Expected until the CCM removes the uninitialized 
taint. If it persists, the CCM is not working — check its logs. |
+| `kubectl logs`/`exec` fail after nodes initialize | The API server is 
preferring the Hostname address, which the CCM set to the CloudStack instance 
hostname. Use `kubelet-preferred-address-types: InternalIP` as the provided 
kind config does. |
+| Simulator never becomes ready | It runs `mvn jetty:run` and fetches from 
Maven Central at startup. Check `docker logs cloudstack-simulator`. |
diff --git a/hack/e2e/10-simulator-up.sh b/hack/e2e/10-simulator-up.sh
new file mode 100755
index 00000000..e475f8a4
--- /dev/null
+++ b/hack/e2e/10-simulator-up.sh
@@ -0,0 +1,111 @@
+#!/usr/bin/env bash
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+# Starts the CloudStack simulator, waits for it to be usable, deploys the
+# advanced zone and mints admin API keys into _out/keys.env.
+
+set -euo pipefail
+source "$(dirname "${BASH_SOURCE[0]}")/env.sh"
+source "${E2E_ROOT}/lib/log.sh"
+source "${E2E_ROOT}/lib/cs.sh"
+
+# --- docker network shared with kind -----------------------------------------
+if ! docker network inspect "$E2E_NET" >/dev/null 2>&1; then
+    log "creating docker network ${E2E_NET} (${E2E_SUBNET})"
+    docker network create --driver bridge --subnet "$E2E_SUBNET" --gateway 
"$E2E_GW" "$E2E_NET"
+fi
+
+# --- simulator container 
------------------------------------------------------
+if ! docker inspect "$SIM_NAME" >/dev/null 2>&1; then
+    log "starting simulator ${SIM_IMAGE} as ${SIM_NAME}"
+    # Container port 8080 is the management API (jetty); 5050 is only the UI
+    # dev server, which proxies to it.
+    docker run -d --name "$SIM_NAME" \
+        --network "$E2E_NET" --network-alias cloudstack-simulator \
+        -p "127.0.0.1:${SIM_HOST_PORT}:8080" \
+        "$SIM_IMAGE"
+else
+    log "simulator container ${SIM_NAME} already exists, reusing it"
+fi
+
+# --- staged readiness 
---------------------------------------------------------
+jetty_up() {
+    local code
+    code="$(curl -s -o /dev/null -w '%{http_code}' -m 5 \
+        "${CS_API_URL}?command=listCapabilities&response=json")"
+    [[ "$code" == "401" || "$code" == "200" ]]
+}
+
+mgmt_server_up() {
+    local count
+    count="$(cs listManagementServersMetrics | jq -r 
'.listmanagementserversmetricsresponse.count // 0')"
+    [[ "$count" -ge 1 ]]
+}
+
+wait_for 600 5 "jetty answering ${CS_API_URL}" jetty_up
+wait_for 300 5 "CloudStack login as ${CS_ADMIN_USER}" cs_login
+# The CCM calls listManagementServersMetrics at startup and refuses to run
+# until it succeeds, so gate on the exact same call.
+wait_for 300 5 "management server registered" mgmt_server_up
+
+# --- zone 
---------------------------------------------------------------------
+zone_enabled() {
+    local state
+    state="$(cs listZones "name=${ZONE_NAME}" | jq -r 
'.listzonesresponse.zone[0].allocationstate // empty')"
+    [[ "$state" == "Enabled" ]]
+}
+
+host_up() {
+    local count
+    count="$(cs listHosts type=Routing state=Up | jq -r 
'.listhostsresponse.count // 0')"
+    [[ "$count" -ge 1 ]]
+}
+
+if zone_enabled; then
+    log "zone ${ZONE_NAME} already deployed"
+else
+    log "deploying zone ${ZONE_NAME} (this takes a few minutes)"
+    docker exec "$SIM_NAME" python3 
/root/tools/marvin/marvin/deployDataCenter.py \
+        -i /root/setup/dev/advanced.cfg
+fi
+wait_for 600 10 "zone ${ZONE_NAME} enabled" zone_enabled
+wait_for 300 10 "at least one routing host up" host_up
+
+# --- admin API keys 
-----------------------------------------------------------
+admin_user_id="$(cs listUsers "username=${CS_ADMIN_USER}" | jq -r 
'.listusersresponse.user[0].id')"
+[[ -n "$admin_user_id" && "$admin_user_id" != "null" ]] || die "could not find 
user ${CS_ADMIN_USER}"
+
+# getUserKeys first: registerUserKeys would rotate (invalidate) an existing
+# pair, which is unfriendly to a long-lived local simulator.
+keys="$(cs getUserKeys "id=${admin_user_id}")"
+api_key="$(jq -r '.getuserkeysresponse.userkeys.apikey // empty' <<<"$keys")"
+secret_key="$(jq -r '.getuserkeysresponse.userkeys.secretkey // empty' 
<<<"$keys")"
+if [[ -z "$api_key" || -z "$secret_key" ]]; then
+    log "no existing keys, registering new ones"
+    keys="$(cs registerUserKeys "id=${admin_user_id}")"
+    api_key="$(jq -r '.registeruserkeysresponse.userkeys.apikey' <<<"$keys")"
+    secret_key="$(jq -r '.registeruserkeysresponse.userkeys.secretkey' 
<<<"$keys")"
+fi
+[[ -n "$api_key" && -n "$secret_key" ]] || die "failed to obtain admin API 
keys"
+
+cat >"${E2E_OUT}/keys.env" <<EOF
+export CS_API_KEY='${api_key}'
+export CS_SECRET_KEY='${secret_key}'
+EOF
+chmod 600 "${E2E_OUT}/keys.env"
+log "simulator ready; admin API keys written to ${E2E_OUT}/keys.env"
diff --git a/hack/e2e/20-kind-up.sh b/hack/e2e/20-kind-up.sh
new file mode 100755
index 00000000..0b1bace9
--- /dev/null
+++ b/hack/e2e/20-kind-up.sh
@@ -0,0 +1,52 @@
+#!/usr/bin/env bash
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+# Creates the kind cluster on the shared docker network and records the
+# node-name -> docker-IP map that the CloudStack VMs must reproduce.
+
+set -euo pipefail
+source "$(dirname "${BASH_SOURCE[0]}")/env.sh"
+source "${E2E_ROOT}/lib/log.sh"
+
+if kind get clusters 2>/dev/null | grep -qx "$KIND_CLUSTER"; then
+    log "kind cluster ${KIND_CLUSTER} already exists, reusing it"
+else
+    log "creating kind cluster ${KIND_CLUSTER} (image ${KIND_NODE_IMAGE}) on 
network ${E2E_NET}"
+    KIND_EXPERIMENTAL_DOCKER_NETWORK="$E2E_NET" kind create cluster \
+        --name "$KIND_CLUSTER" \
+        --config "${E2E_ROOT}/kind-config.yaml" \
+        --image "$KIND_NODE_IMAGE" \
+        --wait 180s
+fi
+
+kind get kubeconfig --name "$KIND_CLUSTER" >"${E2E_OUT}/kubeconfig"
+chmod 600 "${E2E_OUT}/kubeconfig"
+
+# The CCM only initializes a node when the CloudStack VM's NIC IP matches the
+# IP kubelet registered with (kind passes --node-ip). Record each node's IP on
+# the shared network so 30-topology-* can pin the VMs to them.
+: >"${E2E_OUT}/node-ips"
+for node in $(kind get nodes --name "$KIND_CLUSTER"); do
+    ip="$(docker inspect -f "{{(index .NetworkSettings.Networks 
\"${E2E_NET}\").IPAddress}}" "$node")"
+    [[ -n "$ip" ]] || die "could not determine IP of ${node} on ${E2E_NET}"
+    echo "${node} ${ip}" >>"${E2E_OUT}/node-ips"
+done
+log "node IPs:"
+cat "${E2E_OUT}/node-ips" >&2
+
+log "kind cluster ready; kubeconfig at ${E2E_OUT}/kubeconfig"
diff --git a/hack/e2e/30-topology-isolated.sh b/hack/e2e/30-topology-isolated.sh
new file mode 100755
index 00000000..6a560bcd
--- /dev/null
+++ b/hack/e2e/30-topology-isolated.sh
@@ -0,0 +1,87 @@
+#!/usr/bin/env bash
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+# Creates an isolated guest network matching the kind docker subnet and
+# deploys one CloudStack VM per kind node, pinned to the node's docker IP.
+
+set -euo pipefail
+source "$(dirname "${BASH_SOURCE[0]}")/env.sh"
+source "${E2E_ROOT}/lib/log.sh"
+source "${E2E_ROOT}/lib/cs.sh"
+
+[[ -s "${E2E_OUT}/node-ips" ]] || die "missing ${E2E_OUT}/node-ips — run 
20-kind-up.sh first"
+cs_login || die "CloudStack login failed"
+
+zone_id="$(cs listZones "name=${ZONE_NAME}" | jq -r 
'.listzonesresponse.zone[0].id')"
+[[ -n "$zone_id" && "$zone_id" != "null" ]] || die "zone ${ZONE_NAME} not 
found"
+
+# The stock zone guest CIDR is 10.1.1.0/24; align it with the docker subnet so
+# createNetwork accepts our gateway/netmask.
+cs updateZone "id=${zone_id}" "guestcidraddress=${E2E_SUBNET}" >/dev/null
+
+net_id="$(cs listNetworks "keyword=${E2E_ISO_NETWORK}" listall=true |
+    jq -r --arg n "$E2E_ISO_NETWORK" '.listnetworksresponse.network[]? | 
select(.name == $n) | .id')"
+if [[ -z "$net_id" ]]; then
+    offering_id="$(cs listNetworkOfferings 
name=DefaultIsolatedNetworkOfferingWithSourceNatService state=Enabled |
+        jq -r '.listnetworkofferingsresponse.networkoffering[0].id')"
+    [[ -n "$offering_id" && "$offering_id" != "null" ]] || die "isolated 
network offering not found"
+    log "creating isolated network ${E2E_ISO_NETWORK} (${E2E_SUBNET})"
+    net_id="$(cs_async createNetwork "name=${E2E_ISO_NETWORK}" 
"displaytext=${E2E_ISO_NETWORK}" \
+        "zoneid=${zone_id}" "networkofferingid=${offering_id}" \
+        "gateway=${E2E_GW}" "netmask=${E2E_NETMASK}" |
+        jq -r '.network.id // .id')"
+fi
+[[ -n "$net_id" && "$net_id" != "null" ]] || die "failed to create network 
${E2E_ISO_NETWORK}"
+
+offering_id="$(cs listServiceOfferings "name=${E2E_SERVICE_OFFERING}" |
+    jq -r '.listserviceofferingsresponse.serviceoffering[0].id')"
+# templatefilter=executable excludes the SYSTEM (router) template, which
+# cannot be used to deploy user VMs.
+template_id="$(cs listTemplates templatefilter=executable "zoneid=${zone_id}" 
hypervisor=Simulator |
+    jq -r '.listtemplatesresponse.template[]? | select(.isready == true) | 
.id' | head -1)"
+[[ -n "$offering_id" && "$offering_id" != "null" ]] || die "service offering 
'${E2E_SERVICE_OFFERING}' not found"
+[[ -n "$template_id" ]] || die "no ready simulator template found"
+
+while read -r node ip; do
+    existing="$(cs listVirtualMachines "keyword=${node}" listall=true |
+        jq -r --arg n "$node" '.listvirtualmachinesresponse.virtualmachine[]? 
| select(.name == $n) | .id')"
+    if [[ -n "$existing" ]]; then
+        log "VM ${node} already exists"
+        continue
+    fi
+    log "deploying VM ${node} with IP ${ip}"
+    cs_async deployVirtualMachine "name=${node}" "displayname=${node}" \
+        "zoneid=${zone_id}" "serviceofferingid=${offering_id}" 
"templateid=${template_id}" \
+        "networkids=${net_id}" "ipaddress=${ip}" "startvm=true" >/dev/null
+done <"${E2E_OUT}/node-ips"
+
+# Post-condition: every node must now have a matching VM on the right IP, or
+# the CCM will never initialize that node.
+while read -r node ip; do
+    vm_ip="$(cs listVirtualMachines "keyword=${node}" listall=true |
+        jq -r --arg n "$node" '.listvirtualmachinesresponse.virtualmachine[]? 
| select(.name == $n) | .nic[0].ipaddress')"
+    [[ "$vm_ip" == "$ip" ]] || die "VM ${node} has IP '${vm_ip}', expected 
${ip}"
+done <"${E2E_OUT}/node-ips"
+
+cat >"${E2E_OUT}/ids.env" <<EOF
+export E2E_ZONE_ID='${zone_id}'
+export E2E_ISO_NETWORK_ID='${net_id}'
+export E2E_SERVICE_OFFERING_ID='${offering_id}'
+export E2E_TEMPLATE_ID='${template_id}'
+EOF
+log "isolated topology ready (network ${net_id})"
diff --git a/hack/e2e/40-ccm-deploy.sh b/hack/e2e/40-ccm-deploy.sh
new file mode 100755
index 00000000..71f39ab0
--- /dev/null
+++ b/hack/e2e/40-ccm-deploy.sh
@@ -0,0 +1,93 @@
+#!/usr/bin/env bash
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+# Generates cloud-config files, loads the CCM image into kind, deploys
+# deployment.yaml and waits until all nodes are initialized.
+
+set -euo pipefail
+source "$(dirname "${BASH_SOURCE[0]}")/env.sh"
+source "${E2E_ROOT}/lib/log.sh"
+
+# shellcheck source=/dev/null
+source "${E2E_OUT}/keys.env" 2>/dev/null || die "missing ${E2E_OUT}/keys.env — 
run 10-simulator-up.sh first"
+
+# The CCM pod must reach the simulator via its IP on the shared docker
+# network: pods cannot resolve docker's embedded DNS, and host.docker.internal
+# does not exist on Linux.
+sim_ip="$(docker inspect -f "{{(index .NetworkSettings.Networks 
\"${E2E_NET}\").IPAddress}}" "$SIM_NAME")"
+[[ -n "$sim_ip" ]] || die "could not determine simulator IP on ${E2E_NET}"
+
+# PROJECT_ID is optional; 50-topology-vpc.sh re-runs this script with it set.
+project_line=""
+if [[ -n "${E2E_PROJECT_ID:-}" ]]; then
+    project_line="project-id = ${E2E_PROJECT_ID}"
+fi
+
+# In-cluster and host-process configs differ only in api-url.
+cat >"${E2E_OUT}/cloud-config" <<EOF
+[Global]
+api-url    = http://${sim_ip}:8080/client/api
+api-key    = ${CS_API_KEY}
+secret-key = ${CS_SECRET_KEY}
+zone       = ${ZONE_NAME}
+region     = ${E2E_REGION}
+${project_line}
+EOF
+sed "s|http://${sim_ip}:8080|http://localhost:${SIM_HOST_PORT}|" \
+    "${E2E_OUT}/cloud-config" >"${E2E_OUT}/cloud-config-host"
+chmod 600 "${E2E_OUT}/cloud-config" "${E2E_OUT}/cloud-config-host"
+
+# Build the image if it is not present (CI loads a prebuilt artifact instead).
+if ! docker image inspect "$CCM_IMAGE" >/dev/null 2>&1; then
+    log "building ${CCM_IMAGE}"
+    docker build -t "$CCM_IMAGE" "$REPO_ROOT"
+fi
+kind load docker-image "$CCM_IMAGE" --name "$KIND_CLUSTER"
+
+kubectl -n kube-system create secret generic cloudstack-secret \
+    --from-file=cloud-config="${E2E_OUT}/cloud-config" \
+    --dry-run=client -o yaml | kubectl apply -f -
+
+kubectl apply -f "${REPO_ROOT}/deployment.yaml"
+# Adjust the stock manifest for e2e: local image, no leader election (single
+# replica, faster startup), verbose logs, and enough CPU that informer startup
+# is not throttled on shared runners.
+kubectl -n kube-system patch deployment cloud-controller-manager --type=json 
-p '[
+  
{"op":"replace","path":"/spec/template/spec/containers/0/image","value":"'"$CCM_IMAGE"'"},
+  
{"op":"replace","path":"/spec/template/spec/containers/0/imagePullPolicy","value":"Never"},
+  {"op":"replace","path":"/spec/template/spec/containers/0/args","value":[
+     
"--cloud-provider=external-cloudstack","--cloud-config=/config/cloud-config",
+     "--leader-elect=false","--v=4"]},
+  {"op":"replace","path":"/spec/template/spec/containers/0/resources","value":{
+     
"requests":{"cpu":"100m","memory":"128Mi"},"limits":{"cpu":"1","memory":"512Mi"}}}
+]'
+
+kubectl -n kube-system rollout status deployment/cloud-controller-manager 
--timeout=180s
+
+nodes_initialized() {
+    local taints
+    taints="$(kubectl get nodes -o 
jsonpath='{.items[*].spec.taints[?(@.key=="node.cloudprovider.kubernetes.io/uninitialized")].key}')"
+    [[ -z "$taints" ]]
+}
+# If this times out, check the CCM log for
+# 'provided node ip for node ... is not valid': it means the CloudStack VM's
+# NIC IP does not match the kind node's docker IP.
+wait_for 300 5 "all nodes initialized by the CCM" nodes_initialized
+
+log "CCM deployed and all nodes initialized"
+kubectl get nodes -o wide >&2
diff --git a/hack/e2e/50-topology-vpc.sh b/hack/e2e/50-topology-vpc.sh
new file mode 100755
index 00000000..117e4979
--- /dev/null
+++ b/hack/e2e/50-topology-vpc.sh
@@ -0,0 +1,123 @@
+#!/usr/bin/env bash
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+# Phase 2: creates a CloudStack project containing a VPC, a custom ACL list, a
+# tier network and per-node VMs, then re-points the CCM at the project.
+#
+# A project is used so the VPC VMs and the phase-1 isolated-network VMs are
+# mutually invisible: the CCM's verifyHosts matches VM names account-wide and
+# fails when matched VMs are on different networks. With project-id set, only
+# project resources are visible. The tier reuses the docker subnet, so the VMs
+# get the same IPs as phase 1 and node initialization keeps working.
+
+set -euo pipefail
+source "$(dirname "${BASH_SOURCE[0]}")/env.sh"
+source "${E2E_ROOT}/lib/log.sh"
+source "${E2E_ROOT}/lib/cs.sh"
+
+[[ -s "${E2E_OUT}/node-ips" ]] || die "missing ${E2E_OUT}/node-ips — run 
20-kind-up.sh first"
+# shellcheck source=/dev/null
+source "${E2E_OUT}/ids.env" 2>/dev/null || die "missing ${E2E_OUT}/ids.env — 
run 30-topology-isolated.sh first"
+cs_login || die "CloudStack login failed"
+
+# Phase-1 LoadBalancer services must be gone before the CCM switches projects,
+# or their CloudStack resources leak (the project-scoped CCM can't see them).
+leftover="$(kubectl get svc -A -o jsonpath='{range 
.items[?(@.spec.type=="LoadBalancer")]}{.metadata.namespace}/{.metadata.name}{"\n"}{end}')"
+if [[ -n "$leftover" ]]; then
+    log "deleting leftover LoadBalancer services:"
+    echo "$leftover" >&2
+    while IFS=/ read -r ns name; do
+        kubectl -n "$ns" delete svc "$name" --wait=true --timeout=120s
+    done <<<"$leftover"
+fi
+
+project_id="$(cs listProjects listall=true "name=${E2E_PROJECT}" |
+    jq -r '.listprojectsresponse.project[0].id // empty')"
+if [[ -z "$project_id" ]]; then
+    log "creating project ${E2E_PROJECT}"
+    project_id="$(cs_async createProject "name=${E2E_PROJECT}" 
"displaytext=${E2E_PROJECT}" |
+        jq -r '.project.id // .id')"
+fi
+[[ -n "$project_id" && "$project_id" != "null" ]] || die "failed to create 
project"
+
+vpc_id="$(cs listVPCs listall=true "projectid=${project_id}" "name=${E2E_VPC}" 
|
+    jq -r '.listvpcsresponse.vpc[0].id // empty')"
+if [[ -z "$vpc_id" ]]; then
+    vpc_offering_id="$(cs listVPCOfferings "name=Default VPC offering" |
+        jq -r '.listvpcofferingsresponse.vpcoffering[0].id')"
+    log "creating VPC ${E2E_VPC} (${E2E_VPC_CIDR})"
+    vpc_id="$(cs_async createVPC "name=${E2E_VPC}" "displaytext=${E2E_VPC}" \
+        "zoneid=${E2E_ZONE_ID}" "cidr=${E2E_VPC_CIDR}" \
+        "vpcofferingid=${vpc_offering_id}" "projectid=${project_id}" |
+        jq -r '.vpc.id // .id')"
+fi
+[[ -n "$vpc_id" && "$vpc_id" != "null" ]] || die "failed to create VPC"
+
+# A custom ACL list: the CCM refuses to manage rules on the built-in
+# default_allow / default_deny lists.
+acl_id="$(cs listNetworkACLLists "vpcid=${vpc_id}" "name=${E2E_ACL_LIST}" |
+    jq -r '.listnetworkacllistsresponse.networkacllist[0].id // empty')"
+if [[ -z "$acl_id" ]]; then
+    log "creating ACL list ${E2E_ACL_LIST}"
+    acl_id="$(cs_async createNetworkACLList "name=${E2E_ACL_LIST}" \
+        "description=${E2E_ACL_LIST}" "vpcid=${vpc_id}" |
+        jq -r '.networkacllist.id // .id')"
+fi
+[[ -n "$acl_id" && "$acl_id" != "null" ]] || die "failed to create ACL list"
+
+tier_id="$(cs listNetworks listall=true "projectid=${project_id}" 
"keyword=${E2E_TIER}" |
+    jq -r --arg n "$E2E_TIER" '.listnetworksresponse.network[]? | select(.name 
== $n) | .id')"
+if [[ -z "$tier_id" ]]; then
+    tier_offering_id="$(cs listNetworkOfferings 
name=DefaultIsolatedNetworkOfferingForVpcNetworks state=Enabled |
+        jq -r '.listnetworkofferingsresponse.networkoffering[0].id')"
+    log "creating VPC tier ${E2E_TIER} (${E2E_SUBNET})"
+    tier_id="$(cs_async createNetwork "name=${E2E_TIER}" 
"displaytext=${E2E_TIER}" \
+        "zoneid=${E2E_ZONE_ID}" "networkofferingid=${tier_offering_id}" \
+        "vpcid=${vpc_id}" "aclid=${acl_id}" \
+        "gateway=${E2E_GW}" "netmask=${E2E_NETMASK}" "projectid=${project_id}" 
|
+        jq -r '.network.id // .id')"
+fi
+[[ -n "$tier_id" && "$tier_id" != "null" ]] || die "failed to create VPC tier"
+
+while read -r node ip; do
+    existing="$(cs listVirtualMachines listall=true "projectid=${project_id}" 
"keyword=${node}" |
+        jq -r --arg n "$node" '.listvirtualmachinesresponse.virtualmachine[]? 
| select(.name == $n) | .id')"
+    if [[ -n "$existing" ]]; then
+        log "project VM ${node} already exists"
+        continue
+    fi
+    log "deploying project VM ${node} with IP ${ip}"
+    cs_async deployVirtualMachine "name=${node}" "displayname=${node}" \
+        "zoneid=${E2E_ZONE_ID}" "serviceofferingid=${E2E_SERVICE_OFFERING_ID}" 
\
+        "templateid=${E2E_TEMPLATE_ID}" "networkids=${tier_id}" \
+        "ipaddress=${ip}" "projectid=${project_id}" "startvm=true" >/dev/null
+done <"${E2E_OUT}/node-ips"
+
+{
+    echo "export E2E_PROJECT_ID='${project_id}'"
+    echo "export E2E_VPC_ID='${vpc_id}'"
+    echo "export E2E_ACL_ID='${acl_id}'"
+    echo "export E2E_TIER_ID='${tier_id}'"
+} >>"${E2E_OUT}/ids.env"
+
+# Re-point the CCM at the project and restart it.
+E2E_PROJECT_ID="$project_id" "${E2E_ROOT}/40-ccm-deploy.sh"
+kubectl -n kube-system rollout restart deployment/cloud-controller-manager
+kubectl -n kube-system rollout status deployment/cloud-controller-manager 
--timeout=180s
+
+log "VPC topology ready (project ${project_id}, tier ${tier_id}, acl 
${acl_id})"
diff --git a/hack/e2e/90-collect-artifacts.sh b/hack/e2e/90-collect-artifacts.sh
new file mode 100755
index 00000000..99610b17
--- /dev/null
+++ b/hack/e2e/90-collect-artifacts.sh
@@ -0,0 +1,64 @@
+#!/usr/bin/env bash
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+# Collects debugging artifacts from the simulator, the kind cluster and the
+# CloudStack API into _out/artifacts. Never fails.
+
+set -uo pipefail
+source "$(dirname "${BASH_SOURCE[0]}")/env.sh"
+source "${E2E_ROOT}/lib/log.sh"
+source "${E2E_ROOT}/lib/cs.sh"
+
+ART="${E2E_OUT}/artifacts"
+mkdir -p "$ART"
+
+log "collecting artifacts into ${ART}"
+
+docker logs "$SIM_NAME" >"${ART}/simulator.log" 2>&1
+
+# kubectl logs may stop working once the CCM rewrites node addresses, so fall
+# back to reading container logs on the control-plane node directly.
+if ! kubectl -n kube-system logs deployment/cloud-controller-manager --tail=-1 
\
+    >"${ART}/ccm.log" 2>&1; then
+    docker exec "${KIND_CLUSTER}-control-plane" bash -c \
+        'crictl ps -a --name cloud-controller-manager -q | head -1 | xargs -r 
crictl logs' \
+        >"${ART}/ccm.log" 2>&1
+fi
+
+kubectl get nodes -o yaml >"${ART}/nodes.yaml" 2>&1
+kubectl get svc -A -o yaml >"${ART}/services.yaml" 2>&1
+kubectl describe svc -A >"${ART}/svc-describe.txt" 2>&1
+kubectl get events -A --sort-by=.lastTimestamp >"${ART}/events.txt" 2>&1
+kubectl -n kube-system get pods -o wide >"${ART}/kube-system-pods.txt" 2>&1
+
+if cs_login; then
+    # Dump each resource twice: without a project (the isolated-network phase)
+    # and with projectid=-1, which for an admin spans all projects (the VPC
+    # phase). Otherwise the VPC phase's resources are invisible here.
+    for cmd in listLoadBalancerRules listPublicIpAddresses listFirewallRules \
+        listNetworkACLs listVirtualMachines listNetworks; do
+        name="cs-$(echo "$cmd" | tr '[:upper:]' '[:lower:]')"
+        cs "$cmd" listall=true | jq . >"${ART}/${name}.json" 2>&1
+        cs "$cmd" listall=true projectid=-1 | jq . 
>"${ART}/${name}-projects.json" 2>&1
+    done
+fi
+
+kind export logs "${ART}/kind" --name "$KIND_CLUSTER" >/dev/null 2>&1
+
+log "artifacts collected"
+exit 0
diff --git a/hack/e2e/99-down.sh b/hack/e2e/99-down.sh
new file mode 100755
index 00000000..682aa86f
--- /dev/null
+++ b/hack/e2e/99-down.sh
@@ -0,0 +1,38 @@
+#!/usr/bin/env bash
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+# Tears down everything the harness created.
+
+set -uo pipefail
+source "$(dirname "${BASH_SOURCE[0]}")/env.sh"
+source "${E2E_ROOT}/lib/log.sh"
+
+log "deleting kind cluster ${KIND_CLUSTER}"
+kind delete cluster --name "$KIND_CLUSTER" 2>/dev/null
+
+log "removing simulator container ${SIM_NAME}"
+docker rm -f "$SIM_NAME" 2>/dev/null
+
+log "removing docker network ${E2E_NET}"
+docker network rm "$E2E_NET" 2>/dev/null
+
+rm -f "${E2E_OUT}/keys.env" "${E2E_OUT}/cloud-config" 
"${E2E_OUT}/cloud-config-host" \
+    "${E2E_OUT}/kubeconfig" "${E2E_OUT}/node-ips" "${E2E_OUT}/ids.env"
+
+log "done"
+exit 0
diff --git a/hack/e2e/env.sh b/hack/e2e/env.sh
new file mode 100755
index 00000000..bb8f1f2f
--- /dev/null
+++ b/hack/e2e/env.sh
@@ -0,0 +1,65 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+# All tunables for the simulator e2e harness in one place.
+# Every value can be overridden from the environment (CI does this for the
+# simulator tag and the kind node image).
+
+E2E_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+export E2E_ROOT
+export REPO_ROOT="${E2E_ROOT}/../.."
+export E2E_OUT="${E2E_ROOT}/_out"
+
+# Docker network shared by the kind nodes and the simulator. The subnet must
+# match the isolated network created in CloudStack: the CCM refuses to
+# initialize a node whose kubelet-reported IP is missing from the CloudStack
+# VM's NICs, so the VMs are deployed with the kind nodes' docker IPs.
+export E2E_NET="${E2E_NET:-cs-ccm-e2e}"
+export E2E_SUBNET="${E2E_SUBNET:-172.30.0.0/24}"
+export E2E_GW="${E2E_GW:-172.30.0.1}"
+export E2E_NETMASK="${E2E_NETMASK:-255.255.255.0}"
+
+# CloudStack simulator
+export SIM_NAME="${SIM_NAME:-cloudstack-simulator}"
+export SIM_TAG="${SIM_TAG:-4.22.1.0}"
+export SIM_IMAGE="${SIM_IMAGE:-apache/cloudstack-simulator:${SIM_TAG}}"
+export SIM_HOST_PORT="${SIM_HOST_PORT:-8080}"
+export CS_API_URL="${CS_API_URL:-http://localhost:${SIM_HOST_PORT}/client/api}";
+export CS_ADMIN_USER="${CS_ADMIN_USER:-admin}"
+export CS_ADMIN_PASS="${CS_ADMIN_PASS:-password}"
+export ZONE_NAME="${ZONE_NAME:-Sandbox-simulator}"
+export E2E_REGION="${E2E_REGION:-simulator-region}"
+
+# kind
+export KIND_CLUSTER="${KIND_CLUSTER:-cs-ccm-e2e}"
+export KIND_NODE_IMAGE="${KIND_NODE_IMAGE:-kindest/node:v1.37.0}"
+
+# CCM image built from this checkout
+export CCM_IMAGE="${CCM_IMAGE:-apache/cloudstack-kubernetes-provider:e2e}"
+
+# CloudStack names created by the harness
+export E2E_ISO_NETWORK="${E2E_ISO_NETWORK:-ccm-e2e-iso}"
+export E2E_PROJECT="${E2E_PROJECT:-ccm-e2e-vpc}"
+export E2E_VPC="${E2E_VPC:-ccm-e2e-vpc}"
+export E2E_VPC_CIDR="${E2E_VPC_CIDR:-172.30.0.0/22}"
+export E2E_ACL_LIST="${E2E_ACL_LIST:-ccm-e2e-acl}"
+export E2E_TIER="${E2E_TIER:-ccm-e2e-tier}"
+export E2E_SERVICE_OFFERING="${E2E_SERVICE_OFFERING:-Small Instance}"
+
+export KUBECONFIG="${KUBECONFIG:-${E2E_OUT}/kubeconfig}"
+
+mkdir -p "${E2E_OUT}"
diff --git a/hack/e2e/kind-config.yaml b/hack/e2e/kind-config.yaml
new file mode 100644
index 00000000..5c79c241
--- /dev/null
+++ b/hack/e2e/kind-config.yaml
@@ -0,0 +1,62 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+# kind cluster for the CloudStack simulator e2e environment.
+#
+# - `cloud-provider: external` makes every node register with the
+#   node.cloudprovider.kubernetes.io/uninitialized taint, which the CCM under
+#   test is responsible for removing.
+# - `kubelet-preferred-address-types: InternalIP` keeps `kubectl logs`/`exec`
+#   working after node initialization: the CCM sets the node's Hostname
+#   address to the CloudStack instance hostname (the simulated hypervisor
+#   agent), which is not resolvable from the API server.
+# - Two workers so tests can assert that the control-plane node (labeled
+#   node.kubernetes.io/exclude-from-external-load-balancers by kubeadm) is
+#   excluded from load balancer membership.
+kind: Cluster
+apiVersion: kind.x-k8s.io/v1alpha4
+name: cs-ccm-e2e
+networking:
+  ipFamily: ipv4
+  apiServerAddress: "127.0.0.1"
+nodes:
+  - role: control-plane
+    kubeadmConfigPatches:
+      - |
+        kind: InitConfiguration
+        nodeRegistration:
+          kubeletExtraArgs:
+            cloud-provider: external
+      - |
+        kind: ClusterConfiguration
+        apiServer:
+          extraArgs:
+            kubelet-preferred-address-types: InternalIP
+  - role: worker
+    kubeadmConfigPatches:
+      - |
+        kind: JoinConfiguration
+        nodeRegistration:
+          kubeletExtraArgs:
+            cloud-provider: external
+  - role: worker
+    kubeadmConfigPatches:
+      - |
+        kind: JoinConfiguration
+        nodeRegistration:
+          kubeletExtraArgs:
+            cloud-provider: external
diff --git a/hack/e2e/lib/cs.sh b/hack/e2e/lib/cs.sh
new file mode 100644
index 00000000..8c8f6a63
--- /dev/null
+++ b/hack/e2e/lib/cs.sh
@@ -0,0 +1,89 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+# Minimal CloudStack API client for the harness. Uses session (login)
+# authentication so no request signing is needed. The sessionkey must be sent
+# both as a cookie and as a request parameter.
+
+CS_JSESSIONID=""
+CS_SESSIONKEY=""
+
+cs_login() {
+    local out
+    out="$(curl -sS -i -m 30 -X POST "$CS_API_URL" \
+        --data-urlencode "command=login" \
+        --data-urlencode "username=${CS_ADMIN_USER}" \
+        --data-urlencode "password=${CS_ADMIN_PASS}" \
+        --data-urlencode "domain=/" \
+        --data-urlencode "response=json")" || return 1
+    CS_JSESSIONID="$(sed -n 's/.*JSESSIONID=\([^;]*\);.*/\1/p' <<<"$out" | 
head -1)"
+    CS_SESSIONKEY="$(sed -n 's/.*"sessionkey":"\([^"]*\)".*/\1/p' <<<"$out")"
+    [[ -n "$CS_SESSIONKEY" && -n "$CS_JSESSIONID" ]]
+}
+
+# cs <command> [key=value ...] -> JSON response on stdout
+cs() {
+    local cmd="$1"
+    shift
+    local args=(--data-urlencode "command=${cmd}"
+        --data-urlencode "response=json"
+        --data-urlencode "sessionkey=${CS_SESSIONKEY}")
+    local kv
+    for kv in "$@"; do
+        args+=(--data-urlencode "$kv")
+    done
+    curl -sS -G -m 120 \
+        -b "JSESSIONID=${CS_JSESSIONID}; sessionkey=${CS_SESSIONKEY}" \
+        "${args[@]}" "$CS_API_URL"
+}
+
+# cs_async <command> [key=value ...] -> jobresult JSON on stdout.
+# Transparently handles synchronous commands (no jobid in the response).
+cs_async() {
+    local resp jobid result status i errtext
+    resp="$(cs "$@")" || return 1
+    # An API error carries no jobid, so it would otherwise be mistaken for a
+    # synchronous success. Check for it explicitly.
+    errtext="$(jq -r '.[].errortext // empty' <<<"$resp" 2>/dev/null)"
+    if [[ -n "$errtext" ]]; then
+        echo "CloudStack API error from $1: ${errtext}" >&2
+        return 1
+    fi
+    jobid="$(jq -r '.[].jobid // empty' <<<"$resp" 2>/dev/null)"
+    if [[ -z "$jobid" ]]; then
+        # Synchronous command: unwrap the single top-level response object.
+        jq '.[]' <<<"$resp"
+        return 0
+    fi
+    for ((i = 0; i < 120; i++)); do
+        result="$(cs queryAsyncJobResult "jobid=${jobid}")" || return 1
+        status="$(jq -r '.queryasyncjobresultresponse.jobstatus' <<<"$result")"
+        case "$status" in
+        1)
+            jq '.queryasyncjobresultresponse.jobresult' <<<"$result"
+            return 0
+            ;;
+        2)
+            echo "async job ${jobid} failed: $(jq -c 
'.queryasyncjobresultresponse.jobresult' <<<"$result")" >&2
+            return 1
+            ;;
+        esac
+        sleep 5
+    done
+    echo "async job ${jobid} did not finish in time" >&2
+    return 1
+}
diff --git a/hack/e2e/lib/log.sh b/hack/e2e/lib/log.sh
new file mode 100644
index 00000000..3bf5bd2f
--- /dev/null
+++ b/hack/e2e/lib/log.sh
@@ -0,0 +1,56 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+log() {
+    echo "[$(date -u +%H:%M:%S)] $*" >&2
+}
+
+die() {
+    log "FATAL: $*"
+    exit 1
+}
+
+# wait_for <timeout-seconds> <interval-seconds> <description> <command...>
+# Polls <command...> until it succeeds or the timeout elapses.
+wait_for() {
+    local timeout=$1 interval=$2 desc=$3
+    shift 3
+    local start=$SECONDS
+    log "waiting up to ${timeout}s for: ${desc}"
+    while ((SECONDS - start < timeout)); do
+        if "$@" >/dev/null 2>&1; then
+            log "ready after $((SECONDS - start))s: ${desc}"
+            return 0
+        fi
+        sleep "$interval"
+    done
+    die "timed out after ${timeout}s waiting for: ${desc}"
+}
+
+# retry <attempts> <sleep-seconds> <command...>
+retry() {
+    local attempts=$1 pause=$2 i
+    shift 2
+    for ((i = 1; i <= attempts; i++)); do
+        if "$@"; then
+            return 0
+        fi
+        log "attempt ${i}/${attempts} failed: $*"
+        sleep "$pause"
+    done
+    return 1
+}
diff --git a/hack/e2e/up.sh b/hack/e2e/up.sh
new file mode 100755
index 00000000..9f050c90
--- /dev/null
+++ b/hack/e2e/up.sh
@@ -0,0 +1,38 @@
+#!/usr/bin/env bash
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+# One-shot bring-up of the full simulated environment:
+# simulator + zone -> kind cluster -> CloudStack VMs -> CCM.
+
+set -euo pipefail
+here="$(dirname "${BASH_SOURCE[0]}")"
+
+"${here}/10-simulator-up.sh"
+"${here}/20-kind-up.sh"
+"${here}/30-topology-isolated.sh"
+"${here}/40-ccm-deploy.sh"
+
+echo
+echo "Environment is up. Try it:"
+echo "  export KUBECONFIG=${here}/_out/kubeconfig"
+echo "  kubectl create deployment web --image=nginx"
+echo "  kubectl expose deployment web --port=80 --type=LoadBalancer"
+echo "  kubectl get svc web -w   # EXTERNAL-IP appears from 192.168.2.0/24"
+echo
+echo "Run the e2e suite:  make test-e2e"
+echo "Tear down:          ${here}/99-down.sh"
diff --git a/test/e2e/annotations_test.go b/test/e2e/annotations_test.go
new file mode 100644
index 00000000..67cc27fe
--- /dev/null
+++ b/test/e2e/annotations_test.go
@@ -0,0 +1,176 @@
+//go:build e2e
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package e2e
+
+import (
+       "context"
+       "strings"
+       "testing"
+
+       "github.com/blang/semver/v4"
+       corev1 "k8s.io/api/core/v1"
+       metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+const (
+       annotationSourceCidrs  = 
"service.beta.kubernetes.io/cloudstack-load-balancer-source-cidrs"
+       annotationHostname     = 
"service.beta.kubernetes.io/cloudstack-load-balancer-hostname"
+       annotationIPAssociated = 
"service.beta.kubernetes.io/cloudstack-load-balancer-ip-associated-by-controller"
 //nolint:gosec
+)
+
+func TestAnnot_SourceCIDRs(t *testing.T) {
+       f := NewFramework(t)
+       svc := f.CreateLBService(func(s *corev1.Service) {
+               s.Annotations = map[string]string{
+                       annotationSourceCidrs: "10.0.0.0/8,192.168.100.0/24",
+               }
+       })
+       lbName := defaultLoadBalancerName(svc)
+
+       f.WaitForIngressIP(svc)
+       rules := f.WaitForLBRules(lbName, 1)
+       for _, cidr := range []string{"10.0.0.0/8", "192.168.100.0/24"} {
+               if !strings.Contains(rules[0].Cidrlist, cidr) {
+                       t.Errorf("rule cidrlist = %q, want it to contain %s", 
rules[0].Cidrlist, cidr)
+               }
+       }
+       originalRuleID := rules[0].Id
+
+       // Change the CIDR list. On >= 4.22 the rule is updated in place (same
+       // ID); on older versions it is deleted and recreated (new ID).
+       f.UpdateService(svc, func(s *corev1.Service) {
+               s.Annotations[annotationSourceCidrs] = "172.16.0.0/12"
+       })
+       inPlace := f.Version.GTE(semver.Version{Major: 4, Minor: 22, Patch: 0})
+       f.Eventually(lbSyncTimeout, lbSyncInterval, "cidr list update to 
propagate",
+               func() (bool, error) {
+                       current, err := f.LBRules(lbName)
+                       if err != nil || len(current) != 1 {
+                               return false, err
+                       }
+                       if !strings.Contains(current[0].Cidrlist, 
"172.16.0.0/12") {
+                               return false, nil
+                       }
+                       if inPlace && current[0].Id != originalRuleID {
+                               t.Errorf("expected in-place cidr update on %s 
(rule ID changed %s -> %s)",
+                                       f.Version, originalRuleID, 
current[0].Id)
+                       }
+                       if !inPlace && current[0].Id == originalRuleID {
+                               t.Errorf("expected rule recreation on %s (rule 
ID unchanged)", f.Version)
+                       }
+                       return true, nil
+               })
+}
+
+func TestAnnot_Hostname(t *testing.T) {
+       f := NewFramework(t)
+       svc := f.CreateLBService(func(s *corev1.Service) {
+               s.Annotations = map[string]string{
+                       annotationHostname: "lb.example.com",
+               }
+       })
+
+       ingress := f.WaitForIngressIP(svc)
+       if ingress.Hostname != "lb.example.com" {
+               t.Errorf("ingress hostname = %q, want lb.example.com", 
ingress.Hostname)
+       }
+       if ingress.IP != "" {
+               t.Errorf("ingress IP = %q, want empty when hostname annotation 
is set", ingress.IP)
+       }
+}
+
+func TestAnnot_SessionAffinity(t *testing.T) {
+       f := NewFramework(t)
+       svc := f.CreateLBService(func(s *corev1.Service) {
+               s.Spec.SessionAffinity = corev1.ServiceAffinityClientIP
+       })
+       lbName := defaultLoadBalancerName(svc)
+
+       f.WaitForIngressIP(svc)
+       rules := f.WaitForLBRules(lbName, 1)
+       if rules[0].Algorithm != "source" {
+               t.Errorf("algorithm = %q, want source for sessionAffinity 
ClientIP", rules[0].Algorithm)
+       }
+
+       f.UpdateService(svc, func(s *corev1.Service) {
+               s.Spec.SessionAffinity = corev1.ServiceAffinityNone
+       })
+       f.Eventually(lbSyncTimeout, lbSyncInterval, "algorithm to revert to 
roundrobin",
+               func() (bool, error) {
+                       current, err := f.LBRules(lbName)
+                       if err != nil || len(current) != 1 {
+                               return false, err
+                       }
+                       return current[0].Algorithm == "roundrobin", nil
+               })
+}
+
+func TestAnnot_ExplicitLoadBalancerIP(t *testing.T) {
+       f := NewFramework(t)
+
+       // Pick a free IP from the simulator's public range instead of 
hardcoding
+       // one that a parallel test may have grabbed.
+       p := f.CS.Address.NewListPublicIpAddressesParams()
+       p.SetAllocatedonly(false)
+       p.SetListall(true)
+       p.SetState("Free")
+       resp, err := f.CS.Address.ListPublicIpAddresses(p)
+       if err != nil || resp.Count == 0 {
+               t.Fatalf("listing free public IPs: count=%d err=%v", 
resp.Count, err)
+       }
+       freeIP := resp.PublicIpAddresses[0].Ipaddress
+
+       svc := f.CreateLBService(func(s *corev1.Service) {
+               s.Spec.LoadBalancerIP = freeIP
+       })
+
+       ingress := f.WaitForIngressIP(svc)
+       if ingress.IP != freeIP {
+               t.Fatalf("ingress IP = %q, want requested %q", ingress.IP, 
freeIP)
+       }
+
+       // The controller associated the IP itself, so it must record that fact
+       // on the service; on deletion the IP must be released again.
+       f.Eventually(lbSyncTimeout, lbSyncInterval, 
"ip-associated-by-controller annotation",
+               func() (bool, error) {
+                       current, err := 
f.K8s.CoreV1().Services(svc.Namespace).Get(
+                               context.Background(), svc.Name, 
metav1.GetOptions{})
+                       if err != nil {
+                               return false, err
+                       }
+                       return current.Annotations[annotationIPAssociated] == 
"true", nil
+               })
+
+       f.DeleteServiceAndWait(svc)
+       f.Eventually(lbSyncTimeout, lbSyncInterval, "explicitly requested IP to 
be released",
+               func() (bool, error) {
+                       p := f.CS.Address.NewListPublicIpAddressesParams()
+                       p.SetIpaddress(freeIP)
+                       p.SetAllocatedonly(false)
+                       p.SetListall(true)
+                       resp, err := f.CS.Address.ListPublicIpAddresses(p)
+                       if err != nil || resp.Count == 0 {
+                               return false, err
+                       }
+                       return resp.PublicIpAddresses[0].Allocated == "", nil
+               })
+}
diff --git a/test/e2e/framework.go b/test/e2e/framework.go
new file mode 100644
index 00000000..cf82b3f2
--- /dev/null
+++ b/test/e2e/framework.go
@@ -0,0 +1,381 @@
+//go:build e2e
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+// Package e2e contains end-to-end tests that run against a live Kubernetes
+// cluster whose cloud-controller-manager talks to a CloudStack management
+// server (normally the simulator brought up by hack/e2e/up.sh).
+//
+// Configuration comes from the environment:
+//
+//     KUBECONFIG     kubeconfig of the cluster under test
+//     CS_API_URL     CloudStack API endpoint (as reachable from the test 
process)
+//     CS_API_KEY     CloudStack API key
+//     CS_SECRET_KEY  CloudStack secret key
+//     CS_PROJECT_ID  optional project scoping (set for the VPC phase)
+//
+// When any required variable is missing, the tests skip.
+package e2e
+
+import (
+       "context"
+       "crypto/rand"
+       "encoding/hex"
+       "fmt"
+       "os"
+       "strconv"
+       "strings"
+       "testing"
+       "time"
+
+       "github.com/apache/cloudstack-go/v2/cloudstack"
+       "github.com/blang/semver/v4"
+       corev1 "k8s.io/api/core/v1"
+       metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+       "k8s.io/client-go/kubernetes"
+       "k8s.io/client-go/tools/clientcmd"
+)
+
+const (
+       lbSyncTimeout  = 3 * time.Minute
+       lbSyncInterval = 3 * time.Second
+)
+
+// Framework bundles the clients and helpers shared by all e2e tests.
+type Framework struct {
+       T         *testing.T
+       K8s       kubernetes.Interface
+       CS        *cloudstack.CloudStackClient
+       Namespace string
+       ProjectID string
+       Version   semver.Version
+}
+
+// NewFramework builds clients from the environment, skipping the test when
+// the environment is not configured. It creates a per-test namespace that is
+// deleted on cleanup.
+func NewFramework(t *testing.T) *Framework {
+       t.Helper()
+
+       apiURL := os.Getenv("CS_API_URL")
+       apiKey := os.Getenv("CS_API_KEY")
+       secretKey := os.Getenv("CS_SECRET_KEY")
+       if apiURL == "" || apiKey == "" || secretKey == "" {
+               t.Skip("CS_API_URL/CS_API_KEY/CS_SECRET_KEY not set; skipping 
e2e test")
+       }
+
+       kubeconfig := os.Getenv("KUBECONFIG")
+       if kubeconfig == "" {
+               t.Skip("KUBECONFIG not set; skipping e2e test")
+       }
+       restCfg, err := clientcmd.BuildConfigFromFlags("", kubeconfig)
+       if err != nil {
+               t.Fatalf("building kubeconfig: %v", err)
+       }
+       k8s, err := kubernetes.NewForConfig(restCfg)
+       if err != nil {
+               t.Fatalf("building kubernetes client: %v", err)
+       }
+
+       verifySSL := true
+       if noVerify, err := strconv.ParseBool(os.Getenv("CS_SSL_NO_VERIFY")); 
err == nil {
+               verifySSL = !noVerify
+       }
+       cs := cloudstack.NewAsyncClient(apiURL, apiKey, secretKey, verifySSL)
+
+       f := &Framework{
+               T:         t,
+               K8s:       k8s,
+               CS:        cs,
+               ProjectID: os.Getenv("CS_PROJECT_ID"),
+       }
+       f.Version = f.managementServerVersion()
+       f.Namespace = f.createNamespace()
+       return f
+}
+
+func (f *Framework) managementServerVersion() semver.Version {
+       f.T.Helper()
+       resp, err := f.CS.Management.ListManagementServersMetrics(
+               f.CS.Management.NewListManagementServersMetricsParams())
+       if err != nil {
+               f.T.Fatalf("listing management servers: %v", err)
+       }
+       if resp.Count == 0 {
+               f.T.Fatal("no management servers found")
+       }
+       raw := 
strings.Join(strings.SplitN(resp.ManagementServersMetrics[0].Version, ".", 
4)[0:3], ".")
+       v, err := semver.ParseTolerant(raw)
+       if err != nil {
+               f.T.Fatalf("parsing management server version %q: %v", raw, err)
+       }
+       return v
+}
+
+func (f *Framework) createNamespace() string {
+       f.T.Helper()
+       buf := make([]byte, 4)
+       if _, err := rand.Read(buf); err != nil {
+               f.T.Fatalf("generating namespace suffix: %v", err)
+       }
+       name := "ccm-e2e-" + hex.EncodeToString(buf)
+       _, err := f.K8s.CoreV1().Namespaces().Create(context.Background(),
+               &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: name}}, 
metav1.CreateOptions{})
+       if err != nil {
+               f.T.Fatalf("creating namespace %s: %v", name, err)
+       }
+       f.T.Cleanup(func() {
+               _ = f.K8s.CoreV1().Namespaces().Delete(context.Background(), 
name, metav1.DeleteOptions{})
+       })
+       return name
+}
+
+// Eventually polls cond until it returns true or the timeout elapses.
+func (f *Framework) Eventually(timeout, interval time.Duration, desc string, 
cond func() (bool, error)) {
+       f.T.Helper()
+       deadline := time.Now().Add(timeout)
+       var lastErr error
+       for time.Now().Before(deadline) {
+               ok, err := cond()
+               lastErr = err
+               if ok {
+                       return
+               }
+               time.Sleep(interval)
+       }
+       f.T.Fatalf("timed out after %s waiting for %s (last error: %v)", 
timeout, desc, lastErr)
+}
+
+// CreateLBService creates a LoadBalancer service in the test namespace and
+// registers cleanup that both deletes it and waits for the CloudStack rules
+// to disappear, so a leaked rule cannot poison later tests.
+func (f *Framework) CreateLBService(mutate func(*corev1.Service)) 
*corev1.Service {
+       f.T.Helper()
+       svc := &corev1.Service{
+               ObjectMeta: metav1.ObjectMeta{
+                       Name:      "e2e",
+                       Namespace: f.Namespace,
+               },
+               Spec: corev1.ServiceSpec{
+                       Type:     corev1.ServiceTypeLoadBalancer,
+                       Selector: map[string]string{"app": "e2e"},
+                       Ports: []corev1.ServicePort{
+                               {Name: "http", Port: 80, Protocol: 
corev1.ProtocolTCP},
+                       },
+               },
+       }
+       if mutate != nil {
+               mutate(svc)
+       }
+       created, err := f.K8s.CoreV1().Services(f.Namespace).Create(
+               context.Background(), svc, metav1.CreateOptions{})
+       if err != nil {
+               f.T.Fatalf("creating service: %v", err)
+       }
+       f.T.Cleanup(func() { f.DeleteServiceAndWait(created) })
+       return created
+}
+
+// DeleteServiceAndWait deletes the service (if it still exists) and waits for
+// its CloudStack load balancer rules to be cleaned up.
+func (f *Framework) DeleteServiceAndWait(svc *corev1.Service) {
+       f.T.Helper()
+       err := f.K8s.CoreV1().Services(svc.Namespace).Delete(
+               context.Background(), svc.Name, metav1.DeleteOptions{})
+       if err != nil {
+               return // already gone
+       }
+       lbName := defaultLoadBalancerName(svc)
+       deadline := time.Now().Add(lbSyncTimeout)
+       for time.Now().Before(deadline) {
+               rules, err := f.LBRules(lbName)
+               if err == nil && len(rules) == 0 {
+                       return
+               }
+               time.Sleep(lbSyncInterval)
+       }
+       f.T.Logf("warning: load balancer rules for %s not cleaned up in time", 
lbName)
+}
+
+// defaultLoadBalancerName mirrors cloudprovider.DefaultLoadBalancerName: "a"
+// followed by the service UID with dashes stripped, truncated to 32 chars.
+func defaultLoadBalancerName(svc *corev1.Service) string {
+       name := "a" + strings.ReplaceAll(string(svc.UID), "-", "")
+       if len(name) > 32 {
+               name = name[:32]
+       }
+       return name
+}
+
+// LBRules returns the CloudStack load balancer rules whose names start with
+// the given LB name.
+func (f *Framework) LBRules(lbName string) ([]*cloudstack.LoadBalancerRule, 
error) {
+       p := f.CS.LoadBalancer.NewListLoadBalancerRulesParams()
+       p.SetKeyword(lbName)
+       p.SetListall(true)
+       if f.ProjectID != "" {
+               p.SetProjectid(f.ProjectID)
+       }
+       resp, err := f.CS.LoadBalancer.ListLoadBalancerRules(p)
+       if err != nil {
+               return nil, err
+       }
+       var rules []*cloudstack.LoadBalancerRule
+       for _, r := range resp.LoadBalancerRules {
+               if strings.HasPrefix(r.Name, lbName) {
+                       rules = append(rules, r)
+               }
+       }
+       return rules, nil
+}
+
+// WaitForIngressIP waits until the service has a load balancer ingress entry
+// and returns it.
+func (f *Framework) WaitForIngressIP(svc *corev1.Service) 
corev1.LoadBalancerIngress {
+       f.T.Helper()
+       var ingress corev1.LoadBalancerIngress
+       f.Eventually(lbSyncTimeout, lbSyncInterval,
+               fmt.Sprintf("service %s/%s to get an ingress address", 
svc.Namespace, svc.Name),
+               func() (bool, error) {
+                       current, err := 
f.K8s.CoreV1().Services(svc.Namespace).Get(
+                               context.Background(), svc.Name, 
metav1.GetOptions{})
+                       if err != nil {
+                               return false, err
+                       }
+                       if len(current.Status.LoadBalancer.Ingress) == 0 {
+                               return false, nil
+                       }
+                       ingress = current.Status.LoadBalancer.Ingress[0]
+                       return true, nil
+               })
+       return ingress
+}
+
+// WaitForLBRules waits until exactly want rules exist for lbName and returns 
them.
+func (f *Framework) WaitForLBRules(lbName string, want int) 
[]*cloudstack.LoadBalancerRule {
+       f.T.Helper()
+       var rules []*cloudstack.LoadBalancerRule
+       f.Eventually(lbSyncTimeout, lbSyncInterval,
+               fmt.Sprintf("%d load balancer rule(s) named %s-*", want, 
lbName),
+               func() (bool, error) {
+                       var err error
+                       rules, err = f.LBRules(lbName)
+                       if err != nil {
+                               return false, err
+                       }
+                       return len(rules) == want, nil
+               })
+       return rules
+}
+
+// FirewallRules lists the firewall rules on a public IP.
+func (f *Framework) FirewallRules(publicIPID string) 
([]*cloudstack.FirewallRule, error) {
+       p := f.CS.Firewall.NewListFirewallRulesParams()
+       p.SetIpaddressid(publicIPID)
+       p.SetListall(true)
+       if f.ProjectID != "" {
+               p.SetProjectid(f.ProjectID)
+       }
+       resp, err := f.CS.Firewall.ListFirewallRules(p)
+       if err != nil {
+               return nil, err
+       }
+       return resp.FirewallRules, nil
+}
+
+// ACLRules lists the network ACL rules on an ACL list.
+func (f *Framework) ACLRules(aclListID string) ([]*cloudstack.NetworkACL, 
error) {
+       p := f.CS.NetworkACL.NewListNetworkACLsParams()
+       p.SetAclid(aclListID)
+       p.SetListall(true)
+       if f.ProjectID != "" {
+               p.SetProjectid(f.ProjectID)
+       }
+       resp, err := f.CS.NetworkACL.ListNetworkACLs(p)
+       if err != nil {
+               return nil, err
+       }
+       return resp.NetworkACLs, nil
+}
+
+// PublicIP fetches a public IP address record by its ID.
+func (f *Framework) PublicIP(id string) (*cloudstack.PublicIpAddress, error) {
+       p := f.CS.Address.NewListPublicIpAddressesParams()
+       p.SetId(id)
+       p.SetListall(true)
+       p.SetAllocatedonly(false)
+       if f.ProjectID != "" {
+               p.SetProjectid(f.ProjectID)
+       }
+       resp, err := f.CS.Address.ListPublicIpAddresses(p)
+       if err != nil {
+               return nil, err
+       }
+       if resp.Count == 0 {
+               return nil, nil
+       }
+       return resp.PublicIpAddresses[0], nil
+}
+
+// VMByName returns the CloudStack VM with the given name, or nil.
+func (f *Framework) VMByName(name string) (*cloudstack.VirtualMachine, error) {
+       vm, count, err := f.CS.VirtualMachine.GetVirtualMachineByName(
+               name, cloudstack.WithProject(f.ProjectID))
+       if err != nil {
+               if count == 0 {
+                       return nil, nil
+               }
+               return nil, err
+       }
+       return vm, nil
+}
+
+// Nodes returns all nodes of the cluster under test.
+func (f *Framework) Nodes() []corev1.Node {
+       f.T.Helper()
+       nodes, err := f.K8s.CoreV1().Nodes().List(context.Background(), 
metav1.ListOptions{})
+       if err != nil {
+               f.T.Fatalf("listing nodes: %v", err)
+       }
+       return nodes.Items
+}
+
+// UpdateService applies mutate to the latest version of the service and
+// updates it, retrying on conflicts.
+func (f *Framework) UpdateService(svc *corev1.Service, mutate 
func(*corev1.Service)) *corev1.Service {
+       f.T.Helper()
+       var updated *corev1.Service
+       f.Eventually(30*time.Second, time.Second, "service update to apply",
+               func() (bool, error) {
+                       current, err := 
f.K8s.CoreV1().Services(svc.Namespace).Get(
+                               context.Background(), svc.Name, 
metav1.GetOptions{})
+                       if err != nil {
+                               return false, err
+                       }
+                       mutate(current)
+                       updated, err = 
f.K8s.CoreV1().Services(svc.Namespace).Update(
+                               context.Background(), current, 
metav1.UpdateOptions{})
+                       if err != nil {
+                               return false, err
+                       }
+                       return true, nil
+               })
+       return updated
+}
diff --git a/test/e2e/loadbalancer_test.go b/test/e2e/loadbalancer_test.go
new file mode 100644
index 00000000..c2eaae1a
--- /dev/null
+++ b/test/e2e/loadbalancer_test.go
@@ -0,0 +1,213 @@
+//go:build e2e
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package e2e
+
+import (
+       "context"
+       "fmt"
+       "net"
+       "strconv"
+       "strings"
+       "testing"
+
+       corev1 "k8s.io/api/core/v1"
+       metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+func TestLB_CreateSingleTCPPort(t *testing.T) {
+       f := NewFramework(t)
+       svc := f.CreateLBService(nil)
+       lbName := defaultLoadBalancerName(svc)
+
+       ingress := f.WaitForIngressIP(svc)
+       if ingress.IP == "" {
+               t.Fatalf("expected an ingress IP, got %+v", ingress)
+       }
+       if ip := net.ParseIP(ingress.IP); ip == nil {
+               t.Fatalf("ingress IP %q is not a valid IP", ingress.IP)
+       }
+
+       rules := f.WaitForLBRules(lbName, 1)
+       rule := rules[0]
+       wantName := fmt.Sprintf("%s-tcp-80", lbName)
+       if rule.Name != wantName {
+               t.Errorf("rule name = %q, want %q", rule.Name, wantName)
+       }
+       if rule.Algorithm != "roundrobin" {
+               t.Errorf("rule algorithm = %q, want roundrobin", rule.Algorithm)
+       }
+       if rule.Publicport != "80" {
+               t.Errorf("rule public port = %q, want 80", rule.Publicport)
+       }
+       // The private port must be the service's NodePort.
+       current, err := 
f.K8s.CoreV1().Services(svc.Namespace).Get(context.Background(), svc.Name, 
metav1.GetOptions{})
+       if err != nil {
+               t.Fatalf("getting service: %v", err)
+       }
+       nodePort := strconv.Itoa(int(current.Spec.Ports[0].NodePort))
+       if rule.Privateport != nodePort {
+               t.Errorf("rule private port = %q, want NodePort %q", 
rule.Privateport, nodePort)
+       }
+       if rule.Publicip != ingress.IP {
+               t.Errorf("rule public IP = %q, want ingress IP %q", 
rule.Publicip, ingress.IP)
+       }
+       if !strings.Contains(rule.Cidrlist, "0.0.0.0/0") {
+               t.Errorf("rule cidrlist = %q, want it to contain 0.0.0.0/0", 
rule.Cidrlist)
+       }
+
+       // The isolated network offering supports the Firewall service, so a
+       // firewall rule must exist for the port.
+       f.Eventually(lbSyncTimeout, lbSyncInterval, "firewall rule for port 80",
+               func() (bool, error) {
+                       fwRules, err := f.FirewallRules(rule.Publicipid)
+                       if err != nil {
+                               return false, err
+                       }
+                       for _, fw := range fwRules {
+                               if fw.Startport == 80 && fw.Endport == 80 && 
strings.EqualFold(fw.Protocol, "tcp") {
+                                       return true, nil
+                               }
+                       }
+                       return false, nil
+               })
+}
+
+func TestLB_MultiPort(t *testing.T) {
+       f := NewFramework(t)
+       svc := f.CreateLBService(func(s *corev1.Service) {
+               s.Spec.Ports = []corev1.ServicePort{
+                       {Name: "http", Port: 80, Protocol: corev1.ProtocolTCP},
+                       {Name: "https", Port: 443, Protocol: 
corev1.ProtocolTCP},
+               }
+       })
+       lbName := defaultLoadBalancerName(svc)
+
+       f.WaitForIngressIP(svc)
+       rules := f.WaitForLBRules(lbName, 2)
+       if rules[0].Publicipid != rules[1].Publicipid {
+               t.Errorf("expected both rules to share a public IP, got %q and 
%q",
+                       rules[0].Publicipid, rules[1].Publicipid)
+       }
+       ports := map[string]bool{}
+       for _, r := range rules {
+               ports[r.Publicport] = true
+       }
+       if !ports["80"] || !ports["443"] {
+               t.Errorf("expected rules for ports 80 and 443, got %v", ports)
+       }
+}
+
+func TestLB_NodeMembership(t *testing.T) {
+       f := NewFramework(t)
+       svc := f.CreateLBService(nil)
+       lbName := defaultLoadBalancerName(svc)
+
+       f.WaitForIngressIP(svc)
+       rules := f.WaitForLBRules(lbName, 1)
+
+       // Only schedulable workers participate; kubeadm labels the control 
plane
+       // node.kubernetes.io/exclude-from-external-load-balancers.
+       wantIDs := map[string]bool{}
+       for _, node := range f.Nodes() {
+               if _, excluded := 
node.Labels["node.kubernetes.io/exclude-from-external-load-balancers"]; 
excluded {
+                       continue
+               }
+               vm, err := f.VMByName(node.Name)
+               if err != nil || vm == nil {
+                       t.Fatalf("looking up VM for node %s: %v", node.Name, 
err)
+               }
+               wantIDs[vm.Id] = true
+       }
+       if len(wantIDs) == 0 {
+               t.Fatal("no candidate worker nodes found")
+       }
+
+       f.Eventually(lbSyncTimeout, lbSyncInterval, "load balancer rule 
instances to match worker VMs",
+               func() (bool, error) {
+                       p := 
f.CS.LoadBalancer.NewListLoadBalancerRuleInstancesParams(rules[0].Id)
+                       resp, err := 
f.CS.LoadBalancer.ListLoadBalancerRuleInstances(p)
+                       if err != nil {
+                               return false, err
+                       }
+                       gotIDs := map[string]bool{}
+                       for _, inst := range resp.LoadBalancerRuleInstances {
+                               gotIDs[inst.Id] = true
+                       }
+                       if len(gotIDs) != len(wantIDs) {
+                               return false, fmt.Errorf("got %d instances, 
want %d", len(gotIDs), len(wantIDs))
+                       }
+                       for id := range wantIDs {
+                               if !gotIDs[id] {
+                                       return false, fmt.Errorf("VM %s missing 
from rule instances", id)
+                               }
+                       }
+                       return true, nil
+               })
+}
+
+func TestLB_PortChange(t *testing.T) {
+       f := NewFramework(t)
+       svc := f.CreateLBService(nil)
+       lbName := defaultLoadBalancerName(svc)
+
+       f.WaitForIngressIP(svc)
+       f.WaitForLBRules(lbName, 1)
+
+       f.UpdateService(svc, func(s *corev1.Service) {
+               s.Spec.Ports[0].Port = 8080
+       })
+
+       // The old rule (port 80) must be replaced by a new one (port 8080).
+       f.Eventually(lbSyncTimeout, lbSyncInterval, "rule for port 8080 to 
replace port 80",
+               func() (bool, error) {
+                       rules, err := f.LBRules(lbName)
+                       if err != nil {
+                               return false, err
+                       }
+                       return len(rules) == 1 && rules[0].Publicport == 
"8080", nil
+               })
+}
+
+func TestLB_Delete(t *testing.T) {
+       f := NewFramework(t)
+       svc := f.CreateLBService(nil)
+       lbName := defaultLoadBalancerName(svc)
+
+       f.WaitForIngressIP(svc)
+       rules := f.WaitForLBRules(lbName, 1)
+       publicIPID := rules[0].Publicipid
+
+       f.DeleteServiceAndWait(svc)
+
+       if remaining, err := f.LBRules(lbName); err != nil || len(remaining) != 
0 {
+               t.Errorf("expected no remaining rules, got %d (err %v)", 
len(remaining), err)
+       }
+       // The auto-allocated public IP must be released.
+       f.Eventually(lbSyncTimeout, lbSyncInterval, "public IP to be released",
+               func() (bool, error) {
+                       ip, err := f.PublicIP(publicIPID)
+                       if err != nil {
+                               return false, err
+                       }
+                       return ip == nil || ip.Allocated == "", nil
+               })
+}
diff --git a/test/e2e/node_test.go b/test/e2e/node_test.go
new file mode 100644
index 00000000..e94b00c6
--- /dev/null
+++ b/test/e2e/node_test.go
@@ -0,0 +1,137 @@
+//go:build e2e
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package e2e
+
+import (
+       "os"
+       "strings"
+       "testing"
+
+       corev1 "k8s.io/api/core/v1"
+)
+
+const providerIDPrefix = "external-cloudstack://"
+
+// TestNode_Initialized asserts the CCM removed the uninitialized taint from
+// every node.
+func TestNode_Initialized(t *testing.T) {
+       f := NewFramework(t)
+       for _, node := range f.Nodes() {
+               for _, taint := range node.Spec.Taints {
+                       if taint.Key == 
"node.cloudprovider.kubernetes.io/uninitialized" {
+                               t.Errorf("node %s still has the uninitialized 
taint", node.Name)
+                       }
+               }
+       }
+}
+
+// TestNode_ProviderID asserts every node's providerID references the matching
+// CloudStack VM.
+//
+// kind starts kubelet with --provider-id=kind://..., and Kubernetes only lets
+// the provider ID be set once, so under the kind-based harness the CCM never
+// gets to assign it. Where that is the case the test verifies instead that
+// the CCM would derive the right value, and reports the node as skipped so
+// the limitation stays visible rather than silently reducing coverage.
+func TestNode_ProviderID(t *testing.T) {
+       f := NewFramework(t)
+       checked := 0
+       for _, node := range f.Nodes() {
+               vm, err := f.VMByName(node.Name)
+               if err != nil {
+                       t.Fatalf("looking up VM for node %s: %v", node.Name, 
err)
+               }
+               if vm == nil {
+                       t.Fatalf("no CloudStack VM named %s", node.Name)
+               }
+               want := providerIDPrefix + vm.Id
+
+               if node.Spec.ProviderID != "" && 
!strings.HasPrefix(node.Spec.ProviderID, providerIDPrefix) {
+                       t.Logf("node %s has a foreign provider ID %q (set by 
the infrastructure, "+
+                               "not the CCM); expected CloudStack provider ID 
would be %q",
+                               node.Name, node.Spec.ProviderID, want)
+                       continue
+               }
+               if node.Spec.ProviderID != want {
+                       t.Errorf("node %s providerID = %q, want %q", node.Name, 
node.Spec.ProviderID, want)
+               }
+               checked++
+       }
+       if checked == 0 {
+               t.Skip("every node has a provider ID assigned by the 
infrastructure; " +
+                       "the CCM's provider ID assignment is not exercised by 
this environment")
+       }
+}
+
+// TestNode_Labels asserts the CCM applied instance-type, zone and region
+// labels from CloudStack metadata.
+func TestNode_Labels(t *testing.T) {
+       f := NewFramework(t)
+       region := os.Getenv("E2E_REGION")
+       if region == "" {
+               region = "simulator-region"
+       }
+       for _, node := range f.Nodes() {
+               vm, err := f.VMByName(node.Name)
+               if err != nil || vm == nil {
+                       t.Fatalf("looking up VM for node %s: %v", node.Name, 
err)
+               }
+               // labelInvalidCharsRegex strips characters that are invalid in 
label
+               // values, e.g. "Small Instance" becomes "SmallInstance".
+               if got := node.Labels[corev1.LabelInstanceTypeStable]; got == 
"" {
+                       t.Errorf("node %s is missing label %s", node.Name, 
corev1.LabelInstanceTypeStable)
+               }
+               if got := node.Labels[corev1.LabelTopologyZone]; got != 
vm.Zonename {
+                       t.Errorf("node %s zone label = %q, want %q", node.Name, 
got, vm.Zonename)
+               }
+               if got := node.Labels[corev1.LabelTopologyRegion]; got != 
region {
+                       t.Errorf("node %s region label = %q, want %q", 
node.Name, got, region)
+               }
+       }
+}
+
+// TestNode_InternalIP asserts each node's InternalIP equals its CloudStack
+// VM's NIC address. This is the contract that makes the whole environment
+// work: kubelet registers with the docker IP, and the CCM only initializes
+// the node because the VM reports the same address.
+func TestNode_InternalIP(t *testing.T) {
+       f := NewFramework(t)
+       for _, node := range f.Nodes() {
+               vm, err := f.VMByName(node.Name)
+               if err != nil || vm == nil {
+                       t.Fatalf("looking up VM for node %s: %v", node.Name, 
err)
+               }
+               if len(vm.Nic) == 0 {
+                       t.Fatalf("VM %s has no NICs", node.Name)
+               }
+               var internalIP string
+               for _, addr := range node.Status.Addresses {
+                       if addr.Type == corev1.NodeInternalIP {
+                               internalIP = addr.Address
+                       }
+               }
+               if internalIP != vm.Nic[0].Ipaddress {
+                       t.Errorf("node %s InternalIP = %q, want VM NIC IP %q",
+                               node.Name, internalIP, vm.Nic[0].Ipaddress)
+               }
+       }
+}
diff --git a/test/e2e/vpc_test.go b/test/e2e/vpc_test.go
new file mode 100644
index 00000000..69033ecd
--- /dev/null
+++ b/test/e2e/vpc_test.go
@@ -0,0 +1,132 @@
+//go:build e2e
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package e2e
+
+import (
+       "os"
+       "strings"
+       "testing"
+)
+
+// vpcFramework skips unless the harness is in the VPC phase
+// (50-topology-vpc.sh exports E2E_ACL_ID / E2E_VPC_ID and CS_PROJECT_ID).
+func vpcFramework(t *testing.T) (*Framework, string, string) {
+       t.Helper()
+       aclID := os.Getenv("E2E_ACL_ID")
+       vpcID := os.Getenv("E2E_VPC_ID")
+       if aclID == "" || vpcID == "" || os.Getenv("CS_PROJECT_ID") == "" {
+               t.Skip("E2E_ACL_ID/E2E_VPC_ID/CS_PROJECT_ID not set; skipping 
VPC phase test")
+       }
+       return NewFramework(t), aclID, vpcID
+}
+
+// TestVPC_LoadBalancer covers the VPC path end to end: the LB rule is
+// created, the public IP is associated with the VPC, ingress traffic is
+// allowed via a Network ACL rule on the custom ACL list (not a firewall
+// rule), and everything is cleaned up on delete.
+func TestVPC_LoadBalancer(t *testing.T) {
+       f, aclID, vpcID := vpcFramework(t)
+
+       svc := f.CreateLBService(nil)
+       lbName := defaultLoadBalancerName(svc)
+
+       f.WaitForIngressIP(svc)
+       rules := f.WaitForLBRules(lbName, 1)
+       rule := rules[0]
+
+       ip, err := f.PublicIP(rule.Publicipid)
+       if err != nil || ip == nil {
+               t.Fatalf("fetching public IP %s: %v", rule.Publicipid, err)
+       }
+       if ip.Vpcid != vpcID {
+               t.Errorf("public IP vpcid = %q, want %q", ip.Vpcid, vpcID)
+       }
+
+       // An ACL rule for the port must appear on the custom ACL list.
+       f.Eventually(lbSyncTimeout, lbSyncInterval, "network ACL rule for port 
80",
+               func() (bool, error) {
+                       aclRules, err := f.ACLRules(aclID)
+                       if err != nil {
+                               return false, err
+                       }
+                       for _, r := range aclRules {
+                               if r.Startport == "80" && r.Endport == "80" &&
+                                       strings.EqualFold(r.Protocol, "tcp") &&
+                                       strings.EqualFold(r.Action, "Allow") &&
+                                       strings.EqualFold(r.Traffictype, 
"Ingress") {
+                                       return true, nil
+                               }
+                       }
+                       return false, nil
+               })
+
+       // The VPC tier offering has no Firewall service, so no firewall rule 
may
+       // be created for the port.
+       fwRules, err := f.FirewallRules(rule.Publicipid)
+       if err != nil {
+               t.Fatalf("listing firewall rules: %v", err)
+       }
+       for _, fw := range fwRules {
+               if fw.Startport == 80 && fw.Endport == 80 {
+                       t.Errorf("unexpected firewall rule on VPC public IP: 
%+v", fw)
+               }
+       }
+
+       // Deleting the service must remove the ACL rule again.
+       f.DeleteServiceAndWait(svc)
+       f.Eventually(lbSyncTimeout, lbSyncInterval, "network ACL rule to be 
removed",
+               func() (bool, error) {
+                       aclRules, err := f.ACLRules(aclID)
+                       if err != nil {
+                               return false, err
+                       }
+                       for _, r := range aclRules {
+                               if r.Startport == "80" && r.Endport == "80" && 
strings.EqualFold(r.Protocol, "tcp") {
+                                       return false, nil
+                               }
+                       }
+                       return true, nil
+               })
+}
+
+// TestVPC_NodesReinitialized asserts the CCM re-initialized the nodes against
+// the project VMs after the phase switch.
+func TestVPC_NodesReinitialized(t *testing.T) {
+       f, _, _ := vpcFramework(t)
+       for _, node := range f.Nodes() {
+               vm, err := f.VMByName(node.Name)
+               if err != nil {
+                       t.Fatalf("looking up project VM for node %s: %v", 
node.Name, err)
+               }
+               if vm == nil {
+                       t.Errorf("no project VM named %s visible with 
CS_PROJECT_ID", node.Name)
+               }
+       }
+       // No node may carry the uninitialized taint after the CCM restart.
+       for _, node := range f.Nodes() {
+               for _, taint := range node.Spec.Taints {
+                       if taint.Key == 
"node.cloudprovider.kubernetes.io/uninitialized" {
+                               t.Errorf("node %s still has the uninitialized 
taint", node.Name)
+                       }
+               }
+       }
+}

Reply via email to