Repository: hetznercloud/hcloud-cloud-controller-manager
Pull request #755 (2024-10-04 15:40:00)

deps: update module golang.org/x/time to v0.7.0
This PR contains the following updates:

| Package | Change | Age | Adoption | Passing | Confidence |
|---|---|---|---|---|---|
| golang.org/x/time | `v0.6.0` -> `v0.7.0` | [![age](https://developer.mend.io/api/mc/badges/age/go/golang.org%2fx%2ftime/v0.7.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://developer.mend.io/api/mc/badges/adoption/go/golang.org%2fx%2ftime/v0.7.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://developer.mend.io/api/mc/badges/compatibility/go/golang.org%2fx%2ftime/v0.6.0/v0.7.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://developer.mend.io/api/mc/badges/confidence/go/golang.org%2fx%2ftime/v0.6.0/v0.7.0?slim=true)](https://docs.renovatebot.com/merge-confidence/) |

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/hetznercloud/hcloud-cloud-controller-manager).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiIzOC45Ny4wIiwidXBkYXRlZEluVmVyIjoiMzguOTcuMCIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOltdfQ==-->



Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 2c3e4589..b6635004 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -56,7 +56,7 @@ jobs:
     name: Check deployment manifests
     runs-on: ubuntu-latest
     steps:
-      - uses: yokawasa/action-setup-kube-tools@v0.11.1
+      - uses: yokawasa/action-setup-kube-tools@v0.11.2
         with:
           setup-tools: |
             helm
@@ -72,3 +72,27 @@ jobs:
       - name: Show warning
         if: ${{ failure() }}
         run: echo "::error title=Deployment Manifests outdated::Please run scripts/update-deployment-yamls.sh and commit the changes to deploy/"
+
+  helm-chart:
+    name: Verify snapshots
+    runs-on: ubuntu-latest
+    steps:
+      - uses: yokawasa/action-setup-kube-tools@v0.11.2
+        with:
+          setup-tools: |
+            helm
+          helm: v3.16.2 # renovate: datasource=github-releases depName=helm/helm
+
+      - uses: actions/checkout@v4
+
+      - name: Verify
+        run: |
+          scripts/update-helm-snapshots.sh
+          git diff --exit-code -- chart/.snapshots/
+
+      - name: Show warning
+        if: failure()
+        run: echo "::error title=Helm Snapshots outdated::Please run hack/update-helm-snapshots.sh and commit the changes to chart/.snapshots/"
+
+      - name: Helm Lint
+        run: helm lint chart/
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index e9242861..ca811bf3 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -14,6 +14,7 @@ repos:
 
       - id: check-json
       - id: check-yaml
+        args: [--allow-multiple-documents]
         exclude: ^(deploy/.*|chart/templates/.*)$
 
       - id: check-merge-conflict
@@ -21,6 +22,7 @@ repos:
       - id: mixed-line-ending
         args: [--fix=lf]
       - id: trailing-whitespace
+        exclude: ^(chart/.snapshots/.*)$
 
   - repo: local
     hooks:
@@ -32,7 +34,7 @@ repos:
         require_serial: false
         additional_dependencies: [prettier@3.3.3]
         files: \.(md|ya?ml)$
-        exclude: ^(CHANGELOG.md|chart/templates/.*)$
+        exclude: ^(CHANGELOG.md|chart/templates/.*|chart/.snapshots/.*)$
 
   - repo: local
     hooks:
diff --git a/README.md b/README.md
index bd563cef..f973f258 100644
--- a/README.md
+++ b/README.md
@@ -29,6 +29,7 @@ metadata:
     node.kubernetes.io/instance-type: cx22
     topology.kubernetes.io/region: fsn1
     topology.kubernetes.io/zone: fsn1-dc8
+    instance.hetzner.cloud/provided-by: cloud
   name: node
 spec:
   podCIDR: 10.244.0.0/24
diff --git a/chart/.snapshots/default.yaml b/chart/.snapshots/default.yaml
new file mode 100644
index 00000000..471eb1d2
--- /dev/null
+++ b/chart/.snapshots/default.yaml
@@ -0,0 +1,96 @@
+---
+# Source: hcloud-cloud-controller-manager/templates/serviceaccount.yaml
+apiVersion: v1
+kind: ServiceAccount
+metadata:
+  name: hcloud-cloud-controller-manager
+  namespace: kube-system
+---
+# Source: hcloud-cloud-controller-manager/templates/clusterrolebinding.yaml
+kind: ClusterRoleBinding
+apiVersion: rbac.authorization.k8s.io/v1
+metadata:
+  name: "system:hcloud-cloud-controller-manager"
+roleRef:
+  apiGroup: rbac.authorization.k8s.io
+  kind: ClusterRole
+  name: cluster-admin
+subjects:
+  - kind: ServiceAccount
+    name: hcloud-cloud-controller-manager
+    namespace: kube-system
+---
+# Source: hcloud-cloud-controller-manager/templates/deployment.yaml
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: hcloud-cloud-controller-manager
+  namespace: kube-system
+spec:
+  replicas: 1
+  revisionHistoryLimit: 2
+  selector:
+    matchLabels:
+      app.kubernetes.io/instance: 'hcloud-hccm'
+      app.kubernetes.io/name: 'hcloud-cloud-controller-manager'
+  template:
+    metadata:
+      labels:
+        app.kubernetes.io/instance: 'hcloud-hccm'
+        app.kubernetes.io/name: 'hcloud-cloud-controller-manager'
+    spec:
+      serviceAccountName: hcloud-cloud-controller-manager
+      dnsPolicy: Default
+      tolerations:
+        # Allow HCCM itself to schedule on nodes that have not yet been initialized by HCCM.
+        - key: "node.cloudprovider.kubernetes.io/uninitialized"
+          value: "true"
+          effect: "NoSchedule"
+        - key: "CriticalAddonsOnly"
+          operator: "Exists"
+
+        # Allow HCCM to schedule on control plane nodes.
+        - key: "node-role.kubernetes.io/master"
+          effect: NoSchedule
+          operator: Exists
+        - key: "node-role.kubernetes.io/control-plane"
+          effect: NoSchedule
+          operator: Exists
+
+        - key: "node.kubernetes.io/not-ready"
+          effect: "NoExecute"
+      containers:
+        - name: hcloud-cloud-controller-manager
+          args:
+            - "--allow-untagged-cloud"
+            - "--cloud-provider=hcloud"
+            - "--route-reconciliation-period=30s"
+            - "--webhook-secure-port=0"
+            - "--leader-elect=false"
+          env:
+            - name: HCLOUD_TOKEN
+              valueFrom:
+                secretKeyRef:
+                  key: token
+                  name: hcloud
+            - name: ROBOT_PASSWORD
+              valueFrom:
+                secretKeyRef:
+                  key: robot-password
+                  name: hcloud
+                  optional: true
+            - name: ROBOT_USER
+              valueFrom:
+                secretKeyRef:
+                  key: robot-user
+                  name: hcloud
+                  optional: true
+          image: docker.io/hetznercloud/hcloud-cloud-controller-manager:v1.20.0 # x-release-please-version
+          ports:
+            - name: metrics
+              containerPort: 8233
+          resources:
+            requests:
+              cpu: 100m
+              memory: 50Mi
+      priorityClassName: system-cluster-critical
diff --git a/chart/.snapshots/full.daemonset.yaml b/chart/.snapshots/full.daemonset.yaml
new file mode 100644
index 00000000..cbcb9158
--- /dev/null
+++ b/chart/.snapshots/full.daemonset.yaml
@@ -0,0 +1,113 @@
+---
+# Source: hcloud-cloud-controller-manager/templates/serviceaccount.yaml
+apiVersion: v1
+kind: ServiceAccount
+metadata:
+  name: hcloud-cloud-controller-manager
+  namespace: kube-system
+---
+# Source: hcloud-cloud-controller-manager/templates/clusterrolebinding.yaml
+kind: ClusterRoleBinding
+apiVersion: rbac.authorization.k8s.io/v1
+metadata:
+  name: "system:hcloud-cloud-controller-manager"
+roleRef:
+  apiGroup: rbac.authorization.k8s.io
+  kind: ClusterRole
+  name: cluster-admin
+subjects:
+  - kind: ServiceAccount
+    name: hcloud-cloud-controller-manager
+    namespace: kube-system
+---
+# Source: hcloud-cloud-controller-manager/templates/daemonset.yaml
+apiVersion: apps/v1
+kind: DaemonSet
+metadata:
+  name: hcloud-cloud-controller-manager
+  namespace: kube-system
+spec:
+  revisionHistoryLimit: 2
+  selector:
+    matchLabels:
+      app.kubernetes.io/instance: 'hcloud-hccm'
+      app.kubernetes.io/name: 'hcloud-cloud-controller-manager'
+  template:
+    metadata:
+      labels:
+        app.kubernetes.io/instance: 'hcloud-hccm'
+        app.kubernetes.io/name: 'hcloud-cloud-controller-manager'
+        pod-label: pod-label
+      annotations:
+        pod-annotation: pod-annotation
+    spec:
+      serviceAccountName: hcloud-cloud-controller-manager
+      dnsPolicy: Default
+      tolerations:
+        # Allow HCCM itself to schedule on nodes that have not yet been initialized by HCCM.
+        - key: "node.cloudprovider.kubernetes.io/uninitialized"
+          value: "true"
+          effect: "NoSchedule"
+        - key: "CriticalAddonsOnly"
+          operator: "Exists"
+
+        # Allow HCCM to schedule on control plane nodes.
+        - key: "node-role.kubernetes.io/master"
+          effect: NoSchedule
+          operator: Exists
+        - key: "node-role.kubernetes.io/control-plane"
+          effect: NoSchedule
+          operator: Exists
+
+        - key: "node.kubernetes.io/not-ready"
+          effect: "NoExecute"
+        
+        - effect: NoSchedule
+          key: example-key
+          operator: Exists
+      nodeSelector:
+        
+        foo: bar
+      containers:
+        - name: hcloud-cloud-controller-manager
+          command:
+            - "/bin/hcloud-cloud-controller-manager"
+            - "--allow-untagged-cloud"
+            - "--cloud-provider=hcloud"
+            - "--route-reconciliation-period=30s"
+            - "--webhook-secure-port=0"
+          env:
+            - name: HCLOUD_TOKEN
+              valueFrom:
+                secretKeyRef:
+                  key: token
+                  name: hcloud
+            - name: ROBOT_PASSWORD
+              valueFrom:
+                secretKeyRef:
+                  key: robot-password
+                  name: hcloud
+                  optional: true
+            - name: ROBOT_USER
+              valueFrom:
+                secretKeyRef:
+                  key: robot-user
+                  name: hcloud
+                  optional: true
+          image: docker.io/hetznercloud/hcloud-cloud-controller-manager:v1.20.0 # x-release-please-version
+          ports:
+            - name: metrics
+              containerPort: 8233
+          resources:
+            requests:
+              cpu: 100m
+              memory: 50Mi
+          volumeMounts:
+            - mountPath: /var/run/secrets/hcloud
+              name: token-volume
+              readOnly: true
+      priorityClassName: system-cluster-critical
+      volumes:
+        - name: token-volume
+          secret:
+            secretName: hcloud-token
diff --git a/chart/.snapshots/full.values.daemonset.yaml b/chart/.snapshots/full.values.daemonset.yaml
new file mode 100644
index 00000000..4ccdc134
--- /dev/null
+++ b/chart/.snapshots/full.values.daemonset.yaml
@@ -0,0 +1,51 @@
+kind: DaemonSet
+
+monitoring:
+  podMonitor:
+    labels:
+      environment: staging
+    annotations:
+      release: kube-prometheus-stack
+
+additionalTolerations:
+  - key: "example-key"
+    operator: "Exists"
+    effect: "NoSchedule"
+
+nodeSelector:
+  foo: bar
+
+affinity:
+  nodeAffinity:
+    requiredDuringSchedulingIgnoredDuringExecution:
+      nodeSelectorTerms:
+        - matchExpressions:
+            - key: topology.kubernetes.io/zone
+              operator: In
+              values:
+                - antarctica-east1
+                - antarctica-west1
+    preferredDuringSchedulingIgnoredDuringExecution:
+      - weight: 1
+        preference:
+          matchExpressions:
+            - key: another-node-label-key
+              operator: In
+              values:
+                - another-node-label-value
+
+podLabels:
+  pod-label: pod-label
+
+podAnnotations:
+  pod-annotation: pod-annotation
+
+extraVolumeMounts:
+  - name: token-volume
+    readOnly: true
+    mountPath: /var/run/secrets/hcloud
+
+extraVolumes:
+  - name: token-volume
+    secret:
+      secretName: hcloud-token
diff --git a/docs/robot.md b/docs/robot.md
index 8c7385b4..76dc84f0 100644
--- a/docs/robot.md
+++ b/docs/robot.md
@@ -47,6 +47,9 @@ The Node controller adds information about the server to the Node object. The va
   - `topology.kubernetes.io/zone`
     - Examples: `hel1-dc5` `fsn1-dc16`
     - We use the lowercase variant of the location to match the Cloud Datacenters
+  - `instance.hetzner.cloud/provided-by`
+    - Examples: `robot` `cloud`
+    - We detect if the node is a Robot server or Cloud VM and set the label accordingly
 - Provider ID
   - We set the field `Node.spec.providerID` to identify the Robot server after the initial adoption.
   - The format is `hrobot://$SERVER_NUMBER`, but we can also read from the deprecated format used by [syself/hetzner-cloud-controller-manager](https://github.com/syself/hetzner-cloud-controller-manager): `hcloud://bm-$SERVER_NUMBER`
diff --git a/go.mod b/go.mod
index f6861562..4338d00f 100644
--- a/go.mod
+++ b/go.mod
@@ -5,12 +5,12 @@ go 1.22.0
 toolchain go1.23.2
 
 require (
-	github.com/hetznercloud/hcloud-go/v2 v2.14.0
+	github.com/hetznercloud/hcloud-go/v2 v2.15.0
 	github.com/prometheus/client_golang v1.20.5
 	github.com/spf13/pflag v1.0.5
 	github.com/stretchr/testify v1.9.0
 	github.com/syself/hrobot-go v0.2.5
-	golang.org/x/time v0.6.0
+	golang.org/x/time v0.7.0
 	k8s.io/api v0.31.2
 	k8s.io/apimachinery v0.31.2
 	k8s.io/client-go v0.31.2
diff --git a/go.sum b/go.sum
index 2d522d78..f93ed010 100644
--- a/go.sum
+++ b/go.sum
@@ -126,6 +126,8 @@ github.com/hetznercloud/hcloud-go/v2 v2.13.1 h1:jq0GP4QaYE5d8xR/Zw17s9qoaESRJMXf
 github.com/hetznercloud/hcloud-go/v2 v2.13.1/go.mod h1:dhix40Br3fDiBhwaSG/zgaYOFFddpfBm/6R1Zz0IiF0=
 github.com/hetznercloud/hcloud-go/v2 v2.14.0 h1:WQW72DuOGqT486F0eNp92lDH5cwDTmyn9Mhin93m1To=
 github.com/hetznercloud/hcloud-go/v2 v2.14.0/go.mod h1:h8sHav+27Xa+48cVMAvAUMELov5h298Ilg2vflyTHgg=
+github.com/hetznercloud/hcloud-go/v2 v2.15.0 h1:6mpMJ/RuX1woZj+MCJdyKNEX9129KDkEIDeeyfr4GD4=
+github.com/hetznercloud/hcloud-go/v2 v2.15.0/go.mod h1:h8sHav+27Xa+48cVMAvAUMELov5h298Ilg2vflyTHgg=
 github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4=
 github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY=
 github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
@@ -381,6 +383,8 @@ golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
 golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
 golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
 golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
+golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ=
+golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
 golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
diff --git a/hcloud/cloud.go b/hcloud/cloud.go
index c2cef9a0..4fc18f20 100644
--- a/hcloud/cloud.go
+++ b/hcloud/cloud.go
@@ -26,6 +26,7 @@ import (
 	hrobot "github.com/syself/hrobot-go"
 	corev1 "k8s.io/api/core/v1"
 	"k8s.io/client-go/kubernetes/scheme"
+	v1 "k8s.io/client-go/kubernetes/typed/core/v1"
 	"k8s.io/client-go/tools/record"
 	cloudprovider "k8s.io/cloud-provider"
 	"k8s.io/klog/v2"
@@ -126,19 +127,27 @@ func newCloud(_ io.Reader) (cloudprovider.Interface, error) {
 
 	klog.Infof("Hetzner Cloud k8s cloud controller %s started\n", providerVersion)
 
-	eventBroadcaster := record.NewBroadcaster()
-	recorder := eventBroadcaster.NewRecorder(scheme.Scheme, corev1.EventSource{Component: "hcloud-cloud-controller-manager"})
-
 	return &cloud{
 		client:      client,
 		robotClient: robotClient,
 		cfg:         cfg,
 		networkID:   networkID,
-		recorder:    recorder,
 	}, nil
 }
 
-func (c *cloud) Initialize(_ cloudprovider.ControllerClientBuilder, _ <-chan struct{}) {
+func (c *cloud) Initialize(clientBuilder cloudprovider.ControllerClientBuilder, stop <-chan struct{}) {
+	client, _ := clientBuilder.Client("hccm-event-broadcaster")
+
+	eventBroadcaster := record.NewBroadcaster()
+	eventBroadcaster.StartRecordingToSink(&v1.EventSinkImpl{Interface: client.CoreV1().Events("")})
+
+	go func() {
+		<-stop
+		eventBroadcaster.Shutdown()
+	}()
+
+	recorder := eventBroadcaster.NewRecorder(scheme.Scheme, corev1.EventSource{Component: "hcloud-cloud-controller-manager"})
+	c.recorder = recorder
 }
 
 func (c *cloud) Instances() (cloudprovider.Instances, bool) {
diff --git a/hcloud/instances.go b/hcloud/instances.go
index e43d4564..355cc31b 100644
--- a/hcloud/instances.go
+++ b/hcloud/instances.go
@@ -33,6 +33,10 @@ import (
 	"github.com/hetznercloud/hcloud-go/v2/hcloud"
 )
 
+const (
+	ProvidedBy = "instance.hetzner.cloud/provided-by"
+)
+
 type instances struct {
 	client        *hcloud.Client
 	robotClient   robot.Client
@@ -81,7 +85,7 @@ func (i *instances) lookupServer(
 		if i.robotClient == nil {
 			return nil, errMissingRobotClient
 		}
-		server, err := getRobotServerByID(i.robotClient, int(serverID), node)
+		server, err := getRobotServerByID(i, int(serverID), node)
 		if err != nil {
 			return nil, fmt.Errorf("failed to get robot server \"%d\": %w", serverID, err)
 		}
@@ -273,6 +277,9 @@ func (s hcloudServer) Metadata(addressFamily config.AddressFamily, networkID int
 		NodeAddresses: hcloudNodeAddresses(addressFamily, networkID, s.Server),
 		Zone:          s.Datacenter.Name,
 		Region:        s.Datacenter.Location.Name,
+		AdditionalLabels: map[string]string{
+			ProvidedBy: "cloud",
+		},
 	}, nil
 }
 
@@ -299,5 +306,8 @@ func (s robotServer) Metadata(addressFamily config.AddressFamily, _ int64) (*clo
 		NodeAddresses: robotNodeAddresses(addressFamily, s.Server),
 		Zone:          getZoneOfRobotServer(s.Server),
 		Region:        getRegionOfRobotServer(s.Server),
+		AdditionalLabels: map[string]string{
+			ProvidedBy: "robot",
+		},
 	}, nil
 }
diff --git a/hcloud/instances_util.go b/hcloud/instances_util.go
index 2a4136fe..b643e610 100644
--- a/hcloud/instances_util.go
+++ b/hcloud/instances_util.go
@@ -19,6 +19,7 @@ package hcloud
 import (
 	"context"
 	"fmt"
+	"regexp"
 	"strings"
 
 	hrobotmodels "github.com/syself/hrobot-go/models"
@@ -73,14 +74,14 @@ func getRobotServerByName(c robot.Client, node *corev1.Node) (server *hrobotmode
 	return server, nil
 }
 
-func getRobotServerByID(c robot.Client, id int, node *corev1.Node) (*hrobotmodels.Server, error) {
+func getRobotServerByID(i *instances, id int, node *corev1.Node) (*hrobotmodels.Server, error) {
 	const op = "hcloud/getRobotServerByID"
 
-	if c == nil {
+	if i.robotClient == nil {
 		return nil, errMissingRobotClient
 	}
 
-	server, err := c.ServerGet(id)
+	server, err := i.robotClient.ServerGet(id)
 	if err != nil && !hrobotmodels.IsError(err, hrobotmodels.ErrorCodeServerNotFound) {
 		return nil, fmt.Errorf("%s: %w", op, err)
 	}
@@ -91,6 +92,14 @@ func getRobotServerByID(c robot.Client, id int, node *corev1.Node) (*hrobotmodel
 
 	// check whether name matches - otherwise this server does not belong to the respective node anymore
 	if server.Name != node.Name {
+		i.recorder.Eventf(
+			node,
+			corev1.EventTypeWarning,
+			"PossibleNodeDeletion",
+			"Might be deleted by node-lifecycle-manager due to name mismatch; Node name %q differs from Robot name %q",
+			node.ObjectMeta.Name,
+			server.Name,
+		)
 		return nil, nil
 	}
 
@@ -102,7 +111,9 @@ func getInstanceTypeOfRobotServer(server *hrobotmodels.Server) string {
 	if server == nil {
 		panic("getInstanceTypeOfRobotServer called with nil server")
 	}
-	return strings.ReplaceAll(server.Product, " ", "-")
+	productName := strings.ReplaceAll(server.Product, " ", "-")
+	// Removes all characters that are invalid for a Kubernetes label
+	return regexp.MustCompile(`[^a-zA-Z0-9_.-]+`).ReplaceAllString(productName, "")
 }
 
 func getZoneOfRobotServer(server *hrobotmodels.Server) string {
diff --git a/internal/utils/set.go b/internal/utils/set.go
new file mode 100644
index 00000000..429660e6
--- /dev/null
+++ b/internal/utils/set.go
@@ -0,0 +1,43 @@
+package utils
+
+import (
+	"sync"
+)
+
+type SyncSet[T comparable] struct {
+	lock sync.Mutex
+	data map[T]struct{}
+}
+
+func NewSyncSet[T comparable]() *SyncSet[T] {
+	return &SyncSet[T]{
+		lock: sync.Mutex{},
+		data: make(map[T]struct{}, 0),
+	}
+}
+
+func (s *SyncSet[T]) Add(v T) {
+	s.lock.Lock()
+	defer s.lock.Unlock()
+
+	s.data[v] = struct{}{}
+}
+
+func (s *SyncSet[T]) Size() int {
+	s.lock.Lock()
+	defer s.lock.Unlock()
+
+	return len(s.data)
+}
+
+func (s *SyncSet[T]) All() []T {
+	s.lock.Lock()
+	defer s.lock.Unlock()
+
+	result := make([]T, 0, len(s.data))
+	for value := range s.data {
+		result = append(result, value)
+	}
+
+	return result
+}
diff --git a/scripts/update-helm-snapshots.sh b/scripts/update-helm-snapshots.sh
new file mode 100755
index 00000000..02df46cc
--- /dev/null
+++ b/scripts/update-helm-snapshots.sh
@@ -0,0 +1,13 @@
+#!/usr/bin/env bash
+set -ueo pipefail
+
+helm template hcloud-hccm chart \
+  --namespace kube-system |
+    grep -v helm.sh/chart \
+    > chart/.snapshots/default.yaml
+
+helm template hcloud-hccm chart \
+  --namespace kube-system \
+  -f chart/.snapshots/full.values.daemonset.yaml |
+    grep -v helm.sh/chart \
+    > chart/.snapshots/full.daemonset.yaml


Test patch:
diff --git a/.github/workflows/test_e2e.yml b/.github/workflows/test_e2e.yml
index 66d003ef..c2d5bcbf 100644
--- a/.github/workflows/test_e2e.yml
+++ b/.github/workflows/test_e2e.yml
@@ -46,14 +46,14 @@ jobs:
           tofu_version: v1.8.4 # renovate: datasource=github-releases depName=opentofu/opentofu
           tofu_wrapper: false
 
-      - uses: yokawasa/action-setup-kube-tools@v0.11.1
+      - uses: yokawasa/action-setup-kube-tools@v0.11.2
         with:
           setup-tools: |
             helm
             kubectl
             skaffold
           helm: v3.16.2 # renovate: datasource=github-releases depName=helm/helm
-          kubectl: v1.31.0 # renovate: datasource=github-releases depName=kubernetes/kubernetes
+          kubectl: v1.31.2 # renovate: datasource=github-releases depName=kubernetes/kubernetes
           skaffold: v2.13.2 # renovate: datasource=github-releases depName=GoogleContainerTools/skaffold
 
       - name: Install k3sup
@@ -153,14 +153,14 @@ jobs:
           tofu_version: v1.8.4 # renovate: datasource=github-releases depName=opentofu/opentofu
           tofu_wrapper: false
 
-      - uses: yokawasa/action-setup-kube-tools@v0.11.1
+      - uses: yokawasa/action-setup-kube-tools@v0.11.2
         with:
           setup-tools: |
             helm
             kubectl
             skaffold
           helm: v3.16.2 # renovate: datasource=github-releases depName=helm/helm
-          kubectl: v1.31.0 # renovate: datasource=github-releases depName=kubernetes/kubernetes
+          kubectl: v1.31.2 # renovate: datasource=github-releases depName=kubernetes/kubernetes
           skaffold: v2.13.2 # renovate: datasource=github-releases depName=GoogleContainerTools/skaffold
 
       - name: Install k3sup
diff --git a/hcloud/instances_test.go b/hcloud/instances_test.go
index b49fe145..bd3c5588 100644
--- a/hcloud/instances_test.go
+++ b/hcloud/instances_test.go
@@ -361,6 +361,9 @@ func TestInstances_InstanceMetadata(t *testing.T) {
 		},
 		Zone:   "Test DC",
 		Region: "Test Location",
+		AdditionalLabels: map[string]string{
+			"instance.hetzner.cloud/provided-by": "cloud",
+		},
 	}
 
 	if !reflect.DeepEqual(metadata, expectedMetadata) {
@@ -377,7 +380,7 @@ func TestInstances_InstanceMetadataRobotServer(t *testing.T) {
 				ServerIP:      "233.252.0.123",
 				ServerIPv6Net: "2a01:f48:111:4221::",
 				ServerNumber:  321,
-				Product:       "robot-product 1",
+				Product:       "Robot Server™ 1",
 				Name:          "robot-server1",
 				Dc:            "NBG1-DC1",
 			},
@@ -398,13 +401,16 @@ func TestInstances_InstanceMetadataRobotServer(t *testing.T) {
 
 	expectedMetadata := &cloudprovider.InstanceMetadata{
 		ProviderID:   "hrobot://321",
-		InstanceType: "robot-product-1",
+		InstanceType: "Robot-Server-1",
 		NodeAddresses: []corev1.NodeAddress{
 			{Type: corev1.NodeHostName, Address: "robot-server1"},
 			{Type: corev1.NodeExternalIP, Address: "233.252.0.123"},
 		},
 		Zone:   "nbg1-dc1",
 		Region: "nbg1",
+		AdditionalLabels: map[string]string{
+			"instance.hetzner.cloud/provided-by": "robot",
+		},
 	}
 
 	if !reflect.DeepEqual(metadata, expectedMetadata) {
diff --git a/internal/utils/set_test.go b/internal/utils/set_test.go
new file mode 100644
index 00000000..1d29f6e2
--- /dev/null
+++ b/internal/utils/set_test.go
@@ -0,0 +1,20 @@
+package utils
+
+import (
+	"sort"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestSyncSet(t *testing.T) {
+	o := NewSyncSet[int]()
+
+	o.Add(1)
+	o.Add(2)
+	o.Add(3)
+
+	all := o.All()
+	sort.Ints(all)
+	assert.Equal(t, []int{1, 2, 3}, all)
+}
diff --git a/tests/e2e/cloud_test.go b/tests/e2e/cloud_test.go
index c805b9a2..04eb8caa 100644
--- a/tests/e2e/cloud_test.go
+++ b/tests/e2e/cloud_test.go
@@ -35,12 +35,13 @@ func TestNodeSetCorrectNodeLabelsAndIPAddresses(t *testing.T) {
 
 	labels := node.Labels
 	expectedLabels := map[string]string{
-		"node.kubernetes.io/instance-type": server.ServerType.Name,
-		"topology.kubernetes.io/region":    server.Datacenter.Location.Name,
-		"topology.kubernetes.io/zone":      server.Datacenter.Name,
-		"kubernetes.io/hostname":           server.Name,
-		"kubernetes.io/os":                 "linux",
-		"kubernetes.io/arch":               "amd64",
+		"node.kubernetes.io/instance-type":   server.ServerType.Name,
+		"topology.kubernetes.io/region":      server.Datacenter.Location.Name,
+		"topology.kubernetes.io/zone":        server.Datacenter.Name,
+		"kubernetes.io/hostname":             server.Name,
+		"kubernetes.io/os":                   "linux",
+		"kubernetes.io/arch":                 "amd64",
+		"instance.hetzner.cloud/provided-by": "cloud",
 	}
 	for expectedLabel, expectedValue := range expectedLabels {
 		if labelValue, ok := labels[expectedLabel]; !ok || labelValue != expectedValue {
@@ -71,9 +72,8 @@ func TestServiceLoadBalancersMinimalSetup(t *testing.T) {
 	t.Parallel()
 
 	lbTest := lbTestHelper{
-		t:         t,
-		K8sClient: testCluster.k8sClient,
-		podName:   "loadbalancer-minimal",
+		t:       t,
+		podName: "loadbalancer-minimal",
 	}
 
 	pod := lbTest.DeployTestPod()
@@ -97,10 +97,9 @@ func TestServiceLoadBalancersHTTPS(t *testing.T) {
 
 	cert := testCluster.CreateTLSCertificate(t, "loadbalancer-https")
 	lbTest := lbTestHelper{
-		t:         t,
-		K8sClient: testCluster.k8sClient,
-		podName:   "loadbalancer-https",
-		port:      443,
+		t:       t,
+		podName: "loadbalancer-https",
+		port:    443,
 	}
 
 	pod := lbTest.DeployTestPod()
@@ -132,10 +131,9 @@ func TestServiceLoadBalancersHTTPSWithManagedCertificate(t *testing.T) {
 
 	domainName := fmt.Sprintf("%d-ccm-test.%s", rand.Int(), testCluster.certDomain)
 	lbTest := lbTestHelper{
-		t:         t,
-		K8sClient: testCluster.k8sClient,
-		podName:   "loadbalancer-https",
-		port:      443,
+		t:       t,
+		podName: "loadbalancer-https",
+		port:    443,
 	}
 
 	pod := lbTest.DeployTestPod()
@@ -161,6 +159,7 @@ func TestServiceLoadBalancersHTTPSWithManagedCertificate(t *testing.T) {
 	assert.Len(t, certs, 1)
 
 	lbTest.TearDown()
+
 	_, err = testCluster.hcloud.Certificate.Delete(ctx, certs[0])
 	assert.NoError(t, err)
 }
@@ -168,7 +167,7 @@ func TestServiceLoadBalancersHTTPSWithManagedCertificate(t *testing.T) {
 func TestServiceLoadBalancersWithPrivateNetwork(t *testing.T) {
 	t.Parallel()
 
-	lbTest := lbTestHelper{t: t, K8sClient: testCluster.k8sClient, podName: "loadbalancer-private-network"}
+	lbTest := lbTestHelper{t: t, podName: "loadbalancer-private-network"}
 
 	pod := lbTest.DeployTestPod()
 
diff --git a/tests/e2e/testing.go b/tests/e2e/helper_test.go
similarity index 78%
rename from tests/e2e/testing.go
rename to tests/e2e/helper_test.go
index 4f58a3de..08b4171d 100644
--- a/tests/e2e/testing.go
+++ b/tests/e2e/helper_test.go
@@ -5,6 +5,7 @@ package e2e
 import (
 	"context"
 	"crypto/tls"
+	"errors"
 	"fmt"
 	"math/rand"
 	"net/http"
@@ -22,7 +23,9 @@ import (
 	"k8s.io/client-go/kubernetes"
 	"k8s.io/client-go/tools/clientcmd"
 
+	"github.com/hetznercloud/hcloud-cloud-controller-manager/internal/annotation"
 	"github.com/hetznercloud/hcloud-cloud-controller-manager/internal/testsupport"
+	"github.com/hetznercloud/hcloud-cloud-controller-manager/internal/utils"
 	"github.com/hetznercloud/hcloud-go/v2/hcloud"
 )
 
@@ -33,13 +36,16 @@ func init() {
 }
 
 type TestCluster struct {
-	hcloud       *hcloud.Client
-	hrobot       hrobot.RobotClient
-	k8sClient    *kubernetes.Clientset
-	certificates []*hcloud.Certificate
+	hcloud    *hcloud.Client
+	hrobot    hrobot.RobotClient
+	k8sClient *kubernetes.Clientset
+
 	// envName is used as the name prefix in all resources
 	envName    string
 	certDomain string
+
+	certificates  *utils.SyncSet[int64]
+	loadBalancers *utils.SyncSet[int64]
 }
 
 func (tc *TestCluster) Start() error {
@@ -89,17 +95,31 @@ func (tc *TestCluster) Start() error {
 	// The domain specified here must be available in Hetzner DNS of the account running the tests.
 	tc.certDomain = os.Getenv("CERT_DOMAIN")
 
+	tc.certificates = utils.NewSyncSet[int64]()
+	tc.loadBalancers = utils.NewSyncSet[int64]()
+
 	return nil
 }
 
 func (tc *TestCluster) Stop() error {
-	for _, c := range tc.certificates {
-		if _, err := tc.hcloud.Certificate.Delete(context.Background(), c); err != nil {
-			fmt.Printf("delete certificate %d failed: %v", c.ID, err)
+	errs := make([]error, 0, tc.loadBalancers.Size()+tc.certificates.Size())
+	ctx := context.Background()
+
+	for _, item := range tc.loadBalancers.All() {
+		fmt.Printf("deleting load balancer %d\n", item)
+		if _, err := tc.hcloud.LoadBalancer.Delete(ctx, &hcloud.LoadBalancer{ID: item}); err != nil {
+			errs = append(errs, fmt.Errorf("delete load balancer %d failed: %w", item, err))
 		}
 	}
 
-	return nil
+	for _, item := range tc.certificates.All() {
+		fmt.Printf("deleting certificate %d\n", item)
+		if _, err := tc.hcloud.Certificate.Delete(ctx, &hcloud.Certificate{ID: item}); err != nil {
+			errs = append(errs, fmt.Errorf("delete certificate %d failed: %w", item, err))
+		}
+	}
+
+	return errors.Join(errs...)
 }
 
 // CreateTLSCertificate creates a TLS certificate used for testing and posts it
@@ -125,7 +145,7 @@ func (tc *TestCluster) CreateTLSCertificate(t *testing.T, baseName string) *hclo
 		t.Fatalf("no certificate created")
 	}
 
-	tc.certificates = append(tc.certificates, cert)
+	tc.certificates.Add(cert.ID)
 
 	return cert
 }
@@ -146,28 +166,30 @@ func (tc *TestCluster) WorkerNodeName(index int) string {
 }
 
 type lbTestHelper struct {
+	t *testing.T
+
+	namespace string
 	podName   string
 	port      int
-	K8sClient *kubernetes.Clientset
-	t         *testing.T
-	namespace string
 }
 
 // DeployTestPod deploys a basic nginx pod within the k8s cluster
 // and waits until it is "ready".
 func (l *lbTestHelper) DeployTestPod() *corev1.Pod {
+	l.t.Helper()
+
 	ctx := context.Background()
 
 	if l.namespace == "" {
 		l.namespace = "hccm-test-" + strconv.Itoa(rand.Int())
 	}
-	_, err := l.K8sClient.CoreV1().Namespaces().Create(ctx, &corev1.Namespace{
+	_, err := testCluster.k8sClient.CoreV1().Namespaces().Create(ctx, &corev1.Namespace{
 		ObjectMeta: metav1.ObjectMeta{
 			Name: l.namespace,
 		},
 	}, metav1.CreateOptions{})
 	if err != nil && !k8serrors.IsAlreadyExists(err) {
-		panic(err)
+		l.t.Fatal(err)
 	}
 
 	podName := fmt.Sprintf("pod-%s", l.podName)
@@ -194,12 +216,12 @@ func (l *lbTestHelper) DeployTestPod() *corev1.Pod {
 		},
 	}
 
-	pod, err := l.K8sClient.CoreV1().Pods(l.namespace).Create(ctx, &testPod, metav1.CreateOptions{})
+	pod, err := testCluster.k8sClient.CoreV1().Pods(l.namespace).Create(ctx, &testPod, metav1.CreateOptions{})
 	if err != nil {
 		l.t.Fatalf("could not create test pod: %s", err)
 	}
 	err = wait.PollUntilContextTimeout(ctx, 1*time.Second, 1*time.Minute, false, func(ctx context.Context) (done bool, err error) {
-		p, err := l.K8sClient.CoreV1().Pods(l.namespace).Get(ctx, podName, metav1.GetOptions{})
+		p, err := testCluster.k8sClient.CoreV1().Pods(l.namespace).Get(ctx, podName, metav1.GetOptions{})
 		if err != nil {
 			return false, err
 		}
@@ -219,6 +241,8 @@ func (l *lbTestHelper) DeployTestPod() *corev1.Pod {
 
 // ServiceDefinition returns a service definition for a Hetzner Cloud Load Balancer (k8s service).
 func (l *lbTestHelper) ServiceDefinition(pod *corev1.Pod, annotations map[string]string) *corev1.Service {
+	l.t.Helper()
+
 	port := l.port
 	if port == 0 {
 		port = 80
@@ -249,6 +273,8 @@ func (l *lbTestHelper) ServiceDefinition(pod *corev1.Pod, annotations map[string
 // CreateService creates a k8s service based on the given service definition
 // and waits until it is "ready".
 func (l *lbTestHelper) CreateService(lbSvc *corev1.Service) (*corev1.Service, error) {
+	l.t.Helper()
+
 	ctx := context.Background()
 
 	// Default is 15s interval, 10s timeout, 3 retries => 45 seconds until up
@@ -258,16 +284,26 @@ func (l *lbTestHelper) CreateService(lbSvc *corev1.Service) (*corev1.Service, er
 	// lbSvc.Annotations[string(annotation.LBSvcHealthCheckRetries)] = "1"
 	// lbSvc.Annotations[string(annotation.LBSvcHealthCheckProtocol)] = "tcp"
 
-	_, err := l.K8sClient.CoreV1().Services(l.namespace).Create(ctx, lbSvc, metav1.CreateOptions{})
+	_, err := testCluster.k8sClient.CoreV1().Services(l.namespace).Create(ctx, lbSvc, metav1.CreateOptions{})
 	if err != nil {
 		return nil, fmt.Errorf("could not create service: %s", err)
 	}
 
 	err = wait.PollUntilContextTimeout(ctx, 1*time.Second, 5*time.Minute, false, func(ctx context.Context) (done bool, err error) {
-		svc, err := l.K8sClient.CoreV1().Services(l.namespace).Get(ctx, lbSvc.Name, metav1.GetOptions{})
+		svc, err := testCluster.k8sClient.CoreV1().Services(l.namespace).Get(ctx, lbSvc.Name, metav1.GetOptions{})
 		if err != nil {
 			return false, err
 		}
+
+		// Save load balancer id for cleanup
+		lbID, err := LoadBalancerIDFromService(lbSvc)
+		if err != nil {
+			l.t.Error(err)
+		}
+		if lbID != 0 {
+			testCluster.loadBalancers.Add(lbID)
+		}
+
 		ingressIPs := svc.Status.LoadBalancer.Ingress
 		if len(ingressIPs) > 0 {
 			lbSvc = svc
@@ -283,15 +319,17 @@ func (l *lbTestHelper) CreateService(lbSvc *corev1.Service) (*corev1.Service, er
 
 // TearDown deletes the created pod and service.
 func (l *lbTestHelper) TearDown() {
+	l.t.Helper()
+
 	err := wait.PollUntilContextTimeout(context.Background(), 1*time.Second, 3*time.Minute, true, func(ctx context.Context) (bool, error) {
-		err := l.K8sClient.CoreV1().Namespaces().Delete(ctx, l.namespace, metav1.DeleteOptions{})
+		err := testCluster.k8sClient.CoreV1().Namespaces().Delete(ctx, l.namespace, metav1.DeleteOptions{})
 		if err != nil && !k8serrors.IsNotFound(err) {
 			return false, err
 		}
 		return k8serrors.IsNotFound(err), nil
 	})
 	if err != nil {
-		panic(err)
+		l.t.Fatal(err)
 	}
 }
 
@@ -333,3 +371,17 @@ func WaitForHTTPAvailable(t *testing.T, ingressIP string, useHTTPS bool) {
 		t.Errorf("%s not available: %s", ingressIP, err)
 	}
 }
+
+func LoadBalancerIDFromService(svc *corev1.Service) (int64, error) {
+	label, ok := svc.Labels[string(annotation.LBID)]
+	if !ok || label == "" {
+		return 0, nil
+	}
+
+	id, err := strconv.ParseInt(label, 10, 64)
+	if err != nil {
+		return 0, err
+	}
+
+	return id, nil
+}
diff --git a/tests/e2e/robot_test.go b/tests/e2e/robot_test.go
index 58de1101..200169e3 100644
--- a/tests/e2e/robot_test.go
+++ b/tests/e2e/robot_test.go
@@ -41,9 +41,10 @@ func TestNodeSetCorrectNodeLabelsAndIPAddressesRobot(t *testing.T) {
 
 	labels := node.Labels
 	expectedLabels := map[string]string{
-		"kubernetes.io/hostname": server.Name,
-		"kubernetes.io/os":       "linux",
-		"kubernetes.io/arch":     "amd64",
+		"kubernetes.io/hostname":             server.Name,
+		"kubernetes.io/os":                   "linux",
+		"kubernetes.io/arch":                 "amd64",
+		"instance.hetzner.cloud/provided-by": "robot",
 	}
 	for expectedLabel, expectedValue := range expectedLabels {
 		assert.Equal(t, expectedValue, labels[expectedLabel], "node does not have expected label %s", expectedLabel)
@@ -71,9 +72,8 @@ func TestServiceLoadBalancersRobot(t *testing.T) {
 	t.Parallel()
 
 	lbTest := lbTestHelper{
-		t:         t,
-		K8sClient: testCluster.k8sClient,
-		podName:   "loadbalancer-robot-only",
+		t:       t,
+		podName: "loadbalancer-robot-only",
 	}
 
 	pod := lbTest.DeployTestPod()