Skip to content
Merged
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
127 changes: 127 additions & 0 deletions plugins/pass/commands/client.go
Original file line number Diff line number Diff line change
@@ -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
}
17 changes: 13 additions & 4 deletions plugins/pass/commands/get.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,20 +15,29 @@
package commands

import (
_ "embed"
"errors"
"strings"

"github.com/spf13/cobra"

pass "github.com/docker/secrets-engine/plugins/pass/store"
"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 {
Expand Down
5 changes: 5 additions & 0 deletions plugins/pass/commands/get_example.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
### Show a secret with its value masked:

```console
$ docker pass get POSTGRES_PASSWORD
```
1 change: 1 addition & 0 deletions plugins/pass/commands/get_long.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Retrieves a named secret from the local OS keychain. The secret value is masked in output.
101 changes: 10 additions & 91 deletions plugins/pass/commands/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -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
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
20 changes: 10 additions & 10 deletions plugins/pass/commands/run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -310,35 +310,35 @@ 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),
},
},
{
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",
},
} {
Expand All @@ -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
},
Expand Down
Loading