Skip to content

Commit 155717b

Browse files
committed
feat: Support _FILE suffix for environment variables
1 parent 13a35db commit 155717b

File tree

4 files changed

+188
-2
lines changed

4 files changed

+188
-2
lines changed

docs/setup.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ For an example, refer to the [config file](https://github.com/n8n-io/n8n/blob/ma
6363

6464
It is required to pass `N8N_RUNNERS_AUTH_TOKEN` to the launcher and to the n8n instance. This token will allow the launcher to authenticate with the n8n instance and to obtain a grant tokens for every runner it manages. All other env vars are optional and are listed in the [n8n docs](https://docs.n8n.io/hosting/configuration/environment-variables/task-runners).
6565

66+
For any environment variable, you can append `_FILE` to specify a file path to read a value from. For example: `N8N_RUNNERS_AUTH_TOKEN_FILE=/path/to/auth-token.txt`
67+
6668
The launcher can pass env vars to task runners in two ways, as specified in the [config file](#config-file):
6769

6870
| Source | Description | Purpose |

internal/config/config.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,13 +94,13 @@ type RunnerConfig struct {
9494

9595
// LoadLauncherConfig loads the launcher's base config from the launcher's environment and
9696
// loads runner configs from the config file specified by N8N_RUNNERS_CONFIG_PATH.
97-
func LoadLauncherConfig(runnerTypes []string, lookuper envconfig.Lookuper) (*LauncherConfig, error) {
97+
func LoadLauncherConfig(runnerTypes []string, baseLookuper envconfig.Lookuper) (*LauncherConfig, error) {
9898
ctx := context.Background()
9999

100100
var baseConfig BaseConfig
101101
if err := envconfig.ProcessWith(ctx, &envconfig.Config{
102102
Target: &baseConfig,
103-
Lookuper: lookuper,
103+
Lookuper: NewLauncherLookuper(baseLookuper),
104104
}); err != nil {
105105
return nil, err
106106
}

internal/config/lookuper.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package config
2+
3+
import (
4+
"os"
5+
"strings"
6+
7+
"github.com/sethvargo/go-envconfig"
8+
)
9+
10+
type LauncherLookuper struct {
11+
baseLookuper envconfig.Lookuper
12+
}
13+
14+
func NewLauncherLookuper(baseLookuper envconfig.Lookuper) *LauncherLookuper {
15+
return &LauncherLookuper{baseLookuper: baseLookuper}
16+
}
17+
18+
func (f *LauncherLookuper) Lookup(key string) (string, bool) {
19+
fileKey := key + "_FILE"
20+
if filePath, ok := f.baseLookuper.Lookup(fileKey); ok {
21+
// #nosec G304 -- filePath is controlled by system administrator via environment variable
22+
content, err := os.ReadFile(filePath)
23+
if err != nil {
24+
return "", false
25+
}
26+
27+
return strings.TrimRight(string(content), "\n\r"), true
28+
}
29+
30+
return f.baseLookuper.Lookup(key)
31+
}

internal/config/lookuper_test.go

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
package config
2+
3+
import (
4+
"maps"
5+
"os"
6+
"path/filepath"
7+
"testing"
8+
9+
"github.com/sethvargo/go-envconfig"
10+
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/require"
12+
)
13+
14+
func TestFileLookuper(t *testing.T) {
15+
tests := []struct {
16+
name string
17+
envVars map[string]string
18+
fileContent map[string]string // filename -> content
19+
lookupKey string
20+
expectedValue string
21+
expectedFound bool
22+
}{
23+
{
24+
name: "reads from _FILE when it exists",
25+
envVars: map[string]string{
26+
"AUTH_TOKEN_FILE": "/tmp/secret.txt",
27+
},
28+
fileContent: map[string]string{
29+
"/tmp/secret.txt": "my-secret-token",
30+
},
31+
lookupKey: "AUTH_TOKEN",
32+
expectedValue: "my-secret-token",
33+
expectedFound: true,
34+
},
35+
{
36+
name: "trims trailing newlines from file content",
37+
envVars: map[string]string{
38+
"AUTH_TOKEN_FILE": "/tmp/secret.txt",
39+
},
40+
fileContent: map[string]string{
41+
"/tmp/secret.txt": "my-secret-token\n",
42+
},
43+
lookupKey: "AUTH_TOKEN",
44+
expectedValue: "my-secret-token",
45+
expectedFound: true,
46+
},
47+
{
48+
name: "trims multiple trailing newlines",
49+
envVars: map[string]string{
50+
"AUTH_TOKEN_FILE": "/tmp/secret.txt",
51+
},
52+
fileContent: map[string]string{
53+
"/tmp/secret.txt": "my-secret-token\n\n\r\n",
54+
},
55+
lookupKey: "AUTH_TOKEN",
56+
expectedValue: "my-secret-token",
57+
expectedFound: true,
58+
},
59+
{
60+
name: "preserves internal newlines",
61+
envVars: map[string]string{
62+
"MULTI_LINE_FILE": "/tmp/multi.txt",
63+
},
64+
fileContent: map[string]string{
65+
"/tmp/multi.txt": "line1\nline2\nline3\n",
66+
},
67+
lookupKey: "MULTI_LINE",
68+
expectedValue: "line1\nline2\nline3",
69+
expectedFound: true,
70+
},
71+
{
72+
name: "falls back to direct env var when _FILE doesn't exist",
73+
envVars: map[string]string{
74+
"AUTH_TOKEN": "direct-value",
75+
},
76+
lookupKey: "AUTH_TOKEN",
77+
expectedValue: "direct-value",
78+
expectedFound: true,
79+
},
80+
{
81+
name: "_FILE takes precedence over direct env var",
82+
envVars: map[string]string{
83+
"AUTH_TOKEN": "direct-value",
84+
"AUTH_TOKEN_FILE": "/tmp/secret.txt",
85+
},
86+
fileContent: map[string]string{
87+
"/tmp/secret.txt": "file-value",
88+
},
89+
lookupKey: "AUTH_TOKEN",
90+
expectedValue: "file-value",
91+
expectedFound: true,
92+
},
93+
{
94+
name: "returns not found when neither exists",
95+
envVars: map[string]string{},
96+
lookupKey: "AUTH_TOKEN",
97+
expectedValue: "",
98+
expectedFound: false,
99+
},
100+
{
101+
name: "returns not found when file doesn't exist",
102+
envVars: map[string]string{
103+
"AUTH_TOKEN_FILE": "/tmp/nonexistent.txt",
104+
},
105+
lookupKey: "AUTH_TOKEN",
106+
expectedValue: "",
107+
expectedFound: false,
108+
},
109+
{
110+
name: "handles empty file content",
111+
envVars: map[string]string{
112+
"EMPTY_FILE": "/tmp/empty.txt",
113+
},
114+
fileContent: map[string]string{
115+
"/tmp/empty.txt": "",
116+
},
117+
lookupKey: "EMPTY",
118+
expectedValue: "",
119+
expectedFound: true,
120+
},
121+
}
122+
123+
for _, tt := range tests {
124+
t.Run(tt.name, func(t *testing.T) {
125+
tempDir := t.TempDir()
126+
127+
updatedEnvVars := make(map[string]string)
128+
maps.Copy(updatedEnvVars, tt.envVars)
129+
130+
for filePath, content := range tt.fileContent {
131+
tempFile := filepath.Join(tempDir, filepath.Base(filePath))
132+
err := os.WriteFile(tempFile, []byte(content), 0600)
133+
require.NoError(t, err)
134+
135+
for key, path := range updatedEnvVars {
136+
if path == filePath {
137+
updatedEnvVars[key] = tempFile
138+
}
139+
}
140+
}
141+
142+
baseLookuper := envconfig.MapLookuper(updatedEnvVars)
143+
lancherLookuper := NewLauncherLookuper(baseLookuper)
144+
145+
value, found := lancherLookuper.Lookup(tt.lookupKey)
146+
147+
assert.Equal(t, tt.expectedFound, found, "found mismatch")
148+
if tt.expectedFound {
149+
assert.Equal(t, tt.expectedValue, value, "value mismatch")
150+
}
151+
})
152+
}
153+
}

0 commit comments

Comments
 (0)