diff --git a/plugins/pass/commands/client.go b/plugins/pass/commands/client.go new file mode 100644 index 00000000..4778f782 --- /dev/null +++ b/plugins/pass/commands/client.go @@ -0,0 +1,127 @@ +// Copyright 2026 Docker, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package commands + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/docker/secrets-engine/client" + "github.com/docker/secrets-engine/x/api" + "github.com/docker/secrets-engine/x/secrets" +) + +const defaultPreflightPingTimeout = 3 * time.Second + +type clientOpts struct { + timeout time.Duration + responseTimeout time.Duration + socketPath string +} + +func (o clientOpts) isUnbound() bool { + return o.timeout == 0 +} + +// preflightPing fails fast when the engine is unreachable, so an unbounded +// client cannot hang resolution indefinitely. +// TODO: move into client/client.go +func preflightPing(ctx context.Context, c client.Client, timeout time.Duration) error { + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + if _, err := c.Version(ctx); err != nil { + if !errors.Is(err, client.ErrSecretsEngineNotAvailable) { + err = fmt.Errorf("%w: %w", client.ErrSecretsEngineNotAvailable, err) + } + return fmt.Errorf("preflight ping: %w", err) + } + return nil +} + +type ClientOption func(*clientOpts) error + +// WithTimeout sets the client request timeout; 0 disables it. +func WithTimeout(timeout time.Duration) ClientOption { + return func(o *clientOpts) error { + if timeout < 0 { + return errors.New("request timeout duration cannot be negative") + } + o.timeout = timeout + return nil + } +} + +// WithResponseTimeout sets the client response header timeout; 0 disables it. +func WithResponseTimeout(responseTimeout time.Duration) ClientOption { + return func(o *clientOpts) error { + if responseTimeout < 0 { + return errors.New("response timeout duration cannot be negative") + } + o.responseTimeout = responseTimeout + return nil + } +} + +// WithSocketPath overrides the default [api.DesktopSocketPath]. +func WithSocketPath(socketPath string) ClientOption { + return func(o *clientOpts) error { + if socketPath == "" { + return errors.New("no path provided") + } + o.socketPath = socketPath + return nil + } +} + +func parseClientOptions(options ...ClientOption) (clientOpts, error) { + opts := clientOpts{ + timeout: api.DefaultClientRequestTimeout, + responseTimeout: api.DefaultClientResponseHeaderTimeout, + } + for _, o := range options { + if err := o(&opts); err != nil { + return clientOpts{}, err + } + } + return opts, nil +} + +func newClient(opts clientOpts) (client.Client, error) { + socketPath := opts.socketPath + if socketPath == "" { + socketPath = api.DesktopSocketPath() + } + c, err := client.New(client.WithSocketPath(socketPath), + client.WithTimeout(opts.timeout), + client.WithResponseTimeout(opts.responseTimeout), + ) + if err != nil { + return nil, err + } + return c, nil +} + +func authorize(ctx context.Context, a secrets.Authorizer, patterns ...secrets.Pattern) error { + resp, err := a.Authorize(ctx, patterns...) + if err != nil { + return fmt.Errorf("authorizing: %w", err) + } + if !resp.Allow { + return fmt.Errorf("authorizing: %w", secrets.ErrAccessDenied) + } + return nil +} diff --git a/plugins/pass/commands/get.go b/plugins/pass/commands/get.go index 5a0997e7..db8f156f 100644 --- a/plugins/pass/commands/get.go +++ b/plugins/pass/commands/get.go @@ -15,7 +15,9 @@ package commands import ( + _ "embed" "errors" + "strings" "github.com/spf13/cobra" @@ -23,12 +25,19 @@ import ( "github.com/docker/secrets-engine/store" ) +//go:embed get_example.md +var getExample string + +//go:embed get_long.md +var getLong string + func GetCommand() *cobra.Command { cmd := &cobra.Command{ - Use: "get NAME", - Args: cobra.ExactArgs(1), - Short: "Get a secret from a keystore.", - Long: "Retrieves a named secret from the local OS keychain. The secret value is masked in output.", + Use: "get NAME", + Args: cobra.ExactArgs(1), + Short: "Get a secret from a keystore.", + Long: strings.Trim(getLong, "\n"), + Example: strings.Trim(getExample, "\n"), RunE: func(cmd *cobra.Command, args []string) error { id, err := store.ParseID(args[0]) if err != nil { diff --git a/plugins/pass/commands/get_example.md b/plugins/pass/commands/get_example.md new file mode 100644 index 00000000..71532d1c --- /dev/null +++ b/plugins/pass/commands/get_example.md @@ -0,0 +1,5 @@ +### Show a secret with its value masked: + +```console +$ docker pass get POSTGRES_PASSWORD +``` diff --git a/plugins/pass/commands/get_long.md b/plugins/pass/commands/get_long.md new file mode 100644 index 00000000..2e455f43 --- /dev/null +++ b/plugins/pass/commands/get_long.md @@ -0,0 +1 @@ +Retrieves a named secret from the local OS keychain. The secret value is masked in output. diff --git a/plugins/pass/commands/run.go b/plugins/pass/commands/run.go index 976e334f..23fcefda 100644 --- a/plugins/pass/commands/run.go +++ b/plugins/pass/commands/run.go @@ -25,20 +25,15 @@ import ( "os/signal" "sort" "strings" - "time" "github.com/joho/godotenv" "github.com/spf13/cobra" - "github.com/docker/secrets-engine/client" - "github.com/docker/secrets-engine/x/api" "github.com/docker/secrets-engine/x/secrets" ) const sePrefix = "se://" -const defaultPreflightPingTimeout = 3 * time.Second - // ExitCodeError is returned when the child process exits non-zero, letting the // OTel span wrapper finish before the process exits. type ExitCodeError struct { @@ -56,56 +51,17 @@ var runExample string var runLong string type runOpts struct { - envFiles []string - timeout *time.Duration - responseTimeout *time.Duration - socketPath string -} - -type RunOption func(*runOpts) error - -// WithTimeout sets the client request timeout; 0 disables it. -func WithTimeout(timeout time.Duration) RunOption { - return func(o *runOpts) error { - if timeout < 0 { - return errors.New("request timeout duration cannot be negative") - } - o.timeout = &timeout - return nil - } -} - -// WithResponseTimeout sets the client response header timeout; 0 disables it. -func WithResponseTimeout(responseTimeout time.Duration) RunOption { - return func(o *runOpts) error { - if responseTimeout < 0 { - return errors.New("response timeout duration cannot be negative") - } - o.responseTimeout = &responseTimeout - return nil - } -} - -// WithSocketPath overrides the default [api.DesktopSocketPath]. -func WithSocketPath(socketPath string) RunOption { - return func(o *runOpts) error { - if socketPath == "" { - return errors.New("no path provided") - } - o.socketPath = socketPath - return nil - } + clientOpts + envFiles []string } // RunCommand uses [api.DesktopSocketPath] by default. -func RunCommand(options ...RunOption) (*cobra.Command, error) { - opts := runOpts{} - for _, o := range options { - if err := o(&opts); err != nil { - return nil, err - } +func RunCommand(options ...ClientOption) (*cobra.Command, error) { + copts, err := parseClientOptions(options...) + if err != nil { + return nil, err } - return newRunCommand(opts), nil + return newRunCommand(runOpts{clientOpts: copts}), nil } func newRunCommand(opts runOpts) *cobra.Command { @@ -126,12 +82,11 @@ func newRunCommand(opts runOpts) *cobra.Command { return err } - c, err := newRunClient(opts) + c, err := newClient(opts.clientOpts) if err != nil { return err } - - if opts.timeout == nil || *opts.timeout == 0 { + if opts.isUnbound() { if err := preflightPing(cmd.Context(), c, defaultPreflightPingTimeout); err != nil { return err } @@ -217,35 +172,6 @@ func mergeEnv(processEnv, files []string) ([]string, error) { return out, nil } -func newRunClient(opts runOpts) (client.Client, error) { - socketPath := opts.socketPath - if socketPath == "" { - socketPath = api.DesktopSocketPath() - } - copts := []client.Option{client.WithSocketPath(socketPath)} - if opts.timeout != nil { - copts = append(copts, client.WithTimeout(*opts.timeout)) - } - if opts.responseTimeout != nil { - copts = append(copts, client.WithResponseTimeout(*opts.responseTimeout)) - } - return client.New(copts...) -} - -// preflightPing fails fast when the engine is unreachable, so an unbounded -// client cannot hang resolution indefinitely. -func preflightPing(ctx context.Context, c client.Client, timeout time.Duration) error { - ctx, cancel := context.WithTimeout(ctx, timeout) - defer cancel() - if _, err := c.Version(ctx); err != nil { - if !errors.Is(err, client.ErrSecretsEngineNotAvailable) { - err = fmt.Errorf("%w: %w", client.ErrSecretsEngineNotAvailable, err) - } - return fmt.Errorf("preflight ping: %w", err) - } - return nil -} - type envVar struct { key string value string @@ -280,14 +206,7 @@ func authorizeEnv(ctx context.Context, a secrets.Authorizer, vars []envVar) erro if len(patterns) == 0 { return nil } - resp, err := a.Authorize(ctx, patterns...) - if err != nil { - return fmt.Errorf("authorizing: %w", err) - } - if !resp.Allow { - return fmt.Errorf("authorizing: %w", secrets.ErrAccessDenied) - } - return nil + return authorize(ctx, a, patterns...) } func resolveEnv(ctx context.Context, r secrets.Resolver, vars []envVar) ([]string, error) { diff --git a/plugins/pass/commands/run_test.go b/plugins/pass/commands/run_test.go index 039673d8..1814d8da 100644 --- a/plugins/pass/commands/run_test.go +++ b/plugins/pass/commands/run_test.go @@ -106,9 +106,9 @@ func runAsWrapper() { os.Exit(2) } // bounded timeout skips the preflight ping; no engine needed - ropts := []RunOption{WithTimeout(time.Second)} + ropts := []ClientOption{WithTimeout(time.Second)} if socket := os.Getenv(helperSocketEnv); socket != "" { - ropts = []RunOption{WithSocketPath(socket)} + ropts = []ClientOption{WithSocketPath(socket)} } cmd, err := RunCommand(ropts...) if err != nil { @@ -310,13 +310,13 @@ func TestRunCommandOptions(t *testing.T) { for _, tt := range []struct { name string - options []RunOption + options []ClientOption wantErr string }{ {name: "defaults"}, { name: "explicit options", - options: []RunOption{ + options: []ClientOption{ WithSocketPath("/tmp/secrets-engine.sock"), WithTimeout(time.Second), WithResponseTimeout(time.Second), @@ -324,21 +324,21 @@ func TestRunCommandOptions(t *testing.T) { }, { name: "zero disables timeouts", - options: []RunOption{WithTimeout(0), WithResponseTimeout(0)}, + options: []ClientOption{WithTimeout(0), WithResponseTimeout(0)}, }, { name: "empty socket path", - options: []RunOption{WithSocketPath("")}, + options: []ClientOption{WithSocketPath("")}, wantErr: "no path provided", }, { name: "negative request timeout", - options: []RunOption{WithTimeout(-time.Second)}, + options: []ClientOption{WithTimeout(-time.Second)}, wantErr: "request timeout duration cannot be negative", }, { name: "negative response timeout", - options: []RunOption{WithResponseTimeout(-time.Second)}, + options: []ClientOption{WithResponseTimeout(-time.Second)}, wantErr: "response timeout duration cannot be negative", }, } { @@ -360,8 +360,8 @@ func TestRunCommandOptions(t *testing.T) { optionErr := errors.New("invalid option") laterOptionCalled := false cmd, err := RunCommand( - func(*runOpts) error { return optionErr }, - func(*runOpts) error { + func(*clientOpts) error { return optionErr }, + func(*clientOpts) error { laterOptionCalled = true return nil },