diff --git a/cloud/linode/client/client.go b/cloud/linode/client/client.go index b184c602..7c79e15c 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,42 @@ 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(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 } + 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..7d8caa99 100644 --- a/cloud/linode/cloud.go +++ b/cloud/linode/cloud.go @@ -7,6 +7,8 @@ import ( "os" "regexp" "strconv" + "strings" + "sync" "time" "golang.org/x/exp/slices" @@ -21,12 +23,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" + accessTokenEnv = "LINODE_API_TOKEN" + 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} @@ -45,6 +51,73 @@ var ( NodeBalancerPrefixCharLimit int = 19 ) +type tokenFileProvider struct { + path string + now func() time.Time + cacheTTL time.Duration + + mu sync.RWMutex + cachedToken string + 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 +} + +func (t *tokenFileProvider) nowTime() time.Time { + if t.now != nil { + return t.now() + } + + return time.Now() +} + +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) { + token := t.cachedToken + t.mu.RUnlock() + return token, nil + } + t.mu.RUnlock() + + rawToken, err := os.ReadFile(t.path) + if err != nil { + return "", fmt.Errorf("failed to read token file %q: %w", t.String(), err) + } + + token := strings.TrimSpace(string(rawToken)) + if token == "" { + return "", fmt.Errorf("token file %q is empty", t.String()) + } + + t.mu.Lock() + t.cachedToken = token + t.expiresAt = t.nowTime().Add(cacheTTL) + t.mu.Unlock() + + return token, nil +} + func init() { registerMetrics() cloudprovider.RegisterCloudProvider( @@ -56,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) (client.Client, error) { - linodeClient, err := client.New(apiToken, timeout) +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) } @@ -69,27 +142,61 @@ func newLinodeClientWithPrometheus(apiToken string, timeout time.Duration) (clie return client.NewClientWithPrometheus(linodeClient), nil } +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 + } + } + + return tokenCacheTTL +} + +func tokenProviderFromFileOrEnv() (client.TokenProvider, string, error) { + tokenFilePath := strings.TrimSpace(os.Getenv(tokenFilePathEnv)) + if tokenFilePath == "" { + tokenFilePath = defaultTokenFilePath + } + + fileProvider := tokenFileProvider{ + path: tokenFilePath, + cacheTTL: tokenFileCacheTTLFromEnv(), + } + + _, fileErr := fileProvider.GetToken(context.Background()) + if fileErr == nil { + return fileProvider.GetToken, fmt.Sprintf("file %q", fileProvider.String()), nil + } + + if envToken := strings.TrimSpace(os.Getenv(accessTokenEnv)); envToken != "" { + envProvider := staticTokenProvider{token: envToken} + 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) +} + 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) + tokenProvider, tokenSourceDescription, err := tokenProviderFromFileOrEnv() + if err != nil { + return nil, err } // 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 } } - linodeClient, err := newLinodeClientWithPrometheus(apiToken, timeout) + linodeClient, err := newLinodeClientWithPrometheus(timeout, tokenProvider) if err != nil { return nil, err } @@ -104,7 +211,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 %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 4aed65e2..00087bbf 100644 --- a/cloud/linode/cloud_test.go +++ b/cloud/linode/cloud_test.go @@ -1,9 +1,12 @@ package linode import ( + "os" + "path/filepath" "reflect" "strings" "testing" + "time" "github.com/golang/mock/gomock" "github.com/stretchr/testify/assert" @@ -15,13 +18,119 @@ import ( "github.com/linode/linode-cloud-controller-manager/cloud/linode/services" ) +func configureTokenFile(t *testing.T, token string) string { + t.Helper() + + tokenFilePath := filepath.Join(t.TempDir(), "api-token") + err := os.WriteFile(tokenFilePath, []byte(token), 0o600) + require.NoError(t, err) + t.Setenv(tokenFilePathEnv, tokenFilePath) + + return tokenFilePath +} + +func updateTokenFile(t *testing.T, tokenFilePath, token string) { + t.Helper() + + 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 := &tokenFileProvider{ + path: tokenFilePath, + cacheTTL: defaultTokenFileCacheTTL, + now: func() time.Time { + return now + }, + } + + firstToken, err := provider.GetToken(t.Context()) + require.NoError(t, err) + assert.Equal(t, "token-v1", firstToken) + + updateTokenFile(t, tokenFilePath, "token-v2") + + cachedToken, err := provider.GetToken(t.Context()) + require.NoError(t, err) + assert.Equal(t, "token-v1", cachedToken) + + now = now.Add(defaultTokenFileCacheTTL + time.Second) + refreshedToken, err := provider.GetToken(t.Context()) + require.NoError(t, err) + assert.Equal(t, "token-v2", refreshedToken) +} + +func TestTokenFileCacheTTLFromEnv(t *testing.T) { + t.Run("uses default ttl", func(t *testing.T) { + t.Setenv(tokenCacheTTLEnv, "") + assert.Equal(t, defaultTokenFileCacheTTL, tokenFileCacheTTLFromEnv()) + }) + + t.Run("uses configured ttl when valid", func(t *testing.T) { + 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(tokenCacheTTLEnv, "invalid") + assert.Equal(t, defaultTokenFileCacheTTL, tokenFileCacheTTLFromEnv()) + }) + + t.Run("falls back to default ttl when non-positive", func(t *testing.T) { + t.Setenv(tokenCacheTTLEnv, "0") + assert.Equal(t, defaultTokenFileCacheTTL, tokenFileCacheTTLFromEnv()) + }) +} + +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") + + tokenProvider, source, err := tokenProviderFromFileOrEnv() + require.NoError(t, err) + 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")) + + tokenProvider, source, err := tokenProviderFromFileOrEnv() + require.NoError(t, err) + 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() - t.Setenv("LINODE_API_TOKEN", "dummyapitoken") t.Setenv("LINODE_REGION", "us-east") t.Setenv("LINODE_REQUEST_TIMEOUT_SECONDS", "10") + configureTokenFile(t, "dummyapitoken") options.Options.NodeBalancerPrefix = "ccm" t.Run("should not fail if vpc is empty and routecontroller is disabled", func(t *testing.T) { @@ -43,16 +152,19 @@ 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") + 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) { - t.Setenv("LINODE_API_TOKEN", "") + 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/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 829da922..1e599803 100644 --- a/deploy/ccm-linode-template.yaml +++ b/deploy/ccm-linode-template.yaml @@ -118,12 +118,12 @@ spec: volumeMounts: - mountPath: /etc/kubernetes name: k8s + - mountPath: /var/run/secrets/linode + name: linode-api-token + readOnly: true env: - - name: LINODE_API_TOKEN - valueFrom: - secretKeyRef: - name: ccm-linode - key: apiToken + - name: LINODE_API_TOKEN_FILE + value: /var/run/secrets/linode/api-token - name: LINODE_REGION valueFrom: secretKeyRef: @@ -138,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 f1f37e4e..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,6 +217,11 @@ spec: volumeMounts: - mountPath: /etc/kubernetes name: k8s + {{- if $mountSecret }} + - mountPath: {{ $tokenFileDir | quote }} + name: linode-api-token + readOnly: true + {{- end }} {{- with .Values.volumeMounts}} {{- toYaml . | nindent 12 }} {{- end}} @@ -215,16 +234,21 @@ spec: - name: KUBERNETES_SERVICE_PORT value: {{ .Values.k8sServicePort | quote }} {{- end }} - - name: LINODE_API_TOKEN + - 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.apiTokenRef | default "apiToken" }}{{ else }}"apiToken"{{ end }} - - name: LINODE_REGION + name: {{ $secretName }} + key: {{ $regionKey }} + {{- if $mountSecret }} + - name: LINODE_API_TOKEN_FILE + value: {{ $tokenFilePath | quote }} + {{- else }} + - 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.regionRef | default "region" }}{{ else }}"region"{{ end }} + name: {{ $secretName }} + key: {{ $apiTokenKey }} + {{- end }} {{- with .Values.env}} {{- toYaml . | nindent 12 }} {{- end}} @@ -236,6 +260,14 @@ spec: hostPath: path: /etc/kubernetes {{- end }} + {{- if $mountSecret }} + - name: linode-api-token + secret: + secretName: {{ $secretName }} + items: + - 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 17c9d4ed..c5a428cd 100644 --- a/deploy/chart/values.yaml +++ b/deploy/chart/values.yaml @@ -7,8 +7,14 @@ region: "" # Set these values if your APIToken and region are already present in a k8s secret. # secretRef: # name: "linode-ccm" +# # 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 \