From aa02635ce0ae4a387494c4e233ae4b4b11d1c559 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20G=C3=BCttler?= Date: Mon, 9 Mar 2026 15:27:55 +0100 Subject: [PATCH] support token key for hcloud secret hot-reload --- README.md | 3 +- internal/credentials/hotreload.go | 20 +++++-- internal/credentials/hotreload_test.go | 79 ++++++++++++++++++++++++++ 3 files changed, 95 insertions(+), 7 deletions(-) create mode 100644 internal/credentials/hotreload_test.go diff --git a/README.md b/README.md index 6c1946c4d..5610582c5 100644 --- a/README.md +++ b/README.md @@ -62,8 +62,9 @@ See [CAPH docs](https://syself.com/docs/caph/topics/baremetal/creating-workload- ## Usage -We recommend to mount the secret `hetzner` as volume and make it avaiable for the container as `/etc/hetzner-secret`. +We recommend to mount the secret `hetzner` as volume and make it available for the container as `/etc/hetzner-secret`. Then the credentials are automatically reloaded, when the secret changes. +When you use hot-reloading, the secret keys must be named `hcloud` (or `token`, for upstream hcloud-ccm compatibility), `robot-user`, and `robot-password`. You see an example in the [ccm helm chart](https://github.com/syself/charts/tree/main/charts/ccm-hetzner) ## Env Variables diff --git a/internal/credentials/hotreload.go b/internal/credentials/hotreload.go index aace6067a..8a4c2cb91 100644 --- a/internal/credentials/hotreload.go +++ b/internal/credentials/hotreload.go @@ -1,6 +1,7 @@ package credentials import ( + "errors" "fmt" "os" "path/filepath" @@ -91,7 +92,7 @@ func handleEvent(credentialsDir, baseName string, hcloudClient *hcloud.Client, r // This case is executed, when the process is running on a local machine. return loadRobotCredentials(credentialsDir, robotClient) - case "hcloud": + case "hcloud", "token": // This case is executed, when the process is running on a local machine. err := loadHcloudCredentials(credentialsDir, hcloudClient) if err != nil { @@ -237,12 +238,19 @@ func GetInitialHcloudCredentialsFromDirectory(credentialsDir string) (string, er } func readHcloudCredentials(credentialsDir string) (string, error) { - hcloudTokenFile := filepath.Join(credentialsDir, "hcloud") - data, err := os.ReadFile(hcloudTokenFile) - if err != nil { - return "", fmt.Errorf("reading hcloud token from %q failed: %w", hcloudTokenFile, err) + var allErrors []error + for _, key := range []string{"hcloud", "token"} { + // The upstream hcloud-ccm uses "token" as key name in the mounted secret. + // To ease migration between both CCMs, we support both key names. + hcloudTokenFile := filepath.Join(credentialsDir, key) + data, err := os.ReadFile(hcloudTokenFile) + if err != nil { + allErrors = append(allErrors, fmt.Errorf("reading hcloud token from %q failed: %w", hcloudTokenFile, err)) + continue + } + return strings.TrimSpace(string(data)), nil } - return strings.TrimSpace(string(data)), nil + return "", errors.Join(allErrors...) } // GetDirectory returns the directory where the credentials are stored. diff --git a/internal/credentials/hotreload_test.go b/internal/credentials/hotreload_test.go new file mode 100644 index 000000000..7dc485675 --- /dev/null +++ b/internal/credentials/hotreload_test.go @@ -0,0 +1,79 @@ +package credentials + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestReadHcloudCredentials(t *testing.T) { + t.Run("reads hcloud key", func(t *testing.T) { + dir := t.TempDir() + err := os.WriteFile(filepath.Join(dir, "hcloud"), []byte(" token-from-hcloud \n"), 0o600) + if err != nil { + t.Fatalf("write hcloud token: %v", err) + } + + token, err := readHcloudCredentials(dir) + if err != nil { + t.Fatalf("readHcloudCredentials() error = %v", err) + } + if token != "token-from-hcloud" { + t.Fatalf("readHcloudCredentials() token = %q, want %q", token, "token-from-hcloud") + } + }) + + t.Run("falls back to token key", func(t *testing.T) { + dir := t.TempDir() + err := os.WriteFile(filepath.Join(dir, "token"), []byte("token-from-token-key"), 0o600) + if err != nil { + t.Fatalf("write token key: %v", err) + } + + token, err := readHcloudCredentials(dir) + if err != nil { + t.Fatalf("readHcloudCredentials() error = %v", err) + } + if token != "token-from-token-key" { + t.Fatalf("readHcloudCredentials() token = %q, want %q", token, "token-from-token-key") + } + }) + + t.Run("prefers hcloud key over token key", func(t *testing.T) { + dir := t.TempDir() + err := os.WriteFile(filepath.Join(dir, "hcloud"), []byte("first-token"), 0o600) + if err != nil { + t.Fatalf("write hcloud token: %v", err) + } + err = os.WriteFile(filepath.Join(dir, "token"), []byte("second-token"), 0o600) + if err != nil { + t.Fatalf("write token key: %v", err) + } + + token, err := readHcloudCredentials(dir) + if err != nil { + t.Fatalf("readHcloudCredentials() error = %v", err) + } + if token != "first-token" { + t.Fatalf("readHcloudCredentials() token = %q, want %q", token, "first-token") + } + }) + + t.Run("returns error if no key exists", func(t *testing.T) { + dir := t.TempDir() + + _, err := readHcloudCredentials(dir) + if err == nil { + t.Fatal("readHcloudCredentials() error = nil, want non-nil") + } + + errText := err.Error() + if !strings.Contains(errText, filepath.Join(dir, "hcloud")) { + t.Fatalf("error does not mention hcloud key path: %q", errText) + } + if !strings.Contains(errText, filepath.Join(dir, "token")) { + t.Fatalf("error does not mention token key path: %q", errText) + } + }) +}