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