Skip to content

Commit dc05c2e

Browse files
authored
Merge pull request #522 from docker/feat/pass-run
feat: pass run command
2 parents d7a6f0c + 7c401a3 commit dc05c2e

7 files changed

Lines changed: 576 additions & 1 deletion

File tree

plugins/pass/command.go

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ package pass
1616

1717
import (
1818
"context"
19+
"errors"
1920
"os"
2021
"strings"
2122

@@ -114,7 +115,7 @@ services:
114115
// Root returns the root command for the docker-pass CLI plugin
115116
func Root(ctx context.Context, s store.Store, info commands.VersionInfo) *cobra.Command {
116117
cmd := &cobra.Command{
117-
Use: "pass set|get|ls|rm",
118+
Use: "pass set|get|ls|rm|run",
118119
Short: "Manage your local OS keychain secrets.",
119120
Long: `Docker Pass is a helper for securely storing secrets in your local OS keychain and injecting them into containers when needed.
120121
It uses platform-specific credential storage:
@@ -146,6 +147,7 @@ Secrets can be injected into running containers at runtime using the se:// URI s
146147
cmd.AddCommand(wrapRunEWithSpan(commands.ListCommand(s)))
147148
cmd.AddCommand(wrapRunEWithSpan(commands.RmCommand(s)))
148149
cmd.AddCommand(wrapRunEWithSpan(commands.GetCommand(s)))
150+
cmd.AddCommand(wrapRunEWithSpan(commands.RunCommand()))
149151
cmd.AddCommand(commands.VersionCommand(info))
150152

151153
return cmd
@@ -180,9 +182,27 @@ func withOTEL(runE func(cmd *cobra.Command, args []string) error) func(*cobra.Co
180182
trace.WithSpanKind(trace.SpanKindInternal),
181183
trace.WithAttributes(attribute.String("command", cmd.Name())),
182184
)
185+
186+
pendingExit := -1
187+
defer func() {
188+
if pendingExit >= 0 {
189+
os.Exit(pendingExit)
190+
}
191+
}()
183192
defer span.End()
193+
184194
cmd.SetContext(ctx)
185195
err := runE(cmd, args)
196+
197+
var exitErr *commands.ExitCodeError
198+
if errors.As(err, &exitErr) {
199+
pendingExit = exitErr.Code
200+
span.SetAttributes(attribute.Int("command.child_exit_code", exitErr.Code))
201+
span.SetStatus(codes.Ok, "child exited")
202+
calledMetric(ctx, cmd, nil)
203+
return nil
204+
}
205+
186206
calledMetric(ctx, cmd, err)
187207
if err != nil {
188208
span.RecordError(err)

plugins/pass/commands/run.go

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
// Copyright 2025-2026 Docker, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package commands
16+
17+
import (
18+
"context"
19+
"errors"
20+
"fmt"
21+
"os"
22+
"os/exec"
23+
"os/signal"
24+
"strings"
25+
26+
"github.com/spf13/cobra"
27+
28+
"github.com/docker/secrets-engine/client"
29+
"github.com/docker/secrets-engine/x/api"
30+
"github.com/docker/secrets-engine/x/secrets"
31+
)
32+
33+
const sePrefix = "se://"
34+
35+
// ExitCodeError is returned from RunCommand when the executed child process
36+
// terminated with a non-zero status. It carries the exit code the wrapper
37+
// should exit with. Returning this instead of calling os.Exit directly lets
38+
// the surrounding OTel span wrapper finish recording metrics and span data
39+
// before the process exits.
40+
type ExitCodeError struct {
41+
Code int
42+
}
43+
44+
func (e *ExitCodeError) Error() string {
45+
return fmt.Sprintf("child exited with code %d", e.Code)
46+
}
47+
48+
const runExample = `
49+
### Run a command with one secret in its environment:
50+
SE_TOKEN=se://gh-token docker pass run -- gh repo list
51+
52+
### Multiple references:
53+
DB_PASSWORD=se://myapp/postgres/password \
54+
API_KEY=se://myapp/anthropic/api-key \
55+
docker pass run -- ./my-binary
56+
`
57+
58+
func RunCommand() *cobra.Command {
59+
cmd := &cobra.Command{
60+
Use: "run -- CMD [ARGS...]",
61+
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.
66+
67+
Requires the secrets-engine daemon (Docker Desktop) to be running.
68+
69+
If any reference cannot be resolved, the command fails before the child is
70+
started and exits non-zero.`,
71+
Example: strings.Trim(runExample, "\n"),
72+
Args: cobra.MinimumNArgs(1),
73+
RunE: func(cmd *cobra.Command, args []string) error {
74+
c, err := client.New(client.WithSocketPath(api.DefaultSocketPath()))
75+
if err != nil {
76+
return err
77+
}
78+
79+
env, err := resolveEnv(cmd.Context(), c, os.Environ())
80+
if err != nil {
81+
return err
82+
}
83+
84+
// No CommandContext: the signal forwarder owns the child's
85+
// lifecycle. Tying the child to cmd.Context() would let cobra's
86+
// ctx cancellation SIGKILL the child out from under the forwarder.
87+
child := exec.Command(args[0], args[1:]...)
88+
child.Env = env
89+
child.Stdin = os.Stdin
90+
child.Stdout = os.Stdout
91+
child.Stderr = os.Stderr
92+
// Isolate the child in its own process group so that
93+
// terminal-generated signals (Ctrl-C) are delivered to us alone;
94+
// the forwarder is then the sole path that reaches the child.
95+
configureChildProcGroup(child)
96+
97+
// Install the signal handler before Start so a signal arriving in
98+
// the window between fork and the forwarder goroutine cannot kill
99+
// the parent and orphan the child.
100+
sigCh := make(chan os.Signal, 1)
101+
signal.Notify(sigCh, forwardableSignals()...)
102+
defer signal.Stop(sigCh)
103+
104+
if err := child.Start(); err != nil {
105+
return fmt.Errorf("starting child: %w", err)
106+
}
107+
108+
done := make(chan struct{})
109+
go func() {
110+
for {
111+
select {
112+
case sig := <-sigCh:
113+
_ = signalChild(child, sig)
114+
case <-done:
115+
return
116+
}
117+
}
118+
}()
119+
120+
waitErr := child.Wait()
121+
close(done)
122+
123+
if waitErr != nil {
124+
var exitErr *exec.ExitError
125+
if errors.As(waitErr, &exitErr) {
126+
return &ExitCodeError{Code: childExitCode(exitErr.ProcessState)}
127+
}
128+
return waitErr
129+
}
130+
return nil
131+
},
132+
}
133+
return cmd
134+
}
135+
136+
func resolveEnv(ctx context.Context, r secrets.Resolver, env []string) ([]string, error) {
137+
out := make([]string, 0, len(env))
138+
for _, kv := range env {
139+
key, value, _ := strings.Cut(kv, "=")
140+
if !strings.HasPrefix(value, sePrefix) {
141+
out = append(out, kv)
142+
continue
143+
}
144+
resolved, err := resolveRef(ctx, r, key, value)
145+
if err != nil {
146+
return nil, err
147+
}
148+
out = append(out, key+"="+resolved)
149+
}
150+
return out, nil
151+
}
152+
153+
func resolveRef(ctx context.Context, r secrets.Resolver, key, value string) (string, error) {
154+
name := strings.TrimPrefix(value, sePrefix)
155+
// Validate as an ID first so wildcards in the reference are rejected
156+
// instead of silently broadening the lookup.
157+
if _, err := secrets.ParseID(name); err != nil {
158+
return "", fmt.Errorf("resolving %s: %w", key, err)
159+
}
160+
pattern, err := secrets.ParsePattern(name)
161+
if err != nil {
162+
return "", fmt.Errorf("resolving %s: %w", key, err)
163+
}
164+
envs, err := r.GetSecrets(ctx, pattern)
165+
if err != nil {
166+
return "", fmt.Errorf("resolving %s: %w", key, err)
167+
}
168+
if len(envs) == 0 {
169+
return "", fmt.Errorf("resolving %s: %w", key, secrets.ErrNotFound)
170+
}
171+
if len(envs) > 1 {
172+
return "", fmt.Errorf("resolving %s: %d secrets matched %s", key, len(envs), name)
173+
}
174+
return string(envs[0].Value), nil
175+
}

0 commit comments

Comments
 (0)