Kubernetes-native declarative infrastructure for STACKIT.
What is the Cluster API Provider STACKIT
The Cluster API brings declarative, Kubernetes-style APIs to cluster creation, configuration and management.
The API itself is shared across multiple cloud providers allowing for true STACKIT hybrid deployments of Kubernetes.
Cluster API Provider STACKIT is abbreviated as CAPSTK.
⚠️ WARNING ⚠️
cluster-api-provider-stackitis not an officially supported STACKIT project and is still in active development.The implementation is validated through end-to-end tests, but it has not yet received the same level of quality reviews, production hardening, or long-term operational validation as a mature provider or officially supported STACKIT projects.
Use at your own risk. Please review the code carefully before using it in production environments.
Documentation
Please see our book for in-depth documentation.
Use make serve-book to serve the book locally from this repository.
Launching a Kubernetes cluster on STACKIT
Check out the Quick Start for launching a cluster on STACKIT.
Features
- Native Kubernetes manifests and API
- Doesn't use SSH for bootstrapping nodes.
- Installs only the minimal components to bootstrap a control plane and workers.
- Supports control planes and worker nodes on STACKIT VM instances.
- Manages the bootstrapping of security groups and vm instances (networks are excluded for now).
- Optional Bastion hosts for easier access of control plane or worker nodes
- Tested Kubernetes Lifecycle (Scaling, Kubernetes Upgrades), see E2E-Tests
- ClusterClass Topology
- Support varioius Linux Distributions (tested with Ubuntu and Flatcar)
- Release distribution via OCI Images and Helm Charts
-
Manage the bootstrapping of networks, security groups and vm instances.
- Deploys Kubernetes control planes into private subnets with a separate bastion server.
- SKE support
Compatibility with Cluster API and Kubernetes Versions
This provider's versions are compatible with the following versions of Cluster API and support all Kubernetes versions that is supported by its compatible Cluster API version:
| Cluster API v1alpha4 (v0.4) | Cluster API v1beta1 (v1.x) | |
|---|---|---|
CAPSTK v1alpha1 (main) | x | ✓ |
(See Kubernetes support matrix of Cluster API versions).
Getting involved and contributing
Interested in contributing to cluster-api-provider-stackit? We welcome your ideas, contributions, and help. Feel free to reach out to the maintainers to learn how to get involved.
You do not need official write permissions to make an impact. We encourage active community members to take ownership, drive improvements, and help move the project forward.
We are also happy to welcome new maintainers over time. Get involved and show us what you can do!
Quick Start
Prerequisites
Install go, docker, kubectl, kind, clusterctl, and the stackit CLI.
You also need a STACKIT project, service-account JSON key, existing network,
image, and machine type.
Furthermore, make sure that the provided service-account has an appropriate set of permissions.
Place the downloaded service-account JSON key at .stackit/cluster-api-provider-stackit.json
inside the repo (create the .stackit/ directory if it does not exist yet). It is listed in
.gitignore, so the key is never committed, and the path works identically for every
contributor regardless of where the repo is checked out.
export STACKIT_PROJECT_ID=<project-uuid>
export STACKIT_REGION=eu01
export STACKIT_NETWORK_ID=<network-uuid>
export STACKIT_IMAGE_ID=<image-uuid>
export STACKIT_MACHINE_TYPE=c2i.4
export STACKIT_SSH_KEY_NAME=""
export STACKIT_SERVICE_ACCOUNT_JSON_FILE=./.stackit/cluster-api-provider-stackit.json
export STACKIT_SERVICE_ACCOUNT_JSON_B64="$(base64 < "${STACKIT_SERVICE_ACCOUNT_JSON_FILE}" | tr -d '\n')"
export STACKIT_CLOUD_CONTROLLER_MANAGER_IMAGE=ghcr.io/stackitcloud/cloud-provider-stackit/cloud-controller-manager:v1.35.3
The default template (templates/cluster-template.yaml) does not configure SSH access. To use
an SSH key, add sshKeyName: <key-name> to the StackitMachineTemplate specs before creating
the workload cluster. If you use the bastion variant instead
(templates/cluster-template-bastion.yaml), set STACKIT_SSH_KEY_NAME above to your key name,
it is substituted into that template directly.
Create the management cluster
Optional: kind-config.yaml for enterprise proxies (e.g. Zscaler)
If your machine sits behind a TLS-intercepting enterprise proxy such as Zscaler, outbound HTTPS
calls made from inside the kind node container (pulling images, talking to the STACKIT API,
etc.) will fail certificate validation. The node runs as its own container with its own OS
certificate store, which does not include the proxy's root CA even though your host already
trusts it. Mount the host's CA bundle into the node at the same path it expects:
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
- role: control-plane
extraMounts:
- hostPath: /etc/ssl/certs/ca-certificates.crt
containerPath: /etc/ssl/certs/ca-certificates.crt
readOnly: true
Save this as kind-config.yaml in the repo root before running kind create cluster below. If
you are not behind such a proxy, drop the --config kind-config.yaml flag from the command.
kind create cluster --name capi-stackit
kubectl config use-context kind-capi-stackit
clusterctl init \
--config hack/clusterctl-local.yaml \
--core cluster-api \
--bootstrap kubeadm \
--control-plane kubeadm
Install credentials and provider
kubectl create secret generic stackit-credentials \
--namespace default \
--from-literal=project-id="${STACKIT_PROJECT_ID}" \
--from-file=serviceaccount.json="${STACKIT_SERVICE_ACCOUNT_JSON_FILE}"
export IMG=cluster-api-provider-stackit:dev
make docker-build IMG="${IMG}"
kind load docker-image "${IMG}" --name capi-stackit
make deploy IMG="${IMG}"
kubectl rollout status \
-n cluster-api-provider-stackit-system \
deployment/cluster-api-provider-stackit-controller-manager
Create a workload cluster
export CLUSTER_NAME=stackit-workload
export NAMESPACE=default
export KUBERNETES_VERSION=v1.35.3
export KUBERNETES_APT_REPOSITORY_MINOR=v1.35
export CONTROL_PLANE_MACHINE_COUNT=1
export WORKER_MACHINE_COUNT=1
export STACKIT_CREDENTIALS_SECRET_NAME=stackit-credentials
clusterctl generate cluster "${CLUSTER_NAME}" \
--from templates/cluster-template.yaml \
--target-namespace "${NAMESPACE}" \
> cluster.yaml
kubectl apply -f cluster.yaml
Watch progress:
kubectl get cluster,machine,stackitcluster,stackitmachine -n "${NAMESPACE}"
kubectl logs \
-n cluster-api-provider-stackit-system \
deployment/cluster-api-provider-stackit-controller-manager \
-c manager \
--tail=100
After the workload API is reachable, install a CNI:
clusterctl get kubeconfig "${CLUSTER_NAME}" -n "${NAMESPACE}" > /tmp/"${CLUSTER_NAME}".kubeconfig
make install-workload-cni WORKLOAD_KUBECONFIG=/tmp/"${CLUSTER_NAME}".kubeconfig
Clean up
kubectl delete cluster "${CLUSTER_NAME}" -n "${NAMESPACE}"
kubectl get cluster,machine,stackitcluster,stackitmachine -A
make undeploy
make uninstall
kind delete cluster --name capi-stackit
If deletion is interrupted, inspect remaining cloud resources:
stackit server list --project-id "${STACKIT_PROJECT_ID}" --region "${STACKIT_REGION}"
stackit load-balancer list --project-id "${STACKIT_PROJECT_ID}" --region "${STACKIT_REGION}"
Topics
OS Images
Ubuntu Nodes
CAPSTK includes templates/cluster-template.yaml as the default example for
Ubuntu-based clusters:
- control-plane machines use the Ubuntu image from
STACKIT_IMAGE_ID - worker machines use the same Ubuntu image from
STACKIT_IMAGE_ID - kubeadm bootstrap runs through cloud-init
Select one of the available Ubuntu images:
stackit image list \
--project-id "${STACKIT_PROJECT_ID}" \
--region "${STACKIT_REGION}" \
--output-format json \
| jq -r '.[] | select(.name | test("Ubuntu")) | "\(.id)\t\(.name)"'
The default template currently supports generic Ubuntu cloud images by installing Kubernetes node dependencies at first boot:
- install
containerd,kubelet,kubeadm, andkubectlwithapt-get - use the Kubernetes package repository selected by
KUBERNETES_APT_REPOSITORY_MINOR - configure containerd with
SystemdCgroup = true - enable
br_netfilterand Kubernetes bridge/IP forwarding sysctls - register kubelet with
cloud-provider=external - make kubeadm use the STACKIT hostname from cloud-init metadata
(
{{ ds.meta_data.local_hostname }}), so cloud-provider-stackit can map the Kubernetes node to the STACKIT server
Render the default template like any other clusterctl template:
export STACKIT_IMAGE_ID=<ubuntu-image-id>
export KUBERNETES_VERSION=v1.35.3
export KUBERNETES_APT_REPOSITORY_MINOR=v1.35
clusterctl generate cluster "${CLUSTER_NAME}" \
--from templates/cluster-template.yaml \
--kubernetes-version "${KUBERNETES_VERSION}" \
--control-plane-machine-count "${CONTROL_PLANE_MACHINE_COUNT}" \
--worker-machine-count "${WORKER_MACHINE_COUNT}" \
| kubectl apply -f -
Notes:
- Ubuntu SSH access normally uses the
ubuntuuser. - Install a workload CNI after the API server is reachable.
- The default template downloads Kubernetes packages during first boot. This is useful for development and e2e testing, but production clusters should use a kubeadm-ready image that already contains the expected container runtime and Kubernetes node packages for the chosen Kubernetes minor.
- Prefer non-ARM Ubuntu images until this provider publishes a supported image matrix.
Flatcar Worker Nodes
See the official Flatcar STACKIT docs for generic Flatcar image usage on STACKIT.
CAPSTK includes templates/cluster-template-flatcar-workers.yaml as an example
for mixed-image clusters:
- control-plane machines use the regular Ubuntu image from
STACKIT_IMAGE_ID - worker machines use the Flatcar image from
STACKIT_WORKER_IMAGE_ID
Example amd64 Flatcar image:
export STACKIT_WORKER_IMAGE_ID=419c31da-39e3-4ea3-9bd8-699b44e8394f # Flatcar 4459.2.4
Flatcar requires a different bootstrap path than Ubuntu:
- use
KubeadmConfigTemplate.spec.template.spec.format: ignition - enable Cluster API's
KubeadmBootstrapFormatIgnitionfeature gate - do not reuse Ubuntu
apt-getbased bootstrap commands - make kubeadm use the STACKIT hostname from
/run/metadata/flatcar(COREOS_OPENSTACK_HOSTNAME), otherwise the node may register aslocalhostand cloud-provider-stackit cannot map it to the STACKIT server
Render the example template like any other clusterctl template:
clusterctl generate cluster "${CLUSTER_NAME}" \
--from templates/cluster-template-flatcar-workers.yaml \
--kubernetes-version "${KUBERNETES_VERSION}" \
--control-plane-machine-count "${CONTROL_PLANE_MACHINE_COUNT}" \
--worker-machine-count "${WORKER_MACHINE_COUNT}" \
| kubectl apply -f -
Notes:
-
Flatcar SSH access uses the
coreuser. When connecting through the CAPSTK bastion, use the bastion image's SSH user for the first hop, usuallyubuntu, andcorefor the Flatcar node:ssh -i "${CLUSTER_SSH_KEY}" core@<flatcar-node-internal-ip> \ -o "ProxyCommand ssh -W %h:%p -i ${CLUSTER_SSH_KEY} ubuntu@${BASTION_HOST}" -
Install a workload CNI after the API server is reachable.
-
The example template downloads Kubernetes binaries during first boot. Production images should provide those binaries through an image build, package, or system extension instead.
Accessing cluster instances
By default, workload clusters created by cluster-api-provider-stackit do not
expose SSH access. Cluster API bootstrap does not require SSH; CABPK provides
cloud-init user data through Kubernetes bootstrap Secrets.
For break-glass access, a StackitCluster can optionally ask the provider to
create one managed SSH bastion VM. The bastion is attached to the same STACKIT
network as the cluster nodes, gets a provider-managed public IP, and has a
provider-managed security group allowing TCP/22 only from configured CIDRs.
When bastion access is enabled, the provider also manages the node-side SSH path: it creates a separate security group for cluster nodes, allows TCP/22 from the bastion security group, and attaches that node SSH security group to control-plane and worker VMs.
Prerequisites
- The workload cluster was generated with
clusterctl generate clusterand applied to the management cluster. - The configured STACKIT service account role includes the bastion permissions documented in IAM Permissions Used.
- The bastion image is an Ubuntu image with SSH enabled for the expected user,
normally
ubuntu. - The STACKIT SSH key named in
spec.bastion.sshKeyNamealready exists for the service account used by the provider. STACKIT key pairs are not shared across service accounts, so importing a key with an inspection or admin service account does not make it usable by the controller service account. - To SSH from the bastion into cluster nodes, the control-plane and worker
StackitMachineTemplateresources must also setspec.template.spec.sshKeyName. The bastion does not inject SSH keys into existing node VMs.
Enable the bastion
Patch or edit the generated StackitCluster:
apiVersion: infrastructure.cluster.x-k8s.io/v1alpha1
kind: StackitCluster
metadata:
name: <cluster-name>
namespace: <namespace>
spec:
bastion:
enabled: true
imageID: <ubuntu-image-id>
machineType: <machine-type>
sshKeyName: <existing-stackit-ssh-key-name>
allowedCIDRs:
- <your-public-ip-or-network-cidr>
rootVolume:
sizeGiB: 50
performanceClass: storage_premium_perf6
deleteOnTermination: true
cloudInitRef:
kind: ConfigMap
name: <bastion-cloud-init-configmap>
key: userData
Use a narrow allowedCIDRs value such as 203.0.113.10/32 where possible.
0.0.0.0/0 allows SSH from anywhere and should only be used deliberately.
Set rootVolume when the chosen machine type's flavor disk is too small for
the chosen image.
Set cloudInitRef when the bastion host needs additional
packages, users, files, or other cloud-init customization. The provider reads
the referenced ConfigMap or Secret and passes the value as-is to the bastion
VM. It is not applied to control-plane or worker nodes.
For node access, set the same or another existing SSH key on the machine templates:
apiVersion: infrastructure.cluster.x-k8s.io/v1alpha1
kind: StackitMachineTemplate
metadata:
name: <machine-template-name>
spec:
template:
spec:
sshKeyName: <existing-stackit-ssh-key-name>
Leaving StackitMachineTemplate.spec.template.spec.sshKeyName empty is valid,
but SSH to nodes through the bastion will not work.
How node SSH access is wired
Enabling the bastion creates two provider-managed SSH security groups:
- The bastion SSH security group is attached to the bastion VM. It allows
TCP/22 from
spec.bastion.allowedCIDRs. - The node SSH security group is attached to each provider-managed control-plane and worker VM. It allows TCP/22 from the bastion security group by using the bastion security group as the remote source.
This means users connect from their workstation to the bastion public IP, and then from the bastion to node internal IPs. The provider manages the network permissions for both hops, but it does not manage SSH users or private key files. The SSH key named on the bastion and node specs must already exist in STACKIT for the service account used by the provider.
The node SSH security group is shared by all nodes in the cluster. New nodes
created while bastion is enabled get the group attached during
StackitMachine reconciliation.
Customize the bastion with cloud-init
spec.bastion.cloudInitRef references a complete cloud-init user-data document
for the bastion VM. Use a ConfigMap for non-sensitive configuration:
apiVersion: v1
kind: ConfigMap
metadata:
name: bastion-cloud-init
data:
userData: |
#cloud-config
packages:
- jq
---
spec:
bastion:
enabled: true
imageID: <ubuntu-image-id>
machineType: <machine-type>
sshKeyName: <existing-stackit-ssh-key-name>
allowedCIDRs:
- <your-public-ip-or-network-cidr>
cloudInitRef:
kind: ConfigMap
name: bastion-cloud-init
key: userData
Use a Secret instead when the cloud-init document contains sensitive values:
apiVersion: v1
kind: Secret
metadata:
name: bastion-cloud-init
stringData:
userData: |
#cloud-config
packages:
- jq
---
spec:
bastion:
cloudInitRef:
kind: Secret
name: bastion-cloud-init
key: userData
The referenced object must be in the same namespace as the StackitCluster.
The provider does not merge the referenced data with node bootstrap data.
Cloud-init user data is applied only when STACKIT creates a VM, so an existing
bastion cannot be reconfigured in place. If the referenced ConfigMap or Secret
content changes, the provider deletes and recreates the provider-managed
bastion VM with the new user data.
Recreating the bastion temporarily interrupts SSH access, assigns a new provider-managed bastion server, and may assign a new public IP. The provider also removes the old node SSH security group path and recreates it for the new bastion so control-plane and worker nodes remain reachable through the current bastion after reconciliation completes.
Get the bastion IP
Use the management-cluster kubeconfig:
kubectl get stackitcluster <cluster-name> \
--namespace <namespace> \
-o jsonpath='{.status.bastion.publicIP}'
Or inspect the printcolumn:
kubectl get stackitclusters --namespace <namespace>
The Bastion IP column is empty until STACKIT has assigned and attached the
public IP.
Get node internal IPs
Use the workload-cluster kubeconfig:
kubectl get nodes \
-o custom-columns=NAME:.metadata.name,IP:'{.status.addresses[?(@.type=="InternalIP")].address}'
Alternatively, inspect the management-cluster StackitMachine addresses:
kubectl get stackitmachines --namespace <namespace> \
-o custom-columns=NAME:.metadata.name,IP:'{.status.addresses[?(@.type=="InternalIP")].address}'
Connect through the bastion
Set local variables:
export CLUSTER_SSH_KEY="$HOME/.ssh/<private-key-file>"
export BASTION_HOST="$(kubectl get stackitcluster <cluster-name> \
--namespace <namespace> \
-o jsonpath='{.status.bastion.publicIP}')"
Connect to a node internal IP through the bastion:
ssh -i "${CLUSTER_SSH_KEY}" ubuntu@<node-internal-ip> \
-o "ProxyCommand ssh -W %h:%p -i ${CLUSTER_SSH_KEY} ubuntu@${BASTION_HOST}"
Use the node image's SSH user for the final hop. For example, Ubuntu nodes
usually use ubuntu, while Flatcar nodes use core.
An equivalent SSH config entry:
Host 10.*
User ubuntu
IdentityFile <cluster-ssh-key>
ProxyCommand ssh -W %h:%p ubuntu@<bastion-public-ip>
Cleanup
The provider deletes the managed bastion server, public IP, security group, and
security group rules when the StackitCluster is deleted. It also removes the
provider-managed node SSH security group from cluster nodes and deletes that
security group.
The same cleanup path runs when spec.bastion.enabled is changed from true
to false: the provider removes the node SSH security group from
provider-managed control-plane and worker VMs, deletes the node SSH security
group and its rules, and then deletes the bastion server, public IP, and
bastion security group.
Additional Notes
Using the STACKIT CLI instead of kubectl
It is also possible to use STACKIT CLI commands instead of kubectl to gather information about the cluster nodes.
For example, to use the STACKIT CLI to get the public IP address of the SSH bastion host, use this STACKIT CLI command:
set CLUSTER_NAME stackit-bastion-test
stackit server list -o json | jq -r --arg cluster "$CLUSTER_NAME" '
.[]
| select(.labels["cluster.x-k8s.io/cluster-name"] == $cluster)
| select(.labels["cluster-api-provider-stackit/resource-role"] == "bastion")
| .nics[]?.publicIp
'
188.34.94.28
You should substitute the correct cluster name for <CLUSTER_NAME> in the above command.
Similarly, to obtain the list of private IP addresses of the cluster nodes, use this STACKIT CLI command:
set CLUSTER_NAME stackit-bastion-test
stackit server list -o json | jq -r --arg cluster "$CLUSTER_NAME" '
.[]
| select(.labels["cluster.x-k8s.io/cluster-name"] == $cluster)
| select(.labels["cluster-api-provider-stackit/resource-role"] != "bastion")
| .nics[]?.ipv4
'
For names plus private IPs:
stackit server list -o json | jq -r --arg cluster "$CLUSTER_NAME" '
.[]
| select(.labels["cluster.x-k8s.io/cluster-name"] == $cluster)
| select(.labels["cluster-api-provider-stackit/resource-role"] != "bastion")
| [.name, (.nics[]?.ipv4 // empty)]
| @tsv
'
Finally, to obtain STACKIT instance names mapped with their private IPs, you can use this STACKIT CLI command:
stackit server list -o json | jq -r --arg cluster "$CLUSTER_NAME" '
.[]
| select(.labels["cluster.x-k8s.io/cluster-name"] == $cluster)
| select(.labels["cluster-api-provider-stackit/resource-role"] != "bastion")
| [.name, (.nics[]?.ipv4 // empty)]
| @tsv
'
Note that your STACKIT CLI must be configured with credentials that enable you to query the STACKIT Servers API.
Failure Domains
The provider publishes STACKIT availability zones as Cluster API failure
domains on StackitCluster.status.failureDomains.
For a region such as eu01, the current model exposes:
eu01-1
eu01-2
eu01-3
StackitMachine.spec.availabilityZone is validated against the published
failure domains when they are available. Invalid zones result in clear
conditions instead of cloud API calls.
The existing single-zone templates continue to work. More advanced multi-AZ scheduling remains intentionally limited for now.
A future improvement:
Failure domains are currently derived from the configured region as <region>-1, <region>-2, and <region>-3. Production behavior should discover available STACKIT zones dynamically and publish only real failure domains.
Furthermore, Respect Machine.spec.failureDomain. The provider currently uses StackitMachine.spec.availabilityZone. For full CAPI behavior, if CAPI sets Machine.spec.failureDomain, the infrastructure machine must be placed in that failure domain. The provider should also consider surfacing the actual placement through StackitMachine.status.failureDomain.
We should document FailureDomain configuration for Controlplanes and Worker nodes.
IAM Permissions Used
cluster-api-provider-stackit authenticates to STACKIT with the service
account JSON stored in the StackitCluster.spec.credentialsSecretRef Secret.
That service account should use a custom project role with only the permissions
needed by the infrastructure provider.
Do not use broad project administrator roles for the controller. STACKIT documents custom roles as the way to bundle an explicit permission set, and role bindings as the way to assign that role to a user or service account.
Required provider permissions
The current provider implementation uses these STACKIT API operations:
| Provider action | Code path | STACKIT permission |
|---|---|---|
| Read the configured network | GetNetwork | iaas.network.get |
| Create VM instances | CreateServer | iaas.server.create |
| Find existing tagged VM instances | ListServers | iaas.server.list |
| Read VM state | GetServer | iaas.server.get |
| Read VM NIC addresses for CAPI addresses and load balancer targets | ListServerNICs | iaas.server.nic.list |
| Delete VM instances | DeleteServer | iaas.server.delete |
| Create the optional bastion public IP | CreatePublicIP | iaas.public-ip.create |
| Find existing tagged bastion public IPs | ListPublicIPs | iaas.public-ip.list |
| Read the bastion public IP after attach | GetPublicIP | iaas.public-ip.get |
| Attach the bastion public IP to the bastion server | AddPublicIpToServer | iaas.server.public-ip.add |
| Detach the bastion public IP during cleanup | RemovePublicIpFromServer | iaas.server.public-ip.remove |
| Delete the optional bastion public IP | DeletePublicIP | iaas.public-ip.delete |
| Create the optional bastion security group | CreateSecurityGroup | iaas.security-group.create |
| Find existing tagged bastion security groups | ListSecurityGroups | iaas.security-group.list |
| Create bastion SSH ingress rules | CreateSecurityGroupRule | iaas.security-group.rule.create |
| List bastion SSH ingress rules | ListSecurityGroupRules | iaas.security-group.rule.list |
| Delete bastion SSH ingress rules during cleanup | DeleteSecurityGroupRule | iaas.security-group.rule.delete |
| Attach the bastion security group to the bastion server | AddSecurityGroupToServer | iaas.server.security-group.add |
| Detach the bastion security group during cleanup | RemoveSecurityGroupFromServer | iaas.server.security-group.remove |
| Delete the optional bastion security group | DeleteSecurityGroup | iaas.security-group.delete |
| Create the API server network load balancer | CreateLoadBalancer | nlb.loadbalancer.create |
| Find existing tagged load balancers | ListLoadBalancers | nlb.loadbalancer.list |
| Read the load balancer before target updates | GetLoadBalancer | nlb.loadbalancer.get |
| Delete the API server load balancer | DeleteLoadBalancer | nlb.loadbalancer.delete |
| Replace the API server target pool | UpdateTargetPool | nlb.targetpool.replace |
The least-privilege role for the current provider is therefore:
iaas.network.get
iaas.public-ip.create
iaas.public-ip.delete
iaas.public-ip.get
iaas.public-ip.list
iaas.server.create
iaas.server.delete
iaas.server.get
iaas.server.list
iaas.server.nic.list
iaas.server.public-ip.add
iaas.server.public-ip.remove
iaas.server.security-group.add
iaas.server.security-group.remove
iaas.security-group.create
iaas.security-group.delete
iaas.security-group.list
iaas.security-group.rule.create
iaas.security-group.rule.delete
iaas.security-group.rule.list
nlb.loadbalancer.create
nlb.loadbalancer.delete
nlb.loadbalancer.get
nlb.loadbalancer.list
nlb.targetpool.replace
This list covers StackitCluster and StackitMachine reconciliation,
including the optional provider-managed bastion host. It does not include
permissions for manually creating networks, SSH keys, images, or other
prerequisite resources. It also does not include permissions for the in-cluster
cloud-provider-stackit add-on if you configure that add-on to manage
Kubernetes Service load balancers beyond the provider-managed API server load
balancer.
Create a strict role and service account with OpenTofu
Use OpenTofu and the STACKIT provider to create the custom role, service account, role assignment, and service-account key as one managed setup.
The bootstrap identity used by OpenTofu needs these setup permissions:
iam.role.addto create the custom roleiam.role.getandiam.role.listto read role stateiam.member.addto assign the role to the service accountiam.member.getto read role-assignment stateiam.service-account.createto create the service accountiam.service-account.getandiam.service-account.listto read service-account stateiam.service-account-key.createto create the service-account key
If the same OpenTofu configuration should also destroy the setup later, the
bootstrap identity also needs the corresponding remove/delete permissions:
iam.role.remove, iam.service-account.delete, and
iam.service-account-key.delete.
Create a STACKIT role:
resource "stackit_authorization_project_custom_role" "cluster_api_provider_stackit" {
resource_id = var.project_id
name = var.role_name
description = "Least-privilege role for cluster-api-provider-stackit VM, bastion, and API-server load-balancer reconciliation."
permissions = [
"iaas.network.get",
"iaas.public-ip.create",
"iaas.public-ip.delete",
"iaas.public-ip.get",
"iaas.public-ip.list",
"iaas.server.create",
"iaas.server.delete",
"iaas.server.get",
"iaas.server.list",
"iaas.server.nic.list",
"iaas.server.public-ip.add",
"iaas.server.public-ip.remove",
"iaas.server.security-group.add",
"iaas.server.security-group.remove",
"iaas.security-group.create",
"iaas.security-group.delete",
"iaas.security-group.list",
"iaas.security-group.get",
"iaas.security-group.rule.create",
"iaas.security-group.rule.delete",
"iaas.security-group.rule.list",
"iaas.security-group.rule.get",
"nlb.loadbalancer.create",
"nlb.loadbalancer.delete",
"nlb.loadbalancer.get",
"nlb.loadbalancer.list",
"nlb.targetpool.replace",
]
}
Create a STACKIT service account, assign the role and create a service account key:
resource "stackit_service_account" "cluster_api_provider_stackit" {
project_id = var.project_id
name = var.service_account_name
}
resource "stackit_authorization_project_role_assignment" "cluster_api_provider_stackit" {
resource_id = var.project_id
role = stackit_authorization_project_custom_role.cluster_api_provider_stackit.name
subject = stackit_service_account.cluster_api_provider_stackit.email
}
resource "stackit_service_account_key" "cluster_api_provider_stackit" {
project_id = var.project_id
service_account_email = stackit_service_account.cluster_api_provider_stackit.email
depends_on = [
stackit_authorization_project_role_assignment.cluster_api_provider_stackit,
]
}
You will find a working example in hack/tf/iam-setup.
To apply it:
tofu init
tofu apply \
-var "project_id=${STACKIT_PROJECT_ID}" \
-var "bootstrap_service_account_key_path=${BOOTSTRAP_SERVICE_ACCOUNT_KEY_PATH}"
Write the generated key to a local file:
export STACKIT_SERVICE_ACCOUNT_JSON_FILE=./.stackit/cluster-api-provider-stackit-serviceaccount.json
mkdir -p "$(dirname "${STACKIT_SERVICE_ACCOUNT_JSON_FILE}")"
tofu output -raw service_account_key_json > "${STACKIT_SERVICE_ACCOUNT_JSON_FILE}"
Next, create the Kubernetes Secret used by StackitCluster:
kubectl create secret generic stackit-credentials \
--namespace default \
--from-literal=project-id="${STACKIT_PROJECT_ID}" \
--from-file=serviceaccount.json="${STACKIT_SERVICE_ACCOUNT_JSON_FILE}"
For Developers
Verify the strict role with the billable e2e tests, not only by reading the permission list. The create/delete scenario exercises VM creation, VM lookup, VM deletion, load balancer creation, target-pool updates, and load balancer cleanup.
Run at least:
export STACKIT_E2E_CREATE_CLUSTER=true
export STACKIT_E2E_NODE_REF=true
export STACKIT_CREDENTIALS_SECRET_NAME=stackit-credentials
export STACKIT_CREDENTIALS_SECRET_NAMESPACE=default
make test-e2e-workload-noderef
To verify the optional bastion path, first import the SSH key pair with the
same service account stored in stackit-credentials; key pairs imported with a
different service account are not visible to the provider. Then set
STACKIT_SSH_KEY_NAME and STACKIT_BASTION_SSH_KEY_NAME and run:
export STACKIT_E2E_CREATE_CLUSTER=true
export STACKIT_E2E_BASTION=true
export STACKIT_SSH_KEY_NAME=<provider-service-account-keypair-name>
export STACKIT_BASTION_SSH_KEY_NAME="${STACKIT_SSH_KEY_NAME}"
make test-e2e-workload-bastion
For release validation, also run the scale, worker-upgrade, control-plane upgrade, and topology e2e targets with the same strict service account:
make test-e2e-workload-scale
make test-e2e-workload-upgrade-workers
make test-e2e-workload-upgrade-control-plane
make test-e2e-workload-topology
[!CAUTION]
Some broader SDK integration tests call helper APIs that are not used by the provider at runtime. For example,TestSDKClientListNetworksIntegrationcallsListNetworksand therefore needsiaas.network.list; the provider reconciler only callsGetNetworkwith the configured network ID, soiaas.network.getis sufficient for runtime.
Developer Guide
Install CRDs into the current cluster:
make install
Run the controller locally against the current kubeconfig context:
make run
Build and deploy the controller image:
export IMG=<registry>/cluster-api-provider-stackit:<tag>
make docker-build docker-push IMG="$IMG"
make deploy IMG="$IMG"
For a local kind management cluster, build and load the image instead of pushing it:
export IMG=cluster-api-provider-stackit:dev
make docker-build IMG="$IMG"
kind load docker-image "$IMG" --name capi-stackit
make deploy IMG="$IMG"
The local development cluster used during validation is kind-capi-stackit.
Architecture
Overview
The provider has four main areas:
- API types and CRDs
- Controllers
- Cloud abstraction layer
- clusterctl templates and release assets
Key packages:
api/v1alpha1/ Provider API types
controller/ Reconciliation logic
cloud/ Cloud client interface and SDK implementation
cloud/fake/ In-memory fake for tests
util/ Shared helpers
templates/ clusterctl templates
config/ Kubebuilder manifests
The controllers do not call the STACKIT SDK directly. They use the cloud client interface, which keeps reconciliation testable and allows fake-client envtest coverage.
Cluster API Contract
The provider implements the Cluster API infrastructure contract for:
StackitClusterStackitClusterTemplateStackitMachineStackitMachineTemplate
Important contract behavior:
StackitCluster.status.initialization.provisionedis set when cluster infrastructure is ready.StackitMachine.status.initialization.provisionedis set when the VM is provisioned.StackitMachine.spec.providerIDis set to the provider-compatible value.- Conditions include
observedGeneration. - Paused clusters or paused resources must not trigger cloud API calls.
- Finalizers clean up provider-owned resources before Kubernetes object removal.
StackitCluster.spec.controlPlaneEndpoint is used for the Cluster API
infrastructure cluster contract when the provider manages the API server load
balancer endpoint.
Controllers
StackitClusterReconciler is responsible for:
- Reading credentials
- Looking up the configured network
- Managing the optional API server load balancer
- Publishing failure domains
- Updating readiness and contract status
- Cleaning up provider-managed load balancers on deletion
StackitMachineReconciler is responsible for:
- Waiting for bootstrap data
- Creating STACKIT servers
- Setting provider IDs and addresses
- Registering control-plane machines as API server load balancer targets
- Deleting servers and load balancer targets on teardown
Reconciliation must be idempotent. Re-running the same reconcile loop should be safe and should not create duplicate cloud resources.
Cloud Client
The cloud client abstraction hides STACKIT SDK details from controllers.
It covers:
- Server create, get, list, and delete
- Network lookup
- Load balancer create, list, delete, and target-pool updates
- Provider ID helpers
- Error classification
Controllers should handle classified errors differently:
- transient errors should be retried
- terminal validation errors should set clear conditions
- not-found errors during deletion should be treated as successful cleanup where appropriate
Tests use an in-memory fake cloud client for deterministic reconciliation coverage. SDK integration tests are opt-in and require real STACKIT credentials.
STACKIT ProviderID Compatibility
The providerID format is verified against the local cloud-provider-stackit
repository.
Relevant reference points:
pkg/ccm/instances.go:Instances.makeInstanceIDreturnsstackit://<server-id>.pkg/ccm/instances.go:instanceIDFromProviderIDparsesstackit://<server-id>with no project or region component.pkg/ccm/instances.go:getInstanceresolves nodes withGetServer(projectID, region, serverID), where project and region come from the cloud-controller-manager configuration.pkg/ccm/instances_test.go: the "new providerID" table entry expectsstackit://hello-server.
cluster-api-provider-stackit therefore writes:
StackitMachine.spec.providerID = stackit://<server-id>
StackitMachine.status.providerID = stackit://<server-id>
Cluster API then surfaces StackitMachine.spec.providerID to
Machine.spec.providerID, and cloud-provider-stackit uses the same value for
Node.spec.providerID. Project ID and region are intentionally not encoded in
the providerID.
Testing
Unit and Env-Tests
Testing is split into layers:
- unit tests for helper functions and cloud logic
- envtest controller tests with Kubernetes API machinery
- opt-in SDK integration tests against a real STACKIT project
- opt-in e2e tests that create billable STACKIT resources
Default test command:
make test
This runs:
- generated manifest checks through controller-gen
go fmtgo vet- envtest-backed Go tests, excluding
/e2e
Real cloud tests are gated by environment variables so they do not run accidentally.
E2E-Tests
The e2e suite runs against an isolated kind management cluster. The generic target runs manager startup, metrics, webhook certificate, and webhook CA injection checks without STACKIT cloud resources:
make test-e2e
The real cloud paths create billable STACKIT servers, load balancers, public IPs, and security groups. Run them only in a dedicated STACKIT project with cleanup permissions and cost controls. The main workload scenarios have dedicated make targets:
| Scenario | Make target | Opt-in flag |
|---|---|---|
| NodeRef/providerID and Ready Nodes | make test-e2e-workload-noderef | STACKIT_E2E_NODE_REF=true |
| Bastion cluster create/delete with provider-managed bastion and node SSH security group cleanup | make test-e2e-workload-bastion | STACKIT_E2E_CREATE_CLUSTER=true, STACKIT_E2E_BASTION=true |
| Worker scale with Ready Nodes | make test-e2e-workload-scale | STACKIT_E2E_SCALE_WORKLOAD=true |
| Worker upgrade with Ready replacement Nodes | make test-e2e-workload-upgrade-workers | STACKIT_E2E_UPGRADE_WORKLOAD_WORKERS=true |
| Control-plane upgrade with a Ready replacement Node | make test-e2e-workload-upgrade-control-plane | STACKIT_E2E_UPGRADE_WORKLOAD_CONTROL_PLANE=true |
| ClusterClass topology create/ready/delete | make test-e2e-workload-topology | STACKIT_E2E_TOPOLOGY_WORKLOAD=true |
The suite also contains lower-level real cloud scenarios without dedicated make
targets. Run them through go test -tags=e2e ./test/e2e or make test-e2e
with the corresponding opt-in flag and a focused Ginkgo expression:
| Scenario | Opt-in flag | Suggested focus |
|---|---|---|
Single StackitMachine VM create/delete and leak check | STACKIT_E2E_CREATE_VMS=true | create and delete a real STACKIT VM |
| 1 control-plane / 1 worker infrastructure lifecycle without workload Node readiness | STACKIT_E2E_CREATE_CLUSTER=true | create and delete a 1 control-plane / 1 worker workload Cluster |
Infra-only MachineDeployment worker VM scale up/down | STACKIT_E2E_SCALE_WORKERS=true | scale a worker MachineDeployment |
Infra-only MachineDeployment worker VM replacement during version upgrade | STACKIT_E2E_UPGRADE_WORKERS=true | replace worker VMs during a MachineDeployment version upgrade |
Common required environment:
STACKIT_PROJECT_IDSTACKIT_REGIONSTACKIT_NETWORK_IDSTACKIT_IMAGE_IDSTACKIT_AVAILABILITY_ZONESTACKIT_CREDENTIALS_SECRET_NAMESTACKIT_CREDENTIALS_SECRET_NAMESPACE
Useful common options:
KUBERNETES_VERSION, for create/scale/topology pathsSTACKIT_E2E_UPGRADE_FROMandSTACKIT_E2E_UPGRADE_TO, for upgrade pathsSTACKIT_SSH_KEY_NAME, for node SSH keys when a scenario should attach oneSTACKIT_BASTION_SSH_KEY_NAME, orSTACKIT_SSH_KEY_NAME, whenSTACKIT_E2E_BASTION=trueSTACKIT_BASTION_ALLOWED_CIDRS, defaulting to0.0.0.0/0, for bastion SSH ingressSTACKIT_E2E_CNI,STACKIT_E2E_CNI_MANIFEST, and CNI-specific variablesSTACKIT_E2E_TEST_ID, for cleanup traceability
Real Cloud Node Bootstrap
The real workload-cluster e2e path is intentionally allowed to use a generic
Ubuntu cloud image during development. In that mode, the e2e fixture adds
kubeadm preKubeadmCommands that install and configure containerd,
kubelet, kubeadm, and kubectl at runtime before kubeadm joins the node.
Treat this runtime package installation as a development fallback. It proves the provider can pass CABPK-generated cloud-init data through STACKIT user data, but it is slower and less deterministic than a kubeadm-ready image. A production setup should use an image that already contains the expected container runtime and Kubernetes node packages for the selected Kubernetes minor.
Make Targets
Common targets:
| Target | Purpose |
|---|---|
make test | Run generated checks, formatting, vet, and non-e2e tests |
make manifests | Regenerate CRDs and RBAC |
make generate | Regenerate DeepCopy code |
make install | Install CRDs into the current cluster |
make uninstall | Remove CRDs from the current cluster |
make docker-build | Build the controller image |
make deploy | Deploy the controller into the current cluster |
make undeploy | Remove the controller deployment |
make build-installer | Generate dist/install.yaml |
make clusterctl-release | Generate local clusterctl release assets |
make cleanup-stackit | Delete tagged e2e resources through STACKIT APIs |
make install-workload-cni | Install Cilium, Calico, or a custom CNI manifest into a workload cluster |
make test-e2e-workload-noderef | Run billable workload NodeRef/providerID and Ready Node e2e |
make test-e2e-workload-scale | Run billable workload worker scale and Ready Node e2e |
make test-e2e-workload-upgrade-workers | Run billable workload worker upgrade and Ready replacement Node e2e |
make test-e2e-workload-upgrade-control-plane | Run billable workload control-plane upgrade and Ready replacement Node e2e |
make test-e2e-workload-topology | Run billable ClusterClass topology create/ready/delete e2e |
Documentation targets inside docs/:
| Target | Purpose |
|---|---|
make build | Build the mdBook |
make serve | Serve the mdBook locally |
make clean | Remove generated book output |
Troubleshooting
ClusterClass is rejected
If the API server rejects ClusterClass, KubeadmControlPlaneTemplate, or
Cluster.spec.topology, check the CAPI manager feature gates:
kubectl -n capi-system get deployment capi-controller-manager \
-o jsonpath='{.spec.template.spec.containers[0].args}'
kubectl -n capi-kubeadm-control-plane-system \
get deployment capi-kubeadm-control-plane-controller-manager \
-o jsonpath='{.spec.template.spec.containers[0].args}'
Both must include ClusterTopology=true.
Machine waits for bootstrap data
Check the owning Machine and bootstrap Secret:
kubectl describe machine "${MACHINE_NAME}" -n "${NAMESPACE}"
kubectl get secret -n "${NAMESPACE}"
StackitMachine intentionally does not contain user data directly. Bootstrap
data comes from the CAPI bootstrap Secret.
STACKIT CLI might also be valueable to check server logs during boot: stackit server log <server-id>
Cloud resources remain after a failed e2e run
Run direct cleanup by e2e tags:
make cleanup-stackit
Then verify no tagged STACKIT VMs or load balancers remain.
CRD Reference
tbd
Reference
Glossary
CAPSTK
Cluster API Provider STACKIT.
