Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

STACKIT - A Brand By Schwarz Digits

Go Reference


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-stackit is 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, and kubectl with apt-get
  • use the Kubernetes package repository selected by KUBERNETES_APT_REPOSITORY_MINOR
  • configure containerd with SystemdCgroup = true
  • enable br_netfilter and 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 ubuntu user.
  • 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 KubeadmBootstrapFormatIgnition feature gate
  • do not reuse Ubuntu apt-get based bootstrap commands
  • make kubeadm use the STACKIT hostname from /run/metadata/flatcar (COREOS_OPENSTACK_HOSTNAME), otherwise the node may register as localhost and 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 core user. When connecting through the CAPSTK bastion, use the bastion image's SSH user for the first hop, usually ubuntu, and core for 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 cluster and 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.sshKeyName already 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 StackitMachineTemplate resources must also set spec.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 actionCode pathSTACKIT permission
Read the configured networkGetNetworkiaas.network.get
Create VM instancesCreateServeriaas.server.create
Find existing tagged VM instancesListServersiaas.server.list
Read VM stateGetServeriaas.server.get
Read VM NIC addresses for CAPI addresses and load balancer targetsListServerNICsiaas.server.nic.list
Delete VM instancesDeleteServeriaas.server.delete
Create the optional bastion public IPCreatePublicIPiaas.public-ip.create
Find existing tagged bastion public IPsListPublicIPsiaas.public-ip.list
Read the bastion public IP after attachGetPublicIPiaas.public-ip.get
Attach the bastion public IP to the bastion serverAddPublicIpToServeriaas.server.public-ip.add
Detach the bastion public IP during cleanupRemovePublicIpFromServeriaas.server.public-ip.remove
Delete the optional bastion public IPDeletePublicIPiaas.public-ip.delete
Create the optional bastion security groupCreateSecurityGroupiaas.security-group.create
Find existing tagged bastion security groupsListSecurityGroupsiaas.security-group.list
Create bastion SSH ingress rulesCreateSecurityGroupRuleiaas.security-group.rule.create
List bastion SSH ingress rulesListSecurityGroupRulesiaas.security-group.rule.list
Delete bastion SSH ingress rules during cleanupDeleteSecurityGroupRuleiaas.security-group.rule.delete
Attach the bastion security group to the bastion serverAddSecurityGroupToServeriaas.server.security-group.add
Detach the bastion security group during cleanupRemoveSecurityGroupFromServeriaas.server.security-group.remove
Delete the optional bastion security groupDeleteSecurityGroupiaas.security-group.delete
Create the API server network load balancerCreateLoadBalancernlb.loadbalancer.create
Find existing tagged load balancersListLoadBalancersnlb.loadbalancer.list
Read the load balancer before target updatesGetLoadBalancernlb.loadbalancer.get
Delete the API server load balancerDeleteLoadBalancernlb.loadbalancer.delete
Replace the API server target poolUpdateTargetPoolnlb.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.add to create the custom role
  • iam.role.get and iam.role.list to read role state
  • iam.member.add to assign the role to the service account
  • iam.member.get to read role-assignment state
  • iam.service-account.create to create the service account
  • iam.service-account.get and iam.service-account.list to read service-account state
  • iam.service-account-key.create to 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, TestSDKClientListNetworksIntegration calls ListNetworks and therefore needs iaas.network.list; the provider reconciler only calls GetNetwork with the configured network ID, so iaas.network.get is 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:

  • StackitCluster
  • StackitClusterTemplate
  • StackitMachine
  • StackitMachineTemplate

Important contract behavior:

  • StackitCluster.status.initialization.provisioned is set when cluster infrastructure is ready.
  • StackitMachine.status.initialization.provisioned is set when the VM is provisioned.
  • StackitMachine.spec.providerID is 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.makeInstanceID returns stackit://<server-id>.
  • pkg/ccm/instances.go: instanceIDFromProviderID parses stackit://<server-id> with no project or region component.
  • pkg/ccm/instances.go: getInstance resolves nodes with GetServer(projectID, region, serverID), where project and region come from the cloud-controller-manager configuration.
  • pkg/ccm/instances_test.go: the "new providerID" table entry expects stackit://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 fmt
  • go 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:

ScenarioMake targetOpt-in flag
NodeRef/providerID and Ready Nodesmake test-e2e-workload-noderefSTACKIT_E2E_NODE_REF=true
Bastion cluster create/delete with provider-managed bastion and node SSH security group cleanupmake test-e2e-workload-bastionSTACKIT_E2E_CREATE_CLUSTER=true, STACKIT_E2E_BASTION=true
Worker scale with Ready Nodesmake test-e2e-workload-scaleSTACKIT_E2E_SCALE_WORKLOAD=true
Worker upgrade with Ready replacement Nodesmake test-e2e-workload-upgrade-workersSTACKIT_E2E_UPGRADE_WORKLOAD_WORKERS=true
Control-plane upgrade with a Ready replacement Nodemake test-e2e-workload-upgrade-control-planeSTACKIT_E2E_UPGRADE_WORKLOAD_CONTROL_PLANE=true
ClusterClass topology create/ready/deletemake test-e2e-workload-topologySTACKIT_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:

ScenarioOpt-in flagSuggested focus
Single StackitMachine VM create/delete and leak checkSTACKIT_E2E_CREATE_VMS=truecreate and delete a real STACKIT VM
1 control-plane / 1 worker infrastructure lifecycle without workload Node readinessSTACKIT_E2E_CREATE_CLUSTER=truecreate and delete a 1 control-plane / 1 worker workload Cluster
Infra-only MachineDeployment worker VM scale up/downSTACKIT_E2E_SCALE_WORKERS=truescale a worker MachineDeployment
Infra-only MachineDeployment worker VM replacement during version upgradeSTACKIT_E2E_UPGRADE_WORKERS=truereplace worker VMs during a MachineDeployment version upgrade

Common required environment:

  • STACKIT_PROJECT_ID
  • STACKIT_REGION
  • STACKIT_NETWORK_ID
  • STACKIT_IMAGE_ID
  • STACKIT_AVAILABILITY_ZONE
  • STACKIT_CREDENTIALS_SECRET_NAME
  • STACKIT_CREDENTIALS_SECRET_NAMESPACE

Useful common options:

  • KUBERNETES_VERSION, for create/scale/topology paths
  • STACKIT_E2E_UPGRADE_FROM and STACKIT_E2E_UPGRADE_TO, for upgrade paths
  • STACKIT_SSH_KEY_NAME, for node SSH keys when a scenario should attach one
  • STACKIT_BASTION_SSH_KEY_NAME, or STACKIT_SSH_KEY_NAME, when STACKIT_E2E_BASTION=true
  • STACKIT_BASTION_ALLOWED_CIDRS, defaulting to 0.0.0.0/0, for bastion SSH ingress
  • STACKIT_E2E_CNI, STACKIT_E2E_CNI_MANIFEST, and CNI-specific variables
  • STACKIT_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:

TargetPurpose
make testRun generated checks, formatting, vet, and non-e2e tests
make manifestsRegenerate CRDs and RBAC
make generateRegenerate DeepCopy code
make installInstall CRDs into the current cluster
make uninstallRemove CRDs from the current cluster
make docker-buildBuild the controller image
make deployDeploy the controller into the current cluster
make undeployRemove the controller deployment
make build-installerGenerate dist/install.yaml
make clusterctl-releaseGenerate local clusterctl release assets
make cleanup-stackitDelete tagged e2e resources through STACKIT APIs
make install-workload-cniInstall Cilium, Calico, or a custom CNI manifest into a workload cluster
make test-e2e-workload-noderefRun billable workload NodeRef/providerID and Ready Node e2e
make test-e2e-workload-scaleRun billable workload worker scale and Ready Node e2e
make test-e2e-workload-upgrade-workersRun billable workload worker upgrade and Ready replacement Node e2e
make test-e2e-workload-upgrade-control-planeRun billable workload control-plane upgrade and Ready replacement Node e2e
make test-e2e-workload-topologyRun billable ClusterClass topology create/ready/delete e2e

Documentation targets inside docs/:

TargetPurpose
make buildBuild the mdBook
make serveServe the mdBook locally
make cleanRemove 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.