From 89aca443e1864da6b1d5dafaacb36e5e0840b7cf Mon Sep 17 00:00:00 2001 From: Tarun Chinmai Sekar Date: Fri, 12 Jun 2026 09:15:57 -0700 Subject: [PATCH 1/6] feat: hot load secret --- cloud/linode/client/client.go | 37 ++++++- cloud/linode/cloud.go | 154 +++++++++++++++++++++++--- cloud/linode/cloud_test.go | 106 +++++++++++++++++- cloud/linode/options/options.go | 3 + deploy/ccm-linode-template.yaml | 8 +- deploy/chart/templates/daemonset.yaml | 8 +- deploy/chart/values.yaml | 1 + main.go | 3 + 8 files changed, 289 insertions(+), 31 deletions(-) diff --git a/cloud/linode/client/client.go b/cloud/linode/client/client.go index b184c602..f0ac40de 100644 --- a/cloud/linode/client/client.go +++ b/cloud/linode/client/client.go @@ -23,6 +23,8 @@ const ( DefaultLinodeAPIURL = "https://api.linode.com" ) +type TokenProvider func(context.Context) (string, error) + type Client interface { GetInstance(context.Context, int) (*linodego.Instance, error) ListInstances(context.Context, *linodego.ListOptions) ([]linodego.Instance, error) @@ -75,21 +77,48 @@ type Client interface { // linodego.Client implements Client var _ Client = (*linodego.Client)(nil) -// New creates a new linode client with a given token and default timeout -func New(token string, timeout time.Duration) (*linodego.Client, error) { +type tokenTransport struct { + base http.RoundTripper + tokenProvider TokenProvider +} + +func (t *tokenTransport) RoundTrip(req *http.Request) (*http.Response, error) { + token, err := t.tokenProvider(req.Context()) + if err != nil { + return nil, err + } + + clone := req.Clone(req.Context()) + clone.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + + return t.base.RoundTrip(clone) +} + +// New creates a new linode client with a given token and default timeout. +func New(token string, timeout time.Duration, tokenProvider TokenProvider) (*linodego.Client, error) { userAgent := fmt.Sprintf("linode-cloud-controller-manager %s", linodego.DefaultUserAgent) apiURL := os.Getenv("LINODE_URL") if apiURL == "" { apiURL = DefaultLinodeAPIURL } + if tokenProvider == nil { + tokenProvider = func(context.Context) (string, error) { + return token, nil + } + } + + httpClient := &http.Client{Timeout: timeout} + httpClient.Transport = &tokenTransport{ + base: http.DefaultTransport, + tokenProvider: tokenProvider, + } - linodeClient := linodego.NewClient(&http.Client{Timeout: timeout}) + linodeClient := linodego.NewClient(httpClient) client, err := linodeClient.UseURL(apiURL) if err != nil { return nil, err } client.SetUserAgent(userAgent) - client.SetToken(token) klog.V(3).Infof("Linode client created with default timeout of %v", timeout) return client, nil diff --git a/cloud/linode/cloud.go b/cloud/linode/cloud.go index 01a5b003..2362e587 100644 --- a/cloud/linode/cloud.go +++ b/cloud/linode/cloud.go @@ -7,10 +7,15 @@ import ( "os" "regexp" "strconv" + "sync" "time" "golang.org/x/exp/slices" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/informers" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" cloudprovider "k8s.io/cloud-provider" "k8s.io/klog/v2" @@ -21,12 +26,16 @@ import ( const ( // The name of this cloudprovider - ProviderName = "linode" - accessTokenEnv = "LINODE_API_TOKEN" - regionEnv = "LINODE_REGION" - ciliumLBType = "cilium-bgp" - nodeBalancerLBType = "nodebalancer" - tokenHealthCheckPeriod = 5 * time.Minute + ProviderName = "linode" + regionEnv = "LINODE_REGION" + defaultTokenSecretName = "ccm-linode" + defaultTokenSecretKey = "apiToken" + defaultTokenSecretNamespace = "kube-system" + tokenSecretCacheTTLEnv = "LINODE_API_TOKEN_CACHE_TTL_SECONDS" + defaultTokenSecretCacheTTL = time.Minute + ciliumLBType = "cilium-bgp" + nodeBalancerLBType = "nodebalancer" + tokenHealthCheckPeriod = 5 * time.Minute ) var supportedLoadBalancerTypes = []string{ciliumLBType, nodeBalancerLBType} @@ -43,8 +52,69 @@ var ( instanceCache *services.Instances ipHolderCharLimit int = 23 NodeBalancerPrefixCharLimit int = 19 + + newKubernetesClient = defaultKubernetesClient ) +type tokenSecretProvider struct { + kubeClient kubernetes.Interface + namespace string + name string + key string + now func() time.Time + cacheTTL time.Duration + + mu sync.RWMutex + cachedToken string + expiresAt time.Time +} + +func (t *tokenSecretProvider) String() string { + return fmt.Sprintf("%s/%s[%s]", t.namespace, t.name, t.key) +} + +func (t *tokenSecretProvider) nowTime() time.Time { + if t.now != nil { + return t.now() + } + + return time.Now() +} + +func (t *tokenSecretProvider) GetToken(ctx context.Context) (string, error) { + now := t.nowTime() + + t.mu.RLock() + if t.cachedToken != "" && now.Before(t.expiresAt) { + token := t.cachedToken + t.mu.RUnlock() + return token, nil + } + t.mu.RUnlock() + + secret, err := t.kubeClient.CoreV1().Secrets(t.namespace).Get(ctx, t.name, metav1.GetOptions{}) + if err != nil { + return "", fmt.Errorf("failed to get secret %s: %w", t.String(), err) + } + + rawToken, found := secret.Data[t.key] + if !found { + return "", fmt.Errorf("secret %s does not contain key %q", t.String(), t.key) + } + + token := string(rawToken) + if token == "" { + return "", fmt.Errorf("secret %s key %q is empty", t.String(), t.key) + } + + t.mu.Lock() + t.cachedToken = token + t.expiresAt = t.nowTime().Add(t.cacheTTL) + t.mu.Unlock() + + return token, nil +} + func init() { registerMetrics() cloudprovider.RegisterCloudProvider( @@ -56,8 +126,8 @@ func init() { // newLinodeClientWithPrometheus creates a new client kept in its own local // scope and returns an instrumented one that should be used and passed around -func newLinodeClientWithPrometheus(apiToken string, timeout time.Duration) (client.Client, error) { - linodeClient, err := client.New(apiToken, timeout) +func newLinodeClientWithPrometheus(apiToken string, timeout time.Duration, tokenProvider client.TokenProvider) (client.Client, error) { + linodeClient, err := client.New(apiToken, timeout, tokenProvider) if err != nil { return nil, fmt.Errorf("client was not created successfully: %w", err) } @@ -69,16 +139,70 @@ func newLinodeClientWithPrometheus(apiToken string, timeout time.Duration) (clie return client.NewClientWithPrometheus(linodeClient), nil } +func defaultKubernetesClient() (kubernetes.Interface, error) { + var ( + kubeConfig *rest.Config + err error + ) + + kubeconfigFlag := options.Options.KubeconfigFlag + if kubeconfigFlag == nil || kubeconfigFlag.Value.String() == "" { + kubeConfig, err = rest.InClusterConfig() + } else { + kubeConfig, err = clientcmd.BuildConfigFromFlags("", kubeconfigFlag.Value.String()) + } + if err != nil { + return nil, err + } + + return kubernetes.NewForConfig(kubeConfig) +} + +func tokenSecretCacheTTLFromEnv() time.Duration { + tokenCacheTTL := defaultTokenSecretCacheTTL + if raw, ok := os.LookupEnv(tokenSecretCacheTTLEnv); ok { + if ttlSeconds, err := strconv.Atoi(raw); err == nil && ttlSeconds > 0 { + tokenCacheTTL = time.Duration(ttlSeconds) * time.Second + } + } + + return tokenCacheTTL +} + func newCloud() (cloudprovider.Interface, error) { region := os.Getenv(regionEnv) if region == "" { return nil, fmt.Errorf("%s must be set in the environment (use a k8s secret)", regionEnv) } - // Read environment variables (from secrets) - apiToken := os.Getenv(accessTokenEnv) - if apiToken == "" { - return nil, fmt.Errorf("%s must be set in the environment (use a k8s secret)", accessTokenEnv) + secretName := options.Options.LinodeAPITokenSecretName + if secretName == "" { + secretName = defaultTokenSecretName + } + secretKey := options.Options.LinodeAPITokenSecretKey + if secretKey == "" { + secretKey = defaultTokenSecretKey + } + secretNamespace := options.Options.LinodeAPITokenSecretNamespace + if secretNamespace == "" { + secretNamespace = defaultTokenSecretNamespace + } + + kubeClient, err := newKubernetesClient() + if err != nil { + return nil, fmt.Errorf("failed to create kubernetes client for token secret retrieval: %w", err) + } + + tokenProvider := tokenSecretProvider{ + kubeClient: kubeClient, + namespace: secretNamespace, + name: secretName, + key: secretKey, + } + + apiToken, err := tokenProvider.GetToken(context.Background()) + if err != nil { + return nil, err } // set timeout used by linodeclient for API calls @@ -89,7 +213,9 @@ func newCloud() (cloudprovider.Interface, error) { } } - linodeClient, err := newLinodeClientWithPrometheus(apiToken, timeout) + tokenProvider.cacheTTL = tokenSecretCacheTTLFromEnv() + + linodeClient, err := newLinodeClientWithPrometheus(apiToken, timeout, tokenProvider.GetToken) if err != nil { return nil, err } @@ -104,7 +230,7 @@ func newCloud() (cloudprovider.Interface, error) { } if !authenticated { - return nil, fmt.Errorf("linode api token %q is invalid", accessTokenEnv) + return nil, fmt.Errorf("linode api token from secret %s is invalid", tokenProvider.String()) } healthChecker = newHealthChecker(linodeClient, tokenHealthCheckPeriod, options.Options.GlobalStopChannel) diff --git a/cloud/linode/cloud_test.go b/cloud/linode/cloud_test.go index 4aed65e2..e9a52e20 100644 --- a/cloud/linode/cloud_test.go +++ b/cloud/linode/cloud_test.go @@ -4,10 +4,15 @@ import ( "reflect" "strings" "testing" + "time" "github.com/golang/mock/gomock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + k8sfake "k8s.io/client-go/kubernetes/fake" cloudprovider "k8s.io/cloud-provider" "github.com/linode/linode-cloud-controller-manager/cloud/linode/client/mocks" @@ -15,13 +20,108 @@ import ( "github.com/linode/linode-cloud-controller-manager/cloud/linode/services" ) +func configureKubernetesClientWithTokenSecret(t *testing.T, namespace string, tokenData map[string]string) { + t.Helper() + + fakeClient := k8sfake.NewSimpleClientset() + secretData := make(map[string][]byte, len(tokenData)) + for key, value := range tokenData { + secretData[key] = []byte(value) + } + + _, err := fakeClient.CoreV1().Secrets(namespace).Create(t.Context(), &v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "ccm-linode", + Namespace: namespace, + }, + Data: secretData, + }, metav1.CreateOptions{}) + require.NoError(t, err) + + original := newKubernetesClient + t.Cleanup(func() { + newKubernetesClient = original + }) + + newKubernetesClient = func() (kubernetes.Interface, error) { return fakeClient, nil } +} + +func TestTokenSecretProviderCache(t *testing.T) { + namespace := "kube-system" + secretName := "ccm-linode" + secretKey := "apiToken" + + client := k8sfake.NewSimpleClientset(&v1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: secretName, + Namespace: namespace, + }, + Data: map[string][]byte{ + secretKey: []byte("token-v1"), + }, + }) + + now := time.Now() + provider := &tokenSecretProvider{ + kubeClient: client, + namespace: namespace, + name: secretName, + key: secretKey, + cacheTTL: defaultTokenSecretCacheTTL, + now: func() time.Time { + return now + }, + } + + firstToken, err := provider.GetToken(t.Context()) + require.NoError(t, err) + assert.Equal(t, "token-v1", firstToken) + + secret, err := client.CoreV1().Secrets(namespace).Get(t.Context(), secretName, metav1.GetOptions{}) + require.NoError(t, err) + secret.Data[secretKey] = []byte("token-v2") + _, err = client.CoreV1().Secrets(namespace).Update(t.Context(), secret, metav1.UpdateOptions{}) + require.NoError(t, err) + + cachedToken, err := provider.GetToken(t.Context()) + require.NoError(t, err) + assert.Equal(t, "token-v1", cachedToken) + + now = now.Add(defaultTokenSecretCacheTTL + time.Second) + refreshedToken, err := provider.GetToken(t.Context()) + require.NoError(t, err) + assert.Equal(t, "token-v2", refreshedToken) +} + +func TestTokenSecretCacheTTLFromEnv(t *testing.T) { + t.Run("uses default ttl", func(t *testing.T) { + t.Setenv(tokenSecretCacheTTLEnv, "") + assert.Equal(t, defaultTokenSecretCacheTTL, tokenSecretCacheTTLFromEnv()) + }) + + t.Run("uses configured ttl when valid", func(t *testing.T) { + t.Setenv(tokenSecretCacheTTLEnv, "7") + assert.Equal(t, 7*time.Second, tokenSecretCacheTTLFromEnv()) + }) + + t.Run("falls back to default ttl when invalid", func(t *testing.T) { + t.Setenv(tokenSecretCacheTTLEnv, "invalid") + assert.Equal(t, defaultTokenSecretCacheTTL, tokenSecretCacheTTLFromEnv()) + }) + + t.Run("falls back to default ttl when non-positive", func(t *testing.T) { + t.Setenv(tokenSecretCacheTTLEnv, "0") + assert.Equal(t, defaultTokenSecretCacheTTL, tokenSecretCacheTTLFromEnv()) + }) +} + func TestNewCloudRouteControllerDisabled(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - t.Setenv("LINODE_API_TOKEN", "dummyapitoken") t.Setenv("LINODE_REGION", "us-east") t.Setenv("LINODE_REQUEST_TIMEOUT_SECONDS", "10") + configureKubernetesClientWithTokenSecret(t, "kube-system", map[string]string{"apiToken": "dummyapitoken"}) options.Options.NodeBalancerPrefix = "ccm" t.Run("should not fail if vpc is empty and routecontroller is disabled", func(t *testing.T) { @@ -43,16 +143,16 @@ func TestNewCloud(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - t.Setenv("LINODE_API_TOKEN", "dummyapitoken") t.Setenv("LINODE_REGION", "us-east") t.Setenv("LINODE_REQUEST_TIMEOUT_SECONDS", "10") t.Setenv("LINODE_ROUTES_CACHE_TTL_SECONDS", "60") t.Setenv("LINODE_URL", "https://api.linode.com/v4") + configureKubernetesClientWithTokenSecret(t, "kube-system", map[string]string{"apiToken": "dummyapitoken"}) options.Options.LinodeGoDebug = true options.Options.NodeBalancerPrefix = "ccm" t.Run("should fail if api token is empty", func(t *testing.T) { - t.Setenv("LINODE_API_TOKEN", "") + configureKubernetesClientWithTokenSecret(t, "kube-system", map[string]string{"apiToken": ""}) _, err := newCloud() assert.Error(t, err, "expected error when api token is empty") }) diff --git a/cloud/linode/options/options.go b/cloud/linode/options/options.go index aac562f9..2271de30 100644 --- a/cloud/linode/options/options.go +++ b/cloud/linode/options/options.go @@ -37,4 +37,7 @@ var Options struct { NodeCIDRMaskSizeIPv4 int NodeCIDRMaskSizeIPv6 int NodeBalancerPrefix string + LinodeAPITokenSecretName string + LinodeAPITokenSecretKey string + LinodeAPITokenSecretNamespace string } diff --git a/deploy/ccm-linode-template.yaml b/deploy/ccm-linode-template.yaml index 829da922..604b68ab 100644 --- a/deploy/ccm-linode-template.yaml +++ b/deploy/ccm-linode-template.yaml @@ -115,15 +115,13 @@ spec: - --v=3 - --secure-port=10253 - --webhook-secure-port=0 + - --linode-api-token-secret-name=ccm-linode + - --linode-api-token-secret-key=apiToken + - --linode-api-token-secret-namespace=kube-system volumeMounts: - mountPath: /etc/kubernetes name: k8s env: - - name: LINODE_API_TOKEN - valueFrom: - secretKeyRef: - name: ccm-linode - key: apiToken - name: LINODE_REGION valueFrom: secretKeyRef: diff --git a/deploy/chart/templates/daemonset.yaml b/deploy/chart/templates/daemonset.yaml index f1f37e4e..cce6fdc6 100644 --- a/deploy/chart/templates/daemonset.yaml +++ b/deploy/chart/templates/daemonset.yaml @@ -160,6 +160,9 @@ spec: {{- with .Values.tokenHealthChecker }} - --enable-token-health-checker={{ . }} {{- end }} + - --linode-api-token-secret-name={{ if .Values.secretRef }}{{ .Values.secretRef.name | default "ccm-linode" }}{{ else }}ccm-linode{{ end }} + - --linode-api-token-secret-key={{ if .Values.secretRef }}{{ .Values.secretRef.apiTokenRef | default "apiToken" }}{{ else }}apiToken{{ end }} + - --linode-api-token-secret-namespace={{ if .Values.secretRef }}{{ .Values.secretRef.namespace | default .Values.namespace }}{{ else }}{{ .Values.namespace }}{{ end }} {{- with .Values.nodeBalancerTags }} - --nodebalancer-tags={{ join " " . }} {{- end }} @@ -215,11 +218,6 @@ spec: - name: KUBERNETES_SERVICE_PORT value: {{ .Values.k8sServicePort | quote }} {{- end }} - - name: LINODE_API_TOKEN - valueFrom: - secretKeyRef: - name: {{ if .Values.secretRef }}{{ .Values.secretRef.name | default "ccm-linode" }}{{ else }}"ccm-linode"{{ end }} - key: {{ if .Values.secretRef }}{{ .Values.secretRef.apiTokenRef | default "apiToken" }}{{ else }}"apiToken"{{ end }} - name: LINODE_REGION valueFrom: secretKeyRef: diff --git a/deploy/chart/values.yaml b/deploy/chart/values.yaml index 17c9d4ed..2173d1c3 100644 --- a/deploy/chart/values.yaml +++ b/deploy/chart/values.yaml @@ -7,6 +7,7 @@ region: "" # Set these values if your APIToken and region are already present in a k8s secret. # secretRef: # name: "linode-ccm" +# namespace: "kube-system" # apiTokenRef: "apiToken" # regionRef: "region" diff --git a/main.go b/main.go index afe5941c..0b58dadb 100644 --- a/main.go +++ b/main.go @@ -103,6 +103,9 @@ func main() { command.Flags().BoolVar(&ccmOptions.Options.DisableNodeBalancerVPCBackends, "disable-nodebalancer-vpc-backends", false, "disables nodebalancer backends in VPCs (when enabled, nodebalancers will only have private IPs as backends for backward compatibility)") command.Flags().StringVar(&ccmOptions.Options.NodeBalancerPrefix, "nodebalancer-prefix", "ccm", fmt.Sprintf("Name prefix for NoadBalancers. (max. %v char.)", linode.NodeBalancerPrefixCharLimit)) command.Flags().BoolVar(&ccmOptions.Options.DisableIPv6NodeCIDRAllocation, "disable-ipv6-node-cidr-allocation", false, "disables IPv6 node cidr allocation by ipam controller (when enabled, IPv6 cidr ranges will be allocated to nodes)") + command.Flags().StringVar(&ccmOptions.Options.LinodeAPITokenSecretName, "linode-api-token-secret-name", "", "name of kubernetes secret containing Linode API token") + command.Flags().StringVar(&ccmOptions.Options.LinodeAPITokenSecretKey, "linode-api-token-secret-key", "", "key in kubernetes secret containing Linode API token") + command.Flags().StringVar(&ccmOptions.Options.LinodeAPITokenSecretNamespace, "linode-api-token-secret-namespace", "", "namespace of kubernetes secret containing Linode API token") // Set static flags command.Flags().VisitAll(func(fl *pflag.Flag) { From 711ec22dce301c8f05fcc57cd5d2d454d9402f1d Mon Sep 17 00:00:00 2001 From: Tarun Chinmai Sekar Date: Thu, 18 Jun 2026 10:32:46 -0700 Subject: [PATCH 2/6] feat: use file based token --- cloud/linode/cloud.go | 121 ++++++++------------------ cloud/linode/cloud_test.go | 96 ++++++++------------ cloud/linode/options/options.go | 3 - cloud/linode/route_controller.go | 4 + deploy/ccm-linode-template.yaml | 14 ++- deploy/chart/templates/daemonset.yaml | 14 ++- main.go | 3 - 7 files changed, 98 insertions(+), 157 deletions(-) diff --git a/cloud/linode/cloud.go b/cloud/linode/cloud.go index 2362e587..80f9c4a5 100644 --- a/cloud/linode/cloud.go +++ b/cloud/linode/cloud.go @@ -7,15 +7,12 @@ import ( "os" "regexp" "strconv" + "strings" "sync" "time" "golang.org/x/exp/slices" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/informers" - "k8s.io/client-go/kubernetes" - "k8s.io/client-go/rest" - "k8s.io/client-go/tools/clientcmd" cloudprovider "k8s.io/cloud-provider" "k8s.io/klog/v2" @@ -26,16 +23,15 @@ import ( const ( // The name of this cloudprovider - ProviderName = "linode" - regionEnv = "LINODE_REGION" - defaultTokenSecretName = "ccm-linode" - defaultTokenSecretKey = "apiToken" - defaultTokenSecretNamespace = "kube-system" - tokenSecretCacheTTLEnv = "LINODE_API_TOKEN_CACHE_TTL_SECONDS" - defaultTokenSecretCacheTTL = time.Minute - ciliumLBType = "cilium-bgp" - nodeBalancerLBType = "nodebalancer" - tokenHealthCheckPeriod = 5 * time.Minute + ProviderName = "linode" + regionEnv = "LINODE_REGION" + tokenFilePathEnv = "LINODE_API_TOKEN_FILE" + defaultTokenFilePath = "/var/run/secrets/linode/api-token" + tokenCacheTTLEnv = "LINODE_API_TOKEN_CACHE_TTL_SECONDS" + defaultTokenFileCacheTTL = time.Minute + ciliumLBType = "cilium-bgp" + nodeBalancerLBType = "nodebalancer" + tokenHealthCheckPeriod = 5 * time.Minute ) var supportedLoadBalancerTypes = []string{ciliumLBType, nodeBalancerLBType} @@ -52,28 +48,23 @@ var ( instanceCache *services.Instances ipHolderCharLimit int = 23 NodeBalancerPrefixCharLimit int = 19 - - newKubernetesClient = defaultKubernetesClient ) -type tokenSecretProvider struct { - kubeClient kubernetes.Interface - namespace string - name string - key string - now func() time.Time - cacheTTL time.Duration +type tokenFileProvider struct { + path string + now func() time.Time + cacheTTL time.Duration mu sync.RWMutex cachedToken string expiresAt time.Time } -func (t *tokenSecretProvider) String() string { - return fmt.Sprintf("%s/%s[%s]", t.namespace, t.name, t.key) +func (t *tokenFileProvider) String() string { + return t.path } -func (t *tokenSecretProvider) nowTime() time.Time { +func (t *tokenFileProvider) nowTime() time.Time { if t.now != nil { return t.now() } @@ -81,8 +72,12 @@ func (t *tokenSecretProvider) nowTime() time.Time { return time.Now() } -func (t *tokenSecretProvider) GetToken(ctx context.Context) (string, error) { +func (t *tokenFileProvider) GetToken(_ context.Context) (string, error) { now := t.nowTime() + cacheTTL := t.cacheTTL + if cacheTTL <= 0 { + cacheTTL = defaultTokenFileCacheTTL + } t.mu.RLock() if t.cachedToken != "" && now.Before(t.expiresAt) { @@ -92,24 +87,19 @@ func (t *tokenSecretProvider) GetToken(ctx context.Context) (string, error) { } t.mu.RUnlock() - secret, err := t.kubeClient.CoreV1().Secrets(t.namespace).Get(ctx, t.name, metav1.GetOptions{}) + rawToken, err := os.ReadFile(t.path) if err != nil { - return "", fmt.Errorf("failed to get secret %s: %w", t.String(), err) + return "", fmt.Errorf("failed to read token file %q: %w", t.String(), err) } - rawToken, found := secret.Data[t.key] - if !found { - return "", fmt.Errorf("secret %s does not contain key %q", t.String(), t.key) - } - - token := string(rawToken) + token := strings.TrimSpace(string(rawToken)) if token == "" { - return "", fmt.Errorf("secret %s key %q is empty", t.String(), t.key) + return "", fmt.Errorf("token file %q is empty", t.String()) } t.mu.Lock() t.cachedToken = token - t.expiresAt = t.nowTime().Add(t.cacheTTL) + t.expiresAt = t.nowTime().Add(cacheTTL) t.mu.Unlock() return token, nil @@ -138,29 +128,9 @@ func newLinodeClientWithPrometheus(apiToken string, timeout time.Duration, token return client.NewClientWithPrometheus(linodeClient), nil } - -func defaultKubernetesClient() (kubernetes.Interface, error) { - var ( - kubeConfig *rest.Config - err error - ) - - kubeconfigFlag := options.Options.KubeconfigFlag - if kubeconfigFlag == nil || kubeconfigFlag.Value.String() == "" { - kubeConfig, err = rest.InClusterConfig() - } else { - kubeConfig, err = clientcmd.BuildConfigFromFlags("", kubeconfigFlag.Value.String()) - } - if err != nil { - return nil, err - } - - return kubernetes.NewForConfig(kubeConfig) -} - -func tokenSecretCacheTTLFromEnv() time.Duration { - tokenCacheTTL := defaultTokenSecretCacheTTL - if raw, ok := os.LookupEnv(tokenSecretCacheTTLEnv); ok { +func tokenFileCacheTTLFromEnv() time.Duration { + tokenCacheTTL := defaultTokenFileCacheTTL + if raw, ok := os.LookupEnv(tokenCacheTTLEnv); ok { if ttlSeconds, err := strconv.Atoi(raw); err == nil && ttlSeconds > 0 { tokenCacheTTL = time.Duration(ttlSeconds) * time.Second } @@ -175,29 +145,14 @@ func newCloud() (cloudprovider.Interface, error) { return nil, fmt.Errorf("%s must be set in the environment (use a k8s secret)", regionEnv) } - secretName := options.Options.LinodeAPITokenSecretName - if secretName == "" { - secretName = defaultTokenSecretName - } - secretKey := options.Options.LinodeAPITokenSecretKey - if secretKey == "" { - secretKey = defaultTokenSecretKey - } - secretNamespace := options.Options.LinodeAPITokenSecretNamespace - if secretNamespace == "" { - secretNamespace = defaultTokenSecretNamespace + tokenFilePath := os.Getenv(tokenFilePathEnv) + if tokenFilePath == "" { + tokenFilePath = defaultTokenFilePath } - kubeClient, err := newKubernetesClient() - if err != nil { - return nil, fmt.Errorf("failed to create kubernetes client for token secret retrieval: %w", err) - } - - tokenProvider := tokenSecretProvider{ - kubeClient: kubeClient, - namespace: secretNamespace, - name: secretName, - key: secretKey, + tokenProvider := tokenFileProvider{ + path: tokenFilePath, + cacheTTL: tokenFileCacheTTLFromEnv(), } apiToken, err := tokenProvider.GetToken(context.Background()) @@ -213,8 +168,6 @@ func newCloud() (cloudprovider.Interface, error) { } } - tokenProvider.cacheTTL = tokenSecretCacheTTLFromEnv() - linodeClient, err := newLinodeClientWithPrometheus(apiToken, timeout, tokenProvider.GetToken) if err != nil { return nil, err @@ -230,7 +183,7 @@ func newCloud() (cloudprovider.Interface, error) { } if !authenticated { - return nil, fmt.Errorf("linode api token from secret %s is invalid", tokenProvider.String()) + return nil, fmt.Errorf("linode api token from file %q is invalid", tokenProvider.String()) } healthChecker = newHealthChecker(linodeClient, tokenHealthCheckPeriod, options.Options.GlobalStopChannel) diff --git a/cloud/linode/cloud_test.go b/cloud/linode/cloud_test.go index e9a52e20..8cb87982 100644 --- a/cloud/linode/cloud_test.go +++ b/cloud/linode/cloud_test.go @@ -1,6 +1,8 @@ package linode import ( + "os" + "path/filepath" "reflect" "strings" "testing" @@ -9,10 +11,6 @@ import ( "github.com/golang/mock/gomock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - v1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/client-go/kubernetes" - k8sfake "k8s.io/client-go/kubernetes/fake" cloudprovider "k8s.io/cloud-provider" "github.com/linode/linode-cloud-controller-manager/cloud/linode/client/mocks" @@ -20,54 +18,31 @@ import ( "github.com/linode/linode-cloud-controller-manager/cloud/linode/services" ) -func configureKubernetesClientWithTokenSecret(t *testing.T, namespace string, tokenData map[string]string) { +func configureTokenFile(t *testing.T, token string) string { t.Helper() - fakeClient := k8sfake.NewSimpleClientset() - secretData := make(map[string][]byte, len(tokenData)) - for key, value := range tokenData { - secretData[key] = []byte(value) - } - - _, err := fakeClient.CoreV1().Secrets(namespace).Create(t.Context(), &v1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "ccm-linode", - Namespace: namespace, - }, - Data: secretData, - }, metav1.CreateOptions{}) + tokenFilePath := filepath.Join(t.TempDir(), "api-token") + err := os.WriteFile(tokenFilePath, []byte(token), 0o600) require.NoError(t, err) + t.Setenv(tokenFilePathEnv, tokenFilePath) - original := newKubernetesClient - t.Cleanup(func() { - newKubernetesClient = original - }) - - newKubernetesClient = func() (kubernetes.Interface, error) { return fakeClient, nil } + return tokenFilePath } -func TestTokenSecretProviderCache(t *testing.T) { - namespace := "kube-system" - secretName := "ccm-linode" - secretKey := "apiToken" +func updateTokenFile(t *testing.T, tokenFilePath, token string) { + t.Helper() - client := k8sfake.NewSimpleClientset(&v1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: secretName, - Namespace: namespace, - }, - Data: map[string][]byte{ - secretKey: []byte("token-v1"), - }, - }) + err := os.WriteFile(tokenFilePath, []byte(token), 0o600) + require.NoError(t, err) +} + +func TestTokenFileProviderCache(t *testing.T) { + tokenFilePath := configureTokenFile(t, "token-v1") now := time.Now() - provider := &tokenSecretProvider{ - kubeClient: client, - namespace: namespace, - name: secretName, - key: secretKey, - cacheTTL: defaultTokenSecretCacheTTL, + provider := &tokenFileProvider{ + path: tokenFilePath, + cacheTTL: defaultTokenFileCacheTTL, now: func() time.Time { return now }, @@ -77,41 +52,37 @@ func TestTokenSecretProviderCache(t *testing.T) { require.NoError(t, err) assert.Equal(t, "token-v1", firstToken) - secret, err := client.CoreV1().Secrets(namespace).Get(t.Context(), secretName, metav1.GetOptions{}) - require.NoError(t, err) - secret.Data[secretKey] = []byte("token-v2") - _, err = client.CoreV1().Secrets(namespace).Update(t.Context(), secret, metav1.UpdateOptions{}) - require.NoError(t, err) + updateTokenFile(t, tokenFilePath, "token-v2") cachedToken, err := provider.GetToken(t.Context()) require.NoError(t, err) assert.Equal(t, "token-v1", cachedToken) - now = now.Add(defaultTokenSecretCacheTTL + time.Second) + now = now.Add(defaultTokenFileCacheTTL + time.Second) refreshedToken, err := provider.GetToken(t.Context()) require.NoError(t, err) assert.Equal(t, "token-v2", refreshedToken) } -func TestTokenSecretCacheTTLFromEnv(t *testing.T) { +func TestTokenFileCacheTTLFromEnv(t *testing.T) { t.Run("uses default ttl", func(t *testing.T) { - t.Setenv(tokenSecretCacheTTLEnv, "") - assert.Equal(t, defaultTokenSecretCacheTTL, tokenSecretCacheTTLFromEnv()) + t.Setenv(tokenCacheTTLEnv, "") + assert.Equal(t, defaultTokenFileCacheTTL, tokenFileCacheTTLFromEnv()) }) t.Run("uses configured ttl when valid", func(t *testing.T) { - t.Setenv(tokenSecretCacheTTLEnv, "7") - assert.Equal(t, 7*time.Second, tokenSecretCacheTTLFromEnv()) + t.Setenv(tokenCacheTTLEnv, "7") + assert.Equal(t, 7*time.Second, tokenFileCacheTTLFromEnv()) }) t.Run("falls back to default ttl when invalid", func(t *testing.T) { - t.Setenv(tokenSecretCacheTTLEnv, "invalid") - assert.Equal(t, defaultTokenSecretCacheTTL, tokenSecretCacheTTLFromEnv()) + t.Setenv(tokenCacheTTLEnv, "invalid") + assert.Equal(t, defaultTokenFileCacheTTL, tokenFileCacheTTLFromEnv()) }) t.Run("falls back to default ttl when non-positive", func(t *testing.T) { - t.Setenv(tokenSecretCacheTTLEnv, "0") - assert.Equal(t, defaultTokenSecretCacheTTL, tokenSecretCacheTTLFromEnv()) + t.Setenv(tokenCacheTTLEnv, "0") + assert.Equal(t, defaultTokenFileCacheTTL, tokenFileCacheTTLFromEnv()) }) } @@ -121,7 +92,7 @@ func TestNewCloudRouteControllerDisabled(t *testing.T) { t.Setenv("LINODE_REGION", "us-east") t.Setenv("LINODE_REQUEST_TIMEOUT_SECONDS", "10") - configureKubernetesClientWithTokenSecret(t, "kube-system", map[string]string{"apiToken": "dummyapitoken"}) + configureTokenFile(t, "dummyapitoken") options.Options.NodeBalancerPrefix = "ccm" t.Run("should not fail if vpc is empty and routecontroller is disabled", func(t *testing.T) { @@ -147,12 +118,15 @@ func TestNewCloud(t *testing.T) { t.Setenv("LINODE_REQUEST_TIMEOUT_SECONDS", "10") t.Setenv("LINODE_ROUTES_CACHE_TTL_SECONDS", "60") t.Setenv("LINODE_URL", "https://api.linode.com/v4") - configureKubernetesClientWithTokenSecret(t, "kube-system", map[string]string{"apiToken": "dummyapitoken"}) + tokenFilePath := configureTokenFile(t, "dummyapitoken") options.Options.LinodeGoDebug = true options.Options.NodeBalancerPrefix = "ccm" t.Run("should fail if api token is empty", func(t *testing.T) { - configureKubernetesClientWithTokenSecret(t, "kube-system", map[string]string{"apiToken": ""}) + t.Cleanup(func() { + updateTokenFile(t, tokenFilePath, "dummyapitoken") + }) + updateTokenFile(t, tokenFilePath, "") _, err := newCloud() assert.Error(t, err, "expected error when api token is empty") }) diff --git a/cloud/linode/options/options.go b/cloud/linode/options/options.go index 2271de30..aac562f9 100644 --- a/cloud/linode/options/options.go +++ b/cloud/linode/options/options.go @@ -37,7 +37,4 @@ var Options struct { NodeCIDRMaskSizeIPv4 int NodeCIDRMaskSizeIPv6 int NodeBalancerPrefix string - LinodeAPITokenSecretName string - LinodeAPITokenSecretKey string - LinodeAPITokenSecretNamespace string } diff --git a/cloud/linode/route_controller.go b/cloud/linode/route_controller.go index bf785849..8d577868 100644 --- a/cloud/linode/route_controller.go +++ b/cloud/linode/route_controller.go @@ -254,6 +254,10 @@ func (r *routes) handleInterfaces(ctx context.Context, intfRoutes []string, lino klog.V(4).Infof("Unable to update legacy interface %d for node %s", intfVPCIP.InterfaceID, route.TargetNode) return err } + if resp.VPC == nil { + klog.V(4).Infof("update legacy interface %d for node %s. Nil VPC returned. resp is %+v", intfVPCIP.InterfaceID, route.TargetNode, resp) + return nil + } klog.V(4).Infof("Updated routes for node %s. Current routes: %v", route.TargetNode, resp.VPC.IPv4.Ranges) } else { interfaceUpdateOptions := linodego.InstanceConfigInterfaceUpdateOptions{ diff --git a/deploy/ccm-linode-template.yaml b/deploy/ccm-linode-template.yaml index 604b68ab..1e599803 100644 --- a/deploy/ccm-linode-template.yaml +++ b/deploy/ccm-linode-template.yaml @@ -115,13 +115,15 @@ spec: - --v=3 - --secure-port=10253 - --webhook-secure-port=0 - - --linode-api-token-secret-name=ccm-linode - - --linode-api-token-secret-key=apiToken - - --linode-api-token-secret-namespace=kube-system volumeMounts: - mountPath: /etc/kubernetes name: k8s + - mountPath: /var/run/secrets/linode + name: linode-api-token + readOnly: true env: + - name: LINODE_API_TOKEN_FILE + value: /var/run/secrets/linode/api-token - name: LINODE_REGION valueFrom: secretKeyRef: @@ -136,3 +138,9 @@ spec: - name: k8s hostPath: path: /etc/kubernetes + - name: linode-api-token + secret: + secretName: ccm-linode + items: + - key: apiToken + path: api-token diff --git a/deploy/chart/templates/daemonset.yaml b/deploy/chart/templates/daemonset.yaml index cce6fdc6..af052b62 100644 --- a/deploy/chart/templates/daemonset.yaml +++ b/deploy/chart/templates/daemonset.yaml @@ -160,9 +160,6 @@ spec: {{- with .Values.tokenHealthChecker }} - --enable-token-health-checker={{ . }} {{- end }} - - --linode-api-token-secret-name={{ if .Values.secretRef }}{{ .Values.secretRef.name | default "ccm-linode" }}{{ else }}ccm-linode{{ end }} - - --linode-api-token-secret-key={{ if .Values.secretRef }}{{ .Values.secretRef.apiTokenRef | default "apiToken" }}{{ else }}apiToken{{ end }} - - --linode-api-token-secret-namespace={{ if .Values.secretRef }}{{ .Values.secretRef.namespace | default .Values.namespace }}{{ else }}{{ .Values.namespace }}{{ end }} {{- with .Values.nodeBalancerTags }} - --nodebalancer-tags={{ join " " . }} {{- end }} @@ -206,6 +203,9 @@ spec: volumeMounts: - mountPath: /etc/kubernetes name: k8s + - mountPath: /var/run/secrets/linode + name: linode-api-token + readOnly: true {{- with .Values.volumeMounts}} {{- toYaml . | nindent 12 }} {{- end}} @@ -223,6 +223,8 @@ spec: secretKeyRef: name: {{ if .Values.secretRef }}{{ .Values.secretRef.name | default "ccm-linode" }}{{ else }}"ccm-linode"{{ end }} key: {{ if .Values.secretRef }}{{ .Values.secretRef.regionRef | default "region" }}{{ else }}"region"{{ end }} + - name: LINODE_API_TOKEN_FILE + value: /var/run/secrets/linode/api-token {{- with .Values.env}} {{- toYaml . | nindent 12 }} {{- end}} @@ -234,6 +236,12 @@ spec: hostPath: path: /etc/kubernetes {{- end }} + - name: linode-api-token + secret: + secretName: {{ if .Values.secretRef }}{{ .Values.secretRef.name | default "ccm-linode" }}{{ else }}ccm-linode{{ end }} + items: + - key: {{ if .Values.secretRef }}{{ .Values.secretRef.apiTokenRef | default "apiToken" }}{{ else }}apiToken{{ end }} + path: api-token {{- with .Values.volumes}} {{- toYaml . | nindent 8 }} {{- end}} diff --git a/main.go b/main.go index 0b58dadb..afe5941c 100644 --- a/main.go +++ b/main.go @@ -103,9 +103,6 @@ func main() { command.Flags().BoolVar(&ccmOptions.Options.DisableNodeBalancerVPCBackends, "disable-nodebalancer-vpc-backends", false, "disables nodebalancer backends in VPCs (when enabled, nodebalancers will only have private IPs as backends for backward compatibility)") command.Flags().StringVar(&ccmOptions.Options.NodeBalancerPrefix, "nodebalancer-prefix", "ccm", fmt.Sprintf("Name prefix for NoadBalancers. (max. %v char.)", linode.NodeBalancerPrefixCharLimit)) command.Flags().BoolVar(&ccmOptions.Options.DisableIPv6NodeCIDRAllocation, "disable-ipv6-node-cidr-allocation", false, "disables IPv6 node cidr allocation by ipam controller (when enabled, IPv6 cidr ranges will be allocated to nodes)") - command.Flags().StringVar(&ccmOptions.Options.LinodeAPITokenSecretName, "linode-api-token-secret-name", "", "name of kubernetes secret containing Linode API token") - command.Flags().StringVar(&ccmOptions.Options.LinodeAPITokenSecretKey, "linode-api-token-secret-key", "", "key in kubernetes secret containing Linode API token") - command.Flags().StringVar(&ccmOptions.Options.LinodeAPITokenSecretNamespace, "linode-api-token-secret-namespace", "", "namespace of kubernetes secret containing Linode API token") // Set static flags command.Flags().VisitAll(func(fl *pflag.Flag) { From a68cd1cc8a463df62dcf8cf7da3b36b369fd3a27 Mon Sep 17 00:00:00 2001 From: Tarun Chinmai Sekar Date: Thu, 18 Jun 2026 10:41:07 -0700 Subject: [PATCH 3/6] fix: lint --- cloud/linode/cloud.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cloud/linode/cloud.go b/cloud/linode/cloud.go index 80f9c4a5..fa305578 100644 --- a/cloud/linode/cloud.go +++ b/cloud/linode/cloud.go @@ -163,7 +163,7 @@ func newCloud() (cloudprovider.Interface, error) { // set timeout used by linodeclient for API calls timeout := client.DefaultClientTimeout if raw, ok := os.LookupEnv("LINODE_REQUEST_TIMEOUT_SECONDS"); ok { - if t, err := strconv.Atoi(raw); err == nil && t > 0 { + if t, atoiErr := strconv.Atoi(raw); atoiErr == nil && t > 0 { timeout = time.Duration(t) * time.Second } } From 2c8752f57f9993b7ee5b76f58eaf54cafb76cc24 Mon Sep 17 00:00:00 2001 From: Tarun Chinmai Sekar Date: Thu, 18 Jun 2026 11:08:12 -0700 Subject: [PATCH 4/6] feat: add a fallback --- cloud/linode/cloud.go | 50 +++++++++++++++++++++++++++++--------- cloud/linode/cloud_test.go | 40 ++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 11 deletions(-) diff --git a/cloud/linode/cloud.go b/cloud/linode/cloud.go index fa305578..bdac5ec6 100644 --- a/cloud/linode/cloud.go +++ b/cloud/linode/cloud.go @@ -24,6 +24,7 @@ import ( const ( // The name of this cloudprovider ProviderName = "linode" + accessTokenEnv = "LINODE_API_TOKEN" regionEnv = "LINODE_REGION" tokenFilePathEnv = "LINODE_API_TOKEN_FILE" defaultTokenFilePath = "/var/run/secrets/linode/api-token" @@ -60,6 +61,18 @@ type tokenFileProvider struct { expiresAt time.Time } +type staticTokenProvider struct { + token string +} + +func (t staticTokenProvider) GetToken(context.Context) (string, error) { + if t.token == "" { + return "", fmt.Errorf("%s must be set in the environment (use a k8s secret)", accessTokenEnv) + } + + return t.token, nil +} + func (t *tokenFileProvider) String() string { return t.path } @@ -139,23 +152,38 @@ func tokenFileCacheTTLFromEnv() time.Duration { return tokenCacheTTL } -func newCloud() (cloudprovider.Interface, error) { - region := os.Getenv(regionEnv) - if region == "" { - return nil, fmt.Errorf("%s must be set in the environment (use a k8s secret)", regionEnv) - } - - tokenFilePath := os.Getenv(tokenFilePathEnv) +func tokenProviderFromFileOrEnv() (string, client.TokenProvider, string, error) { + tokenFilePath := strings.TrimSpace(os.Getenv(tokenFilePathEnv)) if tokenFilePath == "" { tokenFilePath = defaultTokenFilePath } - tokenProvider := tokenFileProvider{ + fileProvider := tokenFileProvider{ path: tokenFilePath, cacheTTL: tokenFileCacheTTLFromEnv(), } - apiToken, err := tokenProvider.GetToken(context.Background()) + apiToken, fileErr := fileProvider.GetToken(context.Background()) + if fileErr == nil { + return apiToken, fileProvider.GetToken, fmt.Sprintf("file %q", fileProvider.String()), nil + } + + envToken := strings.TrimSpace(os.Getenv(accessTokenEnv)) + if envToken != "" { + envProvider := staticTokenProvider{token: envToken} + return envToken, envProvider.GetToken, fmt.Sprintf("environment variable %q", accessTokenEnv), nil + } + + return "", nil, "", fmt.Errorf("failed to load linode api token from %s=%q: %w; fallback %s is not set", tokenFilePathEnv, tokenFilePath, fileErr, accessTokenEnv) +} + +func newCloud() (cloudprovider.Interface, error) { + region := os.Getenv(regionEnv) + if region == "" { + return nil, fmt.Errorf("%s must be set in the environment (use a k8s secret)", regionEnv) + } + + apiToken, tokenProvider, tokenSourceDescription, err := tokenProviderFromFileOrEnv() if err != nil { return nil, err } @@ -168,7 +196,7 @@ func newCloud() (cloudprovider.Interface, error) { } } - linodeClient, err := newLinodeClientWithPrometheus(apiToken, timeout, tokenProvider.GetToken) + linodeClient, err := newLinodeClientWithPrometheus(apiToken, timeout, tokenProvider) if err != nil { return nil, err } @@ -183,7 +211,7 @@ func newCloud() (cloudprovider.Interface, error) { } if !authenticated { - return nil, fmt.Errorf("linode api token from file %q is invalid", tokenProvider.String()) + return nil, fmt.Errorf("linode api token from %s is invalid", tokenSourceDescription) } healthChecker = newHealthChecker(linodeClient, tokenHealthCheckPeriod, options.Options.GlobalStopChannel) diff --git a/cloud/linode/cloud_test.go b/cloud/linode/cloud_test.go index 8cb87982..9f670a95 100644 --- a/cloud/linode/cloud_test.go +++ b/cloud/linode/cloud_test.go @@ -86,6 +86,46 @@ func TestTokenFileCacheTTLFromEnv(t *testing.T) { }) } +func TestTokenProviderFromFileOrEnv(t *testing.T) { + t.Run("uses file token when available", func(t *testing.T) { + t.Setenv(accessTokenEnv, "env-token") + configureTokenFile(t, "file-token") + + apiToken, tokenProvider, source, err := tokenProviderFromFileOrEnv() + require.NoError(t, err) + assert.Equal(t, "file-token", apiToken) + assert.Equal(t, "file \""+os.Getenv(tokenFilePathEnv)+"\"", source) + + token, err := tokenProvider(t.Context()) + require.NoError(t, err) + assert.Equal(t, "file-token", token) + }) + + t.Run("falls back to env token when file missing", func(t *testing.T) { + t.Setenv(accessTokenEnv, "env-token") + t.Setenv(tokenFilePathEnv, filepath.Join(t.TempDir(), "missing-token-file")) + + apiToken, tokenProvider, source, err := tokenProviderFromFileOrEnv() + require.NoError(t, err) + assert.Equal(t, "env-token", apiToken) + assert.Equal(t, "environment variable \"LINODE_API_TOKEN\"", source) + + token, err := tokenProvider(t.Context()) + require.NoError(t, err) + assert.Equal(t, "env-token", token) + }) + + t.Run("errors when both file and env token unavailable", func(t *testing.T) { + t.Setenv(accessTokenEnv, "") + t.Setenv(tokenFilePathEnv, filepath.Join(t.TempDir(), "missing-token-file")) + + _, _, _, err := tokenProviderFromFileOrEnv() + require.Error(t, err) + require.ErrorContains(t, err, "LINODE_API_TOKEN") + require.ErrorContains(t, err, "failed to load linode api token") + }) +} + func TestNewCloudRouteControllerDisabled(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() From 19c469e8197c3d823a01c656950a2c55f9959f44 Mon Sep 17 00:00:00 2001 From: Tarun Chinmai Sekar <70169773+tchinmai7@users.noreply.github.com> Date: Thu, 18 Jun 2026 11:25:54 -0700 Subject: [PATCH 5/6] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- deploy/chart/values.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy/chart/values.yaml b/deploy/chart/values.yaml index 2173d1c3..67e18684 100644 --- a/deploy/chart/values.yaml +++ b/deploy/chart/values.yaml @@ -7,7 +7,7 @@ region: "" # Set these values if your APIToken and region are already present in a k8s secret. # secretRef: # name: "linode-ccm" -# namespace: "kube-system" +# # NOTE: Secrets are namespace-scoped; the referenced secret must be in the same namespace as the DaemonSet/release. # apiTokenRef: "apiToken" # regionRef: "region" From d6cc5221db458e5bbad919d24db379d28a9dd7f9 Mon Sep 17 00:00:00 2001 From: Tarun Chinmai Sekar Date: Tue, 23 Jun 2026 14:50:55 -0700 Subject: [PATCH 6/6] fix: preserve behavior, remove extra string arg --- cloud/linode/client/client.go | 8 +---- cloud/linode/cloud.go | 22 ++++++------- cloud/linode/cloud_test.go | 8 ++--- deploy/chart/templates/daemonset.yaml | 40 +++++++++++++++++++---- deploy/chart/values.yaml | 5 +++ docs/getting-started/helm-installation.md | 17 ++++++++++ 6 files changed, 70 insertions(+), 30 deletions(-) diff --git a/cloud/linode/client/client.go b/cloud/linode/client/client.go index f0ac40de..7c79e15c 100644 --- a/cloud/linode/client/client.go +++ b/cloud/linode/client/client.go @@ -95,18 +95,12 @@ func (t *tokenTransport) RoundTrip(req *http.Request) (*http.Response, error) { } // New creates a new linode client with a given token and default timeout. -func New(token string, timeout time.Duration, tokenProvider TokenProvider) (*linodego.Client, error) { +func New(timeout time.Duration, tokenProvider TokenProvider) (*linodego.Client, error) { userAgent := fmt.Sprintf("linode-cloud-controller-manager %s", linodego.DefaultUserAgent) apiURL := os.Getenv("LINODE_URL") if apiURL == "" { apiURL = DefaultLinodeAPIURL } - if tokenProvider == nil { - tokenProvider = func(context.Context) (string, error) { - return token, nil - } - } - httpClient := &http.Client{Timeout: timeout} httpClient.Transport = &tokenTransport{ base: http.DefaultTransport, diff --git a/cloud/linode/cloud.go b/cloud/linode/cloud.go index bdac5ec6..7d8caa99 100644 --- a/cloud/linode/cloud.go +++ b/cloud/linode/cloud.go @@ -129,8 +129,8 @@ func init() { // newLinodeClientWithPrometheus creates a new client kept in its own local // scope and returns an instrumented one that should be used and passed around -func newLinodeClientWithPrometheus(apiToken string, timeout time.Duration, tokenProvider client.TokenProvider) (client.Client, error) { - linodeClient, err := client.New(apiToken, timeout, tokenProvider) +func newLinodeClientWithPrometheus(timeout time.Duration, tokenProvider client.TokenProvider) (client.Client, error) { + linodeClient, err := client.New(timeout, tokenProvider) if err != nil { return nil, fmt.Errorf("client was not created successfully: %w", err) } @@ -141,6 +141,7 @@ func newLinodeClientWithPrometheus(apiToken string, timeout time.Duration, token return client.NewClientWithPrometheus(linodeClient), nil } + func tokenFileCacheTTLFromEnv() time.Duration { tokenCacheTTL := defaultTokenFileCacheTTL if raw, ok := os.LookupEnv(tokenCacheTTLEnv); ok { @@ -152,7 +153,7 @@ func tokenFileCacheTTLFromEnv() time.Duration { return tokenCacheTTL } -func tokenProviderFromFileOrEnv() (string, client.TokenProvider, string, error) { +func tokenProviderFromFileOrEnv() (client.TokenProvider, string, error) { tokenFilePath := strings.TrimSpace(os.Getenv(tokenFilePathEnv)) if tokenFilePath == "" { tokenFilePath = defaultTokenFilePath @@ -163,18 +164,17 @@ func tokenProviderFromFileOrEnv() (string, client.TokenProvider, string, error) cacheTTL: tokenFileCacheTTLFromEnv(), } - apiToken, fileErr := fileProvider.GetToken(context.Background()) + _, fileErr := fileProvider.GetToken(context.Background()) if fileErr == nil { - return apiToken, fileProvider.GetToken, fmt.Sprintf("file %q", fileProvider.String()), nil + return fileProvider.GetToken, fmt.Sprintf("file %q", fileProvider.String()), nil } - envToken := strings.TrimSpace(os.Getenv(accessTokenEnv)) - if envToken != "" { + if envToken := strings.TrimSpace(os.Getenv(accessTokenEnv)); envToken != "" { envProvider := staticTokenProvider{token: envToken} - return envToken, envProvider.GetToken, fmt.Sprintf("environment variable %q", accessTokenEnv), nil + return envProvider.GetToken, fmt.Sprintf("environment variable %q", accessTokenEnv), nil } - return "", nil, "", fmt.Errorf("failed to load linode api token from %s=%q: %w; fallback %s is not set", tokenFilePathEnv, tokenFilePath, fileErr, accessTokenEnv) + return nil, "", fmt.Errorf("failed to load linode api token from %s=%q: %w; fallback %s is not set", tokenFilePathEnv, tokenFilePath, fileErr, accessTokenEnv) } func newCloud() (cloudprovider.Interface, error) { @@ -183,7 +183,7 @@ func newCloud() (cloudprovider.Interface, error) { return nil, fmt.Errorf("%s must be set in the environment (use a k8s secret)", regionEnv) } - apiToken, tokenProvider, tokenSourceDescription, err := tokenProviderFromFileOrEnv() + tokenProvider, tokenSourceDescription, err := tokenProviderFromFileOrEnv() if err != nil { return nil, err } @@ -196,7 +196,7 @@ func newCloud() (cloudprovider.Interface, error) { } } - linodeClient, err := newLinodeClientWithPrometheus(apiToken, timeout, tokenProvider) + linodeClient, err := newLinodeClientWithPrometheus(timeout, tokenProvider) if err != nil { return nil, err } diff --git a/cloud/linode/cloud_test.go b/cloud/linode/cloud_test.go index 9f670a95..00087bbf 100644 --- a/cloud/linode/cloud_test.go +++ b/cloud/linode/cloud_test.go @@ -91,9 +91,8 @@ func TestTokenProviderFromFileOrEnv(t *testing.T) { t.Setenv(accessTokenEnv, "env-token") configureTokenFile(t, "file-token") - apiToken, tokenProvider, source, err := tokenProviderFromFileOrEnv() + tokenProvider, source, err := tokenProviderFromFileOrEnv() require.NoError(t, err) - assert.Equal(t, "file-token", apiToken) assert.Equal(t, "file \""+os.Getenv(tokenFilePathEnv)+"\"", source) token, err := tokenProvider(t.Context()) @@ -105,9 +104,8 @@ func TestTokenProviderFromFileOrEnv(t *testing.T) { t.Setenv(accessTokenEnv, "env-token") t.Setenv(tokenFilePathEnv, filepath.Join(t.TempDir(), "missing-token-file")) - apiToken, tokenProvider, source, err := tokenProviderFromFileOrEnv() + tokenProvider, source, err := tokenProviderFromFileOrEnv() require.NoError(t, err) - assert.Equal(t, "env-token", apiToken) assert.Equal(t, "environment variable \"LINODE_API_TOKEN\"", source) token, err := tokenProvider(t.Context()) @@ -119,7 +117,7 @@ func TestTokenProviderFromFileOrEnv(t *testing.T) { t.Setenv(accessTokenEnv, "") t.Setenv(tokenFilePathEnv, filepath.Join(t.TempDir(), "missing-token-file")) - _, _, _, err := tokenProviderFromFileOrEnv() + _, _, err := tokenProviderFromFileOrEnv() require.Error(t, err) require.ErrorContains(t, err, "LINODE_API_TOKEN") require.ErrorContains(t, err, "failed to load linode api token") diff --git a/deploy/chart/templates/daemonset.yaml b/deploy/chart/templates/daemonset.yaml index af052b62..e3293fb7 100644 --- a/deploy/chart/templates/daemonset.yaml +++ b/deploy/chart/templates/daemonset.yaml @@ -1,5 +1,19 @@ apiVersion: apps/v1 kind: DaemonSet +{{- $secretName := "ccm-linode" }} +{{- $apiTokenKey := "apiToken" }} +{{- $regionKey := "region" }} +{{- $mountSecret := false }} +{{- $tokenFilePath := "/var/run/secrets/linode/api-token" }} +{{- if .Values.secretRef }} +{{- $secretName = (.Values.secretRef.name | default "ccm-linode") }} +{{- $apiTokenKey = (.Values.secretRef.apiTokenRef | default "apiToken") }} +{{- $regionKey = (.Values.secretRef.regionRef | default "region") }} +{{- $mountSecret = (.Values.secretRef.mountSecret | default false) }} +{{- $tokenFilePath = (.Values.secretRef.mountPath | default "/var/run/secrets/linode/api-token") }} +{{- end }} +{{- $tokenFileDir := dir $tokenFilePath }} +{{- $tokenFileName := base $tokenFilePath }} metadata: name: ccm-linode labels: @@ -203,9 +217,11 @@ spec: volumeMounts: - mountPath: /etc/kubernetes name: k8s - - mountPath: /var/run/secrets/linode + {{- if $mountSecret }} + - mountPath: {{ $tokenFileDir | quote }} name: linode-api-token readOnly: true + {{- end }} {{- with .Values.volumeMounts}} {{- toYaml . | nindent 12 }} {{- end}} @@ -221,10 +237,18 @@ spec: - name: LINODE_REGION valueFrom: secretKeyRef: - name: {{ if .Values.secretRef }}{{ .Values.secretRef.name | default "ccm-linode" }}{{ else }}"ccm-linode"{{ end }} - key: {{ if .Values.secretRef }}{{ .Values.secretRef.regionRef | default "region" }}{{ else }}"region"{{ end }} + name: {{ $secretName }} + key: {{ $regionKey }} + {{- if $mountSecret }} - name: LINODE_API_TOKEN_FILE - value: /var/run/secrets/linode/api-token + value: {{ $tokenFilePath | quote }} + {{- else }} + - name: LINODE_API_TOKEN + valueFrom: + secretKeyRef: + name: {{ $secretName }} + key: {{ $apiTokenKey }} + {{- end }} {{- with .Values.env}} {{- toYaml . | nindent 12 }} {{- end}} @@ -236,12 +260,14 @@ spec: hostPath: path: /etc/kubernetes {{- end }} + {{- if $mountSecret }} - name: linode-api-token secret: - secretName: {{ if .Values.secretRef }}{{ .Values.secretRef.name | default "ccm-linode" }}{{ else }}ccm-linode{{ end }} + secretName: {{ $secretName }} items: - - key: {{ if .Values.secretRef }}{{ .Values.secretRef.apiTokenRef | default "apiToken" }}{{ else }}apiToken{{ end }} - path: api-token + - key: {{ $apiTokenKey }} + path: {{ $tokenFileName }} + {{- end }} {{- with .Values.volumes}} {{- toYaml . | nindent 8 }} {{- end}} diff --git a/deploy/chart/values.yaml b/deploy/chart/values.yaml index 67e18684..c5a428cd 100644 --- a/deploy/chart/values.yaml +++ b/deploy/chart/values.yaml @@ -10,6 +10,11 @@ region: "" # # NOTE: Secrets are namespace-scoped; the referenced secret must be in the same namespace as the DaemonSet/release. # apiTokenRef: "apiToken" # regionRef: "region" +# # Enable mounted token file mode. Default false/unset keeps legacy env injection mode. +# mountSecret: false +# # Optional token file path used when mountSecret=true. +# # Defaults to /var/run/secrets/linode/api-token. +# mountPath: /var/run/secrets/linode/api-token # Ensures the CCM runs on control plane nodes affinity: diff --git a/docs/getting-started/helm-installation.md b/docs/getting-started/helm-installation.md index 2395398c..bec9ce00 100644 --- a/docs/getting-started/helm-installation.md +++ b/docs/getting-started/helm-installation.md @@ -39,6 +39,23 @@ sharedIPLoadBalancing: allowUnauthorizedMetrics=true ``` +### Token injection modes + +- **Legacy mode (default):** `secretRef.mountSecret` unset/false. Chart injects `LINODE_API_TOKEN` from Secret key. +- **Mounted mode:** set `secretRef.mountSecret: true`. Chart mounts token file and sets `LINODE_API_TOKEN_FILE`. + +Mounted mode example: + +```yaml +secretRef: + name: linode-ccm + apiTokenRef: apiToken + regionRef: region + mountSecret: true + # Optional, defaults to /var/run/secrets/linode/api-token + mountPath: /var/run/secrets/linode/api-token +``` + 3. Install the CCM: ```bash helm install ccm-linode \