From 796ac207a950a3f036e96d0991e844883616a567 Mon Sep 17 00:00:00 2001 From: Cody Kaczynski Date: Thu, 23 Jul 2026 04:16:41 -0400 Subject: [PATCH 1/2] feat: hot-reload management token --- README.md | 19 ++- cmd/latr/main.go | 19 ++- helm/latr/templates/deployment.yaml | 20 +++ helm/latr/values.yaml | 14 ++- internal/linode/client.go | 60 +++++++-- internal/linode/client_test.go | 21 +--- internal/linode/token.go | 146 ++++++++++++++++++++++ internal/linode/token_test.go | 185 ++++++++++++++++++++++++++++ 8 files changed, 447 insertions(+), 37 deletions(-) create mode 100644 internal/linode/token.go create mode 100644 internal/linode/token_test.go diff --git a/README.md b/README.md index 5e85e84..4fcebf4 100644 --- a/README.md +++ b/README.md @@ -112,9 +112,26 @@ go install ./cmd/latr ### Environment Variables -- `LINODE_TOKEN`: Your Linode API token (required) +- `LINODE_TOKEN`: Management Linode API token (PAT) used to create/rotate other tokens. Required unless `LINODE_TOKEN_FILE` is set. +- `LINODE_TOKEN_FILE`: Path to a file containing the management PAT (e.g. Kubernetes secret volume mount). When set and readable, latr **re-reads** this file on a short cache TTL so the PAT can rotate without restarting the process. Prefer this in daemon/Kubernetes deployments. +- `LINODE_TOKEN_CACHE_TTL_SECONDS`: How long to cache a file-backed token before re-reading (default: `60`). - `VAULT_ROLE_ID`: Vault AppRole role ID (optional if in config) - `VAULT_SECRET_ID`: Vault AppRole secret ID (optional if in config) +- `LINODE_API_URL`: Override Linode API base URL (tests / custom endpoints) + +#### Management token hot-reload + +By default, `LINODE_TOKEN` is fixed for the process lifetime. For Kubernetes (or any setup that can update a mounted secret file): + +```bash +export LINODE_TOKEN_FILE=/var/run/secrets/latr/linode-token +# optional: export LINODE_TOKEN_CACHE_TTL_SECONDS=30 +./latr -config config.yaml +``` + +latr injects the bearer token on **each** Linode API request via a `TokenProvider` (same idea as [linode-blockstorage-csi-driver#592](https://github.com/linode/linode-blockstorage-csi-driver/pull/592)). After the mounted file changes, the next request after the cache TTL uses the new value—no pod restart required. + +If both `LINODE_TOKEN_FILE` and `LINODE_TOKEN` are set, the file wins when readable; otherwise latr falls back to the env value. ### Configuration File diff --git a/cmd/latr/main.go b/cmd/latr/main.go index fe9d69d..99a4e98 100644 --- a/cmd/latr/main.go +++ b/cmd/latr/main.go @@ -46,13 +46,6 @@ func main() { os.Exit(1) } - // Load Linode API token from environment - linodeToken := os.Getenv("LINODE_TOKEN") - if linodeToken == "" { - logger.Error("Missing required environment variable", slog.String("variable", "LINODE_TOKEN")) - os.Exit(1) - } - // Load and validate configuration logger.Info("Loading configuration", slog.String("path", *configPath)) cfg, err := config.LoadAndValidate(*configPath) @@ -86,9 +79,15 @@ func main() { logger = observability.GetLogger() defer telemetryCleanup() - // Create Linode client - linodeClient := linode.NewClient(linodeToken) - logger.InfoContext(ctx, "Linode client initialized") + // Management PAT: file (hot-reload) preferred over static env. Token value is never logged. + tokenProvider, tokenSource, err := linode.TokenProviderFromEnv(ctx) + if err != nil { + logger.ErrorContext(ctx, "Failed to load Linode API token", slog.Any("error", err)) + os.Exit(1) + } + linodeClient := linode.NewClientWithTokenProvider(tokenProvider) + logger.InfoContext(ctx, "Linode client initialized", + slog.String("token_source", tokenSource)) // Create Vault client vaultConfig := &vault.Config{ diff --git a/helm/latr/templates/deployment.yaml b/helm/latr/templates/deployment.yaml index 3bcb429..a4ff4b2 100644 --- a/helm/latr/templates/deployment.yaml +++ b/helm/latr/templates/deployment.yaml @@ -42,11 +42,18 @@ spec: - -config - /config/config.yaml env: + {{- if .Values.linodeTokenFile.enabled }} + - name: LINODE_TOKEN_FILE + value: {{ printf "%s/%s" .Values.linodeTokenFile.mountPath .Values.linodeTokenFile.key | quote }} + - name: LINODE_TOKEN_CACHE_TTL_SECONDS + value: {{ .Values.linodeTokenFile.cacheTTLSeconds | default 60 | quote }} + {{- else }} - name: LINODE_TOKEN valueFrom: secretKeyRef: name: {{ include "latr.secretName" . }} key: linode-token + {{- end }} - name: VAULT_ROLE_ID valueFrom: secretKeyRef: @@ -72,6 +79,11 @@ spec: readOnly: true - name: tmp mountPath: /tmp + {{- if .Values.linodeTokenFile.enabled }} + - name: linode-token + mountPath: {{ .Values.linodeTokenFile.mountPath | quote }} + readOnly: true + {{- end }} {{- with .Values.volumeMounts }} {{- toYaml . | nindent 8 }} {{- end }} @@ -81,6 +93,14 @@ spec: name: {{ include "latr.fullname" . }} - name: tmp emptyDir: {} + {{- if .Values.linodeTokenFile.enabled }} + - name: linode-token + secret: + secretName: {{ include "latr.secretName" . }} + items: + - key: {{ .Values.linodeTokenFile.key | quote }} + path: {{ .Values.linodeTokenFile.key | quote }} + {{- end }} {{- with .Values.volumes }} {{- toYaml . | nindent 6 }} {{- end }} diff --git a/helm/latr/values.yaml b/helm/latr/values.yaml index 93bb88b..498a4c8 100644 --- a/helm/latr/values.yaml +++ b/helm/latr/values.yaml @@ -128,7 +128,7 @@ config: # Secrets configuration # These values should be provided via a separate values file or via --set flags secrets: - # Linode API token (required) + # Linode API token (required unless linodeTokenFile.enabled) linodeToken: "" # Vault AppRole credentials @@ -140,6 +140,18 @@ secrets: # The secret should contain keys: linode-token, vault-role-id, vault-secret-id existingSecret: "" +# Mount the management Linode PAT as a file for hot-reload (no pod restart on rotate). +# When enabled, LINODE_TOKEN_FILE is set and LINODE_TOKEN env injection is skipped. +# Pattern matches linode-blockstorage-csi-driver token file mount. +linodeTokenFile: + enabled: false + # Mount path directory; file is written as / + mountPath: /var/run/secrets/latr + # Key inside the Kubernetes Secret (same secret as vault credentials by default) + key: linode-token + # Cache TTL seconds before re-reading the file (LINODE_TOKEN_CACHE_TTL_SECONDS) + cacheTTLSeconds: 60 + # Environment variables # Additional environment variables to set env: [] diff --git a/internal/linode/client.go b/internal/linode/client.go index 5ce54e0..44c01f5 100644 --- a/internal/linode/client.go +++ b/internal/linode/client.go @@ -7,23 +7,62 @@ import ( "os" "time" - "github.com/linode/linodego" "github.com/linode-obs/latr/pkg/models" - "golang.org/x/oauth2" + "github.com/linode/linodego" ) // Client wraps the linodego client type Client struct { - client *linodego.Client - token string + client *linodego.Client + tokenProvider TokenProvider } -// NewClient creates a new Linode API client +// tokenTransport injects Authorization from TokenProvider on each request so +// file-backed tokens can rotate without reconstructing the linodego client. +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()) + if token != "" { + clone.Header.Set("Authorization", "Bearer "+token) + } + + base := t.base + if base == nil { + base = http.DefaultTransport + } + return base.RoundTrip(clone) +} + +// NewClient creates a new Linode API client with a static token. +// Prefer NewClientWithTokenProvider when the management PAT may rotate without restart. func NewClient(token string) *Client { - tokenSource := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token}) - oauth2Client := oauth2.NewClient(context.Background(), tokenSource) + return NewClientWithTokenProvider(StaticTokenProvider(token)) +} + +// NewClientWithTokenProvider creates a Linode API client that obtains the bearer +// token from tokenProvider on each HTTP request (hot-reload friendly). +func NewClientWithTokenProvider(tokenProvider TokenProvider) *Client { + if tokenProvider == nil { + tokenProvider = StaticTokenProvider("") + } + + httpClient := &http.Client{ + Transport: &tokenTransport{ + base: http.DefaultTransport, + tokenProvider: tokenProvider, + }, + } - linodeClient := linodego.NewClient(oauth2Client) + linodeClient := linodego.NewClient(httpClient) // Support base URL override for testing baseURL := os.Getenv("LINODE_API_URL") @@ -31,9 +70,10 @@ func NewClient(token string) *Client { linodeClient.SetBaseURL(baseURL) } + // Do not call SetToken: auth is applied per-request by tokenTransport. return &Client{ - client: &linodeClient, - token: token, + client: &linodeClient, + tokenProvider: tokenProvider, } } diff --git a/internal/linode/client_test.go b/internal/linode/client_test.go index 7aab090..9c8fc4c 100644 --- a/internal/linode/client_test.go +++ b/internal/linode/client_test.go @@ -1,9 +1,7 @@ package linode import ( - "context" "testing" - "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -12,22 +10,15 @@ import ( func TestNewClient(t *testing.T) { client := NewClient("test-token") require.NotNil(t, client) - assert.Equal(t, "test-token", client.token) + require.NotNil(t, client.tokenProvider) + require.NotNil(t, client.client) } -func TestCreateToken(t *testing.T) { - // This test will use a mock server to avoid real API calls - // For now, we'll write a test that verifies the method signature and structure - client := NewClient("test-token") +func TestNewClientWithTokenProvider(t *testing.T) { + p := StaticTokenProvider("from-provider") + client := NewClientWithTokenProvider(p) require.NotNil(t, client) - - ctx := context.Background() - expiry := time.Now().Add(90 * 24 * time.Hour) - - // Note: This will be tested with integration tests or mocks - // For unit tests, we'll verify the client can be created - _ = ctx - _ = expiry + assert.NotNil(t, client.tokenProvider) } func TestParseTokenScopes(t *testing.T) { diff --git a/internal/linode/token.go b/internal/linode/token.go new file mode 100644 index 0000000..8372b3e --- /dev/null +++ b/internal/linode/token.go @@ -0,0 +1,146 @@ +package linode + +import ( + "context" + "fmt" + "os" + "strconv" + "strings" + "sync" + "time" +) + +const ( + // AccessTokenEnv is the environment variable holding a static Linode API token. + AccessTokenEnv = "LINODE_TOKEN" + // TokenFilePathEnv points at a file containing the management PAT (e.g. K8s secret mount). + // When set and readable, latr re-reads this file so the token can rotate without restart. + TokenFilePathEnv = "LINODE_TOKEN_FILE" + // TokenCacheTTLEnv optionally overrides the file token cache TTL in seconds. + TokenCacheTTLEnv = "LINODE_TOKEN_CACHE_TTL_SECONDS" + // DefaultTokenFileCacheTTL is how long a file-backed token is cached between re-reads. + DefaultTokenFileCacheTTL = time.Minute +) + +// TokenProvider returns a Linode API token for the current request. +// Implementations may re-read a mounted secret so tokens can rotate without a restart. +type TokenProvider func(context.Context) (string, error) + +// StaticTokenProvider returns a TokenProvider that always yields the given token. +func StaticTokenProvider(token string) TokenProvider { + return staticTokenProvider{token: token}.GetToken +} + +type staticTokenProvider struct { + token string +} + +func (t staticTokenProvider) GetToken(context.Context) (string, error) { + if t.token == "" { + return "", fmt.Errorf("linode API token is empty; set %s or %s", AccessTokenEnv, TokenFilePathEnv) + } + return t.token, nil +} + +// TokenFileProvider reads a token from a file with a short TTL cache so secret +// updates (e.g. projected K8s Secret) are picked up without restarting the process. +type TokenFileProvider struct { + path string + now func() time.Time + cacheTTL time.Duration + + mu sync.RWMutex + cachedToken string + expiresAt time.Time +} + +// NewTokenFileProvider constructs a file-backed token provider. +func NewTokenFileProvider(path string, cacheTTL time.Duration) *TokenFileProvider { + return &TokenFileProvider{ + path: path, + cacheTTL: cacheTTL, + } +} + +// Path returns the file path this provider reads. +func (t *TokenFileProvider) Path() string { + return t.path +} + +func (t *TokenFileProvider) nowTime() time.Time { + if t.now != nil { + return t.now() + } + return time.Now() +} + +// GetToken returns a cached token when still valid, otherwise re-reads the file. +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.path, err) + } + + token := strings.TrimSpace(string(rawToken)) + if token == "" { + return "", fmt.Errorf("token file %q is empty", t.path) + } + + t.mu.Lock() + t.cachedToken = token + t.expiresAt = t.nowTime().Add(cacheTTL) + t.mu.Unlock() + + return token, nil +} + +// TokenFileCacheTTLFromEnv returns the configured cache TTL or the default. +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 +} + +// TokenProviderFromEnv prefers a mounted token file when LINODE_TOKEN_FILE is set +// and readable, then falls back to LINODE_TOKEN. Returns the provider and a short +// description of the source for logging (never includes the token value). +func TokenProviderFromEnv(ctx context.Context) (TokenProvider, string, error) { + tokenFilePath := strings.TrimSpace(os.Getenv(TokenFilePathEnv)) + if tokenFilePath != "" { + fileProvider := NewTokenFileProvider(tokenFilePath, TokenFileCacheTTLFromEnv()) + if _, err := fileProvider.GetToken(ctx); err == nil { + return fileProvider.GetToken, fmt.Sprintf("file %q (cache TTL %s)", fileProvider.Path(), TokenFileCacheTTLFromEnv()), nil + } else { + // Fall back to env if the file is not yet present (e.g. race at startup). + // Callers that require the file should set only LINODE_TOKEN_FILE and omit LINODE_TOKEN. + if envToken := strings.TrimSpace(os.Getenv(AccessTokenEnv)); envToken != "" { + return StaticTokenProvider(envToken), fmt.Sprintf("environment variable %q (file %q not readable: %v)", AccessTokenEnv, tokenFilePath, err), nil + } + return nil, "", fmt.Errorf("failed to load linode API token from %s=%q: %w", TokenFilePathEnv, tokenFilePath, err) + } + } + + if envToken := strings.TrimSpace(os.Getenv(AccessTokenEnv)); envToken != "" { + return StaticTokenProvider(envToken), fmt.Sprintf("environment variable %q", AccessTokenEnv), nil + } + + return nil, "", fmt.Errorf("linode API token required: set %s or %s", AccessTokenEnv, TokenFilePathEnv) +} diff --git a/internal/linode/token_test.go b/internal/linode/token_test.go new file mode 100644 index 0000000..713150c --- /dev/null +++ b/internal/linode/token_test.go @@ -0,0 +1,185 @@ +package linode + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestStaticTokenProvider(t *testing.T) { + t.Parallel() + + p := StaticTokenProvider("abc123") + tok, err := p(context.Background()) + require.NoError(t, err) + assert.Equal(t, "abc123", tok) + + empty := StaticTokenProvider("") + _, err = empty(context.Background()) + require.Error(t, err) +} + +func TestTokenFileProviderCache(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "token") + require.NoError(t, os.WriteFile(path, []byte(" first-token \n"), 0o600)) + + now := time.Unix(1_700_000_000, 0) + p := NewTokenFileProvider(path, time.Minute) + p.now = func() time.Time { return now } + + tok, err := p.GetToken(context.Background()) + require.NoError(t, err) + assert.Equal(t, "first-token", tok) + + // Change file; still within cache TTL → old value + require.NoError(t, os.WriteFile(path, []byte("second-token"), 0o600)) + tok, err = p.GetToken(context.Background()) + require.NoError(t, err) + assert.Equal(t, "first-token", tok) + + // Advance past TTL → re-read + now = now.Add(time.Minute + time.Second) + tok, err = p.GetToken(context.Background()) + require.NoError(t, err) + assert.Equal(t, "second-token", tok) +} + +func TestTokenFileProviderEmptyFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "token") + require.NoError(t, os.WriteFile(path, []byte(" \n"), 0o600)) + + p := NewTokenFileProvider(path, time.Minute) + _, err := p.GetToken(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "empty") +} + +func TestTokenFileProviderMissingFile(t *testing.T) { + p := NewTokenFileProvider(filepath.Join(t.TempDir(), "missing"), time.Minute) + _, err := p.GetToken(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to read") +} + +func TestTokenFileCacheTTLFromEnv(t *testing.T) { + t.Setenv(TokenCacheTTLEnv, "") + // empty/unset uses default — LookupEnv is false for empty set after clear + _ = os.Unsetenv(TokenCacheTTLEnv) + assert.Equal(t, DefaultTokenFileCacheTTL, TokenFileCacheTTLFromEnv()) + + t.Setenv(TokenCacheTTLEnv, "30") + assert.Equal(t, 30*time.Second, TokenFileCacheTTLFromEnv()) + + t.Setenv(TokenCacheTTLEnv, "0") + assert.Equal(t, DefaultTokenFileCacheTTL, TokenFileCacheTTLFromEnv()) + + t.Setenv(TokenCacheTTLEnv, "nope") + assert.Equal(t, DefaultTokenFileCacheTTL, TokenFileCacheTTLFromEnv()) +} + +func TestTokenProviderFromEnv(t *testing.T) { + ctx := context.Background() + + t.Run("uses file when set and readable", func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "token") + require.NoError(t, os.WriteFile(path, []byte("file-token"), 0o600)) + + t.Setenv(TokenFilePathEnv, path) + t.Setenv(AccessTokenEnv, "env-token") + + p, src, err := TokenProviderFromEnv(ctx) + require.NoError(t, err) + assert.Contains(t, src, "file") + tok, err := p(ctx) + require.NoError(t, err) + assert.Equal(t, "file-token", tok) + }) + + t.Run("falls back to env when file missing", func(t *testing.T) { + t.Setenv(TokenFilePathEnv, filepath.Join(t.TempDir(), "gone")) + t.Setenv(AccessTokenEnv, "env-token") + + p, src, err := TokenProviderFromEnv(ctx) + require.NoError(t, err) + assert.Contains(t, src, AccessTokenEnv) + tok, err := p(ctx) + require.NoError(t, err) + assert.Equal(t, "env-token", tok) + }) + + t.Run("uses env when file unset", func(t *testing.T) { + t.Setenv(TokenFilePathEnv, "") + t.Setenv(AccessTokenEnv, "only-env") + + p, src, err := TokenProviderFromEnv(ctx) + require.NoError(t, err) + assert.Contains(t, src, AccessTokenEnv) + tok, err := p(ctx) + require.NoError(t, err) + assert.Equal(t, "only-env", tok) + }) + + t.Run("errors when file set but unreadable and no env", func(t *testing.T) { + t.Setenv(TokenFilePathEnv, filepath.Join(t.TempDir(), "gone")) + t.Setenv(AccessTokenEnv, "") + + _, _, err := TokenProviderFromEnv(ctx) + require.Error(t, err) + }) + + t.Run("errors when nothing set", func(t *testing.T) { + t.Setenv(TokenFilePathEnv, "") + t.Setenv(AccessTokenEnv, "") + + _, _, err := TokenProviderFromEnv(ctx) + require.Error(t, err) + assert.Contains(t, err.Error(), AccessTokenEnv) + }) +} + +func TestTokenTransportAuthorization(t *testing.T) { + var gotAuth string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, `{"data":[],"page":1,"pages":1,"results":0}`) + })) + t.Cleanup(server.Close) + + t.Setenv("LINODE_API_URL", server.URL+"/") + client := NewClientWithTokenProvider(StaticTokenProvider("secret-pat")) + + _, err := client.FindTokenByLabel(context.Background(), "any") + require.NoError(t, err) + assert.Equal(t, "Bearer secret-pat", gotAuth) +} + +func TestTokenTransportUsesProviderError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Error("HTTP request should not be sent when token provider fails") + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(server.Close) + + t.Setenv("LINODE_API_URL", server.URL+"/") + client := NewClientWithTokenProvider(func(context.Context) (string, error) { + return "", assert.AnError + }) + + _, err := client.FindTokenByLabel(context.Background(), "any") + require.Error(t, err) + // linodego/resty wrap the transport error; ensure the provider error is visible. + assert.Contains(t, err.Error(), assert.AnError.Error()) +} From 60e32615c244a79add950add97bee0186ed37917 Mon Sep 17 00:00:00 2001 From: Cody Kaczynski Date: Thu, 23 Jul 2026 04:47:14 -0400 Subject: [PATCH 2/2] fix: only parse cacheTTL once, clarify token fallback in readme --- README.md | 12 +++++++++++- internal/linode/token.go | 27 +++++++++++++++++++-------- 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 4fcebf4..5ea0bcd 100644 --- a/README.md +++ b/README.md @@ -131,7 +131,17 @@ export LINODE_TOKEN_FILE=/var/run/secrets/latr/linode-token latr injects the bearer token on **each** Linode API request via a `TokenProvider` (same idea as [linode-blockstorage-csi-driver#592](https://github.com/linode/linode-blockstorage-csi-driver/pull/592)). After the mounted file changes, the next request after the cache TTL uses the new value—no pod restart required. -If both `LINODE_TOKEN_FILE` and `LINODE_TOKEN` are set, the file wins when readable; otherwise latr falls back to the env value. +**How the source is chosen (once at startup):** + +1. If `LINODE_TOKEN_FILE` is set **and the file is readable at process start**, latr uses the file provider for the lifetime of the process (re-reading the file on the cache TTL). +2. Otherwise, if `LINODE_TOKEN` is set, latr uses that static value (no hot-reload). +3. If neither works, latr exits with an error. + +Notes: + +- Startup-only fallback: if both are set but the file is **not** readable at start (e.g. mount race), latr falls back to `LINODE_TOKEN` and **does not** switch to the file later. +- Runtime: if the file provider was selected and the file later becomes unreadable or empty, API calls fail until the file is valid again—there is **no** mid-run fallback to `LINODE_TOKEN`. +- For production hot-reload, set `LINODE_TOKEN_FILE` (and omit `LINODE_TOKEN`) so auth cannot silently stick to a static env token. ### Configuration File diff --git a/internal/linode/token.go b/internal/linode/token.go index 8372b3e..c86f62d 100644 --- a/internal/linode/token.go +++ b/internal/linode/token.go @@ -119,20 +119,31 @@ func TokenFileCacheTTLFromEnv() time.Duration { return tokenCacheTTL } -// TokenProviderFromEnv prefers a mounted token file when LINODE_TOKEN_FILE is set -// and readable, then falls back to LINODE_TOKEN. Returns the provider and a short -// description of the source for logging (never includes the token value). +// TokenProviderFromEnv chooses the management-token source once at process start. +// +// Preference: +// 1. LINODE_TOKEN_FILE when set and readable at startup → file provider (hot-reload) +// 2. Otherwise LINODE_TOKEN if set → static provider +// +// Source selection is not re-evaluated later: if the file provider was chosen, +// later read failures do not fall back to LINODE_TOKEN (requests fail until the +// file is readable again). Startup fallback to env only applies when the file +// is missing/unreadable at init (e.g. mount race); prefer setting only +// LINODE_TOKEN_FILE in production if you require file-backed auth. +// +// Returns the provider and a short description of the source for logging +// (never includes the token value). func TokenProviderFromEnv(ctx context.Context) (TokenProvider, string, error) { tokenFilePath := strings.TrimSpace(os.Getenv(TokenFilePathEnv)) if tokenFilePath != "" { - fileProvider := NewTokenFileProvider(tokenFilePath, TokenFileCacheTTLFromEnv()) + cacheTTL := TokenFileCacheTTLFromEnv() + fileProvider := NewTokenFileProvider(tokenFilePath, cacheTTL) if _, err := fileProvider.GetToken(ctx); err == nil { - return fileProvider.GetToken, fmt.Sprintf("file %q (cache TTL %s)", fileProvider.Path(), TokenFileCacheTTLFromEnv()), nil + return fileProvider.GetToken, fmt.Sprintf("file %q (cache TTL %s)", fileProvider.Path(), cacheTTL), nil } else { - // Fall back to env if the file is not yet present (e.g. race at startup). - // Callers that require the file should set only LINODE_TOKEN_FILE and omit LINODE_TOKEN. + // Fall back to env only at startup if the file is not yet present. if envToken := strings.TrimSpace(os.Getenv(AccessTokenEnv)); envToken != "" { - return StaticTokenProvider(envToken), fmt.Sprintf("environment variable %q (file %q not readable: %v)", AccessTokenEnv, tokenFilePath, err), nil + return StaticTokenProvider(envToken), fmt.Sprintf("environment variable %q (file %q not readable at startup: %v)", AccessTokenEnv, tokenFilePath, err), nil } return nil, "", fmt.Errorf("failed to load linode API token from %s=%q: %w", TokenFilePathEnv, tokenFilePath, err) }