Repository: syself/cluster-api-provider-hetzner
Pull request #272 (2022-08-25 13:55:18)

:sparkles: Add support for autoscaling from zero
**What type of PR is this?**
/kind feature

**What this PR does / why we need it**:
This commit introduces support for autoscaling from zero
(https://github.com/kubernetes-sigs/cluster-api/blob/main/docs/proposals/20210310-opt-in-autoscaling-from-zero.md)
for HCloud servers.

We introduce a struct indicating the capacity of machines in the status
of HCloudMachineTemplate. To reconcile this, we have a new controller
for HCloudMachineTemplates.

**TODOs**:
- [x] squashed commits
- [ ] includes documentation
- [x] adds unit tests




Patch:
diff --git a/api/v1beta1/hcloudmachinetemplate_types.go b/api/v1beta1/hcloudmachinetemplate_types.go
index 77deb7dc..f5a81ca9 100644
--- a/api/v1beta1/hcloudmachinetemplate_types.go
+++ b/api/v1beta1/hcloudmachinetemplate_types.go
@@ -17,6 +17,7 @@ limitations under the License.
 package v1beta1
 
 import (
+	corev1 "k8s.io/api/core/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1"
 )
@@ -26,6 +27,20 @@ type HCloudMachineTemplateSpec struct {
 	Template HCloudMachineTemplateResource `json:"template"`
 }
 
+// HCloudMachineTemplateStatus defines the observed state of HCloudMachineTemplate.
+type HCloudMachineTemplateStatus struct {
+	// Capacity defines the resource capacity for this machine.
+	// This value is used for autoscaling from zero operations as defined in:
+	// https://github.com/kubernetes-sigs/cluster-api/blob/main/docs/proposals/20210310-opt-in-autoscaling-from-zero.md
+	// +optional
+	Capacity corev1.ResourceList `json:"capacity,omitempty"`
+
+	// Conditions defines current service state of the HCloudMachineTemplate.
+	// +optional
+	Conditions clusterv1.Conditions `json:"conditions,omitempty"`
+}
+
+// +kubebuilder:subresource:status
 // +kubebuilder:object:root=true
 // +kubebuilder:resource:path=hcloudmachinetemplates,scope=Namespaced,categories=cluster-api,shortName=capihcmt
 // +kubebuilder:printcolumn:name="Image",type="string",JSONPath=".spec.template.spec.imageName",description="Image name"
@@ -39,7 +54,18 @@ type HCloudMachineTemplate struct {
 	metav1.TypeMeta   `json:",inline"`
 	metav1.ObjectMeta `json:"metadata,omitempty"`
 
-	Spec HCloudMachineTemplateSpec `json:"spec,omitempty"`
+	Spec   HCloudMachineTemplateSpec   `json:"spec,omitempty"`
+	Status HCloudMachineTemplateStatus `json:"status,omitempty"`
+}
+
+// GetConditions returns the observations of the operational state of the HCloudMachine resource.
+func (r *HCloudMachineTemplate) GetConditions() clusterv1.Conditions {
+	return r.Status.Conditions
+}
+
+// SetConditions sets the underlying service state of the HCloudMachine to the predescribed clusterv1.Conditions.
+func (r *HCloudMachineTemplate) SetConditions(conditions clusterv1.Conditions) {
+	r.Status.Conditions = conditions
 }
 
 //+kubebuilder:object:root=true
diff --git a/api/v1beta1/zz_generated.deepcopy.go b/api/v1beta1/zz_generated.deepcopy.go
index 41b6a2ce..2cc79ead 100644
--- a/api/v1beta1/zz_generated.deepcopy.go
+++ b/api/v1beta1/zz_generated.deepcopy.go
@@ -259,6 +259,7 @@ func (in *HCloudMachineTemplate) DeepCopyInto(out *HCloudMachineTemplate) {
 	out.TypeMeta = in.TypeMeta
 	in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
 	in.Spec.DeepCopyInto(&out.Spec)
+	in.Status.DeepCopyInto(&out.Status)
 }
 
 // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HCloudMachineTemplate.
@@ -344,6 +345,35 @@ func (in *HCloudMachineTemplateSpec) DeepCopy() *HCloudMachineTemplateSpec {
 	return out
 }
 
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *HCloudMachineTemplateStatus) DeepCopyInto(out *HCloudMachineTemplateStatus) {
+	*out = *in
+	if in.Capacity != nil {
+		in, out := &in.Capacity, &out.Capacity
+		*out = make(v1.ResourceList, len(*in))
+		for key, val := range *in {
+			(*out)[key] = val.DeepCopy()
+		}
+	}
+	if in.Conditions != nil {
+		in, out := &in.Conditions, &out.Conditions
+		*out = make(apiv1beta1.Conditions, len(*in))
+		for i := range *in {
+			(*in)[i].DeepCopyInto(&(*out)[i])
+		}
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HCloudMachineTemplateStatus.
+func (in *HCloudMachineTemplateStatus) DeepCopy() *HCloudMachineTemplateStatus {
+	if in == nil {
+		return nil
+	}
+	out := new(HCloudMachineTemplateStatus)
+	in.DeepCopyInto(out)
+	return out
+}
+
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *HCloudNetworkSpec) DeepCopyInto(out *HCloudNetworkSpec) {
 	*out = *in
diff --git a/config/crd/bases/infrastructure.cluster.x-k8s.io_hcloudmachinetemplates.yaml b/config/crd/bases/infrastructure.cluster.x-k8s.io_hcloudmachinetemplates.yaml
index 85fb650e..0de635fd 100644
--- a/config/crd/bases/infrastructure.cluster.x-k8s.io_hcloudmachinetemplates.yaml
+++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_hcloudmachinetemplates.yaml
@@ -155,7 +155,69 @@ spec:
             required:
             - template
             type: object
+          status:
+            description: HCloudMachineTemplateStatus defines the observed state of
+              HCloudMachineTemplate.
+            properties:
+              capacity:
+                additionalProperties:
+                  anyOf:
+                  - type: integer
+                  - type: string
+                  pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$
+                  x-kubernetes-int-or-string: true
+                description: 'Capacity defines the resource capacity for this machine.
+                  This value is used for autoscaling from zero operations as defined
+                  in: https://github.com/kubernetes-sigs/cluster-api/blob/main/docs/proposals/20210310-opt-in-autoscaling-from-zero.md'
+                type: object
+              conditions:
+                description: Conditions defines current service state of the HCloudMachineTemplate.
+                items:
+                  description: Condition defines an observation of a Cluster API resource
+                    operational state.
+                  properties:
+                    lastTransitionTime:
+                      description: Last time the condition transitioned from one status
+                        to another. This should be when the underlying condition changed.
+                        If that is not known, then using the time when the API field
+                        changed is acceptable.
+                      format: date-time
+                      type: string
+                    message:
+                      description: A human readable message indicating details about
+                        the transition. This field may be empty.
+                      type: string
+                    reason:
+                      description: The reason for the condition's last transition
+                        in CamelCase. The specific API may choose whether or not this
+                        field is considered a guaranteed API. This field may not be
+                        empty.
+                      type: string
+                    severity:
+                      description: Severity provides an explicit classification of
+                        Reason code, so the users or machines can immediately understand
+                        the current situation and act accordingly. The Severity field
+                        MUST be set only when Status=False.
+                      type: string
+                    status:
+                      description: Status of the condition, one of True, False, Unknown.
+                      type: string
+                    type:
+                      description: Type of condition in CamelCase or in foo.example.com/CamelCase.
+                        Many .condition.type values are consistent across resources
+                        like Available, but because arbitrary conditions can be useful
+                        (see .node.status.conditions), the ability to deconflict is
+                        important.
+                      type: string
+                  required:
+                  - lastTransitionTime
+                  - status
+                  - type
+                  type: object
+                type: array
+            type: object
         type: object
     served: true
     storage: true
-    subresources: {}
+    subresources:
+      status: {}
diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml
index 260b3275..3d10916c 100644
--- a/config/rbac/role.yaml
+++ b/config/rbac/role.yaml
@@ -93,6 +93,26 @@ rules:
   - get
   - patch
   - update
+- apiGroups:
+  - infrastructure.cluster.x-k8s.io
+  resources:
+  - hcloudmachinetemplates
+  verbs:
+  - create
+  - delete
+  - get
+  - list
+  - patch
+  - update
+  - watch
+- apiGroups:
+  - infrastructure.cluster.x-k8s.io
+  resources:
+  - hcloudmachinetemplates/status
+  verbs:
+  - get
+  - patch
+  - update
 - apiGroups:
   - infrastructure.cluster.x-k8s.io
   resources:
diff --git a/controllers/hcloudmachinetemplate_controller.go b/controllers/hcloudmachinetemplate_controller.go
new file mode 100644
index 00000000..b8066497
--- /dev/null
+++ b/controllers/hcloudmachinetemplate_controller.go
@@ -0,0 +1,138 @@
+/*
+Copyright 2022 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package controllers
+
+import (
+	"context"
+	"time"
+
+	"github.com/pkg/errors"
+	infrav1 "github.com/syself/cluster-api-provider-hetzner/api/v1beta1"
+	"github.com/syself/cluster-api-provider-hetzner/pkg/scope"
+	secretutil "github.com/syself/cluster-api-provider-hetzner/pkg/secrets"
+	hcloudclient "github.com/syself/cluster-api-provider-hetzner/pkg/services/hcloud/client"
+	"github.com/syself/cluster-api-provider-hetzner/pkg/services/hcloud/machinetemplate"
+	"sigs.k8s.io/cluster-api/util"
+	ctrl "sigs.k8s.io/controller-runtime"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/controller-runtime/pkg/controller"
+	"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
+	"sigs.k8s.io/controller-runtime/pkg/reconcile"
+)
+
+// HCloudMachineTemplateReconciler reconciles a HCloudMachineTemplate object.
+type HCloudMachineTemplateReconciler struct {
+	client.Client
+	APIReader           client.Reader
+	HCloudClientFactory hcloudclient.Factory
+	WatchFilterValue    string
+}
+
+// +kubebuilder:rbac:groups=infrastructure.cluster.x-k8s.io,resources=hcloudmachinetemplates,verbs=get;list;watch;create;update;patch;delete
+// +kubebuilder:rbac:groups=infrastructure.cluster.x-k8s.io,resources=hcloudmachinetemplates/status,verbs=get;update;patch
+
+// Reconcile manages the lifecycle of an HCloudMachineTemplate object.
+func (r *HCloudMachineTemplateReconciler) Reconcile(ctx context.Context, req ctrl.Request) (_ ctrl.Result, reterr error) {
+	log := ctrl.LoggerFrom(ctx).WithValues("hcloudmachinetemplate", req.NamespacedName)
+	log.Info("Reconcile HCloudMachineTemplate")
+
+	machineTemplate := &infrav1.HCloudMachineTemplate{}
+	if err := r.Get(ctx, req.NamespacedName, machineTemplate); err != nil {
+		log.Error(err, "unable to fetch HCloudMachineTemplate")
+		return ctrl.Result{}, client.IgnoreNotFound(err)
+	}
+
+	// Fetch the Cluster.
+	cluster, err := util.GetClusterFromMetadata(ctx, r.Client, machineTemplate.ObjectMeta)
+	if err != nil {
+		log.Info("Machine is missing cluster label or cluster does not exist")
+		return ctrl.Result{}, nil
+	}
+
+	log = log.WithValues("cluster", cluster.Name)
+
+	hetznerCluster := &infrav1.HetznerCluster{}
+
+	hetznerClusterName := client.ObjectKey{
+		Namespace: machineTemplate.Namespace,
+		Name:      cluster.Spec.InfrastructureRef.Name,
+	}
+	if err := r.Client.Get(ctx, hetznerClusterName, hetznerCluster); err != nil {
+		log.Info("HetznerCluster is not available yet")
+		return ctrl.Result{}, nil
+	}
+
+	// Create the scope.
+	secretManager := secretutil.NewSecretManager(log, r.Client, r.APIReader)
+	hcloudToken, _, err := getAndValidateHCloudToken(ctx, req.Namespace, hetznerCluster, secretManager)
+	if err != nil {
+		return hcloudTokenErrorResult(ctx, err, machineTemplate, infrav1.InstanceReadyCondition, r.Client)
+	}
+
+	hcc := r.HCloudClientFactory.NewClient(hcloudToken)
+
+	machineTemplateScope, err := scope.NewHCloudMachineTemplateScope(ctx, scope.HCloudMachineTemplateScopeParams{
+		Client:                r.Client,
+		Logger:                &log,
+		HCloudMachineTemplate: machineTemplate,
+		HCloudClient:          hcc,
+	})
+	if err != nil {
+		return reconcile.Result{}, errors.Errorf("failed to create scope: %+v", err)
+	}
+
+	// Always close the scope when exiting this function so we can persist any HCloudMachine changes.
+	defer func() {
+		if err := machineTemplateScope.Close(ctx); err != nil && reterr == nil {
+			reterr = err
+		}
+	}()
+
+	// check whether rate limit has been reached and if so, then wait.
+	if wait := reconcileRateLimit(machineTemplate); wait {
+		return ctrl.Result{RequeueAfter: 30 * time.Second}, nil
+	}
+
+	return r.reconcile(ctx, machineTemplateScope)
+}
+
+func (r *HCloudMachineTemplateReconciler) reconcile(ctx context.Context, machineTemplateScope *scope.HCloudMachineTemplateScope) (reconcile.Result, error) {
+	machineTemplateScope.Info("Reconciling HCloudMachineTemplate")
+	hcloudMachine := machineTemplateScope.HCloudMachineTemplate
+
+	// If the HCloudMachineTemplate doesn't have our finalizer, add it.
+	controllerutil.AddFinalizer(machineTemplateScope.HCloudMachineTemplate, infrav1.MachineFinalizer)
+
+	// Register the finalizer immediately to avoid orphaning HCloud resources on delete
+	if err := machineTemplateScope.PatchObject(ctx); err != nil {
+		return ctrl.Result{}, err
+	}
+
+	// reconcile machinetemplate
+	if result, brk, err := breakReconcile(machinetemplate.NewService(machineTemplateScope).Reconcile(ctx)); brk {
+		return result, errors.Wrapf(err, "failed to reconcile machinetemplate for HCloudMachineTemplate %s/%s", hcloudMachine.Namespace, hcloudMachine.Name)
+	}
+
+	return reconcile.Result{}, nil
+}
+
+func (r *HCloudMachineTemplateReconciler) SetupWithManager(ctx context.Context, mgr ctrl.Manager, options controller.Options) error {
+	return ctrl.NewControllerManagedBy(mgr).
+		WithOptions(options).
+		For(&infrav1.HCloudMachineTemplate{}).
+		Complete(r)
+}
diff --git a/main.go b/main.go
index 1fa902cd..4a4c7eb1 100644
--- a/main.go
+++ b/main.go
@@ -117,6 +117,7 @@ func main() {
 		setupLog.Error(err, "unable to create controller", "controller", "HetznerCluster")
 		os.Exit(1)
 	}
+
 	if err = (&controllers.HCloudMachineReconciler{
 		Client:              mgr.GetClient(),
 		APIReader:           mgr.GetAPIReader(),
@@ -127,6 +128,16 @@ func main() {
 		os.Exit(1)
 	}
 
+	if err = (&controllers.HCloudMachineTemplateReconciler{
+		Client:              mgr.GetClient(),
+		APIReader:           mgr.GetAPIReader(),
+		HCloudClientFactory: hcloudClientFactory,
+		WatchFilterValue:    watchFilterValue,
+	}).SetupWithManager(ctx, mgr, controller.Options{}); err != nil {
+		setupLog.Error(err, "unable to create controller", "controller", "HCloudMachineTemplate")
+		os.Exit(1)
+	}
+
 	if err = (&controllers.HetznerBareMetalHostReconciler{
 		Client:             mgr.GetClient(),
 		RobotClientFactory: robotclient.NewFactory(),
diff --git a/pkg/scope/hcloudmachinetemplate.go b/pkg/scope/hcloudmachinetemplate.go
new file mode 100644
index 00000000..febb08e9
--- /dev/null
+++ b/pkg/scope/hcloudmachinetemplate.go
@@ -0,0 +1,94 @@
+/*
+Copyright 2022 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+// Package scope defines cluster and machine scope as well as a repository for the Hetzner API
+package scope
+
+import (
+	"context"
+
+	"github.com/go-logr/logr"
+	"github.com/pkg/errors"
+	infrav1 "github.com/syself/cluster-api-provider-hetzner/api/v1beta1"
+	hcloudclient "github.com/syself/cluster-api-provider-hetzner/pkg/services/hcloud/client"
+	"k8s.io/klog/v2/klogr"
+	"sigs.k8s.io/cluster-api/util/patch"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+)
+
+// HCloudMachineTemplateScopeParams defines the input parameters used to create a new scope.
+type HCloudMachineTemplateScopeParams struct {
+	Client                client.Client
+	Logger                *logr.Logger
+	HCloudClient          hcloudclient.Client
+	HCloudMachineTemplate *infrav1.HCloudMachineTemplate
+}
+
+// NewHCloudMachineTemplateScope creates a new Scope from the supplied parameters.
+// This is meant to be called for each reconcile iteration.
+func NewHCloudMachineTemplateScope(ctx context.Context, params HCloudMachineTemplateScopeParams) (*HCloudMachineTemplateScope, error) {
+	if params.HCloudClient == nil {
+		return nil, errors.New("failed to generate new scope from nil HCloudClient")
+	}
+
+	if params.Logger == nil {
+		logger := klogr.New()
+		params.Logger = &logger
+	}
+
+	helper, err := patch.NewHelper(params.HCloudMachineTemplate, params.Client)
+	if err != nil {
+		return nil, errors.Wrap(err, "failed to init patch helper")
+	}
+
+	return &HCloudMachineTemplateScope{
+		Logger:                params.Logger,
+		Client:                params.Client,
+		HCloudMachineTemplate: params.HCloudMachineTemplate,
+		HCloudClient:          params.HCloudClient,
+		patchHelper:           helper,
+	}, nil
+}
+
+// HCloudMachineTemplateScope defines the basic context for an actuator to operate upon.
+type HCloudMachineTemplateScope struct {
+	*logr.Logger
+	Client       client.Client
+	patchHelper  *patch.Helper
+	HCloudClient hcloudclient.Client
+
+	HCloudMachineTemplate *infrav1.HCloudMachineTemplate
+}
+
+// Name returns the HCloudMachineTemplate name.
+func (s *HCloudMachineTemplateScope) Name() string {
+	return s.HCloudMachineTemplate.Name
+}
+
+// Namespace returns the namespace name.
+func (s *HCloudMachineTemplateScope) Namespace() string {
+	return s.HCloudMachineTemplate.Namespace
+}
+
+// Close closes the current scope persisting the cluster configuration and status.
+func (s *HCloudMachineTemplateScope) Close(ctx context.Context) error {
+	return s.patchHelper.Patch(ctx, s.HCloudMachineTemplate)
+}
+
+// PatchObject persists the machine spec and status.
+func (s *HCloudMachineTemplateScope) PatchObject(ctx context.Context) error {
+	return s.patchHelper.Patch(ctx, s.HCloudMachineTemplate)
+}
diff --git a/pkg/services/hcloud/client/client.go b/pkg/services/hcloud/client/client.go
index 0569c581..4c1d8806 100644
--- a/pkg/services/hcloud/client/client.go
+++ b/pkg/services/hcloud/client/client.go
@@ -46,6 +46,7 @@ type Client interface {
 	AttachServerToNetwork(context.Context, *hcloud.Server, hcloud.ServerAttachToNetworkOpts) (*hcloud.Action, error)
 	ListServers(context.Context, hcloud.ServerListOpts) ([]*hcloud.Server, error)
 	DeleteServer(context.Context, *hcloud.Server) error
+	ListServerTypes(context.Context) ([]*hcloud.ServerType, error)
 	PowerOnServer(context.Context, *hcloud.Server) (*hcloud.Action, error)
 	ShutdownServer(context.Context, *hcloud.Server) (*hcloud.Action, error)
 	CreateNetwork(context.Context, hcloud.NetworkCreateOpts) (*hcloud.Network, error)
@@ -170,6 +171,10 @@ func (c *realClient) ListServers(ctx context.Context, opts hcloud.ServerListOpts
 	return c.client.Server.AllWithOpts(ctx, opts)
 }
 
+func (c *realClient) ListServerTypes(ctx context.Context) ([]*hcloud.ServerType, error) {
+	return c.client.ServerType.All(ctx)
+}
+
 func (c *realClient) ShutdownServer(ctx context.Context, server *hcloud.Server) (*hcloud.Action, error) {
 	res, _, err := c.client.Server.Shutdown(ctx, server)
 	return res, err
diff --git a/pkg/services/hcloud/client/fake/hcloud_client.go b/pkg/services/hcloud/client/fake/hcloud_client.go
index 0819113e..284aa219 100644
--- a/pkg/services/hcloud/client/fake/hcloud_client.go
+++ b/pkg/services/hcloud/client/fake/hcloud_client.go
@@ -28,6 +28,12 @@ import (
 	"github.com/syself/cluster-api-provider-hetzner/pkg/utils"
 )
 
+// DefaultCPUCores defines the default cpu cores for HCloud machines' capacities.
+const DefaultCPUCores = 1
+
+// DefaultMemoryInGB defines the default memory in GB for HCloud machines' capacities.
+const DefaultMemoryInGB = float32(4)
+
 type cacheHCloudClient struct {
 	serverCache         serverCache
 	placementGroupCache placementGroupCache
@@ -484,6 +490,29 @@ func (c *cacheHCloudClient) DeleteServer(ctx context.Context, server *hcloud.Ser
 	return nil
 }
 
+func (c *cacheHCloudClient) ListServerTypes(ctx context.Context) ([]*hcloud.ServerType, error) {
+	return []*hcloud.ServerType{
+		{
+			ID:     1,
+			Name:   "cpx11",
+			Cores:  DefaultCPUCores,
+			Memory: DefaultMemoryInGB,
+		},
+		{
+			ID:     2,
+			Name:   "cpx21",
+			Cores:  DefaultCPUCores,
+			Memory: DefaultMemoryInGB,
+		},
+		{
+			ID:     3,
+			Name:   "cpx31",
+			Cores:  DefaultCPUCores,
+			Memory: DefaultMemoryInGB,
+		},
+	}, nil
+}
+
 func (c *cacheHCloudClient) CreateNetwork(ctx context.Context, opts hcloud.NetworkCreateOpts) (*hcloud.Network, error) {
 	if _, found := c.networkCache.nameMap[opts.Name]; found {
 		return nil, fmt.Errorf("already exists")
diff --git a/pkg/services/hcloud/loadbalancer/loadbalancer.go b/pkg/services/hcloud/loadbalancer/loadbalancer.go
index dba30981..43aa9c2e 100644
--- a/pkg/services/hcloud/loadbalancer/loadbalancer.go
+++ b/pkg/services/hcloud/loadbalancer/loadbalancer.go
@@ -294,7 +294,7 @@ func buildLoadBalancerCreateOpts(hc *infrav1.HetznerCluster) hcloud.LoadBalancer
 	}
 
 	listenPort := int(hc.Spec.ControlPlaneEndpoint.Port)
-
+	boolTrue := true
 	return hcloud.LoadBalancerCreateOpts{
 		LoadBalancerType: &hcloud.LoadBalancerType{
 			Name: hc.Spec.ControlPlaneLoadBalancer.Type,
@@ -310,6 +310,7 @@ func buildLoadBalancerCreateOpts(hc *infrav1.HetznerCluster) hcloud.LoadBalancer
 		Labels: map[string]string{
 			clusterTagKey: string(infrav1.ResourceLifecycleOwned),
 		},
+		PublicInterface: &boolTrue,
 		Services: []hcloud.LoadBalancerCreateOptsService{
 			{
 				Protocol:        hcloud.LoadBalancerServiceProtocolTCP,
diff --git a/pkg/services/hcloud/machinetemplate/machinetemplate.go b/pkg/services/hcloud/machinetemplate/machinetemplate.go
new file mode 100644
index 00000000..9402ee5f
--- /dev/null
+++ b/pkg/services/hcloud/machinetemplate/machinetemplate.go
@@ -0,0 +1,96 @@
+/*
+Copyright 2022 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+// Package machinetemplate implements functions to manage the lifecycle of HCloud machine templates
+package machinetemplate
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/hetznercloud/hcloud-go/hcloud"
+	"github.com/pkg/errors"
+	infrav1 "github.com/syself/cluster-api-provider-hetzner/api/v1beta1"
+	"github.com/syself/cluster-api-provider-hetzner/pkg/scope"
+	corev1 "k8s.io/api/core/v1"
+	"sigs.k8s.io/cluster-api/util/conditions"
+	"sigs.k8s.io/cluster-api/util/record"
+	ctrl "sigs.k8s.io/controller-runtime"
+)
+
+// Service defines struct with HCloudMachineTemplate scope to reconcile HCloud machine templates.
+type Service struct {
+	scope *scope.HCloudMachineTemplateScope
+}
+
+// NewService outs a new service with HCloudMachineTemplate scope.
+func NewService(scope *scope.HCloudMachineTemplateScope) *Service {
+	return &Service{
+		scope: scope,
+	}
+}
+
+// Reconcile implements reconcilement of HCloud machines.
+func (s *Service) Reconcile(ctx context.Context) (_ *ctrl.Result, err error) {
+	capacity, err := s.getCapacity(ctx)
+	if err != nil {
+		return nil, errors.Wrap(err, "failed to get capacity")
+	}
+
+	s.scope.HCloudMachineTemplate.Status.Capacity = capacity
+	return nil, nil
+}
+
+func (s *Service) getCapacity(ctx context.Context) (corev1.ResourceList, error) {
+	capacity := make(corev1.ResourceList)
+	// List all server types
+	serverTypes, err := s.scope.HCloudClient.ListServerTypes(ctx)
+	if err != nil {
+		if hcloud.IsError(err, hcloud.ErrorCodeRateLimitExceeded) {
+			conditions.MarkTrue(s.scope.HCloudMachineTemplate, infrav1.RateLimitExceeded)
+			record.Event(s.scope.HCloudMachineTemplate,
+				"RateLimitExceeded",
+				"exceeded rate limit with calling hcloud function ListServerTypes",
+			)
+		}
+		return nil, errors.Wrap(err, "failed to list server types")
+	}
+
+	// Find the correct server type and check number of CPU cores and GB of memory
+	var foundServerType bool
+	for _, serverType := range serverTypes {
+		if serverType.Name == string(s.scope.HCloudMachineTemplate.Spec.Template.Spec.Type) {
+			foundServerType = true
+
+			cpu, err := GetCPUQuantityFromInt(serverType.Cores)
+			if err != nil {
+				return nil, errors.Wrapf(err, "failed to parse quantity. CPU cores: %v. Server type: %+v", serverType.Cores, serverType)
+			}
+			capacity[corev1.ResourceCPU] = cpu
+
+			memory, err := GetMemoryQuantityFromFloat32(serverType.Memory)
+			if err != nil {
+				return nil, errors.Wrapf(err, "failed to parse quantity. Memory: %v. Server type: %+v", serverType.Memory, serverType)
+			}
+			capacity[corev1.ResourceMemory] = memory
+		}
+	}
+	if !foundServerType {
+		return nil, fmt.Errorf("failed to find server type for %s", s.scope.HCloudMachineTemplate.Spec.Template.Spec.Type)
+	}
+
+	return capacity, nil
+}
diff --git a/pkg/services/hcloud/machinetemplate/util.go b/pkg/services/hcloud/machinetemplate/util.go
new file mode 100644
index 00000000..650bebc2
--- /dev/null
+++ b/pkg/services/hcloud/machinetemplate/util.go
@@ -0,0 +1,33 @@
+/*
+Copyright 2022 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package machinetemplate
+
+import (
+	"fmt"
+
+	"k8s.io/apimachinery/pkg/api/resource"
+)
+
+// GetCPUQuantityFromInt returns a resource quantity for CPU cores from an integer.
+func GetCPUQuantityFromInt(cores int) (resource.Quantity, error) {
+	return resource.ParseQuantity(fmt.Sprintf("%v", cores))
+}
+
+// GetMemoryQuantityFromFloat32 returns a resource quantity for memory in GB from a float32.
+func GetMemoryQuantityFromFloat32(memory float32) (resource.Quantity, error) {
+	return resource.ParseQuantity(fmt.Sprintf("%vG", memory))
+}


Test patch:
diff --git a/controllers/controllers_suite_test.go b/controllers/controllers_suite_test.go
index b8bb42d7..a00a9c21 100644
--- a/controllers/controllers_suite_test.go
+++ b/controllers/controllers_suite_test.go
@@ -84,6 +84,13 @@ var _ = BeforeSuite(func() {
 		WatchFilterValue:    "",
 	}).SetupWithManager(ctx, testEnv.Manager, controller.Options{})).To(Succeed())
 
+	Expect((&HCloudMachineTemplateReconciler{
+		Client:              testEnv.Manager.GetClient(),
+		APIReader:           testEnv.Manager.GetAPIReader(),
+		HCloudClientFactory: testEnv.HCloudClientFactory,
+		WatchFilterValue:    "",
+	}).SetupWithManager(ctx, testEnv.Manager, controller.Options{})).To(Succeed())
+
 	Expect((&HetznerBareMetalHostReconciler{
 		Client:             testEnv.Manager.GetClient(),
 		APIReader:          testEnv.Manager.GetAPIReader(),
diff --git a/controllers/hcloudmachinetemplate_controller_test.go b/controllers/hcloudmachinetemplate_controller_test.go
new file mode 100644
index 00000000..f7ccb58d
--- /dev/null
+++ b/controllers/hcloudmachinetemplate_controller_test.go
@@ -0,0 +1,157 @@
+/*
+Copyright 2022 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package controllers
+
+import (
+	"fmt"
+	"time"
+
+	. "github.com/onsi/ginkgo"
+	. "github.com/onsi/gomega"
+	infrav1 "github.com/syself/cluster-api-provider-hetzner/api/v1beta1"
+	"github.com/syself/cluster-api-provider-hetzner/pkg/services/hcloud/client/fake"
+	"github.com/syself/cluster-api-provider-hetzner/pkg/services/hcloud/machinetemplate"
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+)
+
+var _ = Describe("HCloudMachineTemplateReconciler", func() {
+	var (
+		capiCluster  *clusterv1.Cluster
+		infraCluster *infrav1.HetznerCluster
+
+		machineTemplate *infrav1.HCloudMachineTemplate
+
+		testNs *corev1.Namespace
+
+		hetznerSecret *corev1.Secret
+
+		key client.ObjectKey
+	)
+
+	BeforeEach(func() {
+		var err error
+		testNs, err = testEnv.CreateNamespace(ctx, "hcloudmachinetemplate-reconciler")
+		Expect(err).NotTo(HaveOccurred())
+
+		capiCluster = &clusterv1.Cluster{
+			ObjectMeta: metav1.ObjectMeta{
+				GenerateName: "test1-",
+				Namespace:    testNs.Name,
+				Finalizers:   []string{clusterv1.ClusterFinalizer},
+			},
+			Spec: clusterv1.ClusterSpec{
+				InfrastructureRef: &corev1.ObjectReference{
+					APIVersion: "infrastructure.cluster.x-k8s.io/v1beta1",
+					Kind:       "HetznerCluster",
+					Name:       "hetzner-test1",
+					Namespace:  testNs.Name,
+				},
+			},
+			Status: clusterv1.ClusterStatus{
+				InfrastructureReady: true,
+			},
+		}
+		Expect(testEnv.Create(ctx, capiCluster)).To(Succeed())
+
+		infraCluster = &infrav1.HetznerCluster{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      "hetzner-test1",
+				Namespace: testNs.Name,
+				OwnerReferences: []metav1.OwnerReference{
+					{
+						APIVersion: "cluster.x-k8s.io/v1beta1",
+						Kind:       "Cluster",
+						Name:       capiCluster.Name,
+						UID:        capiCluster.UID,
+					},
+				},
+			},
+			Spec: getDefaultHetznerClusterSpec(),
+		}
+
+		Expect(testEnv.Create(ctx, infraCluster)).To(Succeed())
+
+		machineTemplate = &infrav1.HCloudMachineTemplate{
+			ObjectMeta: metav1.ObjectMeta{
+				GenerateName: "hcloud-machine-template-",
+				Namespace:    testNs.Name,
+				Labels: map[string]string{
+					clusterv1.ClusterLabelName: capiCluster.Name,
+				},
+			},
+			Spec: infrav1.HCloudMachineTemplateSpec{
+				Template: infrav1.HCloudMachineTemplateResource{
+					Spec: infrav1.HCloudMachineSpec{
+						ImageName:          "fedora-control-plane",
+						Type:               "cpx31",
+						PlacementGroupName: &defaultPlacementGroupName,
+					},
+				},
+			},
+		}
+		Expect(testEnv.Create(ctx, machineTemplate)).To(Succeed())
+
+		hetznerSecret = getDefaultHetznerSecret(testNs.Name)
+		Expect(testEnv.Create(ctx, hetznerSecret)).To(Succeed())
+
+		key = client.ObjectKey{Namespace: testNs.Name, Name: machineTemplate.Name}
+	})
+
+	AfterEach(func() {
+		Expect(testEnv.Cleanup(ctx, testNs, capiCluster, infraCluster,
+			machineTemplate, hetznerSecret)).To(Succeed())
+	})
+
+	Context("Basic test", func() {
+		It("sets the capacity in status", func() {
+			Eventually(func() bool {
+				if err := testEnv.Get(ctx, key, machineTemplate); err != nil {
+					fmt.Printf("Did not find machine template: %s\n", err)
+					return false
+				}
+
+				fmt.Printf("Capacity: %v\n", machineTemplate.Status.Capacity)
+
+				// If capacity is not set (yet), there is nothing to compare
+				if machineTemplate.Status.Capacity.Cpu() == nil || machineTemplate.Status.Capacity.Memory() == nil {
+					fmt.Printf("Capacity not set: %v\n", machineTemplate.Status.Capacity)
+					return false
+				}
+
+				// Compare CPU
+				expectedCPU, err := machinetemplate.GetCPUQuantityFromInt(fake.DefaultCPUCores)
+				Expect(err).To(Succeed())
+				if !expectedCPU.Equal(*machineTemplate.Status.Capacity.Cpu()) {
+					fmt.Printf("CPU did not equal: Expected: %v. Actual: %v\n", expectedCPU, machineTemplate.Status.Capacity.Cpu())
+					return false
+				}
+
+				// Compare memory
+				expectedMemory, err := machinetemplate.GetMemoryQuantityFromFloat32(fake.DefaultMemoryInGB)
+				Expect(err).To(Succeed())
+				if !expectedMemory.Equal(*machineTemplate.Status.Capacity.Memory()) {
+					fmt.Printf("Memory did not equal: Expected: %v. Actual: %v\n", expectedMemory, machineTemplate.Status.Capacity.Memory())
+					return false
+				}
+				return expectedMemory.Equal(*machineTemplate.Status.Capacity.Memory())
+			}, 10*time.Second, time.Second).Should(BeTrue())
+		})
+	})
+})
diff --git a/controllers/hetznerbaremetalhost_controller_test.go b/controllers/hetznerbaremetalhost_controller_test.go
index 9b99b4a2..ed379b51 100644
--- a/controllers/hetznerbaremetalhost_controller_test.go
+++ b/controllers/hetznerbaremetalhost_controller_test.go
@@ -170,6 +170,7 @@ var _ = Describe("HetznerBareMetalHostReconciler", func() {
 		osSSHClientAfterInstallImage.On("EnsureCloudInit").Return(sshclient.Output{StdOut: "cloud-init"})
 		osSSHClientAfterInstallImage.On("CloudInitStatus").Return(sshclient.Output{StdOut: "status: done"})
 		osSSHClientAfterInstallImage.On("CheckCloudInitLogsForSigTerm").Return(sshclient.Output{})
+		osSSHClientAfterInstallImage.On("ResetKubeadm").Return(sshclient.Output{})
 		osSSHClientAfterInstallImage.On("GetHostName").Return(sshclient.Output{
 			StdOut: infrav1.BareMetalHostNamePrefix + bmMachineName,
 			StdErr: "",
diff --git a/pkg/services/hcloud/client/fake/hcloud_client_test.go b/pkg/services/hcloud/client/fake/hcloud_client_test.go
index 4c1ecfb3..a7a43041 100644
--- a/pkg/services/hcloud/client/fake/hcloud_client_test.go
+++ b/pkg/services/hcloud/client/fake/hcloud_client_test.go
@@ -553,6 +553,31 @@ var _ = Describe("Server", func() {
 		Expect(err).ToNot(Succeed())
 		Expect(hcloud.IsError(err, hcloud.ErrorCodeNotFound)).To(BeTrue())
 	})
+
+	It("returns the correct server types", func() {
+		serverTypes, err := client.ListServerTypes(ctx)
+		Expect(err).To(Succeed())
+		Expect(serverTypes).To(Equal([]*hcloud.ServerType{
+			{
+				ID:     1,
+				Name:   "cpx11",
+				Cores:  fake.DefaultCPUCores,
+				Memory: fake.DefaultMemoryInGB,
+			},
+			{
+				ID:     2,
+				Name:   "cpx21",
+				Cores:  fake.DefaultCPUCores,
+				Memory: fake.DefaultMemoryInGB,
+			},
+			{
+				ID:     3,
+				Name:   "cpx31",
+				Cores:  fake.DefaultCPUCores,
+				Memory: fake.DefaultMemoryInGB,
+			},
+		}))
+	})
 })
 
 var _ = Describe("Network", func() {
diff --git a/pkg/services/hcloud/machinetemplate/util_test.go b/pkg/services/hcloud/machinetemplate/util_test.go
new file mode 100644
index 00000000..e4c61f55
--- /dev/null
+++ b/pkg/services/hcloud/machinetemplate/util_test.go
@@ -0,0 +1,38 @@
+/*
+Copyright 2022 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package machinetemplate
+
+import (
+	. "github.com/onsi/ginkgo/extensions/table"
+	. "github.com/onsi/gomega"
+)
+
+var _ = DescribeTable("GetCPUQuantityFromInt",
+	func(cpuCores int, expectedOutput string) {
+		Expect(GetCPUQuantityFromInt(cpuCores)).To(Equal(expectedOutput))
+	},
+	Entry("1", 1, "1"),
+	Entry("2", 2, "2"),
+)
+
+var _ = DescribeTable("GetMemoryQuantityFromFloat32",
+	func(memory float32, expectedOutput string) {
+		Expect(GetMemoryQuantityFromFloat32(memory)).To(Equal(expectedOutput))
+	},
+	Entry("1", float32(1), "1G"),
+	Entry("2", float32(2), "2G"),
+)