Repository: hetznercloud/hcloud-cloud-controller-manager
Pull request #124 (2020-11-23 08:07:43)

Reference certificates by name
Closes GH-116


Patch:
diff --git a/hcloud/cloud.go b/hcloud/cloud.go
index 3995338e..dac4291e 100644
--- a/hcloud/cloud.go
+++ b/hcloud/cloud.go
@@ -98,6 +98,7 @@ func newCloud(config io.Reader) (cloudprovider.Interface, error) {
 
 	lbOps := &hcops.LoadBalancerOps{
 		LBClient:      &client.LoadBalancer,
+		CertClient:    &client.Certificate,
 		ActionClient:  &client.Action,
 		NetworkClient: &client.Network,
 		NetworkID:     networkID,
diff --git a/internal/annotation/name.go b/internal/annotation/name.go
index 5e4bda62..eff74daa 100644
--- a/internal/annotation/name.go
+++ b/internal/annotation/name.go
@@ -38,11 +38,15 @@ func (s Name) AnnotateService(svc *v1.Service, v interface{}) error {
 	case []string:
 		svc.ObjectMeta.Annotations[k] = strings.Join(vt, ",")
 	case []*hcloud.Certificate:
-		ids := make([]string, len(vt))
+		idsOrNames := make([]string, len(vt))
 		for i, c := range vt {
-			ids[i] = strconv.Itoa(c.ID)
+			if c.ID == 0 && c.Name != "" {
+				idsOrNames[i] = c.Name
+				continue
+			}
+			idsOrNames[i] = strconv.Itoa(c.ID)
 		}
-		svc.ObjectMeta.Annotations[k] = strings.Join(ids, ",")
+		svc.ObjectMeta.Annotations[k] = strings.Join(idsOrNames, ",")
 	case hcloud.NetworkZone:
 		svc.ObjectMeta.Annotations[k] = string(vt)
 	case hcloud.LoadBalancerAlgorithmType:
@@ -258,13 +262,16 @@ func (s Name) CertificatesFromService(svc *v1.Service) ([]*hcloud.Certificate, e
 	var cs []*hcloud.Certificate
 
 	err := s.applyToValue(op, svc, func(v string) error {
-		ids := strings.Split(v, ",")
-		cs = make([]*hcloud.Certificate, len(ids))
+		ss := strings.Split(v, ",")
+		cs = make([]*hcloud.Certificate, len(ss))
 
-		for i, idStr := range ids {
-			id, err := strconv.Atoi(idStr)
+		for i, s := range ss {
+			id, err := strconv.Atoi(s)
 			if err != nil {
-				return err
+				// If we could not parse the string as an integer we assume it
+				// is a name not an id.
+				cs[i] = &hcloud.Certificate{Name: s}
+				continue
 			}
 			cs[i] = &hcloud.Certificate{ID: id}
 		}
diff --git a/internal/hcops/certificates.go b/internal/hcops/certificates.go
new file mode 100644
index 00000000..d77a0a18
--- /dev/null
+++ b/internal/hcops/certificates.go
@@ -0,0 +1,13 @@
+package hcops
+
+import (
+	"context"
+
+	"github.com/hetznercloud/hcloud-go/hcloud"
+)
+
+// HCloudCertificateClient defines the hcloud-go function related to
+// certificate management.
+type HCloudCertificateClient interface {
+	Get(ctx context.Context, idOrName string) (*hcloud.Certificate, *hcloud.Response, error)
+}
diff --git a/internal/hcops/load_balancer.go b/internal/hcops/load_balancer.go
index 16f95b14..23da21ec 100644
--- a/internal/hcops/load_balancer.go
+++ b/internal/hcops/load_balancer.go
@@ -49,6 +49,7 @@ type LoadBalancerOps struct {
 	LBClient      HCloudLoadBalancerClient
 	ActionClient  HCloudActionClient
 	NetworkClient HCloudNetworkClient
+	CertClient    HCloudCertificateClient
 	RetryDelay    time.Duration
 	NetworkID     int
 }
@@ -434,7 +435,7 @@ func (l *LoadBalancerOps) ReconcileHCLBServices(
 		portExists := hclbListenPorts[portNo]
 		delete(hclbListenPorts, portNo)
 
-		b := &hclbServiceOptsBuilder{Port: port, Service: svc}
+		b := &hclbServiceOptsBuilder{Port: port, Service: svc, CertClient: l.CertClient}
 		if portExists {
 			klog.InfoS("update service", "op", op, "port", portNo, "loadBalancerID", lb.ID)
 
@@ -453,7 +454,6 @@ func (l *LoadBalancerOps) ReconcileHCLBServices(
 			if err != nil {
 				return changed, fmt.Errorf("%s: %w", op, err)
 			}
-
 			action, _, err = l.LBClient.AddService(ctx, lb, addOpts)
 			if err != nil {
 				return changed, fmt.Errorf("%s: %w", op, err)
@@ -484,8 +484,9 @@ func (l *LoadBalancerOps) ReconcileHCLBServices(
 }
 
 type hclbServiceOptsBuilder struct {
-	Port    v1.ServicePort
-	Service *v1.Service
+	Port       v1.ServicePort
+	Service    *v1.Service
+	CertClient HCloudCertificateClient
 
 	listenPort      int
 	destinationPort int
@@ -573,6 +574,17 @@ func (b *hclbServiceOptsBuilder) extract() {
 		if errors.Is(err, annotation.ErrNotSet) {
 			return nil
 		}
+		if err != nil {
+			return fmt.Errorf("%s: %w", op, err)
+		}
+
+		ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+		defer cancel()
+
+		certs, err = b.resolveCertNames(ctx, certs)
+		if err != nil {
+			return fmt.Errorf("%s: %w", op, err)
+		}
 		b.httpOpts.Certificates = certs
 		b.addHTTP = true
 		return nil
@@ -607,6 +619,25 @@ func (b *hclbServiceOptsBuilder) extract() {
 	b.extractHealthCheck()
 }
 
+func (b *hclbServiceOptsBuilder) resolveCertNames(ctx context.Context, cs []*hcloud.Certificate) ([]*hcloud.Certificate, error) {
+	const op = "hcops/hclbServiceOptsBuilder.resolveCertNames"
+
+	resolved := make([]*hcloud.Certificate, len(cs))
+	for i, c := range cs {
+		if c.ID != 0 {
+			resolved[i] = c
+			continue
+		}
+
+		c, _, err := b.CertClient.Get(ctx, c.Name)
+		if err != nil {
+			return nil, fmt.Errorf("%s: %w", op, err)
+		}
+		resolved[i] = &hcloud.Certificate{ID: c.ID}
+	}
+	return resolved, nil
+}
+
 func (b *hclbServiceOptsBuilder) extractHealthCheck() {
 	const op = "hcops/hclbServiceOptsBuilder.extractHealthCheck"
 
diff --git a/internal/mocks/casts.go b/internal/mocks/casts.go
index babbf997..ef50dbe4 100644
--- a/internal/mocks/casts.go
+++ b/internal/mocks/casts.go
@@ -52,3 +52,11 @@ func getErrChan(args mock.Arguments, i int) chan error {
 	}
 	return v.(chan error)
 }
+
+func getCertificatePtr(args mock.Arguments, i int) *hcloud.Certificate {
+	v := args.Get(i)
+	if v == nil {
+		return nil
+	}
+	return v.(*hcloud.Certificate)
+}
diff --git a/internal/mocks/certificates.go b/internal/mocks/certificates.go
new file mode 100644
index 00000000..1384f1c5
--- /dev/null
+++ b/internal/mocks/certificates.go
@@ -0,0 +1,17 @@
+package mocks
+
+import (
+	"context"
+
+	"github.com/hetznercloud/hcloud-go/hcloud"
+	"github.com/stretchr/testify/mock"
+)
+
+type CertificateClient struct {
+	mock.Mock
+}
+
+func (m *CertificateClient) Get(ctx context.Context, idOrName string) (*hcloud.Certificate, *hcloud.Response, error) {
+	args := m.Called(ctx, idOrName)
+	return getCertificatePtr(args, 0), getResponsePtr(args, 1), args.Error(2)
+}


Test patch:
diff --git a/e2etests/e2e_test.go b/e2etests/e2e_test.go
index 364d83c1..2727afd3 100644
--- a/e2etests/e2e_test.go
+++ b/e2etests/e2e_test.go
@@ -113,7 +113,30 @@ func TestCloudControllerManagerLoadBalancersMinimalSetup(t *testing.T) {
 	}
 
 	ingressIP := lbSvc.Status.LoadBalancer.Ingress[0].IP // Index 0 is always the public IP of the LB
-	lbTest.WaitForHttpAvailable(ingressIP)
+	WaitForHTTPAvailable(t, ingressIP, false)
+
+	lbTest.TearDown()
+}
+
+func TestCloudControllerManagerLoadBalancersHTTPS(t *testing.T) {
+	cert := testCluster.CreateTLSCertificate(t, "loadbalancer-https")
+	lbTest := lbTestHelper{t: t, K8sClient: testCluster.k8sClient, podName: "loadbalancer-https", port: 443}
+
+	pod := lbTest.DeployTestPod()
+
+	lbSvc := lbTest.ServiceDefinition(pod, map[string]string{
+		string(annotation.LBLocation):            "nbg1",
+		string(annotation.LBSvcHTTPCertificates): cert.Name,
+		string(annotation.LBSvcProtocol):         "https",
+	})
+
+	lbSvc, err := lbTest.CreateService(lbSvc)
+	if err != nil {
+		t.Fatalf("deploying test svc: %s", err)
+	}
+
+	ingressIP := lbSvc.Status.LoadBalancer.Ingress[0].IP // Index 0 is always the public IP of the LB
+	WaitForHTTPAvailable(t, ingressIP, true)
 
 	lbTest.TearDown()
 }
@@ -138,7 +161,7 @@ func TestCloudControllerManagerLoadBalancersWithPrivateNetwork(t *testing.T) {
 	}
 
 	ingressIP := lbSvc.Status.LoadBalancer.Ingress[0].IP // Index 0 is always the public IP of the LB
-	lbTest.WaitForHttpAvailable(ingressIP)
+	WaitForHTTPAvailable(t, ingressIP, false)
 
 	lbTest.TearDown()
 }
@@ -152,7 +175,7 @@ func TestCloudControllerManagerNetworksPodIPsAreAccessible(t *testing.T) {
 
 	pod := nwTest.DeployTestPod()
 
-	nwTest.WaitForHttpAvailable(pod.Status.PodIP)
+	nwTest.WaitForHTTPOnServer(pod.Status.PodIP)
 
 	nwTest.TearDown()
 }
diff --git a/e2etests/setup.go b/e2etests/setup.go
index 3066b22a..d7daa7bc 100644
--- a/e2etests/setup.go
+++ b/e2etests/setup.go
@@ -148,23 +148,42 @@ func (s *hcloudK8sSetup) PrepareK8s(withNetworks bool) (string, error) {
 	}
 
 	fmt.Printf("%s: Apply ccm deployment\n", op)
-	err = RunCommandOnServer(s.privKey, s.server, fmt.Sprintf("KUBECONFIG=/root/.kube/config kubectl apply -f ccm.yml"))
+	err = RunCommandOnServer(s.privKey, s.server, "KUBECONFIG=/root/.kube/config kubectl apply -f ccm.yml")
 	if err != nil {
 		return "", fmt.Errorf("%s Deploy ccm: %s", op, err)
 	}
 	fmt.Printf("%s: Download kubeconfig\n", op)
 
-	cmd := exec.Command("/usr/bin/scp", "-i", "ssh_key", "-o", "UserKnownHostsFile=/dev/null", "-o", "StrictHostKeyChecking=no", fmt.Sprintf("root@%s:/root/.kube/config", s.server.PublicNet.IPv4.IP.String()), "kubeconfig")
-	if ok := os.Getenv("TEST_DEBUG_MODE"); ok != "" {
-		cmd.Stdout = os.Stdout
-	}
-	err = cmd.Run()
+	err = scp("ssh_key", fmt.Sprintf("root@%s:/root/.kube/config", s.server.PublicNet.IPv4.IP.String()), "kubeconfig")
 	if err != nil {
 		return "", fmt.Errorf("%s download kubeconfig: %s", op, err)
 	}
 	return "kubeconfig", nil
 }
 
+func scp(identityFile, src, dest string) error {
+	const op = "e2etests/scp"
+
+	cmd := exec.Command(
+		"/usr/bin/scp",
+		"-F", "/dev/null", // ignore $HOME/.ssh/config
+		"-i", identityFile,
+		"-o", "IdentitiesOnly=yes", // only use the identities passed on the command line
+		"-o", "UserKnownHostsFile=/dev/null",
+		"-o", "StrictHostKeyChecking=no",
+		src,
+		dest,
+	)
+	if ok := os.Getenv("TEST_DEBUG_MODE"); ok != "" {
+		cmd.Stdout = os.Stdout
+		cmd.Stderr = os.Stderr
+	}
+	if err := cmd.Run(); err != nil {
+		return fmt.Errorf("%s: %v", op, err)
+	}
+	return nil
+}
+
 // prepareCCMDeploymentFile patches the Cloud Controller Deployment file
 // It replaces the used image and the pull policy to always use the local image
 // from this test run
diff --git a/e2etests/testing.go b/e2etests/testing.go
index fd14ba42..15c613a1 100644
--- a/e2etests/testing.go
+++ b/e2etests/testing.go
@@ -2,6 +2,7 @@ package e2etests
 
 import (
 	"context"
+	"crypto/tls"
 	"fmt"
 	"math/rand"
 	"net/http"
@@ -14,10 +15,12 @@ import (
 
 	"k8s.io/client-go/tools/clientcmd"
 
+	"github.com/hetznercloud/hcloud-cloud-controller-manager/internal/testsupport"
 	"github.com/hetznercloud/hcloud-go/hcloud"
 	corev1 "k8s.io/api/core/v1"
 	k8serrors "k8s.io/apimachinery/pkg/api/errors"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/util/intstr"
 	"k8s.io/apimachinery/pkg/util/wait"
 	"k8s.io/client-go/kubernetes"
 )
@@ -29,145 +32,156 @@ func init() {
 }
 
 type TestCluster struct {
-	once        sync.Once
-	useNetworks bool
-	setup       *hcloudK8sSetup
-	k8sClient   *kubernetes.Clientset
-	started     bool
-	err         error
+	useNetworks  bool
+	setup        *hcloudK8sSetup
+	k8sClient    *kubernetes.Clientset
+	started      bool
+	certificates []*hcloud.Certificate
+
+	mu sync.Mutex
 }
 
 func (tc *TestCluster) initialize() error {
-	const op = "TestCluster/initialize"
-	tc.once.Do(func() {
-		fmt.Printf("%s: Starting CCM Testsuite\n", op)
-		networksSupport := os.Getenv("USE_NETWORKS")
-		if networksSupport == "yes" {
-			tc.useNetworks = true
-		}
-		isUsingGithubActions := os.Getenv("GITHUB_ACTIONS")
-		isUsingGitlabCI := os.Getenv("CI_JOB_ID")
-		testIdentifier := ""
-		if isUsingGithubActions == "true" {
-			testIdentifier = fmt.Sprintf("gh-%s-%d", os.Getenv("GITHUB_RUN_ID"), rng.Int())
-			fmt.Printf("%s: Running in Github Action\n", op)
-		}
-		if isUsingGitlabCI != "" {
-			testIdentifier = fmt.Sprintf("gl-%s", isUsingGitlabCI)
-			fmt.Printf("%s: Running in Gitlab CI\n", op)
-		}
-		if testIdentifier == "" {
-			testIdentifier = fmt.Sprintf("local-%d", rng.Int())
-			fmt.Printf("%s: Running local\n", op)
-		}
+	const op = "e2tests/TestCluster.initialize"
 
-		k8sVersion := os.Getenv("K8S_VERSION")
-		if k8sVersion == "" {
-			k8sVersion = "1.18.9"
-		}
-		token := os.Getenv("HCLOUD_TOKEN")
-		if len(token) != 64 {
-			tc.err = fmt.Errorf("%s: No valid HCLOUD_TOKEN found", op)
-			return
-		}
-		keepOnFailure := os.Getenv("KEEP_SERVER_ON_FAILURE") == "yes"
+	if tc.started {
+		return nil
+	}
 
-		var additionalSSHKeys []*hcloud.SSHKey
+	fmt.Printf("%s: Starting CCM Testsuite\n", op)
 
-		opts := []hcloud.ClientOption{
-			hcloud.WithToken(token),
-			hcloud.WithApplication("hcloud-ccm-testsuite", "1.0"),
-		}
-		hcloudClient := hcloud.NewClient(opts...)
-		additionalSSHKeysIDOrName := os.Getenv("USE_SSH_KEYS")
-		if additionalSSHKeysIDOrName != "" {
-			idsOrNames := strings.Split(additionalSSHKeysIDOrName, ",")
-			for _, idOrName := range idsOrNames {
-				additionalSSHKey, _, err := hcloudClient.SSHKey.Get(context.Background(), idOrName)
-				if err != nil {
-					tc.err = fmt.Errorf("%s: %s", op, err)
-					return
-				}
-				additionalSSHKeys = append(additionalSSHKeys, additionalSSHKey)
+	networksSupport := os.Getenv("USE_NETWORKS")
+	if networksSupport == "yes" {
+		tc.useNetworks = true
+	}
+	isUsingGithubActions := os.Getenv("GITHUB_ACTIONS")
+	isUsingGitlabCI := os.Getenv("CI_JOB_ID")
+	testIdentifier := ""
+	if isUsingGithubActions == "true" {
+		testIdentifier = fmt.Sprintf("gh-%s-%d", os.Getenv("GITHUB_RUN_ID"), rng.Int())
+		fmt.Printf("%s: Running in Github Action\n", op)
+	}
+	if isUsingGitlabCI != "" {
+		testIdentifier = fmt.Sprintf("gl-%s", isUsingGitlabCI)
+		fmt.Printf("%s: Running in Gitlab CI\n", op)
+	}
+	if testIdentifier == "" {
+		testIdentifier = fmt.Sprintf("local-%d", rng.Int())
+		fmt.Printf("%s: Running local\n", op)
+	}
+
+	k8sVersion := os.Getenv("K8S_VERSION")
+	if k8sVersion == "" {
+		k8sVersion = "1.18.9"
+	}
+	token := os.Getenv("HCLOUD_TOKEN")
+	if len(token) != 64 {
+		return fmt.Errorf("%s: No valid HCLOUD_TOKEN found", op)
+	}
+	keepOnFailure := os.Getenv("KEEP_SERVER_ON_FAILURE") == "yes"
+
+	var additionalSSHKeys []*hcloud.SSHKey
+
+	opts := []hcloud.ClientOption{
+		hcloud.WithToken(token),
+		hcloud.WithApplication("hcloud-ccm-testsuite", "1.0"),
+	}
+	hcloudClient := hcloud.NewClient(opts...)
+	additionalSSHKeysIDOrName := os.Getenv("USE_SSH_KEYS")
+	if additionalSSHKeysIDOrName != "" {
+		idsOrNames := strings.Split(additionalSSHKeysIDOrName, ",")
+		for _, idOrName := range idsOrNames {
+			additionalSSHKey, _, err := hcloudClient.SSHKey.Get(context.Background(), idOrName)
+			if err != nil {
+				return fmt.Errorf("%s: %s", op, err)
 			}
+			additionalSSHKeys = append(additionalSSHKeys, additionalSSHKey)
 		}
+	}
 
-		fmt.Printf("Test against k8s %s\n", k8sVersion)
+	fmt.Printf("Test against k8s %s\n", k8sVersion)
 
-		fmt.Println("Building ccm image")
-		cmd := exec.Command("docker", "build", "-t", fmt.Sprintf("hcloud-ccm:ci_%s", testIdentifier), "../")
-		cmd.Stdout = os.Stdout
-		err := cmd.Run()
-		if err != nil {
-			tc.err = fmt.Errorf("%s: %s", op, err)
-			return
-		}
+	fmt.Println("Building ccm image")
+	cmd := exec.Command("docker", "build", "-t", fmt.Sprintf("hcloud-ccm:ci_%s", testIdentifier), "../")
+	cmd.Stdout = os.Stdout
+	err := cmd.Run()
+	if err != nil {
+		return fmt.Errorf("%s: %s", op, err)
+	}
 
-		fmt.Println("Saving ccm image to disk")
-		cmd = exec.Command("docker", "save", "--output", "ci-hcloud-ccm.tar", fmt.Sprintf("hcloud-ccm:ci_%s", testIdentifier))
-		cmd.Stdout = os.Stdout
-		err = cmd.Run()
-		if err != nil {
-			tc.err = fmt.Errorf("%s: %s", op, err)
-			return
-		}
+	fmt.Println("Saving ccm image to disk")
+	cmd = exec.Command("docker", "save", "--output", "ci-hcloud-ccm.tar", fmt.Sprintf("hcloud-ccm:ci_%s", testIdentifier))
+	cmd.Stdout = os.Stdout
+	err = cmd.Run()
+	if err != nil {
+		return fmt.Errorf("%s: %s", op, err)
+	}
 
-		tc.setup = &hcloudK8sSetup{
-			Hcloud:         hcloudClient,
-			K8sVersion:     k8sVersion,
-			TestIdentifier: testIdentifier,
-			HcloudToken:    token,
-			KeepOnFailure:  keepOnFailure,
-		}
-		fmt.Println("Setting up test env")
+	tc.setup = &hcloudK8sSetup{
+		Hcloud:         hcloudClient,
+		K8sVersion:     k8sVersion,
+		TestIdentifier: testIdentifier,
+		HcloudToken:    token,
+		KeepOnFailure:  keepOnFailure,
+	}
+	fmt.Println("Setting up test env")
 
-		err = tc.setup.PrepareTestEnv(context.Background(), additionalSSHKeys)
-		if err != nil {
-			tc.err = fmt.Errorf("%s: %s", op, err)
-			return
-		}
+	err = tc.setup.PrepareTestEnv(context.Background(), additionalSSHKeys)
+	if err != nil {
+		return fmt.Errorf("%s: %s", op, err)
+	}
 
-		kubeconfigPath, err := tc.setup.PrepareK8s(tc.useNetworks)
-		if err != nil {
-			tc.err = fmt.Errorf("%s: %s", op, err)
-			return
-		}
+	kubeconfigPath, err := tc.setup.PrepareK8s(tc.useNetworks)
+	if err != nil {
+		return fmt.Errorf("%s: %s", op, err)
+	}
 
-		config, err := clientcmd.BuildConfigFromFlags("", kubeconfigPath)
-		if err != nil {
-			fmt.Printf("%s: clientcmd.BuildConfigFromFlags: %s", op, err)
-		}
+	config, err := clientcmd.BuildConfigFromFlags("", kubeconfigPath)
+	if err != nil {
+		return fmt.Errorf("%s: clientcmd.BuildConfigFromFlags: %s", op, err)
+	}
 
-		tc.k8sClient, err = kubernetes.NewForConfig(config)
-		if err != nil {
-			fmt.Printf("%s: kubernetes.NewForConfig: %s", op, err)
-		}
+	tc.k8sClient, err = kubernetes.NewForConfig(config)
+	if err != nil {
+		return fmt.Errorf("%s: kubernetes.NewForConfig: %s", op, err)
+	}
 
-		tc.started = true
-	})
-	return tc.err
+	tc.started = true
+	return nil
 }
 
 func (tc *TestCluster) Start() error {
+	const op = "e2etests/TestCluster.Start"
+
+	tc.mu.Lock()
+	defer tc.mu.Unlock()
+
 	if err := tc.initialize(); err != nil {
-		return fmt.Errorf("start test cluster: %v", err)
+		return fmt.Errorf("%s: %v", op, err)
 	}
 	if err := tc.ensureNodesAreReady(); err != nil {
-		return fmt.Errorf("start test cluster: %v", err)
+		return fmt.Errorf("%s: %v", op, err)
 	}
 	return nil
 }
 
 func (tc *TestCluster) Stop(testFailed bool) error {
-	const op = "TestCluster/Stop"
-	if tc.err != nil {
-		return fmt.Errorf("%s: %s", op, tc.err)
-	}
+	const op = "e2etests/TestCluster.Stop"
+
+	tc.mu.Lock()
+	defer tc.mu.Unlock()
+
 	if !tc.started {
 		return nil
 	}
-	err := tc.setup.TearDown(testFailed)
-	if err != nil {
+
+	for _, c := range tc.certificates {
+		if _, err := tc.setup.Hcloud.Certificate.Delete(context.Background(), c); err != nil {
+			fmt.Printf("%s: delete certificate %d: %v", op, c.ID, err)
+		}
+	}
+
+	if err := tc.setup.TearDown(testFailed); err != nil {
 		fmt.Printf("%s: Tear Down: %s", op, err)
 	}
 	return nil
@@ -175,6 +189,7 @@ func (tc *TestCluster) Stop(testFailed bool) error {
 
 func (tc *TestCluster) ensureNodesAreReady() error {
 	const op = "ensureNodesAreReady"
+
 	err := wait.Poll(1*time.Second, 5*time.Minute, func() (bool, error) {
 		nodes, err := tc.k8sClient.CoreV1().Nodes().List(context.Background(), metav1.ListOptions{})
 		if err != nil {
@@ -209,8 +224,41 @@ func (tc *TestCluster) ensureNodesAreReady() error {
 	return nil
 }
 
+// CreateTLSCertificate creates a TLS certificate used for testing and posts it
+// to the Hetzner Cloud backend.
+//
+// The baseName of the certificate gets a random number suffix attached.
+// baseName and suffix are separated by a single "-" character.
+func (tc *TestCluster) CreateTLSCertificate(t *testing.T, baseName string) *hcloud.Certificate {
+	const op = "e2etests/TestCluster.CreateTLSCertificate"
+
+	rndInt := rng.Int()
+	name := fmt.Sprintf("%s-%d", baseName, rndInt)
+
+	p := testsupport.NewTLSPair(t, fmt.Sprintf("www.example%d.com", rndInt))
+	opts := hcloud.CertificateCreateOpts{
+		Name:        name,
+		Certificate: p.Cert,
+		PrivateKey:  p.Key,
+	}
+	cert, _, err := tc.setup.Hcloud.Certificate.Create(context.Background(), opts)
+	if err != nil {
+		t.Fatalf("%s: %s: %v", op, name, err)
+	}
+	if cert == nil {
+		t.Fatalf("%s: no certificate created", op)
+	}
+
+	tc.mu.Lock()
+	defer tc.mu.Unlock()
+	tc.certificates = append(tc.certificates, cert)
+
+	return cert
+}
+
 type lbTestHelper struct {
 	podName   string
+	port      int
 	K8sClient *kubernetes.Clientset
 	t         *testing.T
 }
@@ -219,6 +267,7 @@ type lbTestHelper struct {
 // and waits until it is "ready"
 func (l *lbTestHelper) DeployTestPod() *corev1.Pod {
 	const op = "lbTestHelper/DeployTestPod"
+
 	podName := fmt.Sprintf("pod-%s", l.podName)
 	testPod := corev1.Pod{
 		ObjectMeta: metav1.ObjectMeta{
@@ -268,6 +317,11 @@ 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 {
+	port := l.port
+	if port == 0 {
+		port = 80
+	}
+
 	return &corev1.Service{
 		ObjectMeta: metav1.ObjectMeta{
 			Name:        fmt.Sprintf("svc-%s", l.podName),
@@ -280,8 +334,9 @@ func (l *lbTestHelper) ServiceDefinition(pod *corev1.Pod, annotations map[string
 			Type: corev1.ServiceTypeLoadBalancer,
 			Ports: []corev1.ServicePort{
 				{
-					Port: 80,
-					Name: "http",
+					Port:       int32(port),
+					TargetPort: intstr.FromInt(80),
+					Name:       "http",
 				},
 			},
 			ExternalTrafficPolicy: corev1.ServiceExternalTrafficPolicyTypeLocal,
@@ -359,30 +414,6 @@ func (l *lbTestHelper) TearDown() {
 	}
 }
 
-// WaitForHttpAvailable tries to connect to the given IP via http
-// It tries it for 2 minutes, if after two minutes the connection
-// wasn't successful and it wasn't a HTTP 200 response it will fail
-func (l *lbTestHelper) WaitForHttpAvailable(ingressIP string) {
-	const op = "lbTestHelper/WaitForHttpAvailable"
-	client := &http.Client{
-		Timeout: 1 * time.Second,
-	}
-	err := wait.Poll(1*time.Second, 2*time.Minute, func() (bool, error) {
-		resp, err := client.Get(fmt.Sprintf("http://%s", ingressIP))
-		if err != nil {
-			return false, nil
-		}
-		defer resp.Body.Close()
-		if resp.StatusCode != http.StatusOK {
-			return false, fmt.Errorf("%s: got HTTP Code %d instead of 200", op, resp.StatusCode)
-		}
-		return true, nil
-	})
-	if err != nil {
-		l.t.Errorf("%s: not available via client.Get: %s", op, err)
-	}
-}
-
 type nwTestHelper struct {
 	podName    string
 	K8sClient  *kubernetes.Clientset
@@ -446,11 +477,12 @@ func (n *nwTestHelper) DeployTestPod() *corev1.Pod {
 	return pod
 }
 
-// WaitForHttpAvailable tries to connect to the given IP via curl
-// It tries it for 2 minutes, if after two minutes the connection
-// wasn't successful and it wasn't a HTTP 200 response it will fail
-func (n *nwTestHelper) WaitForHttpAvailable(podIp string) {
-	const op = "nwTestHelper/WaitForHttpAvailable"
+// WaitForHTTPOnServer tries to connect to the given IP using curl.
+//
+// It tries it for 2 minutes, if after two minutes the connection wasn't
+// successful or it was not a HTTP 200 response it will fail
+func (n *nwTestHelper) WaitForHTTPOnServer(podIp string) {
+	const op = "nwTestHelper/WaitForHTTPAvailable"
 	err := wait.Poll(1*time.Second, 2*time.Minute, func() (bool, error) {
 		err := RunCommandOnServer(n.privateKey, n.server, fmt.Sprintf("curl http://%s", podIp))
 		if err != nil {
@@ -485,3 +517,39 @@ func (n *nwTestHelper) TearDown() {
 		n.t.Errorf("%s: test pod not removed after 1 minute: %s", op, err)
 	}
 }
+
+// WaitForHTTPAvailable tries to connect to the given IP via http
+// It tries it for 2 minutes, if after two minutes the connection
+// wasn't successful and it wasn't a HTTP 200 response it will fail
+func WaitForHTTPAvailable(t *testing.T, ingressIP string, useHTTPS bool) {
+	const op = "e2etests/WaitForHTTPAvailable"
+
+	client := &http.Client{
+		Timeout: 1 * time.Second,
+		Transport: &http.Transport{
+			TLSClientConfig: &tls.Config{
+				InsecureSkipVerify: true, // nolint
+			},
+		},
+	}
+	proto := "http"
+	if useHTTPS {
+		proto = "https"
+
+	}
+
+	err := wait.Poll(1*time.Second, 2*time.Minute, func() (bool, error) {
+		resp, err := client.Get(fmt.Sprintf("%s://%s", proto, ingressIP))
+		if err != nil {
+			return false, nil
+		}
+		defer resp.Body.Close()
+		if resp.StatusCode != http.StatusOK {
+			return false, fmt.Errorf("%s: got HTTP Code %d instead of 200", op, resp.StatusCode)
+		}
+		return true, nil
+	})
+	if err != nil {
+		t.Errorf("%s: not available via client.Get: %s", op, err)
+	}
+}
diff --git a/internal/annotation/name_test.go b/internal/annotation/name_test.go
index c9299d05..5fc8412f 100644
--- a/internal/annotation/name_test.go
+++ b/internal/annotation/name_test.go
@@ -61,6 +61,11 @@ func TestName_AddToService(t *testing.T) {
 			value:    []*hcloud.Certificate{{ID: 1}, {ID: 2}},
 			expected: map[string]string{string(ann): "1,2"},
 		},
+		{
+			name:     "set []*hcloud.Certificate by name",
+			value:    []*hcloud.Certificate{{Name: "cert-1"}, {Name: "cert-2"}},
+			expected: map[string]string{string(ann): "cert-1,cert-2"},
+		},
 		{
 			name:  "set unsupported value",
 			value: struct{}{},
@@ -152,8 +157,8 @@ func (tt *typedAccessorTest) run(t *testing.T, call func(svc *v1.Service) (inter
 	t.Helper()
 
 	for k, v := range tt.svcAnnotations {
-		if err := k.AnnotateService(&svc, v); err != nil {
-			t.Error(err)
+		if err := k.AnnotateService(&svc, v); !assert.NoError(t, err) {
+			return
 		}
 	}
 
@@ -425,22 +430,22 @@ func TestName_NetworkZoneFromService(t *testing.T) {
 func TestName_CertificatesFromService(t *testing.T) {
 	tests := []typedAccessorTest{
 		{
-			name: "values set",
+			name: "ids set",
 			svcAnnotations: map[annotation.Name]interface{}{
 				ann: []*hcloud.Certificate{{ID: 3}, {ID: 5}},
 			},
 			expected: []*hcloud.Certificate{{ID: 3}, {ID: 5}},
 		},
 		{
-			name: "value not set",
-			err:  annotation.ErrNotSet,
-		},
-		{
-			name: "value invalid",
+			name: "names set",
 			svcAnnotations: map[annotation.Name]interface{}{
-				ann: "invalid",
+				ann: []*hcloud.Certificate{{Name: "cert-1"}, {Name: "cert-2"}},
 			},
-			err: errors.New("annotation/Name.CertificatesFromService: strconv.Atoi: parsing \"invalid\": invalid syntax"),
+			expected: []*hcloud.Certificate{{Name: "cert-1"}, {Name: "cert-2"}},
+		},
+		{
+			name: "value not set",
+			err:  annotation.ErrNotSet,
 		},
 	}
 
diff --git a/internal/hcops/load_balancer_test.go b/internal/hcops/load_balancer_test.go
index 914d9da1..af6ba178 100644
--- a/internal/hcops/load_balancer_test.go
+++ b/internal/hcops/load_balancer_test.go
@@ -10,6 +10,7 @@ import (
 	"github.com/hetznercloud/hcloud-cloud-controller-manager/internal/hcops"
 	"github.com/hetznercloud/hcloud-go/hcloud"
 	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/mock"
 	v1 "k8s.io/api/core/v1"
 )
 
@@ -648,7 +649,7 @@ func TestLoadBalancerOps_ReconcileHCLB(t *testing.T) {
 	}
 }
 
-func TestLoadBalancerOps_ReconcileHCLBTargtets(t *testing.T) {
+func TestLoadBalancerOps_ReconcileHCLBTargets(t *testing.T) {
 	tests := []LBReconcilementTestCase{
 		{
 			name: "add k8s nodes as hc Load Balancer targets",
@@ -828,6 +829,79 @@ func TestLoadBalancerOps_ReconcileHCLBServices(t *testing.T) {
 				assert.True(t, changed)
 			},
 		},
+		{
+			name: "reference TLS certificate by id",
+			servicePorts: []v1.ServicePort{
+				{Port: 443, NodePort: 8443},
+			},
+			initialLB: &hcloud.LoadBalancer{
+				ID: 10,
+			},
+			serviceAnnotations: map[annotation.Name]interface{}{
+				annotation.LBSvcHTTPCertificates: []string{"1"},
+			},
+			mock: func(t *testing.T, tt *LBReconcilementTestCase) {
+				opts := hcloud.LoadBalancerAddServiceOpts{
+					Protocol:        hcloud.LoadBalancerServiceProtocolTCP,
+					ListenPort:      hcloud.Int(443),
+					DestinationPort: hcloud.Int(8443),
+					HTTP: &hcloud.LoadBalancerAddServiceOptsHTTP{
+						Certificates: []*hcloud.Certificate{
+							{ID: 1},
+						},
+					},
+					HealthCheck: &hcloud.LoadBalancerAddServiceOptsHealthCheck{
+						Protocol: hcloud.LoadBalancerServiceProtocolTCP,
+						Port:     hcloud.Int(8443),
+					},
+				}
+				action := tt.fx.MockAddService(opts, tt.initialLB, nil)
+				tt.fx.MockWatchProgress(action, nil)
+			},
+			perform: func(t *testing.T, tt *LBReconcilementTestCase) {
+				changed, err := tt.fx.LBOps.ReconcileHCLBServices(tt.fx.Ctx, tt.initialLB, tt.service)
+				assert.NoError(t, err)
+				assert.True(t, changed)
+			},
+		},
+		{
+			name: "reference TLS certificate by name",
+			servicePorts: []v1.ServicePort{
+				{Port: 443, NodePort: 8443},
+			},
+			initialLB: &hcloud.LoadBalancer{
+				ID: 10,
+			},
+			serviceAnnotations: map[annotation.Name]interface{}{
+				annotation.LBSvcHTTPCertificates: []string{"some-cert"},
+			},
+			mock: func(t *testing.T, tt *LBReconcilementTestCase) {
+				cert := &hcloud.Certificate{ID: 1}
+
+				opts := hcloud.LoadBalancerAddServiceOpts{
+					Protocol:        hcloud.LoadBalancerServiceProtocolTCP,
+					ListenPort:      hcloud.Int(443),
+					DestinationPort: hcloud.Int(8443),
+					HTTP: &hcloud.LoadBalancerAddServiceOptsHTTP{
+						Certificates: []*hcloud.Certificate{cert},
+					},
+					HealthCheck: &hcloud.LoadBalancerAddServiceOptsHealthCheck{
+						Protocol: hcloud.LoadBalancerServiceProtocolTCP,
+						Port:     hcloud.Int(8443),
+					},
+				}
+				tt.fx.CertClient.
+					On("Get", mock.Anything, "some-cert").
+					Return(cert, nil, nil)
+				action := tt.fx.MockAddService(opts, tt.initialLB, nil)
+				tt.fx.MockWatchProgress(action, nil)
+			},
+			perform: func(t *testing.T, tt *LBReconcilementTestCase) {
+				changed, err := tt.fx.LBOps.ReconcileHCLBServices(tt.fx.Ctx, tt.initialLB, tt.service)
+				assert.NoError(t, err)
+				assert.True(t, changed)
+			},
+		},
 		{
 			name: "replace hc Load Balancer services",
 			serviceAnnotations: map[annotation.Name]interface{}{
diff --git a/internal/hcops/testing.go b/internal/hcops/testing.go
index d1ebb55b..c91d5438 100644
--- a/internal/hcops/testing.go
+++ b/internal/hcops/testing.go
@@ -13,6 +13,7 @@ type LoadBalancerOpsFixture struct {
 	Name          string
 	Ctx           context.Context
 	LBClient      *mocks.LoadBalancerClient
+	CertClient    *mocks.CertificateClient
 	ActionClient  *mocks.ActionClient
 	NetworkClient *mocks.NetworkClient
 
@@ -26,19 +27,23 @@ func NewLoadBalancerOpsFixture(t *testing.T) *LoadBalancerOpsFixture {
 		Ctx:           context.Background(),
 		ActionClient:  &mocks.ActionClient{},
 		LBClient:      &mocks.LoadBalancerClient{},
+		CertClient:    &mocks.CertificateClient{},
 		NetworkClient: &mocks.NetworkClient{},
 		T:             t,
 	}
 
 	fx.ActionClient.Test(t)
 	fx.LBClient.Test(t)
+	fx.CertClient.Test(t)
 	fx.NetworkClient.Test(t)
 
 	fx.LBOps = &LoadBalancerOps{
 		LBClient:      fx.LBClient,
+		CertClient:    fx.CertClient,
 		ActionClient:  fx.ActionClient,
 		NetworkClient: fx.NetworkClient,
 	}
+
 	return fx
 }
 
@@ -100,5 +105,6 @@ func (fx *LoadBalancerOpsFixture) MockWatchProgress(a *hcloud.Action, err error)
 func (fx *LoadBalancerOpsFixture) AssertExpectations() {
 	fx.ActionClient.AssertExpectations(fx.T)
 	fx.LBClient.AssertExpectations(fx.T)
+	fx.CertClient.AssertExpectations(fx.T)
 	fx.NetworkClient.AssertExpectations(fx.T)
 }
diff --git a/internal/testsupport/tls.go b/internal/testsupport/tls.go
new file mode 100644
index 00000000..30b0f78b
--- /dev/null
+++ b/internal/testsupport/tls.go
@@ -0,0 +1,194 @@
+package testsupport
+
+// Copied and adapted from https://github.com/jsha/minica rev
+// e81e95a9e94be80e9da3c1e2b55accdd4884128b which cannot be used as a library.
+
+import (
+	"bytes"
+	"crypto"
+	"crypto/rand"
+	"crypto/rsa"
+	"crypto/sha1"
+	"crypto/x509"
+	"crypto/x509/pkix"
+	"encoding/asn1"
+	"encoding/hex"
+	"encoding/pem"
+	"math"
+	"math/big"
+	"testing"
+	"time"
+)
+
+const rsaBits = 2048
+
+type issuer struct {
+	key  crypto.Signer
+	cert *x509.Certificate
+}
+
+func makeIssuer(t *testing.T) *issuer {
+	const op = "testsupport/makeIssuer"
+	t.Helper()
+
+	key, err := rsa.GenerateKey(rand.Reader, rsaBits)
+	if err != nil {
+		t.Fatalf("%s: %v", op, err)
+	}
+	cert, err := makeRootCert(key)
+	if err != nil {
+		t.Fatalf("%s: %v", op, err)
+	}
+	return &issuer{key: key, cert: cert}
+}
+
+func makeKey(t *testing.T) *rsa.PrivateKey {
+	const op = "testsupport/makeKey"
+	t.Helper()
+
+	key, err := rsa.GenerateKey(rand.Reader, 2048)
+	if err != nil {
+		t.Fatalf("%s: %v", op, err)
+	}
+	return key
+}
+
+func makeRootCert(key crypto.Signer) (*x509.Certificate, error) {
+	serial, err := rand.Int(rand.Reader, big.NewInt(math.MaxInt64))
+	if err != nil {
+		return nil, err
+	}
+	skid, err := calculateSKID(key.Public())
+	if err != nil {
+		return nil, err
+	}
+	template := &x509.Certificate{
+		Subject: pkix.Name{
+			CommonName: "minica root ca " + hex.EncodeToString(serial.Bytes()[:3]),
+		},
+		SerialNumber: serial,
+		NotBefore:    time.Now(),
+		NotAfter:     time.Now().AddDate(100, 0, 0),
+
+		SubjectKeyId:          skid,
+		AuthorityKeyId:        skid,
+		KeyUsage:              x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
+		ExtKeyUsage:           []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth},
+		BasicConstraintsValid: true,
+		IsCA:                  true,
+		MaxPathLenZero:        true,
+	}
+
+	der, err := x509.CreateCertificate(rand.Reader, template, template, key.Public(), key)
+	if err != nil {
+		return nil, err
+	}
+	return x509.ParseCertificate(der)
+}
+
+func calculateSKID(pubKey crypto.PublicKey) ([]byte, error) {
+	spkiASN1, err := x509.MarshalPKIXPublicKey(pubKey)
+	if err != nil {
+		return nil, err
+	}
+
+	var spki struct {
+		Algorithm        pkix.AlgorithmIdentifier
+		SubjectPublicKey asn1.BitString
+	}
+	_, err = asn1.Unmarshal(spkiASN1, &spki)
+	if err != nil {
+		return nil, err
+	}
+	skid := sha1.Sum(spki.SubjectPublicKey.Bytes)
+	return skid[:], nil
+}
+
+func sign(t *testing.T, iss *issuer, cn string) (*rsa.PrivateKey, *x509.Certificate) {
+	const op = "testsupport/sign"
+	t.Helper()
+
+	if cn == "" {
+		t.Fatalf("%s: no common name", op)
+	}
+
+	key := makeKey(t)
+	serial, err := rand.Int(rand.Reader, big.NewInt(math.MaxInt64))
+	if err != nil {
+		t.Fatalf("%s: %v", op, err)
+	}
+
+	template := &x509.Certificate{
+		Subject: pkix.Name{
+			CommonName: cn,
+		},
+		SerialNumber: serial,
+		NotBefore:    time.Now(),
+		NotAfter:     time.Now().AddDate(0, 0, 30),
+
+		KeyUsage:              x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
+		ExtKeyUsage:           []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth},
+		BasicConstraintsValid: true,
+		IsCA:                  false,
+	}
+	der, err := x509.CreateCertificate(rand.Reader, template, iss.cert, key.Public(), iss.key)
+	if err != nil {
+		t.Fatalf("%s: %v", op, err)
+	}
+	cert, err := x509.ParseCertificate(der)
+	if err != nil {
+		t.Fatalf("%s: %v", op, err)
+	}
+	return key, cert
+}
+
+func encodePrivateKey(t *testing.T, key *rsa.PrivateKey) string {
+	const op = "testsupport/encodePrivateKey"
+	var buf bytes.Buffer
+
+	t.Helper()
+
+	bs := x509.MarshalPKCS1PrivateKey(key)
+	err := pem.Encode(&buf, &pem.Block{
+		Type:  "RSA PRIVATE KEY",
+		Bytes: bs,
+	})
+	if err != nil {
+		t.Fatalf("%s: %v", op, err)
+	}
+	return buf.String()
+}
+
+func encodeCert(t *testing.T, cert *x509.Certificate) string {
+	const op = "testsupport/encodeCert"
+	var buf bytes.Buffer
+	t.Helper()
+
+	err := pem.Encode(&buf, &pem.Block{
+		Type:  "CERTIFICATE",
+		Bytes: cert.Raw,
+	})
+	if err != nil {
+		t.Fatalf("%s: %v", op, err)
+	}
+	return buf.String()
+}
+
+// TLSPair represents a TLS key and certificate pair.
+type TLSPair struct {
+	Key  string
+	Cert string
+}
+
+// NewTLSPair generates new pair of TLS certificate and private key for the
+// passed common name.
+func NewTLSPair(t *testing.T, cn string) *TLSPair {
+	t.Helper()
+
+	iss := makeIssuer(t)
+	key, cert := sign(t, iss, cn)
+	return &TLSPair{
+		Key:  encodePrivateKey(t, key),
+		Cert: encodeCert(t, cert),
+	}
+}