Skip to content

Commit 386852d

Browse files
committed
feat(pass): add --env-file to pass run
`pass run` now reads variables from one or more dotenv files in addition to the process environment, matching `op run --env-file` ergonomics. - `--env-file FILE` is repeatable. - Merge order: process env first, each file in order; later entries override earlier ones. - `se://` references in file values resolve through the daemon the same way process env vars already do. - Missing or unreadable files are a hard error before exec — the child never starts with a partially-merged environment. Parsing uses github.com/joho/godotenv to match real dotenv semantics (quoted values, comments, etc.) without hand-rolling a parser. Signed-off-by: Johannes Großmann <grossmann.johannes@t-online.de>
1 parent 5e1b3c9 commit 386852d

10 files changed

Lines changed: 841 additions & 8 deletions

File tree

plugins/pass/commands/run.go

Lines changed: 52 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,14 @@ import (
1818
"context"
1919
"errors"
2020
"fmt"
21+
"maps"
2122
"os"
2223
"os/exec"
2324
"os/signal"
25+
"sort"
2426
"strings"
2527

28+
"github.com/joho/godotenv"
2629
"github.com/spf13/cobra"
2730

2831
"github.com/docker/secrets-engine/client"
@@ -50,19 +53,28 @@ const runExample = `
5053
SE_TOKEN=se://gh-token docker pass run -- gh repo list
5154
5255
### Multiple references:
53-
DB_PASSWORD=se://myapp/postgres/password \
54-
API_KEY=se://myapp/anthropic/api-key \
55-
docker pass run -- ./my-binary
56+
DB_PASSWORD=se://myapp/postgres/password API_KEY=se://myapp/anthropic/api-key docker pass run -- ./my-binary
57+
58+
### Resolve references from a dotenv file:
59+
docker pass run --env-file .env -- ./my-binary
60+
61+
### Multiple files (later overrides earlier; files override the process environment):
62+
docker pass run --env-file .env --env-file .env.local -- ./my-binary
5663
`
5764

65+
type runOpts struct {
66+
envFiles []string
67+
}
68+
5869
func RunCommand() *cobra.Command {
70+
opts := runOpts{}
5971
cmd := &cobra.Command{
6072
Use: "run -- CMD [ARGS...]",
6173
Short: "Run a command with se:// environment references resolved.",
62-
Long: `Scans the current environment for variables whose value is exactly se://NAME.
63-
Each reference is resolved through the secrets-engine daemon and the resolved
64-
value is passed to the child process. The child inherits stdin, stdout, and
65-
stderr.
74+
Long: `Scans the current environment (plus any --env-file inputs) for variables
75+
whose value is exactly se://NAME. Each reference is resolved through the
76+
secrets-engine daemon and the resolved value is passed to the child process.
77+
The child inherits stdin, stdout, and stderr.
6678
6779
Requires the secrets-engine daemon (Docker Desktop) to be running.
6880
@@ -71,12 +83,17 @@ started and exits non-zero.`,
7183
Example: strings.Trim(runExample, "\n"),
7284
Args: cobra.MinimumNArgs(1),
7385
RunE: func(cmd *cobra.Command, args []string) error {
86+
merged, err := mergeEnv(os.Environ(), opts.envFiles)
87+
if err != nil {
88+
return err
89+
}
90+
7491
c, err := client.New(client.WithSocketPath(api.DefaultSocketPath()))
7592
if err != nil {
7693
return err
7794
}
7895

79-
env, err := resolveEnv(cmd.Context(), c, os.Environ())
96+
env, err := resolveEnv(cmd.Context(), c, merged)
8097
if err != nil {
8198
return err
8299
}
@@ -130,9 +147,36 @@ started and exits non-zero.`,
130147
return nil
131148
},
132149
}
150+
cmd.Flags().StringArrayVar(&opts.envFiles, "env-file", nil,
151+
"Read environment variables from a dotenv-formatted file. Repeatable; later files override earlier files and the process environment.")
133152
return cmd
134153
}
135154

155+
// mergeEnv folds the process environment and any --env-file inputs into a
156+
// single deterministic KEY=VALUE slice. Precedence: process env first, then
157+
// each file in order; later entries override earlier ones.
158+
func mergeEnv(processEnv, files []string) ([]string, error) {
159+
merged := make(map[string]string, len(processEnv))
160+
for _, kv := range processEnv {
161+
if k, v, ok := strings.Cut(kv, "="); ok {
162+
merged[k] = v
163+
}
164+
}
165+
for _, f := range files {
166+
parsed, err := godotenv.Read(f)
167+
if err != nil {
168+
return nil, fmt.Errorf("reading env-file %s: %w", f, err)
169+
}
170+
maps.Copy(merged, parsed)
171+
}
172+
out := make([]string, 0, len(merged))
173+
for k, v := range merged {
174+
out = append(out, k+"="+v)
175+
}
176+
sort.Strings(out)
177+
return out, nil
178+
}
179+
136180
func resolveEnv(ctx context.Context, r secrets.Resolver, env []string) ([]string, error) {
137181
out := make([]string, 0, len(env))
138182
for _, kv := range env {

plugins/pass/commands/run_test.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
"io"
2323
"os"
2424
"os/exec"
25+
"path/filepath"
2526
"runtime"
2627
"strconv"
2728
"syscall"
@@ -176,6 +177,63 @@ func TestResolveEnv(t *testing.T) {
176177
})
177178
}
178179

180+
func TestMergeEnv(t *testing.T) {
181+
t.Parallel()
182+
183+
writeFile := func(t *testing.T, body string) string {
184+
t.Helper()
185+
path := filepath.Join(t.TempDir(), "env")
186+
require.NoError(t, os.WriteFile(path, []byte(body), 0o600))
187+
return path
188+
}
189+
190+
t.Run("no files returns sorted process env", func(t *testing.T) {
191+
out, err := mergeEnv([]string{"B=2", "A=1"}, nil)
192+
require.NoError(t, err)
193+
assert.Equal(t, []string{"A=1", "B=2"}, out)
194+
})
195+
196+
t.Run("file overrides process env", func(t *testing.T) {
197+
f := writeFile(t, "A=from-file\nC=new\n")
198+
out, err := mergeEnv([]string{"A=from-process", "B=keep"}, []string{f})
199+
require.NoError(t, err)
200+
assert.Equal(t, []string{"A=from-file", "B=keep", "C=new"}, out)
201+
})
202+
203+
t.Run("later file overrides earlier file", func(t *testing.T) {
204+
f1 := writeFile(t, "A=from-file-1\n")
205+
f2 := writeFile(t, "A=from-file-2\n")
206+
out, err := mergeEnv(nil, []string{f1, f2})
207+
require.NoError(t, err)
208+
assert.Equal(t, []string{"A=from-file-2"}, out)
209+
})
210+
211+
t.Run("comments and quoted values", func(t *testing.T) {
212+
f := writeFile(t, "# this is a comment\nGREETING=\"hello world\"\nQUOTED='no $expand'\n")
213+
out, err := mergeEnv(nil, []string{f})
214+
require.NoError(t, err)
215+
assert.Equal(t, []string{
216+
"GREETING=hello world",
217+
"QUOTED=no $expand",
218+
}, out)
219+
})
220+
221+
t.Run("missing file returns error and does not partially apply", func(t *testing.T) {
222+
f := writeFile(t, "A=present\n")
223+
out, err := mergeEnv([]string{"B=keep"}, []string{f, "/does/not/exist/.env"})
224+
require.Error(t, err)
225+
assert.Nil(t, out)
226+
assert.Contains(t, err.Error(), "/does/not/exist/.env")
227+
})
228+
229+
t.Run("preserves se:// values for downstream resolveEnv", func(t *testing.T) {
230+
f := writeFile(t, "SE_TOKEN=se://gh-token\nPLAIN=v\n")
231+
out, err := mergeEnv(nil, []string{f})
232+
require.NoError(t, err)
233+
assert.Equal(t, []string{"PLAIN=v", "SE_TOKEN=se://gh-token"}, out)
234+
})
235+
}
236+
179237
// TestRunCommand covers cobra-level behavior that does not depend on a running
180238
// daemon. Resolution behavior is covered by TestResolveEnv.
181239
func TestRunCommand(t *testing.T) {

plugins/pass/go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ require (
1313
github.com/docker/secrets-engine/plugin v0.0.22
1414
github.com/docker/secrets-engine/store v0.0.23
1515
github.com/docker/secrets-engine/x v0.0.32-do.not.use
16+
github.com/joho/godotenv v1.5.1
1617
github.com/spf13/cobra v1.10.1
1718
github.com/stretchr/testify v1.11.1
1819
go.opentelemetry.io/otel v1.40.0

plugins/pass/go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8
3232
github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns=
3333
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
3434
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
35+
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
36+
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
3537
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
3638
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
3739
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=

vendor/github.com/joho/godotenv/.gitignore

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

vendor/github.com/joho/godotenv/LICENCE

Lines changed: 23 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)