Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,9 +112,36 @@ 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.

**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

Expand Down
19 changes: 9 additions & 10 deletions cmd/latr/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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{
Expand Down
20 changes: 20 additions & 0 deletions helm/latr/templates/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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 }}
Expand All @@ -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 }}
Expand Down
14 changes: 13 additions & 1 deletion helm/latr/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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>/<key>
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: []
Expand Down
60 changes: 50 additions & 10 deletions internal/linode/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,33 +7,73 @@ 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")
if baseURL != "" {
linodeClient.SetBaseURL(baseURL)
}

// Do not call SetToken: auth is applied per-request by tokenTransport.
return &Client{
client: &linodeClient,
token: token,
client: &linodeClient,
tokenProvider: tokenProvider,
}
}

Expand Down
21 changes: 6 additions & 15 deletions internal/linode/client_test.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
package linode

import (
"context"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
Expand All @@ -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) {
Expand Down
Loading