From a4343b84ea3fc6662f1f263f41325eea2e749c41 Mon Sep 17 00:00:00 2001
From: Simon Ostendorf <github@simon-ostendorf.de>
Date: Thu, 6 Jun 2024 16:27:36 +0200
Subject: [PATCH] feat: read HCLOUD_TOKEN from file (#652)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

This allows the `HCLOUD_TOKEN` (and `ROBOT_USER` and `ROBOT_PASSWORD`)
to be read from a file. This can be useful if the token is injected
using secret injection (e.g. with the vault agent injector).

If someone is interested in using this with the vault agent injector,
I used the following helm values:

```yaml
image:
  repository: <custom-image-because-changes-are-not-released>
  tag: <custom-image-because-changes-are-not-released>
podAnnotations:
  vault.hashicorp.com/agent-inject: "true"
  vault.hashicorp.com/log-format: json
  vault.hashicorp.com/role: <your-vault-role-name>
  vault.hashicorp.com/secret-volume-path-token: /vault/secrets
  vault.hashicorp.com/agent-inject-file-token: token
  vault.hashicorp.com/agent-inject-secret-token: <your-vault-mount>/data/<your-vault-path>
  vault.hashicorp.com/agent-inject-template-token: |
    {{ with secret "<your-vault-mount>/data/<your-vault-path>" -}}
     {{ .Data.data.token }}
    {{- end }}
env:
  HCLOUD_TOKEN_FILE:
    value: "/vault/secrets/token"
  HCLOUD_TOKEN: null # must be set because helm results in using value and valueFrom and that results in an error
```

This change is inspired from [external-dns cloudflare
provider](https://github.com/kubernetes-sigs/external-dns/blob/master/provider/cloudflare/cloudflare.go#L171).
I requested the same change for the
[csi-driver](https://github.com/hetznercloud/csi-driver/pull/617) to
keep consistency in reading HCLOUD_TOKEN from file.

Closes #595

---------

Co-authored-by: Jonas L. <jooola@users.noreply.github.com>
Co-authored-by: Julian Tölle <julian.toelle@hetzner-cloud.de>
---
 chart/templates/daemonset.yaml  |  7 +++++
 chart/templates/deployment.yaml |  7 +++++
 chart/values.yaml               | 14 +++++++++
 internal/config/config.go       | 42 +++++++++++++++++++++++++--
 internal/config/config_test.go  | 51 +++++++++++++++++++++++++++++++++
 internal/testsupport/files.go   | 45 +++++++++++++++++++++++++++++
 6 files changed, 163 insertions(+), 3 deletions(-)
 create mode 100644 internal/testsupport/files.go

diff --git a/chart/templates/daemonset.yaml b/chart/templates/daemonset.yaml
index 12242b8b..35d5b351 100644
--- a/chart/templates/daemonset.yaml
+++ b/chart/templates/daemonset.yaml
@@ -13,6 +13,13 @@ spec:
     metadata:
       labels:
         {{- include "hcloud-cloud-controller-manager.selectorLabels" . | nindent 8 }}
+        {{- if .Values.podLabels }}
+        {{- toYaml .Values.podLabels | nindent 8 }}
+        {{- end }}
+      {{- if .Values.podAnnotations }}
+      annotations:
+        {{- toYaml .Values.podAnnotations | nindent 8 }}
+      {{- end }}
     spec:
       serviceAccountName: {{ include "hcloud-cloud-controller-manager.name" . }}
       dnsPolicy: Default
diff --git a/chart/templates/deployment.yaml b/chart/templates/deployment.yaml
index b59daa2a..782a58df 100644
--- a/chart/templates/deployment.yaml
+++ b/chart/templates/deployment.yaml
@@ -14,6 +14,13 @@ spec:
     metadata:
       labels:
         {{- include "hcloud-cloud-controller-manager.selectorLabels" . | nindent 8 }}
+        {{- if .Values.podLabels }}
+        {{- toYaml .Values.podLabels | nindent 8 }}
+        {{- end }}
+      {{- if .Values.podAnnotations }}
+      annotations:
+        {{- toYaml .Values.podAnnotations | nindent 8 }}
+      {{- end }}
     spec:
       serviceAccountName: {{ include "hcloud-cloud-controller-manager.name" . }}
       dnsPolicy: Default
diff --git a/chart/values.yaml b/chart/values.yaml
index 795109d5..041cb4ea 100644
--- a/chart/values.yaml
+++ b/chart/values.yaml
@@ -29,6 +29,16 @@ env:
   # HCLOUD_NETWORK - see networking.enabled
   # ROBOT_ENABLED - see robot.enabled
 
+  # You can also use a file to provide secrets to the hcloud-cloud-controller-manager.
+  # This is currently possible for HCLOUD_TOKEN, ROBOT_USER, and ROBOT_PASSWORD.
+  # Use the env var appended with _FILE (e.g. HCLOUD_TOKEN_FILE) and set the value to the file path that should be read
+  # The file must be provided externally (e.g. via secret injection).
+  # Example:
+  # HCLOUD_TOKEN_FILE:
+  #   value: "/etc/hetzner/token"
+  # to disable reading the token from the secret you have to disable the original env var:
+  # HCLOUD_TOKEN: null
+
   HCLOUD_TOKEN:
     valueFrom:
       secretKeyRef:
@@ -103,3 +113,7 @@ nodeSelector: {}
 robot:
   # Set to true to enable support for Robot (Dedicated) servers.
   enabled: false
+
+podLabels: {}
+
+podAnnotations: {}
diff --git a/internal/config/config.go b/internal/config/config.go
index 9c97e2a6..504858a4 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -5,6 +5,7 @@ import (
 	"fmt"
 	"os"
 	"strconv"
+	"strings"
 	"time"
 )
 
@@ -96,6 +97,32 @@ type HCCMConfiguration struct {
 	Route        RouteConfiguration
 }
 
+// read values from environment variables or from file set via _FILE env var
+// values set directly via env var take precedence over values set via file.
+func readFromEnvOrFile(envVar string) (string, error) {
+	// check if the value is set directly via env (e.g. HCLOUD_TOKEN)
+	value, ok := os.LookupEnv(envVar)
+	if ok {
+		return value, nil
+	}
+
+	// check if the value is set via a file (e.g. HCLOUD_TOKEN_FILE)
+	value, ok = os.LookupEnv(envVar + "_FILE")
+	if !ok {
+		// return no error here, the values could be optional
+		// and the function "Validate()" below checks that all required variables are set
+		return "", nil
+	}
+
+	// read file content
+	valueBytes, err := os.ReadFile(value)
+	if err != nil {
+		return "", fmt.Errorf("failed to read %s: %w", envVar+"_FILE", err)
+	}
+
+	return strings.TrimSpace(string(valueBytes)), nil
+}
+
 // Read evaluates all environment variables and returns a [HCCMConfiguration]. It only validates as far as
 // it needs to parse the values. For business logic validation, check out [HCCMConfiguration.Validate].
 func Read() (HCCMConfiguration, error) {
@@ -106,7 +133,10 @@ func Read() (HCCMConfiguration, error) {
 	var errs []error
 	var cfg HCCMConfiguration
 
-	cfg.HCloudClient.Token = os.Getenv(hcloudToken)
+	cfg.HCloudClient.Token, err = readFromEnvOrFile(hcloudToken)
+	if err != nil {
+		errs = append(errs, err)
+	}
 	cfg.HCloudClient.Endpoint = os.Getenv(hcloudEndpoint)
 	cfg.HCloudClient.Debug, err = getEnvBool(hcloudDebug, false)
 	if err != nil {
@@ -117,8 +147,14 @@ func Read() (HCCMConfiguration, error) {
 	if err != nil {
 		errs = append(errs, err)
 	}
-	cfg.Robot.User = os.Getenv(robotUser)
-	cfg.Robot.Password = os.Getenv(robotPassword)
+	cfg.Robot.User, err = readFromEnvOrFile(robotUser)
+	if err != nil {
+		errs = append(errs, err)
+	}
+	cfg.Robot.Password, err = readFromEnvOrFile(robotPassword)
+	if err != nil {
+		errs = append(errs, err)
+	}
 	cfg.Robot.CacheTimeout, err = getEnvDuration(robotCacheTimeout)
 	if err != nil {
 		errs = append(errs, err)
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
index 367b640d..ac8d6f97 100644
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -14,6 +14,7 @@ func TestRead(t *testing.T) {
 	tests := []struct {
 		name    string
 		env     []string
+		files   map[string]string
 		want    HCCMConfiguration
 		wantErr error
 	}{
@@ -48,6 +49,54 @@ func TestRead(t *testing.T) {
 			},
 			wantErr: nil,
 		},
+		{
+			name: "secrets from file",
+			env: []string{
+				"HCLOUD_TOKEN_FILE", "/tmp/hetzner-token",
+				"ROBOT_USER_FILE", "/tmp/hetzner-user",
+				"ROBOT_PASSWORD_FILE", "/tmp/hetzner-password",
+			},
+			files: map[string]string{
+				"hetzner-token":    "jr5g7ZHpPptyhJzZyHw2Pqu4g9gTqDvEceYpngPf79jN_NOT_VALID_dzhepnahq",
+				"hetzner-user":     "foobar",
+				"hetzner-password": `secret-password`,
+			},
+			want: HCCMConfiguration{
+				HCloudClient: HCloudClientConfiguration{Token: "jr5g7ZHpPptyhJzZyHw2Pqu4g9gTqDvEceYpngPf79jN_NOT_VALID_dzhepnahq"},
+				Robot: RobotConfiguration{
+					Enabled:           false,
+					User:              "foobar",
+					Password:          "secret-password",
+					CacheTimeout:      5 * time.Minute,
+					RateLimitWaitTime: 0,
+				},
+				Metrics:      MetricsConfiguration{Enabled: true, Address: ":8233"},
+				Instance:     InstanceConfiguration{AddressFamily: AddressFamilyIPv4},
+				LoadBalancer: LoadBalancerConfiguration{Enabled: true},
+				Route:        RouteConfiguration{Enabled: false},
+			},
+			wantErr: nil,
+		},
+		{
+			name: "secrets from unknown file",
+			env: []string{
+				"HCLOUD_TOKEN_FILE", "/tmp/hetzner-token",
+				"ROBOT_USER_FILE", "/tmp/hetzner-user",
+				"ROBOT_PASSWORD_FILE", "/tmp/hetzner-password",
+			},
+			files: map[string]string{}, // don't create files
+			want: HCCMConfiguration{
+				HCloudClient: HCloudClientConfiguration{Token: ""},
+				Robot:        RobotConfiguration{User: "", Password: "", CacheTimeout: 0},
+				Metrics:      MetricsConfiguration{Enabled: false},
+				Instance:     InstanceConfiguration{},
+				LoadBalancer: LoadBalancerConfiguration{Enabled: false},
+				Route:        RouteConfiguration{Enabled: false},
+			},
+			wantErr: errors.New(`failed to read HCLOUD_TOKEN_FILE: open /tmp/hetzner-token: no such file or directory
+failed to read ROBOT_USER_FILE: open /tmp/hetzner-user: no such file or directory
+failed to read ROBOT_PASSWORD_FILE: open /tmp/hetzner-password: no such file or directory`),
+		},
 		{
 			name: "client",
 			env: []string{
@@ -207,6 +256,8 @@ failed to parse ROBOT_RATE_LIMIT_WAIT_TIME: time: unknown unit "fortnights" in d
 		t.Run(tt.name, func(t *testing.T) {
 			resetEnv := testsupport.Setenv(t, tt.env...)
 			defer resetEnv()
+			resetFiles := testsupport.SetFiles(t, tt.files)
+			defer resetFiles()
 
 			got, err := Read()
 			if tt.wantErr == nil {
diff --git a/internal/testsupport/files.go b/internal/testsupport/files.go
new file mode 100644
index 00000000..1872d73d
--- /dev/null
+++ b/internal/testsupport/files.go
@@ -0,0 +1,45 @@
+package testsupport
+
+import (
+	"os"
+	"testing"
+)
+
+// SetFiles can be used to temporarily create files on the local file system.
+// It returns a function that will clean up all files it created.
+func SetFiles(t *testing.T, files map[string]string) func() {
+	for file, content := range files {
+		filepath := os.TempDir() + "/" + file
+
+		// check if file exists
+		_, err := os.Stat(filepath)
+		if err == nil {
+			t.Fatalf("Trying to set file %s, but it already exists. Please choose another filepath for the test.", filepath)
+		}
+
+		// create file
+		f, err := os.Create(filepath)
+		if err != nil {
+			t.Fatalf("Failed to create file %s: %v", filepath, err)
+		}
+
+		// write content to file
+		_, err = f.WriteString(content)
+		if err != nil {
+			t.Fatalf("Failed to write to file %s: %v", filepath, err)
+		}
+
+		// close file
+		f.Close()
+	}
+
+	return func() {
+		for file := range files {
+			filepath := os.TempDir() + "/" + file
+			err := os.Remove(filepath)
+			if err != nil {
+				t.Fatalf("Failed to remove file %s: %v", filepath, err)
+			}
+		}
+	}
+}
-- 
GitLab