| <!-- |
| 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 |
| * [cmk](https://github.com/apache/cloudstack-cloudmonkey) (CloudMonkey), the |
| CloudStack CLI — the harness drives the CloudStack API through it |
| * `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 # phase 1: load balancer, nodes, annotations |
| make e2e-vpc # switch the environment to a VPC in a project |
| make test-e2e-vpc # phase 2: VPC / network ACL |
| make e2e-down # tear everything down |
| ``` |
| |
| Phase 2 builds on phase 1, so run them in that order; both test targets stop |
| with a clear message if the environment they need is not up. |
| |
| `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 |
| cmk -c hack/e2e/_out/cmk.ini listLoadBalancerRules listall=true |
| ``` |
| |
| The CloudStack UI is also available: run the simulator with `-p 8081:5050` |
| and open <http://localhost:8081/>, logging in as `admin` / `password`. |
| |
| ## How the harness talks to CloudStack |
| |
| The scripts call `cmk` directly, always in the form |
| |
| ```bash |
| cmk -c hack/e2e/_out/cmk.ini <command> [key=value ...] |
| ``` |
| |
| so every CloudStack command in the harness is one you can paste into a shell. |
| The config file is generated by the harness rather than read from |
| `~/.cmk/config`, so your own cmk profiles are left alone. (cmk takes its |
| config path only from `-c` or `$HOME`, with no environment variable for it, |
| which is why the flag is repeated rather than hidden behind a wrapper.) |
| |
| The generated profile authenticates with `admin`/`password` rather than API |
| keys, because the harness has to talk to CloudStack *before* any keys exist — |
| it is what mints them. Two cmk defaults do real work here: |
| |
| * `asyncblock = true` — cmk waits for async jobs such as |
| `deployVirtualMachine` and returns the finished result, so nothing in the |
| harness polls `queryAsyncJobResult`. |
| * `output = json` — responses come back *without* the `<command>response` |
| envelope, so a zone list is `.zone[0].id`, not |
| `.listzonesresponse.zone[0].id`. Worth knowing if you compare the scripts |
| against raw API output. |
| |
| ## 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 the API accepting admin credentials, 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 for the CCM: |
| |
| ```bash |
| cmk -c hack/e2e/_out/cmk.ini listUsers username=admin # -> the user id |
| cmk -c hack/e2e/_out/cmk.ini getUserKeys id=<user id> # -> apikey, secretkey |
| ``` |
| |
| `listUsers` is not a substitute for `getUserKeys` — it returns the API key but |
| never the secret. `registerUserKeys` is used only when no key pair exists yet, |
| because it *rotates* the keys, which would break a simulator you are reusing. |
| |
| 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 |
| cmk updateZone id=$ZONE guestcidraddress=172.30.0.0/24 |
| cmk createNetwork name=ccm-e2e-iso networkofferingid=$OFFERING \ |
| gateway=172.30.0.1 netmask=255.255.255.0 zoneid=$ZONE |
| cmk 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 |
| ``` |
| |
| ### Debugging |
| |
| You can use the VS Code extension |
| [Go](https://marketplace.visualstudio.com/items?itemName=golang.go) to debug |
| the CCM. Add the following 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", |
| "env": {}, |
| "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, |
| "trace": "verbose" |
| }, |
| { |
| "name": "Attach to Process", |
| "type": "go", |
| "request": "attach", |
| "mode": "local", |
| "processId": 0 |
| } |
| ] |
| } |
| ``` |
| |
| To debug against a real CloudStack installation instead of the simulator, |
| point `--cloud-config` at your own `cloud-config` and `--kubeconfig` at your |
| cluster's kubeconfig. |
| |
| ## The VPC scenario |
| |
| `make e2e-vpc` (that is, `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 # phase 1 |
| make test-e2e-vpc # phase 2, after `make e2e-vpc` |
| ``` |
| |
| Configuration comes from the environment, using the same variable names as the |
| existing opt-in acceptance tests in `cloudstack_test.go`. The make targets set |
| these for you from `hack/e2e/_out/`; the table matters if you invoke `go test` |
| directly: |
| |
| | 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 | |
| | --- | --- | |
| | `LB service provider cannot support this rule` on a VPC | The VPC virtual router accepts only a restricted set of public load balancer ports. 80 and 8080 work; an arbitrary high port such as 8081 is rejected. Pick a port the router supports when adding a VPC test. | |
| | 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`. | |